import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createApp } from '../src/server/app.js'; import { pool, 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'; const PUSH_ENDPOINT = 'https://push.example.com/subscription'; 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(); pool.connect.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('does not expose registered push subscription lists', async () => { mockAuthenticatedUser(); query.mockImplementationOnce(query.getMockImplementation()).mockImplementationOnce(async (sql, params) => { expect(sql).toContain('FROM notification_methods'); expect(sql).toContain("notification_type = 'webhook'"); expect(params).toEqual([USER_ID]); return { rows: [ { notification_method_id: WEBHOOK_ID, alias: 'Deploy hook', url: 'https://hooks.example.com/', created_at: '2026-05-20T00:00:00.000Z', updated_at: '2026-05-21T00:00:00.000Z', }, ], }; }).mockImplementationOnce(async (sql, params) => { expect(sql).toContain('FROM user_notification_settings'); expect(params).toEqual([USER_ID]); return { rows: [] }; }); const app = createApp(); const response = await app.request('/api/notification-methods', { headers: { Cookie: authCookie(), }, }); expect(response.status).toBe(200); const data = await response.json(); expect(data.webhooks).toHaveLength(1); expect(data).not.toHaveProperty('pushSubscriptions'); expect(data.webhookMessageSettings).toMatchObject({ timezone: 'Asia/Tokyo', usesDefault: true, }); }); it('requires a CSRF token when saving webhook message settings', async () => { const app = createApp(); const response = await app.request('/api/notification-methods/webhook-message-settings', { method: 'PUT', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ webhookMessageTemplate: '{{siteDomain}}', }), }); expect(response.status).toBe(403); expect(query).not.toHaveBeenCalled(); }); it('rejects invalid webhook message templates before persistence', async () => { mockAuthenticatedUser(); const app = createApp(); const unsupportedVariable = await app.request('/api/notification-methods/webhook-message-settings', { method: 'PUT', headers: { 'Content-Type': 'application/json', Cookie: csrfCookie(), 'x-csrf-token': 'csrf-token', }, body: JSON.stringify({ webhookMessageTemplate: '{{unknownValue}}', }), }); expect(unsupportedVariable.status).toBe(400); expect(query).toHaveBeenCalledTimes(1); }); it('rejects invalid account timezone before persistence', async () => { mockAuthenticatedUser(); const app = createApp(); const invalidTimezone = await app.request('/api/account/profile', { method: 'PATCH', headers: { 'Content-Type': 'application/json', Cookie: csrfCookie(), 'x-csrf-token': 'csrf-token', }, body: JSON.stringify({ displayName: 'Alice', timezone: 'Mars/Base', }), }); expect(invalidTimezone.status).toBe(400); expect(query).toHaveBeenCalledTimes(1); expect(pool.connect).not.toHaveBeenCalled(); }); it('saves account timezone for the session user only', async () => { const client = { query: vi.fn(async (sql, params) => { if (sql === 'BEGIN' || sql === 'COMMIT') return { rows: [] }; expect(sql).toContain('INSERT INTO user_notification_settings'); expect(params).toEqual([USER_ID, 'Alice', 'America/New_York']); return { rows: [ { user_id: USER_ID, username: 'alice', display_name: 'Alice', otp_secret: null, timezone: 'America/New_York', }, ], }; }), release: vi.fn(), }; pool.connect.mockResolvedValue(client); mockAuthenticatedUser(); const app = createApp(); const response = await app.request('/api/account/profile', { method: 'PATCH', headers: { 'Content-Type': 'application/json', Cookie: csrfCookie(), 'x-csrf-token': 'csrf-token', }, body: JSON.stringify({ displayName: 'Alice', timezone: 'America/New_York', }), }); expect(response.status).toBe(200); await expect(response.json()).resolves.toMatchObject({ account: { timezone: 'America/New_York' }, }); expect(client.query).toHaveBeenCalledWith('COMMIT'); expect(client.release).toHaveBeenCalledOnce(); }); it('saves and deletes webhook message settings for the session user only', async () => { mockAuthenticatedUser(); query .mockImplementationOnce(query.getMockImplementation()) .mockImplementationOnce(async (sql, params) => { expect(sql).toContain('INSERT INTO user_notification_settings'); expect(params).toEqual([USER_ID, '{{siteDomain}}']); return { rows: [ { webhook_message_template: '{{siteDomain}}', timezone: 'Asia/Tokyo', }, ], }; }); const app = createApp(); const saveResponse = await app.request('/api/notification-methods/webhook-message-settings', { method: 'PUT', headers: { 'Content-Type': 'application/json', Cookie: csrfCookie(), 'x-csrf-token': 'csrf-token', }, body: JSON.stringify({ webhookMessageTemplate: '{{siteDomain}}', }), }); expect(saveResponse.status).toBe(200); query.mockReset(); mockAuthenticatedUser(); query .mockImplementationOnce(query.getMockImplementation()) .mockImplementationOnce(async (sql, params) => { expect(sql).toContain('UPDATE user_notification_settings'); expect(sql).toContain('webhook_message_template = NULL'); expect(params).toEqual([USER_ID]); return { rows: [{ webhook_message_template: null, timezone: 'Asia/Tokyo' }] }; }); const deleteResponse = await app.request('/api/notification-methods/webhook-message-settings', { method: 'DELETE', headers: { Cookie: csrfCookie(), 'x-csrf-token': 'csrf-token', }, }); expect(deleteResponse.status).toBe(200); }); it('reports the current browser push subscription as registered for the session user', async () => { mockAuthenticatedUser(); query.mockImplementationOnce(query.getMockImplementation()).mockImplementationOnce(async (sql, params) => { expect(sql).toContain('FROM notification_methods'); expect(sql).toContain("notification_type = 'push'"); expect(params).toEqual([USER_ID, PUSH_ENDPOINT]); return { rows: [{ notification_method_id: '66666666-6666-4666-8666-666666666666' }] }; }); const app = createApp(); const response = await app.request('/api/notification-methods/push-subscription-status', { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: csrfCookie(), 'x-csrf-token': 'csrf-token', }, body: JSON.stringify({ endpoint: PUSH_ENDPOINT }), }); expect(response.status).toBe(200); await expect(response.json()).resolves.toEqual({ registered: true }); }); it('reports push subscriptions for other users or unknown endpoints as unregistered', async () => { mockAuthenticatedUser(); query.mockImplementationOnce(query.getMockImplementation()).mockImplementationOnce(async (sql, params) => { expect(sql).toContain('FROM notification_methods'); expect(params).toEqual([USER_ID, PUSH_ENDPOINT]); return { rows: [] }; }); const app = createApp(); const response = await app.request('/api/notification-methods/push-subscription-status', { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: csrfCookie(), 'x-csrf-token': 'csrf-token', }, body: JSON.stringify({ endpoint: PUSH_ENDPOINT }), }); expect(response.status).toBe(200); await expect(response.json()).resolves.toEqual({ registered: false }); }); it('rejects non-HTTPS push subscription status endpoints', async () => { mockAuthenticatedUser(); const app = createApp(); const response = await app.request('/api/notification-methods/push-subscription-status', { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: csrfCookie(), 'x-csrf-token': 'csrf-token', }, body: JSON.stringify({ endpoint: 'http://push.example.com/subscription' }), }); expect(response.status).toBe(400); expect(query).toHaveBeenCalledTimes(1); }); it('requires a CSRF token when deleting push subscriptions', async () => { const app = createApp(); const response = await app.request('/api/notification-methods/push-subscriptions', { method: 'DELETE', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ endpoint: PUSH_ENDPOINT }), }); expect(response.status).toBe(403); expect(query).not.toHaveBeenCalled(); }); it('deletes the current user push subscription by endpoint', async () => { mockAuthenticatedUser(); query.mockImplementationOnce(query.getMockImplementation()).mockImplementationOnce(async (sql, params) => { expect(sql).toContain('DELETE FROM notification_methods'); expect(sql).toContain("notification_type = 'push'"); expect(params).toEqual([USER_ID, PUSH_ENDPOINT]); return { rows: [{ notification_method_id: '66666666-6666-4666-8666-666666666666' }] }; }); const app = createApp(); const response = await app.request('/api/notification-methods/push-subscriptions', { method: 'DELETE', headers: { 'Content-Type': 'application/json', Cookie: csrfCookie(), 'x-csrf-token': 'csrf-token', }, body: JSON.stringify({ endpoint: PUSH_ENDPOINT }), }); expect(response.status).toBe(200); await expect(response.json()).resolves.toEqual({ ok: true }); }); it('does not delete push subscriptions owned by other users or unknown endpoints', async () => { mockAuthenticatedUser(); query.mockImplementationOnce(query.getMockImplementation()).mockImplementationOnce(async (sql, params) => { expect(sql).toContain('DELETE FROM notification_methods'); expect(params).toEqual([USER_ID, PUSH_ENDPOINT]); return { rows: [] }; }); const app = createApp(); const response = await app.request('/api/notification-methods/push-subscriptions', { method: 'DELETE', headers: { 'Content-Type': 'application/json', Cookie: csrfCookie(), 'x-csrf-token': 'csrf-token', }, body: JSON.stringify({ endpoint: PUSH_ENDPOINT }), }); expect(response.status).toBe(404); }); it('rejects non-HTTPS push subscription delete endpoints', async () => { mockAuthenticatedUser(); const app = createApp(); const response = await app.request('/api/notification-methods/push-subscriptions', { method: 'DELETE', headers: { 'Content-Type': 'application/json', Cookie: csrfCookie(), 'x-csrf-token': 'csrf-token', }, body: JSON.stringify({ endpoint: 'http://push.example.com/subscription' }), }); 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 が見つかりません', }); }); });