Files
dashcaddy/dashcaddy-api/__tests__/login-page-datadriven-dc134.test.js
T
DashCaddy Polish Loop 11c719e635 [grade=B urn:ump:3wvdd3yegylr7e2pzn6tebyq5bisf2awmss7p4j3uwpwsujtnwoq] DC-134/135/136: Shipdeck integration - data-driven login pages, deploy events, badge suppression
DC-134: /api/v1/auth/login-page serves a generic gated auto-login page for
any service registered in services.json without a curated flow (App Selector
installs, DC-131 git installs). Curated pages always win; unknown services
still 404; sanitizer keeps digits/hyphens (shipdeck-style ids); display
names HTML-escaped. Kills the sso-gate.js edit + restart per new install.

DC-135: shipdeck journal.jsonl tail worker (startShipdeckWorker) ingests
deploy/rollback lifecycle rows into the Security Center as
source_type=shipdeck (notice/success, error/failure via verify[] block).

DC-136: deploy-aware badge suppression - suppressDuringDeploy() before the
bridge call in /deploy and /rollback, clearDeploySuppression() in finally
(every exit path incl. rejected fetches), reference-counted for overlapping
deploys, 10-min TTL auto-expiry (HEALTH_DEPLOY_SUPPRESS_MAX_MS).

23 new tests across 4 suites; full suite 2923/2923 green.

Codex judge: C (r1) -> C (r2) -> B (r3) -> B zero-blockers (r4,
urn:ump:3wvdd3yegylr7e2pzn6tebyq5bisf2awmss7p4j3uwpwsujtnwoq).
2026-09-16 03:10:54 -07:00

102 lines
4.2 KiB
JavaScript

/**
* DC-134: data-driven login pages for registered-but-uncurated services.
*
* Before: /api/v1/auth/login-page served curated auto-login pages for
* {chat, plex, jellyfin, emby, sec} and 404'd for every other service —
* meaning every shipdeck/App-Selector install needed a code change
* (sso-gate.js edit + API restart) before its gated auto-login worked.
*
* After: any service registered in services.json gets a generic gated
* auto-login page (session pre-verified by the SHELL, then ?direct=1 to
* bypass the Caddy @needsAutoLogin loop). Curated pages always win.
*
* buildLoginPage() is exercised directly — it's the unit that decides
* page rendering, and the route handler is a thin wrapper around it.
*/
'use strict';
// Route-level harness: replicate the minimal deps the sso-gate factory needs.
const express = require('express');
const request = require('supertest');
function createApp({ services }) {
const factory = require('../routes/auth/sso-gate');
const router = factory({
authManager: {},
totpConfig: { enabled: true },
session: { isValid: () => true },
asyncHandler: (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next),
errorResponse: (res, code, msg, extra = {}) => res.status(code).json({ success: false, error: msg, ...extra }),
log: { info: () => {}, warn: () => {}, error: () => {} },
getAppSession: () => null,
appSessionCache: new Map(),
credentialManager: { retrieve: async () => null },
fetchT: async () => { throw new Error('not used'); },
getServiceById: async () => null,
licenseManager: {
hasFeature: () => false,
requirePremium: () => (req, res, next) => next(),
},
servicesStateManager: { read: async () => services },
siteConfig: { dashboardHost: 'status.sami' },
});
const app = express();
app.use('/api/v1', router);
return app;
}
describe('DC-134: data-driven login pages', () => {
const registeredOnly = [
{ id: 'demo-hi3', name: 'Demo Hi3', url: 'https://hi3.sami' },
{ id: 'chat', name: 'Chat', url: 'https://chat.sami' }, // curated + registered
];
test('registered service WITHOUT a curated page gets a generic gated page', async () => {
const res = await request(createApp({ services: registeredOnly }))
.get('/api/v1/auth/login-page?service=demo-hi3');
expect(res.status).toBe(200);
expect(res.headers['content-type']).toMatch(/html/);
expect(res.text).toContain('Signing in to Demo Hi3...');
expect(res.text).toContain("go('/?direct=1')");
// the SHELL must still enforce the session pre-check
expect(res.text).toContain('totp/check-session');
});
test('curated page wins over the data-driven fallback (chat)', async () => {
const res = await request(createApp({ services: registeredOnly }))
.get('/api/v1/auth/login-page?service=chat');
expect(res.status).toBe(200);
expect(res.text).toContain('Signing in...'); // curated title, not "Signing in to Chat..."
expect(res.text).not.toContain('Signing in to Chat...');
});
test('service id with digits/hyphens survives the sanitizer', async () => {
const res = await request(createApp({ services: registeredOnly }))
.get('/api/v1/auth/login-page?service=demo-hi3');
expect(res.status).toBe(200);
});
test('unknown service still returns 404 Unknown service', async () => {
const res = await request(createApp({ services: registeredOnly }))
.get('/api/v1/auth/login-page?service=nonexistent');
expect(res.status).toBe(404);
expect(res.text).toContain('Unknown service');
});
test('services read failure degrades to curated-only behavior (404, no crash)', async () => {
const res = await request(createApp({ services: null }))
.get('/api/v1/auth/login-page?service=demo-hi3');
expect(res.status).toBe(404);
});
test('service display name is HTML-escaped in the title', async () => {
const services = [{ id: 'xss', name: '<script>alert(1)</script>', url: 'https://xss.sami' }];
const res = await request(createApp({ services }))
.get('/api/v1/auth/login-page?service=xss');
expect(res.status).toBe(200);
expect(res.text).not.toContain('<script>alert(1)</script>');
expect(res.text).toContain('&lt;script&gt;');
});
});