diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6426ddb..8f594e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,3 +25,32 @@ jobs: # Fails the job on any type error (non-zero exit). - name: Typecheck run: npx nuxi typecheck + + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + # hledger must be on PATH for the suite to run for real — without it the + # hledger-gated timeout test silently no-ops (the failure mode Issue #11 + # exists to remove). Installed every run (runners are ephemeral) and NOT + # cached: a stale apt cache could reintroduce that silent skip. + - name: Install hledger + run: sudo apt-get update && sudo apt-get install -y hledger + + # Fail loudly here if the install above silently failed, rather than + # letting the gated test skip itself green. + - name: Verify hledger is available + run: hledger --version + + - name: Test + run: npm run test diff --git a/.kiro/specs/audit-test-suite/design.md b/.kiro/specs/audit-test-suite/design.md new file mode 100644 index 0000000..510e7e3 --- /dev/null +++ b/.kiro/specs/audit-test-suite/design.md @@ -0,0 +1,191 @@ +# Design — Audit the test suite + +> Traceability: implements **GitHub Issue #11** ("chore: audit the test suite — +> skipped/gated tests, legacy paths, coverage gaps"). Relates to #4. + +## Problem + +Some tests are silently *not* protecting us: they're skipped, environment-gated +into no-ops, aimed at a legacy code path, or assert hledger's engine behavior +rather than our code. A green run hides this. This ticket inventories every such +test, decides keep/fix/delete for each with rationale, makes the kept ones +actually run in CI, and flags the highest-value coverage gaps. + +## Findings (grounded in the current tree) + +I swept `{utils,server,components,composables}/**/*.test.ts` for +`describe.skip` / `it.skip` / `.skipIf` / `it.todo` / `if (!hledgerAvailable) return`. +**All skip/gate machinery lives in one file:** +`server/utils/__tests__/hledger.test.ts`. (A grep hit in +`utils/filterAccounts.property.test.ts` was a false positive — a bare `return` +inside a helper, no skip.) + +| # | Location | Pattern | Targets | Verdict | +|---|----------|---------|---------|---------| +| F1 | `hledger.test.ts:327` | `describe.skip('addTransaction rejects invalid input')` | legacy `addTransaction` | **DELETE** | +| F2 | `hledger.test.ts:238` | `describe.skipIf(!hledgerAvailable)('addTransaction round-trip')` | legacy `addTransaction` | **DELETE** | +| F3 | `hledger.test.ts:134` | `if (!hledgerAvailable) return` (timeout-kill test, R1.2) | live `hledgerExec` | **KEEP, make CI run it** | +| F4 | `hledger.test.ts:426` | `it('Property 4: addTransaction only spawns hledger processes…')` — extracts the `addTransaction` source body, asserts it exists + delegates to `runHledger` | legacy `addTransaction` | **DELETE** (found at implementation, not in the original skip/gate sweep) | + +> **F4 (found during T2 implementation, not the original sweep).** The sweep +> keyed on skip/gate machinery, so it missed this *non-skipped* test, which is +> coupled to `addTransaction` existing in source via +> `sourceCode.indexOf('export async function addTransaction')` → +> `expect(fnStart).toBeGreaterThan(-1)`. Deleting `addTransaction` (F1/F2 verdict) +> makes that assertion fail. It lives inside the `describe('hledger is the sole +> journal writer')` block alongside **two module-invariant tests that must stay** +> (hledger.ts contains no `fs` writes / does not import `fs` — still true and +> still valuable). Verdict: delete only the `addTransaction`-specific `it`; keep +> the block and its other two tests. + +### Why each verdict + +**F1 — asserts engine behavior we don't own.** The test wants `addTransaction` +to *reject* unbalanced postings. `hledger add` (stdin) doesn't — it silently +zeroes them and exits 0 (the skip comment says as much). So the assertion is +about hledger, not our code. The behavior it *wishes* existed **does** exist on +the production write path: `appendTransaction` → +`validateTransaction` rejects a non-zero cents sum, already covered by +`journalWriter.test.ts:72` ("does not sum to zero") and `:97` ("one-cent +imbalance"). The skipped test is redundant **and** wrong-target → delete. + +**F2 — exercises a legacy path.** Production writes go through the **direct +journal writer** (`appendTransaction`), per `tech.md` and +`separation-of-concerns.md` ("New transactions go through the direct journal +writer … NOT `hledger add`"). `addTransaction` is the old `hledger add` path. +Its round-trip is superseded by the writer's own coverage +(`journalWriter.property.test.ts` append/round-trip, `journalWriter.test.ts` +`appendTransaction()` block). → delete with the function (see below). + +**F3 — genuinely needs a real process.** It sets `HLEDGER_TIMEOUT_MS=1` and +asserts `hledgerExec` kills and rejects with `/timed out/`. That requires a +real binary that takes >1ms; it can't be faked deterministically cross-platform +(the spawn is `spawn(bin, ['print'])` — substituting `node`/a sleeper doesn't +fit the arg shape). The fix is not to the test but to the environment: **CI +must install hledger** so the guard doesn't no-op. Keep the guard as a +graceful-skip for local dev machines without hledger. + +### The legacy-path wart behind F1/F2: `addTransaction` is still wired in + +The issue asks whether `addTransaction` should be retired entirely. It's +**not unused** — `server/api/categories.post.ts` calls it to create/close +category accounts (zero-amount balanced entries). That's the only production +caller, and it's a **separation-of-concerns violation**: it uses `hledger add` +instead of the direct writer, and because `addTransaction` skips +`validateTransaction`, the route has to bolt on a manual `fieldHasIllegalChars` +guard (`categories.post.ts:13`) to compensate. + +So "retire `addTransaction`" is really: **migrate `categories.post.ts` to +`appendTransaction`, then delete `addTransaction`.** The migration is a net +simplification — `appendTransaction` runs `validateTransaction`, so the manual +control-char guard becomes redundant (kept-or-removed per requirements). + +### The bigger CI gap + +`.github/workflows/ci.yml` (just landed on `main`) has **only a `typecheck` +job — no test job at all.** So today the *entire* suite never runs in CI, not +just the gated tests. Installing hledger is necessary but insufficient; CI needs +a **test job** that runs `npm run test` with hledger on PATH. + +## Proposed solution + +Four coordinated changes: + +### 1. Retire the legacy write path (production code) +- `server/api/categories.post.ts`: replace both `addTransaction({...})` calls + with `appendTransaction({...})`. Re-evaluate the manual `fieldHasIllegalChars` + guard — `validateTransaction` now covers control chars in description/account, + but keep an explicit 400 if we want a friendlier message than the joined + validator error (decided in requirements). +- `server/utils/hledger.ts`: delete `export async function addTransaction`. + +### 2. Clean the tests +- Delete F1 (`describe.skip` block) and F2 (`describe.skipIf` block) from + `hledger.test.ts`, plus the now-unused `addTransaction` import. +- The `checkHledgerAvailable()` helper + `hledgerAvailable` const stay — F3 + still uses them. +- Repoint stubs that referenced the deleted function: + `server/api/__tests__/api-routes.test.ts:22` and + `server/api/__tests__/categories-security.test.ts:13` both + `vi.stubGlobal('addTransaction', …)`. After migration they must stub + `appendTransaction` and assert the route still rejects malicious category + names (the security guarantee must not regress). + +### 3. CI runs the suite with hledger +- Add a `test` job to `.github/workflows/ci.yml` (sibling to `typecheck`): + checkout → setup-node 20 + npm cache → `npm ci` → **install hledger** → + `npm run test`. +- hledger install on `ubuntu-latest`: prefer the official + `apt-get install -y hledger` (simple, no Haskell toolchain). Pin/verify with + `hledger --version` as a step so a silent install failure fails the job + rather than letting F3 no-op again. + +### 4. Coverage-gap pass (audit deliverable) +Classify production paths vs. legacy and document in this spec's outcome. +Current state from the sweep: +- **Direct journal writer** — well covered (`journalWriter.test.ts`, + `journalWriter.property.test.ts`). ✓ +- **Budget base resolution on a non-default host** — covered as a *unit* + (`resolveBudgetBase`, `hledger.test.ts:159`). **Gap:** no test that + `budget/assign` routes correctly when the base is *not* `assets:checking`. + Flag; fill if cheap (decided in requirements — R6). + - **Correction (requirements gate):** the original wording said "`budget/assign` + + `budget/transfer` route … by the budget base." Code review showed this is + inaccurate: neither write route calls `resolveBudgetBase` (it is a *read*-side + resolver used by `budget.get.ts`). `budget/assign` routes by the request's + `physicalAccount` (`:budget:` + + `toUnallocatedAccount(physicalAccount)`); `budget/transfer` echoes + fully-qualified envelope paths verbatim and has **no** base concept. The gap + is therefore `assign`-only and cheap (route test, stubbed `appendTransaction`, + no live hledger). `transfer` has nothing base-specific to assert. +- **Register seeding / envelope routing** — flag for inventory; document + whether covered or a follow-up. + +Anything not cheaply fillable here is **documented as a follow-up**, not +silently left — the audit's value is the written classification, not a vow to +close every gap in one PR. + +## Files touched + +| File | Change | +|------|--------| +| `server/api/categories.post.ts` | `addTransaction` → `appendTransaction` (×2); reassess manual guard | +| `server/utils/hledger.ts` | delete `addTransaction` | +| `server/utils/__tests__/hledger.test.ts` | delete F1 + F2 blocks; drop `addTransaction` import | +| `server/api/__tests__/api-routes.test.ts` | stub `appendTransaction` instead of `addTransaction` | +| `server/api/__tests__/categories-security.test.ts` | stub `appendTransaction`; keep security assertions | +| `.github/workflows/ci.yml` | add `test` job (npm ci + install hledger + `npm run test`) | +| `.kiro/specs/audit-test-suite/` | this spec + the written classification outcome | +| (maybe) new coverage test | non-default-base assign/transfer, if cheap | + +## Edge cases +- **Local dev without hledger:** F3 must still skip gracefully (keep the guard); + only CI is guaranteed to have hledger. +- **Security regression risk:** the category-name injection guard is currently + enforced *before* `addTransaction`. After migration, `validateTransaction` + enforces it inside `appendTransaction`. The security tests must continue to + assert a rejection — this is the load-bearing check, not the implementation + detail of where the guard lives. +- **hledger apt version drift:** Ubuntu's packaged hledger may lag. The suite + doesn't depend on bleeding-edge behavior (F3 just needs *a* hledger that can + be made to time out), so the distro package is acceptable; the `--version` + step documents what ran. + +## Alternatives considered + +1. **Keep `addTransaction`; just un-skip/fix its tests.** Rejected as the + primary path: it leaves a legacy `hledger add` write in production that + violates the direct-writer rule and forces the manual guard in + `categories.post.ts`. Retiring it is the steering-aligned fix. *(Offered as + the fallback at the design gate if you'd rather keep this ticket + test-only and split the production change into its own ticket.)* +2. **Make F3 deterministic instead of hledger-gated** (fake a slow binary). + Rejected: no clean cross-platform sleeper fits `spawn(bin, ['print'])`, and + it would test our fake, not the real spawn/kill path. CI-installs-hledger is + simpler and tests the real thing. +3. **Delete the `if (!hledgerAvailable) return` guard outright** so F3 always + runs. Rejected: it would fail on dev machines without hledger. Graceful skip + locally + guaranteed run in CI is the right split. +4. **Add only an hledger install step to the existing typecheck job.** Rejected: + the typecheck job doesn't run tests at all; a dedicated `test` job is needed + regardless. diff --git a/.kiro/specs/audit-test-suite/outcome.md b/.kiro/specs/audit-test-suite/outcome.md new file mode 100644 index 0000000..f06215a --- /dev/null +++ b/.kiro/specs/audit-test-suite/outcome.md @@ -0,0 +1,76 @@ +# Outcome — Audit the test suite + +> The durable deliverable of Issue #11. Records the verdict for every +> gated/legacy test found, a coverage classification of production paths, and the +> gaps left as explicit follow-ups. Written after implementing `tasks.md`. + +## 1. Gated / legacy test verdicts + +| Finding | Test | Verdict | What replaced it | +|---|---|---|---| +| **F1** | `hledger.test.ts` `describe.skip('addTransaction rejects invalid input')` | **DELETED** | Asserted hledger-engine behavior we don't own (`hledger add` silently zeroes unbalanced postings). The real guarantee — rejecting an unbalanced write — lives on the production path and is covered by `journalWriter.test.ts` ("does not sum to zero", "one-cent imbalance"). | +| **F2** | `hledger.test.ts` `describe.skipIf(!hledgerAvailable)('addTransaction round-trip')` | **DELETED** | Exercised the legacy `hledger add` write path. Production writes go through `appendTransaction`; its round-trip is covered by `journalWriter.property.test.ts` and `journal-roundtrip.property.test.ts`. | +| **F3** | `hledger.test.ts` `HLEDGER_TIMEOUT_MS=1` timeout-kill test (with `if (!hledgerAvailable) return`) | **KEPT** | Genuinely needs a real hledger process; can't be faked cross-platform. Guard stays for local dev; **CI now installs hledger** so it runs for real (was silently no-op'ing). | +| **F4** | `hledger.test.ts` `it('Property 4: addTransaction only spawns hledger processes…')` | **DELETED** | Found during implementation, not the original skip/gate sweep — it was coupled to `addTransaction` existing in source. Removed with the function. The two sibling tests in `describe('hledger is the sole journal writer')` (no `fs` writes / no `fs` import) were **kept** — still-valid module invariants. | + +**Net production change:** the legacy `addTransaction` (`hledger add` over stdin) +was retired. `categories.post.ts` now writes via `appendTransaction` (the direct +journal writer), so there is **one** write path, not two. The route keeps an +explicit `fieldHasIllegalChars` pre-check for a friendly 400; `validateTransaction` +inside `appendTransaction` is the backstop. + +## 1b. Latent bug the new CI job immediately caught + +Adding the `test` job paid off on the very first run: it exposed a **real +cross-platform path-traversal hole** that local Windows runs structurally could +not catch. `safeJournalPath` (`server/utils/journalFiles.ts`) used the platform +`basename` to detect path separators. On POSIX that is `path.posix.basename`, +which treats `\` as an ordinary filename character — so `a\x.journal` was +rejected on Windows but **accepted on Linux** (the server's likely deployment +OS). Fix: detect separators with `win32.basename` (strict: `/`, `\`, and `C:` +drive prefixes) so path-bearing names are rejected identically on every OS. The +matching test's sanity helper had the same platform dependency and was switched +to an explicit `/[\\/]/` check; the load-bearing "must throw" assertion was +unchanged. This is exactly the class of silent gap Issue #11 set out to remove. + +## 2. CI gap closed + +Before: `ci.yml` had a `typecheck` job only — **the entire test suite never ran +in CI.** After: a sibling `test` job runs `npm ci` → install hledger → +`hledger --version` (fail-loud gate) → `npm run test`. Install runs every time +and is not cached (ephemeral runners; a stale cache could reintroduce the silent +skip). + +## 3. Production path coverage classification + +| Path | Covering test(s) | Status | +|---|---|---| +| Direct journal writer (`validate`/`format`/`appendTransaction`) | `journalWriter.test.ts`, `journalWriter.property.test.ts`, `journal-roundtrip.property.test.ts` | **Well covered** ✓ | +| `transactions.post` / `.get` / `.delete` | `api-routes.test.ts`, `transactions.delete.test.ts` | **Well covered** ✓ | +| `balances.get`, `accounts.get` | `api-routes.test.ts` | **Covered** ✓ | +| `budget.get` (incl. budget-base derivation) | `migration.test.ts`, `budget-data.test.ts`, `budget-data.property.test.ts` | **Well covered** ✓ | +| `budget/assign` + `budget/transfer` | `budget-endpoints.test.ts` (+ `.property`), `migration.test.ts` | **Well covered** ✓ — incl. **non-default budget base** for assign (added this PR, R6.1) | +| `categories.post` (control-char guard + happy path) | `categories-security.test.ts` | **Covered** ✓ | +| `hledgerExec` / `hledgerExecText` (read adapter, timeout, args) | `hledger.test.ts`, `hledgerArgs.test.ts`, `read-args-security.test.ts` | **Covered** ✓ (timeout test now runs in CI) | +| Journal file mgmt: `journal/activate`, `journal/upload`, file listing | `activate-security.test.ts`, `upload.post.test.ts`, `journal-files-security.test.ts` | **Covered** ✓ | +| Pure utils (format, tree, register, strip, derive, validate forms) | matching `utils/*.test.ts` + `*.property.test.ts` | **Well covered** ✓ | + +## 4. Gaps left as follow-ups (not closed in this PR) + +- **`budget/transfer` non-default base — not applicable, not a gap.** Code review + at the requirements gate showed `transfer` echoes fully-qualified envelope paths + verbatim and has no base-resolution logic; there is nothing base-specific to + assert. R6 was therefore scoped to `assign` only. (See design's "Correction" + note.) +- **`hidden-envelopes.get` / `hidden-envelopes.post` route handlers** appear only + *indirectly* in `budget-data` / `migration` tests; no dedicated route test was + confirmed for the hide/unhide handlers themselves. **Follow-up:** add a route + test asserting the zero-balance-before-hide rule and the unhide path. (Worth a + ticket; out of scope for this audit PR.) +- **`journal/create.post` and `journal/export.get`** — confirm direct handler + coverage (path-traversal cases are covered by `journal-files-security.test.ts`, + but the create/export happy paths were not individually verified during this + audit). **Follow-up:** confirm or add. + +These are written down rather than silently left — the audit's value is the +classification, not a vow to close every gap in one PR. diff --git a/.kiro/specs/audit-test-suite/requirements.md b/.kiro/specs/audit-test-suite/requirements.md new file mode 100644 index 0000000..22a20b5 --- /dev/null +++ b/.kiro/specs/audit-test-suite/requirements.md @@ -0,0 +1,164 @@ +# Requirements — Audit the test suite + +> Traceability: implements **GitHub Issue #11**. Relates to #4. Derived from the +> approved `design.md` in this folder. EARS form: "WHEN … THE SYSTEM SHALL …". + +## Gate decisions (resolved at this requirements gate) + +- **Manual `fieldHasIllegalChars` guard in `categories.post.ts`: KEEP.** After + migrating to `appendTransaction`, retain the explicit pre-check so the route + returns a friendly, category-specific 400 rather than the generic joined + validator string. The guard is defense-in-depth, not the sole protection. +- **Non-default-base assign/transfer coverage gap: FILL NOW (if cheap).** Add a + route-level test that `budget/assign` + `budget/transfer` behave correctly when + the budget base is not `assets:checking`. If it proves not cheap (needs a live + hledger fixture or large scaffolding), downgrade to a documented follow-up and + say so explicitly in the spec outcome. + +--- + +## R1 — Retire the legacy `addTransaction` write path + +**User story:** As a maintainer, I want category create/close to use the same +direct journal writer as every other write, so the codebase has one write path +and no legacy `hledger add` code to reason about. + +- **R1.1** WHEN `categories.post.ts` handles a `create` action THE SYSTEM SHALL + persist the zero-amount balanced entry via `appendTransaction`, not + `addTransaction`. +- **R1.2** WHEN `categories.post.ts` handles a `delete` action THE SYSTEM SHALL + persist the close entry via `appendTransaction`, not `addTransaction`. +- **R1.3** WHEN the migration is complete THE SYSTEM SHALL contain no exported + `addTransaction` function in `server/utils/hledger.ts` and no remaining import + or call of it anywhere in the tree. +- **R1.4** THE SYSTEM SHALL retain the existing `categories.post.ts` behavior + contract: a successful create/delete returns `201` with `{ success: true, + account }`, and `account` is `expenses:`. + +## R2 — Preserve the category-name injection guard (no security regression) + +**User story:** As a maintainer, I must not let the journal-injection protection +regress when the implementation moves. + +- **R2.1** WHEN a category `name` contains a control character (`\r`, `\n`, or + `\t`) THE SYSTEM SHALL reject the request with HTTP `400` and SHALL NOT append + anything to the journal. +- **R2.2** THE SYSTEM SHALL keep an explicit `fieldHasIllegalChars` pre-check in + `categories.post.ts` that returns a category-specific 400 message (friendly + message decision above), in addition to the `validateTransaction` enforcement + inside `appendTransaction`. +- **R2.3** WHEN required fields are missing (`action` or non-blank `name`) or the + action is not `create`/`delete` THE SYSTEM SHALL return `400` as it does today. + +## R3 — Clean the gated/legacy tests + +**User story:** As a maintainer, I want the test suite to contain only tests that +protect real, owned behavior, so a green run is trustworthy. + +- **R3.1** THE SYSTEM SHALL remove finding **F1** — the + `describe.skip('addTransaction rejects invalid input')` block in + `hledger.test.ts` — because it asserts hledger engine behavior we don't own and + is redundant with `journalWriter.test.ts` balance coverage. +- **R3.2** THE SYSTEM SHALL remove finding **F2** — the + `describe.skipIf(!hledgerAvailable)('addTransaction round-trip')` block — as a + legacy-path test superseded by the direct-writer coverage. +- **R3.3** THE SYSTEM SHALL drop the now-unused `addTransaction` import from + `hledger.test.ts` while retaining `checkHledgerAvailable()` and the + `hledgerAvailable` const (still used by F3). +- **R3.4** WHEN F1 and F2 are removed THE SYSTEM SHALL leave finding **F3** (the + `HLEDGER_TIMEOUT_MS=1` timeout-kill test) intact, including its + `if (!hledgerAvailable) return` graceful-skip guard. +- **R3.6** WHEN `addTransaction` is deleted THE SYSTEM SHALL remove finding + **F4** — the `it('Property 4: addTransaction only spawns hledger processes…')` + test (`hledger.test.ts:426`) that asserts the function exists in source — while + retaining the other two tests in the `describe('hledger is the sole journal + writer')` block (the module-invariant checks that hledger.ts performs no direct + `fs` writes and does not import `fs`). +- **R3.5** WHEN the test files that stubbed `addTransaction` + (`api-routes.test.ts`, `categories-security.test.ts`) are updated THE SYSTEM + SHALL stub `appendTransaction` instead, and `categories-security.test.ts` SHALL + continue to assert that malicious category names are rejected (R2.1 must still + be proven by a test). + +## R4 — CI runs the full suite with hledger available + +**User story:** As a maintainer, I want CI to actually run the tests — including +the hledger-gated one — so gated tests stop silently no-op'ing. + +- **R4.1** THE SYSTEM SHALL add a `test` job to `.github/workflows/ci.yml`, + sibling to the existing `typecheck` job, triggered on the same `push`/ + `pull_request` to `main`. +- **R4.2** THE `test` job SHALL: checkout → setup-node 20 with npm cache → + `npm ci` → install hledger → run `npm run test`. THE hledger install SHALL run + on every job invocation (GitHub-hosted runners are ephemeral — no state + persists between runs) and SHALL NOT be cached: a stale/partial apt cache could + reintroduce the silent no-op failure mode this ticket exists to remove. +- **R4.3** WHEN hledger fails to install THE SYSTEM SHALL fail the job at a + `hledger --version` verification step, rather than letting F3 silently skip. +- **R4.4** WHEN the `test` job runs in CI THE SYSTEM SHALL execute F3 for real + (hledger present → guard does not no-op). +- **R4.5** THE SYSTEM SHALL NOT modify the `typecheck` job's behavior. + +## R5 — Coverage-gap classification (audit deliverable) + +**User story:** As a maintainer, I want the audit's findings written down, so the +classification (not just the code changes) is the durable deliverable. + +- **R5.1** THE SYSTEM SHALL record, in a spec outcome document + (`.kiro/specs/audit-test-suite/outcome.md`), the keep/fix/delete verdict and + rationale for every gated/legacy test found (F1–F3). +- **R5.2** THE SYSTEM SHALL classify the production write/read paths as + well-covered, thinly-covered, or uncovered, listing the test file(s) that + cover each. +- **R5.3** WHEN a flagged gap is not closed in this PR THE SYSTEM SHALL document + it as an explicit follow-up (with enough detail to file a ticket), not leave it + silent. + +## R6 — Fill the non-default budget-base coverage gap for `budget/assign` + +**User story:** As a maintainer, I want assurance that an envelope assignment +routes its postings under the account the request names, even when that account +is not the default `assets:checking`. + +> Scoping note: code review at the requirements gate showed the write routes do +> **not** call `resolveBudgetBase` (that is a read-side resolver used by +> `budget.get.ts`). `budget/assign` routes by the request's `physicalAccount`; +> `budget/transfer` echoes fully-qualified envelope paths verbatim and has no +> base concept. So this requirement covers `assign` only — `transfer` has no +> base-routing behavior to assert. See the design's "Correction" note. + +- **R6.1** WHEN `budget/assign` receives a non-default `physicalAccount` (e.g. + `assets:savings`) THE SYSTEM SHALL write each envelope posting under + `:budget:` and the offsetting posting to + `toUnallocatedAccount()`, and a route-level test SHALL assert + this by inspecting the captured transaction (stubbed `appendTransaction`, no + live hledger). +- **R6.2** IF satisfying R6.1 unexpectedly requires a live hledger fixture or + disproportionate scaffolding THE SYSTEM SHALL instead document the gap as a + follow-up per R5.3, and the spec outcome SHALL state why it was deferred. + +--- + +## Non-functional requirements + +- **NFR1 — No config loosening.** No change to `vitest.config.*`, `tsconfig*`, + `nuxt.config.ts`, lint/formatter config, or `package.json` scripts to make the + suite pass. Fix source, not tooling. +- **NFR2 — Suite stays green and typecheck clean.** After all changes, + `npm run test` and `npx nuxi typecheck` both pass locally and in CI. +- **NFR3 — Cross-platform.** Test changes and the CI job must not assume a + developer has hledger; the F3 guard keeps local runs green without it. +- **NFR4 — No type escapes.** No `any`/`as any`/unnecessary `as` introduced in + production code; `any` in test mocks is acceptable per project convention. +- **NFR5 — Net simplification.** Retiring `addTransaction` should reduce total + production LOC (one fewer write path), not add a parallel one. + +## Out of scope + +- Rewriting or expanding the journal-writer test coverage beyond what R3/R6 + require. +- Adding new product features or API routes. +- Broad coverage backfill for paths already classified as well-covered (R5.2). +- Upgrading hledger pinning strategy beyond "a working hledger on CI" (distro + package is acceptable per design). +- Any change to the `typecheck` job (R4.5). diff --git a/.kiro/specs/audit-test-suite/tasks.md b/.kiro/specs/audit-test-suite/tasks.md new file mode 100644 index 0000000..9285176 --- /dev/null +++ b/.kiro/specs/audit-test-suite/tasks.md @@ -0,0 +1,126 @@ +# Tasks — Audit the test suite + +> Implements `design.md` + `requirements.md` in this folder. GitHub Issue #11. +> Branch: `feat/audit-test-suite` (or continue current `chore/audit-test-suite`). +> Order matters: migrate production first (T1), then delete the dead function +> (T2), then fix the tests that referenced it (T3–T5), then CI (T6), then the +> coverage test (T7), then the written audit deliverable (T8), then the final +> green checkpoint (T9). Each task is independently verifiable. + +--- + +- [x] **T1 — Migrate `categories.post.ts` to `appendTransaction`** *(R1.1, R1.2, R1.4, R2.2, R2.3)* + - File: `server/api/categories.post.ts`. + - Replace both `addTransaction({...})` calls (create + close) with + `appendTransaction({...})`; add the `import { appendTransaction }` (the + function lives in `server/utils/journalWriter`). + - **Keep** the manual `fieldHasIllegalChars` pre-check and its friendly 400 + (R2.2). Update its comment — it no longer compensates for `addTransaction` + skipping validation; it now provides a category-specific message on top of + `validateTransaction`. + - Preserve the response contract: `201` + `{ success: true, account }`. + - Note: zero-amount balanced postings already satisfy `validateTransaction` + (explicit amounts summing to 0 cents → passes). + - Verify: typecheck the file; covered end-to-end by T4/T5 tests. + +- [x] **T2 — Delete the legacy `addTransaction` function** *(R1.3, NFR5)* + - File: `server/utils/hledger.ts` — remove `export async function + addTransaction`. + - Grep the tree for any remaining `addTransaction` reference (`rg + addTransaction`); after T1 the only hits should be the test stubs handled in + T4/T5. Production must have zero references. + - Verify: `npx nuxi typecheck` clean (no dangling references in prod code). + +- [x] **T3 — Remove gated/legacy tests F1 + F2 from `hledger.test.ts`** *(R3.1, R3.2, R3.3, R3.4)* + - File: `server/utils/__tests__/hledger.test.ts`. + - Delete the `describe.skip('addTransaction rejects invalid input')` block (F1) + and the `describe.skipIf(!hledgerAvailable)('addTransaction round-trip')` + block (F2). + - Drop the now-unused `addTransaction` import. + - **Keep** `checkHledgerAvailable()`, the `hledgerAvailable` const, and the F3 + timeout-kill test with its `if (!hledgerAvailable) return` guard intact. + - **F4 (found at T2):** also delete the + `it('Property 4: addTransaction only spawns hledger processes…')` test + (≈ line 426) — it asserts `addTransaction` exists in source and breaks once + T2 removes the function. **Keep** the other two `it`s in the + `describe('hledger is the sole journal writer')` block (no `fs` writes / no + `fs` import — still valid module invariants). *(R3.6)* + - Verify: `npx vitest run server/utils/__tests__/hledger.test.ts` — passes, + F3 still present, the two journal-writer-invariant tests still present. + +- [x] **T4 — Repoint `api-routes.test.ts` stub** *(R3.5)* + - File: `server/api/__tests__/api-routes.test.ts` (≈ line 22). + - Change `vi.stubGlobal('addTransaction', …)` to stub `appendTransaction` + (the global the migrated route now calls). + - Verify: `npx vitest run server/api/__tests__/api-routes.test.ts` passes. + +- [x] **T5 — Repoint `categories-security.test.ts` stub + keep the security assertion** *(R2.1, R3.5)* + - File: `server/api/__tests__/categories-security.test.ts` (≈ line 13). + - Stub `appendTransaction` instead of `addTransaction`. + - **Load-bearing:** the test MUST still assert that a malicious category name + (containing `\r`/`\n`/`\t`) is rejected with `400` and that nothing is + written. If the existing assertions only checked the old guard, ensure at + least one case proves R2.1 survives the migration. + - Verify: `npx vitest run server/api/__tests__/categories-security.test.ts` + passes; deliberately confirm the injection case still fails the request. + +- [x] **T6 — Add a `test` job to CI with hledger** *(R4.1, R4.2, R4.3, R4.4, R4.5)* + - File: `.github/workflows/ci.yml`. + - Add a `test` job sibling to `typecheck` (same `push`/`pull_request` → `main` + triggers): checkout → `actions/setup-node@v4` node 20 + npm cache → + `npm ci` → `sudo apt-get update && sudo apt-get install -y hledger` → + `hledger --version` (verification gate, R4.3) → `npm run test`. + - Install runs every invocation, no caching (R4.2 rationale). + - Do **not** touch the `typecheck` job (R4.5). + - Verify: `yq`/manual YAML read for shape; real validation is the PR's CI run + (note in PR that F3 should now execute for real). + +- [x] **T7 — Add the non-default-base assign route test** *(R6.1)* + - File: `server/api/__tests__/budget-endpoints.test.ts` (existing assign + coverage lives here). + - Add a case: call the `budget/assign` handler with + `physicalAccount: 'assets:savings'` and a one-envelope map; stub + `appendTransaction` to capture the `TransactionInput`; assert the envelope + posting account is `assets:savings:budget:` and the offset is + `toUnallocatedAccount('assets:savings')` with the negated total. + - No live hledger. If this proves unexpectedly hard (R6.2), stop, document the + deferral in T8's outcome, and leave a `// TODO(#11 follow-up)` — do not force it. + - Verify: `npx vitest run server/api/__tests__/budget-endpoints.test.ts` passes. + +- [x] **T8 — Write the audit outcome deliverable** *(R5.1, R5.2, R5.3, R6.2 if triggered)* + - File: `.kiro/specs/audit-test-suite/outcome.md` (new). + - Record: F1–F3 verdicts + rationale (keep/delete, what replaced them); a + coverage map of production read/write paths → covering test file(s); any + flagged-but-unclosed gaps as explicit follow-ups (e.g. register seeding / + envelope routing inventory, and the `transfer` non-issue from R6 scoping). + - If T7 was deferred, state why here. + +- [x] **T9 — Final green checkpoint** *(NFR1, NFR2, NFR4)* + - Run the full suite once: `npm run test` — all pass. + - Run `npx nuxi typecheck` — clean. + - Confirm no tooling config (`vitest.config.*`, `tsconfig*`, `nuxt.config.ts`, + lint, `package.json` scripts) was modified (NFR1) and no `any`/`as any` + entered production code (NFR4). + - Update `AI-MAP.md` if the `addTransaction` removal changes the documented + server-util surface. + +--- + +### Coverage matrix (every requirement lands on a task) + +| Requirement | Task(s) | +|---|---| +| R1.1 / R1.2 / R1.4 | T1 | +| R1.3 | T2 | +| R2.1 | T5 | +| R2.2 | T1 | +| R2.3 | T1 | +| R3.1 / R3.2 / R3.3 / R3.4 / R3.6 | T3 | +| R3.5 | T4, T5 | +| R4.1–R4.5 | T6 | +| R5.1 / R5.2 / R5.3 | T8 | +| R6.1 | T7 | +| R6.2 | T7 (escape hatch) → T8 (documented) | +| NFR1 / NFR2 / NFR4 | T9 | +| NFR3 | T3 (F3 guard), T6 (CI provides hledger) | +| NFR5 | T2 | diff --git a/AI-MAP.md b/AI-MAP.md index 26ed71b..c7b2105 100644 --- a/AI-MAP.md +++ b/AI-MAP.md @@ -66,7 +66,7 @@ rejected on delete and upload**, since they break the date-line ↔ tindex mappi ## Server utils (`server/utils/`, Nitro auto-imported) - `hledger.ts` — `resolveJournalPath` (precedence: `config/active-journal.json` → `LEDGER_FILE` → `test-data/sample.journal`), `hledgerExec`, `hledgerExecText`, - `transformTransactions`, `transformBalanceReport`, `addTransaction` (legacy), + `transformTransactions`, `transformBalanceReport`, `resolveBudgetBase`/`DEFAULT_BUDGET_BASE` (derive the asset account hosting the `:budget:` tree, Issue #4). All hledger spawning goes through a private `runHledger` helper: rejects on spawn `error`/timeout (`HLEDGER_TIMEOUT_MS`, diff --git a/server/api/__tests__/api-routes.test.ts b/server/api/__tests__/api-routes.test.ts index 1489b97..c7b3790 100644 --- a/server/api/__tests__/api-routes.test.ts +++ b/server/api/__tests__/api-routes.test.ts @@ -3,7 +3,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' // --- Mock Nitro globals --- const mockHledgerExec = vi.fn() -const mockAddTransaction = vi.fn() const mockAppendTransaction = vi.fn() const mockSetResponseStatus = vi.fn() const mockReadBody = vi.fn() @@ -19,7 +18,6 @@ vi.stubGlobal('createError', (opts: { statusCode: number; message: string }) => }) vi.stubGlobal('setResponseStatus', mockSetResponseStatus) vi.stubGlobal('hledgerExec', mockHledgerExec) -vi.stubGlobal('addTransaction', mockAddTransaction) // Mock the journalWriter module so appendTransaction is intercepted vi.mock('../../utils/journalWriter', () => ({ diff --git a/server/api/__tests__/budget-endpoints.test.ts b/server/api/__tests__/budget-endpoints.test.ts index b19a4b8..f4980fc 100644 --- a/server/api/__tests__/budget-endpoints.test.ts +++ b/server/api/__tests__/budget-endpoints.test.ts @@ -161,6 +161,32 @@ describe('POST /api/budget/assign', () => { statusCode: 400, }) }) + + // R6.1: assign routes by the request's physicalAccount, not a fixed default. + // A non-default base (e.g. assets:savings) must place both the envelope + // posting and the unallocated offset under that base. + it('routes postings under a non-default budget base', async () => { + mockReadBody.mockResolvedValue({ + date: '2025-03-01', + physicalAccount: 'assets:savings', + envelopes: { vacation: 250 }, + }) + mockAppendTransaction.mockResolvedValue(undefined) + + const result = await budgetAssign(fakeEvent) + + expect(mockAppendTransaction).toHaveBeenCalledWith({ + date: '2025-03-01', + status: '*', + description: 'Budget Assignment', + postings: [ + { account: 'assets:savings:budget:vacation', amount: 250 }, + { account: 'assets:savings:budget:unallocated', amount: -250 }, + ], + }) + expect(mockSetResponseStatus).toHaveBeenCalledWith(fakeEvent, 201) + expect(result).toEqual({ success: true }) + }) }) diff --git a/server/api/__tests__/categories-security.test.ts b/server/api/__tests__/categories-security.test.ts index bb249ed..75b7b5d 100644 --- a/server/api/__tests__/categories-security.test.ts +++ b/server/api/__tests__/categories-security.test.ts @@ -1,24 +1,32 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' // Issue #2, R1.4: POST /api/categories must reject control chars in `name` -// before they reach the journal via addTransaction (hledger `add` over stdin). +// before they reach the journal via appendTransaction (the direct journal writer). -const mockAddTransaction = vi.fn() +const mockAppendTransaction = vi.fn() const mockReadBody = vi.fn() const mockSetResponseStatus = vi.fn() vi.stubGlobal('defineEventHandler', (handler: Function) => handler) vi.stubGlobal('readBody', mockReadBody) vi.stubGlobal('setResponseStatus', mockSetResponseStatus) -vi.stubGlobal('addTransaction', mockAddTransaction) vi.stubGlobal('createError', (opts: { statusCode: number; message: string }) => { const err = new Error(opts.message) as any err.statusCode = opts.statusCode return err }) -// Note: journalWriter is intentionally NOT mocked — categories.post imports the -// real fieldHasIllegalChars from it. +// Partial mock: intercept appendTransaction (so the "accepts a normal name" +// case doesn't actually write the journal) while keeping the REAL +// fieldHasIllegalChars that categories.post imports for its guard. +vi.mock('../../utils/journalWriter', async (importOriginal) => { + const actual = await importOriginal() as typeof import('../../utils/journalWriter') + return { + ...actual, + appendTransaction: (...args: any[]) => mockAppendTransaction(...args), + } +}) + const { default: postCategories } = await import('../categories.post') const fakeEvent = {} as any @@ -31,19 +39,19 @@ describe('POST /api/categories — control-char guard', () => { it('returns 400 and does not write for a newline in the name', async () => { mockReadBody.mockResolvedValue({ action: 'create', name: 'food\n2020-01-01 Steal' }) await expect(postCategories(fakeEvent)).rejects.toMatchObject({ statusCode: 400 }) - expect(mockAddTransaction).not.toHaveBeenCalled() + expect(mockAppendTransaction).not.toHaveBeenCalled() }) it('returns 400 for a tab in the name', async () => { mockReadBody.mockResolvedValue({ action: 'create', name: 'food\tdrink' }) await expect(postCategories(fakeEvent)).rejects.toMatchObject({ statusCode: 400 }) - expect(mockAddTransaction).not.toHaveBeenCalled() + expect(mockAppendTransaction).not.toHaveBeenCalled() }) it('accepts a normal category name and writes', async () => { mockReadBody.mockResolvedValue({ action: 'create', name: 'Groceries' }) const res = await postCategories(fakeEvent) expect(res).toMatchObject({ success: true, account: 'expenses:groceries' }) - expect(mockAddTransaction).toHaveBeenCalledTimes(1) + expect(mockAppendTransaction).toHaveBeenCalledTimes(1) }) }) diff --git a/server/api/categories.post.ts b/server/api/categories.post.ts index ffdb930..ac70a30 100644 --- a/server/api/categories.post.ts +++ b/server/api/categories.post.ts @@ -1,4 +1,4 @@ -import { fieldHasIllegalChars } from '../utils/journalWriter' +import { appendTransaction, fieldHasIllegalChars } from '../utils/journalWriter' export default defineEventHandler(async (event) => { const body = await readBody<{ action: string; name: string }>(event) @@ -7,9 +7,9 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 400, message: 'Missing required fields: action and name' }) } - // Reject control characters before they reach the journal (Issue #2, R1.4): - // this write path uses addTransaction (hledger `add` over stdin), which does - // not run validateTransaction, so guard the free-text name here. + // Reject control characters before they reach the journal (Issue #2, R1.4). + // appendTransaction → validateTransaction also rejects these, but this + // explicit pre-check returns a friendlier, category-specific 400 message. if (fieldHasIllegalChars(body.name)) { throw createError({ statusCode: 400, message: 'Category name must not contain newline or tab characters' }) } @@ -25,7 +25,7 @@ export default defineEventHandler(async (event) => { const account = `expenses:${name}` if (action === 'create') { - await addTransaction({ + await appendTransaction({ date: today, description: `Create category ${account}`, status: '*', @@ -35,7 +35,7 @@ export default defineEventHandler(async (event) => { ], }) } else { - await addTransaction({ + await appendTransaction({ date: today, description: `Close category ${account}`, status: '*', diff --git a/server/utils/__tests__/hledger.test.ts b/server/utils/__tests__/hledger.test.ts index 2d3421d..a7b0ae2 100644 --- a/server/utils/__tests__/hledger.test.ts +++ b/server/utils/__tests__/hledger.test.ts @@ -1,10 +1,8 @@ -import { describe, it, expect, afterEach, beforeEach } from 'vitest' +import { describe, it, expect, afterEach } from 'vitest' import fc from 'fast-check' -import { resolveJournalPath, addTransaction, hledgerExec, hledgerExecText, resolveBudgetBase, DEFAULT_BUDGET_BASE, transformTransactions, transformBalanceReport } from '../hledger' +import { resolveJournalPath, hledgerExec, hledgerExecText, resolveBudgetBase, DEFAULT_BUDGET_BASE, transformTransactions, transformBalanceReport } from '../hledger' import * as fs from 'node:fs' import * as path from 'node:path' -import * as os from 'node:os' -import type { TransactionInput } from '../../../types/api' /** * Property 3: Path resolution returns a non-empty string @@ -202,200 +200,6 @@ function checkHledgerAvailable(): boolean { const hledgerAvailable = checkHledgerAvailable() -// --- Shared generators for property tests --- - -// Generator for valid account names: lowercase alpha segments joined by colons -const accountNameArb = fc - .array(fc.stringMatching(/^[a-z]{2,8}$/), { minLength: 1, maxLength: 3 }) - .map((segments) => segments.join(':')) - -// Generator for valid dates in YYYY-MM-DD format -const dateArb = fc - .record({ - year: fc.integer({ min: 2000, max: 2030 }), - month: fc.integer({ min: 1, max: 12 }), - day: fc.integer({ min: 1, max: 28 }), // stay safe with day range - }) - .map(({ year, month, day }) => - `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`, - ) - -// Generator for simple alphanumeric descriptions (no trailing spaces — hledger trims them) -const descriptionArb = fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9 ]{0,19}$/) - .map((s) => s.trimEnd()) - .filter((s) => s.length > 0) - -// Generator for positive amounts (cents precision) -const amountArb = fc.integer({ min: 1, max: 100000 }).map((cents) => cents / 100) - -/** - * Property 1: addTransaction round-trip - * For any valid TransactionInput, after addTransaction(input) succeeds, - * the journal contains a matching transaction. - * - * Validates: Requirements 3.1, 3.2 - */ -describe.skipIf(!hledgerAvailable)('addTransaction round-trip', () => { - const originalEnv = process.env.LEDGER_FILE - let tmpFile: string - - beforeEach(() => { - tmpFile = path.join(os.tmpdir(), `hledger_test_${Date.now()}_${Math.random().toString(36).slice(2)}.journal`) - fs.writeFileSync(tmpFile, '') - process.env.LEDGER_FILE = tmpFile - }) - - afterEach(() => { - if (originalEnv !== undefined) { - process.env.LEDGER_FILE = originalEnv - } else { - delete process.env.LEDGER_FILE - } - try { - fs.unlinkSync(tmpFile) - } catch { - // ignore cleanup errors - } - }) - - // Generator for a valid TransactionInput: - // 2 postings — first with explicit amount, second auto-balanced (no amount) - const transactionInputArb = fc - .record({ - date: dateArb, - description: descriptionArb, - account1: accountNameArb, - account2: accountNameArb, - amount: amountArb, - }) - .filter(({ account1, account2 }) => account1 !== account2) - .map(({ date, description, account1, account2, amount }): TransactionInput => ({ - date, - description, - postings: [ - { account: account1, amount, commodity: '$' }, - { account: account2 }, // auto-balanced by hledger - ], - })) - - it('Property 1: journal contains matching transaction after addTransaction succeeds', async () => { - await fc.assert( - fc.asyncProperty(transactionInputArb, async (input) => { - // Write a fresh empty journal for each property run - fs.writeFileSync(tmpFile, '') - - await addTransaction(input) - - const result = await hledgerExec(['print']) as any[] - expect(result.length).toBeGreaterThanOrEqual(1) - - const txn = result[result.length - 1] - expect(txn.tdate).toBe(input.date) - expect(txn.tdescription).toBe(input.description) - - // Verify accounts match - const actualAccounts = txn.tpostings.map((p: any) => p.paccount).sort() - const expectedAccounts = input.postings.map((p) => p.account).sort() - expect(actualAccounts).toEqual(expectedAccounts) - - // Verify the explicit posting amount - const explicitPosting = input.postings.find((p) => p.amount !== undefined)! - const matchingTxnPosting = txn.tpostings.find( - (p: any) => p.paccount === explicitPosting.account, - ) - expect(matchingTxnPosting).toBeDefined() - const txnAmount = matchingTxnPosting.pamount[0].aquantity.floatingPoint - expect(txnAmount).toBeCloseTo(explicitPosting.amount!, 2) - }), - { numRuns: 5 }, // keep low since each run spawns hledger processes - ) - }, 60_000) -}) - - -/** - * Property 2: addTransaction rejects invalid input - * For any TransactionInput with unbalanced explicit amounts, - * addTransaction throws and the journal is unchanged. - * - * Validates: Requirements 3.3 - * - * NOTE: Skipped — hledger add does not reject unbalanced amounts via stdin. - * It silently zeroes them out and exits 0. This is a spec/design mismatch - * with hledger's actual interactive behavior, not a code bug. - */ -describe.skip('addTransaction rejects invalid input', () => { - const originalEnv = process.env.LEDGER_FILE - let tmpFile: string - - beforeEach(() => { - tmpFile = path.join(os.tmpdir(), `hledger_test_${Date.now()}_${Math.random().toString(36).slice(2)}.journal`) - fs.writeFileSync(tmpFile, '') - process.env.LEDGER_FILE = tmpFile - }) - - afterEach(() => { - if (originalEnv !== undefined) { - process.env.LEDGER_FILE = originalEnv - } else { - delete process.env.LEDGER_FILE - } - try { - fs.unlinkSync(tmpFile) - } catch { - // ignore cleanup errors - } - }) - - // Generator for unbalanced TransactionInput: - // 2 postings, both with explicit amounts that don't sum to zero - const unbalancedTransactionArb = fc - .record({ - date: dateArb, - description: descriptionArb, - account1: accountNameArb, - account2: accountNameArb, - amount1: amountArb, - amount2: amountArb, - }) - .filter(({ account1, account2 }) => account1 !== account2) - .filter(({ amount1, amount2 }) => { - // Ensure amounts don't balance (sum != 0) - // Since both are positive (from amountArb), they can never sum to zero, - // but we also need to ensure they're not equal (which would balance as debit/credit) - // Actually, both amounts are positive and on the same side, so they never balance. - // hledger add expects the sum of all posting amounts to be zero for a valid transaction. - return Math.abs(amount1 + amount2) > 0.001 - }) - .map(({ date, description, account1, account2, amount1, amount2 }): TransactionInput => ({ - date, - description, - postings: [ - { account: account1, amount: amount1, commodity: '$' }, - { account: account2, amount: amount2, commodity: '$' }, - ], - })) - - it('Property 2: addTransaction throws for unbalanced postings and journal is unchanged', async () => { - await fc.assert( - fc.asyncProperty(unbalancedTransactionArb, async (input) => { - // Write a fresh empty journal for each property run - fs.writeFileSync(tmpFile, '') - const contentBefore = fs.readFileSync(tmpFile, 'utf-8') - - // addTransaction should throw for unbalanced input - await expect(addTransaction(input)).rejects.toThrow() - - // Journal file should be unchanged - const contentAfter = fs.readFileSync(tmpFile, 'utf-8') - expect(contentAfter).toBe(contentBefore) - }), - { numRuns: 5 }, // keep low since each run spawns hledger processes - ) - }, 60_000) -}) - - /** * Property 4: hledger is the sole journal writer * The app does not directly write to the journal file — only hledger add modifies it. @@ -423,25 +227,6 @@ describe('hledger is the sole journal writer', () => { } }) - it('Property 4: addTransaction only spawns hledger processes, never writes files directly', () => { - // Extract the addTransaction function body - const fnStart = sourceCode.indexOf('export async function addTransaction') - expect(fnStart).toBeGreaterThan(-1) - const fnBody = sourceCode.slice(fnStart) - - // It must delegate to the shared runHledger helper (which is the sole place - // that spawns hledger), never write the journal directly. (Issue #4 item 1 - // moved the spawn out of each caller into runHledger.) - expect(fnBody).toContain('runHledger(') - // The module's only process-creation primitive is spawn('hledger' ...). - expect(sourceCode).toContain("spawn(bin, args)") - - // It should NOT contain any fs write operations - expect(fnBody).not.toContain('writeFile') - expect(fnBody).not.toContain('appendFile') - expect(fnBody).not.toContain('createWriteStream') - }) - it('Property 4: for any file path, the module only delegates writes to hledger', () => { fc.assert( fc.property( diff --git a/server/utils/__tests__/journalFiles.test.ts b/server/utils/__tests__/journalFiles.test.ts index 6bae957..0056d30 100644 --- a/server/utils/__tests__/journalFiles.test.ts +++ b/server/utils/__tests__/journalFiles.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import fc from 'fast-check' -import { basename, join, sep } from 'node:path' +import { join, sep } from 'node:path' // createError is a Nitro auto-import at runtime; stub it for unit tests. vi.stubGlobal('createError', (opts: { statusCode: number; statusMessage: string }) => { @@ -71,8 +71,10 @@ describe('safeJournalPath() — property (R2, NFR5)', () => { .map(([a, s, b]) => `${a}${s}${b}`) fc.assert( fc.property(evil, (name) => { - // sanity: these names genuinely contain a separator/traversal - expect(basename(name) !== name).toBe(true) + // sanity: these names genuinely contain a separator/traversal. + // Check `/` and `\` explicitly — the platform basename treats `\` as a + // normal char on POSIX, which would make this sanity check false-fail. + expect(/[\\/]/.test(name)).toBe(true) expect(() => safeJournalPath(name)).toThrow() }), { numRuns: 100 } diff --git a/server/utils/hledger.ts b/server/utils/hledger.ts index d502ae6..9944431 100644 --- a/server/utils/hledger.ts +++ b/server/utils/hledger.ts @@ -1,5 +1,4 @@ import { spawn } from 'node:child_process' -import type { TransactionInput } from '../../types/api' import { readActiveJournalPath, SAMPLE_JOURNAL } from './activeJournal' // Re-exported so existing importers of these from hledger.ts keep working. @@ -177,24 +176,3 @@ export function transformBalanceReport(raw: any): any { totals: (rawTotals ?? []).map(transformAmount), } } - -/** Add a transaction by piping input to hledger add via stdin */ -export async function addTransaction(input: TransactionInput): Promise { - const file = resolveJournalPath() - - // Build stdin lines: date, description, then account/amount pairs, end postings, save, quit - const lines: string[] = [input.date, input.description] - for (const p of input.postings) { - lines.push(p.account) - if (p.amount !== undefined) { - const c = p.commodity ?? '$' - lines.push(`${c}${p.amount.toFixed(2)}`) - } else { - lines.push('') // accept hledger's inferred amount - } - } - lines.push('.', 'y', '.') // end postings, confirm save, quit - - const { code, stderr } = await runHledger(['add', '-f', file], lines.join('\n') + '\n') - if (code !== 0) throw new Error(`hledger add failed: ${stderr}`) -} diff --git a/server/utils/journalFiles.ts b/server/utils/journalFiles.ts index 8ceaf21..3e88496 100644 --- a/server/utils/journalFiles.ts +++ b/server/utils/journalFiles.ts @@ -1,4 +1,4 @@ -import { basename, join, resolve, sep } from 'node:path' +import { win32, join, resolve, sep } from 'node:path' /** * Journal file path safety (Issue #2, R2 — path-traversal prevention). @@ -29,9 +29,13 @@ export function safeJournalPath(filename: string): string { throw createError({ statusCode: 400, statusMessage: 'Filename is required' }) } - // basename strips any directory component; if the result differs, the input - // contained a separator (`/` or `\`) or a `..`/`.` traversal segment. - if (basename(name) !== name) { + // win32.basename strips any directory component, treating BOTH `/` and `\` + // (and a `C:` drive prefix) as separators. We use the win32 variant rather + // than the platform basename on purpose: on POSIX the platform basename keeps + // a backslash as an ordinary filename char, so `a\x.journal` would slip + // through on Linux while being rejected on Windows. Strict win32 semantics + // reject path-bearing names identically on every OS. (CI on Linux exposed this.) + if (win32.basename(name) !== name) { throw createError({ statusCode: 400, statusMessage: 'Filename must not contain a path' }) }