| 1 | // internal/appconfig/generation_test.go |
| 2 | package appconfig |
| 3 | |
| 4 | import ( |
| 5 | "context" |
| 6 | "encoding/hex" |
| 7 | "encoding/json" |
| 8 | "errors" |
| 9 | "os" |
| 10 | "testing" |
| 11 | "time" |
| 12 | |
| 13 | "github.com/carlosframework/platform/internal/release" |
| 14 | ) |
| 15 | |
| 16 | // genEtag reads the app's generation etag, "" for absent — the exact |
| 17 | // observation the agent's fast loop makes, so a test that asserts on it is |
| 18 | // asserting on the signal itself rather than on this package's internals. |
| 19 | func genEtag(t *testing.T, s release.Store, acct, app string) string { |
| 20 | t.Helper() |
| 21 | _, etag, err := s.Get(context.Background(), GenKey(acct, app)) |
| 22 | if err != nil { |
| 23 | if errors.Is(err, os.ErrNotExist) { |
| 24 | return "" |
| 25 | } |
| 26 | t.Fatalf("read generation: %v", err) |
| 27 | } |
| 28 | return etag |
| 29 | } |
| 30 | |
| 31 | func TestTouchGenerationCreatesAndMoves(t *testing.T) { |
| 32 | s := release.NewDirStore(t.TempDir()) |
| 33 | ctx := context.Background() |
| 34 | if err := TouchGeneration(ctx, s, "bab", "keymail"); err != nil { |
| 35 | t.Fatalf("first TouchGeneration: %v", err) |
| 36 | } |
| 37 | raw, first, err := s.Get(ctx, GenKey("bab", "keymail")) |
| 38 | if err != nil { |
| 39 | t.Fatalf("Get after first touch: %v", err) |
| 40 | } |
| 41 | var g Generation |
| 42 | if err := json.Unmarshal(raw, &g); err != nil { |
| 43 | t.Fatalf("decode generation: %v", err) |
| 44 | } |
| 45 | if g.Schema != 1 { |
| 46 | t.Fatalf("Schema = %d, want 1", g.Schema) |
| 47 | } |
| 48 | if _, err := time.Parse(time.RFC3339, g.TouchedAt); err != nil { |
| 49 | t.Fatalf("TouchedAt %q is not RFC3339: %v", g.TouchedAt, err) |
| 50 | } |
| 51 | if nonce, err := hex.DecodeString(g.Nonce); err != nil || len(nonce) == 0 { |
| 52 | t.Fatalf("Nonce = %q, want nonempty hex (decode err %v)", g.Nonce, err) |
| 53 | } |
| 54 | // The etag is the whole point: a second touch must be a DIFFERENT |
| 55 | // object, or the loop watching it sees nothing. DirStore's etag is |
| 56 | // content-derived, so this also pins that each touch's bytes differ. |
| 57 | if err := TouchGeneration(ctx, s, "bab", "keymail"); err != nil { |
| 58 | t.Fatalf("second TouchGeneration: %v", err) |
| 59 | } |
| 60 | if second := genEtag(t, s, "bab", "keymail"); second == first { |
| 61 | t.Fatalf("etag %q unchanged after a second touch — the change signal never fires", second) |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | // racingPutStore lands a different writer's value at key just before |
| 66 | // forwarding the caller's Put, which then fails CAS against the etag the |
| 67 | // caller read — the exact race two operators (or an operator and the |
| 68 | // console door) running config writes at the same moment produce. |
| 69 | type racingPutStore struct { |
| 70 | release.Store |
| 71 | key string |
| 72 | done bool |
| 73 | } |
| 74 | |
| 75 | func (r *racingPutStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| 76 | if key == r.key && !r.done { |
| 77 | r.done = true |
| 78 | other := []byte(`{"schema":1,"touched_at":"2026-01-01T00:00:00Z","nonce":"00112233445566778899aabbccddeeff"}`) |
| 79 | if _, err := r.Store.Put(ctx, key, other, ifMatch); err != nil { |
| 80 | return "", err |
| 81 | } |
| 82 | } |
| 83 | return r.Store.Put(ctx, key, body, ifMatch) |
| 84 | } |
| 85 | |
| 86 | func TestTouchGenerationCASConflictIsSuccess(t *testing.T) { |
| 87 | inner := release.NewDirStore(t.TempDir()) |
| 88 | ctx := context.Background() |
| 89 | if err := TouchGeneration(ctx, inner, "bab", "keymail"); err != nil { |
| 90 | t.Fatalf("seed TouchGeneration: %v", err) |
| 91 | } |
| 92 | racing := &racingPutStore{Store: inner, key: GenKey("bab", "keymail")} |
| 93 | if err := TouchGeneration(ctx, racing, "bab", "keymail"); err != nil { |
| 94 | t.Fatalf("TouchGeneration on a lost CAS race: %v, want nil (the signal already fired)", err) |
| 95 | } |
| 96 | if !racing.done { |
| 97 | t.Fatal("the racing writer never ran — this test proved nothing") |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | // TestTouchGenerationSurfacesRealPutErrors is the other half of the CAS |
| 102 | // carve-out: only ErrCASConflict is success. Any other store failure means |
| 103 | // nobody touched the generation, so the caller must hear about it. |
| 104 | func TestTouchGenerationSurfacesRealPutErrors(t *testing.T) { |
| 105 | s := &failingPutStore{Store: release.NewDirStore(t.TempDir()), err: errors.New("bucket is on fire")} |
| 106 | if err := TouchGeneration(context.Background(), s, "bab", "keymail"); err == nil { |
| 107 | t.Fatal("TouchGeneration swallowed a non-CAS Put error, want it surfaced") |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | // failingPutStore fails every Put with a fixed non-CAS error. |
| 112 | type failingPutStore struct { |
| 113 | release.Store |
| 114 | err error |
| 115 | } |
| 116 | |
| 117 | func (f *failingPutStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| 118 | return "", f.err |
| 119 | } |
| 120 | |
| 121 | func TestTouchGenerationRefusesInvalidNames(t *testing.T) { |
| 122 | s := release.NewDirStore(t.TempDir()) |
| 123 | ctx := context.Background() |
| 124 | if err := TouchGeneration(ctx, s, "../etc", "keymail"); err == nil { |
| 125 | t.Fatal("TouchGeneration accepted an invalid account") |
| 126 | } |
| 127 | if err := TouchGeneration(ctx, s, "bab", "../etc"); err == nil { |
| 128 | t.Fatal("TouchGeneration accepted an invalid app") |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | // TestWritesBumpGeneration is the contract the console door and the CLI both |
| 133 | // depend on: EVERY successful config write leaves a moved etag behind, and a |
| 134 | // REFUSED write leaves none — a failed write has nothing to converge to, and |
| 135 | // a spurious touch would cost the fleet a SyncExec pass for nothing. |
| 136 | func TestWritesBumpGeneration(t *testing.T) { |
| 137 | s := release.NewDirStore(t.TempDir()) |
| 138 | ctx := context.Background() |
| 139 | envelope := testEnvelope(t) |
| 140 | |
| 141 | writes := []struct { |
| 142 | name string |
| 143 | run func() error |
| 144 | }{ |
| 145 | {"SetEnv", func() error { |
| 146 | return SetEnv(ctx, s, "bab", "keymail", map[string]string{"LOG_LEVEL": "debug"}) |
| 147 | }}, |
| 148 | {"SetSecret", func() error { |
| 149 | return SetSecret(ctx, s, "bab", "keymail", map[string]string{"SES_SECRET": envelope}) |
| 150 | }}, |
| 151 | {"Unset", func() error { |
| 152 | _, err := Unset(ctx, s, "bab", "keymail", []string{"LOG_LEVEL"}) |
| 153 | return err |
| 154 | }}, |
| 155 | {"SaveEnvironment", func() error { |
| 156 | return SaveEnvironment(ctx, s, "bab", "keymail", "staging", map[string]Var{"PORT": {Kind: "env", Value: "8080"}}, nil) |
| 157 | }}, |
| 158 | {"UnsetEnvironment", func() error { |
| 159 | _, err := UnsetEnvironment(ctx, s, "bab", "keymail", "staging", []string{"PORT"}) |
| 160 | return err |
| 161 | }}, |
| 162 | {"SetExecDelivery", func() error { |
| 163 | return SetExecDelivery(ctx, s, "bab", "keymail", ExecDeliveryFile) |
| 164 | }}, |
| 165 | {"Apply", func() error { |
| 166 | _, err := Apply(ctx, s, "bab", "keymail", map[string]Var{"A": {Kind: "env", Value: "1"}}, []string{"SES_SECRET"}) |
| 167 | return err |
| 168 | }}, |
| 169 | {"ApplyEnvironment", func() error { |
| 170 | _, err := ApplyEnvironment(ctx, s, "bab", "keymail", "staging", map[string]Var{"B": {Kind: "env", Value: "2"}}, nil) |
| 171 | return err |
| 172 | }}, |
| 173 | } |
| 174 | for _, w := range writes { |
| 175 | before := genEtag(t, s, "bab", "keymail") |
| 176 | if err := w.run(); err != nil { |
| 177 | t.Fatalf("%s: %v", w.name, err) |
| 178 | } |
| 179 | if after := genEtag(t, s, "bab", "keymail"); after == before { |
| 180 | t.Fatalf("%s left the generation etag at %q — no convergence signal", w.name, before) |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | // A REFUSED write: unsetting from an app that has no bundle at all is |
| 185 | // applyAt's create-refusal. Nothing landed, so nothing may be signalled. |
| 186 | before := genEtag(t, s, "bab", "unconfigured") |
| 187 | if _, err := Unset(ctx, s, "bab", "unconfigured", []string{"NOPE"}); err == nil { |
| 188 | t.Fatal("Unset on an app with no bundle succeeded, want a refusal") |
| 189 | } |
| 190 | if after := genEtag(t, s, "bab", "unconfigured"); after != before { |
| 191 | t.Fatalf("a refused write bumped the generation (%q -> %q)", before, after) |
| 192 | } |
| 193 | } |
| 194 | |
| 195 | // testEnvelope seals a throwaway value so secret-shaped writes in these |
| 196 | // tests carry a real envelope rather than a string that only looks like one. |
| 197 | func testEnvelope(t *testing.T) string { |
| 198 | t.Helper() |
| 199 | _, pub, err := GenerateKey() |
| 200 | if err != nil { |
| 201 | t.Fatalf("GenerateKey: %v", err) |
| 202 | } |
| 203 | pubKey, err := ParsePublicKey(pub, "test") |
| 204 | if err != nil { |
| 205 | t.Fatalf("ParsePublicKey: %v", err) |
| 206 | } |
| 207 | envelope, err := Seal(pubKey, Binding{Account: "bab", App: "keymail"}, []byte("shh")) |
| 208 | if err != nil { |
| 209 | t.Fatalf("Seal: %v", err) |
| 210 | } |
| 211 | return envelope |
| 212 | } |
| 213 | |
| 214 | // genFailingStore fails Put only for one app's GENERATION key — the shape |
| 215 | // TestTouchFailureNeverFailsTheWrite needs: the manifest write must succeed |
| 216 | // while the follow-up touch cannot. |
| 217 | type genFailingStore struct { |
| 218 | release.Store |
| 219 | genKey string |
| 220 | err error |
| 221 | } |
| 222 | |
| 223 | func (g *genFailingStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| 224 | if key == g.genKey { |
| 225 | return "", g.err |
| 226 | } |
| 227 | return g.Store.Put(ctx, key, body, ifMatch) |
| 228 | } |
| 229 | |
| 230 | // TestTouchFailureNeverFailsTheWrite pins the spec §1 property touchLogged |
| 231 | // exists for: when the manifest write LANDS and only the generation touch |
| 232 | // fails, the caller sees success — returning an error would send an operator |
| 233 | // re-running a write that is already live, and the real cost of the failed |
| 234 | // touch is only that convergence falls back to the hourly pass. |
| 235 | func TestTouchFailureNeverFailsTheWrite(t *testing.T) { |
| 236 | ctx := context.Background() |
| 237 | s := &genFailingStore{ |
| 238 | Store: release.NewDirStore(t.TempDir()), |
| 239 | genKey: GenKey("bab", "keymail"), |
| 240 | err: errors.New("bucket is on fire"), |
| 241 | } |
| 242 | if err := SetEnv(ctx, s, "bab", "keymail", map[string]string{"PORT": "8080"}); err != nil { |
| 243 | t.Fatalf("SetEnv with a failing generation touch = %v, want nil (the manifest write landed)", err) |
| 244 | } |
| 245 | m, err := Get(ctx, s, "bab", "keymail") |
| 246 | if err != nil || m.Vars["PORT"].Value != "8080" { |
| 247 | t.Fatalf("manifest did not land: %v %v", m, err) |
| 248 | } |
| 249 | if _, _, err := s.Get(ctx, GenKey("bab", "keymail")); !errors.Is(err, os.ErrNotExist) { |
| 250 | t.Fatal("generation object exists despite the failing Put — the fixture proves nothing") |
| 251 | } |
| 252 | } |
| 253 | |