agentic-teams: epics, tasks, git forges, agent authority #139
@@ -3075,9 +3075,9 @@
|
|||||||
"130",
|
"130",
|
||||||
"148"
|
"148"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"subtasks": [],
|
"subtasks": [],
|
||||||
"updatedAt": "2026-08-08T08:55:58.627Z"
|
"updatedAt": "2026-08-15T22:10:49.721Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "152",
|
"id": "152",
|
||||||
@@ -3461,9 +3461,9 @@
|
|||||||
],
|
],
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lastModified": "2026-08-14T23:05:31.792Z",
|
"lastModified": "2026-08-15T22:10:49.722Z",
|
||||||
"taskCount": 177,
|
"taskCount": 177,
|
||||||
"completedCount": 136,
|
"completedCount": 137,
|
||||||
"tags": [
|
"tags": [
|
||||||
"master"
|
"master"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Task" ADD COLUMN "mergeCommit" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "GitProvider" ADD COLUMN "webhookSecretKey" TEXT;
|
||||||
|
|
||||||
@@ -1047,6 +1047,10 @@ model Task {
|
|||||||
headCommit String?
|
headCommit String?
|
||||||
prUrl String?
|
prUrl String?
|
||||||
prNumber Int?
|
prNumber Int?
|
||||||
|
// The commit the merge produced. CI runs against this, so a failing status
|
||||||
|
// webhook can find exactly the task that caused it instead of guessing at
|
||||||
|
// "whatever merged most recently".
|
||||||
|
mergeCommit String?
|
||||||
// Free-form reviewer comment attached at the accept gate.
|
// Free-form reviewer comment attached at the accept gate.
|
||||||
reviewNote String? @db.Text
|
reviewNote String? @db.Text
|
||||||
ownerId String
|
ownerId String
|
||||||
@@ -1267,6 +1271,11 @@ model GitProvider {
|
|||||||
// resolved through SecretService and never stored or returned here.
|
// resolved through SecretService and never stored or returned here.
|
||||||
credSecretId String?
|
credSecretId String?
|
||||||
credSecretKey String?
|
credSecretKey String?
|
||||||
|
// Key (in the same Secret) holding this forge's webhook signing secret.
|
||||||
|
// Required before the webhook endpoint accepts anything from this provider —
|
||||||
|
// an unauthenticated webhook that can reopen work is an attack surface, so
|
||||||
|
// the route fails closed when this is unset.
|
||||||
|
webhookSecretKey String?
|
||||||
ownerId String
|
ownerId String
|
||||||
version Int @default(1)
|
version Int @default(1)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ import { registerEpicTaskRoutes } from './routes/epictasks.js';
|
|||||||
import { registerTaskRoutes } from './routes/tasks.js';
|
import { registerTaskRoutes } from './routes/tasks.js';
|
||||||
import { registerCodeRepoRoutes } from './routes/repos.js';
|
import { registerCodeRepoRoutes } from './routes/repos.js';
|
||||||
import { registerGitProviderRoutes } from './routes/gitproviders.js';
|
import { registerGitProviderRoutes } from './routes/gitproviders.js';
|
||||||
|
import { registerGitWebhookRoutes } from './routes/git-webhooks.js';
|
||||||
import { registerSkillRoutes } from './routes/skills.js';
|
import { registerSkillRoutes } from './routes/skills.js';
|
||||||
import { registerGitBackupRoutes } from './routes/git-backup.js';
|
import { registerGitBackupRoutes } from './routes/git-backup.js';
|
||||||
import { PromptService } from './services/prompt.service.js';
|
import { PromptService } from './services/prompt.service.js';
|
||||||
@@ -643,6 +644,13 @@ async function main(): Promise<void> {
|
|||||||
registerTaskRoutes(app, workTaskService, epicTaskRepo);
|
registerTaskRoutes(app, workTaskService, epicTaskRepo);
|
||||||
registerCodeRepoRoutes(app, codeRepoService, projectRepo);
|
registerCodeRepoRoutes(app, codeRepoService, projectRepo);
|
||||||
registerGitProviderRoutes(app, gitProviderService, projectRepo);
|
registerGitProviderRoutes(app, gitProviderService, projectRepo);
|
||||||
|
registerGitWebhookRoutes(app, {
|
||||||
|
providers: gitProviderRepo,
|
||||||
|
secretRepo,
|
||||||
|
secrets: secretService,
|
||||||
|
tasks: workTaskRepo,
|
||||||
|
taskService: workTaskService,
|
||||||
|
});
|
||||||
|
|
||||||
// ── Git-based backup ──
|
// ── Git-based backup ──
|
||||||
const gitBackup = new GitBackupService(prisma);
|
const gitBackup = new GitBackupService(prisma);
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ export function mapUrlToPermission(method: string, url: string): PermissionCheck
|
|||||||
return { kind: 'operation', operation: SET_OWN_PASSWORD_OPERATION };
|
return { kind: 'operation', operation: SET_OWN_PASSWORD_OPERATION };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Git webhooks authenticate by HMAC signature, not by a user token — the
|
||||||
|
// forge has no mcpctl identity. Skipping RBAC here is safe only because the
|
||||||
|
// route itself fails closed without a configured signing secret.
|
||||||
|
if (url.startsWith('/api/v1/webhooks/git/')) return { kind: 'skip' };
|
||||||
|
|
||||||
// Operations (non-resource endpoints)
|
// Operations (non-resource endpoints)
|
||||||
if (segment === 'backup') return { kind: 'operation', operation: 'backup' };
|
if (segment === 'backup') return { kind: 'operation', operation: 'backup' };
|
||||||
if (segment === 'restore') return { kind: 'operation', operation: 'restore' };
|
if (segment === 'restore') return { kind: 'operation', operation: 'restore' };
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export interface TaskUpdateInput {
|
|||||||
headCommit?: string;
|
headCommit?: string;
|
||||||
prUrl?: string;
|
prUrl?: string;
|
||||||
prNumber?: number;
|
prNumber?: number;
|
||||||
|
mergeCommit?: string;
|
||||||
reviewNote?: string;
|
reviewNote?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +62,8 @@ export interface EventCreateInput {
|
|||||||
|
|
||||||
export interface ITaskRepository {
|
export interface ITaskRepository {
|
||||||
findAll(filters?: TaskFilters): Promise<Task[]>;
|
findAll(filters?: TaskFilters): Promise<Task[]>;
|
||||||
|
/** Find the task a merge produced this commit for — the webhook's entry point. */
|
||||||
|
findByMergeCommit(sha: string): Promise<Task | null>;
|
||||||
findById(id: string): Promise<Task | null>;
|
findById(id: string): Promise<Task | null>;
|
||||||
findByName(name: string): Promise<Task | null>;
|
findByName(name: string): Promise<Task | null>;
|
||||||
findByNameAndEpicTask(name: string, epicTaskId: string): Promise<Task | null>;
|
findByNameAndEpicTask(name: string, epicTaskId: string): Promise<Task | null>;
|
||||||
@@ -98,6 +101,10 @@ export class TaskRepository implements ITaskRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findByMergeCommit(sha: string): Promise<Task | null> {
|
||||||
|
return this.prisma.task.findFirst({ where: { mergeCommit: sha }, include: TASK_INCLUDE });
|
||||||
|
}
|
||||||
|
|
||||||
async findById(id: string): Promise<Task | null> {
|
async findById(id: string): Promise<Task | null> {
|
||||||
return this.prisma.task.findUnique({ where: { id }, include: TASK_INCLUDE });
|
return this.prisma.task.findUnique({ where: { id }, include: TASK_INCLUDE });
|
||||||
}
|
}
|
||||||
|
|||||||
142
src/mcpd/src/routes/git-webhooks.ts
Normal file
142
src/mcpd/src/routes/git-webhooks.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
|
||||||
|
import type { IGitProviderRepository } from '../repositories/git-provider.repository.js';
|
||||||
|
import type { ISecretRepository } from '../repositories/interfaces.js';
|
||||||
|
import type { ITaskRepository } from '../repositories/task.repository.js';
|
||||||
|
import type { SecretService } from '../services/secret.service.js';
|
||||||
|
import type { TaskService } from '../services/task.service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post-merge failure → reopen the task that caused it.
|
||||||
|
*
|
||||||
|
* The design's last loop: a task reaches `merged`, CI then fails on the merge
|
||||||
|
* commit, and the same task comes back as `reopened` rather than the breakage
|
||||||
|
* going unnoticed because the board already said done.
|
||||||
|
*
|
||||||
|
* Two things make this safe rather than a liability:
|
||||||
|
*
|
||||||
|
* 1. **It fails closed.** The endpoint is unauthenticated by nature — a forge
|
||||||
|
* calls it — so it accepts nothing unless the provider has a configured
|
||||||
|
* webhook signing secret and the signature verifies. No secret, no effect.
|
||||||
|
* 2. **It matches precisely.** A failing status carries the commit sha, and the
|
||||||
|
* accept path recorded exactly which commit each merge produced. No guessing
|
||||||
|
* at "whatever merged most recently", which would reopen innocent work.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface GiteaStatusEvent {
|
||||||
|
sha?: string;
|
||||||
|
state?: string;
|
||||||
|
context?: string;
|
||||||
|
description?: string;
|
||||||
|
target_url?: string;
|
||||||
|
repository?: { full_name?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Failing states across forges. Anything else is ignored. */
|
||||||
|
const FAILED = new Set(['failure', 'error']);
|
||||||
|
|
||||||
|
export interface GitWebhookDeps {
|
||||||
|
providers: IGitProviderRepository;
|
||||||
|
secretRepo: ISecretRepository;
|
||||||
|
secrets: SecretService;
|
||||||
|
tasks: ITaskRepository;
|
||||||
|
taskService: TaskService;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerGitWebhookRoutes(app: FastifyInstance, deps: GitWebhookDeps): void {
|
||||||
|
// Raw body is required: a signature is over the exact bytes the forge sent,
|
||||||
|
// and re-serializing a parsed object changes them.
|
||||||
|
app.addContentTypeParser(
|
||||||
|
'application/json',
|
||||||
|
{ parseAs: 'string' },
|
||||||
|
(req, body, done) => {
|
||||||
|
(req as unknown as { rawBody?: string }).rawBody = body as string;
|
||||||
|
try {
|
||||||
|
done(null, JSON.parse(body as string));
|
||||||
|
} catch (err) {
|
||||||
|
done(err as Error, undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post<{ Params: { provider: string } }>(
|
||||||
|
'/api/v1/webhooks/git/:provider',
|
||||||
|
async (request, reply) => {
|
||||||
|
const provider = await deps.providers.findByName(request.params.provider);
|
||||||
|
if (provider === null) {
|
||||||
|
// Deliberately vague: this endpoint is public, and confirming which
|
||||||
|
// provider names exist is free reconnaissance.
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'unknown webhook' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider.webhookSecretKey === null || provider.credSecretId === null) {
|
||||||
|
reply.code(403);
|
||||||
|
return {
|
||||||
|
error: 'webhook_not_configured',
|
||||||
|
message: `GitProvider '${provider.name}' has no webhook signing secret, so its webhooks are refused.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = await deps.secretRepo.findById(provider.credSecretId);
|
||||||
|
if (secret === null) {
|
||||||
|
reply.code(403);
|
||||||
|
return { error: 'webhook_not_configured', message: 'Signing secret no longer resolvable.' };
|
||||||
|
}
|
||||||
|
const signingKey = await deps.secrets.resolve(secret.name, provider.webhookSecretKey);
|
||||||
|
|
||||||
|
const raw = (request as unknown as { rawBody?: string }).rawBody ?? '';
|
||||||
|
const sent = String(request.headers['x-gitea-signature'] ?? request.headers['x-hub-signature-256'] ?? '')
|
||||||
|
.replace(/^sha256=/, '');
|
||||||
|
if (!verify(raw, signingKey, sent)) {
|
||||||
|
reply.code(401);
|
||||||
|
return { error: 'bad_signature' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = request.body as GiteaStatusEvent;
|
||||||
|
const state = String(body.state ?? '').toLowerCase();
|
||||||
|
if (!FAILED.has(state)) {
|
||||||
|
// Success and pending are the common case — acknowledge and do nothing.
|
||||||
|
return { ok: true, action: 'ignored', reason: `state '${state}' is not a failure` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sha = body.sha;
|
||||||
|
if (sha === undefined || sha === '') {
|
||||||
|
return { ok: true, action: 'ignored', reason: 'no commit sha in payload' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = await deps.tasks.findByMergeCommit(sha);
|
||||||
|
if (task === null) {
|
||||||
|
// A failing build on a commit mcpctl did not merge is somebody else's
|
||||||
|
// business — not an error, and not something to guess about.
|
||||||
|
return { ok: true, action: 'ignored', reason: `no task merged commit ${sha.slice(0, 8)}` };
|
||||||
|
}
|
||||||
|
if (task.state !== 'merged') {
|
||||||
|
return { ok: true, action: 'ignored', reason: `task '${task.name}' is '${task.state}', not 'merged'` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const why = [
|
||||||
|
`CI failed on the merge commit ${sha.slice(0, 8)}`,
|
||||||
|
body.context !== undefined ? ` (${body.context})` : '',
|
||||||
|
body.description !== undefined ? `: ${body.description}` : '',
|
||||||
|
].join('');
|
||||||
|
|
||||||
|
await deps.taskService.reopen(task.id, why, { type: 'system' });
|
||||||
|
|
||||||
|
return { ok: true, action: 'reopened', task: task.name, reason: why };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Constant-time compare of the forge's HMAC against ours. */
|
||||||
|
function verify(rawBody: string, key: string, sent: string): boolean {
|
||||||
|
if (sent === '') return false;
|
||||||
|
const expected = createHmac('sha256', key).update(rawBody).digest('hex');
|
||||||
|
const a = Buffer.from(expected, 'utf8');
|
||||||
|
const b = Buffer.from(sent, 'utf8');
|
||||||
|
// timingSafeEqual throws on length mismatch, which would itself leak length.
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
return timingSafeEqual(a, b);
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ interface GiteaPull {
|
|||||||
state?: string;
|
state?: string;
|
||||||
merged?: boolean;
|
merged?: boolean;
|
||||||
mergeable?: boolean;
|
mergeable?: boolean;
|
||||||
|
merge_commit_sha?: string;
|
||||||
head?: { sha?: string; ref?: string };
|
head?: { sha?: string; ref?: string };
|
||||||
base?: { ref?: string };
|
base?: { ref?: string };
|
||||||
}
|
}
|
||||||
@@ -234,6 +235,7 @@ function toPullRequest(p: GiteaPull): PullRequest {
|
|||||||
head: p.head?.ref ?? '',
|
head: p.head?.ref ?? '',
|
||||||
base: p.base?.ref ?? '',
|
base: p.base?.ref ?? '',
|
||||||
mergeable: p.mergeable ?? null,
|
mergeable: p.mergeable ?? null,
|
||||||
|
mergeCommitSha: p.merge_commit_sha ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ export interface PullRequest {
|
|||||||
base: string;
|
base: string;
|
||||||
/** Forge's own verdict on whether it can merge cleanly. Null when unknown. */
|
/** Forge's own verdict on whether it can merge cleanly. Null when unknown. */
|
||||||
mergeable: boolean | null;
|
mergeable: boolean | null;
|
||||||
|
/** Commit the merge produced. Null until merged — CI runs against this. */
|
||||||
|
mergeCommitSha: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MergeOptions {
|
export interface MergeOptions {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export class GitTaskMerger implements TaskMerger {
|
|||||||
private readonly mergeMethod: 'merge' | 'squash' | 'rebase' = 'merge',
|
private readonly mergeMethod: 'merge' | 'squash' | 'rebase' = 'merge',
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async mergeForTask(task: Task, expectHeadSha: string | null): Promise<{ merged: boolean; detail: string }> {
|
async mergeForTask(task: Task, expectHeadSha: string | null): Promise<{ merged: boolean; detail: string; mergeCommit?: string }> {
|
||||||
if (task.prNumber === null) {
|
if (task.prNumber === null) {
|
||||||
return { merged: true, detail: 'no pull request attached — approval recorded only' };
|
return { merged: true, detail: 'no pull request attached — approval recorded only' };
|
||||||
}
|
}
|
||||||
@@ -58,9 +58,21 @@ export class GitTaskMerger implements TaskMerger {
|
|||||||
...(expectHeadSha !== null ? { expectHeadSha } : {}),
|
...(expectHeadSha !== null ? { expectHeadSha } : {}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
// Re-read to capture what the merge produced. Best-effort: the merge
|
||||||
|
// already succeeded, so failing to read the sha must not fail the accept —
|
||||||
|
// it only costs the webhook its precise match later.
|
||||||
|
let mergeCommit: string | undefined;
|
||||||
|
try {
|
||||||
|
const after = await driver.getPullRequest(ctx, { owner: slug.owner, repo: slug.repo, number: task.prNumber });
|
||||||
|
mergeCommit = after.mergeCommitSha ?? undefined;
|
||||||
|
} catch {
|
||||||
|
mergeCommit = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
merged: true,
|
merged: true,
|
||||||
detail: `merged ${slug.owner}/${slug.repo}#${String(task.prNumber)} via ${provider.name}`,
|
detail: `merged ${slug.owner}/${slug.repo}#${String(task.prNumber)} via ${provider.name}`,
|
||||||
|
...(mergeCommit !== undefined ? { mergeCommit } : {}),
|
||||||
};
|
};
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof MergeStaleError) {
|
if (err instanceof MergeStaleError) {
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export interface TaskMerger {
|
|||||||
* @param expectHeadSha merge only if the PR head is still this commit —
|
* @param expectHeadSha merge only if the PR head is still this commit —
|
||||||
* the human approved that commit, not whatever landed after.
|
* the human approved that commit, not whatever landed after.
|
||||||
*/
|
*/
|
||||||
mergeForTask(task: Task, expectHeadSha: string | null): Promise<{ merged: boolean; detail: string }>;
|
mergeForTask(task: Task, expectHeadSha: string | null): Promise<{ merged: boolean; detail: string; mergeCommit?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TaskService {
|
export class TaskService {
|
||||||
@@ -301,6 +301,11 @@ export class TaskService {
|
|||||||
throw Object.assign(new Error(result.detail), { statusCode: 409 });
|
throw Object.assign(new Error(result.detail), { statusCode: 409 });
|
||||||
}
|
}
|
||||||
mergeDetail = result.detail;
|
mergeDetail = result.detail;
|
||||||
|
// Record what the merge produced: a failing CI webhook arrives with this
|
||||||
|
// sha, and it is the only precise way back to the task that caused it.
|
||||||
|
if (result.mergeCommit !== undefined) {
|
||||||
|
await this.repo.update(id, { mergeCommit: result.mergeCommit });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.transition(
|
return this.transition(
|
||||||
|
|||||||
149
src/mcpd/tests/git-webhooks.test.ts
Normal file
149
src/mcpd/tests/git-webhooks.test.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { createHmac } from 'node:crypto';
|
||||||
|
|
||||||
|
import type { GitProvider, Secret, Task } from '@prisma/client';
|
||||||
|
import Fastify, { type FastifyInstance } from 'fastify';
|
||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
import { errorHandler } from '../src/middleware/error-handler.js';
|
||||||
|
import { mapUrlToPermission } from '../src/middleware/url-permission.js';
|
||||||
|
import type { IGitProviderRepository } from '../src/repositories/git-provider.repository.js';
|
||||||
|
import type { ISecretRepository } from '../src/repositories/interfaces.js';
|
||||||
|
import type { ITaskRepository } from '../src/repositories/task.repository.js';
|
||||||
|
import { registerGitWebhookRoutes } from '../src/routes/git-webhooks.js';
|
||||||
|
import type { SecretService } from '../src/services/secret.service.js';
|
||||||
|
import type { TaskService } from '../src/services/task.service.js';
|
||||||
|
|
||||||
|
const SECRET = 'hunter2';
|
||||||
|
const SHA = 'deadbeefcafe1234';
|
||||||
|
|
||||||
|
let app: FastifyInstance;
|
||||||
|
afterEach(async () => { if (app) await app.close(); });
|
||||||
|
|
||||||
|
function build(opts: {
|
||||||
|
provider?: Partial<GitProvider> | null;
|
||||||
|
task?: Partial<Task> | null;
|
||||||
|
} = {}) {
|
||||||
|
const reopen = vi.fn(async () => ({}));
|
||||||
|
const providerRow = opts.provider === null ? null : {
|
||||||
|
id: 'gp1', name: 'mysources', type: 'gitea',
|
||||||
|
credSecretId: 'sec1', credSecretKey: 'TOKEN', webhookSecretKey: 'WEBHOOK',
|
||||||
|
...opts.provider,
|
||||||
|
} as GitProvider;
|
||||||
|
|
||||||
|
const taskRow = opts.task === null ? null
|
||||||
|
: ({ id: 't1', name: 'wire-cli', state: 'merged', mergeCommit: SHA, ...opts.task } as Task);
|
||||||
|
|
||||||
|
const instance = Fastify();
|
||||||
|
instance.setErrorHandler(errorHandler);
|
||||||
|
registerGitWebhookRoutes(instance, {
|
||||||
|
providers: { findByName: vi.fn(async () => providerRow) } as unknown as IGitProviderRepository,
|
||||||
|
secretRepo: { findById: vi.fn(async () => ({ id: 'sec1', name: 'gitea-token' } as Secret)) } as unknown as ISecretRepository,
|
||||||
|
secrets: { resolve: vi.fn(async () => SECRET) } as unknown as SecretService,
|
||||||
|
tasks: { findByMergeCommit: vi.fn(async () => taskRow) } as unknown as ITaskRepository,
|
||||||
|
taskService: { reopen } as unknown as TaskService,
|
||||||
|
});
|
||||||
|
return { instance, reopen };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sign = (body: string, key = SECRET) => createHmac('sha256', key).update(body).digest('hex');
|
||||||
|
|
||||||
|
async function post(instance: FastifyInstance, payload: unknown, sig?: string) {
|
||||||
|
const body = JSON.stringify(payload);
|
||||||
|
return instance.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/webhooks/git/mysources',
|
||||||
|
headers: { 'content-type': 'application/json', 'x-gitea-signature': sig ?? sign(body) },
|
||||||
|
payload: body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const failure = { sha: SHA, state: 'failure', context: 'ci/build', description: 'tests failed' };
|
||||||
|
|
||||||
|
describe('git webhook — fails closed', () => {
|
||||||
|
it('refuses when the provider has no signing secret configured', async () => {
|
||||||
|
const b = build({ provider: { webhookSecretKey: null } }); app = b.instance;
|
||||||
|
const res = await post(b.instance, failure);
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
expect(b.reopen).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses an unsigned request', async () => {
|
||||||
|
const b = build(); app = b.instance;
|
||||||
|
const res = await post(b.instance, failure, '');
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
expect(b.reopen).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a wrong signature', async () => {
|
||||||
|
const b = build(); app = b.instance;
|
||||||
|
const res = await post(b.instance, failure, sign(JSON.stringify(failure), 'wrong-key'));
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
expect(b.reopen).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a signature over different bytes than were sent', async () => {
|
||||||
|
// Signing a re-serialized object rather than the raw body is the classic
|
||||||
|
// way this check silently stops working.
|
||||||
|
const b = build(); app = b.instance;
|
||||||
|
const res = await post(b.instance, failure, sign('{"sha":"other"}'));
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not confirm which provider names exist', async () => {
|
||||||
|
const b = build({ provider: null }); app = b.instance;
|
||||||
|
const res = await post(b.instance, failure);
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
expect(JSON.stringify(res.json())).not.toContain('mysources');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is skipped by the RBAC hook — it authenticates by signature', () => {
|
||||||
|
expect(mapUrlToPermission('POST', '/api/v1/webhooks/git/mysources')).toEqual({ kind: 'skip' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('git webhook — reopening', () => {
|
||||||
|
it('reopens the task whose merge produced the failing commit', async () => {
|
||||||
|
const b = build(); app = b.instance;
|
||||||
|
const res = await post(b.instance, failure);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json()).toMatchObject({ action: 'reopened', task: 'wire-cli' });
|
||||||
|
expect(b.reopen).toHaveBeenCalledWith('t1', expect.stringContaining('deadbeef'), { type: 'system' });
|
||||||
|
expect(b.reopen.mock.calls[0]?.[1]).toContain('ci/build');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a passing status', async () => {
|
||||||
|
const b = build(); app = b.instance;
|
||||||
|
const res = await post(b.instance, { ...failure, state: 'success' });
|
||||||
|
expect(res.json()).toMatchObject({ action: 'ignored' });
|
||||||
|
expect(b.reopen).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a failure on a commit mcpctl did not merge', async () => {
|
||||||
|
// Somebody else's build breaking is not mcpctl's business, and guessing at
|
||||||
|
// a task would reopen innocent work.
|
||||||
|
const b = build({ task: null }); app = b.instance;
|
||||||
|
const res = await post(b.instance, failure);
|
||||||
|
expect(res.json().reason).toMatch(/no task merged commit deadbeef/);
|
||||||
|
expect(b.reopen).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a task that has already moved on from merged', async () => {
|
||||||
|
const b = build({ task: { state: 'reopened' } }); app = b.instance;
|
||||||
|
const res = await post(b.instance, failure);
|
||||||
|
expect(res.json().reason).toMatch(/is 'reopened', not 'merged'/);
|
||||||
|
expect(b.reopen).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a payload with no sha', async () => {
|
||||||
|
const b = build(); app = b.instance;
|
||||||
|
const res = await post(b.instance, { state: 'failure' });
|
||||||
|
expect(res.json().reason).toMatch(/no commit sha/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats error the same as failure', async () => {
|
||||||
|
const b = build(); app = b.instance;
|
||||||
|
await post(b.instance, { ...failure, state: 'error' });
|
||||||
|
expect(b.reopen).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user