| 1 | package claimactivation |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "crypto/ed25519" |
| 6 | "crypto/rand" |
| 7 | "crypto/sha256" |
| 8 | "encoding/base64" |
| 9 | "encoding/hex" |
| 10 | "encoding/json" |
| 11 | "net/http" |
| 12 | "net/http/httptest" |
| 13 | "sort" |
| 14 | "strings" |
| 15 | "sync" |
| 16 | "testing" |
| 17 | |
| 18 | "github.com/carlosframework/platform/internal/hostactors" |
| 19 | "github.com/carlosframework/platform/internal/hostfloor" |
| 20 | "github.com/carlosframework/platform/internal/lease/leasetest" |
| 21 | "github.com/carlosframework/platform/internal/release" |
| 22 | ) |
| 23 | |
| 24 | const artifactsBucket = "carlos-trial-bucket" |
| 25 | |
| 26 | // s3Seen is one request the fake bucket answered. |
| 27 | type s3Seen struct{ Method, Key, IfNoneMatch, IfMatch string } |
| 28 | |
| 29 | // artifactsWorld is the trusted signing host against a real S3Store: the |
| 30 | // production client over leasetest's S3 server, which honours If-None-Match |
| 31 | // and If-Match. The roster is initialized and configured through the |
| 32 | // runner's own phases, and the console release is shipped and promoted |
| 33 | // through release.Ship and Signer.Promote with pointer digests on, as |
| 34 | // carlos ship and carlos promote leave it. |
| 35 | type artifactsWorld struct { |
| 36 | r Runner |
| 37 | store release.Store |
| 38 | model *leasetest.VersionedBucket |
| 39 | sumsKey ed25519.PrivateKey |
| 40 | mu sync.Mutex |
| 41 | seen []s3Seen |
| 42 | console []byte |
| 43 | router []byte |
| 44 | } |
| 45 | |
| 46 | func (w *artifactsWorld) requests() []s3Seen { |
| 47 | w.mu.Lock() |
| 48 | defer w.mu.Unlock() |
| 49 | return append([]s3Seen(nil), w.seen...) |
| 50 | } |
| 51 | |
| 52 | // mark is a point in the request log; writesSince lists every request |
| 53 | // after it that could change the bucket. |
| 54 | func (w *artifactsWorld) mark() int { return len(w.requests()) } |
| 55 | |
| 56 | func (w *artifactsWorld) writesSince(mark int) []s3Seen { |
| 57 | var out []s3Seen |
| 58 | for _, r := range w.requests()[mark:] { |
| 59 | if r.Method != http.MethodGet && r.Method != http.MethodHead { |
| 60 | out = append(out, r) |
| 61 | } |
| 62 | } |
| 63 | return out |
| 64 | } |
| 65 | |
| 66 | // fakeConsoleBuild stands in for release.ArtifactBuild over the fixture's |
| 67 | // console bytes, which are not Go binaries: "console build <v> stamp <tag>" |
| 68 | // is a build stamped <tag>. ArtifactBuild itself is tested on real binaries |
| 69 | // in internal/release, and the root-runner witness runs the production |
| 70 | // reader over a real console image. |
| 71 | func fakeConsoleBuild(b []byte) string { |
| 72 | _, stamp, ok := strings.Cut(string(b), " stamp ") |
| 73 | if !ok { |
| 74 | return "" |
| 75 | } |
| 76 | return stamp |
| 77 | } |
| 78 | |
| 79 | func sha(b []byte) string { s := sha256.Sum256(b); return hex.EncodeToString(s[:]) } |
| 80 | |
| 81 | func newArtifactsWorld(t *testing.T) *artifactsWorld { |
| 82 | t.Helper() |
| 83 | w := &artifactsWorld{model: leasetest.NewVersionedBucket(), console: []byte("console build abc1234 stamp v0.28.2"), router: []byte("carlos v0.28.2 linux-arm64")} |
| 84 | w.model.AddBucket(artifactsBucket) |
| 85 | next, _ := leasetest.NewS3Server(w.model) |
| 86 | server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { |
| 87 | _, key, _ := strings.Cut(strings.TrimPrefix(req.URL.Path, "/"), "/") |
| 88 | w.mu.Lock() |
| 89 | w.seen = append(w.seen, s3Seen{req.Method, key, req.Header.Get("If-None-Match"), req.Header.Get("If-Match")}) |
| 90 | w.mu.Unlock() |
| 91 | next.ServeHTTP(rw, req) |
| 92 | })) |
| 93 | t.Cleanup(server.Close) |
| 94 | for k, v := range map[string]string{ |
| 95 | "AWS_ACCESS_KEY_ID": "test-key", "AWS_SECRET_ACCESS_KEY": "test-secret", "AWS_SESSION_TOKEN": "", |
| 96 | "AWS_REGION": "eu-west-1", "AWS_EC2_METADATA_DISABLED": "true", |
| 97 | "AWS_CONFIG_FILE": "/nonexistent", "AWS_SHARED_CREDENTIALS_FILE": "/nonexistent", |
| 98 | "CARLOS_S3_ENDPOINT": server.URL, |
| 99 | } { |
| 100 | t.Setenv(k, v) |
| 101 | } |
| 102 | s3, err := release.NewS3StoreIn(t.Context(), artifactsBucket, "eu-west-1") |
| 103 | if err != nil { |
| 104 | t.Fatal(err) |
| 105 | } |
| 106 | w.store = s3 |
| 107 | pub, key, _ := ed25519.GenerateKey(rand.Reader) |
| 108 | sumsPub, sumsKey, _ := ed25519.GenerateKey(rand.Reader) |
| 109 | w.sumsKey = sumsKey |
| 110 | w.r = Runner{Directory: hostactors.Directory{Store: s3, PublicKey: pub, Bucket: artifactsBucket}, Signer: release.RootSigner(key), SumsKey: base64.StdEncoding.EncodeToString(sumsPub), ConsoleBuild: fakeConsoleBuild} |
| 111 | ctx := t.Context() |
| 112 | if _, err = w.r.Run(ctx, Plan{Phase: "initialize", Bucket: artifactsBucket}, false); err != nil { |
| 113 | t.Fatal(err) |
| 114 | } |
| 115 | if _, err = w.r.Run(ctx, Plan{Phase: "configure", Bucket: artifactsBucket, ExpectedRevision: 1, Regions: map[string]hostactors.Region{"ie": {Account: "123456789012", AWSRegion: "eu-west-1", Namespaces: []string{"platform"}, Pools: []string{"console", "cohort1"}}}}, false); err != nil { |
| 116 | t.Fatal(err) |
| 117 | } |
| 118 | if err = release.WriteDigestMarker(ctx, s3, w.r.Signer, "bab", "test"); err != nil { |
| 119 | t.Fatal(err) |
| 120 | } |
| 121 | w.ship(t, "abc1234", w.console, "") |
| 122 | if err = w.r.Signer.Promote(ctx, s3, "bab", "console", "canary/console", "abc1234"); err != nil { |
| 123 | t.Fatal(err) |
| 124 | } |
| 125 | return w |
| 126 | } |
| 127 | |
| 128 | func (w *artifactsWorld) ship(t *testing.T, version string, body []byte, program string) { |
| 129 | t.Helper() |
| 130 | m := release.Manifest{App: "console", Version: version, Kind: "binary", Program: program, Artifacts: map[string]release.Artifact{IdentityPlatform: {}}} |
| 131 | if err := release.Ship(t.Context(), w.store, "bab", m, map[string][]byte{IdentityPlatform: body}); err != nil { |
| 132 | t.Fatal(err) |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | func (w *artifactsWorld) revision(t *testing.T) uint64 { |
| 137 | t.Helper() |
| 138 | o, err := w.r.Directory.Read(t.Context()) |
| 139 | if err != nil { |
| 140 | t.Fatal(err) |
| 141 | } |
| 142 | return o.Roster.Revision |
| 143 | } |
| 144 | |
| 145 | // sums is a release's SHA256SUMS.txt and its .sig, signed by key, one line |
| 146 | // per file in name order, as the release recipe writes it. |
| 147 | func sums(key ed25519.PrivateKey, files map[string][]byte) (string, string) { |
| 148 | names := make([]string, 0, len(files)) |
| 149 | for name := range files { |
| 150 | names = append(names, name) |
| 151 | } |
| 152 | sort.Strings(names) |
| 153 | var b strings.Builder |
| 154 | for _, name := range names { |
| 155 | b.WriteString(sha(files[name]) + " " + name + "\n") |
| 156 | } |
| 157 | return b.String(), base64.StdEncoding.EncodeToString(ed25519.Sign(key, []byte(b.String()))) + "\n" |
| 158 | } |
| 159 | |
| 160 | // releaseFiles is a release's eight signed files, real-shaped: six carlos |
| 161 | // builds (Windows zipped) and the two debs, for version X.Y.Z. |
| 162 | func releaseFiles(version string, router []byte) map[string][]byte { |
| 163 | files := map[string][]byte{"carlos-linux-arm64": router} |
| 164 | for _, name := range []string{"carlos-darwin-amd64", "carlos-darwin-arm64", "carlos-linux-amd64", "carlos-windows-amd64.zip", "carlos-windows-arm64.zip", "carlos_" + version + "_amd64.deb", "carlos_" + version + "_arm64.deb"} { |
| 165 | files[name] = []byte(name + " " + version) |
| 166 | } |
| 167 | return files |
| 168 | } |
| 169 | |
| 170 | func (w *artifactsWorld) identityPlan(t *testing.T, version string) Plan { |
| 171 | text, sig := sums(w.sumsKey, releaseFiles("0.28.2", w.router)) |
| 172 | return Plan{Phase: "publish-identity", Bucket: artifactsBucket, ExpectedRevision: w.revision(t), Identity: &IdentityInput{ |
| 173 | ReleaseTag: "v0.28.2", MovesProtocol: 1, Sums: text, SumsSignature: sig, |
| 174 | Console: ConsoleRelease{Account: "bab", App: "console", Channel: "canary/console", Version: version}}} |
| 175 | } |
| 176 | |
| 177 | func TestSignPolicyWritesNothingAndSignsTheFloor(t *testing.T) { |
| 178 | w := newArtifactsWorld(t) |
| 179 | ctx := t.Context() |
| 180 | floor := hostfloor.Floor{Min: 1, MinRelease: "v0.28.2"} |
| 181 | p := Plan{Phase: "sign-policy", Bucket: artifactsBucket, ExpectedRevision: w.revision(t), Floor: &floor} |
| 182 | mark := w.mark() |
| 183 | preview, err := w.r.Run(ctx, p, true) |
| 184 | if err != nil { |
| 185 | t.Fatal(err) |
| 186 | } |
| 187 | if !preview.DryRun || len(preview.Writes) != 0 || preview.Output != nil || preview.OutputSHA256 != "" || len(preview.FloorSHA256) != 64 { |
| 188 | t.Fatalf("preview %+v", preview) |
| 189 | } |
| 190 | result, err := w.r.Run(ctx, p, false) |
| 191 | if err != nil { |
| 192 | t.Fatal(err) |
| 193 | } |
| 194 | if len(result.Writes) != 0 || result.FloorSHA256 != preview.FloorSHA256 || result.OutputSHA256 != hostfloor.Digest(result.Output) { |
| 195 | t.Fatalf("result %+v", result) |
| 196 | } |
| 197 | if got := w.writesSince(mark); len(got) != 0 { |
| 198 | t.Fatalf("sign-policy wrote to the bucket: %+v", got) |
| 199 | } |
| 200 | // The output is the file guard-arm reads: its parser, unmarshal then |
| 201 | // Policy.Verify against the deployment key and bucket. |
| 202 | var policy hostfloor.Policy |
| 203 | if err = json.Unmarshal(result.Output, &policy); err != nil || policy.Mode != "armed" { |
| 204 | t.Fatal("not an armed policy", err) |
| 205 | } |
| 206 | verified, err := policy.Verify(w.r.Directory.PublicKey, artifactsBucket) |
| 207 | if err != nil || verified.Floor != floor || verified.Digest != result.FloorSHA256 { |
| 208 | t.Fatal(verified, err) |
| 209 | } |
| 210 | // The JSON the command prints never carries the policy. |
| 211 | printed, _ := json.Marshal(result) |
| 212 | if bytes.Contains(printed, []byte("authorization")) { |
| 213 | t.Fatalf("policy embedded in the printed result: %s", printed) |
| 214 | } |
| 215 | |
| 216 | // Once the deployment is bound to a floor, only that floor is signed. |
| 217 | if err = hostfloor.Publish(ctx, w.store, w.r.Signer, artifactsBucket, floor); err != nil { |
| 218 | t.Fatal(err) |
| 219 | } |
| 220 | again, err := w.r.Run(ctx, p, false) |
| 221 | if err != nil || !bytes.Equal(again.Output, result.Output) { |
| 222 | t.Fatal("signing the published floor again", err) |
| 223 | } |
| 224 | other := hostfloor.Floor{Min: 1, MinRelease: "v0.28.3"} |
| 225 | p.Floor = &other |
| 226 | if _, err = w.r.Run(ctx, p, false); err == nil { |
| 227 | t.Fatal("signed a floor other than the published one") |
| 228 | } |
| 229 | } |
| 230 | |
| 231 | func TestSignPolicyRefusesAHalfPublishedOrOtherFloor(t *testing.T) { |
| 232 | floor := hostfloor.Floor{Min: 1, MinRelease: "v0.28.2"} |
| 233 | t.Run("authorization alone", func(t *testing.T) { |
| 234 | w := newArtifactsWorld(t) |
| 235 | _, auth, err := hostfloor.Authorize(w.r.Signer, artifactsBucket, floor) |
| 236 | if err != nil { |
| 237 | t.Fatal(err) |
| 238 | } |
| 239 | if _, err = w.store.Put(t.Context(), hostfloor.AuthKey, auth, ""); err != nil { |
| 240 | t.Fatal(err) |
| 241 | } |
| 242 | if _, err = w.r.Run(t.Context(), Plan{Phase: "sign-policy", Bucket: artifactsBucket, ExpectedRevision: w.revision(t), Floor: &floor}, false); err == nil { |
| 243 | t.Fatal("signed beside half a floor pair") |
| 244 | } |
| 245 | }) |
| 246 | t.Run("roster prepared for another floor", func(t *testing.T) { |
| 247 | w := newArtifactsWorld(t) |
| 248 | ctx := t.Context() |
| 249 | o, _ := w.r.Directory.Read(ctx) |
| 250 | next := hostactors.Clone(o.Roster) |
| 251 | next.Publication = "preparing" |
| 252 | next.FloorSHA256 = strings.Repeat("e", 64) |
| 253 | if _, err := w.r.Directory.Replace(ctx, w.r.Signer, o, next); err != nil { |
| 254 | t.Fatal(err) |
| 255 | } |
| 256 | if _, err := w.r.Run(ctx, Plan{Phase: "sign-policy", Bucket: artifactsBucket, ExpectedRevision: w.revision(t), Floor: &floor}, false); err == nil { |
| 257 | t.Fatal("signed a floor the roster is not preparing") |
| 258 | } |
| 259 | }) |
| 260 | t.Run("stale revision and bad floors", func(t *testing.T) { |
| 261 | w := newArtifactsWorld(t) |
| 262 | for _, p := range []Plan{ |
| 263 | {Phase: "sign-policy", Bucket: artifactsBucket, ExpectedRevision: w.revision(t) - 1, Floor: &floor}, |
| 264 | {Phase: "sign-policy", Bucket: artifactsBucket, ExpectedRevision: w.revision(t)}, |
| 265 | {Phase: "sign-policy", Bucket: artifactsBucket, ExpectedRevision: w.revision(t), Floor: &hostfloor.Floor{Min: 0, MinRelease: "v0.28.2"}}, |
| 266 | {Phase: "sign-policy", Bucket: artifactsBucket, ExpectedRevision: w.revision(t), Floor: &hostfloor.Floor{Min: 1, MinRelease: "26065378"}}, |
| 267 | } { |
| 268 | if _, err := w.r.Run(t.Context(), p, false); err == nil { |
| 269 | t.Fatal("accepted", p) |
| 270 | } |
| 271 | } |
| 272 | }) |
| 273 | } |
| 274 | |
| 275 | func TestPublishIdentityFromSignedInputsOnly(t *testing.T) { |
| 276 | w := newArtifactsWorld(t) |
| 277 | ctx := t.Context() |
| 278 | p := w.identityPlan(t, "abc1234") |
| 279 | before := w.mark() |
| 280 | preview, err := w.r.Run(ctx, p, true) |
| 281 | if err != nil { |
| 282 | t.Fatal(err) |
| 283 | } |
| 284 | if got := w.writesSince(before); len(got) != 0 { |
| 285 | t.Fatalf("dry run wrote: %+v", got) |
| 286 | } |
| 287 | if len(preview.Writes) != 1 || preview.Writes[0].Key != "control/system/router/releases/v0.28.2.json" || preview.Output != nil { |
| 288 | t.Fatalf("preview %+v", preview) |
| 289 | } |
| 290 | mark := w.mark() |
| 291 | result, err := w.r.Run(ctx, p, false) |
| 292 | if err != nil { |
| 293 | t.Fatal(err) |
| 294 | } |
| 295 | identityPuts := 0 |
| 296 | for _, r := range w.requests()[mark:] { |
| 297 | switch { |
| 298 | case r.Method == http.MethodPut && r.Key == result.IdentityKey: |
| 299 | identityPuts++ |
| 300 | if r.IfNoneMatch != "*" || r.IfMatch != "" { |
| 301 | t.Fatalf("identity PUT not create-only: %+v", r) |
| 302 | } |
| 303 | case r.Method != http.MethodGet && r.Method != http.MethodHead: |
| 304 | t.Fatalf("publish-identity sent %s %s", r.Method, r.Key) |
| 305 | } |
| 306 | } |
| 307 | if identityPuts != 1 || len(result.Writes) != 1 { |
| 308 | t.Fatal("identity writes", identityPuts, result.Writes) |
| 309 | } |
| 310 | stored, _, err := w.store.Get(ctx, result.IdentityKey) |
| 311 | if err != nil || !bytes.Equal(stored, result.Output) || result.OutputSHA256 != hostfloor.Digest(stored) { |
| 312 | t.Fatal("output is not the stored identity", err) |
| 313 | } |
| 314 | // The file is what guard-arm reads with --identity. |
| 315 | got, err := release.VerifySystemIdentity(w.r.Directory.PublicKey, result.Output) |
| 316 | if err != nil { |
| 317 | t.Fatal(err) |
| 318 | } |
| 319 | c := got.Current |
| 320 | if c.Version != "v0.28.2" || c.ReleaseTag != "v0.28.2" || c.Source != "release:v0.28.2" || c.MovesProtocol != 1 || |
| 321 | c.Artifacts[IdentityPlatform] != (release.SystemArtifact{Key: "bootstrap/router/v0.28.2/carlos-linux-arm64", SHA256: sha(w.router)}) || |
| 322 | c.ConsoleArtifacts[IdentityPlatform] != (release.SystemArtifact{Key: "bootstrap/console/v0.28.2/carlos-linux-arm64", SHA256: sha(w.console)}) || |
| 323 | len(c.Artifacts) != 1 || len(c.ConsoleArtifacts) != 1 { |
| 324 | t.Fatalf("identity names %+v", c) |
| 325 | } |
| 326 | floor := hostfloor.Verified{Floor: hostfloor.Floor{Min: 1, MinRelease: "v0.28.2"}} |
| 327 | if hostfloor.CheckImage(w.r.Directory.PublicKey, floor, result.Output, "v0.28.2", IdentityPlatform, sha(w.router)) != nil || |
| 328 | hostfloor.CheckComponent(w.r.Directory.PublicKey, floor, result.Output, "v0.28.2", IdentityPlatform, sha(w.console), "console") != nil { |
| 329 | t.Fatal("guard-arm's checks refuse the published identity") |
| 330 | } |
| 331 | |
| 332 | // The same plan again is a no-op that hands back the stored bytes. |
| 333 | p.ExpectedRevision = w.revision(t) |
| 334 | // Signed within the same second the bytes are identical, and the |
| 335 | // checked store reports the matching create as the resumed write; later |
| 336 | // they differ and PublishSystemIdentity keeps the stored identity. |
| 337 | // Either way the stored bytes are what comes back. |
| 338 | replay, err := w.r.Run(ctx, p, false) |
| 339 | if err != nil || len(replay.Writes) > 1 || !bytes.Equal(replay.Output, stored) { |
| 340 | t.Fatal("replay", replay, err) |
| 341 | } |
| 342 | if now, _, _ := w.store.Get(ctx, result.IdentityKey); !bytes.Equal(now, stored) { |
| 343 | t.Fatal("replay replaced the identity") |
| 344 | } |
| 345 | // Another console build under the same tag is refused, and nothing moves. |
| 346 | other := []byte("console build def5678 stamp v0.28.2") |
| 347 | w.ship(t, "def5678", other, ConsoleProgram) |
| 348 | if err = w.r.Signer.Promote(ctx, w.store, "bab", "console", "canary/console", "def5678"); err != nil { |
| 349 | t.Fatal(err) |
| 350 | } |
| 351 | p = w.identityPlan(t, "def5678") |
| 352 | mark = w.mark() |
| 353 | if _, err = w.r.Run(ctx, p, false); err == nil { |
| 354 | t.Fatal("a different identity replaced the published one") |
| 355 | } |
| 356 | if now, _, _ := w.store.Get(ctx, result.IdentityKey); !bytes.Equal(now, stored) { |
| 357 | t.Fatal("refused publish changed the identity") |
| 358 | } |
| 359 | for _, r := range w.writesSince(mark) { |
| 360 | if r.Key != result.IdentityKey || r.IfNoneMatch != "*" { |
| 361 | t.Fatalf("refused publish sent %+v", r) |
| 362 | } |
| 363 | } |
| 364 | // The older build stays selectable by version through the pointer's |
| 365 | // signed history digests. |
| 366 | p = w.identityPlan(t, "abc1234") |
| 367 | if replay, err = w.r.Run(ctx, p, false); err != nil || !bytes.Equal(replay.Output, stored) { |
| 368 | t.Fatal("history version", err) |
| 369 | } |
| 370 | } |
| 371 | |
| 372 | func TestPublishIdentityRefusesUnsignedOrWrongInputs(t *testing.T) { |
| 373 | for name, tc := range map[string]struct { |
| 374 | setup func(t *testing.T, w *artifactsWorld) |
| 375 | mutate func(w *artifactsWorld, p *Plan) |
| 376 | }{ |
| 377 | "sums signed by another key": {mutate: func(w *artifactsWorld, p *Plan) { |
| 378 | _, other, _ := ed25519.GenerateKey(rand.Reader) |
| 379 | p.Identity.Sums, p.Identity.SumsSignature = sums(other, releaseFiles("0.28.2", w.router)) |
| 380 | }}, |
| 381 | "sums changed after signing": {mutate: func(w *artifactsWorld, p *Plan) { |
| 382 | p.Identity.Sums = strings.Replace(p.Identity.Sums, sha(w.router), strings.Repeat("0", 64), 1) |
| 383 | }}, |
| 384 | "no linux-arm64 router": {mutate: func(w *artifactsWorld, p *Plan) { |
| 385 | files := releaseFiles("0.28.2", w.router) |
| 386 | delete(files, "carlos-linux-arm64") |
| 387 | p.Identity.Sums, p.Identity.SumsSignature = sums(w.sumsKey, files) |
| 388 | }}, |
| 389 | "console is a release file": {mutate: func(w *artifactsWorld, p *Plan) { |
| 390 | files := releaseFiles("0.28.2", w.router) |
| 391 | files["carlos-darwin-arm64"] = w.console |
| 392 | p.Identity.Sums, p.Identity.SumsSignature = sums(w.sumsKey, files) |
| 393 | }}, |
| 394 | // F3: a genuine, validly signed list for another release is not this |
| 395 | // tag's, whatever its router hash. |
| 396 | "v0.28.1's real-shaped sums under v0.28.2": {mutate: func(w *artifactsWorld, p *Plan) { |
| 397 | p.Identity.Sums, p.Identity.SumsSignature = sums(w.sumsKey, releaseFiles("0.28.1", w.router)) |
| 398 | }}, |
| 399 | "amd64 deb missing": {mutate: func(w *artifactsWorld, p *Plan) { |
| 400 | files := releaseFiles("0.28.2", w.router) |
| 401 | delete(files, "carlos_0.28.2_amd64.deb") |
| 402 | p.Identity.Sums, p.Identity.SumsSignature = sums(w.sumsKey, files) |
| 403 | }}, |
| 404 | "arm64 deb missing": {mutate: func(w *artifactsWorld, p *Plan) { |
| 405 | files := releaseFiles("0.28.2", w.router) |
| 406 | delete(files, "carlos_0.28.2_arm64.deb") |
| 407 | p.Identity.Sums, p.Identity.SumsSignature = sums(w.sumsKey, files) |
| 408 | }}, |
| 409 | "another release's deb beside": {mutate: func(w *artifactsWorld, p *Plan) { |
| 410 | files := releaseFiles("0.28.2", w.router) |
| 411 | files["carlos_0.28.1_arm64.deb"] = []byte("old deb") |
| 412 | p.Identity.Sums, p.Identity.SumsSignature = sums(w.sumsKey, files) |
| 413 | }}, |
| 414 | "debs for 0.28.20": {mutate: func(w *artifactsWorld, p *Plan) { |
| 415 | p.Identity.Sums, p.Identity.SumsSignature = sums(w.sumsKey, releaseFiles("0.28.20", w.router)) |
| 416 | }}, |
| 417 | "no sums key": {mutate: func(w *artifactsWorld, p *Plan) { w.r.SumsKey = "" }}, |
| 418 | "moves protocol zero": {mutate: func(w *artifactsWorld, p *Plan) { p.Identity.MovesProtocol = 0 }}, |
| 419 | "build as tag": {mutate: func(w *artifactsWorld, p *Plan) { p.Identity.ReleaseTag = "26065378" }}, |
| 420 | "version not on the channel": {mutate: func(w *artifactsWorld, p *Plan) { |
| 421 | p.Identity.Console.Version = "fff0000" |
| 422 | }}, |
| 423 | "other channel": {mutate: func(w *artifactsWorld, p *Plan) { p.Identity.Console.Channel = "stable/console" }}, |
| 424 | "other account": {mutate: func(w *artifactsWorld, p *Plan) { p.Identity.Console.Account = "bap" }}, |
| 425 | "no account": {mutate: func(w *artifactsWorld, p *Plan) { p.Identity.Console.Account = "" }}, |
| 426 | "pointer without digests": {setup: func(t *testing.T, w *artifactsWorld) { |
| 427 | if err := release.RemoveDigestMarker(t.Context(), w.store, "bab"); err != nil { |
| 428 | t.Fatal(err) |
| 429 | } |
| 430 | w.ship(t, "aaa1111", []byte("console build aaa1111 stamp v0.28.2"), "") |
| 431 | if err := w.r.Signer.Promote(t.Context(), w.store, "bab", "console", "edge/console", "aaa1111"); err != nil { |
| 432 | t.Fatal(err) |
| 433 | } |
| 434 | // The pointer verifies and names the version; it signs no digest. |
| 435 | ptr, err := release.Channel(t.Context(), w.store, w.r.Directory.PublicKey, "bab", "console", "edge/console") |
| 436 | if err != nil || ptr.Version != "aaa1111" || ptr.DigestOf("aaa1111") != "" { |
| 437 | t.Fatal("fixture pointer", ptr, err) |
| 438 | } |
| 439 | }, mutate: func(w *artifactsWorld, p *Plan) { |
| 440 | p.Identity.Console.Channel, p.Identity.Console.Version = "edge/console", "aaa1111" |
| 441 | }}, |
| 442 | "manifest swapped after promotion": {setup: func(t *testing.T, w *artifactsWorld) { |
| 443 | key := release.ManifestKey("bab", "console", "abc1234") |
| 444 | raw, _, err := w.store.Get(t.Context(), key) |
| 445 | if err != nil { |
| 446 | t.Fatal(err) |
| 447 | } |
| 448 | var m release.Manifest |
| 449 | _ = json.Unmarshal(raw, &m) |
| 450 | a := m.Artifacts[IdentityPlatform] |
| 451 | a.SHA256 = strings.Repeat("d", 64) |
| 452 | m.Artifacts[IdentityPlatform] = a |
| 453 | swapped, _ := json.Marshal(m) |
| 454 | w.model.Put(artifactsBucket, key, swapped) |
| 455 | }}, |
| 456 | "console stamped with its build sha": {setup: func(t *testing.T, w *artifactsWorld) { |
| 457 | w.ship(t, "ccc3333", []byte("console build ccc3333 stamp ccc3333"), ConsoleProgram) |
| 458 | if err := w.r.Signer.Promote(t.Context(), w.store, "bab", "console", "canary/console", "ccc3333"); err != nil { |
| 459 | t.Fatal(err) |
| 460 | } |
| 461 | }, mutate: func(w *artifactsWorld, p *Plan) { p.Identity.Console.Version = "ccc3333" }}, |
| 462 | "console with no stamp": {setup: func(t *testing.T, w *artifactsWorld) { |
| 463 | w.ship(t, "ddd4444", []byte("console build ddd4444"), ConsoleProgram) |
| 464 | if err := w.r.Signer.Promote(t.Context(), w.store, "bab", "console", "canary/console", "ddd4444"); err != nil { |
| 465 | t.Fatal(err) |
| 466 | } |
| 467 | }, mutate: func(w *artifactsWorld, p *Plan) { p.Identity.Console.Version = "ddd4444" }}, |
| 468 | "artefact swapped after promotion": {setup: func(t *testing.T, w *artifactsWorld) { |
| 469 | m, err := release.GetManifest(t.Context(), w.store, "bab", "console", "abc1234") |
| 470 | if err != nil { |
| 471 | t.Fatal(err) |
| 472 | } |
| 473 | w.model.Put(artifactsBucket, release.ArtifactKey("bab", "console", "abc1234", m.Artifacts[IdentityPlatform].File), []byte("console build evil stamp v0.28.2")) |
| 474 | }}, |
| 475 | "production stamp reader on fixture bytes": {mutate: func(w *artifactsWorld, p *Plan) { w.r.ConsoleBuild = nil }}, |
| 476 | "the CLI shipped as the console": {setup: func(t *testing.T, w *artifactsWorld) { |
| 477 | w.ship(t, "bbb2222", []byte("carlos cli stamp v0.28.2"), "github.com/carlosframework/platform") |
| 478 | if err := w.r.Signer.Promote(t.Context(), w.store, "bab", "console", "canary/console", "bbb2222"); err != nil { |
| 479 | t.Fatal(err) |
| 480 | } |
| 481 | }, mutate: func(w *artifactsWorld, p *Plan) { p.Identity.Console.Version = "bbb2222" }}, |
| 482 | } { |
| 483 | t.Run(name, func(t *testing.T) { |
| 484 | w := newArtifactsWorld(t) |
| 485 | if tc.setup != nil { |
| 486 | tc.setup(t, w) |
| 487 | } |
| 488 | p := w.identityPlan(t, "abc1234") |
| 489 | if tc.mutate != nil { |
| 490 | tc.mutate(w, &p) |
| 491 | } |
| 492 | mark := w.mark() |
| 493 | for _, dry := range []bool{true, false} { |
| 494 | result, err := w.r.Run(t.Context(), p, dry) |
| 495 | if err == nil { |
| 496 | t.Fatalf("accepted (dry %v): %+v", dry, result) |
| 497 | } |
| 498 | t.Logf("dry %v refused: %v", dry, err) |
| 499 | } |
| 500 | if got := w.writesSince(mark); len(got) != 0 { |
| 501 | t.Fatalf("refusal wrote: %+v", got) |
| 502 | } |
| 503 | }) |
| 504 | } |
| 505 | } |
| 506 | |