| 1 | package release |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "crypto/ed25519" |
| 7 | "encoding/base64" |
| 8 | "encoding/json" |
| 9 | "errors" |
| 10 | "fmt" |
| 11 | "os" |
| 12 | "strings" |
| 13 | "testing" |
| 14 | "time" |
| 15 | |
| 16 | "github.com/carlosframework/platform/internal/caps" |
| 17 | ) |
| 18 | |
| 19 | // testKeys mints a signing pair through the exported key helpers, so the |
| 20 | // round trip seed -> base64 -> key is exercised too. |
| 21 | func testKeys(t *testing.T) (ed25519.PrivateKey, ed25519.PublicKey) { |
| 22 | t.Helper() |
| 23 | seedB64, pubB64, err := GenerateKey() |
| 24 | if err != nil { |
| 25 | t.Fatalf("GenerateKey: %v", err) |
| 26 | } |
| 27 | priv, err := ParseSigningKey(seedB64, "test") |
| 28 | if err != nil { |
| 29 | t.Fatalf("ParseSigningKey: %v", err) |
| 30 | } |
| 31 | pub, err := ParsePublicKey(pubB64, "test") |
| 32 | if err != nil { |
| 33 | t.Fatalf("ParsePublicKey: %v", err) |
| 34 | } |
| 35 | return priv, pub |
| 36 | } |
| 37 | |
| 38 | // shipRelease ships a one-artifact binary release keyed by plat. |
| 39 | func shipRelease(t *testing.T, s Store, acct, app, version, plat string, body []byte) { |
| 40 | t.Helper() |
| 41 | m := Manifest{ |
| 42 | App: app, |
| 43 | Version: version, |
| 44 | Kind: "binary", |
| 45 | Artifacts: map[string]Artifact{plat: {}}, |
| 46 | } |
| 47 | if err := Ship(context.Background(), s, acct, m, map[string][]byte{plat: body}); err != nil { |
| 48 | t.Fatalf("Ship %s/%s: %v", app, version, err) |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | func TestShipThenPromoteThenChannelRoundTrip(t *testing.T) { |
| 53 | s := NewDirStore(t.TempDir()) |
| 54 | ctx := context.Background() |
| 55 | priv, pub := testKeys(t) |
| 56 | |
| 57 | shipRelease(t, s, "bab", "myapp", "v1abc", "linux-amd64", []byte("binary bytes v1")) |
| 58 | if err := Promote(ctx, s, priv, "bab", "myapp", "canary/x", "v1abc"); err != nil { |
| 59 | t.Fatalf("Promote: %v", err) |
| 60 | } |
| 61 | ptr, err := Channel(ctx, s, pub, "bab", "myapp", "canary/x") |
| 62 | if err != nil { |
| 63 | t.Fatalf("Channel: %v", err) |
| 64 | } |
| 65 | if ptr.App != "myapp" || ptr.Channel != "canary/x" || ptr.Version != "v1abc" { |
| 66 | t.Fatalf("pointer = %+v", ptr) |
| 67 | } |
| 68 | if ptr.PromotedAt.IsZero() || len(ptr.History) != 0 { |
| 69 | t.Fatalf("pointer = %+v, want stamped PromotedAt and empty history", ptr) |
| 70 | } |
| 71 | // The manifest round-trips with the sha256 Ship computed. |
| 72 | m, err := GetManifest(ctx, s, "bab", "myapp", "v1abc") |
| 73 | if err != nil { |
| 74 | t.Fatalf("GetManifest: %v", err) |
| 75 | } |
| 76 | art := m.Artifacts["linux-amd64"] |
| 77 | if art.SHA256 != etagOf([]byte("binary bytes v1")) || art.Size != int64(len("binary bytes v1")) { |
| 78 | t.Fatalf("artifact = %+v", art) |
| 79 | } |
| 80 | body, _, err := s.Get(ctx, ArtifactKey("bab", "myapp", "v1abc", art.File)) |
| 81 | if err != nil || string(body) != "binary bytes v1" { |
| 82 | t.Fatalf("artifact bytes = %q, %v", body, err) |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | func TestShipImmutable(t *testing.T) { |
| 87 | s := NewDirStore(t.TempDir()) |
| 88 | // Re-shipping identical bytes is an idempotent no-op... |
| 89 | shipRelease(t, s, "bab", "myapp", "v1abc", "linux-amd64", []byte("same")) |
| 90 | shipRelease(t, s, "bab", "myapp", "v1abc", "linux-amd64", []byte("same")) |
| 91 | // ...but the same version with DIFFERENT content is refused. |
| 92 | m := Manifest{App: "myapp", Version: "v1abc", Kind: "binary", |
| 93 | Artifacts: map[string]Artifact{"linux-amd64": {}}} |
| 94 | err := Ship(context.Background(), s, "bab", m, map[string][]byte{"linux-amd64": []byte("different")}) |
| 95 | if err == nil { |
| 96 | t.Fatalf("Ship accepted different content under an existing version") |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | func TestPromoteRequiresShippedVersion(t *testing.T) { |
| 101 | s := NewDirStore(t.TempDir()) |
| 102 | priv, _ := testKeys(t) |
| 103 | err := Promote(context.Background(), s, priv, "bab", "myapp", "stable", "ghost1") |
| 104 | if err == nil || !errors.Is(err, os.ErrNotExist) { |
| 105 | t.Fatalf("Promote of unshipped version: err = %v, want os.ErrNotExist-wrapped", err) |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | func TestTamperedPointerRejected(t *testing.T) { |
| 110 | s := NewDirStore(t.TempDir()) |
| 111 | ctx := context.Background() |
| 112 | priv, pub := testKeys(t) |
| 113 | shipRelease(t, s, "bab", "myapp", "v1abc", "linux-amd64", []byte("bin")) |
| 114 | if err := Promote(ctx, s, priv, "bab", "myapp", "stable", "v1abc"); err != nil { |
| 115 | t.Fatalf("Promote: %v", err) |
| 116 | } |
| 117 | |
| 118 | key := ChannelKey("bab", "myapp", "stable") |
| 119 | raw, etag, err := s.Get(ctx, key) |
| 120 | if err != nil { |
| 121 | t.Fatalf("Get pointer: %v", err) |
| 122 | } |
| 123 | var sw signedWrapper |
| 124 | if err := json.Unmarshal(raw, &sw); err != nil { |
| 125 | t.Fatalf("unmarshal wrapper: %v", err) |
| 126 | } |
| 127 | |
| 128 | // Flip a byte in the signature. |
| 129 | sig := []byte(sw.Sig) |
| 130 | if sig[10] == 'A' { |
| 131 | sig[10] = 'B' |
| 132 | } else { |
| 133 | sig[10] = 'A' |
| 134 | } |
| 135 | tampered, _ := json.Marshal(signedWrapper{Payload: sw.Payload, Sig: string(sig)}) |
| 136 | if _, err := s.Put(ctx, key, tampered, etag); err != nil { |
| 137 | t.Fatalf("plant tampered pointer: %v", err) |
| 138 | } |
| 139 | if _, err := Channel(ctx, s, pub, "bab", "myapp", "stable"); err == nil { |
| 140 | t.Fatalf("Channel accepted a pointer with a flipped signature byte") |
| 141 | } |
| 142 | |
| 143 | // Flip a byte in the payload instead (signature intact) — the signature |
| 144 | // covers the exact base64 payload bytes, so this must fail too. |
| 145 | payload := []byte(sw.Payload) |
| 146 | if payload[10] == 'A' { |
| 147 | payload[10] = 'B' |
| 148 | } else { |
| 149 | payload[10] = 'A' |
| 150 | } |
| 151 | raw2, etag2, _ := s.Get(ctx, key) |
| 152 | _ = raw2 |
| 153 | tampered2, _ := json.Marshal(signedWrapper{Payload: string(payload), Sig: sw.Sig}) |
| 154 | if _, err := s.Put(ctx, key, tampered2, etag2); err != nil { |
| 155 | t.Fatalf("plant tampered payload: %v", err) |
| 156 | } |
| 157 | if _, err := Channel(ctx, s, pub, "bab", "myapp", "stable"); err == nil { |
| 158 | t.Fatalf("Channel accepted a pointer with a flipped payload byte") |
| 159 | } |
| 160 | |
| 161 | // And a validly-signed pointer copied from ANOTHER channel's key is a |
| 162 | // cross-channel replay — rejected because app/channel live under the |
| 163 | // signature. |
| 164 | if err := Promote(ctx, s, priv, "bab", "myapp", "canary/evil", "v1abc"); err != nil { |
| 165 | t.Fatalf("Promote canary: %v", err) |
| 166 | } |
| 167 | canaryRaw, _, _ := s.Get(ctx, ChannelKey("bab", "myapp", "canary/evil")) |
| 168 | _, curEtag, _ := s.Get(ctx, key) |
| 169 | if _, err := s.Put(ctx, key, canaryRaw, curEtag); err != nil { |
| 170 | t.Fatalf("plant replayed pointer: %v", err) |
| 171 | } |
| 172 | if _, err := Channel(ctx, s, pub, "bab", "myapp", "stable"); err == nil { |
| 173 | t.Fatalf("Channel accepted a pointer replayed from another channel") |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | func TestPromoteHistoryCapTen(t *testing.T) { |
| 178 | s := NewDirStore(t.TempDir()) |
| 179 | ctx := context.Background() |
| 180 | priv, pub := testKeys(t) |
| 181 | for i := 1; i <= 12; i++ { |
| 182 | v := fmt.Sprintf("v%02d", i) |
| 183 | shipRelease(t, s, "bab", "myapp", v, "linux-amd64", []byte("bin "+v)) |
| 184 | if err := Promote(ctx, s, priv, "bab", "myapp", "stable", v); err != nil { |
| 185 | t.Fatalf("Promote %s: %v", v, err) |
| 186 | } |
| 187 | } |
| 188 | ptr, err := Channel(ctx, s, pub, "bab", "myapp", "stable") |
| 189 | if err != nil { |
| 190 | t.Fatalf("Channel: %v", err) |
| 191 | } |
| 192 | if ptr.Version != "v12" { |
| 193 | t.Fatalf("Version = %s, want v12", ptr.Version) |
| 194 | } |
| 195 | if len(ptr.History) != 10 { |
| 196 | t.Fatalf("History has %d entries, want 10: %v", len(ptr.History), ptr.History) |
| 197 | } |
| 198 | if ptr.History[0] != "v11" || ptr.History[9] != "v02" { |
| 199 | t.Fatalf("History = %v, want newest-first v11..v02", ptr.History) |
| 200 | } |
| 201 | } |
| 202 | |
| 203 | func TestRollbackSwapsAndReturns(t *testing.T) { |
| 204 | s := NewDirStore(t.TempDir()) |
| 205 | ctx := context.Background() |
| 206 | priv, pub := testKeys(t) |
| 207 | shipRelease(t, s, "bab", "myapp", "v1", "linux-amd64", []byte("one")) |
| 208 | shipRelease(t, s, "bab", "myapp", "v2", "linux-amd64", []byte("two")) |
| 209 | if err := Promote(ctx, s, priv, "bab", "myapp", "stable", "v1"); err != nil { |
| 210 | t.Fatalf("Promote v1: %v", err) |
| 211 | } |
| 212 | |
| 213 | // Nothing to roll back to yet. |
| 214 | if err := Rollback(ctx, s, priv, "bab", "myapp", "stable"); err == nil { |
| 215 | t.Fatalf("Rollback with empty history succeeded") |
| 216 | } |
| 217 | |
| 218 | if err := Promote(ctx, s, priv, "bab", "myapp", "stable", "v2"); err != nil { |
| 219 | t.Fatalf("Promote v2: %v", err) |
| 220 | } |
| 221 | if err := Rollback(ctx, s, priv, "bab", "myapp", "stable"); err != nil { |
| 222 | t.Fatalf("Rollback: %v", err) |
| 223 | } |
| 224 | ptr, err := Channel(ctx, s, pub, "bab", "myapp", "stable") |
| 225 | if err != nil { |
| 226 | t.Fatalf("Channel: %v", err) |
| 227 | } |
| 228 | if ptr.Version != "v1" || len(ptr.History) != 1 || ptr.History[0] != "v2" { |
| 229 | t.Fatalf("after rollback: %+v, want v1 with history [v2]", ptr) |
| 230 | } |
| 231 | // A second rollback returns to where you were (the swap semantics). |
| 232 | if err := Rollback(ctx, s, priv, "bab", "myapp", "stable"); err != nil { |
| 233 | t.Fatalf("second Rollback: %v", err) |
| 234 | } |
| 235 | ptr, _ = Channel(ctx, s, pub, "bab", "myapp", "stable") |
| 236 | if ptr.Version != "v2" || len(ptr.History) != 1 || ptr.History[0] != "v1" { |
| 237 | t.Fatalf("after second rollback: %+v, want v2 with history [v1]", ptr) |
| 238 | } |
| 239 | } |
| 240 | |
| 241 | func TestChannelWrongKeyRejected(t *testing.T) { |
| 242 | s := NewDirStore(t.TempDir()) |
| 243 | ctx := context.Background() |
| 244 | priv, _ := testKeys(t) |
| 245 | _, otherPub := testKeys(t) |
| 246 | shipRelease(t, s, "bab", "myapp", "v1", "linux-amd64", []byte("one")) |
| 247 | if err := Promote(ctx, s, priv, "bab", "myapp", "stable", "v1"); err != nil { |
| 248 | t.Fatalf("Promote: %v", err) |
| 249 | } |
| 250 | if _, err := Channel(ctx, s, otherPub, "bab", "myapp", "stable"); err == nil { |
| 251 | t.Fatalf("Channel verified against the wrong public key") |
| 252 | } |
| 253 | } |
| 254 | |
| 255 | func TestValidChannel(t *testing.T) { |
| 256 | for c, want := range map[string]bool{ |
| 257 | "stable": true, "beta": true, "canary/feature-x": true, |
| 258 | "canary/..": false, "a/b/c": false, "": false, "sta ble": false, |
| 259 | } { |
| 260 | if got := ValidChannel(c); got != want { |
| 261 | t.Errorf("ValidChannel(%q) = %v, want %v", c, got, want) |
| 262 | } |
| 263 | } |
| 264 | } |
| 265 | |
| 266 | // Guard against the PromotedAt stamp being mistaken for a trust input |
| 267 | // anywhere: the bake clock must run off the box's own sightings, and the |
| 268 | // stamp is informational. This just pins that signPointer stamps it. |
| 269 | func TestSignStampsPromotedAt(t *testing.T) { |
| 270 | priv, pub := testKeys(t) |
| 271 | p := &ChannelPointer{Account: "bab", App: "myapp", Channel: "stable", Version: "v1"} |
| 272 | before := time.Now().Add(-time.Second) |
| 273 | raw, err := signPointer(RootSigner(priv), p) |
| 274 | if err != nil { |
| 275 | t.Fatalf("signPointer: %v", err) |
| 276 | } |
| 277 | got, err := verifyPointer(pub, raw, "bab", "myapp", "stable") |
| 278 | if err != nil { |
| 279 | t.Fatalf("verifyPointer: %v", err) |
| 280 | } |
| 281 | if got.PromotedAt.Before(before) { |
| 282 | t.Fatalf("PromotedAt = %v, want stamped at signing time", got.PromotedAt) |
| 283 | } |
| 284 | } |
| 285 | |
| 286 | // The ladder, end to end through the store: a build walks edge -> beta -> |
| 287 | // stable, a skip is refused with the bypass named, -hotfix places it anyway, |
| 288 | // canary is never refused, and repeating a promotion is a no-op success. |
| 289 | func TestPromoteOnLadderEnforcesOrdering(t *testing.T) { |
| 290 | s := NewDirStore(t.TempDir()) |
| 291 | ctx := context.Background() |
| 292 | priv, pub := testKeys(t) |
| 293 | shipRelease(t, s, "bab", "myapp", "v1", "linux-arm64", []byte("bin v1")) |
| 294 | shipRelease(t, s, "bab", "myapp", "v2", "linux-arm64", []byte("bin v2")) |
| 295 | |
| 296 | promote := func(version, channel string, opt PromoteOptions) (PromoteResult, error) { |
| 297 | return PromoteOnLadder(ctx, s, priv, "bab", "myapp", channel, version, opt) |
| 298 | } |
| 299 | |
| 300 | // A fresh build enters at edge, then climbs one rung at a time. |
| 301 | if _, err := promote("v1", "edge", PromoteOptions{}); err != nil { |
| 302 | t.Fatalf("v1 -> edge: %v", err) |
| 303 | } |
| 304 | res, err := promote("v1", "beta", PromoteOptions{}) |
| 305 | if err != nil { |
| 306 | t.Fatalf("v1 edge -> beta: %v", err) |
| 307 | } |
| 308 | if res.From != RungEdge || res.To != RungBeta || res.NoOp { |
| 309 | t.Fatalf("v1 edge -> beta: result = %+v", res) |
| 310 | } |
| 311 | if _, err := promote("v1", "stable", PromoteOptions{}); err != nil { |
| 312 | t.Fatalf("v1 beta -> stable: %v", err) |
| 313 | } |
| 314 | if p, err := Channel(ctx, s, pub, "bab", "myapp", "stable"); err != nil || p.Version != "v1" { |
| 315 | t.Fatalf("stable = %+v, %v; want v1", p, err) |
| 316 | } |
| 317 | |
| 318 | // A build that has been nowhere cannot land on stable, and the refusal |
| 319 | // says how to override it. |
| 320 | _, err = promote("v2", "stable", PromoteOptions{}) |
| 321 | if err == nil { |
| 322 | t.Fatalf("v2 (on no rung) -> stable was allowed") |
| 323 | } |
| 324 | if !strings.Contains(err.Error(), "-hotfix") { |
| 325 | t.Fatalf("refusal = %q, want it to name --hotfix", err) |
| 326 | } |
| 327 | if p, _ := Channel(ctx, s, pub, "bab", "myapp", "stable"); p.Version != "v1" { |
| 328 | t.Fatalf("stable moved to %s on a refused promotion", p.Version) |
| 329 | } |
| 330 | // The skip is refused too — that is what the ladder is for. |
| 331 | if _, err := promote("v2", "edge", PromoteOptions{}); err != nil { |
| 332 | t.Fatalf("v2 -> edge: %v", err) |
| 333 | } |
| 334 | if _, err := promote("v2", "stable", PromoteOptions{}); err == nil { |
| 335 | t.Fatalf("v2 edge -> stable (a skip) was allowed") |
| 336 | } |
| 337 | |
| 338 | // -hotfix places it anyway, and says so. |
| 339 | res, err = promote("v2", "stable", PromoteOptions{Hotfix: true}) |
| 340 | if err != nil { |
| 341 | t.Fatalf("v2 -> stable --hotfix: %v", err) |
| 342 | } |
| 343 | if !res.Hotfix || res.To != RungStable { |
| 344 | t.Fatalf("hotfix result = %+v", res) |
| 345 | } |
| 346 | if p, _ := Channel(ctx, s, pub, "bab", "myapp", "stable"); p.Version != "v2" { |
| 347 | t.Fatalf("stable = %s after hotfix, want v2", p.Version) |
| 348 | } |
| 349 | |
| 350 | // Repeating a promotion is a no-op success, never an error — even though |
| 351 | // v1 now sits on beta while stable has moved past it. |
| 352 | res, err = promote("v1", "beta", PromoteOptions{}) |
| 353 | if err != nil { |
| 354 | t.Fatalf("re-promoting v1 to the beta it already holds: %v", err) |
| 355 | } |
| 356 | if !res.NoOp { |
| 357 | t.Fatalf("re-promotion result = %+v, want NoOp", res) |
| 358 | } |
| 359 | } |
| 360 | |
| 361 | // Production runs on canary/* channels right now: ordering must never refuse |
| 362 | // one, whatever the build is already on. |
| 363 | func TestPromoteOnLadderNeverRefusesCanary(t *testing.T) { |
| 364 | s := NewDirStore(t.TempDir()) |
| 365 | ctx := context.Background() |
| 366 | priv, pub := testKeys(t) |
| 367 | shipRelease(t, s, "bab", "myapp", "v1", "linux-arm64", []byte("bin v1")) |
| 368 | |
| 369 | for _, channel := range []string{"canary/keymail", "canary/platform-adoption", "canary/rehearsal"} { |
| 370 | if _, err := PromoteOnLadder(ctx, s, priv, "bab", "myapp", channel, "v1", PromoteOptions{}); err != nil { |
| 371 | t.Fatalf("promote v1 -> %s: %v", channel, err) |
| 372 | } |
| 373 | if p, err := Channel(ctx, s, pub, "bab", "myapp", channel); err != nil || p.Version != "v1" { |
| 374 | t.Fatalf("%s = %+v, %v", channel, p, err) |
| 375 | } |
| 376 | } |
| 377 | // ...including a build that has already reached stable. |
| 378 | if _, err := PromoteOnLadder(ctx, s, priv, "bab", "myapp", "edge", "v1", PromoteOptions{Hotfix: true}); err != nil { |
| 379 | t.Fatalf("hotfix v1 -> edge: %v", err) |
| 380 | } |
| 381 | if _, err := PromoteOnLadder(ctx, s, priv, "bab", "myapp", "canary/late", "v1", PromoteOptions{}); err != nil { |
| 382 | t.Fatalf("promote a build that is on edge back onto a canary: %v", err) |
| 383 | } |
| 384 | } |
| 385 | |
| 386 | // VersionRung answers "how mature is this build" from the channel pointers |
| 387 | // that exist, and reports the HIGHEST rung when several point at it. |
| 388 | func TestVersionRungReportsHighestRung(t *testing.T) { |
| 389 | s := NewDirStore(t.TempDir()) |
| 390 | ctx := context.Background() |
| 391 | priv, pub := testKeys(t) |
| 392 | shipRelease(t, s, "bab", "myapp", "v1", "linux-arm64", []byte("bin v1")) |
| 393 | |
| 394 | if rung, err := VersionRung(ctx, s, pub, "bab", "myapp", "v1"); err != nil || rung != "" { |
| 395 | t.Fatalf("VersionRung with no pointers = %q, %v; want \"\"", rung, err) |
| 396 | } |
| 397 | if err := Promote(ctx, s, priv, "bab", "myapp", "canary/x", "v1"); err != nil { |
| 398 | t.Fatalf("Promote: %v", err) |
| 399 | } |
| 400 | if rung, err := VersionRung(ctx, s, pub, "bab", "myapp", "v1"); err != nil || rung != RungCanary { |
| 401 | t.Fatalf("VersionRung on canary = %q, %v", rung, err) |
| 402 | } |
| 403 | if err := Promote(ctx, s, priv, "bab", "myapp", "beta", "v1"); err != nil { |
| 404 | t.Fatalf("Promote: %v", err) |
| 405 | } |
| 406 | if rung, err := VersionRung(ctx, s, pub, "bab", "myapp", "v1"); err != nil || rung != RungBeta { |
| 407 | t.Fatalf("VersionRung on canary+beta = %q, %v; want the highest", rung, err) |
| 408 | } |
| 409 | // A channel that is not a rung says nothing about maturity. |
| 410 | shipRelease(t, s, "bab", "myapp", "v2", "linux-arm64", []byte("bin v2")) |
| 411 | if err := Promote(ctx, s, priv, "bab", "myapp", "wip", "v2"); err != nil { |
| 412 | t.Fatalf("Promote: %v", err) |
| 413 | } |
| 414 | if rung, err := VersionRung(ctx, s, pub, "bab", "myapp", "v2"); err != nil || rung != "" { |
| 415 | t.Fatalf("VersionRung of a build on a non-rung channel = %q, %v; want \"\"", rung, err) |
| 416 | } |
| 417 | } |
| 418 | |
| 419 | // Every promotion is recorded on the pointer it moves: what moved, from which |
| 420 | // rung to which, who did it, whether it bypassed the ladder, and when. |
| 421 | func TestPromotionRecordsAnAuditEntry(t *testing.T) { |
| 422 | s := NewDirStore(t.TempDir()) |
| 423 | ctx := context.Background() |
| 424 | priv, pub := testKeys(t) |
| 425 | shipRelease(t, s, "bab", "myapp", "v1", "linux-arm64", []byte("bin v1")) |
| 426 | shipRelease(t, s, "bab", "myapp", "v2", "linux-arm64", []byte("bin v2")) |
| 427 | |
| 428 | if _, err := PromoteOnLadder(ctx, s, priv, "bab", "myapp", "edge", "v1", |
| 429 | PromoteOptions{Actor: "paul@keymail.dev"}); err != nil { |
| 430 | t.Fatalf("v1 -> edge: %v", err) |
| 431 | } |
| 432 | if _, err := PromoteOnLadder(ctx, s, priv, "bab", "myapp", "beta", "v1", |
| 433 | PromoteOptions{Actor: "paul@keymail.dev"}); err != nil { |
| 434 | t.Fatalf("v1 -> beta: %v", err) |
| 435 | } |
| 436 | p, err := Channel(ctx, s, pub, "bab", "myapp", "beta") |
| 437 | if err != nil { |
| 438 | t.Fatalf("Channel beta: %v", err) |
| 439 | } |
| 440 | if len(p.Audit) != 1 { |
| 441 | t.Fatalf("beta audit = %+v, want one entry", p.Audit) |
| 442 | } |
| 443 | got := p.Audit[0] |
| 444 | if got.Version != "v1" || got.From != RungEdge || got.To != RungBeta || |
| 445 | got.Actor != "paul@keymail.dev" || got.Hotfix || got.At.IsZero() { |
| 446 | t.Fatalf("audit entry = %+v", got) |
| 447 | } |
| 448 | |
| 449 | // A hotfix says so, and the trail keeps what came before it, newest first. |
| 450 | if _, err := PromoteOnLadder(ctx, s, priv, "bab", "myapp", "beta", "v2", |
| 451 | PromoteOptions{Actor: "james@keymail.dev", Hotfix: true}); err != nil { |
| 452 | t.Fatalf("v2 -> beta --hotfix: %v", err) |
| 453 | } |
| 454 | p, err = Channel(ctx, s, pub, "bab", "myapp", "beta") |
| 455 | if err != nil { |
| 456 | t.Fatalf("Channel beta: %v", err) |
| 457 | } |
| 458 | if len(p.Audit) != 2 { |
| 459 | t.Fatalf("beta audit = %+v, want two entries", p.Audit) |
| 460 | } |
| 461 | if !p.Audit[0].Hotfix || p.Audit[0].Version != "v2" || p.Audit[0].Actor != "james@keymail.dev" { |
| 462 | t.Fatalf("newest audit entry = %+v, want the hotfix", p.Audit[0]) |
| 463 | } |
| 464 | if p.Audit[1].Version != "v1" || p.Audit[1].Hotfix { |
| 465 | t.Fatalf("older audit entry = %+v, want v1's ordinary promotion", p.Audit[1]) |
| 466 | } |
| 467 | // A no-op repeat records nothing: nothing happened. |
| 468 | if _, err := PromoteOnLadder(ctx, s, priv, "bab", "myapp", "beta", "v2", |
| 469 | PromoteOptions{Actor: "james@keymail.dev"}); err != nil { |
| 470 | t.Fatalf("repeat promotion: %v", err) |
| 471 | } |
| 472 | if p, _ := Channel(ctx, s, pub, "bab", "myapp", "beta"); len(p.Audit) != 2 { |
| 473 | t.Fatalf("a no-op repeat appended an audit entry: %+v", p.Audit) |
| 474 | } |
| 475 | // Rollback is not a promotion, but it must not lose the trail either. |
| 476 | if err := Rollback(ctx, s, priv, "bab", "myapp", "beta"); err != nil { |
| 477 | t.Fatalf("Rollback: %v", err) |
| 478 | } |
| 479 | if p, _ := Channel(ctx, s, pub, "bab", "myapp", "beta"); len(p.Audit) != 2 { |
| 480 | t.Fatalf("rollback dropped the audit trail: %+v", p.Audit) |
| 481 | } |
| 482 | } |
| 483 | |
| 484 | // The audit trail is additive: pointers written before it existed — and there |
| 485 | // are live ones in production buckets — must still verify and parse, with no |
| 486 | // audit data and no error. This is the exact payload the pre-audit signPointer |
| 487 | // produced, signed the same way. |
| 488 | func TestPointerWrittenBeforeAuditStillParses(t *testing.T) { |
| 489 | s := NewDirStore(t.TempDir()) |
| 490 | ctx := context.Background() |
| 491 | priv, pub := testKeys(t) |
| 492 | shipRelease(t, s, "bab", "myapp", "v1", "linux-arm64", []byte("bin v1")) |
| 493 | |
| 494 | old := `{ |
| 495 | "schema": 1, |
| 496 | "app": "myapp", |
| 497 | "channel": "beta", |
| 498 | "version": "v1", |
| 499 | "promoted_at": "2026-07-01T10:00:00Z", |
| 500 | "history": [ |
| 501 | "v0" |
| 502 | ] |
| 503 | }` |
| 504 | b64 := base64.StdEncoding.EncodeToString([]byte(old)) |
| 505 | wrapper, err := json.MarshalIndent(signedWrapper{ |
| 506 | Payload: b64, |
| 507 | Sig: base64.StdEncoding.EncodeToString(ed25519.Sign(priv, []byte(b64))), |
| 508 | }, "", " ") |
| 509 | if err != nil { |
| 510 | t.Fatalf("marshal wrapper: %v", err) |
| 511 | } |
| 512 | if _, err := s.Put(ctx, ChannelKey("bab", "myapp", "beta"), wrapper, ""); err != nil { |
| 513 | t.Fatalf("plant pre-audit pointer: %v", err) |
| 514 | } |
| 515 | |
| 516 | p, err := Channel(ctx, s, pub, "bab", "myapp", "beta") |
| 517 | if err != nil { |
| 518 | t.Fatalf("Channel on a pre-audit pointer: %v", err) |
| 519 | } |
| 520 | if p.Version != "v1" || len(p.History) != 1 || p.History[0] != "v0" { |
| 521 | t.Fatalf("pre-audit pointer = %+v", p) |
| 522 | } |
| 523 | if p.Audit != nil { |
| 524 | t.Fatalf("pre-audit pointer has audit data: %+v", p.Audit) |
| 525 | } |
| 526 | // It still answers "which rung is this build on", and still promotes — |
| 527 | // the trail simply starts at the next promotion. |
| 528 | if rung, err := VersionRung(ctx, s, pub, "bab", "myapp", "v1"); err != nil || rung != RungBeta { |
| 529 | t.Fatalf("VersionRung on a pre-audit pointer = %q, %v", rung, err) |
| 530 | } |
| 531 | if _, err := PromoteOnLadder(ctx, s, priv, "bab", "myapp", "stable", "v1", |
| 532 | PromoteOptions{Actor: "paul@keymail.dev"}); err != nil { |
| 533 | t.Fatalf("promote a pre-audit build: %v", err) |
| 534 | } |
| 535 | sp, err := Channel(ctx, s, pub, "bab", "myapp", "stable") |
| 536 | if err != nil { |
| 537 | t.Fatalf("Channel stable: %v", err) |
| 538 | } |
| 539 | if len(sp.Audit) != 1 || sp.Audit[0].From != RungBeta || sp.Audit[0].To != RungStable { |
| 540 | t.Fatalf("stable audit = %+v", sp.Audit) |
| 541 | } |
| 542 | } |
| 543 | |
| 544 | // TestShipRefusesAnUnknownCapability: validateManifest's capability gate runs |
| 545 | // at SHIP, where a human is standing at a terminal to read the refusal — not |
| 546 | // only on the box hours later. The refusal has to name the offending value, |
| 547 | // because "invalid capabilities" tells an operator nothing about which line |
| 548 | // of their block to fix. |
| 549 | func TestShipRefusesAnUnknownCapability(t *testing.T) { |
| 550 | s := NewDirStore(t.TempDir()) |
| 551 | m := Manifest{ |
| 552 | App: "myapp", |
| 553 | Version: "v1abc", |
| 554 | Kind: "binary", |
| 555 | Artifacts: map[string]Artifact{"linux-arm64": {}}, |
| 556 | // 8080 is not a capability: only privileged ports are, and a high |
| 557 | // port needs no help from the platform. |
| 558 | Capabilities: &caps.Set{V: 1, Ports: []caps.Port{{Port: 8080, Name: "http"}}}, |
| 559 | } |
| 560 | err := Ship(context.Background(), s, "bab", m, map[string][]byte{"linux-arm64": []byte("bin")}) |
| 561 | if err == nil { |
| 562 | t.Fatal("Ship accepted a capability block the box would refuse — the ship and adopt gates have diverged") |
| 563 | } |
| 564 | if !strings.Contains(err.Error(), "8080") { |
| 565 | t.Fatalf("refusal %q does not name the offending port", err) |
| 566 | } |
| 567 | // And nothing was written: the gate runs before a single byte moves. |
| 568 | if _, err := GetManifest(context.Background(), s, "bab", "myapp", "v1abc"); err == nil { |
| 569 | t.Fatal("a refused ship still wrote a manifest") |
| 570 | } |
| 571 | } |
| 572 | |
| 573 | // TestShipRefusesAnUnsupportedCapabilityVersion pins the other half of §3b's |
| 574 | // fail-closed direction at ship time: a block this binary does not speak is |
| 575 | // refused with a message naming the fix, rather than shipped for a box to |
| 576 | // choke on later. |
| 577 | func TestShipRefusesAnUnsupportedCapabilityVersion(t *testing.T) { |
| 578 | s := NewDirStore(t.TempDir()) |
| 579 | m := Manifest{ |
| 580 | App: "myapp", |
| 581 | Version: "v1abc", |
| 582 | Kind: "binary", |
| 583 | Artifacts: map[string]Artifact{"linux-arm64": {}}, |
| 584 | Capabilities: &caps.Set{V: caps.Version + 1, Daemon: true}, |
| 585 | } |
| 586 | err := Ship(context.Background(), s, "bab", m, map[string][]byte{"linux-arm64": []byte("bin")}) |
| 587 | if err == nil { |
| 588 | t.Fatal("Ship accepted a capability block version this binary does not speak") |
| 589 | } |
| 590 | if !strings.Contains(err.Error(), "update the platform binary") { |
| 591 | t.Fatalf("refusal %q does not name the fix", err) |
| 592 | } |
| 593 | } |
| 594 | |
| 595 | // TestShipRoundTripsACapabilityBlock: a valid declaration survives the whole |
| 596 | // immutable-manifest round trip — marshalled by Ship, re-decoded through |
| 597 | // caps.Set's strict door by GetManifest — with every field intact. This is |
| 598 | // the trust chain the design turns on: the declaration rides the same signed |
| 599 | // pointer -> immutable manifest -> sha256 path as the bytes it describes. |
| 600 | func TestShipRoundTripsACapabilityBlock(t *testing.T) { |
| 601 | s := NewDirStore(t.TempDir()) |
| 602 | ctx := context.Background() |
| 603 | want := &caps.Set{ |
| 604 | V: 1, |
| 605 | Command: "relay", |
| 606 | Daemon: true, |
| 607 | Children: true, |
| 608 | RuntimeDir: "keymail", |
| 609 | Ports: []caps.Port{{Port: 25, Name: "mx"}}, |
| 610 | Data: &caps.Data{Replicate: caps.DataReplicateApp}, |
| 611 | HostRole: true, |
| 612 | } |
| 613 | m := Manifest{ |
| 614 | App: "keymail", Version: "v1abc", Kind: "binary", |
| 615 | Artifacts: map[string]Artifact{"linux-arm64": {}}, |
| 616 | Capabilities: want, |
| 617 | } |
| 618 | if err := Ship(ctx, s, "bab", m, map[string][]byte{"linux-arm64": []byte("bin")}); err != nil { |
| 619 | t.Fatalf("Ship: %v", err) |
| 620 | } |
| 621 | got, err := GetManifest(ctx, s, "bab", "keymail", "v1abc") |
| 622 | if err != nil { |
| 623 | t.Fatalf("GetManifest: %v", err) |
| 624 | } |
| 625 | if got.Capabilities == nil { |
| 626 | t.Fatal("the capability block did not survive the round trip") |
| 627 | } |
| 628 | // Compared through the canonical JSON encoding, not reflect.DeepEqual: |
| 629 | // caps.Set carries an unexported notes slice, so a decoded Set is not |
| 630 | // DeepEqual-safe against a literal once those notes are non-empty. |
| 631 | a, _ := json.Marshal(want) |
| 632 | b, _ := json.Marshal(got.Capabilities) |
| 633 | if string(a) != string(b) { |
| 634 | t.Fatalf("capabilities round-tripped as %s, want %s", b, a) |
| 635 | } |
| 636 | } |
| 637 | |
| 638 | // Fast-track (2026-08-04 console bake controls spec §5): re-sign the head |
| 639 | // with the bake waiver — same version, same history, one new audit entry — |
| 640 | // and never from a scoped key, at mint or at verification. |
| 641 | func TestFastTrackWaivesTheHeadInPlace(t *testing.T) { |
| 642 | s := NewDirStore(t.TempDir()) |
| 643 | ctx := context.Background() |
| 644 | priv, pub := testKeys(t) |
| 645 | shipRelease(t, s, "bab", "kass", "v1", "linux-amd64", []byte("b1")) |
| 646 | shipRelease(t, s, "bab", "kass", "v2", "linux-amd64", []byte("b2")) |
| 647 | if err := Promote(ctx, s, priv, "bab", "kass", "stable", "v1"); err != nil { |
| 648 | t.Fatal(err) |
| 649 | } |
| 650 | if err := Promote(ctx, s, priv, "bab", "kass", "stable", "v2"); err != nil { |
| 651 | t.Fatal(err) |
| 652 | } |
| 653 | |
| 654 | ver, err := FastTrack(ctx, s, priv, "bab", "kass", "stable", "paul@keymail.dev") |
| 655 | if err != nil { |
| 656 | t.Fatalf("FastTrack: %v", err) |
| 657 | } |
| 658 | if ver != "v2" { |
| 659 | t.Fatalf("FastTrack waived %q, want the head v2", ver) |
| 660 | } |
| 661 | p, err := Channel(ctx, s, pub, "bab", "kass", "stable") |
| 662 | if err != nil { |
| 663 | t.Fatal(err) |
| 664 | } |
| 665 | if !p.FastTrack || p.Version != "v2" { |
| 666 | t.Fatalf("pointer = version %s fast_track %v, want v2 waived", p.Version, p.FastTrack) |
| 667 | } |
| 668 | // History survives — a fast-tracked head must stay rollbackable. |
| 669 | if len(p.History) == 0 || p.History[0] != "v1" { |
| 670 | t.Fatalf("history = %v, want v1 preserved", p.History) |
| 671 | } |
| 672 | // The waiver is recorded on the trail it waives. |
| 673 | if len(p.Audit) == 0 || !p.Audit[0].FastTrack || p.Audit[0].Actor != "paul@keymail.dev" { |
| 674 | t.Fatalf("audit[0] = %+v, want a fast_track entry naming the actor", p.Audit) |
| 675 | } |
| 676 | // Idempotent: a second fast-track is a no-op success. |
| 677 | if _, err := FastTrack(ctx, s, priv, "bab", "kass", "stable", "paul@keymail.dev"); err != nil { |
| 678 | t.Fatalf("repeated FastTrack: %v", err) |
| 679 | } |
| 680 | // The NEXT promotion starts clean — fast_track is per write, never |
| 681 | // inherited (spec §5). |
| 682 | shipRelease(t, s, "bab", "kass", "v3", "linux-amd64", []byte("b3")) |
| 683 | if err := Promote(ctx, s, priv, "bab", "kass", "stable", "v3"); err != nil { |
| 684 | t.Fatal(err) |
| 685 | } |
| 686 | if p, _ := Channel(ctx, s, pub, "bab", "kass", "stable"); p.FastTrack { |
| 687 | t.Fatal("fast_track survived onto the next promotion") |
| 688 | } |
| 689 | } |
| 690 | |
| 691 | // Root only, both directions (mint AND verification, like the grant cap): |
| 692 | // a scoped signer cannot SIGN a fast-tracked pointer, and a hand-built |
| 693 | // grant-bearing wrapper whose payload carries fast_track does not VERIFY. |
| 694 | func TestFastTrackIsRootOnly(t *testing.T) { |
| 695 | s := NewDirStore(t.TempDir()) |
| 696 | ctx := context.Background() |
| 697 | priv, pub := testKeys(t) |
| 698 | shipRelease(t, s, "bab", "kass", "v1", "linux-amd64", []byte("b1")) |
| 699 | if err := Promote(ctx, s, priv, "bab", "kass", "edge", "v1"); err != nil { |
| 700 | t.Fatal(err) |
| 701 | } |
| 702 | seed, grantB64, err := MintKeyGrant(priv, "builder", []string{RungEdge}, maxGrantTTL) |
| 703 | if err != nil { |
| 704 | t.Fatal(err) |
| 705 | } |
| 706 | key, err := ParseSigningKey(seed, "test") |
| 707 | if err != nil { |
| 708 | t.Fatal(err) |
| 709 | } |
| 710 | scoped, err := ScopedSigner(key, grantB64, pub) |
| 711 | if err != nil { |
| 712 | t.Fatal(err) |
| 713 | } |
| 714 | // Mint side: the scoped signer refuses the payload. |
| 715 | if _, err := signPointer(scoped, &ChannelPointer{ |
| 716 | Account: "bab", App: "kass", Channel: "edge", Version: "v1", FastTrack: true, |
| 717 | }); err == nil || !strings.Contains(err.Error(), "root") { |
| 718 | t.Fatalf("a scoped signer signed a fast-tracked pointer (err=%v)", err) |
| 719 | } |
| 720 | // Verify side: build the wrapper by hand, exactly as a patched-out mint |
| 721 | // check would, and watch verification refuse it. |
| 722 | payload, _ := json.Marshal(ChannelPointer{Schema: 1, App: "kass", Channel: "edge", |
| 723 | Version: "v1", FastTrack: true, PromotedAt: timeNowTestHelper()}) |
| 724 | b64 := base64.StdEncoding.EncodeToString(payload) |
| 725 | sg, err := parseGrantBlob(grantB64, "test") |
| 726 | if err != nil { |
| 727 | t.Fatal(err) |
| 728 | } |
| 729 | wrapper, _ := json.Marshal(signedWrapper{ |
| 730 | Payload: b64, |
| 731 | Sig: base64.StdEncoding.EncodeToString(ed25519.Sign(key, []byte(b64))), |
| 732 | Grant: sg, |
| 733 | }) |
| 734 | if _, err := verifyPointer(pub, wrapper, "bab", "kass", "edge"); err == nil || |
| 735 | !strings.Contains(err.Error(), "root") { |
| 736 | t.Fatalf("a scoped-signed fast_track pointer verified (err=%v)", err) |
| 737 | } |
| 738 | } |
| 739 | |
| 740 | // The wire change is additive both ways: a pre-change pointer still parses |
| 741 | // (no fast_track key at all), and a fast-tracked pointer parses in a struct |
| 742 | // that predates the field — the old binary simply keeps baking. |
| 743 | func TestFastTrackIsAdditiveOnTheWire(t *testing.T) { |
| 744 | s := NewDirStore(t.TempDir()) |
| 745 | ctx := context.Background() |
| 746 | priv, _ := testKeys(t) |
| 747 | shipRelease(t, s, "bab", "kass", "v1", "linux-amd64", []byte("b1")) |
| 748 | if err := Promote(ctx, s, priv, "bab", "kass", "stable", "v1"); err != nil { |
| 749 | t.Fatal(err) |
| 750 | } |
| 751 | if _, err := FastTrack(ctx, s, priv, "bab", "kass", "stable", ""); err != nil { |
| 752 | t.Fatal(err) |
| 753 | } |
| 754 | raw, _, err := GetQualified(ctx, s, ChannelKey("bab", "kass", "stable")) |
| 755 | if err != nil { |
| 756 | t.Fatal(err) |
| 757 | } |
| 758 | var sw signedWrapper |
| 759 | if err := json.Unmarshal(raw, &sw); err != nil { |
| 760 | t.Fatal(err) |
| 761 | } |
| 762 | payload, err := base64.StdEncoding.DecodeString(sw.Payload) |
| 763 | if err != nil { |
| 764 | t.Fatal(err) |
| 765 | } |
| 766 | // The old binary's shape: every pre-fast_track field, nothing else. |
| 767 | var old struct { |
| 768 | Schema int `json:"schema"` |
| 769 | App string `json:"app"` |
| 770 | Channel string `json:"channel"` |
| 771 | Version string `json:"version"` |
| 772 | } |
| 773 | if err := json.Unmarshal(payload, &old); err != nil { |
| 774 | t.Fatalf("an old struct shape failed to parse a fast-tracked payload: %v", err) |
| 775 | } |
| 776 | if old.Version != "v1" { |
| 777 | t.Fatalf("old shape read version %q", old.Version) |
| 778 | } |
| 779 | } |
| 780 | |
| 781 | func TestManifestLabelNotesAdditive(t *testing.T) { |
| 782 | // A manifest written before label/notes existed must still parse (the |
| 783 | // production buckets are full of them) … |
| 784 | old := []byte(`{"schema":1,"app":"km","version":"de2766f8","kind":"binary","artifacts":{"linux-arm64":{"file":"linux-arm64","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","size":1}}}`) |
| 785 | var m Manifest |
| 786 | if err := json.Unmarshal(old, &m); err != nil { |
| 787 | t.Fatalf("old manifest no longer parses: %v", err) |
| 788 | } |
| 789 | if m.Label != "" || m.Notes != "" { |
| 790 | t.Fatalf("absent fields decoded non-empty: %q %q", m.Label, m.Notes) |
| 791 | } |
| 792 | // … and a label-free manifest must MARSHAL byte-identically to before, |
| 793 | // or idempotent re-ship of an old release breaks on its own metadata. |
| 794 | out, err := json.Marshal(m) |
| 795 | if err != nil { |
| 796 | t.Fatal(err) |
| 797 | } |
| 798 | if bytes.Contains(out, []byte("label")) || bytes.Contains(out, []byte("notes")) { |
| 799 | t.Fatalf("empty label/notes leaked into marshal: %s", out) |
| 800 | } |
| 801 | } |
| 802 | |
| 803 | func TestValidateManifestLabelNotesLimits(t *testing.T) { |
| 804 | base := func() *Manifest { |
| 805 | return &Manifest{Schema: 1, App: "km", Version: "0.5.0-1", Kind: "binary", |
| 806 | Artifacts: map[string]Artifact{"linux-arm64": {File: "linux-arm64", SHA256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Size: 1}}} |
| 807 | } |
| 808 | m := base() |
| 809 | m.Label = "fixing modals" |
| 810 | m.Notes = "- rounded the corners\n- fixed the focus trap" |
| 811 | if err := validateManifest(m); err != nil { |
| 812 | t.Fatalf("valid label/notes refused: %v", err) |
| 813 | } |
| 814 | m = base() |
| 815 | m.Label = strings.Repeat("x", MaxLabelLen+1) |
| 816 | if err := validateManifest(m); err == nil { |
| 817 | t.Fatal("overlong label accepted") |
| 818 | } |
| 819 | m = base() |
| 820 | m.Label = "two\nlines" |
| 821 | if err := validateManifest(m); err == nil { |
| 822 | t.Fatal("multi-line label accepted") |
| 823 | } |
| 824 | m = base() |
| 825 | m.Notes = strings.Repeat("x", MaxNotesLen+1) |
| 826 | if err := validateManifest(m); err == nil { |
| 827 | t.Fatal("overlong notes accepted") |
| 828 | } |
| 829 | } |
| 830 | |
| 831 | func TestPromoteRetryRenewsPromotedAt(t *testing.T) { |
| 832 | s := NewDirStore(t.TempDir()) |
| 833 | ctx := context.Background() |
| 834 | priv, pub := testKeys(t) |
| 835 | shipRelease(t, s, "bab", "console", "v1", adoptPlatform, []byte("b")) |
| 836 | if _, err := PromoteOnLadder(ctx, s, priv, "bab", "console", "canary/x", "v1", PromoteOptions{}); err != nil { |
| 837 | t.Fatal(err) |
| 838 | } |
| 839 | before, _ := Channel(ctx, s, pub, "bab", "console", "canary/x") |
| 840 | time.Sleep(1100 * time.Millisecond) // PromotedAt is truncated to the second |
| 841 | res, err := PromoteOnLadder(ctx, s, priv, "bab", "console", "canary/x", "v1", PromoteOptions{}) |
| 842 | if err != nil || !res.NoOp { |
| 843 | t.Fatalf("plain repeat = %+v, %v; want the existing no-op", res, err) |
| 844 | } |
| 845 | same, _ := Channel(ctx, s, pub, "bab", "console", "canary/x") |
| 846 | if !same.PromotedAt.Equal(before.PromotedAt) { |
| 847 | t.Fatal("a plain repeat rewrote the pointer") |
| 848 | } |
| 849 | if _, err := PromoteOnLadder(ctx, s, priv, "bab", "console", "canary/x", "v1", PromoteOptions{Retry: true}); err != nil { |
| 850 | t.Fatalf("retry: %v", err) |
| 851 | } |
| 852 | after, _ := Channel(ctx, s, pub, "bab", "console", "canary/x") |
| 853 | if after.Version != "v1" || !after.PromotedAt.After(before.PromotedAt) { |
| 854 | t.Fatalf("after retry = %+v, want v1 with a later PromotedAt", after) |
| 855 | } |
| 856 | } |
| 857 | |
| 858 | // A retry that decided on v1, then finds the channel moved to v2 before its |
| 859 | // write, must refuse and leave v2 in place. The guarded write is called |
| 860 | // directly with the version the retry saw, so the window is deterministic. |
| 861 | func TestPromoteRetryWriteRefusesWhenTheChannelMoved(t *testing.T) { |
| 862 | s := NewDirStore(t.TempDir()) |
| 863 | ctx := context.Background() |
| 864 | priv, pub := testKeys(t) |
| 865 | shipRelease(t, s, "bab", "console", "v1", adoptPlatform, []byte("a")) |
| 866 | shipRelease(t, s, "bab", "console", "v2", adoptPlatform, []byte("b")) |
| 867 | sg := RootSigner(priv) |
| 868 | if err := sg.Promote(ctx, s, "bab", "console", "canary/x", "v2"); err != nil { |
| 869 | t.Fatal(err) |
| 870 | } |
| 871 | err := sg.promote(ctx, s, "bab", "console", "canary/x", "v1", nil, "v1", Promotion{Retry: true}) |
| 872 | if err == nil || !strings.Contains(err.Error(), "v2") || !strings.Contains(err.Error(), "v1") { |
| 873 | t.Fatalf("guarded retry write = %v, want a refusal naming both versions", err) |
| 874 | } |
| 875 | cp, cerr := Channel(ctx, s, pub, "bab", "console", "canary/x") |
| 876 | if cerr != nil || cp.Version != "v2" { |
| 877 | t.Fatalf("channel after refused retry = %+v, %v; want v2 untouched", cp, cerr) |
| 878 | } |
| 879 | // And a retry whose expectation holds still writes. |
| 880 | if err := sg.promote(ctx, s, "bab", "console", "canary/x", "v2", nil, "v2", Promotion{Retry: true}); err != nil { |
| 881 | t.Fatalf("guarded retry write with a matching version: %v", err) |
| 882 | } |
| 883 | } |
| 884 | |
| 885 | // Final review F7: the guarded write's own pointer was stamped this very |
| 886 | // second. The retry must still produce a strictly later PromotedAt. |
| 887 | func TestPromoteRetryStampsAfterTheGuardedPointer(t *testing.T) { |
| 888 | s := NewDirStore(t.TempDir()) |
| 889 | ctx := context.Background() |
| 890 | priv, pub := testKeys(t) |
| 891 | shipRelease(t, s, "bab", "console", "v1", adoptPlatform, []byte("a")) |
| 892 | sg := RootSigner(priv) |
| 893 | if err := sg.Promote(ctx, s, "bab", "console", "canary/x", "v1"); err != nil { |
| 894 | t.Fatal(err) |
| 895 | } |
| 896 | before, _ := Channel(ctx, s, pub, "bab", "console", "canary/x") |
| 897 | if err := sg.promote(ctx, s, "bab", "console", "canary/x", "v1", nil, "v1", Promotion{Retry: true}); err != nil { |
| 898 | t.Fatalf("retry: %v", err) |
| 899 | } |
| 900 | after, _ := Channel(ctx, s, pub, "bab", "console", "canary/x") |
| 901 | if !after.PromotedAt.After(before.PromotedAt) { |
| 902 | t.Fatalf("retry stamped %v over %v, want strictly later", after.PromotedAt, before.PromotedAt) |
| 903 | } |
| 904 | } |
| 905 | |
| 906 | // getHook runs onGet before every Get, so a test can land a competing write |
| 907 | // between a caller's first pointer read and its guarded one. |
| 908 | type getHook struct { |
| 909 | Store |
| 910 | onGet func(key string) |
| 911 | } |
| 912 | |
| 913 | func (h *getHook) Get(ctx context.Context, key string) ([]byte, string, error) { |
| 914 | h.onGet(key) |
| 915 | return h.Store.Get(ctx, key) |
| 916 | } |
| 917 | |
| 918 | // Final review F7, the race itself: PromoteOnLadder's first read sees an old |
| 919 | // pointer (no wait needed), then a concurrent retry renews it before the |
| 920 | // guarded read. The stamp is judged against the pointer the write is |
| 921 | // conditional on, so it is still strictly later than the competitor's. |
| 922 | func TestConcurrentRetriesNeverShareAStamp(t *testing.T) { |
| 923 | inner := NewDirStore(t.TempDir()) |
| 924 | ctx := context.Background() |
| 925 | priv, pub := testKeys(t) |
| 926 | shipRelease(t, inner, "bab", "console", "v1", adoptPlatform, []byte("a")) |
| 927 | sg := RootSigner(priv) |
| 928 | if err := sg.Promote(ctx, inner, "bab", "console", "canary/x", "v1"); err != nil { |
| 929 | t.Fatal(err) |
| 930 | } |
| 931 | time.Sleep(1100 * time.Millisecond) // the first read's pointer is a second old |
| 932 | key := ChannelKey("bab", "console", "canary/x") |
| 933 | var competitor time.Time |
| 934 | gets := 0 |
| 935 | s := &getHook{Store: inner} |
| 936 | s.onGet = func(k string) { |
| 937 | if k != key { |
| 938 | return |
| 939 | } |
| 940 | gets++ |
| 941 | if gets == 2 { // sg.promote's guarded read |
| 942 | if err := sg.promote(ctx, inner, "bab", "console", "canary/x", "v1", nil, "v1", Promotion{Retry: true}); err != nil { |
| 943 | t.Errorf("competing retry: %v", err) |
| 944 | } |
| 945 | cp, _ := Channel(ctx, inner, pub, "bab", "console", "canary/x") |
| 946 | competitor = cp.PromotedAt |
| 947 | } |
| 948 | } |
| 949 | if _, err := sg.PromoteOnLadder(ctx, s, "bab", "console", "canary/x", "v1", PromoteOptions{Retry: true}); err != nil { |
| 950 | t.Fatalf("retry: %v", err) |
| 951 | } |
| 952 | if gets < 2 || competitor.IsZero() { |
| 953 | t.Fatalf("the competing retry never ran (gets=%d)", gets) |
| 954 | } |
| 955 | after, _ := Channel(ctx, inner, pub, "bab", "console", "canary/x") |
| 956 | if !after.PromotedAt.After(competitor) { |
| 957 | t.Fatalf("retry stamped %v, the competitor %v: want strictly later", after.PromotedAt, competitor) |
| 958 | } |
| 959 | } |
| 960 | |
| 961 | func TestPromoteRetryNeverChangesTheVersion(t *testing.T) { |
| 962 | s := NewDirStore(t.TempDir()) |
| 963 | ctx := context.Background() |
| 964 | priv, _ := testKeys(t) |
| 965 | shipRelease(t, s, "bab", "console", "v1", adoptPlatform, []byte("a")) |
| 966 | shipRelease(t, s, "bab", "console", "v2", adoptPlatform, []byte("b")) |
| 967 | if _, err := PromoteOnLadder(ctx, s, priv, "bab", "console", "canary/x", "v1", PromoteOptions{}); err != nil { |
| 968 | t.Fatal(err) |
| 969 | } |
| 970 | _, err := PromoteOnLadder(ctx, s, priv, "bab", "console", "canary/x", "v2", PromoteOptions{Retry: true}) |
| 971 | if err == nil || !strings.Contains(err.Error(), "renews a promotion") { |
| 972 | t.Fatalf("retry onto a different version = %v, want a refusal", err) |
| 973 | } |
| 974 | } |
| 975 | |