・プッシュ通知の修正

・メニューをスマホに最適化
・アラート送信済みの条件が再度発動しないように修正
This commit is contained in:
CyberRex
2026-05-25 16:29:38 +09:00
parent 50b872b439
commit a0356e630e
10 changed files with 844 additions and 88 deletions

View File

@@ -18,6 +18,7 @@ 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';
@@ -143,6 +144,182 @@ describe('API security boundaries', () => {
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',
},
],
};
});
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');
});
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');

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
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';
@@ -30,16 +30,24 @@ vi.mock('../src/server/modules/monitoring/notifications.js', () => ({
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() + 12 * 60 * 60 * 1000);
const expiresAt = new Date(Date.now() + 11.5 * 60 * 60 * 1000);
getCertificateExpiry.mockResolvedValue({
issuer,
@@ -63,8 +71,8 @@ describe('certificate monitoring', () => {
alias: 'Example',
conditions: [
{
site_alert_condition_id: '55555555-5555-4555-8555-555555555555',
threshold_hours: 24,
site_alert_condition_id: CONDITION_ID,
threshold_hours: 12,
webhook_method_ids: [WEBHOOK_ID],
push_enabled: true,
},
@@ -116,6 +124,13 @@ describe('certificate monitoring', () => {
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}`);
});
@@ -222,4 +237,257 @@ describe('certificate monitoring', () => {
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 }],
});
});
});