| index 845d9b3ff..223290ddb 100644 |
| --- a/internal/console/materialize_test.go |
| +++ b/internal/console/materialize_test.go |
| @@ -1,7 +1,11 @@ |
| package console |
| |
| import ( |
| + "bytes" |
| "encoding/json" |
| + "log" |
| + "os" |
| + "strings" |
| "testing" |
| "time" |
| |
| @@ -483,6 +487,15 @@ func TestMaterializeRefusesResurrection(t *testing.T) { |
| // session — writes the manifest back, live, naming themselves. |
| rewriteManifest(t, rel, id, "eve@evil.example") |
| |
| + // The refusal must be EXPLICIT, not an accident of some later check. Delete |
| + // the corruption arm and every assertion below still passes — refusal falls |
| + // through to "known account, stop here" — while the operator loses the only |
| + // signal that somebody rewrote a dead account's manifest. So the log line is |
| + // part of the contract and is pinned here. |
| + var logged bytes.Buffer |
| + log.SetOutput(&logged) |
| + t.Cleanup(func() { log.SetOutput(os.Stderr) }) |
| + |
| // Every console start runs this pass, and so does every `console member`. |
| for i := 0; i < 2; i++ { |
| if n, err := MaterializeAccounts(ctx, rel, st); err != nil || n != 0 { |
| @@ -504,6 +517,11 @@ func TestMaterializeRefusesResurrection(t *testing.T) { |
| if members, _ := st.Members(id); len(members) != 0 { |
| t.Errorf("Members = %+v, want none", members) |
| } |
| + if !strings.Contains(logged.String(), "refusing to resurrect") { |
| + t.Errorf("nothing said a word about the rewritten manifest; log was:\n%s\n"+ |
| + "a manifest that came back to life over a tombstoned account is corruption, "+ |
| + "and an operator has to hear about it", logged.String()) |
| + } |
| } |
| |
| // App-row convergence: the console's rows follow the bucket, because trash and |
| @@ -606,6 +624,71 @@ func TestMaterializeAppsConverges(t *testing.T) { |
| } |
| } |
| |
| +// A MISTYPED BUCKET NAME MUST NOT FREE NAMES. |
| +// |
| +// Case (c) is the only branch that reasons from absence, and an empty listing |
| +// is exactly what CARLOS_DEPLOYMENT_BUCKET pointing one character wrong |
| +// produces: a listing that SUCCEEDS and returns nothing. Purging on that reads |
| +// "everything was purged everywhere" from what is really "I am looking in the |
| +// wrong place" — and it is unrecoverable, because this pass is row-driven: once |
| +// the rows are gone, the markers in the real bucket are invisible to it forever |
| +// and the names are free for anyone to claim. |
| +func TestMaterializeAppsRefusesAnEmptyBucket(t *testing.T) { |
| + st := openTestStore(t) |
| + ctx := t.Context() |
| + now := time.Unix(1700000000, 0).UTC() |
| + |
| + empty := release.NewDirStore(t.TempDir()) |
| + id, sqid, err := domains.MintAccount(ctx, empty, "Testco", "paul@keymail.dev", now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if n, err := MaterializeAccounts(ctx, empty, st); err != nil || n != 1 { |
| + t.Fatalf("materialized %d (%v), want the account", n, err) |
| + } |
| + if _, err := st.CreateAppIn(id, "gamma", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := st.TrashAppRow(id, "gamma", now.Add(-48*time.Hour)); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // The wrong bucket: account manifests happen to be there, nothing under |
| + // apps/ is. (A genuinely wrong bucket has neither; this store is the |
| + // STRONGER test, since it still lists something.) |
| + var logged bytes.Buffer |
| + log.SetOutput(&logged) |
| + t.Cleanup(func() { log.SetOutput(os.Stderr) }) |
| + if err := MaterializeApps(ctx, empty, st); err != nil { |
| + t.Fatal(err) |
| + } |
| + if trashed, _ := st.HasTrashedAppIn(id, "gamma"); !trashed { |
| + t.Error("an empty apps/ listing released a held name — one wrong environment variable " + |
| + "is now enough to free every trashed app name in the deployment") |
| + } |
| + if accts, _ := st.AppAccounts("gamma"); len(accts) != 1 { |
| + t.Errorf("AppAccounts(gamma) = %v, want the reservation still held", accts) |
| + } |
| + if !strings.Contains(logged.String(), "CARLOS_DEPLOYMENT_BUCKET") { |
| + t.Errorf("the refusal was silent; log was:\n%s\nan operator has to be told why "+ |
| + "names are not being released", logged.String()) |
| + } |
| + |
| + // And the tripwire is about EMPTINESS, not about this app: one unrelated |
| + // key under apps/ is enough evidence that the bucket is the right bucket, |
| + // and gamma — no marker, no keys of its own — purges as before. |
| + if _, err := empty.Put(ctx, "apps/"+sqid+"/somethingelse/channels/stable.json", []byte(`{}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := MaterializeApps(ctx, empty, st); err != nil { |
| + t.Fatal(err) |
| + } |
| + if accts, _ := st.AppAccounts("gamma"); len(accts) != 0 { |
| + t.Errorf("AppAccounts(gamma) = %v after a non-empty listing, want the name freed — "+ |
| + "the tripwire must hold back purges only while there is no evidence at all", accts) |
| + } |
| +} |
| + |
| // rewriteManifest overwrites a live account manifest with one naming owner — |
| // what a bucket-writer does, and the thing MintAccount's create-only Put does |
| // NOT prevent anyone else from doing. The etag is read and passed because the |