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).
102 lines
4.2 KiB
JavaScript
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('<script>');
|
|
});
|
|
});
|