223 lines
9.3 KiB
Go
223 lines
9.3 KiB
Go
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")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|