b848d6eab9
Step 4 of docs/calendar-auth-migration.md, and the close of
DEFERRED_SECURITY.md item 1: no calendar route reads sessionId/sessionKey from
the query string any more, so a live credential no longer travels through
access logs, browser history and Referer headers.
The four write routes sit behind requireAppAccess('calendar'), which also
narrows who may edit from "any activated @nachklang.art account" to an
explicit per-user permission. They answer 401 signed out and 403 without the
permission, where they previously answered 403 for both.
The three read routes cannot use the middleware: one URL serves an anonymous
visitor, an iCal subscription holding a shared password, and a signed-in
editor who should see drafts. They resolve the session optionally instead, and
a signed-in user without the calendar permission is treated as anonymous
rather than refused - so they keep the public calendar access anyone has.
That public calendar staying anonymous is load-bearing: nachklang.art reads it
to show the next upcoming event. It is now pinned at both the password-table
and the route level, and so is the rule that a shared password can never be
used to write.
credentials.service.ts loses its session half and becomes the password table
it always wanted to be. The shared passwords survive only for iCal clients,
which cannot send a cookie.
Writes record the author as an admin user id and no longer have a legacy int
to write, which is what migration 003 makes room for.
/calendar/users/* is left in place: nothing calls it and a session it mints
opens nothing, but they are still live password-accepting endpoints, so
removing them belongs with the rest of the legacy path in step 5.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
145 lines
5.4 KiB
TypeScript
145 lines
5.4 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 three production frontends', async () => {
|
|
const config = await loadConfig();
|
|
expect(config.APP_ORIGINS).toEqual([
|
|
'https://tickets.nachklang.art',
|
|
'https://feedback.nachklang.art',
|
|
'https://calendar.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/);
|
|
});
|
|
});
|