package release import ( "context" "encoding/hex" "encoding/json" "errors" "os" "testing" "time" ) // restartEtag reads the app's restart-generation etag, "" for absent — the // exact observation a box's sweep makes, so a test asserting on it asserts on // the signal itself rather than on this file's internals. func restartEtag(t *testing.T, s Store, acct, app string) string { t.Helper() _, etag, err := s.Get(context.Background(), RestartGenKey(acct, app)) if err != nil { if errors.Is(err, os.ErrNotExist) { return "" } t.Fatalf("read restart generation: %v", err) } return etag } func TestTouchRestartGenCreatesAndMoves(t *testing.T) { s := NewDirStore(t.TempDir()) ctx := context.Background() if err := TouchRestartGen(ctx, s, "bab", "keymail"); err != nil { t.Fatalf("first TouchRestartGen: %v", err) } raw, first, err := s.Get(ctx, RestartGenKey("bab", "keymail")) if err != nil { t.Fatalf("Get after first touch: %v", err) } var g RestartGeneration if err := json.Unmarshal(raw, &g); err != nil { t.Fatalf("decode restart generation: %v", err) } if g.Schema != 1 { t.Fatalf("Schema = %d, want 1", g.Schema) } if _, err := time.Parse(time.RFC3339, g.TouchedAt); err != nil { t.Fatalf("TouchedAt %q is not RFC3339: %v", g.TouchedAt, err) } if nonce, err := hex.DecodeString(g.Nonce); err != nil || len(nonce) == 0 { t.Fatalf("Nonce = %q, want nonempty hex (decode err %v)", g.Nonce, err) } // The etag is the whole point: a second request must be a DIFFERENT // object, or the sweep watching it sees nothing and nothing restarts. if err := TouchRestartGen(ctx, s, "bab", "keymail"); err != nil { t.Fatalf("second TouchRestartGen: %v", err) } if second := restartEtag(t, s, "bab", "keymail"); second == first { t.Fatalf("etag %q unchanged after a second request — the restart signal never fires", second) } } // racingRestartPutStore lands a different writer's value at key just before // forwarding the caller's Put, which then fails CAS against the etag the // caller read — two people (or a person and a retrying CI job) asking for a // restart at the same moment. type racingRestartPutStore struct { Store key string done bool } func (r *racingRestartPutStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { if key == r.key && !r.done { r.done = true other := []byte(`{"schema":1,"touched_at":"2026-01-01T00:00:00Z","nonce":"00112233445566778899aabbccddeeff"}`) if _, err := r.Store.Put(ctx, key, other, ifMatch); err != nil { return "", err } } return r.Store.Put(ctx, key, body, ifMatch) } func TestTouchRestartGenCASConflictIsSuccess(t *testing.T) { inner := NewDirStore(t.TempDir()) ctx := context.Background() if err := TouchRestartGen(ctx, inner, "bab", "keymail"); err != nil { t.Fatalf("seed TouchRestartGen: %v", err) } racing := &racingRestartPutStore{Store: inner, key: RestartGenKey("bab", "keymail")} if err := TouchRestartGen(ctx, racing, "bab", "keymail"); err != nil { t.Fatalf("TouchRestartGen on a lost CAS race: %v, want nil (the request already fired)", err) } if !racing.done { t.Fatal("the racing writer never ran — this test proved nothing") } } // failingRestartPutStore fails every Put with a fixed non-CAS error. type failingRestartPutStore struct { Store err error } func (f *failingRestartPutStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { return "", f.err } // TestTouchRestartGenSurfacesRealPutErrors is the other half of the CAS // carve-out: ONLY ErrCASConflict is success. Any other store failure means // nobody asked for anything, and a caller told "requested" would stand there // waiting for a restart that is never coming. func TestTouchRestartGenSurfacesRealPutErrors(t *testing.T) { s := &failingRestartPutStore{Store: NewDirStore(t.TempDir()), err: errors.New("bucket is on fire")} if err := TouchRestartGen(context.Background(), s, "bab", "keymail"); err == nil { t.Fatal("TouchRestartGen swallowed a non-CAS Put error, want it surfaced") } } func TestTouchRestartGenRefusesInvalidNames(t *testing.T) { s := NewDirStore(t.TempDir()) ctx := context.Background() if err := TouchRestartGen(ctx, s, "../etc", "keymail"); err == nil { t.Fatal("TouchRestartGen accepted an invalid account") } if err := TouchRestartGen(ctx, s, "bab", "../etc"); err == nil { t.Fatal("TouchRestartGen accepted an invalid app") } } // TestRestartGenKeyIsAnAppSibling pins the key's shape: under the app's own // account-scoped prefix, beside config-gen.json rather than inside anything. func TestRestartGenKeyIsAnAppSibling(t *testing.T) { if got, want := RestartGenKey("bab", "keymail"), "apps/bab/keymail/restart-gen.json"; got != want { t.Fatalf("RestartGenKey = %q, want %q", got, want) } }