diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..39f12d3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,53 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run lint + + test-unit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm test + + test-docker: + runs-on: ubuntu-latest + needs: [lint, test-unit] + strategy: + fail-fast: false + matrix: + runtime: [node, bun, deno] + include: + - runtime: node + integration_cmd: npx vitest run test/integration + - runtime: bun + integration_cmd: bunx vitest run test/integration + - runtime: deno + integration_cmd: deno run -A npm:vitest run test/integration + steps: + - uses: actions/checkout@v4 + - name: Build test-${{ matrix.runtime }} + run: docker compose build test-${{ matrix.runtime }} + - name: Unit tests (${{ matrix.runtime }}) + run: docker compose run --rm test-${{ matrix.runtime }} + - name: Integration tests (${{ matrix.runtime }}) + run: docker compose run --rm test-${{ matrix.runtime }} ${{ matrix.integration_cmd }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c08809..cf78fbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,14 +5,48 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [Unreleased] — M2 Boomerang (v1.2.0) ### Added -- None. +- `CasService.restore()` — reconstruct files from manifests with per-chunk SHA-256 integrity verification. +- `ContentAddressableStore.restoreFile()` — facade method that restores and writes to disk. +- `readTree()` on `GitPersistencePort` / `GitPersistenceAdapter` — parse Git trees via `ls-tree`. +- `STREAM_ERROR` wrapping — stream failures during `store()` surface as `CasError('STREAM_ERROR')` with `{ chunksWritten }` metadata. +- `MISSING_KEY` error code — `restore()` now fails fast when manifest is encrypted but no decryption key is provided. +- CLI: `git cas store`, `git cas tree`, `git cas restore` subcommands via `bin/git-cas.js`. +- Integration test suite (59 tests) running against real Git bare repos inside Docker. +- `commander` dependency for CLI. ### Changed +- `readBlob()` now normalises `Uint8Array` from plumbing into `Buffer` for codec/crypto compatibility. +- `readTree()` uses `git ls-tree -z` (NUL-delimited output) for safe parsing of filenames with leading/trailing spaces. + +### Fixed +- Fuzz tests in stream-error suite now fail explicitly if `store()` does not throw. +- ROADMAP: resolved inconsistent CLI signatures for `git cas tree` (`--slug` vs `--manifest`). + +### Security - None. +## [1.1.0] — M1 Bedrock + +### Added +- `CryptoPort` interface and `NodeCryptoAdapter` — extracted all `node:crypto` usage from the domain layer. +- `CasService.store()` — accepts `AsyncIterable` sources (renamed from `storeFile`). +- Multi-stage Dockerfile (Node 22, Bun, Deno) with `docker-compose.yml` for per-runtime testing. +- BATS parallel test runner (`test/platform/runtimes.bats`). +- Devcontainer setup (`.devcontainer/`) with all three runtimes + BATS. +- Encryption key validation (`INVALID_KEY_TYPE`, `INVALID_KEY_LENGTH` error codes). +- Encryption round-trip unit tests (110 tests including fuzz). +- Empty file (0-byte) edge case tests. +- Error-path unit tests for constructors and core failures. +- Deterministic test digest helper (`digestOf`). + +### Changed +- `CasService` domain layer has zero `node:*` imports — all platform dependencies injected via ports. +- Constructor requires `crypto` and `codec` params (no defaults); facade supplies them. +- Facade `storeFile()` now opens the file and delegates to `CasService.store()`. + ### Fixed - None. diff --git a/Dockerfile b/Dockerfile index 550ee0f..d40a96f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,7 +21,7 @@ CMD ["bunx", "vitest", "run", "test/unit"] # --- Deno --- FROM denoland/deno:latest AS deno USER root -RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y git nodejs npm && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY . . RUN deno install --allow-scripts diff --git a/README.md b/README.md index fc27dae..8a02f66 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ We use the object database. - **Optional AES-256-GCM encryption** store secrets without leaking plaintext into the ODB. - **Manifests** a tiny explicit index of chunks + metadata (JSON/CBOR). - **Tree output** generates standard Git trees so assets snap into commits cleanly. +- **Full round-trip** store, tree, and restore — get your bytes back, verified. **Use it for:** binary assets, build artifacts, model weights, data packs, secret bundles, weird experiments, etc. @@ -43,9 +44,10 @@ const manifest = await cas.storeFile({ }); // Turn the manifest into a Git tree OID -const treeOid = cas.createTree({ manifest }); +const treeOid = await cas.createTree({ manifest }); -// Now you can point a ref/commit at that tree like a normal Git artifact. +// Restore later — get your bytes back, integrity-verified +await cas.restoreFile({ manifest, outputPath: './restored.png' }); ``` ## CLI (git plugin) @@ -53,9 +55,21 @@ const treeOid = cas.createTree({ manifest }); `git-cas` installs as a Git subcommand: ```bash -git cas store ./image.png --slug my-image -git cas tree --slug my-image -git cas restore --out ./image.png +# Store a file — prints manifest JSON +git cas store ./image.png --slug my-image + +# Store and get a tree OID directly +git cas store ./image.png --slug my-image --tree + +# Create a tree from an existing manifest +git cas tree --manifest manifest.json + +# Restore from a tree OID +git cas restore --out ./restored.png + +# Encrypted round-trip (32-byte raw key file) +git cas store ./secret.bin --slug vault --key-file ./my.key --tree +git cas restore --out ./decrypted.bin --key-file ./my.key ``` ## Why not Git LFS? diff --git a/ROADMAP.md b/ROADMAP.md index f1533f7..ceb0573 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -9,7 +9,7 @@ This roadmap is structured as: 3. **Contracts** — Return/throw semantics for all public methods 4. **Version Plan** — Table mapping versions to milestones 5. **Milestone Dependency Graph** — ASCII diagram -6. **Milestones & Task Cards** — 7 milestones, 24 tasks (uniform task card template) +6. **Milestones & Task Cards** — 7 milestones, 26 tasks (uniform task card template) --- @@ -42,6 +42,7 @@ Single registry of all error codes used across the codebase. Each code is a stri | `INVALID_KEY_TYPE` | Encryption key is not a Buffer. | Task 1.3 | | `INTEGRITY_ERROR` | Decryption auth-tag verification failed (wrong key, tampered ciphertext, or tampered tag), or chunk digest mismatch on restore. | Exists (decrypt); extended by Task 1.6, Task 2.1 | | `STREAM_ERROR` | Read stream failed during `storeFile`. Partial chunks may have been written to Git ODB (unreachable; handled by `git gc`). Meta includes `{ chunksWritten: }`. | Task 2.4 | +| `MISSING_KEY` | Encryption key required to restore encrypted content but none was provided. | Task 2.1 | | `TREE_PARSE_ERROR` | `git ls-tree` output could not be parsed into valid entries. | Task 2.2 | | `MANIFEST_NOT_FOUND` | No manifest entry (e.g. `manifest.json` / `manifest.cbor`) found in the Git tree. | Task 4.1 | | `GIT_ERROR` | Underlying Git plumbing command failed. Wraps the original error from the plumbing layer. | Task 2.2, Task 4.1 | @@ -106,6 +107,21 @@ Return and throw semantics for every public method (current and planned). - **Algorithms:** `pbkdf2` (default), `scrypt` — both Node.js built-ins. - **Throws:** Standard Node.js crypto errors on invalid parameters. +### CLI: `git cas store --slug [--key-file ]` *(planned — Task 2.5)* +- **Output:** Prints manifest JSON to stdout. If `--tree` is passed, prints only the Git tree OID instead. +- **Exit 0:** Store succeeded. +- **Exit 1:** Store failed (error message to stderr). + +### CLI: `git cas tree --manifest ` *(planned — Task 2.5)* +- **Output:** Prints Git tree OID to stdout. +- **Exit 0:** Tree created. +- **Exit 1:** Invalid manifest or Git error (message to stderr). + +### CLI: `git cas restore --out [--key-file ]` *(planned — Task 2.6)* +- **Output:** Writes restored file to `--out` path. +- **Exit 0:** Restore succeeded, prints bytes written to stdout. +- **Exit 1:** Integrity error, missing manifest, or I/O error (message to stderr). + --- ## 4) Version Plan @@ -113,7 +129,7 @@ Return and throw semantics for every public method (current and planned). | Version | Milestone | Codename | Theme | |--------:|-----------|----------|-------| | v1.1.0 | M1 | Bedrock | Foundation hardening | -| v1.2.0 | M2 | Boomerang| File retrieval round trip | +| v1.2.0 | M2 | Boomerang| File retrieval round trip + CLI | | v1.3.0 | M3 | Launchpad| CI/CD pipeline | | v1.4.0 | M4 | Compass | Lifecycle management | | v1.5.0 | M5 | Sonar | Observability | @@ -152,13 +168,13 @@ M3 Launchpad (v1.3.0) M4 Compass (v1.4.0) | # | Codename | Theme | Version | Tasks | ~LoC | ~Hours | |---:|--------------|----------------------------|:-------:|------:|-------:|------:| | M1 | Bedrock | Foundation hardening | v1.1.0 | 7 | ~475 | ~6.5h | -| M2 | Boomerang | File retrieval round trip | v1.2.0 | 4 | ~295 | ~9.5h | +| M2 | Boomerang | File retrieval round trip + CLI | v1.2.0 | 6 | ~435 | ~14h | | M3 | Launchpad | CI/CD pipeline | v1.3.0 | 2 | ~110 | ~4h | | M4 | Compass | Lifecycle management | v1.4.0 | 3 | ~180 | ~5.5h | | M5 | Sonar | Observability | v1.5.0 | 2 | ~210 | ~5.5h | | M6 | Cartographer | Documentation | v1.6.0 | 3 | ~750 | ~10h | | M7 | Horizon | Advanced features | v2.0.0 | 3 | ~450 | ~17h | -| | **Total** | | | **24**| **~2,470** | **~58h** | +| | **Total** | | | **26**| **~2,610** | **~62.5h** | --- @@ -535,7 +551,7 @@ As a maintainer, I want error conditions covered by tests so regressions in vali --- # M2 — Boomerang (v1.2.0) -**Theme:** Complete store→retrieve round trip. +**Theme:** Complete store→retrieve round trip + CLI. --- @@ -761,6 +777,132 @@ As a developer, I want storeFile to fail safely on stream errors so partial stor --- +## Task 2.5: CLI scaffold + `store` and `tree` subcommands + +**User Story** +As a developer, I want `git cas store` and `git cas tree` commands so I can use CAS from the terminal without writing Node scripts. + +**Requirements** +- R1: Add `bin/git-cas.js` entry point (Git discovers `git-cas` on PATH for `git cas` subcommands). +- R2: Add `"bin": { "git-cas": "./bin/git-cas.js" }` to `package.json`. +- R3: Use a lightweight CLI framework (e.g., `commander`) for subcommand routing. +- R4: `git cas store --slug [--key-file ] [--tree]`: + - Reads the file, calls `storeFile()`. + - Prints manifest JSON to stdout by default. + - If `--tree` is passed, also calls `createTree()` and prints tree OID. + - `--key-file` reads a 32-byte raw key from a file for encryption. +- R5: `git cas tree --manifest `: + - Reads a manifest JSON from file/stdin, calls `createTree()`. + - Prints tree OID to stdout. +- R6: Exit 0 on success, exit 1 on error with message to stderr. +- R7: `--cwd` flag to set Git working directory (defaults to `.`). + +**Acceptance Criteria** +- AC1: `npx git-cas store ./test.txt --slug test` prints manifest JSON. +- AC2: `npx git-cas store ./test.txt --slug test --tree` prints tree OID. +- AC3: `npx git-cas tree --manifest manifest.json` prints tree OID. +- AC4: Invalid arguments produce helpful usage message and exit 1. +- AC5: `--key-file` with valid 32-byte file encrypts successfully. +- AC6: `--key-file` with wrong-size file exits 1 with clear error. + +**Scope** +- In scope: CLI scaffold, store subcommand, tree subcommand, key-file reading. +- Out of scope: `restore` subcommand (Task 2.6), shell completions, config files. + +**Est. Complexity (LoC)** +- Prod: ~80 +- Tests: ~30 +- Total: ~110 + +**Est. Human Working Hours** +- ~3h + +**Test Plan** +- Golden path: + - store a file via CLI → valid manifest JSON on stdout. + - store with `--tree` → tree OID on stdout. + - tree from manifest file → tree OID on stdout. +- Failures: + - missing file → exit 1 with error. + - missing `--slug` → exit 1 with usage message. + - bad key file → exit 1 with INVALID_KEY_LENGTH/TYPE error. +- Edges: + - 0-byte file store. + - manifest piped via stdin (if supported). +- Fuzz/stress: + - None (thin wrapper over tested API). + +**Definition of Done** +- DoD1: `bin/git-cas.js` exists with store and tree subcommands. +- DoD2: `package.json` declares bin entry. +- DoD3: `npx git-cas --help` prints usage. +- DoD4: Integration smoke test passes against real Git repo. + +**Blocking** +- Blocks: Task 2.6 + +**Blocked By** +- Blocked by: None + +--- + +## Task 2.6: CLI `restore` subcommand + +**User Story** +As a developer, I want `git cas restore --out ` so I can retrieve stored assets from the terminal. + +**Requirements** +- R1: `git cas restore --out [--key-file ]`: + - Reads the tree, extracts the manifest, restores the file to `--out`. + - Prints bytes written to stdout on success. + - `--key-file` supplies decryption key for encrypted assets. +- R2: Exit 0 on success, exit 1 on error (INTEGRITY_ERROR, MANIFEST_NOT_FOUND, etc.) with message to stderr. +- R3: Requires `restoreFile()` (Task 2.1) and `readManifest()` or equivalent tree-reading capability. + +**Acceptance Criteria** +- AC1: `npx git-cas restore --out ./restored.txt` writes correct file. +- AC2: Encrypted asset with `--key-file` restores correctly. +- AC3: Wrong key exits 1 with INTEGRITY_ERROR message. +- AC4: Invalid tree OID exits 1 with clear error. + +**Scope** +- In scope: restore subcommand wired to restoreFile API. +- Out of scope: Streaming output to stdout, partial restore, resume. + +**Est. Complexity (LoC)** +- Prod: ~30 +- Tests: ~20 +- Total: ~50 + +**Est. Human Working Hours** +- ~1.5h + +**Test Plan** +- Golden path: + - store → tree → restore → byte-compare original. + - encrypted store → tree → restore with key → byte-compare. +- Failures: + - wrong key → exit 1 INTEGRITY_ERROR. + - nonexistent tree OID → exit 1. + - missing `--out` → exit 1 with usage. +- Edges: + - 0-byte file round-trip via CLI. +- Fuzz/stress: + - None (thin wrapper over tested API). + +**Definition of Done** +- DoD1: `restore` subcommand added to `bin/git-cas.js`. +- DoD2: Full CLI round-trip (store → tree → restore) documented and tested. +- DoD3: README CLI section is now accurate and deliverable. + +**Blocking** +- Blocks: None + +**Blocked By** +- Blocked by: Task 2.1, Task 2.5 + +--- + # M3 — Launchpad (v1.3.0) **Theme:** Automated quality gates and release process. diff --git a/bin/git-cas.js b/bin/git-cas.js new file mode 100755 index 0000000..0387cde --- /dev/null +++ b/bin/git-cas.js @@ -0,0 +1,134 @@ +#!/usr/bin/env node + +import { readFileSync } from 'node:fs'; +import { program } from 'commander'; +import GitPlumbing from '@git-stunts/plumbing'; +import ContentAddressableStore from '../index.js'; +import Manifest from '../src/domain/value-objects/Manifest.js'; + +program + .name('git-cas') + .description('Content Addressable Storage backed by Git') + .version('1.2.0'); + +/** + * Read a 32-byte raw encryption key from a file. + */ +function readKeyFile(keyFilePath) { + const key = readFileSync(keyFilePath); + return key; +} + +/** + * Create a CAS instance for the given working directory. + */ +function createCas(cwd) { + const plumbing = new GitPlumbing({ cwd }); + return new ContentAddressableStore({ plumbing }); +} + +// --------------------------------------------------------------------------- +// store +// --------------------------------------------------------------------------- +program + .command('store ') + .description('Store a file into Git CAS') + .requiredOption('--slug ', 'Asset slug identifier') + .option('--key-file ', 'Path to 32-byte raw encryption key file') + .option('--tree', 'Also create a Git tree and print its OID') + .option('--cwd ', 'Git working directory', '.') + .action(async (file, opts) => { + try { + const cas = createCas(opts.cwd); + const storeOpts = { + filePath: file, + slug: opts.slug, + }; + + if (opts.keyFile) { + storeOpts.encryptionKey = readKeyFile(opts.keyFile); + } + + const manifest = await cas.storeFile(storeOpts); + + if (opts.tree) { + const treeOid = await cas.createTree({ manifest }); + process.stdout.write(`${treeOid }\n`); + } else { + process.stdout.write(`${JSON.stringify(manifest.toJSON(), null, 2) }\n`); + } + } catch (err) { + process.stderr.write(`error: ${err.message}\n`); + process.exit(1); + } + }); + +// --------------------------------------------------------------------------- +// tree +// --------------------------------------------------------------------------- +program + .command('tree') + .description('Create a Git tree from a manifest') + .requiredOption('--manifest ', 'Path to manifest JSON file') + .option('--cwd ', 'Git working directory', '.') + .action(async (opts) => { + try { + const cas = createCas(opts.cwd); + const raw = readFileSync(opts.manifest, 'utf8'); + const manifest = new Manifest(JSON.parse(raw)); + const treeOid = await cas.createTree({ manifest }); + process.stdout.write(`${treeOid }\n`); + } catch (err) { + process.stderr.write(`error: ${err.message}\n`); + process.exit(1); + } + }); + +// --------------------------------------------------------------------------- +// restore +// --------------------------------------------------------------------------- +program + .command('restore ') + .description('Restore a file from a Git CAS tree') + .requiredOption('--out ', 'Output file path') + .option('--key-file ', 'Path to 32-byte raw encryption key file') + .option('--cwd ', 'Git working directory', '.') + .action(async (treeOid, opts) => { + try { + const cas = createCas(opts.cwd); + + // Read the tree to find the manifest + const entries = await cas.service.persistence.readTree(treeOid); + const manifestEntry = entries.find( + (e) => e.name.startsWith('manifest.'), + ); + if (!manifestEntry) { + process.stderr.write('error: No manifest found in tree\n'); + process.exit(1); + } + + const manifestBlob = await cas.service.persistence.readBlob( + manifestEntry.oid, + ); + const manifest = new Manifest( + cas.service.codec.decode(manifestBlob), + ); + + const restoreOpts = { manifest }; + if (opts.keyFile) { + restoreOpts.encryptionKey = readKeyFile(opts.keyFile); + } + + const { bytesWritten } = await cas.restoreFile({ + ...restoreOpts, + outputPath: opts.out, + }); + + process.stdout.write(`${bytesWritten}\n`); + } catch (err) { + process.stderr.write(`error: ${err.message}\n`); + process.exit(1); + } + }); + +program.parse(); diff --git a/index.js b/index.js index fbba901..a96e742 100644 --- a/index.js +++ b/index.js @@ -2,7 +2,7 @@ * @fileoverview Content Addressable Store - Managed blob storage in Git. */ -import { createReadStream } from 'node:fs'; +import { createReadStream, writeFileSync } from 'node:fs'; import path from 'node:path'; import CasService from './src/domain/services/CasService.js'; import GitPersistenceAdapter from './src/infrastructure/adapters/GitPersistenceAdapter.js'; @@ -93,7 +93,30 @@ export default class ContentAddressableStore { return this.service.store(options); } + /** + * Restores a file from its manifest and writes it to outputPath. + */ + async restoreFile({ manifest, encryptionKey, outputPath }) { + const { buffer, bytesWritten } = await this.service.restore({ + manifest, + encryptionKey, + }); + writeFileSync(outputPath, buffer); + return { bytesWritten }; + } + + /** + * Restores a file from its manifest, returning the buffer directly. + */ + async restore(options) { + return this.service.restore(options); + } + async createTree(options) { return this.service.createTree(options); } + + async verifyIntegrity(manifest) { + return this.service.verifyIntegrity(manifest); + } } diff --git a/package-lock.json b/package-lock.json index e37effe..94f849a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@git-stunts/alfred": "^0.10.0", "@git-stunts/plumbing": "^2.8.0", "cbor-x": "^1.6.0", + "commander": "^14.0.3", "zod": "^3.24.1" }, "devDependencies": { @@ -1399,6 +1400,15 @@ "dev": true, "license": "MIT" }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", diff --git a/package.json b/package.json index 61f3a02..ac8d35a 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,12 @@ { - "name": "@git-stunts/cas", - "version": "1.0.0", - "description": "Git Stunts Lego Block: cas", + "name": "@git-stunts/git-cas", + "version": "1.2.0", + "description": "Content-addressed storage backed by Git's object database, with optional encryption and pluggable codecs", "type": "module", "main": "index.js", + "bin": { + "git-cas": "./bin/git-cas.js" + }, "exports": { ".": "./index.js", "./service": "./src/domain/services/CasService.js", @@ -15,6 +18,10 @@ "test:node": "docker compose run --build --rm test-node", "test:bun": "docker compose run --build --rm test-bun", "test:deno": "docker compose run --build --rm test-deno", + "test:integration": "vitest run test/integration", + "test:integration:node": "docker compose run --build --rm test-node npx vitest run test/integration", + "test:integration:bun": "docker compose run --build --rm test-bun bunx vitest run test/integration", + "test:integration:deno": "docker compose run --build --rm test-deno deno run -A npm:vitest run test/integration", "test:platforms": "bats --jobs 3 test/platform/runtimes.bats", "benchmark": "vitest bench test/benchmark", "benchmark:local": "vitest bench test/benchmark", @@ -27,6 +34,7 @@ "@git-stunts/alfred": "^0.10.0", "@git-stunts/plumbing": "^2.8.0", "cbor-x": "^1.6.0", + "commander": "^14.0.3", "zod": "^3.24.1" }, "devDependencies": { diff --git a/src/domain/schemas/ManifestSchema.js b/src/domain/schemas/ManifestSchema.js index 771fe6c..5a24d56 100644 --- a/src/domain/schemas/ManifestSchema.js +++ b/src/domain/schemas/ManifestSchema.js @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import z from 'zod'; export const ChunkSchema = z.object({ index: z.number().int().min(0), diff --git a/src/domain/services/CasService.js b/src/domain/services/CasService.js index f4d3945..743467d 100644 --- a/src/domain/services/CasService.js +++ b/src/domain/services/CasService.js @@ -39,24 +39,33 @@ export default class CasService { async _chunkAndStore(source, manifestData) { let buffer = Buffer.alloc(0); - for await (const chunk of source) { - buffer = Buffer.concat([buffer, chunk]); - - while (buffer.length >= this.chunkSize) { - const chunkBuf = buffer.slice(0, this.chunkSize); - buffer = buffer.slice(this.chunkSize); - - const digest = this._sha256(chunkBuf); - const blob = await this.persistence.writeBlob(chunkBuf); - - manifestData.chunks.push({ - index: manifestData.chunks.length, - size: chunkBuf.length, - digest, - blob - }); - manifestData.size += chunkBuf.length; + try { + for await (const chunk of source) { + buffer = Buffer.concat([buffer, chunk]); + + while (buffer.length >= this.chunkSize) { + const chunkBuf = buffer.slice(0, this.chunkSize); + buffer = buffer.slice(this.chunkSize); + + const digest = this._sha256(chunkBuf); + const blob = await this.persistence.writeBlob(chunkBuf); + + manifestData.chunks.push({ + index: manifestData.chunks.length, + size: chunkBuf.length, + digest, + blob + }); + manifestData.size += chunkBuf.length; + } } + } catch (err) { + if (err instanceof CasError) {throw err;} + throw new CasError( + `Stream error during store: ${err.message}`, + 'STREAM_ERROR', + { chunksWritten: manifestData.chunks.length, originalError: err }, + ); } // Process remaining buffer @@ -115,7 +124,7 @@ export default class CasService { try { return this.crypto.decryptBuffer(buffer, key, meta); } catch (err) { - if (err instanceof CasError) throw err; + if (err instanceof CasError) {throw err;} throw new CasError('Decryption failed: Integrity check error', 'INTEGRITY_ERROR', { originalError: err }); } } @@ -171,6 +180,68 @@ export default class CasService { return await this.persistence.writeTree(treeEntries); } + /** + * Restores a file from its manifest by reading and reassembling chunks. + * + * If the manifest has encryption metadata, decrypts the reassembled + * ciphertext using the provided key. + * + * @param {Object} options + * @param {import('../value-objects/Manifest.js').default} options.manifest + * @param {Buffer} [options.encryptionKey] + * @returns {Promise<{ buffer: Buffer, bytesWritten: number }>} + */ + /** + * Reads chunk blobs and verifies their SHA-256 digests. + * @private + */ + async _readAndVerifyChunks(chunks) { + const buffers = []; + for (const chunk of chunks) { + const blob = await this.persistence.readBlob(chunk.blob); + const digest = this._sha256(blob); + if (digest !== chunk.digest) { + throw new CasError( + `Chunk ${chunk.index} integrity check failed`, + 'INTEGRITY_ERROR', + { chunkIndex: chunk.index, expected: chunk.digest, actual: digest }, + ); + } + buffers.push(blob); + } + return buffers; + } + + async restore({ manifest, encryptionKey }) { + if (encryptionKey) { + this._validateKey(encryptionKey); + } + + if (manifest.encryption?.encrypted && !encryptionKey) { + throw new CasError( + 'Encryption key required to restore encrypted content', + 'MISSING_KEY', + ); + } + + if (manifest.chunks.length === 0) { + return { buffer: Buffer.alloc(0), bytesWritten: 0 }; + } + + const chunks = await this._readAndVerifyChunks(manifest.chunks); + let buffer = Buffer.concat(chunks); + + if (manifest.encryption?.encrypted) { + buffer = this.decrypt({ + buffer, + key: encryptionKey, + meta: manifest.encryption, + }); + } + + return { buffer, bytesWritten: buffer.length }; + } + /** * Verifies the integrity of a stored file by re-hashing its chunks. * @param {import('../value-objects/Manifest.js').default} manifest diff --git a/src/infrastructure/adapters/GitPersistenceAdapter.js b/src/infrastructure/adapters/GitPersistenceAdapter.js index 41fb734..c50c270 100644 --- a/src/infrastructure/adapters/GitPersistenceAdapter.js +++ b/src/infrastructure/adapters/GitPersistenceAdapter.js @@ -1,5 +1,6 @@ import { Policy } from '@git-stunts/alfred'; import GitPersistencePort from '../../ports/GitPersistencePort.js'; +import CasError from '../../domain/errors/CasError.js'; const DEFAULT_POLICY = Policy.timeout(30_000).wrap( Policy.retry({ @@ -48,7 +49,47 @@ export default class GitPersistenceAdapter extends GitPersistencePort { const stream = await this.plumbing.executeStream({ args: ['cat-file', 'blob', oid], }); - return stream.collect({ asString: false }); + const data = await stream.collect({ asString: false }); + // Plumbing returns Uint8Array; ensure we return a Buffer for codec/crypto compat + return Buffer.from(data.buffer, data.byteOffset, data.byteLength); + }); + } + + async readTree(treeOid) { + return this.policy.execute(async () => { + const output = await this.plumbing.execute({ + args: ['ls-tree', '-z', treeOid], + }); + + if (!output || output.length === 0) { + return []; + } + + return output.split('\0').filter(Boolean).map((entry) => { + // Format: \t + const tabIndex = entry.indexOf('\t'); + if (tabIndex === -1) { + throw new CasError( + `Malformed ls-tree entry: ${entry}`, + 'TREE_PARSE_ERROR', + { rawEntry: entry }, + ); + } + const meta = entry.slice(0, tabIndex).split(' '); + if (meta.length !== 3) { + throw new CasError( + `Malformed ls-tree entry: ${entry}`, + 'TREE_PARSE_ERROR', + { rawEntry: entry }, + ); + } + return { + mode: meta[0], + type: meta[1], + oid: meta[2], + name: entry.slice(tabIndex + 1), + }; + }); }); } } diff --git a/src/ports/GitPersistencePort.js b/src/ports/GitPersistencePort.js index ef01d6a..97d5e6f 100644 --- a/src/ports/GitPersistencePort.js +++ b/src/ports/GitPersistencePort.js @@ -25,4 +25,12 @@ export default class GitPersistencePort { async readBlob(_oid) { throw new Error('Not implemented'); } + + /** + * @param {string} treeOid + * @returns {Promise>} + */ + async readTree(_treeOid) { + throw new Error('Not implemented'); + } } diff --git a/test/benchmark/cas.bench.js b/test/benchmark/cas.bench.js index a438264..4679d67 100644 --- a/test/benchmark/cas.bench.js +++ b/test/benchmark/cas.bench.js @@ -10,8 +10,6 @@ const mockPersistence = { }; describe('CasService Benchmarks', () => { - const service = new CasService({ persistence: mockPersistence, crypto: new NodeCryptoAdapter(), codec: new JsonCodec() }); - bench('service initialization', () => { new CasService({ persistence: mockPersistence, crypto: new NodeCryptoAdapter(), codec: new JsonCodec() }); }); diff --git a/test/integration/round-trip.test.js b/test/integration/round-trip.test.js new file mode 100644 index 0000000..dba9fb8 --- /dev/null +++ b/test/integration/round-trip.test.js @@ -0,0 +1,282 @@ +/** + * Integration tests — store → createTree → readTree → restore round trip. + * + * These tests run against a real Git bare repo and exercise the full stack: + * GitPlumbing → GitPersistenceAdapter → CasService → Facade. + * + * MUST run inside Docker (GIT_STUNTS_DOCKER=1). Refuses to run on the host. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs'; +import { randomBytes } from 'node:crypto'; +import { execSync } from 'node:child_process'; +import path from 'node:path'; +import os from 'node:os'; +import GitPlumbing from '@git-stunts/plumbing'; +import ContentAddressableStore from '../../index.js'; +import CborCodec from '../../src/infrastructure/codecs/CborCodec.js'; +import Manifest from '../../src/domain/value-objects/Manifest.js'; +import CasError from '../../src/domain/errors/CasError.js'; + +// Hard gate: refuse to run outside Docker +if (process.env.GIT_STUNTS_DOCKER !== '1') { + throw new Error( + 'Integration tests MUST run inside Docker (GIT_STUNTS_DOCKER=1). ' + + 'Use: npm run test:integration:node', + ); +} + +let repoDir; +let cas; +let casCbor; + +beforeAll(() => { + repoDir = mkdtempSync(path.join(os.tmpdir(), 'cas-integ-')); + execSync('git init --bare', { cwd: repoDir, stdio: 'ignore' }); + + const plumbing = GitPlumbing.createDefault({ cwd: repoDir }); + cas = new ContentAddressableStore({ plumbing }); + casCbor = new ContentAddressableStore({ plumbing, codec: new CborCodec() }); +}); + +afterAll(() => { + rmSync(repoDir, { recursive: true, force: true }); +}); + +/** + * Helper: write a temp file with the given content, return path. + */ +function tempFile(content) { + const dir = mkdtempSync(path.join(os.tmpdir(), 'cas-file-')); + const fp = path.join(dir, 'input.bin'); + writeFileSync(fp, content); + return { filePath: fp, dir }; +} + +// --------------------------------------------------------------------------- +// Plaintext round trip (JSON) – basic +// --------------------------------------------------------------------------- +describe('plaintext round trip (JSON) – basic', () => { + it('10 KB file', async () => { + const original = randomBytes(10 * 1024); + const { filePath, dir } = tempFile(original); + + const manifest = await cas.storeFile({ filePath, slug: 'plain-10k' }); + const treeOid = await cas.createTree({ manifest }); + + const entries = await cas.service.persistence.readTree(treeOid); + const manifestEntry = entries.find((e) => e.name === 'manifest.json'); + expect(manifestEntry).toBeDefined(); + + const manifestBlob = await cas.service.persistence.readBlob(manifestEntry.oid); + const restored = new Manifest(cas.service.codec.decode(manifestBlob)); + + const { buffer, bytesWritten } = await cas.restore({ manifest: restored }); + expect(buffer.equals(original)).toBe(true); + expect(bytesWritten).toBe(original.length); + + rmSync(dir, { recursive: true, force: true }); + }); + + it('0-byte file', async () => { + const original = Buffer.alloc(0); + const { filePath, dir } = tempFile(original); + + const manifest = await cas.storeFile({ filePath, slug: 'plain-empty' }); + const treeOid = await cas.createTree({ manifest }); + + const entries = await cas.service.persistence.readTree(treeOid); + const manifestEntry = entries.find((e) => e.name === 'manifest.json'); + const manifestBlob = await cas.service.persistence.readBlob(manifestEntry.oid); + const restored = new Manifest(cas.service.codec.decode(manifestBlob)); + + const { buffer, bytesWritten } = await cas.restore({ manifest: restored }); + expect(buffer.length).toBe(0); + expect(bytesWritten).toBe(0); + + rmSync(dir, { recursive: true, force: true }); + }); +}); + +// --------------------------------------------------------------------------- +// Plaintext round trip (JSON) – chunk boundaries +// --------------------------------------------------------------------------- +describe('plaintext round trip (JSON) – chunk boundaries', () => { + it('exact chunkSize file (256 KiB)', async () => { + const original = randomBytes(256 * 1024); + const { filePath, dir } = tempFile(original); + + const manifest = await cas.storeFile({ filePath, slug: 'plain-exact' }); + expect(manifest.chunks.length).toBe(1); + + const { buffer } = await cas.restore({ manifest }); + expect(buffer.equals(original)).toBe(true); + + rmSync(dir, { recursive: true, force: true }); + }); + + it('3x chunkSize file', async () => { + const original = randomBytes(3 * 256 * 1024); + const { filePath, dir } = tempFile(original); + + const manifest = await cas.storeFile({ filePath, slug: 'plain-3x' }); + expect(manifest.chunks.length).toBe(3); + + const { buffer } = await cas.restore({ manifest }); + expect(buffer.equals(original)).toBe(true); + + rmSync(dir, { recursive: true, force: true }); + }); +}); + +// --------------------------------------------------------------------------- +// Encrypted round trip (JSON) – success +// --------------------------------------------------------------------------- +describe('encrypted round trip (JSON) – success', () => { + const key = randomBytes(32); + + it('10 KB encrypted file', async () => { + const original = randomBytes(10 * 1024); + const { filePath, dir } = tempFile(original); + + const manifest = await cas.storeFile({ + filePath, slug: 'enc-10k', encryptionKey: key, + }); + expect(manifest.encryption).toBeDefined(); + expect(manifest.encryption.encrypted).toBe(true); + + const treeOid = await cas.createTree({ manifest }); + const entries = await cas.service.persistence.readTree(treeOid); + const mEntry = entries.find((e) => e.name === 'manifest.json'); + const mBlob = await cas.service.persistence.readBlob(mEntry.oid); + const restored = new Manifest(cas.service.codec.decode(mBlob)); + + const { buffer } = await cas.restore({ manifest: restored, encryptionKey: key }); + expect(buffer.equals(original)).toBe(true); + + rmSync(dir, { recursive: true, force: true }); + }); +}); + +// --------------------------------------------------------------------------- +// Encrypted round trip (JSON) – wrong key +// --------------------------------------------------------------------------- +describe('encrypted round trip (JSON) – wrong key', () => { + const key = randomBytes(32); + + it('wrong key throws INTEGRITY_ERROR', async () => { + const original = randomBytes(1024); + const { filePath, dir } = tempFile(original); + + const manifest = await cas.storeFile({ + filePath, slug: 'enc-wrong-key', encryptionKey: key, + }); + + const wrongKey = randomBytes(32); + await expect( + cas.restore({ manifest, encryptionKey: wrongKey }), + ).rejects.toThrow(CasError); + + try { + await cas.restore({ manifest, encryptionKey: wrongKey }); + } catch (err) { + expect(err.code).toBe('INTEGRITY_ERROR'); + } + + rmSync(dir, { recursive: true, force: true }); + }); +}); + +// --------------------------------------------------------------------------- +// CBOR codec round trip +// --------------------------------------------------------------------------- +describe('CBOR codec round trip', () => { + it('10 KB plaintext via CBOR', async () => { + const original = randomBytes(10 * 1024); + const { filePath, dir } = tempFile(original); + + const manifest = await casCbor.storeFile({ filePath, slug: 'cbor-10k' }); + const treeOid = await casCbor.createTree({ manifest }); + + const entries = await casCbor.service.persistence.readTree(treeOid); + const manifestEntry = entries.find((e) => e.name === 'manifest.cbor'); + expect(manifestEntry).toBeDefined(); + + const manifestBlob = await casCbor.service.persistence.readBlob(manifestEntry.oid); + const restored = new Manifest(casCbor.service.codec.decode(manifestBlob)); + + const { buffer } = await casCbor.restore({ manifest: restored }); + expect(buffer.equals(original)).toBe(true); + + rmSync(dir, { recursive: true, force: true }); + }); + + it('encrypted via CBOR', async () => { + const key = randomBytes(32); + const original = randomBytes(5 * 1024); + const { filePath, dir } = tempFile(original); + + const manifest = await casCbor.storeFile({ + filePath, slug: 'cbor-enc', encryptionKey: key, + }); + + const { buffer } = await casCbor.restore({ manifest, encryptionKey: key }); + expect(buffer.equals(original)).toBe(true); + + rmSync(dir, { recursive: true, force: true }); + }); +}); + +// --------------------------------------------------------------------------- +// restoreFile — write to disk +// --------------------------------------------------------------------------- +describe('restoreFile (write to disk)', () => { + it('restores to a file on disk', async () => { + const original = randomBytes(4096); + const { filePath, dir } = tempFile(original); + + const manifest = await cas.storeFile({ filePath, slug: 'disk-restore' }); + + const outDir = mkdtempSync(path.join(os.tmpdir(), 'cas-out-')); + const outPath = path.join(outDir, 'restored.bin'); + + const { bytesWritten } = await cas.restoreFile({ + manifest, outputPath: outPath, + }); + + expect(bytesWritten).toBe(original.length); + const restored = readFileSync(outPath); + expect(restored.equals(original)).toBe(true); + + rmSync(dir, { recursive: true, force: true }); + rmSync(outDir, { recursive: true, force: true }); + }); +}); + +// --------------------------------------------------------------------------- +// Fuzz: 50 file sizes around chunk boundaries +// --------------------------------------------------------------------------- +describe('fuzz: 50 file sizes around chunk boundaries', () => { + const chunkSize = 256 * 1024; + + for (let i = 0; i < 50; i++) { + // Spread sizes: 0, near chunkSize, 2x, 3x, with ±1 offsets + const base = Math.floor((i / 49) * 3 * chunkSize); + const offset = (i % 3) - 1; // -1, 0, +1 + const size = Math.max(0, base + offset); + + it(`round-trips ${size} bytes (iteration ${i})`, async () => { + const original = Buffer.alloc(size); + for (let b = 0; b < size; b++) {original[b] = (i + b) & 0xff;} + + const { filePath, dir } = tempFile(original); + const manifest = await cas.storeFile({ filePath, slug: `fuzz-${i}` }); + const { buffer } = await cas.restore({ manifest }); + + expect(buffer.equals(original)).toBe(true); + + rmSync(dir, { recursive: true, force: true }); + }); + } +}); diff --git a/test/unit/domain/services/CasService.crypto.test.js b/test/unit/domain/services/CasService.crypto.test.js index 5f679bd..5fe57f0 100644 --- a/test/unit/domain/services/CasService.crypto.test.js +++ b/test/unit/domain/services/CasService.crypto.test.js @@ -5,7 +5,10 @@ import NodeCryptoAdapter from '../../../../src/infrastructure/adapters/NodeCrypt import JsonCodec from '../../../../src/infrastructure/codecs/JsonCodec.js'; import CasError from '../../../../src/domain/errors/CasError.js'; -describe('CasService encryption round-trip', () => { +// --------------------------------------------------------------------------- +// 1. Round-trip golden path +// --------------------------------------------------------------------------- +describe('CasService encryption – round-trip golden path', () => { let service; let mockPersistence; @@ -23,203 +26,288 @@ describe('CasService encryption round-trip', () => { }); }); - // --------------------------------------------------------------------------- - // Round-trip (Golden path) - // --------------------------------------------------------------------------- - describe('round-trip golden path', () => { - const key = randomBytes(32); + const key = randomBytes(32); - it('encrypts then decrypts a 0-byte buffer', () => { - const plaintext = Buffer.alloc(0); - const { buf, meta } = service.encrypt({ buffer: plaintext, key }); - const decrypted = service.decrypt({ buffer: buf, key, meta }); - expect(decrypted.equals(plaintext)).toBe(true); - }); + it('encrypts then decrypts a 0-byte buffer', () => { + const plaintext = Buffer.alloc(0); + const { buf, meta } = service.encrypt({ buffer: plaintext, key }); + const decrypted = service.decrypt({ buffer: buf, key, meta }); + expect(decrypted.equals(plaintext)).toBe(true); + }); - it('encrypts then decrypts a 1-byte buffer', () => { - const plaintext = Buffer.from([0x42]); - const { buf, meta } = service.encrypt({ buffer: plaintext, key }); - const decrypted = service.decrypt({ buffer: buf, key, meta }); - expect(decrypted.equals(plaintext)).toBe(true); - }); + it('encrypts then decrypts a 1-byte buffer', () => { + const plaintext = Buffer.from([0x42]); + const { buf, meta } = service.encrypt({ buffer: plaintext, key }); + const decrypted = service.decrypt({ buffer: buf, key, meta }); + expect(decrypted.equals(plaintext)).toBe(true); + }); - it('encrypts then decrypts a 1 KB buffer', () => { - const plaintext = randomBytes(1024); - const { buf, meta } = service.encrypt({ buffer: plaintext, key }); - const decrypted = service.decrypt({ buffer: buf, key, meta }); - expect(decrypted.equals(plaintext)).toBe(true); - }); + it('encrypts then decrypts a 1 KB buffer', () => { + const plaintext = randomBytes(1024); + const { buf, meta } = service.encrypt({ buffer: plaintext, key }); + const decrypted = service.decrypt({ buffer: buf, key, meta }); + expect(decrypted.equals(plaintext)).toBe(true); + }); - it('encrypts then decrypts a 1 MB buffer', () => { - const plaintext = randomBytes(1024 * 1024); - const { buf, meta } = service.encrypt({ buffer: plaintext, key }); - const decrypted = service.decrypt({ buffer: buf, key, meta }); - expect(decrypted.equals(plaintext)).toBe(true); + it('encrypts then decrypts a 1 MB buffer', () => { + const plaintext = randomBytes(1024 * 1024); + const { buf, meta } = service.encrypt({ buffer: plaintext, key }); + const decrypted = service.decrypt({ buffer: buf, key, meta }); + expect(decrypted.equals(plaintext)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// 2a. Integrity failures – wrong key and tampered ciphertext +// --------------------------------------------------------------------------- +describe('CasService encryption – wrong key and tampered ciphertext', () => { + let service; + let mockPersistence; + + beforeEach(() => { + mockPersistence = { + writeBlob: vi.fn().mockResolvedValue('mock-blob-oid'), + writeTree: vi.fn().mockResolvedValue('mock-tree-oid'), + readBlob: vi.fn().mockResolvedValue(Buffer.from('data')), + }; + service = new CasService({ + persistence: mockPersistence, + crypto: new NodeCryptoAdapter(), + codec: new JsonCodec(), + chunkSize: 1024, }); }); - // --------------------------------------------------------------------------- - // Wrong key - // --------------------------------------------------------------------------- - describe('wrong key', () => { - it('throws INTEGRITY_ERROR when decrypting with a different key', () => { - const keyA = randomBytes(32); - const keyB = randomBytes(32); - const plaintext = Buffer.from('secret message'); + it('throws INTEGRITY_ERROR when decrypting with a different key', () => { + const keyA = randomBytes(32); + const keyB = randomBytes(32); + const plaintext = Buffer.from('secret message'); - const { buf, meta } = service.encrypt({ buffer: plaintext, key: keyA }); + const { buf, meta } = service.encrypt({ buffer: plaintext, key: keyA }); - expect(() => service.decrypt({ buffer: buf, key: keyB, meta })).toThrow(CasError); - try { - service.decrypt({ buffer: buf, key: keyB, meta }); - } catch (err) { - expect(err.code).toBe('INTEGRITY_ERROR'); - } + expect(() => service.decrypt({ buffer: buf, key: keyB, meta })).toThrow(CasError); + try { + service.decrypt({ buffer: buf, key: keyB, meta }); + } catch (err) { + expect(err.code).toBe('INTEGRITY_ERROR'); + } + }); + + it('throws INTEGRITY_ERROR when a bit is flipped in the encrypted buffer', () => { + const key = randomBytes(32); + const plaintext = Buffer.from('this is sensitive data'); + + const { buf, meta } = service.encrypt({ buffer: plaintext, key }); + + const tampered = Buffer.from(buf); + tampered[0] ^= 0x01; + + expect(() => service.decrypt({ buffer: tampered, key, meta })).toThrow(CasError); + try { + service.decrypt({ buffer: tampered, key, meta }); + } catch (err) { + expect(err.code).toBe('INTEGRITY_ERROR'); + } + }); +}); + +// --------------------------------------------------------------------------- +// 2b. Integrity failures – tampered auth tag and tampered nonce +// --------------------------------------------------------------------------- +describe('CasService encryption – tampered auth tag', () => { + let service; + let mockPersistence; + + beforeEach(() => { + mockPersistence = { + writeBlob: vi.fn().mockResolvedValue('mock-blob-oid'), + writeTree: vi.fn().mockResolvedValue('mock-tree-oid'), + readBlob: vi.fn().mockResolvedValue(Buffer.from('data')), + }; + service = new CasService({ + persistence: mockPersistence, + crypto: new NodeCryptoAdapter(), + codec: new JsonCodec(), + chunkSize: 1024, }); }); - // --------------------------------------------------------------------------- - // Tampered ciphertext - // --------------------------------------------------------------------------- - describe('tampered ciphertext', () => { - it('throws INTEGRITY_ERROR when a bit is flipped in the encrypted buffer', () => { - const key = randomBytes(32); - const plaintext = Buffer.from('this is sensitive data'); + it('throws INTEGRITY_ERROR when the auth tag is modified', () => { + const key = randomBytes(32); + const plaintext = Buffer.from('protected payload'); - const { buf, meta } = service.encrypt({ buffer: plaintext, key }); + const { buf, meta } = service.encrypt({ buffer: plaintext, key }); - // Flip the first bit of the first byte - const tampered = Buffer.from(buf); - tampered[0] ^= 0x01; + const tagBuf = Buffer.from(meta.tag, 'base64'); + tagBuf[0] ^= 0x01; + const tamperedMeta = { ...meta, tag: tagBuf.toString('base64') }; - expect(() => service.decrypt({ buffer: tampered, key, meta })).toThrow(CasError); - try { - service.decrypt({ buffer: tampered, key, meta }); - } catch (err) { - expect(err.code).toBe('INTEGRITY_ERROR'); - } + expect(() => service.decrypt({ buffer: buf, key, meta: tamperedMeta })).toThrow(CasError); + try { + service.decrypt({ buffer: buf, key, meta: tamperedMeta }); + } catch (err) { + expect(err.code).toBe('INTEGRITY_ERROR'); + } + }); +}); + +// --------------------------------------------------------------------------- +// 2c. Integrity failures – tampered nonce +// --------------------------------------------------------------------------- +describe('CasService encryption – tampered nonce', () => { + let service; + let mockPersistence; + + beforeEach(() => { + mockPersistence = { + writeBlob: vi.fn().mockResolvedValue('mock-blob-oid'), + writeTree: vi.fn().mockResolvedValue('mock-tree-oid'), + readBlob: vi.fn().mockResolvedValue(Buffer.from('data')), + }; + service = new CasService({ + persistence: mockPersistence, + crypto: new NodeCryptoAdapter(), + codec: new JsonCodec(), + chunkSize: 1024, }); }); - // --------------------------------------------------------------------------- - // Tampered auth tag - // --------------------------------------------------------------------------- - describe('tampered auth tag', () => { - it('throws INTEGRITY_ERROR when the auth tag is modified', () => { - const key = randomBytes(32); - const plaintext = Buffer.from('protected payload'); + it('throws INTEGRITY_ERROR when the nonce is modified', () => { + const key = randomBytes(32); + const plaintext = Buffer.from('nonce-sensitive content'); - const { buf, meta } = service.encrypt({ buffer: plaintext, key }); + const { buf, meta } = service.encrypt({ buffer: plaintext, key }); - // Decode tag, flip one bit, re-encode - const tagBuf = Buffer.from(meta.tag, 'base64'); - tagBuf[0] ^= 0x01; - const tamperedMeta = { ...meta, tag: tagBuf.toString('base64') }; + const nonceBuf = Buffer.from(meta.nonce, 'base64'); + nonceBuf[0] ^= 0x01; + const tamperedMeta = { ...meta, nonce: nonceBuf.toString('base64') }; - expect(() => service.decrypt({ buffer: buf, key, meta: tamperedMeta })).toThrow(CasError); - try { - service.decrypt({ buffer: buf, key, meta: tamperedMeta }); - } catch (err) { - expect(err.code).toBe('INTEGRITY_ERROR'); - } + expect(() => service.decrypt({ buffer: buf, key, meta: tamperedMeta })).toThrow(CasError); + try { + service.decrypt({ buffer: buf, key, meta: tamperedMeta }); + } catch (err) { + expect(err.code).toBe('INTEGRITY_ERROR'); + } + }); +}); + +// --------------------------------------------------------------------------- +// 3. Passthrough (no encryption) +// --------------------------------------------------------------------------- +describe('CasService encryption – passthrough', () => { + let service; + let mockPersistence; + + beforeEach(() => { + mockPersistence = { + writeBlob: vi.fn().mockResolvedValue('mock-blob-oid'), + writeTree: vi.fn().mockResolvedValue('mock-tree-oid'), + readBlob: vi.fn().mockResolvedValue(Buffer.from('data')), + }; + service = new CasService({ + persistence: mockPersistence, + crypto: new NodeCryptoAdapter(), + codec: new JsonCodec(), + chunkSize: 1024, }); }); - // --------------------------------------------------------------------------- - // Tampered nonce - // --------------------------------------------------------------------------- - describe('tampered nonce', () => { - it('throws INTEGRITY_ERROR when the nonce is modified', () => { - const key = randomBytes(32); - const plaintext = Buffer.from('nonce-sensitive content'); + it('returns buffer unchanged when meta.encrypted is false', () => { + const buffer = Buffer.from('not encrypted'); + const result = service.decrypt({ buffer, key: undefined, meta: { encrypted: false } }); + expect(result.equals(buffer)).toBe(true); + }); - const { buf, meta } = service.encrypt({ buffer: plaintext, key }); + it('returns buffer unchanged when meta is undefined', () => { + const buffer = Buffer.from('no meta at all'); + const result = service.decrypt({ buffer, key: undefined, meta: undefined }); + expect(result.equals(buffer)).toBe(true); + }); +}); - // Decode nonce, flip one bit, re-encode - const nonceBuf = Buffer.from(meta.nonce, 'base64'); - nonceBuf[0] ^= 0x01; - const tamperedMeta = { ...meta, nonce: nonceBuf.toString('base64') }; +// --------------------------------------------------------------------------- +// 4. Fuzz round-trip +// --------------------------------------------------------------------------- +describe('CasService encryption – fuzz round-trip', () => { + let service; + let mockPersistence; - expect(() => service.decrypt({ buffer: buf, key, meta: tamperedMeta })).toThrow(CasError); - try { - service.decrypt({ buffer: buf, key, meta: tamperedMeta }); - } catch (err) { - expect(err.code).toBe('INTEGRITY_ERROR'); - } + beforeEach(() => { + mockPersistence = { + writeBlob: vi.fn().mockResolvedValue('mock-blob-oid'), + writeTree: vi.fn().mockResolvedValue('mock-tree-oid'), + readBlob: vi.fn().mockResolvedValue(Buffer.from('data')), + }; + service = new CasService({ + persistence: mockPersistence, + crypto: new NodeCryptoAdapter(), + codec: new JsonCodec(), + chunkSize: 1024, }); }); - // --------------------------------------------------------------------------- - // Passthrough - meta.encrypted = false - // --------------------------------------------------------------------------- - describe('passthrough', () => { - it('returns buffer unchanged when meta.encrypted is false', () => { - const buffer = Buffer.from('not encrypted'); - const result = service.decrypt({ buffer, key: undefined, meta: { encrypted: false } }); - expect(result.equals(buffer)).toBe(true); + const key = randomBytes(32); + + for (let i = 0; i < 50; i++) { + const size = Math.floor((i / 49) * 100 * 1024); + + it(`round-trips a ${size}-byte buffer (iteration ${i})`, () => { + const plaintext = Buffer.alloc(size); + for (let b = 0; b < size; b++) { + plaintext[b] = (i + b) & 0xff; + } + + const { buf, meta } = service.encrypt({ buffer: plaintext, key }); + const decrypted = service.decrypt({ buffer: buf, key, meta }); + expect(decrypted.equals(plaintext)).toBe(true); }); + } +}); + +// --------------------------------------------------------------------------- +// 5. Fuzz tamper detection +// --------------------------------------------------------------------------- +describe('CasService encryption – fuzz tamper', () => { + let service; + let mockPersistence; - it('returns buffer unchanged when meta is undefined', () => { - const buffer = Buffer.from('no meta at all'); - const result = service.decrypt({ buffer, key: undefined, meta: undefined }); - expect(result.equals(buffer)).toBe(true); + beforeEach(() => { + mockPersistence = { + writeBlob: vi.fn().mockResolvedValue('mock-blob-oid'), + writeTree: vi.fn().mockResolvedValue('mock-tree-oid'), + readBlob: vi.fn().mockResolvedValue(Buffer.from('data')), + }; + service = new CasService({ + persistence: mockPersistence, + crypto: new NodeCryptoAdapter(), + codec: new JsonCodec(), + chunkSize: 1024, }); }); - // --------------------------------------------------------------------------- - // Fuzz round-trip - // --------------------------------------------------------------------------- - describe('fuzz round-trip', () => { - const key = randomBytes(32); + const key = randomBytes(32); - for (let i = 0; i < 50; i++) { - // Deterministic sizes from 0 to ~100 KB spread across 50 iterations - const size = Math.floor((i / 49) * 100 * 1024); - - it(`round-trips a ${size}-byte buffer (iteration ${i})`, () => { - // Build a deterministic buffer: each byte = (i + byteIndex) & 0xff - const plaintext = Buffer.alloc(size); - for (let b = 0; b < size; b++) { - plaintext[b] = (i + b) & 0xff; - } - - const { buf, meta } = service.encrypt({ buffer: plaintext, key }); - const decrypted = service.decrypt({ buffer: buf, key, meta }); - expect(decrypted.equals(plaintext)).toBe(true); - }); - } - }); + for (let i = 0; i < 50; i++) { + const size = Math.max(1, Math.floor((i / 49) * 1024)); - // --------------------------------------------------------------------------- - // Fuzz tamper - // --------------------------------------------------------------------------- - describe('fuzz tamper', () => { - const key = randomBytes(32); + it(`detects tamper on a ${size}-byte buffer (iteration ${i})`, () => { + const plaintext = Buffer.alloc(size); + for (let b = 0; b < size; b++) { + plaintext[b] = (i * 7 + b) & 0xff; + } - for (let i = 0; i < 50; i++) { - // Use a minimum size of 1 so we always have at least one byte to tamper - const size = Math.max(1, Math.floor((i / 49) * 1024)); - - it(`detects tamper on a ${size}-byte buffer (iteration ${i})`, () => { - const plaintext = Buffer.alloc(size); - for (let b = 0; b < size; b++) { - plaintext[b] = (i * 7 + b) & 0xff; - } - - const { buf, meta } = service.encrypt({ buffer: plaintext, key }); - - // Tamper one byte at a deterministic index - const tampered = Buffer.from(buf); - const tamperIndex = i % tampered.length; - tampered[tamperIndex] ^= 0x01; - - expect(() => service.decrypt({ buffer: tampered, key, meta })).toThrow(CasError); - try { - service.decrypt({ buffer: tampered, key, meta }); - } catch (err) { - expect(err.code).toBe('INTEGRITY_ERROR'); - } - }); - } - }); + const { buf, meta } = service.encrypt({ buffer: plaintext, key }); + + const tampered = Buffer.from(buf); + const tamperIndex = i % tampered.length; + tampered[tamperIndex] ^= 0x01; + + expect(() => service.decrypt({ buffer: tampered, key, meta })).toThrow(CasError); + try { + service.decrypt({ buffer: tampered, key, meta }); + } catch (err) { + expect(err.code).toBe('INTEGRITY_ERROR'); + } + }); + } }); diff --git a/test/unit/domain/services/CasService.empty-file.test.js b/test/unit/domain/services/CasService.empty-file.test.js index 76f89c3..a52c32b 100644 --- a/test/unit/domain/services/CasService.empty-file.test.js +++ b/test/unit/domain/services/CasService.empty-file.test.js @@ -7,44 +7,51 @@ import CasService from '../../../../src/domain/services/CasService.js'; import NodeCryptoAdapter from '../../../../src/infrastructure/adapters/NodeCryptoAdapter.js'; import JsonCodec from '../../../../src/infrastructure/codecs/JsonCodec.js'; -describe('CasService – empty (0-byte) file handling', () => { +/** + * Helper: writes a 0-byte file and returns its path. + */ +function emptyFile(tempDir, name = 'empty.bin') { + const fp = path.join(tempDir, name); + writeFileSync(fp, Buffer.alloc(0)); + return fp; +} + +/** + * Shared factory: builds the standard test fixtures. + */ +function setup() { + const mockPersistence = { + writeBlob: vi.fn().mockResolvedValue('mock-blob-oid'), + writeTree: vi.fn().mockResolvedValue('mock-tree-oid'), + readBlob: vi.fn().mockResolvedValue(Buffer.alloc(0)), + }; + const service = new CasService({ + persistence: mockPersistence, + crypto: new NodeCryptoAdapter(), + codec: new JsonCodec(), + chunkSize: 1024, + }); + const tempDir = mkdtempSync(path.join(os.tmpdir(), 'cas-empty-')); + return { mockPersistence, service, tempDir }; +} + +// --------------------------------------------------------------------------- +// 1. Store – plaintext +// --------------------------------------------------------------------------- +describe('CasService – empty file store plaintext', () => { let service; - let mockPersistence; let tempDir; beforeEach(() => { - mockPersistence = { - writeBlob: vi.fn().mockResolvedValue('mock-blob-oid'), - writeTree: vi.fn().mockResolvedValue('mock-tree-oid'), - readBlob: vi.fn().mockResolvedValue(Buffer.alloc(0)), - }; - service = new CasService({ - persistence: mockPersistence, - crypto: new NodeCryptoAdapter(), - codec: new JsonCodec(), - chunkSize: 1024, - }); - tempDir = mkdtempSync(path.join(os.tmpdir(), 'cas-empty-')); + ({ service, tempDir } = setup()); }); afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); }); - /** - * Helper: writes a 0-byte file and returns its path. - */ - function emptyFile(name = 'empty.bin') { - const fp = path.join(tempDir, name); - writeFileSync(fp, Buffer.alloc(0)); - return fp; - } - - // --------------------------------------------------------------- - // 1. Store 0-byte file -> manifest has size=0 and chunks=[] - // --------------------------------------------------------------- it('stores a 0-byte file and produces a manifest with size=0 and no chunks', async () => { - const filePath = emptyFile(); + const filePath = emptyFile(tempDir); const manifest = await service.store({ source: createReadStream(filePath), @@ -58,12 +65,25 @@ describe('CasService – empty (0-byte) file handling', () => { expect(manifest.filename).toBe('empty.bin'); expect(manifest.encryption).toBeUndefined(); }); +}); + +// --------------------------------------------------------------------------- +// 2. Store – encrypted +// --------------------------------------------------------------------------- +describe('CasService – empty file store encrypted', () => { + let service; + let tempDir; + + beforeEach(() => { + ({ service, tempDir } = setup()); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); - // --------------------------------------------------------------- - // 2. Store 0-byte file with encryption key -> valid manifest, chunks=[] - // --------------------------------------------------------------- it('stores a 0-byte file with an encryption key and produces a valid encrypted manifest', async () => { - const filePath = emptyFile(); + const filePath = emptyFile(tempDir); const encryptionKey = randomBytes(32); const manifest = await service.store({ @@ -73,11 +93,6 @@ describe('CasService – empty (0-byte) file handling', () => { encryptionKey, }); - // AES-256-GCM produces a 16-byte auth tag even for empty plaintext, - // so cipher.final() may emit a small block. The encrypted stream for - // an empty file still results in a non-zero ciphertext output from - // the cipher finalisation. Regardless of whether the cipher emits - // bytes, the manifest must be structurally valid. expect(manifest.slug).toBe('enc-empty'); expect(manifest.filename).toBe('empty-enc.bin'); expect(manifest.encryption).toBeDefined(); @@ -86,19 +101,33 @@ describe('CasService – empty (0-byte) file handling', () => { expect(manifest.encryption.tag).toEqual(expect.any(String)); expect(manifest.encryption.encrypted).toBe(true); - // Every chunk (if any) must still pass schema validation (index, digest, blob). + // Every chunk (if any) must still pass schema validation. for (const chunk of manifest.chunks) { expect(chunk.index).toBeGreaterThanOrEqual(0); expect(chunk.digest).toHaveLength(64); expect(chunk.blob).toBeTruthy(); } }); +}); + +// --------------------------------------------------------------------------- +// 3. writeBlob not called for chunk data +// --------------------------------------------------------------------------- +describe('CasService – empty file writeBlob not called', () => { + let service; + let mockPersistence; + let tempDir; + + beforeEach(() => { + ({ service, mockPersistence, tempDir } = setup()); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); - // --------------------------------------------------------------- - // 3. writeBlob is NOT called for chunk content on a plain empty file - // --------------------------------------------------------------- it('does not call writeBlob for chunk data when storing a plain 0-byte file', async () => { - const filePath = emptyFile(); + const filePath = emptyFile(tempDir); await service.store({ source: createReadStream(filePath), @@ -110,9 +139,26 @@ describe('CasService – empty (0-byte) file handling', () => { // (it is only called inside _chunkAndStore for chunk data). expect(mockPersistence.writeBlob).not.toHaveBeenCalled(); }); +}); + +// --------------------------------------------------------------------------- +// 4. writeBlob called once for createTree +// --------------------------------------------------------------------------- +describe('CasService – empty file writeBlob createTree', () => { + let service; + let mockPersistence; + let tempDir; + + beforeEach(() => { + ({ service, mockPersistence, tempDir } = setup()); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); it('calls writeBlob exactly once (for the manifest) when createTree follows a plain 0-byte store', async () => { - const filePath = emptyFile(); + const filePath = emptyFile(tempDir); const manifest = await service.store({ source: createReadStream(filePath), @@ -133,13 +179,27 @@ describe('CasService – empty (0-byte) file handling', () => { expect(parsed.slug).toBe('tree-empty'); expect(parsed.chunks).toEqual([]); }); +}); + +// --------------------------------------------------------------------------- +// 5. 100 repeated empty-file stores — no state leakage +// --------------------------------------------------------------------------- +describe('CasService – empty file repeated stores', () => { + let service; + let mockPersistence; + let tempDir; + + beforeEach(() => { + ({ service, mockPersistence, tempDir } = setup()); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); - // --------------------------------------------------------------- - // 4. 100 repeated empty-file stores — no state leakage - // --------------------------------------------------------------- it('handles 100 repeated empty-file stores without state leakage', async () => { for (let i = 0; i < 100; i++) { - const filePath = emptyFile(`empty-${i}.bin`); + const filePath = emptyFile(tempDir, `empty-${i}.bin`); const manifest = await service.store({ source: createReadStream(filePath), slug: `iter-${i}`, diff --git a/test/unit/domain/services/CasService.errors.test.js b/test/unit/domain/services/CasService.errors.test.js index 04573e2..e0c4cff 100644 --- a/test/unit/domain/services/CasService.errors.test.js +++ b/test/unit/domain/services/CasService.errors.test.js @@ -1,8 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { createHash } from 'node:crypto'; -import { createReadStream, writeFileSync, mkdtempSync, rmSync } from 'node:fs'; -import path from 'node:path'; -import os from 'node:os'; +import { createReadStream } from 'node:fs'; import CasService from '../../../../src/domain/services/CasService.js'; import NodeCryptoAdapter from '../../../../src/infrastructure/adapters/NodeCryptoAdapter.js'; import JsonCodec from '../../../../src/infrastructure/codecs/JsonCodec.js'; @@ -11,7 +9,7 @@ import Manifest from '../../../../src/domain/value-objects/Manifest.js'; /** Deterministic SHA-256 hex digest for a given string. */ const sha256 = (str) => createHash('sha256').update(str).digest('hex'); -describe('CasService – error paths', () => { +describe('CasService – constructor – chunkSize validation', () => { let mockPersistence; beforeEach(() => { @@ -22,121 +20,141 @@ describe('CasService – error paths', () => { }; }); - // ─── constructor validation ───────────────────────────────────────── + it('throws when chunkSize is 0', () => { + expect( + () => new CasService({ persistence: mockPersistence, crypto: new NodeCryptoAdapter(), codec: new JsonCodec(), chunkSize: 0 }), + ).toThrow('Chunk size must be at least 1024 bytes'); + }); - describe('constructor – chunkSize validation', () => { - it('throws when chunkSize is 0', () => { - expect( - () => new CasService({ persistence: mockPersistence, crypto: new NodeCryptoAdapter(), codec: new JsonCodec(), chunkSize: 0 }), - ).toThrow('Chunk size must be at least 1024 bytes'); - }); + it('throws when chunkSize is 512', () => { + expect( + () => new CasService({ persistence: mockPersistence, crypto: new NodeCryptoAdapter(), codec: new JsonCodec(), chunkSize: 512 }), + ).toThrow('Chunk size must be at least 1024 bytes'); + }); - it('throws when chunkSize is 512', () => { - expect( - () => new CasService({ persistence: mockPersistence, crypto: new NodeCryptoAdapter(), codec: new JsonCodec(), chunkSize: 512 }), - ).toThrow('Chunk size must be at least 1024 bytes'); + it('accepts chunkSize of exactly 1024', () => { + const service = new CasService({ + persistence: mockPersistence, + crypto: new NodeCryptoAdapter(), + codec: new JsonCodec(), + chunkSize: 1024, }); + expect(service.chunkSize).toBe(1024); + }); +}); - it('accepts chunkSize of exactly 1024', () => { - const service = new CasService({ - persistence: mockPersistence, - crypto: new NodeCryptoAdapter(), - codec: new JsonCodec(), - chunkSize: 1024, - }); - expect(service.chunkSize).toBe(1024); - }); +describe('CasService – store', () => { + let mockPersistence; + + beforeEach(() => { + mockPersistence = { + writeBlob: vi.fn().mockResolvedValue('mock-blob-oid'), + writeTree: vi.fn().mockResolvedValue('mock-tree-oid'), + readBlob: vi.fn().mockResolvedValue(Buffer.from('data')), + }; }); - // ─── store – nonexistent file ─────────────────────────────────── - - describe('store', () => { - it('rejects when source stream errors (nonexistent file)', async () => { - const service = new CasService({ - persistence: mockPersistence, - crypto: new NodeCryptoAdapter(), - codec: new JsonCodec(), - chunkSize: 1024, - }); - - await expect( - service.store({ - source: createReadStream('/no/such/file.bin'), - slug: 'bad-path', - filename: 'file.bin', - }), - ).rejects.toThrow(); + it('rejects when source stream errors (nonexistent file)', async () => { + const service = new CasService({ + persistence: mockPersistence, + crypto: new NodeCryptoAdapter(), + codec: new JsonCodec(), + chunkSize: 1024, }); + + await expect( + service.store({ + source: createReadStream('/no/such/file.bin'), + slug: 'bad-path', + filename: 'file.bin', + }), + ).rejects.toThrow(); }); +}); - // ─── verifyIntegrity – corrupted blob ─────────────────────────────── +describe('CasService – verifyIntegrity', () => { + let mockPersistence; + + beforeEach(() => { + mockPersistence = { + writeBlob: vi.fn().mockResolvedValue('mock-blob-oid'), + writeTree: vi.fn().mockResolvedValue('mock-tree-oid'), + readBlob: vi.fn().mockResolvedValue(Buffer.from('data')), + }; + }); - describe('verifyIntegrity', () => { - it('returns false (not throws) when blob data is corrupted', async () => { - const originalData = 'original-content'; - const correctDigest = sha256(originalData); + it('returns false (not throws) when blob data is corrupted', async () => { + const originalData = 'original-content'; + const correctDigest = sha256(originalData); - // readBlob returns corrupted data that does not match the digest - mockPersistence.readBlob = vi - .fn() - .mockResolvedValue(Buffer.from('corrupted-content')); + // readBlob returns corrupted data that does not match the digest + mockPersistence.readBlob = vi + .fn() + .mockResolvedValue(Buffer.from('corrupted-content')); - const service = new CasService({ - persistence: mockPersistence, - crypto: new NodeCryptoAdapter(), - codec: new JsonCodec(), - chunkSize: 1024, - }); + const service = new CasService({ + persistence: mockPersistence, + crypto: new NodeCryptoAdapter(), + codec: new JsonCodec(), + chunkSize: 1024, + }); - const manifest = new Manifest({ - slug: 'integrity-test', - filename: 'file.bin', - size: originalData.length, - chunks: [ - { - index: 0, - size: originalData.length, - blob: 'blob-oid-1', - digest: correctDigest, - }, - ], - }); - - const result = await service.verifyIntegrity(manifest); - expect(result).toBe(false); + const manifest = new Manifest({ + slug: 'integrity-test', + filename: 'file.bin', + size: originalData.length, + chunks: [ + { + index: 0, + size: originalData.length, + blob: 'blob-oid-1', + digest: correctDigest, + }, + ], }); + + const result = await service.verifyIntegrity(manifest); + expect(result).toBe(false); }); +}); - // ─── createTree – invalid manifest ────────────────────────────────── - - describe('createTree', () => { - it('throws when manifest is not a valid Manifest object', async () => { - const service = new CasService({ - persistence: mockPersistence, - crypto: new NodeCryptoAdapter(), - codec: new JsonCodec(), - chunkSize: 1024, - }); - - // A plain object that lacks .toJSON() and .chunks - await expect( - service.createTree({ manifest: {} }), - ).rejects.toThrow(); - }); +describe('CasService – createTree', () => { + let mockPersistence; + + beforeEach(() => { + mockPersistence = { + writeBlob: vi.fn().mockResolvedValue('mock-blob-oid'), + writeTree: vi.fn().mockResolvedValue('mock-tree-oid'), + readBlob: vi.fn().mockResolvedValue(Buffer.from('data')), + }; + }); - it('throws when manifest.toJSON is not a function', async () => { - const service = new CasService({ - persistence: mockPersistence, - crypto: new NodeCryptoAdapter(), - codec: new JsonCodec(), - chunkSize: 1024, - }); + it('throws when manifest is not a valid Manifest object', async () => { + const service = new CasService({ + persistence: mockPersistence, + crypto: new NodeCryptoAdapter(), + codec: new JsonCodec(), + chunkSize: 1024, + }); - const badManifest = { toJSON: 'not-a-function', chunks: [] }; + // A plain object that lacks .toJSON() and .chunks + await expect( + service.createTree({ manifest: {} }), + ).rejects.toThrow(); + }); - await expect( - service.createTree({ manifest: badManifest }), - ).rejects.toThrow(); + it('throws when manifest.toJSON is not a function', async () => { + const service = new CasService({ + persistence: mockPersistence, + crypto: new NodeCryptoAdapter(), + codec: new JsonCodec(), + chunkSize: 1024, }); + + const badManifest = { toJSON: 'not-a-function', chunks: [] }; + + await expect( + service.createTree({ manifest: badManifest }), + ).rejects.toThrow(); }); }); diff --git a/test/unit/domain/services/CasService.key-validation.test.js b/test/unit/domain/services/CasService.key-validation.test.js index 190f80d..b63c2bb 100644 --- a/test/unit/domain/services/CasService.key-validation.test.js +++ b/test/unit/domain/services/CasService.key-validation.test.js @@ -8,191 +8,229 @@ import NodeCryptoAdapter from '../../../../src/infrastructure/adapters/NodeCrypt import JsonCodec from '../../../../src/infrastructure/codecs/JsonCodec.js'; import CasError from '../../../../src/domain/errors/CasError.js'; -describe('CasService key validation', () => { +function createService(mockPersistence) { + return new CasService({ + persistence: mockPersistence, + crypto: new NodeCryptoAdapter(), + codec: new JsonCodec(), + chunkSize: 1024, + }); +} + +function createMockPersistence() { + return { + writeBlob: vi.fn().mockResolvedValue('mock-blob-oid'), + writeTree: vi.fn().mockResolvedValue('mock-tree-oid'), + readBlob: vi.fn().mockResolvedValue(Buffer.from('data')), + }; +} + +describe('CasService key validation – encrypt() valid keys', () => { let service; - let mockPersistence; beforeEach(() => { - mockPersistence = { - writeBlob: vi.fn().mockResolvedValue('mock-blob-oid'), - writeTree: vi.fn().mockResolvedValue('mock-tree-oid'), - readBlob: vi.fn().mockResolvedValue(Buffer.from('data')), - }; - service = new CasService({ - persistence: mockPersistence, - crypto: new NodeCryptoAdapter(), - codec: new JsonCodec(), - chunkSize: 1024, - }); - }); - - describe('encrypt() key validation', () => { - const plaintext = Buffer.from('hello world'); - - it('accepts a 32-byte Buffer key', () => { - const key = Buffer.alloc(32, 0xaa); - expect(() => service.encrypt({ buffer: plaintext, key })).not.toThrow(); - }); - - it('accepts crypto.randomBytes(32)', () => { - const key = randomBytes(32); - expect(() => service.encrypt({ buffer: plaintext, key })).not.toThrow(); - }); - - it('throws INVALID_KEY_LENGTH for a 16-byte key', () => { - const key = Buffer.alloc(16); - expect(() => service.encrypt({ buffer: plaintext, key })).toThrow(CasError); - try { - service.encrypt({ buffer: plaintext, key }); - } catch (err) { - expect(err.code).toBe('INVALID_KEY_LENGTH'); - expect(err.message).toContain('32 bytes'); - expect(err.meta).toEqual({ expected: 32, actual: 16 }); - } - }); - - it('throws INVALID_KEY_LENGTH for a 64-byte key', () => { - const key = Buffer.alloc(64); - expect(() => service.encrypt({ buffer: plaintext, key })).toThrow(CasError); - try { - service.encrypt({ buffer: plaintext, key }); - } catch (err) { - expect(err.code).toBe('INVALID_KEY_LENGTH'); - expect(err.message).toContain('32 bytes'); - expect(err.meta).toEqual({ expected: 32, actual: 64 }); - } - }); - - it('throws INVALID_KEY_LENGTH for an empty Buffer', () => { - const key = Buffer.alloc(0); - expect(() => service.encrypt({ buffer: plaintext, key })).toThrow(CasError); - try { - service.encrypt({ buffer: plaintext, key }); - } catch (err) { - expect(err.code).toBe('INVALID_KEY_LENGTH'); - expect(err.meta).toEqual({ expected: 32, actual: 0 }); - } - }); - - it('throws INVALID_KEY_TYPE for a string key', () => { - const key = 'not-a-buffer-key-string-value!!!'; - expect(() => service.encrypt({ buffer: plaintext, key })).toThrow(CasError); - try { - service.encrypt({ buffer: plaintext, key }); - } catch (err) { - expect(err.code).toBe('INVALID_KEY_TYPE'); - expect(err.message).toContain('must be a Buffer'); - } - }); - - it('throws INVALID_KEY_TYPE for a number key', () => { - const key = 12345; - expect(() => service.encrypt({ buffer: plaintext, key })).toThrow(CasError); - try { - service.encrypt({ buffer: plaintext, key }); - } catch (err) { - expect(err.code).toBe('INVALID_KEY_TYPE'); - expect(err.message).toContain('must be a Buffer'); - } - }); - - it('throws INVALID_KEY_TYPE for null key', () => { - const key = null; - expect(() => service.encrypt({ buffer: plaintext, key })).toThrow(CasError); - try { - service.encrypt({ buffer: plaintext, key }); - } catch (err) { - expect(err.code).toBe('INVALID_KEY_TYPE'); - expect(err.message).toContain('must be a Buffer'); - } - }); - }); - - describe('store() key validation', () => { - let tempDir; - let filePath; - - beforeEach(() => { - tempDir = mkdtempSync(path.join(os.tmpdir(), 'cas-key-test-')); - filePath = path.join(tempDir, 'test.txt'); - writeFileSync(filePath, 'test content'); - }); - - afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }); - }); - - /** Dummy async iterable that yields nothing — avoids opening a real fd. */ - async function* emptySource() {} - - it('accepts a 32-byte Buffer encryptionKey', async () => { - const key = Buffer.alloc(32, 0xbb); - await expect( - service.store({ source: createReadStream(filePath), slug: 's', filename: 'f.txt', encryptionKey: key }), - ).resolves.toBeDefined(); - }); - - it('accepts crypto.randomBytes(32) as encryptionKey', async () => { - const key = randomBytes(32); - await expect( - service.store({ source: createReadStream(filePath), slug: 's', filename: 'f.txt', encryptionKey: key }), - ).resolves.toBeDefined(); - }); - - it('stores without error when no encryptionKey is provided', async () => { - await expect( - service.store({ source: createReadStream(filePath), slug: 's', filename: 'f.txt' }), - ).resolves.toBeDefined(); - }); - - it('throws INVALID_KEY_LENGTH for a 16-byte encryptionKey', async () => { - const key = Buffer.alloc(16); - await expect( - service.store({ source: emptySource(), slug: 's', filename: 'f.txt', encryptionKey: key }), - ).rejects.toThrow(CasError); - }); - - it('throws INVALID_KEY_LENGTH for a 64-byte encryptionKey', async () => { - const key = Buffer.alloc(64); - await expect( - service.store({ source: emptySource(), slug: 's', filename: 'f.txt', encryptionKey: key }), - ).rejects.toThrow(CasError); - }); - - it('throws INVALID_KEY_TYPE for a string encryptionKey', async () => { - const key = 'string-key'; - await expect( - service.store({ source: emptySource(), slug: 's', filename: 'f.txt', encryptionKey: key }), - ).rejects.toThrow(CasError); - }); - - it('throws INVALID_KEY_TYPE for a number encryptionKey', async () => { - const key = 42; - await expect( - service.store({ source: emptySource(), slug: 's', filename: 'f.txt', encryptionKey: key }), - ).rejects.toThrow(CasError); - }); - - it('does not throw for null encryptionKey (treated as no key)', async () => { - // null is falsy, so store skips validation entirely - await expect( - service.store({ source: createReadStream(filePath), slug: 's', filename: 'f.txt', encryptionKey: null }), - ).resolves.toBeDefined(); - }); - }); - - describe('fuzz: key lengths 0..128', () => { - const plaintext = Buffer.from('fuzz test data'); - - it('only length 32 passes for encrypt()', () => { - for (let len = 0; len <= 128; len++) { - const key = Buffer.alloc(len, 0xff); - if (len === 32) { - expect(() => service.encrypt({ buffer: plaintext, key })).not.toThrow(); - } else { - expect(() => service.encrypt({ buffer: plaintext, key })).toThrow(CasError); - } + service = createService(createMockPersistence()); + }); + + const plaintext = Buffer.from('hello world'); + + it('accepts a 32-byte Buffer key', () => { + const key = Buffer.alloc(32, 0xaa); + expect(() => service.encrypt({ buffer: plaintext, key })).not.toThrow(); + }); + + it('accepts crypto.randomBytes(32)', () => { + const key = randomBytes(32); + expect(() => service.encrypt({ buffer: plaintext, key })).not.toThrow(); + }); +}); + +describe('CasService key validation – encrypt() invalid key length', () => { + let service; + + beforeEach(() => { + service = createService(createMockPersistence()); + }); + + const plaintext = Buffer.from('hello world'); + + it('throws INVALID_KEY_LENGTH for a 16-byte key', () => { + const key = Buffer.alloc(16); + expect(() => service.encrypt({ buffer: plaintext, key })).toThrow(CasError); + try { + service.encrypt({ buffer: plaintext, key }); + } catch (err) { + expect(err.code).toBe('INVALID_KEY_LENGTH'); + expect(err.message).toContain('32 bytes'); + expect(err.meta).toEqual({ expected: 32, actual: 16 }); + } + }); + + it('throws INVALID_KEY_LENGTH for a 64-byte key', () => { + const key = Buffer.alloc(64); + expect(() => service.encrypt({ buffer: plaintext, key })).toThrow(CasError); + try { + service.encrypt({ buffer: plaintext, key }); + } catch (err) { + expect(err.code).toBe('INVALID_KEY_LENGTH'); + expect(err.message).toContain('32 bytes'); + expect(err.meta).toEqual({ expected: 32, actual: 64 }); + } + }); + + it('throws INVALID_KEY_LENGTH for an empty Buffer', () => { + const key = Buffer.alloc(0); + expect(() => service.encrypt({ buffer: plaintext, key })).toThrow(CasError); + try { + service.encrypt({ buffer: plaintext, key }); + } catch (err) { + expect(err.code).toBe('INVALID_KEY_LENGTH'); + expect(err.meta).toEqual({ expected: 32, actual: 0 }); + } + }); +}); + +describe('CasService key validation – encrypt() invalid key type', () => { + let service; + + beforeEach(() => { + service = createService(createMockPersistence()); + }); + + const plaintext = Buffer.from('hello world'); + + it('throws INVALID_KEY_TYPE for a string key', () => { + const key = 'not-a-buffer-key-string-value!!!'; + expect(() => service.encrypt({ buffer: plaintext, key })).toThrow(CasError); + try { + service.encrypt({ buffer: plaintext, key }); + } catch (err) { + expect(err.code).toBe('INVALID_KEY_TYPE'); + expect(err.message).toContain('must be a Buffer'); + } + }); + + it('throws INVALID_KEY_TYPE for a number key', () => { + const key = 12345; + expect(() => service.encrypt({ buffer: plaintext, key })).toThrow(CasError); + try { + service.encrypt({ buffer: plaintext, key }); + } catch (err) { + expect(err.code).toBe('INVALID_KEY_TYPE'); + expect(err.message).toContain('must be a Buffer'); + } + }); + + it('throws INVALID_KEY_TYPE for null key', () => { + const key = null; + expect(() => service.encrypt({ buffer: plaintext, key })).toThrow(CasError); + try { + service.encrypt({ buffer: plaintext, key }); + } catch (err) { + expect(err.code).toBe('INVALID_KEY_TYPE'); + expect(err.message).toContain('must be a Buffer'); + } + }); +}); + +describe('CasService key validation – store() valid keys', () => { + let service; + let tempDir; + let filePath; + + beforeEach(() => { + service = createService(createMockPersistence()); + tempDir = mkdtempSync(path.join(os.tmpdir(), 'cas-key-test-')); + filePath = path.join(tempDir, 'test.txt'); + writeFileSync(filePath, 'test content'); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('accepts a 32-byte Buffer encryptionKey', async () => { + const key = Buffer.alloc(32, 0xbb); + await expect( + service.store({ source: createReadStream(filePath), slug: 's', filename: 'f.txt', encryptionKey: key }), + ).resolves.toBeDefined(); + }); + + it('accepts crypto.randomBytes(32) as encryptionKey', async () => { + const key = randomBytes(32); + await expect( + service.store({ source: createReadStream(filePath), slug: 's', filename: 'f.txt', encryptionKey: key }), + ).resolves.toBeDefined(); + }); + + it('stores without error when no encryptionKey is provided', async () => { + await expect( + service.store({ source: createReadStream(filePath), slug: 's', filename: 'f.txt' }), + ).resolves.toBeDefined(); + }); + + it('does not throw for null encryptionKey (treated as no key)', async () => { + await expect( + service.store({ source: createReadStream(filePath), slug: 's', filename: 'f.txt', encryptionKey: null }), + ).resolves.toBeDefined(); + }); +}); + +describe('CasService key validation – store() invalid keys', () => { + let service; + + beforeEach(() => { + service = createService(createMockPersistence()); + }); + + async function* emptySource() {} + + it('throws INVALID_KEY_LENGTH for a 16-byte encryptionKey', async () => { + const key = Buffer.alloc(16); + await expect( + service.store({ source: emptySource(), slug: 's', filename: 'f.txt', encryptionKey: key }), + ).rejects.toThrow(CasError); + }); + + it('throws INVALID_KEY_LENGTH for a 64-byte encryptionKey', async () => { + const key = Buffer.alloc(64); + await expect( + service.store({ source: emptySource(), slug: 's', filename: 'f.txt', encryptionKey: key }), + ).rejects.toThrow(CasError); + }); + + it('throws INVALID_KEY_TYPE for a string encryptionKey', async () => { + const key = 'string-key'; + await expect( + service.store({ source: emptySource(), slug: 's', filename: 'f.txt', encryptionKey: key }), + ).rejects.toThrow(CasError); + }); + + it('throws INVALID_KEY_TYPE for a number encryptionKey', async () => { + const key = 42; + await expect( + service.store({ source: emptySource(), slug: 's', filename: 'f.txt', encryptionKey: key }), + ).rejects.toThrow(CasError); + }); +}); + +describe('CasService key validation – fuzz: key lengths 0..128', () => { + let service; + + beforeEach(() => { + service = createService(createMockPersistence()); + }); + + const plaintext = Buffer.from('fuzz test data'); + + it('only length 32 passes for encrypt()', () => { + for (let len = 0; len <= 128; len++) { + const key = Buffer.alloc(len, 0xff); + if (len === 32) { + expect(() => service.encrypt({ buffer: plaintext, key })).not.toThrow(); + } else { + expect(() => service.encrypt({ buffer: plaintext, key })).toThrow(CasError); } - }); + } }); }); diff --git a/test/unit/domain/services/CasService.restore.test.js b/test/unit/domain/services/CasService.restore.test.js new file mode 100644 index 0000000..b217de4 --- /dev/null +++ b/test/unit/domain/services/CasService.restore.test.js @@ -0,0 +1,297 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { randomBytes } from 'node:crypto'; +import CasService from '../../../../src/domain/services/CasService.js'; +import NodeCryptoAdapter from '../../../../src/infrastructure/adapters/NodeCryptoAdapter.js'; +import JsonCodec from '../../../../src/infrastructure/codecs/JsonCodec.js'; +import Manifest from '../../../../src/domain/value-objects/Manifest.js'; +import CasError from '../../../../src/domain/errors/CasError.js'; + +// --------------------------------------------------------------------------- +// Module-level helper: store content via async iterable, return manifest +// --------------------------------------------------------------------------- +async function storeBuffer(svc, buf, opts = {}) { + async function* source() { yield buf; } + return svc.store({ + source: source(), + slug: opts.slug || 'test', + filename: opts.filename || 'test.bin', + encryptionKey: opts.encryptionKey, + }); +} + +/** + * Shared factory: builds the standard test fixtures (crypto, blobStore, + * mockPersistence, service) used by every describe block. + */ +function setup() { + const crypto = new NodeCryptoAdapter(); + const blobStore = new Map(); + + const mockPersistence = { + writeBlob: vi.fn().mockImplementation(async (content) => { + const buf = Buffer.isBuffer(content) ? content : Buffer.from(content); + const oid = crypto.sha256(buf); + blobStore.set(oid, buf); + return oid; + }), + writeTree: vi.fn().mockResolvedValue('mock-tree-oid'), + readBlob: vi.fn().mockImplementation(async (oid) => { + const buf = blobStore.get(oid); + if (!buf) { throw new Error(`Blob not found: ${oid}`); } + return buf; + }), + }; + + const service = new CasService({ + persistence: mockPersistence, + crypto, + codec: new JsonCodec(), + chunkSize: 1024, + }); + + return { crypto, blobStore, mockPersistence, service }; +} + +// --------------------------------------------------------------------------- +// Plaintext round-trip +// --------------------------------------------------------------------------- +describe('CasService.restore() – plaintext round-trip', () => { + let service; + + beforeEach(() => { + ({ service } = setup()); + }); + + it('restores a single-chunk file', async () => { + const original = Buffer.from('hello world'); + const manifest = await storeBuffer(service, original); + + const { buffer, bytesWritten } = await service.restore({ manifest }); + + expect(buffer.equals(original)).toBe(true); + expect(bytesWritten).toBe(original.length); + }); + + it('restores a multi-chunk file', async () => { + const original = randomBytes(3 * 1024); // 3 chunks at 1024 + const manifest = await storeBuffer(service, original); + expect(manifest.chunks.length).toBe(3); + + const { buffer, bytesWritten } = await service.restore({ manifest }); + + expect(buffer.equals(original)).toBe(true); + expect(bytesWritten).toBe(original.length); + }); + + it('restores a file that is exact multiple of chunkSize', async () => { + const original = randomBytes(2 * 1024); + const manifest = await storeBuffer(service, original); + expect(manifest.chunks.length).toBe(2); + + const { buffer } = await service.restore({ manifest }); + expect(buffer.equals(original)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Encrypted round-trip +// --------------------------------------------------------------------------- +describe('CasService.restore() – encrypted round-trip', () => { + let service; + + beforeEach(() => { + ({ service } = setup()); + }); + + it('restores encrypted content with correct key', async () => { + const key = randomBytes(32); + const original = Buffer.from('secret data here'); + const manifest = await storeBuffer(service, original, { encryptionKey: key }); + + expect(manifest.encryption).toBeDefined(); + expect(manifest.encryption.encrypted).toBe(true); + + const { buffer, bytesWritten } = await service.restore({ + manifest, + encryptionKey: key, + }); + + expect(buffer.equals(original)).toBe(true); + expect(bytesWritten).toBe(original.length); + }); + + it('restores multi-chunk encrypted content', async () => { + const key = randomBytes(32); + const original = randomBytes(3 * 1024); + const manifest = await storeBuffer(service, original, { encryptionKey: key }); + + const { buffer } = await service.restore({ manifest, encryptionKey: key }); + expect(buffer.equals(original)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Empty manifest +// --------------------------------------------------------------------------- +describe('CasService.restore() – empty manifest', () => { + let service; + + beforeEach(() => { + ({ service } = setup()); + }); + + it('returns 0-byte buffer for empty manifest', async () => { + const manifest = new Manifest({ + slug: 'empty', + filename: 'empty.bin', + size: 0, + chunks: [], + }); + + const { buffer, bytesWritten } = await service.restore({ manifest }); + + expect(buffer.length).toBe(0); + expect(bytesWritten).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// Wrong key +// --------------------------------------------------------------------------- +describe('CasService.restore() – wrong key', () => { + let service; + + beforeEach(() => { + ({ service } = setup()); + }); + + it('throws INTEGRITY_ERROR with wrong decryption key', async () => { + const keyA = randomBytes(32); + const keyB = randomBytes(32); + const original = Buffer.from('encrypted payload'); + const manifest = await storeBuffer(service, original, { encryptionKey: keyA }); + + await expect( + service.restore({ manifest, encryptionKey: keyB }), + ).rejects.toThrow(CasError); + + try { + await service.restore({ manifest, encryptionKey: keyB }); + } catch (err) { + expect(err.code).toBe('INTEGRITY_ERROR'); + } + }); +}); + +// --------------------------------------------------------------------------- +// Corrupted chunk +// --------------------------------------------------------------------------- +describe('CasService.restore() – corrupted chunk', () => { + let service; + let blobStore; + + beforeEach(() => { + ({ service, blobStore } = setup()); + }); + + it('throws INTEGRITY_ERROR when chunk data is corrupted', async () => { + const original = Buffer.from('some content to store'); + const manifest = await storeBuffer(service, original); + + // Corrupt the blob in the store + const firstChunk = manifest.chunks[0]; + const corruptBuf = Buffer.from(blobStore.get(firstChunk.blob)); + corruptBuf[0] ^= 0x01; + blobStore.set(firstChunk.blob, corruptBuf); + + // Overwrite readBlob to return corrupted data + // (the oid key still maps, but content is wrong) + await expect( + service.restore({ manifest }), + ).rejects.toThrow(CasError); + + try { + await service.restore({ manifest }); + } catch (err) { + expect(err.code).toBe('INTEGRITY_ERROR'); + expect(err.meta.chunkIndex).toBe(0); + } + }); +}); + +// --------------------------------------------------------------------------- +// Key validation +// --------------------------------------------------------------------------- +describe('CasService.restore() – key validation', () => { + let service; + + beforeEach(() => { + ({ service } = setup()); + }); + + it('throws INVALID_KEY_LENGTH for 16-byte key', async () => { + const manifest = new Manifest({ + slug: 'x', + filename: 'x.bin', + size: 0, + chunks: [], + encryption: { algorithm: 'aes-256-gcm', nonce: 'x', tag: 'x', encrypted: true }, + }); + + await expect( + service.restore({ manifest, encryptionKey: Buffer.alloc(16) }), + ).rejects.toThrow(CasError); + }); + + it('throws INVALID_KEY_TYPE for string key', async () => { + const manifest = new Manifest({ + slug: 'x', + filename: 'x.bin', + size: 0, + chunks: [], + }); + + await expect( + service.restore({ manifest, encryptionKey: 'bad-key' }), + ).rejects.toThrow(CasError); + }); +}); + +// --------------------------------------------------------------------------- +// Fuzz round-trip +// --------------------------------------------------------------------------- +describe('CasService.restore() – fuzz round-trip', () => { + let service; + const key = randomBytes(32); + + beforeEach(() => { + ({ service } = setup()); + }); + + for (let i = 0; i < 50; i++) { + // Sizes from 0 to 3*chunkSize spread across 50 iterations + const size = Math.floor((i / 49) * 3 * 1024); + + it(`round-trips ${size} bytes (plaintext, iteration ${i})`, async () => { + const original = Buffer.alloc(size); + for (let b = 0; b < size; b++) { original[b] = (i + b) & 0xff; } + + const manifest = await storeBuffer(service, original); + const { buffer } = await service.restore({ manifest }); + expect(buffer.equals(original)).toBe(true); + }); + } + + for (let i = 0; i < 50; i++) { + const size = Math.floor((i / 49) * 3 * 1024); + + it(`round-trips ${size} bytes (encrypted, iteration ${i})`, async () => { + const original = Buffer.alloc(size); + for (let b = 0; b < size; b++) { original[b] = (i * 3 + b) & 0xff; } + + const manifest = await storeBuffer(service, original, { encryptionKey: key }); + const { buffer } = await service.restore({ manifest, encryptionKey: key }); + expect(buffer.equals(original)).toBe(true); + }); + } +}); diff --git a/test/unit/domain/services/CasService.stream-error.test.js b/test/unit/domain/services/CasService.stream-error.test.js new file mode 100644 index 0000000..fa7d900 --- /dev/null +++ b/test/unit/domain/services/CasService.stream-error.test.js @@ -0,0 +1,232 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import CasService from '../../../../src/domain/services/CasService.js'; +import NodeCryptoAdapter from '../../../../src/infrastructure/adapters/NodeCryptoAdapter.js'; +import JsonCodec from '../../../../src/infrastructure/codecs/JsonCodec.js'; +import CasError from '../../../../src/domain/errors/CasError.js'; + +/** + * Creates an async iterable that yields `n` chunks of `chunkSize` bytes + * then throws an error. + */ +function failingSource(chunksBeforeError, chunkSize = 1024) { + let yielded = 0; + return { + [Symbol.asyncIterator]() { + return { + async next() { + if (yielded >= chunksBeforeError) { + throw new Error('simulated stream failure'); + } + yielded++; + return { value: Buffer.alloc(chunkSize, 0xaa), done: false }; + }, + }; + }, + }; +} + +/** + * Shared factory: builds the standard test fixtures used by every block. + */ +function setup() { + const mockPersistence = { + writeBlob: vi.fn().mockResolvedValue('mock-blob-oid'), + writeTree: vi.fn().mockResolvedValue('mock-tree-oid'), + readBlob: vi.fn().mockResolvedValue(Buffer.from('data')), + }; + const service = new CasService({ + persistence: mockPersistence, + crypto: new NodeCryptoAdapter(), + codec: new JsonCodec(), + chunkSize: 1024, + }); + return { mockPersistence, service }; +} + +// --------------------------------------------------------------------------- +// STREAM_ERROR after 3 chunks +// --------------------------------------------------------------------------- +describe('CasService stream error – STREAM_ERROR after 3 chunks', () => { + let service; + + beforeEach(() => { + ({ service } = setup()); + }); + + it('throws STREAM_ERROR when stream fails after 3 chunks', async () => { + await expect( + service.store({ + source: failingSource(3), + slug: 'fail-test', + filename: 'fail.bin', + }), + ).rejects.toThrow(CasError); + + try { + await service.store({ + source: failingSource(3), + slug: 'fail-test', + filename: 'fail.bin', + }); + } catch (err) { + expect(err.code).toBe('STREAM_ERROR'); + expect(err.meta.chunksWritten).toBe(3); + expect(err.message).toContain('simulated stream failure'); + } + }); +}); + +// --------------------------------------------------------------------------- +// STREAM_ERROR immediate failure +// --------------------------------------------------------------------------- +describe('CasService stream error – STREAM_ERROR immediate failure', () => { + let service; + + beforeEach(() => { + ({ service } = setup()); + }); + + it('throws STREAM_ERROR with chunksWritten=0 when stream fails immediately', async () => { + await expect( + service.store({ + source: failingSource(0), + slug: 'fail-test', + filename: 'fail.bin', + }), + ).rejects.toThrow(CasError); + + try { + await service.store({ + source: failingSource(0), + slug: 'fail-test', + filename: 'fail.bin', + }); + } catch (err) { + expect(err.code).toBe('STREAM_ERROR'); + expect(err.meta.chunksWritten).toBe(0); + } + }); +}); + +// --------------------------------------------------------------------------- +// No manifest on error +// --------------------------------------------------------------------------- +describe('CasService stream error – no manifest on error', () => { + let service; + + beforeEach(() => { + ({ service } = setup()); + }); + + it('does not return a manifest on stream error', async () => { + let manifest; + try { + manifest = await service.store({ + source: failingSource(2), + slug: 'no-manifest', + filename: 'fail.bin', + }); + } catch { + // expected + } + expect(manifest).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Successful stores still work +// --------------------------------------------------------------------------- +describe('CasService stream error – successful stores', () => { + let service; + + beforeEach(() => { + ({ service } = setup()); + }); + + it('succeeds when stream completes normally', async () => { + async function* goodSource() { + yield Buffer.alloc(512, 0xbb); + } + + const manifest = await service.store({ + source: goodSource(), + slug: 'ok', + filename: 'ok.bin', + }); + + expect(manifest).toBeDefined(); + expect(manifest.slug).toBe('ok'); + }); +}); + +// --------------------------------------------------------------------------- +// CasError passthrough (not double-wrapped) +// --------------------------------------------------------------------------- +describe('CasService stream error – CasError passthrough', () => { + let service; + + beforeEach(() => { + ({ service } = setup()); + }); + + it('does not wrap CasError as STREAM_ERROR', async () => { + const casErr = new CasError('custom error', 'CUSTOM_CODE'); + const badSource = { + [Symbol.asyncIterator]() { + return { + async next() { throw casErr; }, + }; + }, + }; + + await expect( + service.store({ + source: badSource, + slug: 'x', + filename: 'x.bin', + }), + ).rejects.toThrow(casErr); + + try { + await service.store({ source: badSource, slug: 'x', filename: 'x.bin' }); + } catch (err) { + expect(err.code).toBe('CUSTOM_CODE'); + } + }); +}); + +// --------------------------------------------------------------------------- +// Fuzz: randomized failure points +// --------------------------------------------------------------------------- +describe('CasService stream error – fuzz', () => { + let service; + + beforeEach(() => { + ({ service } = setup()); + }); + + for (let i = 0; i < 20; i++) { + const failAfter = i; + + it(`STREAM_ERROR with chunksWritten=${failAfter} (iteration ${i})`, async () => { + await expect( + service.store({ + source: failingSource(failAfter), + slug: `fuzz-${i}`, + filename: 'fuzz.bin', + }), + ).rejects.toThrow(CasError); + + try { + await service.store({ + source: failingSource(failAfter), + slug: `fuzz-${i}`, + filename: 'fuzz.bin', + }); + } catch (err) { + expect(err.code).toBe('STREAM_ERROR'); + expect(err.meta.chunksWritten).toBe(failAfter); + } + }); + } +}); diff --git a/test/unit/domain/services/CasService.test.js b/test/unit/domain/services/CasService.test.js index 2d6fcbd..f2121dc 100644 --- a/test/unit/domain/services/CasService.test.js +++ b/test/unit/domain/services/CasService.test.js @@ -8,22 +8,33 @@ import JsonCodec from '../../../../src/infrastructure/codecs/JsonCodec.js'; import Manifest from '../../../../src/domain/value-objects/Manifest.js'; import { digestOf } from '../../../helpers/crypto.js'; -describe('CasService', () => { +/** + * Shared factory: builds the standard test fixtures. + */ +function setup() { + const mockPersistence = { + writeBlob: vi.fn().mockResolvedValue('mock-blob-oid'), + writeTree: vi.fn().mockResolvedValue('mock-tree-oid'), + readBlob: vi.fn().mockImplementation((oid) => Promise.resolve(Buffer.from(oid === 'b1' ? 'chunk1' : 'chunk2'))), + }; + const service = new CasService({ + persistence: mockPersistence, + crypto: new NodeCryptoAdapter(), + codec: new JsonCodec(), + chunkSize: 1024, + }); + return { mockPersistence, service }; +} + +// --------------------------------------------------------------------------- +// store +// --------------------------------------------------------------------------- +describe('CasService – store', () => { let service; let mockPersistence; beforeEach(() => { - mockPersistence = { - writeBlob: vi.fn().mockResolvedValue('mock-blob-oid'), - writeTree: vi.fn().mockResolvedValue('mock-tree-oid'), - readBlob: vi.fn().mockImplementation((oid) => Promise.resolve(Buffer.from(oid === 'b1' ? 'chunk1' : 'chunk2'))), - }; - service = new CasService({ - persistence: mockPersistence, - crypto: new NodeCryptoAdapter(), - codec: new JsonCodec(), - chunkSize: 1024, - }); + ({ service, mockPersistence } = setup()); }); it('chunks a file and stores blobs', async () => { @@ -45,6 +56,18 @@ describe('CasService', () => { rmSync(tempDir, { recursive: true, force: true }); }); +}); + +// --------------------------------------------------------------------------- +// createTree +// --------------------------------------------------------------------------- +describe('CasService – createTree', () => { + let service; + let mockPersistence; + + beforeEach(() => { + ({ service, mockPersistence } = setup()); + }); it('creates a tree from manifest', async () => { const manifest = new Manifest({ @@ -67,6 +90,17 @@ describe('CasService', () => { expect.stringContaining(digestOf('chunk-b')) ])); }); +}); + +// --------------------------------------------------------------------------- +// verifyIntegrity +// --------------------------------------------------------------------------- +describe('CasService – verifyIntegrity', () => { + let service; + + beforeEach(() => { + ({ service } = setup()); + }); it('verifies integrity of chunks', async () => { // Helper to calc hash since service._sha256 is private diff --git a/test/unit/domain/value-objects/Chunk.test.js b/test/unit/domain/value-objects/Chunk.test.js index 4baead5..d21e38a 100644 --- a/test/unit/domain/value-objects/Chunk.test.js +++ b/test/unit/domain/value-objects/Chunk.test.js @@ -13,9 +13,10 @@ const validChunkData = () => ({ digest: sha256('test-chunk-0'), }); -describe('Chunk value-object', () => { - // ─── happy path ───────────────────────────────────────────────────── - +// --------------------------------------------------------------------------- +// Creation (happy path) +// --------------------------------------------------------------------------- +describe('Chunk – creation', () => { it('creates a frozen object from valid data', () => { const c = new Chunk(validChunkData()); @@ -30,9 +31,12 @@ describe('Chunk value-object', () => { const c = new Chunk({ ...validChunkData(), index: 0 }); expect(c.index).toBe(0); }); +}); - // ─── index validation ────────────────────────────────────────────── - +// --------------------------------------------------------------------------- +// Field validation +// --------------------------------------------------------------------------- +describe('Chunk – field validation', () => { it('throws when index is negative', () => { const data = { ...validChunkData(), index: -1 }; expect(() => new Chunk(data)).toThrow(/[Ii]nvalid chunk data/); @@ -43,8 +47,6 @@ describe('Chunk value-object', () => { expect(() => new Chunk(data)).toThrow(/[Ii]nvalid chunk data/); }); - // ─── size validation ─────────────────────────────────────────────── - it('throws when size is 0 (schema requires positive)', () => { const data = { ...validChunkData(), size: 0 }; expect(() => new Chunk(data)).toThrow(/[Ii]nvalid chunk data/); @@ -55,8 +57,6 @@ describe('Chunk value-object', () => { expect(() => new Chunk(data)).toThrow(/[Ii]nvalid chunk data/); }); - // ─── digest validation (must be exactly 64 hex chars) ────────────── - it('throws when digest is 63 characters', () => { const data = { ...validChunkData(), digest: 'a'.repeat(63) }; expect(() => new Chunk(data)).toThrow(/[Ii]nvalid chunk data/); @@ -72,15 +72,16 @@ describe('Chunk value-object', () => { expect(() => new Chunk(data)).toThrow(/[Ii]nvalid chunk data/); }); - // ─── blob validation ─────────────────────────────────────────────── - it('throws when blob is empty string', () => { const data = { ...validChunkData(), blob: '' }; expect(() => new Chunk(data)).toThrow(/[Ii]nvalid chunk data/); }); +}); - // ─── missing fields ──────────────────────────────────────────────── - +// --------------------------------------------------------------------------- +// Missing fields +// --------------------------------------------------------------------------- +describe('Chunk – missing fields', () => { it('throws when index is missing', () => { const data = validChunkData(); delete data.index; diff --git a/test/unit/domain/value-objects/Manifest.test.js b/test/unit/domain/value-objects/Manifest.test.js index 0b7a57f..6b70c52 100644 --- a/test/unit/domain/value-objects/Manifest.test.js +++ b/test/unit/domain/value-objects/Manifest.test.js @@ -21,9 +21,10 @@ const validManifestData = () => ({ chunks: [validChunk(0)], }); -describe('Manifest value-object', () => { - // ─── happy path ───────────────────────────────────────────────────── - +// --------------------------------------------------------------------------- +// Creation (happy path + toJSON) +// --------------------------------------------------------------------------- +describe('Manifest – creation', () => { it('creates a frozen object from valid data', () => { const m = new Manifest(validManifestData()); @@ -57,9 +58,12 @@ describe('Manifest value-object', () => { expect(json.size).toBe(data.size); expect(json.chunks).toHaveLength(data.chunks.length); }); +}); - // ─── missing / invalid slug ───────────────────────────────────────── - +// --------------------------------------------------------------------------- +// Validation – slug and filename +// --------------------------------------------------------------------------- +describe('Manifest – validation (slug and filename)', () => { it('throws when slug is missing', () => { const data = validManifestData(); delete data.slug; @@ -71,16 +75,17 @@ describe('Manifest value-object', () => { expect(() => new Manifest(data)).toThrow(/[Ii]nvalid manifest data/); }); - // ─── missing / invalid filename ───────────────────────────────────── - it('throws when filename is missing', () => { const data = validManifestData(); delete data.filename; expect(() => new Manifest(data)).toThrow(/[Ii]nvalid manifest data/); }); +}); - // ─── size validation ──────────────────────────────────────────────── - +// --------------------------------------------------------------------------- +// Validation – size and chunks +// --------------------------------------------------------------------------- +describe('Manifest – validation (size and chunks)', () => { it('throws when size is negative', () => { const data = { ...validManifestData(), size: -1 }; expect(() => new Manifest(data)).toThrow(/[Ii]nvalid manifest data/); @@ -92,8 +97,6 @@ describe('Manifest value-object', () => { expect(m.size).toBe(0); }); - // ─── chunks validation ────────────────────────────────────────────── - it('throws when chunks is not an array', () => { const data = { ...validManifestData(), chunks: 'not-an-array' }; expect(() => new Manifest(data)).toThrow(/[Ii]nvalid manifest data/); @@ -104,8 +107,6 @@ describe('Manifest value-object', () => { expect(() => new Manifest(data)).toThrow(); }); - // ─── missing required fields ──────────────────────────────────────── - it('throws when size field is missing entirely', () => { const data = validManifestData(); delete data.size; diff --git a/test/unit/infrastructure/adapters/GitPersistenceAdapter.readTree.test.js b/test/unit/infrastructure/adapters/GitPersistenceAdapter.readTree.test.js new file mode 100644 index 0000000..cb3b223 --- /dev/null +++ b/test/unit/infrastructure/adapters/GitPersistenceAdapter.readTree.test.js @@ -0,0 +1,115 @@ +import { describe, it, expect, vi } from 'vitest'; +import GitPersistenceAdapter from '../../../../src/infrastructure/adapters/GitPersistenceAdapter.js'; +import CasError from '../../../../src/domain/errors/CasError.js'; + +/** + * Create a mock plumbing that returns the given output for `execute`. + */ +function mockPlumbing(output) { + return { + execute: vi.fn().mockResolvedValue(output), + executeStream: vi.fn(), + }; +} + +/** Stub policy that just runs the fn directly. */ +const noPolicy = { execute: (fn) => fn() }; + +/** Shorthand: create adapter whose plumbing returns `output`. */ +function adapterFor(output) { + return new GitPersistenceAdapter({ plumbing: mockPlumbing(output), policy: noPolicy }); +} + +/** Expected shape for every entry. */ +function entry(oid, name) { + return { mode: '100644', type: 'blob', oid, name }; +} + +// --------------------------------------------------------------------------- +// Parsing – golden path, empty tree, spaces +// --------------------------------------------------------------------------- +describe('GitPersistenceAdapter.readTree() – parsing', () => { + it('parses a typical ls-tree output with manifest and chunks', async () => { + const output = [ + '100644 blob abc123def456\tmanifest.json', + `100644 blob deadbeef1234\t${'a'.repeat(64)}`, + `100644 blob cafebabe5678\t${'b'.repeat(64)}`, + ].join('\0'); + + const entries = await adapterFor(output).readTree('some-tree-oid'); + + expect(entries).toHaveLength(3); + expect(entries[0]).toEqual(entry('abc123def456', 'manifest.json')); + expect(entries[1]).toEqual(entry('deadbeef1234', 'a'.repeat(64))); + expect(entries[2]).toEqual(entry('cafebabe5678', 'b'.repeat(64))); + }); + + it('returns [] for empty output', async () => { + expect(await adapterFor('').readTree('empty-tree')).toEqual([]); + }); + + it('returns [] for NUL-only output', async () => { + expect(await adapterFor('\0').readTree('empty-tree')).toEqual([]); + }); + + it('handles filenames with spaces', async () => { + const output = '100644 blob abc123\tfile with spaces.txt\0'; + const entries = await adapterFor(output).readTree('tree-oid'); + expect(entries[0].name).toBe('file with spaces.txt'); + }); +}); + +// --------------------------------------------------------------------------- +// Errors – malformed output + plumbing error propagation +// --------------------------------------------------------------------------- +describe('GitPersistenceAdapter.readTree() – errors', () => { + it('throws TREE_PARSE_ERROR when entry has no tab', async () => { + const adapter = adapterFor('100644 blob abc123 no-tab-here\0'); + + await expect(adapter.readTree('bad-tree')).rejects.toThrow(CasError); + try { + await adapter.readTree('bad-tree'); + } catch (err) { + expect(err.code).toBe('TREE_PARSE_ERROR'); + } + }); + + it('throws TREE_PARSE_ERROR when metadata has wrong number of fields', async () => { + const adapter = adapterFor('100644 blob\tmanifest.json\0'); + + await expect(adapter.readTree('bad-tree')).rejects.toThrow(CasError); + try { + await adapter.readTree('bad-tree'); + } catch (err) { + expect(err.code).toBe('TREE_PARSE_ERROR'); + } + }); + + it('propagates plumbing errors', async () => { + const plumbing = { + execute: vi.fn().mockRejectedValue(new Error('git failed')), + executeStream: vi.fn(), + }; + const adapter = new GitPersistenceAdapter({ plumbing, policy: noPolicy }); + + await expect(adapter.readTree('bad-oid')).rejects.toThrow('git failed'); + }); +}); + +// --------------------------------------------------------------------------- +// Fuzz – 1000 synthetic entries +// --------------------------------------------------------------------------- +describe('GitPersistenceAdapter.readTree() – fuzz', () => { + it('parses 1000 synthetic entries', async () => { + const lines = []; + for (let i = 0; i < 1000; i++) { + const oid = i.toString(16).padStart(40, '0'); + lines.push(`100644 blob ${oid}\tchunk-${i}`); + } + const output = lines.join('\0'); + const entries = await adapterFor(output).readTree('big-tree'); + expect(entries).toHaveLength(1000); + expect(entries[0].name).toBe('chunk-0'); + expect(entries[999].name).toBe('chunk-999'); + }); +});