package httpserver import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "git.yuntuo.hk/k2safe/orbit/internal/consoleauth" core "git.yuntuo.hk/k2safe/orbit/model" "git.yuntuo.hk/k2safe/orbit/platform/auth" ) type fakeApplications struct { calls int input consoleauth.ApplicationInput op consoleauth.ApplicationOperation } func (f *fakeApplications) ListApplications(context.Context, auth.Token, core.ID) (consoleauth.ApplicationPage, error) { f.calls++ return consoleauth.ApplicationPage{}, nil } func (f *fakeApplications) GetApplication(context.Context, auth.Token, core.ID) (consoleauth.ApplicationDetail, error) { f.calls++ return consoleauth.ApplicationDetail{}, nil } func (f *fakeApplications) PrepareApplication(_ context.Context, _ auth.Token, in consoleauth.ApplicationInput) (consoleauth.ApplicationOperation, error) { f.calls++ f.input = in return consoleauth.ApplicationOperation{}, nil } func (f *fakeApplications) ApplyApplication(_ context.Context, _ auth.Token, op consoleauth.ApplicationOperation) (consoleauth.ApplicationReceipt, error) { f.calls++ f.op = op return consoleauth.ApplicationReceipt{}, nil } func TestConsoleApplicationSessionAndCSRF(t *testing.T) { ns, _ := core.NewID() id, _ := core.NewID() raw, _, _ := auth.Issue(ns, id) cases := []struct { name, method, path, origin, header string cookie bool body string status int }{ {name: "no login", method: "GET", path: "/console/applications", status: 401}, {name: "SDK bearer only", method: "POST", path: "/console/applications/prepare", header: "1", body: `{}`, status: 401}, {name: "missing csrf header", method: "POST", path: "/console/applications/prepare", cookie: true, body: `{}`, status: 403}, {name: "cross origin", method: "POST", path: "/console/applications/apply", cookie: true, header: "1", origin: "https://evil.example", body: `{}`, status: 403}, {name: "null origin", method: "POST", path: "/console/applications/apply", cookie: true, header: "1", origin: "null", body: `{}`, status: 403}, {name: "same origin", method: "POST", path: "/console/applications/prepare", cookie: true, header: "1", origin: "http://example.com", body: `{}`, status: 200}, {name: "unknown field", method: "POST", path: "/console/applications/prepare", cookie: true, header: "1", body: `{"admin":true}`, status: 400}, {name: "read", method: "GET", path: "/console/applications", cookie: true, status: 200}, } for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { authStore := &fakeConsoleAuth{valid: true} store := &fakeApplications{} server := NewWithConsoleAuth("", nil, authStore) RegisterConsoleApplications(server, authStore, store) req := httptest.NewRequest(tt.method, "http://example.com"+tt.path, strings.NewReader(tt.body)) req.Header.Set("Authorization", "Bearer "+raw) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Orbit-Console", tt.header) if tt.origin != "" { req.Header.Set("Origin", tt.origin) } if tt.cookie { req.AddCookie(&http.Cookie{Name: consoleSessionCookie, Value: raw}) } res := httptest.NewRecorder() server.Handler.ServeHTTP(res, req) if res.Code != tt.status { t.Fatalf("status=%d wanted=%d", res.Code, tt.status) } if tt.status != 200 && store.calls != 0 { t.Fatal("rejected request reached store") } if res.Header().Get("Cache-Control") != "no-store" { t.Fatal("management response cacheable") } }) } } // 准备接口生成的内容必须可被严格解码器原样提交,覆盖可选 ID 和空能力组。 func TestConsolePreparedApplicationRoundTrip(t *testing.T) { ns, _ := core.NewID() cred, _ := core.NewID() app, _ := core.NewID() queue, _ := core.NewID() opID, _ := core.NewID() token, _, _ := auth.Issue(ns, cred) for _, kind := range []string{"CREATE", "ROTATE", "SET_EXPIRY", "FINALIZE", "SET_PERMISSIONS"} { op := consoleauth.ApplicationOperation{Input: consoleauth.ApplicationInput{Kind: kind, ValidDays: 30, Reason: "test"}, OperationID: opID, ApplicationID: app, NamespaceID: ns, CredentialID: cred, Token: token, ExpiresAt: func() *time.Time { v := time.Now().UTC(); return &v }()} if kind == "CREATE" { op.QueueID = queue op.Input.Application = "sample" op.Input.Namespace = "default" op.Input.Environment = "test" op.Input.Queue = "work" } else { op.Input.NamespaceID = ns op.Input.CredentialID = cred op.Input.ExpectedRevision = 1 } if kind == "SET_EXPIRY" || kind == "FINALIZE" || kind == "SET_PERMISSIONS" { op.Version = 2 op.Token = "" op.ExpiresAt = nil op.Input.ValidDays = 0 } if kind == "SET_PERMISSIONS" { op.Version = 3 op.Input.Actions = []string{"schedules:manage", "schedules:read", "tasks:submit"} } raw, err := json.Marshal(op) if err != nil { t.Fatal(err) } req := httptest.NewRequest("POST", "/console/applications/apply", bytes.NewReader(raw)) req.Header.Set("Content-Type", "application/json") var got consoleauth.ApplicationOperation if err := decodeBody(req, &got); err != nil { t.Fatalf("%s prepared output rejected: %v", kind, err) } if got.Token != op.Token || got.Input.Kind != kind { t.Fatal("prepared operation changed") } } } // 权限操作仍走管理员 Cookie 与同源保护;新数组字段不能放宽精确 JSON 解码。 func TestConsolePermissionChangeHTTPBoundary(t *testing.T) { ns, _ := core.NewID() cred, _ := core.NewID() raw, _, _ := auth.Issue(ns, cred) valid := `{"kind":"SET_PERMISSIONS","namespace_id":"` + string(ns) + `","credential_id":"` + string(cred) + `","expected_revision":"7","reason":"enable scheduling","actions":["tasks:submit","schedules:read","schedules:manage"]}` for _, apply := range []bool{false, true} { path := "/console/applications/prepare" wrap := func(in string) string { return in } if apply { path = "/console/applications/apply" wrap = func(in string) string { return `{"version":3,"input":` + in + `,"operation_id":"` + string(ns) + `","application_id":"` + string(ns) + `","namespace_id":"` + string(ns) + `","credential_id":"` + string(cred) + `"}` } } for _, tc := range []struct { name, body, origin, fetch string cookie, validSession, header bool status int }{ {"valid", valid, "http://example.com", "same-origin", true, true, true, 200}, {"SDK bearer only", valid, "", "", false, true, true, 401}, {"expired session", valid, "", "", true, false, true, 401}, {"missing custom header", valid, "", "", true, true, false, 403}, {"cross origin", valid, "https://elsewhere.example", "", true, true, true, 403}, {"opaque origin", valid, "null", "", true, true, true, 403}, {"origin credentials", valid, "http://user@example.com", "", true, true, true, 403}, {"cross site metadata", valid, "http://example.com", "cross-site", true, true, true, 403}, {"duplicate actions", strings.Replace(valid, `"actions":`, `"actions":[],"actions":`, 1), "", "", true, true, true, 400}, {"field alias", strings.Replace(valid, `"actions":`, `"Actions":`, 1), "", "", true, true, true, 400}, {"unknown scope field", strings.TrimSuffix(valid, "}") + `,"queues":["*"]}`, "", "", true, true, true, 400}, {"null actions", strings.Replace(valid, `["tasks:submit","schedules:read","schedules:manage"]`, `null`, 1), "", "", true, true, true, 400}, {"null element", strings.Replace(valid, `"tasks:submit"`, `null`, 1), "", "", true, true, true, 400}, {"numeric element", strings.Replace(valid, `"tasks:submit"`, `12`, 1), "", "", true, true, true, 400}, {"numeric revision", strings.Replace(valid, `"expected_revision":"7"`, `"expected_revision":7`, 1), "", "", true, true, true, 400}, } { t.Run(path+"/"+tc.name, func(t *testing.T) { authStore := &fakeConsoleAuth{valid: tc.validSession} store := &fakeApplications{} server := NewWithConsoleAuth("", nil, authStore) RegisterConsoleApplications(server, authStore, store) req := httptest.NewRequest(http.MethodPost, "http://example.com"+path, strings.NewReader(wrap(tc.body))) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+raw) if tc.cookie { req.AddCookie(&http.Cookie{Name: consoleSessionCookie, Value: raw}) } if tc.header { req.Header.Set("X-Orbit-Console", "1") } if tc.origin != "" { req.Header.Set("Origin", tc.origin) } if tc.fetch != "" { req.Header.Set("Sec-Fetch-Site", tc.fetch) } res := httptest.NewRecorder() server.Handler.ServeHTTP(res, req) if res.Code != tc.status { t.Fatalf("status=%d want=%d", res.Code, tc.status) } if res.Header().Get("Cache-Control") != "no-store" { t.Fatal("permission response is cacheable") } if tc.status != 200 { if store.calls != 0 { t.Fatal("rejected request reached store") } return } input := store.input if apply { input = store.op.Input if store.op.Version != 3 || store.op.Token != "" { t.Fatal("permission envelope changed") } } if store.calls != 1 || input.Kind != "SET_PERMISSIONS" || input.ExpectedRevision != 7 || len(input.Actions) != 3 || input.Actions[2] != "schedules:manage" { t.Fatal("permission intent changed") } }) } } }