package console import ( "context" "errors" "net/http" "strings" "testing" "time" "github.com/carlosframework/platform/internal/release" ) // The layout door is the update door's gate, byte for byte: a member of // the ops account gets the bare 404 an unknown path gives, and a system // operator who is not an owner gets in, as they do at update and rollback. func TestSystemLayoutDoorIsTheSystemGate(t *testing.T) { fx := newConsoleFixture(t) if err := fx.st.AddMember(OpsAccountID, "member@keymail.dev", RoleMember, time.Now()); err != nil { t.Fatal(err) } member := cliToken(t, fx, "member@keymail.dev", "code-layout-member") _, want := getRaw(t, fx.ts.URL+"/api/cli/system/no-such-door", member) if st, body := getRaw(t, fx.ts.URL+"/api/cli/system/layout", member); st != http.StatusNotFound || body != want { t.Errorf("GET as a member = %d %q, want 404 %q", st, body, want) } if st, body := postRaw(t, fx.ts.URL+"/api/cli/system/layout", map[string]string{"mode": "nested"}, member); st != http.StatusNotFound || body != want { t.Errorf("POST as a member = %d %q, want 404 %q", st, body, want) } // A member that nothing was recorded for: the object stays absent. if _, found, _ := release.LoadLayoutIntents(context.Background(), fx.rel); found { t.Error("a refused POST recorded an intent") } fx.srv.cfg.SystemOperators = []string{"Roller@keymail.dev"} if err := fx.st.AddMember(OpsAccountID, "roller@keymail.dev", RoleMember, time.Now()); err != nil { t.Fatal(err) } roller := cliToken(t, fx, "roller@keymail.dev", "code-layout-roller") if st, out := getJSON(t, fx.ts.URL+"/api/cli/system/layout", roller); st != http.StatusOK || out["mode"] != "none" { t.Errorf("GET as a system operator = %d %v, want 200 mode none", st, out) } } func TestSystemLayoutDoorRecordsTheIntentAndBumpsTheSerial(t *testing.T) { fx := newConsoleFixture(t) owner := cliToken(t, fx, "paul@keymail.dev", "code-layout-owner") // Every box in scope runs the floor release, so the migrate's floor // check (TestSystemLayoutMigrateRefusedBelowTheFloor) is not what this // test is about. seedBox(t, fx, "kass-1", "linux-arm64", "stable", "v0.25.0") for _, body := range []map[string]any{{"mode": "sideways"}, {"mode": ""}, {"mode": "nested", "box": "Not A Label!"}, {"mode": "nested", "bogus": 1}} { if st, out := postJSON(t, fx.ts.URL+"/api/cli/system/layout", body, owner); st != http.StatusBadRequest { t.Errorf("POST %v = %d %v, want 400", body, st, out) } } if serial(t, fx) != 0 { t.Fatal("a refused request bumped the serial") } st, out := postJSON(t, fx.ts.URL+"/api/cli/system/layout", map[string]any{"mode": "nested", "box": "kass-1", "force": true}, owner) if st != http.StatusOK || out["mode"] != "nested" || out["box"] != "kass-1" || out["force"] != true || str(t, out, "id") == "" { t.Fatalf("record = %d %v", st, out) } in, found, err := release.LoadLayoutIntent(context.Background(), fx.rel, "kass-1") if err != nil || !found || in.Mode != "nested" || in.Scope.Box != "kass-1" || !in.Force || in.ID != str(t, out, "id") { t.Fatalf("object = %+v %v %v", in, found, err) } if _, found, _ := release.LoadLayoutIntent(context.Background(), fx.rel, "kass-2"); found { t.Error("a box-scoped intent applies to another box") } if got := serial(t, fx); got != 1 { t.Fatalf("serial = %d, want 1: boxes converge on the serial move", got) } st, out = getJSON(t, fx.ts.URL+"/api/cli/system/layout", owner) if st != http.StatusOK || out["mode"] != "nested" || out["box"] != "kass-1" || out["force"] != true || out["requests"] != float64(1) { t.Fatalf("get = %d %v", st, out) } // A fleet-wide revert after it is the newest request everywhere. st, out = postJSON(t, fx.ts.URL+"/api/cli/system/layout", map[string]any{"mode": "flat"}, owner) if st != http.StatusOK || out["box"] != "" || out["force"] != false { t.Fatalf("revert = %d %v", st, out) } in, found, _ = release.LoadLayoutIntent(context.Background(), fx.rel, "kass-1") if !found || in.Mode != "flat" { t.Fatalf("kass-1 after a fleet-wide revert = %+v", in) } if got := serial(t, fx); got != 2 { t.Fatalf("serial = %d, want 2", got) } // The audit row names the act. events, err := fx.st.Events(OpsAccountID, "platform", 10) if err != nil { t.Fatal(err) } var saw bool for _, e := range events { if e.Action == "system-layout" && strings.Contains(e.Detail, "nested for box kass-1") && strings.Contains(e.Detail, "force") { saw = true } } if !saw { t.Errorf("audit rows = %+v, want a system-layout row naming nested, box kass-1 and force", events) } } func TestSystemLayoutDoorWithoutABucketRefuses(t *testing.T) { fx := newConsoleFixture(t) owner := cliToken(t, fx, "paul@keymail.dev", "code-layout-nobucket") fx.srv.cfg.Releases = nil if st, out := getJSON(t, fx.ts.URL+"/api/cli/system/layout", owner); st != http.StatusServiceUnavailable { t.Errorf("GET without a bucket = %d %v, want 503", st, out) } if st, out := postJSON(t, fx.ts.URL+"/api/cli/system/layout", map[string]string{"mode": "nested"}, owner); st != http.StatusServiceUnavailable { t.Errorf("POST without a bucket = %d %v, want 503", st, out) } } // #132's interlock: a revert is refused for a box with an active or // revoking maintenance worker head, in scope. A detached head, or a head // on a box outside a box-scoped revert, does not block. Migrating is never // blocked by a worker. A reader that fails blocks: unknown is not "none". func TestSystemLayoutRevertRefusedWhileAWorkerHeadIsAttached(t *testing.T) { fx := newConsoleFixture(t) owner := cliToken(t, fx, "paul@keymail.dev", "code-layout-worker") heads := []release.WorkerHead{ {Box: "kass-1", Status: "active"}, {Box: "kass-2", Status: "detached"}, {Box: "kass-3", Status: "revoking"}, } var readErr error fx.srv.cfg.WorkerHeads = func(context.Context) ([]release.WorkerHead, error) { return heads, readErr } for _, b := range []string{"kass-1", "kass-2", "kass-3"} { seedBox(t, fx, b, "linux-arm64", "stable", "v0.25.0") } st, out := postJSON(t, fx.ts.URL+"/api/cli/system/layout", map[string]string{"mode": "flat"}, owner) if st != http.StatusConflict { t.Fatalf("fleet revert with workers attached = %d %v, want 409", st, out) } if msg := str(t, out, "error"); !strings.Contains(msg, "kass-1 has a maintenance worker (active)") || !strings.Contains(msg, "kass-3 has a maintenance worker (revoking)") || strings.Contains(msg, "kass-2") { t.Errorf("reason = %q, want kass-1 and kass-3 named and kass-2 not", msg) } if st, out := postJSON(t, fx.ts.URL+"/api/cli/system/layout", map[string]string{"mode": "flat", "box": "kass-1"}, owner); st != http.StatusConflict { t.Errorf("box revert on the worker's box = %d %v, want 409", st, out) } if st, out := postJSON(t, fx.ts.URL+"/api/cli/system/layout", map[string]string{"mode": "flat", "box": "kass-2"}, owner); st != http.StatusOK { t.Errorf("box revert beside a detached head = %d %v, want 200", st, out) } if st, out := postJSON(t, fx.ts.URL+"/api/cli/system/layout", map[string]string{"mode": "nested"}, owner); st != http.StatusOK { t.Errorf("migrate with workers attached = %d %v, want 200", st, out) } readErr = errors.New("store down") if st, out := postJSON(t, fx.ts.URL+"/api/cli/system/layout", map[string]string{"mode": "flat", "box": "kass-2"}, owner); st != http.StatusConflict { t.Errorf("revert with an unreadable head store = %d %v, want 409", st, out) } }