| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "fmt" |
| 9 | "net/http" |
| 10 | "net/http/httptest" |
| 11 | "reflect" |
| 12 | "testing" |
| 13 | |
| 14 | "github.com/carlosframework/platform/internal/hostactors" |
| 15 | "github.com/carlosframework/platform/internal/release" |
| 16 | ) |
| 17 | |
| 18 | // roleFixture is a console with region HA on and a signed roster: in ie, |
| 19 | // an operator-enrolled box ie-old and two boxes the group "routers" |
| 20 | // enrolled; uk has no group. |
| 21 | func roleFixture(t *testing.T) *consoleFixture { |
| 22 | t.Helper() |
| 23 | fx := newConsoleFixture(t) |
| 24 | fx.srv.cfg.RegionHA = true |
| 25 | old := moveRosterBox(ieBox("ie-old")) |
| 26 | g1, g2 := moveRosterBox(ieBox("ie-g1")), moveRosterBox(ieBox("ie-g2")) |
| 27 | g1.Group, g2.Group = "routers", "routers" |
| 28 | putMoveRoster(t, fx, hostactors.Roster{ |
| 29 | Boxes: map[string]hostactors.Box{"ie-old": old, "ie-g1": g1, "ie-g2": g2}, |
| 30 | Regions: map[string]hostactors.Region{ |
| 31 | "ie": {Account: moveTestAccount, AWSRegion: "eu-west-1", GroupName: "routers", GroupARN: "arn:group"}, |
| 32 | "uk": {Account: moveTestAccount, AWSRegion: "eu-west-2"}, |
| 33 | }, |
| 34 | }) |
| 35 | return fx |
| 36 | } |
| 37 | |
| 38 | // roleCall drives one role door as addr, past cliRoute. |
| 39 | func roleCall(t *testing.T, h http.HandlerFunc, addr, pathKey, pathValue, body string) (int, map[string]any) { |
| 40 | t.Helper() |
| 41 | r := httptest.NewRequest(http.MethodPost, "/api/cli/system/x/y/role", bytes.NewBufferString(body)) |
| 42 | r.Header.Set("Content-Type", "application/json") |
| 43 | r.SetPathValue(pathKey, pathValue) |
| 44 | r = r.WithContext(context.WithValue(r.Context(), cliRouteKey{}, Principal{Kind: PrincipalUser, Address: addr})) |
| 45 | w := httptest.NewRecorder() |
| 46 | h(w, r) |
| 47 | var out map[string]any |
| 48 | _ = json.Unmarshal(w.Body.Bytes(), &out) |
| 49 | return w.Code, out |
| 50 | } |
| 51 | |
| 52 | func rosterNow(t *testing.T, fx *consoleFixture) hostactors.Roster { |
| 53 | t.Helper() |
| 54 | _, _, o, err := fx.srv.boxRoster(context.Background()) |
| 55 | if err != nil { |
| 56 | t.Fatal(err) |
| 57 | } |
| 58 | return o.Roster |
| 59 | } |
| 60 | |
| 61 | func TestBoxRoleDoor(t *testing.T) { |
| 62 | fx := roleFixture(t) |
| 63 | h := fx.srv.handleAPISystemBoxRole |
| 64 | if st, _ := roleCall(t, h, "someone@keymail.dev", "box", "ie-old", `{"role":"legacy"}`); st != http.StatusNotFound { |
| 65 | t.Fatalf("a non-operator = %d, want 404", st) |
| 66 | } |
| 67 | fx.srv.cfg.RegionHA = false |
| 68 | if st, _ := roleCall(t, h, "paul@keymail.dev", "box", "ie-old", `{"role":"legacy"}`); st != http.StatusNotFound { |
| 69 | t.Fatalf("region HA off = %d, want 404", st) |
| 70 | } |
| 71 | fx.srv.cfg.RegionHA = true |
| 72 | if st, out := roleCall(t, h, "paul@keymail.dev", "box", "ie-old", `{"role":"draining"}`); st != http.StatusBadRequest || out["error"] != boxRoleRefusal { |
| 73 | t.Fatalf("unknown role = %d %v", st, out) |
| 74 | } |
| 75 | if st, out := roleCall(t, h, "paul@keymail.dev", "box", "nobody", `{"role":"legacy"}`); st != http.StatusNotFound || out["error"] != noEnrolledBox("nobody") { |
| 76 | t.Fatalf("unknown box = %d %v", st, out) |
| 77 | } |
| 78 | // By label, as an operator names it. |
| 79 | st, out := roleCall(t, h, "paul@keymail.dev", "box", "ie-old-label", `{"role":"legacy"}`) |
| 80 | if st != http.StatusOK || out["box"] != "ie-old" || out["role"] != "legacy" { |
| 81 | t.Fatalf("set = %d %v", st, out) |
| 82 | } |
| 83 | if got := rosterNow(t, fx).Boxes["ie-old"].Role; got != hostactors.RoleLegacy { |
| 84 | t.Fatalf("the roster says %q", got) |
| 85 | } |
| 86 | st, out = roleCall(t, h, "paul@keymail.dev", "box", "ie-old", `{"role":"default"}`) |
| 87 | if st != http.StatusOK || out["role"] != "default" || rosterNow(t, fx).Boxes["ie-old"].Role != hostactors.RoleDefault { |
| 88 | t.Fatalf("back to default = %d %v", st, out) |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | func TestGroupRoleDoor(t *testing.T) { |
| 93 | fx := roleFixture(t) |
| 94 | h := fx.srv.handleAPISystemGroupRole |
| 95 | if st, _ := roleCall(t, h, "someone@keymail.dev", "region", "ie", `{"role":"legacy"}`); st != http.StatusNotFound { |
| 96 | t.Fatalf("a non-operator = %d, want 404", st) |
| 97 | } |
| 98 | fx.srv.cfg.RegionHA = false |
| 99 | if st, _ := roleCall(t, h, "paul@keymail.dev", "region", "ie", `{"role":"legacy"}`); st != http.StatusNotFound { |
| 100 | t.Fatalf("region HA off = %d, want 404", st) |
| 101 | } |
| 102 | fx.srv.cfg.RegionHA = true |
| 103 | if r := rosterNow(t, fx); r.Regions["ie"].Role != hostactors.RoleDefault || r.Boxes["ie-g1"].Role != hostactors.RoleDefault { |
| 104 | t.Fatalf("a refused door changed the roster: %+v", r) |
| 105 | } |
| 106 | if st, out := roleCall(t, h, "paul@keymail.dev", "region", "ie", `{"role":"platform"}`); st != http.StatusBadRequest || out["error"] != groupRoleRefusal { |
| 107 | t.Fatalf("platform group = %d %v", st, out) |
| 108 | } |
| 109 | if st, out := roleCall(t, h, "paul@keymail.dev", "region", "uk", `{"role":"legacy"}`); st != http.StatusNotFound || out["error"] != noGroupIn("uk") { |
| 110 | t.Fatalf("no group = %d %v", st, out) |
| 111 | } |
| 112 | st, out := roleCall(t, h, "paul@keymail.dev", "region", "ie", `{"role":"legacy"}`) |
| 113 | if st != http.StatusOK || out["group"] != "routers" || out["role"] != "legacy" || !reflect.DeepEqual(out["boxes"], []any{"ie-g1", "ie-g2"}) { |
| 114 | t.Fatalf("set = %d %v", st, out) |
| 115 | } |
| 116 | r := rosterNow(t, fx) |
| 117 | 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 { |
| 118 | t.Fatalf("roster after the group role: %+v", r.Boxes) |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | func TestRoleWriteErrors(t *testing.T) { |
| 123 | for _, c := range []struct { |
| 124 | err error |
| 125 | code int |
| 126 | msg string |
| 127 | }{ |
| 128 | {release.ErrCASConflict, http.StatusConflict, boxRosterChanged}, |
| 129 | {fmt.Errorf("put: %w", release.ErrCASConflict), http.StatusConflict, boxRosterChanged}, |
| 130 | {hostactors.ErrRoster, http.StatusConflict, boxRosterBusy}, |
| 131 | {fmt.Errorf("replace: %w", hostactors.ErrRoster), http.StatusConflict, boxRosterBusy}, |
| 132 | {errors.New("SlowDown"), http.StatusServiceUnavailable, boxRoleWriteFailure}, |
| 133 | } { |
| 134 | w := httptest.NewRecorder() |
| 135 | roleWriteError(w, "test", c.err) |
| 136 | var out map[string]string |
| 137 | _ = json.Unmarshal(w.Body.Bytes(), &out) |
| 138 | if w.Code != c.code || out["error"] != c.msg { |
| 139 | t.Fatalf("%v = %d %v", c.err, w.Code, out) |
| 140 | } |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | // The doors exist only on a host-claim console with region HA on, and are |
| 145 | // operator-only there. With either switch off they are not registered: an |
| 146 | // unauthenticated request, or an operator's, answers exactly as an unknown |
| 147 | // route does, as on main (Astra 2a final review, P3). |
| 148 | func TestRoleDoorsAreRegisteredWithHostClaimsAndRegionHA(t *testing.T) { |
| 149 | st := openGatedStore(t) |
| 150 | rel := release.NewDirStore(t.TempDir()) |
| 151 | paths := []string{"/api/cli/system/boxes/ie-old/role", "/api/cli/system/groups/ie/role"} |
| 152 | token := mintHostClaimToken(t, st, "paul@keymail.dev") |
| 153 | server := func(hostClaim, regionHA bool) *Server { |
| 154 | s, err := NewServer(Config{Origin: "https://console.example", Keymail: "https://keymail.example", Releases: rel, |
| 155 | HostClaim: hostClaim, RegionHA: regionHA, SystemOperators: []string{"paul@keymail.dev"}}, st) |
| 156 | if err != nil { |
| 157 | t.Fatal(err) |
| 158 | } |
| 159 | return s |
| 160 | } |
| 161 | for _, c := range []struct { |
| 162 | name string |
| 163 | hostClaim, regionHA bool |
| 164 | }{ |
| 165 | {"both off", false, false}, |
| 166 | {"claims on, region HA off", true, false}, |
| 167 | {"claims off, region HA on", false, true}, |
| 168 | } { |
| 169 | s := server(c.hostClaim, c.regionHA) |
| 170 | for _, bearer := range []string{"", token} { |
| 171 | ghostStatus, ghostBody := serveHostClaim(t, s, "/api/cli/system/unknown/ie-old/role", bearer) |
| 172 | if ghostStatus != http.StatusNotFound { |
| 173 | t.Fatalf("%s: the unknown route answers %d", c.name, ghostStatus) |
| 174 | } |
| 175 | for _, path := range paths { |
| 176 | if status, body := serveHostClaim(t, s, path, bearer); status != ghostStatus || !bytes.Equal(body, ghostBody) { |
| 177 | t.Errorf("%s: %s (signed %v) = %d %q, want the unknown route's %d %q", c.name, path, bearer != "", status, body, ghostStatus, ghostBody) |
| 178 | } |
| 179 | } |
| 180 | } |
| 181 | } |
| 182 | on := server(true, true) |
| 183 | for _, path := range paths { |
| 184 | if status, _ := serveHostClaim(t, on, path, ""); status != http.StatusUnauthorized { |
| 185 | t.Errorf("both on: %s unsigned = %d, want 401", path, status) |
| 186 | } |
| 187 | if status, _ := serveHostClaim(t, on, path, token); status == http.StatusNotFound || status == http.StatusUnauthorized { |
| 188 | t.Errorf("both on: %s as an operator = %d, want the door's own answer", path, status) |
| 189 | } |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | // roleAudits is the ops account's platform audit rows, oldest first. |
| 194 | func roleAudits(t *testing.T, fx *consoleFixture) []Event { |
| 195 | t.Helper() |
| 196 | ev, err := fx.st.Events(OpsAccountID, "platform", 50) |
| 197 | if err != nil { |
| 198 | t.Fatal(err) |
| 199 | } |
| 200 | for i, j := 0, len(ev)-1; i < j; i, j = i+1, j-1 { |
| 201 | ev[i], ev[j] = ev[j], ev[i] |
| 202 | } |
| 203 | return ev |
| 204 | } |
| 205 | |
| 206 | // A successful change writes exactly one audit row, naming the operator; |
| 207 | // a refused one, or one that changes nothing, writes none. |
| 208 | func TestRoleDoorsAudit(t *testing.T) { |
| 209 | fx := roleFixture(t) |
| 210 | box, group := fx.srv.handleAPISystemBoxRole, fx.srv.handleAPISystemGroupRole |
| 211 | // Refusals: a non-operator, an unknown role, an unknown box, a region |
| 212 | // with no group, and region HA off. |
| 213 | roleCall(t, box, "someone@keymail.dev", "box", "ie-old", `{"role":"legacy"}`) |
| 214 | roleCall(t, box, "paul@keymail.dev", "box", "ie-old", `{"role":"draining"}`) |
| 215 | roleCall(t, box, "paul@keymail.dev", "box", "nobody", `{"role":"legacy"}`) |
| 216 | roleCall(t, group, "paul@keymail.dev", "region", "uk", `{"role":"legacy"}`) |
| 217 | roleCall(t, group, "paul@keymail.dev", "region", "ie", `{"role":"platform"}`) |
| 218 | fx.srv.cfg.RegionHA = false |
| 219 | roleCall(t, box, "paul@keymail.dev", "box", "ie-old", `{"role":"legacy"}`) |
| 220 | fx.srv.cfg.RegionHA = true |
| 221 | // Changing nothing writes nothing either. |
| 222 | if st, _ := roleCall(t, box, "paul@keymail.dev", "box", "ie-old", `{"role":"default"}`); st != http.StatusOK { |
| 223 | t.Fatalf("no-op = %d", st) |
| 224 | } |
| 225 | if ev := roleAudits(t, fx); len(ev) != 0 { |
| 226 | t.Fatalf("refusals and a no-op wrote audit rows: %+v", ev) |
| 227 | } |
| 228 | if st, _ := roleCall(t, box, "paul@keymail.dev", "box", "ie-old", `{"role":"legacy"}`); st != http.StatusOK { |
| 229 | t.Fatalf("box role = %d", st) |
| 230 | } |
| 231 | ev := roleAudits(t, fx) |
| 232 | if len(ev) != 1 || ev[0].Action != "box-role" || ev[0].Address != "paul@keymail.dev" || ev[0].Detail != "gave ie-old the legacy role" { |
| 233 | t.Fatalf("box role audit: %+v", ev) |
| 234 | } |
| 235 | if st, _ := roleCall(t, group, "paul@keymail.dev", "region", "ie", `{"role":"legacy"}`); st != http.StatusOK { |
| 236 | t.Fatalf("group role = %d", st) |
| 237 | } |
| 238 | ev = roleAudits(t, fx) |
| 239 | 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" { |
| 240 | t.Fatalf("group role audit: %+v", ev) |
| 241 | } |
| 242 | } |
| 243 | |
| 244 | // casLossStore loses every write to the roster, as a second writer winning |
| 245 | // the race does. |
| 246 | type casLossStore struct{ release.Store } |
| 247 | |
| 248 | func (s casLossStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| 249 | if key == hostactors.Key { |
| 250 | return "", release.ErrCASConflict |
| 251 | } |
| 252 | return s.Store.Put(ctx, key, body, ifMatch) |
| 253 | } |
| 254 | |
| 255 | // A lost compare-and-swap, through each door, answers 409 with the |
| 256 | // "changed while saving" sentence, leaves the roster as it was and writes |
| 257 | // no audit row. |
| 258 | func TestRoleDoorsLostRaceChangesNothing(t *testing.T) { |
| 259 | fx := roleFixture(t) |
| 260 | before := rosterNow(t, fx) |
| 261 | fx.srv.cfg.Releases = casLossStore{fx.srv.cfg.Releases} |
| 262 | for _, c := range []struct { |
| 263 | name string |
| 264 | h http.HandlerFunc |
| 265 | key string |
| 266 | val string |
| 267 | }{ |
| 268 | {"box", fx.srv.handleAPISystemBoxRole, "box", "ie-old"}, |
| 269 | {"group", fx.srv.handleAPISystemGroupRole, "region", "ie"}, |
| 270 | } { |
| 271 | st, out := roleCall(t, c.h, "paul@keymail.dev", c.key, c.val, `{"role":"legacy"}`) |
| 272 | if st != http.StatusConflict || out["error"] != boxRosterChanged { |
| 273 | t.Fatalf("%s door: %d %v", c.name, st, out) |
| 274 | } |
| 275 | } |
| 276 | if after := rosterNow(t, fx); !reflect.DeepEqual(after, before) { |
| 277 | t.Fatalf("a lost race changed the roster:\nbefore %+v\nafter %+v", before, after) |
| 278 | } |
| 279 | if ev := roleAudits(t, fx); len(ev) != 0 { |
| 280 | t.Fatalf("a lost race wrote audit rows: %+v", ev) |
| 281 | } |
| 282 | } |
| 283 | |