carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package objstore
2
3import (
4 "context"
5 "errors"
6 "os"
7 "strings"
8 "testing"
9 "time"
10
11 "github.com/carlosframework/platform/internal/release"
12)
13
14// queueFixture is a provisioned, scanning-enabled store with an armed engine
15// and n objects the scanner knows about.
16func queueFixture(t *testing.T, n int) (release.Store, *MemScanner, *QueueIndex, string) {
17 t.Helper()
18 s := release.NewDirStore(t.TempDir())
19 _, pub := testSealKeypair(t)
20 p := NewMemProvisioner("eu-west-1")
21 rec := rotateFixture(t, s, pub, p)
22 enableScanning(t, s, "bab", "titogo")
23
24 sc := NewMemScanner()
25 bucket := rec.Provisioned.Bucket
26 for i := 0; i < n; i++ {
27 sc.PutObject(bucket, objectName(i), "etag-"+objectName(i), time.Now().UTC(), 10)
28 }
29 return s, sc, NewQueueIndex(), bucket
30}
31
32func enqueueN(t *testing.T, s release.Store, idx *QueueIndex, n int) {
33 t.Helper()
34 for i := 0; i < n; i++ {
35 if _, err := Enqueue(context.Background(), s, idx, "bab", "titogo", &QueueItem{
36 Key: objectName(i), By: "app",
37 }, time.Now()); err != nil {
38 t.Fatal(err)
39 }
40 }
41}
42
43// Ids sort by time, so a prefix listing IS the queue in order.
44func TestQueueIDSortsByTime(t *testing.T) {
45 base := time.Date(2026, 9, 2, 8, 0, 0, 0, time.UTC)
46 var prev string
47 for i := 0; i < 200; i++ {
48 id := NewQueueID(base.Add(time.Duration(i) * time.Millisecond))
49 if id <= prev {
50 t.Fatalf("id %q does not sort after %q", id, prev)
51 }
52 prev = id
53 }
54 // Two in the same millisecond must not collide.
55 seen := map[string]bool{}
56 for i := 0; i < 1000; i++ {
57 id := NewQueueID(base)
58 if seen[id] {
59 t.Fatal("two ids in the same millisecond collided")
60 }
61 seen[id] = true
62 }
63}
64
65// The object first, the index second. A crash between the two strands one item
66// until the boot rebuild; the other order would mark an app whose item was
67// never written, and a 202 sent in between would stop the client retrying.
68// It is the ORDER that is under test, so the assertions have to be able to see
69// it. Asserting only on the end state passes just as happily with the two
70// swapped, which is the mutation that matters.
71func TestEnqueueWritesObjectBeforeBump(t *testing.T) {
72 ctx := context.Background()
73 s, _, idx, _ := queueFixture(t, 1)
74
75 // (a) At the instant the object is being written, the app is not yet
76 // marked. Bump-first fails here.
77 var markedDuringWrite int
78 watching := &queueStore{Store: s, onPut: func(string) { markedDuringWrite = idx.Len() }}
79 id, err := Enqueue(ctx, watching, idx, "bab", "titogo", &QueueItem{
80 Key: "uploads/a", By: "app",
81 }, time.Now())
82 if err != nil {
83 t.Fatal(err)
84 }
85 if markedDuringWrite != 0 {
86 t.Fatal("the index was bumped before the item object was written")
87 }
88 if _, _, err := s.Get(ctx, QueueKey("bab", "titogo", id)); err != nil {
89 t.Fatalf("item object was not written: %v", err)
90 }
91 if idx.Len() != 1 {
92 t.Fatal("the app was not marked")
93 }
94
95 // (b) And a write that FAILS leaves the app unmarked, so nothing ever
96 // points at an item that does not exist.
97 fresh := NewQueueIndex()
98 failing := &queueStore{Store: s, putErr: errors.New("the bucket said no")}
99 if _, err := Enqueue(ctx, failing, fresh, "bab", "titogo", &QueueItem{
100 Key: "uploads/b", By: "app",
101 }, time.Now()); err == nil {
102 t.Fatal("a failed write reported success")
103 }
104 if fresh.Len() != 0 {
105 t.Fatal("the app was marked for an item that was never written")
106 }
107}
108
109// THE test this design needed. The drain caps work per tick, so on a flooded
110// app it deliberately leaves items behind with no enqueue to bump anything —
111// and an earlier draft released the entry whenever the generation was
112// unchanged, deleting the pointer to exactly those items. They would then sit
113// until the next console boot, and the callback and long-poll wake would be
114// lost for the app the cap exists for.
115func TestIndexEntrySurvivesPartialDrain(t *testing.T) {
116 ctx := context.Background()
117 s, sc, idx, _ := queueFixture(t, 5)
118 enqueueN(t, s, idx, 5)
119
120 done, listedEmpty, err := DrainApp(ctx, s, sc, idx, "bab", "titogo", 2, time.Now())
121 if err != nil {
122 t.Fatal(err)
123 }
124 if len(done) != 2 {
125 t.Fatalf("drained %d, want the cap of 2", len(done))
126 }
127 if listedEmpty {
128 t.Fatal("a capped listing reported itself empty")
129 }
130 if idx.Len() != 1 {
131 t.Fatal("the entry pointing at the remaining items was released")
132 }
133
134 // Successive calls finish the job.
135 for i := 0; i < 5 && idx.Len() > 0; i++ {
136 if _, _, err := DrainApp(ctx, s, sc, idx, "bab", "titogo", 2, time.Now()); err != nil {
137 t.Fatal(err)
138 }
139 }
140 if idx.Len() != 0 {
141 t.Fatal("successive drains never emptied the queue")
142 }
143 left, err := s.List(ctx, QueuePrefix("bab", "titogo"))
144 if err != nil || len(left) != 0 {
145 t.Fatalf("items left behind: %v, %v", left, err)
146 }
147}
148
149// An enqueue landing mid-drain must not have its entry released underneath it.
150//
151// Driven through DRAINAPP, not through Observe and Release by hand: what the
152// generation counter protects is an ORDERING inside DrainApp — Observe before
153// the listing — and a test that calls the three methods itself proves the
154// methods work while leaving the ordering free to be wrong. Moving Observe
155// below the listing passes such a test and fails this one.
156func TestIndexEntrySurvivesEnqueueDuringDrain(t *testing.T) {
157 ctx := context.Background()
158 s, sc, idx, _ := queueFixture(t, 2)
159 enqueueN(t, s, idx, 1)
160
161 // Drain the one item. The entry stays, because a consumed page may have
162 // more behind it.
163 if _, _, err := DrainApp(ctx, s, sc, idx, "bab", "titogo", 10, time.Now()); err != nil {
164 t.Fatal(err)
165 }
166 if idx.Len() != 1 {
167 t.Fatal("a consumed page released the entry before an empty listing confirmed it")
168 }
169
170 // Now the queue really is empty — except that an enqueue lands after the
171 // listing has been taken and before the release decision.
172 racing := &queueStore{Store: s}
173 racing.afterList = func() {
174 if _, err := Enqueue(ctx, s, idx, "bab", "titogo", &QueueItem{
175 Key: objectName(1), By: "app",
176 }, time.Now()); err != nil {
177 t.Error(err)
178 }
179 }
180 if _, listedEmpty, err := DrainApp(ctx, racing, sc, idx, "bab", "titogo", 10, time.Now()); err != nil || !listedEmpty {
181 t.Fatalf("drain = %v, %v", listedEmpty, err)
182 }
183 if idx.Len() != 1 {
184 t.Fatal("the entry for an item enqueued during the drain was released — nothing points at it until boot")
185 }
186 left, err := s.List(ctx, QueuePrefix("bab", "titogo"))
187 if err != nil || len(left) != 1 {
188 t.Fatalf("the late item = %v, %v", left, err)
189 }
190}
191
192// Release needs BOTH conditions. Generation alone is the bug above.
193func TestIndexReleasedOnlyOnEmptyListing(t *testing.T) {
194 ref := AppRef{Account: "bab", App: "titogo"}
195 idx := NewQueueIndex()
196 idx.Bump(ref)
197 observed := idx.Observe(ref)
198
199 if idx.Release(ref, observed, false) {
200 t.Fatal("released on a non-empty listing")
201 }
202 if !idx.Release(ref, observed, true) {
203 t.Fatal("did not release on an empty listing with an unchanged generation")
204 }
205}
206
207// The consent chain, re-checked at the drain because the record can change
208// between enqueue and drain and THIS is where bytes are read.
209func TestDrainAppRefusesAndDeletesWhenNotPermitted(t *testing.T) {
210 ctx := context.Background()
211
212 for name, break_ := range map[string]func(t *testing.T, s release.Store){
213 "no attestation": func(t *testing.T, s release.Store) {
214 rec, etag, _ := Get(ctx, s, "bab", "titogo")
215 rec.Scanning.Requested = nil
216 if _, err := Put(ctx, s, "bab", "titogo", rec, etag); err != nil {
217 t.Fatal(err)
218 }
219 },
220 "attested encrypted": func(t *testing.T, s release.Store) {
221 rec, etag, _ := Get(ctx, s, "bab", "titogo")
222 rec.Scanning.Requested.Encrypted = true
223 if _, err := Put(ctx, s, "bab", "titogo", rec, etag); err != nil {
224 t.Fatal(err)
225 }
226 },
227 "engine not enabled for the store": func(t *testing.T, s release.Store) {
228 rec, etag, _ := Get(ctx, s, "bab", "titogo")
229 rec.Scanning.Enabled = nil
230 if _, err := Put(ctx, s, "bab", "titogo", rec, etag); err != nil {
231 t.Fatal(err)
232 }
233 },
234 "app in the trash": func(t *testing.T, s release.Store) {
235 if err := release.TrashApp(ctx, s, "bab", "titogo", "op@example.com", time.Now().UTC()); err != nil {
236 t.Fatal(err)
237 }
238 },
239 } {
240 t.Run(name, func(t *testing.T) {
241 s, sc, idx, _ := queueFixture(t, 2)
242 enqueueN(t, s, idx, 2)
243 break_(t, s)
244
245 _, _, err := DrainApp(ctx, s, sc, idx, "bab", "titogo", 10, time.Now())
246 if !errors.Is(err, ErrQueueNotPermitted) {
247 t.Fatalf("err = %v, want ErrQueueNotPermitted", err)
248 }
249 // The items are DELETED, not kept: none of these can change back
250 // in a way that makes them useful, and leaving them would block
251 // the app's name for thirty days in the trash case.
252 left, lerr := s.List(ctx, QueuePrefix("bab", "titogo"))
253 if lerr != nil || len(left) != 0 {
254 t.Fatalf("items survived a permanent refusal: %v, %v", left, lerr)
255 }
256 })
257 }
258}
259
260// Disabled deployment-wide is REVERSIBLE, so the items are kept. The refusals
261// above are not, so they are deleted. Getting these two the same way round
262// would either strand a tenant's queue or lose it.
263func TestDrainAppKeepsItemsWhenDeploymentDisabled(t *testing.T) {
264 ctx := context.Background()
265 s, sc, idx, _ := queueFixture(t, 2)
266 enqueueN(t, s, idx, 2)
267
268 av, etag, err := release.GetAntivirus(ctx, s)
269 if err != nil {
270 t.Fatal(err)
271 }
272 av.Enabled = false
273 if _, err := release.PutAntivirus(ctx, s, av, etag); err != nil {
274 t.Fatal(err)
275 }
276
277 if _, _, err := DrainApp(ctx, s, sc, idx, "bab", "titogo", 10, time.Now()); !errors.Is(err, ErrScanningDisabled) {
278 t.Fatalf("err = %v, want ErrScanningDisabled", err)
279 }
280 left, err := s.List(ctx, QueuePrefix("bab", "titogo"))
281 if err != nil || len(left) != 2 {
282 t.Fatalf("a reversible refusal dropped the queue: %v, %v", left, err)
283 }
284 if idx.Len() != 1 {
285 t.Fatal("the index entry was released on a reversible refusal")
286 }
287}
288
289// The drain re-issues failed and denied itself. Without this, an object the
290// sweep last tagged failed would be answered from that tag forever and never
291// rescanned on request — on-demand strictly less able than the hourly pass it
292// exists to improve on.
293func TestDrainAppReIssuesFailedAndDenied(t *testing.T) {
294 ctx := context.Background()
295 for _, stuck := range []Verdict{VerdictFailed, VerdictDenied} {
296 t.Run(string(stuck), func(t *testing.T) {
297 s, sc, idx, bucket := queueFixture(t, 1)
298 key := objectName(0)
299 // A stale verdict already on the object, at its current version.
300 _, obj, err := sc.Status(ctx, bucket, key)
301 if err != nil {
302 t.Fatal(err)
303 }
304 sc.Put(bucket, key, obj.Version(), stuck)
305 enqueueN(t, s, idx, 1)
306
307 done, _, err := DrainApp(ctx, s, sc, idx, "bab", "titogo", 10, time.Now())
308 if err != nil {
309 t.Fatal(err)
310 }
311 if len(done) != 1 {
312 t.Fatalf("drained %d items", len(done))
313 }
314 if !done[0].Scanned {
315 t.Fatalf("a %q verdict was answered from the tag rather than rescanned", stuck)
316 }
317 if done[0].Verdict != VerdictClean {
318 t.Fatalf("verdict = %q, want the rescan's answer", done[0].Verdict)
319 }
320 })
321 }
322}
323
324// A verdict already recorded at the current version costs a tag read and no
325// fetch. That is the amplification guard: re-requesting a judged key must not
326// re-stream its bytes.
327func TestDrainAppReusesAJudgedVerdict(t *testing.T) {
328 ctx := context.Background()
329 s, sc, idx, bucket := queueFixture(t, 1)
330 key := objectName(0)
331 _, obj, err := sc.Status(ctx, bucket, key)
332 if err != nil {
333 t.Fatal(err)
334 }
335 sc.Put(bucket, key, obj.Version(), VerdictClean)
336 enqueueN(t, s, idx, 1)
337
338 done, _, err := DrainApp(ctx, s, sc, idx, "bab", "titogo", 10, time.Now())
339 if err != nil {
340 t.Fatal(err)
341 }
342 if len(done) != 1 || done[0].Scanned {
343 t.Fatalf("a judged key was rescanned: %+v", done)
344 }
345 if done[0].Verdict != VerdictClean {
346 t.Fatalf("verdict = %q", done[0].Verdict)
347 }
348}
349
350// A verdict the object cannot carry goes to a result object instead, and the
351// outcome says so — an infected object with Recorded false is NOT covered by
352// the enforcement deny, because the deny matches a tag that is not there.
353func TestDrainAppWritesResultWhenUnrecorded(t *testing.T) {
354 ctx := context.Background()
355 s, sc, idx, bucket := queueFixture(t, 1)
356 key := objectName(0)
357 sc.Unrecordable(key)
358 enqueueN(t, s, idx, 1)
359
360 done, _, err := DrainApp(ctx, s, sc, idx, "bab", "titogo", 10, time.Now())
361 if err != nil {
362 t.Fatal(err)
363 }
364 if len(done) != 1 || done[0].Recorded {
365 t.Fatalf("outcome = %+v, want recorded false", done)
366 }
367 _, obj, err := sc.Status(ctx, bucket, key)
368 if err != nil {
369 t.Fatal(err)
370 }
371 res, err := ReadScanResult(ctx, s, "bab", "titogo", key, obj.Version())
372 if err != nil || res == nil {
373 t.Fatalf("no result object for an unrecorded verdict: %+v, %v", res, err)
374 }
375 if res.Verdict != string(VerdictClean) {
376 t.Fatalf("result verdict = %q", res.Verdict)
377 }
378}
379
380// And when a later scan DOES persist a verdict, the fallback goes — it must
381// not outlive the real answer.
382func TestDrainAppDeletesResultWhenLaterRecorded(t *testing.T) {
383 ctx := context.Background()
384 s, sc, idx, bucket := queueFixture(t, 1)
385 key := objectName(0)
386
387 sc.Unrecordable(key)
388 enqueueN(t, s, idx, 1)
389 if _, _, err := DrainApp(ctx, s, sc, idx, "bab", "titogo", 10, time.Now()); err != nil {
390 t.Fatal(err)
391 }
392 if _, _, err := s.Get(ctx, ScanResultKey("bab", "titogo", key)); err != nil {
393 t.Fatalf("setup: no result object: %v", err)
394 }
395
396 // The tag limit clears, and the object is re-requested.
397 sc2 := NewMemScanner()
398 _, obj, _ := sc.Status(ctx, bucket, key)
399 sc2.PutObject(bucket, key, obj.ETag, obj.Modified, obj.Size)
400 if _, err := Enqueue(ctx, s, idx, "bab", "titogo", &QueueItem{
401 Key: key, By: "app",
402 }, time.Now()); err != nil {
403 t.Fatal(err)
404 }
405 if _, _, err := DrainApp(ctx, s, sc2, idx, "bab", "titogo", 10, time.Now()); err != nil {
406 t.Fatal(err)
407 }
408 if _, _, err := s.Get(ctx, ScanResultKey("bab", "titogo", key)); !errors.Is(err, os.ErrNotExist) {
409 t.Fatalf("the fallback outlived the real verdict: %v", err)
410 }
411}
412
413// Expired items are dropped rather than scanned: after a sweep cycle the
414// ordinary pass has had its chance, and a stale on-demand answer is worth less
415// than none.
416func TestDrainAppDropsExpiredItems(t *testing.T) {
417 ctx := context.Background()
418 s, sc, idx, _ := queueFixture(t, 1)
419 if _, err := Enqueue(ctx, s, idx, "bab", "titogo", &QueueItem{
420 Key: objectName(0), By: "app",
421 }, time.Now().Add(-48*time.Hour)); err != nil {
422 t.Fatal(err)
423 }
424
425 done, _, err := DrainApp(ctx, s, sc, idx, "bab", "titogo", 10, time.Now())
426 if err != nil {
427 t.Fatal(err)
428 }
429 if len(done) != 0 {
430 t.Fatalf("an expired item was scanned: %+v", done)
431 }
432 left, err := s.List(ctx, QueuePrefix("bab", "titogo"))
433 if err != nil || len(left) != 0 {
434 t.Fatalf("expired item not collected: %v, %v", left, err)
435 }
436}
437
438// The same key twice in one tick is scanned once. This is what stops one app's
439// flood being amplified into repeated fetches of the same bytes.
440//
441// BUT DEDUPING THE WORK IS NOT DEDUPING THE PROMISES. Every enqueue was
442// answered 202, and every one that named a callback path is owed a delivery.
443// An earlier draft deleted the duplicate items along with their paths, so two
444// requests for one key with different callbacks got one callback and the other
445// app endpoint waited forever — a silent loss the drain's own test blessed by
446// asserting only that one outcome came back.
447func TestDrainAppDedupesWorkNotCallbacks(t *testing.T) {
448 ctx := context.Background()
449 s, sc, idx, _ := queueFixture(t, 1)
450 for _, path := range []string{"/hooks/a", "/hooks/b", "/hooks/a", ""} {
451 if _, err := Enqueue(ctx, s, idx, "bab", "titogo", &QueueItem{
452 Key: objectName(0), By: "app", CallbackPath: path,
453 }, time.Now()); err != nil {
454 t.Fatal(err)
455 }
456 }
457
458 done, _, err := DrainApp(ctx, s, sc, idx, "bab", "titogo", 10, time.Now())
459 if err != nil {
460 t.Fatal(err)
461 }
462 if len(done) != 1 {
463 t.Fatalf("drained %d outcomes for one key — the object was read more than once", len(done))
464 }
465 if sc.Scans(objectName(0)) != 1 {
466 t.Fatalf("the engine read the object %d times for four requests", sc.Scans(objectName(0)))
467 }
468 // Both distinct paths survive; the repeat does not become a second
469 // delivery, and the request with no path adds nothing.
470 got := done[0].CallbackPaths
471 if len(got) != 2 || !containsString(got, "/hooks/a") || !containsString(got, "/hooks/b") {
472 t.Fatalf("callback paths = %v, want both distinct paths and no duplicate", got)
473 }
474 left, err := s.List(ctx, QueuePrefix("bab", "titogo"))
475 if err != nil || len(left) != 0 {
476 t.Fatalf("duplicate items left behind: %v, %v", left, err)
477 }
478}
479
480// An object deleted between enqueue and drain is dropped, not an error.
481func TestDrainAppDropsGoneObjects(t *testing.T) {
482 ctx := context.Background()
483 s, sc, idx, _ := queueFixture(t, 0) // the scanner knows about no objects
484 if _, err := Enqueue(ctx, s, idx, "bab", "titogo", &QueueItem{
485 Key: "uploads/vanished", By: "app",
486 }, time.Now()); err != nil {
487 t.Fatal(err)
488 }
489
490 done, _, err := DrainApp(ctx, s, sc, idx, "bab", "titogo", 10, time.Now())
491 if err != nil {
492 t.Fatalf("a vanished object failed the drain: %v", err)
493 }
494 // IT IS AN OUTCOME, and this test used to assert the opposite. "There is
495 // no such object" is the honest answer to the question the app asked, and
496 // reporting nothing meant a long-poll parked on that key sat out its whole
497 // window for an answer that had already been settled — the drain deleted
498 // the item silently and woke nobody.
499 if len(done) != 1 {
500 t.Fatalf("outcomes = %+v, want the one that says the object is gone", done)
501 }
502 if !done[0].Gone {
503 t.Errorf("outcome %+v is not marked Gone, so a caller cannot tell it from a verdict", done[0])
504 }
505 if done[0].Verdict != "" || done[0].Recorded || done[0].Scanned {
506 t.Errorf("a gone object produced a verdict-shaped outcome: %+v", done[0])
507 }
508 left, _ := s.List(ctx, QueuePrefix("bab", "titogo"))
509 if len(left) != 0 {
510 t.Fatal("the item for a vanished object was kept")
511 }
512}
513
514// An engine that goes away keeps the items AND the entry: it is a fact about
515// the engine, not about these objects, and the next tick should retry.
516func TestDrainAppKeepsItemsOnEngineFailure(t *testing.T) {
517 ctx := context.Background()
518 s, sc, idx, _ := queueFixture(t, 1)
519 enqueueN(t, s, idx, 1)
520 sc.Fail("Scan", errors.New("dial unix /run/clamav/clamd.ctl: connection refused"))
521
522 if _, _, err := DrainApp(ctx, s, sc, idx, "bab", "titogo", 10, time.Now()); err == nil {
523 t.Fatal("an unreachable engine reported a successful drain")
524 }
525 left, err := s.List(ctx, QueuePrefix("bab", "titogo"))
526 if err != nil || len(left) != 1 {
527 t.Fatalf("an engine failure dropped the item: %v, %v", left, err)
528 }
529 if idx.Len() != 1 {
530 t.Fatal("an engine failure released the index entry")
531 }
532}
533
534// ---- the store wrapper the ordering and error tests need -------------------
535
536// queueStore wraps a store so one operation can be failed, watched or raced.
537//
538// The optional listings are forwarded BY HAND. An embedded interface promotes
539// only release.Store's own methods, so a wrapper that did not forward would
540// quietly take the drain off PageLister and the collector off ModLister — and
541// every test using it would then exercise the fallback path instead of the one
542// it names.
543type queueStore struct {
544 release.Store
545 putErr error // every Put fails with this
546 onPut func(key string) // runs BEFORE the write lands
547 failGetPrefix string // reads of keys under this prefix fail
548 failGetKey string // reads of exactly this key fail
549 afterList func() // runs after the listing, before the caller sees it
550}
551
552func (q *queueStore) Put(ctx context.Context, key string, body []byte, etag string) (string, error) {
553 if q.onPut != nil {
554 q.onPut(key)
555 }
556 if q.putErr != nil {
557 return "", q.putErr
558 }
559 return q.Store.Put(ctx, key, body, etag)
560}
561
562func (q *queueStore) Get(ctx context.Context, key string) ([]byte, string, error) {
563 if key == q.failGetKey || (q.failGetPrefix != "" && strings.HasPrefix(key, q.failGetPrefix)) {
564 return nil, "", errors.New("objstore test: the bucket had a bad moment")
565 }
566 return q.Store.Get(ctx, key)
567}
568
569func (q *queueStore) ListPage(ctx context.Context, prefix, startAfter string, limit int) ([]string, error) {
570 keys, err := q.Store.(release.PageLister).ListPage(ctx, prefix, startAfter, limit)
571 if q.afterList != nil {
572 f := q.afterList
573 q.afterList = nil // once
574 f()
575 }
576 return keys, err
577}
578
579func (q *queueStore) ListMod(ctx context.Context, prefix string) ([]release.KeyMod, error) {
580 return q.Store.(release.ModLister).ListMod(ctx, prefix)
581}
582
583// Enqueue owns the timestamp, so a door cannot forget to stamp it. A zero At
584// would mint an id sorting at year one and an item the very next drain
585// discards as expired — a 202 that meant nothing.
586func TestEnqueueStampsItsOwnTimestamp(t *testing.T) {
587 ctx := context.Background()
588 s, _, idx, _ := queueFixture(t, 1)
589 now := time.Date(2026, 9, 2, 9, 0, 0, 0, time.UTC)
590
591 item := &QueueItem{Key: "uploads/a", By: "app"} // no At
592 id, err := Enqueue(ctx, s, idx, "bab", "titogo", item, now)
593 if err != nil {
594 t.Fatal(err)
595 }
596 if !item.At.Equal(now) {
597 t.Fatalf("At = %v, want the clock the caller passed", item.At)
598 }
599 if want := NewQueueID(now); id[:12] != want[:12] {
600 t.Fatalf("id %q does not carry the enqueue time (want the prefix of %q)", id, want)
601 }
602 stored, err := readQueueItem(ctx, s, QueueKey("bab", "titogo", id))
603 if err != nil || stored.At.IsZero() {
604 t.Fatalf("stored item = %+v, %v", stored, err)
605 }
606}
607
608// A HeadObject the console may not make is not a recorded verdict.
609//
610// Saying recorded here would put "denied" in a callback beside a claim that
611// the object carries it, when no tag was ever read or written — and the
612// enforcement deny matches on a tag that is not there, so the app would be
613// told a durable answer exists that no door can produce.
614func TestDrainAppDeniedHeadIsNotRecorded(t *testing.T) {
615 ctx := context.Background()
616 s, sc, idx, bucket := queueFixture(t, 1)
617 sc.DenyHead(bucket, objectName(0))
618 enqueueN(t, s, idx, 1)
619
620 done, _, err := DrainApp(ctx, s, sc, idx, "bab", "titogo", 10, time.Now())
621 if err != nil {
622 t.Fatal(err)
623 }
624 if len(done) != 1 {
625 t.Fatalf("drained %d outcomes", len(done))
626 }
627 if done[0].Verdict != VerdictDenied {
628 t.Fatalf("verdict = %q, want denied", done[0].Verdict)
629 }
630 if done[0].Recorded {
631 t.Fatal("a denied HeadObject was reported as a recorded verdict")
632 }
633 if done[0].Scanned {
634 t.Fatal("the engine was asked to read an object with no version")
635 }
636}
637
638// §7.5: deletion is driven by ABSENCE, never by error. A bucket that fails one
639// GET must not cost a tenant an accepted request.
640func TestDrainAppKeepsItemOnTransientReadError(t *testing.T) {
641 ctx := context.Background()
642 s, sc, idx, _ := queueFixture(t, 1)
643 enqueueN(t, s, idx, 1)
644
645 flaky := &queueStore{Store: s, failGetPrefix: QueuePrefix("bab", "titogo")}
646 done, listedEmpty, err := DrainApp(ctx, flaky, sc, idx, "bab", "titogo", 10, time.Now())
647 if err != nil {
648 t.Fatalf("one unreadable item failed the whole drain: %v", err)
649 }
650 if len(done) != 0 {
651 t.Fatalf("outcomes for an item that was never read: %+v", done)
652 }
653 if listedEmpty {
654 t.Fatal("a page with an item in it reported itself empty")
655 }
656 left, err := s.List(ctx, QueuePrefix("bab", "titogo"))
657 if err != nil || len(left) != 1 {
658 t.Fatalf("a transient read error discarded the request: %v, %v", left, err)
659 }
660 if idx.Len() != 1 {
661 t.Fatal("the entry pointing at the kept item was released")
662 }
663
664 // And it drains on the next tick, once the bucket is well again.
665 done, _, err = DrainApp(ctx, s, sc, idx, "bab", "titogo", 10, time.Now())
666 if err != nil || len(done) != 1 {
667 t.Fatalf("retry = %+v, %v", done, err)
668 }
669}
670
671// The record itself failing to read is reversible too, and it is the SIBLING
672// of the check above: QueuePermitted honours "absence, not error" for the
673// record, and an earlier draft honoured it there and nowhere else.
674func TestDrainAppKeepsItemsOnRecordReadError(t *testing.T) {
675 ctx := context.Background()
676 s, sc, idx, _ := queueFixture(t, 1)
677 enqueueN(t, s, idx, 1)
678
679 broken := &queueStore{Store: s, failGetKey: Key("bab", "titogo")}
680 if _, _, err := DrainApp(ctx, broken, sc, idx, "bab", "titogo", 10, time.Now()); err == nil {
681 t.Fatal("an unreadable record reported a successful drain")
682 }
683 left, err := s.List(ctx, QueuePrefix("bab", "titogo"))
684 if err != nil || len(left) != 1 {
685 t.Fatalf("an unreadable record discarded the queue: %v, %v", left, err)
686 }
687 if idx.Len() != 1 {
688 t.Fatal("an unreadable record released the index entry")
689 }
690}
691
692// Engine drift keeps the items too: the record naming a different engine is a
693// thing an operator reverses, not a fact about these objects.
694func TestDrainAppKeepsItemsOnEngineDrift(t *testing.T) {
695 ctx := context.Background()
696 s, sc, idx, _ := queueFixture(t, 1)
697 enqueueN(t, s, idx, 1)
698 rec, etag, _ := Get(ctx, s, "bab", "titogo")
699 rec.Scanning.Enabled.Engine = "someone-elses-scanner"
700 if _, err := Put(ctx, s, "bab", "titogo", rec, etag); err != nil {
701 t.Fatal(err)
702 }
703
704 _, _, err := DrainApp(ctx, s, sc, idx, "bab", "titogo", 10, time.Now())
705 if !errors.Is(err, ErrEngineDrift) {
706 t.Fatalf("err = %v, want ErrEngineDrift", err)
707 }
708 left, lerr := s.List(ctx, QueuePrefix("bab", "titogo"))
709 if lerr != nil || len(left) != 1 {
710 t.Fatalf("drift discarded the queue: %v, %v", left, lerr)
711 }
712 if idx.Len() != 1 {
713 t.Fatal("drift released the index entry")
714 }
715}
716
717// An engine that will not say what it is keeps them as well — the same
718// reasoning as an engine that will not scan.
719func TestDrainAppKeepsItemsOnProvenanceFailure(t *testing.T) {
720 ctx := context.Background()
721 s, sc, idx, _ := queueFixture(t, 1)
722 enqueueN(t, s, idx, 1)
723 sc.Fail("Provenance", errors.New("dial: connection refused"))
724
725 _, _, err := DrainApp(ctx, s, sc, idx, "bab", "titogo", 10, time.Now())
726 if !errors.Is(err, ErrEngineUnreachable) {
727 t.Fatalf("err = %v, want ErrEngineUnreachable", err)
728 }
729 left, lerr := s.List(ctx, QueuePrefix("bab", "titogo"))
730 if lerr != nil || len(left) != 1 {
731 t.Fatalf("an unreachable engine discarded the queue: %v, %v", left, lerr)
732 }
733}
734
735// The one read failure that IS permanent: bytes no build will ever parse.
736// Leaving it would hold the app's index entry open forever.
737func TestDrainAppDeletesUnreadableItem(t *testing.T) {
738 ctx := context.Background()
739 s, sc, idx, _ := queueFixture(t, 1)
740 enqueueN(t, s, idx, 1)
741 if _, err := s.Put(ctx, QueueKey("bab", "titogo", "0000000000000000000000000v"),
742 []byte("{not json"), ""); err != nil {
743 t.Fatal(err)
744 }
745
746 done, _, err := DrainApp(ctx, s, sc, idx, "bab", "titogo", 10, time.Now())
747 if err != nil {
748 t.Fatal(err)
749 }
750 if len(done) != 1 {
751 t.Fatalf("the readable item did not drain alongside the broken one: %+v", done)
752 }
753 left, err := s.List(ctx, QueuePrefix("bab", "titogo"))
754 if err != nil || len(left) != 0 {
755 t.Fatalf("the unparseable item was kept: %v, %v", left, err)
756 }
757}
758
759// A result object is a verdict a door may answer from. When the app may no
760// longer be scanned at all, that verdict must go with the queue items — and it
761// is the worse orphan of the two: an item is drained next tick, a result is
762// found by nothing, blocks the app-name claim gate, and resurrects the app.
763func TestDrainAppDeletesResultsWhenNotPermitted(t *testing.T) {
764 ctx := context.Background()
765 s, sc, idx, _ := queueFixture(t, 1)
766 enqueueN(t, s, idx, 1)
767 if err := WriteScanResult(ctx, s, "bab", "titogo", &ScanResult{
768 Key: objectName(0), Verdict: "clean", Version: "v1", At: time.Now().UTC(),
769 }); err != nil {
770 t.Fatal(err)
771 }
772 rec, etag, _ := Get(ctx, s, "bab", "titogo")
773 rec.Scanning.Enabled = nil
774 if _, err := Put(ctx, s, "bab", "titogo", rec, etag); err != nil {
775 t.Fatal(err)
776 }
777
778 if _, _, err := DrainApp(ctx, s, sc, idx, "bab", "titogo", 10, time.Now()); !errors.Is(err, ErrQueueNotPermitted) {
779 t.Fatalf("drain = %v, want ErrQueueNotPermitted", err)
780 }
781 left, err := s.List(ctx, ScanResultsPrefix("bab", "titogo"))
782 if err != nil || len(left) != 0 {
783 t.Fatalf("a result outlived the consent that allowed it: %v, %v", left, err)
784 }
785}
786
787// A store that was never granted has no bucket to scan. The check has to be
788// the FIRST one, because everything after it dereferences Provisioned.
789func TestDrainAppRefusesWhenNotGranted(t *testing.T) {
790 ctx := context.Background()
791
792 for name, break_ := range map[string]func(t *testing.T, s release.Store){
793 "record absent": func(t *testing.T, s release.Store) {
794 if err := s.Delete(ctx, Key("bab", "titogo")); err != nil {
795 t.Fatal(err)
796 }
797 },
798 "declared but never granted": func(t *testing.T, s release.Store) {
799 rec, etag, _ := Get(ctx, s, "bab", "titogo")
800 rec.Provisioned = nil
801 if _, err := Put(ctx, s, "bab", "titogo", rec, etag); err != nil {
802 t.Fatal(err)
803 }
804 },
805 } {
806 t.Run(name, func(t *testing.T) {
807 s, sc, idx, _ := queueFixture(t, 1)
808 enqueueN(t, s, idx, 1)
809 break_(t, s)
810
811 _, _, err := DrainApp(ctx, s, sc, idx, "bab", "titogo", 10, time.Now())
812 if !errors.Is(err, ErrQueueNotPermitted) {
813 t.Fatalf("err = %v, want ErrQueueNotPermitted", err)
814 }
815 left, lerr := s.List(ctx, QueuePrefix("bab", "titogo"))
816 if lerr != nil || len(left) != 0 {
817 t.Fatalf("items survived a permanent refusal: %v, %v", left, lerr)
818 }
819 })
820 }
821}
822
823// A drain with no engine refuses in words rather than panicking on a nil
824// interface, the way RunSweep does. The goroutine gates on this too, but a
825// package that dereferences whatever it is handed is one call site away from
826// taking the console down.
827func TestDrainAppRefusesWithoutAnEngine(t *testing.T) {
828 ctx := context.Background()
829 s, _, idx, _ := queueFixture(t, 1)
830 enqueueN(t, s, idx, 1)
831
832 _, _, err := DrainApp(ctx, s, nil, idx, "bab", "titogo", 10, time.Now())
833 if !errors.Is(err, ErrNoEngine) {
834 t.Fatalf("err = %v, want ErrNoEngine", err)
835 }
836 left, lerr := s.List(ctx, QueuePrefix("bab", "titogo"))
837 if lerr != nil || len(left) != 1 {
838 t.Fatalf("a missing engine discarded the queue: %v, %v", left, lerr)
839 }
840}
841
842// The purge race, at the drain. The re-read before writing a result is the
843// only thing between an unrecordable verdict and an orphan under a purged
844// prefix — and that orphan does not merely leak: the claim gate refuses the
845// app's name for good, and materialize counts any key under the prefix and
846// fails toward keeping the row, so the deleted app comes BACK on the next boot.
847func TestResultObjectNotWrittenUnderPurgedPrefix(t *testing.T) {
848 ctx := context.Background()
849 s, sc, idx, _ := queueFixture(t, 1)
850 sc.Unrecordable(objectName(0)) // the ten-tag cap, in production
851 enqueueN(t, s, idx, 1)
852
853 // The teardown lands between the PER-OBJECT consent check (the second read
854 // of store.json, added when a review pointed out that one check per page
855 // is not one check per object) and drainOne's re-read before writing (the
856 // third). The re-read is the guard being tested here; without it the
857 // create-only write SUCCEEDS under a prefix nothing will ever list again.
858 //
859 // The count is 3 rather than 2 because there are now three reads of this
860 // key in a drain, not two. If it ever drifts again, the tell is this test
861 // failing with ErrQueueNotPermitted — that means the race moved to an
862 // EARLIER guard, which is the case TestPurgeBeforeTheObjectIsReadIsRefused
863 // below covers on purpose.
864 staged := &recordRaceStore{Store: s, key: Key("bab", "titogo"), before: 3, race: func() {
865 // What PurgeApp leaves behind: the prefix, emptied. Done directly
866 // because PurgeApp itself refuses over a provisioned record, and the
867 // teardown that clears it is not what this test is about.
868 keys, err := s.List(ctx, release.AppPrefix("bab", "titogo"))
869 if err != nil {
870 t.Error(err)
871 return
872 }
873 for _, k := range keys {
874 if err := s.Delete(ctx, k); err != nil {
875 t.Error(err)
876 }
877 }
878 }}
879
880 done, _, err := DrainApp(ctx, staged, sc, idx, "bab", "titogo", 10, time.Now())
881 // REPORTED, not swallowed. The first version returned (out, nil) here, so
882 // the drain read the group as a success: it deleted the items, put an
883 // outcome in done, and carried on scanning the rest of the page knowing
884 // consent was gone. The outcome it produced had Recorded false with no
885 // result object behind it, which task 16 would have turned into a callback
886 // saying "clean" about an object the status door answers "pending" about
887 // forever.
888 if !errors.Is(err, ErrQueueNotPermitted) {
889 t.Fatalf("drain err = %v, want ErrQueueNotPermitted", err)
890 }
891 if len(done) != 0 {
892 t.Errorf("done carries %d outcome(s) for a store that may not be scanned: %+v", len(done), done)
893 }
894 left, lerr := s.List(ctx, release.AppPrefix("bab", "titogo"))
895 if lerr != nil {
896 t.Fatal(lerr)
897 }
898 if len(left) != 0 {
899 t.Fatalf("the purged app's prefix still holds %v — its name can never be claimed again", left)
900 }
901}
902
903// TestPurgeBeforeTheObjectIsReadIsRefused is the guard the per-object consent
904// check adds, and it is the better of the two outcomes: the teardown lands
905// before this object's bytes are read at all, so nothing is fetched, nothing
906// is scanned and no result can be stranded.
907//
908// The page-level check alone could not do this. It runs once and then the loop
909// reads up to fifty objects, so a purge — or a revoked grant, or a member
910// re-attesting the store as client-encrypted — was honoured only from the next
911// tick, and every object still in the page was read under a consent that had
912// already been withdrawn. Found by an external security review.
913func TestPurgeBeforeTheObjectIsReadIsRefused(t *testing.T) {
914 ctx := context.Background()
915 s, sc, idx, _ := queueFixture(t, 1)
916 enqueueN(t, s, idx, 1)
917
918 staged := &recordRaceStore{Store: s, key: Key("bab", "titogo"), before: 2, race: func() {
919 keys, err := s.List(ctx, release.AppPrefix("bab", "titogo"))
920 if err != nil {
921 t.Error(err)
922 return
923 }
924 for _, k := range keys {
925 if err := s.Delete(ctx, k); err != nil {
926 t.Error(err)
927 }
928 }
929 }}
930
931 _, _, err := DrainApp(ctx, staged, sc, idx, "bab", "titogo", 10, time.Now())
932 if !errors.Is(err, ErrQueueNotPermitted) {
933 t.Fatalf("err = %v, want ErrQueueNotPermitted — consent was gone before this object was read", err)
934 }
935 if n := sc.Scans(objectName(0)); n != 0 {
936 t.Errorf("scanned %d time(s) after consent was withdrawn mid-page", n)
937 }
938 left, lerr := s.List(ctx, release.AppPrefix("bab", "titogo"))
939 if lerr != nil {
940 t.Fatal(lerr)
941 }
942 if len(left) != 0 {
943 t.Fatalf("the purged app's prefix still holds %v", left)
944 }
945}
946
947// recordRaceStore runs race() just before the Nth read of one key, so a test
948// can put a competing writer at a chosen point in a walk. Same shape as
949// walk_test.go's raceOnReadStore, which fires on the first read only.
950type recordRaceStore struct {
951 release.Store
952 key string
953 before int // fire immediately before this read, 1-based
954 race func()
955 seen int
956}
957
958func (r *recordRaceStore) Get(ctx context.Context, key string) ([]byte, string, error) {
959 if key == r.key {
960 r.seen++
961 if r.seen == r.before {
962 r.race()
963 }
964 }
965 return r.Store.Get(ctx, key)
966}
967
968func (r *recordRaceStore) ListPage(ctx context.Context, prefix, startAfter string, limit int) ([]string, error) {
969 return r.Store.(release.PageLister).ListPage(ctx, prefix, startAfter, limit)
970}
971
972// THE CONSENT CHAIN IS ONE FUNCTION AND THIS IS THE TABLE THAT SAYS SO.
973//
974// Spec §5.1.1: the enqueue door and the drain must both refuse unless every
975// link holds, and the reason they must not drift is that they have different
976// consequences for the same answer — the door turns it into an HTTP state, the
977// drain turns it into keep-or-delete (§7.5). A bool cannot carry that. It has
978// already been a bool through ten tasks, and every caller reconstructed the
979// reason from the record it was handed back, which is exactly the shape where
980// two callers start disagreeing.
981//
982// So the refusal is named, and the two consequences are read off the name. The
983// split this asserts is §7.5's, verbatim: everything upstream of the
984// deployment switch is permanent, because none of it can change back in a way
985// that makes THIS item useful; the switch itself is reversible, because an
986// operator turns it on again and the item is still worth draining.
987func TestQueueRefusalNamesEachLinkAndItsConsequence(t *testing.T) {
988 ctx := context.Background()
989
990 cases := []struct {
991 name string
992 // break mutates a fully permitted fixture to snap one link.
993 snap func(t *testing.T, s release.Store)
994 want QueueRefusal
995 permanent bool
996 }{
997 {
998 name: "nothing is broken",
999 snap: func(*testing.T, release.Store) {},
1000 want: RefusalNone,
1001 },
1002 {
1003 name: "the store was never granted",
1004 snap: func(t *testing.T, s release.Store) {
1005 mutateRecord(t, s, func(r *Record) { r.Provisioned = nil })
1006 },
1007 want: RefusalNotGranted, permanent: true,
1008 },
1009 {
1010 name: "the app is in the trash",
1011 snap: func(t *testing.T, s release.Store) {
1012 if err := release.TrashApp(ctx, s, "bab", "titogo", "op@example.com", time.Now().UTC()); err != nil {
1013 t.Fatal(err)
1014 }
1015 },
1016 want: RefusalTrashed, permanent: true,
1017 },
1018 {
1019 name: "the member never requested scanning",
1020 snap: func(t *testing.T, s release.Store) {
1021 mutateRecord(t, s, func(r *Record) { r.Scanning.Requested = nil })
1022 },
1023 want: RefusalNotRequested, permanent: true,
1024 },
1025 {
1026 name: "the member attested the store client-encrypted",
1027 snap: func(t *testing.T, s release.Store) {
1028 mutateRecord(t, s, func(r *Record) { r.Scanning.Requested.Encrypted = true })
1029 },
1030 want: RefusalEncrypted, permanent: true,
1031 },
1032 {
1033 name: "the operator never granted scanning",
1034 snap: func(t *testing.T, s release.Store) {
1035 mutateRecord(t, s, func(r *Record) { r.Scanning.Enabled = nil })
1036 },
1037 want: RefusalNotEnabled, permanent: true,
1038 },
1039 {
1040 // The one reversible link, and the reason Permanent() exists at
1041 // all: an operator runs `carlos system antivirus enable` and every
1042 // item still in the queue is worth draining. Delete these and a
1043 // deployment that was off for ten minutes loses a tenant's
1044 // accepted requests.
1045 name: "the deployment switch is off",
1046 snap: func(t *testing.T, s release.Store) {
1047 disableDeployment(t, s)
1048 },
1049 want: RefusalDisabled, permanent: false,
1050 },
1051 }
1052
1053 for _, tc := range cases {
1054 t.Run(tc.name, func(t *testing.T) {
1055 s, _, _, _ := queueFixture(t, 1)
1056 tc.snap(t, s)
1057
1058 rec, refusal, err := QueuePermitted(ctx, s, "bab", "titogo")
1059 if err != nil {
1060 t.Fatalf("QueuePermitted: %v", err)
1061 }
1062 if refusal != tc.want {
1063 t.Fatalf("refusal = %q, want %q", refusal, tc.want)
1064 }
1065 if refusal.Permanent() != tc.permanent {
1066 t.Fatalf("%q.Permanent() = %v, want %v — this decides whether the drain deletes the item or keeps it (§7.5)",
1067 refusal, refusal.Permanent(), tc.permanent)
1068 }
1069 if tc.want == RefusalNone && rec == nil {
1070 t.Fatal("a permitted app came back with no record")
1071 }
1072 })
1073 }
1074}
1075
1076// RefusalNone must never be Permanent: "keep going" is not a deletion reason,
1077// and a Permanent() written as `r != RefusalDisabled` would say it was.
1078func TestPermittedIsNotAPermanentRefusal(t *testing.T) {
1079 if RefusalNone.Permanent() {
1080 t.Fatal("RefusalNone.Permanent() is true — the drain would delete every item it was allowed to scan")
1081 }
1082}
1083
1084// A READ ERROR IS NOT A REFUSAL. It has no name in the chain because nothing
1085// about consent was learned: §7.5's "deletion is driven by absence, never by
1086// error". The error comes back as an error, and the refusal stays empty — so a
1087// caller that forgets to check err cannot mistake a bad bucket moment for
1088// permission, and equally cannot mistake it for a permanent no.
1089func TestQueuePermittedReportsAReadErrorAsAnError(t *testing.T) {
1090 s, _, _, _ := queueFixture(t, 1)
1091 broken := &queueStore{Store: s, failGetKey: "apps/bab/titogo/store.json"}
1092
1093 rec, refusal, err := QueuePermitted(context.Background(), broken, "bab", "titogo")
1094 if err == nil {
1095 t.Fatal("a failing store.json read came back as a clean answer")
1096 }
1097 if refusal != RefusalNone {
1098 t.Fatalf("refusal = %q on a read error, want the empty refusal — a named refusal here would be deleted on (§7.5)", refusal)
1099 }
1100 if rec != nil {
1101 t.Fatal("a record came back from a read that failed")
1102 }
1103}
1104
1105// mutateRecord reads, edits and writes back one store.json with the real
1106// etag, so a test can snap exactly one link of the consent chain and leave
1107// every other link intact.
1108func mutateRecord(t *testing.T, s release.Store, edit func(*Record)) {
1109 t.Helper()
1110 ctx := context.Background()
1111 rec, etag, err := Get(ctx, s, "bab", "titogo")
1112 if err != nil || rec == nil {
1113 t.Fatalf("re-read the record: %+v, %v", rec, err)
1114 }
1115 edit(rec)
1116 if _, err := Put(ctx, s, "bab", "titogo", rec, etag); err != nil {
1117 t.Fatal(err)
1118 }
1119}
1120
1121// disableDeployment turns the deployment switch off the way `carlos system
1122// antivirus disable` does — the record, not an env var.
1123func disableDeployment(t *testing.T, s release.Store) {
1124 t.Helper()
1125 ctx := context.Background()
1126 av, etag, err := release.GetAntivirus(ctx, s)
1127 if err != nil || av == nil {
1128 t.Fatalf("antivirus record: %+v, %v", av, err)
1129 }
1130 av.Enabled = false
1131 if _, err := release.PutAntivirus(ctx, s, av, etag); err != nil {
1132 t.Fatal(err)
1133 }
1134}
1135
1136// readErrorStore fails Get for one exact key, and behaves for everything else.
1137// A transient bad moment on one object, which is the case §7.5 separates from
1138// absence.
1139type readErrorStore struct {
1140 release.Store
1141 key string
1142 err error
1143}
1144
1145func (r *readErrorStore) Get(ctx context.Context, key string) ([]byte, string, error) {
1146 if key == r.key {
1147 return nil, "", r.err
1148 }
1149 return r.Store.Get(ctx, key)
1150}
1151
1152// TestRebuildKeepsAnItemItCouldNotRead pins the claim RebuildQueue's own
1153// comment makes and nothing tested: a STORE error is the store having a bad
1154// moment, so the item counts as live and the index still points at it.
1155//
1156// The opposite — treating an unreadable item as gone — silently strands it,
1157// because this rebuild is the only thing that would ever have listed that
1158// prefix again. §7.5's rule is that deletion is driven by absence, never by
1159// error, and this is that rule on the read side.
1160func TestRebuildKeepsAnItemItCouldNotRead(t *testing.T) {
1161 ctx := context.Background()
1162 s, _, idx, _ := queueFixture(t, 1)
1163 enqueueN(t, s, idx, 1)
1164
1165 keys, err := s.List(ctx, QueuePrefix("bab", "titogo"))
1166 if err != nil || len(keys) != 1 {
1167 t.Fatalf("staging: %v %v", keys, err)
1168 }
1169 broken := &readErrorStore{Store: s, key: keys[0], err: errors.New("s3 had a moment")}
1170
1171 fresh := NewQueueIndex()
1172 live, err := RebuildQueue(ctx, broken, fresh, "bab", "titogo", time.Now())
1173 if err != nil {
1174 t.Fatalf("a read error failed the whole rebuild: %v", err)
1175 }
1176 if live != 1 {
1177 t.Errorf("live = %d, want 1 — an item we could not read is not an item we know is gone", live)
1178 }
1179 if fresh.Len() != 1 {
1180 t.Error("the index does not point at the app, so nothing will ever list that prefix again")
1181 }
1182 left, lerr := s.List(ctx, QueuePrefix("bab", "titogo"))
1183 if lerr != nil || len(left) != 1 {
1184 t.Errorf("the item was deleted on a store error: %v %v", left, lerr)
1185 }
1186}
1187
1188// TestRebuildStillIndexesWhenADeleteFails is the blocker an adversarial review
1189// found: the bump used to happen only on the success path, so one failed
1190// Delete of an EXPIRED item returned early and left the app's LIVE items with
1191// nothing pointing at them — until the next restart, since this pass is the
1192// only thing that collects a stranded item.
1193func TestRebuildStillIndexesWhenADeleteFails(t *testing.T) {
1194 ctx := context.Background()
1195 s, _, idx, _ := queueFixture(t, 2)
1196
1197 // One expired item and one live one.
1198 if _, err := Enqueue(ctx, s, idx, "bab", "titogo",
1199 &QueueItem{Key: objectName(0), By: "app"}, time.Now().Add(-QueueTTL-time.Hour)); err != nil {
1200 t.Fatal(err)
1201 }
1202 if _, err := Enqueue(ctx, s, idx, "bab", "titogo",
1203 &QueueItem{Key: objectName(1), By: "app"}, time.Now()); err != nil {
1204 t.Fatal(err)
1205 }
1206 broken := &deleteErrorStore{Store: s, err: errors.New("s3 refused the delete")}
1207
1208 fresh := NewQueueIndex()
1209 live, err := RebuildQueue(ctx, broken, fresh, "bab", "titogo", time.Now())
1210 if err == nil {
1211 t.Fatal("the failed delete was not reported")
1212 }
1213 _ = live
1214 if fresh.Len() != 1 {
1215 t.Fatalf("index holds %d app(s) after a failed delete, want 1 — the live item is stranded until the next restart", fresh.Len())
1216 }
1217}
1218
1219// deleteErrorStore fails every Delete.
1220type deleteErrorStore struct {
1221 release.Store
1222 err error
1223}
1224
1225func (d *deleteErrorStore) Delete(context.Context, string) error { return d.err }
1226
1227// TestReusedVerdictCarriesNoProvenance is the one an adversarial review found
1228// missing, and the defect it pins was live: the outcome was stamped with the
1229// engine and signature database THIS TICK is configured with, before anything
1230// knew whether the engine had run.
1231//
1232// The scenario is ordinary. The hourly sweep tags an object clean under
1233// signature database 27100. A week later the app asks for a scan; the drain
1234// reads the tag, does not scan, and — before this — reported engine "clamav"
1235// and today's database 27412. That outcome becomes a SIGNED callback body,
1236// so the platform would have put its name to a claim about which database
1237// judged an object when no such judgement happened. The status door already
1238// refuses to make that guess, so the two surfaces disagreed and the signed
1239// one was the one lying.
1240//
1241// Empty is the honest answer. A verdict with no provenance is useful; a
1242// verdict with invented provenance is the failure the feature exists to stop.
1243func TestReusedVerdictCarriesNoProvenance(t *testing.T) {
1244 ctx := context.Background()
1245 s, sc, idx, bucket := queueFixture(t, 1)
1246
1247 // Pre-tag the object at its current version, as a sweep would have.
1248 obj := ScanObject{Key: objectName(0), ETag: "etag-" + objectName(0), Size: 10}
1249 status, seen, err := sc.Status(ctx, bucket, objectName(0))
1250 if err != nil {
1251 t.Fatal(err)
1252 }
1253 _ = status
1254 obj = seen
1255 sc.Put(bucket, objectName(0), obj.Version(), VerdictClean)
1256 enqueueN(t, s, idx, 1)
1257
1258 done, _, err := DrainApp(ctx, s, sc, idx, "bab", "titogo", 10, time.Now())
1259 if err != nil {
1260 t.Fatal(err)
1261 }
1262 if len(done) != 1 {
1263 t.Fatalf("outcomes = %+v, want one", done)
1264 }
1265 got := done[0]
1266 if got.Verdict != VerdictClean || !got.Recorded {
1267 t.Fatalf("outcome = %+v, want the tag's clean verdict", got)
1268 }
1269 if got.Scanned {
1270 t.Fatal("the engine ran for an object that already carried a verdict")
1271 }
1272 if got.Engine != "" || got.Signature != "" {
1273 t.Errorf("reused verdict claims engine %q signature %q — neither judged this object",
1274 got.Engine, got.Signature)
1275 }
1276}
1277
1278// TestScannedVerdictCarriesProvenance is the other half, without which the
1279// test above passes against a drain that never reports provenance at all.
1280func TestScannedVerdictCarriesProvenance(t *testing.T) {
1281 ctx := context.Background()
1282 s, sc, idx, _ := queueFixture(t, 1)
1283 enqueueN(t, s, idx, 1)
1284
1285 done, _, err := DrainApp(ctx, s, sc, idx, "bab", "titogo", 10, time.Now())
1286 if err != nil {
1287 t.Fatal(err)
1288 }
1289 if len(done) != 1 || !done[0].Scanned {
1290 t.Fatalf("outcomes = %+v, want one that was actually scanned", done)
1291 }
1292 if done[0].Engine == "" {
1293 t.Error("an outcome the engine really produced carries no engine name")
1294 }
1295}
1296
1297// TestDrainMetersOnlyBytesTheEngineRead pins Scanned to the bytes reaching
1298// the engine, not to the Scan call having been made. A skipped object is one
1299// over the size ceiling that was never read, and a denied one is a GET the
1300// console was refused; both used to count their full size against the tenant.
1301// The meter is unpriced and best-effort, but it must not overstate.
1302func TestDrainMetersOnlyBytesTheEngineRead(t *testing.T) {
1303 ctx := context.Background()
1304 s, sc, idx, _ := queueFixture(t, 4)
1305 sc.ScanAs(objectName(1), VerdictSkipped)
1306 sc.ScanAs(objectName(2), VerdictDenied)
1307 sc.ScanAs(objectName(3), VerdictInfected)
1308 enqueueN(t, s, idx, 4)
1309
1310 done, _, err := DrainApp(ctx, s, sc, idx, "bab", "titogo", 10, time.Now())
1311 if err != nil {
1312 t.Fatal(err)
1313 }
1314 byKey := map[string]Drained{}
1315 for _, d := range done {
1316 byKey[d.Key] = d
1317 }
1318 if len(byKey) != 4 {
1319 t.Fatalf("outcomes = %+v, want four", done)
1320 }
1321 for _, key := range []string{objectName(1), objectName(2)} {
1322 d := byKey[key]
1323 if n := sc.Scans(key); n != 1 {
1324 t.Fatalf("%s: the engine was asked %d time(s), want once — this test is about an answer the call gave", key, n)
1325 }
1326 if d.Scanned {
1327 t.Errorf("%s answered %q and is metered as scanned: %d bytes nothing read would be charged", key, d.Verdict, d.Size)
1328 }
1329 }
1330 for _, key := range []string{objectName(0), objectName(3)} {
1331 d := byKey[key]
1332 if !d.Scanned || d.Size != 10 {
1333 t.Errorf("%s answered %q with Scanned=%v Size=%d, want a scanned object of 10 bytes", key, d.Verdict, d.Scanned, d.Size)
1334 }
1335 }
1336}
1337
1338// TestRebuildWithNothingStaleDeletesNothing: the rebuild reads every item and
1339// enters its collect only when it found something to collect. Over a store
1340// whose every Delete fails, a prefix of live items rebuilds without error,
1341// which is only possible if no delete was attempted.
1342func TestRebuildWithNothingStaleDeletesNothing(t *testing.T) {
1343 ctx := context.Background()
1344 s, _, idx, _ := queueFixture(t, 2)
1345 enqueueN(t, s, idx, 2)
1346 broken := &deleteErrorStore{Store: s, err: errors.New("no delete may happen here")}
1347
1348 fresh := NewQueueIndex()
1349 live, err := RebuildQueue(ctx, broken, fresh, "bab", "titogo", time.Now())
1350 if err != nil {
1351 t.Fatalf("a rebuild with nothing to collect reached a delete: %v", err)
1352 }
1353 if live != 2 || fresh.Len() != 1 {
1354 t.Fatalf("live = %d, indexed apps = %d, want 2 and 1", live, fresh.Len())
1355 }
1356}
1357