Adding Audit and Webhooks

This commit is contained in:
2026-04-13 19:43:58 -07:00
parent fc62ea634a
commit 4195d384d0
11 changed files with 1589 additions and 17 deletions

View File

@@ -0,0 +1,117 @@
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { mount, flushPromises } from '@vue/test-utils';
import { setActivePinia, createPinia } from 'pinia';
import { createRouter, createMemoryHistory } from 'vue-router';
import WebhookDeliveryHistory from '@/components/settings/WebhookDeliveryHistory.vue';
import type { WebhookDeliveryLogResponse } from '@/types/api';
jest.mock('@/api/webhooks');
jest.mock('@/api/client', () => ({
setAuthTokens: jest.fn(),
clearAuthTokens: jest.fn(),
getAccessToken: jest.fn(),
}));
import * as webhooksApi from '@/api/webhooks';
const mockDeliveries: WebhookDeliveryLogResponse[] = [
{
id: 1, webhookId: 10, eventType: 'feature.updated', success: true,
responseStatusCode: 200, responseBody: 'OK', errorMessage: null,
attemptNumber: 1, attemptedAt: '2026-01-10T10:00:00Z', duration: '00:00:00.123',
},
{
id: 2, webhookId: 10, eventType: 'feature.created', success: false,
responseStatusCode: 500, responseBody: null, errorMessage: 'Internal Server Error',
attemptNumber: 3, attemptedAt: '2026-01-11T11:00:00Z', duration: '00:00:01.500',
},
];
function mountComponent(props: Record<string, unknown> = {}) {
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }] });
return mount(WebhookDeliveryHistory, {
props: { envApiKey: 'env-dev', webhookId: 10, ...props },
global: { plugins: [router] },
});
}
describe('WebhookDeliveryHistory', () => {
beforeEach(() => {
setActivePinia(createPinia());
jest.clearAllMocks();
});
describe('WhenMounted', () => {
it('ThenDeliveriesAreLoaded', async () => {
jest.mocked(webhooksApi.listWebhookDeliveries).mockResolvedValue(mockDeliveries);
mountComponent();
await flushPromises();
expect(webhooksApi.listWebhookDeliveries).toHaveBeenCalledWith('env-dev', 10);
});
it('ThenNoDeliveriesMessageIsShownWhenEmpty', async () => {
jest.mocked(webhooksApi.listWebhookDeliveries).mockResolvedValue([]);
const wrapper = mountComponent();
await flushPromises();
expect(wrapper.find('[data-testid="no-deliveries-message"]').exists()).toBe(true);
});
it('ThenDeliveriesTableIsRendered', async () => {
jest.mocked(webhooksApi.listWebhookDeliveries).mockResolvedValue(mockDeliveries);
const wrapper = mountComponent();
await flushPromises();
expect(wrapper.find('[data-testid="deliveries-table"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="delivery-row-1"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="delivery-row-2"]').exists()).toBe(true);
});
});
describe('WhenRenderingDeliveryStatuses', () => {
it('ThenSuccessfulDeliveryShowsSuccessChip', async () => {
jest.mocked(webhooksApi.listWebhookDeliveries).mockResolvedValue(mockDeliveries);
const wrapper = mountComponent();
await flushPromises();
const successChip = wrapper.find('[data-testid="delivery-status-1"]');
expect(successChip.exists()).toBe(true);
expect(successChip.text()).toContain('Success');
});
it('ThenFailedDeliveryShowsFailedChip', async () => {
jest.mocked(webhooksApi.listWebhookDeliveries).mockResolvedValue(mockDeliveries);
const wrapper = mountComponent();
await flushPromises();
const failedChip = wrapper.find('[data-testid="delivery-status-2"]');
expect(failedChip.exists()).toBe(true);
expect(failedChip.text()).toContain('Failed');
});
it('ThenEventTypesAreDisplayed', async () => {
jest.mocked(webhooksApi.listWebhookDeliveries).mockResolvedValue(mockDeliveries);
const wrapper = mountComponent();
await flushPromises();
expect(wrapper.text()).toContain('feature.updated');
expect(wrapper.text()).toContain('feature.created');
});
it('ThenAttemptNumbersAreDisplayed', async () => {
jest.mocked(webhooksApi.listWebhookDeliveries).mockResolvedValue(mockDeliveries);
const wrapper = mountComponent();
await flushPromises();
expect(wrapper.text()).toContain('3'); // attemptNumber of second delivery
});
});
});

View File

