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 "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.
23type legacyFx struct {
24 *consoleFixture
25 owner string
26 url string
27}
28
29const (
30 legacyOtherID = 4242
31 legacyThirdID = 4343
32)
33
34func 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
55func (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.
61func 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.
67func 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
81func 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
90func 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.
179func 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
204func 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).
251func 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".
268func 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
282func 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
312func 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.
327func 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.
398func 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.
416type interleaveStore struct {
417 release.Store
418 once sync.Once
419 between func()
420}
421
422func (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
430func 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.
470type failingRecordStore struct {
471 release.Store
472 failGet bool
473 many int
474}
475
476func (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
483func (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
493func 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.
523func 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