package console import ( "context" "errors" "fmt" "io" "net/http" "strings" "sync" "testing" "time" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/seeds" "github.com/carlosframework/platform/internal/svcrole" ) // legacyFx is a console whose ops account owner is paul@keymail.dev, with a // second account (sqid "cdef", not the operator account) holding the apps // shop and other, and a third (sqid "efgh") holding an app shop of its own. type legacyFx struct { *consoleFixture owner string url string } const ( legacyOtherID = 4242 legacyThirdID = 4343 ) func legacyFixture(t *testing.T, code string) *legacyFx { t.Helper() fx := newConsoleFixture(t) now := time.Now() if err := fx.st.CreateAccount(legacyOtherID, "cdef", "Other", "someone@keymail.dev", now); err != nil { t.Fatal(err) } for _, app := range []string{"shop", "other"} { if _, err := fx.st.CreateAppIn(legacyOtherID, app, "someone@keymail.dev", now); err != nil { t.Fatal(err) } } if err := fx.st.CreateAccount(legacyThirdID, "efgh", "Third", "third@keymail.dev", now); err != nil { t.Fatal(err) } if _, err := fx.st.CreateAppIn(legacyThirdID, "shop", "third@keymail.dev", now); err != nil { t.Fatal(err) } return &legacyFx{consoleFixture: fx, owner: cliToken(t, fx, "paul@keymail.dev", code), url: fx.ts.URL + "/api/cli/system/legacy-routes"} } func (fx *legacyFx) assign(t *testing.T, name, to string) (int, map[string]any) { t.Helper() return postJSON(t, fx.url, map[string]any{"name": name, "to": to}, fx.owner) } // legacyImportRecord files an import record of cdef/shop in state. func legacyImportRecord(t *testing.T, rs release.Store, host, state string) { t.Helper() legacyImportRecordIn(t, rs, "cdef", host, state) } // legacyImportRecordIn files an import record of /shop in state. func legacyImportRecordIn(t *testing.T, rs release.Store, acct, host, state string) { t.Helper() id, err := seeds.NewID() if err != nil { t.Fatal(err) } now := time.Now().UTC() rec := provision.Record{Schema: 1, Host: host, Slug: strings.Split(host, ".")[0], App: "shop", Account: acct, Channel: "stable", CreatedAt: now, Seed: &provision.SeedSource{ID: id, Kind: seeds.KindImport, State: state, By: "t", At: now}} if err := provision.CreateRecord(context.Background(), rs, rec); err != nil { t.Fatal(err) } } func readOwner(t *testing.T, rs release.Store, name string) (seeds.LegacyOwner, bool) { t.Helper() o, _, err := seeds.ReadLegacyOwners(context.Background(), rs) if err != nil { t.Fatalf("the strict reader refused the file: %v", err) } return o.Lookup(name) } func TestAssignLegacyRoutes(t *testing.T) { fx := legacyFixture(t, "code-assign-owner") for _, bad := range []string{"Not An App!", "", "a/b"} { if st, out := fx.assign(t, bad, "cdef/shop"); st != http.StatusBadRequest || str(t, out, "error") != "name must be an app name" { t.Fatalf("bad name %q = %d %v", bad, st, out) } } for _, bad := range []string{"cdef", "cdef/", "/shop", "cdef/Not An App", "zzzz/shop", "cdef/shop/x"} { if st, out := fx.assign(t, "shop", bad); st != http.StatusBadRequest || str(t, out, "error") != "to must be /" { t.Fatalf("bad to %q = %d %v", bad, st, out) } } // An app that does not exist, under its own name. if st, out := fx.assign(t, "missing", "cdef/missing"); st != http.StatusBadRequest || str(t, out, "error") != "to must be /" { t.Fatalf("a missing app = %d %v", st, out) } if _, _, err := fx.rel.Get(context.Background(), seeds.LegacyOwnersKey); err == nil { t.Fatal("a refused assign wrote the file") } st, out := fx.assign(t, "shop", "cdef/shop") if st != http.StatusOK || out["name"] != "shop" || out["account"] != "cdef" || out["app"] != "shop" { t.Fatalf("assign = %d %v", st, out) } if got, ok := readOwner(t, fx.rel, "shop"); !ok || got != (seeds.LegacyOwner{Account: "cdef", App: "shop"}) { t.Fatalf("file = %+v %v", got, ok) } raw, etag, err := fx.rel.Get(context.Background(), seeds.LegacyOwnersKey) if err != nil || string(raw) != `{"v":1,"names":{"shop":{"account":"cdef","app":"shop"}}}` { t.Fatalf("the file is %q, %v; want exactly the strict reader's shape", raw, err) } // The same mapping again is a no-op: 200, the file untouched. if st, out := fx.assign(t, "shop", "cdef/shop"); st != http.StatusOK || out["account"] != "cdef" { t.Fatalf("again = %d %v", st, out) } if _, etag2, _ := fx.rel.Get(context.Background(), seeds.LegacyOwnersKey); etag2 != etag { t.Fatal("the same mapping again rewrote the file") } // A second name joins the first, and nothing is lost. if st, out := fx.assign(t, "other", "cdef/other"); st != http.StatusOK { t.Fatalf("second name = %d %v", st, out) } if got, ok := readOwner(t, fx.rel, "shop"); !ok || got.App != "shop" { t.Fatalf("the first name went missing: %+v", got) } if got, ok := readOwner(t, fx.rel, "other"); !ok || got != (seeds.LegacyOwner{Account: "cdef", App: "other"}) { t.Fatalf("second name = %+v %v", got, ok) } // Re-assigning with no import in flight replaces the entry. if st, out := fx.assign(t, "shop", "efgh/shop"); st != http.StatusOK || out["account"] != "efgh" { t.Fatalf("re-assign = %d %v", st, out) } if got, _ := readOwner(t, fx.rel, "shop"); got.Account != "efgh" { t.Fatalf("re-assign left %+v", got) } events, err := fx.st.Events(OpsAccountID, "platform", 10) if err != nil { t.Fatal(err) } var n int for _, e := range events { if e.Action == "legacy-route-assign" { n++ if e.Address != "paul@keymail.dev" { t.Errorf("audit row by %q", e.Address) } } } if n != 3 { // shop, other, shop again; the no-op writes no row t.Fatalf("legacy-route-assign rows = %d, want 3: %+v", n, events) } var saw bool for _, e := range events { if e.Action == "legacy-route-assign" && e.Detail == "assigned the legacy routes named shop to efgh/shop" { saw = true } } if !saw { t.Fatalf("no audit row names the re-assignment: %+v", events) } } // The box's swap takes a route filed under a name only for the app of that // name, so an assignment to an app of another name could never be used: it // is refused at the door, and nothing is written or audited. func TestAssignRefusesAnAppOfAnotherName(t *testing.T) { fx := legacyFixture(t, "code-assign-mismatch") const want = "the routes are filed under shop, so they can only be assigned to an app named shop. Use --to cdef/shop." st, out := fx.assign(t, "shop", "cdef/other") if st != http.StatusBadRequest || str(t, out, "error") != want { t.Fatalf("assign to another app's name = %d %v, want 400 %q", st, out, want) } if _, _, err := fx.rel.Get(context.Background(), seeds.LegacyOwnersKey); err == nil { t.Fatal("a refused assign wrote the file") } events, err := fx.st.Events(OpsAccountID, "platform", 10) if err != nil { t.Fatal(err) } for _, e := range events { if e.Action == "legacy-route-assign" { t.Fatalf("a refused assign was audited: %+v", e) } } // The name and the app agree: taken, in any account. if st, out := fx.assign(t, "shop", "efgh/shop"); st != http.StatusOK { t.Fatalf("assign to the app of that name = %d %v", st, out) } } func TestAssignRefusedWhileAnImportIsInFlight(t *testing.T) { for _, c := range []struct { state string busy bool }{ {seeds.StateAwaitingUpload, false}, {seeds.StateUploaded, false}, {seeds.StateReady, true}, {seeds.StateSwapped, true}, {seeds.StatePlaced, true}, {seeds.StateCommitted, false}, {seeds.StateRefused, false}, {seeds.StateAborted, false}, {seeds.StateRolledBack, false}, } { t.Run(c.state, func(t *testing.T) { fx := legacyFixture(t, "code-assign-busy-"+c.state) if st, out := fx.assign(t, "shop", "cdef/shop"); st != http.StatusOK { t.Fatalf("assign = %d %v", st, out) } legacyImportRecord(t, fx.rel, "old.example.com", c.state) st, out := fx.assign(t, "shop", "efgh/shop") if !c.busy { if st != http.StatusOK || out["account"] != "efgh" { t.Fatalf("re-assign past an import in %s = %d %v", c.state, st, out) } if got, ok := readOwner(t, fx.rel, "shop"); !ok || got != (seeds.LegacyOwner{Account: "efgh", App: "shop"}) { t.Fatalf("re-assign past an import in %s left %+v %v", c.state, got, ok) } return } if st != http.StatusConflict || str(t, out, "error") != "shop has an import in flight under its current assignment. Commit or abort it first." { t.Fatalf("re-assign = %d %v", st, out) } if got, _ := readOwner(t, fx.rel, "shop"); got.Account != "cdef" { t.Fatalf("the file changed: %+v", got) } // The same mapping is still a no-op while busy. if st, out := fx.assign(t, "shop", "cdef/shop"); st != http.StatusOK { t.Fatalf("the same mapping while busy = %d %v", st, out) } }) } } // A record that reads but will not decode could be an import between ready // and committed: it counts as busy (preflight M7). func TestAssignCountsAnUndecodableRecordAsBusy(t *testing.T) { fx := legacyFixture(t, "code-assign-garbled") if st, out := fx.assign(t, "shop", "cdef/shop"); st != http.StatusOK { t.Fatalf("assign = %d %v", st, out) } if _, err := fx.rel.Put(context.Background(), provision.RecordKey("cdef", "shop", "old.example.com"), []byte("{not json"), ""); err != nil { t.Fatal(err) } if st, out := fx.assign(t, "shop", "efgh/shop"); st != http.StatusConflict || !strings.Contains(str(t, out, "error"), "has an import in flight") { t.Fatalf("re-assign past an undecodable record = %d %v", st, out) } if got, ok := readOwner(t, fx.rel, "shop"); !ok || got != (seeds.LegacyOwner{Account: "cdef", App: "shop"}) { t.Fatalf("the file changed: %+v %v", got, ok) } } // A file the strict reader refuses is never read as "no assignment". func TestAssignRefusesAnUnreadableFile(t *testing.T) { fx := legacyFixture(t, "code-assign-unreadable") if _, err := fx.rel.Put(context.Background(), seeds.LegacyOwnersKey, []byte(`{"v":1,"names":{"shop":{"account":"cdef"}}}`), ""); err != nil { t.Fatal(err) } 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." { t.Fatalf("assign over an unreadable file = %d %v", st, out) } raw, _, _ := fx.rel.Get(context.Background(), seeds.LegacyOwnersKey) if string(raw) != `{"v":1,"names":{"shop":{"account":"cdef"}}}` { t.Fatalf("the unreadable file was overwritten: %q", raw) } } func TestLegacyRoutesDoorIsForSystemOperators(t *testing.T) { fx := legacyFixture(t, "code-assign-op") if err := fx.st.AddMember(OpsAccountID, "member@keymail.dev", RoleMember, time.Now()); err != nil { t.Fatal(err) } member := cliToken(t, fx.consoleFixture, "member@keymail.dev", "code-assign-member") _, want := getRaw(t, fx.ts.URL+"/api/cli/system/no-such-door", member) if st, body := postRaw(t, fx.url, map[string]string{"name": "shop", "to": "cdef/shop"}, member); st != http.StatusNotFound || body != want { t.Fatalf("a member = %d %q, want the bare 404", st, body) } // The owner of the target account is no operator either. someone := cliToken(t, fx.consoleFixture, "someone@keymail.dev", "code-assign-someone") if st, body := postRaw(t, fx.url, map[string]string{"name": "shop", "to": "cdef/shop"}, someone); st != http.StatusNotFound || body != want { t.Fatalf("the target account's owner = %d %q, want the bare 404", st, body) } // A service credential, of the operator account or of the target app, // meets the closed door. for _, svc := range []string{ serviceToken(t, fx.st, OpsAccountID, "svc-instance", "", svcrole.Instance), serviceToken(t, fx.st, legacyOtherID, "svc-admin", "shop", svcrole.Admin), } { if st, body := postRaw(t, fx.url, map[string]string{"name": "shop", "to": "cdef/shop"}, svc); st != http.StatusForbidden { t.Fatalf("a service credential = %d %q, want 403", st, body) } } if _, _, err := fx.rel.Get(context.Background(), seeds.LegacyOwnersKey); err == nil { t.Fatal("a refused caller wrote the file") } } func TestAssignRefusesOnAClaimConsole(t *testing.T) { fx := legacyFixture(t, "code-assign-claim") fx.srv.cfg.HostClaim = true st, out := fx.assign(t, "shop", "cdef/shop") if st != http.StatusConflict || str(t, out, "error") != seedClaimConsoleRefusal().Msg { t.Fatalf("assign on a claim console = %d %v", st, out) } if _, _, err := fx.rel.Get(context.Background(), seeds.LegacyOwnersKey); err == nil { t.Fatal("a claim console wrote the file") } } // Concurrent assigns of different names: every one that answered 200 is in // the file, and every other answered the save refusal (SD-N116). A blind // write would answer 200 to both and keep one. func TestConcurrentAssignsLoseNoName(t *testing.T) { fx := legacyFixture(t, "code-assign-race") names := []string{"shop", "other", "cart", "blog", "mail", "docs"} for _, n := range names[2:] { if _, err := fx.st.CreateAppIn(legacyOtherID, n, "someone@keymail.dev", time.Now()); err != nil { t.Fatal(err) } } type result struct { name string st int msg string } results := make(chan result, len(names)) errs := make(chan error, len(names)) start := make(chan struct{}) // every request leaves at once var wg sync.WaitGroup for _, n := range names { wg.Add(1) go func() { defer wg.Done() <-start // No t.Fatal off the test goroutine: errors are reported below. st, body, err := postFromGoroutine(fx.url, fmt.Sprintf(`{"name":%q,"to":"cdef/%s"}`, n, n), fx.owner) if err != nil { errs <- fmt.Errorf("%s: %w", n, err) return } results <- result{n, st, body} }() } close(start) wg.Wait() close(results) close(errs) for err := range errs { t.Error(err) } if t.Failed() { t.FailNow() } o, _, err := seeds.ReadLegacyOwners(context.Background(), fx.rel) if err != nil { t.Fatal(err) } ok := 0 for r := range results { switch r.st { case http.StatusOK: ok++ if got, found := o.Lookup(r.name); !found || got != (seeds.LegacyOwner{Account: "cdef", App: r.name}) { t.Errorf("%s answered 200 but the file has %+v %v", r.name, got, found) } case http.StatusBadGateway: if !strings.Contains(r.msg, "could not save the legacy route assignment. Try again.") { t.Errorf("%s = %d %s", r.name, r.st, r.msg) } if _, found := o.Lookup(r.name); found { t.Errorf("%s was refused but is in the file", r.name) } default: t.Errorf("%s = %d %s", r.name, r.st, r.msg) } } if ok == 0 || len(o.Names) != ok { t.Fatalf("%d answered 200, the file holds %d: %+v", ok, len(o.Names), o.Names) } } // postFromGoroutine is postRaw for a goroutine that is not the test's: it // returns its error instead of failing the test. func postFromGoroutine(u, body, bearer string) (int, string, error) { req, err := http.NewRequest(http.MethodPost, u, strings.NewReader(body)) if err != nil { return 0, "", err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+bearer) res, err := http.DefaultClient.Do(req) if err != nil { return 0, "", err } defer res.Body.Close() out, err := io.ReadAll(res.Body) return res.StatusCode, string(out), err } // interleaveStore runs between once, on the first read of the assignment // file, before the caller's write: the shape of two assigns at once. type interleaveStore struct { release.Store once sync.Once between func() } func (s *interleaveStore) Get(ctx context.Context, key string) ([]byte, string, error) { b, etag, err := s.Store.Get(ctx, key) if key == seeds.LegacyOwnersKey { s.once.Do(s.between) } return b, etag, err } func TestAssignLosesARaceByCAS(t *testing.T) { ctx := context.Background() for _, existing := range []bool{false, true} { t.Run(fmt.Sprintf("file present %v", existing), func(t *testing.T) { fx := legacyFixture(t, fmt.Sprintf("code-assign-cas-%v", existing)) if existing { if _, err := fx.st.CreateAppIn(legacyOtherID, "docs", "someone@keymail.dev", time.Now()); err != nil { t.Fatal(err) } if st, out := fx.assign(t, "docs", "cdef/docs"); st != http.StatusOK { t.Fatalf("seed the file = %d %v", st, out) } } rs := &interleaveStore{Store: fx.rel} rs.between = func() { if _, _, err := assignLegacyRoute(ctx, fx.rel, "other", seeds.LegacyOwner{Account: "cdef", App: "other"}); err != nil { t.Errorf("the racing assign: %v", err) } } changed, _, err := assignLegacyRoute(ctx, rs, "shop", seeds.LegacyOwner{Account: "cdef", App: "shop"}) if changed || !errors.Is(err, release.ErrCASConflict) { t.Fatalf("the losing assign = %v, want a CAS conflict", err) } if got, ok := readOwner(t, fx.rel, "other"); !ok || got.App != "other" { t.Fatalf("the winner was lost: %+v %v", got, ok) } if _, ok := readOwner(t, fx.rel, "shop"); ok { t.Fatal("the loser was written") } if existing { if _, ok := readOwner(t, fx.rel, "docs"); !ok { t.Fatal("an entry from before the race was lost") } } }) } } // failingRecordStore fails every record read, or lists more record keys than // the cap, for the in-flight check's strict listing. type failingRecordStore struct { release.Store failGet bool many int } func (s *failingRecordStore) Get(ctx context.Context, key string) ([]byte, string, error) { if s.failGet && strings.HasPrefix(key, provision.RecordsPrefix("cdef", "shop")) { return nil, "", errors.New("injected read failure") } return s.Store.Get(ctx, key) } func (s *failingRecordStore) List(ctx context.Context, prefix string) ([]string, error) { keys, err := s.Store.List(ctx, prefix) if s.many > 0 && prefix == provision.RecordsPrefix("cdef", "shop") { for i := range s.many { keys = append(keys, provision.RecordKey("cdef", "shop", fmt.Sprintf("h%d.example.com", i))) } } return keys, err } func TestAssignFailsClosedOnAnIncompleteListing(t *testing.T) { ctx := context.Background() for _, c := range []struct { name string rs func(release.Store) release.Store }{ {"a record that will not read", func(s release.Store) release.Store { return &failingRecordStore{Store: s, failGet: true} }}, {"more records than the cap", func(s release.Store) release.Store { return &failingRecordStore{Store: s, many: instanceListMax + 1} }}, } { t.Run(c.name, func(t *testing.T) { fx := legacyFixture(t, "code-assign-incomplete-"+strings.ReplaceAll(c.name, " ", "-")) if st, out := fx.assign(t, "shop", "cdef/shop"); st != http.StatusOK { t.Fatalf("assign = %d %v", st, out) } legacyImportRecord(t, fx.rel, "old.example.com", seeds.StateCommitted) changed, _, err := assignLegacyRoute(ctx, c.rs(fx.rel), "shop", seeds.LegacyOwner{Account: "cdef", App: "other"}) if changed || !errors.Is(err, errLegacyRoutesUnread) || errors.Is(err, errLegacyRouteBusy) { t.Fatalf("re-assign over an incomplete listing = %v, want unread and not busy", err) } if got, _ := readOwner(t, fx.rel, "shop"); got.App != "shop" { t.Fatalf("the file changed: %+v", got) } }) } } // Spec ยง7: legacy-route-assign is something a member sees. The row lands in // the audit trail the app's activity tab reads (Events(account, app), as // handleAppActivity calls it) for the app the name now belongs to and, on a // re-assignment, for the app it was taken from. The system row stays. func TestAssignAuditsTheAppsItMovesBetween(t *testing.T) { fx := legacyFixture(t, "code-assign-audit") const thirdID = legacyThirdID trail := func(acctID int64, app string) []string { t.Helper() events, err := fx.st.Events(acctID, app, activityCap) if err != nil { t.Fatal(err) } var out []string for _, e := range events { if e.Action == "legacy-route-assign" { if e.Address != "paul@keymail.dev" { t.Errorf("%d/%s: a row by %q", acctID, app, e.Address) } out = append(out, e.Detail) } } return out } first := "assigned the legacy routes named shop to cdef/shop" moved := "assigned the legacy routes named shop to efgh/shop" if st, out := fx.assign(t, "shop", "cdef/shop"); st != http.StatusOK { t.Fatalf("assign = %d %v", st, out) } if got := trail(legacyOtherID, "shop"); len(got) != 1 || got[0] != first { t.Fatalf("cdef/shop's trail after the first assign = %q", got) } if got := trail(thirdID, "shop"); len(got) != 0 { t.Fatalf("efgh/shop's trail before it is named = %q", got) } // The same mapping again writes no row anywhere. if st, out := fx.assign(t, "shop", "cdef/shop"); st != http.StatusOK { t.Fatalf("again = %d %v", st, out) } if got := trail(legacyOtherID, "shop"); len(got) != 1 { t.Fatalf("the no-op wrote a row: %q", got) } // The move: the new owner and the previous owner both see it. if st, out := fx.assign(t, "shop", "efgh/shop"); st != http.StatusOK { t.Fatalf("move = %d %v", st, out) } if got := trail(thirdID, "shop"); len(got) != 1 || got[0] != moved { t.Fatalf("efgh/shop's trail = %q, want the move", got) } if got := trail(legacyOtherID, "shop"); len(got) != 2 || got[0] != moved || got[1] != first { t.Fatalf("cdef/shop's trail = %q, want the move above the first assign", got) } // No other app's trail, and no account-wide row, picks it up. if got := trail(legacyOtherID, "other"); len(got) != 0 { t.Fatalf("an unrelated app's trail = %q", got) } if got := trail(legacyOtherID, ""); len(got) != 0 { t.Fatalf("the account-wide trail = %q", got) } // The system row stays, once per change. if got := trail(OpsAccountID, "platform"); len(got) != 2 || got[0] != moved || got[1] != first { t.Fatalf("the system trail = %q", got) } // A refused re-assignment writes no row. legacyImportRecordIn(t, fx.rel, "efgh", "old.example.com", seeds.StateSwapped) if st, out := fx.assign(t, "shop", "cdef/shop"); st != http.StatusConflict { t.Fatalf("busy re-assign = %d %v", st, out) } if got := trail(thirdID, "shop"); len(got) != 1 { t.Fatalf("a refusal wrote a row: %q", got) } }