| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "testing" |
| 7 | "time" |
| 8 | |
| 9 | "github.com/carlosframework/platform/internal/objstore" |
| 10 | "github.com/carlosframework/platform/internal/release" |
| 11 | ) |
| 12 | |
| 13 | // countingLister serves one small page from an in-memory key set and counts |
| 14 | // how many times it was asked. The count is the assertion for "did a sweep |
| 15 | // actually run", which a timestamp cannot answer reliably at test speed. |
| 16 | type countingLister struct { |
| 17 | keys []string |
| 18 | calls int |
| 19 | err error |
| 20 | } |
| 21 | |
| 22 | func (l *countingLister) ListPage(_ context.Context, _, startAfter string, _ int32) ([]objstore.ScanObject, bool, error) { |
| 23 | l.calls++ |
| 24 | if l.err != nil { |
| 25 | return nil, false, l.err |
| 26 | } |
| 27 | var objs []objstore.ScanObject |
| 28 | for _, k := range l.keys { |
| 29 | if k > startAfter { |
| 30 | objs = append(objs, objstore.ScanObject{Key: k, ETag: "etag-" + k, Size: 10}) |
| 31 | } |
| 32 | } |
| 33 | return objs, false, nil |
| 34 | } |
| 35 | |
| 36 | // refusingLister fails the test if it is ever consulted. |
| 37 | type refusingLister struct{ t *testing.T } |
| 38 | |
| 39 | func (l *refusingLister) ListPage(context.Context, string, string, int32) ([]objstore.ScanObject, bool, error) { |
| 40 | l.t.Fatal("a bucket was listed that should never have been swept") |
| 41 | return nil, false, nil |
| 42 | } |
| 43 | |
| 44 | // enableDeploymentScanning turns scanning on for the whole deployment. |
| 45 | // |
| 46 | // Absent record = NOT enabled, so every fixture that arms a scanner and |
| 47 | // expects a sweep needs this. It is idempotent: a second call finds the record |
| 48 | // and leaves it alone. |
| 49 | func enableDeploymentScanning(t *testing.T, fx *consoleFixture) { |
| 50 | t.Helper() |
| 51 | ctx := context.Background() |
| 52 | if av, _, err := release.GetAntivirus(ctx, fx.rel); err != nil { |
| 53 | t.Fatal(err) |
| 54 | } else if av != nil { |
| 55 | return |
| 56 | } |
| 57 | if _, err := release.PutAntivirus(ctx, fx.rel, &release.AntivirusSettings{ |
| 58 | Enabled: true, Engine: "clamav", At: time.Now().UTC(), By: "test", |
| 59 | }, ""); err != nil { |
| 60 | t.Fatal(err) |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | // enableStore writes a provisioned, scanning-enabled record for acct/app, and |
| 65 | // turns the deployment switch on so a sweep can actually run. |
| 66 | func enableStore(t *testing.T, fx *consoleFixture, acct, app string, enabled bool) { |
| 67 | t.Helper() |
| 68 | ctx := context.Background() |
| 69 | enableDeploymentScanning(t, fx) |
| 70 | rec := &objstore.Record{ |
| 71 | Provisioned: &objstore.Provisioned{ |
| 72 | Bucket: "carlos-" + acct + "-" + app, |
| 73 | Region: "eu-west-1", |
| 74 | }, |
| 75 | } |
| 76 | if enabled { |
| 77 | rec.Scanning = &objstore.ScanBlock{ |
| 78 | Requested: &objstore.ScanRequested{At: time.Now().UTC(), By: "m@example.com"}, |
| 79 | Enabled: &objstore.ScanEnabled{Engine: "clamav", At: time.Now().UTC(), By: "op@example.com"}, |
| 80 | } |
| 81 | } |
| 82 | if _, err := objstore.Put(ctx, fx.rel, acct, app, rec, ""); err != nil { |
| 83 | t.Fatal(err) |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | func withLister(fx *consoleFixture, l objstore.ObjectLister) { |
| 88 | fx.srv.storeListerFor = func(context.Context, *objstore.Provisioned) (objstore.ObjectLister, error) { |
| 89 | return l, nil |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | // noSweeps fails the test if the pass gets as far as ASKING for a lister. |
| 94 | // |
| 95 | // refusingLister is a weaker assertion than it looks: RunSweep applies its own |
| 96 | // refusals before it lists anything, so a pass that reached RunSweep with the |
| 97 | // switch off would still never call ListPage — and scanPass only logs per-app |
| 98 | // errors, so nothing would surface either. The seam is scanOneApp's last step |
| 99 | // before RunSweep (scanpass.go), which makes it the earliest place a test can |
| 100 | // tell "did not sweep" from "was not stopped". |
| 101 | func noSweeps(t *testing.T, fx *consoleFixture) { |
| 102 | t.Helper() |
| 103 | fx.srv.storeListerFor = func(context.Context, *objstore.Provisioned) (objstore.ObjectLister, error) { |
| 104 | t.Error("the pass went looking for a bucket to sweep") |
| 105 | return &refusingLister{t: t}, nil |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | // No scanner configured: the pass does nothing and touches no backend. This is |
| 110 | // every deployment that has not opted in, which is all of them by default. |
| 111 | func TestScanPassInertWithoutScanner(t *testing.T) { |
| 112 | ctx := context.Background() |
| 113 | fx := newConsoleFixture(t) |
| 114 | enableStore(t, fx, fixtureAcct, fixtureApp, true) |
| 115 | withLister(fx, &refusingLister{t: t}) |
| 116 | |
| 117 | if err := fx.srv.scanPass(ctx); err != nil { |
| 118 | t.Fatal(err) |
| 119 | } |
| 120 | side, _, err := objstore.GetSidecar(ctx, fx.rel, fixtureAcct, fixtureApp) |
| 121 | if err != nil { |
| 122 | t.Fatal(err) |
| 123 | } |
| 124 | if side != nil { |
| 125 | t.Fatalf("a deployment with no engine wrote coverage: %+v", side) |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | // The pass sweeps only stores that are provisioned AND enabled. A store nobody |
| 130 | // opted in for must not acquire a scan record as a side effect of living in |
| 131 | // the same bucket as one that did. |
| 132 | func TestScanPassSweepsOnlyEnabledStores(t *testing.T) { |
| 133 | ctx := context.Background() |
| 134 | fx := newConsoleFixture(t) |
| 135 | fx.srv.cfg.StoreScanner = objstore.NewMemScanner() |
| 136 | |
| 137 | enableStore(t, fx, fixtureAcct, fixtureApp, true) |
| 138 | enableStore(t, fx, fixtureAcct, "unopted", false) |
| 139 | withLister(fx, &countingLister{keys: []string{"a.txt", "b.txt"}}) |
| 140 | |
| 141 | if err := fx.srv.scanPass(ctx); err != nil { |
| 142 | t.Fatal(err) |
| 143 | } |
| 144 | |
| 145 | side, _, err := objstore.GetSidecar(ctx, fx.rel, fixtureAcct, fixtureApp) |
| 146 | if err != nil { |
| 147 | t.Fatal(err) |
| 148 | } |
| 149 | if side == nil || side.LastComplete == nil || side.LastComplete.Objects != 2 { |
| 150 | t.Fatalf("the enabled store was not swept: %+v", side) |
| 151 | } |
| 152 | |
| 153 | other, _, err := objstore.GetSidecar(ctx, fx.rel, fixtureAcct, "unopted") |
| 154 | if err != nil { |
| 155 | t.Fatal(err) |
| 156 | } |
| 157 | if other != nil { |
| 158 | t.Fatalf("a store nobody opted in for was swept: %+v", other) |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | // A completed sweep is not restarted before the interval, so LastComplete |
| 163 | // stays stable long enough to quote in an audit answer instead of being |
| 164 | // rebuilt from scratch every hour. |
| 165 | func TestScanPassHonoursReSweepInterval(t *testing.T) { |
| 166 | ctx := context.Background() |
| 167 | fx := newConsoleFixture(t) |
| 168 | fx.srv.cfg.StoreScanner = objstore.NewMemScanner() |
| 169 | fx.srv.cfg.ScanInterval = time.Hour |
| 170 | enableStore(t, fx, fixtureAcct, fixtureApp, true) |
| 171 | |
| 172 | l := &countingLister{keys: []string{"a.txt"}} |
| 173 | withLister(fx, l) |
| 174 | |
| 175 | if err := fx.srv.scanPass(ctx); err != nil { |
| 176 | t.Fatal(err) |
| 177 | } |
| 178 | if l.calls != 1 { |
| 179 | t.Fatalf("first pass listed %d times, want 1", l.calls) |
| 180 | } |
| 181 | |
| 182 | if err := fx.srv.scanPass(ctx); err != nil { |
| 183 | t.Fatal(err) |
| 184 | } |
| 185 | if l.calls != 1 { |
| 186 | t.Fatalf("a completed sweep was restarted inside the interval: %d listings", l.calls) |
| 187 | } |
| 188 | |
| 189 | // Once the interval has passed, it sweeps again — otherwise coverage |
| 190 | // would be a one-time claim that ages silently. |
| 191 | fx.srv.cfg.ScanInterval = time.Nanosecond |
| 192 | if err := fx.srv.scanPass(ctx); err != nil { |
| 193 | t.Fatal(err) |
| 194 | } |
| 195 | if l.calls != 2 { |
| 196 | t.Fatalf("a stale sweep was not restarted: %d listings", l.calls) |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | // One app's failure does not stop the rest. A pass that gave up on the first |
| 201 | // unreachable bucket would leave every store after it in the listing order |
| 202 | // permanently unswept — an ordering-dependent coverage gap, which is the worst |
| 203 | // kind to diagnose. |
| 204 | func TestScanPassContinuesPastOneAppsFailure(t *testing.T) { |
| 205 | ctx := context.Background() |
| 206 | fx := newConsoleFixture(t) |
| 207 | fx.srv.cfg.StoreScanner = objstore.NewMemScanner() |
| 208 | |
| 209 | enableStore(t, fx, fixtureAcct, "aaa-broken", true) |
| 210 | enableStore(t, fx, fixtureAcct, fixtureApp, true) |
| 211 | |
| 212 | good := &countingLister{keys: []string{"a.txt"}} |
| 213 | fx.srv.storeListerFor = func(_ context.Context, p *objstore.Provisioned) (objstore.ObjectLister, error) { |
| 214 | if p.Bucket == "carlos-"+fixtureAcct+"-aaa-broken" { |
| 215 | return nil, errors.New("endpoint down") |
| 216 | } |
| 217 | return good, nil |
| 218 | } |
| 219 | |
| 220 | if err := fx.srv.scanPass(ctx); err != nil { |
| 221 | t.Fatalf("one app's failure failed the whole pass: %v", err) |
| 222 | } |
| 223 | side, _, err := objstore.GetSidecar(ctx, fx.rel, fixtureAcct, fixtureApp) |
| 224 | if err != nil { |
| 225 | t.Fatal(err) |
| 226 | } |
| 227 | if side == nil || side.LastComplete == nil { |
| 228 | t.Fatal("a healthy store after a broken one was never swept") |
| 229 | } |
| 230 | } |
| 231 | |
| 232 | // A sweep still in flight ignores the interval entirely. This is the only way |
| 233 | // a bucket larger than one budget ever finishes: if a part-done sweep waited |
| 234 | // out the re-sweep floor, a big store would advance one page a day. |
| 235 | func TestScanPassAlwaysContinuesAnInFlightSweep(t *testing.T) { |
| 236 | ctx := context.Background() |
| 237 | fx := newConsoleFixture(t) |
| 238 | fx.srv.cfg.ScanInterval = 365 * 24 * time.Hour |
| 239 | |
| 240 | side := &objstore.ScanSidecar{ |
| 241 | Current: &objstore.Sweep{StartedAt: time.Now().UTC(), At: time.Now().UTC(), After: "a.txt"}, |
| 242 | } |
| 243 | if _, err := objstore.PutSidecar(ctx, fx.rel, fixtureAcct, fixtureApp, side, ""); err != nil { |
| 244 | t.Fatal(err) |
| 245 | } |
| 246 | |
| 247 | rec, _, err := objstore.Get(ctx, fx.rel, fixtureAcct, fixtureApp) |
| 248 | if err != nil { |
| 249 | t.Fatal(err) |
| 250 | } |
| 251 | due, err := fx.srv.storeIsDue(ctx, rec, fixtureAcct, fixtureApp, fx.srv.scanInterval()) |
| 252 | if err != nil { |
| 253 | t.Fatal(err) |
| 254 | } |
| 255 | if !due { |
| 256 | t.Fatal("an in-flight sweep was parked until the re-sweep interval") |
| 257 | } |
| 258 | } |
| 259 | |
| 260 | // The on-demand verb refuses plainly when no engine is configured, rather than |
| 261 | // reporting a sweep that never happened. |
| 262 | func TestSweepNowRefusesWithoutScanner(t *testing.T) { |
| 263 | fx := newConsoleFixture(t) |
| 264 | enableStore(t, fx, fixtureAcct, fixtureApp, true) |
| 265 | |
| 266 | _, err := fx.srv.sweepNow(context.Background(), fixtureAcct, fixtureApp) |
| 267 | if !errors.Is(err, errNoScanner) { |
| 268 | t.Fatalf("err = %v, want errNoScanner", err) |
| 269 | } |
| 270 | } |
| 271 | |
| 272 | // A store re-attested from --client-encrypted back to --plaintext must be |
| 273 | // swept again. |
| 274 | // |
| 275 | // The stale not_applicable sidecar outlives the record change, and reading |
| 276 | // only the sidecar meant such a store was never swept by any tick again — for |
| 277 | // as long as it existed — while `store status` reported "scanning: clamav". |
| 278 | // The RECORD is the authority on the attestation; the sidecar is a cache of a |
| 279 | // decision made from it. |
| 280 | func TestScanPassResweepsAfterReattestationToPlaintext(t *testing.T) { |
| 281 | ctx := context.Background() |
| 282 | fx := newConsoleFixture(t) |
| 283 | fx.srv.cfg.StoreScanner = objstore.NewMemScanner() |
| 284 | enableStore(t, fx, fixtureAcct, fixtureApp, true) // record says plaintext |
| 285 | |
| 286 | // The sidecar still carries the answer from when it was encrypted. |
| 287 | if _, err := objstore.PutSidecar(ctx, fx.rel, fixtureAcct, fixtureApp, |
| 288 | &objstore.ScanSidecar{NotApplicable: true}, ""); err != nil { |
| 289 | t.Fatal(err) |
| 290 | } |
| 291 | |
| 292 | l := &countingLister{keys: []string{"a.txt"}} |
| 293 | withLister(fx, l) |
| 294 | if err := fx.srv.scanPass(ctx); err != nil { |
| 295 | t.Fatal(err) |
| 296 | } |
| 297 | if l.calls == 0 { |
| 298 | t.Fatal("a store re-attested as plaintext was never swept again") |
| 299 | } |
| 300 | side, _, err := objstore.GetSidecar(ctx, fx.rel, fixtureAcct, fixtureApp) |
| 301 | if err != nil { |
| 302 | t.Fatal(err) |
| 303 | } |
| 304 | if side.NotApplicable { |
| 305 | t.Fatal("the stale not_applicable flag survived a sweep of a plaintext store") |
| 306 | } |
| 307 | if side.LastComplete == nil || side.LastComplete.Objects != 1 { |
| 308 | t.Fatalf("coverage = %+v", side.LastComplete) |
| 309 | } |
| 310 | } |
| 311 | |
| 312 | // The mirror: a store the record still says is encrypted stays skipped, and |
| 313 | // costs no listing at all. |
| 314 | func TestScanPassStillSkipsEncryptedStore(t *testing.T) { |
| 315 | ctx := context.Background() |
| 316 | fx := newConsoleFixture(t) |
| 317 | fx.srv.cfg.StoreScanner = objstore.NewMemScanner() |
| 318 | enableStore(t, fx, fixtureAcct, fixtureApp, true) |
| 319 | |
| 320 | rec, etag, err := objstore.Get(ctx, fx.rel, fixtureAcct, fixtureApp) |
| 321 | if err != nil { |
| 322 | t.Fatal(err) |
| 323 | } |
| 324 | rec.Scanning.Requested.Encrypted = true |
| 325 | if _, err := objstore.Put(ctx, fx.rel, fixtureAcct, fixtureApp, rec, etag); err != nil { |
| 326 | t.Fatal(err) |
| 327 | } |
| 328 | if _, err := objstore.PutSidecar(ctx, fx.rel, fixtureAcct, fixtureApp, |
| 329 | &objstore.ScanSidecar{NotApplicable: true}, ""); err != nil { |
| 330 | t.Fatal(err) |
| 331 | } |
| 332 | |
| 333 | withLister(fx, &refusingLister{t: t}) |
| 334 | if err := fx.srv.scanPass(ctx); err != nil { |
| 335 | t.Fatal(err) |
| 336 | } |
| 337 | } |
| 338 | |
| 339 | // The seed carries forward an intent the operator already expressed. Refusing |
| 340 | // to seed would not withhold consent — it would silently withdraw consent |
| 341 | // already given. |
| 342 | func TestScanPassSeedsRecordOnFirstTick(t *testing.T) { |
| 343 | ctx := context.Background() |
| 344 | fx := newConsoleFixture(t) |
| 345 | fx.srv.cfg.StoreScanner = objstore.NewMemScanner() |
| 346 | fx.srv.cfg.ScanSeedFromDeprecatedEnv = true |
| 347 | withLister(fx, &countingLister{}) |
| 348 | |
| 349 | if err := fx.srv.scanPass(ctx); err != nil { |
| 350 | t.Fatal(err) |
| 351 | } |
| 352 | av, _, err := release.GetAntivirus(ctx, fx.rel) |
| 353 | if err != nil || av == nil || !av.Enabled { |
| 354 | t.Fatalf("record after first tick = %+v, %v", av, err) |
| 355 | } |
| 356 | if av.By != "migration from CARLOS_STORE_SCANNER" { |
| 357 | t.Fatalf("by = %q, want the migration to be legible in the record", av.By) |
| 358 | } |
| 359 | } |
| 360 | |
| 361 | // An operator who runs `disable` is not overridden on the next restart. The |
| 362 | // seed is create-only, so it only ever fires when there is no record. |
| 363 | func TestScanPassSeedDoesNotOverrideExistingRecord(t *testing.T) { |
| 364 | ctx := context.Background() |
| 365 | fx := newConsoleFixture(t) |
| 366 | fx.srv.cfg.StoreScanner = objstore.NewMemScanner() |
| 367 | fx.srv.cfg.ScanSeedFromDeprecatedEnv = true |
| 368 | |
| 369 | if _, err := release.PutAntivirus(ctx, fx.rel, &release.AntivirusSettings{ |
| 370 | Enabled: false, Engine: "clamav", At: time.Now().UTC(), By: "op@example.com", |
| 371 | }, ""); err != nil { |
| 372 | t.Fatal(err) |
| 373 | } |
| 374 | withLister(fx, &refusingLister{t: t}) |
| 375 | |
| 376 | if err := fx.srv.scanPass(ctx); err != nil { |
| 377 | t.Fatal(err) |
| 378 | } |
| 379 | av, _, err := release.GetAntivirus(ctx, fx.rel) |
| 380 | if err != nil || av == nil || av.Enabled { |
| 381 | t.Fatalf("a deliberate disable was overridden by the seed: %+v, %v", av, err) |
| 382 | } |
| 383 | } |
| 384 | |
| 385 | // Without the deprecated variable there is no seed, and nothing scans until an |
| 386 | // operator enables it. |
| 387 | func TestScanPassDoesNotSeedWithoutTheDeprecatedVar(t *testing.T) { |
| 388 | ctx := context.Background() |
| 389 | fx := newConsoleFixture(t) |
| 390 | fx.srv.cfg.StoreScanner = objstore.NewMemScanner() |
| 391 | enableStoreRecordOnly(t, fx, fixtureAcct, fixtureApp) |
| 392 | withLister(fx, &refusingLister{t: t}) |
| 393 | |
| 394 | if err := fx.srv.scanPass(ctx); err != nil { |
| 395 | t.Fatal(err) |
| 396 | } |
| 397 | if av, _, err := release.GetAntivirus(ctx, fx.rel); err != nil || av != nil { |
| 398 | t.Fatalf("a record appeared with no migration to justify it: %+v, %v", av, err) |
| 399 | } |
| 400 | } |
| 401 | |
| 402 | // A read error is neither enabled nor disabled: it is an error. Seeding on a |
| 403 | // failed read would enable scanning on a deployment that had disabled it. |
| 404 | func TestScanPassSeedIsSilentOnReadError(t *testing.T) { |
| 405 | ctx := context.Background() |
| 406 | fx := newConsoleFixture(t) |
| 407 | fx.srv.cfg.StoreScanner = objstore.NewMemScanner() |
| 408 | fx.srv.cfg.ScanSeedFromDeprecatedEnv = true |
| 409 | |
| 410 | // A record this build cannot read at all. |
| 411 | if _, err := fx.rel.Put(ctx, release.SystemKey(release.AntivirusName), |
| 412 | []byte(`{"schema":99,"enabled":true}`), ""); err != nil { |
| 413 | t.Fatal(err) |
| 414 | } |
| 415 | withLister(fx, &refusingLister{t: t}) |
| 416 | |
| 417 | if err := fx.srv.scanPass(ctx); err == nil { |
| 418 | t.Fatal("an unreadable record was treated as a state rather than an error") |
| 419 | } |
| 420 | } |
| 421 | |
| 422 | // Disabled means the tick does nothing — not that there is no goroutine, which |
| 423 | // is what would make enabling need a restart. |
| 424 | func TestScanPassDoesNothingWhenDisabled(t *testing.T) { |
| 425 | ctx := context.Background() |
| 426 | fx := newConsoleFixture(t) |
| 427 | fx.srv.cfg.StoreScanner = objstore.NewMemScanner() |
| 428 | enableStoreRecordOnly(t, fx, fixtureAcct, fixtureApp) |
| 429 | |
| 430 | if _, err := release.PutAntivirus(ctx, fx.rel, &release.AntivirusSettings{ |
| 431 | Enabled: false, Engine: "clamav", At: time.Now().UTC(), By: "op@example.com", |
| 432 | }, ""); err != nil { |
| 433 | t.Fatal(err) |
| 434 | } |
| 435 | noSweeps(t, fx) |
| 436 | |
| 437 | if err := fx.srv.scanPass(ctx); err != nil { |
| 438 | t.Fatal(err) |
| 439 | } |
| 440 | } |
| 441 | |
| 442 | // The seed is create-only, and losing that race is NOT the same as winning it. |
| 443 | // |
| 444 | // An operator running `disable` in the same instant as a migrating console's |
| 445 | // first tick is the case: the console read no record, the operator's write |
| 446 | // landed, and the seed came back ErrCASConflict. Treating the conflict as |
| 447 | // success — "somebody else got there first, so it must be on" — would run a |
| 448 | // whole pass over tenant bytes against the switch that operator had just |
| 449 | // turned off. The winner is read, not assumed. |
| 450 | func TestScanPassSeedYieldsToAConcurrentDisable(t *testing.T) { |
| 451 | ctx := context.Background() |
| 452 | fx := newConsoleFixture(t) |
| 453 | fx.srv.cfg.StoreScanner = objstore.NewMemScanner() |
| 454 | fx.srv.cfg.ScanSeedFromDeprecatedEnv = true |
| 455 | enableStoreRecordOnly(t, fx, fixtureAcct, fixtureApp) |
| 456 | noSweeps(t, fx) |
| 457 | |
| 458 | // The operator's write lands between the console's read and its own |
| 459 | // create-only write. |
| 460 | fx.srv.cfg.Releases = &seedRaceStore{Store: fx.rel, race: func() { |
| 461 | if _, err := release.PutAntivirus(ctx, fx.rel, &release.AntivirusSettings{ |
| 462 | Enabled: false, Engine: "clamav", At: time.Now().UTC(), By: "op@example.com", |
| 463 | }, ""); err != nil { |
| 464 | t.Error(err) |
| 465 | } |
| 466 | }} |
| 467 | |
| 468 | if err := fx.srv.scanPass(ctx); err != nil { |
| 469 | t.Fatal(err) |
| 470 | } |
| 471 | av, _, err := release.GetAntivirus(ctx, fx.rel) |
| 472 | if err != nil || av == nil || av.Enabled { |
| 473 | t.Fatalf("the operator's disable was overwritten or ignored: %+v, %v", av, err) |
| 474 | } |
| 475 | } |
| 476 | |
| 477 | // seedRaceStore runs race() once, immediately after the first read of the |
| 478 | // antivirus record — the instant between the seed's read and its write. |
| 479 | type seedRaceStore struct { |
| 480 | release.Store |
| 481 | race func() |
| 482 | seen bool |
| 483 | } |
| 484 | |
| 485 | func (s *seedRaceStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| 486 | body, etag, err := s.Store.Get(ctx, key) |
| 487 | if !s.seen && key == release.SystemKey(release.AntivirusName) { |
| 488 | s.seen = true |
| 489 | s.race() |
| 490 | } |
| 491 | return body, etag, err |
| 492 | } |
| 493 | |
| 494 | // enableStoreRecordOnly writes the per-app record without touching the |
| 495 | // deployment switch, for the tests that are about the switch itself. |
| 496 | func enableStoreRecordOnly(t *testing.T, fx *consoleFixture, acct, app string) { |
| 497 | t.Helper() |
| 498 | rec := &objstore.Record{ |
| 499 | Provisioned: &objstore.Provisioned{Bucket: "carlos-" + acct + "-" + app, Region: "eu-west-1"}, |
| 500 | Scanning: &objstore.ScanBlock{ |
| 501 | Requested: &objstore.ScanRequested{At: time.Now().UTC(), By: "m@example.com"}, |
| 502 | Enabled: &objstore.ScanEnabled{Engine: "clamav", At: time.Now().UTC(), By: "op@example.com"}, |
| 503 | }, |
| 504 | } |
| 505 | if _, err := objstore.Put(context.Background(), fx.rel, acct, app, rec, ""); err != nil { |
| 506 | t.Fatal(err) |
| 507 | } |
| 508 | } |
| 509 | |