Files
immich-folio/lib/__tests__/site-password.test.ts
Ralksta 10ec68f493 release: v0.12.0 (#520)
Forty-one commits since v0.11.0. See CHANGELOG.md for the full list and the upgrade notes.
2026-08-23 23:18:14 +02:00

192 lines
6.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi, beforeEach } from 'vitest';
/**
* The site-wide gate rides on the same HMAC machinery as subpage, album and
* journal passwords — the point of these tests is that it really is the same
* path (signed expiry, constant-time compare, opt-in when unset) and not a
* second, weaker scheme bolted on beside it.
*/
const config = {
authSecret: 'test-secret-that-is-at-least-32-chars-long',
sitePassword: '',
subpages: [] as unknown[],
albumPasswords: {} as Record<string, string>,
};
vi.mock('@/lib/config', () => ({
getConfig: () => config,
}));
import {
authenticate,
isAuthenticated,
isSiteLocked,
isSiteUnlocked,
siteLockResponse,
SITE_AUTH_COOKIE,
SITE_AUTH_KEY,
} from '@/lib/auth';
/** Pull the cookie value back out of a Set-Cookie header. */
function cookieValue(setCookie: string): string {
return setCookie.slice(setCookie.indexOf('=') + 1, setCookie.indexOf(';'));
}
const jar = (value?: string) => (name: string) => (name === SITE_AUTH_COOKIE ? value : undefined);
beforeEach(() => {
config.sitePassword = '';
});
describe('an open site', () => {
it('is not locked when no password is configured', () => {
expect(isSiteLocked()).toBe(false);
});
it('serves everyone, cookie or not', () => {
expect(isSiteUnlocked(jar())).toBe(true);
expect(siteLockResponse(new Request('http://localhost/api/image/x'))).toBeNull();
});
it('refuses to issue a token — there is nothing to unlock', async () => {
expect(await authenticate(SITE_AUTH_KEY, 'anything', 'site')).toBeNull();
});
});
describe('a locked site', () => {
beforeEach(() => {
config.sitePassword = 'letmein';
});
it('reports itself locked', () => {
expect(isSiteLocked()).toBe(true);
});
it('turns away a visitor with no cookie', () => {
expect(isSiteUnlocked(jar())).toBe(false);
});
it('rejects the wrong password', async () => {
expect(await authenticate(SITE_AUTH_KEY, 'wrong', 'site')).toBeNull();
});
it('issues an HttpOnly cookie for the right password', async () => {
const setCookie = await authenticate(SITE_AUTH_KEY, 'letmein', 'site');
expect(setCookie).toContain(`${SITE_AUTH_COOKIE}=`);
expect(setCookie).toContain('HttpOnly');
expect(setCookie).toContain('SameSite=Strict');
expect(setCookie).toContain('Path=/');
});
it('accepts the cookie it just issued', async () => {
const setCookie = (await authenticate(SITE_AUTH_KEY, 'letmein', 'site'))!;
expect(isSiteUnlocked(jar(cookieValue(setCookie)))).toBe(true);
});
it('rejects a token whose expiry has been pushed out by hand', async () => {
const setCookie = (await authenticate(SITE_AUTH_KEY, 'letmein', 'site'))!;
const [, mac] = cookieValue(setCookie).split('.');
const forged = `${Date.now() + 10 * 365 * 24 * 3600_000}.${mac}`;
expect(isSiteUnlocked(jar(forged))).toBe(false);
});
it('rejects an expired token', () => {
// Signed with a past expiry: the signature is valid, the deadline is not.
expect(isSiteUnlocked(jar(`${Date.now() - 1000}.deadbeef`))).toBe(false);
});
it('rejects garbage', () => {
expect(isSiteUnlocked(jar(''))).toBe(false);
expect(isSiteUnlocked(jar('not-a-token'))).toBe(false);
expect(isSiteUnlocked(jar('...'))).toBe(false);
});
it('does not unlock the site with another gate’s cookie', async () => {
config.subpages = [{ slug: 'private', password: 'letmein' }];
const subpageCookie = (await authenticate('private', 'letmein', 'subpage'))!;
// Same password, different key — the key is part of the signed payload.
expect(isSiteUnlocked(jar(cookieValue(subpageCookie)))).toBe(false);
config.subpages = [];
});
it('stops changing its mind once the password is rotated', async () => {
const setCookie = (await authenticate(SITE_AUTH_KEY, 'letmein', 'site'))!;
config.sitePassword = 'something-else';
expect(isSiteUnlocked(jar(cookieValue(setCookie)))).toBe(false);
});
});
describe('siteLockResponse', () => {
beforeEach(() => {
config.sitePassword = 'letmein';
});
it('answers 401 without a cookie, and does not let it be cached', async () => {
const res = siteLockResponse(new Request('http://localhost/api/image/x'))!;
expect(res.status).toBe(401);
expect(res.headers.get('Cache-Control')).toBe('no-store');
// A WWW-Authenticate header would make the browser pop its own dialog.
expect(res.headers.get('WWW-Authenticate')).toBeNull();
});
it('reads the cookie out of the request header', async () => {
const setCookie = (await authenticate(SITE_AUTH_KEY, 'letmein', 'site'))!;
const req = new Request('http://localhost/api/image/x', {
headers: { cookie: `theme=dark; ${SITE_AUTH_COOKIE}=${cookieValue(setCookie)}; other=1` },
});
expect(siteLockResponse(req)).toBeNull();
});
it('is not fooled by a cookie whose name merely ends the same way', async () => {
const setCookie = (await authenticate(SITE_AUTH_KEY, 'letmein', 'site'))!;
const req = new Request('http://localhost/api/image/x', {
headers: { cookie: `not_${SITE_AUTH_COOKIE}=${cookieValue(setCookie)}` },
});
expect(siteLockResponse(req)).not.toBeNull();
});
});
describe('a subpage slugged "site"', () => {
beforeEach(() => {
config.sitePassword = 'letmein';
});
it('does not write to the site gate’s cookie', async () => {
config.subpages = [{ slug: 'site', password: 'subpage-pw' }];
const setCookie = (await authenticate('site', 'subpage-pw', 'subpage'))!;
expect(setCookie.startsWith(`${SITE_AUTH_COOKIE}=`)).toBe(false);
expect(setCookie).toContain('lb_auth_site=');
config.subpages = [];
});
it('does not unlock the site even when both passwords match', async () => {
// Same key ("site"), same password — only the domain separator in the
// signed payload keeps the two tokens apart.
config.subpages = [{ slug: 'site', password: 'letmein' }];
const subpageCookie = cookieValue((await authenticate('site', 'letmein', 'subpage'))!);
expect(isSiteUnlocked(jar(subpageCookie))).toBe(false);
config.subpages = [];
});
it('is not unlocked by the site cookie either', async () => {
config.subpages = [{ slug: 'site', password: 'letmein' }];
const siteCookie = cookieValue((await authenticate(SITE_AUTH_KEY, 'letmein', 'site'))!);
expect(isAuthenticated('site', () => siteCookie, 'subpage')).toBe(false);
config.subpages = [];
});
});
describe('the other gates are untouched', () => {
it('still protects a subpage independently of the site lock', async () => {
config.sitePassword = 'letmein';
config.subpages = [{ slug: 'private', password: 'other' }];
const siteCookie = cookieValue((await authenticate(SITE_AUTH_KEY, 'letmein', 'site'))!);
// Unlocking the site does not unlock a subpage inside it.
expect(isAuthenticated('private', () => siteCookie, 'subpage')).toBe(false);
config.subpages = [];
});
});