Skip to content

QA run · attachments-storage (FULL area) · 92f26f75 · 2026-08-11 · 4 PASS / 4 PARTIAL #7635

Description

@baozhoutao

Full attachments-storage area run of the checklist-test skill — all 8 items driven against a live showcase (opus subagent, isolated port + file DB, admin + two purpose-provisioned member personas). Text-only per RUNNER.md.

Result: 4 PASS · 4 PARTIAL · 0 FAIL. The security core of this area is genuinely solid — every partial is a surfacing/coverage gap, not a broken gate.

Environment — framework 92f26f75 (branch claude/platform-test-checklist-ocwugl) · vendored console pin 6314e87f · showcase app · isolated port + file DB.

✅ PASS — 4 items

  • presigned-upload-roundtrip (5/5 + both negatives) — presign → PUT → complete → download round-trips with a sha256 match on the retrieved bytes; status moves pendingcommitted; the /files/:id/url response is the service-storage success bodies are three shapes, none carrying success: true — the other half of #3675 #3689{success,data:{url}} envelope with no top-level url key; the 302 door resolves to the same bytes. Owner spoofing refused: a presign carrying owner_id:'FORGED-OWNER' and status:'committed' still stored the session user's id and pending. All three upload entry points 401 AUTH_REQUIRED anonymously, and the boot log carries no open-mode notice.
  • read-inherits-parent-rls (4/4 + both negatives) — attachment rows inherit the parent's row-level visibility on bothfind and count: invisible parent → admin 5 rows / restricted persona 0 (HTTP 200, filtered, nothing leaked); by-id read → 404 RECORD_NOT_FOUND for the restricted persona, 200 for admin. Driven at scale: bulked to 2100 join rows to reach the READ_SCAN_LIMIT branch — the server logs the fail-closed warning and the read stays closed (2095 vs 2100, exactly the 5 withheld rows). A per-row probe found zero rows whose parent the persona cannot read, at both 12-row and 2100-row scale.
  • sys-file-status-pipeline (5/5 + both negatives) — one file driven through every real transition with a separate read after each: pendingcommitted → (last join row deleted) deleted + deleted_at → (re-attach) committed + deleted_at null. Gated downloads 404 FILE_NOT_FOUND during both the pending and the tombstoned window. The tombstone fires only on the last reference (two join rows over one file: deleting the first changes nothing), and a scope:'user' control file is left untouched by the join-row hooks.
  • orphan-tombstone-reap (4/4 + both negatives) — pins run first per RUNNER rule 6 (15 passed/1 skipped + 25/25), then hand-driven against the platform's own sweep (the LifecycleService initial sweep, not a bespoke sweeper): a 40-day-old tombstone lost both its row and its bytes on disk; a tombstone that regained a reference behind the hooks' back was un-tombstoned, not reaped, with the declared reap-guard log line; a fresh in-window tombstone, a committed row and a pending row all survived untouched. No half-reaped state in either direction.

🟡 PARTIAL — 4 items

1. download-authz-both-sides — server gates are perfect; the console reports a denial as "no attachments"

Server side is fully proven, on both gating classes (attachments-scope and field-owned) and both routes (/files/:id/url and the 302 door): anonymous → 401 AUTH_REQUIRED; two real member personas → 403 ATTACHMENT_DOWNLOAD_DENIED / FILE_DOWNLOAD_DENIED, with their inability to read the parent proven independently first; an entitled non-owner → 200 with matching bytes; the owner bypass on an unreadable parent → 200 for the owner while a sibling file on the identical parent is 403. Flipping acl to public_read opens the anonymous door and restoring private re-closes it. TTL contrast observed: gated grant tokens ≈300 s (downloadTtl) vs ≈3600 s (presignedTtl).

Clause 4 is partial (console surfacing). On a record whose attachment list 403s, the panel renders "No attachments yet. Upload a file to get started." — and still advertises an Upload affordance the server would refuse.

Reproduction rule — as a member denied the parent, open a record with attachments: the panel fires GET /api/v1/data/sys_attachment?...filter=parent_object=…, receives 403, and renders the empty state. Expected denial copy. Actual empty-state copy on a record that in fact holds 2095+ attachments.

The objectui#2532 failure mode (raw error dump / leaked row) is absent — nothing leaks, and no status code is shown. The gap is that a 40x is swallowed into the empty state rather than mapped to denied, so an operator cannot tell "nothing here" from "not yours".

2. attach-requires-parent-edit — gates all hold; one clause is blocked by a stale built @objectstack/core

