import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createApp } from '../src/server/app.js'; import { query } from '../src/server/db/pool.js'; import { getCertificateExpiry } from '../src/server/modules/monitoring/certificate.js'; vi.mock('../src/server/db/pool.js', () => ({ pool: { connect: vi.fn(), }, query: vi.fn(), })); vi.mock('../src/server/modules/monitoring/certificate.js', () => ({ getCertificateExpiry: vi.fn(), })); const USER_ID = '11111111-1111-4111-8111-111111111111'; const SITE_ID = '22222222-2222-4222-8222-222222222222'; const ALERT_ID = '33333333-3333-4333-8333-333333333333'; const WEBHOOK_ID = '44444444-4444-4444-8444-444444444444'; function authCookie() { return 'certremind_session=session-1'; } function csrfCookie() { return `${authCookie()}; certremind_csrf=csrf-token`; } function mockAuthenticatedUser() { query.mockImplementation(async (sql, params) => { if (sql.includes('FROM sessions s')) { expect(params).toEqual(['session-1']); return { rows: [ { user_id: USER_ID, username: 'alice', display_name: 'Alice', }, ], }; } throw new Error(`Unexpected query: ${sql}`); }); } describe('API security boundaries', () => { beforeEach(() => { query.mockReset(); getCertificateExpiry.mockReset(); }); it('requires a CSRF token for state-changing API requests', async () => { const app = createApp(); const response = await app.request('/api/sites', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ url: 'https://example.com', alias: 'Example' }), }); expect(response.status).toBe(403); await expect(response.json()).resolves.toMatchObject({ error: 'CSRF トークンが不正です', }); expect(query).not.toHaveBeenCalled(); }); it('requires authentication for site listing', async () => { const app = createApp(); const response = await app.request('/api/sites'); expect(response.status).toBe(401); await expect(response.json()).resolves.toMatchObject({ error: '認証が必要です', }); expect(query).not.toHaveBeenCalled(); }); it('uses the session user when listing sites', async () => { mockAuthenticatedUser(); query.mockImplementationOnce(query.getMockImplementation()).mockImplementationOnce(async (sql, params) => { expect(sql).toContain('FROM sites'); expect(params).toEqual([USER_ID]); return { rows: [ { site_id: SITE_ID, url: 'https://example.com/', alias: 'Example', certificate_issuer: 'C = US, O = Example CA, CN = Example Root', certificate_issued_at: '2026-01-01T00:00:00.000Z', certificate_expires_at: '2026-12-31T00:00:00.000Z', certificate_checked_at: '2026-05-21T00:00:00.000Z', certificate_check_error: null, created_at: '2026-05-20T00:00:00.000Z', updated_at: '2026-05-21T00:00:00.000Z', }, ], }; }); const app = createApp(); const response = await app.request('/api/sites', { headers: { Cookie: authCookie(), }, }); expect(response.status).toBe(200); await expect(response.json()).resolves.toMatchObject({ sites: [ { siteId: SITE_ID, certificateIssuer: 'C = US, O = Example CA, CN = Example Root', certificateIssuedAt: '2026-01-01T00:00:00.000Z', certificateExpiresAt: '2026-12-31T00:00:00.000Z', }, ], }); }); it('rejects private webhook URLs before insertion', async () => { mockAuthenticatedUser(); const app = createApp(); const response = await app.request('/api/notification-methods/webhooks', { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: csrfCookie(), 'x-csrf-token': 'csrf-token', }, body: JSON.stringify({ alias: 'Internal', url: 'https://127.0.0.1/hook' }), }); expect(response.status).toBe(400); expect(query).toHaveBeenCalledTimes(1); }); it('stores the initial certificate metadata when creating a site', async () => { const issuer = 'C = US, O = Example CA, CN = Example Root'; const issuedAt = new Date('2026-01-01T00:00:00.000Z'); const expiresAt = new Date('2026-12-31T00:00:00.000Z'); getCertificateExpiry.mockResolvedValue({ issuer, issuedAt, expiresAt, hoursUntilExpiry: 24 * 30 }); mockAuthenticatedUser(); query.mockImplementationOnce(query.getMockImplementation()).mockImplementationOnce(async (sql, params) => { expect(sql).toContain('INSERT INTO sites'); expect(sql).toContain('certificate_issuer'); expect(sql).toContain('certificate_issued_at'); expect(sql).toContain('certificate_expires_at'); expect(sql).toContain('certificate_checked_at'); expect(params).toEqual([USER_ID, 'https://example.com/', 'Example', issuer, issuedAt, expiresAt]); return { rows: [ { site_id: SITE_ID, url: 'https://example.com/', alias: 'Example', certificate_issuer: issuer, certificate_issued_at: issuedAt.toISOString(), certificate_expires_at: expiresAt.toISOString(), certificate_checked_at: '2026-05-21T00:00:00.000Z', certificate_check_error: null, created_at: '2026-05-20T00:00:00.000Z', updated_at: '2026-05-21T00:00:00.000Z', }, ], }; }); const app = createApp(); const response = await app.request('/api/sites', { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: csrfCookie(), 'x-csrf-token': 'csrf-token', }, body: JSON.stringify({ url: 'https://example.com', alias: 'Example' }), }); expect(response.status).toBe(201); expect(getCertificateExpiry).toHaveBeenCalledWith('https://example.com/', { timeoutMs: 3000, }); await expect(response.json()).resolves.toMatchObject({ site: { siteId: SITE_ID, certificateIssuer: issuer, certificateIssuedAt: issuedAt.toISOString(), certificateExpiresAt: expiresAt.toISOString(), }, }); }); it('rejects site creation when the initial certificate expiry cannot be fetched', async () => { getCertificateExpiry.mockRejectedValue(new Error('OpenSSL の実行がタイムアウトしました')); mockAuthenticatedUser(); const app = createApp(); const response = await app.request('/api/sites', { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: csrfCookie(), 'x-csrf-token': 'csrf-token', }, body: JSON.stringify({ url: 'https://example.com', alias: 'Example' }), }); expect(response.status).toBe(400); expect(query).toHaveBeenCalledTimes(1); await expect(response.json()).resolves.toMatchObject({ error: '証明書の期限を取得できませんでした', details: { reason: 'OpenSSL の実行がタイムアウトしました', }, }); }); it('does not mark another user alert as read', async () => { mockAuthenticatedUser(); query.mockImplementationOnce(query.getMockImplementation()).mockImplementationOnce(async (sql, params) => { expect(sql).toContain('UPDATE alert_history'); expect(params).toEqual([USER_ID, ALERT_ID]); return { rows: [] }; }); const app = createApp(); const response = await app.request(`/api/alerts/${ALERT_ID}/read`, { method: 'PATCH', headers: { Cookie: csrfCookie(), 'x-csrf-token': 'csrf-token', }, }); expect(response.status).toBe(404); }); it('rejects settings that reference another user webhook', async () => { mockAuthenticatedUser(); query .mockImplementationOnce(query.getMockImplementation()) .mockImplementationOnce(async () => ({ rows: [ { site_id: SITE_ID, url: 'https://example.com/', alias: 'Example', certificate_issuer: null, certificate_issued_at: null, certificate_expires_at: null, certificate_checked_at: null, certificate_check_error: null, created_at: '2026-05-20T00:00:00.000Z', updated_at: '2026-05-21T00:00:00.000Z', }, ], })) .mockImplementationOnce(async (sql, params) => { expect(sql).toContain('FROM notification_methods'); expect(params).toEqual([USER_ID, [WEBHOOK_ID]]); return { rowCount: 0, rows: [] }; }); const app = createApp(); const response = await app.request(`/api/sites/${SITE_ID}/settings`, { method: 'PUT', headers: { 'Content-Type': 'application/json', Cookie: csrfCookie(), 'x-csrf-token': 'csrf-token', }, body: JSON.stringify({ conditions: [{ thresholdHours: 24 }], webhookMethodIds: [WEBHOOK_ID], pushEnabled: false, }), }); expect(response.status).toBe(400); await expect(response.json()).resolves.toMatchObject({ error: '選択された Webhook が見つかりません', }); }); });