/**
* 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: '', 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('');
expect(res.text).toContain('<script>');
});
});