Passing: a read-but-not-edit persona is refused 403 ATTACHMENT_PARENT_ACCESS on attach while still listing that parent's attachments (read inherits parent READ, attach requires parent EDIT — exactly the contract); uploaded_by is server-stamped over a spoofed value; delete is refused for a non-uploader without parent edit (403 ATTACHMENT_DELETE_DENIED, row still present) and allowed for the uploader even on a parent they cannot edit; deleteMany is all-or-nothing (one refused row aborted the batch, 14→14 rows, both survivors present); an object without enable.files → 403 FILES_DISABLED.

Clause 3 is blocked(environment), and this one needs attention on its own: the predicate-less multi-delete shape cannot be expressed over REST (deleteMany with no ids/where → 400 before the hook is reached), so it rests on its unit pin — and that pin cannot execute in this checkout. All 17 cases in packages/services/service-storage/src/attachment-access-hooks.test.ts error with:

TypeError: withoutOperationPrivateKeys is not a function

because the built @objectstack/core the test resolves is stale relative to packages/core/src/security/operation-private-keys.ts. That is a stale-dist artifact of the prebuilt tree, not a runtime defect — the live server enforced every other gate in this item correctly — but it means the #4757 unscoped-multi-delete refusal is currently unguarded by an executable test here.

3. upload-session-abort — two of the five declared sys_upload_session.status values have no producer

The chunked lane works end to end: init → chunk PUT → progress (percentComplete:50) → complete → committed file → download matches. A wrong resume token is 403 INVALID_RESUME_TOKEN. An abandoned session is reaped with its multipart aborted first, a completed session is reaped without an abort attempt, and the abort-failure veto is pinned green.

Clause 4 is partial. A scan of all session rows after driving both flows returned exactly ['in_progress','completed']. completing is written transiently and immediately overwritten. failed and expired have no writer anywhere in the service — a grep across packages finds them only in the enum declaration and in the retention onlyWhen ({status:{$in:['completed','failed','expired']}}) in system-upload-session.object.ts, plus tests. An expired session's row is reaped by TTL, never re-statused. So the retention rule references two states the system can never enter — an enforce-or-remove candidate.

Clause 3 is also partial on its oracle only: the local adapter's abortChunkedUpload cannot be made to fail from outside the process, so the [storage] reap guard: multipart abort failed line was never emitted live; the veto behaviour itself is pinned and passing.

Contract note: the route takes totalSize (not size) and forces chunkSize ≥ 5 MiB; the item's step text says size.

4. inline-grid-receipt-cells — works correctly; the clause text predates ADR-0104

The grid auto-derives exactly one Receipt column with a real input[type=file] (zero receipt-labelled text inputs — objectui#2360 does not reproduce). Picking a file produces a genuine presign → PUT → complete → thumbnail trace, and submitting the form emits one atomicPOST /api/v1/batch with atomic:true and a {"$ref":0} parent reference, persisting both records.

Clause 3 is partial as a checklist-vs-product drift, not a defect. The clause expects a resolved stored-file object carrying receipt.name and an absolute http(s) URL; the shipped payload is the bare opaque sys_file id. What matters was proven: the persisted value is a managed reference, not a data:/blob: placeholder — the sys_file row reads {status:'committed', ref_object:'showcase_invoice_line', ref_id:…, ref_field:'receipt', acl:'private'}, i.e. the ADR-0104 field-ownership claim fired, and the file is correctly gated (anon 401 / two members 403 / admin 200). storage-routes.ts documents the id as "the value a file field stores as a reference". The clause should be restated against ADR-0104 D3.

Fixtures — the item knownGaps were closed, not skipped

The area's declared gap ("no private files-enabled parent exists on stock showcase" — the only enable.files object is public_read_writeand readable via the showcase everyone baseline) would have forced blocked(fixture) on the entire authz core. It was closed by authoring scratch objects at runtime (POST /api/v1/packages then PUT /meta/objects/qa_vault?package=… with sharingModel:'private', plus qa_shared public_read and qa_nofiles) and provisioning two real personas via sign-up + a runtime permission-set/position binding. No repo file was touched. Worth landing as a permanent fixture so this area does not need re-provisioning each run.

Checklist maintenance falling out of this run

  1. inline-grid-receipt-cells clause 3 — restate against ADR-0104 D3 (opaque id, not a resolved object).
  2. upload-session-abort clause 4 — either give failed/expired a writer or drop them from the enum and the retention onlyWhen.
  3. attach-requires-parent-edit clause 3 — the pin it depends on cannot execute against the prebuilt @objectstack/core; needs a rebuild in CI or the clause needs a live oracle.
  4. upload-session-abort step text says size; the route takes totalSize.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingqa-run

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions