Spaces:
Runtime error
Runtime error
File size: 5,537 Bytes
46252cd | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 | import { Test, TestingModule } from '@nestjs/testing';
import { Reflector } from '@nestjs/core';
import { WebhookController } from './webhook.controller';
import { WebhooksListController } from './webhooks-list.controller';
import { WebhookService } from './webhook.service';
import { Webhook } from './entities/webhook.entity';
import { Session } from '../session/entities/session.entity';
import { REQUIRED_ROLE_KEY } from '../auth/decorators/auth.decorators';
import { ApiKeyRole } from '../auth/entities/api-key.entity';
/**
* Regression locks for the secret/headers leak and read authorization.
* e2e coverage is deferred (the e2e harness is currently broken),
* so these controller-level unit tests are the regression gate.
*/
function createSecretWebhook(overrides: Partial<Webhook> = {}): Webhook {
return {
id: 'wh-uuid-1',
sessionId: 'sess-1',
url: 'https://example.com/webhook',
events: ['message.received'],
secret: 's3cr3t-hmac-key',
headers: { Authorization: 'Bearer receiver-token' },
filters: null,
active: true,
retryCount: 3,
lastTriggeredAt: null,
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-02T00:00:00.000Z'),
session: undefined as unknown as Session,
...overrides,
};
}
describe('Webhook controllers (secret leak + read authz)', () => {
let controller: WebhookController;
let listController: WebhooksListController;
let reflector: Reflector;
let service: jest.Mocked<Partial<WebhookService>>;
beforeEach(async () => {
service = {
create: jest.fn(),
findBySession: jest.fn(),
findOne: jest.fn(),
findAll: jest.fn(),
update: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
controllers: [WebhookController, WebhooksListController],
providers: [{ provide: WebhookService, useValue: service }],
}).compile();
controller = module.get<WebhookController>(WebhookController);
listController = module.get<WebhooksListController>(WebhooksListController);
reflector = new Reflector();
});
// ββ secret/headers must never appear in any response body ββββββ
it('findOne does not return secret or headers, but keeps safe fields', async () => {
(service.findOne as jest.Mock).mockResolvedValue(createSecretWebhook());
const result = await controller.findOne('sess-1', 'wh-uuid-1');
expect(result).not.toHaveProperty('secret');
expect(result).not.toHaveProperty('headers');
expect(JSON.stringify(result)).not.toContain('s3cr3t');
expect(result.id).toBe('wh-uuid-1');
expect(result.url).toBe('https://example.com/webhook');
expect(result.events).toEqual(['message.received']);
expect(result.active).toBe(true);
});
it('findBySession strips secret/headers from every item', async () => {
(service.findBySession as jest.Mock).mockResolvedValue([
createSecretWebhook(),
createSecretWebhook({ id: 'wh-2' }),
]);
const result = await controller.findBySession('sess-1');
expect(result).toHaveLength(2);
for (const w of result) {
expect(w).not.toHaveProperty('secret');
expect(w).not.toHaveProperty('headers');
}
expect(JSON.stringify(result)).not.toContain('s3cr3t');
});
it('cross-session findAll strips secret/headers', async () => {
(service.findAll as jest.Mock).mockResolvedValue([createSecretWebhook()]);
const result = await listController.findAll();
expect(result[0]).not.toHaveProperty('secret');
expect(result[0]).not.toHaveProperty('headers');
expect(JSON.stringify(result)).not.toContain('Bearer receiver-token');
});
it('create response echoes no secret/headers', async () => {
(service.create as jest.Mock).mockResolvedValue(createSecretWebhook());
const result = await controller.create('sess-1', { url: 'https://example.com/webhook', secret: 's3cr3t-hmac-key' });
expect(result).not.toHaveProperty('secret');
expect(result).not.toHaveProperty('headers');
expect(result.id).toBe('wh-uuid-1');
});
it('update response returns no secret/headers', async () => {
(service.update as jest.Mock).mockResolvedValue(createSecretWebhook({ url: 'https://new.example.com/hook' }));
const result = await controller.update('sess-1', 'wh-uuid-1', { url: 'https://new.example.com/hook' });
expect(result).not.toHaveProperty('secret');
expect(result).not.toHaveProperty('headers');
expect(result.url).toBe('https://new.example.com/hook');
});
// ββ read routes require OPERATOR+ (VIEWER β 403 via guard) βββββ
it('findBySession requires OPERATOR role', () => {
// eslint-disable-next-line @typescript-eslint/unbound-method -- reading route metadata, not invoking
const role = reflector.get<ApiKeyRole>(REQUIRED_ROLE_KEY, controller.findBySession);
expect(role).toBe(ApiKeyRole.OPERATOR);
});
it('findOne requires OPERATOR role', () => {
// eslint-disable-next-line @typescript-eslint/unbound-method -- reading route metadata, not invoking
const role = reflector.get<ApiKeyRole>(REQUIRED_ROLE_KEY, controller.findOne);
expect(role).toBe(ApiKeyRole.OPERATOR);
});
it('cross-session findAll requires OPERATOR role', () => {
// eslint-disable-next-line @typescript-eslint/unbound-method -- reading route metadata, not invoking
const role = reflector.get<ApiKeyRole>(REQUIRED_ROLE_KEY, listController.findAll);
expect(role).toBe(ApiKeyRole.OPERATOR);
});
});
|