2405625f99
feedback.auth.ts and tickets.auth.ts each become one binding to requireAppAccess. Everything downstream was already written against requireAdminAuth and res.locals.admin, and both still mean what they meant, so no router or service changed. What changed is the policy: an activated @nachklang.art account is no longer sufficient, an explicit per-app permission is. Three things followed from that and are not obvious from the diff: - APP_ORIGINS gets a production default. It feeds better-auth's trustedOrigins, and this is the first time the tickets and feedback origins matter there - before, the only browser origin that ever reached /admin/auth was the admin app itself. An origin missing from that list fails in a way that is easy to misread: sign-in works, the app works, and only sign-out returns an origin error. - Nothing reads X-Session-* any more; these two files were the last readers, and the calendar module passes its session in query parameters. The headers stay in the CORS allowedHeaders only so a browser still running a pre-cutover bundle gets a clean 401 rather than a preflight failure, and can come out once both frontends are deployed. - 40 admin operations documented a required X-Session-Id/X-Session-Key in swagger. They now declare the AdminSessionCookie scheme the admin module already defined, and each documents a 403 next to its 401. The integration assertions flip as their own comment predicted: one admin cookie opens both /feedback/admin/me and /tickets/admin/me, a user holding only feedback gets 200 and 403 respectively, and a legacy header session gets 401. The unit test that covered the old header authenticator is replaced by one asserting each module is bound to its own app and that neither consults the calendar users service. 163 unit tests and 43 integration tests green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
144 lines
5.3 KiB
TypeScript
144 lines
5.3 KiB
TypeScript
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
|
|
|
// admin.config calls dotenv.config(), which would read the repo's own .env and
|
|
// quietly reintroduce NODE_ENV=development - the exact value several of these
|
|
// cases exist to remove. Stub it so the tests see only what they set.
|
|
vi.mock('dotenv', () => ({config: vi.fn()}));
|
|
|
|
/**
|
|
* admin.config reads the environment once at import, so every case here has to
|
|
* reset the module registry and re-import it. The two things worth pinning are
|
|
* the ones that are silent when wrong: which client-IP header is trusted, and
|
|
* whether an unset NODE_ENV counts as production.
|
|
*/
|
|
|
|
const ORIGINAL_ENV = {...process.env};
|
|
|
|
const loadConfig = async () => {
|
|
vi.resetModules();
|
|
return import('../../src/models/admin/admin.config.js');
|
|
};
|
|
|
|
beforeEach(() => {
|
|
process.env = {...ORIGINAL_ENV};
|
|
// dotenv.config() in admin.config does not overwrite what is already set,
|
|
// so setting these here is enough to keep the local .env out of the test.
|
|
process.env.NODE_ENV = 'test';
|
|
delete process.env.CLIENT_IP_HEADERS;
|
|
delete process.env.TRUSTED_PROXY_IPS;
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env = {...ORIGINAL_ENV};
|
|
});
|
|
|
|
describe('CLIENT_IP_HEADERS', () => {
|
|
it('defaults to the single header Plesk nginx sets', async () => {
|
|
const config = await loadConfig();
|
|
expect(config.CLIENT_IP_HEADERS).toEqual(['x-real-ip']);
|
|
expect(config.TRUST_NO_CLIENT_IP_HEADER).toBe(false);
|
|
});
|
|
|
|
it('reads a comma-separated list', async () => {
|
|
process.env.CLIENT_IP_HEADERS = 'x-real-ip, cf-connecting-ip';
|
|
const config = await loadConfig();
|
|
expect(config.CLIENT_IP_HEADERS).toEqual(['x-real-ip', 'cf-connecting-ip']);
|
|
});
|
|
|
|
it('trusts nothing when set to "none"', async () => {
|
|
// The escape hatch. An empty list is what better-auth reads as "no
|
|
// headers" - it only falls back to its own default when the option is
|
|
// absent - so this really does stop any header being believed.
|
|
process.env.CLIENT_IP_HEADERS = 'none';
|
|
const config = await loadConfig();
|
|
expect(config.CLIENT_IP_HEADERS).toEqual([]);
|
|
expect(config.TRUST_NO_CLIENT_IP_HEADER).toBe(true);
|
|
});
|
|
|
|
it('accepts the hatch case-insensitively and with stray whitespace', async () => {
|
|
process.env.CLIENT_IP_HEADERS = ' NONE ';
|
|
const config = await loadConfig();
|
|
expect(config.CLIENT_IP_HEADERS).toEqual([]);
|
|
});
|
|
|
|
it('treats an empty value as "use the default", not as the hatch', async () => {
|
|
// A blank line in a .env must not silently change how requests are
|
|
// bucketed - only the explicit word does that.
|
|
process.env.CLIENT_IP_HEADERS = '';
|
|
const config = await loadConfig();
|
|
expect(config.CLIENT_IP_HEADERS).toEqual(['x-real-ip']);
|
|
expect(config.TRUST_NO_CLIENT_IP_HEADER).toBe(false);
|
|
});
|
|
|
|
it('does not mistake a header actually named none-ish for the hatch', async () => {
|
|
process.env.CLIENT_IP_HEADERS = 'x-none';
|
|
const config = await loadConfig();
|
|
expect(config.CLIENT_IP_HEADERS).toEqual(['x-none']);
|
|
expect(config.TRUST_NO_CLIENT_IP_HEADER).toBe(false);
|
|
});
|
|
});
|
|
|
|
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';
|
|
expect((await loadConfig()).isProd).toBe(false);
|
|
|
|
process.env.NODE_ENV = 'test';
|
|
expect((await loadConfig()).isProd).toBe(false);
|
|
});
|
|
|
|
it('treats an unset NODE_ENV as production, which is what a bare vhost gives', async () => {
|
|
delete process.env.NODE_ENV;
|
|
// Strict mode refuses to boot without these; supply them so the import
|
|
// gets far enough to answer the question being asked.
|
|
process.env.BETTER_AUTH_SECRET = 'x'.repeat(48);
|
|
process.env.API_BASE_URL = 'https://api.nachklang.art';
|
|
process.env.ADMIN_APP_URL = 'https://admin.nachklang.art';
|
|
|
|
expect((await loadConfig()).isProd).toBe(true);
|
|
});
|
|
|
|
it('refuses to start without a signing key outside development', async () => {
|
|
delete process.env.NODE_ENV;
|
|
delete process.env.BETTER_AUTH_SECRET;
|
|
process.env.API_BASE_URL = 'https://api.nachklang.art';
|
|
process.env.ADMIN_APP_URL = 'https://admin.nachklang.art';
|
|
|
|
await expect(loadConfig()).rejects.toThrow(/BETTER_AUTH_SECRET/);
|
|
});
|
|
});
|