Webhookのメッセージをカスタマイズできるように

This commit is contained in:
CyberRex
2026-05-27 10:59:58 +09:00
parent 2a4050d442
commit 38acbd35bb
14 changed files with 994 additions and 49 deletions

View File

@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createApp } from '../src/server/app.js';
import { query } from '../src/server/db/pool.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', () => ({
@@ -50,6 +50,7 @@ function mockAuthenticatedUser() {
describe('API security boundaries', () => {
beforeEach(() => {
query.mockReset();
pool.connect.mockReset();
getCertificateExpiry.mockReset();
});
@@ -161,6 +162,10 @@ describe('API security boundaries', () => {
},
],
};
}).mockImplementationOnce(async (sql, params) => {
expect(sql).toContain('FROM user_notification_settings');
expect(params).toEqual([USER_ID]);
return { rows: [] };
});
const app = createApp();
@@ -174,6 +179,168 @@ describe('API security boundaries', () => {
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 () => {