[grade=pending] QA sprint: commit 103 at-risk files from multi-agent sprint work

Committed by Hermes autonomous QA sprint 2026-08-13.
These files were modified during the Aug 12 sprint but never committed.
This commit is contained in:
Krystie
2026-08-12 17:34:10 -07:00
parent 0bf4406253
commit 503de258b8
105 changed files with 14057 additions and 2632 deletions
@@ -0,0 +1,411 @@
/**
* End-to-end billing integration test.
*
* Exercises the FULL purchase → fulfillment → activation → Pro unlock flow:
*
* 1. POST /api/v1/billing/checkout → mock Stripe SDK → session { id, url }
* 2. Simulate webhook delivery → bridge.handleWebhook() with a signed
* checkout.session.completed payload
* 3. GET /api/v1/billing/lookup/:sessionId → verify license code returned
* 4. POST /api/v1/license/activate → verify code activates, Pro unlocks
*
* The bridge and the API billing routes communicate through a SHARED
* fulfillment-store file (the production IPC channel — a bind-mounted JSON
* file). This test wires both sides to the same tmp file so the lookup
* endpoint sees the license the bridge persisted, exactly as in production.
*
* The REAL license-keygen + LicenseManager are used (no HMAC mock) so the
* code generated by the bridge is cryptographically valid and activates
* through the real LicenseManager.verifyCode() path. Only Stripe's network
* surface and nodemailer are mocked.
*/
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const crypto = require('crypto');
const express = require('express');
const request = require('supertest');
// ── jest.mock must be hoisted before any require() ─────────────────────────
// Mock nodemailer so the bridge never opens a real SMTP connection. SMTP is
// left unconfigured (no SMTP_HOST/SMTP_FROM) so deliverCode() falls back to
// dev-console mode — the documented dev/test path where the license is marked
// `delivered` without actually sending email.
jest.mock('nodemailer', () => ({
createTransport: jest.fn(() => ({ sendMail: jest.fn() })),
}));
// ── Isolated tmp state (set BEFORE requiring the bridge + routes) ──────────
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'dc-e2e-billing-'));
// Shared fulfillment-store file — the IPC channel between bridge and API.
process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE = path.join(TMP, 'stripe-fulfillments.json');
process.env.STRIPE_BRIDGE_STATE_DIR = TMP;
process.env.STRIPE_BRIDGE_EVENTS_FILE = path.join(TMP, 'stripe-events.json');
process.env.STRIPE_WEBHOOK_SECRET = 'whsec_e2e_' + crypto.randomBytes(8).toString('hex');
// Configure Stripe products so the catalog + stripe-client can resolve price IDs.
process.env.STRIPE_SECRET_KEY = 'sk_test_e2e';
process.env.STRIPE_PRICE_PRO_30D = 'price_30d_e2e';
process.env.STRIPE_PRICE_PRO_90D = 'price_90d_e2e';
process.env.STRIPE_PRICE_PRO_180D = 'price_180d_e2e';
process.env.STRIPE_PRICE_PRO_365D = 'price_365d_e2e';
process.env.STRIPE_PUBLIC_ORIGIN = 'https://status.test';
// No SMTP → bridge uses dev-console delivery (license marked delivered, no email).
delete process.env.SMTP_HOST;
delete process.env.SMTP_FROM;
// ── Real license-keygen with a known master secret ─────────────────────────
// We write a real secret file so the bridge's loadSecret() + generateCodes()
// produce HMAC-valid codes that the LicenseManager can verify with the SAME
// secret. This makes the activation step exercise the real cryptographic path.
const E2E_SECRET = crypto.randomBytes(32).toString('hex');
const SECRET_FILE = path.join(TMP, '.license-secret');
fs.writeFileSync(SECRET_FILE, E2E_SECRET, { mode: 0o600 });
process.env.LICENSE_SECRET_FILE = SECRET_FILE;
// Real keygen — no mock. The counter file is isolated to the tmp dir.
process.env.LICENSE_COUNTER_FILE = path.join(TMP, '.license-counter');
// Now require modules (after env + mock setup).
const keygen = require('../../license-keygen');
const catalog = require('../../src/billing/catalog');
const stripeClient = require('../../src/billing/stripe-client');
const bridge = require('../../scripts/stripe-license-bridge');
const billingRoutesFactory = require('../../routes/billing');
const licenseRoutesFactory = require('../../routes/license');
const { LicenseManager } = require('../../src/managers/license-manager');
const { createFulfillmentStore } = require('../../src/billing/fulfillment-store');
// ── Test app: mounts billing + license routes the same way app.js does ─────
function makeApp(licenseManager) {
const app = express();
app.use(express.json());
function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
app.use('/api/v1/billing', billingRoutesFactory({ asyncHandler }));
app.use('/api/v1/license', licenseRoutesFactory({ licenseManager, asyncHandler }));
// Jest/express error handler — surfaces route errors as JSON so supertest
// can assert on the body.
app.use((err, req, res, next) => {
const status = err.statusCode || 500;
res.status(status).json({ success: false, error: err.message });
});
return app;
}
// ── Helpers ────────────────────────────────────────────────────────────────
/**
* Build a signed Stripe webhook payload for checkout.session.completed.
*/
function buildSignedWebhook(sessionId, productId, customerEmail, opts = {}) {
const product = catalog.getProduct(productId);
const event = {
id: opts.eventId || `evt_e2e_${crypto.randomBytes(6).toString('hex')}`,
type: opts.type || 'checkout.session.completed',
data: {
object: {
id: sessionId,
customer_email: customerEmail,
customer_details: { email: customerEmail },
payment_status: 'paid',
amount_total: product ? product.amountCents : 0,
currency: 'usd',
metadata: { productId, product: 'dashcaddy-pro' },
},
},
};
const rawBody = Buffer.from(JSON.stringify(event));
const ts = Math.floor(Date.now() / 1000);
const sig = crypto.createHmac('sha256', process.env.STRIPE_WEBHOOK_SECRET)
.update(`${ts}.${rawBody}`, 'utf8').digest('hex');
return { rawBody, signatureHeader: `t=${ts},v1=${sig}`, event };
}
/**
* Install a mock Stripe SDK that returns a checkout session with a
* caller-chosen id + url. Captures the params passed to sessions.create().
*/
function installMockStripe(sessionId, sessionUrl) {
let capturedParams;
const mockStripe = jest.fn().mockReturnValue({
checkout: {
sessions: {
create: jest.fn().mockImplementation(async (params) => {
capturedParams = params;
return { id: sessionId, url: sessionUrl };
}),
},
},
});
stripeClient._setStripeSdk(mockStripe);
return { capturedParams: () => capturedParams };
}
// ── Cleanup ────────────────────────────────────────────────────────────────
afterAll(() => {
stripeClient._setStripeSdk(null);
try { fs.rmSync(TMP, { recursive: true, force: true }); } catch (_) { /* best effort */ }
});
// ═══════════════════════════════════════════════════════════════════════════
// THE END-TO-END FLOW
// ═══════════════════════════════════════════════════════════════════════════
describe('end-to-end billing flow: checkout → webhook → lookup → activate → Pro', () => {
const PRODUCT_ID = 'pro-90d';
const CUSTOMER_EMAIL = 'alice@example.com';
const SESSION_ID = `cs_e2e_${crypto.randomBytes(6).toString('hex')}`;
const CHECKOUT_URL = `https://checkout.stripe.com/c/pay/${SESSION_ID}`;
let app;
let licenseManager;
let activationCode; // captured during the flow
beforeAll(() => {
// Real LicenseManager, configured with the same secret the bridge uses.
licenseManager = new LicenseManager(
{
store: jest.fn().mockResolvedValue(undefined),
retrieve: jest.fn().mockResolvedValue(null),
delete: jest.fn().mockResolvedValue(undefined),
},
path.join(TMP, 'config.json'),
{ info: () => {}, warn: () => {}, error: () => {} }
);
// loadSecret reads the file and stores it as masterSecretHash for verifyCode().
licenseManager.loadSecret(SECRET_FILE);
app = makeApp(licenseManager);
});
// ── Step 1: POST /api/v1/billing/checkout ──────────────────────────────
test('Step 1: checkout creates a Stripe session via the mock SDK', async () => {
const stripe = installMockStripe(SESSION_ID, CHECKOUT_URL);
const res = await request(app)
.post('/api/v1/billing/checkout')
.send({ productId: PRODUCT_ID, customerEmail: CUSTOMER_EMAIL })
.expect(200);
expect(res.body.success).toBe(true);
expect(res.body.data.id).toBe(SESSION_ID);
expect(res.body.data.url).toBe(CHECKOUT_URL);
// The mock Stripe SDK was called with the correct product + metadata.
const params = stripe.capturedParams();
expect(params.mode).toBe('payment');
expect(params.metadata.productId).toBe(PRODUCT_ID);
expect(params.line_items[0].price).toBe('price_90d_e2e');
expect(params.customer_email).toBe(CUSTOMER_EMAIL);
});
// ── Step 2: Simulate Stripe webhook delivery ───────────────────────────
test('Step 2: webhook generates + persists + delivers the license', async () => {
const { rawBody, signatureHeader, event } = buildSignedWebhook(
SESSION_ID, PRODUCT_ID, CUSTOMER_EMAIL
);
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
expect(result.status).toBe(200);
expect(result.body.delivered).toBe(true);
expect(result.body.productId).toBe(PRODUCT_ID);
expect(result.body.durationDays).toBe(90);
expect(result.body.codeId).toBeTruthy();
expect(result.body.deliveredVia).toBe('dev-console');
// Capture the code for subsequent steps.
const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
const record = store.readBySession(SESSION_ID);
expect(record).toBeTruthy();
expect(record.status).toBe('delivered');
expect(record.code).toBeTruthy();
activationCode = record.code;
});
// ── Step 3: GET /api/v1/billing/lookup/:sessionId ──────────────────────
test('Step 3: lookup returns the delivered license code', async () => {
const res = await request(app)
.get(`/api/v1/billing/lookup/${SESSION_ID}`)
.expect(200);
expect(res.body.success).toBe(true);
expect(res.body.data.status).toBe('delivered');
expect(res.body.data.code).toBe(activationCode);
expect(res.body.data.codeId).toBeTruthy();
expect(res.body.data.productId).toBe(PRODUCT_ID);
expect(res.body.data.durationDays).toBe(90);
expect(res.body.data.deliveredVia).toBe('dev-console');
// Bearer-style secret — must never be cached.
expect(res.headers['cache-control']).toBe('no-store');
});
// ── Step 4: POST /api/v1/license/activate → Pro unlock ─────────────────
test('Step 4: activate the license → Pro tier unlocks', async () => {
expect(activationCode).toBeTruthy();
const res = await request(app)
.post('/api/v1/license/activate')
.send({ code: activationCode })
.expect(200);
expect(res.body.success).toBe(true);
expect(res.body.license).toBeDefined();
expect(res.body.license.active).toBe(true);
expect(res.body.license.tier).toBe('premium');
expect(res.body.license.durationDays).toBe(90);
expect(res.body.license.expired).toBe(false);
// The LicenseManager itself now reports Pro (this is what gates features
// elsewhere in the app via licenseManager.isPro()).
expect(licenseManager.isPro()).toBe(true);
expect(licenseManager.hasFeature('sso')).toBe(true);
});
// ── Bonus: GET /api/v1/license/status reflects the active Pro license ──
test('Step 5: license status confirms Pro is active', async () => {
const res = await request(app)
.get('/api/v1/license/status')
.expect(200);
expect(res.body.success).toBe(true);
expect(res.body.license.active).toBe(true);
expect(res.body.license.tier).toBe('premium');
expect(res.body.license.expired).toBe(false);
expect(res.body.license.features).toEqual(
expect.arrayContaining(['sso', 'recipes', 'swarm'])
);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// Additional e2e scenarios
// ═══════════════════════════════════════════════════════════════════════════
describe('e2e: lookup returns 404 before webhook delivers the license', () => {
test('lookup before webhook → 404 not found', async () => {
const app = makeApp(null);
const sessionId = `cs_notyet_${crypto.randomBytes(4).toString('hex')}`;
const res = await request(app)
.get(`/api/v1/billing/lookup/${sessionId}`)
.expect(404);
expect(res.body.success).toBe(false);
});
});
describe('e2e: each catalog product flows through to a valid activatable license', () => {
// Use a fresh app + licenseManager per product to avoid activation conflicts.
for (const product of catalog.PRODUCTS) {
test(`product ${product.id} (${product.durationDays}d) activates and unlocks Pro`, async () => {
const sessionId = `cs_e2e_${product.id}_${crypto.randomBytes(4).toString('hex')}`;
const email = `buyer_${product.id}@example.com`;
const lm = new LicenseManager(
{
store: jest.fn().mockResolvedValue(undefined),
retrieve: jest.fn().mockResolvedValue(null),
delete: jest.fn().mockResolvedValue(undefined),
},
path.join(TMP, `config-${product.id}.json`),
{ info: () => {}, warn: () => {}, error: () => {} }
);
lm.loadSecret(SECRET_FILE);
const app = makeApp(lm);
// Checkout
installMockStripe(sessionId, `https://checkout.stripe.com/c/pay/${sessionId}`);
const checkoutRes = await request(app)
.post('/api/v1/billing/checkout')
.send({ productId: product.id, customerEmail: email })
.expect(200);
expect(checkoutRes.body.data.id).toBe(sessionId);
// Webhook
const { rawBody, signatureHeader } = buildSignedWebhook(sessionId, product.id, email);
const whResult = await bridge.handleWebhook({ rawBody, signatureHeader });
expect(whResult.status).toBe(200);
expect(whResult.body.delivered).toBe(true);
expect(whResult.body.durationDays).toBe(product.durationDays);
// Lookup
const lookupRes = await request(app)
.get(`/api/v1/billing/lookup/${sessionId}`)
.expect(200);
expect(lookupRes.body.data.status).toBe('delivered');
expect(lookupRes.body.data.code).toBeTruthy();
const code = lookupRes.body.data.code;
// Activate → Pro
const activateRes = await request(app)
.post('/api/v1/license/activate')
.send({ code })
.expect(200);
expect(activateRes.body.license.tier).toBe('premium');
expect(activateRes.body.license.durationDays).toBe(product.durationDays);
expect(lm.isPro()).toBe(true);
});
}
});
describe('e2e: webhook idempotency — duplicate delivery reuses the same license', () => {
test('a second webhook for the same session does not mint a new code', async () => {
const sessionId = `cs_e2e_dedup_${crypto.randomBytes(4).toString('hex')}`;
const productId = 'pro-30d';
const email = 'dedup@example.com';
// First delivery.
const payload1 = buildSignedWebhook(sessionId, productId, email);
const r1 = await bridge.handleWebhook({
rawBody: payload1.rawBody,
signatureHeader: payload1.signatureHeader,
});
expect(r1.status).toBe(200);
expect(r1.body.delivered).toBe(true);
const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
const firstCode = store.readBySession(sessionId).code;
expect(firstCode).toBeTruthy();
// Same eventId (Stripe retry) → layer-1 idempotency, no regeneration.
const r2 = await bridge.handleWebhook({
rawBody: payload1.rawBody,
signatureHeader: payload1.signatureHeader,
});
expect(r2.status).toBe(200);
expect(r2.body.deduplicated).toBe(true);
const secondCode = store.readBySession(sessionId).code;
expect(secondCode).toBe(firstCode);
});
});
describe('e2e: the license code generated by the bridge verifies via the real keygen', () => {
test('bridge-generated code is cryptographically valid', async () => {
const sessionId = `cs_e2e_crypto_${crypto.randomBytes(4).toString('hex')}`;
const { rawBody, signatureHeader } = buildSignedWebhook(sessionId, 'pro-365d', 'crypto@example.com');
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
expect(result.status).toBe(200);
const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
const code = store.readBySession(sessionId).code;
// verifyCode with the SAME secret the bridge used — this is exactly what
// LicenseManager._validateOffline does during activation.
const verification = keygen.verifyCode(E2E_SECRET, code);
expect(verification.valid).toBe(true);
expect(verification.durationDays).toBe(365);
expect(verification.expired).toBe(false);
});
});
@@ -151,7 +151,8 @@ describe('config/migrations', () => {
const mtimeBefore = fs.statSync(configFile).mtimeMs;
// Wait a tick
const start = Date.now();
while (Date.now() - start < 50) {} // 50ms busy-wait
let spin = start;
while (Date.now() - spin < 50) { spin = Date.now(); } // 50ms busy-wait
loadAndMigrate(configFile, null);
+11 -10
View File
@@ -156,18 +156,19 @@ describe('Error Handler', () => {
});
it('logs non-operational errors as FATAL', () => {
const origError = console.error;
console.error = jest.fn();
const stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
const err = new Error('programming bug');
errorMiddleware(err, req, res, next);
try {
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;
const calls = stderrSpy.mock.calls.map(c => String(c[0]));
const fatalLine = calls.find(l => l.includes('FATAL'));
expect(fatalLine).toBeDefined();
expect(fatalLine).toContain('programming bug');
} finally {
stderrSpy.mockRestore();
}
});
});
@@ -0,0 +1,91 @@
/**
* DC-071: Error tracker tests
*/
const errorTracker = require('../src/utilities/error-tracker');
describe('DC-071: Error Tracker', () => {
beforeEach(() => {
// Reset to clean state
errorTracker.dsn = null;
errorTracker.enabled = false;
});
describe('init()', () => {
it('is disabled without DSN', () => {
const enabled = errorTracker.init({});
expect(enabled).toBe(false);
expect(errorTracker.enabled).toBe(false);
});
it('enables with DSN', () => {
const enabled = errorTracker.init({
dsn: 'https://abc123@sentry.io/123',
release: '1.15.0',
});
expect(enabled).toBe(true);
expect(errorTracker.enabled).toBe(true);
expect(errorTracker.release).toBe('1.15.0');
});
it('reads DSN from env', () => {
process.env.ERROR_TRACKING_DSN = 'https://key@sentry.io/456';
const enabled = errorTracker.init({});
expect(enabled).toBe(true);
delete process.env.ERROR_TRACKING_DSN;
});
});
describe('capture()', () => {
it('returns undefined when disabled', () => {
const result = errorTracker.capture(new Error('test'));
expect(result).toBeUndefined();
});
it('returns event ID when enabled', () => {
errorTracker.init({ dsn: 'https://key@sentry.io/123' });
const eventId = errorTracker.capture(new Error('test'));
expect(eventId).toBeTruthy();
expect(typeof eventId).toBe('string');
});
it('handles null error gracefully', () => {
errorTracker.init({ dsn: 'https://key@sentry.io/123' });
const result = errorTracker.capture(null);
expect(result).toBeUndefined();
});
});
describe('captureMessage()', () => {
it('returns undefined when disabled', () => {
const result = errorTracker.captureMessage('test');
expect(result).toBeUndefined();
});
it('returns event ID when enabled', () => {
errorTracker.init({ dsn: 'https://key@sentry.io/123' });
const eventId = errorTracker.captureMessage('test info', 'info');
expect(eventId).toBeTruthy();
});
});
describe('middleware()', () => {
it('calls next(err) after capturing', () => {
errorTracker.init({ dsn: 'https://key@sentry.io/123' });
const middleware = errorTracker.middleware();
const err = new Error('middleware test');
const req = { url: '/test', method: 'GET', headers: {}, path: '/test' };
const res = {};
let nextCalled = false;
let nextArg = null;
middleware(err, req, res, (e) => { nextCalled = true; nextArg = e; });
expect(nextCalled).toBe(true);
expect(nextArg).toBe(err);
});
});
describe('flush()', () => {
it('resolves without error', async () => {
await expect(errorTracker.flush(100)).resolves.toBeUndefined();
});
});
});
+100
View File
@@ -0,0 +1,100 @@
/**
* DC-077: Tests for the i18n system
*/
const i18n = require('../src/utilities/i18n');
describe('DC-077: i18n system', () => {
describe('t() translation function', () => {
it('translates keys in English by default', () => {
expect(i18n.t('dashboard.title')).toBe('Dashboard');
expect(i18n.t('action.start')).toBe('Start');
});
it('translates keys in Spanish', () => {
expect(i18n.t('dashboard.title', 'es')).toBe('Panel de control');
expect(i18n.t('action.start', 'es')).toBe('Iniciar');
});
it('translates keys in French', () => {
expect(i18n.t('dashboard.title', 'fr')).toBe('Tableau de bord');
expect(i18n.t('action.stop', 'fr')).toBe('Arrêter');
});
it('translates keys in German', () => {
expect(i18n.t('dashboard.title', 'de')).toBe('Dashboard');
expect(i18n.t('action.delete', 'de')).toBe('Löschen');
});
it('translates keys in Arabic', () => {
expect(i18n.t('dashboard.title', 'ar')).toBe('لوحة التحكم');
expect(i18n.t('action.start', 'ar')).toBe('تشغيل');
});
it('falls back to English for unsupported language', () => {
expect(i18n.t('dashboard.title', 'zh')).toBe('Dashboard');
});
it('falls back to key if not found in any language', () => {
expect(i18n.t('nonexistent.key.xyz')).toBe('nonexistent.key.xyz');
});
});
describe('getSupportedLanguages()', () => {
it('returns array of language codes', () => {
const langs = i18n.getSupportedLanguages();
expect(langs).toContain('en');
expect(langs).toContain('es');
expect(langs).toContain('fr');
expect(langs).toContain('de');
expect(langs).toContain('ar');
expect(langs.length).toBeGreaterThanOrEqual(5);
});
});
describe('isSupported()', () => {
it('returns true for supported languages', () => {
expect(i18n.isSupported('en')).toBe(true);
expect(i18n.isSupported('fr')).toBe(true);
});
it('returns false for unsupported languages', () => {
expect(i18n.isSupported('zh')).toBe(false);
expect(i18n.isSupported('ja')).toBe(false);
});
});
describe('detectLanguage()', () => {
it('detects from Accept-Language header', () => {
expect(i18n.detectLanguage('es-ES,es;q=0.9,en;q=0.8')).toBe('es');
expect(i18n.detectLanguage('fr-FR,fr;q=0.9')).toBe('fr');
expect(i18n.detectLanguage('de-DE,de;q=0.9,en;q=0.8')).toBe('de');
});
it('handles quality values correctly', () => {
expect(i18n.detectLanguage('en;q=0.9,fr;q=1.0')).toBe('fr');
});
it('defaults to English for no header', () => {
expect(i18n.detectLanguage(null)).toBe('en');
expect(i18n.detectLanguage(undefined)).toBe('en');
expect(i18n.detectLanguage('')).toBe('en');
});
it('defaults to English for unsupported languages', () => {
expect(i18n.detectLanguage('zh-CN,zh;q=0.9')).toBe('en');
expect(i18n.detectLanguage('ja-JP,ja;q=0.9')).toBe('en');
});
it('strips region codes before matching', () => {
expect(i18n.detectLanguage('en-US,en;q=0.9')).toBe('en');
expect(i18n.detectLanguage('de-AT,de;q=0.9')).toBe('de');
});
});
describe('RTL support', () => {
it('Arabic is in supported languages', () => {
expect(i18n.isSupported('ar')).toBe(true);
expect(i18n.t('dashboard.title', 'ar')).toBeTruthy();
});
});
});
+2 -1
View File
@@ -197,7 +197,8 @@ describe('Metrics (singleton)', () => {
const before = metrics.startTime;
// Sleep a tick so Date.now() moves forward
const start = Date.now();
while (Date.now() - start < 5) {} // ~5ms busy-wait
let spin = start;
while (Date.now() - spin < 5) { spin = Date.now(); } // ~5ms busy-wait
metrics.reset();
expect(metrics.startTime).toBeGreaterThanOrEqual(before);
const summary = metrics.getSummary();
@@ -88,6 +88,13 @@ describe('Platform Paths — cross-platform path resolution', () => {
}
});
it('passes through non-drive-letter strings unchanged on any platform', () => {
const paths = loadPaths();
// Plain strings without drive letters should pass through unchanged
expect(paths.toDockerMountPath('relative/path')).toBe('relative/path');
expect(paths.toDockerMountPath('plainstring')).toBe('plainstring');
});
if (process.platform === 'win32') {
it('converts Windows drive paths to Docker mount format', () => {
const paths = loadPaths();
@@ -0,0 +1,155 @@
/**
* DC-080: Plugin manager tests
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const { PluginManager } = require('../../src/plugins/plugin-manager');
describe('DC-080: Plugin Manager', () => {
let tmpDir, manager;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc-plugins-'));
manager = new PluginManager({
dataDir: tmpDir,
log: { info: jest.fn(), error: jest.fn() },
});
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe('loadAll()', () => {
it('creates plugin directory if it does not exist', async () => {
const pluginDir = path.join(tmpDir, 'plugins');
expect(fs.existsSync(pluginDir)).toBe(false);
await manager.loadAll();
expect(fs.existsSync(pluginDir)).toBe(true);
});
it('loads successfully with empty plugin dir', async () => {
await manager.loadAll();
expect(manager.plugins.size).toBe(0);
expect(manager.loaded).toBe(true);
});
it('skips hidden directories', async () => {
const hiddenDir = path.join(tmpDir, 'plugins', '.hidden');
fs.mkdirSync(hiddenDir, { recursive: true });
await manager.loadAll();
expect(manager.plugins.size).toBe(0);
});
});
describe('loadOne()', () => {
it('loads a plugin with valid manifest', async () => {
const pluginDir = path.join(tmpDir, 'plugins', 'test-plugin');
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(
path.join(pluginDir, 'manifest.json'),
JSON.stringify({
name: 'test-plugin',
version: '1.0.0',
description: 'A test plugin',
})
);
await manager.loadOne(pluginDir);
expect(manager.plugins.has('test-plugin')).toBe(true);
});
it('throws if manifest.json is missing', async () => {
const pluginDir = path.join(tmpDir, 'plugins', 'no-manifest');
fs.mkdirSync(pluginDir, { recursive: true });
await expect(manager.loadOne(pluginDir)).rejects.toThrow('manifest.json');
});
it('throws if manifest lacks name or version', async () => {
const pluginDir = path.join(tmpDir, 'plugins', 'invalid');
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(
path.join(pluginDir, 'manifest.json'),
JSON.stringify({ description: 'no name' })
);
await expect(manager.loadOne(pluginDir)).rejects.toThrow('name and version');
});
it('throws on duplicate plugin name', async () => {
const pluginDir = path.join(tmpDir, 'plugins', 'dup');
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(
path.join(pluginDir, 'manifest.json'),
JSON.stringify({ name: 'dup', version: '1.0.0' })
);
await manager.loadOne(pluginDir);
await expect(manager.loadOne(pluginDir)).rejects.toThrow('already loaded');
});
});
describe('unload()', () => {
it('unloads a loaded plugin', async () => {
const pluginDir = path.join(tmpDir, 'plugins', 'removable');
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(
path.join(pluginDir, 'manifest.json'),
JSON.stringify({ name: 'removable', version: '1.0.0' })
);
await manager.loadOne(pluginDir);
expect(manager.plugins.has('removable')).toBe(true);
manager.unload('removable');
expect(manager.plugins.has('removable')).toBe(false);
});
it('returns false for unknown plugin', () => {
expect(manager.unload('nonexistent')).toBe(false);
});
});
describe('list()', () => {
it('returns empty array when no plugins', () => {
expect(manager.list()).toEqual([]);
});
it('returns plugin metadata', async () => {
const pluginDir = path.join(tmpDir, 'plugins', 'listed');
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(
path.join(pluginDir, 'manifest.json'),
JSON.stringify({ name: 'listed', version: '2.0.0', description: 'Test' })
);
await manager.loadOne(pluginDir);
const list = manager.list();
expect(list).toHaveLength(1);
expect(list[0].name).toBe('listed');
expect(list[0].version).toBe('2.0.0');
});
});
describe('executeHook()', () => {
it('returns empty results when no plugins have the hook', async () => {
await manager.loadAll();
const results = await manager.executeHook('service:health-check');
expect(results).toEqual([]);
});
});
describe('getWidgets()', () => {
it('returns empty array by default', () => {
expect(manager.getWidgets()).toEqual([]);
});
});
describe('getServiceTypes()', () => {
it('returns empty array by default', () => {
expect(manager.getServiceTypes()).toEqual([]);
});
});
});
@@ -0,0 +1,135 @@
/**
* DC-106 + DC-108: Caddycode + Fleet endpoint tests
*/
const express = require('express');
const request = require('supertest');
function createCaddycodeApp() {
const app = express();
app.use(express.json());
const routes = require('../../routes/caddycode');
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
app.use('/api/v1', routes({ asyncHandler: wrap }));
return app;
}
function createFleetApp(log) {
const app = express();
app.use(express.json());
const routes = require('../../routes/fleet');
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
app.use('/api/v1', routes({ log: log || { info: jest.fn(), error: jest.fn() }, asyncHandler: wrap }));
return app;
}
describe('DC-106: Caddyfile-as-Code', () => {
it('POST /generate creates Caddyfile from config', async () => {
const app = createCaddycodeApp();
const res = await request(app)
.post('/api/v1/caddycode/generate')
.send({
domain: 'app.example.com',
upstream: 'localhost:8080',
websocket: true,
cors: true,
});
expect(res.status).toBe(200);
expect(res.body.caddyfile).toContain('app.example.com');
expect(res.body.caddyfile).toContain('reverse_proxy');
expect(res.body.caddyfile).toContain('Access-Control-Allow-Origin');
});
it('POST /generate returns 400 without domain', async () => {
const app = createCaddycodeApp();
const res = await request(app)
.post('/api/v1/caddycode/generate')
.send({ upstream: 'localhost:8080' });
expect(res.status).toBe(400);
});
it('POST /validate finds unbalanced braces', async () => {
const app = createCaddycodeApp();
const res = await request(app)
.post('/api/v1/caddycode/validate')
.send({ caddyfile: 'app.com {\n reverse_proxy localhost:8080\n' });
expect(res.status).toBe(200);
expect(res.body.valid).toBe(false);
expect(res.body.issues[0]).toContain('Unbalanced');
});
it('POST /validate passes for valid Caddyfile', async () => {
const app = createCaddycodeApp();
const res = await request(app)
.post('/api/v1/caddycode/validate')
.send({ caddyfile: 'app.com {\n reverse_proxy localhost:8080\n}' });
expect(res.status).toBe(200);
expect(res.body.valid).toBe(true);
});
it('GET /templates returns preset configs', async () => {
const app = createCaddycodeApp();
const res = await request(app).get('/api/v1/caddycode/templates');
expect(res.status).toBe(200);
expect(Object.keys(res.body.templates).length).toBeGreaterThanOrEqual(5);
});
});
describe('DC-108: Fleet Management', () => {
beforeEach(() => {
process.env.FLEET_HOSTS_FILE = `/tmp/fleet-test-${Date.now()}-${Math.random().toString(36).slice(2)}.json`;
});
afterEach(() => {
try { require('fs').unlinkSync(process.env.FLEET_HOSTS_FILE); } catch { /* ok */ }
});
it('GET /hosts returns empty list initially', async () => {
const app = createFleetApp();
const res = await request(app).get('/api/v1/fleet/hosts');
expect(res.status).toBe(200);
expect(res.body.total).toBe(0);
});
it('POST /hosts registers a new host', async () => {
const app = createFleetApp();
const res = await request(app)
.post('/api/v1/fleet/hosts')
.send({ name: 'Test Host', hostname: '192.168.1.100', apiKey: 'dk_test_12345', tags: ['prod'] });
expect(res.status).toBe(201);
expect(res.body.host.name).toBe('Test Host');
expect(res.body.host.apiKey).toBe('***'); // Key is masked
expect(res.body.host.apiKeyHash).toBeTruthy();
expect(res.body.host.id).toBeTruthy();
});
it('POST /hosts returns 400 without name', async () => {
const app = createFleetApp();
const res = await request(app)
.post('/api/v1/fleet/hosts')
.send({ hostname: '192.168.1.100' });
expect(res.status).toBe(400);
});
it('POST /deploy generates deployment plan', async () => {
const app = createFleetApp();
// First register a host
await request(app)
.post('/api/v1/fleet/hosts')
.send({ name: 'Host 1', hostname: '10.0.0.1' });
const res = await request(app)
.post('/api/v1/fleet/deploy')
.send({ templateId: 'plex', config: { port: 32400 } });
expect(res.status).toBe(200);
expect(res.body.totalHosts).toBeGreaterThanOrEqual(1);
expect(res.body.plan[0].templateId).toBe('plex');
});
});
@@ -0,0 +1,136 @@
/**
* DC-100: Service discovery tests
*/
const express = require('express');
const request = require('supertest');
function createApp(docker, servicesStateManager) {
const app = express();
app.use(express.json());
const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
const discoverRoutes = require('../../routes/discover');
app.use('/api/v1', discoverRoutes({
docker,
servicesStateManager,
asyncHandler,
}));
return app;
}
describe('DC-100: Service Discovery', () => {
it('returns 503 when Docker is not available', async () => {
const app = createApp(null, null);
const res = await request(app).get('/api/v1/discover');
expect(res.status).toBe(503);
expect(res.body.success).toBe(false);
expect(res.body.code).toBe('DC-CONT-011');
});
it('discovers running containers with pattern matching', async () => {
const mockDocker = {
client: {
listContainers: jest.fn().mockResolvedValue([
{
Id: 'abc123def456',
Names: ['/plex-server'],
Image: 'plexinc/pms-docker:latest',
State: 'running',
Ports: [
{ IP: '0.0.0.0', PrivatePort: 32400, PublicPort: 32400, Type: 'tcp' },
],
Labels: {},
},
{
Id: 'def789abc012',
Names: ['/redis-cache'],
Image: 'redis:7-alpine',
State: 'running',
Ports: [
{ IP: '0.0.0.0', PrivatePort: 6379, PublicPort: 6379, Type: 'tcp' },
],
Labels: {},
},
]),
},
};
const mockStateManager = {
read: jest.fn().mockResolvedValue([]),
};
const app = createApp(mockDocker, mockStateManager);
const res = await request(app).get('/api/v1/discover');
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.total).toBe(2);
expect(res.body.discovered).toHaveLength(2);
const plex = res.body.discovered.find(d => d.name === 'plex-server');
expect(plex.suggested.type).toBe('plex');
expect(plex.suggested.name).toBe('Plex');
expect(plex.suggested.port).toBe(32400);
expect(plex.existing).toBe(false);
const redis = res.body.discovered.find(d => d.name === 'redis-cache');
expect(redis.suggested.type).toBe('redis');
});
it('marks already-added services as existing', async () => {
const mockDocker = {
client: {
listContainers: jest.fn().mockResolvedValue([
{
Id: 'abc123def456',
Names: ['/plex-server'],
Image: 'plexinc/pms-docker:latest',
State: 'running',
Ports: [],
Labels: {},
},
]),
},
};
const mockStateManager = {
read: jest.fn().mockResolvedValue([{ id: 'plex-server' }]),
};
const app = createApp(mockDocker, mockStateManager);
const res = await request(app).get('/api/v1/discover');
expect(res.status).toBe(200);
expect(res.body.discovered[0].existing).toBe(true);
});
it('handles empty container list', async () => {
const mockDocker = {
client: {
listContainers: jest.fn().mockResolvedValue([]),
},
};
const app = createApp(mockDocker, null);
const res = await request(app).get('/api/v1/discover');
expect(res.status).toBe(200);
expect(res.body.total).toBe(0);
expect(res.body.discovered).toEqual([]);
});
it('returns 500 on Docker error', async () => {
const mockDocker = {
client: {
listContainers: jest.fn().mockRejectedValue(new Error('connection refused')),
},
};
const app = createApp(mockDocker, null);
const res = await request(app).get('/api/v1/discover');
expect(res.status).toBe(500);
expect(res.body.success).toBe(false);
});
});
@@ -0,0 +1,522 @@
/**
* DC-083: Branch coverage tests for the new /system/health endpoint in routes/health.js.
*
* The endpoint at GET /api/system/health aggregates four checks (services, memory,
* diskSpace, incidents) into an overall status. It has many uncovered branches:
* - status === 'ok' / 'degraded' / 'down' in the services check
* - status === 'ok' / 'warning' in the memory check
* - status === 'ok' / 'warning' / 'critical' in the diskSpace check
* - status === 'ok' / 'degraded' in the incidents check
* - each check has a try/catch → unknown fallback
* - overall status computation (unhealthy / degraded / healthy)
*
* Also covers additional uncovered branches in the /health-checks/* endpoints:
* - unhealthy filter in /health-checks/status
* - incidents open/non-empty
* - incidents/history with pagination params
* - /health/probe with and without ?url
* - /health/services with array vs object services data, error paths
*/
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);
}
// ---- Mocks (mirrors health.routes.test.js) ----
jest.mock('child_process', () => ({ execSync: jest.fn() }));
jest.mock('../../platform-paths', () => ({
caCertDir: '/mock/ca',
pkiRootCert: '/mock/pki/root.crt',
dataDir: '/mock/data',
}));
jest.mock('../../src/utilities/fs-helpers', () => ({ exists: jest.fn().mockResolvedValue(true) }));
jest.mock('../../src/utilities/url-resolver', () => ({
resolveServiceUrl: jest.fn((id) => `https://${id}.test`),
}));
jest.mock('../../src/utilities/pagination', () => ({
paginate: jest.fn((data, params) => ({ data, pagination: params ? { page: 1, limit: 10, total: data.length } : null })),
parsePaginationParams: jest.fn(() => null),
}));
const { exists } = require('../../src/utilities/fs-helpers');
const { resolveServiceUrl } = require('../../src/utilities/url-resolver');
const { execSync } = require('child_process');
const platformPaths = require('../../platform-paths');
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));
app.use((err, req, res, next) => {
const status = err.statusCode || 500;
res.status(status).json({ success: false, error: err.message });
});
return { app, deps };
}
describe('System health endpoint (DC-083)', () => {
beforeEach(() => {
jest.clearAllMocks();
exists.mockResolvedValue(true);
execSync.mockReturnValue('notAfter=Dec 22 12:00:00 2034 GMT');
});
describe('GET /api/system/health', () => {
it('returns healthy overall when all checks pass', async () => {
const healthChecker = {
getCurrentStatus: jest.fn().mockReturnValue({
svc1: { status: 'up' },
svc2: { status: 'healthy' },
svc3: { status: 'online' },
}),
getOpenIncidents: jest.fn().mockReturnValue([]),
getServiceStats: jest.fn(),
configureService: jest.fn(),
removeService: jest.fn(),
getIncidentHistory: jest.fn().mockReturnValue([]),
};
// disk: 40% used → ok. df output format: header line + data line.
// parts[0]='40%', parseInt → 40
execSync.mockReturnValue('Use% Size Avail\n 40% 100G 60G');
const { app } = createApp({ healthChecker });
const res = await request(app).get('/api/system/health');
expect(res.status).toBe(200);
expect(res.body.status).toBe('healthy');
expect(res.body.checks.services.status).toBe('ok');
expect(res.body.checks.services.healthy).toBe(3);
expect(res.body.checks.memory.status).toBe('ok');
expect(res.body.checks.diskSpace.status).toBe('ok');
expect(res.body.checks.incidents.status).toBe('ok');
});
it('returns degraded when some services are unhealthy (mixed)', async () => {
const healthChecker = {
getCurrentStatus: jest.fn().mockReturnValue({
svc1: { status: 'up' },
svc2: { status: 'down' },
}),
getOpenIncidents: jest.fn().mockReturnValue([]),
getServiceStats: jest.fn(),
configureService: jest.fn(),
removeService: jest.fn(),
getIncidentHistory: jest.fn().mockReturnValue([]),
};
const { app } = createApp({ healthChecker });
const res = await request(app).get('/api/system/health');
expect(res.status).toBe(200);
expect(res.body.checks.services.status).toBe('degraded');
expect(res.body.checks.services.unhealthy).toBe(1);
expect(res.body.checks.services.unknown).toBe(0);
// Overall degraded because services degraded
expect(res.body.status).toBe('degraded');
});
it('returns down when ALL services are unhealthy', async () => {
const healthChecker = {
getCurrentStatus: jest.fn().mockReturnValue({
svc1: { status: 'down' },
svc2: { status: 'offline' },
}),
getOpenIncidents: jest.fn().mockReturnValue([]),
getServiceStats: jest.fn(),
configureService: jest.fn(),
removeService: jest.fn(),
getIncidentHistory: jest.fn().mockReturnValue([]),
};
const { app } = createApp({ healthChecker });
const res = await request(app).get('/api/system/health');
expect(res.body.checks.services.status).toBe('down');
// Overall unhealthy because services down
expect(res.body.status).toBe('unhealthy');
});
it('counts unknown status values (not up/down/healthy/etc.)', async () => {
const healthChecker = {
getCurrentStatus: jest.fn().mockReturnValue({
svc1: { state: 'starting' }, // unknown state value
svc2: { status: 'paused' }, // unknown status value
svc3: { }, // no status/state → unknown
}),
getOpenIncidents: jest.fn().mockReturnValue([]),
getServiceStats: jest.fn(),
configureService: jest.fn(),
removeService: jest.fn(),
getIncidentHistory: jest.fn().mockReturnValue([]),
};
const { app } = createApp({ healthChecker });
const res = await request(app).get('/api/system/health');
expect(res.body.checks.services.total).toBe(3);
expect(res.body.checks.services.healthy).toBe(0);
expect(res.body.checks.services.unhealthy).toBe(0);
expect(res.body.checks.services.unknown).toBe(3);
});
it('returns degraded when incidents are open', async () => {
const healthChecker = {
getCurrentStatus: jest.fn().mockReturnValue({}),
getOpenIncidents: jest.fn().mockReturnValue([{ id: 'inc1' }, { id: 'inc2' }]),
getServiceStats: jest.fn(),
configureService: jest.fn(),
removeService: jest.fn(),
getIncidentHistory: jest.fn().mockReturnValue([]),
};
const { app } = createApp({ healthChecker });
const res = await request(app).get('/api/system/health');
expect(res.body.checks.incidents.status).toBe('degraded');
expect(res.body.checks.incidents.count).toBe(2);
expect(res.body.status).toBe('degraded');
});
it('returns warning when disk usage between 90-95%', async () => {
const healthChecker = {
getCurrentStatus: jest.fn().mockReturnValue({}),
getOpenIncidents: jest.fn().mockReturnValue([]),
getServiceStats: jest.fn(),
configureService: jest.fn(),
removeService: jest.fn(),
getIncidentHistory: jest.fn().mockReturnValue([]),
};
execSync.mockReturnValue('Use% Size Avail\n 92% 100G 8G');
const { app } = createApp({ healthChecker });
const res = await request(app).get('/api/system/health');
expect(res.body.checks.diskSpace.status).toBe('warning');
expect(res.body.checks.diskSpace.usedPercent).toBe(92);
expect(res.body.status).toBe('degraded');
});
it('returns critical when disk usage >= 95%', async () => {
const healthChecker = {
getCurrentStatus: jest.fn().mockReturnValue({}),
getOpenIncidents: jest.fn().mockReturnValue([]),
getServiceStats: jest.fn(),
configureService: jest.fn(),
removeService: jest.fn(),
getIncidentHistory: jest.fn().mockReturnValue([]),
};
execSync.mockReturnValue('Use% Size Avail\n 97% 100G 3G');
const { app } = createApp({ healthChecker });
const res = await request(app).get('/api/system/health');
expect(res.body.checks.diskSpace.status).toBe('critical');
expect(res.body.status).toBe('unhealthy');
});
it('falls back to unknown for services when getCurrentStatus throws', async () => {
const healthChecker = {
getCurrentStatus: jest.fn().mockImplementation(() => { throw new Error('boom'); }),
getOpenIncidents: jest.fn().mockReturnValue([]),
getServiceStats: jest.fn(),
configureService: jest.fn(),
removeService: jest.fn(),
getIncidentHistory: jest.fn().mockReturnValue([]),
};
const { app } = createApp({ healthChecker });
const res = await request(app).get('/api/system/health');
expect(res.body.checks.services.status).toBe('unknown');
// unknown → degraded overall
expect(res.body.status).toBe('degraded');
});
it('falls back to unknown for disk when execSync throws', async () => {
const healthChecker = {
getCurrentStatus: jest.fn().mockReturnValue({}),
getOpenIncidents: jest.fn().mockReturnValue([]),
getServiceStats: jest.fn(),
configureService: jest.fn(),
removeService: jest.fn(),
getIncidentHistory: jest.fn().mockReturnValue([]),
};
execSync.mockImplementation(() => { throw new Error('df failed'); });
const { app } = createApp({ healthChecker });
const res = await request(app).get('/api/system/health');
expect(res.body.checks.diskSpace.status).toBe('unknown');
});
it('falls back to unknown for incidents when getOpenIncidents throws', async () => {
const healthChecker = {
getCurrentStatus: jest.fn().mockReturnValue({}),
getOpenIncidents: jest.fn().mockImplementation(() => { throw new Error('inc fail'); }),
getServiceStats: jest.fn(),
configureService: jest.fn(),
removeService: jest.fn(),
getIncidentHistory: jest.fn().mockReturnValue([]),
};
const { app } = createApp({ healthChecker });
const res = await request(app).get('/api/system/health');
expect(res.body.checks.incidents.status).toBe('unknown');
expect(res.body.checks.incidents.count).toBe(0);
});
it('sets Cache-Control: no-store header', async () => {
const { app } = createApp();
const res = await request(app).get('/api/system/health');
expect(res.headers['cache-control']).toBe('no-store');
});
it('includes uptime block with seconds and human-readable', async () => {
const { app } = createApp();
const res = await request(app).get('/api/system/health');
expect(res.body.checks.uptime).toHaveProperty('seconds');
expect(res.body.checks.uptime).toHaveProperty('human');
expect(typeof res.body.checks.uptime.seconds).toBe('number');
});
it('handles empty df output (only header line) — no diskSpace block set to ok', async () => {
// df returns just one line → lines.length < 2 → diskSpace not assigned in try
// (stays undefined → overall status considers it). Actually the try block
// does NOT set diskSpace when lines.length < 2, so diskSpace is undefined
// and Object.values(checks) excludes it. Verify no crash.
execSync.mockReturnValue('Use% Size Avail');
const { app } = createApp();
const res = await request(app).get('/api/system/health');
expect(res.status).toBe(200);
});
});
// ---- Coverage for health-checks/status unhealthy filter ----
describe('GET /api/health-checks/status — unhealthy filter coverage', () => {
it('counts unhealthy services via various status/state tokens', async () => {
const healthChecker = {
getCurrentStatus: jest.fn().mockReturnValue({
svc1: { status: 'down' },
svc2: { state: 'unhealthy' },
svc3: { status: 'offline' },
svc4: { status: 'error' },
svc5: { status: 'up' },
}),
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.summary.unhealthy).toBe(4);
expect(res.body.summary.healthy).toBe(1);
expect(res.body.summary.unknown).toBe(0);
expect(res.body.summary.total).toBe(5);
});
it('handles null/undefined status entries', async () => {
const healthChecker = {
getCurrentStatus: jest.fn().mockReturnValue({
svc1: null,
svc2: {},
svc3: { status: 'up' },
}),
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);
// null and {} are not healthy or unhealthy → unknown
expect(res.body.summary.unknown).toBe(2);
expect(res.body.summary.healthy).toBe(1);
});
});
// ---- Coverage for /health/probe ----
describe('GET /api/health/probe', () => {
it('returns 400 when url query param missing', async () => {
const { app } = createApp();
const res = await request(app).get('/api/health/probe');
expect(res.status).toBe(400);
});
it('returns probe result when url provided and fetch succeeds', async () => {
const fetchT = jest.fn().mockResolvedValue({ ok: true, status: 200, json: () => ({}) });
const { app } = createApp({ fetchT });
const res = await request(app).get('/api/health/probe?url=https://example.com');
expect(res.status).toBe(200);
expect(res.body.status).toBe('healthy');
expect(res.body.statusCode).toBe(200);
});
it('returns unhealthy when probe fetch fails completely', async () => {
const fetchT = jest.fn().mockRejectedValue(new Error('timeout'));
const { app } = createApp({ fetchT });
const res = await request(app).get('/api/health/probe?url=https://down.example');
expect(res.status).toBe(200);
expect(res.body.status).toBe('unhealthy');
expect(res.body.reason).toBe('fetch failed');
});
it('marks status as unhealthy when statusCode >= 500', async () => {
const fetchT = jest.fn().mockResolvedValue({ ok: false, status: 503 });
const { app } = createApp({ fetchT });
const res = await request(app).get('/api/health/probe?url=https://500.example');
expect(res.body.status).toBe('unhealthy');
expect(res.body.statusCode).toBe(503);
});
it('marks status as healthy when statusCode is 401/403 (auth wall)', async () => {
const fetchT = jest.fn().mockResolvedValue({ ok: false, status: 401 });
const { app } = createApp({ fetchT });
const res = await request(app).get('/api/health/probe?url=https://auth.example');
expect(res.body.status).toBe('healthy');
expect(res.body.statusCode).toBe(401);
});
});
// ---- Coverage for /health/services with various service shapes ----
describe('GET /api/health/services — service shape branches', () => {
it('handles services as object with .services array', async () => {
const stateManager = {
read: jest.fn().mockResolvedValue({ services: [{ id: 'svc1', name: 'S1' }] }),
write: jest.fn(),
update: jest.fn(),
};
const fetchT = jest.fn().mockResolvedValue({ ok: true, status: 200 });
const { app } = createApp({ servicesStateManager: stateManager, fetchT });
const res = await request(app).get('/api/health/services');
expect(res.status).toBe(200);
expect(res.body.health).toHaveProperty('svc1');
});
it('uses service.name (lowercased) as id when service.id absent', async () => {
const stateManager = {
read: jest.fn().mockResolvedValue([{ name: 'MyService' }]),
write: jest.fn(),
update: jest.fn(),
};
const fetchT = jest.fn().mockResolvedValue({ ok: true, status: 200 });
const { app } = createApp({ servicesStateManager: stateManager, fetchT });
const res = await request(app).get('/api/health/services');
expect(res.status).toBe(200);
expect(res.body.health).toHaveProperty('myservice');
});
it('skips services with no id and no name', async () => {
const stateManager = {
read: jest.fn().mockResolvedValue([{ port: 8080 }]),
write: jest.fn(),
update: jest.fn(),
};
const { app } = createApp({ servicesStateManager: stateManager });
const res = await request(app).get('/api/health/services');
expect(res.status).toBe(200);
expect(res.body.health).toEqual({});
});
it('marks service as unknown when URL resolves to null', async () => {
resolveServiceUrl.mockReturnValue(null);
const stateManager = {
read: jest.fn().mockResolvedValue([{ id: 'novurl', name: 'No URL' }]),
write: jest.fn(),
update: jest.fn(),
};
const { app } = createApp({ servicesStateManager: stateManager });
const res = await request(app).get('/api/health/services');
expect(res.body.health.novurl.status).toBe('unknown');
expect(res.body.health.novurl.reason).toMatch(/No URL/);
resolveServiceUrl.mockReturnValue('https://fallback.test');
});
it('uses pylon relay when direct check fails and pylon configured', async () => {
// Direct HEAD and GET both throw → falls through to pylon
const fetchT = jest.fn()
.mockRejectedValueOnce(new Error('HEAD fail')) // HEAD
.mockRejectedValueOnce(new Error('GET fail')) // GET (fallback in checkDirect)
.mockResolvedValueOnce({ // pylon probe
ok: true, status: 200,
json: () => ({ status: 'healthy', statusCode: 200, responseTime: 42 }),
});
const stateManager = {
read: jest.fn().mockResolvedValue([{ id: 'svc1', name: 'S1' }]),
write: jest.fn(),
update: jest.fn(),
};
const { app } = createApp({
servicesStateManager: stateManager,
fetchT,
siteConfig: { tld: 'sami', pylon: { url: 'http://pylon.test', key: 'k' } },
});
const res = await request(app).get('/api/health/services');
expect(res.body.health.svc1.via).toBe('pylon');
expect(res.body.health.svc1.status).toBe('healthy');
});
it('marks unhealthy when both direct and pylon fail (pylon configured)', async () => {
const fetchT = jest.fn()
.mockRejectedValueOnce(new Error('HEAD fail'))
.mockRejectedValueOnce(new Error('GET fail'))
.mockRejectedValueOnce(new Error('pylon fail'));
const stateManager = {
read: jest.fn().mockResolvedValue([{ id: 'svc1', name: 'S1' }]),
write: jest.fn(),
update: jest.fn(),
};
const { app } = createApp({
servicesStateManager: stateManager,
fetchT,
siteConfig: { tld: 'sami', pylon: { url: 'http://pylon.test' } },
});
const res = await request(app).get('/api/health/services');
expect(res.body.health.svc1.status).toBe('unhealthy');
expect(res.body.health.svc1.reason).toMatch(/direct \+ pylon/);
});
it('catches errors thrown by resolveServiceUrl and marks as error', async () => {
resolveServiceUrl.mockImplementation(() => { throw new Error('resolver exploded'); });
const stateManager = {
read: jest.fn().mockResolvedValue([{ id: 'svc1', name: 'S1' }]),
write: jest.fn(),
update: jest.fn(),
};
const { app } = createApp({ servicesStateManager: stateManager });
const res = await request(app).get('/api/health/services');
expect(res.body.health.svc1.status).toBe('error');
expect(res.body.health.svc1.reason).toMatch(/resolver exploded/);
resolveServiceUrl.mockReturnValue('https://fallback.test');
});
});
// ---- Coverage for /health-checks/incidents and history with pagination ----
describe('GET /api/health-checks/incidents — non-empty', () => {
it('returns incidents list', async () => {
const healthChecker = {
getCurrentStatus: jest.fn().mockReturnValue({}),
getServiceStats: jest.fn(),
configureService: jest.fn(),
removeService: jest.fn(),
getOpenIncidents: jest.fn().mockReturnValue([{ id: 'inc1', serviceId: 'svc1' }]),
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);
});
});
});
@@ -0,0 +1,81 @@
/**
* DC-105: Wizard endpoint tests
*/
const express = require('express');
const request = require('supertest');
function createApp(templates) {
const app = express();
app.use(express.json());
const wizardRoutes = require('../../routes/wizard');
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
app.use('/api/v1', wizardRoutes({ APP_TEMPLATES: templates || [], asyncHandler: wrap }));
return app;
}
describe('DC-105: Smart Defaults Wizard', () => {
it('GET /categories returns 6 categories', async () => {
const app = createApp();
const res = await request(app).get('/api/v1/wizard/categories');
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.categories).toHaveLength(6);
expect(res.body.categories[0]).toHaveProperty('id');
expect(res.body.categories[0]).toHaveProperty('label');
expect(res.body.categories[0]).toHaveProperty('icon');
});
it('POST /recommend returns services for media-streaming', async () => {
const app = createApp([
{ id: 'plex', name: 'Plex', image: 'plexinc/pms-docker', ports: [32400] },
{ id: 'sonarr', name: 'Sonarr', image: 'lscr.io/linuxserver/sonarr', ports: [8989] },
]);
const res = await request(app)
.post('/api/v1/wizard/recommend')
.send({ categories: ['media-streaming'], hardwareProfile: 'medium' });
expect(res.status).toBe(200);
expect(res.body.totalRecommended).toBeGreaterThan(0);
expect(res.body.services[0].template).toBe('plex');
expect(res.body.services[0].available).toBe(true);
});
it('POST /recommend returns 400 without categories', async () => {
const app = createApp();
const res = await request(app)
.post('/api/v1/wizard/recommend')
.send({ categories: [] });
expect(res.status).toBe(400);
});
it('POST /recommend limits services by hardware profile', async () => {
const app = createApp();
const res = await request(app)
.post('/api/v1/wizard/recommend')
.send({ categories: ['media-streaming', 'development', 'monitoring'], hardwareProfile: 'minimal' });
expect(res.status).toBe(200);
expect(res.body.totalRecommended).toBeLessThanOrEqual(3);
});
it('POST /apply returns deployment plan', async () => {
const app = createApp();
const res = await request(app)
.post('/api/v1/wizard/apply')
.send({ services: ['plex', 'sonarr'], subdomainPrefix: 'sami-' });
expect(res.status).toBe(200);
expect(res.body.totalSteps).toBe(2);
expect(res.body.plan[0].subdomain).toBe('sami-plex');
});
it('POST /apply returns 400 without services', async () => {
const app = createApp();
const res = await request(app)
.post('/api/v1/wizard/apply')
.send({ services: [] });
expect(res.status).toBe(400);
});
});
@@ -778,11 +778,11 @@ describe('UpdateManager — Docker image update lifecycle', () => {
statusCode: 200,
headers: {},
on: jest.fn((event, handler) => {
if (event === 'data') handler(Buffer.from(JSON.stringify({
if (event === 'data') {handler(Buffer.from(JSON.stringify({
description: 'Plex Media Server',
pull_count: 1000000,
star_count: 500
})));
})));}
if (event === 'end') handler();
})
}));
@@ -830,12 +830,12 @@ describe('UpdateManager — Docker image update lifecycle', () => {
statusCode: 200,
headers: {},
on: jest.fn((event, handler) => {
if (event === 'data') handler(Buffer.from(JSON.stringify({
if (event === 'data') {handler(Buffer.from(JSON.stringify({
results: [
{ name: 'latest', last_pushed: '2026-04-01T00:00:00Z' },
{ name: '1.40', last_pushed: '2026-03-15T00:00:00Z' }
]
})));
})));}
if (event === 'end') handler();
})
}));
@@ -0,0 +1,137 @@
/**
* DC-076: Tests for the dashboard WebSocket server
*/
const http = require('http');
const WebSocket = require('ws');
const EventEmitter = require('events');
const createDashboardWS = require('../../src/websocket/dashboard-ws');
function createMockServer() {
return http.createServer((req, res) => {
res.writeHead(404);
res.end();
});
}
describe('DC-076: Dashboard WebSocket', () => {
let server, wsServer, port;
beforeEach((done) => {
server = createMockServer();
server.listen(0, () => {
port = server.address().port;
const resourceMonitor = new EventEmitter();
const healthChecker = new EventEmitter();
const updateManager = new EventEmitter();
wsServer = createDashboardWS(server, {
resourceMonitor,
healthChecker,
updateManager,
log: { info: jest.fn(), error: jest.fn() },
});
done();
});
});
afterEach((done) => {
wsServer.close();
server.close(done);
});
it('accepts connections at the upgrade path', (done) => {
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`);
ws.on('open', () => {
ws.close();
});
ws.on('close', () => {
done();
});
ws.on('error', done);
});
it('sends a connected event on join', (done) => {
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`);
ws.on('message', (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.type === 'connected') {
expect(msg.data).toHaveProperty('clients');
ws.close();
done();
}
});
ws.on('error', done);
});
it('responds to ping with pong', (done) => {
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`);
ws.on('open', () => {
ws.send(JSON.stringify({ type: 'ping' }));
});
ws.on('message', (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.type === 'pong') {
ws.close();
done();
}
});
ws.on('error', done);
});
it('responds to subscribe with subscribed confirmation', (done) => {
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`);
ws.on('open', () => {
ws.send(JSON.stringify({ type: 'subscribe', events: ['resource-alert', 'incident'] }));
});
ws.on('message', (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.type === 'subscribed') {
expect(msg.events).toEqual(['resource-alert', 'incident']);
ws.close();
done();
}
});
ws.on('error', done);
});
it('responds to client-count request', (done) => {
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`);
ws.on('open', () => {
ws.send(JSON.stringify({ type: 'client-count' }));
});
ws.on('message', (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.type === 'client-count') {
expect(msg.count).toBeGreaterThanOrEqual(1);
ws.close();
done();
}
});
ws.on('error', done);
});
it('returns error for invalid JSON', (done) => {
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`);
ws.on('open', () => {
ws.send('not json');
});
ws.on('message', (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.type === 'error') {
expect(msg.error).toContain('Invalid JSON');
ws.close();
done();
}
});
ws.on('error', done);
});
it('tracks client count', () => {
expect(wsServer.getClientCount()).toBe(0);
});
it('broadcast method does not throw with no clients', () => {
expect(() => wsServer.broadcast('test', { foo: 'bar' })).not.toThrow();
});
});