package console import ( "context" "errors" "testing" "time" "github.com/carlosframework/platform/internal/objstore" "github.com/carlosframework/platform/internal/release" ) // countingLister serves one small page from an in-memory key set and counts // how many times it was asked. The count is the assertion for "did a sweep // actually run", which a timestamp cannot answer reliably at test speed. type countingLister struct { keys []string calls int err error } func (l *countingLister) ListPage(_ context.Context, _, startAfter string, _ int32) ([]objstore.ScanObject, bool, error) { l.calls++ if l.err != nil { return nil, false, l.err } var objs []objstore.ScanObject for _, k := range l.keys { if k > startAfter { objs = append(objs, objstore.ScanObject{Key: k, ETag: "etag-" + k, Size: 10}) } } return objs, false, nil } // refusingLister fails the test if it is ever consulted. type refusingLister struct{ t *testing.T } func (l *refusingLister) ListPage(context.Context, string, string, int32) ([]objstore.ScanObject, bool, error) { l.t.Fatal("a bucket was listed that should never have been swept") return nil, false, nil } // enableDeploymentScanning turns scanning on for the whole deployment. // // Absent record = NOT enabled, so every fixture that arms a scanner and // expects a sweep needs this. It is idempotent: a second call finds the record // and leaves it alone. func enableDeploymentScanning(t *testing.T, fx *consoleFixture) { t.Helper() ctx := context.Background() if av, _, err := release.GetAntivirus(ctx, fx.rel); err != nil { t.Fatal(err) } else if av != nil { return } if _, err := release.PutAntivirus(ctx, fx.rel, &release.AntivirusSettings{ Enabled: true, Engine: "clamav", At: time.Now().UTC(), By: "test", }, ""); err != nil { t.Fatal(err) } } // enableStore writes a provisioned, scanning-enabled record for acct/app, and // turns the deployment switch on so a sweep can actually run. func enableStore(t *testing.T, fx *consoleFixture, acct, app string, enabled bool) { t.Helper() ctx := context.Background() enableDeploymentScanning(t, fx) rec := &objstore.Record{ Provisioned: &objstore.Provisioned{ Bucket: "carlos-" + acct + "-" + app, Region: "eu-west-1", }, } if enabled { rec.Scanning = &objstore.ScanBlock{ Requested: &objstore.ScanRequested{At: time.Now().UTC(), By: "m@example.com"}, Enabled: &objstore.ScanEnabled{Engine: "clamav", At: time.Now().UTC(), By: "op@example.com"}, } } if _, err := objstore.Put(ctx, fx.rel, acct, app, rec, ""); err != nil { t.Fatal(err) } } func withLister(fx *consoleFixture, l objstore.ObjectLister) { fx.srv.storeListerFor = func(context.Context, *objstore.Provisioned) (objstore.ObjectLister, error) { return l, nil } } // noSweeps fails the test if the pass gets as far as ASKING for a lister. // // refusingLister is a weaker assertion than it looks: RunSweep applies its own // refusals before it lists anything, so a pass that reached RunSweep with the // switch off would still never call ListPage — and scanPass only logs per-app // errors, so nothing would surface either. The seam is scanOneApp's last step // before RunSweep (scanpass.go), which makes it the earliest place a test can // tell "did not sweep" from "was not stopped". func noSweeps(t *testing.T, fx *consoleFixture) { t.Helper() fx.srv.storeListerFor = func(context.Context, *objstore.Provisioned) (objstore.ObjectLister, error) { t.Error("the pass went looking for a bucket to sweep") return &refusingLister{t: t}, nil } } // No scanner configured: the pass does nothing and touches no backend. This is // every deployment that has not opted in, which is all of them by default. func TestScanPassInertWithoutScanner(t *testing.T) { ctx := context.Background() fx := newConsoleFixture(t) enableStore(t, fx, fixtureAcct, fixtureApp, true) withLister(fx, &refusingLister{t: t}) if err := fx.srv.scanPass(ctx); err != nil { t.Fatal(err) } side, _, err := objstore.GetSidecar(ctx, fx.rel, fixtureAcct, fixtureApp) if err != nil { t.Fatal(err) } if side != nil { t.Fatalf("a deployment with no engine wrote coverage: %+v", side) } } // The pass sweeps only stores that are provisioned AND enabled. A store nobody // opted in for must not acquire a scan record as a side effect of living in // the same bucket as one that did. func TestScanPassSweepsOnlyEnabledStores(t *testing.T) { ctx := context.Background() fx := newConsoleFixture(t) fx.srv.cfg.StoreScanner = objstore.NewMemScanner() enableStore(t, fx, fixtureAcct, fixtureApp, true) enableStore(t, fx, fixtureAcct, "unopted", false) withLister(fx, &countingLister{keys: []string{"a.txt", "b.txt"}}) if err := fx.srv.scanPass(ctx); err != nil { t.Fatal(err) } side, _, err := objstore.GetSidecar(ctx, fx.rel, fixtureAcct, fixtureApp) if err != nil { t.Fatal(err) } if side == nil || side.LastComplete == nil || side.LastComplete.Objects != 2 { t.Fatalf("the enabled store was not swept: %+v", side) } other, _, err := objstore.GetSidecar(ctx, fx.rel, fixtureAcct, "unopted") if err != nil { t.Fatal(err) } if other != nil { t.Fatalf("a store nobody opted in for was swept: %+v", other) } } // A completed sweep is not restarted before the interval, so LastComplete // stays stable long enough to quote in an audit answer instead of being // rebuilt from scratch every hour. func TestScanPassHonoursReSweepInterval(t *testing.T) { ctx := context.Background() fx := newConsoleFixture(t) fx.srv.cfg.StoreScanner = objstore.NewMemScanner() fx.srv.cfg.ScanInterval = time.Hour enableStore(t, fx, fixtureAcct, fixtureApp, true) l := &countingLister{keys: []string{"a.txt"}} withLister(fx, l) if err := fx.srv.scanPass(ctx); err != nil { t.Fatal(err) } if l.calls != 1 { t.Fatalf("first pass listed %d times, want 1", l.calls) } if err := fx.srv.scanPass(ctx); err != nil { t.Fatal(err) } if l.calls != 1 { t.Fatalf("a completed sweep was restarted inside the interval: %d listings", l.calls) } // Once the interval has passed, it sweeps again — otherwise coverage // would be a one-time claim that ages silently. fx.srv.cfg.ScanInterval = time.Nanosecond if err := fx.srv.scanPass(ctx); err != nil { t.Fatal(err) } if l.calls != 2 { t.Fatalf("a stale sweep was not restarted: %d listings", l.calls) } } // One app's failure does not stop the rest. A pass that gave up on the first // unreachable bucket would leave every store after it in the listing order // permanently unswept — an ordering-dependent coverage gap, which is the worst // kind to diagnose. func TestScanPassContinuesPastOneAppsFailure(t *testing.T) { ctx := context.Background() fx := newConsoleFixture(t) fx.srv.cfg.StoreScanner = objstore.NewMemScanner() enableStore(t, fx, fixtureAcct, "aaa-broken", true) enableStore(t, fx, fixtureAcct, fixtureApp, true) good := &countingLister{keys: []string{"a.txt"}} fx.srv.storeListerFor = func(_ context.Context, p *objstore.Provisioned) (objstore.ObjectLister, error) { if p.Bucket == "carlos-"+fixtureAcct+"-aaa-broken" { return nil, errors.New("endpoint down") } return good, nil } if err := fx.srv.scanPass(ctx); err != nil { t.Fatalf("one app's failure failed the whole pass: %v", err) } side, _, err := objstore.GetSidecar(ctx, fx.rel, fixtureAcct, fixtureApp) if err != nil { t.Fatal(err) } if side == nil || side.LastComplete == nil { t.Fatal("a healthy store after a broken one was never swept") } } // A sweep still in flight ignores the interval entirely. This is the only way // a bucket larger than one budget ever finishes: if a part-done sweep waited // out the re-sweep floor, a big store would advance one page a day. func TestScanPassAlwaysContinuesAnInFlightSweep(t *testing.T) { ctx := context.Background() fx := newConsoleFixture(t) fx.srv.cfg.ScanInterval = 365 * 24 * time.Hour side := &objstore.ScanSidecar{ Current: &objstore.Sweep{StartedAt: time.Now().UTC(), At: time.Now().UTC(), After: "a.txt"}, } if _, err := objstore.PutSidecar(ctx, fx.rel, fixtureAcct, fixtureApp, side, ""); err != nil { t.Fatal(err) } rec, _, err := objstore.Get(ctx, fx.rel, fixtureAcct, fixtureApp) if err != nil { t.Fatal(err) } due, err := fx.srv.storeIsDue(ctx, rec, fixtureAcct, fixtureApp, fx.srv.scanInterval()) if err != nil { t.Fatal(err) } if !due { t.Fatal("an in-flight sweep was parked until the re-sweep interval") } } // The on-demand verb refuses plainly when no engine is configured, rather than // reporting a sweep that never happened. func TestSweepNowRefusesWithoutScanner(t *testing.T) { fx := newConsoleFixture(t) enableStore(t, fx, fixtureAcct, fixtureApp, true) _, err := fx.srv.sweepNow(context.Background(), fixtureAcct, fixtureApp) if !errors.Is(err, errNoScanner) { t.Fatalf("err = %v, want errNoScanner", err) } } // A store re-attested from --client-encrypted back to --plaintext must be // swept again. // // The stale not_applicable sidecar outlives the record change, and reading // only the sidecar meant such a store was never swept by any tick again — for // as long as it existed — while `store status` reported "scanning: clamav". // The RECORD is the authority on the attestation; the sidecar is a cache of a // decision made from it. func TestScanPassResweepsAfterReattestationToPlaintext(t *testing.T) { ctx := context.Background() fx := newConsoleFixture(t) fx.srv.cfg.StoreScanner = objstore.NewMemScanner() enableStore(t, fx, fixtureAcct, fixtureApp, true) // record says plaintext // The sidecar still carries the answer from when it was encrypted. if _, err := objstore.PutSidecar(ctx, fx.rel, fixtureAcct, fixtureApp, &objstore.ScanSidecar{NotApplicable: true}, ""); err != nil { t.Fatal(err) } l := &countingLister{keys: []string{"a.txt"}} withLister(fx, l) if err := fx.srv.scanPass(ctx); err != nil { t.Fatal(err) } if l.calls == 0 { t.Fatal("a store re-attested as plaintext was never swept again") } side, _, err := objstore.GetSidecar(ctx, fx.rel, fixtureAcct, fixtureApp) if err != nil { t.Fatal(err) } if side.NotApplicable { t.Fatal("the stale not_applicable flag survived a sweep of a plaintext store") } if side.LastComplete == nil || side.LastComplete.Objects != 1 { t.Fatalf("coverage = %+v", side.LastComplete) } } // The mirror: a store the record still says is encrypted stays skipped, and // costs no listing at all. func TestScanPassStillSkipsEncryptedStore(t *testing.T) { ctx := context.Background() fx := newConsoleFixture(t) fx.srv.cfg.StoreScanner = objstore.NewMemScanner() enableStore(t, fx, fixtureAcct, fixtureApp, true) rec, etag, err := objstore.Get(ctx, fx.rel, fixtureAcct, fixtureApp) if err != nil { t.Fatal(err) } rec.Scanning.Requested.Encrypted = true if _, err := objstore.Put(ctx, fx.rel, fixtureAcct, fixtureApp, rec, etag); err != nil { t.Fatal(err) } if _, err := objstore.PutSidecar(ctx, fx.rel, fixtureAcct, fixtureApp, &objstore.ScanSidecar{NotApplicable: true}, ""); err != nil { t.Fatal(err) } withLister(fx, &refusingLister{t: t}) if err := fx.srv.scanPass(ctx); err != nil { t.Fatal(err) } } // The seed carries forward an intent the operator already expressed. Refusing // to seed would not withhold consent — it would silently withdraw consent // already given. func TestScanPassSeedsRecordOnFirstTick(t *testing.T) { ctx := context.Background() fx := newConsoleFixture(t) fx.srv.cfg.StoreScanner = objstore.NewMemScanner() fx.srv.cfg.ScanSeedFromDeprecatedEnv = true withLister(fx, &countingLister{}) if err := fx.srv.scanPass(ctx); err != nil { t.Fatal(err) } av, _, err := release.GetAntivirus(ctx, fx.rel) if err != nil || av == nil || !av.Enabled { t.Fatalf("record after first tick = %+v, %v", av, err) } if av.By != "migration from CARLOS_STORE_SCANNER" { t.Fatalf("by = %q, want the migration to be legible in the record", av.By) } } // An operator who runs `disable` is not overridden on the next restart. The // seed is create-only, so it only ever fires when there is no record. func TestScanPassSeedDoesNotOverrideExistingRecord(t *testing.T) { ctx := context.Background() fx := newConsoleFixture(t) fx.srv.cfg.StoreScanner = objstore.NewMemScanner() fx.srv.cfg.ScanSeedFromDeprecatedEnv = true if _, err := release.PutAntivirus(ctx, fx.rel, &release.AntivirusSettings{ Enabled: false, Engine: "clamav", At: time.Now().UTC(), By: "op@example.com", }, ""); err != nil { t.Fatal(err) } withLister(fx, &refusingLister{t: t}) if err := fx.srv.scanPass(ctx); err != nil { t.Fatal(err) } av, _, err := release.GetAntivirus(ctx, fx.rel) if err != nil || av == nil || av.Enabled { t.Fatalf("a deliberate disable was overridden by the seed: %+v, %v", av, err) } } // Without the deprecated variable there is no seed, and nothing scans until an // operator enables it. func TestScanPassDoesNotSeedWithoutTheDeprecatedVar(t *testing.T) { ctx := context.Background() fx := newConsoleFixture(t) fx.srv.cfg.StoreScanner = objstore.NewMemScanner() enableStoreRecordOnly(t, fx, fixtureAcct, fixtureApp) withLister(fx, &refusingLister{t: t}) if err := fx.srv.scanPass(ctx); err != nil { t.Fatal(err) } if av, _, err := release.GetAntivirus(ctx, fx.rel); err != nil || av != nil { t.Fatalf("a record appeared with no migration to justify it: %+v, %v", av, err) } } // A read error is neither enabled nor disabled: it is an error. Seeding on a // failed read would enable scanning on a deployment that had disabled it. func TestScanPassSeedIsSilentOnReadError(t *testing.T) { ctx := context.Background() fx := newConsoleFixture(t) fx.srv.cfg.StoreScanner = objstore.NewMemScanner() fx.srv.cfg.ScanSeedFromDeprecatedEnv = true // A record this build cannot read at all. if _, err := fx.rel.Put(ctx, release.SystemKey(release.AntivirusName), []byte(`{"schema":99,"enabled":true}`), ""); err != nil { t.Fatal(err) } withLister(fx, &refusingLister{t: t}) if err := fx.srv.scanPass(ctx); err == nil { t.Fatal("an unreadable record was treated as a state rather than an error") } } // Disabled means the tick does nothing — not that there is no goroutine, which // is what would make enabling need a restart. func TestScanPassDoesNothingWhenDisabled(t *testing.T) { ctx := context.Background() fx := newConsoleFixture(t) fx.srv.cfg.StoreScanner = objstore.NewMemScanner() enableStoreRecordOnly(t, fx, fixtureAcct, fixtureApp) if _, err := release.PutAntivirus(ctx, fx.rel, &release.AntivirusSettings{ Enabled: false, Engine: "clamav", At: time.Now().UTC(), By: "op@example.com", }, ""); err != nil { t.Fatal(err) } noSweeps(t, fx) if err := fx.srv.scanPass(ctx); err != nil { t.Fatal(err) } } // The seed is create-only, and losing that race is NOT the same as winning it. // // An operator running `disable` in the same instant as a migrating console's // first tick is the case: the console read no record, the operator's write // landed, and the seed came back ErrCASConflict. Treating the conflict as // success — "somebody else got there first, so it must be on" — would run a // whole pass over tenant bytes against the switch that operator had just // turned off. The winner is read, not assumed. func TestScanPassSeedYieldsToAConcurrentDisable(t *testing.T) { ctx := context.Background() fx := newConsoleFixture(t) fx.srv.cfg.StoreScanner = objstore.NewMemScanner() fx.srv.cfg.ScanSeedFromDeprecatedEnv = true enableStoreRecordOnly(t, fx, fixtureAcct, fixtureApp) noSweeps(t, fx) // The operator's write lands between the console's read and its own // create-only write. fx.srv.cfg.Releases = &seedRaceStore{Store: fx.rel, race: func() { if _, err := release.PutAntivirus(ctx, fx.rel, &release.AntivirusSettings{ Enabled: false, Engine: "clamav", At: time.Now().UTC(), By: "op@example.com", }, ""); err != nil { t.Error(err) } }} if err := fx.srv.scanPass(ctx); err != nil { t.Fatal(err) } av, _, err := release.GetAntivirus(ctx, fx.rel) if err != nil || av == nil || av.Enabled { t.Fatalf("the operator's disable was overwritten or ignored: %+v, %v", av, err) } } // seedRaceStore runs race() once, immediately after the first read of the // antivirus record — the instant between the seed's read and its write. type seedRaceStore struct { release.Store race func() seen bool } func (s *seedRaceStore) Get(ctx context.Context, key string) ([]byte, string, error) { body, etag, err := s.Store.Get(ctx, key) if !s.seen && key == release.SystemKey(release.AntivirusName) { s.seen = true s.race() } return body, etag, err } // enableStoreRecordOnly writes the per-app record without touching the // deployment switch, for the tests that are about the switch itself. func enableStoreRecordOnly(t *testing.T, fx *consoleFixture, acct, app string) { t.Helper() rec := &objstore.Record{ Provisioned: &objstore.Provisioned{Bucket: "carlos-" + acct + "-" + app, Region: "eu-west-1"}, Scanning: &objstore.ScanBlock{ Requested: &objstore.ScanRequested{At: time.Now().UTC(), By: "m@example.com"}, Enabled: &objstore.ScanEnabled{Engine: "clamav", At: time.Now().UTC(), By: "op@example.com"}, }, } if _, err := objstore.Put(context.Background(), fx.rel, acct, app, rec, ""); err != nil { t.Fatal(err) } }