A project keeps a list of repositories (api, web, admin…), each with its own secret, instead of a single webhook secret. They all call the same URL; the secret that verifies the signature tells which one it was, and every commit, branch or pull request is linked with its repository's name (shown on the ticket and in the MCP). Rotating or removing one leaves the others alone, and what it linked stays. A project that had turned the single webhook on keeps its secret, as a repository named "principal". API: the enable/disable endpoints and mutations give way to adding a repository, rotating its secret and removing it (REST and GraphQL). The web settings card lists the repositories. Docs, Word and PDF updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
82 lines
2.6 KiB
TypeScript
82 lines
2.6 KiB
TypeScript
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
|
import { ForbiddenError } from '@task-stream/api/shared/domain';
|
|
import { createHmac } from 'node:crypto';
|
|
import { RecordGitMentionsCommand } from './git/git';
|
|
import { GitService } from './git.service';
|
|
import { FindProjectsRepository } from './ports/find-projects.repository';
|
|
import { GitIntegrationsRepository } from './ports/git-integrations.repository';
|
|
|
|
const push = {
|
|
ref: 'refs/heads/main',
|
|
repository: { html_url: 'https://git.example.org/acme/api' },
|
|
commits: [
|
|
{
|
|
id: 'abc1234',
|
|
message: 'WEB-7: pay by card',
|
|
url: 'https://git.example.org/acme/api/commit/abc1234',
|
|
timestamp: '2026-10-09T10:00:00Z',
|
|
author: { name: 'Ana', username: 'ana' },
|
|
},
|
|
],
|
|
};
|
|
|
|
describe('GitService', () => {
|
|
const executed: object[] = [];
|
|
const git = new GitService(
|
|
{
|
|
execute: async (command: object) => {
|
|
executed.push(command);
|
|
return 1;
|
|
},
|
|
} as unknown as CommandBus,
|
|
{} as QueryBus,
|
|
{
|
|
findById: async (id: string) => (id === 'p1' ? { id: 'p1' } : null),
|
|
findByKey: async (key: string) => (key === 'WEB' ? { id: 'p1' } : null),
|
|
} as unknown as FindProjectsRepository,
|
|
{
|
|
repositoriesOf: async () => [
|
|
{ id: 'r1', name: 'web', secret: 'web-secret', createdAt: '' },
|
|
{ id: 'r2', name: 'api', secret: 'api-secret', createdAt: '' },
|
|
],
|
|
saveAll: async () => undefined,
|
|
} as GitIntegrationsRepository,
|
|
);
|
|
|
|
/** A Gitea delivery signed with `secret`. */
|
|
const delivery = (secret: string, project = 'WEB') => {
|
|
const rawBody = Buffer.from(JSON.stringify(push));
|
|
return {
|
|
project,
|
|
rawBody,
|
|
body: push,
|
|
headers: {
|
|
'x-gitea-event': 'push',
|
|
'x-gitea-signature': createHmac('sha256', secret)
|
|
.update(rawBody)
|
|
.digest('hex'),
|
|
},
|
|
};
|
|
};
|
|
|
|
beforeEach(() => (executed.length = 0));
|
|
|
|
it('tells the repository by the secret its webhook was signed with', async () => {
|
|
expect(await git.receive(delivery('api-secret'))).toEqual({ recorded: 1 });
|
|
expect(executed).toEqual([
|
|
expect.objectContaining({ projectId: 'p1', repository: 'api' }),
|
|
]);
|
|
expect(executed[0]).toBeInstanceOf(RecordGitMentionsCommand);
|
|
});
|
|
|
|
it('refuses a secret no repository of the project has, or another project', async () => {
|
|
await expect(git.receive(delivery('stolen'))).rejects.toThrow(
|
|
ForbiddenError,
|
|
);
|
|
await expect(git.receive(delivery('api-secret', 'NOPE'))).rejects.toThrow(
|
|
ForbiddenError,
|
|
);
|
|
expect(executed).toEqual([]);
|
|
});
|
|
});
|