Put the feedback and tickets admin areas behind the shared identity (#13)
Jenkins Production Deployment
Jenkins Production Deployment
Reviewed-on: #13 Co-authored-by: Patrick Müller <mail@pmueller.me> Co-committed-by: Patrick Müller <mail@pmueller.me>
This commit was merged in pull request #13.
This commit is contained in:
@@ -78,6 +78,40 @@ describe('CLIENT_IP_HEADERS', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('APP_ORIGINS', () => {
|
||||
beforeEach(() => {
|
||||
delete process.env.APP_ORIGINS;
|
||||
});
|
||||
|
||||
// These reach better-auth's trustedOrigins, and the step 4 cutover made the
|
||||
// tickets and feedback origins load-bearing: without them their sign-out
|
||||
// call is rejected while everything else still works.
|
||||
it('defaults to the two production frontends', async () => {
|
||||
const config = await loadConfig();
|
||||
expect(config.APP_ORIGINS).toEqual([
|
||||
'https://tickets.nachklang.art',
|
||||
'https://feedback.nachklang.art'
|
||||
]);
|
||||
});
|
||||
|
||||
it('is overridden wholesale by the environment, for a staging host', async () => {
|
||||
process.env.APP_ORIGINS = 'https://tickets.staging.example, https://feedback.staging.example/';
|
||||
const config = await loadConfig();
|
||||
expect(config.APP_ORIGINS).toEqual([
|
||||
'https://tickets.staging.example',
|
||||
// Trailing slash stripped: an origin with one never matches.
|
||||
'https://feedback.staging.example'
|
||||
]);
|
||||
});
|
||||
|
||||
it('always includes the admin app itself in ADMIN_ALLOWED_ORIGINS', async () => {
|
||||
process.env.ADMIN_APP_URL = 'https://admin.nachklang.art';
|
||||
const config = await loadConfig();
|
||||
expect(config.ADMIN_ALLOWED_ORIGINS).toContain('https://admin.nachklang.art');
|
||||
expect(config.ADMIN_ALLOWED_ORIGINS).toContain('https://tickets.nachklang.art');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isProd', () => {
|
||||
it('is false only for the explicit relaxed environments', async () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import {vi, describe, it, expect, beforeEach, type Mock} from 'vitest';
|
||||
import express, {Request, Response} from 'express';
|
||||
|
||||
/**
|
||||
* Shared body for the two cutover tests (2026-09-06). feedback.auth.ts and
|
||||
* tickets.auth.ts used to carry their own header-session authenticator against
|
||||
* the calendar users table; both are now one binding to the shared admin gate.
|
||||
*
|
||||
* What is worth asserting is not how that gate works - admin.middleware.test.ts
|
||||
* owns that - but that each module is bound to *its own* app, and that neither
|
||||
* consults the calendar users service any more. The mocks live in the calling
|
||||
* file because vi.mock is per-module-graph; only the assertions are shared.
|
||||
*/
|
||||
|
||||
export interface BindingMocks {
|
||||
/** auth.api.getSession from the mocked admin.auth.js */
|
||||
getSession: Mock;
|
||||
/** loadAccess from the mocked users.admin.service.js */
|
||||
loadAccess: Mock;
|
||||
/** checkSession from the mocked calendar users.service.js */
|
||||
checkSession: Mock;
|
||||
}
|
||||
|
||||
const makeReq = (): Request => ({headers: {cookie: 'nachklang.session_token=abc'}} as unknown as Request);
|
||||
|
||||
const makeRes = (): Response => {
|
||||
const res: any = {};
|
||||
res.status = vi.fn().mockReturnValue(res);
|
||||
res.send = vi.fn().mockReturnValue(res);
|
||||
res.locals = {};
|
||||
return res as Response;
|
||||
};
|
||||
|
||||
const userWith = (...apps: string[]) => ({
|
||||
id: 'u1',
|
||||
email: 'a@nachklang.art',
|
||||
displayName: 'Anna Admin',
|
||||
disabled: false,
|
||||
permissions: apps.map(app => ({app, role: 'access'})),
|
||||
apps
|
||||
});
|
||||
|
||||
export const describeAdminBinding = (
|
||||
app: string,
|
||||
otherApp: string,
|
||||
middleware: express.RequestHandler,
|
||||
mocks: () => BindingMocks
|
||||
): void => {
|
||||
describe(`${app} requireAdminAuth`, () => {
|
||||
let m: BindingMocks;
|
||||
|
||||
const run = async () => {
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
await middleware(makeReq(), res, next);
|
||||
return {res, next};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
m = mocks();
|
||||
m.getSession.mockReset();
|
||||
m.loadAccess.mockReset();
|
||||
m.checkSession.mockReset();
|
||||
});
|
||||
|
||||
it('responds 401 and does not call next() without a session', async () => {
|
||||
m.getSession.mockResolvedValue(null);
|
||||
|
||||
const {res, next} = await run();
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`responds 403 for a signed-in user who only has ${otherApp}`, async () => {
|
||||
m.getSession.mockResolvedValue({user: {id: 'u1'}});
|
||||
m.loadAccess.mockResolvedValue(userWith(otherApp));
|
||||
|
||||
const {res, next} = await run();
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('responds 403 for a disabled user who still holds the permission', async () => {
|
||||
m.getSession.mockResolvedValue({user: {id: 'u1'}});
|
||||
m.loadAccess.mockResolvedValue({...userWith(app), disabled: true});
|
||||
|
||||
const {res, next} = await run();
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sets res.locals.admin and calls next() with the permission', async () => {
|
||||
m.getSession.mockResolvedValue({user: {id: 'u1'}});
|
||||
m.loadAccess.mockResolvedValue(userWith(app));
|
||||
|
||||
const {res, next} = await run();
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.locals.admin).toMatchObject({id: 'u1', email: 'a@nachklang.art', displayName: 'Anna Admin'});
|
||||
});
|
||||
|
||||
// Weaker than it looks and kept deliberately: neither module imports
|
||||
// checkSession any more, so this cannot fail today. It is a tripwire for
|
||||
// the change that would matter - someone reintroducing a header-session
|
||||
// fallback "just for the calendar users who have not been invited yet",
|
||||
// which is exactly the shortcut the cutover exists to close.
|
||||
it('never falls back to a calendar header session', async () => {
|
||||
m.getSession.mockResolvedValue(null);
|
||||
|
||||
await run();
|
||||
|
||||
expect(m.checkSession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user