| 1 | package release |
| 2 | |
| 3 | // The pipeline object (2026-08-20 release pipelines v2 spec §2): a |
| 4 | // root-signed per-app ordered channel list with per-channel bake and |
| 5 | // approval rules, replacing the implicit edge/beta/stable ladder assumption. |
| 6 | // The properties tested here: validation catches the reserved/duplicate/ |
| 7 | // malformed-channel cases before anything is signed; the accessors read the |
| 8 | // object the way later tasks will (Entry, Index, ChannelNamed, |
| 9 | // MaxApprovals); the object round-trips through the same root-only signed |
| 10 | // wrapper discipline as production.go, including monotonic SetAt and |
| 11 | // refusal of a copied object at the wrong app. |
| 12 | |
| 13 | import ( |
| 14 | "context" |
| 15 | "crypto/ed25519" |
| 16 | "strings" |
| 17 | "testing" |
| 18 | "time" |
| 19 | ) |
| 20 | |
| 21 | func TestPipelineValidation(t *testing.T) { |
| 22 | good := []PipelineChannel{{Name: "edge"}, {Name: "production", Bake: "24h", |
| 23 | Rules: ChannelRules{Passkey: true, PromoteApprovals: 2, ChangeApprovals: 2}}} |
| 24 | for _, tc := range []struct { |
| 25 | name string |
| 26 | channels []PipelineChannel |
| 27 | wantErr string // "" = valid |
| 28 | }{ |
| 29 | {"default single edge", DefaultPipelineChannels(), ""}, |
| 30 | {"two channels with rules", good, ""}, |
| 31 | {"empty list", nil, "at least one channel"}, |
| 32 | {"reserved canary", []PipelineChannel{{Name: "canary"}}, "reserved"}, |
| 33 | {"slash in name", []PipelineChannel{{Name: "canary/x"}}, "may not contain"}, |
| 34 | {"duplicate names", []PipelineChannel{{Name: "edge"}, {Name: "edge"}}, "duplicate"}, |
| 35 | {"bad duration", []PipelineChannel{{Name: "edge", Bake: "soon"}}, "bake"}, |
| 36 | {"negative bake", []PipelineChannel{{Name: "edge", Bake: "-1h"}}, "bake"}, |
| 37 | {"negative approvals", []PipelineChannel{{Name: "edge", |
| 38 | Rules: ChannelRules{PromoteApprovals: -1}}}, "approvals"}, |
| 39 | } { |
| 40 | p := &Pipeline{Schema: 1, Account: "bae", App: "web", Channels: tc.channels, SetAt: time.Now()} |
| 41 | err := validatePipeline(p, "bae", "web") |
| 42 | if tc.wantErr == "" && err != nil { |
| 43 | t.Errorf("%s: unexpected error %v", tc.name, err) |
| 44 | } |
| 45 | if tc.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tc.wantErr)) { |
| 46 | t.Errorf("%s: err = %v, want mention of %q", tc.name, err, tc.wantErr) |
| 47 | } |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | func TestPipelineAccessors(t *testing.T) { |
| 52 | p := &Pipeline{Schema: 1, Channels: []PipelineChannel{ |
| 53 | {Name: "edge"}, {Name: "staging", Bake: "1h"}, {Name: "production", Bake: "24h"}}} |
| 54 | if p.Entry() != "edge" { |
| 55 | t.Errorf("Entry() = %q, want edge", p.Entry()) |
| 56 | } |
| 57 | if i := p.Index("staging"); i != 1 { |
| 58 | t.Errorf("Index(staging) = %d, want 1", i) |
| 59 | } |
| 60 | if i := p.Index("canary/x"); i != -1 { |
| 61 | t.Errorf("Index(canary/x) = %d, want -1 (canary is never a pipeline channel)", i) |
| 62 | } |
| 63 | if i := p.Index("nope"); i != -1 { |
| 64 | t.Errorf("Index(nope) = %d, want -1", i) |
| 65 | } |
| 66 | c, ok := p.ChannelNamed("production") |
| 67 | if !ok || c.Bake != "24h" { |
| 68 | t.Errorf("ChannelNamed(production) = %+v, %v", c, ok) |
| 69 | } |
| 70 | if d, err := c.BakeWindow(); err != nil || d != 24*time.Hour { |
| 71 | t.Errorf("BakeWindow() = %v, %v", d, err) |
| 72 | } |
| 73 | var zero PipelineChannel |
| 74 | if d, err := zero.BakeWindow(); err != nil || d != 0 { |
| 75 | t.Errorf("zero BakeWindow() = %v, %v; want 0, nil", d, err) |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | func TestSetGetPipelineRoundTrip(t *testing.T) { |
| 80 | // Mirror TestSetProductionMonotonicSetAtOnRapidFlip's harness: |
| 81 | // dir store + root key. Missing object reads as (nil, nil). |
| 82 | s, priv, pub := pipelineTestHarness(t) |
| 83 | ctx := context.Background() |
| 84 | if p, err := GetPipeline(ctx, s, pub, "bae", "web"); p != nil || err != nil { |
| 85 | t.Fatalf("missing pipeline = %v, %v; want nil, nil", p, err) |
| 86 | } |
| 87 | set, err := SetPipeline(ctx, s, priv, "bae", "web", DefaultPipelineChannels(), "owner@example.test") |
| 88 | if err != nil { |
| 89 | t.Fatal(err) |
| 90 | } |
| 91 | got, err := GetPipeline(ctx, s, pub, "bae", "web") |
| 92 | if err != nil || got == nil || got.Entry() != "edge" || got.Actor != "owner@example.test" { |
| 93 | t.Fatalf("round trip = %+v, %v", got, err) |
| 94 | } |
| 95 | // Monotonic SetAt: an immediate rewrite must stamp strictly later. |
| 96 | set2, err := SetPipeline(ctx, s, priv, "bae", "web", |
| 97 | []PipelineChannel{{Name: "edge"}, {Name: "production", Bake: "24h"}}, "owner@example.test") |
| 98 | if err != nil { |
| 99 | t.Fatal(err) |
| 100 | } |
| 101 | if !set2.SetAt.After(set.SetAt) { |
| 102 | t.Fatalf("SetAt not monotonic: %v then %v", set.SetAt, set2.SetAt) |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | func TestGetPipelineRefusesTamperAndWrongApp(t *testing.T) { |
| 107 | s, priv, pub := pipelineTestHarness(t) |
| 108 | ctx := context.Background() |
| 109 | if _, err := SetPipeline(ctx, s, priv, "bae", "web", DefaultPipelineChannels(), ""); err != nil { |
| 110 | t.Fatal(err) |
| 111 | } |
| 112 | // Copy the object to another app's key: signed payload names bae/web, |
| 113 | // so it must refuse at bae/other (same trap production.go documents). |
| 114 | raw, _, err := s.Get(ctx, PipelineKey("bae", "web")) |
| 115 | if err != nil { |
| 116 | t.Fatal(err) |
| 117 | } |
| 118 | if _, err := s.Put(ctx, PipelineKey("bae", "other"), raw, ""); err != nil { |
| 119 | t.Fatal(err) |
| 120 | } |
| 121 | if _, err := GetPipeline(ctx, s, pub, "bae", "other"); err == nil { |
| 122 | t.Fatal("copied pipeline verified at the wrong app; want refusal") |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | func TestPipelinePromotionAllowed(t *testing.T) { |
| 127 | p := &Pipeline{Schema: 1, Channels: []PipelineChannel{ |
| 128 | {Name: "edge"}, {Name: "staging"}, {Name: "production"}}} |
| 129 | for _, tc := range []struct { |
| 130 | name, from, to string |
| 131 | wantErr string // substring; "" = allowed |
| 132 | }{ |
| 133 | {"canary always", "production", "canary/x", ""}, |
| 134 | {"fresh build enters at entry", "", "edge", ""}, |
| 135 | {"nothing else enters at entry", "staging", "edge", "-hotfix"}, |
| 136 | {"edge to staging", "edge", "staging", ""}, |
| 137 | {"staging to production", "staging", "production", ""}, |
| 138 | {"skip refused", "edge", "production", "staging"}, |
| 139 | {"unknown target names the list", "edge", "beta", "edge, staging, production"}, |
| 140 | } { |
| 141 | err := PipelinePromotionAllowed(p, tc.from, tc.to) |
| 142 | if tc.wantErr == "" && err != nil { |
| 143 | t.Errorf("%s: unexpected refusal %v", tc.name, err) |
| 144 | } |
| 145 | if tc.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tc.wantErr)) { |
| 146 | t.Errorf("%s: err = %v, want mention of %q", tc.name, err, tc.wantErr) |
| 147 | } |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | func TestPipelineSingleChannelPromoteNamesSetup(t *testing.T) { |
| 152 | p := &Pipeline{Schema: 1, Channels: DefaultPipelineChannels()} |
| 153 | err := PipelinePromotionAllowed(p, "edge", "production") |
| 154 | if err == nil || !strings.Contains(err.Error(), "carlos pipeline") { |
| 155 | t.Fatalf("one-channel refusal = %v; want it to point at carlos pipeline setup (spec §5)", err) |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | // pipelineTestHarness builds a fresh store and root key pair, copying |
| 160 | // exactly how production_test.go's tests build theirs (NewDirStore over a |
| 161 | // temp dir, testKeys for the signing pair) — there is no in-memory store in |
| 162 | // this package; NewDirStore(t.TempDir()) is the existing convention. |
| 163 | func pipelineTestHarness(t *testing.T) (Store, ed25519.PrivateKey, ed25519.PublicKey) { |
| 164 | t.Helper() |
| 165 | s := NewDirStore(t.TempDir()) |
| 166 | priv, pub := testKeys(t) |
| 167 | return s, priv, pub |
| 168 | } |
| 169 | |
| 170 | // VersionStage is VersionRung for a pipeline, and PromoteOnLadder must |
| 171 | // consult it — order gate, entry, hotfix bypass, and canary staying |
| 172 | // unconditional — the moment an app has one set (task 3, spec §3/§5). |
| 173 | func TestVersionStageAndPipelinePromote(t *testing.T) { |
| 174 | s, priv, pub := pipelineTestHarness(t) |
| 175 | ctx := context.Background() |
| 176 | chans := []PipelineChannel{{Name: "edge"}, {Name: "production"}} |
| 177 | if _, err := SetPipeline(ctx, s, priv, "bae", "web", chans, ""); err != nil { |
| 178 | t.Fatal(err) |
| 179 | } |
| 180 | shipRelease(t, s, "bae", "web", "v1", "linux-arm64", []byte("bin v1")) |
| 181 | // Fresh build promotes onto the entry channel. |
| 182 | if _, err := PromoteOnLadder(ctx, s, priv, "bae", "web", "edge", "v1", PromoteOptions{}); err != nil { |
| 183 | t.Fatalf("promote v1 onto entry: %v", err) |
| 184 | } |
| 185 | pipe, err := GetPipeline(ctx, s, pub, "bae", "web") |
| 186 | if err != nil || pipe == nil { |
| 187 | t.Fatalf("GetPipeline: %+v, %v", pipe, err) |
| 188 | } |
| 189 | if st, err := VersionStage(ctx, s, pub, "bae", "web", "v1", pipe); err != nil || st != "edge" { |
| 190 | t.Fatalf("VersionStage = %q, %v; want edge", st, err) |
| 191 | } |
| 192 | // edge -> production allowed; a second fresh promote onto production refused. |
| 193 | if _, err := PromoteOnLadder(ctx, s, priv, "bae", "web", "production", "v1", PromoteOptions{}); err != nil { |
| 194 | t.Fatalf("edge->production: %v", err) |
| 195 | } |
| 196 | shipRelease(t, s, "bae", "web", "v2", "linux-arm64", []byte("bin v2")) |
| 197 | if _, err := PromoteOnLadder(ctx, s, priv, "bae", "web", "production", "v2", PromoteOptions{}); err == nil { |
| 198 | t.Fatal("v2 (on no channel) promoted straight onto production; want order refusal") |
| 199 | } |
| 200 | // Hotfix bypasses order. |
| 201 | if _, err := PromoteOnLadder(ctx, s, priv, "bae", "web", "production", "v2", PromoteOptions{Hotfix: true}); err != nil { |
| 202 | t.Fatalf("hotfix: %v", err) |
| 203 | } |
| 204 | // Rung names outside the pipeline are refused for a pipelined app. |
| 205 | if _, err := PromoteOnLadder(ctx, s, priv, "bae", "web", "stable", "v2", PromoteOptions{}); err == nil { |
| 206 | t.Fatal("stable promoted on a pipelined app whose pipeline has no stable") |
| 207 | } |
| 208 | // Canary stays unconditional. |
| 209 | if _, err := PromoteOnLadder(ctx, s, priv, "bae", "web", "canary/x", "v2", PromoteOptions{}); err != nil { |
| 210 | t.Fatalf("canary on pipelined app: %v", err) |
| 211 | } |
| 212 | } |
| 213 | |
| 214 | // A grant names frozen rungs only (grant.go), so "production" is |
| 215 | // uncoverable: the scoped signer must refuse (spec §6), and -hotfix — which |
| 216 | // bypasses only the ORDER gate — can never widen a delegation's reach. |
| 217 | func TestScopedKeyCannotSignPipelineChannel(t *testing.T) { |
| 218 | s, priv, _ := pipelineTestHarness(t) |
| 219 | ctx := context.Background() |
| 220 | if _, err := SetPipeline(ctx, s, priv, "bae", "web", |
| 221 | []PipelineChannel{{Name: "edge"}, {Name: "production"}}, ""); err != nil { |
| 222 | t.Fatal(err) |
| 223 | } |
| 224 | rootPub := priv.Public().(ed25519.PublicKey) |
| 225 | key, blob := mintGrant(t, priv, "builder", []string{RungEdge}, time.Hour) |
| 226 | sg, err := ScopedSigner(key, blob, rootPub) |
| 227 | if err != nil { |
| 228 | t.Fatalf("ScopedSigner: %v", err) |
| 229 | } |
| 230 | shipRelease(t, s, "bae", "web", "v1", "linux-arm64", []byte("bin v1")) |
| 231 | if _, err := sg.PromoteOnLadder(ctx, s, "bae", "web", "production", "v1", PromoteOptions{Hotfix: true}); err == nil { |
| 232 | t.Fatal("scoped key signed a pipeline channel pointer; want refusal even with --hotfix") |
| 233 | } |
| 234 | } |
| 235 | |
| 236 | // TestPipelinePromoteNamesTheChannelAsTo: PromoteResult.To was Rung(channel) |
| 237 | // with no fallback, so a promotion onto an app-defined channel reported "" |
| 238 | // — an empty "to" in the API's answer and, worse, an empty To in the |
| 239 | // Promotion record written into the pointer's own audit trail, which is the |
| 240 | // permanent record of where a build went. From already carried the channel |
| 241 | // name on this path (VersionStage answers in channel names), so the trail |
| 242 | // read "edge -> " for the app's whole history. The fallback is the channel's |
| 243 | // own name; frozen rungs keep answering with the rung. |
| 244 | func TestPipelinePromoteNamesTheChannelAsTo(t *testing.T) { |
| 245 | s, priv, pub := pipelineTestHarness(t) |
| 246 | ctx := context.Background() |
| 247 | if _, err := SetPipeline(ctx, s, priv, "bae", "web", |
| 248 | []PipelineChannel{{Name: "edge"}, {Name: "production"}}, ""); err != nil { |
| 249 | t.Fatal(err) |
| 250 | } |
| 251 | shipRelease(t, s, "bae", "web", "v1", "linux-arm64", []byte("bin v1")) |
| 252 | if _, err := PromoteOnLadder(ctx, s, priv, "bae", "web", "edge", "v1", PromoteOptions{}); err != nil { |
| 253 | t.Fatalf("promote to edge: %v", err) |
| 254 | } |
| 255 | res, err := PromoteOnLadder(ctx, s, priv, "bae", "web", "production", "v1", PromoteOptions{Actor: "paul@keymail.dev"}) |
| 256 | if err != nil { |
| 257 | t.Fatalf("promote to production: %v", err) |
| 258 | } |
| 259 | if res.To != "production" { |
| 260 | t.Errorf("PromoteResult.To = %q, want %q — an app-defined channel names itself", res.To, "production") |
| 261 | } |
| 262 | if res.From != "edge" { |
| 263 | t.Errorf("PromoteResult.From = %q, want %q", res.From, "edge") |
| 264 | } |
| 265 | p, err := Channel(ctx, s, pub, "bae", "web", "production") |
| 266 | if err != nil { |
| 267 | t.Fatalf("read pointer: %v", err) |
| 268 | } |
| 269 | if len(p.Audit) == 0 { |
| 270 | t.Fatalf("pointer carries no audit trail: %+v", p) |
| 271 | } |
| 272 | if got := p.Audit[len(p.Audit)-1]; got.To != "production" || got.From != "edge" { |
| 273 | t.Errorf("promotion record = %+v, want from edge to production", got) |
| 274 | } |
| 275 | |
| 276 | // The repeat (a no-op) names the channel on BOTH sides: the build is on |
| 277 | // production, which is exactly what makes this a repeat, so reporting "" |
| 278 | // for where it sits would be the same empty answer one rung down. |
| 279 | again, err := PromoteOnLadder(ctx, s, priv, "bae", "web", "production", "v1", PromoteOptions{}) |
| 280 | if err != nil { |
| 281 | t.Fatalf("repeat promote: %v", err) |
| 282 | } |
| 283 | if !again.NoOp { |
| 284 | t.Errorf("repeat promote = %+v, want NoOp", again) |
| 285 | } |
| 286 | if again.From != "production" || again.To != "production" { |
| 287 | t.Errorf("repeat promote from/to = %q/%q, want production/production", again.From, again.To) |
| 288 | } |
| 289 | } |
| 290 | |
| 291 | // The frozen rungs are untouched: a legacy promote still reports the RUNG, |
| 292 | // which is not always the channel name (canary/x reports "canary"). |
| 293 | func TestLegacyPromoteStillReportsTheRung(t *testing.T) { |
| 294 | s, priv, _ := pipelineTestHarness(t) |
| 295 | ctx := context.Background() |
| 296 | shipRelease(t, s, "bae", "web", "v1", "linux-arm64", []byte("bin v1")) |
| 297 | res, err := PromoteOnLadder(ctx, s, priv, "bae", "web", "canary/x", "v1", PromoteOptions{}) |
| 298 | if err != nil { |
| 299 | t.Fatalf("promote to canary/x: %v", err) |
| 300 | } |
| 301 | if res.To != RungCanary { |
| 302 | t.Errorf("PromoteResult.To = %q, want the rung %q", res.To, RungCanary) |
| 303 | } |
| 304 | // A different build for edge: v1 is on canary now, and the legacy ladder |
| 305 | // only lets a build with no rung yet enter at edge. |
| 306 | shipRelease(t, s, "bae", "web", "v2", "linux-arm64", []byte("bin v2")) |
| 307 | res, err = PromoteOnLadder(ctx, s, priv, "bae", "web", "edge", "v2", PromoteOptions{}) |
| 308 | if err != nil { |
| 309 | t.Fatalf("promote to edge: %v", err) |
| 310 | } |
| 311 | if res.To != RungEdge { |
| 312 | t.Errorf("PromoteResult.To = %q, want the rung %q", res.To, RungEdge) |
| 313 | } |
| 314 | } |
| 315 | |