Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afcccf811e | ||
|
|
0aa7244cf4 | ||
|
|
1d8919532b | ||
|
|
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,62 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Runtime state/config files (generated, not source)
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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,220 @@
|
|||||||
|
# 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]
|
||||||
|
|
||||||
|
## [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,230 @@
|
|||||||
|
# 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 vs Development Paths
|
||||||
|
|
||||||
|
### Production Files (LIVE - what actually runs)
|
||||||
|
```
|
||||||
|
C:/caddy/
|
||||||
|
├── Caddyfile # Active Caddy configuration
|
||||||
|
├── services.json # Services shown on dashboard
|
||||||
|
├── dns-credentials.json # DNS API credentials
|
||||||
|
├── config.json # DashCaddy configuration
|
||||||
|
└── sites/
|
||||||
|
└── status/ # Dashboard frontend files
|
||||||
|
└── assets/ # Logos, fonts, icons
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development Files (for editing/testing)
|
||||||
|
```
|
||||||
|
e:/CaddyCerts/sites/
|
||||||
|
├── caddy-api/
|
||||||
|
│ ├── server.js # API server source code
|
||||||
|
│ ├── app-templates.js # Docker app templates (52+ apps)
|
||||||
|
│ ├── services.json # DEV ONLY - not used in production!
|
||||||
|
│ └── ...
|
||||||
|
└── status/
|
||||||
|
└── index.html # Dashboard UI source
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker Container Mount Points
|
||||||
|
|
||||||
|
The `caddy-api` container mounts production files:
|
||||||
|
|
||||||
|
| Container Path | Host Path (Production) |
|
||||||
|
|----------------|------------------------|
|
||||||
|
| `/app/services.json` | `C:/caddy/services.json` |
|
||||||
|
| `/app/dns-credentials.json` | `C:/caddy/dns-credentials.json` |
|
||||||
|
| `/caddyfile` | `C:/caddy/Caddyfile` |
|
||||||
|
| `/app/assets` | `C:/caddy/sites/status/assets` |
|
||||||
|
|
||||||
|
## When Making Changes
|
||||||
|
|
||||||
|
### To add/remove services from dashboard:
|
||||||
|
Edit `C:/caddy/services.json` (NOT e:/CaddyCerts/sites/caddy-api/services.json)
|
||||||
|
|
||||||
|
### To modify Caddy reverse proxy rules:
|
||||||
|
Edit `C:/caddy/Caddyfile`, then reload via:
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:2019/load -H "Content-Type: text/caddyfile" --data-binary @"C:/caddy/Caddyfile"
|
||||||
|
```
|
||||||
|
|
||||||
|
### To modify API server code:
|
||||||
|
Edit `e:/CaddyCerts/sites/caddy-api/server.js`, then:
|
||||||
|
1. Copy to production: `C:/caddy/sites/caddy-api/`
|
||||||
|
2. Restart container: `docker restart caddy-api`
|
||||||
|
|
||||||
|
### To modify app templates:
|
||||||
|
Edit `e:/CaddyCerts/sites/caddy-api/app-templates.js`
|
||||||
|
(Templates are loaded at runtime, changes require container restart)
|
||||||
|
|
||||||
|
### To modify dashboard UI:
|
||||||
|
Edit `e:/CaddyCerts/sites/status/index.html`
|
||||||
|
Copy to `C:/caddy/sites/status/` for production
|
||||||
|
|
||||||
|
### To modify DashCA (CA certificate distribution):
|
||||||
|
Edit files in `e:/CaddyCerts/sites/ca/`, then:
|
||||||
|
1. Regenerate certificate formats: `cd e:/CaddyCerts/sites/ca/scripts && bash generate-all.sh`
|
||||||
|
2. Copy to production: `cp -r e:/CaddyCerts/sites/ca/* C:/caddy/sites/ca/`
|
||||||
|
3. Reload Caddy if Caddyfile changes were made
|
||||||
|
|
||||||
|
## DashCA - Certificate Authority Distribution
|
||||||
|
|
||||||
|
**Purpose**: Provides a one-click installation page for the root CA certificate, allowing users to easily trust *.sami domains on any device.
|
||||||
|
|
||||||
|
**Access**: https://ca.sami (or https://ca.yourdomain for other installations)
|
||||||
|
|
||||||
|
### File Locations
|
||||||
|
|
||||||
|
**Development (for editing):**
|
||||||
|
```
|
||||||
|
e:/CaddyCerts/sites/ca/
|
||||||
|
├── index.html # Landing page
|
||||||
|
├── root.crt, root.der # Certificate formats
|
||||||
|
├── root.mobileconfig # Apple profile
|
||||||
|
├── intermediate.crt # Intermediate CA
|
||||||
|
├── cert-info.json # Certificate metadata
|
||||||
|
├── scripts/
|
||||||
|
│ ├── install.ps1 # Windows installer
|
||||||
|
│ ├── install.sh # Linux/macOS installer
|
||||||
|
│ ├── generate-cert-info.js # Extract cert metadata
|
||||||
|
│ ├── generate-mobileconfig.js # Generate Apple profile
|
||||||
|
│ └── generate-all.sh # Regenerate all formats
|
||||||
|
└── assets/ # Icons, logos
|
||||||
|
```
|
||||||
|
|
||||||
|
**Production (served by Caddy):**
|
||||||
|
```
|
||||||
|
C:/caddy/sites/ca/
|
||||||
|
├── index.html
|
||||||
|
├── root.crt, root.der
|
||||||
|
├── root.mobileconfig
|
||||||
|
├── install.ps1, install.sh
|
||||||
|
└── assets/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Certificate Source
|
||||||
|
|
||||||
|
Caddy's built-in PKI generates certificates at:
|
||||||
|
- **Root CA**: `C:/caddy/certs/pki/authorities/local/root.crt`
|
||||||
|
- **Intermediate CA**: `C:/caddy/certs/pki/authorities/local/intermediate.crt`
|
||||||
|
|
||||||
|
**Certificate Info:**
|
||||||
|
- **CN**: Sami Home Network Root CA
|
||||||
|
- **Algorithm**: ECDSA P-256 with SHA-256
|
||||||
|
- **Valid Until**: Dec 22, 2034 (~10 years)
|
||||||
|
- **Fingerprint**: `08:98:A5:63:F5:A1:A2:58:5F:02:D7:A8:A2:54:87:E6:BC:33:96:21:29:0E`
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
|
||||||
|
DashCA is a **static site** (not Docker-based), deployed via the app selector:
|
||||||
|
1. Navigate to App Selector in dashboard
|
||||||
|
2. Find "DashCA" in Security category
|
||||||
|
3. Click Deploy
|
||||||
|
4. System automatically:
|
||||||
|
- Creates `C:/caddy/sites/ca/` directory
|
||||||
|
- Copies files from development directory
|
||||||
|
- Generates certificate formats (DER, mobileconfig)
|
||||||
|
- Adds ca.sami block to Caddyfile
|
||||||
|
- Reloads Caddy configuration
|
||||||
|
- Registers service in `services.json`
|
||||||
|
|
||||||
|
### Updating Certificates
|
||||||
|
|
||||||
|
When Caddy's CA certificate is renewed (every ~10 years):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Regenerate all certificate formats
|
||||||
|
cd e:/CaddyCerts/sites/ca/scripts
|
||||||
|
bash generate-all.sh
|
||||||
|
|
||||||
|
# 2. Update fingerprint in installation scripts
|
||||||
|
# Edit install.ps1 - update $ExpectedFingerprint
|
||||||
|
# Edit install.sh - update EXPECTED_FP
|
||||||
|
|
||||||
|
# 3. Copy to production
|
||||||
|
cp -r e:/CaddyCerts/sites/ca/* C:/caddy/sites/ca/
|
||||||
|
|
||||||
|
# 4. Notify users via dashboard or email
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
|
||||||
|
- **GET /api/ca/info** - Returns certificate metadata (name, fingerprint, expiration, etc.)
|
||||||
|
- **GET /api/health/ca** - Returns CA expiration health status
|
||||||
|
- `healthy`: >90 days remaining
|
||||||
|
- `warning`: 30-90 days remaining
|
||||||
|
- `critical`: <30 days remaining
|
||||||
|
|
||||||
|
### Caddyfile Configuration
|
||||||
|
|
||||||
|
DashCA's Caddyfile block (auto-generated on deployment):
|
||||||
|
- **Root**: `C:/caddy/sites/ca`
|
||||||
|
- **TLS**: Internal (uses Caddy's local CA)
|
||||||
|
- **MIME Types**: Proper headers for .crt, .der, .mobileconfig, .ps1, .sh files
|
||||||
|
- **SPA Fallback**: Rewrites non-file requests to /index.html
|
||||||
|
- **Cache Control**: Certificates cached for 24h, HTML not cached
|
||||||
|
|
||||||
|
### Supported Platforms
|
||||||
|
|
||||||
|
- **Windows**: PowerShell installer (installs to LocalMachine\Root store)
|
||||||
|
- **macOS**: .mobileconfig profile or command-line installer
|
||||||
|
- **Linux**: Shell installer (Debian, RedHat, Arch)
|
||||||
|
- **iOS**: .mobileconfig profile (requires manual trust in Settings)
|
||||||
|
- **Android**: Direct .crt download (installs as user certificate)
|
||||||
|
|
||||||
|
### Landing Page Features
|
||||||
|
|
||||||
|
- Automatic OS detection
|
||||||
|
- QR code for mobile access
|
||||||
|
- Certificate info display (loaded from `/api/ca/info`)
|
||||||
|
- Platform-specific installation instructions
|
||||||
|
- Copy-to-clipboard for fingerprint and commands
|
||||||
|
- Download links for all certificate formats
|
||||||
|
|
||||||
|
### Troubleshooting
|
||||||
|
|
||||||
|
**Issue**: Certificate fingerprint mismatch during installation
|
||||||
|
**Cause**: CA certificate was renewed
|
||||||
|
**Solution**: Regenerate certificates and update fingerprints in install scripts
|
||||||
|
|
||||||
|
**Issue**: *.sami sites still show warnings after CA install
|
||||||
|
**Cause**: Browser may have cached the untrusted state
|
||||||
|
**Solution**: Clear browser cache, restart browser, or visit site in incognito mode
|
||||||
|
|
||||||
|
**Issue**: iOS doesn't trust certificate after profile install
|
||||||
|
**Cause**: iOS requires manual trust enablement
|
||||||
|
**Solution**: Settings → General → About → Certificate Trust Settings → Enable trust
|
||||||
|
|
||||||
|
## Key Services
|
||||||
|
|
||||||
|
| Service | Port | Description |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| Caddy (HTTPS) | 443 | Reverse proxy |
|
||||||
|
| Caddy Admin | 2019 | Caddy API (note: NOT 2021) |
|
||||||
|
| DashCaddy API | 3001 | Dashboard backend |
|
||||||
|
| DNS2 (Primary) | 100.74.102.61:5380 | Technitium DNS |
|
||||||
|
| DNS1 (Secondary) | 192.168.254.204:5380 | Technitium DNS |
|
||||||
|
|
||||||
|
## Common Mistakes to Avoid
|
||||||
|
|
||||||
|
1. **Wrong services.json**: The API container reads from `C:/caddy/services.json`, not the development copy
|
||||||
|
2. **Caddy admin port**: It's 2019, not 2021 (check with `netstat` if unsure)
|
||||||
|
3. **DNS server**: DNS2 (100.74.102.61) is PRIMARY, DNS1 is secondary
|
||||||
|
4. **Caddyfile not reloaded**: After editing, must POST to /load endpoint or restart Caddy
|
||||||
|
|
||||||
|
## Project Info
|
||||||
|
|
||||||
|
- **Name**: DashCaddy
|
||||||
|
- **Version**: 1.0
|
||||||
|
- **Purpose**: Unified management for Docker + Caddy + DNS
|
||||||
|
- **Local TLD**: .sami
|
||||||
@@ -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,370 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
## 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,16 @@
|
|||||||
|
|
||||||
|
# Backups
|
||||||
|
.backup/
|
||||||
|
server-old.js
|
||||||
|
*.bak
|
||||||
|
*.bak2
|
||||||
|
*.bak3
|
||||||
|
*.bak4
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
error.log
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Test artifacts
|
||||||
|
coverage/
|
||||||
|
audit-routes.js
|
||||||
@@ -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.8.0
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
const { APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS } = require('../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('../crypto-utils', () => ({
|
||||||
|
loadOrCreateKey: jest.fn(() => mockFixedKey),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../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('../auth-manager');
|
||||||
|
const credentialManager = require('../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,784 @@
|
|||||||
|
// Backup Manager Tests
|
||||||
|
// Validates backup/restore lifecycle for DashCaddy configurations
|
||||||
|
|
||||||
|
jest.mock('fs');
|
||||||
|
jest.mock('child_process');
|
||||||
|
jest.mock('../credential-manager', () => ({
|
||||||
|
exportBackup: jest.fn().mockReturnValue({ encrypted: 'cred-data' }),
|
||||||
|
importBackup: jest.fn()
|
||||||
|
}));
|
||||||
|
jest.mock('../resource-monitor', () => ({
|
||||||
|
exportStats: jest.fn().mockReturnValue({ stats: [{ cpu: 10 }] }),
|
||||||
|
importStats: jest.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const credentialManager = require('../credential-manager');
|
||||||
|
const resourceMonitor = require('../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('../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 first character of the IV
|
||||||
|
const str = encrypted.toString();
|
||||||
|
const tampered = Buffer.from('X' + str.substring(1));
|
||||||
|
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,347 @@
|
|||||||
|
// Mock dependencies before requiring the module
|
||||||
|
jest.mock('../keychain-manager', () => ({
|
||||||
|
available: false,
|
||||||
|
store: jest.fn().mockResolvedValue(false),
|
||||||
|
retrieve: jest.fn().mockResolvedValue(null),
|
||||||
|
delete: jest.fn().mockResolvedValue(true),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../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('../keychain-manager');
|
||||||
|
cryptoUtils = require('../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('../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('../keychain-manager');
|
||||||
|
keychainManager.available = true;
|
||||||
|
keychainManager.store.mockResolvedValue(true);
|
||||||
|
credentialManager = require('../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('../keychain-manager');
|
||||||
|
keychainManager.available = true;
|
||||||
|
keychainManager.store.mockResolvedValue(false);
|
||||||
|
cryptoUtils = require('../crypto-utils');
|
||||||
|
credentialManager = require('../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('../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,340 @@
|
|||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
// Mock crypto-utils to provide a predictable signing key
|
||||||
|
const mockFixedKey = Buffer.alloc(32, 'test-key-material');
|
||||||
|
jest.mock('../crypto-utils', () => ({
|
||||||
|
loadOrCreateKey: jest.fn(() => mockFixedKey),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const {
|
||||||
|
CSRF_TOKEN_LENGTH,
|
||||||
|
CSRF_COOKIE_NAME,
|
||||||
|
CSRF_HEADER_NAME,
|
||||||
|
generateToken,
|
||||||
|
signToken,
|
||||||
|
parseCookie,
|
||||||
|
csrfCookieMiddleware,
|
||||||
|
csrfValidationMiddleware,
|
||||||
|
renewCSRFToken
|
||||||
|
} = require('../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';
|
||||||
|
|
||||||
|
const excludedPaths = ['/api/v1/totp/verify', '/api/v1/totp/setup', '/health', '/api/v1/health'];
|
||||||
|
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,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('../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('../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('../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('../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,190 @@
|
|||||||
|
jest.mock('../error-logger', () => ({
|
||||||
|
logError: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { asyncHandler, errorMiddleware, notFoundHandler } = require('../error-handler');
|
||||||
|
const {
|
||||||
|
AppError,
|
||||||
|
ValidationError,
|
||||||
|
AuthenticationError,
|
||||||
|
NotFoundError,
|
||||||
|
RateLimitError,
|
||||||
|
DockerError,
|
||||||
|
} = require('../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('asyncHandler', () => {
|
||||||
|
it('calls the wrapped function', async () => {
|
||||||
|
const fn = jest.fn().mockResolvedValue();
|
||||||
|
const wrapped = asyncHandler(fn);
|
||||||
|
await wrapped(req, res, next);
|
||||||
|
expect(fn).toHaveBeenCalledWith(req, res, next);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls next(err) on rejected promise', async () => {
|
||||||
|
const error = new Error('async fail');
|
||||||
|
const fn = jest.fn().mockRejectedValue(error);
|
||||||
|
const wrapped = asyncHandler(fn);
|
||||||
|
await wrapped(req, res, next);
|
||||||
|
expect(next).toHaveBeenCalledWith(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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('../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('../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('../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('../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,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('../../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('../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('../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,116 @@
|
|||||||
|
const { paginate, parsePaginationParams, DEFAULT_LIMIT, MAX_LIMIT } = require('../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('../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,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('../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,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('../../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('../../fs-helpers', () => ({
|
||||||
|
exists: jest.fn().mockResolvedValue(true),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../url-resolver', () => ({
|
||||||
|
resolveServiceUrl: jest.fn((id) => `https://${id}.test`),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../pagination', () => ({
|
||||||
|
paginate: jest.fn((data, params) => ({ data, pagination: null })),
|
||||||
|
parsePaginationParams: jest.fn(() => null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { exists } = require('../../fs-helpers');
|
||||||
|
const { resolveServiceUrl } = require('../../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.status).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.status).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.status).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.status).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.status).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(200);
|
||||||
|
expect(res.body.status).toBe('error');
|
||||||
|
expect(res.body.message).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(200);
|
||||||
|
expect(res.body.status).toBe('error');
|
||||||
|
expect(res.body.message).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('../../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('../../input-validator', () => ({
|
||||||
|
validateServiceConfig: jest.fn(),
|
||||||
|
isValidPort: jest.fn(p => p >= 1 && p <= 65535),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../fs-helpers', () => ({
|
||||||
|
exists: jest.fn().mockResolvedValue(true),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../url-resolver', () => ({
|
||||||
|
resolveServiceUrl: jest.fn((id) => `https://${id}.test`),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../pagination', () => ({
|
||||||
|
paginate: jest.fn((data, params) => ({ data, pagination: null })),
|
||||||
|
parsePaginationParams: jest.fn(() => null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../response-helpers', () => ({
|
||||||
|
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('../../fs-helpers');
|
||||||
|
const { validateServiceConfig } = require('../../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 array 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([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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('../../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,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('../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,122 @@
|
|||||||
|
const { resolveServiceUrl } = require('../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;
|
||||||
|
}
|
||||||