Public: anyone can read this; the crew can change it.
An admin sends a webhook a test `ping` delivery through the API and MCP, and sees it among the deliveries #8006
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7872-an-admin-sends-a-webhook-a-test-ping-del → main 7 commits fork
14 files
changed +445
−33
docs/MCP.md +1 −1
| @@ -97,7 +97,7 @@ for a tunnel and a forge that sleeps. | ||
| 97 | 97 | | `health` | how this forge is: the version and commit it runs, draining or not, the rollout and its incidents | | |
| 98 | 98 | | `here` | `{ url }`: on a forge page, what you may do there as who you are, each action with its route and tool; on the link of one of this forge's app hosts, what its `/__forge/` door shows: `{ app, repo, environment, pull: { number, title } or null, working: [names], links: { home, repo, report } }`; a foreign host, or a private app you may not enter, is refused like a missing page | | |
| 99 | 99 | | `hold` | `{ app, pull | path, why?, until?, release?, dryRun? }`: hold a pull so no rule reviews or lands it, or let it go; a person who can write, never an agent; or with `path` (no `pull`) hold new work on a file so the picker starts no build whose issue names it, or list the holds and who set each; an admin of the app; plans unless `dryRun: false` | yes | |
| 100 | | `hooks` | (admin scope) `{ repo, action, url, secret, kinds, id }`: `list` (pages: `limit` 1–200, `cursor`; `{ items, next_cursor }`), `add` (`secret`, 16–200 characters, is required) or `remove` the repository's webhooks, as `…/hooks`; `deliveries` with `id` answers that hook's last 20, newest first (`{ items: [{ id, state, attempts, lastStatus, lastError, kind }] }`); an admin-scope token on a repository you administer, and a secret is never shown | yes | | |
| 100 | | `hooks` | (admin scope) `{ repo, action, url, secret, kinds, id }`: `list` (pages: `limit` 1–200, `cursor`; `{ items, next_cursor }`), `add` (`secret`, 16–200 characters, is required) or `remove` the repository's webhooks, as `…/hooks`; `deliveries` with `id` answers that hook's last 20, newest first (`{ items: [{ id, state, attempts, lastStatus, lastError, kind }] }`); `ping` with `id` queues one `ping` delivery (`{ id }`), at most 3 a minute per hook; an admin-scope token on a repository you administer, and a secret is never shown | yes | | |
| 101 | 101 | | `invites` | `{ action, id? }`: `list` the outstanding invites (state and expiry, never a link) or `revoke` one by `id`; a person admin with an admin token, stricter than the API's list, which an `ro` token may read | yes | |
| 102 | 102 | | `issue` | `{ repo, number }`: one issue's title, state, closeReason, body, labels, assignee, the newest 50 comments (author, authorIsAgent, body, at), `commentsTotal` and `commentsNext` (older ones: `GET …/comments?cursor=`), and blockedBy/blocks, as `GET …/issues/:number`; a sealed one you cannot see is not found; draws the `ui://forge/issue` card: title, state, assignee, labels and what it waits for, never the body or comments | | |
| 103 | 103 | | `issue_teams` | `{ repo, number, teams? }`: the crew teams an issue names; with `teams` (at most two slugs, `[]` clears) names them, and each reviews the closing pull | yes | |
docs/features/webhook-ping.md added +11
| @@ -0,0 +1,11 @@ | ||
| 1 | # Send a webhook a test ping | |
| 2 | ||
| 3 | A repository admin can check that a webhook's receiver answers without waiting for a real event: a `ping` is one signed delivery to that hook only, whatever kinds it follows. | |
| 4 | ||
| 5 | - **Page.** Settings › Webhooks has a **Send ping** button on each hook, a same-origin POST to `/:owner/:name/settings/hooks/:id/ping`. The ping appears at once among that hook's deliveries; a refused one says how long to wait. | |
| 6 | - **API.** `POST /api/v1/repos/:owner/:name/hooks/:id/ping` with an admin-scope token answers 202 `{ id }`. Below admin, or for a hook not in the repository, it is 404. | |
| 7 | - **MCP.** `hooks` with `action: ping` and `id`, admin scope; it answers `{ id }`. | |
| 8 | - **Terminal.** Open a hook's Deliveries screen and press `p`. The screen and the key are a repository admin's; the new row shows at the top as `ping · pending`. | |
| 9 | - **Body.** `{ kind: "ping", repo, hook, at }`, signed with the hook's secret like any delivery. The worker sends it on its next pass, with the same egress refusal and retries; a paused hook holds it pending until it is resumed. | |
| 10 | - **Rate limit.** At most 3 a minute per hook. Past that the API answers 429 with `retry-after`, MCP says the same, and the page and the terminal say how many seconds to wait. | |
| 11 | - **Not an event.** The ping lives on its own delivery row (`webhook_ping`), never in the event log, so no feed, stream or other hook hears of it. A failed ping is not redelivered with `r`: send another. |
src/contract/openapi/routes/index.ts +1
| @@ -242,6 +242,7 @@ export { route as postApiV1ReposOwnerNameFollowingSever } from "./postApiV1Repos | ||
| 242 | 242 | export { route as postApiV1ReposOwnerNameFollowingTake } from "./postApiV1ReposOwnerNameFollowingTake.ts"; |
| 243 | 243 | export { route as postApiV1ReposOwnerNameForks } from "./postApiV1ReposOwnerNameForks.ts"; |
| 244 | 244 | export { route as postApiV1ReposOwnerNameHooks } from "./postApiV1ReposOwnerNameHooks.ts"; |
| 245 | export { route as postApiV1ReposOwnerNameHooksIdPing } from "./postApiV1ReposOwnerNameHooksIdPing.ts"; | |
| 245 | 246 | export { route as postApiV1ReposOwnerNameIssues } from "./postApiV1ReposOwnerNameIssues.ts"; |
| 246 | 247 | export { route as postApiV1ReposOwnerNameIssuesNumberComments } from "./postApiV1ReposOwnerNameIssuesNumberComments.ts"; |
| 247 | 248 | export { route as postApiV1ReposOwnerNameIssuesNumberDependencies } from "./postApiV1ReposOwnerNameIssuesNumberDependencies.ts"; |
src/contract/openapi/routes/postApiV1ReposOwnerNameHooksIdPing.ts added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | import { REPO, type Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "post", | |
| 5 | path: `${REPO}/hooks/{id}/ping`, | |
| 6 | summary: | |
| 7 | "Queue one signed `ping` delivery { kind, repo, hook, at } to this webhook only (admin scope); answers 202 { id } of the delivery, 429 past 3 a minute per hook", | |
| 8 | auth: true, | |
| 9 | }; |
src/core/migration-pins/20261102160000-webhook-ping-own-row.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 9ec6439b3eb7d11764ea98a509a4978937e9c1f11ee06a53c0d943ec3dde3ca0 |
src/core/migrations/20261102160000-webhook-ping-own-row.ts added +19
| @@ -0,0 +1,19 @@ | ||
| 1 | // A test `ping` belongs to its own row, not to the repository's event log | |
| 2 | // (#8006). A delivery must name an event, and a roll keeps the old release | |
| 3 | // reading `webhook_delivery`, so the ping gets a table of its own beside it | |
| 4 | // rather than a rebuilt delivery table: nothing the old release reads changes. | |
| 5 | export default ` | |
| 6 | CREATE TABLE IF NOT EXISTS webhook_ping ( | |
| 7 | id TEXT PRIMARY KEY, | |
| 8 | webhook_id TEXT NOT NULL REFERENCES webhook(id) ON DELETE CASCADE, | |
| 9 | state TEXT NOT NULL DEFAULT 'pending' CHECK (state IN ('pending','ok','failed')), | |
| 10 | attempts INTEGER NOT NULL DEFAULT 0, | |
| 11 | next_try_at TEXT, | |
| 12 | last_status INTEGER, | |
| 13 | last_error TEXT, | |
| 14 | payload TEXT NOT NULL, | |
| 15 | created_at TEXT NOT NULL | |
| 16 | ); | |
| 17 | CREATE INDEX IF NOT EXISTS ping_due ON webhook_ping(state, next_try_at); | |
| 18 | CREATE INDEX IF NOT EXISTS ping_hook ON webhook_ping(webhook_id, created_at); | |
| 19 | `; |
src/core/webhooks.ts +92 −19
| @@ -187,6 +187,49 @@ export function enqueueForEvent(db: Database, event: ForgeEvent): number { | ||
| 187 | 187 | return queued; |
| 188 | 188 | } |
| 189 | 189 | |
| 190 | /** Pings one hook may be sent in a minute. */ | |
| 191 | export const PING_LIMIT = 3; | |
| 192 | const PING_WINDOW_MS = 60_000; | |
| 193 | ||
| 194 | /** | |
| 195 | * Queue one `ping` delivery for one hook, whatever kinds it subscribes to. | |
| 196 | * The payload lives on the delivery row, never in the event log, so no feed, | |
| 197 | * stream or other hook hears of it; the request is made later by `deliverDue`, which keeps the | |
| 198 | * egress refusal. `retryAfter` is in seconds when the hook was pinged too | |
| 199 | * often. | |
| 200 | */ | |
| 201 | export function pingWebhook( | |
| 202 | db: Database, | |
| 203 | repoSlug: string, | |
| 204 | hook: Webhook, | |
| 205 | now = new Date(), | |
| 206 | ): Result<{ id: string }, { retryAfter: number }> { | |
| 207 | const since = new Date(now.getTime() - PING_WINDOW_MS).toISOString(); | |
| 208 | const recent = db | |
| 209 | .query<{ n: number; first: string | null }, [string, string]>( | |
| 210 | `SELECT COUNT(*) AS n, MIN(created_at) AS first | |
| 211 | FROM webhook_ping | |
| 212 | WHERE webhook_id = ?1 AND created_at > ?2`, | |
| 213 | ) | |
| 214 | .get(hook.id, since)!; | |
| 215 | if (recent.n >= PING_LIMIT) { | |
| 216 | const first = recent.first ? Date.parse(recent.first) : now.getTime(); | |
| 217 | const wait = Math.ceil((first + PING_WINDOW_MS - now.getTime()) / 1000); | |
| 218 | return err({ retryAfter: Math.max(1, wait) }); | |
| 219 | } | |
| 220 | const at = now.toISOString(); | |
| 221 | const deliveryId = id(); | |
| 222 | db.query( | |
| 223 | "INSERT INTO webhook_ping (id, webhook_id, payload, created_at) VALUES (?1, ?2, ?3, ?4)", | |
| 224 | ).run( | |
| 225 | deliveryId, | |
| 226 | hook.id, | |
| 227 | JSON.stringify({ kind: "ping", repo: repoSlug, hook: hook.id, at }), | |
| 228 | at, | |
| 229 | ); | |
| 230 | return ok({ id: deliveryId }); | |
| 231 | } | |
| 232 | ||
| 190 | 233 | export type RedeliverError = "not_found" | "not_failed" | "sealed"; |
| 191 | 234 | |
| 192 | 235 | /** |
| @@ -244,7 +287,9 @@ function redeliverIn(db: Database, deliveryId: string, webhookId: string): Resul | ||
| 244 | 287 | interface DueRow { |
| 245 | 288 | id: string; |
| 246 | 289 | webhook_id: string; |
| 247 | event_id: string; | |
| 290 | event_id: string | null; | |
| 291 | /** True when the row is in `webhook_ping`, not `webhook_delivery`. */ | |
| 292 | ping: boolean; | |
| 248 | 293 | attempts: number; |
| 249 | 294 | url: string; |
| 250 | 295 | secret: string; |
| @@ -271,9 +316,9 @@ export async function deliverDue( | ||
| 271 | 316 | outbound: OutboundOptions = {}, |
| 272 | 317 | ): Promise<number> { |
| 273 | 318 | const nowIso = now.toISOString(); |
| 274 | const due = db | |
| 319 | const events = db | |
| 275 | 320 | .query<DueRow, [string]>( |
| 276 | `SELECT d.id, d.webhook_id, d.event_id, d.attempts, | |
| 321 | `SELECT d.id, d.webhook_id, d.event_id, 0 AS ping, d.attempts, | |
| 277 | 322 | w.url, w.secret, |
| 278 | 323 | e.kind, e.repo_id, e.actor_id, e.payload, e.created_at |
| 279 | 324 | FROM webhook_delivery d |
| @@ -284,6 +329,20 @@ export async function deliverDue( | ||
| 284 | 329 | ORDER BY d.id LIMIT 50`, |
| 285 | 330 | ) |
| 286 | 331 | .all(nowIso); |
| 332 | const pings = db | |
| 333 | .query<DueRow, [string]>( | |
| 334 | `SELECT d.id, d.webhook_id, NULL AS event_id, 1 AS ping, d.attempts, | |
| 335 | w.url, w.secret, | |
| 336 | 'ping' AS kind, NULL AS repo_id, NULL AS actor_id, | |
| 337 | d.payload, d.created_at | |
| 338 | FROM webhook_ping d | |
| 339 | JOIN webhook w ON w.id = d.webhook_id | |
| 340 | WHERE d.state = 'pending' AND w.active = 1 | |
| 341 | AND (d.next_try_at IS NULL OR d.next_try_at <= ?1) | |
| 342 | ORDER BY d.id LIMIT 50`, | |
| 343 | ) | |
| 344 | .all(nowIso); | |
| 345 | const due = [...events, ...pings].map((r) => ({ ...r, ping: !!r.ping })); | |
| 287 | 346 | |
| 288 | 347 | for (const row of due) { |
| 289 | 348 | // Claimed before it is sent: two forge processes over one database (a |
| @@ -295,7 +354,7 @@ export async function deliverDue( | ||
| 295 | 354 | const claimAt = Date.now(); |
| 296 | 355 | const claimed = db |
| 297 | 356 | .query( |
| 298 | `UPDATE webhook_delivery SET next_try_at = ?2 | |
| 357 | `UPDATE ${tableOf(row)} SET next_try_at = ?2 | |
| 299 | 358 | WHERE id = ?1 AND state = 'pending' |
| 300 | 359 | AND (next_try_at IS NULL OR next_try_at <= ?3)`, |
| 301 | 360 | ) |
| @@ -324,6 +383,11 @@ export async function deliverDue( | ||
| 324 | 383 | return due.length; |
| 325 | 384 | } |
| 326 | 385 | |
| 386 | /** Where a due row lives: a ping has its own table, never the event log's. */ | |
| 387 | function tableOf(row: DueRow): "webhook_ping" | "webhook_delivery" { | |
| 388 | return row.ping ? "webhook_ping" : "webhook_delivery"; | |
| 389 | } | |
| 390 | ||
| 327 | 391 | async function deliverOne( |
| 328 | 392 | db: Database, |
| 329 | 393 | log: Log, |
| @@ -331,14 +395,18 @@ async function deliverOne( | ||
| 331 | 395 | now: Date, |
| 332 | 396 | outbound: OutboundOptions, |
| 333 | 397 | ): Promise<void> { |
| 334 | const body = JSON.stringify({ | |
| 335 | id: row.event_id, | |
| 336 | kind: row.kind, | |
| 337 | repo_id: row.repo_id, | |
| 338 | actor_id: row.actor_id, | |
| 339 | payload: JSON.parse(row.payload), | |
| 340 | created_at: row.created_at, | |
| 341 | }); | |
| 398 | // A ping's body is its payload as it stands: { kind, repo, hook, at }. | |
| 399 | const body = | |
| 400 | row.ping | |
| 401 | ? row.payload | |
| 402 | : JSON.stringify({ | |
| 403 | id: row.event_id, | |
| 404 | kind: row.kind, | |
| 405 | repo_id: row.repo_id, | |
| 406 | actor_id: row.actor_id, | |
| 407 | payload: JSON.parse(row.payload), | |
| 408 | created_at: row.created_at, | |
| 409 | }); | |
| 342 | 410 | const signature = await sign(row.secret, body); |
| 343 | 411 | const attempt = row.attempts + 1; |
| 344 | 412 | |
| @@ -370,7 +438,7 @@ async function deliverOne( | ||
| 370 | 438 | // seconds, so there is nothing to retry: fail it now, with the reason. |
| 371 | 439 | log.warn("webhook delivery refused", { delivery: row.id }); |
| 372 | 440 | db.query( |
| 373 | "UPDATE webhook_delivery SET state = 'failed', attempts = ?1, last_status = NULL, last_error = ?2 WHERE id = ?3", | |
| 441 | `UPDATE ${tableOf(row)} SET state = 'failed', attempts = ?1, last_status = NULL, last_error = ?2 WHERE id = ?3`, | |
| 374 | 442 | ).run(attempt, error.message, row.id); |
| 375 | 443 | return; |
| 376 | 444 | } |
| @@ -383,7 +451,7 @@ async function deliverOne( | ||
| 383 | 451 | const ok2xx = status >= 200 && status < 300; |
| 384 | 452 | if (ok2xx) { |
| 385 | 453 | db.query( |
| 386 | "UPDATE webhook_delivery SET state = 'ok', attempts = ?1, last_status = ?2, last_error = NULL WHERE id = ?3", | |
| 454 | `UPDATE ${tableOf(row)} SET state = 'ok', attempts = ?1, last_status = ?2, last_error = NULL WHERE id = ?3`, | |
| 387 | 455 | ).run(attempt, status, row.id); |
| 388 | 456 | return; |
| 389 | 457 | } |
| @@ -401,7 +469,7 @@ function backOff( | ||
| 401 | 469 | const attempt = row.attempts + 1; |
| 402 | 470 | if (attempt >= MAX_ATTEMPTS) { |
| 403 | 471 | db.query( |
| 404 | "UPDATE webhook_delivery SET state = 'failed', attempts = ?1, last_status = ?2 WHERE id = ?3", | |
| 472 | `UPDATE ${tableOf(row)} SET state = 'failed', attempts = ?1, last_status = ?2 WHERE id = ?3`, | |
| 405 | 473 | ).run(attempt, status, row.id); |
| 406 | 474 | log.warn("webhook delivery gave up", { |
| 407 | 475 | delivery: row.id, |
| @@ -414,7 +482,7 @@ function backOff( | ||
| 414 | 482 | const delayMs = attempt * attempt * 10_000; |
| 415 | 483 | const nextTry = new Date(now.getTime() + delayMs).toISOString(); |
| 416 | 484 | db.query( |
| 417 | "UPDATE webhook_delivery SET attempts = ?1, last_status = ?2, next_try_at = ?3 WHERE id = ?4", | |
| 485 | `UPDATE ${tableOf(row)} SET attempts = ?1, last_status = ?2, next_try_at = ?3 WHERE id = ?4`, | |
| 418 | 486 | ).run(attempt, status, nextTry, row.id); |
| 419 | 487 | } |
| 420 | 488 | |
| @@ -484,9 +552,14 @@ export function recentDeliveries( | ||
| 484 | 552 | }, |
| 485 | 553 | [string, number] |
| 486 | 554 | >( |
| 487 | `SELECT d.id, d.state, d.attempts, d.last_status, d.last_error, e.kind | |
| 488 | FROM webhook_delivery d JOIN event e ON e.id = d.event_id | |
| 489 | WHERE d.webhook_id = ?1 ORDER BY d.id DESC LIMIT ?2`, | |
| 555 | `SELECT id, state, attempts, last_status, last_error, kind FROM ( | |
| 556 | SELECT d.id, d.state, d.attempts, d.last_status, d.last_error, e.kind | |
| 557 | FROM webhook_delivery d JOIN event e ON e.id = d.event_id | |
| 558 | WHERE d.webhook_id = ?1 | |
| 559 | UNION ALL | |
| 560 | SELECT id, state, attempts, last_status, last_error, 'ping' AS kind | |
| 561 | FROM webhook_ping WHERE webhook_id = ?1 | |
| 562 | ) ORDER BY id DESC LIMIT ?2`, | |
| 490 | 563 | ) |
| 491 | 564 | .all(webhookId, limit); |
| 492 | 565 | return rows.map((r) => ({ |
src/http/api/hooks.ts +18 −1
| @@ -9,7 +9,7 @@ | ||
| 9 | 9 | import { createWebhook, deleteWebhook } from "../../actions/repos.ts"; |
| 10 | 10 | import { canAdmin } from "../../core/repos.ts"; |
| 11 | 11 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 12 | import { getWebhook, listWebhooks, recentDeliveries } from "../../core/webhooks.ts"; | |
| 12 | import { getWebhook, listWebhooks, pingWebhook, recentDeliveries } from "../../core/webhooks.ts"; | |
| 13 | 13 | import { loadRepo, pageOf, pageParams } from "../api.ts"; |
| 14 | 14 | import type { Handler } from "../context.ts"; |
| 15 | 15 | import { body, json, notFound, requireScope, respondAccess, str } from "../respond.ts"; |
| @@ -38,6 +38,23 @@ export function hookRoutes(router: Router<Handler>): void { | ||
| 38 | 38 | return json({ items: recentDeliveries(ctx.app.db, ctx.params.id!, 20) }); |
| 39 | 39 | }); |
| 40 | 40 | |
| 41 | router.add("POST", "/api/v1/repos/:owner/:name/hooks/:id/ping", (ctx) => { | |
| 42 | const denied = requireScope(ctx, "admin"); | |
| 43 | if (denied) return denied.status === 403 ? notFound() : denied; | |
| 44 | const repo = loadRepo(ctx); | |
| 45 | if (!repo || !canAdmin(ctx.app.db, repo, ctx.auth.user)) return notFound(); | |
| 46 | const hook = getWebhook(ctx.app.db, repo.id, ctx.params.id!); | |
| 47 | if (!hook) return notFound(); | |
| 48 | const pinged = pingWebhook(ctx.app.db, `${ctx.params.owner}/${ctx.params.name}`, hook); | |
| 49 | if (!pinged.ok) | |
| 50 | return json( | |
| 51 | { error: "rate_limited", message: `too many pings: try again in ${pinged.error.retryAfter} s` }, | |
| 52 | 429, | |
| 53 | { "retry-after": String(pinged.error.retryAfter) }, | |
| 54 | ); | |
| 55 | return json({ id: pinged.value.id }, 202); | |
| 56 | }); | |
| 57 | ||
| 41 | 58 | router.add("POST", "/api/v1/repos/:owner/:name/hooks", async (ctx) => { |
| 42 | 59 | const denied = requireScope(ctx, "admin"); |
| 43 | 60 | if (denied) return denied; |
src/http/mcp/tools/hooks.ts +13 −4
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { canAdmin } from "../../../core/repos.ts"; |
| 2 | import { getWebhook, listWebhooks, recentDeliveries } from "../../../core/webhooks.ts"; | |
| 2 | import { getWebhook, listWebhooks, pingWebhook, recentDeliveries } from "../../../core/webhooks.ts"; | |
| 3 | 3 | import { createWebhook, deleteWebhook } from "../../../actions/repos.ts"; |
| 4 | 4 | import { pageRows } from "../page.ts"; |
| 5 | 5 | import { good, bad, type Answer, type Args, type Tool, REPO_ARG, repoArg, isAnswer, str } from "../shared.ts"; |
| @@ -20,13 +20,13 @@ const notAdmin = (): Answer => bad("not_found", "no such app or repository, or y | ||
| 20 | 20 | |
| 21 | 21 | export const tool: Tool = { |
| 22 | 22 | name: "hooks", |
| 23 | doc: "`{ repo, action, url, secret, kinds, id }`: `list` (pages: `limit` 1–200, `cursor`; `{ items, next_cursor }`), `add` (`secret`, 16–200 characters, is required) or `remove` the repository's webhooks, as `…/hooks`; `deliveries` with `id` answers that hook's last 20, newest first (`{ items: [{ id, state, attempts, lastStatus, lastError, kind }] }`); an admin-scope token on a repository you administer, and a secret is never shown", | |
| 23 | doc: "`{ repo, action, url, secret, kinds, id }`: `list` (pages: `limit` 1–200, `cursor`; `{ items, next_cursor }`), `add` (`secret`, 16–200 characters, is required) or `remove` the repository's webhooks, as `…/hooks`; `deliveries` with `id` answers that hook's last 20, newest first (`{ items: [{ id, state, attempts, lastStatus, lastError, kind }] }`); `ping` with `id` queues one `ping` delivery (`{ id }`), at most 3 a minute per hook; an admin-scope token on a repository you administer, and a secret is never shown", | |
| 24 | 24 | description: "List, add (`url`, `secret`, `kinds`) or remove (`id`) webhooks; admins.", |
| 25 | 25 | inputSchema: { |
| 26 | 26 | type: "object", |
| 27 | 27 | properties: { |
| 28 | 28 | repo: REPO_ARG, |
| 29 | action: { type: "string", enum: ["list", "add", "remove", "deliveries"] }, | |
| 29 | action: { type: "string", enum: ["list", "add", "remove", "deliveries", "ping"] }, | |
| 30 | 30 | url: { type: "string", maxLength: 2048 }, |
| 31 | 31 | kinds: { type: "string", maxLength: 200 }, |
| 32 | 32 | secret: { type: "string", maxLength: 200 }, |
| @@ -54,6 +54,15 @@ export const tool: Tool = { | ||
| 54 | 54 | if (hook === undefined) return bad("not_found", "no such webhook here"); |
| 55 | 55 | return good({ items: recentDeliveries(ctx.app.db, hook.id, 20) }); |
| 56 | 56 | } |
| 57 | if (action === "ping") { | |
| 58 | const long = hookTooLong(args); | |
| 59 | if (long !== undefined) return long; | |
| 60 | if (!canAdmin(ctx.app.db, repo, user)) return notAdmin(); | |
| 61 | const hook = getWebhook(ctx.app.db, repo.id, str(args["id"])); | |
| 62 | if (hook === undefined) return bad("not_found", "no such webhook here"); | |
| 63 | const pinged = pingWebhook(ctx.app.db, `${repo.ownerName}/${repo.name}`, hook); | |
| 64 | return pinged.ok ? good({ id: pinged.value.id }) : bad("rate_limited", `too many pings: try again in ${pinged.error.retryAfter} s`); | |
| 65 | } | |
| 57 | 66 | if (action === "add") { |
| 58 | 67 | const long = hookTooLong(args); |
| 59 | 68 | if (long !== undefined) return long; |
| @@ -78,6 +87,6 @@ export const tool: Tool = { | ||
| 78 | 87 | const removed = deleteWebhook(ctx.app, user, repo, str(args["id"])); |
| 79 | 88 | return removed.ok ? good({}) : bad("not_found", "no such webhook here"); |
| 80 | 89 | } |
| 81 | return bad("invalid", "action is list, add, remove or deliveries"); | |
| 90 | return bad("invalid", "action is list, add, remove, deliveries or ping"); | |
| 82 | 91 | }, |
| 83 | 92 | }; |
src/http/server.ts +2 −1
| @@ -411,7 +411,7 @@ import { | ||
| 411 | 411 | } from "../web/pages/settings.ts"; |
| 412 | 412 | import { dangerPage, repoDelete } from "../web/pages/danger.ts"; |
| 413 | 413 | import { detachSubmit } from "../web/pages/detach.ts"; |
| 414 | import { hooksPage, webhookCreate, webhookDelete } from "../web/pages/hooks.ts"; | |
| 414 | import { hooksPage, webhookCreate, webhookDelete, webhookPing } from "../web/pages/hooks.ts"; | |
| 415 | 415 | import { |
| 416 | 416 | crewAdd, |
| 417 | 417 | crewRemove, |
| @@ -1545,6 +1545,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1545 | 1545 | router.add("GET", "/:owner/:name/settings/hooks", hooksPage); |
| 1546 | 1546 | router.add("POST", "/:owner/:name/settings/hooks", webhookCreate); |
| 1547 | 1547 | router.add("POST", "/:owner/:name/settings/hooks/:id/delete", webhookDelete); |
| 1548 | router.add("POST", "/:owner/:name/settings/hooks/:id/ping", webhookPing); | |
| 1548 | 1549 | router.add("GET", "/:owner/:name/settings/danger", dangerPage); |
| 1549 | 1550 | router.add("POST", "/:owner/:name/settings/delete", repoDelete); |
| 1550 | 1551 | router.add("POST", "/:owner/:name/settings/detach", detachSubmit); |
src/tui/screens/hooks.ts +22 −4
| @@ -1,7 +1,9 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * A repository's webhooks in the terminal, an admin's: each hook's url, |
| 3 | 3 | * kinds and last delivery, never its secret. `enter` opens its last 20 |
| 4 | * deliveries; `r` there sends a failed one again through `redeliver`. | |
| 4 | * deliveries; `r` there sends a failed one again through `redeliver`, and | |
| 5 | * `p` sends the hook a `ping` through `pingWebhook` (a refusal past the rate | |
| 6 | * limit says how long to wait). | |
| 5 | 7 | * `a` asks for url, kinds and a secret (typed masked, 16–200 characters) |
| 6 | 8 | * and adds it through `createWebhook`; `p` pauses or resumes the selected |
| 7 | 9 | * one through `pauseWebhook` (a paused row reads `paused` and gets no |
| @@ -15,7 +17,14 @@ import { | ||
| 15 | 17 | pauseWebhook, |
| 16 | 18 | } from "../../actions/repos.ts"; |
| 17 | 19 | import { canAdmin, type Repo } from "../../core/repos.ts"; |
| 18 | import { listWebhooks, recentDeliveries, redeliver, type DeliveryInfo, type Webhook } from "../../core/webhooks.ts"; | |
| 20 | import { | |
| 21 | listWebhooks, | |
| 22 | pingWebhook, | |
| 23 | recentDeliveries, | |
| 24 | redeliver, | |
| 25 | type DeliveryInfo, | |
| 26 | type Webhook, | |
| 27 | } from "../../core/webhooks.ts"; | |
| 19 | 28 | import type { Ctx, Screen } from "../app/app.ts"; |
| 20 | 29 | import type { Command } from "../app/palette.ts"; |
| 21 | 30 | import { binding } from "../app/keys.ts"; |
| @@ -53,7 +62,7 @@ function lastDelivery(f: Session, hook: Webhook): string { | ||
| 53 | 62 | } |
| 54 | 63 | |
| 55 | 64 | /** One hook's last 20 deliveries, newest first: kind · state · status or reason · attempts. */ |
| 56 | function deliveriesScreen(f: Session, ctx: Ctx, hook: Webhook, allowed: () => boolean): Screen { | |
| 65 | function deliveriesScreen(f: Session, ctx: Ctx, repo: Repo, hook: Webhook, allowed: () => boolean): Screen { | |
| 57 | 66 | const state = new ListState(); |
| 58 | 67 | let all: DeliveryInfo[] = []; |
| 59 | 68 | return { |
| @@ -63,6 +72,7 @@ function deliveriesScreen(f: Session, ctx: Ctx, hook: Webhook, allowed: () => bo | ||
| 63 | 72 | return [ |
| 64 | 73 | binding(["j", "k"], "move"), |
| 65 | 74 | binding(["r"], "redeliver", { disabled: !failed }), |
| 75 | binding(["p"], "send a ping"), | |
| 66 | 76 | binding(["esc", "q"], "back"), |
| 67 | 77 | ]; |
| 68 | 78 | }, |
| @@ -100,6 +110,14 @@ function deliveriesScreen(f: Session, ctx: Ctx, hook: Webhook, allowed: () => bo | ||
| 100 | 110 | ctx.invalidate(); |
| 101 | 111 | return true; |
| 102 | 112 | } |
| 113 | if (e.key === "p") { | |
| 114 | const sent = pingWebhook(f.db, `${repo.ownerName}/${repo.name}`, hook); | |
| 115 | if (sent.ok) ctx.toast("ping queued", "success"); | |
| 116 | else ctx.toast(`too many pings: try again in ${sent.error.retryAfter} s`, "danger"); | |
| 117 | state.index = 0; | |
| 118 | ctx.invalidate(); | |
| 119 | return true; | |
| 120 | } | |
| 103 | 121 | return state.key(e, all.length); |
| 104 | 122 | }, |
| 105 | 123 | }; |
| @@ -203,7 +221,7 @@ export function hooksScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 203 | 221 | else if (e.key === "enter") { |
| 204 | 222 | const hook = hooks()[state.index]; |
| 205 | 223 | if (hook === undefined) return false; |
| 206 | ctx.push(deliveriesScreen(f, ctx, hook, allowed)); | |
| 224 | ctx.push(deliveriesScreen(f, ctx, repo, hook, allowed)); | |
| 207 | 225 | } else if (e.key === "p") { |
| 208 | 226 | const hook = hooks()[state.index]; |
| 209 | 227 | if (hook === undefined) return false; |
src/web/pages/hooks.ts +21 −1
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | |
| 8 | 8 | import { createWebhook, deleteWebhook } from "../../actions/repos.ts"; |
| 9 | 9 | import { notePhase, timePhase } from "../../core/route_timing.ts"; |
| 10 | import { listWebhooks, recentDeliveries } from "../../core/webhooks.ts"; | |
| 10 | import { getWebhook, listWebhooks, pingWebhook, recentDeliveries } from "../../core/webhooks.ts"; | |
| 11 | 11 | import type { Ctx } from "../../http/context.ts"; |
| 12 | 12 | import { repoBase, repoNotFound } from "../chrome.ts"; |
| 13 | 13 | import { field } from "../form.ts"; |
| @@ -85,6 +85,9 @@ export function hooksPage(ctx: Ctx): Response { | ||
| 85 | 85 | head: html`<span class="grow truncate mono" title="${hook.url}" |
| 86 | 86 | >${hook.url}</span |
| 87 | 87 | > |
| 88 | <form method="post" action="${base}/settings/hooks/${hook.id}/ping"> | |
| 89 | ${btn("Send ping", { size: "sm" })} | |
| 90 | </form> | |
| 88 | 91 | <span class="badge" data-variant="secondary" |
| 89 | 92 | >${hook.kinds}</span |
| 90 | 93 | > |
| @@ -188,3 +191,20 @@ export async function webhookDelete(ctx: Ctx): Promise<Response> { | ||
| 188 | 191 | : { tone: "danger", message: "That webhook no longer exists." }, |
| 189 | 192 | ); |
| 190 | 193 | } |
| 194 | ||
| 195 | export async function webhookPing(ctx: Ctx): Promise<Response> { | |
| 196 | const gate = await adminForm(ctx); | |
| 197 | if (gate instanceof Response) return gate; | |
| 198 | const { repo } = gate; | |
| 199 | const to = `${repoBase(repo)}/settings/hooks`; | |
| 200 | const hook = getWebhook(ctx.app.db, repo.id, ctx.params.id ?? ""); | |
| 201 | if (!hook) return flashTo(ctx, to, { tone: "danger", message: "That webhook no longer exists." }); | |
| 202 | const pinged = pingWebhook(ctx.app.db, `${repo.ownerName}/${repo.name}`, hook); | |
| 203 | return flashTo( | |
| 204 | ctx, | |
| 205 | to, | |
| 206 | pinged.ok | |
| 207 | ? { message: "Ping queued: it shows among this webhook's deliveries." } | |
| 208 | : { tone: "danger", message: `Too many pings: try again in ${pinged.error.retryAfter} s.` }, | |
| 209 | ); | |
| 210 | } |
test/hook-ping.test.ts added +203
| @@ -0,0 +1,203 @@ | ||
| 1 | /** POST …/hooks/:id/ping, MCP `hooks` action `ping` and the settings page's Send ping (#8006). */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { createSession } from "../src/core/sessions.ts"; | |
| 9 | import { createToken } from "../src/core/tokens.ts"; | |
| 10 | import { createUser } from "../src/core/users.ts"; | |
| 11 | import { createWebhook, deliverDue, recentDeliveries, verifySignature } from "../src/core/webhooks.ts"; | |
| 12 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 13 | import { LOOPBACK } from "./helpers/loopback-hook.ts"; | |
| 14 | import { quietLogs } from "./helpers/globals.ts"; | |
| 15 | ||
| 16 | quietLogs(); | |
| 17 | ||
| 18 | let dataDir: string; | |
| 19 | let app: App; | |
| 20 | let servers: Servers; | |
| 21 | let adminTok = ""; | |
| 22 | let otherTok = ""; | |
| 23 | let cookie = ""; | |
| 24 | let hookA = ""; | |
| 25 | let hookB = ""; | |
| 26 | let secretA = ""; | |
| 27 | let received: { body: string; sig: string; kind: string }[] = []; | |
| 28 | let receiver: ReturnType<typeof Bun.serve>; | |
| 29 | const cells = process.env["FORGE_CELLS"]; | |
| 30 | ||
| 31 | beforeAll(async () => { | |
| 32 | process.env["FORGE_CELLS"] = "off"; | |
| 33 | receiver = Bun.serve({ | |
| 34 | port: 0, | |
| 35 | hostname: "127.0.0.1", | |
| 36 | async fetch(req) { | |
| 37 | received.push({ | |
| 38 | body: await req.text(), | |
| 39 | sig: req.headers.get("x-forge-signature-256") ?? "", | |
| 40 | kind: req.headers.get("x-forge-event") ?? "", | |
| 41 | }); | |
| 42 | return new Response("ok"); | |
| 43 | }, | |
| 44 | }); | |
| 45 | dataDir = await mkdtemp(join(tmpdir(), "forge-hook-ping-")); | |
| 46 | app = createApp({ dataDir, port: 0 }); | |
| 47 | servers = await serve(app); | |
| 48 | const mk = async (username: string, first: boolean) => { | |
| 49 | const made = await createUser(app.db, { | |
| 50 | origin: "seed", | |
| 51 | trusted: true, | |
| 52 | username, | |
| 53 | email: `${username}@forge.local`, | |
| 54 | password: "a-good-passphrase", | |
| 55 | adminIfFirst: first, | |
| 56 | }); | |
| 57 | if (!made.ok) throw new Error(String(made.error)); | |
| 58 | return made.value; | |
| 59 | }; | |
| 60 | const alice = await mk("alice", true); | |
| 61 | const bob = await mk("bob", false); | |
| 62 | const ra = await createRepo(app, alice, { name: "a", visibility: "public" }); | |
| 63 | if (!ra.ok) throw new Error("repo"); | |
| 64 | // Subscribes to issues only: a ping must reach it anyway. The other hook hears nothing. | |
| 65 | const a = createWebhook(app.db, { repoId: ra.value.id, url: "https://a.example/h", kinds: "issue.opened" }); | |
| 66 | const b = createWebhook(app.db, { repoId: ra.value.id, url: "https://b.example/h" }); | |
| 67 | if (!a.ok || !b.ok) throw new Error("hook"); | |
| 68 | hookA = a.value.webhook.id; | |
| 69 | hookB = b.value.webhook.id; | |
| 70 | secretA = a.value.secret; | |
| 71 | adminTok = createToken(app.db, alice.id, "t", "admin").token; | |
| 72 | cookie = `forge_session=${createSession(app.db, alice.id).token}`; | |
| 73 | otherTok = createToken(app.db, bob.id, "o", "admin").token; | |
| 74 | }); | |
| 75 | afterAll(async () => { | |
| 76 | receiver.stop(true); | |
| 77 | await servers.stop(); | |
| 78 | app.close(); | |
| 79 | if (cells === undefined) delete process.env["FORGE_CELLS"]; | |
| 80 | else process.env["FORGE_CELLS"] = cells; | |
| 81 | await rm(dataDir, { recursive: true, force: true }); | |
| 82 | }); | |
| 83 | ||
| 84 | const ping = (hook: string, tok: string) => | |
| 85 | fetch(`${servers.url}/api/v1/repos/alice/a/hooks/${hook}/ping`, { method: "POST", headers: { authorization: `Bearer ${tok}` } }); | |
| 86 | const mcp = async (args: unknown) => { | |
| 87 | const res = await fetch(`${servers.url}/mcp`, { | |
| 88 | method: "POST", | |
| 89 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${adminTok}` }, | |
| 90 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "hooks", arguments: args } }), | |
| 91 | }); | |
| 92 | const body = (await res.json()) as any; | |
| 93 | const text: string = body.result.content[0].text; | |
| 94 | return body.result.isError === true ? { error: text } : { value: JSON.parse(text) }; | |
| 95 | }; | |
| 96 | const resetWindow = () => app.db.run("UPDATE webhook_ping SET created_at = '2020-01-01T00:00:00.000Z'"); | |
| 97 | ||
| 98 | test("a ping answers 202, shows among the deliveries and reaches only that hook", async () => { | |
| 99 | const res = await ping(hookA, adminTok); | |
| 100 | expect(res.status).toBe(202); | |
| 101 | const { id } = (await res.json()) as { id: string }; | |
| 102 | const api = await fetch(`${servers.url}/api/v1/repos/alice/a/hooks/${hookA}/deliveries`, { headers: { authorization: `Bearer ${adminTok}` } }); | |
| 103 | const items = ((await api.json()) as any).items; | |
| 104 | expect(items[0]).toMatchObject({ id, kind: "ping", state: "pending" }); | |
| 105 | const viaMcp = await mcp({ repo: "alice/a", action: "deliveries", id: hookA }); | |
| 106 | expect(viaMcp.value.items[0]).toMatchObject({ id, kind: "ping" }); | |
| 107 | expect(recentDeliveries(app.db, hookB)).toHaveLength(0); | |
| 108 | }); | |
| 109 | ||
| 110 | test("the ping is signed and goes through deliverDue, body { kind, repo, hook, at }", async () => { | |
| 111 | received = []; | |
| 112 | app.db.run("UPDATE webhook SET url = ?2 WHERE id = ?1", [hookA, `http://127.0.0.1:${receiver.port}/h`]); | |
| 113 | await deliverDue(app.db, app.log, new Date(), LOOPBACK); | |
| 114 | expect(received).toHaveLength(1); | |
| 115 | const hit = received[0]!; | |
| 116 | expect(hit.kind).toBe("ping"); | |
| 117 | const body = JSON.parse(hit.body); | |
| 118 | expect(Object.keys(body).sort()).toEqual(["at", "hook", "kind", "repo"]); | |
| 119 | expect(body).toMatchObject({ kind: "ping", repo: "alice/a", hook: hookA }); | |
| 120 | expect(await verifySignature(secretA, hit.body, hit.sig)).toBe(true); | |
| 121 | }); | |
| 122 | ||
| 123 | test("a private address is refused as a failed delivery with its reason, never fetched inline", async () => { | |
| 124 | resetWindow(); | |
| 125 | const res = await ping(hookB, adminTok); | |
| 126 | expect(res.status).toBe(202); | |
| 127 | expect(recentDeliveries(app.db, hookB)[0]).toMatchObject({ kind: "ping", state: "pending", attempts: 0 }); | |
| 128 | app.db.run("UPDATE webhook SET url = 'https://127.0.0.1/h' WHERE id = ?1", [hookB]); | |
| 129 | await deliverDue(app.db, app.log); | |
| 130 | expect(recentDeliveries(app.db, hookB)[0]).toMatchObject({ kind: "ping", state: "failed" }); | |
| 131 | expect(recentDeliveries(app.db, hookB)[0]!.lastError).toBeTruthy(); | |
| 132 | }); | |
| 133 | ||
| 134 | test("a non-admin, a missing hook and no credentials are refused", async () => { | |
| 135 | expect((await ping(hookA, otherTok)).status).toBe(404); | |
| 136 | expect((await ping("nope", adminTok)).status).toBe(404); | |
| 137 | const anon = await fetch(`${servers.url}/api/v1/repos/alice/a/hooks/${hookA}/ping`, { method: "POST" }); | |
| 138 | expect(anon.status).toBe(401); | |
| 139 | }); | |
| 140 | ||
| 141 | test("past three a minute it is 429 with retry-after, over the API and MCP", async () => { | |
| 142 | resetWindow(); | |
| 143 | for (let i = 0; i < 3; i++) expect((await ping(hookA, adminTok)).status).toBe(202); | |
| 144 | const over = await ping(hookA, adminTok); | |
| 145 | expect(over.status).toBe(429); | |
| 146 | expect(Number(over.headers.get("retry-after"))).toBeGreaterThan(0); | |
| 147 | const m = await mcp({ repo: "alice/a", action: "ping", id: hookA }); | |
| 148 | expect(m.error).toContain("too many pings"); | |
| 149 | resetWindow(); | |
| 150 | const ok = await mcp({ repo: "alice/a", action: "ping", id: hookA }); | |
| 151 | expect(typeof ok.value.id).toBe("string"); | |
| 152 | }); | |
| 153 | ||
| 154 | test("a ping is not an event: the repository's event log, feed and stream never hear of it", async () => { | |
| 155 | resetWindow(); | |
| 156 | const before = app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM event").get()!.n; | |
| 157 | expect((await ping(hookA, adminTok)).status).toBe(202); | |
| 158 | expect(app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM event").get()!.n).toBe(before); | |
| 159 | expect(app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM event WHERE kind = 'ping'").get()!.n).toBe(0); | |
| 160 | const events = await fetch(`${servers.url}/api/v1/repos/alice/a/events?kind=ping`); | |
| 161 | expect(((await events.json()) as any).items).toHaveLength(0); | |
| 162 | const all = await fetch(`${servers.url}/api/v1/repos/alice/a/events`); | |
| 163 | expect(JSON.stringify(await all.json())).not.toContain("ping"); | |
| 164 | }); | |
| 165 | ||
| 166 | test("the limit counts deliveries, so it holds with nothing in the event log", async () => { | |
| 167 | resetWindow(); | |
| 168 | for (let i = 0; i < 3; i++) expect((await ping(hookB, adminTok)).status).toBe(202); | |
| 169 | expect((await ping(hookB, adminTok)).status).toBe(429); | |
| 170 | expect(app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM event WHERE kind = 'ping'").get()!.n).toBe(0); | |
| 171 | }); | |
| 172 | ||
| 173 | const send = (hook: string, headers: Record<string, string>) => | |
| 174 | fetch(`${servers.url}/alice/a/settings/hooks/${hook}/ping`, { method: "POST", redirect: "manual", headers: { cookie, ...headers } }); | |
| 175 | ||
| 176 | test("the hooks page has a Send ping form; posting it queues a delivery, a cross-site post does not", async () => { | |
| 177 | resetWindow(); | |
| 178 | const page = await (await fetch(`${servers.url}/alice/a/settings/hooks`, { headers: { cookie } })).text(); | |
| 179 | expect(page).toContain(`action="/alice/a/settings/hooks/${hookA}/ping"`); | |
| 180 | expect(page).toContain("Send ping"); | |
| 181 | const n = () => recentDeliveries(app.db, hookA, 50).length; | |
| 182 | const before = n(); | |
| 183 | expect((await send(hookA, { origin: "https://evil.example" })).status).toBe(403); | |
| 184 | expect(n()).toBe(before); | |
| 185 | const ok = await send(hookA, { origin: servers.url }); | |
| 186 | expect(ok.status).toBe(303); | |
| 187 | expect(n()).toBe(before + 1); | |
| 188 | expect(recentDeliveries(app.db, hookA, 1)[0]).toMatchObject({ kind: "ping", state: "pending" }); | |
| 189 | }); | |
| 190 | ||
| 191 | test("the page post past the limit is a message, not a delivery; a stranger gets nothing", async () => { | |
| 192 | resetWindow(); | |
| 193 | for (let i = 0; i < 3; i++) await send(hookA, { origin: servers.url }); | |
| 194 | const before = recentDeliveries(app.db, hookA, 50).length; | |
| 195 | const over = await send(hookA, { origin: servers.url }); | |
| 196 | expect(over.status).toBe(303); | |
| 197 | expect(recentDeliveries(app.db, hookA, 50)).toHaveLength(before); | |
| 198 | const shown = await (await fetch(`${servers.url}/alice/a/settings/hooks`, { headers: { cookie: over.headers.getSetCookie().map((c) => c.split(";")[0]).join("; ") + "; " + cookie } })).text(); | |
| 199 | expect(shown).toContain("Too many pings"); | |
| 200 | const anon = await fetch(`${servers.url}/alice/a/settings/hooks/${hookA}/ping`, { method: "POST", redirect: "manual", headers: { origin: servers.url } }); | |
| 201 | expect(anon.status).toBe(404); | |
| 202 | expect(recentDeliveries(app.db, hookA, 50)).toHaveLength(before); | |
| 203 | }); |
test/tui-hooks-deliveries.test.ts +32 −2
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * Enter on a webhook in the terminal's Hooks screen opens its last 20 |
| 3 | 3 | * deliveries (#7873): state, status and why it failed, never a secret; |
| 4 | * esc or q returns to the same row; losing admin closes it. | |
| 4 | * esc or q returns to the same row; losing admin closes it. `r` redelivers | |
| 5 | * a failed row (#8075); `p` sends the hook a ping (#7872). | |
| 5 | 6 | */ |
| 6 | 7 | import { afterAll, beforeAll, expect, test } from "bun:test"; |
| 7 | 8 | import { mkdtemp, rm } from "node:fs/promises"; |
| @@ -11,7 +12,7 @@ import { createRepo } from "../src/actions/repos.ts"; | ||
| 11 | 12 | import { createApp, type App } from "../src/app.ts"; |
| 12 | 13 | import type { Repo } from "../src/core/repos.ts"; |
| 13 | 14 | import { createUser, type User } from "../src/core/users.ts"; |
| 14 | import { createWebhook, enqueueForEvent, listWebhooks } from "../src/core/webhooks.ts"; | |
| 15 | import { createWebhook, enqueueForEvent, listWebhooks, PING_LIMIT } from "../src/core/webhooks.ts"; | |
| 15 | 16 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 16 | 17 | import { hooksScreen } from "../src/tui/screens/hooks.ts"; |
| 17 | 18 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| @@ -174,3 +175,32 @@ test("a person who lost admin cannot redeliver with r: the row stays failed", as | ||
| 174 | 175 | expect(app.db.query("SELECT 1 FROM webhook_delivery WHERE state = 'pending'").get()).toBeNull(); |
| 175 | 176 | h.stop(); |
| 176 | 177 | }); |
| 178 | ||
| 179 | test("p sends the hook a ping that shows at once among its deliveries; past the limit it says how long to wait", async () => { | |
| 180 | const h = open(); | |
| 181 | await h.feed("j", "j", "enter"); | |
| 182 | expect(h.text()).toContain("No deliveries yet."); | |
| 183 | for (let i = 0; i < PING_LIMIT; i++) { | |
| 184 | await h.feed("p"); | |
| 185 | expect(h.text()).toContain("ping queued"); | |
| 186 | } | |
| 187 | expect(h.text()).toMatch(/ping · pending · — · 0/); | |
| 188 | await h.feed("p"); | |
| 189 | expect(h.text()).toMatch(/too many pings: try again in \d+ s/); | |
| 190 | const hook = listWebhooks(app.db, repo.id)[2]!; | |
| 191 | expect(app.db.query("SELECT COUNT(*) AS n FROM webhook_ping WHERE webhook_id = ?1").get(hook.id)).toEqual({ | |
| 192 | n: PING_LIMIT, | |
| 193 | }); | |
| 194 | h.stop(); | |
| 195 | }); | |
| 196 | ||
| 197 | test("a person who lost admin cannot ping with p", async () => { | |
| 198 | const before = (app.db.query("SELECT COUNT(*) AS n FROM webhook_ping").get() as { n: number }).n; | |
| 199 | const h = open(); | |
| 200 | await h.feed("enter"); | |
| 201 | (h.session as { user: User }).user = bob; | |
| 202 | await h.feed("p"); | |
| 203 | expect(h.text()).not.toContain("ping queued"); | |
| 204 | expect((app.db.query("SELECT COUNT(*) AS n FROM webhook_ping").get() as { n: number }).n).toBe(before); | |
| 205 | h.stop(); | |
| 206 | }); |
new above
offline