package console import ( "context" "encoding/json" "errors" "net/http" "net/url" "strings" "sync/atomic" "testing" "time" "github.com/carlosframework/platform/internal/objstore" "github.com/carlosframework/platform/internal/release" ) // revivalMutate changes the store record from inside the engine's Status hook, // which runs on the handler's goroutine, so it reports rather than t.Fatal. func revivalMutate(fx *consoleFixture, f func(*objstore.Record)) error { ctx := context.Background() rec, etag, err := objstore.Get(ctx, fx.rel, release.OpsAccount, "titogo") if err != nil { return err } if rec == nil { return errors.New("no record to mutate") } f(rec) _, err = objstore.Put(ctx, fx.rel, release.OpsAccount, "titogo", rec, etag) return err } func revivalTrash(fx *consoleFixture) error { body, err := json.Marshal(release.AppTombstone{ Schema: 1, Account: release.OpsAccount, App: "titogo", DeletedAt: time.Now().UTC(), Actor: "test", }) if err != nil { return err } _, err = fx.rel.Put(context.Background(), release.AppTombstoneKey(release.OpsAccount, "titogo"), body, "") return err } // TestRevivalLongPollRechecksConsentBeforeEveryRead: a consent change that lands // while a request is parked stops the next tag read, on both the wake path and // the final timeout reread, and is answered with the door's own refusal. func TestRevivalLongPollRechecksConsentBeforeEveryRead(t *testing.T) { for _, tc := range []struct { name string wake bool change func(*consoleFixture) error wantStatus int wantReason string }{ {name: "revoke before wake", wake: true, wantStatus: http.StatusConflict, wantReason: "not_enabled", change: func(fx *consoleFixture) error { return revivalMutate(fx, func(r *objstore.Record) { r.Scanning.Enabled = nil }) }}, {name: "unrequest before final reread", wantStatus: http.StatusConflict, wantReason: "not_requested", change: func(fx *consoleFixture) error { return revivalMutate(fx, func(r *objstore.Record) { r.Scanning = nil }) }}, {name: "encrypted before wake", wake: true, wantStatus: http.StatusConflict, wantReason: "encrypted", change: func(fx *consoleFixture) error { return revivalMutate(fx, func(r *objstore.Record) { r.Scanning.Requested.Encrypted = true }) }}, {name: "disable before final reread", wantStatus: http.StatusServiceUnavailable, wantReason: "disabled", change: func(fx *consoleFixture) error { av, etag, err := release.GetAntivirus(context.Background(), fx.rel) if err != nil || av == nil { return errors.New("no antivirus record") } av.Enabled = false _, err = release.PutAntivirus(context.Background(), fx.rel, av, etag) return err }}, {name: "trash before wake", wake: true, wantStatus: http.StatusNotFound, change: revivalTrash}, } { t.Run(tc.name, func(t *testing.T) { fx, sc, tok, bucket := statusArmed(t, "code-revival-"+strings.ReplaceAll(tc.name, " ", "-")) v := seedObject(sc, bucket, "uploads/x.pdf", "etag-x") // The change lands inside the FIRST read, after its consent check: // that read is allowed, and every read after it must not happen. // Any later read is handed a clean tag first, so a read that did // happen would also show as a 200 clean. var reads atomic.Int32 sc.OnStatus(func() { if reads.Add(1) != 1 { sc.Put(bucket, "uploads/x.pdf", v, objstore.VerdictClean) return } if err := tc.change(fx); err != nil { t.Errorf("change: %v", err) } if tc.wake { fx.srv.scanNotify(objstore.AppRef{Account: release.OpsAccount, App: "titogo"}, "uploads/x.pdf") } }) wait := "400ms" if tc.wake { wait = "20s" } start := time.Now() status, _, body := rawReq(t, http.MethodGet, enqueueURL(fx, "titogo")+"?key=uploads/x.pdf&wait="+wait, tok, "") if status != tc.wantStatus { t.Fatalf("status = %d %s, want %d", status, body, tc.wantStatus) } out := map[string]any{} if status != http.StatusNotFound { if err := json.Unmarshal([]byte(body), &out); err != nil { t.Fatalf("decode %q: %v", body, err) } } if tc.wantReason != "" && (out["reason"] != tc.wantReason || out["state"] != "unavailable") { t.Errorf("answer = %v, want unavailable %s", out, tc.wantReason) } if n := reads.Load(); n != 1 { t.Errorf("Status called %d times, want 1 — the door read the object after consent was withdrawn", n) } if tc.wake && time.Since(start) > 10*time.Second { t.Errorf("took %v — the wake was not what returned it", time.Since(start)) } }) } } // TestRevivalRecordedIsAJSONBoolean: `recorded` is a boolean on the wire. A // string "false" is truthy in most clients, which inverts its meaning. func TestRevivalRecordedIsAJSONBoolean(t *testing.T) { ctx := context.Background() fx, sc, tok, bucket := statusArmed(t, "code-revival-recorded") vTag := seedObject(sc, bucket, "uploads/tagged.pdf", "etag-t") sc.Put(bucket, "uploads/tagged.pdf", vTag, objstore.VerdictClean) vRes := seedObject(sc, bucket, "uploads/untaggable.pdf", "etag-u") if err := objstore.WriteScanResult(ctx, fx.rel, release.OpsAccount, "titogo", &objstore.ScanResult{ Key: "uploads/untaggable.pdf", Verdict: "infected", Version: vRes, Engine: "clamav", At: time.Now().UTC(), }); err != nil { t.Fatal(err) } seedObject(sc, bucket, "uploads/denied.pdf", "etag-d") sc.DenyHead(bucket, "uploads/denied.pdf") seedObject(sc, bucket, "uploads/pending.pdf", "etag-p") for _, tc := range []struct { key, state string recorded bool }{ {"uploads/tagged.pdf", "clean", true}, {"uploads/untaggable.pdf", "infected", false}, {"uploads/denied.pdf", "denied", false}, {"uploads/pending.pdf", "pending", false}, {"uploads/gone.pdf", "gone", false}, } { status, _, body := rawReq(t, http.MethodGet, enqueueURL(fx, "titogo")+"?key="+url.QueryEscape(tc.key), tok, "") if status != http.StatusOK { t.Fatalf("%s = %d %s", tc.key, status, body) } var out struct { State string `json:"state"` Recorded json.RawMessage `json:"recorded"` } if err := json.Unmarshal([]byte(body), &out); err != nil { t.Fatalf("%s: decode %q: %v", tc.key, body, err) } if out.State != tc.state { t.Errorf("%s: state = %q, want %q", tc.key, out.State, tc.state) } want := "false" if tc.recorded { want = "true" } if string(out.Recorded) != want { t.Errorf("%s: recorded = %s, want JSON boolean %s (body %s)", tc.key, out.Recorded, want, body) } } } // TestRevivalStatusKeyIsExact: the door asks about the key it was given, byte // for byte. A trimmed key would answer with another object's verdict. func TestRevivalStatusKeyIsExact(t *testing.T) { fx, sc, tok, bucket := statusArmed(t, "code-revival-key") v := seedObject(sc, bucket, "uploads/x.pdf", "etag-clean") sc.Put(bucket, "uploads/x.pdf", v, objstore.VerdictClean) seedObject(sc, bucket, " uploads/x.pdf ", "etag-padded") if _, out := getStatus(t, fx, tok, "key="+url.QueryEscape("uploads/x.pdf")); out["state"] != "clean" { t.Fatalf("control = %v, want clean", out) } if _, out := getStatus(t, fx, tok, "key="+url.QueryEscape(" uploads/x.pdf ")); out["state"] != "pending" { t.Errorf("padded key = %v, want its own pending — not the trimmed key's clean", out) } if _, out := getStatus(t, fx, tok, "key="+url.QueryEscape("uploads/x.pdf\t")); out["state"] != "gone" { t.Errorf("unseeded padded key = %v, want gone", out) } if status, _ := getStatus(t, fx, tok, "key="+url.QueryEscape(" \t ")); status != http.StatusBadRequest { t.Errorf("all-whitespace key = %d, want 400", status) } }