release: v0.12.0 (#520)
Forty-one commits since v0.11.0. See CHANGELOG.md for the full list and the upgrade notes.
This commit is contained in:
191
lib/__tests__/site-password.test.ts
Normal file
191
lib/__tests__/site-password.test.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
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 = [];
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user