/** * 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>'); }); });