package console import ( "bytes" "context" "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "reflect" "testing" "github.com/carlosframework/platform/internal/hostactors" "github.com/carlosframework/platform/internal/release" ) // roleFixture is a console with region HA on and a signed roster: in ie, // an operator-enrolled box ie-old and two boxes the group "routers" // enrolled; uk has no group. func roleFixture(t *testing.T) *consoleFixture { t.Helper() fx := newConsoleFixture(t) fx.srv.cfg.RegionHA = true old := moveRosterBox(ieBox("ie-old")) g1, g2 := moveRosterBox(ieBox("ie-g1")), moveRosterBox(ieBox("ie-g2")) g1.Group, g2.Group = "routers", "routers" putMoveRoster(t, fx, hostactors.Roster{ Boxes: map[string]hostactors.Box{"ie-old": old, "ie-g1": g1, "ie-g2": g2}, Regions: map[string]hostactors.Region{ "ie": {Account: moveTestAccount, AWSRegion: "eu-west-1", GroupName: "routers", GroupARN: "arn:group"}, "uk": {Account: moveTestAccount, AWSRegion: "eu-west-2"}, }, }) return fx } // roleCall drives one role door as addr, past cliRoute. func roleCall(t *testing.T, h http.HandlerFunc, addr, pathKey, pathValue, body string) (int, map[string]any) { t.Helper() r := httptest.NewRequest(http.MethodPost, "/api/cli/system/x/y/role", bytes.NewBufferString(body)) r.Header.Set("Content-Type", "application/json") r.SetPathValue(pathKey, pathValue) r = r.WithContext(context.WithValue(r.Context(), cliRouteKey{}, Principal{Kind: PrincipalUser, Address: addr})) w := httptest.NewRecorder() h(w, r) var out map[string]any _ = json.Unmarshal(w.Body.Bytes(), &out) return w.Code, out } func rosterNow(t *testing.T, fx *consoleFixture) hostactors.Roster { t.Helper() _, _, o, err := fx.srv.boxRoster(context.Background()) if err != nil { t.Fatal(err) } return o.Roster } func TestBoxRoleDoor(t *testing.T) { fx := roleFixture(t) h := fx.srv.handleAPISystemBoxRole if st, _ := roleCall(t, h, "someone@keymail.dev", "box", "ie-old", `{"role":"legacy"}`); st != http.StatusNotFound { t.Fatalf("a non-operator = %d, want 404", st) } fx.srv.cfg.RegionHA = false if st, _ := roleCall(t, h, "paul@keymail.dev", "box", "ie-old", `{"role":"legacy"}`); st != http.StatusNotFound { t.Fatalf("region HA off = %d, want 404", st) } fx.srv.cfg.RegionHA = true if st, out := roleCall(t, h, "paul@keymail.dev", "box", "ie-old", `{"role":"draining"}`); st != http.StatusBadRequest || out["error"] != boxRoleRefusal { t.Fatalf("unknown role = %d %v", st, out) } if st, out := roleCall(t, h, "paul@keymail.dev", "box", "nobody", `{"role":"legacy"}`); st != http.StatusNotFound || out["error"] != noEnrolledBox("nobody") { t.Fatalf("unknown box = %d %v", st, out) } // By label, as an operator names it. st, out := roleCall(t, h, "paul@keymail.dev", "box", "ie-old-label", `{"role":"legacy"}`) if st != http.StatusOK || out["box"] != "ie-old" || out["role"] != "legacy" { t.Fatalf("set = %d %v", st, out) } if got := rosterNow(t, fx).Boxes["ie-old"].Role; got != hostactors.RoleLegacy { t.Fatalf("the roster says %q", got) } st, out = roleCall(t, h, "paul@keymail.dev", "box", "ie-old", `{"role":"default"}`) if st != http.StatusOK || out["role"] != "default" || rosterNow(t, fx).Boxes["ie-old"].Role != hostactors.RoleDefault { t.Fatalf("back to default = %d %v", st, out) } } func TestGroupRoleDoor(t *testing.T) { fx := roleFixture(t) h := fx.srv.handleAPISystemGroupRole if st, _ := roleCall(t, h, "someone@keymail.dev", "region", "ie", `{"role":"legacy"}`); st != http.StatusNotFound { t.Fatalf("a non-operator = %d, want 404", st) } fx.srv.cfg.RegionHA = false if st, _ := roleCall(t, h, "paul@keymail.dev", "region", "ie", `{"role":"legacy"}`); st != http.StatusNotFound { t.Fatalf("region HA off = %d, want 404", st) } fx.srv.cfg.RegionHA = true if r := rosterNow(t, fx); r.Regions["ie"].Role != hostactors.RoleDefault || r.Boxes["ie-g1"].Role != hostactors.RoleDefault { t.Fatalf("a refused door changed the roster: %+v", r) } if st, out := roleCall(t, h, "paul@keymail.dev", "region", "ie", `{"role":"platform"}`); st != http.StatusBadRequest || out["error"] != groupRoleRefusal { t.Fatalf("platform group = %d %v", st, out) } if st, out := roleCall(t, h, "paul@keymail.dev", "region", "uk", `{"role":"legacy"}`); st != http.StatusNotFound || out["error"] != noGroupIn("uk") { t.Fatalf("no group = %d %v", st, out) } st, out := roleCall(t, h, "paul@keymail.dev", "region", "ie", `{"role":"legacy"}`) if st != http.StatusOK || out["group"] != "routers" || out["role"] != "legacy" || !reflect.DeepEqual(out["boxes"], []any{"ie-g1", "ie-g2"}) { t.Fatalf("set = %d %v", st, out) } r := rosterNow(t, fx) if r.Regions["ie"].Role != hostactors.RoleLegacy || r.Boxes["ie-g1"].Role != hostactors.RoleLegacy || r.Boxes["ie-g2"].Role != hostactors.RoleLegacy || r.Boxes["ie-old"].Role != hostactors.RoleDefault { t.Fatalf("roster after the group role: %+v", r.Boxes) } } func TestRoleWriteErrors(t *testing.T) { for _, c := range []struct { err error code int msg string }{ {release.ErrCASConflict, http.StatusConflict, boxRosterChanged}, {fmt.Errorf("put: %w", release.ErrCASConflict), http.StatusConflict, boxRosterChanged}, {hostactors.ErrRoster, http.StatusConflict, boxRosterBusy}, {fmt.Errorf("replace: %w", hostactors.ErrRoster), http.StatusConflict, boxRosterBusy}, {errors.New("SlowDown"), http.StatusServiceUnavailable, boxRoleWriteFailure}, } { w := httptest.NewRecorder() roleWriteError(w, "test", c.err) var out map[string]string _ = json.Unmarshal(w.Body.Bytes(), &out) if w.Code != c.code || out["error"] != c.msg { t.Fatalf("%v = %d %v", c.err, w.Code, out) } } } // The doors exist only on a host-claim console with region HA on, and are // operator-only there. With either switch off they are not registered: an // unauthenticated request, or an operator's, answers exactly as an unknown // route does, as on main (Astra 2a final review, P3). func TestRoleDoorsAreRegisteredWithHostClaimsAndRegionHA(t *testing.T) { st := openGatedStore(t) rel := release.NewDirStore(t.TempDir()) paths := []string{"/api/cli/system/boxes/ie-old/role", "/api/cli/system/groups/ie/role"} token := mintHostClaimToken(t, st, "paul@keymail.dev") server := func(hostClaim, regionHA bool) *Server { s, err := NewServer(Config{Origin: "https://console.example", Keymail: "https://keymail.example", Releases: rel, HostClaim: hostClaim, RegionHA: regionHA, SystemOperators: []string{"paul@keymail.dev"}}, st) if err != nil { t.Fatal(err) } return s } for _, c := range []struct { name string hostClaim, regionHA bool }{ {"both off", false, false}, {"claims on, region HA off", true, false}, {"claims off, region HA on", false, true}, } { s := server(c.hostClaim, c.regionHA) for _, bearer := range []string{"", token} { ghostStatus, ghostBody := serveHostClaim(t, s, "/api/cli/system/unknown/ie-old/role", bearer) if ghostStatus != http.StatusNotFound { t.Fatalf("%s: the unknown route answers %d", c.name, ghostStatus) } for _, path := range paths { if status, body := serveHostClaim(t, s, path, bearer); status != ghostStatus || !bytes.Equal(body, ghostBody) { t.Errorf("%s: %s (signed %v) = %d %q, want the unknown route's %d %q", c.name, path, bearer != "", status, body, ghostStatus, ghostBody) } } } } on := server(true, true) for _, path := range paths { if status, _ := serveHostClaim(t, on, path, ""); status != http.StatusUnauthorized { t.Errorf("both on: %s unsigned = %d, want 401", path, status) } if status, _ := serveHostClaim(t, on, path, token); status == http.StatusNotFound || status == http.StatusUnauthorized { t.Errorf("both on: %s as an operator = %d, want the door's own answer", path, status) } } } // roleAudits is the ops account's platform audit rows, oldest first. func roleAudits(t *testing.T, fx *consoleFixture) []Event { t.Helper() ev, err := fx.st.Events(OpsAccountID, "platform", 50) if err != nil { t.Fatal(err) } for i, j := 0, len(ev)-1; i < j; i, j = i+1, j-1 { ev[i], ev[j] = ev[j], ev[i] } return ev } // A successful change writes exactly one audit row, naming the operator; // a refused one, or one that changes nothing, writes none. func TestRoleDoorsAudit(t *testing.T) { fx := roleFixture(t) box, group := fx.srv.handleAPISystemBoxRole, fx.srv.handleAPISystemGroupRole // Refusals: a non-operator, an unknown role, an unknown box, a region // with no group, and region HA off. roleCall(t, box, "someone@keymail.dev", "box", "ie-old", `{"role":"legacy"}`) roleCall(t, box, "paul@keymail.dev", "box", "ie-old", `{"role":"draining"}`) roleCall(t, box, "paul@keymail.dev", "box", "nobody", `{"role":"legacy"}`) roleCall(t, group, "paul@keymail.dev", "region", "uk", `{"role":"legacy"}`) roleCall(t, group, "paul@keymail.dev", "region", "ie", `{"role":"platform"}`) fx.srv.cfg.RegionHA = false roleCall(t, box, "paul@keymail.dev", "box", "ie-old", `{"role":"legacy"}`) fx.srv.cfg.RegionHA = true // Changing nothing writes nothing either. if st, _ := roleCall(t, box, "paul@keymail.dev", "box", "ie-old", `{"role":"default"}`); st != http.StatusOK { t.Fatalf("no-op = %d", st) } if ev := roleAudits(t, fx); len(ev) != 0 { t.Fatalf("refusals and a no-op wrote audit rows: %+v", ev) } if st, _ := roleCall(t, box, "paul@keymail.dev", "box", "ie-old", `{"role":"legacy"}`); st != http.StatusOK { t.Fatalf("box role = %d", st) } ev := roleAudits(t, fx) if len(ev) != 1 || ev[0].Action != "box-role" || ev[0].Address != "paul@keymail.dev" || ev[0].Detail != "gave ie-old the legacy role" { t.Fatalf("box role audit: %+v", ev) } if st, _ := roleCall(t, group, "paul@keymail.dev", "region", "ie", `{"role":"legacy"}`); st != http.StatusOK { t.Fatalf("group role = %d", st) } ev = roleAudits(t, fx) if len(ev) != 2 || ev[1].Action != "group-role" || ev[1].Address != "paul@keymail.dev" || ev[1].Detail != "gave the group in ie and its boxes the legacy role" { t.Fatalf("group role audit: %+v", ev) } } // casLossStore loses every write to the roster, as a second writer winning // the race does. type casLossStore struct{ release.Store } func (s casLossStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { if key == hostactors.Key { return "", release.ErrCASConflict } return s.Store.Put(ctx, key, body, ifMatch) } // A lost compare-and-swap, through each door, answers 409 with the // "changed while saving" sentence, leaves the roster as it was and writes // no audit row. func TestRoleDoorsLostRaceChangesNothing(t *testing.T) { fx := roleFixture(t) before := rosterNow(t, fx) fx.srv.cfg.Releases = casLossStore{fx.srv.cfg.Releases} for _, c := range []struct { name string h http.HandlerFunc key string val string }{ {"box", fx.srv.handleAPISystemBoxRole, "box", "ie-old"}, {"group", fx.srv.handleAPISystemGroupRole, "region", "ie"}, } { st, out := roleCall(t, c.h, "paul@keymail.dev", c.key, c.val, `{"role":"legacy"}`) if st != http.StatusConflict || out["error"] != boxRosterChanged { t.Fatalf("%s door: %d %v", c.name, st, out) } } if after := rosterNow(t, fx); !reflect.DeepEqual(after, before) { t.Fatalf("a lost race changed the roster:\nbefore %+v\nafter %+v", before, after) } if ev := roleAudits(t, fx); len(ev) != 0 { t.Fatalf("a lost race wrote audit rows: %+v", ev) } }