First commit
This commit is contained in:
293
tests/apiSecurity.test.js
Normal file
293
tests/apiSecurity.test.js
Normal file
@@ -0,0 +1,293 @@
|
||||
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 が見つかりません',
|
||||
});
|
||||
});
|
||||
});
|
||||
225
tests/monitoring.test.js
Normal file
225
tests/monitoring.test.js
Normal file
@@ -0,0 +1,225 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { runCertificateMonitoring } from '../src/server/modules/monitoring/monitor.js';
|
||||
import { pool } from '../src/server/db/pool.js';
|
||||
import { getCertificateExpiry } from '../src/server/modules/monitoring/certificate.js';
|
||||
import { deliverNotifications } from '../src/server/modules/monitoring/notifications.js';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
client: {
|
||||
query: vi.fn(),
|
||||
release: vi.fn(),
|
||||
},
|
||||
getCertificateExpiry: vi.fn(),
|
||||
deliverNotifications: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../src/server/db/pool.js', () => ({
|
||||
pool: {
|
||||
connect: vi.fn(async () => mocks.client),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../src/server/modules/monitoring/certificate.js', () => ({
|
||||
getCertificateExpiry: mocks.getCertificateExpiry,
|
||||
}));
|
||||
|
||||
vi.mock('../src/server/modules/monitoring/notifications.js', () => ({
|
||||
deliverNotifications: mocks.deliverNotifications,
|
||||
}));
|
||||
|
||||
const SITE_ID = '22222222-2222-4222-8222-222222222222';
|
||||
const USER_ID = '11111111-1111-4111-8111-111111111111';
|
||||
const WEBHOOK_ID = '44444444-4444-4444-8444-444444444444';
|
||||
|
||||
describe('certificate monitoring', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('stores the latest certificate expiry and creates an alert when a threshold matches', 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(Date.now() + 12 * 60 * 60 * 1000);
|
||||
|
||||
getCertificateExpiry.mockResolvedValue({
|
||||
issuer,
|
||||
issuedAt,
|
||||
expiresAt,
|
||||
hoursUntilExpiry: 12,
|
||||
});
|
||||
deliverNotifications.mockResolvedValue({
|
||||
webhook: [{ ok: true }],
|
||||
push: { ok: true },
|
||||
});
|
||||
|
||||
mocks.client.query.mockImplementation(async (sql, params) => {
|
||||
if (sql.includes('FROM sites s') && sql.includes('LEFT JOIN site_alert_conditions')) {
|
||||
return {
|
||||
rows: [
|
||||
{
|
||||
site_id: SITE_ID,
|
||||
user_id: USER_ID,
|
||||
url: 'https://example.com/',
|
||||
alias: 'Example',
|
||||
conditions: [
|
||||
{
|
||||
site_alert_condition_id: '55555555-5555-4555-8555-555555555555',
|
||||
threshold_hours: 24,
|
||||
webhook_method_ids: [WEBHOOK_ID],
|
||||
push_enabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (sql.includes('SET certificate_issuer')) {
|
||||
expect(sql).toContain('certificate_issued_at');
|
||||
expect(sql).toContain('certificate_expires_at');
|
||||
expect(params).toEqual([SITE_ID, issuer, issuedAt, expiresAt]);
|
||||
return { rows: [] };
|
||||
}
|
||||
|
||||
if (sql.includes('FROM notification_methods') && sql.includes("notification_type = 'webhook'")) {
|
||||
expect(params).toEqual([USER_ID, [WEBHOOK_ID]]);
|
||||
return {
|
||||
rows: [
|
||||
{
|
||||
notification_method_id: WEBHOOK_ID,
|
||||
alias: 'Deploy hook',
|
||||
url: 'https://hooks.example.com/',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (sql.includes('FROM notification_methods') && sql.includes("notification_type = 'push'")) {
|
||||
expect(params).toEqual([USER_ID]);
|
||||
return {
|
||||
rows: [
|
||||
{
|
||||
notification_method_id: '66666666-6666-4666-8666-666666666666',
|
||||
push_endpoint: 'https://push.example.com/subscription',
|
||||
push_p256dh: 'p256dh',
|
||||
push_auth: 'auth',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (sql.includes('INSERT INTO alert_history')) {
|
||||
expect(params[0]).toBe(USER_ID);
|
||||
expect(params[1]).toBe(SITE_ID);
|
||||
expect(params[2]).toBe('certificate_expiring');
|
||||
expect(params[4]).toEqual(['app', 'webhook', 'push']);
|
||||
return { rows: [{ alert_id: '77777777-7777-4777-8777-777777777777' }] };
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected query: ${sql}`);
|
||||
});
|
||||
|
||||
const result = await runCertificateMonitoring({ concurrency: 1 });
|
||||
|
||||
expect(pool.connect).toHaveBeenCalledOnce();
|
||||
expect(getCertificateExpiry).toHaveBeenCalledWith('https://example.com/');
|
||||
expect(deliverNotifications).toHaveBeenCalledOnce();
|
||||
expect(mocks.client.release).toHaveBeenCalledOnce();
|
||||
expect(result).toMatchObject({
|
||||
checkedSites: 1,
|
||||
alertsCreated: 1,
|
||||
results: [{ siteId: SITE_ID, ok: true, alertsCreated: 1 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('stores certificate check errors without clearing the previous expiry', async () => {
|
||||
const error = new Error('openssl failed');
|
||||
|
||||
getCertificateExpiry.mockRejectedValue(error);
|
||||
mocks.client.query.mockImplementation(async (sql, params) => {
|
||||
if (sql.includes('FROM sites s') && sql.includes('LEFT JOIN site_alert_conditions')) {
|
||||
return {
|
||||
rows: [
|
||||
{
|
||||
site_id: SITE_ID,
|
||||
user_id: USER_ID,
|
||||
url: 'https://example.com/',
|
||||
alias: 'Example',
|
||||
conditions: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (sql.includes('SET certificate_checked_at') && sql.includes('certificate_check_error = $2')) {
|
||||
expect(sql).not.toContain('certificate_expires_at');
|
||||
expect(params).toEqual([SITE_ID, error.message]);
|
||||
return { rows: [] };
|
||||
}
|
||||
|
||||
if (sql.includes('INSERT INTO alert_history')) {
|
||||
expect(params[2]).toBe('certificate_check_failed');
|
||||
expect(JSON.parse(params[5])).toMatchObject({ error: error.message });
|
||||
return { rows: [{ alert_id: '77777777-7777-4777-8777-777777777777' }] };
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected query: ${sql}`);
|
||||
});
|
||||
|
||||
const result = await runCertificateMonitoring({ concurrency: 1 });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
checkedSites: 1,
|
||||
alertsCreated: 1,
|
||||
results: [{ siteId: SITE_ID, ok: false, error: error.message }],
|
||||
});
|
||||
});
|
||||
|
||||
it('checks and stores certificate expiry for sites without alert conditions', 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(Date.now() + 90 * 24 * 60 * 60 * 1000);
|
||||
|
||||
getCertificateExpiry.mockResolvedValue({
|
||||
issuer,
|
||||
issuedAt,
|
||||
expiresAt,
|
||||
hoursUntilExpiry: 90 * 24,
|
||||
});
|
||||
|
||||
mocks.client.query.mockImplementation(async (sql, params) => {
|
||||
if (sql.includes('FROM sites s') && sql.includes('LEFT JOIN site_alert_conditions')) {
|
||||
return {
|
||||
rows: [
|
||||
{
|
||||
site_id: SITE_ID,
|
||||
user_id: USER_ID,
|
||||
url: 'https://example.com/',
|
||||
alias: 'Example',
|
||||
conditions: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (sql.includes('SET certificate_issuer')) {
|
||||
expect(sql).toContain('certificate_issued_at');
|
||||
expect(sql).toContain('certificate_expires_at');
|
||||
expect(params).toEqual([SITE_ID, issuer, issuedAt, expiresAt]);
|
||||
return { rows: [] };
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected query: ${sql}`);
|
||||
});
|
||||
|
||||
const result = await runCertificateMonitoring({ concurrency: 1 });
|
||||
|
||||
expect(getCertificateExpiry).toHaveBeenCalledWith('https://example.com/');
|
||||
expect(deliverNotifications).not.toHaveBeenCalled();
|
||||
expect(result).toMatchObject({
|
||||
checkedSites: 1,
|
||||
alertsCreated: 0,
|
||||
results: [{ siteId: SITE_ID, ok: true, alertsCreated: 0 }],
|
||||
});
|
||||
});
|
||||
});
|
||||
21
tests/urlPolicy.test.js
Normal file
21
tests/urlPolicy.test.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { defaultAliasForUrl, normalizeHttpsUrl } from '../src/server/utils/urlPolicy.js';
|
||||
|
||||
describe('urlPolicy', () => {
|
||||
it('normalizes host-only values to https URLs', () => {
|
||||
expect(normalizeHttpsUrl('example.com')).toBe('https://example.com/');
|
||||
});
|
||||
|
||||
it('rejects non-https URLs', () => {
|
||||
expect(() => normalizeHttpsUrl('http://example.com')).toThrow('HTTPS');
|
||||
});
|
||||
|
||||
it('rejects localhost addresses', () => {
|
||||
expect(() => normalizeHttpsUrl('https://localhost')).toThrow('監視できない');
|
||||
expect(() => normalizeHttpsUrl('https://127.0.0.1')).toThrow('監視できない');
|
||||
});
|
||||
|
||||
it('uses the hostname as a default alias', () => {
|
||||
expect(defaultAliasForUrl('https://www.example.com/path')).toBe('www.example.com');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user