[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).
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* 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>');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user