Skip to content

fix: review hardening — provider correctness, write-path tests, release safety - #23

Merged
saby1101 merged 3 commits into
mainfrom
fix/review-hardening
Aug 14, 2026
Merged

saby1101 merged 3 commits into
mainfrom
fix/review-hardening

Conversation

@saby1101

Copy link
Copy Markdown
Member

Everything from the five-agent review that was ready to act on. Replaces #20, #21 and #22, which were the same work split across three reviews for no good reason.

Three commits, kept separate for bisect: correctness fixes, then the tests, then the release workflow.

Correctness (5078b8a)

GCS getAsStream resolved for missing objects. return gcsfile.createReadStream() did no I/O before returning, so the 404 arrived later as a stream 'error'. Every other provider rejects. A caller doing reply.send(await getAsStream(k)) sent a 200 with a broken body, or died on an unhandled 'error'. GCS is the production backend — this was the only finding with live exposure. Now checks existence first.

Dispatch accepted Object.prototype names. Configure[opts.type] walked the prototype chain:

type="toString"    -> registered, FileStore=UNDEFINED
type="constructor" -> registered, FileStore=UNDEFINED

A typo booted clean with nothing decorated. Guarded with hasOwnProperty.call — Object.hasOwn needs ES2022 and lib is ES2020; widening that is not a bug fix's business.

streamToBuffer could hang forever. A stream destroyed without an error emits only 'close', leaving the promise pending and hanging whichever request awaited getAsBuffer. Now rejects, with a settled guard so late events cannot settle twice.

Dead branch on local reads, and the tests that enshrined it. if (await fs.promises.stat(p)) can never be falsy, so File not found was unreachable and callers got raw ENOENT. Two specs reached it only by mocking stat to resolve undefined — a state the real filesystem never produces — asserting an error the code cannot throw, purely to satisfy the coverage threshold. getAsBuffer now reads directly; getAsStream keeps its stat deliberately, since createReadStream is lazy like the GCS reader.

Tests (2b57363)

Write failures were never asserted. Every write test checked a mock's arguments then resolved. Nothing fed a rejected write, so .catch(() => undefined) on any provider's write left the suite green. Six such mutations survived; two re-run here:

S3 save + .catch(() => undefined)       -> 1 test failed  (was: survived)
Azure Buffer.from(data,"utf8")->"ascii" -> 1 test failed  (was: survived)

no-floating-promises catches a dropped await, not a swallowed rejection — lint was never the backstop.

Adds a rejection test per write method across all providers, asserts Azure's getContainerClient argument (nothing checked it, so every operation could have targeted the wrong container), and covers unicode payloads — every fixture was ASCII, which is why the ascii mutation passed.

MinIO now runs in CI, and the integration spec reads a missing key — the real GetObject 404 path, the class of error the mocks cannot see and that #9 was about. 6 → 9 integration tests.

Started as a step, not a service container: services: cannot pass a command and the image needs server /data. My first attempt used bitnami/minio as a service, which would have failed — that image no longer exists on Docker Hub.

Release safety (1411ce9)

The trigger was wrong for drafts. types: [created] fires when a draft is saved (publishing from the default branch before the tag exists) and does not fire when that draft is released. Every release so far was created and published in one action, which is why it never bit — one click on "Save draft" breaks it both ways. Now [published].

Tag and version could disagree silently. A guard now fails the job when they differ; the tag is passed via env: rather than interpolated, so there is no injection surface.

publishConfig.registry pins GitHub Packages — CI only worked because setup-node writes the runner .npmrc; a local publish would target npmjs.org.

Verification

lint     clean
build    clean
tests    149 passed, 100% statements/branches/functions/lines
minio    9 passed against a real container

Source restored bit-identical after the mutation runs.

Deliberately not included

Bucket-existence probe. A renamed or misconfigured bucket makes every object read as missing — the silent-data-loss shape. HeadBucket at boot would catch it, but a pod with object-level IAM and no bucket-level permission would then fail to boot. Fail-vs-warn is a deployment decision, not a fix.

Local backend tidy — traversal guard, atomic writes, ENOTDIR and contentType parity. PLATFORM is unset in every manifest, so LocalFileStore is dev-only.

saby1101 and others added 3 commits August 14, 2026 12:09
GCS getAsStream returned createReadStream() without any I/O, so a missing object
resolved successfully and failed later as a stream 'error'. A caller doing
reply.send(await getAsStream(k)) sent a 200 with a broken body, or crashed the
process on an unhandled error. Every other provider rejects. GCS is the production
backend. It now checks existence first.

Plugin dispatch read Configure[opts.type] straight off an object literal, so
type:"toString" resolved Object.prototype.toString and registered successfully with
no FileStore decorated — a typo became a silent no-op boot rather than a failure.

streamToBuffer only handled data/error/end. A stream destroyed without an error
emits close alone, leaving the promise pending forever and hanging the request that
awaited getAsBuffer. It now rejects, and a settled guard keeps late events from
settling twice.

Local getAsBuffer/getAsStream wrapped reads in if (await stat(p)), which can never be
falsy since stat throws — so the File not found branch was unreachable and callers
got raw ENOENT. Two specs reached it only by mocking stat to resolve undefined, a
state the real filesystem never produces, and asserted an error the code cannot throw.
getAsBuffer now reads directly; getAsStream keeps the stat so a missing file rejects
rather than deferring to a stream error, matching the GCS fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every write test asserted only that a mock was called with the right arguments and
then resolved. Nothing fed a rejected write, so swallowing an error — dropping the
await, or attaching .catch(() => undefined) — left the suite green on all five
providers while writes silently failed. Six such mutations survived the previous
suite; two were re-run against this one and both now fail.

Adds a rejection test per write method across s3, gcs, azure and local, plus:

- Azure getContainerClient was never asserted, so every operation could target the
  wrong container unnoticed.
- Unicode payloads were untested — all fixtures were ASCII, so changing Azure's utf8
  encoding to ascii passed. Covered for azure, gcs and the MinIO round trip.
- The integration spec had no missing-key read. That is the real GetObject 404 path,
  the same error-shape class as the NoSuchKey/NotFound mismatch in #9, which mocks
  cannot catch.

CI now runs the integration suite against MinIO. It is started as a step rather than
a service container: services cannot pass a command and the minio image needs
"server /data". Skipping by default locally is right; never running in CI is not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…sion

The trigger was release:created. GitHub fires created when a draft release is saved,
which would publish from the default branch before the tag exists; it then fires
published (not created) when that draft is released, so the real release would ship
nothing. Every release so far was created and published in one action, which is why
this never bit.

The job also published whatever version happened to be in package.json at the release
commit, so a tag and a version could disagree silently. A guard now compares them and
fails the job.

publishConfig.registry pins GitHub Packages. CI worked only because setup-node writes
the runner .npmrc; a local npm publish would target npmjs.org and fail against a scope
we do not own.

Lint and build join the pre-publish test gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@saby1101
saby1101 merged commit 162aa13 into main Aug 14, 2026
2 checks passed
@saby1101
saby1101 deleted the fix/review-hardening branch August 14, 2026 12:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant