| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "io" |
| 8 | "net/http" |
| 9 | "strings" |
| 10 | "sync" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/carlosframework/platform/internal/provision" |
| 15 | "github.com/carlosframework/platform/internal/release" |
| 16 | "github.com/carlosframework/platform/internal/seeds" |
| 17 | "github.com/carlosframework/platform/internal/svcrole" |
| 18 | ) |
| 19 | |
| 20 | // legacyFx is a console whose ops account owner is paul@keymail.dev, with a |
| 21 | // second account (sqid "cdef", not the operator account) holding the apps |
| 22 | // shop and other, and a third (sqid "efgh") holding an app shop of its own. |
| 23 | type legacyFx struct { |
| 24 | *consoleFixture |
| 25 | owner string |
| 26 | url string |
| 27 | } |
| 28 | |
| 29 | const ( |
| 30 | legacyOtherID = 4242 |
| 31 | legacyThirdID = 4343 |
| 32 | ) |
| 33 | |
| 34 | func legacyFixture(t *testing.T, code string) *legacyFx { |
| 35 | t.Helper() |
| 36 | fx := newConsoleFixture(t) |
| 37 | now := time.Now() |
| 38 | if err := fx.st.CreateAccount(legacyOtherID, "cdef", "Other", "someone@keymail.dev", now); err != nil { |
| 39 | t.Fatal(err) |
| 40 | } |
| 41 | for _, app := range []string{"shop", "other"} { |
| 42 | if _, err := fx.st.CreateAppIn(legacyOtherID, app, "someone@keymail.dev", now); err != nil { |
| 43 | t.Fatal(err) |
| 44 | } |
| 45 | } |
| 46 | if err := fx.st.CreateAccount(legacyThirdID, "efgh", "Third", "third@keymail.dev", now); err != nil { |
| 47 | t.Fatal(err) |
| 48 | } |
| 49 | if _, err := fx.st.CreateAppIn(legacyThirdID, "shop", "third@keymail.dev", now); err != nil { |
| 50 | t.Fatal(err) |
| 51 | } |
| 52 | return &legacyFx{consoleFixture: fx, owner: cliToken(t, fx, "paul@keymail.dev", code), url: fx.ts.URL + "/api/cli/system/legacy-routes"} |
| 53 | } |
| 54 | |
| 55 | func (fx *legacyFx) assign(t *testing.T, name, to string) (int, map[string]any) { |
| 56 | t.Helper() |
| 57 | return postJSON(t, fx.url, map[string]any{"name": name, "to": to}, fx.owner) |
| 58 | } |
| 59 | |
| 60 | // legacyImportRecord files an import record of cdef/shop in state. |
| 61 | func legacyImportRecord(t *testing.T, rs release.Store, host, state string) { |
| 62 | t.Helper() |
| 63 | legacyImportRecordIn(t, rs, "cdef", host, state) |
| 64 | } |
| 65 | |
| 66 | // legacyImportRecordIn files an import record of <acct>/shop in state. |
| 67 | func legacyImportRecordIn(t *testing.T, rs release.Store, acct, host, state string) { |
| 68 | t.Helper() |
| 69 | id, err := seeds.NewID() |
| 70 | if err != nil { |
| 71 | t.Fatal(err) |
| 72 | } |
| 73 | now := time.Now().UTC() |
| 74 | rec := provision.Record{Schema: 1, Host: host, Slug: strings.Split(host, ".")[0], App: "shop", Account: acct, Channel: "stable", |
| 75 | CreatedAt: now, Seed: &provision.SeedSource{ID: id, Kind: seeds.KindImport, State: state, By: "t", At: now}} |
| 76 | if err := provision.CreateRecord(context.Background(), rs, rec); err != nil { |
| 77 | t.Fatal(err) |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | func readOwner(t *testing.T, rs release.Store, name string) (seeds.LegacyOwner, bool) { |
| 82 | t.Helper() |
| 83 | o, _, err := seeds.ReadLegacyOwners(context.Background(), rs) |
| 84 | if err != nil { |
| 85 | t.Fatalf("the strict reader refused the file: %v", err) |
| 86 | } |
| 87 | return o.Lookup(name) |
| 88 | } |
| 89 | |
| 90 | func TestAssignLegacyRoutes(t *testing.T) { |
| 91 | fx := legacyFixture(t, "code-assign-owner") |
| 92 | for _, bad := range []string{"Not An App!", "", "a/b"} { |
| 93 | if st, out := fx.assign(t, bad, "cdef/shop"); st != http.StatusBadRequest || str(t, out, "error") != "name must be an app name" { |
| 94 | t.Fatalf("bad name %q = %d %v", bad, st, out) |
| 95 | } |
| 96 | } |
| 97 | for _, bad := range []string{"cdef", "cdef/", "/shop", "cdef/Not An App", "zzzz/shop", "cdef/shop/x"} { |
| 98 | if st, out := fx.assign(t, "shop", bad); st != http.StatusBadRequest || str(t, out, "error") != "to must be <account>/<app>" { |
| 99 | t.Fatalf("bad to %q = %d %v", bad, st, out) |
| 100 | } |
| 101 | } |
| 102 | // An app that does not exist, under its own name. |
| 103 | if st, out := fx.assign(t, "missing", "cdef/missing"); st != http.StatusBadRequest || str(t, out, "error") != "to must be <account>/<app>" { |
| 104 | t.Fatalf("a missing app = %d %v", st, out) |
| 105 | } |
| 106 | if _, _, err := fx.rel.Get(context.Background(), seeds.LegacyOwnersKey); err == nil { |
| 107 | t.Fatal("a refused assign wrote the file") |
| 108 | } |
| 109 | |
| 110 | st, out := fx.assign(t, "shop", "cdef/shop") |
| 111 | if st != http.StatusOK || out["name"] != "shop" || out["account"] != "cdef" || out["app"] != "shop" { |
| 112 | t.Fatalf("assign = %d %v", st, out) |
| 113 | } |
| 114 | if got, ok := readOwner(t, fx.rel, "shop"); !ok || got != (seeds.LegacyOwner{Account: "cdef", App: "shop"}) { |
| 115 | t.Fatalf("file = %+v %v", got, ok) |
| 116 | } |
| 117 | raw, etag, err := fx.rel.Get(context.Background(), seeds.LegacyOwnersKey) |
| 118 | if err != nil || string(raw) != `{"v":1,"names":{"shop":{"account":"cdef","app":"shop"}}}` { |
| 119 | t.Fatalf("the file is %q, %v; want exactly the strict reader's shape", raw, err) |
| 120 | } |
| 121 | |
| 122 | // The same mapping again is a no-op: 200, the file untouched. |
| 123 | if st, out := fx.assign(t, "shop", "cdef/shop"); st != http.StatusOK || out["account"] != "cdef" { |
| 124 | t.Fatalf("again = %d %v", st, out) |
| 125 | } |
| 126 | if _, etag2, _ := fx.rel.Get(context.Background(), seeds.LegacyOwnersKey); etag2 != etag { |
| 127 | t.Fatal("the same mapping again rewrote the file") |
| 128 | } |
| 129 | |
| 130 | // A second name joins the first, and nothing is lost. |
| 131 | if st, out := fx.assign(t, "other", "cdef/other"); st != http.StatusOK { |
| 132 | t.Fatalf("second name = %d %v", st, out) |
| 133 | } |
| 134 | if got, ok := readOwner(t, fx.rel, "shop"); !ok || got.App != "shop" { |
| 135 | t.Fatalf("the first name went missing: %+v", got) |
| 136 | } |
| 137 | if got, ok := readOwner(t, fx.rel, "other"); !ok || got != (seeds.LegacyOwner{Account: "cdef", App: "other"}) { |
| 138 | t.Fatalf("second name = %+v %v", got, ok) |
| 139 | } |
| 140 | |
| 141 | // Re-assigning with no import in flight replaces the entry. |
| 142 | if st, out := fx.assign(t, "shop", "efgh/shop"); st != http.StatusOK || out["account"] != "efgh" { |
| 143 | t.Fatalf("re-assign = %d %v", st, out) |
| 144 | } |
| 145 | if got, _ := readOwner(t, fx.rel, "shop"); got.Account != "efgh" { |
| 146 | t.Fatalf("re-assign left %+v", got) |
| 147 | } |
| 148 | |
| 149 | events, err := fx.st.Events(OpsAccountID, "platform", 10) |
| 150 | if err != nil { |
| 151 | t.Fatal(err) |
| 152 | } |
| 153 | var n int |
| 154 | for _, e := range events { |
| 155 | if e.Action == "legacy-route-assign" { |
| 156 | n++ |
| 157 | if e.Address != "paul@keymail.dev" { |
| 158 | t.Errorf("audit row by %q", e.Address) |
| 159 | } |
| 160 | } |
| 161 | } |
| 162 | if n != 3 { // shop, other, shop again; the no-op writes no row |
| 163 | t.Fatalf("legacy-route-assign rows = %d, want 3: %+v", n, events) |
| 164 | } |
| 165 | var saw bool |
| 166 | for _, e := range events { |
| 167 | if e.Action == "legacy-route-assign" && e.Detail == "assigned the legacy routes named shop to efgh/shop" { |
| 168 | saw = true |
| 169 | } |
| 170 | } |
| 171 | if !saw { |
| 172 | t.Fatalf("no audit row names the re-assignment: %+v", events) |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | // The box's swap takes a route filed under a name only for the app of that |
| 177 | // name, so an assignment to an app of another name could never be used: it |
| 178 | // is refused at the door, and nothing is written or audited. |
| 179 | func TestAssignRefusesAnAppOfAnotherName(t *testing.T) { |
| 180 | fx := legacyFixture(t, "code-assign-mismatch") |
| 181 | const want = "the routes are filed under shop, so they can only be assigned to an app named shop. Use --to cdef/shop." |
| 182 | st, out := fx.assign(t, "shop", "cdef/other") |
| 183 | if st != http.StatusBadRequest || str(t, out, "error") != want { |
| 184 | t.Fatalf("assign to another app's name = %d %v, want 400 %q", st, out, want) |
| 185 | } |
| 186 | if _, _, err := fx.rel.Get(context.Background(), seeds.LegacyOwnersKey); err == nil { |
| 187 | t.Fatal("a refused assign wrote the file") |
| 188 | } |
| 189 | events, err := fx.st.Events(OpsAccountID, "platform", 10) |
| 190 | if err != nil { |
| 191 | t.Fatal(err) |
| 192 | } |
| 193 | for _, e := range events { |
| 194 | if e.Action == "legacy-route-assign" { |
| 195 | t.Fatalf("a refused assign was audited: %+v", e) |
| 196 | } |
| 197 | } |
| 198 | // The name and the app agree: taken, in any account. |
| 199 | if st, out := fx.assign(t, "shop", "efgh/shop"); st != http.StatusOK { |
| 200 | t.Fatalf("assign to the app of that name = %d %v", st, out) |
| 201 | } |
| 202 | } |
| 203 | |
| 204 | func TestAssignRefusedWhileAnImportIsInFlight(t *testing.T) { |
| 205 | for _, c := range []struct { |
| 206 | state string |
| 207 | busy bool |
| 208 | }{ |
| 209 | {seeds.StateAwaitingUpload, false}, |
| 210 | {seeds.StateUploaded, false}, |
| 211 | {seeds.StateReady, true}, |
| 212 | {seeds.StateSwapped, true}, |
| 213 | {seeds.StatePlaced, true}, |
| 214 | {seeds.StateCommitted, false}, |
| 215 | {seeds.StateRefused, false}, |
| 216 | {seeds.StateAborted, false}, |
| 217 | {seeds.StateRolledBack, false}, |
| 218 | } { |
| 219 | t.Run(c.state, func(t *testing.T) { |
| 220 | fx := legacyFixture(t, "code-assign-busy-"+c.state) |
| 221 | if st, out := fx.assign(t, "shop", "cdef/shop"); st != http.StatusOK { |
| 222 | t.Fatalf("assign = %d %v", st, out) |
| 223 | } |
| 224 | legacyImportRecord(t, fx.rel, "old.example.com", c.state) |
| 225 | st, out := fx.assign(t, "shop", "efgh/shop") |
| 226 | if !c.busy { |
| 227 | if st != http.StatusOK || out["account"] != "efgh" { |
| 228 | t.Fatalf("re-assign past an import in %s = %d %v", c.state, st, out) |
| 229 | } |
| 230 | if got, ok := readOwner(t, fx.rel, "shop"); !ok || got != (seeds.LegacyOwner{Account: "efgh", App: "shop"}) { |
| 231 | t.Fatalf("re-assign past an import in %s left %+v %v", c.state, got, ok) |
| 232 | } |
| 233 | return |
| 234 | } |
| 235 | if st != http.StatusConflict || str(t, out, "error") != "shop has an import in flight under its current assignment. Commit or abort it first." { |
| 236 | t.Fatalf("re-assign = %d %v", st, out) |
| 237 | } |
| 238 | if got, _ := readOwner(t, fx.rel, "shop"); got.Account != "cdef" { |
| 239 | t.Fatalf("the file changed: %+v", got) |
| 240 | } |
| 241 | // The same mapping is still a no-op while busy. |
| 242 | if st, out := fx.assign(t, "shop", "cdef/shop"); st != http.StatusOK { |
| 243 | t.Fatalf("the same mapping while busy = %d %v", st, out) |
| 244 | } |
| 245 | }) |
| 246 | } |
| 247 | } |
| 248 | |
| 249 | // A record that reads but will not decode could be an import between ready |
| 250 | // and committed: it counts as busy (preflight M7). |
| 251 | func TestAssignCountsAnUndecodableRecordAsBusy(t *testing.T) { |
| 252 | fx := legacyFixture(t, "code-assign-garbled") |
| 253 | if st, out := fx.assign(t, "shop", "cdef/shop"); st != http.StatusOK { |
| 254 | t.Fatalf("assign = %d %v", st, out) |
| 255 | } |
| 256 | if _, err := fx.rel.Put(context.Background(), provision.RecordKey("cdef", "shop", "old.example.com"), []byte("{not json"), ""); err != nil { |
| 257 | t.Fatal(err) |
| 258 | } |
| 259 | if st, out := fx.assign(t, "shop", "efgh/shop"); st != http.StatusConflict || !strings.Contains(str(t, out, "error"), "has an import in flight") { |
| 260 | t.Fatalf("re-assign past an undecodable record = %d %v", st, out) |
| 261 | } |
| 262 | if got, ok := readOwner(t, fx.rel, "shop"); !ok || got != (seeds.LegacyOwner{Account: "cdef", App: "shop"}) { |
| 263 | t.Fatalf("the file changed: %+v %v", got, ok) |
| 264 | } |
| 265 | } |
| 266 | |
| 267 | // A file the strict reader refuses is never read as "no assignment". |
| 268 | func TestAssignRefusesAnUnreadableFile(t *testing.T) { |
| 269 | fx := legacyFixture(t, "code-assign-unreadable") |
| 270 | if _, err := fx.rel.Put(context.Background(), seeds.LegacyOwnersKey, []byte(`{"v":1,"names":{"shop":{"account":"cdef"}}}`), ""); err != nil { |
| 271 | t.Fatal(err) |
| 272 | } |
| 273 | if st, out := fx.assign(t, "shop", "cdef/shop"); st != http.StatusBadGateway || str(t, out, "error") != "could not read the legacy route assignments. Try again." { |
| 274 | t.Fatalf("assign over an unreadable file = %d %v", st, out) |
| 275 | } |
| 276 | raw, _, _ := fx.rel.Get(context.Background(), seeds.LegacyOwnersKey) |
| 277 | if string(raw) != `{"v":1,"names":{"shop":{"account":"cdef"}}}` { |
| 278 | t.Fatalf("the unreadable file was overwritten: %q", raw) |
| 279 | } |
| 280 | } |
| 281 | |
| 282 | func TestLegacyRoutesDoorIsForSystemOperators(t *testing.T) { |
| 283 | fx := legacyFixture(t, "code-assign-op") |
| 284 | if err := fx.st.AddMember(OpsAccountID, "member@keymail.dev", RoleMember, time.Now()); err != nil { |
| 285 | t.Fatal(err) |
| 286 | } |
| 287 | member := cliToken(t, fx.consoleFixture, "member@keymail.dev", "code-assign-member") |
| 288 | _, want := getRaw(t, fx.ts.URL+"/api/cli/system/no-such-door", member) |
| 289 | if st, body := postRaw(t, fx.url, map[string]string{"name": "shop", "to": "cdef/shop"}, member); st != http.StatusNotFound || body != want { |
| 290 | t.Fatalf("a member = %d %q, want the bare 404", st, body) |
| 291 | } |
| 292 | // The owner of the target account is no operator either. |
| 293 | someone := cliToken(t, fx.consoleFixture, "someone@keymail.dev", "code-assign-someone") |
| 294 | if st, body := postRaw(t, fx.url, map[string]string{"name": "shop", "to": "cdef/shop"}, someone); st != http.StatusNotFound || body != want { |
| 295 | t.Fatalf("the target account's owner = %d %q, want the bare 404", st, body) |
| 296 | } |
| 297 | // A service credential, of the operator account or of the target app, |
| 298 | // meets the closed door. |
| 299 | for _, svc := range []string{ |
| 300 | serviceToken(t, fx.st, OpsAccountID, "svc-instance", "", svcrole.Instance), |
| 301 | serviceToken(t, fx.st, legacyOtherID, "svc-admin", "shop", svcrole.Admin), |
| 302 | } { |
| 303 | if st, body := postRaw(t, fx.url, map[string]string{"name": "shop", "to": "cdef/shop"}, svc); st != http.StatusForbidden { |
| 304 | t.Fatalf("a service credential = %d %q, want 403", st, body) |
| 305 | } |
| 306 | } |
| 307 | if _, _, err := fx.rel.Get(context.Background(), seeds.LegacyOwnersKey); err == nil { |
| 308 | t.Fatal("a refused caller wrote the file") |
| 309 | } |
| 310 | } |
| 311 | |
| 312 | func TestAssignRefusesOnAClaimConsole(t *testing.T) { |
| 313 | fx := legacyFixture(t, "code-assign-claim") |
| 314 | fx.srv.cfg.HostClaim = true |
| 315 | st, out := fx.assign(t, "shop", "cdef/shop") |
| 316 | if st != http.StatusConflict || str(t, out, "error") != seedClaimConsoleRefusal().Msg { |
| 317 | t.Fatalf("assign on a claim console = %d %v", st, out) |
| 318 | } |
| 319 | if _, _, err := fx.rel.Get(context.Background(), seeds.LegacyOwnersKey); err == nil { |
| 320 | t.Fatal("a claim console wrote the file") |
| 321 | } |
| 322 | } |
| 323 | |
| 324 | // Concurrent assigns of different names: every one that answered 200 is in |
| 325 | // the file, and every other answered the save refusal (SD-N116). A blind |
| 326 | // write would answer 200 to both and keep one. |
| 327 | func TestConcurrentAssignsLoseNoName(t *testing.T) { |
| 328 | fx := legacyFixture(t, "code-assign-race") |
| 329 | names := []string{"shop", "other", "cart", "blog", "mail", "docs"} |
| 330 | for _, n := range names[2:] { |
| 331 | if _, err := fx.st.CreateAppIn(legacyOtherID, n, "someone@keymail.dev", time.Now()); err != nil { |
| 332 | t.Fatal(err) |
| 333 | } |
| 334 | } |
| 335 | type result struct { |
| 336 | name string |
| 337 | st int |
| 338 | msg string |
| 339 | } |
| 340 | results := make(chan result, len(names)) |
| 341 | errs := make(chan error, len(names)) |
| 342 | start := make(chan struct{}) // every request leaves at once |
| 343 | var wg sync.WaitGroup |
| 344 | for _, n := range names { |
| 345 | wg.Add(1) |
| 346 | go func() { |
| 347 | defer wg.Done() |
| 348 | <-start |
| 349 | // No t.Fatal off the test goroutine: errors are reported below. |
| 350 | st, body, err := postFromGoroutine(fx.url, fmt.Sprintf(`{"name":%q,"to":"cdef/%s"}`, n, n), fx.owner) |
| 351 | if err != nil { |
| 352 | errs <- fmt.Errorf("%s: %w", n, err) |
| 353 | return |
| 354 | } |
| 355 | results <- result{n, st, body} |
| 356 | }() |
| 357 | } |
| 358 | close(start) |
| 359 | wg.Wait() |
| 360 | close(results) |
| 361 | close(errs) |
| 362 | for err := range errs { |
| 363 | t.Error(err) |
| 364 | } |
| 365 | if t.Failed() { |
| 366 | t.FailNow() |
| 367 | } |
| 368 | o, _, err := seeds.ReadLegacyOwners(context.Background(), fx.rel) |
| 369 | if err != nil { |
| 370 | t.Fatal(err) |
| 371 | } |
| 372 | ok := 0 |
| 373 | for r := range results { |
| 374 | switch r.st { |
| 375 | case http.StatusOK: |
| 376 | ok++ |
| 377 | if got, found := o.Lookup(r.name); !found || got != (seeds.LegacyOwner{Account: "cdef", App: r.name}) { |
| 378 | t.Errorf("%s answered 200 but the file has %+v %v", r.name, got, found) |
| 379 | } |
| 380 | case http.StatusBadGateway: |
| 381 | if !strings.Contains(r.msg, "could not save the legacy route assignment. Try again.") { |
| 382 | t.Errorf("%s = %d %s", r.name, r.st, r.msg) |
| 383 | } |
| 384 | if _, found := o.Lookup(r.name); found { |
| 385 | t.Errorf("%s was refused but is in the file", r.name) |
| 386 | } |
| 387 | default: |
| 388 | t.Errorf("%s = %d %s", r.name, r.st, r.msg) |
| 389 | } |
| 390 | } |
| 391 | if ok == 0 || len(o.Names) != ok { |
| 392 | t.Fatalf("%d answered 200, the file holds %d: %+v", ok, len(o.Names), o.Names) |
| 393 | } |
| 394 | } |
| 395 | |
| 396 | // postFromGoroutine is postRaw for a goroutine that is not the test's: it |
| 397 | // returns its error instead of failing the test. |
| 398 | func postFromGoroutine(u, body, bearer string) (int, string, error) { |
| 399 | req, err := http.NewRequest(http.MethodPost, u, strings.NewReader(body)) |
| 400 | if err != nil { |
| 401 | return 0, "", err |
| 402 | } |
| 403 | req.Header.Set("Content-Type", "application/json") |
| 404 | req.Header.Set("Authorization", "Bearer "+bearer) |
| 405 | res, err := http.DefaultClient.Do(req) |
| 406 | if err != nil { |
| 407 | return 0, "", err |
| 408 | } |
| 409 | defer res.Body.Close() |
| 410 | out, err := io.ReadAll(res.Body) |
| 411 | return res.StatusCode, string(out), err |
| 412 | } |
| 413 | |
| 414 | // interleaveStore runs between once, on the first read of the assignment |
| 415 | // file, before the caller's write: the shape of two assigns at once. |
| 416 | type interleaveStore struct { |
| 417 | release.Store |
| 418 | once sync.Once |
| 419 | between func() |
| 420 | } |
| 421 | |
| 422 | func (s *interleaveStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| 423 | b, etag, err := s.Store.Get(ctx, key) |
| 424 | if key == seeds.LegacyOwnersKey { |
| 425 | s.once.Do(s.between) |
| 426 | } |
| 427 | return b, etag, err |
| 428 | } |
| 429 | |
| 430 | func TestAssignLosesARaceByCAS(t *testing.T) { |
| 431 | ctx := context.Background() |
| 432 | for _, existing := range []bool{false, true} { |
| 433 | t.Run(fmt.Sprintf("file present %v", existing), func(t *testing.T) { |
| 434 | fx := legacyFixture(t, fmt.Sprintf("code-assign-cas-%v", existing)) |
| 435 | if existing { |
| 436 | if _, err := fx.st.CreateAppIn(legacyOtherID, "docs", "someone@keymail.dev", time.Now()); err != nil { |
| 437 | t.Fatal(err) |
| 438 | } |
| 439 | if st, out := fx.assign(t, "docs", "cdef/docs"); st != http.StatusOK { |
| 440 | t.Fatalf("seed the file = %d %v", st, out) |
| 441 | } |
| 442 | } |
| 443 | rs := &interleaveStore{Store: fx.rel} |
| 444 | rs.between = func() { |
| 445 | if _, _, err := assignLegacyRoute(ctx, fx.rel, "other", seeds.LegacyOwner{Account: "cdef", App: "other"}); err != nil { |
| 446 | t.Errorf("the racing assign: %v", err) |
| 447 | } |
| 448 | } |
| 449 | changed, _, err := assignLegacyRoute(ctx, rs, "shop", seeds.LegacyOwner{Account: "cdef", App: "shop"}) |
| 450 | if changed || !errors.Is(err, release.ErrCASConflict) { |
| 451 | t.Fatalf("the losing assign = %v, want a CAS conflict", err) |
| 452 | } |
| 453 | if got, ok := readOwner(t, fx.rel, "other"); !ok || got.App != "other" { |
| 454 | t.Fatalf("the winner was lost: %+v %v", got, ok) |
| 455 | } |
| 456 | if _, ok := readOwner(t, fx.rel, "shop"); ok { |
| 457 | t.Fatal("the loser was written") |
| 458 | } |
| 459 | if existing { |
| 460 | if _, ok := readOwner(t, fx.rel, "docs"); !ok { |
| 461 | t.Fatal("an entry from before the race was lost") |
| 462 | } |
| 463 | } |
| 464 | }) |
| 465 | } |
| 466 | } |
| 467 | |
| 468 | // failingRecordStore fails every record read, or lists more record keys than |
| 469 | // the cap, for the in-flight check's strict listing. |
| 470 | type failingRecordStore struct { |
| 471 | release.Store |
| 472 | failGet bool |
| 473 | many int |
| 474 | } |
| 475 | |
| 476 | func (s *failingRecordStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| 477 | if s.failGet && strings.HasPrefix(key, provision.RecordsPrefix("cdef", "shop")) { |
| 478 | return nil, "", errors.New("injected read failure") |
| 479 | } |
| 480 | return s.Store.Get(ctx, key) |
| 481 | } |
| 482 | |
| 483 | func (s *failingRecordStore) List(ctx context.Context, prefix string) ([]string, error) { |
| 484 | keys, err := s.Store.List(ctx, prefix) |
| 485 | if s.many > 0 && prefix == provision.RecordsPrefix("cdef", "shop") { |
| 486 | for i := range s.many { |
| 487 | keys = append(keys, provision.RecordKey("cdef", "shop", fmt.Sprintf("h%d.example.com", i))) |
| 488 | } |
| 489 | } |
| 490 | return keys, err |
| 491 | } |
| 492 | |
| 493 | func TestAssignFailsClosedOnAnIncompleteListing(t *testing.T) { |
| 494 | ctx := context.Background() |
| 495 | for _, c := range []struct { |
| 496 | name string |
| 497 | rs func(release.Store) release.Store |
| 498 | }{ |
| 499 | {"a record that will not read", func(s release.Store) release.Store { return &failingRecordStore{Store: s, failGet: true} }}, |
| 500 | {"more records than the cap", func(s release.Store) release.Store { return &failingRecordStore{Store: s, many: instanceListMax + 1} }}, |
| 501 | } { |
| 502 | t.Run(c.name, func(t *testing.T) { |
| 503 | fx := legacyFixture(t, "code-assign-incomplete-"+strings.ReplaceAll(c.name, " ", "-")) |
| 504 | if st, out := fx.assign(t, "shop", "cdef/shop"); st != http.StatusOK { |
| 505 | t.Fatalf("assign = %d %v", st, out) |
| 506 | } |
| 507 | legacyImportRecord(t, fx.rel, "old.example.com", seeds.StateCommitted) |
| 508 | changed, _, err := assignLegacyRoute(ctx, c.rs(fx.rel), "shop", seeds.LegacyOwner{Account: "cdef", App: "other"}) |
| 509 | if changed || !errors.Is(err, errLegacyRoutesUnread) || errors.Is(err, errLegacyRouteBusy) { |
| 510 | t.Fatalf("re-assign over an incomplete listing = %v, want unread and not busy", err) |
| 511 | } |
| 512 | if got, _ := readOwner(t, fx.rel, "shop"); got.App != "shop" { |
| 513 | t.Fatalf("the file changed: %+v", got) |
| 514 | } |
| 515 | }) |
| 516 | } |
| 517 | } |
| 518 | |
| 519 | // Spec §7: legacy-route-assign is something a member sees. The row lands in |
| 520 | // the audit trail the app's activity tab reads (Events(account, app), as |
| 521 | // handleAppActivity calls it) for the app the name now belongs to and, on a |
| 522 | // re-assignment, for the app it was taken from. The system row stays. |
| 523 | func TestAssignAuditsTheAppsItMovesBetween(t *testing.T) { |
| 524 | fx := legacyFixture(t, "code-assign-audit") |
| 525 | const thirdID = legacyThirdID |
| 526 | trail := func(acctID int64, app string) []string { |
| 527 | t.Helper() |
| 528 | events, err := fx.st.Events(acctID, app, activityCap) |
| 529 | if err != nil { |
| 530 | t.Fatal(err) |
| 531 | } |
| 532 | var out []string |
| 533 | for _, e := range events { |
| 534 | if e.Action == "legacy-route-assign" { |
| 535 | if e.Address != "paul@keymail.dev" { |
| 536 | t.Errorf("%d/%s: a row by %q", acctID, app, e.Address) |
| 537 | } |
| 538 | out = append(out, e.Detail) |
| 539 | } |
| 540 | } |
| 541 | return out |
| 542 | } |
| 543 | first := "assigned the legacy routes named shop to cdef/shop" |
| 544 | moved := "assigned the legacy routes named shop to efgh/shop" |
| 545 | if st, out := fx.assign(t, "shop", "cdef/shop"); st != http.StatusOK { |
| 546 | t.Fatalf("assign = %d %v", st, out) |
| 547 | } |
| 548 | if got := trail(legacyOtherID, "shop"); len(got) != 1 || got[0] != first { |
| 549 | t.Fatalf("cdef/shop's trail after the first assign = %q", got) |
| 550 | } |
| 551 | if got := trail(thirdID, "shop"); len(got) != 0 { |
| 552 | t.Fatalf("efgh/shop's trail before it is named = %q", got) |
| 553 | } |
| 554 | |
| 555 | // The same mapping again writes no row anywhere. |
| 556 | if st, out := fx.assign(t, "shop", "cdef/shop"); st != http.StatusOK { |
| 557 | t.Fatalf("again = %d %v", st, out) |
| 558 | } |
| 559 | if got := trail(legacyOtherID, "shop"); len(got) != 1 { |
| 560 | t.Fatalf("the no-op wrote a row: %q", got) |
| 561 | } |
| 562 | |
| 563 | // The move: the new owner and the previous owner both see it. |
| 564 | if st, out := fx.assign(t, "shop", "efgh/shop"); st != http.StatusOK { |
| 565 | t.Fatalf("move = %d %v", st, out) |
| 566 | } |
| 567 | if got := trail(thirdID, "shop"); len(got) != 1 || got[0] != moved { |
| 568 | t.Fatalf("efgh/shop's trail = %q, want the move", got) |
| 569 | } |
| 570 | if got := trail(legacyOtherID, "shop"); len(got) != 2 || got[0] != moved || got[1] != first { |
| 571 | t.Fatalf("cdef/shop's trail = %q, want the move above the first assign", got) |
| 572 | } |
| 573 | // No other app's trail, and no account-wide row, picks it up. |
| 574 | if got := trail(legacyOtherID, "other"); len(got) != 0 { |
| 575 | t.Fatalf("an unrelated app's trail = %q", got) |
| 576 | } |
| 577 | if got := trail(legacyOtherID, ""); len(got) != 0 { |
| 578 | t.Fatalf("the account-wide trail = %q", got) |
| 579 | } |
| 580 | // The system row stays, once per change. |
| 581 | if got := trail(OpsAccountID, "platform"); len(got) != 2 || got[0] != moved || got[1] != first { |
| 582 | t.Fatalf("the system trail = %q", got) |
| 583 | } |
| 584 | |
| 585 | // A refused re-assignment writes no row. |
| 586 | legacyImportRecordIn(t, fx.rel, "efgh", "old.example.com", seeds.StateSwapped) |
| 587 | if st, out := fx.assign(t, "shop", "cdef/shop"); st != http.StatusConflict { |
| 588 | t.Fatalf("busy re-assign = %d %v", st, out) |
| 589 | } |
| 590 | if got := trail(thirdID, "shop"); len(got) != 1 { |
| 591 | t.Fatalf("a refusal wrote a row: %q", got) |
| 592 | } |
| 593 | } |
| 594 | |