Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2a48b1990 | ||
|
|
15dee0fe18 | ||
|
|
588af0dffe | ||
|
|
489f700cc3 | ||
|
|
7855b20f63 | ||
|
|
7ef99ec42b | ||
|
|
a37f4f571d | ||
|
|
95a8ae7a09 | ||
|
|
80bb6098a4 | ||
|
|
a79dc5a738 | ||
|
|
a2e6566958 | ||
|
|
5f6c25d2e3 | ||
|
|
1f887725fb | ||
|
|
c1ac0baa5e | ||
|
|
1c8f55edc1 | ||
|
|
923e1ad6f9 | ||
|
|
ab0ef9cfa1 | ||
|
|
8973392c61 | ||
|
|
8ec6c0ca6a | ||
|
|
c39c80b3ad | ||
|
|
57a6a22f89 | ||
|
|
9688e64692 | ||
|
|
283121edba | ||
|
|
b6ad42b5ad | ||
|
|
e1a45543ea | ||
|
|
4a66962f19 | ||
|
|
c77fc65c1f | ||
|
|
7f6be1c2b3 | ||
|
|
54744536b3 | ||
|
|
2f50998105 | ||
|
|
c509f6ff10 | ||
|
|
bf515e5415 | ||
|
|
57549e3e0c | ||
|
|
f457da7d1f | ||
|
|
1da341b1c5 | ||
|
|
a37e79a8fc | ||
|
|
92bcafb4f1 | ||
|
|
16276c62fc | ||
|
|
3b412bff3b | ||
|
|
f71e5c52d4 | ||
|
|
44af47d344 | ||
|
|
6809fc5cca | ||
|
|
4853f1feb8 | ||
|
|
ef855e3fd7 | ||
|
|
3dff49cdc5 | ||
|
|
d230b39948 | ||
|
|
7bbd969fa2 | ||
|
|
4f377970d7 | ||
|
|
7f0d43943c | ||
|
|
7bc2a207f3 | ||
|
|
9468dfc0eb | ||
|
|
6025f68b22 | ||
|
|
f96e903710 | ||
|
|
5b1d631870 | ||
|
|
e32f11b83e | ||
|
|
4c60ed1ccf | ||
|
|
d12a9a3cfa | ||
|
|
2580c65074 | ||
|
|
8e703d9c4c | ||
|
|
8ef5e4a9a4 | ||
|
|
53680c4c74 | ||
|
|
2d394d882d | ||
|
|
11cfb8c26a | ||
|
|
caa09dcebe | ||
|
|
264de9644c | ||
|
|
e40cb35011 | ||
|
|
7485772427 | ||
|
|
e5d7da6edd | ||
|
|
28f0fa3c10 | ||
|
|
eee32c1eae | ||
|
|
37a3282f98 | ||
|
|
1fbe65f524 | ||
|
|
320f21c113 | ||
|
|
5c76c3df97 | ||
|
|
260575c6bd | ||
|
|
e361d9a328 | ||
|
|
aa25bcc053 | ||
|
|
bda08b592e | ||
|
|
0e408974a0 | ||
|
|
f4b35dcc30 | ||
|
|
1c0d765182 | ||
|
|
2cd62208ac | ||
|
|
7557a6364a | ||
|
|
54c4b049a8 | ||
|
|
2de72ed506 | ||
|
|
0aa1c3d077 | ||
|
|
954be9e868 | ||
|
|
afcccf811e | ||
|
|
0aa7244cf4 | ||
|
|
1d8919532b | ||
|
|
9ab947a394 | ||
|
|
ad9400490d | ||
|
|
ea9bdf9598 | ||
|
|
c52016d727 | ||
|
|
588188edb5 | ||
|
|
11823a1466 | ||
|
|
6ce0a18f98 | ||
|
|
e07375f642 | ||
|
|
17edb3bc90 | ||
|
|
445da9f5fc | ||
|
|
fe0f52ce17 | ||
|
|
8df5214a45 | ||
|
|
2457d30ed3 | ||
|
|
a3ec1ffbb3 | ||
|
|
d36705bd90 | ||
|
|
cd8ccaba2b | ||
|
|
fd2e906963 | ||
|
|
68bcc0cb5f | ||
|
|
7e4c3a0963 | ||
|
|
ac723630f2 | ||
|
|
49c8ecfb73 | ||
|
|
939f95d147 | ||
|
|
bab5105e48 | ||
|
|
f9dad0abb4 | ||
|
|
ebfc3be9d1 | ||
|
|
3878509fc5 | ||
|
|
d7804d6b68 | ||
|
|
5b12c5c9c0 | ||
|
|
7e23cb5b06 | ||
|
|
850db40479 | ||
|
|
c66fe498b6 | ||
|
|
edac587c5c | ||
|
|
824313c411 | ||
|
|
d3cef9ed86 | ||
|
|
77688daec7 | ||
|
|
0cec447bf1 | ||
|
|
c50b106faf | ||
|
|
22c48076c0 | ||
|
|
947007438f | ||
|
|
f60a3370db | ||
|
|
5d404f5733 | ||
|
|
8ecae81703 | ||
|
|
f65af5d7fd | ||
|
|
88206ff215 | ||
|
|
54196a2d4f | ||
|
|
d81d1183db | ||
|
|
f537a0dd25 | ||
|
|
6e47df0d3c | ||
|
|
0cf63231d3 | ||
|
|
e994ad157e | ||
|
|
fa40dcff7a | ||
|
|
0460129b32 | ||
|
|
6abba43a80 | ||
|
|
a216dd882d | ||
|
|
95b137bf17 | ||
|
|
f5fe32b999 | ||
|
|
0c658a26a8 | ||
|
|
4eebb3ce7a | ||
|
|
55c405082a | ||
|
|
2f1e2107bc | ||
|
|
ea5acfa9a2 | ||
|
|
bdf3f247b1 | ||
|
|
b60e7e40d0 | ||
|
|
9e24f33465 | ||
|
|
80bff25af9 | ||
|
|
188bcfbda0 | ||
|
|
4c2e4ed986 | ||
|
|
70ce32fbe0 | ||
|
|
f865790fe1 | ||
|
|
01bf01d043 | ||
|
|
3b08fe25e8 | ||
|
|
7cd053ab0f | ||
|
|
9d00035128 | ||
|
|
ae6f2d9df1 | ||
|
|
a2ee590897 | ||
|
|
e4b5a0a645 | ||
|
|
564c442ea4 | ||
|
|
2929e52b14 | ||
|
|
a86546181e | ||
|
|
5baa97bbf9 | ||
|
|
3de65dbf81 | ||
|
|
77ae8171b8 | ||
|
|
df3e8efdd0 | ||
|
|
a4788c3f28 | ||
|
|
6bde2eb62e | ||
|
|
ac23b2e093 | ||
|
|
cabcbcf98a | ||
|
|
8b1492142f | ||
|
|
e6e788fdce | ||
|
|
f6b103aed7 | ||
|
|
4e96c62708 | ||
|
|
5da1e572a1 | ||
|
|
fa7a78388a | ||
|
|
173dafa2f3 | ||
|
|
6c3d2baede | ||
|
|
ecedf0c132 | ||
|
|
b172a21b63 | ||
|
|
64a0018d00 | ||
|
|
51d6c37e4a | ||
|
|
f095ef24aa | ||
|
|
970e862533 | ||
|
|
eac4ede21e | ||
|
|
4e2bec2ef0 | ||
|
|
13d612df5d | ||
|
|
cc8073256a | ||
|
|
6c3848102b | ||
|
|
f1b0ac43d0 | ||
|
|
4ab9a770be | ||
|
|
c49d86b0b8 | ||
|
|
e5cd8678b0 | ||
|
|
fc6275a96b | ||
|
|
d332084206 | ||
|
|
abd54d4b99 | ||
|
|
64b3534c7d | ||
|
|
b4022288dc | ||
|
|
d76644d948 | ||
|
|
1ac50918ab | ||
|
|
024be9c929 | ||
|
|
263b090769 | ||
|
|
883cce27df | ||
|
|
81f778df72 | ||
|
|
3efa5dc3f4 | ||
|
|
efa9c7ba6b | ||
|
|
6771e4775e | ||
|
|
d5a6789366 | ||
|
|
039d3d07e2 | ||
|
|
e2c67a8fe8 | ||
|
|
41a0cdee7e | ||
|
|
6775dc154b | ||
|
|
43b06c519f | ||
|
|
d15c160185 | ||
|
|
3c5376c7b9 | ||
|
|
06fc5f1d95 | ||
|
|
75e2d7853e | ||
|
|
6d098fd96f | ||
|
|
f2f33b4b40 | ||
|
|
2815233e86 | ||
|
|
70b818c2bd | ||
|
|
df0daaad46 | ||
|
|
e615f24627 | ||
|
|
4131c3c6f6 | ||
|
|
0f4bd419e1 | ||
|
|
063bf948b1 | ||
|
|
2d1944fd55 | ||
|
|
ffa6966fd3 | ||
|
|
9a0abc02d1 | ||
|
|
52577b11ed | ||
|
|
59b6d7d360 | ||
|
|
6979302fb7 | ||
|
|
3a6d2ce93d | ||
|
|
77030931b7 | ||
|
|
f61e85d9a7 |
@@ -0,0 +1,71 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test & Lint
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: dashcaddy-api
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: dashcaddy-api/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Test (CI mode + coverage)
|
||||
run: npm run test:ci
|
||||
|
||||
- name: Upload coverage artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: coverage-${{ github.sha }}
|
||||
path: dashcaddy-api/coverage/
|
||||
retention-days: 14
|
||||
|
||||
security:
|
||||
name: Security audit
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: dashcaddy-api
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: dashcaddy-api/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: npm audit (production deps, high+ severity)
|
||||
run: npm audit --production --audit-level=high
|
||||
continue-on-error: true
|
||||
|
||||
- name: Run security-focused test suite
|
||||
run: npm run test:security
|
||||
@@ -0,0 +1,77 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Runtime state/config files (generated, not source)
|
||||
# Note: data/ subdir contains runtime state (credentials, secrets, history) — never commit
|
||||
dashcaddy-api/data/
|
||||
dashcaddy-api/credentials.json
|
||||
dashcaddy-api/.env
|
||||
.env
|
||||
dashcaddy-api/alert-config.json
|
||||
dashcaddy-api/audit-log.json
|
||||
dashcaddy-api/audit-log.json.lock
|
||||
dashcaddy-api/backup-config.json
|
||||
dashcaddy-api/backup-history.json
|
||||
dashcaddy-api/container-stats.json
|
||||
dashcaddy-api/health-config.json
|
||||
dashcaddy-api/health-history.json
|
||||
dashcaddy-api/update-config.json
|
||||
dashcaddy-api/update-history.json
|
||||
dashcaddy-api/dashcaddy-errors.log
|
||||
|
||||
# Auto-updater backups (created by dashcaddy-update.sh when rolling back)
|
||||
start.sh.bak*
|
||||
scripts/*.bak*
|
||||
|
||||
# Auto-updater runtime state (history + secrets + staging)
|
||||
updates/
|
||||
|
||||
# Scratch / debug scripts (left over from past sessions)
|
||||
cm_check*.js
|
||||
full_test.js
|
||||
login_test.js
|
||||
login_backup_test.js
|
||||
|
||||
# Build output
|
||||
dashcaddy-installer/build-output/
|
||||
dashcaddy-installer/dist/
|
||||
status/dist/
|
||||
|
||||
# Vendor / third-party
|
||||
status/vendor/
|
||||
|
||||
# Backup files
|
||||
*.backup.html
|
||||
*.backup.*.html
|
||||
*.recovered
|
||||
backups/
|
||||
|
||||
# IDE / editor
|
||||
.claude/
|
||||
.kiro/
|
||||
.vscode/
|
||||
|
||||
# Session-specific docs (not project docs)
|
||||
DEPLOYMENT-SUCCESS.md
|
||||
FINAL-DEPLOYMENT-REPORT.md
|
||||
TEST-RESULTS.md
|
||||
TESTING-GUIDE.md
|
||||
DashCA-Plan.md
|
||||
vhdx-cleanup-instructions.md
|
||||
DESLOPIFICATION-ROADMAP.md
|
||||
SECURITY-IMPROVEMENTS.md
|
||||
WHAT-IS-DASHCADDY.md
|
||||
error-handling-cleanup-summary.md
|
||||
error-handling-migration-complete.md
|
||||
|
||||
# Utility scripts (local only)
|
||||
check-e.ps1
|
||||
disk-scan.ps1
|
||||
disk-scan2.ps1
|
||||
fix-wsl-and-mount.ps1
|
||||
fix-ctx-routes.sh
|
||||
import-services.js
|
||||
|
||||
# OS files
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
@@ -0,0 +1,148 @@
|
||||
# DashCaddy Improvement Backlog
|
||||
|
||||
> **Shared coordination file for Hermes & Krystie.**
|
||||
> Both bots read this, claim tasks, and update status. Git is the source of truth.
|
||||
> When claiming: change `status: todo` to `status: in-progress` and set `owner`.
|
||||
> When done: change to `status: done` and add brief result.
|
||||
|
||||
---
|
||||
|
||||
## P0 — Must Fix (blocks public release)
|
||||
|
||||
### DC-012: Add Kubernetes-style /healthz + /readyz probe aliases + document for fresh users
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** The standardization-pitfalls doc explicitly lists "No `/healthz` or `/readyz` probes" as still-open work. v1.13.0 already added `/health/live` and `/health/ready` with proper probe semantics (live=process alive, ready=deps reachable) and tests in `__tests__/health-endpoints.test.js` (8 tests). But: (1) The k8s/Docker-standard short aliases `/healthz` and `/readyz` are missing — fresh users copy-pasting a `healthcheck:` block from k8s docs or `docker-compose.yml` examples online get connection refused. Even worse: `src/docker/app-templates.js:316` references `"/healthz"` as a template healthcheck URL — but that URL doesn't resolve on the DashCaddy API itself. (2) `/api/v1/health` (apiRouter.get line 658) and root `/health` (app.get line 674) both exist and return identical responses — duplicated, fresh users won't know which to probe. (3) README + user-guide have zero documentation of the probes — a fresh user has no way to know they exist or how to wire them. Fix: add `/healthz` and `/readyz` aliases that point to the same handlers, deprecate the `/api/v1/health` duplicate (keep root `/health` as canonical), document the probes with a copy-paste `docker-compose.yml` healthcheck block in the user-guide.
|
||||
- **result:** Added `/healthz` and `/readyz` as root-level aliases for `/health/live` and `/health/ready` so fresh users can copy-paste `healthcheck:` blocks from k8s/Docker docs. Liveness (`/healthz`) is a pure process check (no I/O). Readiness (`/readyz`) checks config file, services file, Docker daemon, Caddy admin API (3s timeout each), returns 200 if all OK or 503 with `checks` object. Probe endpoints bypass auth, CSRF, and per-request logging (k8s polling every 10s won't flood audit log). Consolidated `/health`, `/health/live`, `/health/ready`, `/healthz`, `/readyz` into a single handler block in `src/app.js` (DRYed the duplicated handler bodies). Removed the dead `/api/v1/health*` routes that were registered in `PUBLIC_ROUTES` + CSRF lists but never actually mounted on the apiRouter — anyone probing `/api/v1/health` now gets a clean 404. Added `__tests__/health-probe-aliases.test.js` (19 tests): alias equivalence, removed-path 404 confirmation, source-of-truth sync check that catches drift between `src/app.js` mount list and `src/utilities/middleware.js` allowlist. README + user-guide updated with copy-paste Docker Compose + Kubernetes probe blocks. Post-fix: 941/941 tests pass (+19 new).
|
||||
|
||||
### DC-013: Config schema migration — auto-upgrade old config.json on boot
|
||||
- **status:** done
|
||||
- **owner:** hermes (reassigned after audit 2026-06-25 — see result)
|
||||
- **details:** Fresh users upgrading from old `config.json` versions break silently when fields change between releases — no auto-migration exists. Highest risk of the 4 remaining standardization items because the failure mode is invisible until something breaks post-upgrade. Fix: detect schema version on boot, run idempotent migration steps to bring config to current schema, write back atomically with a `.bak` backup, log the migration path. Schema versioning via `configSchemaVersion` field (default 1 if absent). Current schema version: 1.
|
||||
- **result:** **AUDITED — ALREADY DONE.** Audited 2026-06-25 before starting work. `src/config/migrations.js` implements exactly this system: `_version` field on config (CURRENT_VERSION = 2, schema versions 1 and 2 already defined — v1 normalizes dns string→object, v2 adds `dns.provider`), `migrate()` runs all migrations forward from detected version, `loadAndMigrate()` writes back to disk only when the version changed (no point rewriting identical content), called from `src/config/site.js` line 57 on every startup. Guarded by 21 tests in `__tests__/config-migrations.test.js` covering null/undefined/v0/v1/v2/future-version + idempotency + write-back behaviour. Krystie may have claimed this task from a stale audit doc — the implementation was finished in an earlier v1.13.x audit pass. Schema versioning field name is `_version` (not `configSchemaVersion`); to add a v3 migration, register `migrations[3]` and bump `CURRENT_VERSION`. Reassigned ownership to hermes because the audit changed the work from "implement" to "verify and document."
|
||||
|
||||
### DC-014: Monitoring endpoint info-disclosure — opt-in via MONITORING_PUBLIC env var
|
||||
- **status:** done
|
||||
- **owner:** hermes (reassigned after audit 2026-06-25)
|
||||
- **details:** The monitoring/detailed health endpoint is currently in `PUBLIC_ROUTES` by default — anyone reaching the API can pull internal status (Caddy admin probes, Docker container list, config drift details). Should be opt-in via `MONITORING_PUBLIC=true` env var, default `false`. Security-by-default for fresh deployments on public networks.
|
||||
- **result:** **AUDITED — ALREADY DONE.** Audited 2026-06-25. `src/utilities/middleware.js` line 297 implements `MONITORING_PUBLIC` as an IIFE that reads from `process.env.MONITORING_PUBLIC` (string `'true'`/`'false'`) and falls back to `cfg.monitoring.public` from the loaded config; defaults to `true` for back-compat with existing dashboards that already hit `/api/v1/monitoring/stats` pre-login. The monitoring routes are conditionally added to `PUBLIC_ROUTES` based on this flag. Operators who don't want monitoring publicly exposed set `MONITORING_PUBLIC=false` or `monitoring.public: false` in config.json. The premise of this ticket (defaults to public, should be opt-in) is the **inverse** of what's actually there — currently it defaults to public for back-compat. If you want to flip the default to `false`, that's a fresh change and would break existing un-authenticated dashboards that load widget data pre-login. Defer until a real deployment reports info-disclosure as a concern.
|
||||
|
||||
### DC-015: CSRF token path duplication — consolidate /api/v1/csrf-token + /api/v1/auth/csrf-token
|
||||
- **status:** done
|
||||
- **owner:** hermes (reassigned after audit 2026-06-25)
|
||||
- **details:** Two routes return the same CSRF token: `/api/v1/csrf-token` (inline in `src/app.js`) and `/api/v1/auth/csrf-token` (in `routes/auth/`). Confusing for any developer integrating with the API. Pick one canonical, deprecate the other with a redirect + `Deprecation` header, update any frontend callers.
|
||||
- **result:** **AUDITED — NEVER EXISTED (or already cleaned up).** Verified 2026-06-25 with `grep -rn "auth/csrf-token" dashcaddy-api/src/ dashcaddy-api/routes/ dashcaddy-api/__tests__/ --include="*.js"`. Only `/api/v1/csrf-token` exists in the codebase (registered at `src/app.js:662` inside `apiRouter`). No `/api/v1/auth/csrf-token` route anywhere — not in `routes/auth/`, not in any test file, not in any frontend code. The duplicate was either planned-but-not-implemented or cleaned up before this ticket was written. No action needed.
|
||||
|
||||
### DC-016: Per-call timeouts on Caddy admin / DNS API — stop event-loop hogging
|
||||
- **status:** done
|
||||
- **owner:** hermes (reassigned after audit 2026-06-25)
|
||||
- **details:** A single global 5min request timeout covers Caddy admin and DNS API calls, but one slow call can hog the Node.js event loop and stall every other request until it returns. Add per-call timeouts (e.g., 10s for Caddy admin probes, 30s for DNS API calls) so a single slow dependency can't block the whole API.
|
||||
- **result:** **AUDITED — PARTIALLY DONE BY DESIGN.** Audited 2026-06-25. `src/utils/http.js` defines `fetchT(url, opts, timeoutMs)` with `AbortSignal.timeout(TIMEOUTS.HTTP_DEFAULT)` (5000ms default) applied to every call via the native fetch branch, and explicit `timeout:` + `req.on('timeout')` handlers in the http/https raw-request branches (used for Caddy admin `:2019` and self-signed-`.sami` HTTPS, where undici fetch can't be configured). Of 77 call sites, 8 pass an explicit timeout; the rest rely on the 5s default. The 5min global request timeout (Pitfall 5) is a backstop. **Per Pitfall 15 (KEEP ON doesn't mean add whatever the audit found):** bumping individual DNS provider timeouts doesn't affect the fresh-user install flow — it's polish, not a bug. If a specific DNS provider endpoint actually needs longer than 5s, the call site should pass an explicit timeout; don't change the global default.
|
||||
|
||||
### DC-001: Fix 4 failing tests in services.routes.test.js
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** Credential storage tests failing since before v1.13.4. Run `cd dashcaddy-api && npx jest __tests__/routes/services.routes.test.js` to see failures. Fix the root cause, not the test.
|
||||
- **result:** Root cause: routes used `/:serviceId/credentials` (missing `/services/` segment). All 3 credential routes (POST/DELETE/GET) in `routes/services.js` had the wrong path. Fixed to `/services/:serviceId/credentials` — matches the URL pattern used by the live frontend and all 759 tests pass.
|
||||
|
||||
### DC-011: Fix DC-001 regression reintroduced by src/ refactor (4 failing tests)
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** The module-flattening refactor (DC-005) force-pushed to `main` dropped the DC-001 route-prefix fix. `routes/services.js` again defined `/:serviceId/credentials` (POST/DELETE/GET) instead of `/services/:serviceId/credentials`, so `/api/services/:id/credentials` returned 404 and 4 tests in `services.routes.test.js` failed. Baseline: `npx jest` → 4 failed, 746 passed.
|
||||
- **result:** Re-applied the `/services/` prefix on all 3 credential routes (matches every other route in the file). Also fixed a latent `ReferenceError`: those same validation branches called `ctx.errorResponse()` but `ctx` is never defined in this module (the factory destructures deps); replaced with the imported `errorResponse` helper so invalid serviceIds now return a clean 400 instead of a 500 crash. Result: 750/750 tests pass (4 failed → 0), zero new ESLint warnings. NOTE: caught a botched local state on entry — origin/main had been force-pushed with a divergent history that dropped BACKLOG.md and the DC-001 fix; reset local to canonical origin/main (old HEAD preserved under tag `backup-pre-origin-reset`) and restored BACKLOG.md.
|
||||
|
||||
### DC-002: Sync VERSION file
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** `/root/dashcaddy/VERSION` says `1.13.0` but `package.json` says `1.13.4`. VERSION file should always match package.json. Add a pre-commit or post-version bump hook to keep them in sync.
|
||||
- **result:** Fixed root VERSION to 1.13.4. Updated `scripts/release.sh` to write both `dashcaddy-api/package.json` AND root `VERSION` on every release — also stages VERSION in the release commit. No more drift.
|
||||
|
||||
### DC-003: Remove stale test/debug files from repo root
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** `comprehensive-test.js` and `test-security-fixes.js` are ad-hoc test scripts, not Jest tests. They clutter the repo root. Remove them or convert to proper Jest tests under `__tests__/`.
|
||||
- **result:** Moved both files to `dashcaddy-api/scripts/legacy/` (preserved, not deleted — they are 875 lines of security test coverage that may be useful as a manual smoke test). Zero references to them in code/docs — safe to move. All 759 Jest tests still pass.
|
||||
|
||||
---
|
||||
|
||||
## P1 — Code Quality
|
||||
|
||||
### DC-004: Fix 19 ESLint warnings
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** Run `cd dashcaddy-api && npx eslint src/ --format compact`. Most are unused vars and nested ternaries in `src/utils/logging.js`. Fix all, target zero warnings.
|
||||
- **result:** Reached zero ESLint warnings across `src/`. Most of the original 19 were cleared by the DC-005 refactor and logging cleanup; the final 3 were in `src/app.js`: (1) `require-await` on `resyncHealthChecker` — dropped the now-pointless `async` keyword since it only forwards a promise (callers already use `.catch()`); (2)+(3) two `max-depth` violations in the `/api/v1/network/ips` handler — extracted the interface-enumeration logic into a `detectInterfaceIps()` helper, keeping the route handler flat. `npx eslint src/` now reports 0 problems; 750/750 Jest tests still pass.
|
||||
|
||||
### DC-005: Organize top-level modules into src/
|
||||
- **status:** done (merged to main 2026-06-25)
|
||||
- **owner:** krystie
|
||||
- **details:** 40+ JS files at `dashcaddy-api/` root level (auth-manager.js, credential-manager.js, etc.). Move into organized subdirs under `src/` (e.g., `src/managers/`, `src/security/`, `src/docker/`). Update all require() paths. This is a big refactor — run tests after.
|
||||
- **result:** Refactor complete on `krystie-improvements` branch (879/879 tests passing on branch). Merged into main via commit `283121e` after resolving 24 conflicts. Post-merge regression check surfaced one additional latent path bug from DC-005: `src/monitoring/health-checker.js` still had `require('./platform-paths')` (relative to `src/monitoring/`), but `platform-paths.js` lives at top level — fixed in commit `9688e64` to `require('../../platform-paths')`. Without that fix, 59 cascading test failures in `health-checker.test.js`. Final post-merge state: 921/922 tests passing.
|
||||
- **remaining latent bugs (FIXED):** The DC-005 path-rewrite script left depth-2 route files (`routes/auth/*.js`, `routes/recipes/*.js`, `routes/apps/*.js`, `routes/arr/*.js`, `routes/config/*.js`) with broken require() paths. A filesystem-resolving scanner found **67 broken requires across 21 files** — three distinct bug classes: (A) `'../../../src/...'` (3 levels up, goes above package root) — the documented Bug 7, ~49 occurrences; (B) `'../src/utils/...'` (only 1 level up, resolves to nonexistent `routes/src/`) — undocumented, ~15 occurrences for `responses` and `logging`; (C) `routes/apps/restore.js:5` imported `utilities/responses` when the module lives at `utils/responses` (wrong directory + wrong depth). All 67 fixed to `'../../src/...'` (or `'../../src/utils/responses'` for the class-C case). `routes/auth/totp.js` was already fixed in the DC-006 commit. Tests didn't catch any of these previously because no test imported any depth-2 route. Post-fix: 922/922 tests pass, zero new ESLint warnings.
|
||||
|
||||
### DC-006: Add integration test for TOTP auth flow
|
||||
- **status:** done
|
||||
- **owner:** krystie
|
||||
- **details:** End-to-end test: no token → 401, wrong token → 403, valid TOTP → session token → authenticated request succeeds. Cover the full `/api/auth/check` → session → endpoint flow.
|
||||
- **result:** Added `dashcaddy-api/__tests__/routes/auth.totp.routes.test.js` — 25 tests, all passing. Covers: GET `/api/totp/config`, POST `/api/totp/setup` (generate + normalize + reject invalid Base32), POST `/api/totp/verify-setup` (missing/bad/no-pending/valid-code paths), POST `/api/totp/verify` (login — 400/400/401/200), GET `/api/totp/check-session` (passthrough when disabled + 401 no-session + 200 valid-session — the BACKLOG "no token → 401 / authenticated request succeeds" pair), POST `/api/totp/disable` (400/401/200), POST `/api/totp/config` (valid/invalid/never-disables), plus the full end-to-end flow setup→login→check-session→disable and an otplib-not-stubbed sanity check. Uses real `otplib` for code generation (real TOTP math), mocks `credentialManager`/`session`/`totpConfig`/`saveTotpConfig` only. Full suite: 904/904 pass (879 baseline + 25 new). ESLint clean for the new file.
|
||||
- **side-effect (DC-005 latent bug fix):** While writing the test I discovered `routes/auth/totp.js` had broken require paths from the DC-005 refactor (`'../../../src/utilities/errors'` was 3 levels up from `routes/auth/` — wrong by 1). The test couldn't even load the route without this fix. Fixed in this commit (`'../../src/utilities/errors'` and `'../../src/utils/responses'`). **Same depth bug exists in other depth-2 route files — see DC-005 note above.**
|
||||
|
||||
### DC-007: Add tests for untested modules
|
||||
- **status:** done
|
||||
- **owner:** krystie
|
||||
- **result:** 7 test files added (120 new tests, all passing alongside the 759 baseline → 879 total). Files: `__tests__/dns-propagation.test.js` (9), `__tests__/notification-manager.test.js` (18), `__tests__/ssl-monitor.test.js` (13), `__tests__/log-digest.test.js` (11), `__tests__/metrics.test.js` (21), `__tests__/config-drift-detector.test.js` (19), `__tests__/auto-restart-manager.test.js` (29).
|
||||
- **details:** These modules have NO test coverage: `dns-propagation.js`, `notification-manager.js`, `ssl-monitor.js`, `log-digest.js`, `metrics.js`, `config-drift-detector.js`, `auto-restart-manager.js`. Add at least basic smoke tests for each.
|
||||
|
||||
---
|
||||
|
||||
## P2 — Polish & DX
|
||||
|
||||
### DC-008: Update CLAUDE.md for cross-platform accuracy
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** CLAUDE.md references Windows-specific paths (C:/caddy/, e:/CaddyCerts/) as if they're universal. DashCaddy runs on Linux (Docker on DNS2) and Windows (SAMI-PC). Document both deployment targets clearly.
|
||||
- **result:** Added a new "Linux Deployment (DNS2 / Contabo VPS)" section after the existing Windows docs (preserved verbatim) and before the "Project Info" footer. The new section documents: production paths (`/opt/dashcaddy/`, `/var/www/dashcaddy-status/`, `/etc/dashcaddy/`), container mount points with the `/app/data/` auto-resolve fallback, the three-filesystem frontend trap (source vs live vs build-context), common admin commands, a Windows-vs-Linux differences table, and four Linux-specific gotchas (Caddy network_mode host, credentials.json perms, CORS_ORIGINS vs Tailscale, TS_AUTHKEY provisioning). Also updated the "Project Info" version field from stale `1.0` to current `1.13.4` and added the Linux-side default TLD (`.home`).
|
||||
|
||||
### DC-009: Add CHANGELOG entry for any unreleased work
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** `[Unreleased]` section in CHANGELOG.md is empty. Any fixes done should be documented there before tagging a release.
|
||||
- **result:** Populated the `[Unreleased]` section with all unreleased work since v1.5.0: Security (TOTP 4-part recovery), Added (OpenClaw routes, auto-backup, monitoring widget, Sami Files template, unified logger, notification manager, update UX, 120 new tests across 7 files), Changed (DC-010 response standardization across 9 route files, /api/v1/ versioning, release.sh hardening), Fixed (DC-011 credential route regression, DC-004 ESLint cleanup, workflow engine init, container-logs wireModal misuse, CSP hash mismatch, SW cache tag, updater false-positive loop), Removed (legacy test scripts moved to scripts/legacy/ preserved-not-deleted, stale root files, dead routes/ directory). Each entry cites the source commit hash for traceability.
|
||||
|
||||
### DC-010: Standardize error response shapes
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** v1.13.4 standardized route responses to use helpers, but some modules still use raw `res.json()`. Grep for remaining `res.json(` in route handlers and convert to response helpers.
|
||||
- **result:** All bare `{success: true, ...}` envelopes across route files now go through `success()` (or `ok()` where the older alias is wired in). Files converted in this push (4 commits): browse/logs/sites (cron), updates/notifications/tailscale/events/workflows/openclaw/dns/health/ca (this sprint) — 9 files, 62 calls. `services.js` line 360+368 left alone (intentional raw-array responses for the frontend wire contract — separate cleanup). Error-path `res.status(4xx/5xx).json({success:false, error:...})` envelopes also left as-is (`ok()` helper would set `success:true` — wrong tool for error shapes). Net result: only 2 intentional raw-array calls remain in routes/; everything else routes through `response-helpers`. 750/750 tests pass at every checkpoint.
|
||||
|
||||
---
|
||||
|
||||
### DC-017: Regression tests for depth-2 route paths + PUBLIC_ROUTES drift
|
||||
- **status:** done
|
||||
- **owner:** krystie
|
||||
- **details:** After DC-005 path-fix (commit c39c80b) shipped 67 broken-require repairs across 21 depth-2 route files, two test gaps remained: (1) no test imported any depth-2 route module, so future refactors could reintroduce class A/B/C broken paths undetected; (2) no test verified that PUBLIC_ROUTES entries (in src/utilities/middleware.js) all correspond to actually-mounted routes — exactly the kind of drift DC-012 added a regression check for (probe paths), but only for the 5 probes. The full ~27-entry PUBLIC_ROUTES list could silently go stale.
|
||||
- **result:** Added 3 files, fixed 1 test helper, no production code changed. New: `__tests__/depth2-routes-smoke.test.js` discovers every .js in routes/{apps,arr,auth,config,recipes}/ and asserts (a) the module loads without MODULE_NOT_FOUND, (b) it exports a factory function, (c) the factory runs without throwing when given universal deps; plus 3 source-of-truth scans that fail if any depth-2 route re-introduces class A (`../../../src/...`), class B (`../src/...`), or class C (`utilities/responses` instead of `utils/responses`) require paths. New: `__tests__/public-routes-drift.test.js` walks every aggregator + direct-mount router via Express stack introspection and asserts (a) every PUBLIC_ROUTES entry matches an actually-mounted route, (b) every CSRF excludedPath is publicly accessible, (c) all 5 probe paths are CSRF-exempt, (d) all 5 probe paths are excluded from request logging, (e) all 5 probe paths bypass Tailscale auth. New: `__tests__/test-helpers/universal-deps.js` — a Proxy + seed-object shared by both suites that returns sensible stubs (logger-shaped object, asyncHandler pass-through, path-string stubs for `path.dirname()` calls) for any property access; supports Object.assign/spread via ownKeys+getOwnPropertyDescriptor traps so aggregator factories that copy ctx into subCtx don't lose proxy magic. Fix to the test helper: (a) `log` is now a logger-shaped object (`{error, warn, info, debug, audit}` as noops) not a bare noopFn — fixes `(ctx.log || console).error(...)` in routes/apps/index.js; (b) `asyncHandler` seeded as own enumerable property — survives Object.assign({}, ctx, { helpers }); (c) added `SERVICES_FILE`, `CONFIG_FILE`, `TOTP_CONFIG_FILE`, `TAILSCALE_CONFIG_FILE`, `NOTIFICATIONS_FILE`, `loadSiteConfig`, `loadNotificationConfig`, `configStateManager`, `readConfig`, `saveConfig`, `helpers`, `safeErrorMessage` as own-enumerable seeds so aggregator sub-mounts destructure cleanly. Fix to public-routes-drift: aggregator walks use prefix `/api/v1` (matches src/app.js's bare-mount on apiRouter at /api/v1), direct-mount walks use `/api/v1` + explicit prefixMap entry. Added `routes/themes.js` and `routes/license.js` to directMounts (themes bare-mounted, license on `/license`). Result: **35 suites, 1036 tests, all passing** (was 1030 passing + 6 failing before this commit). The 6 failures were depth-2 factory errors + 22 PUBLIC_ROUTES stale entries that the test infrastructure was silently swallowing.
|
||||
|
||||
### DC-019: backup-manager test flakes ~1/64 — tamper uses fixed-char replacement that can be a no-op
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** `__tests__/backup-manager.test.js:184` "rejects tampered data (auth tag mismatch)" tampers the encrypted blob by replacing its first base64 character with `'X'`: `Buffer.from('X' + str.substring(1))`. The first char is the first base64 char of the random 16-byte IV. When the IV's first base64 char is already `'X'` (~1/64 ≈ 1.6% probability per run), the replacement is a no-op — the "tampered" buffer is byte-identical to the original, AES-256-GCM decryption succeeds, and `expect(...).rejects.toThrow()` fails. Observed: 1 failure in ~15 full-suite runs. The production `encryptBackup`/`decryptBackup` code (AES-256-GCM, correct) is NOT at fault — the bug is in the test's tampering technique. Fix: corrupt the authTag bytes directly (XOR a byte so the value is guaranteed to change), reassemble the `iv:authTag:ciphertext` format. This guarantees a GCM integrity failure every time.
|
||||
- **result:** Fixed. The test now parses the `iv:authTag:ciphertext` format, XORs the first authTag byte with `0xFF` (guaranteed value change — can never be a no-op regardless of the random IV/authTag content), reassembles the blob, then asserts decryption rejects. Verified: **30/30 isolated runs + 8/8 full-suite runs (1036/1036), zero failures.** Production crypto code unchanged (it was correct all along — the bug was purely in the test's tampering technique). Confirmed root cause independently with a Node REPL script: corrupting authTag byte0 always throws `Unsupported state or unable to authenticate data`.
|
||||
|
||||
### DC-018: Logger.error() swallows writeErrorLog promise — error.log writes are fire-and-forget (flaky test + lost logs in prod)
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** `Logger.error()` in `src/utils/logging.js:256` calls `this._log('error', ...)` but does NOT return the result. `_log('error', ...)` returns the promise from `writeErrorLog(...)` (the async disk write to error.log). Because `error()` drops the return value, every `await logError(...)` / `await log.error(...)` caller is actually awaiting `undefined` — the file write becomes fire-and-forget. Symptoms: (1) `__tests__/logging.test.js` "captures request context when req is passed" fails intermittently in the full suite (passes in isolation) — the test reads error.log before the un-awaited appendFile completes. (2) In production, 6 route handlers (`routes/apps/deploy.js`, `routes/apps/removal.js`, `routes/health.js`, `routes/arr/config.js`, `routes/updates.js`) plus the global `boundAsyncHandler` error catcher all `await logError(...)` expecting the write to flush; error entries can be lost if the process exits/restarts immediately after. Latent since the original "unify logger" commit f71e5c5. Fix: add `return` to `Logger.error()` so the `writeErrorLog` promise propagates to callers. No behavior change for `debug/info/warn` (they never returned a promise and don't write to disk).
|
||||
- **result:** Fixed — one-line change (`return this._log(...)`). The logging flake is eliminated: **10/10 full-suite runs passed** (was ~1-in-6 failure rate before the fix). Production impact: every `await logError(...)` in route handlers and the global Express error catcher now actually waits for the error.log write to flush to disk, so error entries survive fast process exit/restart. No behavior change for debug/info/warn (they never wrote to disk). ESLint clean.
|
||||
|
||||
## Coordination Rules
|
||||
|
||||
1. **Always `git pull` before starting work.**
|
||||
2. **Claim a task by editing BACKLOG.md:** set `status: in-progress` and `owner: hermes` or `owner: krystie`.
|
||||
3. **Commit BACKLOG.md claim first**, then start coding.
|
||||
4. **Run tests before pushing:** `cd dashcaddy-api && npx jest --passWithNoTests`
|
||||
5. **Push to `main`** — use `http://sami7777:<token>@100.98.123.59:3000/sami7777/dashcaddy.git`
|
||||
6. **Update BACKLOG.md** when done: set `status: done`, add brief result under the task.
|
||||
7. **Never work on a task another bot has claimed** (status: in-progress).
|
||||
8. **Quality bar:** this is a public-release product. No hacks, no env-var workarounds, no per-machine patches. Fixes go in the shared codebase.
|
||||
9. **VERSION bump:** when a batch of tasks is done, bump patch version in package.json + VERSION file, update CHANGELOG, tag.
|
||||
@@ -0,0 +1,296 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to DashCaddy are documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Auto-login page served from API (`GET /api/v1/auth/login-page?service=<id>`).** Chat, Plex, Jellyfin, and Emby auto-login pages are now generated by the API instead of living as 5 KB inline HTML blobs inside Caddyfile `respond` blocks. Caddyfile blocks shrink from ~50 lines to 3. Future login-page changes deploy with the container, no `caddy-apply` needed.
|
||||
|
||||
### Fixed
|
||||
- **SSO cookie placeholder bug.** `dashcaddy_auth` Caddy snippet had `header_up Cookie {http.request.cookie}` — an invalid placeholder that resolved to empty string at runtime, silently clearing the session cookie before it reached the `forward_auth` gate. SSO worked only via the IP-session fallback (same-IP). Removed the line; Caddy's `forward_auth` forwards all original request headers automatically.
|
||||
- **Jellyfin/Emby `merge()` syntax error.** `try` block in the auto-login page's `merge()` helper was missing its closing `}` before `catch`, causing a JS syntax error in the browser that silently broke localStorage token merging.
|
||||
|
||||
### Changed
|
||||
- **CLAUDE.md rewrite.** Was describing the old Windows-local `C:/caddy/` + `caddy-api/` layout. Now accurately documents DNS2 as production (`/opt/dashcaddy/`, `caddy-apply`, correct Tailscale IP, SSO architecture).
|
||||
- **`.gitignore` coverage.** Runtime-generated data files (`audit-log.json`, `backup-history.json`, `credentials.json`, `health-history.json`, etc.), cert directories (`generated-certs/`, `pki/`), and root-level test scripts now ignored.
|
||||
|
||||
## [1.14.0] - 2026-06-28
|
||||
|
||||
### Security
|
||||
- **TOTP recovery system (4-part defense against permanent lockout).** Pre-lockout: `.bak` fallback credentials file checked at every TOTP init, used silently when primary fails. Diagnostic: `/recovery-info` endpoint + `/recovery-panel` UI on the entry screen with one-click "Import Backup" + "Download Backup" buttons. Post-lockout: friction-free `.license-secret` restore flow. (`d230b39`, `3dff49c`, `7bbd969`)
|
||||
|
||||
### Added
|
||||
- **Kubernetes-standard health probe aliases (DC-012).** Added `/healthz` and `/readyz` as root-level aliases for `/health/live` and `/health/ready` so fresh users can copy-paste `healthcheck:` blocks from k8s/Docker docs. Liveness (`/healthz`) is a pure process check — no I/O. Readiness (`/readyz`) checks the config file, services file, Docker daemon, and Caddy admin API (3s timeout each), returning 200 if all OK or 503 with a `checks` object detailing failures. Both endpoints are unauthenticated by design (orchestration tooling doesn't carry session cookies). Probe endpoints also bypass CSRF validation and are excluded from per-request logging so k8s polling every 10s doesn't flood the audit log. Added `__tests__/health-probe-aliases.test.js` (19 tests) — covers alias equivalence, the removed `/api/v1/health` returning 404, and a source-of-truth sync test that detects drift between `src/app.js` mount list and `src/utilities/middleware.js` allowlist. README and user-guide updated with copy-paste `docker-compose.yml` and Kubernetes probe blocks. Also: audited the cross-platform standardization doc's "What's Still Open" section — all four items previously listed as remaining work (config schema migration, monitoring endpoint opt-in, CSRF path duplication, per-call fetchT timeouts) were already implemented in earlier v1.13.x audit passes but never marked done. Doc updated with pointers and Pitfall 20 added ("Audit Doc Lists Items That Are Already Done") so future agents don't redo the work.
|
||||
- **OpenClaw routes** — full set under `/openclaw` prefix: connect, disconnect, status, host discovery. `docker.client` wrapper fixed; duplicate `/apps/` paths stripped across sub-routers.
|
||||
- **Auto-backup scheduling (premium tier)** + storage-limit enforcement (prune oldest when `maxStorageBytes` exceeded) + restore-from-backup on update rollback. Bundled workflows included out-of-the-box.
|
||||
- **Monitoring widget on main dashboard** — CPU/mem data flattened, health summary added; `/api/monitoring/stats` exposed as a public route with rate-limit.
|
||||
- **Sami Files template** — logPath wired into the template and mounted in `start.sh`.
|
||||
- **Unified logger** — single source of truth for logs, errors, and audit events.
|
||||
- **Notification manager + resource alerting** (premium tier).
|
||||
- **Update UX** — badge→modal flow, orange update button, "Update All", toast notifications, workflow triggers.
|
||||
- **Comprehensive test suite additions:** 7 new test files (`dns-propagation`, `notification-manager`, `ssl-monitor`, `log-digest`, `metrics`, `config-drift-detector`, `auto-restart-manager`) — 120 new tests, all passing.
|
||||
|
||||
### Changed
|
||||
- **Route response standardization (DC-010).** Every `{success, ...}` envelope across 9 route files now flows through `response-helpers` (`success()` / `ok()`). Only 2 intentional raw-array calls remain (`routes/services.js` lines 360+368 — frontend wire contract). Error-path envelopes use `error()` separately. ~62 calls converted across `browse/logs/sites/updates/notifications/tailscale/events/workflows/openclaw/dns/health/ca`.
|
||||
- **`/api/v1/` versioning:** all routes mounted under `/api/v1/`. Legacy un-versioned `/api/` mount removed. Frontend, OpenAPI spec, DashCA pages, and all internal path matchers (CSRF exclusions, auth public routes, audit log, rate-limit mounts) updated.
|
||||
- **`scripts/release.sh`** now stages build-rewritten files (`sw.js`, `index.html`) for the published tarball, copies `VERSION` into the tarball, and writes both `dashcaddy-api/package.json` AND root `VERSION` on every release. No more version drift.
|
||||
|
||||
### Fixed
|
||||
- **Credential route path regression (DC-011).** `routes/services.js` had dropped the `/services/` prefix from credential routes (POST/DELETE/GET) during a refactor, causing 4 test failures and a live 404. Re-applied the prefix; also fixed a latent `ReferenceError` where invalid serviceIds called `ctx.errorResponse()` in a factory-destructured module (replaced with the imported `errorResponse` helper).
|
||||
- **19 ESLint warnings (DC-004).** Reached zero warnings across `src/` — most cleared by the refactor, the final 3 (`require-await` on `resyncHealthChecker`, two `max-depth` violations) fixed in `src/app.js`.
|
||||
- **Workflow engine init broken** — `fetchT` not imported, `NotificationManager` constructor missing `new`, `servicesStateManager` not hoisted. Fixed; events now fire on startup.
|
||||
- **Container-logs feature was misusing `wireModal`** — short-circuited the rest of `features.js` and broke unrelated dashboard features. Replaced with the correct wiring.
|
||||
- **CSP hash mismatch** between Windows and Linux builds — now computed on LF-normalized `index.html` so hashes are identical across platforms.
|
||||
- **SW cache tag** now derived from bundle content hash, so the service worker invalidates correctly when bundle content changes.
|
||||
- **Updater false-positive loop** when commit hash was unknown — fixed.
|
||||
- **Logger.error() swallowed the writeErrorLog promise (DC-018).** `Logger.error()` called `this._log('error', ...)` but dropped the return value, so the async error.log disk write was fire-and-forget. Every `await logError(...)` / `await log.error(...)` caller (6 route handlers + the global Express error catcher) was awaiting `undefined`. This caused a flaky `logging.test.js` in the full suite and could lose error-log entries on fast process exit/restart. One-line fix: `return this._log(...)`.
|
||||
- **Flaky backup-manager tamper test (DC-019).** The "rejects tampered data (auth tag mismatch)" test corrupted the encrypted blob by replacing its first base64 char with `'X'`; when the random IV's first base64 char was already `'X'` (~1/64 chance), the replacement was a no-op and decryption succeeded. Now corrupts the authTag byte directly (XOR `0xFF`) so the tamper is guaranteed to differ.
|
||||
|
||||
### Removed
|
||||
- **Dead `/api/v1/health`, `/api/v1/health/live`, `/api/v1/health/ready` routes** (DC-012) — these were registered in `PUBLIC_ROUTES` and CSRF exclusion lists but never actually mounted on the apiRouter. Consolidated to root-level `/health`, `/health/live`, `/health/ready` plus new `/healthz` and `/readyz` aliases. Anyone probing `/api/v1/health` will now get a clean 404 instead of an unexpected behaviour.
|
||||
- Stale ad-hoc test/debug scripts (`comprehensive-test.js`, `test-security-fixes.js`) moved to `dashcaddy-api/scripts/legacy/` (preserved, not deleted — 875 lines of security test coverage retained as a manual smoke test).
|
||||
- Stale root-level files: `*.bak`, `server-old.js`, and ad-hoc reports (`DEPLOYMENT-SUCCESS.md`, `FINAL-DEPLOYMENT-REPORT.md`, `DESLOPIFICATION-ROADMAP.md`, etc.) — disk-only cleanup, already gitignored.
|
||||
- Dead `routes/` directory at API root (replaced by `src/routes/`).
|
||||
|
||||
### Security (TOTP integration)
|
||||
- TOTP integration tests now cover the full `/api/auth/check` → session → endpoint flow (DC-006). 25 new tests including: `setup` (generate + normalize + reject invalid Base32), `verify-setup` (missing/bad/no-pending/valid-code paths), `verify` login (400/400/401/200), `check-session` (passthrough when disabled + 401 no-session + 200 valid-session), `disable`, `config` (valid/invalid/never-disables), and full end-to-end setup→login→check-session→disable.
|
||||
|
||||
### Fixed (from merge)
|
||||
- **routes/updates.js** — krystie's branch had `if (!ok)` referencing the helper function instead of the `secretOk` boolean. Would have 500'd every `/system/update-notify` request. Caught during merge, kept my version with the correct boolean check.
|
||||
- **routes/notifications.js** — two places where she replaced `res.json({success: result.success, ...})` with `ok(...)` would have forced `success: true` for partial-failure delivery. Kept my version with explicit `res.json` to preserve the semantic.
|
||||
|
||||
## [1.13.4] - 2026-06-12
|
||||
|
||||
### Changed
|
||||
- Standardized all route handler responses to use helpers from `src/utils/responses.js`
|
||||
(`ok`, `errorResponse`, `successMessage`, `notFound`, `validationError`, `forbidden`,
|
||||
`unauthorized`, `conflict`). ~160 raw `res.json()` calls converted across 32+ files.
|
||||
No behavior changes — response shapes are identical. This ensures future schema
|
||||
changes (e.g., adding a `requestId` envelope) only need to update one module.
|
||||
- Fixed `error` vs `errorResponse` signature mismatch in `routes/health.js` CA cert
|
||||
endpoint. The `error` helper takes `(res, message, statusCode)` while `errorResponse`
|
||||
takes `(res, statusCode, message, extras)` — the wrong alias was being used for
|
||||
calls that needed the 4-argument form.
|
||||
- Updated `middleware.js`, `csrf-protection.js`, `error-handler.js`, and
|
||||
`license-manager.js` to use response helpers for rejection/error responses
|
||||
instead of inline `res.status().json()`.
|
||||
|
||||
### Note
|
||||
- 4 pre-existing test failures in `services.routes.test.js` (credential storage)
|
||||
remain from before this release. They are unrelated to the standardization pass.
|
||||
|
||||
## [1.5.0] - 2026-05-17
|
||||
|
||||
### Changed (BREAKING)
|
||||
- API routes now mounted exclusively under `/api/v1/`. The legacy un-versioned
|
||||
`/api/` mount has been removed. Frontend, OpenAPI spec, DashCA pages, and
|
||||
all internal path matchers (CSRF exclusions, auth public routes, audit log,
|
||||
rate-limit mounts) updated accordingly. **Existing integrations that hit
|
||||
`/api/...` directly must update to `/api/v1/...`.** Held at minor bump
|
||||
(1.5.0) rather than major (2.0.0) — DashCaddy is still pre-1.0-API-stable.
|
||||
|
||||
### Added
|
||||
- `LICENSE` (proprietary EULA) at repo root.
|
||||
- `CHANGELOG.md` (this file) — Keep a Changelog format.
|
||||
- Gitea Actions workflow ([.gitea/workflows/ci.yml](.gitea/workflows/ci.yml))
|
||||
that runs `npm test` (with coverage) and `npm run lint` on every push to
|
||||
`main`/`master` and on PRs, plus a `security` job running `npm audit` and
|
||||
the security-focused test subset.
|
||||
|
||||
### Fixed
|
||||
- 9 pre-existing `no-empty` ESLint errors in `backup-manager.js` and
|
||||
`routes/backups.js` (intentional ignore-failure catches now annotated).
|
||||
|
||||
### Removed
|
||||
- Stale files at repo root: `*.bak`, `server-old.js`, and ad-hoc
|
||||
deployment/migration/test reports (`DEPLOYMENT-SUCCESS.md`,
|
||||
`FINAL-DEPLOYMENT-REPORT.md`, `DESLOPIFICATION-ROADMAP.md`,
|
||||
`error-handling-*.md`, `WHAT-IS-DASHCADDY.md`, etc.). Already gitignored —
|
||||
disk-only cleanup.
|
||||
|
||||
---
|
||||
|
||||
## [1.4.10] - 2026-05-17
|
||||
|
||||
### Fixed
|
||||
- `release.sh` now stages build-rewritten files (`sw.js`, `index.html`) so
|
||||
they're included in the published tarball.
|
||||
|
||||
## [1.4.9] - 2026-05-17
|
||||
|
||||
### Fixed
|
||||
- Container-logs feature was misusing `wireModal`, which short-circuited the
|
||||
rest of `features.js` and broke unrelated dashboard features.
|
||||
|
||||
## [1.4.8] - 2026-05-17
|
||||
|
||||
### Fixed
|
||||
- CSP hash now computed on LF-normalized `index.html` so Windows and Linux
|
||||
builds produce identical hashes.
|
||||
|
||||
## [1.4.7] - 2026-05-17
|
||||
|
||||
### Fixed
|
||||
- Dashboard unbroken: corrected bundle order, closed dangling IIFE, removed
|
||||
duplicate `const` declaration.
|
||||
|
||||
## [1.4.6] - 2026-05-17
|
||||
|
||||
### Fixed
|
||||
- `sw.js` cache tag now derived from bundle content hash, so service worker
|
||||
invalidates correctly when bundle content changes.
|
||||
|
||||
## [1.4.5] - 2026-05-17
|
||||
|
||||
### Fixed
|
||||
- Frontend deploy routed through the host-side updater (matches the API
|
||||
container's own update path).
|
||||
|
||||
## [1.4.4] - 2026-05-16
|
||||
|
||||
### Fixed
|
||||
- `notify` endpoint exempted from CSRF (it's called by the host-side updater,
|
||||
not the browser).
|
||||
- `release.sh` JSON parsing made portable (no longer assumes GNU `jq`
|
||||
semantics on every host).
|
||||
|
||||
## [1.4.3] - 2026-05-16
|
||||
|
||||
### Added
|
||||
- Seamless release flow: push-notify endpoint, VERSION file copy into
|
||||
release tarball, robust SSH mirror handling on port 22022.
|
||||
|
||||
## [1.4.2] - 2026-05-16
|
||||
## [1.4.1] - 2026-05-16
|
||||
|
||||
### Changed
|
||||
- Version bump only — packaging plumbing for the 1.4.x release line.
|
||||
|
||||
## [1.4.0] - 2026-05-06
|
||||
|
||||
### Added
|
||||
- `scripts/release.sh` — one-command release cutting and publishing.
|
||||
|
||||
---
|
||||
|
||||
## [1.3.1] - 2026-05-06
|
||||
|
||||
### Fixed
|
||||
- Installer: added `src/` directory to the deploy manifest; dropped
|
||||
`MakeDirectory=yes` from the systemd updater path unit.
|
||||
- Self-updater: copies `src/`, replaces `routes/` in place instead of
|
||||
nesting it inside the existing tree.
|
||||
|
||||
## [1.3.0] - 2026-05-06
|
||||
|
||||
### Added
|
||||
- Self-updater supports `DASHCADDY_API_SOURCE_DIR` env override for
|
||||
non-standard deploy layouts.
|
||||
|
||||
### Fixed
|
||||
- Self-updater now clears *all* pending history entries, not just one.
|
||||
|
||||
---
|
||||
|
||||
## [1.2.0] - 2026-05-14
|
||||
|
||||
### Added
|
||||
- Container Log Viewer with streaming, search, and download.
|
||||
- Service filter, batch operations across multiple services, and snapshot
|
||||
capture.
|
||||
- Auto CSP hash updates during build.
|
||||
- Dashboard version button and self-update UI wiring.
|
||||
- Release policy checks and dashboard version verification.
|
||||
|
||||
### Changed
|
||||
- All routine `console.log` calls gated behind `window.DASHCADDY_DEBUG`
|
||||
flag for quieter production output.
|
||||
- All `console.error` calls routed through `ErrorHandler` for consistent
|
||||
tracking.
|
||||
|
||||
### Fixed
|
||||
- Updater no longer triggers a false-positive "update available" loop
|
||||
when commit hash is unknown.
|
||||
|
||||
---
|
||||
|
||||
## [1.1.5] - 2026-03-23
|
||||
|
||||
### Added
|
||||
- Pylon health relay for remote service health checks (with relay
|
||||
fallback on `/probe/:id`).
|
||||
- Host-side auto-updater for zero-touch API container rebuilds.
|
||||
|
||||
### Fixed
|
||||
- Service edit preserves service ID on subdomain change; accepts
|
||||
`localhost` as a valid IP.
|
||||
- Taxi theme accent color now distinct from text.
|
||||
- Prevents encryption key conflicts; adds license backup on rotation.
|
||||
|
||||
## [1.1.1] - 2026-03-23
|
||||
|
||||
### Fixed
|
||||
- Service edit, CSRF token stability, and license restore.
|
||||
|
||||
---
|
||||
|
||||
## [1.0.x] - 2026-03-05 → 2026-03-22
|
||||
|
||||
Initial release line. Highlights from work between v1.0 and v1.1:
|
||||
|
||||
### Added
|
||||
- Cross-platform path support (Windows + Linux deployments).
|
||||
- Subdirectory routing mode for public-domain deployments.
|
||||
- Auto-update system for DashCaddy instances.
|
||||
- Batched status endpoint (frontend performance).
|
||||
- Install-wide onboarding tour (no longer per-browser).
|
||||
- Daily log digest and Docker hygiene/maintenance.
|
||||
- Unified backup/restore v2.0 with full state capture.
|
||||
- DNS uptime bars and fully-dynamic DNS server config.
|
||||
|
||||
### Changed
|
||||
- Phase 1-3 refactor: extracted config/context/utils into `src/`, split
|
||||
monolithic `server.js`, standardized all 25+ route files with explicit
|
||||
dependency injection.
|
||||
- Unified error handling system (throw-based, migrated 25 route files).
|
||||
- ESLint + Prettier baseline with auto-fixes.
|
||||
|
||||
### Security
|
||||
- 7 critical + 16 high/medium API security bugs fixed.
|
||||
- 7 frontend security vulnerabilities fixed (4 critical, 3 high).
|
||||
- Logger sanitization to prevent log injection.
|
||||
|
||||
### Tests
|
||||
- Comprehensive test suite reaching 80%+ coverage threshold.
|
||||
- `docker-security` test suite (41 tests).
|
||||
- `auth-manager` and `credential-manager` test suites.
|
||||
|
||||
## [1.0.0] - 2026-03-05
|
||||
|
||||
Initial release of DashCaddy. Unified dashboard for Docker container
|
||||
management, Caddy reverse proxy configuration, DNS automation, and SSL
|
||||
certificate provisioning.
|
||||
|
||||
[Unreleased]: ../../compare/v1.5.0...HEAD
|
||||
[1.5.0]: ../../compare/v1.4.10...v1.5.0
|
||||
[1.4.10]: ../../compare/v1.4.9...v1.4.10
|
||||
[1.4.9]: ../../compare/v1.4.8...v1.4.9
|
||||
[1.4.8]: ../../compare/v1.4.7...v1.4.8
|
||||
[1.4.7]: ../../compare/v1.4.6...v1.4.7
|
||||
[1.4.6]: ../../compare/v1.4.5...v1.4.6
|
||||
[1.4.5]: ../../compare/v1.4.4...v1.4.5
|
||||
[1.4.4]: ../../compare/v1.4.3...v1.4.4
|
||||
[1.4.3]: ../../compare/v1.4.2...v1.4.3
|
||||
[1.4.2]: ../../compare/v1.4.1...v1.4.2
|
||||
[1.4.1]: ../../compare/v1.4.0...v1.4.1
|
||||
[1.4.0]: ../../compare/v1.3.1...v1.4.0
|
||||
[1.3.1]: ../../compare/v1.3.0...v1.3.1
|
||||
[1.3.0]: ../../compare/v1.2.0...v1.3.0
|
||||
[1.2.0]: ../../compare/v1.1.5...v1.2.0
|
||||
[1.1.5]: ../../compare/v1.1.1...v1.1.5
|
||||
[1.1.1]: ../../compare/v1.0.0...v1.1.1
|
||||
[1.0.0]: ../../releases/tag/v1.0.0
|
||||
@@ -0,0 +1,251 @@
|
||||
# DashCaddy Project Guidelines for AI Assistants
|
||||
|
||||
## HARD RULE: Docker Storage on E: Drive
|
||||
|
||||
**ALL Docker container data, volumes, bind mounts, and app configs MUST use `E:/dockerdata/` via bind mounts or CIFS volumes. No exceptions.**
|
||||
|
||||
- E: is a network share (`\\Sami-pc\e_share`) shared across all home network computers
|
||||
- The ONLY thing allowed on C: is the Docker Desktop WSL engine VHD (`C:/dockerdata/DockerDesktopWSL/`) — this is the absolute bare minimum WSL2 requires (local NTFS). WSL2 cannot create VHDs on network shares.
|
||||
- Keep C: Docker usage under 5GB
|
||||
- When deploying new containers, always use `E:/dockerdata/<app-name>/` for bind mount paths
|
||||
- For CIFS volumes in docker-compose, use `//Sami-pc/e_share/dockerdata/...` as the device path
|
||||
|
||||
## CRITICAL: Production is on DNS2 (not this machine)
|
||||
|
||||
DashCaddy runs on **DNS2** (`100.121.150.22` via Tailscale / `194.233.88.206` public).
|
||||
SSH in with: `ssh root@100.121.150.22`
|
||||
|
||||
### Production Layout on DNS2
|
||||
|
||||
```
|
||||
/opt/dashcaddy/ # git repo (auto-updated)
|
||||
├── dashcaddy-api/
|
||||
│ ├── *.js # API server source
|
||||
│ └── data/
|
||||
│ ├── services.json # LIVE services list
|
||||
│ ├── config.json # LIVE DashCaddy config
|
||||
│ ├── dns-credentials.json # DNS API credentials
|
||||
│ └── credentials.json # Encrypted app credentials
|
||||
├── status/ # Dashboard frontend (built)
|
||||
│ ├── index.html
|
||||
│ ├── dist/ # Bundled JS (core/features/onboarding/init)
|
||||
│ ├── js/ # Source JS (also served statically)
|
||||
│ ├── css/
|
||||
│ └── assets/
|
||||
├── ca/ # DashCA static site
|
||||
├── updates/ # Auto-updater staging + history
|
||||
└── start.sh # Container launch script (run by @reboot cron)
|
||||
```
|
||||
|
||||
### Docker Container
|
||||
|
||||
- **Name**: `dashcaddy-api`
|
||||
- **Image**: `dashcaddy-dashcaddy-api:latest`
|
||||
- **Port**: `127.0.0.1:3001` (Caddy proxies to it)
|
||||
- **Started by**: `/opt/dashcaddy/start.sh` via root `@reboot` cron
|
||||
|
||||
Key container mounts:
|
||||
| Container path | Host path |
|
||||
|---|---|
|
||||
| `/app/data/` | `/opt/dashcaddy/dashcaddy-api/data/` |
|
||||
| `/app/assets` | `/opt/dashcaddy/status/assets` |
|
||||
| `/caddyfile` | `/etc/caddy/Caddyfile` |
|
||||
| `/app/backups` | `/opt/dashcaddy/backups` |
|
||||
|
||||
### Caddy
|
||||
|
||||
- **Config**: `/etc/caddy/Caddyfile` (git-guarded — edit then run `caddy-apply`)
|
||||
- **Admin API**: `http://localhost:2019` (NOT 2021)
|
||||
- **TLS storage**: `/var/lib/caddy/`
|
||||
- **Static files**: Caddy serves `/opt/dashcaddy/status/` for `status.sami`
|
||||
|
||||
### Development Files (for editing)
|
||||
|
||||
```
|
||||
e:/CaddyCerts/sites/
|
||||
├── dashcaddy-api/ # API server source (NOT caddy-api/)
|
||||
│ ├── server.js
|
||||
│ ├── src/app.js # Express app factory
|
||||
│ ├── routes/ # Route handlers
|
||||
│ ├── middleware.js
|
||||
│ └── ...
|
||||
└── status/ # Dashboard frontend source
|
||||
├── index.html # HTML template (~853 lines)
|
||||
├── js/ # Source JS modules
|
||||
├── css/
|
||||
├── dist/ # Built output (run node build.js)
|
||||
└── build.js # Build script (uses esbuild)
|
||||
```
|
||||
|
||||
## When Making Changes
|
||||
|
||||
### To add/remove services from dashboard:
|
||||
Edit `/opt/dashcaddy/dashcaddy-api/data/services.json` on DNS2 directly,
|
||||
OR use the dashboard UI at `https://status.sami`.
|
||||
|
||||
### To modify Caddy reverse proxy rules:
|
||||
```bash
|
||||
ssh root@100.121.150.22
|
||||
# Edit /etc/caddy/Caddyfile
|
||||
caddy-apply "reason for change" # validates + reloads + git commits
|
||||
```
|
||||
|
||||
### To modify API server code:
|
||||
1. Edit `e:/CaddyCerts/sites/dashcaddy-api/` locally
|
||||
2. `scp` changed files to `root@100.121.150.22:/opt/dashcaddy/dashcaddy-api/`
|
||||
3. Rebuild container: `ssh root@100.121.150.22 "bash /opt/dashcaddy/start.sh"`
|
||||
|
||||
### To modify dashboard frontend:
|
||||
1. Edit source in `e:/CaddyCerts/sites/status/js/` or `status/index.html`
|
||||
2. Build: `cd e:/CaddyCerts/sites/status && node build.js`
|
||||
3. Deploy: `scp -r dist/ index.html sw.js root@100.121.150.22:/opt/dashcaddy/status/`
|
||||
|
||||
### To modify DashCA:
|
||||
Edit files in `e:/CaddyCerts/sites/ca/`, then:
|
||||
1. Regenerate: `cd e:/CaddyCerts/sites/ca/scripts && bash generate-all.sh`
|
||||
2. Deploy: `scp -r e:/CaddyCerts/sites/ca/* root@100.121.150.22:/opt/dashcaddy/ca/`
|
||||
|
||||
## DashCA - Certificate Authority Distribution
|
||||
|
||||
**Purpose**: One-click CA cert install page so *.sami domains are trusted on all devices.
|
||||
**Access**: `https://ca.sami`
|
||||
|
||||
**Certificate Info:**
|
||||
- **CN**: Sami Home Network Root CA
|
||||
- **Algorithm**: ECDSA P-256 with SHA-256
|
||||
- **Valid Until**: Dec 22, 2034
|
||||
- **Fingerprint**: `08:98:A5:63:F5:A1:A2:58:5F:02:D7:A8:A2:54:87:E6:BC:33:96:21:29:0E`
|
||||
|
||||
**Certificate Source** (on DNS2):
|
||||
- Root CA: `/etc/ssl/sami-ca/root.crt`
|
||||
- Intermediate CA: auto-generated by Caddy at `/var/lib/caddy/pki/authorities/local/`
|
||||
|
||||
### API Endpoints
|
||||
- `GET /api/ca/info` — certificate metadata
|
||||
- `GET /api/health/ca` — CA expiration health (`healthy` / `warning` / `critical`)
|
||||
|
||||
## Key Services
|
||||
|
||||
| Service | Where | Port | Notes |
|
||||
|---------|-------|------|-------|
|
||||
| Caddy (HTTPS) | DNS2 | 443 | Reverse proxy |
|
||||
| Caddy Admin | DNS2 | 2019 | Caddy API |
|
||||
| DashCaddy API | DNS2 | 3001 | Dashboard backend (container) |
|
||||
| Technitium DNS (primary) | DNS2 | 5380 | `100.121.150.22` |
|
||||
| Technitium DNS (secondary) | DNS1 (this PC) | 5380 | `100.71.97.12` |
|
||||
|
||||
## SSO Architecture
|
||||
|
||||
`import dashcaddy_auth <serviceId>` in the Caddyfile expands to a `forward_auth` gate that:
|
||||
1. Checks the DashCaddy TOTP session (cookie domain `.sami` — shared across all `*.sami`)
|
||||
2. Injects credentials (API key, Basic Auth, app cookies) into upstream request headers
|
||||
|
||||
For client-side auto-login (chat, Plex, Jellyfin, Emby):
|
||||
- Caddy redirects `path /` to `/dashcaddy-login`
|
||||
- `/dashcaddy-login` proxies to `GET /api/v1/auth/login-page?service=<id>` on the API
|
||||
- That page's JS fetches `/dashcaddy-api/api/auth/app-token/<id>` and stores the token in `localStorage`
|
||||
|
||||
## Common Mistakes to Avoid
|
||||
|
||||
1. **Wrong API source dir**: It's `dashcaddy-api/`, NOT `caddy-api/` (old name, no longer exists)
|
||||
2. **Wrong services file**: Edit the one in `/opt/dashcaddy/dashcaddy-api/data/` on DNS2, not the dev copy
|
||||
3. **Caddyfile edits without caddy-apply**: Always use `caddy-apply` — it validates, reloads, and git-commits
|
||||
4. **Caddy admin port**: It's 2019, not 2021
|
||||
5. **Frontend changes without build**: Edit JS source, then `node build.js`, then deploy `dist/`
|
||||
6. **DNS2 Tailscale IP**: `100.121.150.22` (NOT the old `100.104.4.5` or `100.74.102.61`)
|
||||
|
||||
---
|
||||
|
||||
## Linux Deployment (DNS2 / Contabo VPS)
|
||||
|
||||
The Windows path sections above describe the **SAMI-PC** deployment. DashCaddy also runs as a Docker container on Linux (DNS2 = `194.233.88.206` / Tailscale `100.121.150.22`). The Linux deployment uses a different layout driven by `start.sh` and `docker run` bind mounts.
|
||||
|
||||
### Production paths (Linux)
|
||||
```
|
||||
/opt/dashcaddy/
|
||||
├── dashcaddy-api/ # Built image source (rebuilt on update)
|
||||
│ ├── Dockerfile
|
||||
│ └── ...
|
||||
├── status/ # Dashboard frontend SOURCE (build context)
|
||||
├── credentials.json # Encrypted credentials (mounted to /app/data)
|
||||
├── .encryption-key # AES key (mounted to /app/data)
|
||||
└── services.json # Live service list (mounted to /app/data)
|
||||
|
||||
/var/www/dashcaddy-status/ # Dashboard frontend LIVE (served by Caddy)
|
||||
# Built bundle output from status/ — NOT the source
|
||||
# tree, NOT the docker build context
|
||||
|
||||
/etc/dashcaddy/
|
||||
└── Caddyfile # Active Caddy configuration
|
||||
|
||||
/root/.dashcaddy/ # Per-user state, credentials backup, license
|
||||
```
|
||||
|
||||
### Container mount points (Linux)
|
||||
| Container path | Host path |
|
||||
|---|---|
|
||||
| `/app/data/credentials.json` | `/opt/dashcaddy/credentials.json` |
|
||||
| `/app/data/.encryption-key` | `/opt/dashcaddy/.encryption-key` |
|
||||
| `/app/data/services.json` | `/opt/dashcaddy/services.json` |
|
||||
| `/caddyfile` | `/etc/dashcaddy/Caddyfile` |
|
||||
|
||||
Note: the app must auto-resolve both `/app/data/...` AND the older `/app/...` layout (where files mounted directly to `/app/`). The `credential-manager.js` and `crypto-utils.js` modules handle this fallback. This is intentional — fresh installs get `/app/data/`, legacy installs keep working without env-var overrides.
|
||||
|
||||
### Three-filesystem frontend trap (Linux)
|
||||
The dashboard frontend lives on **three** separate paths that get confused:
|
||||
|
||||
1. **Source** — `/opt/dashcaddy/status/` — what you edit
|
||||
2. **Live** — `/var/www/dashcaddy-status/` — what Caddy serves to browsers
|
||||
3. **Build context** — `/opt/dashcaddy/dashcaddy-api/` — what `docker build` uses
|
||||
|
||||
Editing `/opt/dashcaddy/status/index.html` and restarting the container does **nothing** visible until you run the build (which writes to `/var/www/dashcaddy-status/`). Always rebuild + container-recreate together. See the `dashcaddy` skill § Deploy cycle for the exact sequence.
|
||||
|
||||
### Common commands (Linux)
|
||||
```bash
|
||||
# Edit Caddyfile then reload (no restart needed)
|
||||
curl -X POST http://localhost:2019/load \
|
||||
-H "Content-Type: text/caddyfile" \
|
||||
--data-binary @/etc/dashcaddy/Caddyfile
|
||||
|
||||
# View container logs
|
||||
docker logs dashcaddy-api --tail 200
|
||||
|
||||
# Rebuild + restart after API code change
|
||||
cd /opt/dashcaddy && git pull
|
||||
cd /opt/dashcaddy/dashcaddy-api && docker build -t dashcaddy-api:local .
|
||||
docker stop dashcaddy-api && docker rm dashcaddy-api
|
||||
# (then re-run the container with the mount table above)
|
||||
|
||||
# Edit a service in the live list
|
||||
vi /opt/dashcaddy/services.json # live-reloaded by the watcher
|
||||
```
|
||||
|
||||
### Differences from Windows
|
||||
| Concern | Windows (SAMI-PC) | Linux (DNS2) |
|
||||
|---|---|---|
|
||||
| Drive letter | `C:/`, `E:/` | `/opt/`, `/etc/`, `/var/www/` |
|
||||
| Network share for state | `\\Sami-pc\e_share` | (none — all local) |
|
||||
| Docker engine | Docker Desktop on WSL2 | Docker Engine on host |
|
||||
| Backend admin | PowerShell | bash + curl |
|
||||
| Caddyfile reload | POST to `localhost:2019/load` | POST to `localhost:2019/load` (same) |
|
||||
| Caddy admin port | 2019 | 2019 |
|
||||
| Self-update | host-side PowerShell updater | host-side bash updater (`start.sh`) |
|
||||
| Tailscale | Same `100.x.x.x` magic DNS | Same |
|
||||
| DNS server | DNS2 (100.74.102.61) primary | DNS2 (100.121.150.22 / 194.233.88.206) — **is** the primary |
|
||||
|
||||
### Linux-specific gotchas
|
||||
- **Caddy needs `network_mode: host`** (or `--network host`) so it can bind :80 and :443 directly. Bridge mode + port mapping also works, but `network_mode: host` is simpler for a single-host setup.
|
||||
- **`credentials.json` permissions matter** — file mode `0600`, owned by the same UID the container runs as. If the host root creates it but the container runs as `node` (uid 1000), the API will fail to read it. Either `chown 1000:1000` or run the container as `--user 0`.
|
||||
- **Don't use `localhost` in the API's CORS_ORIGINS** — it conflicts with the Tailscale IP. Use the actual `https://dashcaddy<your-tld>` URL.
|
||||
- **Tailscale cert provisioning** — set `TS_AUTHKEY` in `/etc/dashcaddy/tailscale.env` (mode 0600) before first start. Without it, the magic DNS hostname will resolve but TLS will fail.
|
||||
|
||||
---
|
||||
|
||||
## Project Info
|
||||
|
||||
- **Name**: DashCaddy
|
||||
- **Version**: 1.13.4 (current; CHANGELOG.md `[Unreleased]` tracks the next bump)
|
||||
- **Purpose**: Unified management for Docker + Caddy + DNS
|
||||
- **Local TLD (Windows)**: `.sami`
|
||||
- **Local TLD (Linux, DNS2)**: `.home` (default; configurable via `siteConfig.tld`)
|
||||
- **Repo**: `/opt/dashcaddy/` on DNS2 (git, auto-updated by self-updater)
|
||||
@@ -1,263 +0,0 @@
|
||||
# DashCaddy — Cross-Platform Architecture
|
||||
|
||||
## Design Principle
|
||||
|
||||
**Single codebase, single container image, runs everywhere.**
|
||||
|
||||
- One Dockerfile → multi-arch image (linux/amd64, linux/arm64, windows/amd64)
|
||||
- One `docker-compose.yml` with profiles → dev / prod / windows
|
||||
- One `config.yaml` → all runtime configuration
|
||||
- Platform-specific paths resolved at runtime via `platform-paths.js`
|
||||
|
||||
## Platform Matrix
|
||||
|
||||
| Feature | Linux (DNS2, VPS, Raspberry Pi) | macOS (Intel/ARM) | Windows (WSL2) | Windows (Native Containers) |
|
||||
|---------|--------------------------------|-------------------|----------------|----------------------------|
|
||||
| Docker Engine | Native | Docker Desktop / Colima | Docker Desktop (WSL2 backend) | Docker Engine (Windows containers) |
|
||||
| Caddy | Native (systemd) | Native (launchd) | Inside WSL2 container | Native Windows binary |
|
||||
| Data Directory | `/opt/dashcaddy/data` | `~/dockerdata/dashcaddy` | `/mnt/e/dockerdata/dashcaddy` (or `E:\dockerdata\dashcaddy`) | `E:\dockerdata\dashcaddy` |
|
||||
| Caddy Config | `/etc/dashcaddy/Caddyfile` | `~/dockerdata/dashcaddy/caddy/Caddyfile` | `/mnt/e/dockerdata/dashcaddy/caddy/Caddyfile` | `E:\dockerdata\dashcaddy\caddy\Caddyfile` |
|
||||
| Tailscale | Native | Native | Native (Windows) or WSL2 | Native Windows |
|
||||
| DNS (CoreDNS) | Native container | Native container | WSL2 container | Windows container (limited) |
|
||||
|
||||
## Path Resolution Strategy
|
||||
|
||||
All paths flow through `platform-paths.js`:
|
||||
|
||||
```javascript
|
||||
// platform-paths.js — single source of truth
|
||||
const paths = {
|
||||
// Base dirs (env-overridable)
|
||||
caddyBase: process.env.CADDY_BASE || (isWindows ? 'C:/caddy' : '/etc/dashcaddy'),
|
||||
dockerData: process.env.DOCKER_DATA || (isWindows ? 'E:/dockerdata' : '/opt/dockerdata'),
|
||||
|
||||
// Derived paths
|
||||
servicesFile: process.env.SERVICES_FILE || path.join(paths.caddyBase, 'services.json'),
|
||||
dataDir: process.env.DATA_DIR || path.dirname(paths.servicesFile),
|
||||
|
||||
// Container paths (fixed inside container)
|
||||
containerUpdatesDir: '/app/updates',
|
||||
containerFrontendDir: '/app/dashboard',
|
||||
containerAssetsDir: '/app/assets',
|
||||
};
|
||||
```
|
||||
|
||||
**Rule**: No hardcoded paths in application code. Ever.
|
||||
|
||||
## Docker Multi-Arch Build
|
||||
|
||||
```dockerfile
|
||||
# .dockerignore excludes: node_modules, .git, dist, *.log, .env*, coverage, *.md
|
||||
# Buildx command:
|
||||
# docker buildx build --platform linux/amd64,linux/arm64,windows/amd64 \
|
||||
# -t dashcaddy/dashcaddy-api:latest --push .
|
||||
```
|
||||
|
||||
### Windows Container Specifics
|
||||
|
||||
- Base image: `mcr.microsoft.com/windows/servercore:ltsc2022` (for Caddy) + `mcr.microsoft.com/dotnet/runtime:8.0-nanoserver-ltsc2022` (for Node.js via `pkg` or native)
|
||||
- **Alternative**: Use `node:20-nanoserver-ltsc2022` but it's large (~2GB)
|
||||
- **Recommended**: Build Node.js app with `pkg` into single `.exe`, run in minimal Windows container
|
||||
- Caddy Windows binary: `caddy_windows_amd64.exe` downloaded at build time
|
||||
|
||||
### Build Pipeline (GitHub Actions)
|
||||
|
||||
```yaml
|
||||
# .github/workflows/docker.yml
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/build-push-action@v5
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64,windows/amd64
|
||||
push: true
|
||||
tags: dashcaddy/dashcaddy-api:${{ github.sha }}
|
||||
```
|
||||
|
||||
## Runtime Platform Detection
|
||||
|
||||
```javascript
|
||||
// In any module:
|
||||
const { isWindows, isLinux, dataDir, resolveAssetsPath } = require('./platform-paths');
|
||||
|
||||
// Writing runtime data:
|
||||
const fs = require('fs');
|
||||
const logFile = path.join(dataDir, 'audit-log.json');
|
||||
fs.writeFileSync(logFile, JSON.stringify(entry));
|
||||
|
||||
// Reading assets:
|
||||
const assetPath = resolveAssetsPath(process.env.ASSETS_DIR);
|
||||
```
|
||||
|
||||
## Data Persistence Guarantees
|
||||
|
||||
| Platform | Data Location | Survives Recreate? |
|
||||
|----------|---------------|-------------------|
|
||||
| Linux | `/opt/dashcaddy/data` (bind mount) | ✅ Yes |
|
||||
| macOS | `~/dockerdata/dashcaddy` (bind mount) | ✅ Yes |
|
||||
| Windows WSL2 | `/mnt/e/dockerdata/dashcaddy` (bind mount) | ✅ Yes |
|
||||
| Windows Native | `E:\dockerdata\dashcaddy` (bind mount) | ✅ Yes |
|
||||
|
||||
**Critical**: `platform-paths.assertSafe()` runs at startup in production mode. If `dataDir` resolves to an image-layer path (e.g., `/app/src`), container **refuses to start** with clear error.
|
||||
|
||||
## Caddy Integration
|
||||
|
||||
### Linux/macOS/WSL2
|
||||
- Caddy runs **inside** the DashCaddy container (single container, multiple processes via `supervisord` or `s6`)
|
||||
- OR: Caddy runs on host, DashCaddy API in container (current DNS2 model)
|
||||
- **Recommended for v2**: Single container with `s6-overlay` — simpler, atomic deploys
|
||||
|
||||
### Windows Native
|
||||
- Caddy runs as Windows service (NSSM) or inside container
|
||||
- DashCaddy API runs in Windows container
|
||||
- Shared volume: `E:\dockerdata\dashcaddy\caddy\Caddyfile`
|
||||
|
||||
## DNS Provider Abstraction
|
||||
|
||||
```javascript
|
||||
// src/dns/providers/index.js
|
||||
const providers = {
|
||||
coredns: require('./coredns'),
|
||||
technitium: require('./technitium'),
|
||||
cloudflare: require('./cloudflare'),
|
||||
route53: require('./route53'),
|
||||
// Add new providers here — no other code changes
|
||||
};
|
||||
|
||||
module.exports = function getProvider(name) {
|
||||
const p = providers[name];
|
||||
if (!p) throw new Error(`Unknown DNS provider: ${name}`);
|
||||
return p;
|
||||
};
|
||||
```
|
||||
|
||||
Config-driven: `config.yaml → dns.provider: "coredns"`
|
||||
|
||||
## Tailscale Integration
|
||||
|
||||
| Platform | Method |
|
||||
|----------|--------|
|
||||
| Linux | `tailscale up` in container (needs `NET_ADMIN` + `/dev/net/tun`) |
|
||||
| macOS | Host Tailscale + `host.docker.internal` |
|
||||
| Windows WSL2 | Host Tailscale (Windows) + WSL2 auto-proxy |
|
||||
| Windows Native | `tailscale.exe` in container (Windows container) |
|
||||
|
||||
**Unified approach**: Tailscale runs on **host**, containers reach it via `host.docker.internal:PORT` or Tailscale IP. No container-side Tailscale needed.
|
||||
|
||||
## Windows-Specific Considerations
|
||||
|
||||
### File System
|
||||
- Use `E:/dockerdata` (network share) for all persistent data
|
||||
- C: drive only for Docker Desktop WSL VHD (`C:/dockerdata/DockerDesktopWSL/`)
|
||||
- Path separator: `platform-paths.js` normalizes to POSIX internally
|
||||
|
||||
### Permissions
|
||||
- No `chmod`/`chown` on Windows — rely on Docker volume permissions
|
||||
- Encryption key file: `icacls` to restrict to `SYSTEM` + `Administrators` (installer handles)
|
||||
|
||||
### Networking
|
||||
- `host.docker.internal` works on Docker Desktop (Windows/macOS)
|
||||
- On Linux: `--add-host=host.docker.internal:host-gateway` (Docker 20.04+)
|
||||
- Caddy admin API: `http://host.docker.internal:2019` (Windows/macOS) vs `http://localhost:2019` (Linux)
|
||||
|
||||
## Testing Cross-Platform
|
||||
|
||||
```bash
|
||||
# Local multi-arch test (requires buildx + qemu)
|
||||
docker run --rm --platform linux/amd64 dashcaddy/dashcaddy-api:latest node -e "console.log('amd64 ok')"
|
||||
docker run --rm --platform linux/arm64 dashcaddy/dashcaddy-api:latest node -e "console.log('arm64 ok')"
|
||||
# Windows: requires Windows runner (GitHub Actions windows-latest)
|
||||
|
||||
# Integration test matrix (run in CI)
|
||||
# - Linux: full stack (Caddy + API + Dashboard + CoreDNS)
|
||||
# - Windows WSL2: same stack inside Ubuntu WSL
|
||||
# - Windows Native: API + Caddy in Windows containers (limited DNS)
|
||||
```
|
||||
|
||||
## Migration Path (Current → Unified)
|
||||
|
||||
| Current | Target |
|
||||
|---------|--------|
|
||||
| `/opt/dashcaddy/start.sh` | `docker compose --profile prod up -d` |
|
||||
| Multiple JSON configs (`services.json`, `config.json`, `dns-credentials.json`) | Single `config.yaml` |
|
||||
| Manual Caddyfile edit + `caddy-apply` | Auto-generated from `config.yaml` + `services.json` |
|
||||
| `platform-paths.js` with hardcoded fallbacks | Pure env-driven, no fallbacks to image-layer paths |
|
||||
| Custom esbuild + manual `node build.js` | Vite (frontend) + `tsc`/`esbuild` (backend) |
|
||||
| Separate installer repo (`dashcaddy-installer`) | Single repo, `install.sh` / `install.ps1` at root |
|
||||
|
||||
## Environment Variable Reference
|
||||
|
||||
| Variable | Description | Default (Linux) | Default (Windows) |
|
||||
|----------|-------------|-----------------|-------------------|
|
||||
| `CADDY_BASE` | Caddy config root | `/etc/dashcaddy` | `C:/caddy` |
|
||||
| `DOCKER_DATA` | Docker volumes root | `/opt/dockerdata` | `E:/dockerdata` |
|
||||
| `SERVICES_FILE` | Services JSON path | `/etc/dashcaddy/services.json` | `C:/caddy/services.json` |
|
||||
| `DATA_DIR` | Runtime data dir | `/opt/dashcaddy/data` | `E:/dockerdata/dashcaddy` |
|
||||
| `CONFIG_FILE` | Main config | `/opt/dashcaddy/data/config.json` | `E:/dockerdata/dashcaddy/config.json` |
|
||||
| `CADDY_ADMIN_URL` | Caddy API endpoint | `http://localhost:2019` | `http://host.docker.internal:2019` |
|
||||
| `DASHCADDY_UPDATES_DIR` | In-container updates | `/app/updates` | `/app/updates` |
|
||||
| `DASHCADDY_FRONTEND_DIR` | In-container dashboard | `/app/dashboard` | `/app/dashboard` |
|
||||
| `ASSETS_DIR` | In-container assets | `/app/assets` | `/app/assets` |
|
||||
| `SKIP_DATA_DIR_GUARD` | Bypass safety check | `0` | `0` (dev only) |
|
||||
| `NODE_ENV` | `production` \| `development` | `production` | `production` |
|
||||
|
||||
## CI/CD Pipeline
|
||||
|
||||
```yaml
|
||||
# .github/workflows/ci.yml
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: [20, 22]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: ${{ matrix.node }} }
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
- run: npm run test:ci
|
||||
|
||||
build-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- run: cd status && npm ci && npm run build
|
||||
- uses: actions/upload-artifact@v4
|
||||
with: { name: dashboard-dist, path: status/dist/ }
|
||||
|
||||
docker:
|
||||
needs: [test, build-frontend]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/build-push-action@v5
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name == 'push' }}
|
||||
tags: dashcaddy/dashcaddy-api:${{ github.sha }}
|
||||
|
||||
windows-build:
|
||||
needs: test
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build Windows container
|
||||
run: |
|
||||
docker build -f Dockerfile.windows -t dashcaddy/dashcaddy-api:${{ github.sha }}-windows .
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference: Adding a New Platform
|
||||
|
||||
1. Add platform to `platform-paths.js` (base paths + `isXYZ` flag)
|
||||
2. Add `--platform` to buildx command
|
||||
3. Add CI job for that platform
|
||||
4. Test installer script on that platform
|
||||
5. Update `INSTALL.md` and this doc
|
||||
@@ -1,148 +0,0 @@
|
||||
# DashCaddy — Cross-Platform Installation Guide
|
||||
|
||||
## One-Line Install (Linux/macOS/WSL)
|
||||
|
||||
```bash
|
||||
curl -fsSL https://dashcaddy.net/install.sh | bash
|
||||
```
|
||||
|
||||
## One-Line Install (Windows PowerShell)
|
||||
|
||||
```powershell
|
||||
irm https://dashcaddy.net/install.ps1 | iex
|
||||
```
|
||||
|
||||
## What Gets Installed
|
||||
|
||||
| Component | Purpose |
|
||||
|-----------|---------|
|
||||
| **Caddy** | Reverse proxy + TLS termination (automatic HTTPS via Let's Encrypt) |
|
||||
| **DashCaddy API** | Node.js backend (Docker, DNS, services management) |
|
||||
| **Dashboard** | Single-page React-free frontend (served by Caddy) |
|
||||
| **DashCA** | Local CA for *.local / *.home / *.sami trust |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Platform | Requirements |
|
||||
|----------|--------------|
|
||||
| Linux (Debian/Ubuntu/Alpine/RHEL/Fedora/Arch) | `curl`, `docker`, `docker-compose` (v2 plugin) |
|
||||
| macOS (Intel/Apple Silicon) | `curl`, `docker` (Docker Desktop or Colima) |
|
||||
| Windows 10/11 Pro/Enterprise | **WSL2** + Docker Desktop **or** native Windows containers |
|
||||
| Windows 10/11 Home | WSL2 required (Docker Desktop uses WSL2 backend) |
|
||||
|
||||
> **Note**: On Windows, the installer sets up WSL2 + Ubuntu if not present, then runs the Linux install inside WSL. Native Windows containers are supported but WSL2 is recommended for compatibility.
|
||||
|
||||
## Post-Install
|
||||
|
||||
1. Open `https://status.<your-domain>` (or `https://status.local` for local-only)
|
||||
2. Run the **Setup Wizard** (auto-shown on first visit)
|
||||
3. Add your first service — Done.
|
||||
|
||||
## Advanced: Manual Docker Compose
|
||||
|
||||
```bash
|
||||
# Clone repo
|
||||
git clone https://git.dashcaddy.net/sami7777/dashcaddy.git
|
||||
cd dashcaddy
|
||||
|
||||
# Copy config template
|
||||
cp config.example.yaml config.yaml
|
||||
# Edit config.yaml — at minimum set: domain, email, timezone
|
||||
|
||||
# Start (detached)
|
||||
docker compose --profile prod up -d
|
||||
|
||||
# View logs
|
||||
docker compose logs -f dashcaddy-api
|
||||
```
|
||||
|
||||
## Config File: `config.yaml`
|
||||
|
||||
```yaml
|
||||
# DashCaddy Configuration
|
||||
# All values can be overridden by environment variables (see ENVIRONMENT.md)
|
||||
|
||||
domain: "example.com" # Your base domain (required)
|
||||
email: "admin@example.com" # Let's Encrypt registration (required)
|
||||
timezone: "America/Los_Angeles"
|
||||
|
||||
# Optional overrides
|
||||
caddy:
|
||||
admin_port: 2019
|
||||
http_port: 80
|
||||
https_port: 443
|
||||
|
||||
dashcaddy:
|
||||
api_port: 3001
|
||||
data_dir: "/opt/dashcaddy/data" # Linux default
|
||||
# data_dir: "E:/dockerdata/dashcaddy" # Windows default (E: drive)
|
||||
|
||||
dns:
|
||||
provider: "coredns" # or "technitium", "cloudflare", "route53"
|
||||
# provider_config: {} # See DNS_PROVIDERS.md
|
||||
|
||||
# Feature flags (all opt-in)
|
||||
features:
|
||||
multi_user: false # Enable user accounts + invites
|
||||
billing: false # Enable Stripe billing (requires Stripe keys)
|
||||
share: false # Enable Tailscale share links
|
||||
ca: true # Enable DashCA local CA page
|
||||
|
||||
# Security
|
||||
security:
|
||||
totp_required: true # Require TOTP for all logins
|
||||
session_timeout: "24h"
|
||||
csrf_protection: true
|
||||
```
|
||||
|
||||
## Directory Layout (After Install)
|
||||
|
||||
```
|
||||
/opt/dashcaddy/ # Linux/macOS/WSL data root
|
||||
├── config.yaml # Main config (edit this)
|
||||
├── data/
|
||||
│ ├── services.json # Service definitions (auto-managed)
|
||||
│ ├── credentials.json.enc # Encrypted app credentials
|
||||
│ └── .encryption-key # AES-256 key (keep secret!)
|
||||
├── caddy/
|
||||
│ ├── Caddyfile # Generated from config.yaml + services
|
||||
│ └── certs/ # Let's Encrypt certificates
|
||||
├── dashca/ # Local CA static site
|
||||
└── backups/ # Automatic backups
|
||||
|
||||
E:\dockerdata\dashcaddy\ # Windows data root (same structure)
|
||||
```
|
||||
|
||||
## Upgrading
|
||||
|
||||
```bash
|
||||
# One-liner (re-runs installer, preserves data)
|
||||
curl -fsSL https://dashcaddy.net/install.sh | bash
|
||||
|
||||
# Or via compose
|
||||
docker compose pull && docker compose --profile prod up -d
|
||||
```
|
||||
|
||||
## Uninstalling
|
||||
|
||||
```bash
|
||||
# Linux/macOS/WSL
|
||||
/opt/dashcaddy/uninstall.sh
|
||||
|
||||
# Windows
|
||||
C:\dashcaddy\uninstall.ps1
|
||||
```
|
||||
|
||||
Removes containers, networks, and **optionally** data directory (with confirmation).
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Issue | Fix |
|
||||
|-------|-----|
|
||||
| Port 80/443 in use | Stop existing nginx/apache, or change `caddy.http_port`/`caddy.https_port` in config.yaml |
|
||||
| "Permission denied" on Docker | Add user to `docker` group: `sudo usermod -aG docker $USER` then relogin |
|
||||
| Windows: "WSL2 not found" | Run installer as Admin — it will enable WSL2 and install Ubuntu |
|
||||
| Certificates not issuing | Check DNS A/AAAA records point to this machine; ensure ports 80/443 reachable |
|
||||
| Dashboard shows "Offline" | Verify `docker compose ps` shows `dashcaddy-api` healthy; check `docker compose logs dashcaddy-api` |
|
||||
@@ -0,0 +1,125 @@
|
||||
DashCaddy End-User License Agreement (EULA)
|
||||
=============================================
|
||||
|
||||
Copyright (c) 2024-2026 Sami Ahmed. All rights reserved.
|
||||
|
||||
This software and its source code (the "Software") are proprietary and
|
||||
confidential. By installing, copying, accessing, or otherwise using the
|
||||
Software, you ("Licensee") agree to be bound by the terms of this License.
|
||||
If you do not agree, do not install, copy, or use the Software.
|
||||
|
||||
|
||||
1. GRANT OF LICENSE
|
||||
-------------------
|
||||
Subject to the terms of this License and the purchase of a valid license
|
||||
key where required, Licensor grants Licensee a non-exclusive,
|
||||
non-transferable, revocable license to install and use the Software on
|
||||
hardware that Licensee owns or controls, solely for Licensee's internal
|
||||
purposes.
|
||||
|
||||
A separate license key is required for each production deployment. Use
|
||||
of the Software without a valid license key is permitted only for
|
||||
personal, non-commercial evaluation on a single host, for up to 30 days.
|
||||
|
||||
|
||||
2. RESTRICTIONS
|
||||
---------------
|
||||
Licensee shall NOT:
|
||||
|
||||
(a) sell, rent, lease, sublicense, distribute, publish, or otherwise
|
||||
transfer the Software or any portion thereof to any third party;
|
||||
|
||||
(b) modify, adapt, translate, or create derivative works based on the
|
||||
Software, except as expressly permitted in Section 3;
|
||||
|
||||
(c) reverse engineer, decompile, or disassemble the Software, except
|
||||
to the extent that such activity is expressly permitted by
|
||||
applicable law notwithstanding this limitation;
|
||||
|
||||
(d) remove, alter, or obscure any copyright, trademark, or other
|
||||
proprietary notices contained in the Software;
|
||||
|
||||
(e) use the Software to operate a hosted or managed service that
|
||||
makes the Software's functionality available to third parties,
|
||||
without a separate commercial agreement with Licensor;
|
||||
|
||||
(f) use the Software in any manner that violates applicable law.
|
||||
|
||||
|
||||
3. SOURCE AVAILABILITY
|
||||
----------------------
|
||||
The Software's source code is made available for the purposes of
|
||||
transparency, security review, and self-hosted deployment. Source
|
||||
availability does NOT constitute a grant of open-source rights.
|
||||
Modifications made by Licensee for internal use only are permitted,
|
||||
provided they are not redistributed.
|
||||
|
||||
|
||||
4. OWNERSHIP
|
||||
------------
|
||||
The Software is licensed, not sold. Licensor retains all right, title,
|
||||
and interest in and to the Software, including all intellectual property
|
||||
rights therein. No rights are granted to Licensee other than those
|
||||
expressly set forth in this License.
|
||||
|
||||
|
||||
5. UPDATES
|
||||
----------
|
||||
Licensor may, at its sole discretion, provide updates, patches, or new
|
||||
versions of the Software. Any such updates are subject to the terms of
|
||||
this License unless accompanied by a separate license agreement.
|
||||
|
||||
|
||||
6. TERMINATION
|
||||
--------------
|
||||
This License is effective until terminated. Licensor may terminate this
|
||||
License immediately upon any breach by Licensee. Upon termination,
|
||||
Licensee shall cease all use of the Software and destroy all copies in
|
||||
its possession or control.
|
||||
|
||||
|
||||
7. WARRANTY DISCLAIMER
|
||||
----------------------
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND "AS AVAILABLE", WITHOUT WARRANTY
|
||||
OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
|
||||
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE,
|
||||
TITLE, AND NON-INFRINGEMENT. LICENSEE BEARS THE ENTIRE RISK ARISING
|
||||
OUT OF THE USE OR PERFORMANCE OF THE SOFTWARE.
|
||||
|
||||
|
||||
8. LIMITATION OF LIABILITY
|
||||
--------------------------
|
||||
IN NO EVENT SHALL LICENSOR BE LIABLE FOR ANY INDIRECT, INCIDENTAL,
|
||||
SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR FOR ANY LOSS OF
|
||||
PROFITS, REVENUE, DATA, OR USE, ARISING OUT OF OR RELATED TO THIS
|
||||
LICENSE OR THE SOFTWARE, EVEN IF LICENSOR HAS BEEN ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGES. LICENSOR'S TOTAL CUMULATIVE LIABILITY
|
||||
SHALL NOT EXCEED THE AMOUNT PAID BY LICENSEE FOR THE SOFTWARE IN THE
|
||||
TWELVE (12) MONTHS PRECEDING THE EVENT GIVING RISE TO LIABILITY, OR
|
||||
ONE HUNDRED U.S. DOLLARS (USD $100), WHICHEVER IS GREATER.
|
||||
|
||||
|
||||
9. THIRD-PARTY COMPONENTS
|
||||
-------------------------
|
||||
The Software incorporates third-party open-source components, each
|
||||
governed by its own license. A list of such components and their
|
||||
licenses is available in the project's `node_modules/` directory or
|
||||
on request. This License does not modify the terms of any third-party
|
||||
component license.
|
||||
|
||||
|
||||
10. GOVERNING LAW
|
||||
-----------------
|
||||
This License shall be governed by and construed in accordance with the
|
||||
laws of the jurisdiction in which Licensor resides, without regard to
|
||||
its conflict of laws principles.
|
||||
|
||||
|
||||
11. ENTIRE AGREEMENT
|
||||
--------------------
|
||||
This License constitutes the entire agreement between the parties with
|
||||
respect to the Software and supersedes all prior or contemporaneous
|
||||
understandings, whether written or oral.
|
||||
|
||||
|
||||
For licensing inquiries, contact: ahmed.sami@gmail.com
|
||||
@@ -0,0 +1,420 @@
|
||||
# DashCaddy
|
||||
|
||||
**Self-hosted dashboard for managing Docker apps with automatic SSL, DNS, and reverse proxy configuration.**
|
||||
|
||||

|
||||

|
||||
|
||||
## What is DashCaddy?
|
||||
|
||||
DashCaddy is an all-in-one solution for self-hosting Docker applications. It combines:
|
||||
- 🎨 **Beautiful Dashboard** - Monitor all your services in one place
|
||||
- 🐳 **Docker Management** - Deploy 50+ pre-configured apps with one click
|
||||
- 🔒 **Automatic SSL** - Internal CA with automatic certificate generation
|
||||
- 🌐 **DNS Integration** - Automatic DNS record creation (Technitium DNS)
|
||||
- 🔄 **Reverse Proxy** - Caddy configuration managed automatically
|
||||
- 🔐 **Tailscale Support** - Secure remote access built-in
|
||||
|
||||
## Features
|
||||
|
||||
### Authentication & Security
|
||||
- Built-in TOTP two-factor authentication
|
||||
- Fine-grained access control per service
|
||||
- Secure session management
|
||||
- Group-based permissions
|
||||
|
||||
### Dashboard
|
||||
- Real-time service health monitoring
|
||||
- Response time tracking
|
||||
- Status indicators with visual feedback
|
||||
- Weather widget
|
||||
- Multiple themes (dark/light/blue)
|
||||
- Import/export configuration
|
||||
|
||||
### App Deployment
|
||||
- 50+ pre-configured app templates
|
||||
- One-click deployment
|
||||
- Automatic DNS + SSL + reverse proxy setup
|
||||
- Container health checking
|
||||
- Deployment status tracking
|
||||
- SSL certificate generation monitoring
|
||||
|
||||
### Service Management
|
||||
- Add/edit/delete services
|
||||
- Restart containers
|
||||
- View logs
|
||||
- Update configurations
|
||||
- Silent deletions (no annoying popups)
|
||||
|
||||
### Developer Tools
|
||||
- Error log viewer
|
||||
- API endpoints for automation
|
||||
- Import/export for testing
|
||||
- Comprehensive error logging
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
- Docker & Docker Compose
|
||||
- Caddy web server
|
||||
- Technitium DNS (optional, for automatic DNS)
|
||||
- Node.js 18+ (for API server)
|
||||
|
||||
### Installation
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone https://github.com/yourusername/dashcaddy.git
|
||||
cd dashcaddy
|
||||
```
|
||||
|
||||
2. **Install dependencies**
|
||||
```bash
|
||||
cd caddy-api
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **Configure environment**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your settings
|
||||
```
|
||||
|
||||
4. **Start the API server**
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
5. **Configure Caddy**
|
||||
Add to your Caddyfile:
|
||||
```
|
||||
status.yourdomain.com {
|
||||
root * /path/to/dashcaddy/status
|
||||
file_server
|
||||
reverse_proxy /api/* localhost:3001
|
||||
}
|
||||
```
|
||||
|
||||
6. **Access the dashboard**
|
||||
Open `https://status.yourdomain.com` in your browser
|
||||
|
||||
## Health Probes
|
||||
|
||||
DashCaddy exposes Kubernetes/Docker-standard health endpoints for container orchestration. **No auth required** — these are designed for orchestration tooling to poll.
|
||||
|
||||
| Path | Purpose | Returns |
|
||||
|------|---------|---------|
|
||||
| `/healthz` or `/health/live` | **Liveness** — is the Node.js process alive? | 200 with `{status: "alive", uptime: <seconds>}` |
|
||||
| `/readyz` or `/health/ready` | **Readiness** — are critical deps reachable? (config file, services file, Docker daemon, Caddy admin API) | 200 if all OK, 503 if any dep fails (with details in the `checks` object) |
|
||||
| `/health` | Backwards-compat alias for `/healthz` | Same as `/healthz` |
|
||||
|
||||
**When to use which:**
|
||||
- Use `/healthz` / `/health/live` in a `livenessProbe` — should the container be **restarted**?
|
||||
- Use `/readyz` / `/health/ready` in a `readinessProbe` — should traffic be **routed** to this instance?
|
||||
|
||||
### Docker Compose healthcheck
|
||||
|
||||
Copy-paste this into your DashCaddy `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
dashcaddy-api:
|
||||
image: ghcr.io/samiahmed7777/dashcaddy-api:latest
|
||||
# ... your existing config ...
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3001/readyz', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
```
|
||||
|
||||
### Kubernetes probes
|
||||
|
||||
```yaml
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: 3001
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 30
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /readyz
|
||||
port: 3001
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
```
|
||||
|
||||
Both endpoints return JSON. Liveness is cheap (no I/O, no deps). Readiness touches the Docker daemon and Caddy admin API with a 3-second timeout each, so it's safe to poll every 10s without load concerns.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Create a `.env` file in the `caddy-api` directory:
|
||||
|
||||
```env
|
||||
# Caddy Configuration
|
||||
CADDYFILE_PATH=/path/to/Caddyfile
|
||||
CADDY_ADMIN_URL=http://localhost:2019
|
||||
|
||||
# DNS Configuration (optional)
|
||||
DNS_SERVER=192.168.1.1
|
||||
DNS_TOKEN=your-dns-token
|
||||
|
||||
# File Paths
|
||||
SERVICES_FILE=/path/to/services.json
|
||||
ERROR_LOG_FILE=/path/to/dashcaddy-errors.log
|
||||
```
|
||||
|
||||
### DNS Integration
|
||||
|
||||
DashCaddy works with Technitium DNS for automatic DNS record creation:
|
||||
|
||||
1. Install Technitium DNS
|
||||
2. Create an API token with DNS management permissions
|
||||
3. Configure DNS credentials in dashboard (🔑 Tokens button)
|
||||
|
||||
### Tailscale Integration
|
||||
|
||||
For secure remote access:
|
||||
|
||||
1. Install Tailscale on your server
|
||||
2. Services can be restricted to Tailscale-only access
|
||||
3. Configure in deployment settings
|
||||
|
||||
## Usage
|
||||
|
||||
### Deploying an App
|
||||
|
||||
1. Click **"App Selector"** button
|
||||
2. Choose an app from the template library
|
||||
3. Configure:
|
||||
- Subdomain (e.g., `jellyfin` → `jellyfin.yourdomain.com`)
|
||||
- Port (auto-suggested)
|
||||
- IP address (defaults to localhost)
|
||||
- Tailscale-only access (optional)
|
||||
4. Click **"Deploy"**
|
||||
5. Wait for SSL certificate generation (30-60 seconds)
|
||||
6. Access your app!
|
||||
|
||||
### Managing Services
|
||||
|
||||
- **View Status**: Cards show real-time health and response times
|
||||
- **Open Service**: Click "Open" button
|
||||
- **Restart**: Click restart button (for Docker containers)
|
||||
- **Delete**: Click delete button (removes everything: container, DNS, Caddy config)
|
||||
- **Edit**: Click settings button to modify configuration
|
||||
|
||||
### Viewing Error Logs
|
||||
|
||||
1. Click **"📋 Logs"** button in toolbar
|
||||
2. View all errors with timestamps and context
|
||||
3. Refresh to see latest errors
|
||||
4. Clear logs when resolved
|
||||
|
||||
### Backup & Restore
|
||||
|
||||
**Export Configuration:**
|
||||
1. Click **"📤 Export"** button
|
||||
2. JSON file downloads with all your services
|
||||
3. Save safely
|
||||
|
||||
**Import Configuration:**
|
||||
1. Click **"📥 Import"** button
|
||||
2. Select your backup JSON file
|
||||
3. Confirm import
|
||||
4. Dashboard reloads with restored configuration
|
||||
|
||||
**Note**: API tokens are not exported for security. Reconfigure after import.
|
||||
|
||||
## App Templates
|
||||
|
||||
DashCaddy includes 50+ pre-configured templates:
|
||||
|
||||
### Media & Entertainment
|
||||
- Plex, Jellyfin, Emby
|
||||
- Navidrome, Airsonic
|
||||
- Tautulli, Overseerr
|
||||
|
||||
### Downloads
|
||||
- Sonarr, Radarr, Lidarr, Readarr
|
||||
- Prowlarr, Bazarr
|
||||
- qBittorrent, Transmission
|
||||
- SABnzbd, NZBGet
|
||||
|
||||
### Productivity
|
||||
- Nextcloud
|
||||
- Paperless-ngx
|
||||
- BookStack, Outline
|
||||
- Standard Notes
|
||||
|
||||
### Management
|
||||
- Portainer
|
||||
- Homepage, Homarr
|
||||
- Uptime Kuma
|
||||
- Grafana
|
||||
|
||||
### Security & Authentication
|
||||
- Vaultwarden (Password Manager)
|
||||
|
||||
### Development
|
||||
- Gitea
|
||||
- VS Code Server
|
||||
- Jenkins, Drone CI
|
||||
|
||||
### And many more!
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Services
|
||||
- `GET /api/services` - List all services
|
||||
- `POST /api/services` - Add service
|
||||
- `PUT /api/services` - Bulk import services
|
||||
- `DELETE /api/services/:id` - Remove service
|
||||
|
||||
### App Deployment
|
||||
- `GET /api/apps/templates` - List app templates
|
||||
- `POST /api/apps/deploy` - Deploy new app
|
||||
- `DELETE /api/apps/:id` - Remove deployed app
|
||||
|
||||
### Error Logs
|
||||
- `GET /api/error-logs` - Get error logs
|
||||
- `DELETE /api/error-logs` - Clear error logs
|
||||
|
||||
### DNS Management
|
||||
- `POST /api/dns/record` - Create DNS record
|
||||
- `DELETE /api/dns/record` - Delete DNS record
|
||||
|
||||
### Caddy Management
|
||||
- `GET /api/caddy/config` - Get Caddyfile content
|
||||
- `POST /api/caddy/reload` - Reload Caddy configuration
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### SSL Certificate Errors
|
||||
|
||||
**Problem**: "Secure Connection Failed" when accessing new service
|
||||
|
||||
**Solution**:
|
||||
- Wait 30-60 seconds for certificate generation
|
||||
- Check dashboard notification for SSL status
|
||||
- Manually reload Caddy: `caddy reload --config /path/to/Caddyfile`
|
||||
- Check error logs in dashboard
|
||||
|
||||
### DNS Not Resolving
|
||||
|
||||
**Problem**: Service URL doesn't resolve
|
||||
|
||||
**Solution**:
|
||||
- Verify DNS server is running
|
||||
- Check DNS credentials in 🔑 Tokens menu
|
||||
- Manually add DNS record in Technitium DNS
|
||||
- Flush DNS cache: `ipconfig /flushdns` (Windows) or `sudo systemd-resolve --flush-caches` (Linux)
|
||||
|
||||
### Container Won't Start
|
||||
|
||||
**Problem**: Deployment succeeds but service is offline
|
||||
|
||||
**Solution**:
|
||||
- Check Docker logs: `docker logs [container-id]`
|
||||
- Verify port isn't already in use
|
||||
- Check container resource limits
|
||||
- View error logs in dashboard
|
||||
|
||||
### Import/Export Issues
|
||||
|
||||
**Problem**: Import fails or data is incomplete
|
||||
|
||||
**Solution**:
|
||||
- Validate JSON format
|
||||
- Check file has `version` and `services` fields
|
||||
- Reconfigure API tokens after import
|
||||
- Check error logs for details
|
||||
|
||||
## Development
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
dashcaddy/
|
||||
├── status/ # Dashboard frontend
|
||||
│ ├── index.html # Main dashboard
|
||||
│ └── assets/ # Logos, icons, fonts
|
||||
├── caddy-api/ # API backend
|
||||
│ ├── server.js # Express server
|
||||
│ ├── app-templates.js # App template definitions
|
||||
│ └── package.json # Dependencies
|
||||
├── dashcaddy-installer/ # Electron installer (WIP)
|
||||
└── docs/ # Documentation
|
||||
```
|
||||
|
||||
### Adding Custom App Templates
|
||||
|
||||
Edit `caddy-api/app-templates.js`:
|
||||
|
||||
```javascript
|
||||
"myapp": {
|
||||
name: "My App",
|
||||
description: "Description of my app",
|
||||
icon: "🚀",
|
||||
logo: "https://cdn.example.com/logo.png",
|
||||
category: "Productivity",
|
||||
docker: {
|
||||
image: "myapp/myapp:latest",
|
||||
ports: ["{{PORT}}:8080"],
|
||||
volumes: ["/opt/myapp:/data"],
|
||||
environment: {
|
||||
"APP_ENV": "production"
|
||||
}
|
||||
},
|
||||
subdomain: "myapp",
|
||||
defaultPort: 8080,
|
||||
healthCheck: "/health"
|
||||
}
|
||||
```
|
||||
|
||||
### Contributing
|
||||
|
||||
Contributions are welcome! Please:
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Test thoroughly
|
||||
5. Submit a pull request
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [ ] Service groups/categories
|
||||
- [ ] Container log viewer
|
||||
- [ ] DNS management UI
|
||||
- [ ] Backup automation
|
||||
- [ ] Multi-user support
|
||||
- [ ] Mobile app
|
||||
- [ ] Analytics dashboard
|
||||
- [ ] Template marketplace
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see LICENSE file for details
|
||||
|
||||
## Credits
|
||||
|
||||
- **Dashboard Icons**: [walkxcode/dashboard-icons](https://github.com/walkxcode/dashboard-icons) (MIT License)
|
||||
- **Caddy**: [caddyserver.com](https://caddyserver.com/)
|
||||
- **Technitium DNS**: [technitium.com/dns](https://technitium.com/dns/)
|
||||
|
||||
## Support
|
||||
|
||||
- **Issues**: [GitHub Issues](https://github.com/yourusername/dashcaddy/issues)
|
||||
- **Discussions**: [GitHub Discussions](https://github.com/yourusername/dashcaddy/discussions)
|
||||
- **Documentation**: [Wiki](https://github.com/yourusername/dashcaddy/wiki)
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
Built with ❤️ for the self-hosting community.
|
||||
|
||||
---
|
||||
|
||||
**DashCaddy** - Making self-hosting beautiful and effortless.
|
||||
@@ -1,514 +0,0 @@
|
||||
# DashCaddy — Code Simplification & Maintainability
|
||||
|
||||
## Goal
|
||||
|
||||
Keep all existing functionality while making the codebase:
|
||||
- **Easier to read** (fewer files, clearer structure)
|
||||
- **Easier to modify** (focused modules, fewer edge cases)
|
||||
- **Easier to debug** (deterministic flows, focused logging)
|
||||
- **Easier to test** (focused unit tests, reliable mocks)
|
||||
|
||||
---
|
||||
|
||||
## 1. Monolithic → Modular Consolidation
|
||||
|
||||
### What was fragmented
|
||||
- **Configuration** spread across `services.json`, `config.json`, `dns-credentials.json`, `credentials.json.enc`
|
||||
- **API surface** split across multiple `routes/*` modules without a clear hierarchy
|
||||
- **Build** custom `esbuild` + `package.json` shenanigans
|
||||
- **Security** scattered across `middleware.js`, `input-validator.js`, `csrf-protection.js`
|
||||
|
||||
### Consolidation strategy
|
||||
|
||||
#### A. Single Config (`config.yaml`)
|
||||
|
||||
```yaml
|
||||
# Replace all JSON configs with this single source of truth
|
||||
# Loaded once at startup, with env overrides
|
||||
|
||||
# Services (previously services.json)
|
||||
services:
|
||||
- id: plex
|
||||
type: "media-server"
|
||||
port: 32400
|
||||
host: "192.168.1.50"
|
||||
auth:
|
||||
enabled: true
|
||||
username: "admin"
|
||||
password_encrypted: "..."
|
||||
|
||||
# Core config (previously config.json)
|
||||
core:
|
||||
domain: "example.com"
|
||||
timezone: "America/Los_Angeles"
|
||||
log_path: "/opt/dashcaddy/data/logs"
|
||||
backup_retention: 30
|
||||
|
||||
# DNS config (previously dns-credentials.json)
|
||||
dns:
|
||||
provider: "coredns"
|
||||
# provider-specific config
|
||||
servers: ["10.0.0.1", "10.0.1.1"]
|
||||
|
||||
# Encryption key (previously credentials.json.enc)
|
||||
encryption_key_encrypted: "..."
|
||||
```
|
||||
|
||||
#### B. Unified API Router
|
||||
|
||||
**Previous pattern:**
|
||||
- `routes/health.js`, `routes/auth.js`, `routes/dns.js`, `routes/services.js`
|
||||
- Each exports its own middleware chain, scattered imports
|
||||
|
||||
**New pattern:**
|
||||
- **Single `routes/index.js`** — entry point that declares routes once, with schema validation
|
||||
- **Per-feature submodules** under `routes/core/`, `routes/admin/`, `routes/integrations/` (but importable directly)
|
||||
- **Centralized rate limiting, validation, auth** middleware stack
|
||||
|
||||
```javascript
|
||||
// routes/index.js (single file, but organized with requires)
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
// Core system routes
|
||||
router.use('/health', require('./core/health'));
|
||||
router.use('/api/v1', require('./core/api'));
|
||||
|
||||
// Admin routes
|
||||
router.use('/api/v1/admin', require('./admin/users'));
|
||||
router.use('/api/v1/admin/services', require('./admin/services'));
|
||||
|
||||
// Service integrations
|
||||
router.use('/api/v1/integrations/plex', require('./integrations/plex'));
|
||||
|
||||
module.exports = router;
|
||||
```
|
||||
|
||||
#### C. Consolidated Security Middleware
|
||||
|
||||
**Previous:**
|
||||
- `middleware.js` (generic)
|
||||
- `input-validator.js` (Joi)
|
||||
- `csrf-protection.js` (express-csrf)
|
||||
- `auth-manager.js` (session + TOTP)
|
||||
|
||||
**Unified:**
|
||||
- **`security.js`** — exports `authenticate`, `validate`, `csrfProtect`, `rateLimit` etc.
|
||||
- **Single initialization** in `server.js`
|
||||
- **Clear order**: CORS → Helmet → CSRF → Auth → Rate Limit → Validation
|
||||
|
||||
#### D. Simplified Build
|
||||
|
||||
**Previous:**
|
||||
- `status/build.js` with complex esbuild config
|
||||
- Separate build for `frontend`, `backend`
|
||||
- Hard to run locally
|
||||
|
||||
**Unified:**
|
||||
- **`scripts/build.js`** — runnable from repo root
|
||||
- **Vite frontend** (optional) OR **esbuild** (default)
|
||||
- **Docker-first**: Build inside container, serve via Caddy
|
||||
|
||||
---
|
||||
|
||||
## 2. Layered Architecture (Presentation → Core → Infrastructure)
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────────┐
|
||||
│ Presentation │
|
||||
│ (status/ folder) │
|
||||
│ ├─ index.html ← Static HTML template │
|
||||
│ ├─ dist/ ← Bundled JavaScript │
|
||||
│ ├─ assets/ ← Images, CSS, static assets │
|
||||
│ └─ sw.js ← Service worker │
|
||||
├───────────────────────────────────────────────────────────────┤
|
||||
│ Business Logic │
|
||||
│ (dashcaddy-api/src/) │
|
||||
│ ├─ app/ ← Express app factory │
|
||||
│ ├─ services/ ← Service CRUD, discovery, auth │
|
||||
│ ├─ security/ ← Unified auth + validation │
|
||||
│ ├─ dns/ ← DNS provider abstraction │
|
||||
│ ├─ backups/ ← Backup/restore operations │
|
||||
│ └─ license/ ← License management │
|
||||
├───────────────────────────────────────────────────────────────┤
|
||||
│ Infrastructure │
|
||||
│ (node_modules, external) │
|
||||
│ ├─ dockerode ← Docker operations │
|
||||
│ ├─ ssh2-sftp-client ← File transfers │
|
||||
│ ├─ webdav ← WebDAV integration │
|
||||
│ └─ tls-certificate ← Let's Encrypt automation │
|
||||
└───────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Benefits
|
||||
|
||||
| Aspect | Before | After |
|
||||
|--------|--------|-------|
|
||||
| **Finding a route** | `grep -r "app.get" routes/` | `grep -r "router.use" routes/index.js` |
|
||||
| **Adding a new service type** | Add `routes/service-type.js`, wire in `server.js` | Add to `src/services/` → auto-discovery via `services/discovery.js` |
|
||||
| **Security patch** | Edit multiple files | Edit single `security.js` |
|
||||
| **Running tests** | `npm run test:unit && npm run test:routes && npm run test:security` | `npm test` (single entry point) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Deterministic File Layout
|
||||
|
||||
### Problem
|
||||
Paths varied across platforms, making CI/CD and local dev confusing.
|
||||
|
||||
### Solution
|
||||
**Zero-config, platform-agnostic layout:**
|
||||
|
||||
```
|
||||
repo/
|
||||
├─ README.md ← Always present (quick install)
|
||||
├─ INSTALL.md ← Detailed setup (platform-specific)
|
||||
├─ .env.example ← Env variable documentation
|
||||
├─ docker-compose.yml ← Single-compose, multi-profile
|
||||
├─ dashcaddy-api/ ← API source (Node.js)
|
||||
├─ status/ ← Dashboard frontend source
|
||||
├─ dashcaddy-installer/ ← Cross-platform installers
|
||||
├─ scripts/ ← Helper scripts (daily-update, adversarial-find-errors, etc.)
|
||||
├─ skills/ ← Hermes skills (orchestration)
|
||||
└─ docs/ ← Architecture, API, CONTRIBUTING
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- **No nested repo root changes** (no `src/` inside `dashcaddy-api/`, no `lib/` inside `status/`)
|
||||
- **`data/` lives outside the repo** (`/opt/dashcaddy/data` on Linux, `E:/dockerdata/dashcaddy` on Windows)
|
||||
- **Static assets** (`status/dist/`, `status/assets/`) are built and deployed, not source
|
||||
- **`platform-paths.js`** resolves everything at runtime — no hardcoded platform checks in application code
|
||||
|
||||
---
|
||||
|
||||
## 4. Simplified Testing Strategy
|
||||
|
||||
### Test Pyramid
|
||||
|
||||
1. **Unit Tests** (`__tests__/core/*.test.js`)
|
||||
- Test individual functions (no external calls)
|
||||
- Mock `fs`, `dockerode`, external HTTP
|
||||
|
||||
2. **Integration Tests** (`__tests__/routes/`, `__tests__/admin/`)
|
||||
- Test route chains end-to-end with mocked external deps
|
||||
- Fast, deterministic, no real Docker/containers
|
||||
|
||||
3. **Adversarial Tests** (`adversarial-find-errors.py`)
|
||||
- Live contract checks against running instance
|
||||
- Same test as CI/CD, runs locally via `npm run adversarial`
|
||||
|
||||
4. **E2E/Contract Tests** (`__tests__/integration/`, `docker-compose -f docker-compose.test.yml`)
|
||||
- Real Docker container stack (for UI flows, real DNS, etc.)
|
||||
|
||||
### Simplified Test Runner
|
||||
|
||||
**Previous:**
|
||||
```bash
|
||||
# Complex
|
||||
npm run test:ci
|
||||
# or
|
||||
npm run test:unit && npm run test:routes && npm run test:security
|
||||
```
|
||||
|
||||
**Unified:**
|
||||
```javascript
|
||||
// package.json scripts
|
||||
"scripts": {
|
||||
"test": "jest",
|
||||
"test:ci": "jest --ci --coverage --maxWorkers=2",
|
||||
"test:integration": "jest --testPathPattern=__tests__/integration",
|
||||
"adversarial": "python3 scripts/adversarial-find-errors.py"
|
||||
}
|
||||
```
|
||||
|
||||
**Single command for CI:** `npm run test:ci`
|
||||
|
||||
---
|
||||
|
||||
## 5. Simplified Logging & Monitoring
|
||||
|
||||
### Problem
|
||||
Multiple log files, unclear severity levels, no structured output.
|
||||
|
||||
### Solution
|
||||
**Unified logging system:**
|
||||
|
||||
1. **`src/logging/`** — single module
|
||||
- Levels: `INFO`, `WARN`, `ERROR`, `DEBUG`
|
||||
- Structured output: `{ timestamp, level, area, message, context }`
|
||||
- Console + file (JSON lines) + optional syslog
|
||||
|
||||
2. **Consistent area names:**
|
||||
- `auth`, `dns`, `services`, `security`, `backups`, `license`, `integrations/plex`
|
||||
|
||||
3. **Single audit-log:**
|
||||
- All state changes go to `/opt/dashcaddy/data/audit-log.jsonl`
|
||||
- One-liner entry: `{ "ts": "2026-08-21T02:40:16Z", "area": "services", "event": "create", "payload": {"id": "plex"} }`
|
||||
|
||||
### Example logging call
|
||||
|
||||
```javascript
|
||||
// In src/services/index.js
|
||||
const logger = require('../logging');
|
||||
|
||||
logger.log('INFO', 'services', 'Service created', { id: serviceId, type: 'plex' });
|
||||
logger.error('DNS', 'Failed to provision DNS record', { record: 'plex.example.com', error: err.message });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Simplified Deployment Pipeline
|
||||
|
||||
### Before: Complex Docker orchestration
|
||||
```bash
|
||||
# Build
|
||||
./dashcaddy-installer/install.sh
|
||||
# Deploy
|
||||
ssh root@dns2 /opt/dashcaddy/start.sh
|
||||
# Update
|
||||
git checkout new-feature && ./dashcaddy-installer/install.sh
|
||||
```
|
||||
|
||||
### Unified: Docker Compose + Profiles
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml (single file)
|
||||
services:
|
||||
dashcaddy-api:
|
||||
build: .
|
||||
profiles: [prod, windows]
|
||||
volumes:
|
||||
- ./dashcaddy-api:/app/src
|
||||
- ./status:/app/dashboard
|
||||
- ./data:/opt/dashcaddy/data
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
depends_on:
|
||||
- caddy
|
||||
|
||||
caddy:
|
||||
image: caddy:2.10-alpine
|
||||
profiles: [prod]
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./caddy/Caddyfile:/etc/caddy/Caddyfile
|
||||
- ./caddy/data:/data
|
||||
```
|
||||
|
||||
**Profiles:**
|
||||
- `prod` — Production stack (Caddy + API + DNS)
|
||||
- `dev` — API only (local development)
|
||||
- `windows` — Windows container variant
|
||||
|
||||
**Commands:**
|
||||
```bash
|
||||
# Start production
|
||||
docker compose --profile prod up -d
|
||||
|
||||
# Local dev (no Caddy, no DNS)
|
||||
docker compose --profile dev up -d
|
||||
|
||||
# Windows native (if using Windows containers)
|
||||
docker compose --profile windows up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Simplified Installer Scripts
|
||||
|
||||
### Unified `install.sh` / `install.ps1`
|
||||
|
||||
**Single command installs:**
|
||||
- Docker (if not present)
|
||||
- Caddy (via package manager)
|
||||
- DashCaddy repo (auto-pull latest)
|
||||
- Environment variables (`.env`)
|
||||
- Optional Tailscale setup
|
||||
- Start services
|
||||
|
||||
**No manual steps needed:**
|
||||
- No `apt install`, `systemctl enable`, etc.
|
||||
- All platform detection inside script
|
||||
- Rollback on failure
|
||||
|
||||
### Example usage
|
||||
|
||||
```bash
|
||||
# Linux/macOS/WSL
|
||||
curl -fsSL https://dashcaddy.net/install.sh | bash
|
||||
|
||||
# Windows
|
||||
irm https://dashcaddy.net/install.ps1 | iex
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Simplified Documentation
|
||||
|
||||
### Docs structure
|
||||
|
||||
```
|
||||
/docs/
|
||||
├─ ARCHITECTURE.md # System overview, layering, platform paths
|
||||
├─ CONTRIBUTING.md # Code style, testing, PR process
|
||||
├─ API-REFERENCE.md # All API endpoints, parameters, responses
|
||||
├─ DNS_PROVIDERS.md # How to add new DNS provider
|
||||
├─ SECURITY.md # Threat model, best practices
|
||||
└─ TROUBLESHOOTING.md # Common issues + solutions
|
||||
```
|
||||
|
||||
**Single source of truth** — CLI docs, README, and web docs generated from these.
|
||||
|
||||
---
|
||||
|
||||
## 9. Simplified CI/CD Pipeline
|
||||
|
||||
### One CI job for all platforms
|
||||
|
||||
```yaml
|
||||
# .github/workflows/ci.yml
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: '20' }
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
- run: npm run test:ci
|
||||
|
||||
build:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/build-push-action@v5
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64,windows/amd64
|
||||
push: ${{ github.event_name == 'push' }}
|
||||
tags: dashcaddy/dashcaddy-api:${{ github.sha }}
|
||||
|
||||
windows:
|
||||
needs: test
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/build-push-action@v5
|
||||
with:
|
||||
platforms: windows/amd64
|
||||
push: ${{ github.event_name == 'push' }}
|
||||
tags: dashcaddy/dashcaddy-api:${{ github.sha }}-windows
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Deterministic builds across platforms
|
||||
- Same test suite runs everywhere
|
||||
- Single PR triggers all platform builds
|
||||
|
||||
---
|
||||
|
||||
## 10. Simplified Upgrade Path
|
||||
|
||||
### Versioning policy
|
||||
- **Semantic Versioning** (MAJOR.MINOR.PATCH)
|
||||
- **One minor version** = new feature, no breaking changes
|
||||
- **Patch** = bug fixes only
|
||||
- **Major** = breaking changes (rare, documented 6 months ahead)
|
||||
|
||||
### Upgrade commands
|
||||
|
||||
```bash
|
||||
# Upgrade to latest stable
|
||||
curl -fsSL https://dashcaddy.net/install.sh | bash
|
||||
|
||||
# Or via existing Docker compose
|
||||
docker compose pull && docker compose --profile prod up -d
|
||||
```
|
||||
|
||||
### Migration guides
|
||||
- Each major version includes a `/docs/MIGRATION-vX.Y.md`
|
||||
- Auto-generated release notes
|
||||
|
||||
---
|
||||
|
||||
## 11. Simplified Monitoring & Health Checks
|
||||
|
||||
### Health check endpoints
|
||||
|
||||
```bash
|
||||
# System health
|
||||
curl http://localhost:3001/api/v1/health
|
||||
# Dashboard health
|
||||
curl http://localhost:3001/api/v1/health/dashboard
|
||||
# DNS health
|
||||
curl http://localhost:3001/api/v1/health/dns
|
||||
```
|
||||
|
||||
### Unified status reporting
|
||||
- Every 5 minutes: `cron/sweep.sh` collects logs, generates `/tmp/dashcaddy-errors/adversarial-report.md`
|
||||
- Daily: `cron/dc-daily-update.py` posts summary to Telegram topic
|
||||
- Alerts: Slack/Email webhook if errors > threshold
|
||||
|
||||
### Structured metrics
|
||||
- All metrics go to `data/metrics.jsonl` (one JSON object per line)
|
||||
- Prometheus exporter (optional) for integration with monitoring stack
|
||||
|
||||
---
|
||||
|
||||
## 12. Simplified Training & Onboarding
|
||||
|
||||
### README-first approach
|
||||
- `README.md` includes **one-line install** + **basic usage**
|
||||
- Clickable links to `INSTALL.md` (platform-specific) + `ARCHITECTURE.md`
|
||||
|
||||
### Code comments
|
||||
- **Clear purpose**: `/** * Describe what this function does * */`
|
||||
- **Usage examples**: `// Example: router.get('/', homeHandler)`
|
||||
- **Side effects**: Document async operations, external calls
|
||||
|
||||
### Pull request template
|
||||
- **Required checklist:**
|
||||
- [ ] Tests pass (`npm run test:ci`)
|
||||
- [ ] Lint clean (`npm run lint`)
|
||||
- [ ] No new files outside allowed directories
|
||||
- [ ] Updated `CHANGELOG.md` with concise description
|
||||
- [ ] Added `docs/` if new feature/feature change
|
||||
|
||||
---
|
||||
|
||||
## Summary of Simplification
|
||||
|
||||
| Area | Before | After |
|
||||
|------|--------|-------|
|
||||
| **Config** | 3+ JSON files scattered | 1 `config.yaml` with env overrides |
|
||||
| **API routes** | 20+ files, scattered imports | 1 `routes/index.js`, organized submodules |
|
||||
| **Security** | 4+ middleware files | 1 `security.js` with clear order |
|
||||
| **Build** | Custom esbuild + manual steps | Single `scripts/build.js` |
|
||||
| **Testing** | 3+ npm scripts, different scopes | 1 `npm test` + optional `adversarial` |
|
||||
| **Logging** | Mixed console.log, error.log | Structured JSON lines in `audit-log.jsonl` |
|
||||
| **Deployment** | Manual docker + custom scripts | Docker Compose + Profiles |
|
||||
| **Installer** | Separate scripts per platform | Unified `install.sh`/`install.ps1` |
|
||||
| **Docs** | Wikipedia-sized README | Split into focused markdown files |
|
||||
| **CI/CD** | Platform-specific pipelines | Single matrix build with multi-arch |
|
||||
|
||||
**Result:** Much easier to understand, modify, and extend while preserving 100% of existing functionality.
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Run the simplified tests**: `npm run test:ci`
|
||||
2. **Review the new config**: Edit `config.yaml` and run `./scripts/validate-config.js`
|
||||
3. **Test the installer**: `curl -fsSL https://dashcaddy.net/install.sh | bash` (in VM)
|
||||
4. **Check the new logs**: `cat /opt/dashcaddy/data/audit-log.jsonl`
|
||||
5. **Upgrade existing deployment**: `docker compose --profile prod up -d`
|
||||
|
||||
All changes are **backward compatible** — no breaking changes, no data loss, no API changes.
|
||||
|
||||
---
|
||||
|
||||
*DashCaddy v2.0 — Simpler by design, stronger by execution.*
|
||||
@@ -1,167 +0,0 @@
|
||||
# DashCaddy Windows App — Build & Release Checklist
|
||||
|
||||
## What's Complete ✅
|
||||
|
||||
### Desktop App (WinUI 3 / .NET 8)
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `desktop/DashCaddy.Desktop.csproj` | Project file with MSIX packaging |
|
||||
| `desktop/App.xaml` / `App.xaml.cs` | App entry, service initialization |
|
||||
| `desktop/MainWindow.xaml` / `.cs` | Main UI with service list, toolbar, status bar |
|
||||
| `desktop/ViewModels/MainViewModel.cs` | Central state, service management |
|
||||
| `desktop/ViewModels/ServiceViewModel.cs` | Service model with health status |
|
||||
| `desktop/ViewModels/Converters.cs` | XAML converters (status→color, bool→visibility) |
|
||||
| `desktop/Models/ServiceModels.cs` | DTOs matching your Node.js API |
|
||||
| `desktop/Services/DockerService.cs` | Docker.DotNet wrapper |
|
||||
| `desktop/Services/ApiClient.cs` | HTTP client for your Node API |
|
||||
| `desktop/Services/CaddyConfigGenerator.cs` | Caddyfile generation |
|
||||
| `desktop/Services/DnsClient.cs` | DNS API client |
|
||||
| `desktop/Services/TemplateRegistry.cs` | 9 built-in templates (Plex, HA, Jellyfin, etc.) |
|
||||
| `desktop/Services/ComposeParser.cs` | Docker Compose import |
|
||||
| `desktop/AddServiceDialog.xaml` / `.cs` | 3-mode add service (template/compose/custom) |
|
||||
| `desktop/TemplatesDialog.xaml` / `.cs` | Template browser |
|
||||
| `desktop/SettingsDialog.xaml` / `.cs` | Domain, Docker, DNS settings |
|
||||
| `desktop/Styles/Colors.xaml` / `Controls.xaml` | Fluent design styles |
|
||||
|
||||
### Installer (NSIS + PowerShell)
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `installer/windows/dashcaddy.nsi` | NSIS installer script (per-user, no admin) |
|
||||
| `installer/windows/bootstrap.ps1` | Post-install: Docker, WSL2, compose, services |
|
||||
| `installer/windows/build.ps1` | Build script: .NET publish → NSIS package |
|
||||
|
||||
---
|
||||
|
||||
## To Build the Installer
|
||||
|
||||
### Prerequisites (on Windows build machine)
|
||||
```powershell
|
||||
# 1. Visual Studio 2022 with "Windows App SDK" workload
|
||||
# 2. .NET 8 SDK
|
||||
# 3. NSIS 3.08+ (makensis.exe)
|
||||
# 4. Code signing cert (optional but recommended)
|
||||
```
|
||||
|
||||
### One-Command Build
|
||||
```powershell
|
||||
cd dashcaddy/installer/windows
|
||||
.\build.ps1 -Version 1.15.0
|
||||
```
|
||||
|
||||
**Output:** `artifacts/DashCaddy-Setup-1.15.0.exe` (~150-200 MB)
|
||||
|
||||
---
|
||||
|
||||
## What the Installer Does (User Experience)
|
||||
|
||||
```
|
||||
User double-clicks DashCaddy-Setup-1.15.0.exe
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ 1. Welcome → License → Choose Folder (%LOCALAPPDATA%) │
|
||||
│ 2. Components: App, Docker Desktop, WSL2, Auto-start │
|
||||
│ 3. Install: │
|
||||
│ • Extract WinUI 3 app (~50 MB) │
|
||||
│ • Install Docker Desktop (via winget, silent) │
|
||||
│ • Enable WSL2 + Ubuntu (reboot if needed) │
|
||||
│ • Pull 3 Docker images (dashcaddy-api, caddy, coredns) │
|
||||
│ • Start all services via docker compose │
|
||||
│ • Register auto-start on login │
|
||||
│ 4. Finish → Launches DashCaddy.app │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ DashCaddy Window Opens: │
|
||||
│ • Green/Yellow/Red status badges (12/12 running) │
|
||||
│ • Service list with toggle switches │
|
||||
│ • "+ Add Service" → Templates (Plex, HA, Jellyfin...) │
|
||||
│ • "Import Compose" → Drag .yaml file │
|
||||
│ • "Open Dashboard" → Browser to https://status.local │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration with Your Existing Stack
|
||||
|
||||
| Your Existing Component | How Desktop App Uses It |
|
||||
|------------------------|------------------------|
|
||||
| `dashcaddy-api` (Node.js in Docker) | `ApiClient.cs` calls `/api/v1/services`, `/api/v1/health` |
|
||||
| `platform-paths.js` paths | `bootstrap.ps1` creates same paths on Windows (`E:/dockerdata/...`) |
|
||||
| Caddy reverse proxy | `CaddyConfigGenerator.cs` regenerates Caddyfile from service list |
|
||||
| CoreDNS | `Create-Corefile` in bootstrap |
|
||||
| DC-086 hysteresis | `ApiClient.GetHealthAsync()` returns same health data |
|
||||
| Templates (DC-083/084) | `TemplateRegistry.cs` has 9 templates matching your compose files |
|
||||
|
||||
---
|
||||
|
||||
## Remaining Tasks to Ship
|
||||
|
||||
| Task | Effort | Notes |
|
||||
|------|--------|-------|
|
||||
| **Build on Windows machine** | 30 min | Run `build.ps1` on Windows with VS2022 |
|
||||
| **Code sign installer** | 15 min | `signtool sign /fd sha256 /tr http://timestamp.digicert.com DashCaddy-Setup-1.15.0.exe` |
|
||||
| **Test on clean VM** | 1 hr | Fresh Windows 10/11, verify Docker+WSL install flow |
|
||||
| **Host installer** | 15 min | Upload to `https://dashcaddy.net/downloads/DashCaddy-Setup-1.15.0.exe` |
|
||||
| **Auto-update via MSIX** | 1 hr | Configure `AppInstallerUri` in csproj, host `.appinstaller` file |
|
||||
| **Submit to Winget** | 30 min | PR to `microsoft/winget-pkgs` with manifest |
|
||||
| **Submit to Chocolatey** | 30 min | `choco pack` + push to community repo |
|
||||
|
||||
---
|
||||
|
||||
## Architecture Summary
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ DashCaddy for Windows │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ 📦 DashCaddy-Setup-1.15.0.exe (NSIS, ~180 MB) │
|
||||
│ └─ Per-user install to %LOCALAPPDATA%\DashCaddy\ │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ 🖥 DashCaddy.exe (WinUI 3, single-file, self-contained) │
|
||||
│ ├─ MainWindow: Service dashboard with health badges │
|
||||
│ ├─ Add Service: Template / Compose / Custom │
|
||||
│ ├─ Settings: Domain, DNS, Docker paths │
|
||||
│ └─ Talks to: http://localhost:3001/api (your Node API) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ 🐳 Docker Desktop (auto-installed via winget) │
|
||||
│ ├─ dashcaddy-api:3001 ← Your existing Node.js API │
|
||||
│ ├─ caddy:80/443 ← Reverse proxy + TLS │
|
||||
│ └─ coredns:53 ← Local DNS for *.local │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ 📁 Data in %LOCALAPPDATA%\DashCaddy\ │
|
||||
│ ├─ data\caddy\Caddyfile ← Auto-generated │
|
||||
│ ├─ data\coredns\Corefile ← Local DNS │
|
||||
│ ├─ config.yaml ← User settings │
|
||||
│ └─ logs\ ← App + bootstrap logs │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
| Decision | Rationale |
|
||||
|----------|-----------|
|
||||
| **Per-user install (%LOCALAPPDATA%)** | No UAC prompt, works on locked-down corporate machines |
|
||||
| **WinUI 3 + MSIX** | Native Windows 10/11 look, auto-updates, clean uninstall |
|
||||
| **Docker Desktop via winget** | Standard Windows way, handles WSL2, auto-updates |
|
||||
| **bootstrap.ps1 does heavy lifting** | Keeps NSIS simple, PowerShell has better Docker/WSL APIs |
|
||||
| **Talks to your existing Node API** | Zero backend changes — reuses all your DC-085/086 work |
|
||||
| **9 built-in templates** | Covers 80% of self-hosting use cases out of the box |
|
||||
| **Import Docker Compose** | Power users can bring any stack |
|
||||
|
||||
---
|
||||
|
||||
## Next Step
|
||||
|
||||
**Run the build on a Windows machine:**
|
||||
```powershell
|
||||
git clone https://git.dashcaddy.net/sami7777/dashcaddy.git
|
||||
cd dashcaddy/installer/windows
|
||||
.\build.ps1 -Version 1.15.0
|
||||
```
|
||||
|
||||
Then test the installer on a clean Windows VM. That's it — you'll have a professional Windows app that makes self-hosting as easy as installing any other Windows program.
|
||||
@@ -0,0 +1,281 @@
|
||||
# DashCA - Certificate Authority Distribution
|
||||
|
||||
A self-hosted landing page for distributing your root CA certificate with one-click installation across all major platforms.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Regenerate All Certificate Formats
|
||||
|
||||
```bash
|
||||
cd scripts
|
||||
bash generate-all.sh
|
||||
```
|
||||
|
||||
This will:
|
||||
1. Copy root.crt and intermediate.crt from Caddy PKI
|
||||
2. Generate root.der (DER format for Windows)
|
||||
3. Generate root.mobileconfig (Apple profile for iOS/macOS)
|
||||
4. Extract certificate metadata to cert-info.json
|
||||
|
||||
### Deploy to Production
|
||||
|
||||
```bash
|
||||
# Copy all files to production directory
|
||||
cp -r e:/CaddyCerts/sites/ca/* C:/caddy/sites/ca/
|
||||
```
|
||||
|
||||
Or deploy via the dashboard app selector (preferred method).
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
ca/
|
||||
├── index.html # Landing page with OS detection
|
||||
├── root.crt # Root CA certificate (PEM format)
|
||||
├── root.der # Root CA certificate (DER format)
|
||||
├── root.mobileconfig # Apple configuration profile
|
||||
├── intermediate.crt # Intermediate CA certificate
|
||||
├── cert-info.json # Certificate metadata (auto-generated)
|
||||
├── scripts/
|
||||
│ ├── install.ps1 # Windows PowerShell installer
|
||||
│ ├── install.sh # Linux/macOS shell installer
|
||||
│ ├── generate-cert-info.js # Extract certificate metadata
|
||||
│ ├── generate-mobileconfig.js # Generate Apple profile
|
||||
│ └── generate-all.sh # Wrapper script to regenerate all
|
||||
└── assets/
|
||||
└── (icons, logos, etc.)
|
||||
```
|
||||
|
||||
## Certificate Information
|
||||
|
||||
**Source:** Caddy's built-in PKI at `C:/caddy/certs/pki/authorities/local/`
|
||||
|
||||
- **Name:** Sami Home Network Root CA
|
||||
- **Algorithm:** ECDSA P-256 with SHA-256
|
||||
- **Valid Until:** Dec 22, 2034
|
||||
- **Fingerprint:** `08:98:A5:63:F5:A1:A2:58:5F:02:D7:A8:A2:54:87:E6:BC:33:96:21:29:0E`
|
||||
|
||||
## Installation Scripts
|
||||
|
||||
### Windows (install.ps1)
|
||||
|
||||
Features:
|
||||
- Requires Administrator privileges
|
||||
- Downloads certificate from ca.sami
|
||||
- Verifies SHA-256 fingerprint
|
||||
- Installs to LocalMachine\Root store
|
||||
- Checks for existing installation
|
||||
|
||||
**One-liner:**
|
||||
```powershell
|
||||
irm https://ca.sami/install.ps1 | iex
|
||||
```
|
||||
|
||||
### Linux/macOS (install.sh)
|
||||
|
||||
Features:
|
||||
- Requires sudo/root
|
||||
- Auto-detects OS (Debian, RedHat, Arch, macOS)
|
||||
- Platform-specific installation commands
|
||||
- Fingerprint verification with OpenSSL
|
||||
- Checks for existing installation
|
||||
|
||||
**One-liner:**
|
||||
```bash
|
||||
curl -fsSL https://ca.sami/install.sh | sudo bash
|
||||
```
|
||||
|
||||
### Apple Devices (root.mobileconfig)
|
||||
|
||||
Features:
|
||||
- Works on both iOS and macOS
|
||||
- XML configuration profile format
|
||||
- Contains base64-encoded certificate
|
||||
- Unique UUIDs per generation
|
||||
- User must manually trust after installation (iOS)
|
||||
|
||||
**Installation:**
|
||||
1. Download root.mobileconfig
|
||||
2. iOS: Settings prompts automatically
|
||||
3. macOS: System Settings → Profiles → Install
|
||||
4. iOS: Enable trust in Certificate Trust Settings
|
||||
|
||||
## Landing Page Features
|
||||
|
||||
The landing page (`index.html`) includes:
|
||||
|
||||
- **OS Detection:** Automatically detects Windows, macOS, Linux, iOS, Android
|
||||
- **Certificate Info Display:** Shows name, fingerprint, expiration, algorithm
|
||||
- **QR Code:** For easy mobile access (powered by qrcodejs library)
|
||||
- **Download Links:** All certificate formats and installation scripts
|
||||
- **Platform Tabs:** Detailed instructions for each operating system
|
||||
- **Copy-to-Clipboard:** For fingerprint and command-line scripts
|
||||
- **DashCaddy Theme:** Dark mode with Sami Grotesk font
|
||||
|
||||
**API Integration:**
|
||||
- Loads certificate info from `/api/ca/info` endpoint
|
||||
- Falls back to static info if API unavailable
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. **Edit Files:** Make changes in `e:/CaddyCerts/sites/ca/`
|
||||
2. **Test Locally:** Open `index.html` in browser (file:// protocol works)
|
||||
3. **Regenerate Certificates:** Run `scripts/generate-all.sh` if CA renewed
|
||||
4. **Deploy:** Copy to production or use dashboard deployment
|
||||
5. **Verify:** Visit https://ca.sami and test on target platforms
|
||||
|
||||
## Updating After CA Renewal
|
||||
|
||||
When Caddy regenerates its CA certificate (every ~10 years):
|
||||
|
||||
### 1. Regenerate Certificate Formats
|
||||
|
||||
```bash
|
||||
cd e:/CaddyCerts/sites/ca/scripts
|
||||
bash generate-all.sh
|
||||
```
|
||||
|
||||
### 2. Update Fingerprints in Scripts
|
||||
|
||||
The new fingerprint will be in `cert-info.json`. Update these files:
|
||||
|
||||
**install.ps1** (line 17):
|
||||
```powershell
|
||||
$ExpectedFingerprint = "NEW:FIN:GER:PRINT:HERE"
|
||||
```
|
||||
|
||||
**install.sh** (line 13):
|
||||
```bash
|
||||
EXPECTED_FP="NEW:FIN:GER:PRINT:HERE"
|
||||
```
|
||||
|
||||
### 3. Deploy to Production
|
||||
|
||||
```bash
|
||||
cp -r e:/CaddyCerts/sites/ca/* C:/caddy/sites/ca/
|
||||
```
|
||||
|
||||
### 4. Notify Users
|
||||
|
||||
- Add banner to dashboard
|
||||
- Send notification via configured channels
|
||||
- Update documentation with new expiration date
|
||||
|
||||
## API Endpoints
|
||||
|
||||
DashCA integrates with DashCaddy API:
|
||||
|
||||
### GET /api/ca/info
|
||||
|
||||
Returns certificate metadata:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"certificate": {
|
||||
"name": "Sami Home Network Root CA",
|
||||
"fingerprint": "08:98:A5:...",
|
||||
"validFrom": "Feb 12 07:44:51 2025 GMT",
|
||||
"validUntil": "Dec 22 07:44:51 2034 GMT",
|
||||
"daysUntilExpiration": 3235,
|
||||
"algorithm": "ECDSA P-256 with SHA-256",
|
||||
"serialNumber": "c1:dc:48:...",
|
||||
"downloadUrl": "https://ca.sami/root.crt"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/health/ca
|
||||
|
||||
Returns CA expiration health status:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"message": "CA certificate valid for 3235 days",
|
||||
"daysUntilExpiration": 3235,
|
||||
"expiresAt": "Dec 22 07:44:51 2034 GMT"
|
||||
}
|
||||
```
|
||||
|
||||
**Status values:**
|
||||
- `healthy`: >90 days remaining
|
||||
- `warning`: 30-90 days
|
||||
- `critical`: <30 days or expired
|
||||
- `error`: Certificate not found or error reading
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Certificate Not Found Error
|
||||
|
||||
**Symptom:** Scripts fail with "certificate not found"
|
||||
**Cause:** Caddy hasn't generated the local CA yet
|
||||
**Solution:** Visit any *.sami domain to trigger CA generation
|
||||
|
||||
### Fingerprint Mismatch
|
||||
|
||||
**Symptom:** Install scripts reject certificate with fingerprint mismatch
|
||||
**Cause:** CA was renewed but scripts not updated
|
||||
**Solution:** Run `generate-all.sh` and update fingerprints in install scripts
|
||||
|
||||
### iOS Profile Won't Install
|
||||
|
||||
**Symptom:** .mobileconfig shows error when installing
|
||||
**Cause:** Invalid XML or missing UUIDs
|
||||
**Solution:** Regenerate with `node generate-mobileconfig.js`
|
||||
|
||||
### Android Shows "Not Trusted"
|
||||
|
||||
**Symptom:** Certificate installs but sites still show warnings
|
||||
**Cause:** Android installs as "user" certificate; some apps don't trust user CAs
|
||||
**Solution:** This is by design. System CA installation requires root access.
|
||||
|
||||
### Landing Page Shows "Loading..."
|
||||
|
||||
**Symptom:** Certificate info stuck on loading state
|
||||
**Cause:** API endpoint not accessible
|
||||
**Solution:** Check that dashcaddy-api server is running and `/api/ca/info` responds
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
Before deploying to production:
|
||||
|
||||
- [ ] All certificate formats generated successfully
|
||||
- [ ] Landing page loads correctly in browser
|
||||
- [ ] OS detection works (test multiple user agents)
|
||||
- [ ] QR code renders and scans correctly
|
||||
- [ ] Download links work for all file types
|
||||
- [ ] API endpoint returns valid certificate info
|
||||
- [ ] Copy-to-clipboard buttons work
|
||||
- [ ] Platform instruction tabs function correctly
|
||||
- [ ] Responsive design works on mobile viewport
|
||||
- [ ] HTTPS access works after deployment
|
||||
|
||||
## Security Notes
|
||||
|
||||
- **Private Key:** NEVER serve the CA private key (`root.key`). Only public certificates are safe to distribute.
|
||||
- **Fingerprint Verification:** Install scripts verify fingerprint to prevent MITM attacks
|
||||
- **Access Control:** ca.sami should only be accessible on your Tailnet/internal network
|
||||
- **HTTPS Enforcement:** The page itself uses HTTPS (via Caddy's internal CA) to protect the distribution
|
||||
- **No Auto-Execution:** All installation methods require explicit user action
|
||||
|
||||
## Contributing
|
||||
|
||||
When adding features to DashCA:
|
||||
|
||||
1. Test on multiple platforms before committing
|
||||
2. Update this README with new features
|
||||
3. Add relevant sections to troubleshooting guide
|
||||
4. Update CLAUDE.md if deployment process changes
|
||||
5. Ensure backward compatibility with existing certificates
|
||||
|
||||
## Resources
|
||||
|
||||
- **Caddy PKI Documentation:** https://caddyserver.com/docs/caddyfile/directives/tls#pki
|
||||
- **mobileconfig Format:** https://developer.apple.com/documentation/devicemanagement
|
||||
- **OpenSSL Certificate Commands:** https://www.openssl.org/docs/man1.1.1/man1/x509.html
|
||||
- **QR Code Library:** https://github.com/davidshimjs/qrcodejs
|
||||
|
||||
---
|
||||
|
||||
**Part of the DashCaddy project** - Unified management for Docker + Caddy + DNS
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "Sami Home Network Root CA",
|
||||
"fingerprint": "08:98:A5:63:F5:A1:A2:58:5F:02:D7:A8:A2:54:87:E6:BC:33:96:9F:9B:5D:B0:53:62:20:7F:AF:96:21:29:0E",
|
||||
"validFrom": "Feb 12 07:44:51 2025 GMT",
|
||||
"validUntil": "Dec 22 07:44:51 2034 GMT",
|
||||
"daysUntilExpiration": 3235,
|
||||
"algorithm": "ECDSA P-256 with SHA-256",
|
||||
"issuer": "Sami Home Network Root CA",
|
||||
"serialNumber": "C1DC482220B562C06853903A8956D052",
|
||||
"generatedAt": "2026-02-11T10:43:32.863Z"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBtTCCAVugAwIBAgIRAIyx9ujLhds2Wffi6rROHOYwCgYIKoZIzj0EAwIwJDEi
|
||||
MCAGA1UEAxMZU2FtaSBIb21lIE5ldHdvcmsgUm9vdCBDQTAeFw0yNjAyMTAxMTMx
|
||||
MjBaFw0yNjAyMTcxMTMxMjBaMCwxKjAoBgNVBAMTIVNhbWkgSG9tZSBOZXR3b3Jr
|
||||
IEludGVybWVkaWF0ZSBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABL3XMHS8
|
||||
bbGgHsGojPWIgDqHH65nxm/yvfrA/w5rXe1QNZ0oQfXdhUODuu1oTjdQiGSOxp5J
|
||||
N7+r73DIIjDoO1SjZjBkMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/
|
||||
AgEAMB0GA1UdDgQWBBRvN+rmvteWGd3Gj1ek/5lJWq5MXzAfBgNVHSMEGDAWgBQ1
|
||||
JUJhev790of0c/LsH+PAvsy4iTAKBggqhkjOPQQDAgNIADBFAiEAvWR3KVBGMsWp
|
||||
OEyqcRAmI5kDvfE/zC8bf3IZru5pGFsCIEvil49Fg2ifB8+w5c2T0wjllpsBOUUy
|
||||
HjpIXBIn9ix7
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,11 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBjTCCATKgAwIBAgIRAMHcSCIgtWLAaFOQOolW0FIwCgYIKoZIzj0EAwIwJDEi
|
||||
MCAGA1UEAxMZU2FtaSBIb21lIE5ldHdvcmsgUm9vdCBDQTAeFw0yNTAyMTIwNzQ0
|
||||
NTFaFw0zNDEyMjIwNzQ0NTFaMCQxIjAgBgNVBAMTGVNhbWkgSG9tZSBOZXR3b3Jr
|
||||
IFJvb3QgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATs8K5hvh7qC77kdFgk
|
||||
wyIu6SvzEtrK416lLkQkC+E79xIwGRKsZ7T/gd+0Bk0NMUZBxLww4F2Rl/kt3eGu
|
||||
49rSo0UwQzAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBATAdBgNV
|
||||
HQ4EFgQUNSVCYXr+/dKH9HPy7B/jwL7MuIkwCgYIKoZIzj0EAwIDSQAwRgIhAJE5
|
||||
d02KdZA6V79f4qNfmy3tJMmnL4MA2MHhDQ5qqZyqAiEA2UisGjAXYV3GAGo1d+8C
|
||||
yam9Y42t1K8Fx5q5iy+bs8w=
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PayloadContent</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>PayloadCertificateFileName</key>
|
||||
<string>root.crt</string>
|
||||
<key>PayloadContent</key>
|
||||
<data>
|
||||
MIIBjTCCATKgAwIBAgIRAMHcSCIgtWLAaFOQOolW0FIwCgYIKoZIzj0EAwIwJDEiMCAGA1UEAxMZU2FtaSBIb21lIE5ldHdvcmsgUm9vdCBDQTAeFw0yNTAyMTIwNzQ0NTFaFw0zNDEyMjIwNzQ0NTFaMCQxIjAgBgNVBAMTGVNhbWkgSG9tZSBOZXR3b3JrIFJvb3QgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATs8K5hvh7qC77kdFgkwyIu6SvzEtrK416lLkQkC+E79xIwGRKsZ7T/gd+0Bk0NMUZBxLww4F2Rl/kt3eGu49rSo0UwQzAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBATAdBgNVHQ4EFgQUNSVCYXr+/dKH9HPy7B/jwL7MuIkwCgYIKoZIzj0EAwIDSQAwRgIhAJE5d02KdZA6V79f4qNfmy3tJMmnL4MA2MHhDQ5qqZyqAiEA2UisGjAXYV3GAGo1d+8Cyam9Y42t1K8Fx5q5iy+bs8w=
|
||||
</data>
|
||||
<key>PayloadDescription</key>
|
||||
<string>Root CA certificate for Sami Home Network</string>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>Sami Home Network Root CA</string>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>com.sami-home.ca.root-ca</string>
|
||||
<key>PayloadType</key>
|
||||
<string>com.apple.security.root</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>059F6B88-E62A-4219-90D5-7FABBE83540A</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</array>
|
||||
<key>PayloadDescription</key>
|
||||
<string>Install the Sami Home Network Root CA to trust locally-issued certificates for *.sami domains.</string>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>Sami Home Network Root CA</string>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>com.sami-home.ca</string>
|
||||
<key>PayloadOrganization</key>
|
||||
<string>Sami Home Network</string>
|
||||
<key>PayloadRemovalDisallowed</key>
|
||||
<false/>
|
||||
<key>PayloadType</key>
|
||||
<string>Configuration</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>AF495D1C-16AF-44A7-8C6C-173CC8E82FC3</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,50 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# DashCA Certificate Generation Script
|
||||
# This script generates all required certificate formats
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CA_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
CADDY_CERT_DIR="C:/caddy/certs/pki/authorities/local"
|
||||
|
||||
echo "======================================"
|
||||
echo "DashCA Certificate Format Generator"
|
||||
echo "======================================"
|
||||
echo ""
|
||||
|
||||
# Step 1: Copy certificates from Caddy
|
||||
echo "[1/4] Copying certificates from Caddy PKI..."
|
||||
if [ ! -f "$CADDY_CERT_DIR/root.crt" ]; then
|
||||
echo "ERROR: Root certificate not found at $CADDY_CERT_DIR/root.crt"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cp "$CADDY_CERT_DIR/root.crt" "$CA_DIR/"
|
||||
cp "$CADDY_CERT_DIR/intermediate.crt" "$CA_DIR/" 2>/dev/null || echo " (Intermediate certificate not found, skipping)"
|
||||
echo " ✓ Certificates copied"
|
||||
|
||||
# Step 2: Generate DER format
|
||||
echo "[2/4] Generating DER format..."
|
||||
openssl x509 -in "$CA_DIR/root.crt" -outform DER -out "$CA_DIR/root.der"
|
||||
echo " ✓ DER format generated: root.der"
|
||||
|
||||
# Step 3: Generate certificate info JSON
|
||||
echo "[3/4] Extracting certificate metadata..."
|
||||
node "$SCRIPT_DIR/generate-cert-info.js"
|
||||
|
||||
# Step 4: Generate Apple mobileconfig
|
||||
echo "[4/4] Generating Apple mobile configuration profile..."
|
||||
node "$SCRIPT_DIR/generate-mobileconfig.js"
|
||||
|
||||
echo ""
|
||||
echo "======================================"
|
||||
echo "✓ All certificate formats generated!"
|
||||
echo "======================================"
|
||||
echo ""
|
||||
echo "Files created in: $CA_DIR"
|
||||
ls -lh "$CA_DIR"/*.{crt,der,mobileconfig,json} 2>/dev/null || echo "Files created successfully"
|
||||
echo ""
|
||||
echo "To deploy to production:"
|
||||
echo " cp -r $CA_DIR/* C:/caddy/sites/ca/"
|
||||
echo ""
|
||||
@@ -0,0 +1,75 @@
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const CERT_PATH = path.join(__dirname, '../root.crt');
|
||||
const OUTPUT_PATH = path.join(__dirname, '../cert-info.json');
|
||||
|
||||
function extractCertInfo() {
|
||||
try {
|
||||
console.log('Extracting certificate information from:', CERT_PATH);
|
||||
|
||||
// Extract SHA-256 fingerprint
|
||||
const fingerprint = execSync(`openssl x509 -in "${CERT_PATH}" -noout -fingerprint -sha256`)
|
||||
.toString()
|
||||
.trim()
|
||||
.split('=')[1];
|
||||
|
||||
// Extract validity dates
|
||||
const dates = execSync(`openssl x509 -in "${CERT_PATH}" -noout -dates`).toString();
|
||||
const notBefore = dates.match(/notBefore=(.*)/)[1].trim();
|
||||
const notAfter = dates.match(/notAfter=(.*)/)[1].trim();
|
||||
|
||||
// Extract subject
|
||||
const subject = execSync(`openssl x509 -in "${CERT_PATH}" -noout -subject`)
|
||||
.toString()
|
||||
.trim()
|
||||
.split('CN = ')[1] || execSync(`openssl x509 -in "${CERT_PATH}" -noout -subject`)
|
||||
.toString()
|
||||
.trim()
|
||||
.split('CN=')[1];
|
||||
|
||||
// Extract serial number
|
||||
const serialNumber = execSync(`openssl x509 -in "${CERT_PATH}" -noout -serial`)
|
||||
.toString()
|
||||
.trim()
|
||||
.split('=')[1];
|
||||
|
||||
// Calculate days until expiration
|
||||
const expirationDate = new Date(notAfter);
|
||||
const today = new Date();
|
||||
const daysUntilExpiration = Math.floor((expirationDate - today) / (1000 * 60 * 60 * 24));
|
||||
|
||||
const certInfo = {
|
||||
name: subject,
|
||||
fingerprint: fingerprint,
|
||||
validFrom: notBefore,
|
||||
validUntil: notAfter,
|
||||
daysUntilExpiration: daysUntilExpiration,
|
||||
algorithm: 'ECDSA P-256 with SHA-256',
|
||||
issuer: subject, // Self-signed root CA
|
||||
serialNumber: serialNumber,
|
||||
generatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
fs.writeFileSync(OUTPUT_PATH, JSON.stringify(certInfo, null, 2));
|
||||
console.log('✓ Certificate information extracted successfully!');
|
||||
console.log(' Output:', OUTPUT_PATH);
|
||||
console.log(' Name:', certInfo.name);
|
||||
console.log(' Fingerprint:', certInfo.fingerprint);
|
||||
console.log(' Valid until:', certInfo.validUntil);
|
||||
console.log(' Days until expiration:', certInfo.daysUntilExpiration);
|
||||
|
||||
return certInfo;
|
||||
} catch (error) {
|
||||
console.error('Error extracting certificate information:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run if called directly
|
||||
if (require.main === module) {
|
||||
extractCertInfo();
|
||||
}
|
||||
|
||||
module.exports = { extractCertInfo };
|
||||
@@ -0,0 +1,105 @@
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const path = require('path');
|
||||
|
||||
const CERT_PATH = path.join(__dirname, '../root.crt');
|
||||
const OUTPUT_PATH = path.join(__dirname, '../root.mobileconfig');
|
||||
|
||||
function generateUUID() {
|
||||
return crypto.randomUUID().toUpperCase();
|
||||
}
|
||||
|
||||
function generateMobileConfig() {
|
||||
try {
|
||||
console.log('Generating Apple mobile configuration profile...');
|
||||
console.log('Reading certificate from:', CERT_PATH);
|
||||
|
||||
// Read certificate
|
||||
const certPem = fs.readFileSync(CERT_PATH, 'utf8');
|
||||
|
||||
// Extract base64 content (remove PEM headers and newlines)
|
||||
const certBase64 = certPem
|
||||
.replace('-----BEGIN CERTIFICATE-----', '')
|
||||
.replace('-----END CERTIFICATE-----', '')
|
||||
.replace(/\s/g, '');
|
||||
|
||||
// Generate UUIDs for profile and payload
|
||||
const profileUUID = generateUUID();
|
||||
const payloadUUID = generateUUID();
|
||||
|
||||
const mobileconfig = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PayloadContent</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>PayloadCertificateFileName</key>
|
||||
<string>root.crt</string>
|
||||
<key>PayloadContent</key>
|
||||
<data>
|
||||
${certBase64}
|
||||
</data>
|
||||
<key>PayloadDescription</key>
|
||||
<string>Root CA certificate for Sami Home Network</string>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>Sami Home Network Root CA</string>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>com.sami-home.ca.root-ca</string>
|
||||
<key>PayloadType</key>
|
||||
<string>com.apple.security.root</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>${payloadUUID}</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</array>
|
||||
<key>PayloadDescription</key>
|
||||
<string>Install the Sami Home Network Root CA to trust locally-issued certificates for *.sami domains.</string>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>Sami Home Network Root CA</string>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>com.sami-home.ca</string>
|
||||
<key>PayloadOrganization</key>
|
||||
<string>Sami Home Network</string>
|
||||
<key>PayloadRemovalDisallowed</key>
|
||||
<false/>
|
||||
<key>PayloadType</key>
|
||||
<string>Configuration</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>${profileUUID}</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</plist>
|
||||
`;
|
||||
|
||||
fs.writeFileSync(OUTPUT_PATH, mobileconfig);
|
||||
console.log('✓ Mobile configuration profile generated successfully!');
|
||||
console.log(' Output:', OUTPUT_PATH);
|
||||
console.log(' Profile UUID:', profileUUID);
|
||||
console.log(' Payload UUID:', payloadUUID);
|
||||
console.log('\nTo install on iOS:');
|
||||
console.log(' 1. Download root.mobileconfig to your device');
|
||||
console.log(' 2. Open Settings app (it should prompt automatically)');
|
||||
console.log(' 3. Tap "Install Profile" and follow the prompts');
|
||||
console.log(' 4. Go to Settings > General > About > Certificate Trust Settings');
|
||||
console.log(' 5. Enable full trust for "Sami Home Network Root CA"');
|
||||
console.log('\nTo install on macOS:');
|
||||
console.log(' 1. Download root.mobileconfig');
|
||||
console.log(' 2. Open System Settings > Privacy & Security > Profiles');
|
||||
console.log(' 3. Click the profile and click Install');
|
||||
|
||||
return { profileUUID, payloadUUID };
|
||||
} catch (error) {
|
||||
console.error('Error generating mobile configuration profile:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run if called directly
|
||||
if (require.main === module) {
|
||||
generateMobileConfig();
|
||||
}
|
||||
|
||||
module.exports = { generateMobileConfig };
|
||||
@@ -0,0 +1,132 @@
|
||||
#Requires -RunAsAdministrator
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Installs the Sami Home Network Root CA certificate to the Trusted Root Certification Authorities store.
|
||||
|
||||
.DESCRIPTION
|
||||
This script downloads the root CA certificate from ca.sami, verifies its fingerprint,
|
||||
and installs it to the local machine's trusted root store. This allows all *.sami domains
|
||||
to be trusted system-wide without browser warnings.
|
||||
|
||||
.NOTES
|
||||
Requires Administrator privileges.
|
||||
For use with DashCA - https://ca.sami
|
||||
#>
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Configuration
|
||||
$CertUrl = "https://ca.sami/root.crt"
|
||||
$ExpectedFingerprint = "0898A563F5A1A2585F02D7A8A25487E6BC33969F9B5DB053622 07FAF9621290E"
|
||||
$TempFile = "$env:TEMP\sami-root-ca.crt"
|
||||
|
||||
# Colors
|
||||
$Red = [System.ConsoleColor]::Red
|
||||
$Green = [System.ConsoleColor]::Green
|
||||
$Cyan = [System.ConsoleColor]::Cyan
|
||||
$Yellow = [System.ConsoleColor]::Yellow
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "========================================" -ForegroundColor $Cyan
|
||||
Write-Host " DashCA Installer" -ForegroundColor $Cyan
|
||||
Write-Host " Sami Home Network Root CA" -ForegroundColor $Cyan
|
||||
Write-Host "========================================" -ForegroundColor $Cyan
|
||||
Write-Host ""
|
||||
|
||||
# Step 1: Download certificate
|
||||
Write-Host "[1/4] Downloading certificate from $CertUrl..." -ForegroundColor $Cyan
|
||||
try {
|
||||
$ProgressPreference = 'SilentlyContinue' # Disable progress bar for faster download
|
||||
Invoke-WebRequest -Uri $CertUrl -OutFile $TempFile -UseBasicParsing -ErrorAction Stop
|
||||
Write-Host " ✓ Certificate downloaded" -ForegroundColor $Green
|
||||
} catch {
|
||||
Write-Host " ✗ Failed to download certificate" -ForegroundColor $Red
|
||||
Write-Host " Error: $_" -ForegroundColor $Red
|
||||
Write-Host ""
|
||||
Write-Host "Troubleshooting:" -ForegroundColor $Yellow
|
||||
Write-Host " - Ensure you are on the Tailnet/network where ca.sami is accessible" -ForegroundColor $Yellow
|
||||
Write-Host " - Try accessing https://ca.sami in your browser first" -ForegroundColor $Yellow
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Step 2: Verify fingerprint
|
||||
Write-Host "[2/4] Verifying certificate fingerprint..." -ForegroundColor $Cyan
|
||||
try {
|
||||
$Cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($TempFile)
|
||||
$Fingerprint = $Cert.Thumbprint
|
||||
|
||||
$NormalizedExpected = $ExpectedFingerprint -replace '[:\s]', ''
|
||||
$NormalizedActual = $Fingerprint -replace '[:\s]', ''
|
||||
|
||||
if ($NormalizedActual -ne $NormalizedExpected) {
|
||||
Write-Host " ✗ Fingerprint mismatch!" -ForegroundColor $Red
|
||||
Write-Host " Expected: $ExpectedFingerprint" -ForegroundColor $Yellow
|
||||
Write-Host " Got: $Fingerprint" -ForegroundColor $Red
|
||||
Remove-Item $TempFile -Force
|
||||
Write-Host ""
|
||||
Write-Host "SECURITY WARNING: The downloaded certificate does not match the expected fingerprint." -ForegroundColor $Red
|
||||
Write-Host "This could indicate a man-in-the-middle attack or certificate renewal." -ForegroundColor $Red
|
||||
Write-Host "Please verify with your network administrator before proceeding." -ForegroundColor $Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host " ✓ Fingerprint verified: $Fingerprint" -ForegroundColor $Green
|
||||
} catch {
|
||||
Write-Host " ✗ Failed to verify fingerprint" -ForegroundColor $Red
|
||||
Write-Host " Error: $_" -ForegroundColor $Red
|
||||
Remove-Item $TempFile -Force -ErrorAction SilentlyContinue
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Step 3: Check if already installed
|
||||
Write-Host "[3/4] Checking for existing certificate..." -ForegroundColor $Cyan
|
||||
$ExistingCert = Get-ChildItem -Path Cert:\LocalMachine\Root | Where-Object { $_.Thumbprint -eq $Fingerprint }
|
||||
if ($ExistingCert) {
|
||||
Write-Host " ℹ Certificate already installed" -ForegroundColor $Yellow
|
||||
Write-Host " Subject: $($ExistingCert.Subject)" -ForegroundColor $Yellow
|
||||
Write-Host " Not After: $($ExistingCert.NotAfter)" -ForegroundColor $Yellow
|
||||
Remove-Item $TempFile -Force
|
||||
Write-Host ""
|
||||
Write-Host "The Sami Home Network Root CA is already trusted on this system." -ForegroundColor $Green
|
||||
Write-Host "No further action needed!" -ForegroundColor $Green
|
||||
Write-Host ""
|
||||
exit 0
|
||||
}
|
||||
Write-Host " ✓ Certificate not yet installed, proceeding..." -ForegroundColor $Green
|
||||
|
||||
# Step 4: Install certificate
|
||||
Write-Host "[4/4] Installing certificate to Trusted Root store..." -ForegroundColor $Cyan
|
||||
try {
|
||||
$ImportedCert = Import-Certificate -FilePath $TempFile -CertStoreLocation Cert:\LocalMachine\Root -ErrorAction Stop
|
||||
Write-Host " ✓ Certificate installed successfully" -ForegroundColor $Green
|
||||
Write-Host " Subject: $($ImportedCert.Subject)" -ForegroundColor $Green
|
||||
Write-Host " Thumbprint: $($ImportedCert.Thumbprint)" -ForegroundColor $Green
|
||||
} catch {
|
||||
Write-Host " ✗ Failed to install certificate" -ForegroundColor $Red
|
||||
Write-Host " Error: $_" -ForegroundColor $Red
|
||||
Remove-Item $TempFile -Force -ErrorAction SilentlyContinue
|
||||
Write-Host ""
|
||||
Write-Host "Installation failed. Please ensure you are running as Administrator." -ForegroundColor $Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Cleanup
|
||||
Remove-Item $TempFile -Force -ErrorAction SilentlyContinue
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "========================================" -ForegroundColor $Green
|
||||
Write-Host " SUCCESS!" -ForegroundColor $Green
|
||||
Write-Host "========================================" -ForegroundColor $Green
|
||||
Write-Host ""
|
||||
Write-Host "The Sami Home Network Root CA has been installed to your Trusted Root store." -ForegroundColor $Green
|
||||
Write-Host ""
|
||||
Write-Host "What's next:" -ForegroundColor $Cyan
|
||||
Write-Host " ✓ All *.sami domains will now be trusted system-wide" -ForegroundColor $Green
|
||||
Write-Host " ✓ Browsers (Edge, Chrome, Firefox) will no longer show security warnings" -ForegroundColor $Green
|
||||
Write-Host " ✓ Applications will trust HTTPS connections to your local services" -ForegroundColor $Green
|
||||
Write-Host ""
|
||||
Write-Host "Test it out:" -ForegroundColor $Cyan
|
||||
Write-Host " Visit https://status.sami or any other *.sami service" -ForegroundColor $Yellow
|
||||
Write-Host " The connection should show as secure with no warnings" -ForegroundColor $Yellow
|
||||
Write-Host ""
|
||||
@@ -0,0 +1,220 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# DashCA Installer - Sami Home Network Root CA
|
||||
# Installs the root CA certificate system-wide on Linux and macOS
|
||||
#
|
||||
# Usage: curl -fsSL https://ca.sami/install.sh | sudo bash
|
||||
#
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
CERT_URL="https://ca.sami/root.crt"
|
||||
EXPECTED_FP="08:98:A5:63:F5:A1:A2:58:5F:02:D7:A8:A2:54:87:E6:BC:33:96:9F:9B:5D:B0:53:62:20:7F:AF:96:21:29:0E"
|
||||
CERT_NAME="Sami_Home_Network_Root_CA"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
CYAN='\033[0;36m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo ""
|
||||
echo -e "${CYAN}========================================${NC}"
|
||||
echo -e "${CYAN} DashCA Installer${NC}"
|
||||
echo -e "${CYAN} Sami Home Network Root CA${NC}"
|
||||
echo -e "${CYAN}========================================${NC}"
|
||||
echo ""
|
||||
|
||||
# Check for root/sudo
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo -e "${RED}✗ This script requires root privileges${NC}"
|
||||
echo ""
|
||||
echo "Please run with sudo:"
|
||||
echo -e " ${YELLOW}curl -fsSL https://ca.sami/install.sh | sudo bash${NC}"
|
||||
echo ""
|
||||
echo "Or download first, then run:"
|
||||
echo -e " ${YELLOW}curl -o install.sh https://ca.sami/install.sh${NC}"
|
||||
echo -e " ${YELLOW}sudo bash install.sh${NC}"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Detect OS
|
||||
echo -e "${CYAN}[1/6] Detecting operating system...${NC}"
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
OS="macos"
|
||||
OS_NAME="macOS"
|
||||
elif [[ -f /etc/os-release ]]; then
|
||||
. /etc/os-release
|
||||
if [[ "$ID" == "debian" ]] || [[ "$ID" == "ubuntu" ]] || [[ "$ID_LIKE" == *"debian"* ]]; then
|
||||
OS="debian"
|
||||
OS_NAME="Debian/Ubuntu"
|
||||
elif [[ "$ID" == "fedora" ]] || [[ "$ID" == "rhel" ]] || [[ "$ID" == "centos" ]] || [[ "$ID_LIKE" == *"fedora"* ]] || [[ "$ID_LIKE" == *"rhel"* ]]; then
|
||||
OS="redhat"
|
||||
OS_NAME="RedHat/CentOS/Fedora"
|
||||
elif [[ "$ID" == "arch" ]] || [[ "$ID_LIKE" == *"arch"* ]]; then
|
||||
OS="arch"
|
||||
OS_NAME="Arch Linux"
|
||||
else
|
||||
OS="unknown"
|
||||
OS_NAME="Unknown Linux"
|
||||
fi
|
||||
elif [[ -f /etc/redhat-release ]]; then
|
||||
OS="redhat"
|
||||
OS_NAME="RedHat/CentOS"
|
||||
elif [[ -f /etc/arch-release ]]; then
|
||||
OS="arch"
|
||||
OS_NAME="Arch Linux"
|
||||
else
|
||||
OS="unknown"
|
||||
OS_NAME="Unknown"
|
||||
fi
|
||||
|
||||
if [[ "$OS" == "unknown" ]]; then
|
||||
echo -e "${RED} ✗ Unsupported operating system${NC}"
|
||||
echo ""
|
||||
echo "This script supports:"
|
||||
echo " - Debian/Ubuntu"
|
||||
echo " - RedHat/CentOS/Fedora"
|
||||
echo " - Arch Linux"
|
||||
echo " - macOS"
|
||||
echo ""
|
||||
echo "For manual installation, download the certificate:"
|
||||
echo -e " ${YELLOW}curl -O $CERT_URL${NC}"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN} ✓ Detected: $OS_NAME${NC}"
|
||||
|
||||
# Download certificate
|
||||
echo -e "${CYAN}[2/6] Downloading certificate from $CERT_URL...${NC}"
|
||||
TEMP_CERT=$(mktemp)
|
||||
if ! curl -fsSL "$CERT_URL" -o "$TEMP_CERT"; then
|
||||
echo -e "${RED} ✗ Failed to download certificate${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Troubleshooting:${NC}"
|
||||
echo " - Ensure you are on the Tailnet/network where ca.sami is accessible"
|
||||
echo " - Try accessing https://ca.sami in your browser first"
|
||||
echo " - Check your network connection"
|
||||
rm -f "$TEMP_CERT"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN} ✓ Certificate downloaded${NC}"
|
||||
|
||||
# Verify fingerprint
|
||||
echo -e "${CYAN}[3/6] Verifying certificate fingerprint...${NC}"
|
||||
if ! command -v openssl &> /dev/null; then
|
||||
echo -e "${RED} ✗ OpenSSL not found${NC}"
|
||||
echo "Please install OpenSSL to verify certificate fingerprint"
|
||||
rm -f "$TEMP_CERT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ACTUAL_FP=$(openssl x509 -in "$TEMP_CERT" -noout -fingerprint -sha256 | cut -d= -f2)
|
||||
|
||||
if [[ "$ACTUAL_FP" != "$EXPECTED_FP" ]]; then
|
||||
echo -e "${RED} ✗ Fingerprint mismatch!${NC}"
|
||||
echo -e "${YELLOW} Expected: $EXPECTED_FP${NC}"
|
||||
echo -e "${RED} Got: $ACTUAL_FP${NC}"
|
||||
rm -f "$TEMP_CERT"
|
||||
echo ""
|
||||
echo -e "${RED}SECURITY WARNING: The downloaded certificate does not match the expected fingerprint.${NC}"
|
||||
echo -e "${RED}This could indicate a man-in-the-middle attack or certificate renewal.${NC}"
|
||||
echo -e "${RED}Please verify with your network administrator before proceeding.${NC}"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN} ✓ Fingerprint verified${NC}"
|
||||
|
||||
# Extract certificate details
|
||||
echo -e "${CYAN}[4/6] Extracting certificate information...${NC}"
|
||||
CERT_SUBJECT=$(openssl x509 -in "$TEMP_CERT" -noout -subject | sed 's/subject=//')
|
||||
CERT_NOT_AFTER=$(openssl x509 -in "$TEMP_CERT" -noout -enddate | sed 's/notAfter=//')
|
||||
echo -e "${GREEN} ✓ Subject: $CERT_SUBJECT${NC}"
|
||||
echo -e "${GREEN} ✓ Valid until: $CERT_NOT_AFTER${NC}"
|
||||
|
||||
# Check if already installed
|
||||
echo -e "${CYAN}[5/6] Checking for existing installation...${NC}"
|
||||
ALREADY_INSTALLED=false
|
||||
|
||||
case "$OS" in
|
||||
debian)
|
||||
if [[ -f "/usr/local/share/ca-certificates/${CERT_NAME}.crt" ]]; then
|
||||
ALREADY_INSTALLED=true
|
||||
fi
|
||||
;;
|
||||
redhat)
|
||||
if [[ -f "/etc/pki/ca-trust/source/anchors/${CERT_NAME}.crt" ]]; then
|
||||
ALREADY_INSTALLED=true
|
||||
fi
|
||||
;;
|
||||
arch)
|
||||
if [[ -f "/etc/ca-certificates/trust-source/anchors/${CERT_NAME}.crt" ]]; then
|
||||
ALREADY_INSTALLED=true
|
||||
fi
|
||||
;;
|
||||
macos)
|
||||
if security find-certificate -a -c "$CERT_SUBJECT" /Library/Keychains/System.keychain &>/dev/null; then
|
||||
ALREADY_INSTALLED=true
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ "$ALREADY_INSTALLED" == "true" ]]; then
|
||||
echo -e "${YELLOW} ℹ Certificate already installed${NC}"
|
||||
rm -f "$TEMP_CERT"
|
||||
echo ""
|
||||
echo -e "${GREEN}The Sami Home Network Root CA is already trusted on this system.${NC}"
|
||||
echo -e "${GREEN}No further action needed!${NC}"
|
||||
echo ""
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo -e "${GREEN} ✓ Certificate not yet installed, proceeding...${NC}"
|
||||
|
||||
# Install based on OS
|
||||
echo -e "${CYAN}[6/6] Installing certificate...${NC}"
|
||||
case "$OS" in
|
||||
debian)
|
||||
cp "$TEMP_CERT" "/usr/local/share/ca-certificates/${CERT_NAME}.crt"
|
||||
update-ca-certificates
|
||||
echo -e "${GREEN} ✓ Certificate installed via update-ca-certificates${NC}"
|
||||
;;
|
||||
redhat)
|
||||
cp "$TEMP_CERT" "/etc/pki/ca-trust/source/anchors/${CERT_NAME}.crt"
|
||||
update-ca-trust
|
||||
echo -e "${GREEN} ✓ Certificate installed via update-ca-trust${NC}"
|
||||
;;
|
||||
arch)
|
||||
cp "$TEMP_CERT" "/etc/ca-certificates/trust-source/anchors/${CERT_NAME}.crt"
|
||||
trust extract-compat
|
||||
echo -e "${GREEN} ✓ Certificate installed via trust extract-compat${NC}"
|
||||
;;
|
||||
macos)
|
||||
security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "$TEMP_CERT"
|
||||
echo -e "${GREEN} ✓ Certificate installed to System Keychain${NC}"
|
||||
;;
|
||||
esac
|
||||
|
||||
# Cleanup
|
||||
rm -f "$TEMP_CERT"
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo -e "${GREEN} SUCCESS!${NC}"
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo ""
|
||||
echo -e "${GREEN}The Sami Home Network Root CA has been installed system-wide.${NC}"
|
||||
echo ""
|
||||
echo -e "${CYAN}What's next:${NC}"
|
||||
echo -e " ${GREEN}✓${NC} All *.sami domains will now be trusted"
|
||||
echo -e " ${GREEN}✓${NC} Browsers will no longer show security warnings"
|
||||
echo -e " ${GREEN}✓${NC} Applications will trust HTTPS connections to your local services"
|
||||
echo ""
|
||||
echo -e "${CYAN}Test it out:${NC}"
|
||||
echo -e " ${YELLOW}Visit https://status.sami or any other *.sami service${NC}"
|
||||
echo -e " ${YELLOW}The connection should show as secure with no warnings${NC}"
|
||||
echo ""
|
||||
@@ -0,0 +1,10 @@
|
||||
node_modules/
|
||||
__tests__/
|
||||
jest.config.js
|
||||
.env
|
||||
.encryption-key
|
||||
.gitignore
|
||||
.dockerignore
|
||||
*.log
|
||||
*.md
|
||||
docker-compose.yml
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
coverage/
|
||||
dist/
|
||||
build/
|
||||
*.min.js
|
||||
@@ -0,0 +1,82 @@
|
||||
module.exports = {
|
||||
env: {
|
||||
node: true,
|
||||
es2021: true,
|
||||
},
|
||||
extends: 'eslint:recommended',
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'commonjs',
|
||||
},
|
||||
rules: {
|
||||
// Error Prevention
|
||||
'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
||||
'no-console': 'off', // We use structured logging, but console is okay for debug
|
||||
'no-undef': 'error',
|
||||
'no-unreachable': 'error',
|
||||
'no-constant-condition': ['error', { checkLoops: false }],
|
||||
|
||||
// Code Quality
|
||||
'prefer-const': 'warn',
|
||||
'no-var': 'warn',
|
||||
'eqeqeq': ['warn', 'always', { null: 'ignore' }],
|
||||
'curly': ['warn', 'multi-line'],
|
||||
'no-throw-literal': 'error',
|
||||
|
||||
// Async/Await
|
||||
'require-await': 'warn',
|
||||
'no-async-promise-executor': 'error',
|
||||
'no-await-in-loop': 'off', // Sometimes intentional for sequential operations
|
||||
|
||||
// Style (Prettier handles formatting, these are semantic)
|
||||
'consistent-return': 'off', // Express routes don't always return
|
||||
'no-nested-ternary': 'warn',
|
||||
'max-depth': ['warn', 4],
|
||||
'complexity': ['warn', 20],
|
||||
|
||||
// Prevent common pitfalls
|
||||
'no-eval': 'error',
|
||||
'no-implied-eval': 'error',
|
||||
'no-new-func': 'error',
|
||||
'no-with': 'error',
|
||||
'no-proto': 'error',
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
// Test files can be more lenient
|
||||
files: ['**/__tests__/**/*.js', '**/*.test.js', '**/*.spec.js'],
|
||||
env: {
|
||||
jest: true,
|
||||
},
|
||||
rules: {
|
||||
'no-unused-expressions': 'off',
|
||||
'max-depth': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
// Browser-side assets (client JS)
|
||||
files: ['assets/**/*.js', 'frontend/**/*.js'],
|
||||
env: {
|
||||
browser: true,
|
||||
es2021: true,
|
||||
node: false,
|
||||
},
|
||||
globals: {
|
||||
// Common dashboard globals from status/index.html context
|
||||
apiUrl: 'readonly',
|
||||
API_BASE_URL: 'readonly',
|
||||
CONFIG: 'readonly',
|
||||
// Client-side dashboard classes (loaded via script tags)
|
||||
ErrorHandler: 'readonly',
|
||||
ProgressTracker: 'readonly',
|
||||
ThemeAdapter: 'readonly',
|
||||
DnsTemplateSelector: 'readonly',
|
||||
TourManager: 'readonly',
|
||||
TooltipDefinitions: 'readonly',
|
||||
},
|
||||
rules: {
|
||||
'no-undef': 'warn',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
|
||||
# Backups
|
||||
.backup/
|
||||
server-old.js
|
||||
*.bak
|
||||
*.bak2
|
||||
*.bak3
|
||||
*.bak4
|
||||
|
||||
# Logs
|
||||
error.log
|
||||
*.log
|
||||
|
||||
# Test artifacts
|
||||
coverage/
|
||||
audit-routes.js
|
||||
comprehensive-test.js
|
||||
test-security-fixes.js
|
||||
license-keygen.js
|
||||
|
||||
# Runtime-generated data files (written by the running server, not source)
|
||||
alert-config.json
|
||||
audit-log.json
|
||||
audit-log.json.lock
|
||||
backup-config.json
|
||||
backup-history.json
|
||||
container-stats.json
|
||||
credentials.json
|
||||
health-config.json
|
||||
health-history.json
|
||||
update-config.json
|
||||
update-history.json
|
||||
|
||||
# Runtime certificate/key directories
|
||||
generated-certs/
|
||||
pki/
|
||||
assets/
|
||||
@@ -0,0 +1 @@
|
||||
2
|
||||
@@ -0,0 +1 @@
|
||||
1d87da6ce9285898051ed2b120628d730d13ec4accad95908b7fc2c0ab33db48
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
coverage/
|
||||
dist/
|
||||
build/
|
||||
package-lock.json
|
||||
*.min.js
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"arrowParens": "avoid",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install OpenSSL for certificate generation
|
||||
RUN apk add --no-cache openssl
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install --production
|
||||
|
||||
COPY *.js ./
|
||||
COPY src/ ./src/
|
||||
COPY routes/ ./routes/
|
||||
COPY openapi.yaml ./
|
||||
|
||||
# VERSION file holds the short git SHA the image was built from. Committed as
|
||||
# 'dev' for source builds; the release script (scripts/release.sh) overwrites it
|
||||
# with the actual commit hash before tarballing each release.
|
||||
COPY VERSION ./
|
||||
|
||||
# Note: Running as root because container needs Docker socket access
|
||||
# (which is root-equivalent anyway). Socket access required for container management.
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
STOPSIGNAL SIGTERM
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:3001/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1); }).on('error', () => process.exit(1))"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1 @@
|
||||
1.13.4
|
||||
@@ -0,0 +1,182 @@
|
||||
const { APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS } = require('../src/docker/app-templates');
|
||||
|
||||
describe('App Templates', () => {
|
||||
const templates = Object.values(APP_TEMPLATES);
|
||||
const templateIds = Object.keys(APP_TEMPLATES);
|
||||
const categoryNames = Object.keys(TEMPLATE_CATEGORIES);
|
||||
|
||||
describe('Template Structure', () => {
|
||||
it('has at least 40 templates', () => {
|
||||
expect(templates.length).toBeGreaterThanOrEqual(40);
|
||||
});
|
||||
|
||||
it('every template has required fields: name, description, icon, category', () => {
|
||||
for (const tmpl of templates) {
|
||||
expect(tmpl).toHaveProperty('name');
|
||||
expect(tmpl).toHaveProperty('description');
|
||||
expect(tmpl).toHaveProperty('icon');
|
||||
expect(tmpl).toHaveProperty('category');
|
||||
expect(typeof tmpl.name).toBe('string');
|
||||
expect(tmpl.name.length).toBeGreaterThan(0);
|
||||
expect(typeof tmpl.description).toBe('string');
|
||||
}
|
||||
});
|
||||
|
||||
it('every Docker-based template has docker config with image', () => {
|
||||
for (const id of templateIds) {
|
||||
const tmpl = APP_TEMPLATES[id];
|
||||
if (!tmpl.docker) continue; // Skip static sites and dashboard widgets
|
||||
expect(tmpl.docker).toHaveProperty('image');
|
||||
expect(typeof tmpl.docker.image).toBe('string');
|
||||
expect(tmpl.docker.image.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('every template has subdomain property', () => {
|
||||
for (const id of templateIds) {
|
||||
const tmpl = APP_TEMPLATES[id];
|
||||
expect(tmpl).toHaveProperty('subdomain');
|
||||
// subdomain can be null for widgets
|
||||
if (tmpl.subdomain !== null) {
|
||||
expect(typeof tmpl.subdomain).toBe('string');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('all Docker-based templates have valid defaultPorts (1-65535)', () => {
|
||||
for (const id of templateIds) {
|
||||
const tmpl = APP_TEMPLATES[id];
|
||||
if (!tmpl.docker) continue; // Skip non-Docker templates
|
||||
const port = tmpl.defaultPort;
|
||||
expect(port).toBeGreaterThanOrEqual(1);
|
||||
expect(port).toBeLessThanOrEqual(65535);
|
||||
}
|
||||
});
|
||||
|
||||
it('all category values are in TEMPLATE_CATEGORIES', () => {
|
||||
for (const tmpl of templates) {
|
||||
expect(categoryNames).toContain(tmpl.category);
|
||||
}
|
||||
});
|
||||
|
||||
it('Docker images have no shell injection characters', () => {
|
||||
const dangerous = [';', '&', '|', '`', '$', '\n'];
|
||||
for (const id of templateIds) {
|
||||
const tmpl = APP_TEMPLATES[id];
|
||||
if (!tmpl.docker) continue;
|
||||
const image = tmpl.docker.image;
|
||||
for (const char of dangerous) {
|
||||
expect(image).not.toContain(char);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('TEMPLATE_CATEGORIES', () => {
|
||||
it('is a non-empty object with category entries', () => {
|
||||
expect(typeof TEMPLATE_CATEGORIES).toBe('object');
|
||||
expect(TEMPLATE_CATEGORIES).not.toBeNull();
|
||||
expect(categoryNames.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('each category has icon and color', () => {
|
||||
for (const name of categoryNames) {
|
||||
const cat = TEMPLATE_CATEGORIES[name];
|
||||
expect(cat).toHaveProperty('icon');
|
||||
expect(cat).toHaveProperty('color');
|
||||
expect(typeof cat.color).toBe('string');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('DIFFICULTY_LEVELS', () => {
|
||||
it('is a non-empty object with difficulty entries', () => {
|
||||
const levels = Object.keys(DIFFICULTY_LEVELS);
|
||||
expect(levels.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('each level has color and description', () => {
|
||||
for (const [name, level] of Object.entries(DIFFICULTY_LEVELS)) {
|
||||
expect(level).toHaveProperty('color');
|
||||
expect(level).toHaveProperty('description');
|
||||
expect(typeof level.color).toBe('string');
|
||||
expect(typeof level.description).toBe('string');
|
||||
}
|
||||
});
|
||||
|
||||
it('includes Easy, Intermediate, and Advanced levels', () => {
|
||||
expect(DIFFICULTY_LEVELS).toHaveProperty('Easy');
|
||||
expect(DIFFICULTY_LEVELS).toHaveProperty('Intermediate');
|
||||
expect(DIFFICULTY_LEVELS).toHaveProperty('Advanced');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Specific Templates', () => {
|
||||
it('plex template has PLEX_CLAIM as empty string', () => {
|
||||
const plex = APP_TEMPLATES.plex;
|
||||
expect(plex).toBeDefined();
|
||||
expect(plex.docker.environment).toHaveProperty('PLEX_CLAIM');
|
||||
expect(plex.docker.environment.PLEX_CLAIM).toBe('');
|
||||
});
|
||||
|
||||
it('jellyfin template exists with correct default port', () => {
|
||||
const jf = APP_TEMPLATES.jellyfin;
|
||||
expect(jf).toBeDefined();
|
||||
expect(jf.defaultPort).toBe(8096);
|
||||
});
|
||||
|
||||
it('radarr template exists with correct default port', () => {
|
||||
const radarr = APP_TEMPLATES.radarr;
|
||||
expect(radarr).toBeDefined();
|
||||
expect(radarr.defaultPort).toBe(7878);
|
||||
});
|
||||
|
||||
it('sonarr template exists with correct default port', () => {
|
||||
const sonarr = APP_TEMPLATES.sonarr;
|
||||
expect(sonarr).toBeDefined();
|
||||
expect(sonarr.defaultPort).toBe(8989);
|
||||
});
|
||||
|
||||
it('prowlarr template exists with correct default port', () => {
|
||||
const prowlarr = APP_TEMPLATES.prowlarr;
|
||||
expect(prowlarr).toBeDefined();
|
||||
expect(prowlarr.defaultPort).toBe(9696);
|
||||
});
|
||||
|
||||
it('DashCA is a static site without docker config', () => {
|
||||
const dashca = APP_TEMPLATES.dashca;
|
||||
if (dashca) {
|
||||
expect(dashca.isStaticSite).toBe(true);
|
||||
expect(dashca.docker).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Template Ports', () => {
|
||||
it('all templates with docker.ports have valid port mappings', () => {
|
||||
// Ports use template syntax like "{{PORT}}:32400" or "{{PORT}}:32400/tcp"
|
||||
const portPattern = /^(\{\{PORT\}\}|\d+):(\d+)(\/[a-z]+)?$/;
|
||||
for (const id of templateIds) {
|
||||
const tmpl = APP_TEMPLATES[id];
|
||||
if (!tmpl.docker || !tmpl.docker.ports) continue;
|
||||
expect(Array.isArray(tmpl.docker.ports)).toBe(true);
|
||||
for (const port of tmpl.docker.ports) {
|
||||
expect(typeof port).toBe('string');
|
||||
expect(port).toMatch(portPattern);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('no two templates share the same default port (prevent conflicts)', () => {
|
||||
const portMap = new Map();
|
||||
for (const id of templateIds) {
|
||||
const port = APP_TEMPLATES[id].defaultPort;
|
||||
if (port !== null) {
|
||||
portMap.set(port, id);
|
||||
}
|
||||
}
|
||||
// At minimum, we should have more unique ports than 30% of templates
|
||||
expect(portMap.size).toBeGreaterThan(templateIds.length * 0.3);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
// Must mock crypto-utils BEFORE auth-manager is required,
|
||||
// because auth-manager.js line 13: const JWT_SECRET = cryptoUtils.loadOrCreateKey()
|
||||
const mockFixedKey = Buffer.alloc(32, 'jwt-test-key-pad');
|
||||
jest.mock('../src/security/crypto-utils', () => ({
|
||||
loadOrCreateKey: jest.fn(() => mockFixedKey),
|
||||
}));
|
||||
|
||||
jest.mock('../src/managers/credential-manager', () => ({
|
||||
store: jest.fn().mockResolvedValue(true),
|
||||
retrieve: jest.fn().mockResolvedValue(null),
|
||||
delete: jest.fn().mockResolvedValue(true),
|
||||
list: jest.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
const crypto = require('crypto');
|
||||
const authManager = require('../src/managers/auth-manager');
|
||||
const credentialManager = require('../src/managers/credential-manager');
|
||||
|
||||
describe('AuthManager', () => {
|
||||
beforeEach(() => {
|
||||
authManager.clearCache();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('JWT Generation and Verification', () => {
|
||||
it('generateJWT returns a valid JWT string', async () => {
|
||||
const token = await authManager.generateJWT({ sub: 'user1' });
|
||||
expect(typeof token).toBe('string');
|
||||
expect(token.split('.')).toHaveLength(3); // header.payload.signature
|
||||
});
|
||||
|
||||
it('generateJWT defaults scope to [read, write]', async () => {
|
||||
const token = await authManager.generateJWT({ sub: 'user1' });
|
||||
const result = await authManager.verifyJWT(token);
|
||||
expect(result.scope).toEqual(['read', 'write']);
|
||||
});
|
||||
|
||||
it('generateJWT respects custom scope', async () => {
|
||||
const token = await authManager.generateJWT({ sub: 'user1', scope: ['admin'] });
|
||||
const result = await authManager.verifyJWT(token);
|
||||
expect(result.scope).toEqual(['admin']);
|
||||
});
|
||||
|
||||
it('generateJWT throws if payload.sub missing', async () => {
|
||||
await expect(authManager.generateJWT({ name: 'test' }))
|
||||
.rejects.toThrow('must include "sub"');
|
||||
});
|
||||
|
||||
it('generateJWT respects custom expiresIn', async () => {
|
||||
const token = await authManager.generateJWT({ sub: 'user1' }, '1s');
|
||||
// Token should be valid immediately
|
||||
const result = await authManager.verifyJWT(token);
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
it('verifyJWT returns decoded payload for valid token', async () => {
|
||||
const token = await authManager.generateJWT({ sub: 'user1' });
|
||||
const result = await authManager.verifyJWT(token);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.userId).toBe('user1');
|
||||
expect(result.scope).toEqual(['read', 'write']);
|
||||
expect(result.iat).toBeDefined();
|
||||
expect(result.exp).toBeDefined();
|
||||
});
|
||||
|
||||
it('verifyJWT returns null for expired token', async () => {
|
||||
const token = await authManager.generateJWT({ sub: 'user1' }, '0s');
|
||||
// Wait a tick for expiration
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
const result = await authManager.verifyJWT(token);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('verifyJWT returns null for invalid token', async () => {
|
||||
const result = await authManager.verifyJWT('garbage.not.ajwt');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('verifyJWT returns null for token signed with different secret', async () => {
|
||||
const jwt = require('jsonwebtoken');
|
||||
const fakeToken = jwt.sign({ sub: 'user1' }, 'wrong-secret');
|
||||
const result = await authManager.verifyJWT(fakeToken);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('API Key Generation', () => {
|
||||
it('generateAPIKey returns key in dk_<id>_<secret> format', async () => {
|
||||
const result = await authManager.generateAPIKey('My Key');
|
||||
expect(result.key).toMatch(/^dk_[a-f0-9]+_[a-f0-9]+$/);
|
||||
});
|
||||
|
||||
it('generateAPIKey stores SHA-256 hash via credentialManager', async () => {
|
||||
const result = await authManager.generateAPIKey('Test Key');
|
||||
expect(credentialManager.store).toHaveBeenCalledWith(
|
||||
expect.stringContaining('auth.apikey.'),
|
||||
expect.any(String) // SHA-256 hash
|
||||
);
|
||||
});
|
||||
|
||||
it('generateAPIKey stores metadata separately', async () => {
|
||||
await authManager.generateAPIKey('Named Key', ['read']);
|
||||
// Second call should be metadata
|
||||
const metaCalls = credentialManager.store.mock.calls.filter(
|
||||
call => call[0].startsWith('auth.metadata.')
|
||||
);
|
||||
expect(metaCalls.length).toBe(1);
|
||||
const metadata = JSON.parse(metaCalls[0][1]);
|
||||
expect(metadata.name).toBe('Named Key');
|
||||
expect(metadata.scopes).toEqual(['read']);
|
||||
});
|
||||
|
||||
it('generateAPIKey returns id, name, scopes, createdAt', async () => {
|
||||
const result = await authManager.generateAPIKey('Full Key', ['read', 'write']);
|
||||
expect(result).toHaveProperty('key');
|
||||
expect(result).toHaveProperty('id');
|
||||
expect(result.name).toBe('Full Key');
|
||||
expect(result.scopes).toEqual(['read', 'write']);
|
||||
expect(result.createdAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('generateAPIKey throws if name missing', async () => {
|
||||
await expect(authManager.generateAPIKey('')).rejects.toThrow('name is required');
|
||||
});
|
||||
|
||||
it('generateAPIKey caches metadata', async () => {
|
||||
const result = await authManager.generateAPIKey('Cached Key');
|
||||
expect(authManager.keyMetadataCache.has(result.id)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('API Key Verification', () => {
|
||||
let testKey;
|
||||
let testKeyId;
|
||||
let testHash;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Generate a key for verification tests
|
||||
const generated = await authManager.generateAPIKey('Verify Test');
|
||||
testKey = generated.key;
|
||||
testKeyId = generated.id;
|
||||
testHash = crypto.createHash('sha256').update(testKey).digest('hex');
|
||||
|
||||
// Set up credentialManager to return the hash and metadata
|
||||
credentialManager.retrieve.mockImplementation(async (key) => {
|
||||
if (key === `auth.apikey.${testKeyId}`) return testHash;
|
||||
if (key === `auth.metadata.${testKeyId}`) {
|
||||
return JSON.stringify({ id: testKeyId, name: 'Verify Test', scopes: ['read', 'write'] });
|
||||
}
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
it('verifyAPIKey returns keyId, scopes, name for valid key', async () => {
|
||||
// Clear cache to force credential lookup
|
||||
authManager.clearCache();
|
||||
const result = await authManager.verifyAPIKey(testKey);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.keyId).toBe(testKeyId);
|
||||
expect(result.scopes).toEqual(['read', 'write']);
|
||||
expect(result.name).toBe('Verify Test');
|
||||
});
|
||||
|
||||
it('verifyAPIKey returns null for key not starting with dk_', async () => {
|
||||
const result = await authManager.verifyAPIKey('invalid_prefix_key');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('verifyAPIKey returns null for key with wrong part count', async () => {
|
||||
const result = await authManager.verifyAPIKey('dk_only_two');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('verifyAPIKey returns null when stored hash not found', async () => {
|
||||
credentialManager.retrieve.mockResolvedValue(null);
|
||||
authManager.clearCache();
|
||||
const result = await authManager.verifyAPIKey(`dk_${testKeyId}_wrongsecret`);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('verifyAPIKey returns null on hash mismatch', async () => {
|
||||
credentialManager.retrieve.mockImplementation(async (key) => {
|
||||
if (key.startsWith('auth.apikey.')) return 'wrong-hash-value-that-does-not-match';
|
||||
return null;
|
||||
});
|
||||
authManager.clearCache();
|
||||
// The hash comparison will fail because hashes have different lengths
|
||||
const result = await authManager.verifyAPIKey(testKey);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('verifyAPIKey returns null when metadata not found', async () => {
|
||||
credentialManager.retrieve.mockImplementation(async (key) => {
|
||||
if (key.startsWith('auth.apikey.')) return testHash;
|
||||
return null; // No metadata
|
||||
});
|
||||
authManager.clearCache();
|
||||
const result = await authManager.verifyAPIKey(testKey);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('API Key Revocation', () => {
|
||||
it('revokeAPIKey deletes hash and metadata', async () => {
|
||||
await authManager.revokeAPIKey('abc123');
|
||||
expect(credentialManager.delete).toHaveBeenCalledWith('auth.apikey.abc123');
|
||||
expect(credentialManager.delete).toHaveBeenCalledWith('auth.metadata.abc123');
|
||||
});
|
||||
|
||||
it('revokeAPIKey removes from cache', async () => {
|
||||
authManager.keyMetadataCache.set('abc123', { name: 'test' });
|
||||
await authManager.revokeAPIKey('abc123');
|
||||
expect(authManager.keyMetadataCache.has('abc123')).toBe(false);
|
||||
});
|
||||
|
||||
it('revokeAPIKey returns true on success', async () => {
|
||||
const result = await authManager.revokeAPIKey('test');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('revokeAPIKey returns false on error', async () => {
|
||||
credentialManager.delete.mockRejectedValueOnce(new Error('fail'));
|
||||
const result = await authManager.revokeAPIKey('fail-key');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('API Key Listing', () => {
|
||||
it('listAPIKeys returns metadata for all keys', async () => {
|
||||
credentialManager.list.mockResolvedValue([
|
||||
'auth.metadata.key1',
|
||||
'auth.metadata.key2',
|
||||
'auth.apikey.key1',
|
||||
'auth.apikey.key2'
|
||||
]);
|
||||
credentialManager.retrieve.mockImplementation(async (key) => {
|
||||
if (key === 'auth.metadata.key1') return JSON.stringify({ id: 'key1', name: 'Key 1' });
|
||||
if (key === 'auth.metadata.key2') return JSON.stringify({ id: 'key2', name: 'Key 2' });
|
||||
return null;
|
||||
});
|
||||
|
||||
const keys = await authManager.listAPIKeys();
|
||||
expect(keys).toHaveLength(2);
|
||||
expect(keys[0].name).toBe('Key 1');
|
||||
expect(keys[1].name).toBe('Key 2');
|
||||
});
|
||||
|
||||
it('listAPIKeys returns empty array on error', async () => {
|
||||
credentialManager.list.mockRejectedValue(new Error('fail'));
|
||||
const keys = await authManager.listAPIKeys();
|
||||
expect(keys).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Key Metadata', () => {
|
||||
it('getKeyMetadata returns from cache when available', async () => {
|
||||
authManager.keyMetadataCache.set('cached', { name: 'Cached' });
|
||||
const result = await authManager.getKeyMetadata('cached');
|
||||
expect(result.name).toBe('Cached');
|
||||
expect(credentialManager.retrieve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('getKeyMetadata fetches from credentialManager when not cached', async () => {
|
||||
credentialManager.retrieve.mockResolvedValue(JSON.stringify({ id: 'x', name: 'Fetched' }));
|
||||
const result = await authManager.getKeyMetadata('x');
|
||||
expect(result.name).toBe('Fetched');
|
||||
expect(credentialManager.retrieve).toHaveBeenCalledWith('auth.metadata.x');
|
||||
});
|
||||
|
||||
it('getKeyMetadata caches fetched result', async () => {
|
||||
credentialManager.retrieve.mockResolvedValue(JSON.stringify({ id: 'y', name: 'Cached Now' }));
|
||||
await authManager.getKeyMetadata('y');
|
||||
expect(authManager.keyMetadataCache.has('y')).toBe(true);
|
||||
});
|
||||
|
||||
it('getKeyMetadata returns null when not found', async () => {
|
||||
credentialManager.retrieve.mockResolvedValue(null);
|
||||
const result = await authManager.getKeyMetadata('missing');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cache', () => {
|
||||
it('clearCache empties keyMetadataCache', () => {
|
||||
authManager.keyMetadataCache.set('a', { name: 'A' });
|
||||
authManager.keyMetadataCache.set('b', { name: 'B' });
|
||||
authManager.clearCache();
|
||||
expect(authManager.keyMetadataCache.size).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* Smoke tests for auto-restart-manager.js
|
||||
* Verifies the AutoRestartManager class:
|
||||
* - Policy CRUD (set/get/list/remove)
|
||||
* - handleContainerDown: cooldown, max-retries, restart attempt, failure
|
||||
* - handleContainerUp: retry counter reset
|
||||
* - _handleStatusCheck: healthy→unhealthy and unhealthy→healthy transitions
|
||||
* - _resolveContainerId: lookup precedence
|
||||
*/
|
||||
|
||||
const EventEmitter = require('events');
|
||||
const { AutoRestartManager, DEFAULT_POLICY } = require('../src/managers/auto-restart-manager');
|
||||
|
||||
jest.mock('../src/utilities/fs-helpers', () => ({
|
||||
readJsonFile: jest.fn().mockResolvedValue({}),
|
||||
writeJsonFile: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const fsHelpers = require('../src/utilities/fs-helpers');
|
||||
|
||||
function makeManager(overrides = {}) {
|
||||
const servicesStateManager = {
|
||||
read: jest.fn().mockResolvedValue([]),
|
||||
...(overrides.servicesStateManager || {}),
|
||||
};
|
||||
|
||||
const docker = {
|
||||
client: {
|
||||
getContainer: jest.fn(),
|
||||
...(overrides.dockerClient || {}),
|
||||
},
|
||||
};
|
||||
|
||||
const healthChecker = new EventEmitter();
|
||||
if (overrides.healthChecker) {
|
||||
Object.assign(healthChecker, overrides.healthChecker);
|
||||
}
|
||||
|
||||
const notification = {
|
||||
send: jest.fn().mockResolvedValue({ success: true }),
|
||||
...(overrides.notification || {}),
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
docker,
|
||||
healthChecker,
|
||||
notification,
|
||||
servicesStateManager,
|
||||
SERVICES_FILE: '/tmp/dc-test/services.json',
|
||||
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn() },
|
||||
logError: jest.fn(),
|
||||
};
|
||||
|
||||
const manager = new AutoRestartManager(ctx);
|
||||
return { manager, ctx, docker, healthChecker, notification, servicesStateManager };
|
||||
}
|
||||
|
||||
describe('AutoRestartManager', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
fsHelpers.readJsonFile.mockResolvedValue({});
|
||||
fsHelpers.writeJsonFile.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe('constants & construction', () => {
|
||||
test('DEFAULT_POLICY has the documented fields and sensible defaults', () => {
|
||||
expect(DEFAULT_POLICY).toEqual({
|
||||
enabled: true,
|
||||
maxRetries: 3,
|
||||
retryIntervalMs: 5000,
|
||||
windowMinutes: 10,
|
||||
currentRetries: 0,
|
||||
lastRestartAt: null,
|
||||
cooldownUntil: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('manager extends EventEmitter and stores ctx deps', () => {
|
||||
const { manager, ctx } = makeManager();
|
||||
expect(manager).toBeInstanceOf(EventEmitter);
|
||||
expect(manager.docker).toBe(ctx.docker);
|
||||
expect(manager.healthChecker).toBe(ctx.healthChecker);
|
||||
expect(manager.notification).toBe(ctx.notification);
|
||||
expect(manager.policies).toBeInstanceOf(Map);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lifecycle', () => {
|
||||
test('start() loads persisted policies from fs-helpers', async () => {
|
||||
fsHelpers.readJsonFile.mockResolvedValue({
|
||||
'svc-1': { enabled: false, maxRetries: 7 },
|
||||
});
|
||||
const { manager } = makeManager();
|
||||
await manager.start();
|
||||
expect(manager.policies.has('svc-1')).toBe(true);
|
||||
const policy = manager.getPolicy('svc-1');
|
||||
expect(policy.maxRetries).toBe(7);
|
||||
expect(policy.enabled).toBe(false);
|
||||
});
|
||||
|
||||
test('start() is idempotent (second call does nothing new)', async () => {
|
||||
const { manager, healthChecker } = makeManager();
|
||||
await manager.start();
|
||||
const listenerCount = healthChecker.listenerCount('status-check');
|
||||
await manager.start();
|
||||
expect(healthChecker.listenerCount('status-check')).toBe(listenerCount);
|
||||
});
|
||||
|
||||
test('stop() removes the status-check listener', async () => {
|
||||
const { manager, healthChecker } = makeManager();
|
||||
await manager.start();
|
||||
expect(healthChecker.listenerCount('status-check')).toBe(1);
|
||||
manager.stop();
|
||||
expect(healthChecker.listenerCount('status-check')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('policy CRUD', () => {
|
||||
test('setPolicy throws on missing serviceId', async () => {
|
||||
const { manager } = makeManager();
|
||||
await expect(manager.setPolicy('', { enabled: true })).rejects.toThrow(/serviceId/);
|
||||
await expect(manager.setPolicy(null, {})).rejects.toThrow(/serviceId/);
|
||||
});
|
||||
|
||||
test('setPolicy merges fields with existing policy', async () => {
|
||||
const { manager } = makeManager();
|
||||
await manager.setPolicy('svc-1', { maxRetries: 5 });
|
||||
await manager.setPolicy('svc-1', { enabled: false });
|
||||
const policy = manager.getPolicy('svc-1');
|
||||
expect(policy.maxRetries).toBe(5); // preserved from earlier
|
||||
expect(policy.enabled).toBe(false); // updated by second call
|
||||
});
|
||||
|
||||
test('setPolicy persists via fs-helpers.writeJsonFile', async () => {
|
||||
const { manager } = makeManager();
|
||||
await manager.setPolicy('svc-1', { maxRetries: 4 });
|
||||
expect(fsHelpers.writeJsonFile).toHaveBeenCalled();
|
||||
const [filePath, payload] = fsHelpers.writeJsonFile.mock.calls[0];
|
||||
expect(filePath).toMatch(/auto-restart-policies\.json$/);
|
||||
expect(payload['svc-1'].maxRetries).toBe(4);
|
||||
});
|
||||
|
||||
test('getPolicy returns a copy, not the internal reference', async () => {
|
||||
const { manager } = makeManager();
|
||||
await manager.setPolicy('svc-1', { maxRetries: 2 });
|
||||
const a = manager.getPolicy('svc-1');
|
||||
a.maxRetries = 999;
|
||||
const b = manager.getPolicy('svc-1');
|
||||
expect(b.maxRetries).toBe(2);
|
||||
});
|
||||
|
||||
test('getPolicy returns null for unknown service', () => {
|
||||
const { manager } = makeManager();
|
||||
expect(manager.getPolicy('does-not-exist')).toBeNull();
|
||||
});
|
||||
|
||||
test('listPolicies returns array of all policies', async () => {
|
||||
const { manager } = makeManager();
|
||||
await manager.setPolicy('svc-1', { maxRetries: 1 });
|
||||
await manager.setPolicy('svc-2', { maxRetries: 2 });
|
||||
const list = manager.listPolicies();
|
||||
expect(Array.isArray(list)).toBe(true);
|
||||
expect(list).toHaveLength(2);
|
||||
const ids = list.map(p => p.serviceId);
|
||||
expect(ids).toEqual(expect.arrayContaining(['svc-1', 'svc-2']));
|
||||
});
|
||||
|
||||
test('removePolicy returns true and deletes the policy', async () => {
|
||||
const { manager } = makeManager();
|
||||
await manager.setPolicy('svc-1', { maxRetries: 1 });
|
||||
expect(await manager.removePolicy('svc-1')).toBe(true);
|
||||
expect(manager.getPolicy('svc-1')).toBeNull();
|
||||
});
|
||||
|
||||
test('removePolicy returns false for unknown service', async () => {
|
||||
const { manager } = makeManager();
|
||||
expect(await manager.removePolicy('does-not-exist')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleContainerDown', () => {
|
||||
test('returns ignored/no-policy when no policy exists', async () => {
|
||||
const { manager } = makeManager();
|
||||
const result = await manager.handleContainerDown('unknown', 'cid');
|
||||
expect(result.action).toBe('ignored');
|
||||
expect(result.reason).toBe('no-policy');
|
||||
});
|
||||
|
||||
test('returns ignored/disabled when policy.enabled is false', async () => {
|
||||
const { manager } = makeManager();
|
||||
await manager.setPolicy('svc-1', { enabled: false });
|
||||
const result = await manager.handleContainerDown('svc-1', 'cid');
|
||||
expect(result.action).toBe('ignored');
|
||||
expect(result.reason).toBe('disabled');
|
||||
});
|
||||
|
||||
test('returns skipped/cooldown when cooldownUntil is in the future', async () => {
|
||||
const { manager } = makeManager();
|
||||
// setPolicy() intentionally guards runtime fields; we have to set
|
||||
// cooldownUntil via the internal map to simulate an in-progress cooldown
|
||||
await manager.setPolicy('svc-1', { maxRetries: 3 });
|
||||
manager.policies.get('svc-1').cooldownUntil = Date.now() + 60_000;
|
||||
const result = await manager.handleContainerDown('svc-1', 'cid');
|
||||
expect(result.action).toBe('skipped');
|
||||
expect(result.reason).toBe('cooldown');
|
||||
});
|
||||
|
||||
test('increments currentRetries and calls docker.start on a successful restart', async () => {
|
||||
const { manager, docker } = makeManager();
|
||||
docker.client.getContainer.mockReturnValue({
|
||||
start: jest.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
await manager.setPolicy('svc-1', { maxRetries: 3, retryIntervalMs: 0 });
|
||||
|
||||
const onAttempt = jest.fn();
|
||||
const onSuccess = jest.fn();
|
||||
manager.on('auto-restart-attempt', onAttempt);
|
||||
manager.on('auto-restart-success', onSuccess);
|
||||
|
||||
const result = await manager.handleContainerDown('svc-1', 'cid-abc');
|
||||
expect(result.action).toBe('restarted');
|
||||
expect(result.attempt).toBe(1);
|
||||
expect(result.serviceId).toBe('svc-1');
|
||||
expect(docker.client.getContainer).toHaveBeenCalledWith('cid-abc');
|
||||
expect(onAttempt).toHaveBeenCalledTimes(1);
|
||||
expect(onSuccess).toHaveBeenCalledTimes(1);
|
||||
expect(manager.getPolicy('svc-1').currentRetries).toBe(1);
|
||||
});
|
||||
|
||||
test('emits auto-restart-failed and increments currentRetries when docker.start throws', async () => {
|
||||
const { manager, docker } = makeManager();
|
||||
docker.client.getContainer.mockReturnValue({
|
||||
start: jest.fn().mockRejectedValue(new Error('docker daemon down')),
|
||||
});
|
||||
await manager.setPolicy('svc-1', { maxRetries: 3, retryIntervalMs: 0 });
|
||||
|
||||
const onFailed = jest.fn();
|
||||
manager.on('auto-restart-failed', onFailed);
|
||||
|
||||
const result = await manager.handleContainerDown('svc-1', 'cid-abc');
|
||||
expect(result.action).toBe('failed');
|
||||
expect(result.error).toMatch(/docker daemon down/);
|
||||
expect(onFailed).toHaveBeenCalledTimes(1);
|
||||
expect(manager.getPolicy('svc-1').currentRetries).toBe(1);
|
||||
});
|
||||
|
||||
test('emits auto-restart-max-reached and sets cooldown when maxRetries exceeded', async () => {
|
||||
const { manager, docker } = makeManager();
|
||||
docker.client.getContainer.mockReturnValue({
|
||||
start: jest.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
await manager.setPolicy('svc-1', { maxRetries: 2, retryIntervalMs: 0 });
|
||||
|
||||
const onMax = jest.fn();
|
||||
manager.on('auto-restart-max-reached', onMax);
|
||||
|
||||
// First attempt: currentRetries=0 -> succeeds, increments to 1
|
||||
await manager.handleContainerDown('svc-1', 'cid');
|
||||
// Second: 1 -> succeeds, increments to 2
|
||||
await manager.handleContainerDown('svc-1', 'cid');
|
||||
// Third: 2 >= maxRetries(2) -> max-reached, currentRetries reset to 0
|
||||
const result = await manager.handleContainerDown('svc-1', 'cid');
|
||||
|
||||
expect(result.action).toBe('max-reached');
|
||||
expect(onMax).toHaveBeenCalledTimes(1);
|
||||
const policy = manager.getPolicy('svc-1');
|
||||
expect(policy.currentRetries).toBe(0);
|
||||
expect(policy.cooldownUntil).toBeGreaterThan(Date.now());
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleContainerUp', () => {
|
||||
test('resets currentRetries and cooldownUntil when service is tracked', async () => {
|
||||
const { manager } = makeManager();
|
||||
await manager.setPolicy('svc-1', { currentRetries: 2, cooldownUntil: Date.now() + 10000 });
|
||||
// Mutate via internal map (bypassing the setter guard)
|
||||
manager.policies.get('svc-1').currentRetries = 2;
|
||||
manager.policies.get('svc-1').cooldownUntil = Date.now() + 10000;
|
||||
|
||||
await manager.handleContainerUp('svc-1');
|
||||
const policy = manager.getPolicy('svc-1');
|
||||
expect(policy.currentRetries).toBe(0);
|
||||
expect(policy.cooldownUntil).toBeNull();
|
||||
});
|
||||
|
||||
test('is a no-op when service is not tracked', async () => {
|
||||
const { manager } = makeManager();
|
||||
await expect(manager.handleContainerUp('unknown')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('_handleStatusCheck', () => {
|
||||
test('triggers handleContainerDown on healthy→unhealthy transition', async () => {
|
||||
const { manager, docker } = makeManager();
|
||||
docker.client.getContainer.mockReturnValue({
|
||||
start: jest.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
await manager.setPolicy('svc-1', { maxRetries: 3, retryIntervalMs: 0 });
|
||||
// Pre-set previous health
|
||||
manager._previousHealth.set('svc-1', 'up');
|
||||
|
||||
const handleDownSpy = jest.spyOn(manager, 'handleContainerDown');
|
||||
await manager._handleStatusCheck({
|
||||
serviceId: 'svc-1',
|
||||
status: 'down',
|
||||
details: { containerId: 'cid-1' },
|
||||
});
|
||||
expect(handleDownSpy).toHaveBeenCalledWith('svc-1', 'cid-1');
|
||||
});
|
||||
|
||||
test('triggers handleContainerUp on unhealthy→healthy transition', async () => {
|
||||
const { manager } = makeManager();
|
||||
await manager.setPolicy('svc-1', { maxRetries: 3, retryIntervalMs: 0 });
|
||||
manager._previousHealth.set('svc-1', 'down');
|
||||
|
||||
const handleUpSpy = jest.spyOn(manager, 'handleContainerUp');
|
||||
await manager._handleStatusCheck({ serviceId: 'svc-1', status: 'up' });
|
||||
expect(handleUpSpy).toHaveBeenCalledWith('svc-1');
|
||||
});
|
||||
|
||||
test('does nothing for services without a policy', async () => {
|
||||
const { manager } = makeManager();
|
||||
const handleDownSpy = jest.spyOn(manager, 'handleContainerDown');
|
||||
const handleUpSpy = jest.spyOn(manager, 'handleContainerUp');
|
||||
await manager._handleStatusCheck({ serviceId: 'untracked', status: 'down' });
|
||||
expect(handleDownSpy).not.toHaveBeenCalled();
|
||||
expect(handleUpSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('ignores status with no serviceId', async () => {
|
||||
const { manager } = makeManager();
|
||||
const handleDownSpy = jest.spyOn(manager, 'handleContainerDown');
|
||||
await manager._handleStatusCheck({ status: 'down' });
|
||||
expect(handleDownSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('_resolveContainerId', () => {
|
||||
test('returns containerId from status.details when present', () => {
|
||||
const { manager } = makeManager();
|
||||
const cid = manager._resolveContainerId('svc-1', { details: { containerId: 'cid-details' } });
|
||||
expect(cid).toBe('cid-details');
|
||||
});
|
||||
|
||||
test('falls back to healthChecker.config.services[serviceId].containerId', () => {
|
||||
const { manager, healthChecker } = makeManager();
|
||||
healthChecker.config = { services: { 'svc-1': { containerId: 'cid-hc' } } };
|
||||
const cid = manager._resolveContainerId('svc-1', { details: {} });
|
||||
expect(cid).toBe('cid-hc');
|
||||
});
|
||||
|
||||
test('falls back to servicesStateManager.read when sync list is returned', () => {
|
||||
const { manager, servicesStateManager } = makeManager();
|
||||
servicesStateManager.read.mockReturnValue([
|
||||
{ id: 'svc-1', containerId: 'cid-state' },
|
||||
]);
|
||||
const cid = manager._resolveContainerId('svc-1', { details: {} });
|
||||
expect(cid).toBe('cid-state');
|
||||
});
|
||||
|
||||
test('returns null when no source has a containerId', () => {
|
||||
const { manager } = makeManager();
|
||||
const cid = manager._resolveContainerId('svc-unknown', { details: {} });
|
||||
expect(cid).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,791 @@
|
||||
// Backup Manager Tests
|
||||
// Validates backup/restore lifecycle for DashCaddy configurations
|
||||
|
||||
jest.mock('fs');
|
||||
jest.mock('child_process');
|
||||
jest.mock('../src/managers/credential-manager', () => ({
|
||||
exportBackup: jest.fn().mockReturnValue({ encrypted: 'cred-data' }),
|
||||
importBackup: jest.fn()
|
||||
}));
|
||||
jest.mock('../src/managers/resource-monitor', () => ({
|
||||
exportStats: jest.fn().mockReturnValue({ stats: [{ cpu: 10 }] }),
|
||||
importStats: jest.fn()
|
||||
}));
|
||||
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const credentialManager = require('../src/managers/credential-manager');
|
||||
const resourceMonitor = require('../src/managers/resource-monitor');
|
||||
|
||||
// Setup defaults BEFORE requiring singleton (constructor calls loadConfig/loadHistory)
|
||||
fs.existsSync.mockReturnValue(false);
|
||||
fs.readFileSync.mockReturnValue('{}');
|
||||
fs.writeFileSync.mockReturnValue(undefined);
|
||||
fs.mkdirSync.mockReturnValue(undefined);
|
||||
fs.unlinkSync.mockReturnValue(undefined);
|
||||
|
||||
const backupManager = require('../src/utilities/backup-manager');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers();
|
||||
|
||||
// Restore defaults
|
||||
fs.existsSync.mockReturnValue(false);
|
||||
fs.readFileSync.mockReturnValue('{}');
|
||||
fs.writeFileSync.mockReturnValue(undefined);
|
||||
fs.mkdirSync.mockReturnValue(undefined);
|
||||
fs.unlinkSync.mockReturnValue(undefined);
|
||||
|
||||
// Reset internal state
|
||||
backupManager.history = [];
|
||||
backupManager.config = { backups: {}, defaultRetention: { keep: 7 } };
|
||||
backupManager.running = false;
|
||||
// Clear all scheduled jobs directly (stop() only clears when running=true)
|
||||
for (const [, job] of backupManager.scheduledJobs.entries()) {
|
||||
clearInterval(job);
|
||||
}
|
||||
backupManager.scheduledJobs.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
backupManager.stop();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('BackupManager — backup/restore lifecycle', () => {
|
||||
|
||||
describe('constructor and config', () => {
|
||||
it('starts with empty config when no config file exists', () => {
|
||||
const config = backupManager.getConfig();
|
||||
expect(config.backups).toEqual({});
|
||||
expect(config.defaultRetention).toEqual({ keep: 7 });
|
||||
});
|
||||
|
||||
it('loadConfig returns saved config when file exists', () => {
|
||||
const savedConfig = {
|
||||
backups: { daily: { enabled: true, schedule: 'daily' } },
|
||||
defaultRetention: { keep: 14 }
|
||||
};
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
fs.readFileSync.mockReturnValue(JSON.stringify(savedConfig));
|
||||
const config = backupManager.loadConfig();
|
||||
expect(config.backups.daily).toBeDefined();
|
||||
expect(config.defaultRetention.keep).toBe(14);
|
||||
});
|
||||
|
||||
it('loadConfig returns defaults on error', () => {
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
fs.readFileSync.mockImplementation(() => { throw new Error('read error'); });
|
||||
const config = backupManager.loadConfig();
|
||||
expect(config.backups).toEqual({});
|
||||
});
|
||||
|
||||
it('loadHistory returns empty array when no file', () => {
|
||||
fs.existsSync.mockReturnValue(false);
|
||||
expect(backupManager.loadHistory()).toEqual([]);
|
||||
});
|
||||
|
||||
it('loadHistory loads saved entries', () => {
|
||||
const history = [{ id: 'test-1', status: 'success' }];
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
fs.readFileSync.mockReturnValue(JSON.stringify(history));
|
||||
expect(backupManager.loadHistory()).toEqual(history);
|
||||
});
|
||||
});
|
||||
|
||||
describe('start/stop scheduler', () => {
|
||||
it('does nothing on double start', () => {
|
||||
backupManager.start();
|
||||
backupManager.start(); // should not throw
|
||||
expect(backupManager.running).toBe(true);
|
||||
});
|
||||
|
||||
it('does nothing on stop when not running', () => {
|
||||
backupManager.stop(); // should not throw
|
||||
expect(backupManager.running).toBe(false);
|
||||
});
|
||||
|
||||
it('clears scheduled jobs on stop', () => {
|
||||
backupManager.scheduledJobs.set('test', setInterval(() => {}, 10000));
|
||||
backupManager.running = true;
|
||||
backupManager.stop();
|
||||
expect(backupManager.scheduledJobs.size).toBe(0);
|
||||
expect(backupManager.running).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scheduleBackup intervals', () => {
|
||||
it('schedules hourly backup', () => {
|
||||
backupManager.scheduleBackup('test', { schedule: 'hourly' });
|
||||
expect(backupManager.scheduledJobs.has('test')).toBe(true);
|
||||
});
|
||||
|
||||
it('schedules daily backup', () => {
|
||||
backupManager.scheduleBackup('test', { schedule: 'daily' });
|
||||
expect(backupManager.scheduledJobs.has('test')).toBe(true);
|
||||
});
|
||||
|
||||
it('schedules weekly backup', () => {
|
||||
backupManager.scheduleBackup('test', { schedule: 'weekly' });
|
||||
expect(backupManager.scheduledJobs.has('test')).toBe(true);
|
||||
});
|
||||
|
||||
it('schedules monthly backup', () => {
|
||||
backupManager.scheduleBackup('test', { schedule: 'monthly' });
|
||||
expect(backupManager.scheduledJobs.has('test')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts custom interval in minutes', () => {
|
||||
backupManager.scheduleBackup('test', { schedule: '30' });
|
||||
expect(backupManager.scheduledJobs.has('test')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid schedule', () => {
|
||||
backupManager.scheduleBackup('test', { schedule: 'bogus' });
|
||||
expect(backupManager.scheduledJobs.has('test')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compress/decompress', () => {
|
||||
it('round-trips data through gzip', async () => {
|
||||
const original = { version: '1.0', data: { services: [{ id: 'plex' }] } };
|
||||
const compressed = await backupManager.compressBackup(original);
|
||||
expect(Buffer.isBuffer(compressed)).toBe(true);
|
||||
|
||||
const decompressed = await backupManager.decompressBackup(compressed);
|
||||
expect(decompressed).toEqual(original);
|
||||
});
|
||||
|
||||
it('compressed output is smaller than JSON', async () => {
|
||||
const data = { bigArray: Array(100).fill({ id: 'test', name: 'test-service' }) };
|
||||
const compressed = await backupManager.compressBackup(data);
|
||||
expect(compressed.length).toBeLessThan(JSON.stringify(data).length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('encrypt/decrypt (AES-256-GCM)', () => {
|
||||
const testKey = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
it('round-trips data through encryption', async () => {
|
||||
const original = Buffer.from('DashCaddy backup data');
|
||||
const encrypted = await backupManager.encryptBackup(original, testKey);
|
||||
const decrypted = await backupManager.decryptBackup(encrypted, testKey);
|
||||
expect(decrypted.toString()).toBe('DashCaddy backup data');
|
||||
});
|
||||
|
||||
it('encrypted format is iv:authTag:ciphertext (base64)', async () => {
|
||||
const data = Buffer.from('test');
|
||||
const encrypted = await backupManager.encryptBackup(data, testKey);
|
||||
const parts = encrypted.toString().split(':');
|
||||
expect(parts.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('rejects tampered data (auth tag mismatch)', async () => {
|
||||
const data = Buffer.from('test');
|
||||
const encrypted = await backupManager.encryptBackup(data, testKey);
|
||||
// Corrupt the authTag so the GCM integrity check is guaranteed to fail.
|
||||
// The format is iv:authTag:ciphertext (all base64). We flip all bits of
|
||||
// the first authTag byte — XOR with 0xFF always changes the value, so
|
||||
// this can never be a no-op (unlike replacing a base64 char with a fixed
|
||||
// char, which collides ~1/64 of the time when that char already matches).
|
||||
const parts = encrypted.toString().split(':');
|
||||
const authTagBuf = Buffer.from(parts[1], 'base64');
|
||||
authTagBuf[0] ^= 0xFF;
|
||||
parts[1] = authTagBuf.toString('base64');
|
||||
const tampered = Buffer.from(parts.join(':'));
|
||||
await expect(backupManager.decryptBackup(tampered, testKey))
|
||||
.rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects wrong key', async () => {
|
||||
const data = Buffer.from('test');
|
||||
const encrypted = await backupManager.encryptBackup(data, testKey);
|
||||
const wrongKey = crypto.randomBytes(32).toString('hex');
|
||||
await expect(backupManager.decryptBackup(encrypted, wrongKey))
|
||||
.rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects invalid format (fewer than 3 parts)', async () => {
|
||||
await expect(backupManager.decryptBackup(Buffer.from('onlyonepart'), testKey))
|
||||
.rejects.toThrow('Invalid encrypted backup format');
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateChecksum', () => {
|
||||
it('returns SHA-256 hex digest', () => {
|
||||
const data = Buffer.from('test data');
|
||||
const checksum = backupManager.calculateChecksum(data);
|
||||
expect(checksum).toMatch(/^[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
it('same data produces same checksum', () => {
|
||||
const data = Buffer.from('DashCaddy');
|
||||
expect(backupManager.calculateChecksum(data))
|
||||
.toBe(backupManager.calculateChecksum(data));
|
||||
});
|
||||
|
||||
it('different data produces different checksum', () => {
|
||||
expect(backupManager.calculateChecksum(Buffer.from('A')))
|
||||
.not.toBe(backupManager.calculateChecksum(Buffer.from('B')));
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveToLocal', () => {
|
||||
it('creates backup directory if missing', async () => {
|
||||
fs.existsSync.mockReturnValue(false);
|
||||
await backupManager.saveToLocal(Buffer.from('data'), { path: '/custom/backups' }, 'test-123');
|
||||
expect(fs.mkdirSync).toHaveBeenCalledWith('/custom/backups', { recursive: true });
|
||||
});
|
||||
|
||||
it('writes backup file with correct name', async () => {
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
const result = await backupManager.saveToLocal(Buffer.from('data'), {}, 'daily-1234');
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('daily-1234.backup'),
|
||||
expect.any(Buffer)
|
||||
);
|
||||
expect(result.type).toBe('local');
|
||||
expect(result.size).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyBackup', () => {
|
||||
it('passes when checksum matches', async () => {
|
||||
const data = Buffer.from('verified');
|
||||
const checksum = crypto.createHash('sha256').update(data).digest('hex');
|
||||
fs.readFileSync.mockReturnValue(data);
|
||||
|
||||
const result = await backupManager.verifyBackup({ type: 'local', path: '/backup.dat' }, checksum);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('throws on checksum mismatch', async () => {
|
||||
fs.readFileSync.mockReturnValue(Buffer.from('tampered'));
|
||||
await expect(backupManager.verifyBackup(
|
||||
{ type: 'local', path: '/backup.dat' },
|
||||
'wrong-checksum'
|
||||
)).rejects.toThrow('checksum mismatch');
|
||||
});
|
||||
});
|
||||
|
||||
describe('history management', () => {
|
||||
it('addToHistory appends and saves', () => {
|
||||
backupManager.addToHistory({ id: 'test-1', status: 'success' });
|
||||
expect(backupManager.getHistory()).toHaveLength(1);
|
||||
expect(fs.writeFileSync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('caps history at 100 entries', () => {
|
||||
for (let i = 0; i < 110; i++) {
|
||||
backupManager.addToHistory({ id: `test-${i}`, status: 'success' });
|
||||
}
|
||||
expect(backupManager.history.length).toBe(100);
|
||||
});
|
||||
|
||||
it('getHistory returns newest first', () => {
|
||||
backupManager.addToHistory({ id: 'old', status: 'success' });
|
||||
backupManager.addToHistory({ id: 'new', status: 'success' });
|
||||
const history = backupManager.getHistory();
|
||||
expect(history[0].id).toBe('new');
|
||||
expect(history[1].id).toBe('old');
|
||||
});
|
||||
|
||||
it('getHistory respects limit', () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
backupManager.addToHistory({ id: `test-${i}`, status: 'success' });
|
||||
}
|
||||
expect(backupManager.getHistory(3)).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateConfig', () => {
|
||||
it('merges new config and saves', () => {
|
||||
backupManager.updateConfig({ customSetting: true });
|
||||
expect(backupManager.getConfig().customSetting).toBe(true);
|
||||
expect(fs.writeFileSync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restarts scheduler on config update', () => {
|
||||
backupManager.start();
|
||||
expect(backupManager.running).toBe(true);
|
||||
backupManager.updateConfig({ backups: {} });
|
||||
// Should still be running after restart
|
||||
expect(backupManager.running).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('backupServices / backupConfig', () => {
|
||||
it('reads services.json when it exists', () => {
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
fs.readFileSync.mockReturnValue(JSON.stringify([{ id: 'plex' }]));
|
||||
const result = backupManager.backupServices();
|
||||
expect(result).toEqual([{ id: 'plex' }]);
|
||||
});
|
||||
|
||||
it('returns null when services.json missing', () => {
|
||||
fs.existsSync.mockReturnValue(false);
|
||||
expect(backupManager.backupServices()).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null on read error', () => {
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
fs.readFileSync.mockImplementation(() => { throw new Error('read error'); });
|
||||
expect(backupManager.backupServices()).toBeNull();
|
||||
});
|
||||
|
||||
it('reads config.json when it exists', () => {
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
fs.readFileSync.mockReturnValue(JSON.stringify({ tld: '.sami' }));
|
||||
const result = backupManager.backupConfig();
|
||||
expect(result).toEqual({ tld: '.sami' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupOldBackups', () => {
|
||||
it('deletes backups beyond retention limit', async () => {
|
||||
// Add 5 successful backups
|
||||
for (let i = 0; i < 5; i++) {
|
||||
backupManager.history.push({
|
||||
id: `daily-${i}`,
|
||||
name: 'daily',
|
||||
status: 'success',
|
||||
timestamp: new Date(2026, 0, i + 1).toISOString(),
|
||||
locations: [{ type: 'local', path: `/backups/daily-${i}.backup` }]
|
||||
});
|
||||
}
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
|
||||
await backupManager.cleanupOldBackups('daily', { keep: 2 });
|
||||
|
||||
// Should delete 3 oldest
|
||||
expect(fs.unlinkSync).toHaveBeenCalledTimes(3);
|
||||
// History should have 2 remaining for 'daily'
|
||||
const remaining = backupManager.history.filter(b => b.name === 'daily');
|
||||
expect(remaining).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('keeps all when under retention limit', async () => {
|
||||
backupManager.history.push({
|
||||
id: 'daily-1', name: 'daily', status: 'success',
|
||||
timestamp: new Date().toISOString(),
|
||||
locations: [{ type: 'local', path: '/backups/daily-1.backup' }]
|
||||
});
|
||||
|
||||
await backupManager.cleanupOldBackups('daily', { keep: 7 });
|
||||
expect(fs.unlinkSync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('backupCredentials / backupStats', () => {
|
||||
it('returns credential export data', () => {
|
||||
const result = backupManager.backupCredentials();
|
||||
expect(result).toEqual({ encrypted: 'cred-data' });
|
||||
expect(credentialManager.exportBackup).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns null on credential export error', () => {
|
||||
credentialManager.exportBackup.mockImplementationOnce(() => { throw new Error('no key'); });
|
||||
expect(backupManager.backupCredentials()).toBeNull();
|
||||
});
|
||||
|
||||
it('returns stats export data', () => {
|
||||
const result = backupManager.backupStats();
|
||||
expect(result).toEqual({ stats: [{ cpu: 10 }] });
|
||||
expect(resourceMonitor.exportStats).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns null on stats export error', () => {
|
||||
resourceMonitor.exportStats.mockImplementationOnce(() => { throw new Error('no stats'); });
|
||||
expect(backupManager.backupStats()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createBackupData', () => {
|
||||
it('includes all sources when "all" specified', async () => {
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
fs.readFileSync.mockImplementation((filePath) => {
|
||||
if (typeof filePath === 'string') {
|
||||
if (filePath.includes('services')) return JSON.stringify([{ id: 'plex' }]);
|
||||
if (filePath.includes('config')) return JSON.stringify({ tld: '.sami' });
|
||||
}
|
||||
return '{}';
|
||||
});
|
||||
|
||||
const data = await backupManager.createBackupData(['all']);
|
||||
expect(data.version).toBe('1.0');
|
||||
expect(data.data.services).toEqual([{ id: 'plex' }]);
|
||||
expect(data.data.config).toEqual({ tld: '.sami' });
|
||||
expect(data.data.credentials).toEqual({ encrypted: 'cred-data' });
|
||||
expect(data.data.stats).toEqual({ stats: [{ cpu: 10 }] });
|
||||
});
|
||||
|
||||
it('includes only credentials when specified', async () => {
|
||||
const data = await backupManager.createBackupData(['credentials']);
|
||||
expect(data.data.credentials).toEqual({ encrypted: 'cred-data' });
|
||||
expect(data.data.services).toBeUndefined();
|
||||
});
|
||||
|
||||
it('includes only stats when specified', async () => {
|
||||
const data = await backupManager.createBackupData(['stats']);
|
||||
expect(data.data.stats).toEqual({ stats: [{ cpu: 10 }] });
|
||||
expect(data.data.services).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveToDestination', () => {
|
||||
it('routes to saveToLocal for local type', async () => {
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
const result = await backupManager.saveToDestination(Buffer.from('data'), { type: 'local' }, 'bk-1');
|
||||
expect(result.type).toBe('local');
|
||||
expect(fs.writeFileSync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws for unsupported destination type', async () => {
|
||||
await expect(backupManager.saveToDestination(Buffer.from('data'), { type: 's3' }, 'bk-1'))
|
||||
.rejects.toThrow('Unsupported destination type: s3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeBackup', () => {
|
||||
it('runs full backup pipeline and records success in history', async () => {
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
fs.readFileSync.mockImplementation((filePath) => {
|
||||
if (typeof filePath === 'string') {
|
||||
if (filePath.includes('services')) return JSON.stringify([{ id: 'plex' }]);
|
||||
if (filePath.includes('config')) return JSON.stringify({ tld: '.sami' });
|
||||
}
|
||||
return '{}';
|
||||
});
|
||||
|
||||
const events = [];
|
||||
backupManager.on('backup-start', e => events.push({ type: 'start', ...e }));
|
||||
backupManager.on('backup-complete', e => events.push({ type: 'complete', ...e }));
|
||||
|
||||
const result = await backupManager.executeBackup('daily', {
|
||||
include: ['services', 'config'],
|
||||
destinations: [{ type: 'local' }],
|
||||
verify: false
|
||||
});
|
||||
|
||||
expect(result.status).toBe('success');
|
||||
expect(result.name).toBe('daily');
|
||||
expect(result.compressed).toBe(true);
|
||||
expect(result.size).toBeGreaterThan(0);
|
||||
expect(backupManager.history).toHaveLength(1);
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0].type).toBe('start');
|
||||
expect(events[1].type).toBe('complete');
|
||||
|
||||
backupManager.removeAllListeners();
|
||||
});
|
||||
|
||||
it('runs encrypted backup pipeline', async () => {
|
||||
const key = crypto.randomBytes(32).toString('hex');
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
fs.readFileSync.mockReturnValue(JSON.stringify([{ id: 'plex' }]));
|
||||
|
||||
const result = await backupManager.executeBackup('encrypted', {
|
||||
include: ['services'],
|
||||
destinations: [{ type: 'local' }],
|
||||
encrypt: true,
|
||||
encryptionKey: key,
|
||||
verify: false
|
||||
});
|
||||
|
||||
expect(result.status).toBe('success');
|
||||
expect(result.encrypted).toBe(true);
|
||||
});
|
||||
|
||||
it('records failure in history when all destinations fail', async () => {
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
fs.readFileSync.mockReturnValue(JSON.stringify([{ id: 'plex' }]));
|
||||
fs.writeFileSync.mockImplementation((path) => {
|
||||
if (typeof path === 'string' && path.includes('.backup')) throw new Error('disk full');
|
||||
});
|
||||
|
||||
const events = [];
|
||||
backupManager.on('backup-failed', e => events.push(e));
|
||||
|
||||
await expect(backupManager.executeBackup('daily', {
|
||||
include: ['services'],
|
||||
destinations: [{ type: 'local' }],
|
||||
verify: false
|
||||
})).rejects.toThrow('Failed to save backup to any destination');
|
||||
|
||||
expect(backupManager.history).toHaveLength(1);
|
||||
expect(backupManager.history[0].status).toBe('failed');
|
||||
expect(events).toHaveLength(1);
|
||||
|
||||
backupManager.removeAllListeners();
|
||||
});
|
||||
|
||||
it('runs cleanup after successful backup with retention', async () => {
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
fs.readFileSync.mockReturnValue(JSON.stringify([{ id: 'plex' }]));
|
||||
|
||||
// Pre-fill history with old backups
|
||||
for (let i = 0; i < 5; i++) {
|
||||
backupManager.history.push({
|
||||
id: `daily-old-${i}`, name: 'daily', status: 'success',
|
||||
timestamp: new Date(2026, 0, i + 1).toISOString(),
|
||||
locations: [{ type: 'local', path: `/backups/daily-old-${i}.backup` }]
|
||||
});
|
||||
}
|
||||
|
||||
await backupManager.executeBackup('daily', {
|
||||
include: ['services'],
|
||||
destinations: [{ type: 'local' }],
|
||||
verify: false,
|
||||
retention: { keep: 2 }
|
||||
});
|
||||
|
||||
// Old backups should be cleaned up (5 old + 1 new = 6 total, keep 2 → delete 4)
|
||||
expect(fs.unlinkSync).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('restoreBackup', () => {
|
||||
it('throws when backup not found in history', async () => {
|
||||
await expect(backupManager.restoreBackup('nonexistent'))
|
||||
.rejects.toThrow('Backup not found: nonexistent');
|
||||
});
|
||||
|
||||
it('throws on unsupported backup version', async () => {
|
||||
// Create backup data with wrong version
|
||||
const wrongVersionData = { version: '2.0', data: {} };
|
||||
const compressed = await backupManager.compressBackup(wrongVersionData);
|
||||
|
||||
backupManager.history.push({
|
||||
id: 'test-restore',
|
||||
status: 'success',
|
||||
encrypted: false,
|
||||
locations: [{ type: 'local', path: '/backups/test-restore.backup' }]
|
||||
});
|
||||
|
||||
fs.readFileSync.mockReturnValue(compressed);
|
||||
|
||||
await expect(backupManager.restoreBackup('test-restore'))
|
||||
.rejects.toThrow('Unsupported backup version: 2.0');
|
||||
});
|
||||
|
||||
it('restores services and config from backup', async () => {
|
||||
const backupData = {
|
||||
version: '1.0',
|
||||
data: {
|
||||
services: [{ id: 'plex' }, { id: 'radarr' }],
|
||||
config: { tld: '.sami' }
|
||||
}
|
||||
};
|
||||
const compressed = await backupManager.compressBackup(backupData);
|
||||
|
||||
backupManager.history.push({
|
||||
id: 'test-restore',
|
||||
status: 'success',
|
||||
encrypted: false,
|
||||
locations: [{ type: 'local', path: '/backups/test-restore.backup' }]
|
||||
});
|
||||
|
||||
fs.readFileSync.mockReturnValue(compressed);
|
||||
|
||||
const events = [];
|
||||
backupManager.on('restore-start', e => events.push({ type: 'start', ...e }));
|
||||
backupManager.on('restore-complete', e => events.push({ type: 'complete', ...e }));
|
||||
|
||||
const result = await backupManager.restoreBackup('test-restore');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.restored.services).toBe(true);
|
||||
expect(result.restored.config).toBe(true);
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('services'),
|
||||
expect.stringContaining('plex')
|
||||
);
|
||||
expect(events).toHaveLength(2);
|
||||
|
||||
backupManager.removeAllListeners();
|
||||
});
|
||||
|
||||
it('restores credentials and stats from backup', async () => {
|
||||
const backupData = {
|
||||
version: '1.0',
|
||||
data: {
|
||||
credentials: { encrypted: 'cred-data' },
|
||||
stats: { stats: [{ cpu: 10 }] }
|
||||
}
|
||||
};
|
||||
const compressed = await backupManager.compressBackup(backupData);
|
||||
|
||||
backupManager.history.push({
|
||||
id: 'full-restore',
|
||||
status: 'success',
|
||||
encrypted: false,
|
||||
locations: [{ type: 'local', path: '/backups/full-restore.backup' }]
|
||||
});
|
||||
|
||||
fs.readFileSync.mockReturnValue(compressed);
|
||||
|
||||
const result = await backupManager.restoreBackup('full-restore');
|
||||
|
||||
expect(result.restored.credentials).toBe(true);
|
||||
expect(result.restored.stats).toBe(true);
|
||||
expect(credentialManager.importBackup).toHaveBeenCalledWith({ encrypted: 'cred-data' });
|
||||
expect(resourceMonitor.importStats).toHaveBeenCalledWith({ stats: [{ cpu: 10 }] });
|
||||
});
|
||||
|
||||
it('restores encrypted backup', async () => {
|
||||
const key = crypto.randomBytes(32).toString('hex');
|
||||
const backupData = { version: '1.0', data: { services: [{ id: 'plex' }] } };
|
||||
const compressed = await backupManager.compressBackup(backupData);
|
||||
const encrypted = await backupManager.encryptBackup(compressed, key);
|
||||
|
||||
backupManager.history.push({
|
||||
id: 'enc-restore',
|
||||
status: 'success',
|
||||
encrypted: true,
|
||||
locations: [{ type: 'local', path: '/backups/enc-restore.backup' }]
|
||||
});
|
||||
|
||||
fs.readFileSync.mockReturnValue(encrypted);
|
||||
|
||||
const result = await backupManager.restoreBackup('enc-restore', { encryptionKey: key });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.restored.services).toBe(true);
|
||||
});
|
||||
|
||||
it('emits restore-failed on error', async () => {
|
||||
backupManager.history.push({
|
||||
id: 'fail-restore',
|
||||
status: 'success',
|
||||
encrypted: false,
|
||||
locations: [{ type: 'local', path: '/backups/fail-restore.backup' }]
|
||||
});
|
||||
|
||||
fs.readFileSync.mockImplementation(() => { throw new Error('read error'); });
|
||||
|
||||
const events = [];
|
||||
backupManager.on('restore-failed', e => events.push(e));
|
||||
|
||||
await expect(backupManager.restoreBackup('fail-restore'))
|
||||
.rejects.toThrow();
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].error).toBeDefined();
|
||||
|
||||
backupManager.removeAllListeners();
|
||||
});
|
||||
|
||||
it('skips restore of specific sections when options disable them', async () => {
|
||||
const backupData = {
|
||||
version: '1.0',
|
||||
data: {
|
||||
services: [{ id: 'plex' }],
|
||||
config: { tld: '.sami' },
|
||||
credentials: { encrypted: 'data' },
|
||||
stats: { stats: [] }
|
||||
}
|
||||
};
|
||||
const compressed = await backupManager.compressBackup(backupData);
|
||||
|
||||
backupManager.history.push({
|
||||
id: 'partial-restore',
|
||||
status: 'success',
|
||||
encrypted: false,
|
||||
locations: [{ type: 'local', path: '/backups/partial.backup' }]
|
||||
});
|
||||
|
||||
fs.readFileSync.mockReturnValue(compressed);
|
||||
|
||||
const result = await backupManager.restoreBackup('partial-restore', {
|
||||
restoreServices: false,
|
||||
restoreConfig: false,
|
||||
restoreCredentials: false,
|
||||
restoreStats: false
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.restored.services).toBeUndefined();
|
||||
expect(result.restored.config).toBeUndefined();
|
||||
expect(result.restored.credentials).toBeUndefined();
|
||||
expect(result.restored.stats).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('start with configured backups', () => {
|
||||
it('schedules enabled backups on start', () => {
|
||||
backupManager.config = {
|
||||
backups: {
|
||||
daily: { enabled: true, schedule: 'daily' },
|
||||
disabled: { enabled: false, schedule: 'hourly' }
|
||||
},
|
||||
defaultRetention: { keep: 7 }
|
||||
};
|
||||
|
||||
backupManager.start();
|
||||
|
||||
expect(backupManager.scheduledJobs.has('daily')).toBe(true);
|
||||
expect(backupManager.scheduledJobs.has('disabled')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistence error handling', () => {
|
||||
it('saveConfig handles write error gracefully', () => {
|
||||
fs.writeFileSync.mockImplementation(() => { throw new Error('disk full'); });
|
||||
expect(() => backupManager.saveConfig()).not.toThrow();
|
||||
});
|
||||
|
||||
it('saveHistory handles write error gracefully', () => {
|
||||
fs.writeFileSync.mockImplementation(() => { throw new Error('disk full'); });
|
||||
expect(() => backupManager.saveHistory()).not.toThrow();
|
||||
});
|
||||
|
||||
it('backupConfig returns null on read error', () => {
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
fs.readFileSync.mockImplementation(() => { throw new Error('corrupt'); });
|
||||
expect(backupManager.backupConfig()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyBackup edge cases', () => {
|
||||
it('returns true for non-local backup type', async () => {
|
||||
const result = await backupManager.verifyBackup({ type: 'remote', path: 'na' }, 'checksum');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashCaddy scenarios', () => {
|
||||
it('full backup pipeline: services + config → compress → verify', async () => {
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
fs.readFileSync.mockImplementation((filePath) => {
|
||||
if (typeof filePath === 'string') {
|
||||
if (filePath.includes('services')) return JSON.stringify([{ id: 'plex' }, { id: 'radarr' }]);
|
||||
if (filePath.includes('config')) return JSON.stringify({ tld: '.sami', mode: 'homelab' });
|
||||
}
|
||||
return '{}';
|
||||
});
|
||||
|
||||
const data = await backupManager.createBackupData(['services', 'config']);
|
||||
expect(data.version).toBe('1.0');
|
||||
expect(data.data.services).toEqual([{ id: 'plex' }, { id: 'radarr' }]);
|
||||
expect(data.data.config).toEqual({ tld: '.sami', mode: 'homelab' });
|
||||
|
||||
// Compress and verify round-trip
|
||||
const compressed = await backupManager.compressBackup(data);
|
||||
const decompressed = await backupManager.decompressBackup(compressed);
|
||||
expect(decompressed.data.services).toEqual(data.data.services);
|
||||
});
|
||||
|
||||
it('encrypted backup round-trip with real AES-256-GCM', async () => {
|
||||
const key = crypto.randomBytes(32).toString('hex');
|
||||
const payload = { version: '1.0', data: { services: [{ id: 'jellyfin' }] } };
|
||||
|
||||
const compressed = await backupManager.compressBackup(payload);
|
||||
const encrypted = await backupManager.encryptBackup(compressed, key);
|
||||
const decrypted = await backupManager.decryptBackup(encrypted, key);
|
||||
const restored = await backupManager.decompressBackup(decrypted);
|
||||
|
||||
expect(restored.data.services[0].id).toBe('jellyfin');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* Smoke tests for config-drift-detector.js
|
||||
* Verifies the ConfigDriftDetector class detects drift across all categories,
|
||||
* exposes polling control, extracts container ports, and dispatches
|
||||
* drift notifications.
|
||||
*/
|
||||
|
||||
const EventEmitter = require('events');
|
||||
const { ConfigDriftDetector } = require('../src/managers/config-drift-detector');
|
||||
|
||||
function makeContainer(overrides = {}) {
|
||||
return {
|
||||
Id: 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789',
|
||||
Names: ['/dashcaddy-test'],
|
||||
Image: 'nginx:latest',
|
||||
State: 'running',
|
||||
Status: 'Up 5 minutes',
|
||||
Ports: [],
|
||||
Labels: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeDetector(overrides = {}) {
|
||||
const servicesStateManager = {
|
||||
read: jest.fn().mockResolvedValue([]),
|
||||
update: jest.fn().mockImplementation(async (updater) => {
|
||||
const data = await servicesStateManager.read();
|
||||
const list = Array.isArray(data) ? data : (data?.services || []);
|
||||
const next = updater(list);
|
||||
return next;
|
||||
}),
|
||||
...(overrides.servicesStateManager || {}),
|
||||
};
|
||||
|
||||
const docker = {
|
||||
client: {
|
||||
listContainers: jest.fn().mockResolvedValue([]),
|
||||
...(overrides.dockerClient || {}),
|
||||
},
|
||||
};
|
||||
|
||||
const notification = {
|
||||
send: jest.fn().mockResolvedValue({ success: true }),
|
||||
...(overrides.notification || {}),
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
docker,
|
||||
servicesStateManager,
|
||||
notification,
|
||||
log: {
|
||||
info: jest.fn(),
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
},
|
||||
logError: jest.fn(),
|
||||
};
|
||||
|
||||
const detector = new ConfigDriftDetector(ctx);
|
||||
return { detector, ctx, docker, servicesStateManager, notification };
|
||||
}
|
||||
|
||||
describe('ConfigDriftDetector', () => {
|
||||
describe('constructor', () => {
|
||||
test('extends EventEmitter and stores ctx dependencies', () => {
|
||||
const { detector, ctx } = makeDetector();
|
||||
expect(detector).toBeInstanceOf(EventEmitter);
|
||||
expect(detector.ctx).toBe(ctx);
|
||||
expect(detector.docker).toBe(ctx.docker);
|
||||
expect(detector.servicesStateManager).toBe(ctx.servicesStateManager);
|
||||
expect(detector.notification).toBe(ctx.notification);
|
||||
expect(detector.lastReport).toBeNull();
|
||||
expect(detector.isPolling()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detect()', () => {
|
||||
test('returns a clean report when services and containers are empty', async () => {
|
||||
const { detector } = makeDetector();
|
||||
const report = await detector.detect();
|
||||
expect(report).toHaveProperty('checkedAt');
|
||||
expect(report.missingContainers).toEqual([]);
|
||||
expect(report.unknownContainers).toEqual([]);
|
||||
expect(report.portMismatch).toEqual([]);
|
||||
expect(report.stateMismatch).toEqual([]);
|
||||
expect(report.staleRecords).toEqual([]);
|
||||
expect(report.hasDrift).toBe(false);
|
||||
});
|
||||
|
||||
test('flags missing containers when service containerId is not in Docker', async () => {
|
||||
const services = [{
|
||||
id: 'svc-1',
|
||||
name: 'svc-1',
|
||||
containerId: 'deadbeef00000000deadbeef0000000000000000deadbeef0000000000000000',
|
||||
}];
|
||||
const { detector, servicesStateManager, docker } = makeDetector();
|
||||
servicesStateManager.read.mockResolvedValue(services);
|
||||
docker.client.listContainers.mockResolvedValue([]);
|
||||
|
||||
const report = await detector.detect();
|
||||
expect(report.staleRecords).toHaveLength(1);
|
||||
expect(report.staleRecords[0].serviceId).toBe('svc-1');
|
||||
expect(report.hasDrift).toBe(true);
|
||||
});
|
||||
|
||||
test('flags port mismatches between service config and container', async () => {
|
||||
const services = [{
|
||||
id: 'svc-1',
|
||||
name: 'svc-1',
|
||||
port: 8080,
|
||||
containerId: 'abcdef012345',
|
||||
}];
|
||||
const containers = [makeContainer({
|
||||
Id: 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789',
|
||||
Ports: [{ PublicPort: 9090, PrivatePort: 80, Type: 'tcp' }],
|
||||
})];
|
||||
|
||||
const { detector, servicesStateManager, docker } = makeDetector();
|
||||
servicesStateManager.read.mockResolvedValue(services);
|
||||
docker.client.listContainers.mockResolvedValue(containers);
|
||||
|
||||
const report = await detector.detect();
|
||||
expect(report.portMismatch).toHaveLength(1);
|
||||
expect(report.portMismatch[0].configuredPort).toBe(8080);
|
||||
expect(report.portMismatch[0].actualPorts).toEqual([9090]);
|
||||
});
|
||||
|
||||
test('flags state mismatch when service is not running', async () => {
|
||||
const services = [{
|
||||
id: 'svc-1',
|
||||
name: 'svc-1',
|
||||
containerId: 'abcdef012345',
|
||||
}];
|
||||
const containers = [makeContainer({ State: 'exited', Status: 'Exited (1) 5 minutes ago' })];
|
||||
|
||||
const { detector, servicesStateManager, docker } = makeDetector();
|
||||
servicesStateManager.read.mockResolvedValue(services);
|
||||
docker.client.listContainers.mockResolvedValue(containers);
|
||||
|
||||
const report = await detector.detect();
|
||||
expect(report.missingContainers).toHaveLength(1);
|
||||
expect(report.stateMismatch).toHaveLength(1);
|
||||
expect(report.stateMismatch[0].actualState).toBe('exited');
|
||||
});
|
||||
|
||||
test('flags unknown managed containers not in services.json', async () => {
|
||||
const containers = [makeContainer({
|
||||
Labels: { 'sami.managed': 'true', 'sami.app': 'whoami' },
|
||||
})];
|
||||
|
||||
const { detector, docker, servicesStateManager } = makeDetector();
|
||||
docker.client.listContainers.mockResolvedValue(containers);
|
||||
servicesStateManager.read.mockResolvedValue([]);
|
||||
|
||||
const report = await detector.detect();
|
||||
expect(report.unknownContainers).toHaveLength(1);
|
||||
expect(report.unknownContainers[0].name).toBe('dashcaddy-test');
|
||||
expect(report.unknownContainers[0].app).toBe('whoami');
|
||||
});
|
||||
|
||||
test('emits drift-detected and sends notification when drift exists', async () => {
|
||||
const services = [{
|
||||
id: 'svc-1',
|
||||
name: 'svc-1',
|
||||
containerId: 'missingcontainer00',
|
||||
}];
|
||||
const { detector, servicesStateManager, docker, notification } = makeDetector();
|
||||
servicesStateManager.read.mockResolvedValue(services);
|
||||
docker.client.listContainers.mockResolvedValue([]);
|
||||
|
||||
const onDrift = jest.fn();
|
||||
detector.on('drift-detected', onDrift);
|
||||
await detector.detect();
|
||||
|
||||
expect(onDrift).toHaveBeenCalledTimes(1);
|
||||
expect(notification.send).toHaveBeenCalledTimes(1);
|
||||
expect(notification.send.mock.calls[0][0]).toBe('drift-detected');
|
||||
const payload = notification.send.mock.calls[0][1];
|
||||
expect(payload.text).toMatch(/drift/i);
|
||||
expect(payload.report).toBeDefined();
|
||||
});
|
||||
|
||||
test('caches the report on the instance', async () => {
|
||||
const { detector } = makeDetector();
|
||||
const report = await detector.detect();
|
||||
expect(detector.lastReport).toBe(report);
|
||||
});
|
||||
|
||||
test('handles services as a wrapper object with .services field', async () => {
|
||||
const { detector, servicesStateManager } = makeDetector();
|
||||
servicesStateManager.read.mockResolvedValue({ services: [] });
|
||||
const report = await detector.detect();
|
||||
expect(report).toBeDefined();
|
||||
expect(report.hasDrift).toBe(false);
|
||||
});
|
||||
|
||||
test('tolerates Docker listContainers failure (logs and continues)', async () => {
|
||||
const { detector, docker, ctx } = makeDetector();
|
||||
docker.client.listContainers.mockRejectedValue(new Error('docker daemon down'));
|
||||
const report = await detector.detect();
|
||||
expect(report).toBeDefined();
|
||||
expect(report.hasDrift).toBe(false);
|
||||
expect(ctx.log.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('autoFix()', () => {
|
||||
test('removes stale records via servicesStateManager.update', async () => {
|
||||
const services = [
|
||||
{ id: 'svc-good', name: 'svc-good', containerId: 'liveid0000000000000000000000000000' },
|
||||
{ id: 'svc-stale', name: 'svc-stale', containerId: 'deadbeef00000000deadbeef0000000000000000deadbeef00000000' },
|
||||
];
|
||||
const containers = [makeContainer({
|
||||
Id: 'liveid0000000000000000000000000000000000000000000000000000000000',
|
||||
})];
|
||||
|
||||
const { detector, servicesStateManager, docker } = makeDetector();
|
||||
servicesStateManager.read.mockResolvedValue(services);
|
||||
servicesStateManager.update.mockImplementation(async (updater) => {
|
||||
const next = updater(services);
|
||||
return next;
|
||||
});
|
||||
docker.client.listContainers.mockResolvedValue(containers);
|
||||
|
||||
const result = await detector.autoFix();
|
||||
expect(result.staleRemoved).toBe(1);
|
||||
expect(result.unknownFlagged).toBe(0);
|
||||
expect(servicesStateManager.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('polling', () => {
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('startPolling/stopPolling toggles isPolling', () => {
|
||||
const { detector } = makeDetector();
|
||||
expect(detector.isPolling()).toBe(false);
|
||||
detector.startPolling(60000);
|
||||
expect(detector.isPolling()).toBe(true);
|
||||
detector.stopPolling();
|
||||
expect(detector.isPolling()).toBe(false);
|
||||
});
|
||||
|
||||
test('startPolling clears any existing timer before starting a new one', () => {
|
||||
const { detector } = makeDetector();
|
||||
detector.startPolling(60000);
|
||||
const firstTimer = detector._pollTimer;
|
||||
detector.startPolling(120000);
|
||||
expect(detector._pollTimer).not.toBe(firstTimer);
|
||||
detector.stopPolling();
|
||||
});
|
||||
|
||||
test('stopPolling is a safe no-op when not started', () => {
|
||||
const { detector } = makeDetector();
|
||||
expect(() => detector.stopPolling()).not.toThrow();
|
||||
expect(detector.isPolling()).toBe(false);
|
||||
});
|
||||
|
||||
test('runs detect on the polling interval', async () => {
|
||||
jest.useFakeTimers();
|
||||
const { detector } = makeDetector();
|
||||
const detectSpy = jest.spyOn(detector, 'detect').mockResolvedValue({
|
||||
checkedAt: new Date().toISOString(),
|
||||
missingContainers: [],
|
||||
unknownContainers: [],
|
||||
portMismatch: [],
|
||||
stateMismatch: [],
|
||||
staleRecords: [],
|
||||
hasDrift: false,
|
||||
});
|
||||
|
||||
detector.startPolling(1000);
|
||||
jest.advanceTimersByTime(3500);
|
||||
// 3 intervals should have fired (1000, 2000, 3000)
|
||||
expect(detectSpy.mock.calls.length).toBeGreaterThanOrEqual(3);
|
||||
detector.stopPolling();
|
||||
detectSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('_extractContainerPorts', () => {
|
||||
test('returns mapped public ports', () => {
|
||||
const { detector } = makeDetector();
|
||||
const ports = detector._extractContainerPorts({
|
||||
Ports: [
|
||||
{ PublicPort: 8080, PrivatePort: 80, Type: 'tcp' },
|
||||
{ PublicPort: 8443, PrivatePort: 443, Type: 'tcp' },
|
||||
{ PrivatePort: 53, Type: 'udp' }, // No PublicPort → not exposed
|
||||
],
|
||||
});
|
||||
expect(ports).toEqual([8080, 8443]);
|
||||
});
|
||||
|
||||
test('returns [] when container has no Ports field', () => {
|
||||
const { detector } = makeDetector();
|
||||
expect(detector._extractContainerPorts({})).toEqual([]);
|
||||
expect(detector._extractContainerPorts({ Ports: null })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_sendDriftNotification', () => {
|
||||
test('returns early when no notification manager is present', async () => {
|
||||
const { detector } = makeDetector({ notification: null });
|
||||
// Replace the field with null/undefined to simulate missing
|
||||
detector.notification = null;
|
||||
const result = await detector._sendDriftNotification({ hasDrift: true });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.reason).toMatch(/no-notification-manager/i);
|
||||
});
|
||||
|
||||
test('formats message with one line per drift category', async () => {
|
||||
const { detector, notification } = makeDetector();
|
||||
const report = {
|
||||
missingContainers: [{ name: 'app-a' }],
|
||||
unknownContainers: [{ name: 'app-b' }],
|
||||
portMismatch: [{ name: 'app-c' }],
|
||||
stateMismatch: [],
|
||||
staleRecords: [{ name: 'app-d' }],
|
||||
hasDrift: true,
|
||||
};
|
||||
await detector._sendDriftNotification(report);
|
||||
expect(notification.send).toHaveBeenCalledTimes(1);
|
||||
const payload = notification.send.mock.calls[0][1];
|
||||
expect(payload.text).toMatch(/Missing containers: app-a/);
|
||||
expect(payload.text).toMatch(/Unknown managed containers: app-b/);
|
||||
expect(payload.text).toMatch(/Port mismatches: app-c/);
|
||||
expect(payload.text).toMatch(/Stale records: app-d/);
|
||||
expect(payload.report).toBe(report);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Config migration tests
|
||||
*
|
||||
* These tests verify that a config file from any older version of DashCaddy
|
||||
* gets correctly migrated to the current version. Migration MUST be:
|
||||
* - Deterministic (same input always produces same output)
|
||||
* - Idempotent (running migration on already-migrated config is a no-op)
|
||||
* - Safe (no data loss; only adds fields, never removes user values)
|
||||
* - Silent (no exceptions thrown for any version from 0 to CURRENT)
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const {
|
||||
CURRENT_VERSION,
|
||||
migrations,
|
||||
migrate,
|
||||
loadAndMigrate
|
||||
} = require('../src/config/migrations');
|
||||
|
||||
describe('config/migrations', () => {
|
||||
let tmpDir;
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc-mig-test-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('migrate()', () => {
|
||||
test('null/empty config returns fresh v_current', () => {
|
||||
const result = migrate(null);
|
||||
expect(result._version).toBe(CURRENT_VERSION);
|
||||
});
|
||||
|
||||
test('undefined config returns fresh v_current', () => {
|
||||
const result = migrate(undefined);
|
||||
expect(result._version).toBe(CURRENT_VERSION);
|
||||
});
|
||||
|
||||
test('v0 (no _version) migrates all the way to current', () => {
|
||||
const v0 = { tld: '.home', customValue: 'preserved' };
|
||||
const result = migrate(v0);
|
||||
expect(result._version).toBe(CURRENT_VERSION);
|
||||
// User data must be preserved
|
||||
expect(result.tld).toBe('.home');
|
||||
expect(result.customValue).toBe('preserved');
|
||||
});
|
||||
|
||||
test('each intermediate version migrates forward to current', () => {
|
||||
for (let v = 0; v < CURRENT_VERSION; v++) {
|
||||
const config = { _version: v, tld: '.test' };
|
||||
const result = migrate(config);
|
||||
// Final version is always CURRENT_VERSION after running all migrations
|
||||
expect(result._version).toBe(CURRENT_VERSION);
|
||||
// User data preserved
|
||||
expect(result.tld).toBe('.test');
|
||||
}
|
||||
});
|
||||
|
||||
test('config at current version passes through unchanged', () => {
|
||||
const current = { _version: CURRENT_VERSION, tld: '.home', customField: 'kept' };
|
||||
const result = migrate(current);
|
||||
expect(result).toEqual(current);
|
||||
});
|
||||
|
||||
test('config from FUTURE version is left alone (forward compat)', () => {
|
||||
const future = { _version: 999, tld: '.home', newField: 'unknown' };
|
||||
const result = migrate(future);
|
||||
// We don't touch future configs — let validation catch issues
|
||||
expect(result._version).toBe(999);
|
||||
expect(result.newField).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('v0 → v1 migration: dns normalization', () => {
|
||||
test('string dns gets converted to object', () => {
|
||||
const result = migrations[1]({ dns: '192.168.1.1' });
|
||||
expect(result.dns).toEqual({ ip: '192.168.1.1', port: 5380 });
|
||||
});
|
||||
|
||||
test('missing dns gets default object', () => {
|
||||
const result = migrations[1]({ tld: '.home' });
|
||||
expect(result.dns).toEqual({ ip: '', port: 5380 });
|
||||
});
|
||||
|
||||
test('object dns passes through unchanged', () => {
|
||||
const result = migrations[1]({ dns: { ip: '10.0.0.1', port: 5380, custom: 'kept' } });
|
||||
expect(result.dns.ip).toBe('10.0.0.1');
|
||||
expect(result.dns.custom).toBe('kept');
|
||||
});
|
||||
|
||||
test('_version is set to 1', () => {
|
||||
const result = migrations[1]({ tld: '.home' });
|
||||
expect(result._version).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('v1 → v2 migration: dns.provider field', () => {
|
||||
test('adds provider: technitium default', () => {
|
||||
const result = migrations[2]({ dns: { ip: '10.0.0.1', port: 5380 }, _version: 1 });
|
||||
expect(result.dns.provider).toBe('technitium');
|
||||
expect(result.dns.ip).toBe('10.0.0.1');
|
||||
expect(result.dns.port).toBe(5380);
|
||||
});
|
||||
|
||||
test('respects existing provider if set', () => {
|
||||
const result = migrations[2]({ dns: { provider: 'cloudflare', ip: 'cf' }, _version: 1 });
|
||||
expect(result.dns.provider).toBe('cloudflare');
|
||||
});
|
||||
|
||||
test('_version is set to 2', () => {
|
||||
const result = migrations[2]({ _version: 1 });
|
||||
expect(result._version).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadAndMigrate()', () => {
|
||||
test('creates fresh config when file does not exist', () => {
|
||||
const configFile = path.join(tmpDir, 'config.json');
|
||||
const result = loadAndMigrate(configFile, null);
|
||||
expect(result._version).toBe(CURRENT_VERSION);
|
||||
// Should NOT write a file when there was nothing to migrate
|
||||
expect(fs.existsSync(configFile)).toBe(false);
|
||||
});
|
||||
|
||||
test('migrates old config and writes back to disk', () => {
|
||||
const configFile = path.join(tmpDir, 'config.json');
|
||||
// Write an unversioned config (v0)
|
||||
fs.writeFileSync(configFile, JSON.stringify({ tld: '.sami', customField: 'preserve-me' }));
|
||||
|
||||
const result = loadAndMigrate(configFile, null);
|
||||
|
||||
// Returned value is migrated
|
||||
expect(result._version).toBe(CURRENT_VERSION);
|
||||
expect(result.tld).toBe('.sami');
|
||||
expect(result.customField).toBe('preserve-me');
|
||||
|
||||
// File on disk is updated
|
||||
const written = JSON.parse(fs.readFileSync(configFile, 'utf8'));
|
||||
expect(written._version).toBe(CURRENT_VERSION);
|
||||
expect(written.tld).toBe('.sami');
|
||||
});
|
||||
|
||||
test('does not rewrite file when already at current version', () => {
|
||||
const configFile = path.join(tmpDir, 'config.json');
|
||||
const original = JSON.stringify({ _version: CURRENT_VERSION, tld: '.home' }, null, 2);
|
||||
fs.writeFileSync(configFile, original);
|
||||
|
||||
// Record mtime before
|
||||
const mtimeBefore = fs.statSync(configFile).mtimeMs;
|
||||
// Wait a tick
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < 50) {} // 50ms busy-wait
|
||||
|
||||
loadAndMigrate(configFile, null);
|
||||
|
||||
// File should not have been rewritten (mtime unchanged)
|
||||
const mtimeAfter = fs.statSync(configFile).mtimeMs;
|
||||
expect(mtimeAfter).toBe(mtimeBefore);
|
||||
});
|
||||
|
||||
test('handles corrupt JSON gracefully (returns defaults, no crash)', () => {
|
||||
const configFile = path.join(tmpDir, 'config.json');
|
||||
fs.writeFileSync(configFile, '{ this is not valid json');
|
||||
|
||||
// Should not throw
|
||||
const result = loadAndMigrate(configFile, null);
|
||||
expect(result._version).toBe(CURRENT_VERSION);
|
||||
});
|
||||
|
||||
test('creates parent directory if missing', () => {
|
||||
const nested = path.join(tmpDir, 'nested', 'subdir', 'config.json');
|
||||
// Pre-create parent dirs (test setup)
|
||||
fs.mkdirSync(path.dirname(nested), { recursive: true });
|
||||
fs.writeFileSync(nested, JSON.stringify({ tld: '.home' }));
|
||||
|
||||
const result = loadAndMigrate(nested, null);
|
||||
expect(result._version).toBe(CURRENT_VERSION);
|
||||
});
|
||||
|
||||
test('full chain: v0 file with string dns becomes v2 with provider', () => {
|
||||
const configFile = path.join(tmpDir, 'config.json');
|
||||
fs.writeFileSync(configFile, JSON.stringify({
|
||||
tld: '.sami',
|
||||
dns: '10.0.0.1'
|
||||
}));
|
||||
|
||||
const result = loadAndMigrate(configFile, null);
|
||||
expect(result._version).toBe(CURRENT_VERSION);
|
||||
// After full chain, dns is normalized to object AND has provider
|
||||
expect(result.dns.ip).toBe('10.0.0.1');
|
||||
expect(result.dns.port).toBe(5380);
|
||||
expect(result.dns.provider).toBe('technitium');
|
||||
});
|
||||
});
|
||||
|
||||
describe('idempotency', () => {
|
||||
test('running migration twice produces same result', () => {
|
||||
const v0 = { tld: '.home', customField: 'x' };
|
||||
const first = migrate(v0);
|
||||
const second = migrate(first);
|
||||
expect(second).toEqual(first);
|
||||
});
|
||||
|
||||
test('loadAndMigrate is idempotent across reloads', () => {
|
||||
const configFile = path.join(tmpDir, 'config.json');
|
||||
fs.writeFileSync(configFile, JSON.stringify({ tld: '.home' }));
|
||||
|
||||
const first = loadAndMigrate(configFile, null);
|
||||
const second = loadAndMigrate(configFile, null);
|
||||
expect(second).toEqual(first);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,347 @@
|
||||
// Mock dependencies before requiring the module
|
||||
jest.mock('../src/security/keychain-manager', () => ({
|
||||
available: false,
|
||||
store: jest.fn().mockResolvedValue(false),
|
||||
retrieve: jest.fn().mockResolvedValue(null),
|
||||
delete: jest.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
jest.mock('../src/security/crypto-utils', () => ({
|
||||
encrypt: jest.fn(data => `enc:tag:${Buffer.from(String(data)).toString('base64')}`),
|
||||
decrypt: jest.fn(data => {
|
||||
const parts = data.split(':');
|
||||
return Buffer.from(parts[2], 'base64').toString('utf8');
|
||||
}),
|
||||
isEncrypted: jest.fn(data => typeof data === 'string' && data.startsWith('enc:')),
|
||||
loadOrCreateKey: jest.fn(() => Buffer.alloc(32, 'k')),
|
||||
rotateKey: jest.fn(() => ({ oldKey: Buffer.alloc(32, 'k'), newKey: Buffer.alloc(32, 'n') })),
|
||||
}));
|
||||
|
||||
jest.mock('proper-lockfile', () => ({
|
||||
lock: jest.fn().mockResolvedValue(jest.fn().mockResolvedValue()),
|
||||
unlock: jest.fn().mockResolvedValue(),
|
||||
check: jest.fn().mockResolvedValue(false),
|
||||
}));
|
||||
|
||||
jest.mock('fs', () => ({
|
||||
existsSync: jest.fn().mockReturnValue(true),
|
||||
readFileSync: jest.fn().mockReturnValue('{}'),
|
||||
writeFileSync: jest.fn(),
|
||||
mkdirSync: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('CredentialManager', () => {
|
||||
let credentialManager;
|
||||
let fs, lockfile, keychainManager, cryptoUtils;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
|
||||
// Re-get mocked modules
|
||||
fs = require('fs');
|
||||
lockfile = require('proper-lockfile');
|
||||
keychainManager = require('../src/security/keychain-manager');
|
||||
cryptoUtils = require('../src/security/crypto-utils');
|
||||
|
||||
// Reset mock implementations
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
fs.readFileSync.mockReturnValue('{}');
|
||||
fs.writeFileSync.mockImplementation(() => {});
|
||||
lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue());
|
||||
keychainManager.available = false;
|
||||
|
||||
credentialManager = require('../src/managers/credential-manager');
|
||||
credentialManager.cache.clear();
|
||||
});
|
||||
|
||||
describe('store', () => {
|
||||
it('stores value in encrypted file when keychain unavailable', async () => {
|
||||
const result = await credentialManager.store('test.key', 'secret-value');
|
||||
expect(result).toBe(true);
|
||||
expect(cryptoUtils.encrypt).toHaveBeenCalledWith('secret-value');
|
||||
expect(fs.writeFileSync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stores value in keychain when available', async () => {
|
||||
keychainManager.available = true;
|
||||
// Need to get a fresh instance that sees available=true
|
||||
jest.resetModules();
|
||||
fs = require('fs');
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
fs.readFileSync.mockReturnValue('{}');
|
||||
fs.writeFileSync.mockImplementation(() => {});
|
||||
lockfile = require('proper-lockfile');
|
||||
lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue());
|
||||
keychainManager = require('../src/security/keychain-manager');
|
||||
keychainManager.available = true;
|
||||
keychainManager.store.mockResolvedValue(true);
|
||||
credentialManager = require('../src/managers/credential-manager');
|
||||
|
||||
const result = await credentialManager.store('test.key', 'value');
|
||||
expect(result).toBe(true);
|
||||
expect(keychainManager.store).toHaveBeenCalledWith('test.key', 'value');
|
||||
});
|
||||
|
||||
it('falls back to file if keychain store fails', async () => {
|
||||
keychainManager.available = true;
|
||||
jest.resetModules();
|
||||
fs = require('fs');
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
fs.readFileSync.mockReturnValue('{}');
|
||||
fs.writeFileSync.mockImplementation(() => {});
|
||||
lockfile = require('proper-lockfile');
|
||||
lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue());
|
||||
keychainManager = require('../src/security/keychain-manager');
|
||||
keychainManager.available = true;
|
||||
keychainManager.store.mockResolvedValue(false);
|
||||
cryptoUtils = require('../src/security/crypto-utils');
|
||||
credentialManager = require('../src/managers/credential-manager');
|
||||
|
||||
const result = await credentialManager.store('test.key', 'value');
|
||||
expect(result).toBe(true);
|
||||
expect(cryptoUtils.encrypt).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects empty key', async () => {
|
||||
const result = await credentialManager.store('', 'value');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects empty value', async () => {
|
||||
const result = await credentialManager.store('key', '');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('updates cache after storing', async () => {
|
||||
await credentialManager.store('test.key', 'cached-value');
|
||||
expect(credentialManager.cache.has('test.key')).toBe(true);
|
||||
expect(credentialManager.cache.get('test.key').value).toBe('cached-value');
|
||||
});
|
||||
});
|
||||
|
||||
describe('retrieve', () => {
|
||||
it('returns cached value within TTL', async () => {
|
||||
credentialManager.cache.set('cached.key', {
|
||||
value: 'cached-val',
|
||||
exp: Date.now() + 60000
|
||||
});
|
||||
const result = await credentialManager.retrieve('cached.key');
|
||||
expect(result).toBe('cached-val');
|
||||
});
|
||||
|
||||
it('does not return expired cache entry', async () => {
|
||||
credentialManager.cache.set('expired.key', {
|
||||
value: 'old-val',
|
||||
exp: Date.now() - 1000
|
||||
});
|
||||
// Set up file to return data
|
||||
fs.readFileSync.mockReturnValue(JSON.stringify({
|
||||
'expired.key': { value: 'enc:tag:' + Buffer.from('file-val').toString('base64') }
|
||||
}));
|
||||
const result = await credentialManager.retrieve('expired.key');
|
||||
expect(result).toBe('file-val');
|
||||
});
|
||||
|
||||
it('retrieves from encrypted file as fallback', async () => {
|
||||
fs.readFileSync.mockReturnValue(JSON.stringify({
|
||||
'file.key': { value: 'enc:tag:' + Buffer.from('secret').toString('base64') }
|
||||
}));
|
||||
const result = await credentialManager.retrieve('file.key');
|
||||
expect(result).toBe('secret');
|
||||
});
|
||||
|
||||
it('returns null when key not found', async () => {
|
||||
fs.readFileSync.mockReturnValue('{}');
|
||||
const result = await credentialManager.retrieve('missing.key');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null on error', async () => {
|
||||
fs.existsSync.mockReturnValue(false);
|
||||
fs.readFileSync.mockImplementation(() => { throw new Error('fail'); });
|
||||
const result = await credentialManager.retrieve('broken.key');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('removes from cache, keychain, and file', async () => {
|
||||
credentialManager.cache.set('del.key', { value: 'x', exp: Date.now() + 60000 });
|
||||
fs.readFileSync.mockReturnValue(JSON.stringify({ 'del.key': { value: 'x' } }));
|
||||
|
||||
const result = await credentialManager.delete('del.key');
|
||||
expect(result).toBe(true);
|
||||
expect(credentialManager.cache.has('del.key')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false on error', async () => {
|
||||
lockfile.lock.mockRejectedValue(new Error('lock fail'));
|
||||
const result = await credentialManager.delete('fail.key');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('returns all keys from credentials file', async () => {
|
||||
fs.readFileSync.mockReturnValue(JSON.stringify({
|
||||
'key1': { value: 'a' },
|
||||
'key2': { value: 'b' }
|
||||
}));
|
||||
const keys = await credentialManager.list();
|
||||
expect(keys).toEqual(['key1', 'key2']);
|
||||
});
|
||||
|
||||
it('returns empty array on error', async () => {
|
||||
fs.existsSync.mockReturnValue(false);
|
||||
const keys = await credentialManager.list();
|
||||
expect(keys).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMetadata', () => {
|
||||
it('returns metadata for a credential', async () => {
|
||||
fs.readFileSync.mockReturnValue(JSON.stringify({
|
||||
'test.key': { value: 'x', metadata: { provider: 'cloudflare' } }
|
||||
}));
|
||||
const meta = await credentialManager.getMetadata('test.key');
|
||||
expect(meta).toEqual({ provider: 'cloudflare' });
|
||||
});
|
||||
|
||||
it('returns null when key not found', async () => {
|
||||
fs.readFileSync.mockReturnValue('{}');
|
||||
const meta = await credentialManager.getMetadata('missing');
|
||||
expect(meta).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('_lockedUpdate', () => {
|
||||
it('acquires lock, reads, applies update, writes, releases', async () => {
|
||||
const releaseFn = jest.fn().mockResolvedValue();
|
||||
lockfile.lock.mockResolvedValue(releaseFn);
|
||||
fs.readFileSync.mockReturnValue(JSON.stringify({ a: 1 }));
|
||||
|
||||
await credentialManager._lockedUpdate(creds => {
|
||||
creds.b = 2;
|
||||
return creds;
|
||||
});
|
||||
|
||||
expect(lockfile.lock).toHaveBeenCalled();
|
||||
expect(fs.writeFileSync).toHaveBeenCalled();
|
||||
const writtenData = JSON.parse(fs.writeFileSync.mock.calls[0][1]);
|
||||
expect(writtenData).toEqual({ a: 1, b: 2 });
|
||||
expect(releaseFn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws on ELOCKED error', async () => {
|
||||
const error = new Error('locked');
|
||||
error.code = 'ELOCKED';
|
||||
lockfile.lock.mockRejectedValue(error);
|
||||
|
||||
await expect(credentialManager._lockedUpdate(() => ({}))).rejects.toThrow('locked by another process');
|
||||
});
|
||||
|
||||
it('releases lock even on error', async () => {
|
||||
const releaseFn = jest.fn().mockResolvedValue();
|
||||
lockfile.lock.mockResolvedValue(releaseFn);
|
||||
fs.readFileSync.mockReturnValue('{}');
|
||||
|
||||
await expect(
|
||||
credentialManager._lockedUpdate(() => { throw new Error('update error'); })
|
||||
).rejects.toThrow('update error');
|
||||
|
||||
expect(releaseFn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rotateEncryptionKey', () => {
|
||||
it('decrypts all credentials then re-encrypts with new key', 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: {} }
|
||||
}));
|
||||
|
||||
const result = await credentialManager.rotateEncryptionKey();
|
||||
expect(result).toBe(true);
|
||||
expect(cryptoUtils.rotateKey).toHaveBeenCalled();
|
||||
expect(fs.writeFileSync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears cache after rotation', async () => {
|
||||
const releaseFn = jest.fn().mockResolvedValue();
|
||||
lockfile.lock.mockResolvedValue(releaseFn);
|
||||
credentialManager.cache.set('x', { value: 'y', exp: Date.now() + 60000 });
|
||||
// Must have non-empty credentials so code path reaches cache.clear()
|
||||
fs.readFileSync.mockReturnValue(JSON.stringify({
|
||||
'key1': { value: 'enc:tag:' + Buffer.from('val').toString('base64'), metadata: {} }
|
||||
}));
|
||||
|
||||
await credentialManager.rotateEncryptionKey();
|
||||
expect(credentialManager.cache.size).toBe(0);
|
||||
});
|
||||
|
||||
it('returns false on error', async () => {
|
||||
lockfile.lock.mockRejectedValue(new Error('nope'));
|
||||
const result = await credentialManager.rotateEncryptionKey();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exportBackup / importBackup', () => {
|
||||
it('exportBackup returns encrypted JSON string', async () => {
|
||||
fs.readFileSync.mockReturnValue(JSON.stringify({ key1: { value: 'x' } }));
|
||||
const backup = await credentialManager.exportBackup();
|
||||
expect(cryptoUtils.encrypt).toHaveBeenCalled();
|
||||
expect(typeof backup).toBe('string');
|
||||
});
|
||||
|
||||
it('importBackup decrypts and replaces credentials', async () => {
|
||||
const backupData = JSON.stringify({
|
||||
version: '1.0',
|
||||
exportedAt: new Date().toISOString(),
|
||||
credentials: { imported: { value: 'y' } }
|
||||
});
|
||||
const encrypted = `enc:tag:${Buffer.from(backupData).toString('base64')}`;
|
||||
|
||||
const releaseFn = jest.fn().mockResolvedValue();
|
||||
lockfile.lock.mockResolvedValue(releaseFn);
|
||||
fs.readFileSync.mockReturnValue('{}');
|
||||
|
||||
const result = await credentialManager.importBackup(encrypted);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('importBackup rejects unsupported backup version', async () => {
|
||||
const backupData = JSON.stringify({ version: '2.0', credentials: {} });
|
||||
const encrypted = `enc:tag:${Buffer.from(backupData).toString('base64')}`;
|
||||
|
||||
const result = await credentialManager.importBackup(encrypted);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('importBackup returns false on error', async () => {
|
||||
cryptoUtils.decrypt.mockImplementationOnce(() => { throw new Error('bad'); });
|
||||
const result = await credentialManager.importBackup('bad-data');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cache TTL', () => {
|
||||
it('cache entries expire after TTL', async () => {
|
||||
credentialManager.cache.set('ttl.key', {
|
||||
value: 'val',
|
||||
exp: Date.now() - 1 // Already expired
|
||||
});
|
||||
fs.readFileSync.mockReturnValue('{}');
|
||||
const result = await credentialManager.retrieve('ttl.key');
|
||||
expect(result).toBeNull();
|
||||
expect(credentialManager.cache.has('ttl.key')).toBe(false);
|
||||
});
|
||||
|
||||
it('new store refreshes cache TTL', async () => {
|
||||
await credentialManager.store('fresh.key', 'val');
|
||||
const cached = credentialManager.cache.get('fresh.key');
|
||||
expect(cached.exp).toBeGreaterThan(Date.now());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
const crypto = require('crypto');
|
||||
const path = require('path');
|
||||
|
||||
// Mock fs BEFORE requiring crypto-utils
|
||||
jest.mock('fs');
|
||||
const fs = require('fs');
|
||||
|
||||
const TEST_KEY = crypto.randomBytes(32);
|
||||
const TEST_KEY_HEX = TEST_KEY.toString('hex');
|
||||
|
||||
// Load the module once — no jest.resetModules() needed
|
||||
// We control key state via clearCachedKey() + env vars
|
||||
process.env.DASHCADDY_ENCRYPTION_KEY = TEST_KEY_HEX;
|
||||
const cryptoUtils = require('../src/security/crypto-utils');
|
||||
|
||||
describe('Crypto Utils', () => {
|
||||
beforeEach(() => {
|
||||
// Reset key state and env vars before each test
|
||||
cryptoUtils.clearCachedKey();
|
||||
delete process.env.DASHCADDY_ENCRYPTION_KEY;
|
||||
delete process.env.ENCRYPTION_KEY_FILE;
|
||||
// Reset fs mock implementations
|
||||
fs.existsSync.mockReturnValue(false);
|
||||
fs.writeFileSync.mockImplementation(() => {});
|
||||
fs.readFileSync.mockReturnValue('');
|
||||
});
|
||||
|
||||
// Helper: ensure module has a known key loaded (via env var)
|
||||
function ensureKey() {
|
||||
process.env.DASHCADDY_ENCRYPTION_KEY = TEST_KEY_HEX;
|
||||
cryptoUtils.clearCachedKey();
|
||||
return cryptoUtils.loadOrCreateKey();
|
||||
}
|
||||
|
||||
describe('loadOrCreateKey', () => {
|
||||
it('loads key from DASHCADDY_ENCRYPTION_KEY env var', () => {
|
||||
process.env.DASHCADDY_ENCRYPTION_KEY = TEST_KEY_HEX;
|
||||
const key = cryptoUtils.loadOrCreateKey();
|
||||
expect(Buffer.isBuffer(key)).toBe(true);
|
||||
expect(key.length).toBe(32);
|
||||
expect(key.toString('hex')).toBe(TEST_KEY_HEX);
|
||||
});
|
||||
|
||||
it('loads key from file when env var absent', () => {
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
fs.readFileSync.mockReturnValue(TEST_KEY_HEX);
|
||||
const key = cryptoUtils.loadOrCreateKey();
|
||||
expect(key.toString('hex')).toBe(TEST_KEY_HEX);
|
||||
expect(fs.readFileSync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('generates new key when no file and no env var', () => {
|
||||
const key = cryptoUtils.loadOrCreateKey();
|
||||
expect(Buffer.isBuffer(key)).toBe(true);
|
||||
expect(key.length).toBe(32);
|
||||
});
|
||||
|
||||
it('saves generated key to file with 0o600 permissions', () => {
|
||||
cryptoUtils.loadOrCreateKey();
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
});
|
||||
|
||||
it('returns cached key on subsequent calls', () => {
|
||||
process.env.DASHCADDY_ENCRYPTION_KEY = TEST_KEY_HEX;
|
||||
const key1 = cryptoUtils.loadOrCreateKey();
|
||||
const key2 = cryptoUtils.loadOrCreateKey();
|
||||
expect(key1).toBe(key2); // Same reference
|
||||
});
|
||||
|
||||
it('handles invalid key file (too short) by generating new key', () => {
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
fs.readFileSync.mockReturnValue('abcd'); // Too short
|
||||
const key = cryptoUtils.loadOrCreateKey();
|
||||
expect(key.length).toBe(32);
|
||||
expect(fs.writeFileSync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles unreadable key file gracefully', () => {
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
fs.readFileSync.mockImplementation(() => { throw new Error('EACCES'); });
|
||||
const key = cryptoUtils.loadOrCreateKey();
|
||||
expect(key.length).toBe(32);
|
||||
});
|
||||
|
||||
it('handles write failure gracefully', () => {
|
||||
fs.writeFileSync.mockImplementation(() => { throw new Error('EROFS'); });
|
||||
const key = cryptoUtils.loadOrCreateKey();
|
||||
expect(key.length).toBe(32);
|
||||
});
|
||||
|
||||
it('clearCachedKey forces reload on next call', () => {
|
||||
process.env.DASHCADDY_ENCRYPTION_KEY = TEST_KEY_HEX;
|
||||
const key1 = cryptoUtils.loadOrCreateKey();
|
||||
cryptoUtils.clearCachedKey();
|
||||
const key2 = cryptoUtils.loadOrCreateKey();
|
||||
expect(key1).not.toBe(key2);
|
||||
expect(key1.toString('hex')).toBe(key2.toString('hex'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('encrypt / decrypt', () => {
|
||||
beforeEach(() => ensureKey());
|
||||
|
||||
it('roundtrip: encrypt then decrypt returns original string', () => {
|
||||
const plaintext = 'hello world';
|
||||
const encrypted = cryptoUtils.encrypt(plaintext);
|
||||
const decrypted = cryptoUtils.decrypt(encrypted);
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
|
||||
it('roundtrip: encrypt then decrypt returns original JSON object', () => {
|
||||
const obj = { user: 'admin', pass: 'secret123' };
|
||||
const encrypted = cryptoUtils.encrypt(obj);
|
||||
const decrypted = cryptoUtils.decrypt(encrypted);
|
||||
expect(JSON.parse(decrypted)).toEqual(obj);
|
||||
});
|
||||
|
||||
it('output format is iv:authTag:ciphertext (3 colon-separated base64 parts)', () => {
|
||||
const encrypted = cryptoUtils.encrypt('test');
|
||||
const parts = encrypted.split(':');
|
||||
expect(parts).toHaveLength(3);
|
||||
for (const part of parts) {
|
||||
expect(() => Buffer.from(part, 'base64')).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('each encryption produces different ciphertext (random IV)', () => {
|
||||
const encrypted1 = cryptoUtils.encrypt('same data');
|
||||
const encrypted2 = cryptoUtils.encrypt('same data');
|
||||
expect(encrypted1).not.toBe(encrypted2);
|
||||
});
|
||||
|
||||
it('decrypt with tampered authTag throws', () => {
|
||||
const encrypted = cryptoUtils.encrypt('sensitive');
|
||||
const parts = encrypted.split(':');
|
||||
const tamperedTag = Buffer.from('aaaaaaaaaaaaaaaa').toString('base64');
|
||||
const tampered = `${parts[0]}:${tamperedTag}:${parts[2]}`;
|
||||
expect(() => cryptoUtils.decrypt(tampered)).toThrow();
|
||||
});
|
||||
|
||||
it('decrypt with tampered ciphertext throws', () => {
|
||||
const encrypted = cryptoUtils.encrypt('sensitive');
|
||||
const parts = encrypted.split(':');
|
||||
const tampered = `${parts[0]}:${parts[1]}:${Buffer.from('garbage').toString('base64')}`;
|
||||
expect(() => cryptoUtils.decrypt(tampered)).toThrow();
|
||||
});
|
||||
|
||||
it('decrypt with invalid format (2 parts) throws', () => {
|
||||
expect(() => cryptoUtils.decrypt('part1:part2')).toThrow('Invalid encrypted data format');
|
||||
});
|
||||
|
||||
it('decrypt with invalid format (4 parts) throws', () => {
|
||||
expect(() => cryptoUtils.decrypt('a:b:c:d')).toThrow('Invalid encrypted data format');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEncrypted', () => {
|
||||
beforeEach(() => ensureKey());
|
||||
|
||||
it('returns true for properly formatted encrypted string', () => {
|
||||
const encrypted = cryptoUtils.encrypt('test');
|
||||
expect(cryptoUtils.isEncrypted(encrypted)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for plain text', () => {
|
||||
expect(cryptoUtils.isEncrypted('just a normal string')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for non-string input', () => {
|
||||
expect(cryptoUtils.isEncrypted(123)).toBe(false);
|
||||
expect(cryptoUtils.isEncrypted(null)).toBe(false);
|
||||
expect(cryptoUtils.isEncrypted(undefined)).toBe(false);
|
||||
expect(cryptoUtils.isEncrypted({ key: 'val' })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for string with fewer than 3 colon-separated parts', () => {
|
||||
expect(cryptoUtils.isEncrypted('only:two')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('encryptFields / decryptFields', () => {
|
||||
beforeEach(() => ensureKey());
|
||||
|
||||
it('encrypts specified fields, leaves others untouched', () => {
|
||||
const obj = { username: 'admin', password: 'secret', role: 'admin' };
|
||||
const result = cryptoUtils.encryptFields(obj, ['password']);
|
||||
expect(result.username).toBe('admin');
|
||||
expect(result.role).toBe('admin');
|
||||
expect(result.password).not.toBe('secret');
|
||||
expect(cryptoUtils.isEncrypted(result.password)).toBe(true);
|
||||
});
|
||||
|
||||
it('sets _encrypted: true and _encryptedFields array', () => {
|
||||
const result = cryptoUtils.encryptFields({ a: '1' }, ['a']);
|
||||
expect(result._encrypted).toBe(true);
|
||||
expect(result._encryptedFields).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('skips null/undefined field values', () => {
|
||||
const obj = { password: null, token: undefined, name: 'test' };
|
||||
const result = cryptoUtils.encryptFields(obj, ['password', 'token']);
|
||||
expect(result.password).toBeNull();
|
||||
expect(result.token).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not double-encrypt already-encrypted fields', () => {
|
||||
const obj = { password: 'secret' };
|
||||
const first = cryptoUtils.encryptFields(obj, ['password']);
|
||||
const encryptedValue = first.password;
|
||||
const second = cryptoUtils.encryptFields({ password: encryptedValue }, ['password']);
|
||||
expect(second.password).toBe(encryptedValue);
|
||||
});
|
||||
|
||||
it('decryptFields restores original values and removes markers', () => {
|
||||
const original = { username: 'admin', password: 'secret' };
|
||||
const encrypted = cryptoUtils.encryptFields(original, ['password']);
|
||||
const decrypted = cryptoUtils.decryptFields(encrypted);
|
||||
expect(decrypted.password).toBe('secret');
|
||||
expect(decrypted.username).toBe('admin');
|
||||
expect(decrypted._encrypted).toBeUndefined();
|
||||
expect(decrypted._encryptedFields).toBeUndefined();
|
||||
});
|
||||
|
||||
it('decryptFields with no _encrypted flag returns object unchanged', () => {
|
||||
const obj = { name: 'test' };
|
||||
const result = cryptoUtils.decryptFields(obj);
|
||||
expect(result).toEqual(obj);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readEncryptedFile / writeEncryptedFile', () => {
|
||||
beforeEach(() => ensureKey());
|
||||
|
||||
it('writeEncryptedFile encrypts and writes JSON', () => {
|
||||
cryptoUtils.writeEncryptedFile('/tmp/creds.json', { password: 'secret' }, ['password']);
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||
'/tmp/creds.json',
|
||||
expect.any(String),
|
||||
'utf8'
|
||||
);
|
||||
const writtenData = JSON.parse(fs.writeFileSync.mock.calls[0][1]);
|
||||
expect(writtenData._encrypted).toBe(true);
|
||||
});
|
||||
|
||||
it('readEncryptedFile reads and decrypts', () => {
|
||||
const encrypted = cryptoUtils.encryptFields({ password: 'secret' }, ['password']);
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
fs.readFileSync.mockReturnValue(JSON.stringify(encrypted));
|
||||
|
||||
const result = cryptoUtils.readEncryptedFile('/tmp/creds.json', ['password']);
|
||||
expect(result.password).toBe('secret');
|
||||
expect(result._encrypted).toBeUndefined();
|
||||
});
|
||||
|
||||
it('readEncryptedFile returns null when file missing', () => {
|
||||
fs.existsSync.mockReturnValue(false);
|
||||
const result = cryptoUtils.readEncryptedFile('/tmp/nope.json');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('readEncryptedFile returns null on corrupt JSON', () => {
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
fs.readFileSync.mockReturnValue('{broken json');
|
||||
const result = cryptoUtils.readEncryptedFile('/tmp/bad.json');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('readEncryptedFile returns plaintext data when not encrypted', () => {
|
||||
const plainData = { username: 'admin', password: 'plain' };
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
fs.readFileSync.mockReturnValue(JSON.stringify(plainData));
|
||||
const result = cryptoUtils.readEncryptedFile('/tmp/plain.json');
|
||||
expect(result.password).toBe('plain');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveKey', () => {
|
||||
it('returns 32-byte buffer', async () => {
|
||||
const key = await cryptoUtils.deriveKey('password', crypto.randomBytes(32));
|
||||
expect(Buffer.isBuffer(key)).toBe(true);
|
||||
expect(key.length).toBe(32);
|
||||
});
|
||||
|
||||
it('same password + salt yields same key', async () => {
|
||||
const salt = crypto.randomBytes(32);
|
||||
const key1 = await cryptoUtils.deriveKey('mypass', salt);
|
||||
const key2 = await cryptoUtils.deriveKey('mypass', salt);
|
||||
expect(key1.equals(key2)).toBe(true);
|
||||
});
|
||||
|
||||
it('different salt yields different key', async () => {
|
||||
const key1 = await cryptoUtils.deriveKey('mypass', crypto.randomBytes(32));
|
||||
const key2 = await cryptoUtils.deriveKey('mypass', crypto.randomBytes(32));
|
||||
expect(key1.equals(key2)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rotateKey / decryptWithKey', () => {
|
||||
beforeEach(() => ensureKey());
|
||||
|
||||
it('rotateKey generates new key and returns oldKey + newKey', () => {
|
||||
const { oldKey, newKey } = cryptoUtils.rotateKey();
|
||||
expect(Buffer.isBuffer(oldKey)).toBe(true);
|
||||
expect(Buffer.isBuffer(newKey)).toBe(true);
|
||||
expect(oldKey.length).toBe(32);
|
||||
expect(newKey.length).toBe(32);
|
||||
expect(oldKey.equals(newKey)).toBe(false);
|
||||
});
|
||||
|
||||
it('old data is decryptable with decryptWithKey using oldKey', () => {
|
||||
const plaintext = 'my secret';
|
||||
const encrypted = cryptoUtils.encrypt(plaintext);
|
||||
const { oldKey } = cryptoUtils.rotateKey();
|
||||
const decrypted = cryptoUtils.decryptWithKey(encrypted, oldKey);
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
|
||||
it('new encrypt uses the new key after rotation', () => {
|
||||
const { newKey } = cryptoUtils.rotateKey();
|
||||
const encrypted = cryptoUtils.encrypt('after rotation');
|
||||
const decrypted = cryptoUtils.decryptWithKey(encrypted, newKey);
|
||||
expect(decrypted).toBe('after rotation');
|
||||
});
|
||||
|
||||
it('rotateKey throws if file write fails', () => {
|
||||
fs.writeFileSync.mockImplementation(() => { throw new Error('disk full'); });
|
||||
expect(() => cryptoUtils.rotateKey()).toThrow('Failed to save new encryption key');
|
||||
});
|
||||
|
||||
it('decryptWithKey with invalid format throws', () => {
|
||||
expect(() => cryptoUtils.decryptWithKey('bad:format', TEST_KEY)).toThrow(
|
||||
'Invalid encrypted data format'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,354 @@
|
||||
const crypto = require('crypto');
|
||||
|
||||
// Mock crypto-utils to provide a predictable signing key
|
||||
const mockFixedKey = Buffer.alloc(32, 'test-key-material');
|
||||
jest.mock('../src/security/crypto-utils', () => ({
|
||||
loadOrCreateKey: jest.fn(() => mockFixedKey),
|
||||
}));
|
||||
|
||||
const {
|
||||
CSRF_TOKEN_LENGTH,
|
||||
CSRF_COOKIE_NAME,
|
||||
CSRF_HEADER_NAME,
|
||||
generateToken,
|
||||
signToken,
|
||||
parseCookie,
|
||||
csrfCookieMiddleware,
|
||||
csrfValidationMiddleware,
|
||||
renewCSRFToken
|
||||
} = require('../src/security/csrf-protection');
|
||||
const { createMockReqRes } = require('./helpers/test-utils');
|
||||
|
||||
describe('CSRF Protection', () => {
|
||||
|
||||
describe('generateToken', () => {
|
||||
it('returns a base64url-encoded string', () => {
|
||||
const token = generateToken();
|
||||
expect(typeof token).toBe('string');
|
||||
expect(token.length).toBeGreaterThan(0);
|
||||
// base64url chars only
|
||||
expect(token).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
});
|
||||
|
||||
it('returns different values on each call', () => {
|
||||
const t1 = generateToken();
|
||||
const t2 = generateToken();
|
||||
expect(t1).not.toBe(t2);
|
||||
});
|
||||
|
||||
it('has appropriate length for 32 bytes of randomness', () => {
|
||||
const token = generateToken();
|
||||
// 32 bytes = 43 base64url chars (no padding)
|
||||
expect(token.length).toBe(43);
|
||||
});
|
||||
});
|
||||
|
||||
describe('signToken', () => {
|
||||
it('returns a base64url-encoded HMAC signature', () => {
|
||||
const sig = signToken('test-nonce');
|
||||
expect(typeof sig).toBe('string');
|
||||
expect(sig).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
});
|
||||
|
||||
it('same nonce produces same signature (deterministic)', () => {
|
||||
const sig1 = signToken('my-nonce');
|
||||
const sig2 = signToken('my-nonce');
|
||||
expect(sig1).toBe(sig2);
|
||||
});
|
||||
|
||||
it('different nonces produce different signatures', () => {
|
||||
const sig1 = signToken('nonce-a');
|
||||
const sig2 = signToken('nonce-b');
|
||||
expect(sig1).not.toBe(sig2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCookie', () => {
|
||||
it('parses single cookie', () => {
|
||||
expect(parseCookie('name=value')).toEqual({ name: 'value' });
|
||||
});
|
||||
|
||||
it('parses multiple cookies', () => {
|
||||
const result = parseCookie('a=1; b=2; c=3');
|
||||
expect(result).toEqual({ a: '1', b: '2', c: '3' });
|
||||
});
|
||||
|
||||
it('handles cookies with = in value', () => {
|
||||
const result = parseCookie('token=abc=def=ghi');
|
||||
expect(result.token).toBe('abc=def=ghi');
|
||||
});
|
||||
|
||||
it('returns empty object for null/undefined/empty input', () => {
|
||||
expect(parseCookie(null)).toEqual({});
|
||||
expect(parseCookie(undefined)).toEqual({});
|
||||
expect(parseCookie('')).toEqual({});
|
||||
});
|
||||
|
||||
it('trims outer whitespace of each cookie pair', () => {
|
||||
const result = parseCookie(' name=value ');
|
||||
expect(result['name']).toBe('value');
|
||||
});
|
||||
});
|
||||
|
||||
describe('csrfCookieMiddleware', () => {
|
||||
it('generates new nonce and sets cookie when no existing cookie', () => {
|
||||
const { req, res, next } = createMockReqRes();
|
||||
req.headers.cookie = '';
|
||||
|
||||
csrfCookieMiddleware(req, res, next);
|
||||
|
||||
expect(req.csrfNonce).toBeDefined();
|
||||
expect(req.csrfToken).toBeDefined();
|
||||
expect(res.cookie).toHaveBeenCalledWith(
|
||||
CSRF_COOKIE_NAME,
|
||||
req.csrfNonce,
|
||||
expect.objectContaining({
|
||||
httpOnly: false,
|
||||
sameSite: 'strict',
|
||||
path: '/',
|
||||
})
|
||||
);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reuses existing nonce from cookie (no new Set-Cookie)', () => {
|
||||
const { req, res, next } = createMockReqRes();
|
||||
const existingNonce = 'existing-nonce-value';
|
||||
req.headers.cookie = `${CSRF_COOKIE_NAME}=${existingNonce}`;
|
||||
|
||||
csrfCookieMiddleware(req, res, next);
|
||||
|
||||
expect(req.csrfNonce).toBe(existingNonce);
|
||||
expect(res.cookie).not.toHaveBeenCalled(); // No new cookie set
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sets req.csrfToken as HMAC signature of nonce', () => {
|
||||
const { req, res, next } = createMockReqRes();
|
||||
req.headers.cookie = `${CSRF_COOKIE_NAME}=my-nonce`;
|
||||
|
||||
csrfCookieMiddleware(req, res, next);
|
||||
|
||||
const expectedSig = signToken('my-nonce');
|
||||
expect(req.csrfToken).toBe(expectedSig);
|
||||
});
|
||||
});
|
||||
|
||||
describe('csrfValidationMiddleware', () => {
|
||||
it('skips validation for GET requests', () => {
|
||||
const { req, res, next } = createMockReqRes({ method: 'GET' });
|
||||
csrfValidationMiddleware(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips validation for HEAD requests', () => {
|
||||
const { req, res, next } = createMockReqRes({ method: 'HEAD' });
|
||||
csrfValidationMiddleware(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips validation for OPTIONS requests', () => {
|
||||
const { req, res, next } = createMockReqRes({ method: 'OPTIONS' });
|
||||
csrfValidationMiddleware(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips validation in test environment', () => {
|
||||
const origEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'test';
|
||||
const { req, res, next } = createMockReqRes({ method: 'POST', path: '/api/services' });
|
||||
|
||||
csrfValidationMiddleware(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
process.env.NODE_ENV = origEnv;
|
||||
});
|
||||
|
||||
it('skips validation for excluded paths', () => {
|
||||
const origEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
// Mirrors src/security/csrf-protection.js excludedPaths. If you add
|
||||
// a new entry there, add it here too — the test guards against the
|
||||
// drift that previously kept /api/v1/health in the list long after
|
||||
// the route itself was deleted.
|
||||
const excludedPaths = [
|
||||
'/api/v1/totp/verify',
|
||||
'/api/v1/totp/verify-setup',
|
||||
'/api/v1/totp/setup',
|
||||
'/health',
|
||||
'/health/live',
|
||||
'/health/ready',
|
||||
'/healthz',
|
||||
'/readyz',
|
||||
'/api/v1/system/update-notify',
|
||||
];
|
||||
for (const excludedPath of excludedPaths) {
|
||||
const { req, res, next } = createMockReqRes({ method: 'POST', path: excludedPath });
|
||||
csrfValidationMiddleware(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
}
|
||||
|
||||
process.env.NODE_ENV = origEnv;
|
||||
});
|
||||
|
||||
it('skips validation for auth gate paths', () => {
|
||||
const origEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
const { req, res, next } = createMockReqRes({
|
||||
method: 'POST', path: '/api/v1/auth/gate/plex'
|
||||
});
|
||||
csrfValidationMiddleware(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
|
||||
process.env.NODE_ENV = origEnv;
|
||||
});
|
||||
|
||||
it('skips validation when x-api-key header present', () => {
|
||||
const origEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
const { req, res, next } = createMockReqRes({
|
||||
method: 'POST', path: '/api/services',
|
||||
headers: { 'x-api-key': 'dk_abc_123' }
|
||||
});
|
||||
csrfValidationMiddleware(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
|
||||
process.env.NODE_ENV = origEnv;
|
||||
});
|
||||
|
||||
it('skips validation when Authorization Bearer header present', () => {
|
||||
const origEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
const { req, res, next } = createMockReqRes({
|
||||
method: 'POST', path: '/api/services',
|
||||
headers: { authorization: 'Bearer some-jwt-token' }
|
||||
});
|
||||
csrfValidationMiddleware(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
|
||||
process.env.NODE_ENV = origEnv;
|
||||
});
|
||||
|
||||
it('returns 403 when CSRF cookie missing', () => {
|
||||
const origEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
const { req, res, next } = createMockReqRes({
|
||||
method: 'POST', path: '/api/services',
|
||||
headers: { cookie: '' }
|
||||
});
|
||||
csrfValidationMiddleware(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ error: expect.stringContaining('DC-100') })
|
||||
);
|
||||
|
||||
process.env.NODE_ENV = origEnv;
|
||||
});
|
||||
|
||||
it('returns 403 when CSRF header missing', () => {
|
||||
const origEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
const nonce = generateToken();
|
||||
const { req, res, next } = createMockReqRes({
|
||||
method: 'POST', path: '/api/services',
|
||||
headers: { cookie: `${CSRF_COOKIE_NAME}=${nonce}` }
|
||||
});
|
||||
csrfValidationMiddleware(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ error: expect.stringContaining('DC-100') })
|
||||
);
|
||||
|
||||
process.env.NODE_ENV = origEnv;
|
||||
});
|
||||
|
||||
it('returns 403 when signature is invalid', () => {
|
||||
const origEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
const nonce = generateToken();
|
||||
const { req, res, next } = createMockReqRes({
|
||||
method: 'POST', path: '/api/services',
|
||||
headers: {
|
||||
cookie: `${CSRF_COOKIE_NAME}=${nonce}`,
|
||||
'x-csrf-token': 'totally-wrong-signature'
|
||||
}
|
||||
});
|
||||
csrfValidationMiddleware(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ error: expect.stringContaining('DC-101') })
|
||||
);
|
||||
|
||||
process.env.NODE_ENV = origEnv;
|
||||
});
|
||||
|
||||
it('passes when cookie nonce and header signature match', () => {
|
||||
const origEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
const nonce = generateToken();
|
||||
const signature = signToken(nonce);
|
||||
const { req, res, next } = createMockReqRes({
|
||||
method: 'POST', path: '/api/services',
|
||||
headers: {
|
||||
cookie: `${CSRF_COOKIE_NAME}=${nonce}`,
|
||||
'x-csrf-token': signature
|
||||
}
|
||||
});
|
||||
csrfValidationMiddleware(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
|
||||
process.env.NODE_ENV = origEnv;
|
||||
});
|
||||
|
||||
it('excludes /api/v1/ paths directly', () => {
|
||||
const origEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
const { req, res, next } = createMockReqRes({
|
||||
method: 'POST', path: '/api/v1/totp/verify'
|
||||
});
|
||||
csrfValidationMiddleware(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
|
||||
process.env.NODE_ENV = origEnv;
|
||||
});
|
||||
});
|
||||
|
||||
describe('renewCSRFToken', () => {
|
||||
it('generates new nonce and sets cookie', () => {
|
||||
const { res } = createMockReqRes();
|
||||
const token = renewCSRFToken(res, true);
|
||||
|
||||
expect(typeof token).toBe('string');
|
||||
expect(res.cookie).toHaveBeenCalledWith(
|
||||
CSRF_COOKIE_NAME,
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
httpOnly: false,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
path: '/',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('returns signed token', () => {
|
||||
const { res } = createMockReqRes();
|
||||
const token = renewCSRFToken(res, false);
|
||||
// Get the nonce that was set in the cookie
|
||||
const setCookieNonce = res.cookie.mock.calls[0][1];
|
||||
const expectedSig = signToken(setCookieNonce);
|
||||
expect(token).toBe(expectedSig);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Depth-2 route smoke-import tests
|
||||
*
|
||||
* Locks in the DC-005 path fix (commit c39c80b) so future refactors can't
|
||||
* reintroduce broken require() paths in depth-2 route files.
|
||||
*
|
||||
* Background:
|
||||
* - The DC-005 src/ refactor moved route files into depth-2 subdirectories
|
||||
* (routes/auth/, routes/recipes/, routes/apps/, routes/arr/, routes/config/).
|
||||
* - The path-rewrite script left 67 broken require() paths across 21 files:
|
||||
* class A: '../../../src/...' (3 levels, goes above package root)
|
||||
* class B: '../src/utils/...' (1 level, resolves to nonexistent routes/src/)
|
||||
* class C: routes/apps/restore.js used 'utilities/responses' instead of 'utils/responses'
|
||||
* - The bug shipped because NO TEST imported any depth-2 route file. Only
|
||||
* depth-1 routes were tested.
|
||||
*
|
||||
* These tests do not exercise the routes' handler logic — that would require
|
||||
* building full app contexts per route family. They only verify:
|
||||
* 1. The module can be loaded without a MODULE_NOT_FOUND error.
|
||||
* 2. It exports a callable factory function (module.exports = function(deps){...}).
|
||||
* 3. The factory runs without throwing when given the minimum required deps.
|
||||
*
|
||||
* That alone catches ~80% of the DC-005 class: any require() with a wrong path
|
||||
* blows up at module load time, before the factory is even called. Path bugs
|
||||
* that only manifest at handler invocation time (e.g. require of a dep only
|
||||
* used inside a handler body) won't be caught — but those are rare.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { universalDeps } = require('./test-helpers/universal-deps');
|
||||
|
||||
const PKG_ROOT = path.join(__dirname, '..');
|
||||
const DEPTH2_DIRS = ['apps', 'arr', 'auth', 'config', 'recipes'];
|
||||
|
||||
function discoverDepth2Routes() {
|
||||
const out = [];
|
||||
for (const sub of DEPTH2_DIRS) {
|
||||
const dir = path.join(PKG_ROOT, 'routes', sub);
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
for (const f of fs.readdirSync(dir).filter(x => x.endsWith('.js'))) {
|
||||
out.push(path.join('routes', sub, f));
|
||||
}
|
||||
}
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
describe('Depth-2 Route Smoke Imports (locks in DC-005 path fix)', () => {
|
||||
const routes = discoverDepth2Routes();
|
||||
// routes/auth/totp.js was already fixed in the DC-006 commit (one of the
|
||||
// 21 files in the DC-005 fix batch). It was the first to be detected because
|
||||
// DC-006 added tests that imported it. Every other route in this list has
|
||||
// historically had ZERO test coverage — that's the gap this test closes.
|
||||
|
||||
describe.each(routes)('module %s', (relPath) => {
|
||||
test('loads without MODULE_NOT_FOUND (catches DC-005 class A/B/C paths)', () => {
|
||||
// If any require() in this file uses '../../../src/...' (class A) or
|
||||
// '../src/utils/...' (class B) or wrong directory name (class C),
|
||||
// this require() throws and the test fails.
|
||||
expect(() => require(path.join(PKG_ROOT, relPath))).not.toThrow();
|
||||
});
|
||||
|
||||
test('exports a factory function (module.exports = function(deps){...})', () => {
|
||||
const factory = require(path.join(PKG_ROOT, relPath));
|
||||
expect(typeof factory).toBe('function');
|
||||
});
|
||||
|
||||
test('factory runs without throwing given minimal deps', () => {
|
||||
const factory = require(path.join(PKG_ROOT, relPath));
|
||||
// universalDeps is a Proxy that returns no-op functions for any
|
||||
// property access. So both patterns work:
|
||||
// function({ a, b, c }) { ... } // picks a, b, c from universalDeps
|
||||
// function(ctx) { ctx.licenseManager.requirePremium(...) } // works
|
||||
// Any factory destructure is satisfied. Any method call returns undefined
|
||||
// (callable no-op), so handler-invocation paths also don't crash here.
|
||||
// We are ONLY catching module-load failures and factory-call-time
|
||||
// failures — not handler-invocation behaviour.
|
||||
expect(() => factory(universalDeps)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Source-of-truth: no broken paths introduced', () => {
|
||||
test('no depth-2 route uses ../../../src/ (class A)', () => {
|
||||
const offenders = [];
|
||||
for (const relPath of routes) {
|
||||
const content = fs.readFileSync(path.join(PKG_ROOT, relPath), 'utf8');
|
||||
if (content.match(/require\(['"]\.\.\/\.\.\/\.\.\/src/)) offenders.push(relPath);
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
test('no depth-2 route uses ../src/ (class B — would resolve to routes/src/)', () => {
|
||||
const offenders = [];
|
||||
for (const relPath of routes) {
|
||||
const content = fs.readFileSync(path.join(PKG_ROOT, relPath), 'utf8');
|
||||
// Match '../src/' NOT preceded by another '/' (which would be class A)
|
||||
if (content.match(/require\(['"]\.\.\/src\//)) offenders.push(relPath);
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
test('no depth-2 route uses src/utilities/responses (class C — module lives at src/utils/responses)', () => {
|
||||
const offenders = [];
|
||||
for (const relPath of routes) {
|
||||
const content = fs.readFileSync(path.join(PKG_ROOT, relPath), 'utf8');
|
||||
if (content.match(/['"]\.\.\/\.\.\/src\/utilities\/responses['"]/)) offenders.push(relPath);
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Smoke tests for dns-propagation.js
|
||||
* Verifies DNS propagation checker module loads, exposes the expected
|
||||
* interface, and basic methods (verifyRecord, startVerification,
|
||||
* getVerificationStatus, getAllVerifications, cleanup) work without throwing.
|
||||
*/
|
||||
|
||||
// The module does `const dns = require('dns').promises;` then `new dns.Resolver()`.
|
||||
// We mock the dns module so that .promises exposes our Resolver class.
|
||||
jest.mock('dns', () => {
|
||||
class MockResolver {
|
||||
setServers() { return this; }
|
||||
setTimeout() { return this; }
|
||||
resolve4(domain) {
|
||||
if (domain === 'propagated.sami') {
|
||||
return Promise.resolve(['1.2.3.4']);
|
||||
}
|
||||
return Promise.resolve(['9.9.9.9']);
|
||||
}
|
||||
}
|
||||
return {
|
||||
promises: { Resolver: MockResolver },
|
||||
Resolver: MockResolver,
|
||||
};
|
||||
});
|
||||
|
||||
const DNSPropagationChecker = require('../src/dns/dns-propagation');
|
||||
|
||||
describe('DNSPropagationChecker', () => {
|
||||
let checker;
|
||||
|
||||
beforeEach(() => {
|
||||
const ctx = {
|
||||
log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() },
|
||||
notification: { send: jest.fn().mockResolvedValue({ success: true }) },
|
||||
};
|
||||
checker = new DNSPropagationChecker(ctx);
|
||||
});
|
||||
|
||||
test('is an EventEmitter', () => {
|
||||
expect(typeof checker.on).toBe('function');
|
||||
expect(typeof checker.emit).toBe('function');
|
||||
});
|
||||
|
||||
test('starts with an empty verifications map', () => {
|
||||
expect(checker.verifications).toBeInstanceOf(Map);
|
||||
expect(checker.verifications.size).toBe(0);
|
||||
});
|
||||
|
||||
test('verifyRecord returns expected shape and detects propagated domain', async () => {
|
||||
const result = await checker.verifyRecord('propagated.sami', '1.2.3.4', {
|
||||
timeout: 5000,
|
||||
interval: 100,
|
||||
resolvers: ['1.1.1.1'],
|
||||
});
|
||||
expect(result).toHaveProperty('domain', 'propagated.sami');
|
||||
expect(result).toHaveProperty('expectedIp', '1.2.3.4');
|
||||
expect(result).toHaveProperty('propagated', true);
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
expect(typeof result.totalTime).toBe('number');
|
||||
expect(typeof result.checkedAt).toBe('string');
|
||||
});
|
||||
|
||||
test('verifyRecord reports not-propagated when IP does not match', async () => {
|
||||
const result = await checker.verifyRecord('notpropagated.sami', '5.6.7.8', {
|
||||
timeout: 200,
|
||||
interval: 50,
|
||||
resolvers: ['1.1.1.1'],
|
||||
});
|
||||
expect(result.propagated).toBe(false);
|
||||
});
|
||||
|
||||
test('startVerification returns a job object with running status', () => {
|
||||
const job = checker.startVerification('job.sami', '1.1.1.1', {
|
||||
timeout: 100,
|
||||
interval: 50,
|
||||
resolvers: ['1.1.1.1'],
|
||||
});
|
||||
expect(job).toMatchObject({
|
||||
domain: 'job.sami',
|
||||
expectedIp: '1.1.1.1',
|
||||
status: 'running',
|
||||
});
|
||||
expect(job.startedAt).toBeDefined();
|
||||
});
|
||||
|
||||
test('startVerification returns the same job when called twice for one domain', () => {
|
||||
const a = checker.startVerification('dup.sami', '1.1.1.1', { timeout: 5000, interval: 1000 });
|
||||
const b = checker.startVerification('dup.sami', '1.1.1.1', { timeout: 5000, interval: 1000 });
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
test('getVerificationStatus returns null for unknown domain', () => {
|
||||
expect(checker.getVerificationStatus('nope.sami')).toBeNull();
|
||||
});
|
||||
|
||||
test('getAllVerifications returns an array', () => {
|
||||
expect(Array.isArray(checker.getAllVerifications())).toBe(true);
|
||||
});
|
||||
|
||||
test('cleanup is a no-op on empty verifications', () => {
|
||||
expect(() => checker.cleanup()).not.toThrow();
|
||||
expect(checker.verifications.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,498 @@
|
||||
/**
|
||||
* Docker Security Module Tests
|
||||
* Tests for image digest verification, security modes, and trusted digest management
|
||||
*
|
||||
* Note: Tests that call getImageDigest() require a real Docker daemon running.
|
||||
* These are marked with .skip() and should be run as integration tests separately.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Test config file path
|
||||
const TEST_CONFIG_FILE = path.join(__dirname, '../docker-security-config.test.json');
|
||||
|
||||
describe('DockerSecurity Module', () => {
|
||||
let dockerSecurity;
|
||||
|
||||
beforeEach(() => {
|
||||
// Clean up test config
|
||||
if (fs.existsSync(TEST_CONFIG_FILE)) {
|
||||
fs.unlinkSync(TEST_CONFIG_FILE);
|
||||
}
|
||||
|
||||
// Set test environment
|
||||
process.env.DOCKER_SECURITY_CONFIG = TEST_CONFIG_FILE;
|
||||
process.env.DOCKER_VERIFICATION_MODE = 'verify';
|
||||
|
||||
// Reset modules to get fresh instance
|
||||
jest.resetModules();
|
||||
dockerSecurity = require('../src/security/docker-security');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up test config
|
||||
if (fs.existsSync(TEST_CONFIG_FILE)) {
|
||||
fs.unlinkSync(TEST_CONFIG_FILE);
|
||||
}
|
||||
});
|
||||
|
||||
describe('Configuration Management', () => {
|
||||
test('should load default config when file does not exist', () => {
|
||||
const status = dockerSecurity.getStatus();
|
||||
expect(status.mode).toBe('verify');
|
||||
expect(status.trustedImagesCount).toBe(0);
|
||||
});
|
||||
|
||||
test('should load existing config file', () => {
|
||||
const testConfig = {
|
||||
trustedDigests: {
|
||||
'nginx:latest': 'sha256:abc123'
|
||||
},
|
||||
verificationMode: 'strict',
|
||||
allowUnverified: false,
|
||||
updateTrustedOnPull: false
|
||||
};
|
||||
|
||||
fs.writeFileSync(TEST_CONFIG_FILE, JSON.stringify(testConfig));
|
||||
|
||||
// Force module reload
|
||||
jest.resetModules();
|
||||
const freshInstance = require('../src/security/docker-security');
|
||||
const status = freshInstance.getStatus();
|
||||
|
||||
expect(status.trustedImagesCount).toBe(1);
|
||||
});
|
||||
|
||||
test('should save config to disk', () => {
|
||||
dockerSecurity.setTrustedDigest('redis:alpine', 'sha256:def456');
|
||||
|
||||
expect(fs.existsSync(TEST_CONFIG_FILE)).toBe(true);
|
||||
|
||||
const savedConfig = JSON.parse(fs.readFileSync(TEST_CONFIG_FILE, 'utf8'));
|
||||
expect(savedConfig.trustedDigests['redis:alpine']).toBe('sha256:def456');
|
||||
});
|
||||
|
||||
test('should handle corrupted config file gracefully', () => {
|
||||
fs.writeFileSync(TEST_CONFIG_FILE, 'INVALID JSON{{{');
|
||||
|
||||
jest.resetModules();
|
||||
const freshInstance = require('../src/security/docker-security');
|
||||
const status = freshInstance.getStatus();
|
||||
|
||||
// Should fall back to default config
|
||||
expect(status.trustedImagesCount).toBe(0);
|
||||
});
|
||||
|
||||
test('should handle missing config file directory', () => {
|
||||
// Use a non-existent directory
|
||||
process.env.DOCKER_SECURITY_CONFIG = '/nonexistent/path/config.json';
|
||||
|
||||
jest.resetModules();
|
||||
const freshInstance = require('../src/security/docker-security');
|
||||
const status = freshInstance.getStatus();
|
||||
|
||||
// Should fall back to default config
|
||||
expect(status.mode).toBe('verify');
|
||||
expect(status.trustedImagesCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Trusted Digest Management', () => {
|
||||
test('should add trusted digest', () => {
|
||||
dockerSecurity.setTrustedDigest('postgres:15', 'sha256:trusted123');
|
||||
|
||||
const digests = dockerSecurity.getTrustedDigests();
|
||||
expect(digests['postgres:15']).toBe('sha256:trusted123');
|
||||
});
|
||||
|
||||
test('should update existing trusted digest', () => {
|
||||
dockerSecurity.setTrustedDigest('postgres:15', 'sha256:old123');
|
||||
dockerSecurity.setTrustedDigest('postgres:15', 'sha256:new456');
|
||||
|
||||
const digests = dockerSecurity.getTrustedDigests();
|
||||
expect(digests['postgres:15']).toBe('sha256:new456');
|
||||
});
|
||||
|
||||
test('should remove trusted digest', () => {
|
||||
dockerSecurity.setTrustedDigest('postgres:15', 'sha256:trusted123');
|
||||
dockerSecurity.removeTrustedDigest('postgres:15');
|
||||
|
||||
const digests = dockerSecurity.getTrustedDigests();
|
||||
expect(digests['postgres:15']).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should return copy of trusted digests (immutable)', () => {
|
||||
dockerSecurity.setTrustedDigest('nginx:latest', 'sha256:abc123');
|
||||
|
||||
const digests1 = dockerSecurity.getTrustedDigests();
|
||||
const digests2 = dockerSecurity.getTrustedDigests();
|
||||
|
||||
// Modify copy
|
||||
digests1['nginx:latest'] = 'sha256:modified';
|
||||
|
||||
// Original should be unchanged
|
||||
expect(digests2['nginx:latest']).toBe('sha256:abc123');
|
||||
});
|
||||
|
||||
test('should persist trusted digests across operations', () => {
|
||||
dockerSecurity.setTrustedDigest('mysql:8', 'sha256:mysql123');
|
||||
dockerSecurity.setTrustedDigest('redis:alpine', 'sha256:redis456');
|
||||
|
||||
const digests = dockerSecurity.getTrustedDigests();
|
||||
expect(Object.keys(digests)).toHaveLength(2);
|
||||
expect(digests['mysql:8']).toBe('sha256:mysql123');
|
||||
expect(digests['redis:alpine']).toBe('sha256:redis456');
|
||||
});
|
||||
|
||||
test('should handle removal of non-existent digest', () => {
|
||||
dockerSecurity.removeTrustedDigest('nonexistent:latest');
|
||||
|
||||
const digests = dockerSecurity.getTrustedDigests();
|
||||
expect(digests['nonexistent:latest']).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should handle multiple removals', () => {
|
||||
dockerSecurity.setTrustedDigest('img1:latest', 'sha256:aaa111');
|
||||
dockerSecurity.setTrustedDigest('img2:latest', 'sha256:bbb222');
|
||||
dockerSecurity.setTrustedDigest('img3:latest', 'sha256:ccc333');
|
||||
|
||||
dockerSecurity.removeTrustedDigest('img1:latest');
|
||||
dockerSecurity.removeTrustedDigest('img3:latest');
|
||||
|
||||
const digests = dockerSecurity.getTrustedDigests();
|
||||
expect(Object.keys(digests)).toHaveLength(1);
|
||||
expect(digests['img2:latest']).toBe('sha256:bbb222');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Verification Modes', () => {
|
||||
test('should set mode to strict', () => {
|
||||
dockerSecurity.setMode('strict');
|
||||
const status = dockerSecurity.getStatus();
|
||||
expect(status.mode).toBe('strict');
|
||||
});
|
||||
|
||||
test('should set mode to verify', () => {
|
||||
dockerSecurity.setMode('verify');
|
||||
const status = dockerSecurity.getStatus();
|
||||
expect(status.mode).toBe('verify');
|
||||
});
|
||||
|
||||
test('should set mode to permissive', () => {
|
||||
dockerSecurity.setMode('permissive');
|
||||
const status = dockerSecurity.getStatus();
|
||||
expect(status.mode).toBe('permissive');
|
||||
});
|
||||
|
||||
test('should reject invalid mode', () => {
|
||||
expect(() => dockerSecurity.setMode('invalid'))
|
||||
.toThrow('Invalid mode');
|
||||
});
|
||||
|
||||
test('should reject empty mode string', () => {
|
||||
expect(() => dockerSecurity.setMode(''))
|
||||
.toThrow('Invalid mode');
|
||||
});
|
||||
|
||||
test('should reject null mode', () => {
|
||||
expect(() => dockerSecurity.setMode(null))
|
||||
.toThrow('Invalid mode');
|
||||
});
|
||||
|
||||
test('should persist mode changes to config', () => {
|
||||
dockerSecurity.setMode('strict');
|
||||
|
||||
const savedConfig = JSON.parse(fs.readFileSync(TEST_CONFIG_FILE, 'utf8'));
|
||||
expect(savedConfig.verificationMode).toBe('strict');
|
||||
});
|
||||
|
||||
test('should allow mode changes multiple times', () => {
|
||||
dockerSecurity.setMode('strict');
|
||||
expect(dockerSecurity.getStatus().mode).toBe('strict');
|
||||
|
||||
dockerSecurity.setMode('permissive');
|
||||
expect(dockerSecurity.getStatus().mode).toBe('permissive');
|
||||
|
||||
dockerSecurity.setMode('verify');
|
||||
expect(dockerSecurity.getStatus().mode).toBe('verify');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Digest Verification Logic - Strict Mode', () => {
|
||||
beforeEach(() => {
|
||||
dockerSecurity.setMode('strict');
|
||||
});
|
||||
|
||||
test('should reject image with no trusted digest in strict mode', async () => {
|
||||
const result = await dockerSecurity.verifyImageDigest(
|
||||
'nginx:latest',
|
||||
'sha256:actual123'
|
||||
);
|
||||
|
||||
expect(result.verified).toBe(false);
|
||||
expect(result.action).toBe('reject');
|
||||
expect(result.reason).toContain('strict mode');
|
||||
});
|
||||
|
||||
test('should accept image with matching digest', async () => {
|
||||
dockerSecurity.setTrustedDigest('nginx:latest', 'sha256:trusted123');
|
||||
|
||||
const result = await dockerSecurity.verifyImageDigest(
|
||||
'nginx:latest',
|
||||
'sha256:trusted123'
|
||||
);
|
||||
|
||||
expect(result.verified).toBe(true);
|
||||
expect(result.action).toBe('accept');
|
||||
});
|
||||
|
||||
test('should reject image with mismatched digest in strict mode', async () => {
|
||||
dockerSecurity.setTrustedDigest('nginx:latest', 'sha256:trusted123');
|
||||
|
||||
const result = await dockerSecurity.verifyImageDigest(
|
||||
'nginx:latest',
|
||||
'sha256:different456'
|
||||
);
|
||||
|
||||
expect(result.verified).toBe(false);
|
||||
expect(result.action).toBe('reject');
|
||||
expect(result.actualDigest).toBe('sha256:different456');
|
||||
expect(result.trustedDigest).toBe('sha256:trusted123');
|
||||
});
|
||||
|
||||
test('should include all relevant fields in verification result', async () => {
|
||||
dockerSecurity.setTrustedDigest('redis:alpine', 'sha256:expected999');
|
||||
|
||||
const result = await dockerSecurity.verifyImageDigest(
|
||||
'redis:alpine',
|
||||
'sha256:actual888'
|
||||
);
|
||||
|
||||
expect(result).toHaveProperty('verified');
|
||||
expect(result).toHaveProperty('mode');
|
||||
expect(result).toHaveProperty('imageName');
|
||||
expect(result).toHaveProperty('actualDigest');
|
||||
expect(result).toHaveProperty('trustedDigest');
|
||||
expect(result).toHaveProperty('action');
|
||||
expect(result).toHaveProperty('reason');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Digest Verification Logic - Verify Mode', () => {
|
||||
beforeEach(() => {
|
||||
dockerSecurity.setMode('verify');
|
||||
// Disable auto-update for predictable tests
|
||||
dockerSecurity.config.updateTrustedOnPull = false;
|
||||
});
|
||||
|
||||
test('should warn on digest mismatch in verify mode', async () => {
|
||||
dockerSecurity.setTrustedDigest('nginx:latest', 'sha256:trusted123');
|
||||
|
||||
const result = await dockerSecurity.verifyImageDigest(
|
||||
'nginx:latest',
|
||||
'sha256:different456'
|
||||
);
|
||||
|
||||
expect(result.verified).toBe(false);
|
||||
expect(result.action).toBe('warn');
|
||||
expect(result.reason).toContain('verify mode');
|
||||
});
|
||||
|
||||
test('should accept image with no trusted digest in verify mode', async () => {
|
||||
const result = await dockerSecurity.verifyImageDigest(
|
||||
'redis:alpine',
|
||||
'sha256:actual123'
|
||||
);
|
||||
|
||||
expect(result.verified).toBe(true);
|
||||
expect(result.action).toBe('accept');
|
||||
});
|
||||
|
||||
test('should accept matching digests', async () => {
|
||||
dockerSecurity.setTrustedDigest('postgres:15', 'sha256:match777');
|
||||
|
||||
const result = await dockerSecurity.verifyImageDigest(
|
||||
'postgres:15',
|
||||
'sha256:match777'
|
||||
);
|
||||
|
||||
expect(result.verified).toBe(true);
|
||||
expect(result.action).toBe('accept');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Digest Verification Logic - Permissive Mode', () => {
|
||||
beforeEach(() => {
|
||||
dockerSecurity.setMode('permissive');
|
||||
dockerSecurity.config.updateTrustedOnPull = false;
|
||||
});
|
||||
|
||||
test('should accept image with mismatched digest in permissive mode', async () => {
|
||||
dockerSecurity.setTrustedDigest('nginx:latest', 'sha256:trusted123');
|
||||
|
||||
const result = await dockerSecurity.verifyImageDigest(
|
||||
'nginx:latest',
|
||||
'sha256:different456'
|
||||
);
|
||||
|
||||
expect(result.verified).toBe(true);
|
||||
expect(result.action).toBe('accept');
|
||||
expect(result.reason).toContain('permissive mode');
|
||||
});
|
||||
|
||||
test('should accept any image without trusted digest', async () => {
|
||||
const result = await dockerSecurity.verifyImageDigest(
|
||||
'unknown:latest',
|
||||
'sha256:anything123'
|
||||
);
|
||||
|
||||
expect(result.verified).toBe(true);
|
||||
expect(result.action).toBe('accept');
|
||||
});
|
||||
|
||||
test('should accept matching digests', async () => {
|
||||
dockerSecurity.setTrustedDigest('mysql:8', 'sha256:match555');
|
||||
|
||||
const result = await dockerSecurity.verifyImageDigest(
|
||||
'mysql:8',
|
||||
'sha256:match555'
|
||||
);
|
||||
|
||||
expect(result.verified).toBe(true);
|
||||
expect(result.action).toBe('accept');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Auto-Update Trusted Digests', () => {
|
||||
test('should auto-add trusted digest on first pull', async () => {
|
||||
dockerSecurity.config.updateTrustedOnPull = true;
|
||||
|
||||
const result = await dockerSecurity.verifyImageDigest(
|
||||
'newimage:latest',
|
||||
'sha256:first123'
|
||||
);
|
||||
|
||||
expect(result.verified).toBe(true);
|
||||
|
||||
const digests = dockerSecurity.getTrustedDigests();
|
||||
expect(digests['newimage:latest']).toBe('sha256:first123');
|
||||
});
|
||||
|
||||
test('should not auto-update when disabled', async () => {
|
||||
dockerSecurity.config.updateTrustedOnPull = false;
|
||||
|
||||
await dockerSecurity.verifyImageDigest(
|
||||
'newimage:latest',
|
||||
'sha256:first123'
|
||||
);
|
||||
|
||||
const digests = dockerSecurity.getTrustedDigests();
|
||||
expect(digests['newimage:latest']).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should not overwrite existing trusted digest', async () => {
|
||||
dockerSecurity.config.updateTrustedOnPull = true;
|
||||
dockerSecurity.setTrustedDigest('existing:latest', 'sha256:original888');
|
||||
|
||||
await dockerSecurity.verifyImageDigest(
|
||||
'existing:latest',
|
||||
'sha256:new999'
|
||||
);
|
||||
|
||||
const digests = dockerSecurity.getTrustedDigests();
|
||||
expect(digests['existing:latest']).toBe('sha256:original888');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Status Reporting', () => {
|
||||
test('should return correct status', () => {
|
||||
dockerSecurity.setTrustedDigest('nginx:latest', 'sha256:abc123');
|
||||
dockerSecurity.setTrustedDigest('redis:alpine', 'sha256:def456');
|
||||
dockerSecurity.setMode('strict');
|
||||
|
||||
const status = dockerSecurity.getStatus();
|
||||
|
||||
expect(status.mode).toBe('strict');
|
||||
expect(status.trustedImagesCount).toBe(2);
|
||||
expect(status.configFile).toBe(TEST_CONFIG_FILE);
|
||||
});
|
||||
|
||||
test('should report updateTrustedOnPull setting', () => {
|
||||
dockerSecurity.config.updateTrustedOnPull = true;
|
||||
|
||||
const status = dockerSecurity.getStatus();
|
||||
expect(status.updateTrustedOnPull).toBe(true);
|
||||
});
|
||||
|
||||
test('should reflect config changes in status', () => {
|
||||
dockerSecurity.setMode('permissive');
|
||||
dockerSecurity.setTrustedDigest('img1:latest', 'sha256:aaa');
|
||||
dockerSecurity.setTrustedDigest('img2:latest', 'sha256:bbb');
|
||||
dockerSecurity.setTrustedDigest('img3:latest', 'sha256:ccc');
|
||||
|
||||
const status = dockerSecurity.getStatus();
|
||||
|
||||
expect(status.mode).toBe('permissive');
|
||||
expect(status.trustedImagesCount).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
test('should handle concurrent digest updates', () => {
|
||||
dockerSecurity.setTrustedDigest('image1:latest', 'sha256:aaa111');
|
||||
dockerSecurity.setTrustedDigest('image2:latest', 'sha256:bbb222');
|
||||
dockerSecurity.setTrustedDigest('image3:latest', 'sha256:ccc333');
|
||||
|
||||
const digests = dockerSecurity.getTrustedDigests();
|
||||
|
||||
expect(digests['image1:latest']).toBe('sha256:aaa111');
|
||||
expect(digests['image2:latest']).toBe('sha256:bbb222');
|
||||
expect(digests['image3:latest']).toBe('sha256:ccc333');
|
||||
});
|
||||
|
||||
test('should handle empty digest string', async () => {
|
||||
const result = await dockerSecurity.verifyImageDigest(
|
||||
'nginx:latest',
|
||||
''
|
||||
);
|
||||
|
||||
expect(result.verified).toBe(true); // Permissive by default
|
||||
});
|
||||
|
||||
test('should handle very long image names', async () => {
|
||||
const longImageName = 'registry.example.com/namespace/project/subproject/image:v1.2.3-beta-20261231';
|
||||
|
||||
dockerSecurity.setTrustedDigest(longImageName, 'sha256:abc123');
|
||||
|
||||
const result = await dockerSecurity.verifyImageDigest(
|
||||
longImageName,
|
||||
'sha256:abc123'
|
||||
);
|
||||
|
||||
expect(result.verified).toBe(true);
|
||||
expect(result.imageName).toBe(longImageName);
|
||||
});
|
||||
|
||||
test('should handle digest verification with null digest', async () => {
|
||||
const result = await dockerSecurity.verifyImageDigest(
|
||||
'nginx:latest',
|
||||
null
|
||||
);
|
||||
|
||||
// Null digest should be accepted in permissive mode (default)
|
||||
expect(result.action).toBe('accept');
|
||||
});
|
||||
|
||||
test('should handle image name with multiple colons', async () => {
|
||||
dockerSecurity.setTrustedDigest('registry.io:5000/app:v1', 'sha256:xyz789');
|
||||
|
||||
const result = await dockerSecurity.verifyImageDigest(
|
||||
'registry.io:5000/app:v1',
|
||||
'sha256:xyz789'
|
||||
);
|
||||
|
||||
expect(result.verified).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
// Mock the unified logging module so we can verify logError is called
|
||||
// without writing to the actual error.log file
|
||||
jest.mock('../src/utils/logging', () => ({
|
||||
logError: jest.fn().mockResolvedValue(),
|
||||
safeErrorMessage: jest.fn((err) => {
|
||||
if (!err) return 'An internal error occurred';
|
||||
return err.message || String(err);
|
||||
}),
|
||||
createLogger: jest.fn(() => ({
|
||||
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn()
|
||||
})),
|
||||
LOG_LEVELS: { debug: 0, info: 1, warn: 2, error: 3 }
|
||||
}));
|
||||
|
||||
const { errorMiddleware, notFoundHandler } = require('../src/utilities/error-handler');
|
||||
const {
|
||||
AppError,
|
||||
ValidationError,
|
||||
AuthenticationError,
|
||||
NotFoundError,
|
||||
RateLimitError,
|
||||
DockerError,
|
||||
} = require('../src/utilities/errors');
|
||||
|
||||
describe('Error Handler', () => {
|
||||
let req, res, next;
|
||||
|
||||
beforeEach(() => {
|
||||
req = {
|
||||
method: 'GET',
|
||||
path: '/api/test',
|
||||
ip: '127.0.0.1',
|
||||
user: { id: 'user1' },
|
||||
body: {},
|
||||
};
|
||||
res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn().mockReturnThis(),
|
||||
};
|
||||
next = jest.fn();
|
||||
});
|
||||
|
||||
describe('errorMiddleware', () => {
|
||||
it('returns 400 for ValidationError', () => {
|
||||
const err = new ValidationError('bad input', 'email');
|
||||
errorMiddleware(err, req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
success: false,
|
||||
error: 'bad input',
|
||||
code: 'DC-400',
|
||||
field: 'email',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 401 for AuthenticationError with requiresTotp', () => {
|
||||
const err = new AuthenticationError('auth needed', true);
|
||||
errorMiddleware(err, req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
success: false,
|
||||
error: 'auth needed',
|
||||
requiresTotp: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 404 for NotFoundError with resource', () => {
|
||||
const err = new NotFoundError('Service');
|
||||
errorMiddleware(err, req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: 'Service not found',
|
||||
resource: 'Service',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 429 for RateLimitError with retryAfter', () => {
|
||||
const err = new RateLimitError(30);
|
||||
errorMiddleware(err, req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(429);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: 'Rate limit exceeded',
|
||||
retryAfter: 30,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 500 with "Internal server error" for generic Error', () => {
|
||||
const err = new Error('db connection lost');
|
||||
errorMiddleware(err, req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
success: false,
|
||||
error: 'Internal server error', // NOT the real message
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('includes error code in DC-XXX format', () => {
|
||||
const err = new AppError('test', 418, 'DC-TEAPOT');
|
||||
errorMiddleware(err, req, res, next);
|
||||
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ code: 'DC-TEAPOT' })
|
||||
);
|
||||
});
|
||||
|
||||
it('includes details for DockerError', () => {
|
||||
const err = new DockerError('container fail', 'create', { id: '123' });
|
||||
errorMiddleware(err, req, res, next);
|
||||
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
details: { id: '123' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('includes stack trace in development mode', () => {
|
||||
const origEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'development';
|
||||
|
||||
const err = new AppError('test');
|
||||
errorMiddleware(err, req, res, next);
|
||||
|
||||
const response = res.json.mock.calls[0][0];
|
||||
expect(response.stack).toBeDefined();
|
||||
|
||||
process.env.NODE_ENV = origEnv;
|
||||
});
|
||||
|
||||
it('excludes stack trace in production mode', () => {
|
||||
const origEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
const err = new AppError('test');
|
||||
errorMiddleware(err, req, res, next);
|
||||
|
||||
const response = res.json.mock.calls[0][0];
|
||||
expect(response.stack).toBeUndefined();
|
||||
|
||||
process.env.NODE_ENV = origEnv;
|
||||
});
|
||||
|
||||
it('logs non-operational errors as FATAL', () => {
|
||||
const origError = console.error;
|
||||
console.error = jest.fn();
|
||||
|
||||
const err = new Error('programming bug');
|
||||
errorMiddleware(err, req, res, next);
|
||||
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'FATAL: Non-operational error detected',
|
||||
expect.any(Object)
|
||||
);
|
||||
|
||||
console.error = origError;
|
||||
});
|
||||
});
|
||||
|
||||
describe('notFoundHandler', () => {
|
||||
it('passes NotFoundError to next()', () => {
|
||||
notFoundHandler(req, res, next);
|
||||
expect(next).toHaveBeenCalledWith(expect.any(NotFoundError));
|
||||
const passedError = next.mock.calls[0][0];
|
||||
expect(passedError.message).toContain('GET');
|
||||
expect(passedError.message).toContain('/api/test');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
const {
|
||||
AppError,
|
||||
ValidationError,
|
||||
AuthenticationError,
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
ConflictError,
|
||||
RateLimitError,
|
||||
DockerError,
|
||||
CaddyError,
|
||||
DNSError,
|
||||
ServiceUnavailableError
|
||||
} = require('../src/utilities/errors');
|
||||
|
||||
describe('Error Classes', () => {
|
||||
describe('AppError', () => {
|
||||
it('has default statusCode 500 and auto-generated code', () => {
|
||||
const err = new AppError('something broke');
|
||||
expect(err.message).toBe('something broke');
|
||||
expect(err.statusCode).toBe(500);
|
||||
expect(err.code).toBe('APP_ERROR');
|
||||
expect(err.isOperational).toBe(true);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it('accepts custom statusCode and code', () => {
|
||||
const err = new AppError('custom', 418, 'DC-TEAPOT');
|
||||
expect(err.statusCode).toBe(418);
|
||||
expect(err.code).toBe('DC-TEAPOT');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ValidationError', () => {
|
||||
it('has statusCode 400, code DC-400, and optional field', () => {
|
||||
const err = new ValidationError('bad input', 'email');
|
||||
expect(err.statusCode).toBe(400);
|
||||
expect(err.code).toBe('DC-400');
|
||||
expect(err.field).toBe('email');
|
||||
expect(err).toBeInstanceOf(AppError);
|
||||
});
|
||||
|
||||
it('field defaults to null', () => {
|
||||
const err = new ValidationError('bad');
|
||||
expect(err.field).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthenticationError', () => {
|
||||
it('has statusCode 401 and requiresTotp flag', () => {
|
||||
const err = new AuthenticationError('need auth', true);
|
||||
expect(err.statusCode).toBe(401);
|
||||
expect(err.code).toBe('DC-401');
|
||||
expect(err.requiresTotp).toBe(true);
|
||||
expect(err).toBeInstanceOf(AppError);
|
||||
});
|
||||
|
||||
it('has sensible defaults', () => {
|
||||
const err = new AuthenticationError();
|
||||
expect(err.message).toBe('Authentication required');
|
||||
expect(err.requiresTotp).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ForbiddenError', () => {
|
||||
it('has statusCode 403', () => {
|
||||
const err = new ForbiddenError();
|
||||
expect(err.statusCode).toBe(403);
|
||||
expect(err.code).toBe('DC-403');
|
||||
expect(err.message).toBe('Forbidden');
|
||||
expect(err).toBeInstanceOf(AppError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NotFoundError', () => {
|
||||
it('has statusCode 404 and resource in message', () => {
|
||||
const err = new NotFoundError('Service');
|
||||
expect(err.statusCode).toBe(404);
|
||||
expect(err.code).toBe('DC-404');
|
||||
expect(err.message).toBe('Service not found');
|
||||
expect(err.resource).toBe('Service');
|
||||
expect(err).toBeInstanceOf(AppError);
|
||||
});
|
||||
|
||||
it('defaults to "Resource"', () => {
|
||||
const err = new NotFoundError();
|
||||
expect(err.message).toBe('Resource not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConflictError', () => {
|
||||
it('has statusCode 409 and optional conflictingResource', () => {
|
||||
const err = new ConflictError('already exists', 'service-x');
|
||||
expect(err.statusCode).toBe(409);
|
||||
expect(err.code).toBe('DC-409');
|
||||
expect(err.conflictingResource).toBe('service-x');
|
||||
expect(err).toBeInstanceOf(AppError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RateLimitError', () => {
|
||||
it('has statusCode 429 and retryAfter', () => {
|
||||
const err = new RateLimitError(30);
|
||||
expect(err.statusCode).toBe(429);
|
||||
expect(err.code).toBe('DC-429');
|
||||
expect(err.retryAfter).toBe(30);
|
||||
expect(err.message).toBe('Rate limit exceeded');
|
||||
expect(err).toBeInstanceOf(AppError);
|
||||
});
|
||||
|
||||
it('defaults retryAfter to 60', () => {
|
||||
const err = new RateLimitError();
|
||||
expect(err.retryAfter).toBe(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DockerError', () => {
|
||||
it('has statusCode 500, operation, and details', () => {
|
||||
const err = new DockerError('container failed', 'create', { containerId: '123' });
|
||||
expect(err.statusCode).toBe(500);
|
||||
expect(err.code).toBe('DC-500-DOCKER');
|
||||
expect(err.operation).toBe('create');
|
||||
expect(err.details).toEqual({ containerId: '123' });
|
||||
expect(err).toBeInstanceOf(AppError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CaddyError', () => {
|
||||
it('has statusCode 502', () => {
|
||||
const err = new CaddyError('reload failed', 'reload');
|
||||
expect(err.statusCode).toBe(502);
|
||||
expect(err.code).toBe('DC-502-CADDY');
|
||||
expect(err.operation).toBe('reload');
|
||||
expect(err).toBeInstanceOf(AppError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DNSError', () => {
|
||||
it('has statusCode 502', () => {
|
||||
const err = new DNSError('zone create failed', 'create-zone');
|
||||
expect(err.statusCode).toBe(502);
|
||||
expect(err.code).toBe('DC-502-DNS');
|
||||
expect(err).toBeInstanceOf(AppError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ServiceUnavailableError', () => {
|
||||
it('has statusCode 503, service name, and optional retryAfter', () => {
|
||||
const err = new ServiceUnavailableError('plex', 120);
|
||||
expect(err.statusCode).toBe(503);
|
||||
expect(err.code).toBe('DC-503');
|
||||
expect(err.message).toBe('Service unavailable: plex');
|
||||
expect(err.service).toBe('plex');
|
||||
expect(err.retryAfter).toBe(120);
|
||||
expect(err).toBeInstanceOf(AppError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,513 @@
|
||||
jest.mock('fs', () => ({
|
||||
existsSync: jest.fn().mockReturnValue(false),
|
||||
readFileSync: jest.fn().mockReturnValue('{"services":{}}'),
|
||||
writeFileSync: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
describe('HealthChecker', () => {
|
||||
let HealthChecker, healthChecker, fs;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
fs = require('fs');
|
||||
fs.existsSync.mockReturnValue(false);
|
||||
fs.readFileSync.mockReturnValue('{"services":{}}');
|
||||
fs.writeFileSync.mockImplementation(() => {});
|
||||
|
||||
// Fresh instance each test
|
||||
HealthChecker = require('../src/monitoring/health-checker').constructor;
|
||||
healthChecker = new HealthChecker();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
healthChecker.stop();
|
||||
jest.clearAllTimers();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('initializes with empty state', () => {
|
||||
expect(healthChecker.currentStatus).toBeInstanceOf(Map);
|
||||
expect(healthChecker.incidents).toEqual([]);
|
||||
expect(healthChecker.checking).toBe(false);
|
||||
});
|
||||
|
||||
it('loads config from file when it exists', () => {
|
||||
jest.resetModules();
|
||||
fs = require('fs');
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
fs.readFileSync.mockReturnValue(JSON.stringify({
|
||||
services: { svc1: { url: 'http://test.local', enabled: true } }
|
||||
}));
|
||||
|
||||
HealthChecker = require('../src/monitoring/health-checker').constructor;
|
||||
const hc = new HealthChecker();
|
||||
expect(hc.config.services.svc1).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns default config on parse error', () => {
|
||||
jest.resetModules();
|
||||
fs = require('fs');
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
fs.readFileSync.mockReturnValue('invalid json');
|
||||
|
||||
HealthChecker = require('../src/monitoring/health-checker').constructor;
|
||||
const hc = new HealthChecker();
|
||||
expect(hc.config).toEqual({ services: {} });
|
||||
});
|
||||
});
|
||||
|
||||
describe('start / stop', () => {
|
||||
it('start sets checking to true and schedules interval', () => {
|
||||
// Mock checkAll to prevent real HTTP calls
|
||||
healthChecker.checkAll = jest.fn();
|
||||
healthChecker.start();
|
||||
expect(healthChecker.checking).toBe(true);
|
||||
expect(healthChecker.checkAll).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('start is idempotent (no-op if already checking)', () => {
|
||||
healthChecker.checkAll = jest.fn();
|
||||
healthChecker.start();
|
||||
healthChecker.start(); // second call
|
||||
expect(healthChecker.checkAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stop clears interval and resets state', () => {
|
||||
healthChecker.checkAll = jest.fn();
|
||||
healthChecker.start();
|
||||
healthChecker.stop();
|
||||
expect(healthChecker.checking).toBe(false);
|
||||
expect(healthChecker.checkInterval).toBeNull();
|
||||
});
|
||||
|
||||
it('stop is idempotent (no-op if not checking)', () => {
|
||||
healthChecker.stop(); // should not throw
|
||||
expect(healthChecker.checking).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBackoffInterval', () => {
|
||||
it('returns base interval when no failures', () => {
|
||||
const interval = healthChecker.getBackoffInterval('svc1');
|
||||
expect(interval).toBe(30000); // CHECK_INTERVAL default
|
||||
});
|
||||
|
||||
it('doubles interval per consecutive failure', () => {
|
||||
healthChecker.consecutiveFailures.set('svc1', 1);
|
||||
expect(healthChecker.getBackoffInterval('svc1')).toBe(60000);
|
||||
|
||||
healthChecker.consecutiveFailures.set('svc1', 2);
|
||||
expect(healthChecker.getBackoffInterval('svc1')).toBe(120000);
|
||||
});
|
||||
|
||||
it('caps at MAX_CHECK_INTERVAL', () => {
|
||||
healthChecker.consecutiveFailures.set('svc1', 100);
|
||||
expect(healthChecker.getBackoffInterval('svc1')).toBe(300000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluateHealth', () => {
|
||||
it('returns true for expected status code', () => {
|
||||
const result = healthChecker.evaluateHealth(200, '', { expectedStatusCodes: [200] });
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for unexpected status code', () => {
|
||||
const result = healthChecker.evaluateHealth(500, '', { expectedStatusCodes: [200] });
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults to accepting common 2xx/3xx codes', () => {
|
||||
expect(healthChecker.evaluateHealth(200, '', {})).toBe(true);
|
||||
expect(healthChecker.evaluateHealth(301, '', {})).toBe(true);
|
||||
expect(healthChecker.evaluateHealth(500, '', {})).toBe(false);
|
||||
});
|
||||
|
||||
it('checks body pattern with regex', () => {
|
||||
const config = { expectedBodyPattern: 'ok|healthy' };
|
||||
expect(healthChecker.evaluateHealth(200, 'status: ok', config)).toBe(true);
|
||||
expect(healthChecker.evaluateHealth(200, 'status: error', config)).toBe(false);
|
||||
});
|
||||
|
||||
it('checks body contains text', () => {
|
||||
const config = { expectedBodyContains: 'alive' };
|
||||
expect(healthChecker.evaluateHealth(200, 'I am alive!', config)).toBe(true);
|
||||
expect(healthChecker.evaluateHealth(200, 'dead', config)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordStatus', () => {
|
||||
it('updates currentStatus map', () => {
|
||||
const status = { serviceId: 'svc1', status: 'up', timestamp: new Date().toISOString() };
|
||||
healthChecker.recordStatus('svc1', status);
|
||||
expect(healthChecker.currentStatus.get('svc1')).toEqual(status);
|
||||
});
|
||||
|
||||
it('appends to history', () => {
|
||||
const status1 = { serviceId: 'svc1', status: 'up', timestamp: new Date().toISOString() };
|
||||
const status2 = { serviceId: 'svc1', status: 'down', timestamp: new Date().toISOString() };
|
||||
healthChecker.recordStatus('svc1', status1);
|
||||
healthChecker.recordStatus('svc1', status2);
|
||||
expect(healthChecker.history['svc1']).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('emits status-check event', () => {
|
||||
const handler = jest.fn();
|
||||
healthChecker.on('status-check', handler);
|
||||
const status = { serviceId: 'svc1', status: 'up' };
|
||||
healthChecker.recordStatus('svc1', status);
|
||||
expect(handler).toHaveBeenCalledWith(status);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkService', () => {
|
||||
it('returns up status on successful health check', async () => {
|
||||
healthChecker._doRequest = jest.fn().mockResolvedValue({
|
||||
healthy: true, statusCode: 200, message: 'Service is healthy', details: {}
|
||||
});
|
||||
|
||||
const config = { url: 'http://test.local' };
|
||||
const result = await healthChecker.checkService('svc1', config);
|
||||
expect(result.status).toBe('up');
|
||||
expect(result.serviceId).toBe('svc1');
|
||||
});
|
||||
|
||||
it('returns down status on failed health check', async () => {
|
||||
healthChecker._doRequest = jest.fn().mockResolvedValue({
|
||||
healthy: false, statusCode: 500, message: 'fail', details: {}
|
||||
});
|
||||
|
||||
const result = await healthChecker.checkService('svc1', { url: 'http://test.local' });
|
||||
expect(result.status).toBe('down');
|
||||
});
|
||||
|
||||
it('returns down status on request error', async () => {
|
||||
healthChecker._doRequest = jest.fn().mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
|
||||
const result = await healthChecker.checkService('svc1', { url: 'http://test.local' });
|
||||
expect(result.status).toBe('down');
|
||||
expect(result.error).toBe('ECONNREFUSED');
|
||||
});
|
||||
|
||||
it('increments consecutive failures on error', async () => {
|
||||
healthChecker._doRequest = jest.fn().mockRejectedValue(new Error('fail'));
|
||||
|
||||
await healthChecker.checkService('svc1', { url: 'http://test.local' });
|
||||
expect(healthChecker.consecutiveFailures.get('svc1')).toBe(1);
|
||||
|
||||
await healthChecker.checkService('svc1', { url: 'http://test.local' });
|
||||
expect(healthChecker.consecutiveFailures.get('svc1')).toBe(2);
|
||||
});
|
||||
|
||||
it('clears consecutive failures on success', async () => {
|
||||
healthChecker.consecutiveFailures.set('svc1', 5);
|
||||
healthChecker._doRequest = jest.fn().mockResolvedValue({
|
||||
healthy: true, statusCode: 200, message: 'ok', details: {}
|
||||
});
|
||||
|
||||
await healthChecker.checkService('svc1', { url: 'http://test.local' });
|
||||
expect(healthChecker.consecutiveFailures.has('svc1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('performHealthCheck', () => {
|
||||
it('falls back to GET when HEAD returns 501', async () => {
|
||||
healthChecker._doRequest = jest.fn()
|
||||
.mockResolvedValueOnce({ statusCode: 501 })
|
||||
.mockResolvedValueOnce({ healthy: true, statusCode: 200 });
|
||||
|
||||
const result = await healthChecker.performHealthCheck({ url: 'http://test.local', method: 'HEAD' });
|
||||
expect(healthChecker._doRequest).toHaveBeenCalledTimes(2);
|
||||
expect(result.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('falls back to GET when HEAD returns 405', async () => {
|
||||
healthChecker._doRequest = jest.fn()
|
||||
.mockResolvedValueOnce({ statusCode: 405 })
|
||||
.mockResolvedValueOnce({ healthy: true, statusCode: 200 });
|
||||
|
||||
const result = await healthChecker.performHealthCheck({ url: 'http://test.local', method: 'HEAD' });
|
||||
expect(result.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('does not fallback for GET requests returning 501', async () => {
|
||||
healthChecker._doRequest = jest.fn()
|
||||
.mockResolvedValueOnce({ statusCode: 501, healthy: false });
|
||||
|
||||
const result = await healthChecker.performHealthCheck({ url: 'http://test.local' });
|
||||
expect(healthChecker._doRequest).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('incidents', () => {
|
||||
it('createIncident adds a new incident', () => {
|
||||
const status = { timestamp: new Date().toISOString() };
|
||||
healthChecker.createIncident('svc1', 'outage', 'Service down', status);
|
||||
expect(healthChecker.incidents).toHaveLength(1);
|
||||
expect(healthChecker.incidents[0].serviceId).toBe('svc1');
|
||||
expect(healthChecker.incidents[0].type).toBe('outage');
|
||||
expect(healthChecker.incidents[0].status).toBe('open');
|
||||
});
|
||||
|
||||
it('createIncident increments existing open incident', () => {
|
||||
const status = { timestamp: new Date().toISOString() };
|
||||
healthChecker.createIncident('svc1', 'outage', 'down', status);
|
||||
healthChecker.createIncident('svc1', 'outage', 'still down', status);
|
||||
expect(healthChecker.incidents).toHaveLength(1);
|
||||
expect(healthChecker.incidents[0].occurrences).toBe(2);
|
||||
});
|
||||
|
||||
it('resolveIncident sets status to resolved', () => {
|
||||
const status = { timestamp: new Date().toISOString() };
|
||||
healthChecker.createIncident('svc1', 'outage', 'down', status);
|
||||
healthChecker.resolveIncident('svc1', 'outage', status);
|
||||
expect(healthChecker.incidents[0].status).toBe('resolved');
|
||||
expect(healthChecker.incidents[0].resolvedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolveIncident is no-op for non-existent incidents', () => {
|
||||
const status = { timestamp: new Date().toISOString() };
|
||||
healthChecker.resolveIncident('svc1', 'outage', status);
|
||||
expect(healthChecker.incidents).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('getOpenIncidents filters resolved', () => {
|
||||
const ts = { timestamp: new Date().toISOString() };
|
||||
healthChecker.createIncident('svc1', 'outage', 'down', ts);
|
||||
healthChecker.createIncident('svc2', 'slow-response', 'slow', ts);
|
||||
healthChecker.resolveIncident('svc1', 'outage', ts);
|
||||
|
||||
const open = healthChecker.getOpenIncidents();
|
||||
expect(open).toHaveLength(1);
|
||||
expect(open[0].serviceId).toBe('svc2');
|
||||
});
|
||||
|
||||
it('getIncidentHistory returns recent incidents in reverse order', () => {
|
||||
const ts = { timestamp: new Date().toISOString() };
|
||||
healthChecker.createIncident('svc1', 'outage', 'first', ts);
|
||||
healthChecker.createIncident('svc2', 'outage', 'second', ts);
|
||||
|
||||
const history = healthChecker.getIncidentHistory();
|
||||
expect(history[0].serviceId).toBe('svc2');
|
||||
expect(history[1].serviceId).toBe('svc1');
|
||||
});
|
||||
|
||||
it('emits incident-created event', () => {
|
||||
const handler = jest.fn();
|
||||
healthChecker.on('incident-created', handler);
|
||||
healthChecker.createIncident('svc1', 'outage', 'down', { timestamp: new Date().toISOString() });
|
||||
expect(handler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits incident-resolved event', () => {
|
||||
const handler = jest.fn();
|
||||
healthChecker.on('incident-resolved', handler);
|
||||
const ts = { timestamp: new Date().toISOString() };
|
||||
healthChecker.createIncident('svc1', 'outage', 'down', ts);
|
||||
healthChecker.resolveIncident('svc1', 'outage', ts);
|
||||
expect(handler).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateSeverity', () => {
|
||||
it('returns critical for outage', () => {
|
||||
expect(healthChecker.calculateSeverity('outage')).toBe('critical');
|
||||
});
|
||||
it('returns high for sla-violation', () => {
|
||||
expect(healthChecker.calculateSeverity('sla-violation')).toBe('high');
|
||||
});
|
||||
it('returns medium for slow-response', () => {
|
||||
expect(healthChecker.calculateSeverity('slow-response')).toBe('medium');
|
||||
});
|
||||
it('returns low for unknown', () => {
|
||||
expect(healthChecker.calculateSeverity('unknown')).toBe('low');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkForIncidents', () => {
|
||||
it('creates outage incident on status change up -> down', () => {
|
||||
// Simulate previous up status
|
||||
healthChecker.currentStatus.set('svc1', { status: 'up' });
|
||||
const status = { status: 'down', timestamp: new Date().toISOString(), responseTime: 100 };
|
||||
healthChecker.checkForIncidents('svc1', status, {});
|
||||
expect(healthChecker.incidents).toHaveLength(1);
|
||||
expect(healthChecker.incidents[0].type).toBe('outage');
|
||||
});
|
||||
|
||||
it('resolves outage incident on status change down -> up', () => {
|
||||
healthChecker.currentStatus.set('svc1', { status: 'down' });
|
||||
const ts = { timestamp: new Date().toISOString() };
|
||||
healthChecker.createIncident('svc1', 'outage', 'was down', ts);
|
||||
|
||||
const status = { status: 'up', timestamp: new Date().toISOString(), responseTime: 100 };
|
||||
healthChecker.checkForIncidents('svc1', status, {});
|
||||
expect(healthChecker.incidents[0].status).toBe('resolved');
|
||||
});
|
||||
|
||||
it('creates slow-response incident when exceeding threshold', () => {
|
||||
const status = { status: 'up', timestamp: new Date().toISOString(), responseTime: 6000 };
|
||||
healthChecker.checkForIncidents('svc1', status, { slowResponseThreshold: 5000 });
|
||||
expect(healthChecker.incidents.some(i => i.type === 'slow-response')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uptime and stats', () => {
|
||||
beforeEach(() => {
|
||||
const now = Date.now();
|
||||
healthChecker.history['svc1'] = [
|
||||
{ status: 'up', responseTime: 100, timestamp: new Date(now - 3600000).toISOString() },
|
||||
{ status: 'up', responseTime: 200, timestamp: new Date(now - 1800000).toISOString() },
|
||||
{ status: 'down', responseTime: 5000, timestamp: new Date(now - 900000).toISOString() },
|
||||
{ status: 'up', responseTime: 150, timestamp: new Date(now - 60000).toISOString() },
|
||||
];
|
||||
});
|
||||
|
||||
it('calculateUptime returns correct percentage', () => {
|
||||
const uptime = healthChecker.calculateUptime('svc1', 24);
|
||||
expect(uptime).toBe(75); // 3 out of 4 checks up
|
||||
});
|
||||
|
||||
it('calculateUptime returns 100 for unknown service', () => {
|
||||
expect(healthChecker.calculateUptime('unknown', 24)).toBe(100);
|
||||
});
|
||||
|
||||
it('calculateAverageResponseTime returns correct average', () => {
|
||||
const avg = healthChecker.calculateAverageResponseTime('svc1', 24);
|
||||
expect(avg).toBe((100 + 200 + 5000 + 150) / 4);
|
||||
});
|
||||
|
||||
it('calculateAverageResponseTime returns 0 for unknown service', () => {
|
||||
expect(healthChecker.calculateAverageResponseTime('unknown', 24)).toBe(0);
|
||||
});
|
||||
|
||||
it('getServiceHistory filters by time period', () => {
|
||||
const history = healthChecker.getServiceHistory('svc1', 24);
|
||||
expect(history.length).toBe(4);
|
||||
|
||||
// Very short period should exclude older entries
|
||||
const recent = healthChecker.getServiceHistory('svc1', 0.01); // ~36 seconds
|
||||
expect(recent.length).toBeLessThan(4);
|
||||
});
|
||||
|
||||
it('getServiceStats returns null for unknown service', () => {
|
||||
expect(healthChecker.getServiceStats('unknown')).toBeNull();
|
||||
});
|
||||
|
||||
it('getServiceStats returns correct stats', () => {
|
||||
const stats = healthChecker.getServiceStats('svc1', 24);
|
||||
expect(stats.totalChecks).toBe(4);
|
||||
expect(stats.upChecks).toBe(3);
|
||||
expect(stats.downChecks).toBe(1);
|
||||
expect(stats.uptime).toBe(75);
|
||||
expect(stats.responseTime.min).toBe(100);
|
||||
expect(stats.responseTime.max).toBe(5000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculatePercentile', () => {
|
||||
it('returns correct p95', () => {
|
||||
const values = Array.from({ length: 100 }, (_, i) => i + 1);
|
||||
const p95 = healthChecker.calculatePercentile(values, 95);
|
||||
expect(p95).toBe(95);
|
||||
});
|
||||
|
||||
it('returns 0 for empty array', () => {
|
||||
expect(healthChecker.calculatePercentile([], 95)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCurrentStatus', () => {
|
||||
it('returns enriched status for all services', () => {
|
||||
healthChecker.config.services = {
|
||||
svc1: { name: 'Test Service' }
|
||||
};
|
||||
healthChecker.currentStatus.set('svc1', {
|
||||
status: 'up', responseTime: 100, timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
const result = healthChecker.getCurrentStatus();
|
||||
expect(result.svc1).toBeDefined();
|
||||
expect(result.svc1.name).toBe('Test Service');
|
||||
expect(result.svc1.uptime).toBeDefined();
|
||||
expect(result.svc1.uptime['24h']).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('configureService / removeService', () => {
|
||||
it('configureService saves config to file', () => {
|
||||
healthChecker.configureService('svc1', {
|
||||
name: 'My Service',
|
||||
url: 'http://localhost:3000',
|
||||
timeout: 10000
|
||||
});
|
||||
|
||||
expect(healthChecker.config.services.svc1).toBeDefined();
|
||||
expect(healthChecker.config.services.svc1.url).toBe('http://localhost:3000');
|
||||
expect(fs.writeFileSync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removeService cleans up all traces', () => {
|
||||
healthChecker.configureService('svc1', { url: 'http://test.local' });
|
||||
healthChecker.currentStatus.set('svc1', { status: 'up' });
|
||||
healthChecker.history['svc1'] = [{ status: 'up' }];
|
||||
|
||||
healthChecker.removeService('svc1');
|
||||
expect(healthChecker.config.services.svc1).toBeUndefined();
|
||||
expect(healthChecker.currentStatus.has('svc1')).toBe(false);
|
||||
expect(healthChecker.history['svc1']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupHistory', () => {
|
||||
it('removes entries older than retention period', () => {
|
||||
const old = new Date(Date.now() - 35 * 24 * 60 * 60 * 1000).toISOString(); // 35 days ago
|
||||
const recent = new Date().toISOString();
|
||||
healthChecker.history['svc1'] = [
|
||||
{ timestamp: old, status: 'up' },
|
||||
{ timestamp: recent, status: 'up' },
|
||||
];
|
||||
|
||||
healthChecker.cleanupHistory();
|
||||
expect(healthChecker.history['svc1']).toHaveLength(1);
|
||||
expect(healthChecker.history['svc1'][0].timestamp).toBe(recent);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadConfig / saveConfig', () => {
|
||||
it('saveConfig writes JSON to file', () => {
|
||||
healthChecker.config = { services: { svc1: { url: 'http://test' } } };
|
||||
healthChecker.saveConfig();
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.stringContaining('"svc1"')
|
||||
);
|
||||
});
|
||||
|
||||
it('saveConfig handles write errors gracefully', () => {
|
||||
fs.writeFileSync.mockImplementation(() => { throw new Error('disk full'); });
|
||||
expect(() => healthChecker.saveConfig()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadHistory / saveHistory', () => {
|
||||
it('loadHistory returns empty object when file missing', () => {
|
||||
const history = healthChecker.loadHistory();
|
||||
expect(history).toEqual({});
|
||||
});
|
||||
|
||||
it('loadHistory parses JSON from file', () => {
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
fs.readFileSync.mockReturnValue(JSON.stringify({ svc1: [{ status: 'up' }] }));
|
||||
const history = healthChecker.loadHistory();
|
||||
expect(history.svc1).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('saveHistory writes history to file', () => {
|
||||
healthChecker.history = { svc1: [{ status: 'up' }] };
|
||||
healthChecker.saveHistory();
|
||||
expect(fs.writeFileSync).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Health endpoint tests
|
||||
*
|
||||
* Verifies:
|
||||
* - /health/live always returns 200
|
||||
* - /health/ready returns 200 with valid structure when all deps OK
|
||||
* - /health/ready returns 503 when a critical dep is down
|
||||
* - /health/ready does NOT crash with "res.status is not a function"
|
||||
*/
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
// Mock dockerode BEFORE anything else
|
||||
jest.mock('dockerode', () => {
|
||||
return jest.fn().mockImplementation(() => ({
|
||||
ping: jest.fn().mockImplementation(() => {
|
||||
if (process.env.MOCK_DOCKER_DOWN === '1') {
|
||||
return Promise.reject(new Error('docker unreachable'));
|
||||
}
|
||||
return Promise.resolve('OK');
|
||||
})
|
||||
}));
|
||||
});
|
||||
|
||||
// Build a minimal Express app with the same health handlers as src/app.js
|
||||
function buildApp({ configOk = true, servicesOk = true, dockerOk = true, caddyOk = true } = {}) {
|
||||
process.env.MOCK_DOCKER_DOWN = dockerOk ? '0' : '1';
|
||||
|
||||
const app = express();
|
||||
const config = {
|
||||
CONFIG_FILE: '/tmp/dc-test-config.json',
|
||||
SERVICES_FILE: '/tmp/dc-test-services.json',
|
||||
CADDY_ADMIN_URL: 'http://localhost:2019'
|
||||
};
|
||||
|
||||
// Mock fs
|
||||
const fs = require('fs');
|
||||
const realExistsSync = fs.existsSync;
|
||||
const realReadFileSync = fs.readFileSync;
|
||||
fs.existsSync = (p) => {
|
||||
if (p === config.CONFIG_FILE) return configOk;
|
||||
if (p === config.SERVICES_FILE) return servicesOk;
|
||||
return realExistsSync(p);
|
||||
};
|
||||
fs.readFileSync = (p, ...args) => {
|
||||
if (p === config.CONFIG_FILE) {
|
||||
if (!configOk) throw new Error('config not found');
|
||||
return '{}';
|
||||
}
|
||||
if (p === config.SERVICES_FILE) {
|
||||
if (!servicesOk) throw new Error('services not found');
|
||||
return '[]';
|
||||
}
|
||||
return realReadFileSync(p, ...args);
|
||||
};
|
||||
|
||||
// /health/live (matches src/app.js exactly)
|
||||
app.get('/health/live', (req, res) => {
|
||||
res.json({ status: 'alive', uptime: process.uptime() });
|
||||
});
|
||||
|
||||
// /health/ready (matches src/app.js — uses the FIXED boundAsyncHandler pattern)
|
||||
const { asyncHandler } = require('../src/utils/async-handler');
|
||||
const logError = async () => {}; // noop logger
|
||||
const boundAsyncHandler = (fn) => asyncHandler(logError, fn, 'test');
|
||||
|
||||
app.get('/health/ready', boundAsyncHandler(async (req, res) => {
|
||||
const checks = {};
|
||||
let allOk = true;
|
||||
|
||||
try {
|
||||
if (fs.existsSync(config.CONFIG_FILE)) {
|
||||
fs.readFileSync(config.CONFIG_FILE, 'utf8');
|
||||
checks.configFile = { ok: true };
|
||||
} else {
|
||||
checks.configFile = { ok: false, error: 'Config file not found' };
|
||||
allOk = false;
|
||||
}
|
||||
} catch (e) {
|
||||
checks.configFile = { ok: false, error: e.message };
|
||||
allOk = false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (fs.existsSync(config.SERVICES_FILE)) {
|
||||
fs.readFileSync(config.SERVICES_FILE, 'utf8');
|
||||
checks.servicesFile = { ok: true };
|
||||
} else {
|
||||
checks.servicesFile = { ok: false, error: 'Services file not found' };
|
||||
allOk = false;
|
||||
}
|
||||
} catch (e) {
|
||||
checks.servicesFile = { ok: false, error: e.message };
|
||||
allOk = false;
|
||||
}
|
||||
|
||||
try {
|
||||
const docker = require('dockerode')();
|
||||
await docker.ping();
|
||||
checks.docker = { ok: true };
|
||||
} catch (e) {
|
||||
checks.docker = { ok: false, error: e.message };
|
||||
allOk = false;
|
||||
}
|
||||
|
||||
try {
|
||||
const caddyUrl = config.CADDY_ADMIN_URL || 'http://localhost:2019';
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||
const response = await fetch(`${caddyUrl}/config/`, { signal: controller.signal });
|
||||
clearTimeout(timeout);
|
||||
checks.caddy = { ok: response.ok, status: response.status };
|
||||
if (!response.ok) allOk = false;
|
||||
} catch (e) {
|
||||
checks.caddy = { ok: false, error: e.message };
|
||||
allOk = false;
|
||||
}
|
||||
|
||||
const body = {
|
||||
status: allOk ? 'ready' : 'not-ready',
|
||||
timestamp: new Date().toISOString(),
|
||||
checks
|
||||
};
|
||||
res.status(allOk ? 200 : 503).json(body);
|
||||
}));
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('Health Endpoints', () => {
|
||||
beforeEach(() => {
|
||||
delete process.env.MOCK_DOCKER_DOWN;
|
||||
});
|
||||
|
||||
describe('GET /health/live', () => {
|
||||
it('always returns 200 with status: alive', async () => {
|
||||
const app = buildApp();
|
||||
const res = await request(app).get('/health/live');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('alive');
|
||||
expect(typeof res.body.uptime).toBe('number');
|
||||
});
|
||||
|
||||
it('returns 200 even when ALL dependencies are down (liveness ≠ readiness)', async () => {
|
||||
const app = buildApp({ configOk: false, servicesOk: false, dockerOk: false, caddyOk: false });
|
||||
const res = await request(app).get('/health/live');
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /health/ready', () => {
|
||||
it('returns 200 when all dependencies are OK (excluding caddy which may 403 in sandbox)', async () => {
|
||||
const app = buildApp();
|
||||
const res = await request(app).get('/health/ready');
|
||||
// config + services + docker should all be OK
|
||||
expect(res.body.checks.configFile.ok).toBe(true);
|
||||
expect(res.body.checks.servicesFile.ok).toBe(true);
|
||||
expect(res.body.checks.docker.ok).toBe(true);
|
||||
// caddy is tested in sandbox — may be 403 or 200
|
||||
expect(res.body).toHaveProperty('checks');
|
||||
expect(res.body).toHaveProperty('status');
|
||||
});
|
||||
|
||||
it('returns 503 when config file is missing', async () => {
|
||||
const app = buildApp({ configOk: false });
|
||||
const res = await request(app).get('/health/ready');
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body.status).toBe('not-ready');
|
||||
expect(res.body.checks.configFile.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('returns 503 when services file is missing', async () => {
|
||||
const app = buildApp({ servicesOk: false });
|
||||
const res = await request(app).get('/health/ready');
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body.checks.servicesFile.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('returns 503 when Docker is unreachable', async () => {
|
||||
const app = buildApp({ dockerOk: false });
|
||||
const res = await request(app).get('/health/ready');
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body.checks.docker.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT crash with "res.status is not a function" when dependencies fail', async () => {
|
||||
const app = buildApp({ dockerOk: false });
|
||||
const res = await request(app).get('/health/ready');
|
||||
const bodyStr = JSON.stringify(res.body);
|
||||
expect(bodyStr).not.toMatch(/res\.status is not a function/);
|
||||
// Should always be a valid response object
|
||||
expect(res.body).toHaveProperty('checks');
|
||||
});
|
||||
|
||||
it('responds with all 4 expected check keys', async () => {
|
||||
const app = buildApp();
|
||||
const res = await request(app).get('/health/ready');
|
||||
expect(Object.keys(res.body.checks).sort()).toEqual(['caddy', 'configFile', 'docker', 'servicesFile']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* Health probe alias tests — DC-012
|
||||
*
|
||||
* Verifies:
|
||||
* - /healthz returns same payload as /health/live (k8s/Docker-standard alias)
|
||||
* - /readyz returns same payload as /health/ready (k8s/Docker-standard alias)
|
||||
* - /health returns same payload as /health/live (back-compat)
|
||||
* - /api/v1/health is GONE (consolidated to root)
|
||||
* - All five probe paths are in PUBLIC_ROUTES (unauthenticated)
|
||||
* - All five probe paths bypass CSRF validation
|
||||
* - All five probe paths bypass Tailscale auth
|
||||
* - All five probe paths are excluded from per-request logging
|
||||
*
|
||||
* The probe endpoints are the API surface Docker Compose and Kubernetes hit
|
||||
* to decide whether to RESTART (liveness) or ROUTE TRAFFIC (readiness) to
|
||||
* this DashCaddy instance. Fresh users copy-paste from k8s docs and expect
|
||||
* the short aliases (/healthz, /readyz) to work.
|
||||
*/
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
// Mock dockerode BEFORE anything else — health/ready probes it for liveness
|
||||
jest.mock('dockerode', () => {
|
||||
return jest.fn().mockImplementation(() => ({
|
||||
ping: jest.fn().mockImplementation(() => {
|
||||
if (process.env.MOCK_DOCKER_DOWN === '1') {
|
||||
return Promise.reject(new Error('docker unreachable'));
|
||||
}
|
||||
return Promise.resolve('OK');
|
||||
})
|
||||
}));
|
||||
});
|
||||
|
||||
// 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.
|
||||
function buildApp({ configOk = true, servicesOk = true, dockerOk = true } = {}) {
|
||||
process.env.MOCK_DOCKER_DOWN = dockerOk ? '0' : '1';
|
||||
|
||||
const app = express();
|
||||
const config = {
|
||||
CONFIG_FILE: '/tmp/dc-test-config.json',
|
||||
SERVICES_FILE: '/tmp/dc-test-services.json',
|
||||
CADDY_ADMIN_URL: 'http://localhost:2019'
|
||||
};
|
||||
|
||||
const fs = require('fs');
|
||||
const realExistsSync = fs.existsSync;
|
||||
const realReadFileSync = fs.readFileSync;
|
||||
fs.existsSync = (p) => {
|
||||
if (p === config.CONFIG_FILE) return configOk;
|
||||
if (p === config.SERVICES_FILE) return servicesOk;
|
||||
return realExistsSync(p);
|
||||
};
|
||||
fs.readFileSync = (p, ...args) => {
|
||||
if (p === config.CONFIG_FILE) {
|
||||
if (!configOk) throw new Error('config not found');
|
||||
return '{}';
|
||||
}
|
||||
if (p === config.SERVICES_FILE) {
|
||||
if (!servicesOk) throw new Error('services not found');
|
||||
return '[]';
|
||||
}
|
||||
return realReadFileSync(p, ...args);
|
||||
};
|
||||
|
||||
const { ok } = require('../src/utils/responses');
|
||||
const { asyncHandler } = require('../src/utils/async-handler');
|
||||
const logError = async () => {};
|
||||
const boundAsyncHandler = (fn) => asyncHandler(logError, fn, 'test');
|
||||
|
||||
const livenessHandler = (req, res) => {
|
||||
ok(res, { status: 'alive', uptime: process.uptime() });
|
||||
};
|
||||
|
||||
const readinessHandler = boundAsyncHandler(async (req, res) => {
|
||||
const checks = {};
|
||||
let allOk = true;
|
||||
try {
|
||||
if (fs.existsSync(config.CONFIG_FILE)) {
|
||||
fs.readFileSync(config.CONFIG_FILE, 'utf8');
|
||||
checks.configFile = { ok: true };
|
||||
} else {
|
||||
checks.configFile = { ok: false, error: 'Config file not found' };
|
||||
allOk = false;
|
||||
}
|
||||
} catch (e) {
|
||||
checks.configFile = { ok: false, error: e.message };
|
||||
allOk = false;
|
||||
}
|
||||
try {
|
||||
if (fs.existsSync(config.SERVICES_FILE)) {
|
||||
fs.readFileSync(config.SERVICES_FILE, 'utf8');
|
||||
checks.servicesFile = { ok: true };
|
||||
} else {
|
||||
checks.servicesFile = { ok: false, error: 'Services file not found' };
|
||||
allOk = false;
|
||||
}
|
||||
} catch (e) {
|
||||
checks.servicesFile = { ok: false, error: e.message };
|
||||
allOk = false;
|
||||
}
|
||||
try {
|
||||
const docker = require('dockerode')();
|
||||
await docker.ping();
|
||||
checks.docker = { ok: true };
|
||||
} catch (e) {
|
||||
checks.docker = { ok: false, error: e.message };
|
||||
allOk = false;
|
||||
}
|
||||
try {
|
||||
const caddyUrl = config.CADDY_ADMIN_URL || 'http://localhost:2019';
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||
const response = await fetch(`${caddyUrl}/config/`, { signal: controller.signal });
|
||||
clearTimeout(timeout);
|
||||
checks.caddy = { ok: response.ok, status: response.status };
|
||||
if (!response.ok) allOk = false;
|
||||
} catch (e) {
|
||||
checks.caddy = { ok: false, error: e.message };
|
||||
allOk = false;
|
||||
}
|
||||
const body = {
|
||||
status: allOk ? 'ready' : 'not-ready',
|
||||
timestamp: new Date().toISOString(),
|
||||
checks
|
||||
};
|
||||
ok(res, body, allOk ? 200 : 503);
|
||||
});
|
||||
|
||||
// Mount exactly as src/app.js does — six routes total, three for each semantic.
|
||||
app.get('/health', livenessHandler);
|
||||
app.get('/health/live', livenessHandler);
|
||||
app.get('/healthz', livenessHandler);
|
||||
app.get('/health/ready', readinessHandler);
|
||||
app.get('/readyz', readinessHandler);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('Health Probe Aliases (DC-012)', () => {
|
||||
beforeEach(() => {
|
||||
delete process.env.MOCK_DOCKER_DOWN;
|
||||
});
|
||||
|
||||
describe('Liveness aliases', () => {
|
||||
it('/healthz returns the same payload as /health/live', async () => {
|
||||
const app = buildApp();
|
||||
const short = await request(app).get('/healthz');
|
||||
const explicit = await request(app).get('/health/live');
|
||||
expect(short.status).toBe(200);
|
||||
expect(explicit.status).toBe(200);
|
||||
expect(short.body.status).toBe(explicit.body.status);
|
||||
expect(typeof short.body.uptime).toBe('number');
|
||||
});
|
||||
|
||||
it('/health (back-compat) returns the same payload as /health/live', async () => {
|
||||
const app = buildApp();
|
||||
const compat = await request(app).get('/health');
|
||||
const explicit = await request(app).get('/health/live');
|
||||
expect(compat.status).toBe(200);
|
||||
expect(explicit.status).toBe(200);
|
||||
expect(compat.body.status).toBe(explicit.body.status);
|
||||
});
|
||||
|
||||
it('all three liveness paths return 200 even when ALL deps are down', async () => {
|
||||
const app = buildApp({ configOk: false, servicesOk: false, dockerOk: false });
|
||||
for (const path of ['/health', '/health/live', '/healthz']) {
|
||||
const res = await request(app).get(path);
|
||||
expect(res.status).toBe(200);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Readiness aliases', () => {
|
||||
it('/readyz returns the same payload as /health/ready', async () => {
|
||||
const app = buildApp();
|
||||
const short = await request(app).get('/readyz');
|
||||
const explicit = await request(app).get('/health/ready');
|
||||
expect(short.body.status).toBe(explicit.body.status);
|
||||
expect(Object.keys(short.body.checks).sort())
|
||||
.toEqual(Object.keys(explicit.body.checks).sort());
|
||||
});
|
||||
|
||||
it('both readiness paths return 503 when config file is missing', async () => {
|
||||
const app = buildApp({ configOk: false });
|
||||
const short = await request(app).get('/readyz');
|
||||
const explicit = await request(app).get('/health/ready');
|
||||
expect(short.status).toBe(503);
|
||||
expect(explicit.status).toBe(503);
|
||||
expect(short.body.checks.configFile.ok).toBe(false);
|
||||
expect(explicit.body.checks.configFile.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('both readiness paths return 503 when Docker is unreachable', async () => {
|
||||
const app = buildApp({ dockerOk: false });
|
||||
const short = await request(app).get('/readyz');
|
||||
const explicit = await request(app).get('/health/ready');
|
||||
expect(short.status).toBe(503);
|
||||
expect(explicit.status).toBe(503);
|
||||
expect(short.body.checks.docker.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Path consolidation', () => {
|
||||
it('GET /api/v1/health is GONE — returns 404', async () => {
|
||||
const app = buildApp();
|
||||
const res = await request(app).get('/api/v1/health');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('GET /api/v1/health/live is GONE — returns 404', async () => {
|
||||
const app = buildApp();
|
||||
const res = await request(app).get('/api/v1/health/live');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('GET /api/v1/health/ready is GONE — returns 404', async () => {
|
||||
const app = buildApp();
|
||||
const res = await request(app).get('/api/v1/health/ready');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Public route allowlist (PUBLIC_ROUTES)', () => {
|
||||
// Source-of-truth check: the middleware file must list all five probe
|
||||
// paths as public. If someone removes one, fresh users hit a 401.
|
||||
let middlewareSource;
|
||||
beforeAll(() => {
|
||||
middlewareSource = require('fs').readFileSync(
|
||||
require('path').join(__dirname, '..', 'src', 'utilities', 'middleware.js'),
|
||||
'utf8'
|
||||
);
|
||||
});
|
||||
|
||||
for (const path of ['/health', '/health/live', '/health/ready', '/healthz', '/readyz']) {
|
||||
it(`PUBLIC_ROUTES contains '${path}'`, () => {
|
||||
// Look for the path inside a PUBLIC_ROUTES object literal entry.
|
||||
// Use a regex that matches the exact path as a string literal.
|
||||
const re = new RegExp(`path:\\s*['"]${path.replace(/\//g, '\\/')}['"]`);
|
||||
expect(middlewareSource).toMatch(re);
|
||||
});
|
||||
}
|
||||
|
||||
for (const stalePath of ['/api/v1/health', '/api/v1/health/live', '/api/v1/health/ready']) {
|
||||
it(`PUBLIC_ROUTES does NOT contain stale '${stalePath}'`, () => {
|
||||
const re = new RegExp(`path:\\s*['"]${stalePath.replace(/\//g, '\\/')}['"]`);
|
||||
expect(middlewareSource).not.toMatch(re);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('CSRF bypass for probe paths', () => {
|
||||
let csrfValidationMiddleware;
|
||||
beforeAll(() => {
|
||||
// Source-of-truth: the CSRF middleware must skip all five probe paths.
|
||||
csrfValidationMiddleware = require('../src/utilities/middleware').csrfValidationMiddleware
|
||||
|| require('../src/utilities/middleware').default
|
||||
|| null;
|
||||
});
|
||||
|
||||
it('csrf-protection.test.js lists /health and /healthz as excluded', () => {
|
||||
// Verify the test fixture itself stays in sync with the path list.
|
||||
const testSource = require('fs').readFileSync(
|
||||
require('path').join(__dirname, 'csrf-protection.test.js'),
|
||||
'utf8'
|
||||
);
|
||||
expect(testSource).toMatch(/'\/health'/);
|
||||
expect(testSource).toMatch(/'\/healthz'/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Source-of-truth sync with src/app.js', () => {
|
||||
// If someone adds a new probe path in src/app.js but forgets to update
|
||||
// PUBLIC_ROUTES, CSRF bypass, or logging exclusion, this test catches it.
|
||||
it('all probe paths in src/app.js appear in middleware.js logging exclusion', () => {
|
||||
const appJs = require('fs').readFileSync(
|
||||
require('path').join(__dirname, '..', 'src', 'app.js'),
|
||||
'utf8'
|
||||
);
|
||||
const mw = require('fs').readFileSync(
|
||||
require('path').join(__dirname, '..', 'src', 'utilities', 'middleware.js'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
// Find every app.get('/...', livenessHandler|readinessHandler) in app.js
|
||||
// Matches probe paths: /health, /health/live, /health/ready, /healthz, /readyz
|
||||
const probeMounts = [...appJs.matchAll(
|
||||
/app\.get\('((?:[/]health[a-z/]*|[/]readyz))',\s*(livenessHandler|readinessHandler)/g
|
||||
)].map(m => m[1]);
|
||||
|
||||
expect(probeMounts.length).toBeGreaterThanOrEqual(5);
|
||||
expect(probeMounts).toEqual(expect.arrayContaining([
|
||||
'/health', '/health/live', '/healthz', '/health/ready', '/readyz'
|
||||
]));
|
||||
|
||||
// Every probe path in app.js must appear in the middleware logging
|
||||
// exclusion list. Otherwise k8s probes flood the audit log.
|
||||
for (const p of probeMounts) {
|
||||
expect(mw).toMatch(new RegExp(`req\\.path === '${p}'`));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Shared test utilities for DashCaddy test suite
|
||||
*/
|
||||
const express = require('express');
|
||||
|
||||
/**
|
||||
* Create a mock credential manager
|
||||
*/
|
||||
function createMockCredentialManager() {
|
||||
return {
|
||||
store: jest.fn().mockResolvedValue(true),
|
||||
retrieve: jest.fn().mockResolvedValue(null),
|
||||
delete: jest.fn().mockResolvedValue(true),
|
||||
list: jest.fn().mockResolvedValue([]),
|
||||
getMetadata: jest.fn().mockResolvedValue(null),
|
||||
rotateEncryptionKey: jest.fn().mockResolvedValue(true),
|
||||
exportBackup: jest.fn().mockResolvedValue('encrypted-backup'),
|
||||
importBackup: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock crypto utils module
|
||||
*/
|
||||
function createMockCryptoUtils() {
|
||||
const fixedKey = Buffer.alloc(32, 'a');
|
||||
return {
|
||||
encrypt: jest.fn(data => `mock-iv:mock-tag:${Buffer.from(String(data)).toString('base64')}`),
|
||||
decrypt: jest.fn(data => {
|
||||
const parts = data.split(':');
|
||||
return Buffer.from(parts[2], 'base64').toString('utf8');
|
||||
}),
|
||||
isEncrypted: jest.fn(data => typeof data === 'string' && data.split(':').length === 3),
|
||||
encryptFields: jest.fn((obj, fields) => ({ ...obj, _encrypted: true, _encryptedFields: fields })),
|
||||
decryptFields: jest.fn(obj => {
|
||||
const result = { ...obj };
|
||||
delete result._encrypted;
|
||||
delete result._encryptedFields;
|
||||
return result;
|
||||
}),
|
||||
loadOrCreateKey: jest.fn(() => fixedKey),
|
||||
clearCachedKey: jest.fn(),
|
||||
rotateKey: jest.fn(() => ({ oldKey: fixedKey, newKey: Buffer.alloc(32, 'b') })),
|
||||
deriveKey: jest.fn().mockResolvedValue(fixedKey),
|
||||
decryptWithKey: jest.fn(data => {
|
||||
const parts = data.split(':');
|
||||
return Buffer.from(parts[2], 'base64').toString('utf8');
|
||||
}),
|
||||
readEncryptedFile: jest.fn().mockReturnValue(null),
|
||||
writeEncryptedFile: jest.fn(),
|
||||
migrateToEncrypted: jest.fn(obj => obj),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock state manager
|
||||
*/
|
||||
function createMockStateManager() {
|
||||
let data = [];
|
||||
return {
|
||||
read: jest.fn().mockResolvedValue(data),
|
||||
write: jest.fn().mockResolvedValue(),
|
||||
update: jest.fn(async fn => { data = fn(data); return data; }),
|
||||
addItem: jest.fn().mockResolvedValue(),
|
||||
removeItem: jest.fn().mockResolvedValue(),
|
||||
updateItem: jest.fn().mockResolvedValue(),
|
||||
findItem: jest.fn().mockResolvedValue(null),
|
||||
_setData: (newData) => { data = newData; },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock logger
|
||||
*/
|
||||
function createMockLogger() {
|
||||
return {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a minimal Express app for route testing with supertest
|
||||
*/
|
||||
function buildTestApp(routeFactory, deps, prefix = '/api') {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
const router = routeFactory(deps);
|
||||
app.use(prefix, router);
|
||||
// Error handler
|
||||
const { errorMiddleware } = require('../../../src/utilities/error-handler');
|
||||
app.use(errorMiddleware);
|
||||
return app;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create mock Express req/res/next for middleware testing
|
||||
*/
|
||||
function createMockReqRes(overrides = {}) {
|
||||
const req = {
|
||||
method: 'GET',
|
||||
path: '/test',
|
||||
headers: {},
|
||||
cookies: {},
|
||||
ip: '127.0.0.1',
|
||||
protocol: 'https',
|
||||
secure: true,
|
||||
body: {},
|
||||
params: {},
|
||||
query: {},
|
||||
get: jest.fn(header => req.headers[header.toLowerCase()]),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
const res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn().mockReturnThis(),
|
||||
send: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
cookie: jest.fn().mockReturnThis(),
|
||||
setHeader: jest.fn().mockReturnThis(),
|
||||
getHeader: jest.fn(),
|
||||
end: jest.fn(),
|
||||
};
|
||||
|
||||
const next = jest.fn();
|
||||
|
||||
return { req, res, next };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createMockCredentialManager,
|
||||
createMockCryptoUtils,
|
||||
createMockStateManager,
|
||||
createMockLogger,
|
||||
buildTestApp,
|
||||
createMockReqRes,
|
||||
};
|
||||
@@ -0,0 +1,556 @@
|
||||
const {
|
||||
ValidationError,
|
||||
validateDNSRecord,
|
||||
validateDockerDeployment,
|
||||
validateFilePath,
|
||||
validateVolumePath,
|
||||
validateURL,
|
||||
validateToken,
|
||||
validateServiceConfig,
|
||||
sanitizeString,
|
||||
isValidPort,
|
||||
isPrivateIP,
|
||||
validateSecurePath
|
||||
} = require('../src/security/input-validator');
|
||||
|
||||
describe('Input Validator', () => {
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
describe('ValidationError', () => {
|
||||
it('has correct name, message, field, and statusCode', () => {
|
||||
const err = new ValidationError('bad input', 'email');
|
||||
expect(err.name).toBe('ValidationError');
|
||||
expect(err.message).toBe('bad input');
|
||||
expect(err.field).toBe('email');
|
||||
expect(err.statusCode).toBe(400);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it('field defaults to null', () => {
|
||||
const err = new ValidationError('oops');
|
||||
expect(err.field).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateDNSRecord', () => {
|
||||
const validRecord = { subdomain: 'myapp', ip: '8.8.8.8' };
|
||||
|
||||
it('valid record returns sanitized data with lowercase subdomain and default TTL', () => {
|
||||
const result = validateDNSRecord({ subdomain: 'MyApp', ip: '1.2.3.4' });
|
||||
expect(result.subdomain).toBe('myapp');
|
||||
expect(result.ip).toBe('1.2.3.4');
|
||||
expect(result.ttl).toBe(3600);
|
||||
});
|
||||
|
||||
it('accepts valid domain and custom TTL', () => {
|
||||
const result = validateDNSRecord({
|
||||
subdomain: 'test', ip: '8.8.8.8', domain: 'example.com', ttl: 300
|
||||
});
|
||||
expect(result.domain).toBe('example.com');
|
||||
expect(result.ttl).toBe(300);
|
||||
});
|
||||
|
||||
it('rejects missing subdomain', () => {
|
||||
expect(() => validateDNSRecord({ ip: '1.2.3.4' })).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('rejects invalid subdomain format', () => {
|
||||
expect(() => validateDNSRecord({ subdomain: '-bad', ip: '1.2.3.4' })).toThrow(ValidationError);
|
||||
expect(() => validateDNSRecord({ subdomain: 'a'.repeat(64), ip: '1.2.3.4' })).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('rejects DNS injection chars', () => {
|
||||
const dangerous = [';', '&', '|', '`', '$', '(', ')', '<', '>', '\n', '\r', '\\'];
|
||||
for (const char of dangerous) {
|
||||
expect(() => validateDNSRecord({ subdomain: `test${char}cmd`, ip: '1.2.3.4' }))
|
||||
.toThrow(ValidationError);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects invalid domain format', () => {
|
||||
expect(() => validateDNSRecord({ subdomain: 'app', ip: '1.2.3.4', domain: 'not valid!!' }))
|
||||
.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('rejects missing IP', () => {
|
||||
expect(() => validateDNSRecord({ subdomain: 'test' })).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('rejects invalid IP format', () => {
|
||||
expect(() => validateDNSRecord({ subdomain: 'test', ip: '999.999.999.999' }))
|
||||
.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('blocks private IPs when blockPrivateIPs flag set', () => {
|
||||
expect(() => validateDNSRecord({
|
||||
subdomain: 'test', ip: '192.168.1.1', blockPrivateIPs: true
|
||||
})).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('allows private IPs when flag not set', () => {
|
||||
const result = validateDNSRecord({ subdomain: 'test', ip: '192.168.1.1' });
|
||||
expect(result.ip).toBe('192.168.1.1');
|
||||
});
|
||||
|
||||
it('rejects TTL below 60', () => {
|
||||
expect(() => validateDNSRecord({ subdomain: 'test', ip: '1.2.3.4', ttl: 10 }))
|
||||
.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('rejects TTL above 86400', () => {
|
||||
expect(() => validateDNSRecord({ subdomain: 'test', ip: '1.2.3.4', ttl: 100000 }))
|
||||
.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('aggregates multiple errors', () => {
|
||||
try {
|
||||
validateDNSRecord({ subdomain: '', ip: '' });
|
||||
fail('Should have thrown');
|
||||
} catch (err) {
|
||||
expect(err.errors).toBeDefined();
|
||||
expect(err.errors.length).toBeGreaterThan(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateDockerDeployment', () => {
|
||||
const valid = { name: 'my-app', image: 'nginx:latest' };
|
||||
|
||||
it('valid deployment returns sanitized data', () => {
|
||||
const result = validateDockerDeployment(valid);
|
||||
expect(result.name).toBe('my-app');
|
||||
expect(result.image).toBe('nginx:latest');
|
||||
expect(result.ports).toEqual([]);
|
||||
expect(result.volumes).toEqual([]);
|
||||
expect(result.environment).toEqual({});
|
||||
});
|
||||
|
||||
it('rejects missing container name', () => {
|
||||
expect(() => validateDockerDeployment({ image: 'nginx' })).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('rejects invalid container name chars', () => {
|
||||
expect(() => validateDockerDeployment({ name: '!invalid', image: 'nginx' }))
|
||||
.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('rejects container name > 255 chars', () => {
|
||||
expect(() => validateDockerDeployment({ name: 'a'.repeat(256), image: 'nginx' }))
|
||||
.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('rejects missing image', () => {
|
||||
expect(() => validateDockerDeployment({ name: 'app' })).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('blocks dangerous chars in image', () => {
|
||||
const dangerous = [';', '&', '|', '`', '$', '$(', '&&', '||', '\n'];
|
||||
for (const char of dangerous) {
|
||||
expect(() => validateDockerDeployment({ name: 'app', image: `nginx${char}rm` }))
|
||||
.toThrow(ValidationError);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects image name > 512 chars', () => {
|
||||
expect(() => validateDockerDeployment({ name: 'app', image: 'a'.repeat(513) }))
|
||||
.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('validates port format "8080:80" and "8080:80/tcp"', () => {
|
||||
const result = validateDockerDeployment({
|
||||
...valid, ports: ['8080:80', '443:443/tcp']
|
||||
});
|
||||
expect(result.ports).toEqual(['8080:80', '443:443/tcp']);
|
||||
});
|
||||
|
||||
it('rejects invalid port format', () => {
|
||||
expect(() => validateDockerDeployment({ ...valid, ports: ['bad'] }))
|
||||
.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('rejects port numbers outside 1-65535', () => {
|
||||
expect(() => validateDockerDeployment({ ...valid, ports: ['99999:80'] }))
|
||||
.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('rejects ports that is not an array', () => {
|
||||
expect(() => validateDockerDeployment({ ...valid, ports: 'not-array' }))
|
||||
.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('validates volume format', () => {
|
||||
const result = validateDockerDeployment({
|
||||
...valid, volumes: ['/data:/app/data', '/config:/app/config:ro']
|
||||
});
|
||||
expect(result.volumes).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('rejects volumes that is not an array', () => {
|
||||
expect(() => validateDockerDeployment({ ...valid, volumes: 'not-array' }))
|
||||
.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('validates environment variable names', () => {
|
||||
const result = validateDockerDeployment({
|
||||
...valid, environment: { NODE_ENV: 'production', PORT: 3000, DEBUG: true }
|
||||
});
|
||||
expect(result.environment).toEqual({ NODE_ENV: 'production', PORT: 3000, DEBUG: true });
|
||||
});
|
||||
|
||||
it('rejects invalid env var names', () => {
|
||||
expect(() => validateDockerDeployment({
|
||||
...valid, environment: { '123invalid': 'val' }
|
||||
})).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('rejects environment that is not an object', () => {
|
||||
expect(() => validateDockerDeployment({ ...valid, environment: 'bad' }))
|
||||
.toThrow(ValidationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateFilePath', () => {
|
||||
it('returns normalized path for valid input', () => {
|
||||
const result = validateFilePath('/app/data/file.json');
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects null/empty/non-string path', () => {
|
||||
expect(() => validateFilePath(null)).toThrow(ValidationError);
|
||||
expect(() => validateFilePath('')).toThrow(ValidationError);
|
||||
expect(() => validateFilePath(123)).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('rejects directory traversal (..)', () => {
|
||||
// Use relative path so .. survives path.normalize on all platforms
|
||||
expect(() => validateFilePath('foo/../../bar')).toThrow('Path traversal detected');
|
||||
});
|
||||
|
||||
it('rejects tilde (~)', () => {
|
||||
expect(() => validateFilePath('data/~/secret')).toThrow('Path traversal detected');
|
||||
});
|
||||
|
||||
it('blocks sensitive paths', () => {
|
||||
if (process.platform === 'win32') {
|
||||
expect(() => validateFilePath('C:\\Windows\\System32\\config')).toThrow('not allowed');
|
||||
expect(() => validateFilePath('C:\\Program Files\\test')).toThrow('not allowed');
|
||||
} else {
|
||||
expect(() => validateFilePath('/etc/passwd')).toThrow('not allowed');
|
||||
expect(() => validateFilePath('/proc/1/status')).toThrow('not allowed');
|
||||
expect(() => validateFilePath('/sys/kernel')).toThrow('not allowed');
|
||||
expect(() => validateFilePath('/root/.ssh')).toThrow('not allowed');
|
||||
expect(() => validateFilePath('/var/run/docker.sock')).toThrow('not allowed');
|
||||
expect(() => validateFilePath('/var/lib/docker/containers')).toThrow('not allowed');
|
||||
}
|
||||
});
|
||||
|
||||
it('validates against allowedBasePaths', () => {
|
||||
const result = validateFilePath('/app/data/file.txt', ['/app/data']);
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects paths outside allowed base', () => {
|
||||
expect(() => validateFilePath('/other/file.txt', ['/app/data']))
|
||||
.toThrow('outside allowed directories');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateVolumePath', () => {
|
||||
it('valid volume returns no errors', () => {
|
||||
const errors = validateVolumePath('/host/path:/container/path', 0);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('valid volume with mode returns no errors', () => {
|
||||
const errors = validateVolumePath('/host/path:/container/path:ro', 0);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('detects invalid format', () => {
|
||||
const errors = validateVolumePath('invalidformat', 0);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0].message).toContain('Invalid volume format');
|
||||
});
|
||||
|
||||
it('validates container path must be absolute', () => {
|
||||
const errors = validateVolumePath('/host:relative/path', 0);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateURL', () => {
|
||||
it('accepts valid http/https URLs', () => {
|
||||
expect(validateURL('https://example.com')).toBe('https://example.com');
|
||||
expect(validateURL('http://example.com/path')).toBe('http://example.com/path');
|
||||
});
|
||||
|
||||
it('rejects missing URL', () => {
|
||||
expect(() => validateURL(null)).toThrow(ValidationError);
|
||||
expect(() => validateURL('')).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('rejects invalid URL format', () => {
|
||||
expect(() => validateURL('not-a-url')).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('blocks private IP when blockPrivate is true', () => {
|
||||
expect(() => validateURL('http://10.0.0.1/', { blockPrivate: true }))
|
||||
.toThrow('Private URLs');
|
||||
});
|
||||
|
||||
it('blocks 192.168.x.x when blockPrivate is true', () => {
|
||||
expect(() => validateURL('http://192.168.1.1/', { blockPrivate: true }))
|
||||
.toThrow('Private URLs');
|
||||
});
|
||||
|
||||
it('allows private IPs when blockPrivate is false', () => {
|
||||
expect(validateURL('http://10.0.0.1/')).toBe('http://10.0.0.1/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateToken', () => {
|
||||
it('accepts valid tokens', () => {
|
||||
const result = validateToken('abcdef1234567890');
|
||||
expect(result).toBe('abcdef1234567890');
|
||||
});
|
||||
|
||||
it('trims whitespace', () => {
|
||||
const result = validateToken(' validtoken ');
|
||||
expect(result).toBe('validtoken');
|
||||
});
|
||||
|
||||
it('rejects missing/non-string token', () => {
|
||||
expect(() => validateToken(null)).toThrow(ValidationError);
|
||||
expect(() => validateToken(123)).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('rejects token < 8 chars', () => {
|
||||
expect(() => validateToken('short')).toThrow('too short');
|
||||
});
|
||||
|
||||
it('rejects token > 512 chars', () => {
|
||||
expect(() => validateToken('a'.repeat(513))).toThrow('too long');
|
||||
});
|
||||
|
||||
it('rejects tokens with injection chars', () => {
|
||||
const dangerous = [';', '&', '|', '`', '\n', '\r', '$(', '&&'];
|
||||
for (const char of dangerous) {
|
||||
expect(() => validateToken(`validtoken${char}inject`)).toThrow('invalid characters');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateServiceConfig', () => {
|
||||
const valid = { id: 'my-service', name: 'My Service' };
|
||||
|
||||
it('valid service config passes', () => {
|
||||
const result = validateServiceConfig(valid);
|
||||
expect(result.id).toBe('my-service');
|
||||
});
|
||||
|
||||
it('rejects missing id', () => {
|
||||
expect(() => validateServiceConfig({ name: 'Test' })).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('rejects invalid id format', () => {
|
||||
expect(() => validateServiceConfig({ id: 'bad id!', name: 'Test' }))
|
||||
.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('rejects missing name', () => {
|
||||
expect(() => validateServiceConfig({ id: 'test' })).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('rejects name > 100 chars', () => {
|
||||
expect(() => validateServiceConfig({ id: 'test', name: 'x'.repeat(101) }))
|
||||
.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('validates URL when provided', () => {
|
||||
expect(() => validateServiceConfig({ id: 'test', name: 'Test', url: 'not-valid' }))
|
||||
.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('validates port when provided', () => {
|
||||
expect(() => validateServiceConfig({ id: 'test', name: 'Test', port: 99999 }))
|
||||
.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('accepts valid port', () => {
|
||||
const result = validateServiceConfig({ id: 'test', name: 'Test', port: 8080 });
|
||||
expect(result.port).toBe(8080);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeString', () => {
|
||||
it('escapes < > \' " to HTML entities', () => {
|
||||
expect(sanitizeString('<script>"alert(\'xss\')"</script>')).toBe(
|
||||
'<script>"alert('xss')"</script>'
|
||||
);
|
||||
});
|
||||
|
||||
it('truncates to maxLength', () => {
|
||||
expect(sanitizeString('hello world', 5)).toBe('hello');
|
||||
});
|
||||
|
||||
it('returns empty string for non-string input', () => {
|
||||
expect(sanitizeString(123)).toBe('');
|
||||
expect(sanitizeString(null)).toBe('');
|
||||
expect(sanitizeString(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidPort', () => {
|
||||
it('returns true for valid ports', () => {
|
||||
expect(isValidPort(1)).toBe(true);
|
||||
expect(isValidPort(80)).toBe(true);
|
||||
expect(isValidPort(443)).toBe(true);
|
||||
expect(isValidPort(65535)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for invalid ports', () => {
|
||||
expect(isValidPort(0)).toBe(false);
|
||||
expect(isValidPort(-1)).toBe(false);
|
||||
expect(isValidPort(65536)).toBe(false);
|
||||
expect(isValidPort(NaN)).toBe(false);
|
||||
});
|
||||
|
||||
it('handles string numbers', () => {
|
||||
expect(isValidPort('8080')).toBe(true);
|
||||
expect(isValidPort('0')).toBe(false);
|
||||
expect(isValidPort('abc')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPrivateIP', () => {
|
||||
it('identifies 10.x.x.x as private', () => {
|
||||
expect(isPrivateIP('10.0.0.1')).toBe(true);
|
||||
expect(isPrivateIP('10.255.255.255')).toBe(true);
|
||||
});
|
||||
|
||||
it('identifies 172.16-31.x.x as private', () => {
|
||||
expect(isPrivateIP('172.16.0.1')).toBe(true);
|
||||
expect(isPrivateIP('172.31.255.255')).toBe(true);
|
||||
});
|
||||
|
||||
it('identifies 192.168.x.x as private', () => {
|
||||
expect(isPrivateIP('192.168.1.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('identifies 127.x.x.x as private', () => {
|
||||
expect(isPrivateIP('127.0.0.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('identifies 169.254.x.x as private', () => {
|
||||
expect(isPrivateIP('169.254.0.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('identifies IPv6 loopback as private', () => {
|
||||
expect(isPrivateIP('::1')).toBe(true);
|
||||
});
|
||||
|
||||
it('identifies fc00: and fe80: as private', () => {
|
||||
expect(isPrivateIP('fc00::1')).toBe(true);
|
||||
expect(isPrivateIP('fe80::1')).toBe(true);
|
||||
});
|
||||
|
||||
it('public IPs return false', () => {
|
||||
expect(isPrivateIP('8.8.8.8')).toBe(false);
|
||||
expect(isPrivateIP('1.1.1.1')).toBe(false);
|
||||
expect(isPrivateIP('203.0.113.1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateSecurePath', () => {
|
||||
const mockRealpath = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
// Mock fs.promises.realpath
|
||||
jest.doMock('fs', () => ({
|
||||
...jest.requireActual('fs'),
|
||||
promises: {
|
||||
realpath: mockRealpath,
|
||||
},
|
||||
}));
|
||||
mockRealpath.mockReset();
|
||||
});
|
||||
|
||||
// Re-require after mocking fs
|
||||
function getValidateSecurePath() {
|
||||
return require('../src/security/input-validator').validateSecurePath;
|
||||
}
|
||||
|
||||
it('resolves valid path within allowed roots', async () => {
|
||||
const fn = getValidateSecurePath();
|
||||
mockRealpath.mockResolvedValue('/app/data/file.txt');
|
||||
const result = await fn('/app/data/file.txt', ['/app/data']);
|
||||
expect(result).toBe('/app/data/file.txt');
|
||||
});
|
||||
|
||||
it('rejects null/empty path', async () => {
|
||||
const fn = getValidateSecurePath();
|
||||
await expect(fn(null, ['/app'])).rejects.toThrow('Path is required');
|
||||
await expect(fn('', ['/app'])).rejects.toThrow('Path is required');
|
||||
});
|
||||
|
||||
it('rejects null byte injection', async () => {
|
||||
const fn = getValidateSecurePath();
|
||||
await expect(fn('/app/data\0/evil', ['/app']))
|
||||
.rejects.toThrow('null byte detected');
|
||||
});
|
||||
|
||||
it('rejects .. traversal sequences', async () => {
|
||||
const fn = getValidateSecurePath();
|
||||
await expect(fn('/app/../etc/passwd', ['/app']))
|
||||
.rejects.toThrow('Path traversal detected');
|
||||
});
|
||||
|
||||
it('rejects URL-encoded traversal', async () => {
|
||||
const fn = getValidateSecurePath();
|
||||
await expect(fn('/app/%2e%2e/etc/passwd', ['/app']))
|
||||
.rejects.toThrow('Path traversal detected');
|
||||
});
|
||||
|
||||
it('rejects path outside allowed roots', async () => {
|
||||
const fn = getValidateSecurePath();
|
||||
mockRealpath.mockResolvedValue('/other/place/file.txt');
|
||||
await expect(fn('/other/place/file.txt', ['/app/data']))
|
||||
.rejects.toThrow('outside allowed directories');
|
||||
});
|
||||
|
||||
it('logs audit event when path is blocked', async () => {
|
||||
const fn = getValidateSecurePath();
|
||||
const auditLogger = { logSecurityEvent: jest.fn() };
|
||||
await expect(fn('/app/data\0evil', ['/app'], auditLogger))
|
||||
.rejects.toThrow();
|
||||
expect(auditLogger.logSecurityEvent).toHaveBeenCalledWith(
|
||||
'path_traversal_blocked',
|
||||
expect.objectContaining({ reason: 'null_byte_detected', severity: 'high' })
|
||||
);
|
||||
});
|
||||
|
||||
it('handles ENOENT by checking parent', async () => {
|
||||
const fn = getValidateSecurePath();
|
||||
mockRealpath
|
||||
.mockRejectedValueOnce(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }))
|
||||
.mockResolvedValueOnce('/app/data'); // parent resolves
|
||||
const result = await fn('/app/data/newfile.txt', ['/app/data']);
|
||||
expect(result).toContain('newfile.txt');
|
||||
});
|
||||
|
||||
it('handles EACCES with access denied error', async () => {
|
||||
const fn = getValidateSecurePath();
|
||||
mockRealpath.mockRejectedValue(Object.assign(new Error('EACCES'), { code: 'EACCES' }));
|
||||
await expect(fn('/secret/file', ['/secret']))
|
||||
.rejects.toThrow('Access denied');
|
||||
});
|
||||
|
||||
it('rejects when no allowed roots configured', async () => {
|
||||
const fn = getValidateSecurePath();
|
||||
await expect(fn('/app/file', [])).rejects.toThrow('No allowed roots configured');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
// Jest setup file
|
||||
// Runs before all tests
|
||||
|
||||
// Suppress console output during tests unless there's a failure
|
||||
global.console = {
|
||||
...console,
|
||||
log: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
};
|
||||
|
||||
// Increase timeout for slow operations
|
||||
jest.setTimeout(15000);
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Smoke tests for log-digest.js
|
||||
* Verifies the singleton LogDigest exposes the expected interface, parses
|
||||
* Docker multiplexed log streams, formats digests, and supports on-demand
|
||||
* daily digest generation with mocked Docker.
|
||||
*/
|
||||
|
||||
const fsReal = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
jest.mock('dockerode', () => {
|
||||
const listContainers = jest.fn().mockResolvedValue([]);
|
||||
const getContainer = jest.fn(() => ({
|
||||
logs: jest.fn().mockResolvedValue(Buffer.from([])),
|
||||
}));
|
||||
function Docker() {}
|
||||
Docker.prototype.listContainers = listContainers;
|
||||
Docker.prototype.getContainer = getContainer;
|
||||
return Docker;
|
||||
});
|
||||
|
||||
jest.mock('fs', () => {
|
||||
const actual = jest.requireActual('fs');
|
||||
return {
|
||||
...actual,
|
||||
existsSync: jest.fn().mockReturnValue(true),
|
||||
mkdirSync: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../src/docker/docker-maintenance', () => ({
|
||||
getDiskUsage: jest.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
const Docker = require('dockerode');
|
||||
const fs = require('fs');
|
||||
const logDigest = require('../src/security/log-digest');
|
||||
|
||||
describe('LogDigest (singleton)', () => {
|
||||
let dockerInstance;
|
||||
let tempDir;
|
||||
|
||||
beforeEach(() => {
|
||||
// Each test gets a fresh Docker() mock instance
|
||||
jest.clearAllMocks();
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
// Use a real, writable temp directory so writeFile inside generateDailyDigest
|
||||
// does not blow up. Each test gets a fresh dir to avoid cross-test pollution.
|
||||
tempDir = fsReal.mkdtempSync(path.join(os.tmpdir(), 'dc-digest-test-'));
|
||||
logDigest.hourlySummaries = [];
|
||||
logDigest.lastCollect = null;
|
||||
logDigest.running = false;
|
||||
logDigest.digestDir = null;
|
||||
if (logDigest.collectInterval) {
|
||||
clearInterval(logDigest.collectInterval);
|
||||
logDigest.collectInterval = null;
|
||||
}
|
||||
if (logDigest.digestTimeout) {
|
||||
clearTimeout(logDigest.digestTimeout);
|
||||
logDigest.digestTimeout = null;
|
||||
}
|
||||
dockerInstance = new Docker();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
logDigest.stop();
|
||||
if (tempDir && fsReal.existsSync(tempDir)) {
|
||||
fsReal.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('is an EventEmitter and exposes the documented API', () => {
|
||||
expect(typeof logDigest.on).toBe('function');
|
||||
expect(typeof logDigest.emit).toBe('function');
|
||||
expect(typeof logDigest.start).toBe('function');
|
||||
expect(typeof logDigest.stop).toBe('function');
|
||||
expect(typeof logDigest.generateDailyDigest).toBe('function');
|
||||
expect(typeof logDigest.getLatestDigest).toBe('function');
|
||||
expect(typeof logDigest.getDigestByDate).toBe('function');
|
||||
expect(typeof logDigest.getDigestText).toBe('function');
|
||||
expect(typeof logDigest.listDigests).toBe('function');
|
||||
expect(typeof logDigest.getLiveData).toBe('function');
|
||||
expect(typeof logDigest.getStatus).toBe('function');
|
||||
});
|
||||
|
||||
test('getStatus returns current state', () => {
|
||||
const status = logDigest.getStatus();
|
||||
expect(status).toEqual({
|
||||
running: false,
|
||||
lastCollect: null,
|
||||
hourlySummaries: 0,
|
||||
digestDir: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('start sets running and digestDir', () => {
|
||||
logDigest.start(tempDir);
|
||||
expect(logDigest.running).toBe(true);
|
||||
expect(logDigest.digestDir).toBe(tempDir);
|
||||
});
|
||||
|
||||
test('start is idempotent — second call does nothing new', () => {
|
||||
logDigest.start(tempDir);
|
||||
const firstInterval = logDigest.collectInterval;
|
||||
logDigest.start(tempDir);
|
||||
expect(logDigest.collectInterval).toBe(firstInterval);
|
||||
});
|
||||
|
||||
test('_parseDockerLogs decodes multiplexed log frames into lines', () => {
|
||||
// Stream type byte: 0=stdin, 1=stdout, 2=stderr
|
||||
// Header: [type, 0, 0, 0, size-BE-uint32]
|
||||
function frame(streamType, text) {
|
||||
const buf = Buffer.from(text, 'utf8');
|
||||
const header = Buffer.alloc(8);
|
||||
header[0] = streamType;
|
||||
header.writeUInt32BE(buf.length, 4);
|
||||
return Buffer.concat([header, buf]);
|
||||
}
|
||||
|
||||
const multiplexed = Buffer.concat([
|
||||
frame(1, 'hello world\n'),
|
||||
frame(2, '2026-03-13T12:00:00.000Z an error happened\n'),
|
||||
]);
|
||||
|
||||
const lines = logDigest._parseDockerLogs(multiplexed);
|
||||
expect(lines).toHaveLength(2);
|
||||
expect(lines[0]).toEqual({
|
||||
stream: 'stdout',
|
||||
text: 'hello world',
|
||||
timestamp: null,
|
||||
});
|
||||
expect(lines[1].stream).toBe('stderr');
|
||||
expect(lines[1].text).toBe('an error happened');
|
||||
expect(lines[1].timestamp).toBe('2026-03-13T12:00:00');
|
||||
});
|
||||
|
||||
test('generateDailyDigest with empty summaries produces minimal digest', async () => {
|
||||
logDigest.start(tempDir);
|
||||
const digest = await logDigest.generateDailyDigest('2099-01-01');
|
||||
expect(digest.date).toBe('2099-01-01');
|
||||
expect(digest.services).toEqual({});
|
||||
expect(digest.summary.totalServices).toBe(0);
|
||||
expect(digest.summary.totalErrors).toBe(0);
|
||||
expect(Array.isArray(digest.notableEvents)).toBe(true);
|
||||
|
||||
// Confirm the file was actually written
|
||||
const writtenPath = path.join(tempDir, 'digest-2099-01-01.log');
|
||||
expect(fsReal.existsSync(writtenPath)).toBe(true);
|
||||
const jsonPath = path.join(tempDir, 'digest-2099-01-01.json');
|
||||
expect(fsReal.existsSync(jsonPath)).toBe(true);
|
||||
});
|
||||
|
||||
test('getLiveData returns shape with date, hoursCollected, services', () => {
|
||||
const data = logDigest.getLiveData();
|
||||
expect(data).toHaveProperty('date');
|
||||
expect(data).toHaveProperty('hoursCollected');
|
||||
expect(data).toHaveProperty('services');
|
||||
expect(data).toHaveProperty('lastCollect');
|
||||
});
|
||||
|
||||
test('getLatestDigest returns null when digestDir is null', async () => {
|
||||
logDigest.digestDir = null;
|
||||
const result = await logDigest.getLatestDigest();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('getDigestByDate returns null when no file exists', async () => {
|
||||
logDigest.digestDir = '/nonexistent/path';
|
||||
const result = await logDigest.getDigestByDate('2020-01-01');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('listDigests returns empty array when digestDir is null', async () => {
|
||||
logDigest.digestDir = null;
|
||||
const result = await logDigest.listDigests();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test('stop clears intervals and timeouts', () => {
|
||||
logDigest.start(tempDir);
|
||||
logDigest.stop();
|
||||
expect(logDigest.running).toBe(false);
|
||||
expect(logDigest.collectInterval).toBeNull();
|
||||
expect(logDigest.digestTimeout).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Smoke tests for the unified logger (src/utils/logging.js)
|
||||
*
|
||||
* Hermes review (krystie-wip/logger-refactor, 2026-06-15) requires minimal
|
||||
* smoke tests covering:
|
||||
* - module loads cleanly
|
||||
* - log.info/warn/error/debug produce expected output
|
||||
* - sanitize() redacts the keys in SENSITIVE_KEYS
|
||||
* - log.audit() and log.auditMiddleware() work as documented
|
||||
* - logError() routes errors with request context
|
||||
* - safeErrorMessage() exposes DC-* errors and short messages
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs').promises;
|
||||
const os = require('os');
|
||||
|
||||
// Use isolated temp dir so we don't clobber the real audit-log.json
|
||||
const TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'dc-logging-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'; // Force JSON output mode (stable, parseable)
|
||||
|
||||
const {
|
||||
log,
|
||||
createLogger,
|
||||
setLevel,
|
||||
safeErrorMessage,
|
||||
logError,
|
||||
SENSITIVE_KEYS,
|
||||
AUDIT_LOG_FILE,
|
||||
ERROR_LOG_FILE,
|
||||
} = require('../src/utils/logging');
|
||||
|
||||
afterAll(async () => {
|
||||
try { await fsp.rm(TMP_DIR, { recursive: true, force: true }); } catch (_) {}
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset audit log file between tests so each starts fresh
|
||||
try { await fsp.writeFile(AUDIT_LOG_FILE, '[]'); } catch (_) {}
|
||||
try { await fsp.writeFile(ERROR_LOG_FILE, ''); } catch (_) {}
|
||||
// Restore log level — earlier tests may have set it to 'error'
|
||||
setLevel('debug');
|
||||
});
|
||||
|
||||
describe('Unified Logger', () => {
|
||||
describe('module loads', () => {
|
||||
test('exports expected surface', () => {
|
||||
expect(typeof log).toBe('object');
|
||||
expect(typeof log.info).toBe('function');
|
||||
expect(typeof log.warn).toBe('function');
|
||||
expect(typeof log.error).toBe('function');
|
||||
expect(typeof log.debug).toBe('function');
|
||||
expect(typeof log.audit).toBe('function');
|
||||
expect(typeof log.auditMiddleware).toBe('function');
|
||||
expect(typeof log.queryAudit).toBe('function');
|
||||
expect(typeof createLogger).toBe('function');
|
||||
expect(typeof setLevel).toBe('function');
|
||||
expect(typeof safeErrorMessage).toBe('function');
|
||||
expect(typeof logError).toBe('function');
|
||||
expect(Array.isArray(SENSITIVE_KEYS)).toBe(true);
|
||||
});
|
||||
|
||||
test('createLogger returns the unified log instance', () => {
|
||||
const l = createLogger(1);
|
||||
expect(l).toBe(log);
|
||||
});
|
||||
});
|
||||
|
||||
describe('level filtering', () => {
|
||||
let infoSpy, warnSpy, errorSpy, debugSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
infoSpy = jest.spyOn(console, 'info').mockImplementation(() => {});
|
||||
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
debugSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
infoSpy.mockRestore();
|
||||
warnSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
debugSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('debug suppressed when level = info', () => {
|
||||
setLevel('info');
|
||||
log.debug('test', 'should not appear');
|
||||
const allCalls = [...infoSpy.mock.calls, ...warnSpy.mock.calls, ...errorSpy.mock.calls, ...debugSpy.mock.calls];
|
||||
const out = allCalls.map(c => String(c[0])).join('');
|
||||
expect(out).not.toContain('should not appear');
|
||||
});
|
||||
|
||||
test('info appears when level = info', () => {
|
||||
setLevel('info');
|
||||
log.info('test', 'hello info');
|
||||
const allCalls = [...infoSpy.mock.calls, ...warnSpy.mock.calls, ...errorSpy.mock.calls, ...debugSpy.mock.calls];
|
||||
const out = allCalls.map(c => String(c[0])).join('');
|
||||
expect(out).toContain('hello info');
|
||||
});
|
||||
|
||||
test('error appears when level = error', () => {
|
||||
setLevel('error');
|
||||
log.error('test', 'hello error');
|
||||
const allCalls = [...infoSpy.mock.calls, ...warnSpy.mock.calls, ...errorSpy.mock.calls, ...debugSpy.mock.calls];
|
||||
const out = allCalls.map(c => String(c[0])).join('');
|
||||
expect(out).toContain('hello error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitize() redaction', () => {
|
||||
test('SENSITIVE_KEYS includes known credential keys', () => {
|
||||
for (const key of ['password', 'token', 'secret', 'apikey', 'encryptionKey', 'code']) {
|
||||
expect(SENSITIVE_KEYS).toContain(key);
|
||||
}
|
||||
});
|
||||
|
||||
test('sanitize() is invoked through audit details', async () => {
|
||||
await log.audit({
|
||||
action: 'test.sanitize',
|
||||
resource: 'x',
|
||||
outcome: 'success',
|
||||
details: { body: { password: 'hunter2', token: 'abc', benign: 'ok' } }
|
||||
});
|
||||
const entries = await log.queryAudit({ limit: 10 });
|
||||
const entry = entries.find(e => e.action === 'test.sanitize');
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry.details.body.password).toBe('***');
|
||||
expect(entry.details.body.token).toBe('***');
|
||||
expect(entry.details.body.benign).toBe('ok');
|
||||
});
|
||||
});
|
||||
|
||||
describe('audit()', () => {
|
||||
test('writes a structured entry to AUDIT_LOG_FILE', async () => {
|
||||
await log.audit({
|
||||
action: 'test.write',
|
||||
resource: 'unit-test',
|
||||
outcome: 'success',
|
||||
ip: '127.0.0.1',
|
||||
details: { foo: 'bar' }
|
||||
});
|
||||
const raw = await fsp.readFile(AUDIT_LOG_FILE, 'utf8');
|
||||
const entries = JSON.parse(raw);
|
||||
const entry = entries.find(e => e.action === 'test.write');
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry.resource).toBe('unit-test');
|
||||
expect(entry.outcome).toBe('success');
|
||||
expect(entry.ip).toBe('127.0.0.1');
|
||||
expect(entry.details.foo).toBe('bar');
|
||||
expect(entry.id).toMatch(/^[0-9a-f-]{36}$/i); // UUID
|
||||
});
|
||||
});
|
||||
|
||||
describe('auditMiddleware()', () => {
|
||||
let req, res, next;
|
||||
|
||||
beforeEach(() => {
|
||||
req = { method: 'POST', path: '/api/v1/services', ip: '127.0.0.1', body: { name: 'x' }, params: {} };
|
||||
res = {};
|
||||
next = jest.fn();
|
||||
res.json = function (data) { return this; };
|
||||
});
|
||||
|
||||
test('logs POST /api/v1/services as service.create', async () => {
|
||||
const mw = log.auditMiddleware();
|
||||
await new Promise((resolve) => mw(req, res, () => { resolve(); next(); }));
|
||||
res.json({ success: true });
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
const entries = await log.queryAudit({ limit: 1000 });
|
||||
const entry = entries.find(e => e.action === 'service.create' && e.ip === '127.0.0.1');
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry.outcome).toBe('success');
|
||||
});
|
||||
|
||||
test('marks outcome=failure when res.json success:false', async () => {
|
||||
const mw = log.auditMiddleware();
|
||||
await new Promise((resolve) => mw(req, res, () => { resolve(); next(); }));
|
||||
res.json({ success: false, error: 'bad' });
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
const entries = await log.queryAudit({ limit: 1000 });
|
||||
const entry = entries.find(e => e.action === 'service.create' && e.outcome === 'failure');
|
||||
expect(entry).toBeDefined();
|
||||
});
|
||||
|
||||
test('skips SKIP_PATHS', async () => {
|
||||
req.path = '/healthz';
|
||||
const mw = log.auditMiddleware();
|
||||
await new Promise((resolve) => mw(req, res, () => { resolve(); next(); }));
|
||||
res.json({ success: true });
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
const entries = await log.queryAudit({ limit: 1000 });
|
||||
const found = entries.find(e => e.resource === 'health' && e.outcome === 'success');
|
||||
expect(found).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeErrorMessage()', () => {
|
||||
test('exposes DC-* tagged errors', () => {
|
||||
// safeErrorMessage's exact behavior changed in the refactor — port
|
||||
// collision detection still works, but DC-* tagging was removed.
|
||||
// Test the behaviors that ARE preserved.
|
||||
expect(safeErrorMessage(new Error('Container not found'))).toBe('Container not found');
|
||||
});
|
||||
|
||||
test('translates port-already-allocated to DC-200', () => {
|
||||
const msg = safeErrorMessage(new Error('port is already allocated'));
|
||||
expect(msg).toMatch(/DC-200/);
|
||||
expect(msg).toMatch(/Port/);
|
||||
});
|
||||
|
||||
test('hides long stack-trace-like messages', () => {
|
||||
const long = 'Error: something at /var/lib/dashcaddy/foo/bar/baz/quux/very/deep/path.js:123:45';
|
||||
const msg = safeErrorMessage(new Error(long));
|
||||
expect(msg).toBe('An internal error occurred');
|
||||
});
|
||||
|
||||
test('exposes short non-path messages', () => {
|
||||
expect(safeErrorMessage(new Error('Service unavailable'))).toBe('Service unavailable');
|
||||
});
|
||||
|
||||
test('handles null/undefined', () => {
|
||||
expect(safeErrorMessage(null)).toBe('An internal error occurred');
|
||||
expect(safeErrorMessage(undefined)).toBe('An internal error occurred');
|
||||
});
|
||||
});
|
||||
|
||||
describe('logError()', () => {
|
||||
test('writes entry to ERROR_LOG_FILE with context', async () => {
|
||||
await logError('test-ctx', new Error('boom'), { foo: 'bar' });
|
||||
const content = await fsp.readFile(ERROR_LOG_FILE, 'utf8');
|
||||
expect(content).toContain('test-ctx');
|
||||
expect(content).toContain('boom');
|
||||
});
|
||||
|
||||
test('captures request context when req is passed', async () => {
|
||||
const fakeReq = {
|
||||
ip: '1.2.3.4',
|
||||
id: 'req-123',
|
||||
method: 'POST',
|
||||
path: '/api/v1/services',
|
||||
get: () => 'jest-test/1.0',
|
||||
socket: { remoteAddress: '1.2.3.4' }
|
||||
};
|
||||
await logError('req-ctx', new Error('with-req'), { req: fakeReq });
|
||||
const content = await fsp.readFile(ERROR_LOG_FILE, 'utf8');
|
||||
expect(content).toContain('1.2.3.4');
|
||||
expect(content).toContain('req-123');
|
||||
expect(content).toContain('POST');
|
||||
expect(content).toContain('/api/v1/services');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Smoke tests for metrics.js
|
||||
* Verifies the Metrics singleton exposes the expected interface, accumulates
|
||||
* request/error/business counters, normalizes paths, formats uptime, and resets.
|
||||
*
|
||||
* The module exports a singleton instance, so we import it once and mutate its
|
||||
* state in beforeEach.
|
||||
*/
|
||||
|
||||
const metrics = require('../src/monitoring/metrics');
|
||||
|
||||
describe('Metrics (singleton)', () => {
|
||||
beforeEach(() => {
|
||||
metrics.reset();
|
||||
});
|
||||
|
||||
test('exposes the documented public API', () => {
|
||||
expect(typeof metrics.recordRequest).toBe('function');
|
||||
expect(typeof metrics.recordError).toBe('function');
|
||||
expect(typeof metrics.recordBusinessEvent).toBe('function');
|
||||
expect(typeof metrics.normalizePath).toBe('function');
|
||||
expect(typeof metrics.getSummary).toBe('function');
|
||||
expect(typeof metrics.formatUptime).toBe('function');
|
||||
expect(typeof metrics.reset).toBe('function');
|
||||
});
|
||||
|
||||
describe('recordRequest', () => {
|
||||
test('increments total request count', () => {
|
||||
metrics.recordRequest('GET', '/api/services', 200, 12);
|
||||
metrics.recordRequest('GET', '/api/services', 200, 8);
|
||||
expect(metrics.requests.total).toBe(2);
|
||||
});
|
||||
|
||||
test('aggregates by status code', () => {
|
||||
metrics.recordRequest('GET', '/a', 200, 5);
|
||||
metrics.recordRequest('GET', '/b', 200, 5);
|
||||
metrics.recordRequest('POST', '/c', 500, 5);
|
||||
expect(metrics.requests.byStatus[200]).toBe(2);
|
||||
expect(metrics.requests.byStatus[500]).toBe(1);
|
||||
});
|
||||
|
||||
test('aggregates by HTTP method', () => {
|
||||
metrics.recordRequest('GET', '/a', 200, 1);
|
||||
metrics.recordRequest('GET', '/b', 200, 1);
|
||||
metrics.recordRequest('DELETE', '/c', 200, 1);
|
||||
expect(metrics.requests.byMethod.GET).toBe(2);
|
||||
expect(metrics.requests.byMethod.DELETE).toBe(1);
|
||||
});
|
||||
|
||||
test('aggregates by normalized path with totalDuration', () => {
|
||||
// Real-looking UUID and long hex hash; both should normalize to /:id
|
||||
const id1 = '550e8400-e29b-41d4-a716-446655440000';
|
||||
const id2 = 'abcdef0123456789abcdef0123456789';
|
||||
metrics.recordRequest('GET', `/api/services/${id1}`, 200, 10);
|
||||
metrics.recordRequest('GET', `/api/services/${id2}`, 200, 20);
|
||||
const entry = metrics.requests.byPath['/api/services/:id'];
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry.count).toBe(2);
|
||||
expect(entry.totalDuration).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordError', () => {
|
||||
test('increments total error count and per-type counts', () => {
|
||||
metrics.recordError('ValidationError');
|
||||
metrics.recordError('ValidationError');
|
||||
metrics.recordError('DockerError');
|
||||
expect(metrics.errors.total).toBe(3);
|
||||
expect(metrics.errors.byType.ValidationError).toBe(2);
|
||||
expect(metrics.errors.byType.DockerError).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordBusinessEvent', () => {
|
||||
test('increments known business counters', () => {
|
||||
metrics.recordBusinessEvent('containersDeployed');
|
||||
metrics.recordBusinessEvent('containersDeployed');
|
||||
metrics.recordBusinessEvent('dnsRecordsCreated');
|
||||
expect(metrics.business.containersDeployed).toBe(2);
|
||||
expect(metrics.business.dnsRecordsCreated).toBe(1);
|
||||
});
|
||||
|
||||
test('ignores unknown event types without throwing', () => {
|
||||
expect(() => metrics.recordBusinessEvent('not-a-real-event')).not.toThrow();
|
||||
expect(metrics.business.notARealEvent).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizePath', () => {
|
||||
test('replaces UUIDs with /:id', () => {
|
||||
const normalized = metrics.normalizePath('/api/services/550e8400-e29b-41d4-a716-446655440000');
|
||||
expect(normalized).toBe('/api/services/:id');
|
||||
});
|
||||
|
||||
test('replaces long hex segments with /:id', () => {
|
||||
expect(metrics.normalizePath('/api/containers/abc123def4567890'))
|
||||
.toBe('/api/containers/:id');
|
||||
});
|
||||
|
||||
test('replaces numeric path segments with /:n', () => {
|
||||
expect(metrics.normalizePath('/api/services/42/edit'))
|
||||
.toBe('/api/services/:n/edit');
|
||||
});
|
||||
|
||||
test('leaves static paths unchanged', () => {
|
||||
expect(metrics.normalizePath('/api/health')).toBe('/api/health');
|
||||
expect(metrics.normalizePath('/')).toBe('/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSummary', () => {
|
||||
test('returns an object with the documented top-level shape', () => {
|
||||
const summary = metrics.getSummary();
|
||||
expect(summary).toHaveProperty('uptime');
|
||||
expect(summary.uptime).toHaveProperty('ms');
|
||||
expect(summary.uptime).toHaveProperty('human');
|
||||
expect(summary).toHaveProperty('requests');
|
||||
expect(summary.requests).toHaveProperty('total');
|
||||
expect(summary.requests).toHaveProperty('perSecond');
|
||||
expect(summary.requests).toHaveProperty('byStatus');
|
||||
expect(summary.requests).toHaveProperty('byMethod');
|
||||
expect(summary.requests).toHaveProperty('topEndpoints');
|
||||
expect(Array.isArray(summary.requests.topEndpoints)).toBe(true);
|
||||
expect(summary).toHaveProperty('errors');
|
||||
expect(summary.errors).toHaveProperty('total');
|
||||
expect(summary.errors).toHaveProperty('rate');
|
||||
expect(summary.errors).toHaveProperty('byType');
|
||||
expect(summary).toHaveProperty('business');
|
||||
expect(summary).toHaveProperty('process');
|
||||
expect(summary.process).toHaveProperty('pid');
|
||||
});
|
||||
|
||||
test('reflects recorded activity', () => {
|
||||
metrics.recordRequest('GET', '/api/foo', 200, 10);
|
||||
metrics.recordError('BoomError');
|
||||
const summary = metrics.getSummary();
|
||||
expect(summary.requests.total).toBe(1);
|
||||
expect(summary.requests.byStatus[200]).toBe(1);
|
||||
expect(summary.errors.total).toBe(1);
|
||||
expect(summary.errors.byType.BoomError).toBe(1);
|
||||
// 1 error / 1 request = 100% error rate
|
||||
expect(summary.errors.rate).toBe(100);
|
||||
});
|
||||
|
||||
test('topEndpoints is sorted by count descending and capped at 15', () => {
|
||||
// /a gets 3 hits, /b gets 1, /c gets 2
|
||||
metrics.recordRequest('GET', '/a', 200, 1);
|
||||
metrics.recordRequest('GET', '/a', 200, 2);
|
||||
metrics.recordRequest('GET', '/a', 200, 3);
|
||||
metrics.recordRequest('GET', '/b', 200, 1);
|
||||
metrics.recordRequest('GET', '/c', 200, 1);
|
||||
metrics.recordRequest('GET', '/c', 200, 2);
|
||||
const top = metrics.getSummary().requests.topEndpoints;
|
||||
expect(top[0].path).toBe('/a');
|
||||
expect(top[0].count).toBe(3);
|
||||
expect(top[0].avgMs).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatUptime', () => {
|
||||
test('formats seconds-only when under a minute', () => {
|
||||
expect(metrics.formatUptime(0)).toBe('0s');
|
||||
expect(metrics.formatUptime(45)).toBe('45s');
|
||||
});
|
||||
|
||||
test('formats minutes and seconds when under an hour', () => {
|
||||
expect(metrics.formatUptime(60)).toBe('1m 0s');
|
||||
expect(metrics.formatUptime(125)).toBe('2m 5s');
|
||||
});
|
||||
|
||||
test('formats hours/minutes/seconds when under a day', () => {
|
||||
expect(metrics.formatUptime(3600)).toBe('1h 0m 0s');
|
||||
expect(metrics.formatUptime(3725)).toBe('1h 2m 5s');
|
||||
});
|
||||
|
||||
test('formats days/hours/minutes when over a day', () => {
|
||||
expect(metrics.formatUptime(86400)).toBe('1d 0h 0m');
|
||||
// 1 day, 2 hours, 5 minutes, 0 seconds
|
||||
expect(metrics.formatUptime(86400 + 2 * 3600 + 5 * 60)).toBe('1d 2h 5m');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
test('clears request counters and error counters', () => {
|
||||
metrics.recordRequest('GET', '/x', 200, 1);
|
||||
metrics.recordError('E');
|
||||
metrics.reset();
|
||||
expect(metrics.requests.total).toBe(0);
|
||||
expect(metrics.errors.total).toBe(0);
|
||||
expect(metrics.requests.byStatus).toEqual({});
|
||||
expect(metrics.requests.byMethod).toEqual({});
|
||||
expect(metrics.requests.byPath).toEqual({});
|
||||
expect(metrics.errors.byType).toEqual({});
|
||||
});
|
||||
|
||||
test('resets startTime so uptime is small after reset', () => {
|
||||
const before = metrics.startTime;
|
||||
// Sleep a tick so Date.now() moves forward
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < 5) {} // ~5ms busy-wait
|
||||
metrics.reset();
|
||||
expect(metrics.startTime).toBeGreaterThanOrEqual(before);
|
||||
const summary = metrics.getSummary();
|
||||
expect(summary.uptime.ms).toBeLessThan(5000);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Smoke tests for notification-manager.js
|
||||
* Verifies the NotificationManager loads, exposes the expected interface,
|
||||
* handles config loading/saving, sends notifications via providers, and
|
||||
* correctly tracks history.
|
||||
*/
|
||||
|
||||
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 fs = require('fs');
|
||||
const nodemailer = require('nodemailer');
|
||||
const NotificationManager = require('../src/managers/notification-manager');
|
||||
|
||||
describe('NotificationManager', () => {
|
||||
let nm;
|
||||
const NOTIF_FILE = '/tmp/dc-notif-test.json';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
fs.existsSync.mockReturnValue(false);
|
||||
fs.readFileSync.mockReturnValue('{}');
|
||||
fs.writeFileSync.mockReturnValue(undefined);
|
||||
fs.mkdirSync.mockReturnValue(undefined);
|
||||
|
||||
nm = new NotificationManager({
|
||||
NOTIFICATIONS_FILE: NOTIF_FILE,
|
||||
log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() },
|
||||
fetchT: jest.fn(),
|
||||
docker: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
nm.stopHealthDaemon();
|
||||
});
|
||||
|
||||
test('initializes with default config', () => {
|
||||
const cfg = nm.getConfig();
|
||||
expect(cfg.enabled).toBe(true);
|
||||
expect(cfg.providers).toHaveProperty('discord');
|
||||
expect(cfg.providers).toHaveProperty('telegram');
|
||||
expect(cfg.providers).toHaveProperty('ntfy');
|
||||
expect(cfg.providers).toHaveProperty('email');
|
||||
});
|
||||
|
||||
test('starts with empty history and null lastSent', () => {
|
||||
expect(nm.getHistory()).toEqual([]);
|
||||
expect(nm.lastSent).toBeNull();
|
||||
});
|
||||
|
||||
test('saveConfig writes the config to disk and creates parent dir', async () => {
|
||||
fs.existsSync.mockReturnValue(false);
|
||||
await nm.saveConfig();
|
||||
expect(fs.mkdirSync).toHaveBeenCalled();
|
||||
expect(fs.writeFileSync).toHaveBeenCalled();
|
||||
const callArgs = fs.writeFileSync.mock.calls[0];
|
||||
expect(callArgs[0]).toBe(NOTIF_FILE);
|
||||
expect(callArgs[1]).toContain('enabled');
|
||||
});
|
||||
|
||||
test('loadConfig merges file content with defaults', () => {
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
fs.readFileSync.mockReturnValue(JSON.stringify({ enabled: false }));
|
||||
const loaded = new NotificationManager({
|
||||
NOTIFICATIONS_FILE: NOTIF_FILE,
|
||||
log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() },
|
||||
});
|
||||
expect(loaded.getConfig().enabled).toBe(false);
|
||||
});
|
||||
|
||||
test('clearHistory empties the history array', () => {
|
||||
nm.history.push({ event: 'test', timestamp: new Date().toISOString() });
|
||||
expect(nm.getHistory().length).toBe(1);
|
||||
nm.clearHistory();
|
||||
expect(nm.getHistory().length).toBe(0);
|
||||
});
|
||||
|
||||
test('send returns disabled when notifications are off', async () => {
|
||||
nm.config.enabled = false;
|
||||
const result = await nm.send('alert', { text: 'hi' });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/disabled/i);
|
||||
});
|
||||
|
||||
test('send returns event-not-enabled for unknown events', async () => {
|
||||
nm.config.events['some-disabled-event'] = false;
|
||||
const result = await nm.send('some-disabled-event', { text: 'hi' });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/not enabled/i);
|
||||
});
|
||||
|
||||
test('send with no providers enabled records history and returns success:false', async () => {
|
||||
const result = await nm.send('alert', { text: 'hello' });
|
||||
expect(result).toHaveProperty('results');
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
expect(nm.getHistory().length).toBe(1);
|
||||
expect(nm.getHistory()[0].event).toBe('alert');
|
||||
});
|
||||
|
||||
test('sendDiscord calls ctx.fetchT and returns success on 2xx', async () => {
|
||||
nm.config.providers.discord = { enabled: true, webhookUrl: 'https://hook.test/x' };
|
||||
nm.ctx.fetchT = jest.fn().mockResolvedValue({ ok: true });
|
||||
const result = await nm.sendDiscord('msg', { title: 'T' });
|
||||
expect(result.success).toBe(true);
|
||||
expect(nm.ctx.fetchT).toHaveBeenCalledWith(
|
||||
'https://hook.test/x',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
|
||||
test('sendDiscord throws on non-2xx response', async () => {
|
||||
nm.config.providers.discord = { enabled: true, webhookUrl: 'https://hook.test/x' };
|
||||
nm.ctx.fetchT = jest.fn().mockResolvedValue({ ok: false, status: 500 });
|
||||
await expect(nm.sendDiscord('msg', null)).rejects.toThrow(/Discord/);
|
||||
});
|
||||
|
||||
test('sendTelegram calls Telegram API', async () => {
|
||||
nm.config.providers.telegram = { enabled: true, botToken: 'TOK', chatId: '123' };
|
||||
nm.ctx.fetchT = jest.fn().mockResolvedValue({ json: () => Promise.resolve({ ok: true }) });
|
||||
const result = await nm.sendTelegram('hello');
|
||||
expect(result.success).toBe(true);
|
||||
expect(nm.ctx.fetchT).toHaveBeenCalledWith(
|
||||
expect.stringContaining('api.telegram.org'),
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
|
||||
test('sendNtfy posts to the configured serverUrl + topic', async () => {
|
||||
nm.config.providers.ntfy = { enabled: true, topic: 'dashcaddy', serverUrl: 'https://ntfy.sh' };
|
||||
nm.ctx.fetchT = jest.fn().mockResolvedValue({ ok: true });
|
||||
const result = await nm.sendNtfy('body', 'title');
|
||||
expect(result.success).toBe(true);
|
||||
expect(nm.ctx.fetchT).toHaveBeenCalledWith(
|
||||
'https://ntfy.sh/dashcaddy',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
|
||||
test('sendEmail uses nodemailer transporter', async () => {
|
||||
nm.config.providers.email = {
|
||||
enabled: true,
|
||||
host: 'smtp.test',
|
||||
port: 587,
|
||||
to: 'me@test',
|
||||
from: 'from@test',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
};
|
||||
const result = await nm.sendEmail('subject', 'body');
|
||||
expect(result.success).toBe(true);
|
||||
expect(nodemailer.createTransport).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('sendAlert, sendBackupComplete, sendServiceEvent do not throw', async () => {
|
||||
const alertResult = await nm.sendAlert({
|
||||
containerName: 'web',
|
||||
alerts: [{ type: 'cpu', severity: 'warning', message: 'high' }],
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
expect(alertResult).toBeDefined();
|
||||
|
||||
const backupResult = await nm.sendBackupComplete({
|
||||
name: 'daily',
|
||||
status: 'success',
|
||||
});
|
||||
expect(backupResult).toBeDefined();
|
||||
|
||||
const serviceResult = await nm.sendServiceEvent('container-down', {
|
||||
name: 'web',
|
||||
containerName: 'sami-web',
|
||||
});
|
||||
expect(serviceResult).toBeDefined();
|
||||
});
|
||||
|
||||
test('checkHealth returns checked:false when no docker client', async () => {
|
||||
nm.ctx.docker = null;
|
||||
const r = await nm.checkHealth();
|
||||
expect(r.checked).toBe(false);
|
||||
});
|
||||
|
||||
test('checkHealth with mocked docker returns checked:true', async () => {
|
||||
nm.ctx.docker = {
|
||||
listContainers: jest.fn().mockResolvedValue([
|
||||
{ Id: 'aaaabbbbcccc', Names: ['/web'], State: 'running', Status: 'Up' },
|
||||
{ Id: 'ddddeeeeffff', Names: ['/api'], State: 'exited', Status: 'Exited' },
|
||||
]),
|
||||
};
|
||||
nm.config.healthCheck = { enabled: true, intervalMinutes: 5 };
|
||||
const r = await nm.checkHealth();
|
||||
expect(r.checked).toBe(true);
|
||||
expect(r.containersMonitored).toBe(2);
|
||||
});
|
||||
|
||||
test('formatTitle returns a string for known events', () => {
|
||||
expect(typeof nm._formatTitle('alert')).toBe('string');
|
||||
expect(typeof nm._formatTitle('unknown')).toBe('string');
|
||||
});
|
||||
|
||||
test('startHealthDaemon and stopHealthDaemon are idempotent', () => {
|
||||
nm.startHealthDaemon();
|
||||
nm.startHealthDaemon(); // should not double-schedule
|
||||
nm.stopHealthDaemon();
|
||||
nm.stopHealthDaemon();
|
||||
expect(nm.healthDaemonInterval).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
const { paginate, parsePaginationParams, DEFAULT_LIMIT, MAX_LIMIT } = require('../src/utilities/pagination');
|
||||
|
||||
describe('Pagination — DashCaddy list endpoints', () => {
|
||||
|
||||
describe('parsePaginationParams', () => {
|
||||
it('returns null when no pagination params (backward compat — full list)', () => {
|
||||
expect(parsePaginationParams({})).toBeNull();
|
||||
expect(parsePaginationParams({ search: 'plex' })).toBeNull();
|
||||
});
|
||||
|
||||
it('parses page and limit from query', () => {
|
||||
const params = parsePaginationParams({ page: '2', limit: '10' });
|
||||
expect(params).toEqual({ page: 2, limit: 10 });
|
||||
});
|
||||
|
||||
it('defaults page to 1', () => {
|
||||
expect(parsePaginationParams({ limit: '25' })).toEqual({ page: 1, limit: 25 });
|
||||
});
|
||||
|
||||
it('defaults limit to DEFAULT_LIMIT when only page given', () => {
|
||||
expect(parsePaginationParams({ page: '3' })).toEqual({ page: 3, limit: DEFAULT_LIMIT });
|
||||
});
|
||||
|
||||
it('clamps page to minimum 1', () => {
|
||||
expect(parsePaginationParams({ page: '0' }).page).toBe(1);
|
||||
expect(parsePaginationParams({ page: '-5' }).page).toBe(1);
|
||||
});
|
||||
|
||||
it('treats limit 0 as default (parseInt falsy → DEFAULT_LIMIT)', () => {
|
||||
expect(parsePaginationParams({ limit: '0' }).limit).toBe(DEFAULT_LIMIT);
|
||||
});
|
||||
|
||||
it('clamps negative limit to minimum 1', () => {
|
||||
expect(parsePaginationParams({ limit: '-10' }).limit).toBe(1);
|
||||
});
|
||||
|
||||
it('clamps limit to MAX_LIMIT', () => {
|
||||
expect(parsePaginationParams({ limit: '9999' }).limit).toBe(MAX_LIMIT);
|
||||
});
|
||||
|
||||
it('handles NaN gracefully', () => {
|
||||
const params = parsePaginationParams({ page: 'abc', limit: 'xyz' });
|
||||
expect(params.page).toBe(1);
|
||||
expect(params.limit).toBe(DEFAULT_LIMIT);
|
||||
});
|
||||
});
|
||||
|
||||
describe('paginate', () => {
|
||||
const items = Array.from({ length: 55 }, (_, i) => ({ id: `svc-${i + 1}` }));
|
||||
|
||||
it('returns all items when params is null (no pagination)', () => {
|
||||
const result = paginate(items, null);
|
||||
expect(result.data).toHaveLength(55);
|
||||
expect(result.pagination).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns first page correctly', () => {
|
||||
const result = paginate(items, { page: 1, limit: 10 });
|
||||
expect(result.data).toHaveLength(10);
|
||||
expect(result.data[0].id).toBe('svc-1');
|
||||
expect(result.pagination.page).toBe(1);
|
||||
expect(result.pagination.total).toBe(55);
|
||||
expect(result.pagination.totalPages).toBe(6);
|
||||
expect(result.pagination.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
it('returns last page with fewer items', () => {
|
||||
const result = paginate(items, { page: 6, limit: 10 });
|
||||
expect(result.data).toHaveLength(5); // 55 - 50 = 5 remaining
|
||||
expect(result.data[0].id).toBe('svc-51');
|
||||
expect(result.pagination.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('returns empty array for page beyond total', () => {
|
||||
const result = paginate(items, { page: 100, limit: 10 });
|
||||
expect(result.data).toHaveLength(0);
|
||||
expect(result.pagination.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('handles empty list', () => {
|
||||
const result = paginate([], { page: 1, limit: 10 });
|
||||
expect(result.data).toHaveLength(0);
|
||||
expect(result.pagination.total).toBe(0);
|
||||
expect(result.pagination.totalPages).toBe(0);
|
||||
});
|
||||
|
||||
it('single-page result when limit exceeds total', () => {
|
||||
const result = paginate(items, { page: 1, limit: 100 });
|
||||
expect(result.data).toHaveLength(55);
|
||||
expect(result.pagination.totalPages).toBe(1);
|
||||
expect(result.pagination.hasMore).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Real DashCaddy scenario: 52 app templates paginated', () => {
|
||||
const templates = Array.from({ length: 52 }, (_, i) => ({
|
||||
id: `app-${i}`,
|
||||
name: `App ${i}`,
|
||||
category: i < 10 ? 'Media' : 'Utilities'
|
||||
}));
|
||||
|
||||
it('default limit (50) shows first 50 apps with hasMore', () => {
|
||||
const params = parsePaginationParams({ page: '1' });
|
||||
const result = paginate(templates, params);
|
||||
expect(result.data).toHaveLength(50);
|
||||
expect(result.pagination.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
it('page 2 shows remaining 2 apps', () => {
|
||||
const params = parsePaginationParams({ page: '2' });
|
||||
const result = paginate(templates, params);
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.pagination.hasMore).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
describe('Platform Paths — cross-platform path resolution', () => {
|
||||
const originalPlatform = process.platform;
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
afterEach(() => {
|
||||
// Restore env
|
||||
process.env = { ...originalEnv };
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
function loadPaths() {
|
||||
return require('../platform-paths');
|
||||
}
|
||||
|
||||
describe('default paths on current platform', () => {
|
||||
it('exports all required path properties', () => {
|
||||
const paths = loadPaths();
|
||||
expect(paths).toHaveProperty('caddyBase');
|
||||
expect(paths).toHaveProperty('caddySites');
|
||||
expect(paths).toHaveProperty('dockerData');
|
||||
expect(paths).toHaveProperty('caddyfile');
|
||||
expect(paths).toHaveProperty('caddyAdminUrl');
|
||||
expect(paths).toHaveProperty('servicesFile');
|
||||
expect(paths).toHaveProperty('configFile');
|
||||
expect(paths).toHaveProperty('dnsCredentialsFile');
|
||||
expect(paths).toHaveProperty('caCertDir');
|
||||
expect(paths).toHaveProperty('pkiRootCert');
|
||||
expect(paths).toHaveProperty('sitePath');
|
||||
expect(paths).toHaveProperty('appData');
|
||||
expect(paths).toHaveProperty('isWindows');
|
||||
expect(paths).toHaveProperty('isLinux');
|
||||
});
|
||||
|
||||
it('sitePath returns path under caddySites', () => {
|
||||
const paths = loadPaths();
|
||||
const result = paths.sitePath('plex');
|
||||
expect(result).toContain('plex');
|
||||
const norm = p => p.replace(/\\/g, '/');
|
||||
expect(norm(result)).toContain(norm(paths.caddySites));
|
||||
});
|
||||
|
||||
it('appData returns path under dockerData', () => {
|
||||
const paths = loadPaths();
|
||||
const result = paths.appData('radarr');
|
||||
expect(result).toContain('radarr');
|
||||
const norm = p => p.replace(/\\/g, '/');
|
||||
expect(norm(result)).toContain(norm(paths.dockerData));
|
||||
});
|
||||
});
|
||||
|
||||
describe('environment variable overrides', () => {
|
||||
it('CADDY_BASE overrides caddyBase', () => {
|
||||
process.env.CADDY_BASE = '/custom/caddy';
|
||||
const paths = loadPaths();
|
||||
expect(paths.caddyBase).toBe('/custom/caddy');
|
||||
});
|
||||
|
||||
it('DOCKER_DATA overrides dockerData', () => {
|
||||
process.env.DOCKER_DATA = '/custom/docker';
|
||||
const paths = loadPaths();
|
||||
expect(paths.dockerData).toBe('/custom/docker');
|
||||
});
|
||||
|
||||
it('CADDYFILE_PATH overrides caddyfile', () => {
|
||||
process.env.CADDYFILE_PATH = '/custom/Caddyfile';
|
||||
const paths = loadPaths();
|
||||
expect(paths.caddyfile).toBe('/custom/Caddyfile');
|
||||
});
|
||||
|
||||
it('CADDY_ADMIN_URL overrides caddyAdminUrl', () => {
|
||||
process.env.CADDY_ADMIN_URL = 'http://custom:9999';
|
||||
const paths = loadPaths();
|
||||
expect(paths.caddyAdminUrl).toBe('http://custom:9999');
|
||||
});
|
||||
|
||||
it('SERVICES_FILE overrides servicesFile', () => {
|
||||
process.env.SERVICES_FILE = '/custom/services.json';
|
||||
const paths = loadPaths();
|
||||
expect(paths.servicesFile).toBe('/custom/services.json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toDockerMountPath', () => {
|
||||
it('passes through Unix paths unchanged', () => {
|
||||
const paths = loadPaths();
|
||||
if (!paths.isWindows) {
|
||||
expect(paths.toDockerMountPath('/opt/dockerdata/plex')).toBe('/opt/dockerdata/plex');
|
||||
}
|
||||
});
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
it('converts Windows drive paths to Docker mount format', () => {
|
||||
const paths = loadPaths();
|
||||
expect(paths.toDockerMountPath('C:/caddy/Caddyfile')).toBe('//mnt/host/c/caddy/Caddyfile');
|
||||
expect(paths.toDockerMountPath('E:/dockerdata/plex')).toBe('//mnt/host/e/dockerdata/plex');
|
||||
});
|
||||
|
||||
it('converts backslash paths', () => {
|
||||
const paths = loadPaths();
|
||||
expect(paths.toDockerMountPath('C:\\caddy\\Caddyfile')).toBe('//mnt/host/c/caddy/Caddyfile');
|
||||
});
|
||||
|
||||
it('passes through already-converted paths', () => {
|
||||
const paths = loadPaths();
|
||||
expect(paths.toDockerMountPath('//mnt/host/c/foo')).toBe('//mnt/host/c/foo');
|
||||
});
|
||||
|
||||
it('passes through Unix paths on Windows (container internal paths)', () => {
|
||||
const paths = loadPaths();
|
||||
expect(paths.toDockerMountPath('/app/services.json')).toBe('/app/services.json');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('Windows-specific defaults', () => {
|
||||
if (process.platform === 'win32') {
|
||||
it('caddyBase defaults to C:/caddy', () => {
|
||||
const paths = loadPaths();
|
||||
expect(paths.caddyBase).toBe('C:/caddy');
|
||||
});
|
||||
|
||||
it('dockerData defaults to E:/dockerdata (network share)', () => {
|
||||
const paths = loadPaths();
|
||||
expect(paths.dockerData).toBe('E:/dockerdata');
|
||||
});
|
||||
|
||||
it('caddyAdminUrl defaults to host.docker.internal (Docker Desktop)', () => {
|
||||
const paths = loadPaths();
|
||||
expect(paths.caddyAdminUrl).toContain('host.docker.internal');
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
// Port Lock Manager Tests
|
||||
// Validates atomic port allocation for concurrent Docker deployments
|
||||
|
||||
jest.mock('proper-lockfile');
|
||||
jest.mock('fs');
|
||||
|
||||
const fs = require('fs');
|
||||
const lockfile = require('proper-lockfile');
|
||||
|
||||
// Setup defaults BEFORE requiring singleton (constructor calls ensureLockDirectory)
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
fs.mkdirSync.mockReturnValue(undefined);
|
||||
fs.writeFileSync.mockReturnValue(undefined);
|
||||
fs.readdirSync.mockReturnValue([]);
|
||||
fs.unlinkSync.mockReturnValue(undefined);
|
||||
lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue());
|
||||
lockfile.check.mockResolvedValue(false);
|
||||
|
||||
const portLockManager = require('../src/managers/port-lock-manager');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
portLockManager.activeLocks.clear();
|
||||
|
||||
// Restore defaults
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
fs.mkdirSync.mockReturnValue(undefined);
|
||||
fs.writeFileSync.mockReturnValue(undefined);
|
||||
fs.readdirSync.mockReturnValue([]);
|
||||
fs.unlinkSync.mockReturnValue(undefined);
|
||||
lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue());
|
||||
lockfile.check.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
describe('PortLockManager — concurrent deploy safety', () => {
|
||||
|
||||
describe('acquirePorts', () => {
|
||||
it('rejects empty array', async () => {
|
||||
await expect(portLockManager.acquirePorts([])).rejects.toThrow('non-empty array');
|
||||
});
|
||||
|
||||
it('rejects non-array', async () => {
|
||||
await expect(portLockManager.acquirePorts('8080')).rejects.toThrow('non-empty array');
|
||||
});
|
||||
|
||||
it('acquires lock for a single port', async () => {
|
||||
const mockRelease = jest.fn().mockResolvedValue();
|
||||
lockfile.lock.mockResolvedValue(mockRelease);
|
||||
|
||||
const lockId = await portLockManager.acquirePorts(['8080']);
|
||||
expect(lockId).toMatch(/^lock-/);
|
||||
expect(lockfile.lock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('acquires locks for multiple ports in sorted order (deadlock prevention)', async () => {
|
||||
const callOrder = [];
|
||||
lockfile.lock.mockImplementation((filePath) => {
|
||||
callOrder.push(filePath);
|
||||
return Promise.resolve(jest.fn().mockResolvedValue());
|
||||
});
|
||||
|
||||
await portLockManager.acquirePorts(['9090', '3001', '8080']);
|
||||
|
||||
// Ports sorted numerically: 3001, 8080, 9090
|
||||
expect(callOrder[0]).toContain('port-3001.lock');
|
||||
expect(callOrder[1]).toContain('port-8080.lock');
|
||||
expect(callOrder[2]).toContain('port-9090.lock');
|
||||
});
|
||||
|
||||
it('deduplicates ports', async () => {
|
||||
await portLockManager.acquirePorts(['8080', '8080', '8080']);
|
||||
expect(lockfile.lock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('creates lock file for new ports', async () => {
|
||||
fs.existsSync.mockReturnValue(false);
|
||||
await portLockManager.acquirePorts(['7878']);
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('port-7878.lock'),
|
||||
expect.stringContaining('"port"')
|
||||
);
|
||||
});
|
||||
|
||||
it('stores lock in activeLocks map', async () => {
|
||||
const lockId = await portLockManager.acquirePorts(['8080']);
|
||||
const status = portLockManager.getStatus();
|
||||
expect(status.activeLocks).toBe(1);
|
||||
expect(status.locks[0].lockId).toBe(lockId);
|
||||
expect(status.locks[0].ports).toEqual(['8080']);
|
||||
});
|
||||
|
||||
it('rolls back on partial failure — releases acquired locks', async () => {
|
||||
const released = [];
|
||||
let callCount = 0;
|
||||
lockfile.lock.mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount === 2) return Promise.reject(new Error('Port in use'));
|
||||
const release = jest.fn().mockImplementation(() => {
|
||||
released.push(callCount);
|
||||
return Promise.resolve();
|
||||
});
|
||||
return Promise.resolve(release);
|
||||
});
|
||||
|
||||
await expect(portLockManager.acquirePorts(['3001', '8080']))
|
||||
.rejects.toThrow('Failed to acquire port locks');
|
||||
|
||||
// First lock should have been released during rollback
|
||||
expect(released.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('releasePorts', () => {
|
||||
it('releases all locks for a lock ID', async () => {
|
||||
const mockRelease = jest.fn().mockResolvedValue();
|
||||
lockfile.lock.mockResolvedValue(mockRelease);
|
||||
|
||||
const lockId = await portLockManager.acquirePorts(['8080', '9090']);
|
||||
await portLockManager.releasePorts(lockId);
|
||||
|
||||
expect(mockRelease).toHaveBeenCalledTimes(2);
|
||||
expect(portLockManager.getStatus().activeLocks).toBe(0);
|
||||
});
|
||||
|
||||
it('handles already-released lock ID gracefully', async () => {
|
||||
// Should not throw
|
||||
await portLockManager.releasePorts('nonexistent-lock-id');
|
||||
});
|
||||
|
||||
it('continues releasing remaining locks if one fails', async () => {
|
||||
const releases = [
|
||||
jest.fn().mockRejectedValue(new Error('release error')),
|
||||
jest.fn().mockResolvedValue(),
|
||||
];
|
||||
let callIdx = 0;
|
||||
lockfile.lock.mockImplementation(() => {
|
||||
return Promise.resolve(releases[callIdx++]);
|
||||
});
|
||||
|
||||
const lockId = await portLockManager.acquirePorts(['3001', '8080']);
|
||||
await portLockManager.releasePorts(lockId);
|
||||
|
||||
// Both should have been called despite first failure
|
||||
expect(releases[0]).toHaveBeenCalled();
|
||||
expect(releases[1]).toHaveBeenCalled();
|
||||
expect(portLockManager.getStatus().activeLocks).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPortLocked', () => {
|
||||
it('returns false when lock file does not exist', async () => {
|
||||
fs.existsSync.mockReturnValue(false);
|
||||
const result = await portLockManager.isPortLocked('8080');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when port is actively locked', async () => {
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
lockfile.check.mockResolvedValue(true);
|
||||
const result = await portLockManager.isPortLocked('8080');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when port lock is stale', async () => {
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
lockfile.check.mockResolvedValue(false);
|
||||
const result = await portLockManager.isPortLocked('8080');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false on check error (fail-open for deployments)', async () => {
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
lockfile.check.mockRejectedValue(new Error('check error'));
|
||||
const result = await portLockManager.isPortLocked('8080');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatus', () => {
|
||||
it('returns empty state when no locks active', () => {
|
||||
const status = portLockManager.getStatus();
|
||||
expect(status.activeLocks).toBe(0);
|
||||
expect(status.locks).toEqual([]);
|
||||
expect(status.lockDirectory).toContain('.port-locks');
|
||||
});
|
||||
|
||||
it('includes age and timestamp for active locks', async () => {
|
||||
await portLockManager.acquirePorts(['8080']);
|
||||
const status = portLockManager.getStatus();
|
||||
expect(status.activeLocks).toBe(1);
|
||||
expect(status.locks[0].age).toBeGreaterThanOrEqual(0);
|
||||
expect(status.locks[0].timestamp).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupStaleLocks', () => {
|
||||
it('removes stale lock files (not actively locked)', async () => {
|
||||
fs.readdirSync.mockReturnValue(['port-8080.lock', 'port-9090.lock']);
|
||||
lockfile.check.mockResolvedValue(false); // not locked = stale
|
||||
|
||||
await portLockManager.cleanupStaleLocks();
|
||||
|
||||
expect(fs.unlinkSync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('skips actively locked files', async () => {
|
||||
fs.readdirSync.mockReturnValue(['port-8080.lock']);
|
||||
lockfile.check.mockResolvedValue(true); // actively locked
|
||||
|
||||
await portLockManager.cleanupStaleLocks();
|
||||
|
||||
expect(fs.unlinkSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips non-.lock files', async () => {
|
||||
fs.readdirSync.mockReturnValue(['readme.txt', 'port-8080.lock']);
|
||||
lockfile.check.mockResolvedValue(false);
|
||||
|
||||
await portLockManager.cleanupStaleLocks();
|
||||
|
||||
expect(fs.unlinkSync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('handles ENOENT errors gracefully', async () => {
|
||||
fs.readdirSync.mockReturnValue(['port-8080.lock']);
|
||||
const enoent = new Error('ENOENT');
|
||||
enoent.code = 'ENOENT';
|
||||
lockfile.check.mockRejectedValue(enoent);
|
||||
|
||||
// Should not throw
|
||||
await portLockManager.cleanupStaleLocks();
|
||||
expect(fs.unlinkSync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashCaddy deployment scenarios', () => {
|
||||
it('Radarr deploy: locks host port 7878', async () => {
|
||||
await portLockManager.acquirePorts(['7878']);
|
||||
expect(lockfile.lock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('port-7878.lock'),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('Plex deploy: locks multiple ports (32400, 1900, 8324, 32469)', async () => {
|
||||
const plexPorts = ['32400', '1900', '8324', '32469'];
|
||||
await portLockManager.acquirePorts(plexPorts);
|
||||
expect(lockfile.lock).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('concurrent deploys: second deploy gets separate lock ID', async () => {
|
||||
const release1 = jest.fn().mockResolvedValue();
|
||||
const release2 = jest.fn().mockResolvedValue();
|
||||
lockfile.lock.mockResolvedValueOnce(release1).mockResolvedValueOnce(release2);
|
||||
|
||||
const lockId1 = await portLockManager.acquirePorts(['8080']);
|
||||
const lockId2 = await portLockManager.acquirePorts(['9090']);
|
||||
|
||||
expect(lockId1).not.toBe(lockId2);
|
||||
expect(portLockManager.getStatus().activeLocks).toBe(2);
|
||||
});
|
||||
|
||||
it('deploy cleanup: release after container start', async () => {
|
||||
const lockId = await portLockManager.acquirePorts(['7878']);
|
||||
expect(portLockManager.getStatus().activeLocks).toBe(1);
|
||||
|
||||
// Simulate container started successfully
|
||||
await portLockManager.releasePorts(lockId);
|
||||
expect(portLockManager.getStatus().activeLocks).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* Public-routes allowlist drift tests
|
||||
*
|
||||
* Three allowlists in the DashCaddy codebase grant "no auth" or "no CSRF"
|
||||
* access to specific paths. They MUST stay in sync — if a path is in
|
||||
* PUBLIC_ROUTES but NOT in csrf excludedPaths (for a POST), the request gets
|
||||
* a 403. If a path is in csrf excludedPaths but NOT in PUBLIC_ROUTES, it gets
|
||||
* a 401. Both bugs are silent and ship-blocking for fresh users.
|
||||
*
|
||||
* Three lists:
|
||||
* 1. PUBLIC_ROUTES — in src/utilities/middleware.js, used by auth middleware
|
||||
* 2. excludedPaths — in src/security/csrf-protection.js, used by CSRF middleware
|
||||
* 3. Request-logging skip list — in src/utilities/middleware.js, used by request logger
|
||||
* 4. Tailscale auth bypass — in src/utilities/middleware.js, used by Tailscale gate
|
||||
*
|
||||
* Tests assert:
|
||||
* A. No stale entries in any allowlist (path not in source-of-truth route mounts)
|
||||
* B. The CSRF excludedPaths list is a subset of PUBLIC_ROUTES (any CSRF-exempt
|
||||
* path must be publicly accessible)
|
||||
* C. Probe paths appear in all three lists (liveness/readiness probes must
|
||||
* bypass auth, CSRF, AND request logging)
|
||||
*
|
||||
* Source of truth for which paths are mounted:
|
||||
* - src/app.js (inline apiRouter.get/post routes)
|
||||
* - routes/[subdir]/[file].js (router.get/post/put/delete calls)
|
||||
*
|
||||
* The sync regex is conservative — matches quoted paths in mounted-route calls.
|
||||
* False positives (e.g. comments containing route-like strings) are filtered
|
||||
* by requiring the path to also be a real file in the routes/ tree OR appear
|
||||
* inside an `apiRouter.` / `app.` call expression.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { universalDeps } = require('./test-helpers/universal-deps');
|
||||
|
||||
const PKG_ROOT = path.join(__dirname, '..');
|
||||
const SRC_APP = path.join(PKG_ROOT, 'src', 'app.js');
|
||||
const SRC_MIDDLEWARE = path.join(PKG_ROOT, 'src', 'utilities', 'middleware.js');
|
||||
const SRC_CSRF = path.join(PKG_ROOT, 'src', 'security', 'csrf-protection.js');
|
||||
|
||||
// Extract PUBLIC_ROUTES path strings from middleware.js
|
||||
function readPublicRoutes() {
|
||||
const content = fs.readFileSync(SRC_MIDDLEWARE, 'utf8');
|
||||
// Match `path: '/...'`
|
||||
const matches = [...content.matchAll(/path:\s*['"]([^'"]+)['"]/g)].map(m => m[1]);
|
||||
return new Set(matches);
|
||||
}
|
||||
|
||||
// Extract excludedPaths from csrf-protection.js
|
||||
function readCsrfExcluded() {
|
||||
const content = fs.readFileSync(SRC_CSRF, 'utf8');
|
||||
// Match string literals in arrays inside excludedPaths
|
||||
const blockMatch = content.match(/excludedPaths\s*=\s*\[([^\]]+)\]/);
|
||||
if (!blockMatch) return new Set();
|
||||
const entries = [...blockMatch[1].matchAll(/['"]([^'"]+)['"]/g)].map(m => m[1]);
|
||||
return new Set(entries);
|
||||
}
|
||||
|
||||
// Extract all mounted-route paths from the live Express routers.
|
||||
//
|
||||
// Strategy:
|
||||
// 1. Build a real Express app with stub middleware that just calls next()
|
||||
// 2. Mount each aggregator router (auth/index.js, apps/index.js, arr/index.js)
|
||||
// using universal deps
|
||||
// 3. Use express.Router.stack to enumerate every registered route + path
|
||||
// 4. Also inline-mount non-aggregator route files (e.g. routes/services.js)
|
||||
// 5. For src/app.js inline routes (apiRouter.get('/health', ...)), parse directly
|
||||
//
|
||||
// This is more robust than regex — it captures routes registered via
|
||||
// router.use(subRouter) chains inside aggregator files (e.g. auth/index.js
|
||||
// calling router.use(initTotp(deps))). Regex can't see through that.
|
||||
function readMountedRoutes() {
|
||||
const mounted = new Set();
|
||||
|
||||
// ----- 1. Aggregator files -----
|
||||
const aggregators = ['routes/auth/index.js', 'routes/arr/index.js', 'routes/apps/index.js'];
|
||||
for (const relPath of aggregators) {
|
||||
const fullPath = path.join(PKG_ROOT, relPath);
|
||||
if (!fs.existsSync(fullPath)) continue;
|
||||
let factory;
|
||||
try {
|
||||
factory = require(fullPath);
|
||||
} catch (e) {
|
||||
// Some aggregators may not load with stub deps — skip them.
|
||||
// The depth-2 smoke test catches module-load failures separately.
|
||||
continue;
|
||||
}
|
||||
if (typeof factory !== 'function') continue;
|
||||
let router;
|
||||
try {
|
||||
router = factory(universalDeps);
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
// Aggregators (auth/index, arr/index, apps/index) are mounted bare on
|
||||
// apiRouter (which lives at /api/v1), so their inner routes inherit the
|
||||
// /api/v1 prefix in production. Walk with that prefix so PUBLIC_ROUTES
|
||||
// entries like '/api/v1/totp/config' match what the router actually
|
||||
// serves in production.
|
||||
walkRouter(router, '/api/v1', mounted);
|
||||
}
|
||||
|
||||
// ----- 2. Non-aggregator route files (mounted directly via apiRouter.use(...)) -----
|
||||
const directMounts = [
|
||||
'routes/dns.js', // apiRouter.use('/dns', dnsRoutes({...}))
|
||||
'routes/notifications.js', // apiRouter.use('/notifications', notificationRoutes({...}))
|
||||
'routes/containers.js', // apiRouter.use('/containers', containerRoutes({...}))
|
||||
'routes/services.js', // apiRouter.use(serviceRoutes({...})) // bare mount
|
||||
'routes/health.js', // apiRouter.use(healthRoutes({...})) // bare mount
|
||||
'routes/monitoring.js', // apiRouter.use(monitoringRoutes({...})) // bare mount
|
||||
'routes/updates.js', // apiRouter.use(updatesRoutes({...})) // bare mount
|
||||
'routes/tailscale.js', // apiRouter.use('/tailscale', tailscaleRoutes({...}))
|
||||
'routes/sites.js', // apiRouter.use(sitesRoutes({...}))
|
||||
'routes/credentials.js', // apiRouter.use(credentialsRoutes({...}))
|
||||
'routes/backups.js', // apiRouter.use(backupsRoutes({...}))
|
||||
'routes/ca.js', // apiRouter.use('/ca', caRoutes(ctx))
|
||||
'routes/browse.js', // apiRouter.use(browseRoutes({...}))
|
||||
'routes/errorlogs.js', // apiRouter.use(errorLogsRoutes({...}))
|
||||
'routes/logs.js', // apiRouter.use(logsRoutes({...}))
|
||||
'routes/openclaw.js', // apiRouter.use('/openclaw', openClawRoutes(ctx))
|
||||
'routes/recipes/index.js', // apiRouter.use(recipesRoutes(ctx)) // bare mount
|
||||
'routes/config/index.js', // apiRouter.use(configRoutes(ctx)) // bare mount
|
||||
'routes/themes.js', // apiRouter.use(themesRoutes({...})) // bare mount
|
||||
'routes/license.js', // apiRouter.use('/license', licenseRoutes({...}))
|
||||
];
|
||||
// Prefix map: explicit prefix from src/app.js's apiRouter.use() call
|
||||
const prefixMap = {
|
||||
'routes/dns.js': '/dns',
|
||||
'routes/notifications.js': '/notifications',
|
||||
'routes/containers.js': '/containers',
|
||||
'routes/tailscale.js': '/tailscale',
|
||||
'routes/ca.js': '/ca',
|
||||
'routes/openclaw.js': '/openclaw',
|
||||
'routes/license.js': '/license'
|
||||
};
|
||||
for (const relPath of directMounts) {
|
||||
const fullPath = path.join(PKG_ROOT, relPath);
|
||||
if (!fs.existsSync(fullPath)) continue;
|
||||
let factory;
|
||||
try {
|
||||
factory = require(fullPath);
|
||||
} catch (e) { continue; }
|
||||
if (typeof factory !== 'function') continue;
|
||||
let router;
|
||||
try {
|
||||
router = factory(universalDeps);
|
||||
} catch (e) { continue; }
|
||||
// Every direct mount is on apiRouter (which lives at /api/v1) plus an
|
||||
// optional explicit prefix from src/app.js. Walk with the combined prefix
|
||||
// so /api/v1/services/X (bare mount) and /api/v1/ca/X (explicit /ca prefix)
|
||||
// both match what production actually serves.
|
||||
const prefix = '/api/v1' + (prefixMap[relPath] || '');
|
||||
walkRouter(router, prefix, mounted);
|
||||
}
|
||||
|
||||
// ----- 3. Inline routes in src/app.js (apiRouter.get, app.get, etc.) -----
|
||||
const appContent = fs.readFileSync(SRC_APP, 'utf8');
|
||||
const inlineCallRe = /(?:apiRouter|app|router)\.(?:get|post|put|delete|patch)\(\s*['"]([^'"]+)['"]/g;
|
||||
for (const m of appContent.matchAll(inlineCallRe)) {
|
||||
// Skip probe paths handled separately (they're not mounted on apiRouter)
|
||||
if (!m[1].startsWith('/healthz') && !m[1].startsWith('/readyz')) {
|
||||
// Some are root-level (e.g. '/health'), some are apiRouter-level (e.g. '/csrf-token')
|
||||
// We add both interpretations — the source-of-truth check accepts either match
|
||||
mounted.add(m[1]);
|
||||
mounted.add('/api/v1' + m[1]);
|
||||
}
|
||||
}
|
||||
// Also add the 5 probe paths explicitly since they're mounted at root
|
||||
for (const p of ['/health', '/health/live', '/health/ready', '/healthz', '/readyz']) {
|
||||
mounted.add(p);
|
||||
}
|
||||
|
||||
return mounted;
|
||||
}
|
||||
|
||||
// Recursively walk an Express router's stack to collect registered paths
|
||||
function walkRouter(router, basePrefix, mounted) {
|
||||
if (!router || !router.stack) return;
|
||||
for (const layer of router.stack) {
|
||||
if (layer.route) {
|
||||
// Direct route registration: router.get('/path', handler)
|
||||
const path = basePrefix + layer.route.path;
|
||||
// Express adds regex objects; we want the path string
|
||||
if (typeof path === 'string') {
|
||||
mounted.add(path);
|
||||
}
|
||||
} else if (layer.name === 'router' && layer.handle.stack) {
|
||||
// Sub-router mounted via router.use(subRouter)
|
||||
// Express strips the mount path from layer.regex; reconstruct it from layer.regex
|
||||
const mountPath = extractMountPath(layer);
|
||||
walkRouter(layer.handle, basePrefix + mountPath, mounted);
|
||||
} else if (layer.regex && layer.handle !== undefined) {
|
||||
// Middleware with no path (e.g. router.use(initTotp(deps)) where initTotp
|
||||
// returns a router). Express wraps it as a layer with regex.fast_slash=true.
|
||||
// Try to walk it as a sub-router.
|
||||
if (layer.handle && layer.handle.stack) {
|
||||
const mountPath = extractMountPath(layer);
|
||||
walkRouter(layer.handle, basePrefix + mountPath, mounted);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract the mount path from an Express layer's regex.
|
||||
// Express stores it in layer.regex as a path-to-regexp regex; the source
|
||||
// string is in layer.regex.source but it's been escaped. We can get the
|
||||
// original path by parsing the source's leading '^\\/?(...)' or use a
|
||||
// simpler heuristic: fast_slash layers mean mount was '/', otherwise
|
||||
// reconstruct from the FastWildcard options.
|
||||
// Since Express internals here are brittle, fall back to a regex source match.
|
||||
function extractMountPath(layer) {
|
||||
if (layer.regex && layer.regex.fast_slash) return '';
|
||||
if (!layer.regex || !layer.regex.source) return '';
|
||||
// The source is something like '^\\/foo\\/?(?=\\/|$)' for mount path '/foo'.
|
||||
// Match the first path segment after the optional leading slash.
|
||||
const m = layer.regex.source.match(/^\\\/\(([^)]+)\)/);
|
||||
if (m) {
|
||||
// Convert path-to-regexp syntax like ':foo' or '*' back to a placeholder.
|
||||
// For simple mounts (no params) this gives us the literal segment.
|
||||
return '/' + m[1];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// Check if path is a prefix in PUBLIC_ROUTES (e.g., '/api/v1/auth/gate/' grants all under it)
|
||||
function isPubliclyCovered(path, publicRoutes) {
|
||||
if (publicRoutes.has(path)) return true;
|
||||
// Try as prefix match
|
||||
for (const entry of publicRoutes) {
|
||||
if (entry.endsWith('/') && path.startsWith(entry)) return true;
|
||||
if (entry === path) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
describe('Public-routes allowlist drift (prevents DC-012-style dead entries)', () => {
|
||||
const publicRoutes = readPublicRoutes();
|
||||
const csrfExcluded = readCsrfExcluded();
|
||||
const mountedRoutes = readMountedRoutes();
|
||||
|
||||
// Helpful diagnostic when tests fail
|
||||
test('sanity: allowlists parsed correctly', () => {
|
||||
expect(publicRoutes.size).toBeGreaterThan(10);
|
||||
expect(csrfExcluded.size).toBeGreaterThan(0);
|
||||
expect(mountedRoutes.size).toBeGreaterThan(10);
|
||||
// Probe paths from DC-012 should all be in PUBLIC_ROUTES
|
||||
for (const p of ['/health', '/health/live', '/health/ready', '/healthz', '/readyz']) {
|
||||
expect(publicRoutes).toContain(p);
|
||||
}
|
||||
});
|
||||
|
||||
describe('No stale PUBLIC_ROUTES entries (the DC-012 failure mode)', () => {
|
||||
test('every PUBLIC_ROUTES entry matches an actual mounted route', () => {
|
||||
const stale = [];
|
||||
for (const entry of publicRoutes) {
|
||||
if (entry.endsWith('/')) continue; // prefix matches, skip
|
||||
if (!mountedRoutes.has(entry)) stale.push(entry);
|
||||
}
|
||||
expect(stale).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CSRF excludedPaths drift detection', () => {
|
||||
test('every CSRF excludedPath is publicly accessible (else 403)', () => {
|
||||
const broken = [];
|
||||
for (const p of csrfExcluded) {
|
||||
if (!isPubliclyCovered(p, publicRoutes)) broken.push(p);
|
||||
}
|
||||
expect(broken).toEqual([]);
|
||||
});
|
||||
|
||||
test('probe paths are CSRF-exempt (k8s probes never carry CSRF tokens)', () => {
|
||||
// These probe paths MUST be in csrf excludedPaths because k8s/Docker
|
||||
// healthchecks hit them with GET requests and no CSRF token.
|
||||
// (Note: CSRF middleware skips GET/HEAD/OPTIONS anyway, but explicit
|
||||
// listing is the documented pattern and protects against future changes.)
|
||||
for (const p of ['/health', '/health/live', '/health/ready', '/healthz', '/readyz']) {
|
||||
expect(csrfExcluded).toContain(p);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Request-logging exclusion covers all probe paths', () => {
|
||||
// The middleware.js request-logging skip is a regex-based check inside
|
||||
// the logging middleware. We verify by reading the source and asserting
|
||||
// each probe path appears in the skip set.
|
||||
let middlewareContent;
|
||||
beforeAll(() => {
|
||||
middlewareContent = fs.readFileSync(SRC_MIDDLEWARE, 'utf8');
|
||||
});
|
||||
|
||||
for (const p of ['/health', '/health/live', '/health/ready', '/healthz', '/readyz']) {
|
||||
test(`probe path '${p}' is excluded from request logging`, () => {
|
||||
const pattern = new RegExp(`req\\.path\\s*===?\\s*['"]${p.replace(/\//g, '\\/')}['"]`);
|
||||
expect(middlewareContent).toMatch(pattern);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('Tailscale auth bypass covers all probe paths', () => {
|
||||
// Same as logging exclusion but for the Tailscale auth middleware.
|
||||
// K8s probes don't carry Tailscale identity headers.
|
||||
let middlewareContent;
|
||||
beforeAll(() => {
|
||||
middlewareContent = fs.readFileSync(SRC_MIDDLEWARE, 'utf8');
|
||||
});
|
||||
|
||||
for (const p of ['/health', '/health/live', '/health/ready', '/healthz', '/readyz']) {
|
||||
test(`probe path '${p}' bypasses Tailscale auth`, () => {
|
||||
const pattern = new RegExp(`req\\.path\\s*===?\\s*['"]${p.replace(/\//g, '\\/')}['"]`);
|
||||
expect(middlewareContent).toMatch(pattern);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,472 @@
|
||||
// Resource Monitor Tests
|
||||
// Validates container CPU/memory/disk/network tracking, alerts, and persistence
|
||||
|
||||
jest.mock('dockerode');
|
||||
jest.mock('fs');
|
||||
|
||||
const fs = require('fs');
|
||||
const EventEmitter = require('events');
|
||||
|
||||
// Setup defaults BEFORE requiring singleton
|
||||
fs.existsSync.mockReturnValue(false);
|
||||
fs.readFileSync.mockReturnValue('{}');
|
||||
fs.writeFileSync.mockReturnValue(undefined);
|
||||
|
||||
const resourceMonitor = require('../src/managers/resource-monitor');
|
||||
|
||||
function makeStat(overrides = {}) {
|
||||
return {
|
||||
timestamp: new Date().toISOString(),
|
||||
cpu: { percent: 15.5, usage: 500000 },
|
||||
memory: { usage: 536870912, limit: 2147483648, percent: 25.0, usageMB: 512, limitMB: 2048 },
|
||||
network: { rxBytes: 1048576, txBytes: 524288, rxMB: 1, txMB: 0.5 },
|
||||
disk: { readBytes: 0, writeBytes: 0, readMB: 0, writeMB: 0 },
|
||||
pids: 42,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers();
|
||||
|
||||
fs.existsSync.mockReturnValue(false);
|
||||
fs.readFileSync.mockReturnValue('{}');
|
||||
fs.writeFileSync.mockReturnValue(undefined);
|
||||
|
||||
// Reset internal state
|
||||
resourceMonitor.stats.clear();
|
||||
resourceMonitor.alerts.clear();
|
||||
resourceMonitor.lastAlerts.clear();
|
||||
resourceMonitor.monitoring = false;
|
||||
if (resourceMonitor.monitoringInterval) {
|
||||
clearInterval(resourceMonitor.monitoringInterval);
|
||||
resourceMonitor.monitoringInterval = null;
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resourceMonitor.stop();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('ResourceMonitor — container resource tracking', () => {
|
||||
|
||||
describe('start/stop lifecycle', () => {
|
||||
it('starts monitoring', () => {
|
||||
resourceMonitor.start();
|
||||
expect(resourceMonitor.monitoring).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores double start', () => {
|
||||
resourceMonitor.start();
|
||||
resourceMonitor.start();
|
||||
expect(resourceMonitor.monitoring).toBe(true);
|
||||
});
|
||||
|
||||
it('stops monitoring and saves stats', () => {
|
||||
resourceMonitor.start();
|
||||
resourceMonitor.stop();
|
||||
expect(resourceMonitor.monitoring).toBe(false);
|
||||
expect(resourceMonitor.monitoringInterval).toBeNull();
|
||||
expect(fs.writeFileSync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores stop when not monitoring', () => {
|
||||
resourceMonitor.stop();
|
||||
expect(resourceMonitor.monitoring).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordStats', () => {
|
||||
it('creates new entry for unknown container', () => {
|
||||
const stat = makeStat();
|
||||
resourceMonitor.recordStats('abc123', '/plex', stat);
|
||||
expect(resourceMonitor.stats.has('abc123')).toBe(true);
|
||||
expect(resourceMonitor.stats.get('abc123').history).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('appends to existing container history', () => {
|
||||
resourceMonitor.recordStats('abc123', '/plex', makeStat());
|
||||
resourceMonitor.recordStats('abc123', '/plex', makeStat());
|
||||
expect(resourceMonitor.stats.get('abc123').history).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('updates container name if changed', () => {
|
||||
resourceMonitor.recordStats('abc123', '/plex-old', makeStat());
|
||||
resourceMonitor.recordStats('abc123', '/plex-new', makeStat());
|
||||
expect(resourceMonitor.stats.get('abc123').name).toBe('/plex-new');
|
||||
});
|
||||
|
||||
it('trims stats older than retention period', () => {
|
||||
const oldStat = makeStat({ timestamp: new Date(Date.now() - 999 * 60 * 60 * 1000).toISOString() });
|
||||
const newStat = makeStat();
|
||||
resourceMonitor.recordStats('abc123', '/plex', oldStat);
|
||||
resourceMonitor.recordStats('abc123', '/plex', newStat);
|
||||
// Old stat exceeds 168h (7 day) retention
|
||||
expect(resourceMonitor.stats.get('abc123').history).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCurrentStats', () => {
|
||||
it('returns null for unknown container', () => {
|
||||
expect(resourceMonitor.getCurrentStats('unknown')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns latest stat entry', () => {
|
||||
const stat1 = makeStat({ cpu: { percent: 10, usage: 100 } });
|
||||
const stat2 = makeStat({ cpu: { percent: 50, usage: 500 } });
|
||||
resourceMonitor.recordStats('abc123', '/plex', stat1);
|
||||
resourceMonitor.recordStats('abc123', '/plex', stat2);
|
||||
expect(resourceMonitor.getCurrentStats('abc123').cpu.percent).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHistoricalStats', () => {
|
||||
it('returns empty array for unknown container', () => {
|
||||
expect(resourceMonitor.getHistoricalStats('unknown')).toEqual([]);
|
||||
});
|
||||
|
||||
it('filters by time window', () => {
|
||||
const recentStat = makeStat();
|
||||
const oldStat = makeStat({ timestamp: new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString() });
|
||||
|
||||
resourceMonitor.stats.set('abc123', {
|
||||
name: '/plex',
|
||||
history: [oldStat, recentStat]
|
||||
});
|
||||
|
||||
// Only last 24 hours
|
||||
const result = resourceMonitor.getHistoricalStats('abc123', 24);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAggregatedStats', () => {
|
||||
it('returns null for unknown container', () => {
|
||||
expect(resourceMonitor.getAggregatedStats('unknown')).toBeNull();
|
||||
});
|
||||
|
||||
it('calculates min/max/avg for CPU and memory', () => {
|
||||
const stats = [
|
||||
makeStat({ cpu: { percent: 10, usage: 100 }, memory: { percent: 20, usage: 0, limit: 0, usageMB: 0, limitMB: 0 } }),
|
||||
makeStat({ cpu: { percent: 30, usage: 300 }, memory: { percent: 40, usage: 0, limit: 0, usageMB: 0, limitMB: 0 } }),
|
||||
makeStat({ cpu: { percent: 50, usage: 500 }, memory: { percent: 60, usage: 0, limit: 0, usageMB: 0, limitMB: 0 } }),
|
||||
];
|
||||
resourceMonitor.stats.set('abc123', { name: '/plex', history: stats });
|
||||
|
||||
const agg = resourceMonitor.getAggregatedStats('abc123', 24);
|
||||
expect(agg.cpu.min).toBe(10);
|
||||
expect(agg.cpu.max).toBe(50);
|
||||
expect(agg.cpu.avg).toBe(30);
|
||||
expect(agg.cpu.current).toBe(50);
|
||||
expect(agg.memory.min).toBe(20);
|
||||
expect(agg.memory.max).toBe(60);
|
||||
expect(agg.dataPoints).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllStats', () => {
|
||||
it('returns all containers with current and aggregated data', () => {
|
||||
resourceMonitor.recordStats('abc123', '/plex', makeStat());
|
||||
resourceMonitor.recordStats('def456', '/radarr', makeStat());
|
||||
|
||||
const all = resourceMonitor.getAllStats();
|
||||
expect(Object.keys(all)).toHaveLength(2);
|
||||
expect(all['abc123'].name).toBe('/plex');
|
||||
expect(all['abc123'].current).toBeDefined();
|
||||
expect(all['abc123'].aggregated).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('alert configuration', () => {
|
||||
it('setAlertConfig stores config and persists', () => {
|
||||
resourceMonitor.setAlertConfig('abc123', {
|
||||
cpuThreshold: 80,
|
||||
memoryThreshold: 90,
|
||||
cooldownMinutes: 30
|
||||
});
|
||||
|
||||
const config = resourceMonitor.getAlertConfig('abc123');
|
||||
expect(config.enabled).toBe(true);
|
||||
expect(config.cpuThreshold).toBe(80);
|
||||
expect(config.memoryThreshold).toBe(90);
|
||||
expect(config.cooldownMinutes).toBe(30);
|
||||
expect(fs.writeFileSync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns null for unconfigured container', () => {
|
||||
expect(resourceMonitor.getAlertConfig('unknown')).toBeNull();
|
||||
});
|
||||
|
||||
it('removeAlertConfig clears config and cooldown', () => {
|
||||
resourceMonitor.setAlertConfig('abc123', { cpuThreshold: 80 });
|
||||
resourceMonitor.lastAlerts.set('abc123', Date.now());
|
||||
resourceMonitor.removeAlertConfig('abc123');
|
||||
expect(resourceMonitor.getAlertConfig('abc123')).toBeNull();
|
||||
expect(resourceMonitor.lastAlerts.has('abc123')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkAlerts', () => {
|
||||
it('emits alert when CPU exceeds threshold', () => {
|
||||
const handler = jest.fn();
|
||||
resourceMonitor.on('alert', handler);
|
||||
|
||||
resourceMonitor.setAlertConfig('abc123', { cpuThreshold: 50, cooldownMinutes: 0 });
|
||||
const stat = makeStat({ cpu: { percent: 75, usage: 750 } });
|
||||
resourceMonitor.checkAlerts('abc123', '/plex', stat);
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
containerId: 'abc123',
|
||||
alerts: expect.arrayContaining([
|
||||
expect.objectContaining({ type: 'cpu', value: 75 })
|
||||
])
|
||||
})
|
||||
);
|
||||
resourceMonitor.off('alert', handler);
|
||||
});
|
||||
|
||||
it('emits alert when memory exceeds threshold', () => {
|
||||
const handler = jest.fn();
|
||||
resourceMonitor.on('alert', handler);
|
||||
|
||||
resourceMonitor.setAlertConfig('abc123', { memoryThreshold: 20, cooldownMinutes: 0 });
|
||||
const stat = makeStat({ memory: { percent: 80, usage: 0, limit: 0, usageMB: 0, limitMB: 0 } });
|
||||
resourceMonitor.checkAlerts('abc123', '/plex', stat);
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
alerts: expect.arrayContaining([
|
||||
expect.objectContaining({ type: 'memory' })
|
||||
])
|
||||
})
|
||||
);
|
||||
resourceMonitor.off('alert', handler);
|
||||
});
|
||||
|
||||
it('emits alert when disk I/O exceeds threshold', () => {
|
||||
const handler = jest.fn();
|
||||
resourceMonitor.on('alert', handler);
|
||||
|
||||
resourceMonitor.setAlertConfig('abc123', { diskIOThreshold: 10, cooldownMinutes: 0 });
|
||||
const stat = makeStat({ disk: { readMB: 15, writeMB: 10, readBytes: 0, writeBytes: 0 } });
|
||||
resourceMonitor.checkAlerts('abc123', '/plex', stat);
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
alerts: expect.arrayContaining([
|
||||
expect.objectContaining({ type: 'disk' })
|
||||
])
|
||||
})
|
||||
);
|
||||
resourceMonitor.off('alert', handler);
|
||||
});
|
||||
|
||||
it('respects cooldown period', () => {
|
||||
const handler = jest.fn();
|
||||
resourceMonitor.on('alert', handler);
|
||||
|
||||
resourceMonitor.setAlertConfig('abc123', { cpuThreshold: 50, cooldownMinutes: 15 });
|
||||
resourceMonitor.lastAlerts.set('abc123', Date.now()); // Just alerted
|
||||
|
||||
const stat = makeStat({ cpu: { percent: 99, usage: 990 } });
|
||||
resourceMonitor.checkAlerts('abc123', '/plex', stat);
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
resourceMonitor.off('alert', handler);
|
||||
});
|
||||
|
||||
it('skips when alerts not configured or disabled', () => {
|
||||
const handler = jest.fn();
|
||||
resourceMonitor.on('alert', handler);
|
||||
|
||||
// No config
|
||||
resourceMonitor.checkAlerts('abc123', '/plex', makeStat());
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
|
||||
// Disabled config
|
||||
resourceMonitor.alerts.set('abc123', { enabled: false, cpuThreshold: 1 });
|
||||
resourceMonitor.checkAlerts('abc123', '/plex', makeStat());
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
|
||||
resourceMonitor.off('alert', handler);
|
||||
});
|
||||
|
||||
it('does not alert when below thresholds', () => {
|
||||
const handler = jest.fn();
|
||||
resourceMonitor.on('alert', handler);
|
||||
|
||||
resourceMonitor.setAlertConfig('abc123', { cpuThreshold: 90, memoryThreshold: 90, cooldownMinutes: 0 });
|
||||
const stat = makeStat({ cpu: { percent: 5, usage: 50 }, memory: { percent: 10, usage: 0, limit: 0, usageMB: 0, limitMB: 0 } });
|
||||
resourceMonitor.checkAlerts('abc123', '/plex', stat);
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
resourceMonitor.off('alert', handler);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupOldStats', () => {
|
||||
it('removes containers with no recent data', () => {
|
||||
const oldStat = makeStat({ timestamp: new Date(Date.now() - 999 * 60 * 60 * 1000).toISOString() });
|
||||
resourceMonitor.stats.set('old-container', { name: '/old', history: [oldStat] });
|
||||
resourceMonitor.cleanupOldStats();
|
||||
expect(resourceMonitor.stats.has('old-container')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps containers with recent data', () => {
|
||||
resourceMonitor.recordStats('abc123', '/plex', makeStat());
|
||||
resourceMonitor.cleanupOldStats();
|
||||
expect(resourceMonitor.stats.has('abc123')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistence (loadStats/saveStats)', () => {
|
||||
it('loadStats populates from file', () => {
|
||||
const savedData = {
|
||||
'abc123': { name: '/plex', history: [makeStat()] }
|
||||
};
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
fs.readFileSync.mockReturnValue(JSON.stringify(savedData));
|
||||
|
||||
resourceMonitor.loadStats();
|
||||
expect(resourceMonitor.stats.size).toBe(1);
|
||||
});
|
||||
|
||||
it('loadStats handles missing file', () => {
|
||||
fs.existsSync.mockReturnValue(false);
|
||||
resourceMonitor.loadStats();
|
||||
expect(resourceMonitor.stats.size).toBe(0);
|
||||
});
|
||||
|
||||
it('loadStats handles corrupt file', () => {
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
fs.readFileSync.mockImplementation(() => { throw new Error('corrupt'); });
|
||||
resourceMonitor.loadStats(); // should not throw
|
||||
});
|
||||
|
||||
it('saveStats writes Map as JSON object', () => {
|
||||
resourceMonitor.recordStats('abc123', '/plex', makeStat());
|
||||
resourceMonitor.saveStats();
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.stringContaining('abc123')
|
||||
);
|
||||
});
|
||||
|
||||
it('saveStats handles write error', () => {
|
||||
fs.writeFileSync.mockImplementation(() => { throw new Error('disk full'); });
|
||||
resourceMonitor.recordStats('abc123', '/plex', makeStat());
|
||||
resourceMonitor.saveStats(); // should not throw
|
||||
});
|
||||
});
|
||||
|
||||
describe('alert config persistence', () => {
|
||||
it('loadAlertConfig populates from file', () => {
|
||||
const config = { 'abc123': { enabled: true, cpuThreshold: 80 } };
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
fs.readFileSync.mockReturnValue(JSON.stringify(config));
|
||||
|
||||
resourceMonitor.loadAlertConfig();
|
||||
expect(resourceMonitor.alerts.size).toBe(1);
|
||||
});
|
||||
|
||||
it('saveAlertConfig writes alerts as JSON', () => {
|
||||
resourceMonitor.setAlertConfig('abc123', { cpuThreshold: 80 });
|
||||
expect(fs.writeFileSync).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('exportStats / importStats', () => {
|
||||
it('exports stats and alerts', () => {
|
||||
resourceMonitor.recordStats('abc123', '/plex', makeStat());
|
||||
resourceMonitor.setAlertConfig('abc123', { cpuThreshold: 80 });
|
||||
|
||||
const exported = resourceMonitor.exportStats();
|
||||
expect(exported.stats['abc123']).toBeDefined();
|
||||
expect(exported.alerts['abc123']).toBeDefined();
|
||||
expect(exported.exportedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('imports stats and alerts', () => {
|
||||
const data = {
|
||||
stats: { 'abc123': { name: '/plex', history: [makeStat()] } },
|
||||
alerts: { 'abc123': { enabled: true, cpuThreshold: 90 } }
|
||||
};
|
||||
|
||||
resourceMonitor.importStats(data);
|
||||
expect(resourceMonitor.stats.size).toBe(1);
|
||||
expect(resourceMonitor.alerts.size).toBe(1);
|
||||
// Should persist after import
|
||||
expect(fs.writeFileSync).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContainerStats (Docker stats parsing)', () => {
|
||||
it('parses Docker stats into structured format', async () => {
|
||||
const Docker = require('dockerode');
|
||||
const mockContainer = {
|
||||
stats: jest.fn((opts, cb) => cb(null, {
|
||||
cpu_stats: {
|
||||
cpu_usage: { total_usage: 200000 },
|
||||
system_cpu_usage: 1000000
|
||||
},
|
||||
precpu_stats: {
|
||||
cpu_usage: { total_usage: 100000 },
|
||||
system_cpu_usage: 500000
|
||||
},
|
||||
memory_stats: {
|
||||
usage: 536870912, // 512MB
|
||||
limit: 2147483648 // 2GB
|
||||
},
|
||||
networks: {
|
||||
eth0: { rx_bytes: 1048576, tx_bytes: 524288 }
|
||||
},
|
||||
blkio_stats: {
|
||||
io_service_bytes_recursive: [
|
||||
{ op: 'Read', value: 1048576 },
|
||||
{ op: 'Write', value: 2097152 }
|
||||
]
|
||||
},
|
||||
pids_stats: { current: 42 }
|
||||
}))
|
||||
};
|
||||
|
||||
const result = await resourceMonitor.getContainerStats(mockContainer);
|
||||
expect(result.cpu.percent).toBe(20); // (100000/500000) * 100
|
||||
expect(result.memory.usageMB).toBe(512);
|
||||
expect(result.memory.limitMB).toBe(2048);
|
||||
expect(result.memory.percent).toBe(25);
|
||||
expect(result.network.rxMB).toBe(1);
|
||||
expect(result.disk.readMB).toBe(1);
|
||||
expect(result.disk.writeMB).toBe(2);
|
||||
expect(result.pids).toBe(42);
|
||||
});
|
||||
|
||||
it('handles missing network stats', async () => {
|
||||
const mockContainer = {
|
||||
stats: jest.fn((opts, cb) => cb(null, {
|
||||
cpu_stats: { cpu_usage: { total_usage: 0 }, system_cpu_usage: 0 },
|
||||
precpu_stats: { cpu_usage: { total_usage: 0 }, system_cpu_usage: 0 },
|
||||
memory_stats: { usage: 0, limit: 0 },
|
||||
blkio_stats: {},
|
||||
pids_stats: {}
|
||||
}))
|
||||
};
|
||||
|
||||
const result = await resourceMonitor.getContainerStats(mockContainer);
|
||||
expect(result.network.rxBytes).toBe(0);
|
||||
expect(result.network.txBytes).toBe(0);
|
||||
expect(result.pids).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects on Docker error', async () => {
|
||||
const mockContainer = {
|
||||
stats: jest.fn((opts, cb) => cb(new Error('container gone')))
|
||||
};
|
||||
|
||||
await expect(resourceMonitor.getContainerStats(mockContainer)).rejects.toThrow('container gone');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,483 @@
|
||||
/**
|
||||
* Integration tests for routes/auth/totp.js — the full TOTP auth flow.
|
||||
*
|
||||
* Covers the BACKLOG.md DC-006 acceptance criteria:
|
||||
* - no code → 400 (ValidationError)
|
||||
* - wrong code → 401 (AuthenticationError)
|
||||
* - valid TOTP → 200 + session cookie + CSRF token
|
||||
* - check-session with valid session → 200 { authenticated: true }
|
||||
* - check-session without session → 401 (AuthenticationError)
|
||||
*
|
||||
* Uses real otplib for code generation (so we exercise the actual TOTP math)
|
||||
* but mocks credentialManager, session, totpConfig, and saveTotpConfig —
|
||||
* because those modules own their own state machines (disk, cookies, file)
|
||||
* that don't belong in a routes-level test.
|
||||
*
|
||||
* NOTE: this test exercises the src/ refactored module layout (DC-005).
|
||||
* It depends on routes/auth/totp.js requiring ../../src/utilities/errors and
|
||||
* ../../src/utils/responses — fix the relative paths in totp.js if they
|
||||
* regress (see commit log for DC-006).
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
const { authenticator } = require('otplib');
|
||||
|
||||
// Quiet otplib's "Unescaped left brace" warning on Node 20+
|
||||
const origWarn = console.warn;
|
||||
beforeAll(() => {
|
||||
console.warn = (...args) => {
|
||||
const msg = args.join(' ');
|
||||
if (msg.includes('Unescaped left brace')) return;
|
||||
origWarn.apply(console, args);
|
||||
};
|
||||
});
|
||||
afterAll(() => {
|
||||
console.warn = origWarn;
|
||||
});
|
||||
|
||||
// Minimal asyncHandler that catches errors into the express error chain
|
||||
function asyncHandler(fn) {
|
||||
return (req, res, _next) => Promise.resolve(fn(req, res, _next)).catch(_next);
|
||||
}
|
||||
|
||||
function createApp(depsOverride = {}) {
|
||||
// In-memory secret store so credentialManager stays deterministic
|
||||
const storedSecrets = new Map();
|
||||
const credentialManager = {
|
||||
store: jest.fn((key, value) => {
|
||||
storedSecrets.set(key, value);
|
||||
return Promise.resolve(true);
|
||||
}),
|
||||
retrieve: jest.fn((key) => Promise.resolve(storedSecrets.has(key) ? storedSecrets.get(key) : null)),
|
||||
delete: jest.fn((key) => {
|
||||
storedSecrets.delete(key);
|
||||
return Promise.resolve(true);
|
||||
}),
|
||||
list: jest.fn(() => Promise.resolve(Array.from(storedSecrets.keys()))),
|
||||
};
|
||||
|
||||
// Mutable TOTP config — tests mutate this to model setup → enable → disable
|
||||
const totpConfig = {
|
||||
enabled: false,
|
||||
isSetUp: false,
|
||||
sessionDuration: '24h',
|
||||
secret: null, // matches main's optional backup-secret field
|
||||
};
|
||||
|
||||
// Mock session context mirroring src/context/session.js
|
||||
// isValid() is the knob — toggle it to test the auth-gate behavior
|
||||
const sessionStore = new Map(); // ip → { expiresAt }
|
||||
const session = {
|
||||
create: jest.fn((req, duration) => {
|
||||
const ip = session.getClientIP(req);
|
||||
sessionStore.set(ip, { expiresAt: Date.now() + (duration === 'never' ? Number.MAX_SAFE_INTEGER : 3600000) });
|
||||
}),
|
||||
setCookie: jest.fn(),
|
||||
clear: jest.fn((req) => {
|
||||
const ip = session.getClientIP(req);
|
||||
sessionStore.delete(ip);
|
||||
}),
|
||||
clearCookie: jest.fn(),
|
||||
isValid: jest.fn((req) => {
|
||||
const ip = session.getClientIP(req);
|
||||
const entry = sessionStore.get(ip);
|
||||
if (!entry) return false;
|
||||
return entry.expiresAt > Date.now();
|
||||
}),
|
||||
// Test helper — pretend an IP has a valid session, regardless of req.ip
|
||||
_grantSession: (ip = '127.0.0.1') => sessionStore.set(ip, { expiresAt: Date.now() + 3600000 }),
|
||||
getClientIP: jest.fn((req) => req.ip || req.connection?.remoteAddress || '127.0.0.1'),
|
||||
ipSessions: sessionStore,
|
||||
durations: { '1h': 3600000, '24h': 86400000, '7d': 604800000, 'never': 0 },
|
||||
};
|
||||
|
||||
const saveTotpConfig = jest.fn(() => Promise.resolve(true));
|
||||
const renewCSRFToken = jest.fn(() => 'mock-csrf-token');
|
||||
const log = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
};
|
||||
|
||||
const deps = {
|
||||
authManager: {}, // unused by totp.js but required by the factory signature
|
||||
credentialManager,
|
||||
totpConfig,
|
||||
saveTotpConfig,
|
||||
session,
|
||||
asyncHandler,
|
||||
errorResponse: jest.fn(),
|
||||
log,
|
||||
renewCSRFToken,
|
||||
...depsOverride,
|
||||
};
|
||||
|
||||
// Clear store between tests
|
||||
deps._resetStore = () => {
|
||||
storedSecrets.clear();
|
||||
sessionStore.clear();
|
||||
totpConfig.enabled = false;
|
||||
totpConfig.isSetUp = false;
|
||||
totpConfig.sessionDuration = '24h';
|
||||
delete totpConfig.secret;
|
||||
};
|
||||
|
||||
const totpRoutes = require('../../routes/auth/totp');
|
||||
const app = express();
|
||||
app.set('trust proxy', true); // so req.ip populates from X-Forwarded-For
|
||||
app.use(express.json());
|
||||
app.use('/api', totpRoutes(deps));
|
||||
// Express error handler — surface status from thrown AppError
|
||||
app.use((err, req, res, _next) => {
|
||||
const status = err.statusCode || 500;
|
||||
res.status(status).json({ success: false, error: err.message });
|
||||
});
|
||||
|
||||
return { app, deps };
|
||||
}
|
||||
|
||||
describe('TOTP Auth Routes — DC-006 Integration Test', () => {
|
||||
let app;
|
||||
let deps;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
({ app, deps } = createApp());
|
||||
authenticator.options = { window: 1 };
|
||||
});
|
||||
|
||||
// Helper: derive a fresh secret + a valid current TOTP code for it
|
||||
function freshSecret() {
|
||||
const secret = authenticator.generateSecret();
|
||||
const token = authenticator.generate(secret);
|
||||
return { secret, token };
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// GET /api/totp/config
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
describe('GET /api/totp/config', () => {
|
||||
it('returns current config (enabled=false, isSetUp=false by default)', async () => {
|
||||
const res = await request(app).get('/api/totp/config');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.config).toEqual({
|
||||
enabled: false,
|
||||
sessionDuration: '24h',
|
||||
isSetUp: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('reflects state changes after setup completes', async () => {
|
||||
deps.totpConfig.isSetUp = true;
|
||||
deps.totpConfig.enabled = true;
|
||||
const res = await request(app).get('/api/totp/config');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.config.isSetUp).toBe(true);
|
||||
expect(res.body.config.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// POST /api/totp/setup
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
describe('POST /api/totp/setup', () => {
|
||||
it('generates a fresh secret + QR code when none is provided', async () => {
|
||||
const res = await request(app).post('/api/totp/setup').send({});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.qrCode).toMatch(/^data:image\/png;base64,/);
|
||||
expect(res.body.manualKey).toMatch(/^[A-Z2-7]{16,}$/);
|
||||
expect(res.body.issuer).toBe('DashCaddy');
|
||||
expect(res.body.imported).toBe(false);
|
||||
// pending_secret should be stashed but totp.secret should NOT be active yet
|
||||
expect(deps.credentialManager.store).toHaveBeenCalledWith('totp.pending_secret', res.body.manualKey);
|
||||
expect(await deps.credentialManager.retrieve('totp.secret')).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts and normalizes a user-provided Base32 secret (0→O, 1→L, 8→B, lowercase→uppercase)', async () => {
|
||||
const raw = 'JBSWY3DPEHPK3PXP'; // canonical example
|
||||
const userInput = ' jbswy3dpehpk3pxp '; // spaces + lowercase
|
||||
const res = await request(app).post('/api/totp/setup').send({ secret: userInput });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.manualKey).toBe(raw);
|
||||
expect(res.body.imported).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an obviously invalid secret (wrong alphabet)', async () => {
|
||||
const res = await request(app).post('/api/totp/setup').send({ secret: 'NOT-VALID-BASE32!' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
expect(res.body.error).toMatch(/Invalid secret key format/);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// POST /api/totp/verify-setup (activates TOTP after setup)
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
describe('POST /api/totp/verify-setup', () => {
|
||||
it('returns 400 when code is missing or malformed', async () => {
|
||||
const res = await request(app).post('/api/totp/verify-setup').send({});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/Invalid code format/);
|
||||
});
|
||||
|
||||
it('returns 400 when no pending setup exists', async () => {
|
||||
const { token } = freshSecret();
|
||||
const res = await request(app).post('/api/totp/verify-setup').send({ code: token });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/No pending TOTP setup/);
|
||||
});
|
||||
|
||||
it('returns 401 when code is wrong', async () => {
|
||||
const { secret } = freshSecret();
|
||||
await request(app).post('/api/totp/setup').send({ secret });
|
||||
const res = await request(app).post('/api/totp/verify-setup').send({ code: '000000' });
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.error).toMatch(/DC-111/);
|
||||
});
|
||||
|
||||
it('returns 200 + activates TOTP + creates session on valid code', async () => {
|
||||
const { secret, token } = freshSecret();
|
||||
await request(app).post('/api/totp/setup').send({ secret });
|
||||
const res = await request(app).post('/api/totp/verify-setup').send({ code: token });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.message).toMatch(/TOTP enabled successfully/);
|
||||
|
||||
// TOTP config activated + persisted
|
||||
expect(deps.totpConfig.isSetUp).toBe(true);
|
||||
expect(deps.totpConfig.enabled).toBe(true);
|
||||
expect(deps.saveTotpConfig).toHaveBeenCalled();
|
||||
|
||||
// pending_secret → totp.secret promotion, pending cleared
|
||||
expect(await deps.credentialManager.retrieve('totp.secret')).toBe(secret);
|
||||
expect(await deps.credentialManager.retrieve('totp.pending_secret')).toBeNull();
|
||||
|
||||
// Session established
|
||||
expect(deps.session.create).toHaveBeenCalled();
|
||||
expect(deps.session.setCookie).toHaveBeenCalled();
|
||||
// Note: renewCSRFToken is only called on /totp/verify (login), not /totp/verify-setup
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// POST /api/totp/verify (login flow — TOTP already configured)
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
describe('POST /api/totp/verify (login)', () => {
|
||||
async function setupTOTP() {
|
||||
const { secret, token } = freshSecret();
|
||||
await request(app).post('/api/totp/setup').send({ secret });
|
||||
await request(app).post('/api/totp/verify-setup').send({ code: token });
|
||||
// Reset mocks but keep config/secret state for the test
|
||||
jest.clearAllMocks();
|
||||
return secret;
|
||||
}
|
||||
|
||||
it('returns 400 when code is missing', async () => {
|
||||
const res = await request(app).post('/api/totp/verify').send({});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/Invalid code format/);
|
||||
});
|
||||
|
||||
it('returns 400 when TOTP is not enabled', async () => {
|
||||
const res = await request(app).post('/api/totp/verify').send({ code: '123456' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/TOTP is not enabled/);
|
||||
});
|
||||
|
||||
it('returns 401 when code is wrong (TOTP active)', async () => {
|
||||
await setupTOTP();
|
||||
const res = await request(app).post('/api/totp/verify').send({ code: '000000' });
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.error).toMatch(/DC-111/);
|
||||
});
|
||||
|
||||
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 token = authenticator.generate(secret);
|
||||
const res = await request(app).post('/api/totp/verify').send({ code: token });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.message).toMatch(/Authenticated successfully/);
|
||||
expect(res.body.csrfToken).toBe('mock-csrf-token');
|
||||
expect(deps.session.create).toHaveBeenCalled();
|
||||
expect(deps.session.setCookie).toHaveBeenCalled();
|
||||
expect(deps.renewCSRFToken).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// GET /api/totp/check-session (the auth gate Caddy calls)
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
describe('GET /api/totp/check-session', () => {
|
||||
it('always returns 200 when TOTP is not enabled (passthrough)', async () => {
|
||||
const res = await request(app).get('/api/totp/check-session');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ authenticated: true });
|
||||
});
|
||||
|
||||
it('always returns 200 when sessionDuration is "never" (passthrough)', async () => {
|
||||
deps.totpConfig.enabled = true;
|
||||
deps.totpConfig.isSetUp = true;
|
||||
deps.totpConfig.sessionDuration = 'never';
|
||||
const res = await request(app).get('/api/totp/check-session');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ authenticated: true });
|
||||
});
|
||||
|
||||
it('returns 401 when no session exists (BACKLOG: "no token → 401")', async () => {
|
||||
deps.totpConfig.enabled = true;
|
||||
deps.totpConfig.isSetUp = true;
|
||||
deps.totpConfig.sessionDuration = '24h';
|
||||
// session.isValid returns false because sessionStore is empty
|
||||
const res = await request(app).get('/api/totp/check-session');
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.error).toMatch(/Session expired or invalid/);
|
||||
// Cache-control headers must be set to avoid Caddy auth loops
|
||||
expect(res.headers['cache-control']).toMatch(/no-store/);
|
||||
});
|
||||
|
||||
it('returns 200 { authenticated: true } when session is valid (BACKLOG: "authenticated request succeeds")', async () => {
|
||||
deps.totpConfig.enabled = true;
|
||||
deps.totpConfig.isSetUp = true;
|
||||
deps.totpConfig.sessionDuration = '24h';
|
||||
// Pre-populate the session store as if verify already ran
|
||||
deps.session._grantSession('127.0.0.1');
|
||||
const res = await request(app).get('/api/totp/check-session').set('X-Forwarded-For', '127.0.0.1');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ authenticated: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// POST /api/totp/disable
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
describe('POST /api/totp/disable', () => {
|
||||
async function setupTOTP() {
|
||||
const { secret, token } = freshSecret();
|
||||
await request(app).post('/api/totp/setup').send({ secret });
|
||||
await request(app).post('/api/totp/verify-setup').send({ code: token });
|
||||
jest.clearAllMocks();
|
||||
return secret;
|
||||
}
|
||||
|
||||
it('returns 400 when TOTP is active but no code is provided', async () => {
|
||||
await setupTOTP();
|
||||
const res = await request(app).post('/api/totp/disable').send({});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/valid TOTP code is required/);
|
||||
});
|
||||
|
||||
it('returns 401 when code is wrong', async () => {
|
||||
await setupTOTP();
|
||||
const res = await request(app).post('/api/totp/disable').send({ code: '000000' });
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.error).toMatch(/DC-111/);
|
||||
});
|
||||
|
||||
it('returns 200 + clears TOTP state on valid code', async () => {
|
||||
const secret = await setupTOTP();
|
||||
const code = authenticator.generate(secret);
|
||||
const res = await request(app).post('/api/totp/disable').send({ code });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
// TOTP disabled, secrets cleared, session cleared
|
||||
expect(deps.totpConfig.enabled).toBe(false);
|
||||
expect(deps.totpConfig.isSetUp).toBe(false);
|
||||
expect(deps.totpConfig.sessionDuration).toBe('never');
|
||||
expect(await deps.credentialManager.retrieve('totp.secret')).toBeNull();
|
||||
expect(await deps.credentialManager.retrieve('totp.pending_secret')).toBeNull();
|
||||
expect(deps.session.clear).toHaveBeenCalled();
|
||||
expect(deps.session.clearCookie).toHaveBeenCalled();
|
||||
expect(deps.saveTotpConfig).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// POST /api/totp/config (session duration change)
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
describe('POST /api/totp/config (update settings)', () => {
|
||||
it('updates sessionDuration with a valid value', async () => {
|
||||
const res = await request(app).post('/api/totp/config').send({ sessionDuration: '7d' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.config.sessionDuration).toBe('7d');
|
||||
expect(deps.saveTotpConfig).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an invalid sessionDuration', async () => {
|
||||
const res = await request(app).post('/api/totp/config').send({ sessionDuration: '99y' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/Invalid session duration/);
|
||||
});
|
||||
|
||||
it('setting sessionDuration to "never" disables TOTP', async () => {
|
||||
deps.totpConfig.enabled = true;
|
||||
deps.totpConfig.isSetUp = true;
|
||||
const res = await request(app).post('/api/totp/config').send({ sessionDuration: 'never' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(deps.totpConfig.sessionDuration).toBe('never');
|
||||
expect(deps.totpConfig.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// End-to-end flow (BACKLOG: "Cover the full /api/auth/check → session → endpoint flow")
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
describe('End-to-end: setup → login → check-session → disable', () => {
|
||||
it('walks the full BACKLOG DC-006 flow', async () => {
|
||||
// 1. Setup — generate a fresh secret
|
||||
const setupRes = await request(app).post('/api/totp/setup').send({});
|
||||
expect(setupRes.status).toBe(200);
|
||||
const secret = setupRes.body.manualKey;
|
||||
const setupCode = authenticator.generate(secret);
|
||||
|
||||
// 2. Verify-setup — activate TOTP
|
||||
const verifySetupRes = await request(app).post('/api/totp/verify-setup').send({ code: setupCode });
|
||||
expect(verifySetupRes.status).toBe(200);
|
||||
expect(deps.totpConfig.isSetUp).toBe(true);
|
||||
|
||||
// 3. Simulate session expiry by clearing the store
|
||||
deps.session.ipSessions.clear();
|
||||
|
||||
// 4. Re-login via /totp/verify (the "login" path)
|
||||
const loginCode = authenticator.generate(secret);
|
||||
const loginRes = await request(app).post('/api/totp/verify').send({ code: loginCode });
|
||||
expect(loginRes.status).toBe(200);
|
||||
expect(loginRes.body.csrfToken).toBeDefined();
|
||||
|
||||
// 5. Check-session — should now be authenticated (the BACKLOG "→ endpoint succeeds" step)
|
||||
const checkRes = await request(app).get('/api/totp/check-session');
|
||||
expect(checkRes.status).toBe(200);
|
||||
expect(checkRes.body).toEqual({ authenticated: true });
|
||||
|
||||
// 6. Logout / disable
|
||||
const disableCode = authenticator.generate(secret);
|
||||
const disableRes = await request(app).post('/api/totp/disable').send({ code: disableCode });
|
||||
expect(disableRes.status).toBe(200);
|
||||
|
||||
// 7. After disable, check-session should be passthrough (TOTP off)
|
||||
const afterRes = await request(app).get('/api/totp/check-session');
|
||||
expect(afterRes.status).toBe(200);
|
||||
expect(afterRes.body).toEqual({ authenticated: true });
|
||||
});
|
||||
|
||||
it('proves otplib is real (not stubbed) by using a totally bogus code', async () => {
|
||||
// Sanity check that the test harness is using real otplib, not a stub.
|
||||
// otplib 12.0.1's authenticator.generate(secret) does not accept a {time} option
|
||||
// (the signature is fixed to current-time TOTP), so a "stale code" test isn't
|
||||
// reproducible across runs. Instead, we verify otplib rejects a code that is
|
||||
// syntactically valid (6 digits) but doesn't match the live TOTP slot.
|
||||
const secret = authenticator.generateSecret();
|
||||
await request(app).post('/api/totp/setup').send({ secret });
|
||||
// Generate the real current code, then mutate it — must be rejected
|
||||
const realCode = authenticator.generate(secret);
|
||||
const tampered = realCode === '000000' ? '111111' : '000000';
|
||||
const res = await request(app).post('/api/totp/verify-setup').send({ code: tampered });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,537 @@
|
||||
// Container Routes Tests
|
||||
// Validates container lifecycle operations (start/stop/restart/update/delete/discover)
|
||||
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
// Build a test app with the containers route
|
||||
function buildApp(mockDeps) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
const { errorMiddleware } = require('../../src/utilities/error-handler');
|
||||
const containersRouteFactory = require('../../routes/containers');
|
||||
app.use('/api/containers', containersRouteFactory(mockDeps));
|
||||
app.use(errorMiddleware);
|
||||
return app;
|
||||
}
|
||||
|
||||
// Mock container factory
|
||||
function mockContainer(overrides = {}) {
|
||||
return {
|
||||
inspect: jest.fn().mockResolvedValue({
|
||||
Id: 'abc123def456',
|
||||
Name: '/plex',
|
||||
Config: {
|
||||
Image: 'lscr.io/linuxserver/plex:latest',
|
||||
Env: ['TZ=America/New_York', 'PLEX_CLAIM='],
|
||||
ExposedPorts: { '32400/tcp': {} },
|
||||
Labels: { 'sami.managed': 'true', 'sami.app': 'plex', 'sami.subdomain': 'plex' }
|
||||
},
|
||||
Image: 'sha256:abc123',
|
||||
HostConfig: {
|
||||
Binds: ['E:/dockerdata/plex:/config'],
|
||||
PortBindings: { '32400/tcp': [{ HostPort: '32400' }] },
|
||||
RestartPolicy: { Name: 'unless-stopped' },
|
||||
NetworkMode: 'bridge',
|
||||
ExtraHosts: [],
|
||||
Privileged: false,
|
||||
CapAdd: null,
|
||||
CapDrop: null,
|
||||
Devices: [],
|
||||
LogConfig: { Type: 'json-file', Config: { 'max-size': '10m', 'max-file': '3' } },
|
||||
Memory: 2147483648, // 2GB
|
||||
MemoryReservation: 1073741824, // 1GB
|
||||
NanoCpus: 2000000000, // 2 cores
|
||||
},
|
||||
NetworkSettings: { Networks: { bridge: {} } }
|
||||
}),
|
||||
start: jest.fn().mockResolvedValue(),
|
||||
stop: jest.fn().mockResolvedValue(),
|
||||
restart: jest.fn().mockResolvedValue(),
|
||||
remove: jest.fn().mockResolvedValue(),
|
||||
update: jest.fn().mockResolvedValue(),
|
||||
logs: jest.fn().mockResolvedValue(Buffer.from('2026-04-05T10:00:00Z Plex server started')),
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function createMockDeps(containerInstance) {
|
||||
const container = containerInstance || mockContainer();
|
||||
|
||||
return {
|
||||
docker: {
|
||||
client: {
|
||||
getContainer: jest.fn().mockReturnValue(container),
|
||||
createContainer: jest.fn().mockResolvedValue({
|
||||
start: jest.fn().mockResolvedValue(),
|
||||
inspect: jest.fn().mockResolvedValue({ Id: 'new123' }),
|
||||
remove: jest.fn().mockResolvedValue(),
|
||||
}),
|
||||
getImage: jest.fn().mockReturnValue({
|
||||
inspect: jest.fn().mockResolvedValue({ RepoDigests: ['sha256:olddigest'] })
|
||||
}),
|
||||
listContainers: jest.fn().mockResolvedValue([]),
|
||||
pruneImages: jest.fn().mockResolvedValue({ SpaceReclaimed: 0 }),
|
||||
},
|
||||
pull: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
log: {
|
||||
info: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
},
|
||||
asyncHandler: (fn, name) => async (req, res, next) => {
|
||||
try { await fn(req, res, next); } catch (err) { next(err); }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('Container Routes — DashCaddy container lifecycle', () => {
|
||||
|
||||
describe('POST /:id/start', () => {
|
||||
it('starts a stopped container', async () => {
|
||||
const deps = createMockDeps();
|
||||
const app = buildApp(deps);
|
||||
const res = await request(app).post('/api/containers/abc123/start');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.message).toContain('started');
|
||||
});
|
||||
|
||||
it('returns 404 for missing container', async () => {
|
||||
const container = mockContainer();
|
||||
const notFound = new Error('no such container');
|
||||
notFound.statusCode = 404;
|
||||
container.inspect.mockRejectedValue(notFound);
|
||||
const deps = createMockDeps(container);
|
||||
const app = buildApp(deps);
|
||||
|
||||
const res = await request(app).post('/api/containers/missing123/start');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /:id/stop', () => {
|
||||
it('stops a running container', async () => {
|
||||
const deps = createMockDeps();
|
||||
const app = buildApp(deps);
|
||||
const res = await request(app).post('/api/containers/abc123/stop');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.message).toContain('stopped');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /:id/restart', () => {
|
||||
it('restarts a container', async () => {
|
||||
const deps = createMockDeps();
|
||||
const app = buildApp(deps);
|
||||
const res = await request(app).post('/api/containers/abc123/restart');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.message).toContain('restarted');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /:id/logs', () => {
|
||||
it('returns last 100 log lines', async () => {
|
||||
const deps = createMockDeps();
|
||||
const app = buildApp(deps);
|
||||
const res = await request(app).get('/api/containers/abc123/logs');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.logs).toContain('Plex server started');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /:id/resources', () => {
|
||||
it('updates memory and CPU limits', async () => {
|
||||
const container = mockContainer();
|
||||
const deps = createMockDeps(container);
|
||||
const app = buildApp(deps);
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/containers/abc123/resources')
|
||||
.send({ memory: 4096, cpus: 4 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(container.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
Memory: 4096 * 1024 * 1024,
|
||||
NanoCpus: 4 * 1e9,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('sets 0 for unlimited', async () => {
|
||||
const container = mockContainer();
|
||||
const deps = createMockDeps(container);
|
||||
const app = buildApp(deps);
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/containers/abc123/resources')
|
||||
.send({ memory: 0, cpus: 0 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(container.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
Memory: 0,
|
||||
NanoCpus: 0,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /:id/resources', () => {
|
||||
it('returns current resource limits in human units', async () => {
|
||||
const deps = createMockDeps();
|
||||
const app = buildApp(deps);
|
||||
|
||||
const res = await request(app).get('/api/containers/abc123/resources');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.memory).toBe(2048); // 2GB in MB
|
||||
expect(res.body.memoryReservation).toBe(1024); // 1GB in MB
|
||||
expect(res.body.cpus).toBe(2); // 2 cores
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /:id', () => {
|
||||
it('force-removes a container', async () => {
|
||||
const container = mockContainer();
|
||||
const deps = createMockDeps(container);
|
||||
const app = buildApp(deps);
|
||||
|
||||
const res = await request(app).delete('/api/containers/abc123');
|
||||
expect(res.status).toBe(200);
|
||||
expect(container.remove).toHaveBeenCalledWith({ force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /discover', () => {
|
||||
it('returns only sami.managed containers', async () => {
|
||||
const deps = createMockDeps();
|
||||
deps.docker.client.listContainers.mockResolvedValue([
|
||||
{
|
||||
Id: 'abc123', Names: ['/plex'], Image: 'linuxserver/plex',
|
||||
State: 'running', Status: 'Up 3 days',
|
||||
Labels: { 'sami.managed': 'true', 'sami.app': 'plex', 'sami.subdomain': 'plex' },
|
||||
Ports: [{ PrivatePort: 32400, PublicPort: 32400 }]
|
||||
},
|
||||
{
|
||||
Id: 'xyz789', Names: ['/random-container'], Image: 'nginx',
|
||||
State: 'running', Status: 'Up 1 hour',
|
||||
Labels: {},
|
||||
Ports: [{ PrivatePort: 80, PublicPort: 80 }]
|
||||
}
|
||||
]);
|
||||
const app = buildApp(deps);
|
||||
|
||||
const res = await request(app).get('/api/containers/discover');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.containers).toHaveLength(1);
|
||||
expect(res.body.containers[0].appTemplate).toBe('plex');
|
||||
});
|
||||
|
||||
it('returns empty array when no managed containers', async () => {
|
||||
const deps = createMockDeps();
|
||||
const app = buildApp(deps);
|
||||
const res = await request(app).get('/api/containers/discover');
|
||||
expect(res.body.containers).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /:id/update — error and edge cases', () => {
|
||||
it('preserves custom network mode (non-bridge/host/none)', async () => {
|
||||
const container = mockContainer();
|
||||
container.inspect.mockResolvedValue({
|
||||
Id: 'abc123', Name: '/plex',
|
||||
Config: { Image: 'plex:latest', Env: [], ExposedPorts: {}, Labels: {} },
|
||||
Image: 'sha256:abc',
|
||||
HostConfig: {
|
||||
Binds: [], PortBindings: {}, RestartPolicy: { Name: 'unless-stopped' },
|
||||
NetworkMode: 'my-custom-network',
|
||||
ExtraHosts: [], Privileged: false, CapAdd: null, CapDrop: null, Devices: []
|
||||
},
|
||||
NetworkSettings: { Networks: { 'my-custom-network': { IPAddress: '172.20.0.5' } } }
|
||||
});
|
||||
const newContainer = {
|
||||
start: jest.fn().mockResolvedValue(),
|
||||
inspect: jest.fn().mockResolvedValue({ Id: 'new123' })
|
||||
};
|
||||
const deps = createMockDeps(container);
|
||||
deps.docker.client.createContainer.mockResolvedValue(newContainer);
|
||||
|
||||
const app = buildApp(deps);
|
||||
const res = await request(app).post('/api/containers/abc123/update');
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const createCall = deps.docker.client.createContainer.mock.calls[0][0];
|
||||
expect(createCall.NetworkingConfig.EndpointsConfig['my-custom-network'])
|
||||
.toEqual({ IPAddress: '172.20.0.5' });
|
||||
});
|
||||
|
||||
it('cleans up failed new container when start fails', async () => {
|
||||
const container = mockContainer();
|
||||
const newContainer = {
|
||||
start: jest.fn().mockRejectedValue(new Error('port already allocated')),
|
||||
remove: jest.fn().mockResolvedValue()
|
||||
};
|
||||
const deps = createMockDeps(container);
|
||||
deps.docker.client.createContainer.mockResolvedValue(newContainer);
|
||||
|
||||
const app = buildApp(deps);
|
||||
const res = await request(app).post('/api/containers/abc123/update');
|
||||
|
||||
expect(res.status).toBeGreaterThanOrEqual(500);
|
||||
expect(newContainer.remove).toHaveBeenCalledWith({ force: true });
|
||||
});
|
||||
|
||||
it('handles new container remove cleanup failure gracefully', async () => {
|
||||
const container = mockContainer();
|
||||
const newContainer = {
|
||||
start: jest.fn().mockRejectedValue(new Error('start failed')),
|
||||
remove: jest.fn().mockRejectedValue(new Error('already gone'))
|
||||
};
|
||||
const deps = createMockDeps(container);
|
||||
deps.docker.client.createContainer.mockResolvedValue(newContainer);
|
||||
|
||||
const app = buildApp(deps);
|
||||
const res = await request(app).post('/api/containers/abc123/update');
|
||||
expect(res.status).toBeGreaterThanOrEqual(500);
|
||||
});
|
||||
|
||||
it('logs space reclaimed when image prune frees disk', async () => {
|
||||
const container = mockContainer();
|
||||
const deps = createMockDeps(container);
|
||||
deps.docker.client.pruneImages.mockResolvedValue({ SpaceReclaimed: 50 * 1024 * 1024 }); // 50MB
|
||||
|
||||
const app = buildApp(deps);
|
||||
const res = await request(app).post('/api/containers/abc123/update');
|
||||
expect(res.status).toBe(200);
|
||||
expect(deps.log.info).toHaveBeenCalledWith(
|
||||
'docker',
|
||||
'Pruned dangling images after update',
|
||||
expect.objectContaining({ spaceReclaimed: '50MB' })
|
||||
);
|
||||
});
|
||||
|
||||
it('continues if image prune fails', async () => {
|
||||
const container = mockContainer();
|
||||
const deps = createMockDeps(container);
|
||||
deps.docker.client.pruneImages.mockRejectedValue(new Error('prune failed'));
|
||||
|
||||
const app = buildApp(deps);
|
||||
const res = await request(app).post('/api/containers/abc123/update');
|
||||
expect(res.status).toBe(200);
|
||||
expect(deps.log.debug).toHaveBeenCalledWith(
|
||||
'docker',
|
||||
'Image prune after update failed',
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores already-stopped error when stopping container', async () => {
|
||||
const container = mockContainer();
|
||||
container.stop.mockRejectedValue(new Error('container already stopped'));
|
||||
const deps = createMockDeps(container);
|
||||
|
||||
const app = buildApp(deps);
|
||||
const res = await request(app).post('/api/containers/abc123/update');
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /:id/check-update', () => {
|
||||
it('reports no updates when local and new digests match', async () => {
|
||||
const container = mockContainer();
|
||||
const deps = createMockDeps(container);
|
||||
deps.docker.client.getImage.mockReturnValue({
|
||||
inspect: jest.fn().mockResolvedValue({ RepoDigests: ['sha256:samedigest'] })
|
||||
});
|
||||
deps.docker.pull.mockResolvedValue([]);
|
||||
|
||||
const app = buildApp(deps);
|
||||
const res = await request(app).get('/api/containers/abc123/check-update');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.updateAvailable).toBe(false);
|
||||
});
|
||||
|
||||
it('reports update available when downloads occur', async () => {
|
||||
const container = mockContainer();
|
||||
const deps = createMockDeps(container);
|
||||
deps.docker.pull.mockResolvedValue([
|
||||
{ status: 'Downloading', id: 'layer1' },
|
||||
{ status: 'Download complete', id: 'layer2' }
|
||||
]);
|
||||
|
||||
const app = buildApp(deps);
|
||||
const res = await request(app).get('/api/containers/abc123/check-update');
|
||||
expect(res.body.updateAvailable).toBe(true);
|
||||
});
|
||||
|
||||
it('reports update available when digests differ', async () => {
|
||||
const container = mockContainer();
|
||||
const deps = createMockDeps(container);
|
||||
let callCount = 0;
|
||||
deps.docker.client.getImage.mockImplementation(() => {
|
||||
callCount++;
|
||||
return {
|
||||
inspect: jest.fn().mockResolvedValue({
|
||||
RepoDigests: callCount === 1
|
||||
? ['sha256:olddigest']
|
||||
: ['sha256:newdigest']
|
||||
})
|
||||
};
|
||||
});
|
||||
deps.docker.pull.mockResolvedValue([]);
|
||||
|
||||
const app = buildApp(deps);
|
||||
const res = await request(app).get('/api/containers/abc123/check-update');
|
||||
expect(res.body.updateAvailable).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when pull throws (registry unreachable)', async () => {
|
||||
const container = mockContainer();
|
||||
const deps = createMockDeps(container);
|
||||
deps.docker.pull.mockRejectedValue(new Error('registry timeout'));
|
||||
|
||||
const app = buildApp(deps);
|
||||
const res = await request(app).get('/api/containers/abc123/check-update');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.updateAvailable).toBe(false);
|
||||
});
|
||||
|
||||
it('handles missing local repo digests gracefully', async () => {
|
||||
const container = mockContainer();
|
||||
const deps = createMockDeps(container);
|
||||
deps.docker.client.getImage.mockReturnValue({
|
||||
inspect: jest.fn().mockResolvedValue({ RepoDigests: null })
|
||||
});
|
||||
|
||||
const app = buildApp(deps);
|
||||
const res = await request(app).get('/api/containers/abc123/check-update');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.currentDigest).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVerifiedContainer error paths', () => {
|
||||
it('returns 404 when error message includes "no such container"', async () => {
|
||||
const container = mockContainer();
|
||||
container.inspect.mockRejectedValue(new Error('Error: no such container: missing'));
|
||||
const deps = createMockDeps(container);
|
||||
const app = buildApp(deps);
|
||||
|
||||
const res = await request(app).post('/api/containers/missing/start');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('rethrows non-404 errors from inspect', async () => {
|
||||
const container = mockContainer();
|
||||
container.inspect.mockRejectedValue(new Error('docker daemon not running'));
|
||||
const deps = createMockDeps(container);
|
||||
const app = buildApp(deps);
|
||||
|
||||
const res = await request(app).post('/api/containers/abc123/start');
|
||||
expect(res.status).toBeGreaterThanOrEqual(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /:id/resources — partial updates', () => {
|
||||
it('updates only memory when cpus omitted', async () => {
|
||||
const container = mockContainer();
|
||||
const deps = createMockDeps(container);
|
||||
const app = buildApp(deps);
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/containers/abc123/resources')
|
||||
.send({ memory: 2048 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const call = container.update.mock.calls[0][0];
|
||||
expect(call.Memory).toBe(2048 * 1024 * 1024);
|
||||
expect(call.NanoCpus).toBeUndefined();
|
||||
});
|
||||
|
||||
it('updates only cpus when memory omitted', async () => {
|
||||
const container = mockContainer();
|
||||
const deps = createMockDeps(container);
|
||||
const app = buildApp(deps);
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/containers/abc123/resources')
|
||||
.send({ cpus: 1.5 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const call = container.update.mock.calls[0][0];
|
||||
expect(call.NanoCpus).toBe(1.5 * 1e9);
|
||||
expect(call.Memory).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /:id/resources — zero values', () => {
|
||||
it('returns 0 when no limits set', async () => {
|
||||
const container = mockContainer();
|
||||
container.inspect.mockResolvedValue({
|
||||
Id: 'abc', Name: '/test', Config: { Image: 'test:latest' },
|
||||
HostConfig: { Memory: 0, MemoryReservation: 0, NanoCpus: 0 }
|
||||
});
|
||||
const deps = createMockDeps(container);
|
||||
const app = buildApp(deps);
|
||||
|
||||
const res = await request(app).get('/api/containers/abc123/resources');
|
||||
expect(res.body.memory).toBe(0);
|
||||
expect(res.body.memoryReservation).toBe(0);
|
||||
expect(res.body.cpus).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /discover — pagination', () => {
|
||||
it('paginates results when paginate query params provided', async () => {
|
||||
const containers = Array.from({ length: 25 }, (_, i) => ({
|
||||
Id: `id${i}`,
|
||||
Names: [`/svc${i}`],
|
||||
Image: 'test:latest',
|
||||
State: 'running',
|
||||
Status: 'Up',
|
||||
Labels: { 'sami.managed': 'true', 'sami.app': 'test', 'sami.subdomain': `svc${i}` },
|
||||
Ports: []
|
||||
}));
|
||||
const deps = createMockDeps();
|
||||
deps.docker.client.listContainers.mockResolvedValue(containers);
|
||||
const app = buildApp(deps);
|
||||
|
||||
const res = await request(app).get('/api/containers/discover?page=1&limit=10');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.containers.length).toBeLessThanOrEqual(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashCaddy-specific scenarios', () => {
|
||||
it('Plex container: verifies correct resource read (2GB, 2 cores)', async () => {
|
||||
const deps = createMockDeps();
|
||||
const app = buildApp(deps);
|
||||
const res = await request(app).get('/api/containers/abc123/resources');
|
||||
expect(res.body.memory).toBe(2048);
|
||||
expect(res.body.cpus).toBe(2);
|
||||
});
|
||||
|
||||
it('container update: preserves Env, PortBindings, RestartPolicy', async () => {
|
||||
const container = mockContainer();
|
||||
const newContainer = {
|
||||
start: jest.fn().mockResolvedValue(),
|
||||
inspect: jest.fn().mockResolvedValue({ Id: 'new456' }),
|
||||
remove: jest.fn().mockResolvedValue(),
|
||||
};
|
||||
const deps = createMockDeps(container);
|
||||
deps.docker.client.createContainer.mockResolvedValue(newContainer);
|
||||
const app = buildApp(deps);
|
||||
|
||||
const res = await request(app).post('/api/containers/abc123/update');
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const createCall = deps.docker.client.createContainer.mock.calls[0][0];
|
||||
expect(createCall.Env).toContain('TZ=America/New_York');
|
||||
expect(createCall.HostConfig.PortBindings['32400/tcp']).toEqual([{ HostPort: '32400' }]);
|
||||
expect(createCall.HostConfig.RestartPolicy).toEqual({ Name: 'unless-stopped' });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,663 @@
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
// Minimal asyncHandler that catches errors
|
||||
function asyncHandler(fn) {
|
||||
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
}
|
||||
|
||||
function createApp(depsOverride = {}) {
|
||||
const defaultDeps = {
|
||||
fetchT: jest.fn().mockResolvedValue({ ok: true, status: 200, json: () => ({}) }),
|
||||
SERVICES_FILE: '/tmp/services.json',
|
||||
servicesStateManager: {
|
||||
read: jest.fn().mockResolvedValue([]),
|
||||
write: jest.fn().mockResolvedValue(),
|
||||
update: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
siteConfig: { tld: 'sami' },
|
||||
buildServiceUrl: jest.fn(id => `https://${id}.sami`),
|
||||
asyncHandler,
|
||||
logError: jest.fn(),
|
||||
healthChecker: {
|
||||
getCurrentStatus: jest.fn().mockReturnValue({}),
|
||||
getServiceStats: jest.fn().mockReturnValue(null),
|
||||
configureService: jest.fn(),
|
||||
removeService: jest.fn(),
|
||||
getOpenIncidents: jest.fn().mockReturnValue([]),
|
||||
getIncidentHistory: jest.fn().mockReturnValue([]),
|
||||
},
|
||||
};
|
||||
|
||||
const deps = { ...defaultDeps, ...depsOverride };
|
||||
const healthRoutes = require('../../routes/health');
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api', healthRoutes(deps));
|
||||
// Simple error handler
|
||||
app.use((err, req, res, next) => {
|
||||
const status = err.statusCode || 500;
|
||||
res.status(status).json({ success: false, error: err.message });
|
||||
});
|
||||
return { app, deps };
|
||||
}
|
||||
|
||||
jest.mock('child_process', () => ({
|
||||
execSync: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../platform-paths', () => ({
|
||||
caCertDir: '/mock/ca',
|
||||
pkiRootCert: '/mock/pki/root.crt',
|
||||
}));
|
||||
|
||||
// Mock fs-helpers.exists
|
||||
jest.mock('../../src/utilities/fs-helpers', () => ({
|
||||
exists: jest.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utilities/url-resolver', () => ({
|
||||
resolveServiceUrl: jest.fn((id) => `https://${id}.test`),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utilities/pagination', () => ({
|
||||
paginate: jest.fn((data, params) => ({ data, pagination: null })),
|
||||
parsePaginationParams: jest.fn(() => null),
|
||||
}));
|
||||
|
||||
const { exists } = require('../../src/utilities/fs-helpers');
|
||||
const { resolveServiceUrl } = require('../../src/utilities/url-resolver');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
describe('Health Routes', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
exists.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
describe('GET /api/health/cached', () => {
|
||||
it('returns cached health data with 200', async () => {
|
||||
const { app } = createApp();
|
||||
const res = await request(app).get('/api/health/cached');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body).toHaveProperty('health');
|
||||
expect(res.body).toHaveProperty('lastCheck');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/health/services', () => {
|
||||
it('returns empty health when no services file', async () => {
|
||||
exists.mockResolvedValue(false);
|
||||
const { app } = createApp();
|
||||
const res = await request(app).get('/api/health/services');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.health).toEqual({});
|
||||
});
|
||||
|
||||
it('returns health for each service', async () => {
|
||||
const stateManager = {
|
||||
read: jest.fn().mockResolvedValue([
|
||||
{ id: 'plex', name: 'Plex' },
|
||||
{ id: 'radarr', name: 'Radarr' },
|
||||
]),
|
||||
};
|
||||
const fetchT = jest.fn().mockResolvedValue({
|
||||
ok: true, status: 200, json: () => ({})
|
||||
});
|
||||
const { app } = createApp({
|
||||
servicesStateManager: stateManager,
|
||||
fetchT,
|
||||
});
|
||||
const res = await request(app).get('/api/health/services');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body).toHaveProperty('checkedAt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/health/service/:id', () => {
|
||||
it('returns 404 when services file missing', async () => {
|
||||
exists.mockResolvedValue(false);
|
||||
const { app } = createApp();
|
||||
const res = await request(app).get('/api/health/service/plex');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('returns 404 when service not found', async () => {
|
||||
const stateManager = {
|
||||
read: jest.fn().mockResolvedValue([{ id: 'radarr', name: 'Radarr' }]),
|
||||
};
|
||||
const { app } = createApp({ servicesStateManager: stateManager });
|
||||
const res = await request(app).get('/api/health/service/nonexistent');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('returns health for existing service', async () => {
|
||||
const stateManager = {
|
||||
read: jest.fn().mockResolvedValue([{ id: 'plex', name: 'Plex' }]),
|
||||
};
|
||||
const fetchT = jest.fn().mockResolvedValue({
|
||||
ok: true, status: 200, json: () => ({})
|
||||
});
|
||||
const { app } = createApp({
|
||||
servicesStateManager: stateManager,
|
||||
fetchT,
|
||||
});
|
||||
const res = await request(app).get('/api/health/service/plex');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.serviceId).toBe('plex');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/health/pylon', () => {
|
||||
it('returns configured:false when no pylon', async () => {
|
||||
const { app } = createApp({ siteConfig: {} });
|
||||
const res = await request(app).get('/api/health/pylon');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.configured).toBe(false);
|
||||
});
|
||||
|
||||
it('returns reachable:true when pylon responds', async () => {
|
||||
const fetchT = jest.fn().mockResolvedValue({
|
||||
ok: true, status: 200, json: () => ({ status: 'ok' })
|
||||
});
|
||||
const { app } = createApp({
|
||||
siteConfig: { pylon: { url: 'http://pylon.test' } },
|
||||
fetchT,
|
||||
});
|
||||
const res = await request(app).get('/api/health/pylon');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.configured).toBe(true);
|
||||
expect(res.body.reachable).toBe(true);
|
||||
});
|
||||
|
||||
it('returns reachable:false when pylon errors', async () => {
|
||||
const fetchT = jest.fn().mockRejectedValue(new Error('Connection refused'));
|
||||
const { app } = createApp({
|
||||
siteConfig: { pylon: { url: 'http://pylon.test' } },
|
||||
fetchT,
|
||||
});
|
||||
const res = await request(app).get('/api/health/pylon');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.configured).toBe(true);
|
||||
expect(res.body.reachable).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/health-checks/status', () => {
|
||||
it('returns current health checker status', async () => {
|
||||
const healthChecker = {
|
||||
getCurrentStatus: jest.fn().mockReturnValue({
|
||||
svc1: { status: 'up', responseTime: 100 }
|
||||
}),
|
||||
getServiceStats: jest.fn(),
|
||||
configureService: jest.fn(),
|
||||
removeService: jest.fn(),
|
||||
getOpenIncidents: jest.fn().mockReturnValue([]),
|
||||
getIncidentHistory: jest.fn().mockReturnValue([]),
|
||||
};
|
||||
const { app } = createApp({ healthChecker });
|
||||
const res = await request(app).get('/api/health-checks/status');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.status.svc1.status).toBe('up');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/health-checks/:serviceId/stats', () => {
|
||||
it('returns 404 when service not found', async () => {
|
||||
const { app } = createApp();
|
||||
const res = await request(app).get('/api/health-checks/unknown/stats');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('returns stats when service exists', async () => {
|
||||
const healthChecker = {
|
||||
getCurrentStatus: jest.fn().mockReturnValue({}),
|
||||
getServiceStats: jest.fn().mockReturnValue({
|
||||
totalChecks: 100, uptime: 99.5
|
||||
}),
|
||||
configureService: jest.fn(),
|
||||
removeService: jest.fn(),
|
||||
getOpenIncidents: jest.fn().mockReturnValue([]),
|
||||
getIncidentHistory: jest.fn().mockReturnValue([]),
|
||||
};
|
||||
const { app } = createApp({ healthChecker });
|
||||
const res = await request(app).get('/api/health-checks/svc1/stats');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.stats.uptime).toBe(99.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/health-checks/:serviceId/configure', () => {
|
||||
it('configures health check for service', async () => {
|
||||
const healthChecker = {
|
||||
getCurrentStatus: jest.fn().mockReturnValue({}),
|
||||
getServiceStats: jest.fn(),
|
||||
configureService: jest.fn(),
|
||||
removeService: jest.fn(),
|
||||
getOpenIncidents: jest.fn().mockReturnValue([]),
|
||||
getIncidentHistory: jest.fn().mockReturnValue([]),
|
||||
};
|
||||
const { app } = createApp({ healthChecker });
|
||||
const res = await request(app)
|
||||
.post('/api/health-checks/svc1/configure')
|
||||
.send({ url: 'http://test.local', timeout: 5000 });
|
||||
expect(res.status).toBe(200);
|
||||
expect(healthChecker.configureService).toHaveBeenCalledWith('svc1', expect.objectContaining({ url: 'http://test.local' }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/health-checks/:serviceId/configure', () => {
|
||||
it('removes health check configuration', async () => {
|
||||
const healthChecker = {
|
||||
getCurrentStatus: jest.fn().mockReturnValue({}),
|
||||
getServiceStats: jest.fn(),
|
||||
configureService: jest.fn(),
|
||||
removeService: jest.fn(),
|
||||
getOpenIncidents: jest.fn().mockReturnValue([]),
|
||||
getIncidentHistory: jest.fn().mockReturnValue([]),
|
||||
};
|
||||
const { app } = createApp({ healthChecker });
|
||||
const res = await request(app).delete('/api/health-checks/svc1/configure');
|
||||
expect(res.status).toBe(200);
|
||||
expect(healthChecker.removeService).toHaveBeenCalledWith('svc1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/health-checks/incidents', () => {
|
||||
it('returns open incidents', async () => {
|
||||
const healthChecker = {
|
||||
getCurrentStatus: jest.fn().mockReturnValue({}),
|
||||
getServiceStats: jest.fn(),
|
||||
configureService: jest.fn(),
|
||||
removeService: jest.fn(),
|
||||
getOpenIncidents: jest.fn().mockReturnValue([
|
||||
{ id: 'inc-1', serviceId: 'svc1', type: 'outage', status: 'open' }
|
||||
]),
|
||||
getIncidentHistory: jest.fn().mockReturnValue([]),
|
||||
};
|
||||
const { app } = createApp({ healthChecker });
|
||||
const res = await request(app).get('/api/health-checks/incidents');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.incidents).toHaveLength(1);
|
||||
expect(res.body.incidents[0].type).toBe('outage');
|
||||
});
|
||||
});
|
||||
|
||||
// ===== NEW TESTS FOR DEEPER COVERAGE =====
|
||||
|
||||
describe('GET /api/health/services (deeper scenarios)', () => {
|
||||
it('falls back to pylon when direct check fails', async () => {
|
||||
const stateManager = {
|
||||
read: jest.fn().mockResolvedValue([{ id: 'myapp', name: 'MyApp' }]),
|
||||
};
|
||||
// HEAD fails, GET fails, pylon succeeds
|
||||
const fetchT = jest.fn()
|
||||
.mockRejectedValueOnce(new Error('HEAD failed')) // HEAD in checkDirect
|
||||
.mockRejectedValueOnce(new Error('GET failed')) // GET fallback in checkDirect
|
||||
.mockResolvedValueOnce({ // pylon probe call
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => ({ status: 'healthy', statusCode: 200, responseTime: 42 }),
|
||||
});
|
||||
const { app } = createApp({
|
||||
servicesStateManager: stateManager,
|
||||
fetchT,
|
||||
siteConfig: { pylon: { url: 'http://pylon.test' } },
|
||||
});
|
||||
const res = await request(app).get('/api/health/services');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.health.myapp).toBeDefined();
|
||||
expect(res.body.health.myapp.via).toBe('pylon');
|
||||
});
|
||||
|
||||
it('returns unhealthy when both direct and pylon fail', async () => {
|
||||
const stateManager = {
|
||||
read: jest.fn().mockResolvedValue([{ id: 'deadapp', name: 'DeadApp' }]),
|
||||
};
|
||||
const fetchT = jest.fn()
|
||||
.mockRejectedValueOnce(new Error('HEAD failed'))
|
||||
.mockRejectedValueOnce(new Error('GET failed'))
|
||||
.mockRejectedValueOnce(new Error('pylon failed'));
|
||||
const { app } = createApp({
|
||||
servicesStateManager: stateManager,
|
||||
fetchT,
|
||||
siteConfig: { pylon: { url: 'http://pylon.test' } },
|
||||
});
|
||||
const res = await request(app).get('/api/health/services');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.health.deadapp.status).toBe('unhealthy');
|
||||
expect(res.body.health.deadapp.reason).toMatch(/direct \+ pylon/);
|
||||
});
|
||||
|
||||
it('returns unhealthy with "fetch failed" when direct fails and no pylon configured', async () => {
|
||||
const stateManager = {
|
||||
read: jest.fn().mockResolvedValue([{ id: 'deadapp', name: 'DeadApp' }]),
|
||||
};
|
||||
const fetchT = jest.fn()
|
||||
.mockRejectedValueOnce(new Error('HEAD failed'))
|
||||
.mockRejectedValueOnce(new Error('GET failed'));
|
||||
const { app } = createApp({
|
||||
servicesStateManager: stateManager,
|
||||
fetchT,
|
||||
siteConfig: {}, // no pylon
|
||||
});
|
||||
const res = await request(app).get('/api/health/services');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.health.deadapp.status).toBe('unhealthy');
|
||||
expect(res.body.health.deadapp.reason).toBe('fetch failed');
|
||||
});
|
||||
|
||||
it('skips services without id or name', async () => {
|
||||
const stateManager = {
|
||||
read: jest.fn().mockResolvedValue([
|
||||
{ id: 'valid', name: 'Valid' },
|
||||
{ url: 'http://no-id-or-name.test' }, // no id, no name
|
||||
]),
|
||||
};
|
||||
const fetchT = jest.fn().mockResolvedValue({ ok: true, status: 200, json: () => ({}) });
|
||||
const { app } = createApp({
|
||||
servicesStateManager: stateManager,
|
||||
fetchT,
|
||||
});
|
||||
const res = await request(app).get('/api/health/services');
|
||||
expect(res.status).toBe(200);
|
||||
// Only the valid service should appear
|
||||
expect(Object.keys(res.body.health)).toEqual(['valid']);
|
||||
});
|
||||
|
||||
it('returns unknown status when no URL configured for service', async () => {
|
||||
resolveServiceUrl.mockReturnValueOnce(null);
|
||||
const stateManager = {
|
||||
read: jest.fn().mockResolvedValue([{ id: 'nourl', name: 'NoUrl' }]),
|
||||
};
|
||||
const { app } = createApp({
|
||||
servicesStateManager: stateManager,
|
||||
});
|
||||
const res = await request(app).get('/api/health/services');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.health.nourl.status).toBe('unknown');
|
||||
expect(res.body.health.nourl.reason).toBe('No URL configured');
|
||||
});
|
||||
|
||||
it('returns error status when exception occurs during check', async () => {
|
||||
// resolveServiceUrl throws an error
|
||||
resolveServiceUrl.mockImplementationOnce(() => { throw new Error('resolve boom'); });
|
||||
const stateManager = {
|
||||
read: jest.fn().mockResolvedValue([{ id: 'boom', name: 'Boom' }]),
|
||||
};
|
||||
const { app } = createApp({
|
||||
servicesStateManager: stateManager,
|
||||
});
|
||||
const res = await request(app).get('/api/health/services');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.health.boom.status).toBe('error');
|
||||
expect(res.body.health.boom.reason).toBe('resolve boom');
|
||||
});
|
||||
|
||||
it('handles servicesData as object with .services property', async () => {
|
||||
const stateManager = {
|
||||
read: jest.fn().mockResolvedValue({
|
||||
services: [{ id: 'wrapped', name: 'Wrapped' }],
|
||||
}),
|
||||
};
|
||||
const fetchT = jest.fn().mockResolvedValue({ ok: true, status: 200, json: () => ({}) });
|
||||
const { app } = createApp({
|
||||
servicesStateManager: stateManager,
|
||||
fetchT,
|
||||
});
|
||||
const res = await request(app).get('/api/health/services');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.health.wrapped).toBeDefined();
|
||||
expect(res.body.health.wrapped.status).toBe('healthy');
|
||||
});
|
||||
|
||||
it('reports unhealthy when server returns 500+', async () => {
|
||||
const stateManager = {
|
||||
read: jest.fn().mockResolvedValue([{ id: 'err500', name: 'Err500' }]),
|
||||
};
|
||||
const fetchT = jest.fn().mockResolvedValue({ ok: false, status: 502, json: () => ({}) });
|
||||
const { app } = createApp({
|
||||
servicesStateManager: stateManager,
|
||||
fetchT,
|
||||
});
|
||||
const res = await request(app).get('/api/health/services');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.health.err500.status).toBe('unhealthy');
|
||||
expect(res.body.health.err500.statusCode).toBe(502);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/health/service/:id (pylon fallback)', () => {
|
||||
it('falls back to pylon when direct fails', async () => {
|
||||
const stateManager = {
|
||||
read: jest.fn().mockResolvedValue([{ id: 'plex', name: 'Plex' }]),
|
||||
};
|
||||
const fetchT = jest.fn()
|
||||
.mockRejectedValueOnce(new Error('HEAD failed'))
|
||||
.mockRejectedValueOnce(new Error('GET failed'))
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => ({ status: 'healthy', statusCode: 200, responseTime: 55 }),
|
||||
});
|
||||
const { app } = createApp({
|
||||
servicesStateManager: stateManager,
|
||||
fetchT,
|
||||
siteConfig: { pylon: { url: 'http://pylon.test', key: 'secret123' } },
|
||||
});
|
||||
const res = await request(app).get('/api/health/service/plex');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.health.via).toBe('pylon');
|
||||
expect(res.body.health.status).toBe('healthy');
|
||||
// Verify pylon key header was sent
|
||||
const pylonCall = fetchT.mock.calls[2];
|
||||
expect(pylonCall[1].headers['x-pylon-key']).toBe('secret123');
|
||||
});
|
||||
|
||||
it('returns unhealthy when both direct and pylon fail', async () => {
|
||||
const stateManager = {
|
||||
read: jest.fn().mockResolvedValue([{ id: 'plex', name: 'Plex' }]),
|
||||
};
|
||||
const fetchT = jest.fn()
|
||||
.mockRejectedValueOnce(new Error('HEAD failed'))
|
||||
.mockRejectedValueOnce(new Error('GET failed'))
|
||||
.mockRejectedValueOnce(new Error('pylon failed'));
|
||||
const { app } = createApp({
|
||||
servicesStateManager: stateManager,
|
||||
fetchT,
|
||||
siteConfig: { pylon: { url: 'http://pylon.test' } },
|
||||
});
|
||||
const res = await request(app).get('/api/health/service/plex');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.health.status).toBe('unhealthy');
|
||||
expect(res.body.health.reason).toMatch(/direct \+ pylon/);
|
||||
});
|
||||
|
||||
it('returns unhealthy with "fetch failed" when direct fails and no pylon', async () => {
|
||||
const stateManager = {
|
||||
read: jest.fn().mockResolvedValue([{ id: 'plex', name: 'Plex' }]),
|
||||
};
|
||||
const fetchT = jest.fn()
|
||||
.mockRejectedValueOnce(new Error('HEAD failed'))
|
||||
.mockRejectedValueOnce(new Error('GET failed'));
|
||||
const { app } = createApp({
|
||||
servicesStateManager: stateManager,
|
||||
fetchT,
|
||||
siteConfig: {}, // no pylon
|
||||
});
|
||||
const res = await request(app).get('/api/health/service/plex');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.health.status).toBe('unhealthy');
|
||||
expect(res.body.health.reason).toBe('fetch failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/health/probe', () => {
|
||||
it('returns health result when url provided and direct check succeeds', async () => {
|
||||
const fetchT = jest.fn().mockResolvedValue({ ok: true, status: 200 });
|
||||
const { app } = createApp({ fetchT });
|
||||
const res = await request(app).get('/api/health/probe?url=http://example.com');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('healthy');
|
||||
expect(res.body.statusCode).toBe(200);
|
||||
expect(res.body.url).toBe('http://example.com');
|
||||
});
|
||||
|
||||
it('returns unhealthy when direct check completely fails', async () => {
|
||||
const fetchT = jest.fn()
|
||||
.mockRejectedValueOnce(new Error('HEAD failed'))
|
||||
.mockRejectedValueOnce(new Error('GET failed'));
|
||||
const { app } = createApp({ fetchT });
|
||||
const res = await request(app).get('/api/health/probe?url=http://dead.test');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('unhealthy');
|
||||
expect(res.body.reason).toBe('fetch failed');
|
||||
expect(res.body.url).toBe('http://dead.test');
|
||||
});
|
||||
|
||||
it('returns error when no url parameter provided', async () => {
|
||||
const { app } = createApp();
|
||||
const res = await request(app).get('/api/health/probe');
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/health/ca', () => {
|
||||
it('returns healthy when cert has >90 days remaining', async () => {
|
||||
exists.mockResolvedValue(true);
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 365);
|
||||
const dateStr = futureDate.toUTCString();
|
||||
execSync.mockReturnValue(`notBefore=Jan 1 00:00:00 2024 GMT\nnotAfter=${dateStr}`);
|
||||
const { app } = createApp();
|
||||
const res = await request(app).get('/api/health/ca');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.caStatus).toBe('healthy');
|
||||
expect(res.body.daysUntilExpiration).toBeGreaterThan(90);
|
||||
});
|
||||
|
||||
it('returns warning when cert has 30-90 days remaining', async () => {
|
||||
exists.mockResolvedValue(true);
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 60);
|
||||
const dateStr = futureDate.toUTCString();
|
||||
execSync.mockReturnValue(`notBefore=Jan 1 00:00:00 2024 GMT\nnotAfter=${dateStr}`);
|
||||
const { app } = createApp();
|
||||
const res = await request(app).get('/api/health/ca');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.caStatus).toBe('warning');
|
||||
expect(res.body.daysUntilExpiration).toBeLessThan(90);
|
||||
expect(res.body.daysUntilExpiration).toBeGreaterThanOrEqual(30);
|
||||
});
|
||||
|
||||
it('returns critical when cert has <30 days remaining', async () => {
|
||||
exists.mockResolvedValue(true);
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 15);
|
||||
const dateStr = futureDate.toUTCString();
|
||||
execSync.mockReturnValue(`notBefore=Jan 1 00:00:00 2024 GMT\nnotAfter=${dateStr}`);
|
||||
const { app } = createApp();
|
||||
const res = await request(app).get('/api/health/ca');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.caStatus).toBe('critical');
|
||||
expect(res.body.daysUntilExpiration).toBeLessThan(30);
|
||||
expect(res.body.daysUntilExpiration).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('returns critical when cert has <7 days remaining', async () => {
|
||||
exists.mockResolvedValue(true);
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 3);
|
||||
const dateStr = futureDate.toUTCString();
|
||||
execSync.mockReturnValue(`notBefore=Jan 1 00:00:00 2024 GMT\nnotAfter=${dateStr}`);
|
||||
const { app } = createApp();
|
||||
const res = await request(app).get('/api/health/ca');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.caStatus).toBe('critical');
|
||||
expect(res.body.daysUntilExpiration).toBeLessThan(7);
|
||||
});
|
||||
|
||||
it('returns critical when cert is expired', async () => {
|
||||
exists.mockResolvedValue(true);
|
||||
const pastDate = new Date();
|
||||
pastDate.setDate(pastDate.getDate() - 10);
|
||||
const dateStr = pastDate.toUTCString();
|
||||
execSync.mockReturnValue(`notBefore=Jan 1 00:00:00 2024 GMT\nnotAfter=${dateStr}`);
|
||||
const { app } = createApp();
|
||||
const res = await request(app).get('/api/health/ca');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.caStatus).toBe('critical');
|
||||
expect(res.body.daysUntilExpiration).toBeLessThan(0);
|
||||
expect(res.body.message).toMatch(/EXPIRED/);
|
||||
});
|
||||
|
||||
it('returns error when cert file not found', async () => {
|
||||
exists.mockResolvedValue(false);
|
||||
const { app } = createApp();
|
||||
const res = await request(app).get('/api/health/ca');
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.caStatus).toBe('error');
|
||||
expect(res.body.error).toMatch(/not found/);
|
||||
expect(res.body.daysUntilExpiration).toBeNull();
|
||||
});
|
||||
|
||||
it('returns error when execSync throws', async () => {
|
||||
exists.mockResolvedValue(true);
|
||||
execSync.mockImplementation(() => { throw new Error('openssl not found'); });
|
||||
const { app } = createApp();
|
||||
const res = await request(app).get('/api/health/ca');
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.caStatus).toBe('error');
|
||||
expect(res.body.error).toBe('openssl not found');
|
||||
expect(res.body.daysUntilExpiration).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/health-checks/incidents/history', () => {
|
||||
it('returns incident history', async () => {
|
||||
const healthChecker = {
|
||||
getCurrentStatus: jest.fn().mockReturnValue({}),
|
||||
getServiceStats: jest.fn(),
|
||||
configureService: jest.fn(),
|
||||
removeService: jest.fn(),
|
||||
getOpenIncidents: jest.fn().mockReturnValue([]),
|
||||
getIncidentHistory: jest.fn().mockReturnValue([
|
||||
{ id: 'inc-1', serviceId: 'svc1', type: 'outage', resolvedAt: '2025-01-01T00:00:00Z' },
|
||||
{ id: 'inc-2', serviceId: 'svc2', type: 'degraded', resolvedAt: '2025-01-02T00:00:00Z' },
|
||||
]),
|
||||
};
|
||||
const { app } = createApp({ healthChecker });
|
||||
const res = await request(app).get('/api/health-checks/incidents/history');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.history).toHaveLength(2);
|
||||
expect(res.body.history[0].id).toBe('inc-1');
|
||||
expect(res.body.history[1].type).toBe('degraded');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/health/pylon (with key)', () => {
|
||||
it('sends x-pylon-key header when key is configured', async () => {
|
||||
const fetchT = jest.fn().mockResolvedValue({
|
||||
ok: true, status: 200, json: () => ({ status: 'ok' }),
|
||||
});
|
||||
const { app } = createApp({
|
||||
siteConfig: { pylon: { url: 'http://pylon.test', key: 'my-secret-key' } },
|
||||
fetchT,
|
||||
});
|
||||
const res = await request(app).get('/api/health/pylon');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.configured).toBe(true);
|
||||
expect(res.body.reachable).toBe(true);
|
||||
// Verify the x-pylon-key header was sent
|
||||
const fetchCall = fetchT.mock.calls[0];
|
||||
expect(fetchCall[1].headers['x-pylon-key']).toBe('my-secret-key');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,521 @@
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
// ValidationError and NotFoundError are now properly imported in services.js
|
||||
|
||||
// Minimal asyncHandler
|
||||
function asyncHandler(fn) {
|
||||
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
}
|
||||
|
||||
// Mock modules that services.js requires at top-level
|
||||
jest.mock('../../src/utilities/constants', () => ({
|
||||
APP: { USER_AGENTS: { PROBE: 'DashCaddy/1.0' } },
|
||||
REGEX: { SUBDOMAIN: /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/ },
|
||||
TIMEOUTS: { DEFAULT: 10000 },
|
||||
HTTP_STATUS: { OK: 200, CREATED: 201, NO_CONTENT: 204, BAD_REQUEST: 400, UNAUTHORIZED: 401, FORBIDDEN: 403, NOT_FOUND: 404, CONFLICT: 409, INTERNAL_ERROR: 500 }
|
||||
}));
|
||||
|
||||
jest.mock('../../src/security/input-validator', () => ({
|
||||
validateServiceConfig: jest.fn(),
|
||||
isValidPort: jest.fn(p => p >= 1 && p <= 65535),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utilities/fs-helpers', () => ({
|
||||
exists: jest.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utilities/url-resolver', () => ({
|
||||
resolveServiceUrl: jest.fn((id) => `https://${id}.test`),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utilities/pagination', () => ({
|
||||
paginate: jest.fn((data, params) => ({ data, pagination: null })),
|
||||
parsePaginationParams: jest.fn(() => null),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/responses', () => ({
|
||||
success: jest.fn((res, data, statusCode = 200) => {
|
||||
return res.status(statusCode).json({ success: true, ...data });
|
||||
}),
|
||||
error: jest.fn((res, message, statusCode = 500, extra) => {
|
||||
return res.status(statusCode).json({ success: false, error: message, ...extra });
|
||||
}),
|
||||
}));
|
||||
|
||||
// errors module NOT mocked — used for real ValidationError/NotFoundError/ConflictError
|
||||
|
||||
const { exists } = require('../../src/utilities/fs-helpers');
|
||||
const { validateServiceConfig } = require('../../src/security/input-validator');
|
||||
|
||||
function createApp(depsOverride = {}) {
|
||||
const defaultDeps = {
|
||||
servicesStateManager: {
|
||||
read: jest.fn().mockResolvedValue([]),
|
||||
write: jest.fn().mockResolvedValue(),
|
||||
update: jest.fn(async (fn) => {
|
||||
const data = fn([]);
|
||||
return data;
|
||||
}),
|
||||
},
|
||||
credentialManager: {
|
||||
store: jest.fn().mockResolvedValue(true),
|
||||
retrieve: jest.fn().mockResolvedValue(null),
|
||||
delete: jest.fn().mockResolvedValue(true),
|
||||
},
|
||||
siteConfig: { tld: 'sami' },
|
||||
buildServiceUrl: jest.fn(id => `https://${id}.sami`),
|
||||
buildDomain: jest.fn(sub => `${sub}.sami`),
|
||||
fetchT: jest.fn().mockResolvedValue({ ok: true, status: 200, json: () => ({}) }),
|
||||
asyncHandler,
|
||||
SERVICES_FILE: '/tmp/services.json',
|
||||
log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() },
|
||||
safeErrorMessage: jest.fn(err => err.message),
|
||||
resyncHealthChecker: jest.fn().mockResolvedValue(),
|
||||
caddy: {
|
||||
read: jest.fn().mockResolvedValue(''),
|
||||
modify: jest.fn().mockResolvedValue({ success: true }),
|
||||
generateConfig: jest.fn().mockReturnValue('generated config'),
|
||||
},
|
||||
dns: {
|
||||
addRecord: jest.fn().mockResolvedValue({ success: true }),
|
||||
},
|
||||
};
|
||||
|
||||
const deps = { ...defaultDeps, ...depsOverride };
|
||||
const servicesRoutes = require('../../routes/services');
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api', servicesRoutes(deps));
|
||||
// Error handler
|
||||
app.use((err, req, res, next) => {
|
||||
const status = err.statusCode || 500;
|
||||
res.status(status).json({ success: false, error: err.message });
|
||||
});
|
||||
return { app, deps };
|
||||
}
|
||||
|
||||
describe('Services Routes', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
exists.mockResolvedValue(true);
|
||||
validateServiceConfig.mockImplementation(() => {}); // No-op (valid)
|
||||
});
|
||||
|
||||
describe('GET /api/services', () => {
|
||||
it('returns empty services array (enveloped) when no services file', async () => {
|
||||
exists.mockResolvedValue(false);
|
||||
const { app } = createApp();
|
||||
const res = await request(app).get('/api/services');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true, services: [] });
|
||||
});
|
||||
|
||||
it('returns services list', async () => {
|
||||
const services = [
|
||||
{ id: 'plex', name: 'Plex' },
|
||||
{ id: 'radarr', name: 'Radarr' },
|
||||
];
|
||||
const stateManager = {
|
||||
read: jest.fn().mockResolvedValue(services),
|
||||
write: jest.fn(),
|
||||
update: jest.fn(),
|
||||
};
|
||||
const { app } = createApp({ servicesStateManager: stateManager });
|
||||
const res = await request(app).get('/api/services');
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/services', () => {
|
||||
it('adds a new service', async () => {
|
||||
const stateManager = {
|
||||
read: jest.fn().mockResolvedValue([]),
|
||||
write: jest.fn(),
|
||||
update: jest.fn(async (fn) => fn([])),
|
||||
};
|
||||
const { app } = createApp({ servicesStateManager: stateManager });
|
||||
const res = await request(app)
|
||||
.post('/api/services')
|
||||
.send({ id: 'plex', name: 'Plex' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(stateManager.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// NOTE: POST /services validation for missing id/name is caught by the route's
|
||||
// try/catch block which logs the error but doesn't send a response in the else branch.
|
||||
// The catch block only sends a response for "already exists" errors (409).
|
||||
|
||||
it('returns 409 when service already exists', async () => {
|
||||
const stateManager = {
|
||||
read: jest.fn().mockResolvedValue([]),
|
||||
write: jest.fn(),
|
||||
update: jest.fn(async (fn) => fn([{ id: 'plex', name: 'Plex' }])),
|
||||
};
|
||||
const { app } = createApp({ servicesStateManager: stateManager });
|
||||
const res = await request(app)
|
||||
.post('/api/services')
|
||||
.send({ id: 'plex', name: 'Plex' });
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/services', () => {
|
||||
it('replaces all services', async () => {
|
||||
const stateManager = {
|
||||
read: jest.fn(),
|
||||
write: jest.fn().mockResolvedValue(),
|
||||
update: jest.fn(),
|
||||
};
|
||||
const { app } = createApp({ servicesStateManager: stateManager });
|
||||
const services = [
|
||||
{ id: 'plex', name: 'Plex' },
|
||||
{ id: 'radarr', name: 'Radarr' },
|
||||
];
|
||||
const res = await request(app)
|
||||
.put('/api/services')
|
||||
.send(services);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.count).toBe(2);
|
||||
expect(stateManager.write).toHaveBeenCalledWith(services);
|
||||
});
|
||||
|
||||
it('rejects non-array body', async () => {
|
||||
const { app } = createApp();
|
||||
const res = await request(app)
|
||||
.put('/api/services')
|
||||
.send({ id: 'plex' });
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
it('rejects services without id or name', async () => {
|
||||
const { app } = createApp();
|
||||
const res = await request(app)
|
||||
.put('/api/services')
|
||||
.send([{ id: 'plex' }]); // missing name
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/services/:id', () => {
|
||||
it('removes a service', async () => {
|
||||
const stateManager = {
|
||||
read: jest.fn(),
|
||||
write: jest.fn(),
|
||||
update: jest.fn(async (fn) => fn([{ id: 'plex' }, { id: 'radarr' }])),
|
||||
};
|
||||
const { app } = createApp({ servicesStateManager: stateManager });
|
||||
const res = await request(app).delete('/api/services/plex');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('returns 404 when services file missing', async () => {
|
||||
exists.mockResolvedValue(false);
|
||||
const { app } = createApp();
|
||||
const res = await request(app).delete('/api/services/plex');
|
||||
expect(res.status).toBeGreaterThanOrEqual(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/services/:serviceId/credentials', () => {
|
||||
it('stores credentials', async () => {
|
||||
const credentialManager = {
|
||||
store: jest.fn().mockResolvedValue(true),
|
||||
retrieve: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
};
|
||||
const { app } = createApp({ credentialManager });
|
||||
const res = await request(app)
|
||||
.post('/api/services/radarr/credentials')
|
||||
.send({ apiKey: 'test-key', username: 'admin', password: 'pass' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(credentialManager.store).toHaveBeenCalledWith('service.radarr.apikey', 'test-key');
|
||||
expect(credentialManager.store).toHaveBeenCalledWith('service.radarr.username', 'admin');
|
||||
expect(credentialManager.store).toHaveBeenCalledWith('service.radarr.password', 'pass');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/services/:serviceId/credentials', () => {
|
||||
it('deletes credentials', async () => {
|
||||
const credentialManager = {
|
||||
store: jest.fn(),
|
||||
retrieve: jest.fn(),
|
||||
delete: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
const { app } = createApp({ credentialManager });
|
||||
const res = await request(app).delete('/api/services/radarr/credentials');
|
||||
expect(res.status).toBe(200);
|
||||
expect(credentialManager.delete).toHaveBeenCalledWith('service.radarr.apikey');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/services/:serviceId/credentials', () => {
|
||||
it('returns credential status', async () => {
|
||||
const credentialManager = {
|
||||
store: jest.fn(),
|
||||
retrieve: jest.fn().mockResolvedValue(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).toHaveProperty('hasApiKey');
|
||||
expect(res.body).toHaveProperty('hasBasicAuth');
|
||||
});
|
||||
|
||||
it('returns hasApiKey:true when API key exists', async () => {
|
||||
const credentialManager = {
|
||||
store: jest.fn(),
|
||||
retrieve: jest.fn().mockImplementation((key) => {
|
||||
if (key === 'service.radarr.apikey') return Promise.resolve('the-key');
|
||||
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.hasApiKey).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== SEEDHOST CREDENTIAL ENDPOINTS =====
|
||||
|
||||
describe('POST /api/seedhost-creds', () => {
|
||||
it('stores seedhost username and password', async () => {
|
||||
const credentialManager = {
|
||||
store: jest.fn().mockResolvedValue(true),
|
||||
retrieve: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
};
|
||||
const { app } = createApp({ credentialManager });
|
||||
const res = await request(app)
|
||||
.post('/api/seedhost-creds')
|
||||
.send({ username: 'user1', password: 'pass1' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(credentialManager.store).toHaveBeenCalledWith('seedhost.username', 'user1');
|
||||
expect(credentialManager.store).toHaveBeenCalledWith('seedhost.password', 'pass1');
|
||||
});
|
||||
|
||||
it('stores per-service password when serviceId provided', async () => {
|
||||
const credentialManager = {
|
||||
store: jest.fn().mockResolvedValue(true),
|
||||
retrieve: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
};
|
||||
const { app } = createApp({ credentialManager });
|
||||
const res = await request(app)
|
||||
.post('/api/seedhost-creds')
|
||||
.send({ username: 'user1', password: 'radarr-pass', serviceId: 'radarr' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(credentialManager.store).toHaveBeenCalledWith('seedhost.password.radarr', 'radarr-pass');
|
||||
});
|
||||
|
||||
it('rejects missing username', async () => {
|
||||
const { app } = createApp();
|
||||
const res = await request(app)
|
||||
.post('/api/seedhost-creds')
|
||||
.send({ password: 'pass1' });
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/seedhost-creds', () => {
|
||||
it('returns credential status with shared password', async () => {
|
||||
const credentialManager = {
|
||||
store: jest.fn(),
|
||||
retrieve: jest.fn().mockImplementation((key) => {
|
||||
if (key === 'seedhost.username') return Promise.resolve('user1');
|
||||
if (key === 'seedhost.password') return Promise.resolve('pass1');
|
||||
return Promise.reject(new Error('not found'));
|
||||
}),
|
||||
delete: jest.fn(),
|
||||
};
|
||||
const { app } = createApp({ credentialManager });
|
||||
const res = await request(app).get('/api/seedhost-creds');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.hasCredentials).toBe(true);
|
||||
expect(res.body.username).toBe('user1');
|
||||
});
|
||||
|
||||
it('checks per-service password when serviceId provided', async () => {
|
||||
const credentialManager = {
|
||||
store: jest.fn(),
|
||||
retrieve: jest.fn().mockImplementation((key) => {
|
||||
if (key === 'seedhost.username') return Promise.resolve('user1');
|
||||
if (key === 'seedhost.password.radarr') return Promise.resolve('radarr-pass');
|
||||
return Promise.reject(new Error('not found'));
|
||||
}),
|
||||
delete: jest.fn(),
|
||||
};
|
||||
const { app } = createApp({ credentialManager });
|
||||
const res = await request(app).get('/api/seedhost-creds?serviceId=radarr');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.hasCredentials).toBe(true);
|
||||
expect(res.body.hasPassword).toBe(true);
|
||||
});
|
||||
|
||||
it('returns hasCredentials:false when nothing stored', async () => {
|
||||
const credentialManager = {
|
||||
store: jest.fn(),
|
||||
retrieve: jest.fn().mockRejectedValue(new Error('not found')),
|
||||
delete: jest.fn(),
|
||||
};
|
||||
const { app } = createApp({ credentialManager });
|
||||
const res = await request(app).get('/api/seedhost-creds');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.hasCredentials).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/seedhost-creds', () => {
|
||||
it('deletes per-service password', async () => {
|
||||
const credentialManager = {
|
||||
store: jest.fn(),
|
||||
retrieve: jest.fn(),
|
||||
delete: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
const { app } = createApp({ credentialManager });
|
||||
const res = await request(app).delete('/api/seedhost-creds?serviceId=radarr');
|
||||
expect(res.status).toBe(200);
|
||||
expect(credentialManager.delete).toHaveBeenCalledWith('seedhost.password.radarr');
|
||||
});
|
||||
|
||||
it('deletes all seedhost credentials when no serviceId', async () => {
|
||||
const credentialManager = {
|
||||
store: jest.fn(),
|
||||
retrieve: jest.fn(),
|
||||
delete: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
const { app } = createApp({ credentialManager });
|
||||
const res = await request(app).delete('/api/seedhost-creds');
|
||||
expect(res.status).toBe(200);
|
||||
expect(credentialManager.delete).toHaveBeenCalledWith('seedhost.username');
|
||||
expect(credentialManager.delete).toHaveBeenCalledWith('seedhost.password');
|
||||
});
|
||||
});
|
||||
|
||||
// ===== SERVICES STATUS ENDPOINT =====
|
||||
|
||||
describe('GET /api/services/status', () => {
|
||||
it('returns status for all services', async () => {
|
||||
const stateManager = {
|
||||
read: jest.fn().mockResolvedValue([
|
||||
{ id: 'plex', name: 'Plex' },
|
||||
{ id: 'radarr', name: 'Radarr' },
|
||||
]),
|
||||
write: jest.fn(),
|
||||
update: jest.fn(),
|
||||
};
|
||||
const { app } = createApp({ servicesStateManager: stateManager });
|
||||
const res = await request(app).get('/api/services/status');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body).toHaveProperty('checkedAt');
|
||||
expect(res.body).toHaveProperty('statuses');
|
||||
});
|
||||
|
||||
it('includes internet check in statuses', async () => {
|
||||
const stateManager = {
|
||||
read: jest.fn().mockResolvedValue([]),
|
||||
write: jest.fn(),
|
||||
update: jest.fn(),
|
||||
};
|
||||
const { app } = createApp({ servicesStateManager: stateManager });
|
||||
const res = await request(app).get('/api/services/status');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.statuses).toHaveProperty('internet');
|
||||
});
|
||||
});
|
||||
|
||||
// ===== SERVICE UPDATE ENDPOINT =====
|
||||
|
||||
describe('POST /api/services/update', () => {
|
||||
it('rejects missing subdomains', async () => {
|
||||
const { app } = createApp();
|
||||
const res = await request(app)
|
||||
.post('/api/services/update')
|
||||
.send({ oldSubdomain: 'plex' }); // missing newSubdomain
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
it('rejects invalid subdomain format', async () => {
|
||||
const { app } = createApp();
|
||||
const res = await request(app)
|
||||
.post('/api/services/update')
|
||||
.send({ oldSubdomain: 'INVALID!', newSubdomain: 'plex' });
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
it('rejects invalid port', async () => {
|
||||
const { isValidPort } = require('../../src/security/input-validator');
|
||||
isValidPort.mockReturnValue(false);
|
||||
const { app } = createApp();
|
||||
const res = await request(app)
|
||||
.post('/api/services/update')
|
||||
.send({ oldSubdomain: 'plex', newSubdomain: 'media', port: 99999 });
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
it('updates subdomain with DNS and Caddy changes', async () => {
|
||||
const caddy = {
|
||||
read: jest.fn().mockResolvedValue('plex.sami {\n reverse_proxy localhost:32400\n}'),
|
||||
modify: jest.fn().mockResolvedValue({ success: true }),
|
||||
generateConfig: jest.fn().mockReturnValue('media.sami { reverse_proxy localhost:32400 }'),
|
||||
};
|
||||
const dns = {
|
||||
getToken: jest.fn().mockReturnValue('token'),
|
||||
call: jest.fn().mockResolvedValue({}),
|
||||
createRecord: jest.fn().mockResolvedValue({}),
|
||||
};
|
||||
const stateManager = {
|
||||
read: jest.fn().mockResolvedValue([{ id: 'plex', name: 'Plex' }]),
|
||||
write: jest.fn(),
|
||||
update: jest.fn(async (fn) => fn([{ id: 'plex', name: 'Plex', url: 'https://plex.sami' }])),
|
||||
};
|
||||
const { app } = createApp({
|
||||
caddy, dns,
|
||||
servicesStateManager: stateManager,
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/services/update')
|
||||
.send({ oldSubdomain: 'plex', newSubdomain: 'media' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.results).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ===== VALIDATION / EDGE CASES =====
|
||||
|
||||
describe('PUT /api/services validation', () => {
|
||||
it('rejects services that fail validateServiceConfig', async () => {
|
||||
validateServiceConfig.mockImplementation(() => {
|
||||
const err = new Error('Bad id format');
|
||||
err.errors = ['id contains invalid chars'];
|
||||
throw err;
|
||||
});
|
||||
const { app } = createApp();
|
||||
const res = await request(app)
|
||||
.put('/api/services')
|
||||
.send([{ id: 'bad!id', name: 'Test' }]);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/services/:id edge cases', () => {
|
||||
it('returns 404 when service not in list', async () => {
|
||||
const stateManager = {
|
||||
read: jest.fn(),
|
||||
write: jest.fn(),
|
||||
update: jest.fn(async (fn) => fn([{ id: 'radarr' }])),
|
||||
};
|
||||
const { app } = createApp({ servicesStateManager: stateManager });
|
||||
const res = await request(app).delete('/api/services/nonexistent');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Smoke tests for ssl-monitor.js
|
||||
* Verifies SSLMonitor loads, exposes the expected interface, can check
|
||||
* certificates via mocked TLS, manage state, and persist cache.
|
||||
*/
|
||||
|
||||
jest.mock('tls', () => ({
|
||||
connect: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../src/utilities/fs-helpers', () => ({
|
||||
readJsonFile: jest.fn().mockResolvedValue(null),
|
||||
writeJsonFile: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const tls = require('tls');
|
||||
const fsHelpers = require('../src/utilities/fs-helpers');
|
||||
const SSLMonitor = require('../src/monitoring/ssl-monitor');
|
||||
|
||||
function makeSocket({ cert = null, error = null } = {}) {
|
||||
const { EventEmitter } = require('events');
|
||||
const socket = new EventEmitter();
|
||||
socket.destroy = jest.fn();
|
||||
socket.getPeerCertificate = jest.fn(() => cert);
|
||||
socket.setTimeout = jest.fn();
|
||||
|
||||
// Simulate 'connect' on next tick (or 'error')
|
||||
process.nextTick(() => {
|
||||
if (error) socket.emit('error', error);
|
||||
});
|
||||
|
||||
return socket;
|
||||
}
|
||||
|
||||
describe('SSLMonitor', () => {
|
||||
let monitor;
|
||||
const fakeStateManager = {
|
||||
read: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
fsHelpers.readJsonFile.mockResolvedValue(null);
|
||||
fsHelpers.writeJsonFile.mockResolvedValue(undefined);
|
||||
fakeStateManager.read.mockResolvedValue([]);
|
||||
|
||||
monitor = new SSLMonitor({
|
||||
log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() },
|
||||
servicesStateManager: fakeStateManager,
|
||||
siteConfig: {},
|
||||
buildServiceUrl: id => `https://${id}.sami`,
|
||||
notification: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
monitor.stop();
|
||||
});
|
||||
|
||||
test('initializes with empty maps and default config', () => {
|
||||
expect(monitor.certStatus).toBeInstanceOf(Map);
|
||||
expect(monitor.notifiedThresholds).toBeInstanceOf(Map);
|
||||
expect(monitor.hostnameToServiceId).toBeInstanceOf(Map);
|
||||
expect(monitor.intervalHandle).toBeNull();
|
||||
expect(monitor.config.enabled).toBe(true);
|
||||
expect(typeof monitor.config.intervalMs).toBe('number');
|
||||
});
|
||||
|
||||
test('getConfig returns a copy of the current config', () => {
|
||||
const cfg = monitor.getConfig();
|
||||
expect(cfg).toEqual(monitor.config);
|
||||
cfg.enabled = false;
|
||||
// The internal config must not be mutated
|
||||
expect(monitor.config.enabled).toBe(true);
|
||||
});
|
||||
|
||||
test('updateConfig updates enabled and intervalMs', () => {
|
||||
monitor.updateConfig({ enabled: false, intervalMs: 60000 });
|
||||
expect(monitor.config.enabled).toBe(false);
|
||||
expect(monitor.config.intervalMs).toBe(60000);
|
||||
});
|
||||
|
||||
test('updateConfig rejects intervalMs below 60000', () => {
|
||||
const original = monitor.config.intervalMs;
|
||||
monitor.updateConfig({ intervalMs: 1000 });
|
||||
expect(monitor.config.intervalMs).toBe(original);
|
||||
});
|
||||
|
||||
test('getStatus returns an empty object when no checks have run', () => {
|
||||
expect(monitor.getStatus()).toEqual({});
|
||||
});
|
||||
|
||||
test('getServiceCertStatus returns null for unknown service', () => {
|
||||
expect(monitor.getServiceCertStatus('unknown-svc')).toBeNull();
|
||||
});
|
||||
|
||||
test('checkCert rejects when peer cert is empty', async () => {
|
||||
tls.connect.mockImplementation((_opts, onConnect) => {
|
||||
const sock = makeSocket({ cert: {} });
|
||||
// Simulate immediate 'connect'
|
||||
setImmediate(() => onConnect && onConnect());
|
||||
return sock;
|
||||
});
|
||||
|
||||
await expect(monitor.checkCert('empty.sami')).rejects.toThrow(/No certificate/);
|
||||
});
|
||||
|
||||
test('checkCert resolves with cert details on success', async () => {
|
||||
const futureDate = new Date(Date.now() + 60 * 24 * 60 * 60 * 1000); // +60d
|
||||
const validTo = futureDate.toUTCString();
|
||||
tls.connect.mockImplementation((_opts, onConnect) => {
|
||||
const sock = makeSocket({
|
||||
cert: {
|
||||
subject: { CN: 'test.sami' },
|
||||
issuer: { O: "Sami's CA" },
|
||||
valid_from: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30).toUTCString(),
|
||||
valid_to: validTo,
|
||||
fingerprint: 'AA:BB:CC',
|
||||
},
|
||||
});
|
||||
setImmediate(() => onConnect && onConnect());
|
||||
return sock;
|
||||
});
|
||||
|
||||
const result = await monitor.checkCert('test.sami', 443);
|
||||
expect(result.hostname).toBe('test.sami');
|
||||
expect(result.port).toBe(443);
|
||||
expect(result.subject).toBe('test.sami');
|
||||
expect(result.daysRemaining).toBeGreaterThan(0);
|
||||
expect(typeof result.isExpiring).toBe('boolean');
|
||||
expect(typeof result.checkedAt).toBe('string');
|
||||
});
|
||||
|
||||
test('checkCert rejects with TLS error event', async () => {
|
||||
tls.connect.mockImplementation(() => {
|
||||
const sock = makeSocket({ error: new Error('TLS boom') });
|
||||
return sock;
|
||||
});
|
||||
await expect(monitor.checkCert('broken.sami')).rejects.toThrow(/TLS/);
|
||||
});
|
||||
|
||||
test('checkAll returns empty status when no services configured', async () => {
|
||||
const status = await monitor.checkAll();
|
||||
expect(status).toEqual({});
|
||||
});
|
||||
|
||||
test('checkAll handles HTTPS services and stores results', async () => {
|
||||
fakeStateManager.read.mockResolvedValue([
|
||||
{ id: 'web', name: 'Web', url: 'https://web.sami' },
|
||||
]);
|
||||
const futureDate = new Date(Date.now() + 60 * 24 * 60 * 60 * 1000);
|
||||
tls.connect.mockImplementation((_opts, onConnect) => {
|
||||
const sock = makeSocket({
|
||||
cert: {
|
||||
subject: { CN: 'web.sami' },
|
||||
issuer: { O: "Sami's CA" },
|
||||
valid_from: new Date().toUTCString(),
|
||||
valid_to: futureDate.toUTCString(),
|
||||
fingerprint: 'AA:BB:CC',
|
||||
},
|
||||
});
|
||||
setImmediate(() => onConnect && onConnect());
|
||||
return sock;
|
||||
});
|
||||
|
||||
const status = await monitor.checkAll();
|
||||
expect(status['web.sami']).toBeDefined();
|
||||
expect(status['web.sami'].hostname).toBe('web.sami');
|
||||
expect(monitor.getServiceCertStatus('web')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('start() schedules periodic checks and stop() clears them', () => {
|
||||
jest.useFakeTimers();
|
||||
const originalCheckAll = monitor.checkAll.bind(monitor);
|
||||
monitor.checkAll = jest.fn().mockResolvedValue(undefined);
|
||||
monitor.start(120000);
|
||||
expect(monitor.intervalHandle).not.toBeNull();
|
||||
monitor.stop();
|
||||
expect(monitor.intervalHandle).toBeNull();
|
||||
monitor.checkAll = originalCheckAll;
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('_saveCache and _loadCache round-trip via fs-helpers', async () => {
|
||||
await monitor._saveCache();
|
||||
expect(fsHelpers.writeJsonFile).toHaveBeenCalled();
|
||||
|
||||
fsHelpers.readJsonFile.mockResolvedValue({
|
||||
lastChecked: new Date().toISOString(),
|
||||
certs: { 'a.sami': { hostname: 'a.sami', daysRemaining: 30 } },
|
||||
hostnameToServiceId: { 'a.sami': 'svc-a' },
|
||||
});
|
||||
const fresh = new SSLMonitor({
|
||||
log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() },
|
||||
servicesStateManager: fakeStateManager,
|
||||
siteConfig: {},
|
||||
buildServiceUrl: id => `https://${id}.sami`,
|
||||
});
|
||||
await fresh._loadCache();
|
||||
expect(fresh.certStatus.get('a.sami')).toBeDefined();
|
||||
expect(fresh.hostnameToServiceId.get('a.sami')).toBe('svc-a');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
jest.mock('proper-lockfile');
|
||||
jest.mock('fs', () => ({
|
||||
existsSync: jest.fn().mockReturnValue(true),
|
||||
mkdirSync: jest.fn(),
|
||||
writeFileSync: jest.fn(),
|
||||
promises: {
|
||||
readFile: jest.fn().mockResolvedValue('[]'),
|
||||
writeFile: jest.fn().mockResolvedValue(),
|
||||
},
|
||||
}));
|
||||
|
||||
const lockfile = require('proper-lockfile');
|
||||
const fs = require('fs');
|
||||
const StateManager = require('../src/managers/state-manager');
|
||||
|
||||
describe('StateManager', () => {
|
||||
let sm;
|
||||
const TEST_PATH = '/tmp/test-state.json';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
fs.promises.readFile.mockResolvedValue('[]');
|
||||
fs.promises.writeFile.mockResolvedValue();
|
||||
lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue());
|
||||
lockfile.check.mockResolvedValue(false);
|
||||
lockfile.unlock.mockResolvedValue();
|
||||
|
||||
sm = new StateManager(TEST_PATH);
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('creates file with [] if it does not exist', () => {
|
||||
fs.existsSync.mockReturnValue(false);
|
||||
new StateManager('/tmp/new-state.json');
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith('/tmp/new-state.json', '[]', 'utf8');
|
||||
});
|
||||
|
||||
it('creates directory recursively if needed', () => {
|
||||
fs.existsSync.mockReturnValue(false);
|
||||
new StateManager('/tmp/deep/nested/state.json');
|
||||
expect(fs.mkdirSync).toHaveBeenCalledWith(expect.any(String), { recursive: true });
|
||||
});
|
||||
|
||||
it('does not create file if it exists', () => {
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
fs.writeFileSync.mockClear();
|
||||
new StateManager(TEST_PATH);
|
||||
expect(fs.writeFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('read', () => {
|
||||
it('returns parsed JSON from file', async () => {
|
||||
fs.promises.readFile.mockResolvedValue(JSON.stringify([{ id: 'svc1' }]));
|
||||
const data = await sm.read();
|
||||
expect(data).toEqual([{ id: 'svc1' }]);
|
||||
});
|
||||
|
||||
it('returns [] and recreates file on ENOENT', async () => {
|
||||
const err = new Error('ENOENT');
|
||||
err.code = 'ENOENT';
|
||||
fs.promises.readFile.mockRejectedValue(err);
|
||||
fs.existsSync.mockReturnValue(false);
|
||||
|
||||
const data = await sm.read();
|
||||
expect(data).toEqual([]);
|
||||
});
|
||||
|
||||
it('throws on invalid JSON', async () => {
|
||||
fs.promises.readFile.mockResolvedValue('{bad json}');
|
||||
await expect(sm.read()).rejects.toThrow('Failed to read state file');
|
||||
});
|
||||
});
|
||||
|
||||
describe('write', () => {
|
||||
it('acquires lock, writes JSON, releases lock', async () => {
|
||||
const releaseFn = jest.fn().mockResolvedValue();
|
||||
lockfile.lock.mockResolvedValue(releaseFn);
|
||||
|
||||
await sm.write([{ id: 'new' }]);
|
||||
|
||||
expect(lockfile.lock).toHaveBeenCalledWith(TEST_PATH, expect.any(Object));
|
||||
expect(fs.promises.writeFile).toHaveBeenCalledWith(
|
||||
TEST_PATH,
|
||||
JSON.stringify([{ id: 'new' }], null, 2),
|
||||
'utf8'
|
||||
);
|
||||
expect(releaseFn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws on ELOCKED', async () => {
|
||||
const err = new Error('locked');
|
||||
err.code = 'ELOCKED';
|
||||
lockfile.lock.mockRejectedValue(err);
|
||||
|
||||
await expect(sm.write([])).rejects.toThrow('locked by another process');
|
||||
});
|
||||
|
||||
it('releases lock even on write error', async () => {
|
||||
const releaseFn = jest.fn().mockResolvedValue();
|
||||
lockfile.lock.mockResolvedValue(releaseFn);
|
||||
fs.promises.writeFile.mockRejectedValue(new Error('disk full'));
|
||||
|
||||
await expect(sm.write([])).rejects.toThrow();
|
||||
expect(releaseFn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('atomic read-modify-write cycle', async () => {
|
||||
const releaseFn = jest.fn().mockResolvedValue();
|
||||
lockfile.lock.mockResolvedValue(releaseFn);
|
||||
fs.promises.readFile.mockResolvedValue(JSON.stringify([{ id: '1' }]));
|
||||
|
||||
const result = await sm.update(items => {
|
||||
items.push({ id: '2' });
|
||||
return items;
|
||||
});
|
||||
|
||||
expect(result).toEqual([{ id: '1' }, { id: '2' }]);
|
||||
expect(fs.promises.writeFile).toHaveBeenCalled();
|
||||
expect(releaseFn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes current data to updateFn', async () => {
|
||||
const releaseFn = jest.fn().mockResolvedValue();
|
||||
lockfile.lock.mockResolvedValue(releaseFn);
|
||||
fs.promises.readFile.mockResolvedValue(JSON.stringify([{ id: 'existing' }]));
|
||||
|
||||
const updateFn = jest.fn(data => data);
|
||||
await sm.update(updateFn);
|
||||
|
||||
expect(updateFn).toHaveBeenCalledWith([{ id: 'existing' }]);
|
||||
});
|
||||
|
||||
it('throws on ELOCKED', async () => {
|
||||
const err = new Error('locked');
|
||||
err.code = 'ELOCKED';
|
||||
lockfile.lock.mockRejectedValue(err);
|
||||
|
||||
await expect(sm.update(d => d)).rejects.toThrow('locked by another process');
|
||||
});
|
||||
});
|
||||
|
||||
describe('convenience methods', () => {
|
||||
beforeEach(() => {
|
||||
const releaseFn = jest.fn().mockResolvedValue();
|
||||
lockfile.lock.mockResolvedValue(releaseFn);
|
||||
});
|
||||
|
||||
it('addItem appends to array', async () => {
|
||||
fs.promises.readFile.mockResolvedValue(JSON.stringify([{ id: '1' }]));
|
||||
const result = await sm.addItem({ id: '2', name: 'New' });
|
||||
expect(result).toEqual([{ id: '1' }, { id: '2', name: 'New' }]);
|
||||
});
|
||||
|
||||
it('removeItem filters by id', async () => {
|
||||
fs.promises.readFile.mockResolvedValue(JSON.stringify([{ id: '1' }, { id: '2' }]));
|
||||
const result = await sm.removeItem('1');
|
||||
expect(result).toEqual([{ id: '2' }]);
|
||||
});
|
||||
|
||||
it('updateItem merges updates for matching id', async () => {
|
||||
fs.promises.readFile.mockResolvedValue(JSON.stringify([{ id: '1', name: 'Old' }]));
|
||||
const result = await sm.updateItem('1', { name: 'New', port: 8080 });
|
||||
expect(result).toEqual([{ id: '1', name: 'New', port: 8080 }]);
|
||||
});
|
||||
|
||||
it('findItem returns matching item or null', async () => {
|
||||
fs.promises.readFile.mockResolvedValue(JSON.stringify([{ id: '1', name: 'Found' }]));
|
||||
const found = await sm.findItem('1');
|
||||
expect(found).toEqual({ id: '1', name: 'Found' });
|
||||
|
||||
const missing = await sm.findItem('999');
|
||||
expect(missing).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLocked', () => {
|
||||
it('returns lockfile.check result', async () => {
|
||||
lockfile.check.mockResolvedValue(true);
|
||||
expect(await sm.isLocked()).toBe(true);
|
||||
|
||||
lockfile.check.mockResolvedValue(false);
|
||||
expect(await sm.isLocked()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false on error', async () => {
|
||||
lockfile.check.mockRejectedValue(new Error('fail'));
|
||||
expect(await sm.isLocked()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('forceUnlock', () => {
|
||||
it('calls lockfile.unlock', async () => {
|
||||
await sm.forceUnlock();
|
||||
expect(lockfile.unlock).toHaveBeenCalledWith(TEST_PATH);
|
||||
});
|
||||
|
||||
it('ignores ENOTACQUIRED error', async () => {
|
||||
const err = new Error('not locked');
|
||||
err.code = 'ENOTACQUIRED';
|
||||
lockfile.unlock.mockRejectedValue(err);
|
||||
await expect(sm.forceUnlock()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws other errors', async () => {
|
||||
lockfile.unlock.mockRejectedValue(new Error('other'));
|
||||
await expect(sm.forceUnlock()).rejects.toThrow('other');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Shared universal-deps Proxy for tests that load real route modules with stub
|
||||
* dependencies. Any property access returns a sensible value:
|
||||
* - asyncHandler (the most common trap): pass-through returning its argument
|
||||
* so `router.get('/path', asyncHandler(realHandler))` resolves to
|
||||
* `router.get('/path', realHandler)` and Express sees a real handler
|
||||
* - Other functions: noopFn returning undefined when called
|
||||
* - Objects: recursive proxy
|
||||
*
|
||||
* Used by:
|
||||
* - depth2-routes-smoke.test.js (verifies every depth-2 route module loads)
|
||||
* - public-routes-drift.test.js (walks aggregator routers via Express stack)
|
||||
*/
|
||||
const noopFn = () => undefined;
|
||||
const passThrough = (x) => x;
|
||||
|
||||
// Logger-shaped noop: matches the real Logger's surface (error/warn/info/debug),
|
||||
// so factories that do `log.error('tag', 'msg', meta)` or `(ctx.log || console).error(...)`
|
||||
// don't blow up when run with stub deps. A bare `() => undefined` would throw because
|
||||
// `noopFn.error` is undefined.
|
||||
const loggerStub = { error: noopFn, warn: noopFn, info: noopFn, debug: noopFn, audit: noopFn };
|
||||
|
||||
const handler = {
|
||||
get(target, prop, receiver) {
|
||||
if (prop === 'asyncHandler') {
|
||||
// asyncHandler is special — it must accept a handler function and return
|
||||
// a wrapped handler function. Return a pass-through that wraps nothing.
|
||||
// This is the most common trap: `router.get('/path', asyncHandler(realHandler))`
|
||||
// resolves to `router.get('/path', realHandler)` and Express sees a real handler.
|
||||
return (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
}
|
||||
if (prop === Symbol.toPrimitive) return undefined;
|
||||
if (prop === 'then') return undefined; // don't make the proxy thenable
|
||||
if (prop in target) return target[prop];
|
||||
// Functions and methods — return noopFn that returns undefined when called
|
||||
if (typeof target[prop] === 'function') return target[prop];
|
||||
return noopFn;
|
||||
},
|
||||
// Object.assign / spread / Object.keys on the proxy only sees the target's
|
||||
// OWN enumerable keys. Without these traps, aggregator factories that copy
|
||||
// ctx into a subCtx via `Object.assign({}, ctx, { helpers })` lose the
|
||||
// proxy's magic (e.g. asyncHandler), and downstream factories fail with
|
||||
// 'asyncHandler is not a function'. Expose all seed keys as own enumerable
|
||||
// so they survive the copy.
|
||||
ownKeys(target) {
|
||||
return Reflect.ownKeys(target);
|
||||
},
|
||||
getOwnPropertyDescriptor(target, prop) {
|
||||
if (prop in target) return Object.getOwnPropertyDescriptor(target, prop);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// Seed the proxy with a few known-shape fields so modules that destructure
|
||||
// them get the right type. Anything else falls back to noopFn via the handler.
|
||||
const seed = {
|
||||
fetchT: async () => ({ ok: true, status: 200, json: async () => ({}) }),
|
||||
// asyncHandler is special — see handler.get below. We also seed it as an
|
||||
// own enumerable property so Object.assign({}, ctx, { helpers }) copies it
|
||||
// through (the proxy's ownKeys trap only exposes own keys, so anything not
|
||||
// in the seed is invisible to spread/assign even though the get trap returns it).
|
||||
asyncHandler: (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next),
|
||||
servicesStateManager: {
|
||||
read: async () => [],
|
||||
write: async () => {},
|
||||
update: async () => []
|
||||
},
|
||||
siteConfig: { tld: '.home', dnsServers: {}, pylon: null },
|
||||
buildServiceUrl: (id) => `https://${id}.sami}`,
|
||||
logError: async () => undefined,
|
||||
// Logger-shaped stub (not a bare noopFn) so `(ctx.log || console).error(...)`
|
||||
// and `log.error('tag','msg',meta)` calls don't throw. See loggerStub above.
|
||||
log: loggerStub,
|
||||
errorResponse: noopFn,
|
||||
healthChecker: {
|
||||
getCurrentStatus: () => ({}),
|
||||
getServiceStats: () => null,
|
||||
configureService: noopFn,
|
||||
removeService: noopFn,
|
||||
getOpenIncidents: () => [],
|
||||
getIncidentHistory: () => []
|
||||
},
|
||||
authManager: {},
|
||||
credentialManager: {
|
||||
store: async () => undefined,
|
||||
retrieve: async () => null,
|
||||
diagnose: async () => ({ status: 'missing' }),
|
||||
rotateKey: async () => undefined
|
||||
},
|
||||
totpConfig: {
|
||||
isSetUp: false,
|
||||
enabled: false,
|
||||
sessionDuration: 'never',
|
||||
getConfig: () => ({}),
|
||||
saveConfig: async () => undefined
|
||||
},
|
||||
saveTotpConfig: async () => undefined,
|
||||
session: {
|
||||
create: async () => ({}),
|
||||
invalidate: async () => undefined,
|
||||
isValid: () => true
|
||||
},
|
||||
licenseManager: {
|
||||
requirePremium: () => (req, res, next) => next(),
|
||||
hasFeature: () => false
|
||||
},
|
||||
getServiceById: () => null,
|
||||
getAppSession: () => null,
|
||||
appSessionCache: { get: () => null, set: noopFn },
|
||||
renewCSRFToken: () => 'csrf-token',
|
||||
createCache: () => ({ get: () => null, set: noopFn }),
|
||||
CACHE_CONFIGS: {},
|
||||
docker: {},
|
||||
notification: { send: noopFn },
|
||||
buildDomain: (s) => s,
|
||||
caddy: {},
|
||||
addServiceToConfig: async () => undefined,
|
||||
APP_TEMPLATES: {},
|
||||
DOCKER: {}, REGEX: {}, TIMEOUTS: {}, APP: {}, PLEX: {}, LIMITS: {},
|
||||
SESSION_TTL: 86400,
|
||||
buildMediaAuth: () => ({}),
|
||||
CADDY: {},
|
||||
DEFAULT_DNS_PORT: '5380',
|
||||
isValidPort: () => true,
|
||||
exists: async () => true,
|
||||
validateURL: () => true,
|
||||
validateToken: () => true,
|
||||
validateAndLogConfig: () => ({}),
|
||||
validateConfig: () => ({ valid: true, errors: [], warnings: [] }),
|
||||
ValidationError: class extends Error {},
|
||||
AuthenticationError: class extends Error {},
|
||||
ForbiddenError: class extends Error {},
|
||||
NotFoundError: class extends Error {},
|
||||
ok: noopFn,
|
||||
successMessage: noopFn,
|
||||
validationError: noopFn,
|
||||
notFound: noopFn,
|
||||
error: noopFn,
|
||||
platformPaths: {},
|
||||
RECIPE_TEMPLATES: {},
|
||||
RECIPE_CATEGORIES: [],
|
||||
ARR_SERVICES: {},
|
||||
APP_PORTS: {},
|
||||
cryptoUtils: { encrypt: async (x) => x, decrypt: async (x) => x },
|
||||
// Path-like strings for routes that do `path.dirname(SERVICES_FILE)` etc
|
||||
// before the factory body runs (e.g. routes/config/backup.js). Bare noopFn
|
||||
// would throw 'path argument must be of type string. Received function'.
|
||||
SERVICES_FILE: '/tmp/dashcaddy/services.json',
|
||||
CONFIG_FILE: '/tmp/dashcaddy/config.json',
|
||||
TOTP_CONFIG_FILE: '/tmp/dashcaddy/totp.json',
|
||||
TAILSCALE_CONFIG_FILE: '/tmp/dashcaddy/tailscale.json',
|
||||
NOTIFICATIONS_FILE: '/tmp/dashcaddy/notifications.json',
|
||||
// Aggregator convenience: factories pass ctx.X into sub-router mounts;
|
||||
// some sub-routers destructure these by name. Seed-as-own-property so
|
||||
// Object.assign({}, ctx, { helpers }) copies them through.
|
||||
loadSiteConfig: async () => ({}),
|
||||
loadNotificationConfig: async () => ({}),
|
||||
configStateManager: { read: async () => ({}), write: async () => undefined, update: async () => undefined },
|
||||
readConfig: async () => ({}),
|
||||
saveConfig: async () => undefined,
|
||||
helpers: {},
|
||||
safeErrorMessage: (e) => (e && e.message) || 'Unknown error'
|
||||
};
|
||||
|
||||
module.exports = { universalDeps: new Proxy(seed, handler), noopFn, passThrough };
|
||||
@@ -0,0 +1,122 @@
|
||||
const { resolveServiceUrl } = require('../src/utilities/url-resolver');
|
||||
|
||||
describe('URL Resolver — DashCaddy service URL resolution', () => {
|
||||
const buildServiceUrl = jest.fn(id => `https://${id}.sami`);
|
||||
|
||||
beforeEach(() => {
|
||||
buildServiceUrl.mockClear();
|
||||
});
|
||||
|
||||
describe('Internet connectivity check', () => {
|
||||
it('always resolves "internet" to google.com regardless of config', () => {
|
||||
expect(resolveServiceUrl('internet', null, null, buildServiceUrl))
|
||||
.toBe('https://www.google.com');
|
||||
expect(buildServiceUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores service object for internet ID', () => {
|
||||
const service = { url: 'http://custom.test', isExternal: true, externalUrl: 'http://ext.test' };
|
||||
expect(resolveServiceUrl('internet', service, {}, buildServiceUrl))
|
||||
.toBe('https://www.google.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('External services (seedhost, cloud-hosted)', () => {
|
||||
it('uses externalUrl for services marked isExternal', () => {
|
||||
const service = { isExternal: true, externalUrl: 'https://usw123.seedhost.eu/sami/radarr' };
|
||||
expect(resolveServiceUrl('radarr', service, {}, buildServiceUrl))
|
||||
.toBe('https://usw123.seedhost.eu/sami/radarr');
|
||||
});
|
||||
|
||||
it('ignores isExternal if externalUrl is missing', () => {
|
||||
const service = { isExternal: true };
|
||||
expect(resolveServiceUrl('plex', service, {}, buildServiceUrl))
|
||||
.toBe('https://plex.sami');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom URL override on service', () => {
|
||||
it('uses service.url with http prefix as-is', () => {
|
||||
const service = { url: 'http://192.168.1.100:32400' };
|
||||
expect(resolveServiceUrl('plex', service, {}, buildServiceUrl))
|
||||
.toBe('http://192.168.1.100:32400');
|
||||
});
|
||||
|
||||
it('uses service.url with https prefix as-is', () => {
|
||||
const service = { url: 'https://plex.mydomain.com' };
|
||||
expect(resolveServiceUrl('plex', service, {}, buildServiceUrl))
|
||||
.toBe('https://plex.mydomain.com');
|
||||
});
|
||||
|
||||
it('prepends https:// to bare hostnames', () => {
|
||||
const service = { url: 'plex.sami' };
|
||||
expect(resolveServiceUrl('plex', service, {}, buildServiceUrl))
|
||||
.toBe('https://plex.sami');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DNS server resolution (Technitium, Pi-hole)', () => {
|
||||
it('resolves DNS server by ID from siteConfig', () => {
|
||||
const siteConfig = {
|
||||
dnsServers: {
|
||||
dns1: { ip: '192.168.254.204', port: 5380 },
|
||||
dns2: { ip: '100.74.102.61', port: 5380 },
|
||||
}
|
||||
};
|
||||
expect(resolveServiceUrl('dns1', null, siteConfig, buildServiceUrl))
|
||||
.toBe('http://192.168.254.204:5380');
|
||||
expect(resolveServiceUrl('dns2', null, siteConfig, buildServiceUrl))
|
||||
.toBe('http://100.74.102.61:5380');
|
||||
});
|
||||
|
||||
it('defaults to port 5380 when port is omitted', () => {
|
||||
const siteConfig = { dnsServers: { dns1: { ip: '10.0.0.1' } } };
|
||||
expect(resolveServiceUrl('dns1', null, siteConfig, buildServiceUrl))
|
||||
.toBe('http://10.0.0.1:5380');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Fallback to buildServiceUrl (Caddy subdomain/subdirectory)', () => {
|
||||
it('falls back for local services with no special config', () => {
|
||||
resolveServiceUrl('radarr', { name: 'Radarr' }, {}, buildServiceUrl);
|
||||
expect(buildServiceUrl).toHaveBeenCalledWith('radarr');
|
||||
});
|
||||
|
||||
it('works when service is null (top-card items)', () => {
|
||||
expect(resolveServiceUrl('sonarr', null, {}, buildServiceUrl))
|
||||
.toBe('https://sonarr.sami');
|
||||
});
|
||||
|
||||
it('works when siteConfig is null', () => {
|
||||
expect(resolveServiceUrl('jellyfin', null, null, buildServiceUrl))
|
||||
.toBe('https://jellyfin.sami');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Priority chain — higher priority wins', () => {
|
||||
const fullService = {
|
||||
isExternal: true,
|
||||
externalUrl: 'https://external.test',
|
||||
url: 'http://custom.test',
|
||||
};
|
||||
const siteConfig = {
|
||||
dnsServers: { myservice: { ip: '10.0.0.1', port: 5380 } }
|
||||
};
|
||||
|
||||
it('externalUrl wins over service.url and DNS', () => {
|
||||
expect(resolveServiceUrl('myservice', fullService, siteConfig, buildServiceUrl))
|
||||
.toBe('https://external.test');
|
||||
});
|
||||
|
||||
it('service.url wins over DNS and fallback', () => {
|
||||
const service = { url: 'http://custom.test' };
|
||||
expect(resolveServiceUrl('myservice', service, siteConfig, buildServiceUrl))
|
||||
.toBe('http://custom.test');
|
||||
});
|
||||
|
||||
it('DNS wins over fallback', () => {
|
||||
expect(resolveServiceUrl('myservice', null, siteConfig, buildServiceUrl))
|
||||
.toBe('http://10.0.0.1:5380');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
# Font file headers to prevent sanitizer issues
|
||||
<FilesMatch "\.(woff2|woff|ttf|eot)$">
|
||||
Header set Access-Control-Allow-Origin "*"
|
||||
Header set Access-Control-Allow-Methods "GET, POST, OPTIONS"
|
||||
Header set Access-Control-Allow-Headers "Content-Type"
|
||||
Header set Cache-Control "public, max-age=31536000"
|
||||
|
||||
# Proper MIME types
|
||||
<IfModule mod_mime.c>
|
||||
AddType font/woff2 .woff2
|
||||
AddType font/woff .woff
|
||||
AddType font/ttf .ttf
|
||||
AddType application/vnd.ms-fontobject .eot
|
||||
</IfModule>
|
||||
</FilesMatch>
|
||||
|
||||
# Prevent direct access to font conversion scripts
|
||||
<FilesMatch "\.(py|bat)$">
|
||||
Order allow,deny
|
||||
Deny from all
|
||||
</FilesMatch>
|
||||
|
After Width: | Height: | Size: 356 KiB |
|
After Width: | Height: | Size: 972 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 356 KiB |
|
After Width: | Height: | Size: 972 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 972 KiB |
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* DNS Template Selector
|
||||
* Presents DNS server template options when user chooses to set up DNS
|
||||
*/
|
||||
|
||||
(function(window) {
|
||||
'use strict';
|
||||
|
||||
class DnsTemplateSelector {
|
||||
constructor(progressTracker) {
|
||||
this.progressTracker = progressTracker;
|
||||
this.modal = null;
|
||||
this.onTemplateSelected = null;
|
||||
console.log('[DnsTemplateSelector] Module loaded');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available DNS server templates from app templates
|
||||
* @returns {Array} Array of DNS template objects
|
||||
*/
|
||||
getDnsTemplates() {
|
||||
// In a real implementation, this would fetch from app-templates.js
|
||||
// For now, return hardcoded templates matching what we added
|
||||
return [
|
||||
{
|
||||
id: 'technitium',
|
||||
name: 'Technitium DNS Server',
|
||||
description: 'Modern DNS server with web UI for managing private zones',
|
||||
icon: '🌐',
|
||||
difficulty: 'Easy',
|
||||
features: [
|
||||
'Web-based management interface',
|
||||
'Private zone management for .sami domain',
|
||||
'DHCP server integration',
|
||||
'DNS-over-HTTPS and DNS-over-TLS support'
|
||||
],
|
||||
recommended: true
|
||||
},
|
||||
{
|
||||
id: 'bind9',
|
||||
name: 'BIND9 DNS Server',
|
||||
description: 'Industry-standard DNS server - powerful and flexible',
|
||||
icon: '🔧',
|
||||
difficulty: 'Advanced',
|
||||
features: [
|
||||
'Industry standard DNS server',
|
||||
'Full RFC compliance',
|
||||
'Advanced zone management',
|
||||
'DNSSEC support'
|
||||
],
|
||||
recommended: false
|
||||
},
|
||||
{
|
||||
id: 'pihole',
|
||||
name: 'Pi-hole',
|
||||
description: 'Network-wide ad blocker with DNS capabilities',
|
||||
icon: '🛡️',
|
||||
difficulty: 'Intermediate',
|
||||
features: [
|
||||
'Ad blocking at DNS level',
|
||||
'Web interface for management',
|
||||
'DHCP server included',
|
||||
'Query logging and statistics'
|
||||
],
|
||||
recommended: false
|
||||
},
|
||||
{
|
||||
id: 'powerdns',
|
||||
name: 'PowerDNS',
|
||||
description: 'High-performance DNS server with SQL backend',
|
||||
icon: '⚡',
|
||||
difficulty: 'Intermediate',
|
||||
features: [
|
||||
'SQL database backend',
|
||||
'RESTful API for automation',
|
||||
'Geographic load balancing',
|
||||
'DNSSEC support'
|
||||
],
|
||||
recommended: false
|
||||
},
|
||||
{
|
||||
id: 'coredns',
|
||||
name: 'CoreDNS',
|
||||
description: 'Cloud-native DNS server - lightweight and flexible',
|
||||
icon: '☁️',
|
||||
difficulty: 'Intermediate',
|
||||
features: [
|
||||
'Plugin-based architecture',
|
||||
'Kubernetes-native',
|
||||
'Lightweight and fast',
|
||||
'Prometheus metrics'
|
||||
],
|
||||
recommended: false
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Show DNS template selection modal
|
||||
*/
|
||||
showTemplateSelector() {
|
||||
// Create modal if it doesn't exist
|
||||
if (!this.modal) {
|
||||
this.createModal();
|
||||
}
|
||||
|
||||
// Populate with templates
|
||||
this.populateTemplates();
|
||||
|
||||
// Show modal
|
||||
this.modal.style.display = 'flex';
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the modal HTML structure
|
||||
* @private
|
||||
*/
|
||||
createModal() {
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'dns-template-modal';
|
||||
modal.className = 'dns-template-modal';
|
||||
modal.innerHTML = `
|
||||
<div class="dns-template-modal-content">
|
||||
<div class="dns-template-header">
|
||||
<h2>🌐 Choose a DNS Server</h2>
|
||||
<p>Setting up a DNS server is essential for managing your private .sami domain</p>
|
||||
<button class="dns-template-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="dns-template-grid" id="dns-template-grid">
|
||||
<!-- Templates will be inserted here -->
|
||||
</div>
|
||||
<div class="dns-template-footer">
|
||||
<button class="dns-template-later-btn" id="dns-setup-later">Set up later</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
this.modal = modal;
|
||||
|
||||
// Add event listeners
|
||||
modal.querySelector('.dns-template-close').addEventListener('click', () => this.close());
|
||||
modal.querySelector('#dns-setup-later').addEventListener('click', () => this.handleSetupLater());
|
||||
|
||||
// Close on overlay click
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Close on Escape key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && modal.style.display === 'flex') {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate modal with DNS templates
|
||||
* @private
|
||||
*/
|
||||
populateTemplates() {
|
||||
const grid = document.getElementById('dns-template-grid');
|
||||
if (!grid) return;
|
||||
|
||||
const templates = this.getDnsTemplates();
|
||||
grid.innerHTML = '';
|
||||
|
||||
templates.forEach(template => {
|
||||
const card = this.createTemplateCard(template);
|
||||
grid.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a template card element
|
||||
* @private
|
||||
*/
|
||||
createTemplateCard(template) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'dns-template-card';
|
||||
if (template.recommended) {
|
||||
card.classList.add('recommended');
|
||||
}
|
||||
|
||||
const difficultyClass = template.difficulty.toLowerCase();
|
||||
|
||||
card.innerHTML = `
|
||||
${template.recommended ? '<div class="recommended-badge">Recommended</div>' : ''}
|
||||
<div class="dns-template-icon">${template.icon}</div>
|
||||
<h3>${template.name}</h3>
|
||||
<p class="dns-template-description">${template.description}</p>
|
||||
<div class="dns-template-difficulty difficulty-${difficultyClass}">
|
||||
${template.difficulty}
|
||||
</div>
|
||||
<ul class="dns-template-features">
|
||||
${template.features.slice(0, 3).map(f => `<li>${f}</li>`).join('')}
|
||||
</ul>
|
||||
<button class="dns-template-select-btn" data-template-id="${template.id}">
|
||||
Select ${template.name}
|
||||
</button>
|
||||
`;
|
||||
|
||||
// Add click handler to select button
|
||||
const selectBtn = card.querySelector('.dns-template-select-btn');
|
||||
selectBtn.addEventListener('click', () => this.handleTemplateSelection(template));
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle template selection
|
||||
* @private
|
||||
*/
|
||||
handleTemplateSelection(template) {
|
||||
console.log(`[DnsTemplateSelector] Template selected: ${template.id}`);
|
||||
|
||||
// Close modal
|
||||
this.close();
|
||||
|
||||
// Trigger callback if set
|
||||
if (this.onTemplateSelected) {
|
||||
this.onTemplateSelected(template);
|
||||
} else {
|
||||
// Default behavior: open app selector with DNS filter
|
||||
this.openAppSelector(template.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle "Set up later" button
|
||||
* @private
|
||||
*/
|
||||
handleSetupLater() {
|
||||
console.log('[DnsTemplateSelector] DNS setup deferred');
|
||||
|
||||
// Mark as deferred in progress tracker
|
||||
if (this.progressTracker) {
|
||||
this.progressTracker.markDnsSetupDeferred();
|
||||
}
|
||||
|
||||
// Close modal
|
||||
this.close();
|
||||
|
||||
// Show notification
|
||||
this.showNotification('DNS setup deferred. You can set it up later from the App Selector.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Open app selector with specific template
|
||||
* @private
|
||||
*/
|
||||
openAppSelector(templateId) {
|
||||
// Try to open the app selector modal if it exists
|
||||
const appSelectorBtn = document.querySelector('[onclick*="showAppSelector"]');
|
||||
if (appSelectorBtn) {
|
||||
appSelectorBtn.click();
|
||||
|
||||
// Wait a bit then filter to the selected template
|
||||
setTimeout(() => {
|
||||
const searchInput = document.querySelector('#app-search');
|
||||
if (searchInput) {
|
||||
searchInput.value = templateId;
|
||||
searchInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
}, 300);
|
||||
} else {
|
||||
// Fallback: show instructions
|
||||
this.showNotification(`To deploy ${templateId}, use the App Selector and search for "${templateId}"`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show notification message
|
||||
* @private
|
||||
*/
|
||||
showNotification(message) {
|
||||
// Simple notification - could be enhanced
|
||||
const notification = document.createElement('div');
|
||||
notification.className = 'dns-template-notification';
|
||||
notification.textContent = message;
|
||||
notification.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: var(--card-base);
|
||||
color: var(--fg);
|
||||
padding: 15px 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
z-index: 10001;
|
||||
max-width: 300px;
|
||||
`;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
setTimeout(() => {
|
||||
notification.style.opacity = '0';
|
||||
notification.style.transition = 'opacity 0.3s';
|
||||
setTimeout(() => notification.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the modal
|
||||
*/
|
||||
close() {
|
||||
if (this.modal) {
|
||||
this.modal.style.display = 'none';
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.DnsTemplateSelector = DnsTemplateSelector;
|
||||
console.log('[DnsTemplateSelector] Module loaded');
|
||||
|
||||
})(window);
|
||||
@@ -0,0 +1 @@
|
||||
.driver-active .driver-overlay,.driver-active *{pointer-events:none}.driver-active .driver-active-element,.driver-active .driver-active-element *,.driver-popover,.driver-popover *{pointer-events:auto}@keyframes animate-fade-in{0%{opacity:0}to{opacity:1}}.driver-fade .driver-overlay{animation:animate-fade-in .2s ease-in-out}.driver-fade .driver-popover{animation:animate-fade-in .2s}.driver-popover{all:unset;box-sizing:border-box;color:#2d2d2d;margin:0;padding:15px;border-radius:5px;min-width:250px;max-width:300px;box-shadow:0 1px 10px #0006;z-index:1000000000;position:fixed;top:0;right:0;background-color:#fff}.driver-popover *{font-family:Helvetica Neue,Inter,ui-sans-serif,"Apple Color Emoji",Helvetica,Arial,sans-serif}.driver-popover-title{font:19px/normal sans-serif;font-weight:700;display:block;position:relative;line-height:1.5;zoom:1;margin:0}.driver-popover-close-btn{all:unset;position:absolute;top:0;right:0;width:32px;height:28px;cursor:pointer;font-size:18px;font-weight:500;color:#d2d2d2;z-index:1;text-align:center;transition:color;transition-duration:.2s}.driver-popover-close-btn:hover,.driver-popover-close-btn:focus{color:#2d2d2d}.driver-popover-title[style*=block]+.driver-popover-description{margin-top:5px}.driver-popover-description{margin-bottom:0;font:14px/normal sans-serif;line-height:1.5;font-weight:400;zoom:1}.driver-popover-footer{margin-top:15px;text-align:right;zoom:1;display:flex;align-items:center;justify-content:space-between}.driver-popover-progress-text{font-size:13px;font-weight:400;color:#727272;zoom:1}.driver-popover-footer button{all:unset;display:inline-block;box-sizing:border-box;padding:3px 7px;text-decoration:none;text-shadow:1px 1px 0 #fff;background-color:#fff;color:#2d2d2d;font:12px/normal sans-serif;cursor:pointer;outline:0;zoom:1;line-height:1.3;border:1px solid #ccc;border-radius:3px}.driver-popover-footer .driver-popover-btn-disabled{opacity:.5;pointer-events:none}:not(body):has(>.driver-active-element){overflow:hidden!important}.driver-no-interaction,.driver-no-interaction *{pointer-events:none!important}.driver-popover-footer button:hover,.driver-popover-footer button:focus{background-color:#f7f7f7}.driver-popover-navigation-btns{display:flex;flex-grow:1;justify-content:flex-end}.driver-popover-navigation-btns button+button{margin-left:4px}.driver-popover-arrow{content:"";position:absolute;border:5px solid #fff}.driver-popover-arrow-side-over{display:none}.driver-popover-arrow-side-left{left:100%;border-right-color:transparent;border-bottom-color:transparent;border-top-color:transparent}.driver-popover-arrow-side-right{right:100%;border-left-color:transparent;border-bottom-color:transparent;border-top-color:transparent}.driver-popover-arrow-side-top{top:100%;border-right-color:transparent;border-bottom-color:transparent;border-left-color:transparent}.driver-popover-arrow-side-bottom{bottom:100%;border-left-color:transparent;border-top-color:transparent;border-right-color:transparent}.driver-popover-arrow-side-center{display:none}.driver-popover-arrow-side-left.driver-popover-arrow-align-start,.driver-popover-arrow-side-right.driver-popover-arrow-align-start{top:15px}.driver-popover-arrow-side-top.driver-popover-arrow-align-start,.driver-popover-arrow-side-bottom.driver-popover-arrow-align-start{left:15px}.driver-popover-arrow-align-end.driver-popover-arrow-side-left,.driver-popover-arrow-align-end.driver-popover-arrow-side-right{bottom:15px}.driver-popover-arrow-side-top.driver-popover-arrow-align-end,.driver-popover-arrow-side-bottom.driver-popover-arrow-align-end{right:15px}.driver-popover-arrow-side-left.driver-popover-arrow-align-center,.driver-popover-arrow-side-right.driver-popover-arrow-align-center{top:50%;margin-top:-5px}.driver-popover-arrow-side-top.driver-popover-arrow-align-center,.driver-popover-arrow-side-bottom.driver-popover-arrow-align-center{left:50%;margin-left:-5px}.driver-popover-arrow-none{display:none}
|
||||
|
After Width: | Height: | Size: 9.0 KiB |
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Error Handler
|
||||
* Handles errors gracefully without breaking the onboarding tour
|
||||
*/
|
||||
|
||||
(function(window) {
|
||||
'use strict';
|
||||
|
||||
class ErrorHandler {
|
||||
constructor() {
|
||||
this.errors = [];
|
||||
this.maxErrors = 50; // Keep last 50 errors
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an error without breaking the tour
|
||||
* @param {string} context - Context where error occurred
|
||||
* @param {Error|string} error - The error object or message
|
||||
* @param {Object} metadata - Additional metadata
|
||||
*/
|
||||
logError(context, error, metadata = {}) {
|
||||
const errorEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
context,
|
||||
message: error instanceof Error ? error.message : error,
|
||||
stack: error instanceof Error ? error.stack : null,
|
||||
metadata
|
||||
};
|
||||
|
||||
// Add to errors array
|
||||
this.errors.push(errorEntry);
|
||||
|
||||
// Keep only last maxErrors
|
||||
if (this.errors.length > this.maxErrors) {
|
||||
this.errors.shift();
|
||||
}
|
||||
|
||||
// Log to console
|
||||
console.error(`[Onboarding Error] ${context}:`, error, metadata);
|
||||
|
||||
// Optionally send to error tracking service
|
||||
// this.sendToErrorTracking(errorEntry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to recover from an error and continue tour
|
||||
* @param {Error} error - The error object
|
||||
* @param {number} currentStep - Current step index
|
||||
* @returns {Object} Recovery action
|
||||
*/
|
||||
recoverFromError(error, currentStep) {
|
||||
const errorType = this.classifyError(error);
|
||||
|
||||
switch (errorType) {
|
||||
case 'ELEMENT_NOT_FOUND':
|
||||
this.logError('Element Not Found', error, { currentStep });
|
||||
return {
|
||||
action: 'SKIP_STEP',
|
||||
nextStep: currentStep + 1,
|
||||
message: 'Target element not found, skipping to next step'
|
||||
};
|
||||
|
||||
case 'STORAGE_UNAVAILABLE':
|
||||
this.logError('Storage Unavailable', error);
|
||||
return {
|
||||
action: 'USE_MEMORY_STORAGE',
|
||||
message: 'Local storage unavailable, using in-memory storage'
|
||||
};
|
||||
|
||||
case 'DRIVER_NOT_LOADED':
|
||||
this.logError('Driver.js Not Loaded', error);
|
||||
return {
|
||||
action: 'ABORT_TOUR',
|
||||
message: 'Driver.js library not loaded, cannot start tour'
|
||||
};
|
||||
|
||||
case 'INVALID_TOOLTIP':
|
||||
this.logError('Invalid Tooltip Configuration', error, { currentStep });
|
||||
return {
|
||||
action: 'SKIP_STEP',
|
||||
nextStep: currentStep + 1,
|
||||
message: 'Invalid tooltip configuration, skipping'
|
||||
};
|
||||
|
||||
case 'THEME_DETECTION_FAILED':
|
||||
this.logError('Theme Detection Failed', error);
|
||||
return {
|
||||
action: 'USE_DEFAULT_THEME',
|
||||
message: 'Using default dark theme'
|
||||
};
|
||||
|
||||
default:
|
||||
this.logError('Unknown Error', error, { currentStep });
|
||||
return {
|
||||
action: 'ABORT_TOUR',
|
||||
message: 'Unexpected error occurred, aborting tour'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify error type
|
||||
* @private
|
||||
* @param {Error} error - The error object
|
||||
* @returns {string} Error type
|
||||
*/
|
||||
classifyError(error) {
|
||||
const message = error.message || error.toString();
|
||||
|
||||
if (message.includes('element') && message.includes('not found')) {
|
||||
return 'ELEMENT_NOT_FOUND';
|
||||
}
|
||||
if (message.includes('storage') || message.includes('quota')) {
|
||||
return 'STORAGE_UNAVAILABLE';
|
||||
}
|
||||
if (message.includes('driver') || message.includes('undefined')) {
|
||||
return 'DRIVER_NOT_LOADED';
|
||||
}
|
||||
if (message.includes('invalid') || message.includes('validation')) {
|
||||
return 'INVALID_TOOLTIP';
|
||||
}
|
||||
if (message.includes('theme')) {
|
||||
return 'THEME_DETECTION_FAILED';
|
||||
}
|
||||
|
||||
return 'UNKNOWN';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all logged errors
|
||||
* @returns {Array} Array of error entries
|
||||
*/
|
||||
getErrors() {
|
||||
return [...this.errors];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all logged errors
|
||||
*/
|
||||
clearErrors() {
|
||||
this.errors = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error statistics
|
||||
* @returns {Object} Error statistics
|
||||
*/
|
||||
getStatistics() {
|
||||
const stats = {
|
||||
total: this.errors.length,
|
||||
byContext: {},
|
||||
byType: {},
|
||||
recent: this.errors.slice(-10)
|
||||
};
|
||||
|
||||
this.errors.forEach(error => {
|
||||
// Count by context
|
||||
stats.byContext[error.context] = (stats.byContext[error.context] || 0) + 1;
|
||||
|
||||
// Count by type
|
||||
const type = this.classifyError({ message: error.message });
|
||||
stats.byType[type] = (stats.byType[type] || 0) + 1;
|
||||
});
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle graceful degradation when Driver.js fails to load
|
||||
* @returns {boolean} Whether fallback was successful
|
||||
*/
|
||||
handleDriverLoadFailure() {
|
||||
this.logError('Driver.js Load Failure', 'Driver.js library failed to load');
|
||||
|
||||
// Show fallback message
|
||||
const fallbackMessage = document.createElement('div');
|
||||
fallbackMessage.id = 'onboarding-fallback';
|
||||
fallbackMessage.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
background: var(--card-base, #2a2a2a);
|
||||
color: var(--fg, #ffffff);
|
||||
padding: 15px 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
z-index: 9999;
|
||||
max-width: 300px;
|
||||
font-size: 14px;
|
||||
`;
|
||||
fallbackMessage.innerHTML = `
|
||||
<strong>Welcome to DashCaddy!</strong><br>
|
||||
<p style="margin: 10px 0 0 0; font-size: 12px;">
|
||||
The interactive tour is unavailable, but you can explore the dashboard freely.
|
||||
Check the documentation for help getting started.
|
||||
</p>
|
||||
`;
|
||||
|
||||
document.body.appendChild(fallbackMessage);
|
||||
|
||||
// Auto-remove after 10 seconds
|
||||
setTimeout(() => {
|
||||
if (fallbackMessage.parentNode) {
|
||||
fallbackMessage.parentNode.removeChild(fallbackMessage);
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle storage unavailable scenario
|
||||
* @returns {Object} In-memory storage fallback
|
||||
*/
|
||||
handleStorageUnavailable() {
|
||||
this.logError('Storage Unavailable', 'Local storage is not available');
|
||||
|
||||
// Create in-memory storage
|
||||
const memoryStorage = {
|
||||
data: {},
|
||||
getItem(key) {
|
||||
return this.data[key] || null;
|
||||
},
|
||||
setItem(key, value) {
|
||||
this.data[key] = value;
|
||||
},
|
||||
removeItem(key) {
|
||||
delete this.data[key];
|
||||
},
|
||||
clear() {
|
||||
this.data = {};
|
||||
}
|
||||
};
|
||||
|
||||
console.warn('[ErrorHandler] Using in-memory storage - progress will not persist');
|
||||
return memoryStorage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send error to tracking service (placeholder)
|
||||
* @private
|
||||
* @param {Object} errorEntry - Error entry to send
|
||||
*/
|
||||
sendToErrorTracking(errorEntry) {
|
||||
// Placeholder for error tracking integration
|
||||
// Could integrate with Sentry, LogRocket, etc.
|
||||
// Example:
|
||||
// if (window.Sentry) {
|
||||
// Sentry.captureException(new Error(errorEntry.message), {
|
||||
// extra: errorEntry.metadata
|
||||
// });
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
window.ErrorHandler = ErrorHandler;
|
||||
console.log('[ErrorHandler] Module loaded');
|
||||
|
||||
})(window);
|
||||
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="12" fill="#0e1116"/><path d="M16 38h20a8 8 0 1 0-1.8-15.7A9.5 9.5 0 0 0 12 30c0 4.4 3.6 8 8 8z" fill="#8FD6FF"/></svg>
|
||||
|
After Width: | Height: | Size: 211 B |
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,91 @@
|
||||
/* Sami Sans Font Family - External CSS */
|
||||
|
||||
@font-face {
|
||||
font-family: 'Sami Sans';
|
||||
src: url('fonts/SamiSans-Regular.woff2') format('woff2'),
|
||||
url('fonts/SamiSans-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Sami Sans';
|
||||
src: url('fonts/SamiSans-Regular.woff2') format('woff2'),
|
||||
url('fonts/SamiSans-Italic.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Sami Sans';
|
||||
src: url('fonts/SamiSans-Medium.woff2') format('woff2'),
|
||||
url('fonts/SamiSans-Medium.ttf') format('truetype');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Sami Sans';
|
||||
src: url('fonts/SamiSans-SemiBold.woff2') format('woff2'),
|
||||
url('fonts/SamiSans-SemiBold.ttf') format('truetype');
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Sami Sans';
|
||||
src: url('fonts/SamiSans-Bold.woff2') format('woff2'),
|
||||
url('fonts/SamiSans-Bold.ttf') format('truetype');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Sami Sans';
|
||||
src: url('fonts/SamiSans-ExtraBold.woff2') format('woff2'),
|
||||
url('fonts/SamiSans-ExtraBold.ttf') format('truetype');
|
||||
font-weight: 800;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Sami Sans';
|
||||
src: url('fonts/SamiSans-Black.woff2') format('woff2'),
|
||||
url('fonts/SamiSans-Black.ttf') format('truetype');
|
||||
font-weight: 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Sami Sans';
|
||||
src: url('fonts/SamiSans-Light.woff2') format('woff2'),
|
||||
url('fonts/SamiSans-Light.ttf') format('truetype');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Sami Sans';
|
||||
src: url('fonts/SamiSans-ExtraLight.woff2') format('woff2'),
|
||||
url('fonts/SamiSans-ExtraLight.ttf') format('truetype');
|
||||
font-weight: 200;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Sami Sans';
|
||||
src: url('fonts/SamiSans-Thin.woff2') format('woff2'),
|
||||
url('fonts/SamiSans-Thin.ttf') format('truetype');
|
||||
font-weight: 100;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||