diff --git a/.github/workflows/npm-publish-github-packages.yml b/.github/workflows/npm-publish-github-packages.yml index e8d96a3..5faba15 100644 --- a/.github/workflows/npm-publish-github-packages.yml +++ b/.github/workflows/npm-publish-github-packages.yml @@ -1,11 +1,15 @@ -# This workflow will run tests using node and then publish a package to GitHub Packages when a release is created -# For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages +# Publishes to GitHub Packages when a release is published. +# See .claude/skills/release/SKILL.md for the release procedure. name: Node.js Package on: release: - types: [created] + # published, not created: GitHub fires `created` when a *draft* is saved, which + # would publish from the default branch before the tag exists — and then fires + # `published` (not `created`) when that draft is released, so the real release + # would ship nothing. + types: [published] env: NODE_VERSION: 24 @@ -21,6 +25,8 @@ jobs: node-version: ${{ env.NODE_VERSION }} cache: pnpm - run: pnpm install --frozen-lockfile + - run: pnpm run lint + - run: pnpm run build - run: pnpm test publish-gpr: @@ -38,9 +44,23 @@ jobs: cache: pnpm registry-url: https://npm.pkg.github.com/ - run: pnpm install --frozen-lockfile + + # The release tag and package.json must agree, or the published version silently + # differs from the tag people will go looking for. TAG comes through env rather + # than being interpolated into the script. + - name: Verify tag matches package.json version + env: + TAG: ${{ github.event.release.tag_name }} + run: | + VERSION="v$(node -p 'require("./package.json").version')" + if [ "$VERSION" != "$TAG" ]; then + echo "Release tag $TAG does not match package.json $VERSION" >&2 + exit 1 + fi + # npm publish, not pnpm publish: pnpm adds git-state checks the release flow # does not need. prepublishOnly runs the build either way. # No --provenance: GitHub Packages does not accept provenance attestations. - run: npm publish env: - NODE_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}} + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d4d3dfc..9329627 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,3 +23,31 @@ jobs: - run: pnpm run lint - run: pnpm run build - run: pnpm test + + # Started as a step, not a service: `services:` cannot pass a command, and the + # minio image needs `server /data`. Without this the integration spec skips, and + # skip-by-default plus never-in-CI means it rots — which matters because mocked + # tests cannot catch an SDK behaviour change. + - name: Start MinIO + run: | + docker run -d --name ci-minio -p 9000:9000 \ + -e MINIO_ROOT_USER=minioadmin \ + -e MINIO_ROOT_PASSWORD=minioadmin \ + minio/minio:latest server /data + for i in $(seq 1 30); do + if curl -sf http://127.0.0.1:9000/minio/health/live; then + echo "minio ready"; exit 0 + fi + sleep 2 + done + echo "minio did not become healthy" >&2 + docker logs ci-minio >&2 + exit 1 + + - run: pnpm run test:integration + env: + MINIO_TEST_ENDPOINT: http://127.0.0.1:9000 + + - name: Stop MinIO + if: always() + run: docker rm -f ci-minio || true diff --git a/package.json b/package.json index b2d3cf6..74e5350 100644 --- a/package.json +++ b/package.json @@ -63,5 +63,8 @@ "typescript": "^6.0.3", "typescript-eslint": "^8.67.0" }, - "packageManager": "pnpm@10.13.1" + "packageManager": "pnpm@10.13.1", + "publishConfig": { + "registry": "https://npm.pkg.github.com/" + } } diff --git a/src/file-store.azure.spec.ts b/src/file-store.azure.spec.ts index 873a79a..5667670 100644 --- a/src/file-store.azure.spec.ts +++ b/src/file-store.azure.spec.ts @@ -10,6 +10,7 @@ describe("AzureFileStore", () => { let fastify: ReturnType; let mockBlobClient: any; let mockContainerClient: any; + let mockGetContainerClient: jest.Mock; let store: FileStore; beforeEach(async () => { @@ -30,8 +31,9 @@ describe("AzureFileStore", () => { getBlockBlobClient: jest.fn(() => mockBlobClient), }; + mockGetContainerClient = jest.fn(() => mockContainerClient); (BlobServiceClient as unknown as jest.Mock).mockImplementation(() => ({ - getContainerClient: jest.fn(() => mockContainerClient), + getContainerClient: mockGetContainerClient, })); process.env.AZURE_STORAGE_ACCOUNT_URL = @@ -58,6 +60,12 @@ describe("AzureFileStore", () => { expect(store).toBeDefined(); }); + // Nothing asserted the container name, so every operation could target the wrong + // container and the suite would stay green. + it("opens the container named by AZURE_STORAGE_CONTAINER", () => { + expect(mockGetContainerClient).toHaveBeenCalledWith("test-container"); + }); + it("should throw when AZURE_STORAGE_ACCOUNT_URL is missing", async () => { delete process.env.AZURE_STORAGE_ACCOUNT_URL; const f = Fastify(); @@ -102,6 +110,27 @@ describe("AzureFileStore", () => { ); }); + // Without this, changing the utf8 encoding to ascii mangles every non-ASCII byte + // and the suite stays green — all other payload fixtures are ASCII. + it("encodes a unicode payload as utf8", async () => { + mockBlobClient.uploadData.mockResolvedValueOnce({}); + + await store.save("u.txt", "text/plain", "héllo→世界"); + + expect(mockBlobClient.uploadData).toHaveBeenCalledWith( + Buffer.from("héllo→世界", "utf8"), + { blobHTTPHeaders: { blobContentType: "text/plain" } }, + ); + }); + + it("propagates a rejected upload, not just an errorCode", async () => { + mockBlobClient.uploadData.mockRejectedValueOnce(new Error("AuthFailure")); + + await expect(store.save("a.txt", "text/plain", "x")).rejects.toThrow( + "AuthFailure", + ); + }); + it("should pass a Buffer payload through untouched", async () => { mockBlobClient.uploadData.mockResolvedValueOnce({}); const data = Buffer.from([1, 2, 3]); @@ -140,6 +169,24 @@ describe("AzureFileStore", () => { }); }); + describe("write failures", () => { + it("propagates a rejected uploadFile", async () => { + mockBlobClient.uploadFile.mockRejectedValueOnce(new Error("io error")); + + await expect( + store.copyFromLocalFile("a.txt", "text/plain", "/tmp/x"), + ).rejects.toThrow("io error"); + }); + + it("propagates a rejected uploadStream", async () => { + mockBlobClient.uploadStream.mockRejectedValueOnce(new Error("aborted")); + + await expect( + store.copyFromStream("a.txt", "text/plain", Readable.from(["x"])), + ).rejects.toThrow("aborted"); + }); + }); + describe("copyFromLocalFile", () => { it("should upload the local file", async () => { mockBlobClient.uploadFile.mockResolvedValueOnce({}); diff --git a/src/file-store.gcs.spec.ts b/src/file-store.gcs.spec.ts index e423b18..7af6b1f 100644 --- a/src/file-store.gcs.spec.ts +++ b/src/file-store.gcs.spec.ts @@ -88,6 +88,24 @@ describe("GCPFileStore", () => { contentType: "text/plain", }); }); + + it("propagates a failed save", async () => { + mockFile.save.mockRejectedValueOnce(new Error("quota exceeded")); + + await expect( + store.save("test.txt", "text/plain", "content"), + ).rejects.toThrow("quota exceeded"); + }); + + it("round-trips a unicode payload unchanged", async () => { + mockFile.save.mockResolvedValueOnce(undefined); + + await store.save("u.txt", "text/plain", "héllo→世界"); + + expect(mockFile.save).toHaveBeenCalledWith("héllo→世界", { + contentType: "text/plain", + }); + }); }); describe("getAsBuffer", () => { @@ -113,15 +131,35 @@ describe("GCPFileStore", () => { destination: "dest.txt", }); }); + + it("propagates a failed upload", async () => { + mockBucket.upload.mockRejectedValueOnce(new Error("upload failed")); + + await expect( + store.copyFromLocalFile("dest.txt", "text/plain", "/tmp/src.txt"), + ).rejects.toThrow("upload failed"); + }); }); describe("getAsStream", () => { it("should return the read stream", async () => { const rs = Readable.from(["data"]); + mockFile.exists.mockResolvedValueOnce([true]); mockFile.createReadStream.mockReturnValueOnce(rs); expect(await store.getAsStream("test.txt")).toBe(rs); }); + + // createReadStream does no I/O, so without an existence check a missing object + // resolves and fails later as a stream 'error'. Every other provider rejects. + it("rejects for a missing object instead of returning a doomed stream", async () => { + mockFile.exists.mockResolvedValueOnce([false]); + + await expect(store.getAsStream("missing.txt")).rejects.toThrow( + "File not found: missing.txt", + ); + expect(mockFile.createReadStream).not.toHaveBeenCalled(); + }); }); describe("copyFromStream", () => { @@ -147,6 +185,19 @@ describe("GCPFileStore", () => { }); expect(Buffer.concat(chunks).toString()).toBe("piped content"); }); + + it("propagates a write-stream failure", async () => { + const ws = new Writable({ + write(_chunk, _enc, cb) { + cb(new Error("disk full")); + }, + }); + mockFile.createWriteStream.mockReturnValueOnce(ws); + + await expect( + store.copyFromStream("test.txt", "text/plain", Readable.from(["x"])), + ).rejects.toThrow("disk full"); + }); }); describe("getInfo", () => { diff --git a/src/file-store.local.spec.ts b/src/file-store.local.spec.ts index cf03d90..7bf0318 100644 --- a/src/file-store.local.spec.ts +++ b/src/file-store.local.spec.ts @@ -75,6 +75,19 @@ describe("LocalFileStore", () => { fastify.register(FileStorePlugin, { type: "nope" as any }), ).rejects.toThrow("Unknown storage type: nope"); }); + + // A plain lookup finds Object.prototype.toString and registers with no FileStore + // decorated — a typo becoming a silent no-op boot instead of a failure. + it.each(["toString", "constructor", "valueOf", "hasOwnProperty"])( + "rejects the inherited Object.prototype name %s", + async (type) => { + const f = Fastify({ logger: false }); + await expect( + f.register(FileStorePlugin, { type: type as never }), + ).rejects.toThrow(`Unknown storage type: ${type}`); + await f.close(); + }, + ); }); describe("exists", () => { @@ -165,12 +178,11 @@ describe("LocalFileStore", () => { expect((await store.getAsBuffer("read.txt")).toString()).toBe("buffered"); }); - it("throws File not found when stat yields nothing", async () => { + it("rejects with ENOENT for a missing file", async () => { const store = await register(); - jest.spyOn(fs.promises, "stat").mockResolvedValue(undefined); - await expect(store.getAsBuffer("ghost.txt")).rejects.toThrow( - `File not found: ${path.join(tempDir, "ghost.txt")}`, - ); + await expect(store.getAsBuffer("ghost.txt")).rejects.toMatchObject({ + code: "ENOENT", + }); }); }); @@ -182,12 +194,12 @@ describe("LocalFileStore", () => { expect((await streamToBuffer(rs)).toString()).toBe("streamed"); }); - it("throws File not found when stat yields nothing", async () => { + // Rejects rather than deferring to a stream 'error': createReadStream is lazy. + it("rejects with ENOENT for a missing file", async () => { const store = await register(); - jest.spyOn(fs.promises, "stat").mockResolvedValue(undefined); - await expect(store.getAsStream("ghost.txt")).rejects.toThrow( - `File not found: ${path.join(tempDir, "ghost.txt")}`, - ); + await expect(store.getAsStream("ghost.txt")).rejects.toMatchObject({ + code: "ENOENT", + }); }); }); @@ -215,6 +227,19 @@ describe("LocalFileStore", () => { await fs.promises.readFile(path.join(tempDir, "x/y/z.txt"), "utf8"), ).toBe("piped"); }); + + it("propagates a source stream error", async () => { + const store = await register(); + const bad = new Readable({ + read() { + this.destroy(new Error("source failed")); + }, + }); + + await expect( + store.copyFromStream("bad.txt", "text/plain", bad), + ).rejects.toThrow("source failed"); + }); }); }); diff --git a/src/file-store.minio.integration.spec.ts b/src/file-store.minio.integration.spec.ts index bd05aa8..2f86c0a 100644 --- a/src/file-store.minio.integration.spec.ts +++ b/src/file-store.minio.integration.spec.ts @@ -64,6 +64,26 @@ describeIf("MinIO integration", () => { expect(await store.getInfo(`missing-${Date.now()}.txt`)).toBeNull(); }); + // The real GetObject 404 path. Mocks cannot catch a change in this error shape — + // the NoSuchKey/NotFound mismatch fixed in #9 was invisible to them. + it("rejects getAsBuffer for a missing key", async () => { + await expect( + store.getAsBuffer(`missing-${Date.now()}.txt`), + ).rejects.toBeDefined(); + }); + + it("rejects getAsStream for a missing key", async () => { + await expect( + store.getAsStream(`missing-${Date.now()}.txt`), + ).rejects.toBeDefined(); + }); + + it("round-trips a unicode payload and key", async () => { + const key = `unicode-héllo-世界-${Date.now()}.txt`; + await store.save(key, "text/plain", "héllo→世界"); + expect((await store.getAsBuffer(key)).toString()).toBe("héllo→世界"); + }); + it("round-trips a string payload", async () => { const key = `round-trip-${Date.now()}.txt`; await store.save(key, "text/plain", "hello minio"); diff --git a/src/file-store.s3.spec.ts b/src/file-store.s3.spec.ts index c5fcb2b..598a19d 100644 --- a/src/file-store.s3.spec.ts +++ b/src/file-store.s3.spec.ts @@ -208,6 +208,16 @@ describe("S3FileStore", () => { }); expect(mockSend).toHaveBeenCalledWith(expect.any(S3.PutObjectCommand)); }); + + // Without this, deleting the await or attaching .catch(() => undefined) leaves + // the suite green while every write silently fails. + it("propagates a failed PutObject", async () => { + mockSend.mockRejectedValueOnce(new Error("AccessDenied")); + + await expect( + store.save("a/b.txt", "text/plain", "content"), + ).rejects.toThrow("AccessDenied"); + }); }); describe("getAsBuffer", () => { @@ -264,6 +274,14 @@ describe("S3FileStore", () => { params.params.Body.destroy(); }); + + it("propagates a failed upload", async () => { + mockDone.mockRejectedValueOnce(new Error("upload aborted")); + + await expect( + store.copyFromLocalFile("a/b.ts", "text/plain", __filename), + ).rejects.toThrow("upload aborted"); + }); }); describe("copyFromStream", () => { @@ -283,6 +301,14 @@ describe("S3FileStore", () => { }); expect(mockDone).toHaveBeenCalled(); }); + + it("propagates a failed upload", async () => { + mockDone.mockRejectedValueOnce(new Error("premature close")); + + await expect( + store.copyFromStream("a/b.txt", "text/plain", Readable.from(["x"])), + ).rejects.toThrow("premature close"); + }); }); describe("getInfo", () => { diff --git a/src/file-store.ts b/src/file-store.ts index f08bd80..9f48012 100644 --- a/src/file-store.ts +++ b/src/file-store.ts @@ -79,7 +79,11 @@ const Configure = { const plugin: FastifyPluginAsync<{ type: keyof typeof Configure; }> = async function (f, opts): Promise { - const configure = Configure[opts.type]; + // hasOwn, not a plain lookup: `Configure["toString"]` finds Object.prototype.toString + // and registers with no FileStore decorated, turning a typo into a silent no-op boot. + const configure = Object.prototype.hasOwnProperty.call(Configure, opts.type) + ? Configure[opts.type] + : undefined; if (!configure) { throw new Error(`Unknown storage type: ${opts.type}`); } @@ -127,11 +131,10 @@ class LocalFileStore implements FileStore { await fs.promises.writeFile(p, data); } async getAsBuffer(filepath: string): Promise { - const p = path.join(this.dir, filepath); - if (await fs.promises.stat(p)) { - return fs.promises.readFile(p); - } - throw new Error(`File not found: ${p}`); + // No stat guard: a fulfilled stat is never falsy, so the old "File not found" + // branch was unreachable and readFile already rejects with ENOENT. Dropping it + // also removes a redundant stat and the TOCTOU window between the two calls. + return fs.promises.readFile(path.join(this.dir, filepath)); } async copyFromLocalFile( filepath: string, @@ -145,10 +148,10 @@ class LocalFileStore implements FileStore { } async getAsStream(filepath: string): Promise { const p = path.join(this.dir, filepath); - if (await fs.promises.stat(p)) { - return fs.createReadStream(p); - } - throw new Error(`File not found: ${p}`); + // stat first so a missing file rejects the promise rather than surfacing later as + // a stream 'error' — createReadStream is lazy, like the GCS reader. + await fs.promises.stat(p); + return fs.createReadStream(p); } async copyFromStream( filepath: string, @@ -302,6 +305,14 @@ class GCPFileStore implements FileStore { async getAsStream(filepath: string): Promise { const gcsfile = this.storage.bucket(this.bucket).file(filepath); + // createReadStream does no I/O before returning, so without this check a missing + // object resolves and only fails later as a stream 'error' — which a caller doing + // `reply.send(await getAsStream(k))` turns into a 200 with a broken body, or an + // unhandled 'error' that takes the process down. Every other provider rejects. + const [exists] = await gcsfile.exists(); + if (!exists) { + throw new Error(`File not found: ${filepath}`); + } return gcsfile.createReadStream(); } diff --git a/src/utils.spec.ts b/src/utils.spec.ts index 9e40900..5462bf5 100644 --- a/src/utils.spec.ts +++ b/src/utils.spec.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from "node:events"; import { Readable } from "node:stream"; import { DataStream, streamToBuffer } from "./utils"; @@ -163,15 +164,56 @@ describe("Utils", () => { }); }); - describe("DataStream interface", () => { - it("should define the correct interface structure", () => { - // This is a compile-time test to ensure the interface is properly defined - const mockStream: DataStream = { - on: jest.fn().mockReturnThis(), - }; + describe("premature close", () => { + // A stream destroyed without an error emits only 'close'. Before this was handled + // the promise stayed pending forever and hung the request awaiting getAsBuffer. + it("rejects when the stream is destroyed without an error", async () => { + const stream = new Readable({ read() {} }); + stream.push("partial"); + setImmediate(() => stream.destroy()); + + await expect(streamToBuffer(stream)).rejects.toThrow( + "stream closed before end", + ); + }); + + it("rejects with the original error when destroyed with one", async () => { + const stream = new Readable({ read() {} }); + setImmediate(() => stream.destroy(new Error("boom"))); + + await expect(streamToBuffer(stream)).rejects.toThrow("boom"); + }); + + it("still resolves normally when close follows end", async () => { + const stream: DataStream = Readable.from(["a", "b"]); + + expect((await streamToBuffer(stream)).toString()).toBe("ab"); + }); + + // Late events must not settle the promise a second time. EventEmitter is used + // directly so the exact sequence can be driven; a real Readable will not emit + // 'error' after 'end'. + it("ignores an error arriving after end", async () => { + const em = new EventEmitter() as unknown as DataStream; + const p = streamToBuffer(em); + const e = em as unknown as EventEmitter; + e.emit("data", Buffer.from("ok")); + e.emit("end"); + e.emit("error", new Error("too late")); + e.emit("close"); + + expect((await p).toString()).toBe("ok"); + }); + + it("ignores end and close arriving after an error", async () => { + const em = new EventEmitter() as unknown as DataStream; + const p = streamToBuffer(em); + const e = em as unknown as EventEmitter; + e.emit("error", new Error("first")); + e.emit("end"); + e.emit("close"); - expect(typeof mockStream.on).toBe("function"); - expect(mockStream.on("test", () => {})).toBe(mockStream); + await expect(p).rejects.toThrow("first"); }); }); }); diff --git a/src/utils.ts b/src/utils.ts index 6468f0d..346b7a7 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -8,13 +8,29 @@ export interface DataStream { export function streamToBuffer(stream: DataStream): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; + let settled = false; + stream.on("data", (chunk: Buffer | string) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)), ); // Typed as Error so the rejection carries a stack; node streams always emit one. - stream.on("error", (err: Error) => reject(err)); - stream.on("end", () => - resolve(chunks.length === 1 ? chunks[0] : Buffer.concat(chunks)), - ); + stream.on("error", (err: Error) => { + if (settled) return; + settled = true; + reject(err); + }); + stream.on("end", () => { + if (settled) return; + settled = true; + resolve(chunks.length === 1 ? chunks[0] : Buffer.concat(chunks)); + }); + // A stream destroyed without an error argument emits only 'close', which would + // otherwise leave this promise pending forever and hang the awaiting request. + // 'close' follows 'end' in the normal path, where the settled flag ignores it. + stream.on("close", () => { + if (settled) return; + settled = true; + reject(new Error("stream closed before end")); + }); }); }