| new file mode 100644 |
| index 000000000..a0ed7b0b5 |
| --- /dev/null |
| +++ b/internal/objstore/scanblock_test.go |
| @@ -0,0 +1,153 @@ |
| +package objstore |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/ed25519" |
| + "crypto/rand" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// The scanning block round-trips, and its three sub-blocks are null-or-present |
| +// so a client can tell "requested, waiting for an operator" from "enabled" |
| +// without inspecting fields — the same tri-state the store's own blocks give. |
| +func TestScanBlockRoundTrip(t *testing.T) { |
| + now := time.Now().UTC().Truncate(time.Second) |
| + rec := &Record{ |
| + App: "titogo", |
| + Scanning: &ScanBlock{ |
| + Requested: &ScanRequested{At: now, By: "m@example.com"}, |
| + Enabled: &ScanEnabled{Engine: "clamav", At: now, By: "op@example.com"}, |
| + Enforce: &DenyTag{Key: TagVerdict, Value: string(VerdictInfected)}, |
| + }, |
| + } |
| + body, err := json.Marshal(rec) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var back Record |
| + if err := json.Unmarshal(body, &back); err != nil { |
| + t.Fatal(err) |
| + } |
| + if back.Scanning == nil || back.Scanning.Enabled == nil { |
| + t.Fatalf("scanning.enabled did not round-trip: %s", body) |
| + } |
| + if back.Scanning.Enabled.Engine != "clamav" { |
| + t.Fatalf("engine = %q, want clamav", back.Scanning.Enabled.Engine) |
| + } |
| + if back.Scanning.Enforce == nil || back.Scanning.Enforce.Value != string(VerdictInfected) { |
| + t.Fatalf("enforce did not round-trip: %s", body) |
| + } |
| +} |
| + |
| +// An absent scanning block stays absent. Every deployment that never touches |
| +// scanning must not grow a null key in every store record it owns. |
| +func TestScanBlockOmittedWhenAbsent(t *testing.T) { |
| + body, err := json.Marshal(&Record{App: "titogo"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if bytes.Contains(body, []byte("scanning")) { |
| + t.Fatalf("an absent scanning block leaked into the JSON: %s", body) |
| + } |
| +} |
| + |
| +// The attestation is a real field, not an inference: a store whose objects |
| +// arrive already encrypted cannot be scanned, and the record has to say so |
| +// because nothing can tell by looking (spec §2.2). |
| +func TestScanRequestedCarriesEncryptedAttestation(t *testing.T) { |
| + body, err := json.Marshal(&ScanRequested{ |
| + At: time.Now().UTC(), By: "m@example.com", Encrypted: true, |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var back ScanRequested |
| + if err := json.Unmarshal(body, &back); err != nil { |
| + t.Fatal(err) |
| + } |
| + if !back.Encrypted { |
| + t.Fatalf("the encrypted attestation did not round-trip: %s", body) |
| + } |
| +} |
| + |
| +// The scanning block is ADDITIVE. release.PurgeApp parses this shape with its |
| +// own local struct — it cannot import objstore, because objstore imports |
| +// release — and must keep refusing on grant/provisioned while ignoring keys it |
| +// has never heard of. objstore.go's PARSER LIST comment claims exactly this, |
| +// and TestPurgeGuardSeesOurRecord pins the two names without ever writing a |
| +// scanning key, so without this test the additive half of the claim is |
| +// unasserted. |
| +// |
| +// Driven through release.PurgeApp itself rather than an extracted predicate: |
| +// the guard is inline in PurgeApp, and pulling it out to make it callable |
| +// would refactor the fleet-wide purge path for the sake of a test. |
| +func TestPurgeGuardIgnoresScanningBlock(t *testing.T) { |
| + ctx := context.Background() |
| + _, pub := testSealKeypair(t) |
| + relPub, _, err := ed25519.GenerateKey(rand.Reader) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + backdated := time.Now().Add(-2 * release.TrashCooloff) |
| + |
| + // A record naming backend estate still blocks the purge with a scanning |
| + // block sitting beside it — the new key must not shadow the guard. |
| + t.Run("provisioned-plus-scanning-still-blocks", func(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + p := NewMemProvisioner("eu-west-1") |
| + rotateFixture(t, s, pub, p) |
| + |
| + stored, etag, err := Get(ctx, s, "bab", "titogo") |
| + if err != nil || stored == nil { |
| + t.Fatalf("re-read: %+v, %v", stored, err) |
| + } |
| + stored.Scanning = &ScanBlock{ |
| + Enabled: &ScanEnabled{Engine: "clamav", At: time.Now().UTC(), By: "op@example.com"}, |
| + } |
| + if _, err := Put(ctx, s, "bab", "titogo", stored, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := release.TrashApp(ctx, s, "bab", "titogo", "paul@keymail.dev", backdated); err != nil { |
| + t.Fatal(err) |
| + } |
| + err = release.PurgeApp(ctx, s, relPub, "bab", "titogo", time.Now()) |
| + if !errors.Is(err, release.ErrStoreProvisioned) { |
| + t.Fatalf("PurgeApp over provisioned+scanning = %v, want release.ErrStoreProvisioned", err) |
| + } |
| + }) |
| + |
| + // The other direction, and the one that proves the key is genuinely |
| + // ignored rather than accidentally load-bearing: scanning alone names no |
| + // backend estate, so it must not block a purge. |
| + t.Run("scanning-alone-names-no-estate", func(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + declare(t, s, "bab", "titogo", "TITO_S3") |
| + |
| + stored, etag, err := Get(ctx, s, "bab", "titogo") |
| + if err != nil || stored == nil { |
| + t.Fatalf("re-read: %+v, %v", stored, err) |
| + } |
| + stored.Scanning = &ScanBlock{ |
| + Requested: &ScanRequested{At: time.Now().UTC(), By: "m@example.com"}, |
| + } |
| + if _, err := Put(ctx, s, "bab", "titogo", stored, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := release.TrashApp(ctx, s, "bab", "titogo", "paul@keymail.dev", backdated); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := release.PurgeApp(ctx, s, relPub, "bab", "titogo", time.Now()); err != nil { |
| + t.Fatalf("PurgeApp over a declared+scanning record: %v", err) |
| + } |
| + if _, _, err := s.Get(ctx, Key("bab", "titogo")); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("store.json survived the purge: %v", err) |
| + } |
| + }) |
| +} |