595 lines
19 KiB
JavaScript
595 lines
19 KiB
JavaScript
import { afterEach, 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';
|
|
const CONDITION_ID = '55555555-5555-4555-8555-555555555555';
|
|
const NOW = new Date('2026-05-25T00:00:00.000Z');
|
|
|
|
describe('certificate monitoring', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(NOW);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
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() + 11.5 * 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: CONDITION_ID,
|
|
threshold_hours: 12,
|
|
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 user_notification_settings')) {
|
|
expect(params).toEqual([USER_ID]);
|
|
return { rows: [] };
|
|
}
|
|
|
|
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' }] };
|
|
}
|
|
|
|
if (sql.includes('UPDATE site_alert_conditions')) {
|
|
expect(sql).toContain('last_notified_certificate_expires_at');
|
|
expect(sql).toContain('last_notified_at = now()');
|
|
expect(params).toEqual([CONDITION_ID, expiresAt]);
|
|
return { rows: [] };
|
|
}
|
|
|
|
throw new Error(`Unexpected query: ${sql}`);
|
|
});
|
|
|
|
const result = await runCertificateMonitoring({ concurrency: 1 });
|
|
|
|
expect(pool.connect).toHaveBeenCalledOnce();
|
|
expect(getCertificateExpiry).toHaveBeenCalledWith('https://example.com/');
|
|
expect(deliverNotifications).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
webhookMessageBody:
|
|
'example.com の有効期限が 12時間前 になりました。\n2026/05/25 20:30:00 に期限切れになります。',
|
|
}),
|
|
);
|
|
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 }],
|
|
});
|
|
});
|
|
|
|
it('does not notify again when the same certificate expiry was already handled', 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() + 11.5 * 60 * 60 * 1000);
|
|
|
|
getCertificateExpiry.mockResolvedValue({
|
|
issuer,
|
|
issuedAt,
|
|
expiresAt,
|
|
hoursUntilExpiry: 12,
|
|
});
|
|
|
|
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: CONDITION_ID,
|
|
threshold_hours: 12,
|
|
webhook_method_ids: [WEBHOOK_ID],
|
|
push_enabled: true,
|
|
last_notified_certificate_expires_at: expiresAt,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
if (sql.includes('SET certificate_issuer')) {
|
|
expect(params).toEqual([SITE_ID, issuer, issuedAt, expiresAt]);
|
|
return { rows: [] };
|
|
}
|
|
|
|
throw new Error(`Unexpected query: ${sql}`);
|
|
});
|
|
|
|
const result = await runCertificateMonitoring({ concurrency: 1 });
|
|
|
|
expect(deliverNotifications).not.toHaveBeenCalled();
|
|
expect(result).toMatchObject({
|
|
checkedSites: 1,
|
|
alertsCreated: 0,
|
|
results: [{ siteId: SITE_ID, ok: true, alertsCreated: 0 }],
|
|
});
|
|
});
|
|
|
|
it('marks a condition as handled without notifying when it is more than an hour late', 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() + 23 * 60 * 60 * 1000);
|
|
|
|
getCertificateExpiry.mockResolvedValue({
|
|
issuer,
|
|
issuedAt,
|
|
expiresAt,
|
|
hoursUntilExpiry: 23,
|
|
});
|
|
|
|
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: CONDITION_ID,
|
|
threshold_hours: 24,
|
|
webhook_method_ids: [WEBHOOK_ID],
|
|
push_enabled: true,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
if (sql.includes('SET certificate_issuer')) {
|
|
expect(params).toEqual([SITE_ID, issuer, issuedAt, expiresAt]);
|
|
return { rows: [] };
|
|
}
|
|
|
|
if (sql.includes('UPDATE site_alert_conditions')) {
|
|
expect(sql).toContain('last_notification_skipped_at = now()');
|
|
expect(params).toEqual([CONDITION_ID, expiresAt]);
|
|
return { rows: [] };
|
|
}
|
|
|
|
throw new Error(`Unexpected query: ${sql}`);
|
|
});
|
|
|
|
const result = await runCertificateMonitoring({ concurrency: 1 });
|
|
|
|
expect(deliverNotifications).not.toHaveBeenCalled();
|
|
expect(result).toMatchObject({
|
|
checkedSites: 1,
|
|
alertsCreated: 0,
|
|
results: [{ siteId: SITE_ID, ok: true, alertsCreated: 0 }],
|
|
});
|
|
});
|
|
|
|
it('notifies and records handled state when a matching condition is less than an hour late', 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() + 23.5 * 60 * 60 * 1000);
|
|
|
|
getCertificateExpiry.mockResolvedValue({
|
|
issuer,
|
|
issuedAt,
|
|
expiresAt,
|
|
hoursUntilExpiry: 23,
|
|
});
|
|
deliverNotifications.mockResolvedValue({
|
|
webhook: [],
|
|
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: CONDITION_ID,
|
|
threshold_hours: 24,
|
|
webhook_method_ids: [],
|
|
push_enabled: true,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
if (sql.includes('SET certificate_issuer')) {
|
|
expect(params).toEqual([SITE_ID, issuer, issuedAt, expiresAt]);
|
|
return { rows: [] };
|
|
}
|
|
|
|
if (sql.includes('FROM notification_methods') && sql.includes("notification_type = 'push'")) {
|
|
expect(params).toEqual([USER_ID]);
|
|
return { rows: [] };
|
|
}
|
|
|
|
if (sql.includes('INSERT INTO alert_history')) {
|
|
expect(params[2]).toBe('certificate_expiring');
|
|
expect(params[4]).toEqual(['app', 'push']);
|
|
return { rows: [{ alert_id: '77777777-7777-4777-8777-777777777777' }] };
|
|
}
|
|
|
|
if (sql.includes('UPDATE site_alert_conditions')) {
|
|
expect(sql).toContain('last_notified_at = now()');
|
|
expect(params).toEqual([CONDITION_ID, expiresAt]);
|
|
return { rows: [] };
|
|
}
|
|
|
|
throw new Error(`Unexpected query: ${sql}`);
|
|
});
|
|
|
|
const result = await runCertificateMonitoring({ concurrency: 1 });
|
|
|
|
expect(deliverNotifications).toHaveBeenCalledOnce();
|
|
expect(result).toMatchObject({
|
|
checkedSites: 1,
|
|
alertsCreated: 1,
|
|
results: [{ siteId: SITE_ID, ok: true, alertsCreated: 1 }],
|
|
});
|
|
});
|
|
|
|
it('notifies again when a new certificate expiry is observed', async () => {
|
|
const issuer = 'C = US, O = Example CA, CN = Example Root';
|
|
const issuedAt = new Date('2026-01-01T00:00:00.000Z');
|
|
const previousExpiresAt = new Date('2026-05-30T00:00:00.000Z');
|
|
const expiresAt = new Date(Date.now() + 11.5 * 60 * 60 * 1000);
|
|
|
|
getCertificateExpiry.mockResolvedValue({
|
|
issuer,
|
|
issuedAt,
|
|
expiresAt,
|
|
hoursUntilExpiry: 12,
|
|
});
|
|
deliverNotifications.mockResolvedValue({
|
|
webhook: [],
|
|
push: { ok: false, skipped: 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: CONDITION_ID,
|
|
threshold_hours: 12,
|
|
webhook_method_ids: [],
|
|
push_enabled: false,
|
|
last_notified_certificate_expires_at: previousExpiresAt,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
if (sql.includes('SET certificate_issuer')) {
|
|
expect(params).toEqual([SITE_ID, issuer, issuedAt, expiresAt]);
|
|
return { rows: [] };
|
|
}
|
|
|
|
if (sql.includes('INSERT INTO alert_history')) {
|
|
expect(params[2]).toBe('certificate_expiring');
|
|
expect(params[4]).toEqual(['app']);
|
|
return { rows: [{ alert_id: '77777777-7777-4777-8777-777777777777' }] };
|
|
}
|
|
|
|
if (sql.includes('UPDATE site_alert_conditions')) {
|
|
expect(params).toEqual([CONDITION_ID, expiresAt]);
|
|
return { rows: [] };
|
|
}
|
|
|
|
throw new Error(`Unexpected query: ${sql}`);
|
|
});
|
|
|
|
const result = await runCertificateMonitoring({ concurrency: 1 });
|
|
|
|
expect(deliverNotifications).toHaveBeenCalledOnce();
|
|
expect(result).toMatchObject({
|
|
checkedSites: 1,
|
|
alertsCreated: 1,
|
|
results: [{ siteId: SITE_ID, ok: true, alertsCreated: 1 }],
|
|
});
|
|
});
|
|
|
|
it('renders a custom webhook message with the configured timezone and timing labels', async () => {
|
|
vi.setSystemTime(new Date('2026-12-24T05:36:07.000Z'));
|
|
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-31T05:06:07.000Z');
|
|
|
|
getCertificateExpiry.mockResolvedValue({
|
|
issuer,
|
|
issuedAt,
|
|
expiresAt,
|
|
hoursUntilExpiry: 168,
|
|
});
|
|
deliverNotifications.mockResolvedValue({
|
|
webhook: [],
|
|
push: { ok: false, skipped: 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 Site',
|
|
conditions: [
|
|
{
|
|
site_alert_condition_id: CONDITION_ID,
|
|
threshold_hours: 168,
|
|
webhook_method_ids: [WEBHOOK_ID],
|
|
push_enabled: false,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
if (sql.includes('SET certificate_issuer')) {
|
|
expect(params).toEqual([SITE_ID, issuer, issuedAt, expiresAt]);
|
|
return { rows: [] };
|
|
}
|
|
|
|
if (sql.includes('FROM notification_methods') && sql.includes("notification_type = 'webhook'")) {
|
|
return {
|
|
rows: [
|
|
{
|
|
notification_method_id: WEBHOOK_ID,
|
|
alias: 'Deploy hook',
|
|
url: 'https://hooks.example.com/',
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
if (sql.includes('FROM user_notification_settings')) {
|
|
expect(params).toEqual([USER_ID]);
|
|
return {
|
|
rows: [
|
|
{
|
|
webhook_message_template:
|
|
'{{siteName}}/{{siteDomain}}/{{condTiming}}/{{expiryDate}}/{{expiryTime}}/{{expiryDateTime}}',
|
|
timezone: 'America/New_York',
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
if (sql.includes('INSERT INTO alert_history')) {
|
|
return { rows: [{ alert_id: '77777777-7777-4777-8777-777777777777' }] };
|
|
}
|
|
|
|
if (sql.includes('UPDATE site_alert_conditions')) {
|
|
expect(params).toEqual([CONDITION_ID, expiresAt]);
|
|
return { rows: [] };
|
|
}
|
|
|
|
throw new Error(`Unexpected query: ${sql}`);
|
|
});
|
|
|
|
await runCertificateMonitoring({ concurrency: 1 });
|
|
|
|
expect(deliverNotifications).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
webhookMessageBody:
|
|
'Example Site/example.com/1週間前/2026/12/31/00:06:07/2026/12/31 00:06:07',
|
|
}),
|
|
);
|
|
});
|
|
});
|