carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
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.
21func 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.
39func 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
52func 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
61func 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
92func 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
122func 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).
148func 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.
194func 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.
208func 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.
246type casLossStore struct{ release.Store }
247
248func (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.
258func 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