carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
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.
16type countingLister struct {
17 keys []string
18 calls int
19 err error
20}
21
22func (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.
37type refusingLister struct{ t *testing.T }
38
39func (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.
49func 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.
66func 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
87func 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".
101func 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.
111func 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.
132func 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.
165func 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.
204func 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.
235func 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.
262func 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.
280func 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.
314func 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.
342func 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.
363func 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.
387func 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.
404func 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.
424func 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.
450func 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.
479type seedRaceStore struct {
480 release.Store
481 race func()
482 seen bool
483}
484
485func (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.
496func 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