@@ -0,0 +1,131 @@
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils';
import { setActivePinia, createPinia } from 'pinia';
import { createRouter, createMemoryHistory } from 'vue-router';
import WebhookDialog from '@/components/settings/WebhookDialog.vue';
import type { WebhookResponse } from '@/types/api';
jest.mock('@/api/webhooks');
jest.mock('@/api/client', () => ({
setAuthTokens: jest.fn(),
clearAuthTokens: jest.fn(),
getAccessToken: jest.fn(),
}));
import * as webhooksApi from '@/api/webhooks';
const mockWebhook: WebhookResponse = {
id: 1, url: 'https://example.com/hook', secret: 'mysecret', scope: 'Organization',
enabled: true, environmentId: null, organizationId: 1, createdAt: '2026-01-01T00:00:00Z',
};
const dialogStub = { template: '<div><slot /></div>' };
function mountDialog(props: Record<string, unknown> = {}) {
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }] });
return mount(WebhookDialog, {
props: { modelValue: true, orgId: 1, ...props },
global: { plugins: [router], stubs: { 'v-dialog': dialogStub } },
});
}
describe('WebhookDialog', () => {
beforeEach(() => {
setActivePinia(createPinia());
jest.clearAllMocks();
});
describe('WhenOpenedForCreate', () => {
it('ThenDialogIsRendered', () => {
const wrapper = mountDialog();
expect(wrapper.find('[data-testid="webhook-dialog"]').exists()).toBe(true);
});
it('ThenTitleShowsAddWebhook', () => {
const wrapper = mountDialog();
expect(wrapper.text()).toContain('Add Webhook');
});
it('ThenCreateOrgWebhookIsCalledOnSave', async () => {
const created = { ...mockWebhook, id: 99 };
jest.mocked(webhooksApi.createOrgWebhook).mockResolvedValue(created);
const wrapper = mountDialog();
await (wrapper.findComponent('[data-testid="webhook-url-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'https://example.com/new');
await wrapper.vm.$nextTick();
await wrapper.find('[data-testid="save-webhook-btn"]').trigger('click');
await flushPromises();
expect(webhooksApi.createOrgWebhook).toHaveBeenCalledWith(
1,
expect.objectContaining({ url: 'https://example.com/new' }),
);
});
it('ThenSavedEventIsEmitted', async () => {
const created = { ...mockWebhook, id: 99 };
jest.mocked(webhooksApi.createOrgWebhook).mockResolvedValue(created);
const wrapper = mountDialog();
await (wrapper.findComponent('[data-testid="webhook-url-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'https://example.com/new');
await wrapper.vm.$nextTick();
await wrapper.find('[data-testid="save-webhook-btn"]').trigger('click');
await flushPromises();
expect(wrapper.emitted('saved')).toBeTruthy();
expect(wrapper.emitted('saved')![0]).toEqual([created]);
});
});
describe('WhenOpenedForEdit', () => {
it('ThenTitleShowsEditWebhook', () => {
const wrapper = mountDialog({ webhook: mockWebhook });
expect(wrapper.text()).toContain('Edit Webhook');
});
it('ThenFormIsPrePopulatedWithWebhookData', () => {
const wrapper = mountDialog({ webhook: mockWebhook });
const urlInput = wrapper.findComponent('[data-testid="webhook-url-input"]') as VueWrapper<any>;
expect(urlInput.props('modelValue')).toBe(mockWebhook.url);
});
it('ThenUpdateOrgWebhookIsCalledOnSave', async () => {
const updated = { ...mockWebhook, url: 'https://example.com/updated' };
jest.mocked(webhooksApi.updateOrgWebhook).mockResolvedValue(updated);
const wrapper = mountDialog({ webhook: mockWebhook });
await wrapper.find('[data-testid="save-webhook-btn"]').trigger('click');
await flushPromises();
expect(webhooksApi.updateOrgWebhook).toHaveBeenCalledWith(
1, mockWebhook.id,
expect.objectContaining({ url: mockWebhook.url }),
);
});
});
describe('WhenEnvScopedDialogIsUsed', () => {
it('ThenCreateEnvWebhookIsCalledOnSave', async () => {
const created = { ...mockWebhook, id: 99, scope: 'Environment' as const };
jest.mocked(webhooksApi.createEnvWebhook).mockResolvedValue(created);
const wrapper = mountDialog({ orgId: undefined, envApiKey: 'env-dev' });
await (wrapper.findComponent('[data-testid="webhook-url-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'https://example.com/new');
await wrapper.vm.$nextTick();
await wrapper.find('[data-testid="save-webhook-btn"]').trigger('click');
await flushPromises();
expect(webhooksApi.createEnvWebhook).toHaveBeenCalledWith(
'env-dev',
expect.objectContaining({ url: 'https://example.com/new' }),
);
});
});
});

View File

@@ -0,0 +1,149 @@
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils';
import { setActivePinia, createPinia } from 'pinia';
import { createRouter, createMemoryHistory } from 'vue-router';
import WebhooksPanel from '@/components/settings/WebhooksPanel.vue';
import type { WebhookResponse } from '@/types/api';
jest.mock('@/api/webhooks');
jest.mock('@/api/client', () => ({
setAuthTokens: jest.fn(),
clearAuthTokens: jest.fn(),
getAccessToken: jest.fn(),
}));
import * as webhooksApi from '@/api/webhooks';
const mockOrgWebhooks: WebhookResponse[] = [
{
id: 1, url: 'https://example.com/hook1', secret: null, scope: 'Organization',
enabled: true, environmentId: null, organizationId: 1, createdAt: '2026-01-01T00:00:00Z',
},
{
id: 2, url: 'https://example.com/hook2', secret: 'abc', scope: 'Organization',
enabled: false, environmentId: null, organizationId: 1, createdAt: '2026-01-02T00:00:00Z',
},
];
const dialogStub = { template: '<div><slot /></div>' };
function mountPanel(props: Record<string, unknown> = {}) {
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }] });
return mount(WebhooksPanel, {
props: { orgId: 1, ...props },
global: { plugins: [router], stubs: { 'v-dialog': dialogStub } },
});
}
describe('WebhooksPanel', () => {
beforeEach(() => {
setActivePinia(createPinia());
jest.clearAllMocks();
});
describe('WhenMountedWithOrgScope', () => {
it('ThenOrgWebhooksAreLoaded', async () => {
jest.mocked(webhooksApi.listOrgWebhooks).mockResolvedValue(mockOrgWebhooks);
mountPanel();
await flushPromises();
expect(webhooksApi.listOrgWebhooks).toHaveBeenCalledWith(1);
});
it('ThenWebhooksTableIsRendered', async () => {
jest.mocked(webhooksApi.listOrgWebhooks).mockResolvedValue(mockOrgWebhooks);
const wrapper = mountPanel();
await flushPromises();
expect(wrapper.find('[data-testid="webhooks-table"]').exists()).toBe(true);
expect(wrapper.text()).toContain('https://example.com/hook1');
});
it('ThenNoWebhooksMessageIsShownWhenEmpty', async () => {
jest.mocked(webhooksApi.listOrgWebhooks).mockResolvedValue([]);
const wrapper = mountPanel();
await flushPromises();
expect(wrapper.find('[data-testid="no-webhooks-message"]').exists()).toBe(true);
});
});
describe('WhenMountedWithEnvScope', () => {
it('ThenEnvWebhooksAreLoaded', async () => {
jest.mocked(webhooksApi.listEnvWebhooks).mockResolvedValue([]);
mountPanel({ orgId: undefined, envApiKey: 'env-dev' });
await flushPromises();
expect(webhooksApi.listEnvWebhooks).toHaveBeenCalledWith('env-dev');
});
});
describe('WhenEnabledToggleIsChanged', () => {
it('ThenUpdateOrgWebhookIsCalledWithToggledEnabled', async () => {
jest.mocked(webhooksApi.listOrgWebhooks).mockResolvedValue(mockOrgWebhooks);
jest.mocked(webhooksApi.updateOrgWebhook).mockResolvedValue({ ...mockOrgWebhooks[0], enabled: false });
const wrapper = mountPanel();
await flushPromises();
await (wrapper.findComponent(`[data-testid="webhook-enabled-toggle-1"]`) as VueWrapper<any>).vm.$emit('update:modelValue', false);
await flushPromises();
expect(webhooksApi.updateOrgWebhook).toHaveBeenCalledWith(
1, 1,
expect.objectContaining({ enabled: false }),
);
});
});
describe('WhenDeleteIsConfirmed', () => {
it('ThenDeleteOrgWebhookIsCalledAndWebhookIsRemoved', async () => {
jest.mocked(webhooksApi.listOrgWebhooks).mockResolvedValue([...mockOrgWebhooks]);
jest.mocked(webhooksApi.deleteOrgWebhook).mockResolvedValue(undefined);
const wrapper = mountPanel();
await flushPromises();
await wrapper.find('[data-testid="delete-webhook-1"]').trigger('click');
await wrapper.vm.$nextTick();
await wrapper.find('[data-testid="confirm-delete-webhook-btn"]').trigger('click');
await flushPromises();
expect(webhooksApi.deleteOrgWebhook).toHaveBeenCalledWith(1, 1);
expect(wrapper.text()).not.toContain('https://example.com/hook1');
});
});
describe('WhenWebhookIsSaved', () => {
it('ThenNewWebhookIsAddedToList', async () => {
jest.mocked(webhooksApi.listOrgWebhooks).mockResolvedValue([]);
const wrapper = mountPanel();
await flushPromises();
const newHook: WebhookResponse = { ...mockOrgWebhooks[0], id: 99, url: 'https://new.example.com/hook' };
await wrapper.findComponent({ name: 'WebhookDialog' }).vm.$emit('saved', newHook);
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain('https://new.example.com/hook');
});
it('ThenExistingWebhookIsUpdatedInList', async () => {
jest.mocked(webhooksApi.listOrgWebhooks).mockResolvedValue([...mockOrgWebhooks]);
const wrapper = mountPanel();
await flushPromises();
const updated: WebhookResponse = { ...mockOrgWebhooks[0], url: 'https://updated.example.com/hook' };
await wrapper.findComponent({ name: 'WebhookDialog' }).vm.$emit('saved', updated);
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain('https://updated.example.com/hook');
});
});
});