chore: production readiness audit
- Move plans/ to dev-docs/, delete unused scaffold SVGs - Create .dockerignore, rename container to immich-folio - Extract BackLink component and toPhotoItems() helper in page.tsx - Add keyboard accessibility (role=button, tabIndex, onKeyDown) to PhotoGrid - Add aria-label to Lightbox EXIF toggle - Fix React lint: replace setState-in-effect with useState initializer - Add rate limiting to /api/auth (brute-force protection, 10 RPM) - Add HSTS and X-DNS-Prefetch-Control security headers - Add Secure flag to auth cookie in production - New unit tests: rate-limit.test.ts (5), auth.test.ts (12) - All 47 tests pass, 0 lint errors, build clean
This commit is contained in:
94
lib/__tests__/auth.test.ts
Normal file
94
lib/__tests__/auth.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// Mock config to provide a predictable API key and subpage config
|
||||
vi.mock('@/lib/config', () => ({
|
||||
getConfig: () => ({
|
||||
immich: { apiKey: 'test-api-key-for-auth-tests' },
|
||||
subpages: [
|
||||
{
|
||||
name: 'Private',
|
||||
slug: 'private',
|
||||
albumIds: ['00000000-0000-0000-0000-000000000001'],
|
||||
password: 'secret123',
|
||||
},
|
||||
{
|
||||
name: 'Public',
|
||||
slug: 'public',
|
||||
albumIds: ['00000000-0000-0000-0000-000000000002'],
|
||||
// no password
|
||||
},
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
import { isProtected, authenticate, isAuthenticated, findSubpageBySlug } from '@/lib/auth';
|
||||
|
||||
describe('findSubpageBySlug', () => {
|
||||
it('returns the subpage config for a known slug', () => {
|
||||
const sp = findSubpageBySlug('private');
|
||||
expect(sp).toBeDefined();
|
||||
expect(sp?.name).toBe('Private');
|
||||
});
|
||||
|
||||
it('returns undefined for an unknown slug', () => {
|
||||
expect(findSubpageBySlug('nonexistent')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isProtected', () => {
|
||||
it('returns true for a password-protected subpage', () => {
|
||||
expect(isProtected('private')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for a non-protected subpage', () => {
|
||||
expect(isProtected('public')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for an unknown slug', () => {
|
||||
expect(isProtected('unknown')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('authenticate', () => {
|
||||
it('returns a Set-Cookie string for the correct password', () => {
|
||||
const cookie = authenticate('private', 'secret123');
|
||||
expect(cookie).toBeTruthy();
|
||||
expect(cookie).toContain('lb_auth_private=');
|
||||
expect(cookie).toContain('HttpOnly');
|
||||
expect(cookie).toContain('SameSite=Strict');
|
||||
});
|
||||
|
||||
it('returns null for the wrong password', () => {
|
||||
expect(authenticate('private', 'wrongpass')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a non-protected slug', () => {
|
||||
expect(authenticate('public', 'anything')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAuthenticated', () => {
|
||||
it('returns true when a valid cookie is present', () => {
|
||||
// First authenticate to get the expected token
|
||||
const cookie = authenticate('private', 'secret123')!;
|
||||
const token = cookie.split('=')[1].split(';')[0];
|
||||
|
||||
const getCookie = (name: string) => (name === 'lb_auth_private' ? token : undefined);
|
||||
expect(isAuthenticated('private', getCookie)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when no cookie is present', () => {
|
||||
const getCookie = () => undefined;
|
||||
expect(isAuthenticated('private', getCookie)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for an invalid token', () => {
|
||||
const getCookie = (name: string) => (name === 'lb_auth_private' ? 'badtoken' : undefined);
|
||||
expect(isAuthenticated('private', getCookie)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for non-protected pages (no auth needed)', () => {
|
||||
const getCookie = () => undefined;
|
||||
expect(isAuthenticated('public', getCookie)).toBe(true);
|
||||
});
|
||||
});
|
||||
60
lib/__tests__/rate-limit.test.ts
Normal file
60
lib/__tests__/rate-limit.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { checkRateLimit } from '@/lib/rate-limit';
|
||||
|
||||
describe('checkRateLimit', () => {
|
||||
// Use unique IP prefixes per test to avoid cross-contamination
|
||||
let testIp: string;
|
||||
let counter = 0;
|
||||
|
||||
beforeEach(() => {
|
||||
counter++;
|
||||
testIp = `test-ip-${counter}-${Date.now()}`;
|
||||
});
|
||||
|
||||
it('allows the first request', () => {
|
||||
const result = checkRateLimit(testIp, 5);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.remaining).toBe(4);
|
||||
});
|
||||
|
||||
it('tracks remaining count correctly', () => {
|
||||
checkRateLimit(testIp, 5);
|
||||
const result = checkRateLimit(testIp, 5);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.remaining).toBe(3);
|
||||
});
|
||||
|
||||
it('blocks requests exceeding the limit', () => {
|
||||
const maxRpm = 3;
|
||||
checkRateLimit(testIp, maxRpm); // 1
|
||||
checkRateLimit(testIp, maxRpm); // 2
|
||||
checkRateLimit(testIp, maxRpm); // 3
|
||||
|
||||
const result = checkRateLimit(testIp, maxRpm); // 4 — blocked
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.remaining).toBe(0);
|
||||
});
|
||||
|
||||
it('tracks separate IPs independently', () => {
|
||||
const ip1 = `${testIp}-a`;
|
||||
const ip2 = `${testIp}-b`;
|
||||
|
||||
checkRateLimit(ip1, 2);
|
||||
checkRateLimit(ip1, 2);
|
||||
const blocked = checkRateLimit(ip1, 2);
|
||||
expect(blocked.success).toBe(false);
|
||||
|
||||
// ip2 should still be allowed
|
||||
const allowed = checkRateLimit(ip2, 2);
|
||||
expect(allowed.success).toBe(true);
|
||||
expect(allowed.remaining).toBe(1);
|
||||
});
|
||||
|
||||
it('returns a future resetAt timestamp', () => {
|
||||
const before = Date.now();
|
||||
const result = checkRateLimit(testIp, 5);
|
||||
expect(result.resetAt).toBeGreaterThan(before);
|
||||
// Should be within ~60 seconds from now
|
||||
expect(result.resetAt).toBeLessThanOrEqual(before + 61_000);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user