config PRs: remove the hive's config-PR webhook, poll and core merge
An operator's merge on the forge deploys a config PR through
swarm-controller's DeployRequest{rev}. The hive-side path that queued a
MergeConfigPr approval and merged the PR as `core` goes:
- the `/webhook/config-pr` receiver, its HMAC secret, the WebhookRegister
boot node and the org-hook registration; the hive vhost's `/webhook/`
location
- the 5-minute config-PR poll
- ApprovalKind::MergeConfigPr, its dashboard card, and the deploy DAG it
drove (DeployWindow, MergeVerify, DeployApply, FinalizeDeploy,
DeployTail), with verify_commit, the two-phase meta deploy, rollback
refs, the PR-failure comment and forge/pr_merge.rs
- `fetched_sha`, `sha_short`/`pr_number` on approval events, and
`sha`/`tag` on HelperEvent::ApprovalResolved: only the merge path set
them
`config_repo`, `merged_pr_for_commit` and `post_pr_comment` move to
forge/pr_comment.rs for the merged-rev deploy's refusal comment.
Approvals v5 drops stored `merge_config_pr` rows; a test reopens a v4
database holding them.
Closes #4850
This commit is contained in:
parent
a5ea015bc6
commit
efbfec6d01
39 changed files with 286 additions and 3199 deletions
|
|
@ -89,15 +89,10 @@ pub(super) fn gc_orphans(coord: &Coordinator, approvals: Vec<Approval>) -> Vec<A
|
|||
let note = "agent state dir missing";
|
||||
let _ = coord.approvals.mark_failed(a.id, note);
|
||||
tracing::info!(id = a.id, agent = %a.agent, "auto-failed orphan approval");
|
||||
let sha_short = a
|
||||
.fetched_sha
|
||||
.as_deref()
|
||||
.map(|s| s[..s.len().min(12)].to_owned());
|
||||
coord.emit_approval_resolved(crate::coordinator::ApprovalResolved {
|
||||
id: a.id,
|
||||
agent: a.agent.as_str(),
|
||||
approval_kind: <&str>::from(a.kind),
|
||||
sha_short,
|
||||
status: "failed",
|
||||
note: Some(note.to_owned()),
|
||||
description: a.description.clone(),
|
||||
|
|
|
|||
|
|
@ -45,7 +45,6 @@ use crate::lifecycle;
|
|||
(name = "state_files", description = "proxied reads of allow-listed per-agent state files"),
|
||||
(name = "state_snapshot", description = "cold-load dashboard snapshot"),
|
||||
(name = "tombstones", description = "purge of retained state for destroyed agents"),
|
||||
(name = "webhook", description = "forgejo webhook receivers"),
|
||||
)
|
||||
)]
|
||||
struct ApiDoc;
|
||||
|
|
@ -67,7 +66,6 @@ mod schedules;
|
|||
mod state_files;
|
||||
mod state_snapshot;
|
||||
mod tombstones;
|
||||
mod webhook;
|
||||
|
||||
// Run after lock bumps by the job queue (`job_queue/exec.rs`); the view
|
||||
// type feeds `DashboardEvent::MetaInputsChanged` (`dashboard_events.rs`).
|
||||
|
|
@ -87,11 +85,6 @@ pub(crate) use tombstones::emit_tombstones_snapshot;
|
|||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
coord: Arc<Coordinator>,
|
||||
/// HMAC-SHA256 secret shared with Forgejo webhook registrations.
|
||||
/// Verified on every incoming `/webhook/*` POST.
|
||||
/// `None` when the secret could not be loaded at startup — all
|
||||
/// `/webhook/*` requests are rejected with 503 in that case.
|
||||
webhook_secret: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(
|
||||
|
|
@ -101,11 +94,7 @@ struct AppState {
|
|||
handler; splitting that exhaustive list across helpers would \
|
||||
obscure the route map for no readability gain"
|
||||
)]
|
||||
pub async fn serve(
|
||||
port: u16,
|
||||
coord: Arc<Coordinator>,
|
||||
webhook_secret: Option<String>,
|
||||
) -> Result<()> {
|
||||
pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
||||
// API-only: the gateway static-serves the dashboard dist and proxies
|
||||
// non-static requests here (see `nix/host-modules/hive-gateway/vhosts.nix`).
|
||||
// Unmatched paths 404.
|
||||
|
|
@ -162,7 +151,6 @@ pub async fn serve(
|
|||
.routes(routes!(schedules::post_schedule_resume))
|
||||
.routes(routes!(schedules::post_schedule_fire_now))
|
||||
.routes(routes!(schedules::post_rebuild_queue_cancel))
|
||||
.routes(routes!(webhook::post_webhook_config_pr))
|
||||
.routes(routes!(approvals::post_approve))
|
||||
.routes(routes!(approvals::post_deny))
|
||||
.routes(routes!(lifecycle_ops::post_destroy))
|
||||
|
|
@ -191,10 +179,7 @@ pub async fn serve(
|
|||
"/api/openapi.json",
|
||||
get(move || async move { Json(api.clone()) }),
|
||||
)
|
||||
.with_state(AppState {
|
||||
coord,
|
||||
webhook_secret,
|
||||
});
|
||||
.with_state(AppState { coord });
|
||||
// Binds loopback-only; external access via gateway.
|
||||
// Rationale: docs/networking/gateway.md::Firewall posture.
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], port));
|
||||
|
|
@ -392,7 +377,6 @@ mod router_build_probe {
|
|||
.routes(routes!(schedules::post_schedule_resume))
|
||||
.routes(routes!(schedules::post_schedule_fire_now))
|
||||
.routes(routes!(schedules::post_rebuild_queue_cancel))
|
||||
.routes(routes!(webhook::post_webhook_config_pr))
|
||||
.routes(routes!(approvals::post_approve))
|
||||
.routes(routes!(approvals::post_deny))
|
||||
.routes(routes!(lifecycle_ops::post_destroy))
|
||||
|
|
|
|||
|
|
@ -131,8 +131,7 @@ struct ApprovalHistoryView {
|
|||
id: i64,
|
||||
agent: String,
|
||||
kind: &'static str,
|
||||
/// First 12 chars of the canonical sha (preferred) or
|
||||
/// manager-supplied ref. None when the approval carries neither.
|
||||
/// First 12 chars of the manager-supplied ref. None when it is empty.
|
||||
sha_short: Option<String>,
|
||||
/// `approved` / `denied` / `failed`.
|
||||
status: &'static str,
|
||||
|
|
@ -149,25 +148,14 @@ struct ApprovalView {
|
|||
id: i64,
|
||||
agent: String,
|
||||
kind: &'static str,
|
||||
/// First 12 chars of the reviewed PR head sha, for `MergeConfigPr`
|
||||
/// only. Display-only (the short chip on the card).
|
||||
sha_short: Option<String>,
|
||||
/// Manager-supplied description shown on the approval card.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
/// Forge PR number, for `MergeConfigPr` only. Lets the frontend
|
||||
/// build a "review PR on forge" link
|
||||
/// (`{forgeBase}/agent-configs/{agent}/pulls/{pr_number}`). `None`
|
||||
/// for every other kind.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pr_number: Option<u64>,
|
||||
/// Raw `commit_ref` payload for `UpdateMetaInputs` (JSON-encoded
|
||||
/// `Vec<String>` of input names; `"[]"` = all inputs) and
|
||||
/// `SchedulePrompt` (JSON-encoded `SchedulePromptPayload`). The
|
||||
/// frontend parses this to render a human-readable card body.
|
||||
/// `None` for every other kind.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
commit_ref: Option<String>,
|
||||
commit_ref: String,
|
||||
/// RFC 3339 UTC time the approval was queued. Rendered as a
|
||||
/// relative time on the card so the operator can spot a stale
|
||||
/// request.
|
||||
|
|
@ -388,13 +376,11 @@ fn build_transient_views(
|
|||
out
|
||||
}
|
||||
|
||||
/// Render each pending approval into its dashboard view (short sha for
|
||||
/// `MergeConfigPr`).
|
||||
/// Project a resolved sqlite row into the lean shape the dashboard
|
||||
/// history tab consumes — no `diff_html` (rendering 30 of them
|
||||
/// per /api/state poll would mean 30 git diffs per refresh).
|
||||
fn history_view(a: Approval) -> ApprovalHistoryView {
|
||||
let displayed = a.fetched_sha.as_deref().unwrap_or(&a.commit_ref);
|
||||
let displayed = a.commit_ref.as_str();
|
||||
let sha_short = if displayed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
|
|
@ -421,54 +407,17 @@ fn history_view(a: Approval) -> ApprovalHistoryView {
|
|||
}
|
||||
|
||||
fn build_approval_views(approvals: Vec<Approval>) -> Vec<ApprovalView> {
|
||||
let mut out = Vec::with_capacity(approvals.len());
|
||||
for a in approvals {
|
||||
out.push(match a.kind {
|
||||
hive_sh4re::approvals::ApprovalKind::UpdateMetaInputs => ApprovalView {
|
||||
id: a.id,
|
||||
agent: a.agent.to_string(),
|
||||
kind: "update_meta_inputs",
|
||||
sha_short: None,
|
||||
description: a.description,
|
||||
pr_number: None,
|
||||
commit_ref: Some(a.commit_ref),
|
||||
requested_at: a.requested_at,
|
||||
},
|
||||
hive_sh4re::approvals::ApprovalKind::SchedulePrompt => ApprovalView {
|
||||
id: a.id,
|
||||
agent: a.agent.to_string(),
|
||||
kind: "schedule_prompt",
|
||||
sha_short: None,
|
||||
description: a.description,
|
||||
pr_number: None,
|
||||
commit_ref: Some(a.commit_ref),
|
||||
requested_at: a.requested_at,
|
||||
},
|
||||
hive_sh4re::approvals::ApprovalKind::MergeConfigPr => {
|
||||
// commit_ref = PR number; fetched_sha = the reviewed PR
|
||||
// head. Show the head sha; the config diff surface lives
|
||||
// on the forge PR itself.
|
||||
let sha = a
|
||||
.fetched_sha
|
||||
.as_deref()
|
||||
.map(|s| s[..s.len().min(12)].to_owned());
|
||||
// Surface the PR number so the frontend can link to the
|
||||
// PR on the forge. commit_ref holds the number as text.
|
||||
let pr_number = a.commit_ref.parse::<u64>().ok();
|
||||
ApprovalView {
|
||||
id: a.id,
|
||||
agent: a.agent.to_string(),
|
||||
kind: "merge_config_pr",
|
||||
sha_short: sha,
|
||||
description: a.description,
|
||||
pr_number,
|
||||
commit_ref: None,
|
||||
requested_at: a.requested_at,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
out
|
||||
approvals
|
||||
.into_iter()
|
||||
.map(|a| ApprovalView {
|
||||
id: a.id,
|
||||
agent: a.agent.to_string(),
|
||||
kind: <&str>::from(a.kind),
|
||||
description: a.description,
|
||||
commit_ref: a.commit_ref,
|
||||
requested_at: a.requested_at,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `/api/jobq/graph` query string — the generic jobq/wire query shape (any
|
||||
|
|
|
|||
|
|
@ -1,362 +0,0 @@
|
|||
//! Forgejo webhook endpoints.
|
||||
//!
|
||||
//! - **`/webhook/config-pr`** — `pull_request` events on any `agent-configs/*`
|
||||
//! repo queue a [`hive_sh4re::approvals::ApprovalKind::MergeConfigPr`] approval row
|
||||
//! so the operator can review + approve the merge from the dashboard.
|
||||
//!
|
||||
//! There was a second endpoint here, `/webhook/knowledge`, which pulled the
|
||||
//! local `internal/knowledge` clone on push. It is gone along with the
|
||||
//! per-hive registration that fed it: a webhook has exactly one target URL,
|
||||
//! so every hive registering one against the shared repository was
|
||||
//! last-writer-wins. The swarm controller now holds the single registration
|
||||
//! and addresses an event to each hive over the queue, which
|
||||
//! [`crate::workers::knowledge`] documents.
|
||||
//!
|
||||
//! The endpoint is reached via the gateway (HTTPS, public domain URL) so
|
||||
//! Forgejo's SSRF guard does not block delivery. Each delivery is verified
|
||||
//! against the `X-Hub-Signature-256` HMAC header Forgejo attaches; the
|
||||
//! shared secret is auto-generated at startup and persisted to
|
||||
//! [`crate::paths::webhook_secret_file()`].
|
||||
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::AppState;
|
||||
|
||||
// ── HMAC helper ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Verify the `X-Hub-Signature-256` header on an incoming Forgejo webhook.
|
||||
/// Returns `Err` (with a safe-to-log message) on mismatch, missing header,
|
||||
/// or when the HMAC secret is unavailable (load failure at startup).
|
||||
fn verify_hmac(state: &AppState, headers: &HeaderMap, body: &Bytes) -> Result<(), String> {
|
||||
let secret = state
|
||||
.webhook_secret
|
||||
.as_deref()
|
||||
.ok_or_else(|| "webhook HMAC secret unavailable; endpoint disabled".to_owned())?;
|
||||
let sig = headers
|
||||
.get("x-hub-signature-256")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
if sig.is_empty() {
|
||||
return Err("missing X-Hub-Signature-256 header".to_owned());
|
||||
}
|
||||
crate::webhook_secret::verify_signature(secret, body, sig).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ── config-PR webhook ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Minimal Forgejo `pull_request`-webhook payload.
|
||||
///
|
||||
/// Forgejo fires this for actions: `opened`, `closed`, `reopened`,
|
||||
/// `synchronized`, `assigned`, `unassigned`, `label_updated`,
|
||||
/// `label_cleared`, `milestoned`, `demilestoned`, `review_requested`,
|
||||
/// `review_request_removed`, `auto_merge_enabled`, `auto_merge_disabled`.
|
||||
/// We only act on `opened` and `synchronized` (Forgejo spells the
|
||||
/// push-update action past-tense, unlike GitHub's `synchronize`).
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct PrWebhookPayload {
|
||||
/// What triggered this event (`opened`, `closed`, `synchronized`, …).
|
||||
action: Option<String>,
|
||||
/// PR index on the repo.
|
||||
number: Option<u64>,
|
||||
pull_request: Option<PrWebhookPr>,
|
||||
repository: Option<PrWebhookRepo>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PrWebhookPr {
|
||||
head: Option<PrWebhookHead>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PrWebhookHead {
|
||||
sha: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PrWebhookRepo {
|
||||
full_name: Option<String>,
|
||||
}
|
||||
|
||||
/// POST `/webhook/config-pr` — Forgejo `pull_request` webhook for
|
||||
/// `agent-configs/*` repos.
|
||||
///
|
||||
/// On `opened` or `synchronized` for an `agent-configs/<agent>` PR:
|
||||
/// fetches the current PR head sha, queues a `MergeConfigPr` approval row,
|
||||
/// and emits the `ApprovalAdded` event so the dashboard card appears
|
||||
/// immediately.
|
||||
///
|
||||
/// All other actions (closed, label changes, etc.) are silently ignored —
|
||||
/// the operator can deny a pending approval if the PR is later closed.
|
||||
///
|
||||
/// Always returns HTTP 200 (even on queue failure) so Forgejo does not
|
||||
/// retry the delivery. Failures are logged at `warn` level.
|
||||
///
|
||||
/// Expected Forgejo webhook configuration:
|
||||
/// - URL: `https://<HYPERHIVE_HIVE_DOMAIN>/webhook/config-pr`
|
||||
/// - Content type: `application/json`
|
||||
/// - Events: "Pull Request" only
|
||||
/// - Secret: auto-generated HMAC key (see [`crate::webhook_secret`])
|
||||
/// - Organisation: `agent-configs` (org-level hook covers all config repos)
|
||||
///
|
||||
/// hive-c0re registers this hook automatically at startup via
|
||||
/// [`crate::forge::ensure_config_pr_webhook`].
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/webhook/config-pr",
|
||||
request_body(
|
||||
content = String,
|
||||
content_type = "application/json",
|
||||
description = "Forgejo pull_request-webhook payload, taken as raw \
|
||||
bytes (not a typed extractor) so HMAC verification \
|
||||
runs over the exact wire bytes before any JSON \
|
||||
parsing"
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "processed (approval queued or ignored)", body = String),
|
||||
(status = 400, description = "invalid JSON payload"),
|
||||
(status = 401, description = "bad or missing HMAC signature"),
|
||||
(status = 503, description = "HMAC secret unavailable at startup"),
|
||||
),
|
||||
tag = "webhook"
|
||||
)]
|
||||
pub(super) async fn post_webhook_config_pr(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
if let Err(e) = verify_hmac(&state, &headers, &body) {
|
||||
tracing::warn!("webhook/config-pr: HMAC verification failed: {e}");
|
||||
let status = if e.contains("unavailable") {
|
||||
StatusCode::SERVICE_UNAVAILABLE
|
||||
} else {
|
||||
StatusCode::UNAUTHORIZED
|
||||
};
|
||||
return (status, e).into_response();
|
||||
}
|
||||
|
||||
let payload = match serde_json::from_slice::<PrWebhookPayload>(&body) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::warn!("webhook/config-pr: JSON parse error: {e}");
|
||||
return (StatusCode::BAD_REQUEST, "invalid JSON").into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let action = payload.action.as_deref().unwrap_or("");
|
||||
// Only act on a newly-opened PR or a new push to its branch. Forgejo's
|
||||
// webhook payload spells the push-update action `synchronized` (past
|
||||
// tense), NOT GitHub's `synchronize` — matching only the GitHub spelling
|
||||
// silently dropped every PR-update delivery (the whole reason updates were
|
||||
// "only found by poll"). Accept both so the handler is correct against
|
||||
// Forgejo and stays GitHub-compatible.
|
||||
if action != "opened" && action != "synchronize" && action != "synchronized" {
|
||||
tracing::debug!(action, "webhook/config-pr: ignoring action");
|
||||
return (StatusCode::OK, "ignored").into_response();
|
||||
}
|
||||
|
||||
let full_name = payload
|
||||
.repository
|
||||
.as_ref()
|
||||
.and_then(|r| r.full_name.as_deref())
|
||||
.unwrap_or("");
|
||||
|
||||
// Expect `agent-configs/<agent>`.
|
||||
let agent = match full_name.strip_prefix(&format!("{}/", crate::forge::CONFIG_ORG)) {
|
||||
Some(name) if !name.is_empty() && !name.contains('/') => name,
|
||||
_ => {
|
||||
tracing::debug!(
|
||||
full_name,
|
||||
"webhook/config-pr: ignoring non-config-repo event"
|
||||
);
|
||||
return (StatusCode::OK, "ignored").into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let pr_number = match payload.number {
|
||||
Some(n) if n > 0 => n,
|
||||
_ => {
|
||||
tracing::warn!(full_name, "webhook/config-pr: missing or zero PR number");
|
||||
return (StatusCode::OK, "ignored").into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// The payload already carries the head sha — use it as an early hint for
|
||||
// logging, but the canonical sha comes from `submit_merge_config_pr`'s
|
||||
// fresh forge API call so we don't trust a potentially-stale payload sha.
|
||||
let payload_sha = payload
|
||||
.pull_request
|
||||
.as_ref()
|
||||
.and_then(|pr| pr.head.as_ref())
|
||||
.and_then(|h| h.sha.as_deref())
|
||||
.unwrap_or("<unknown>");
|
||||
|
||||
tracing::info!(
|
||||
%full_name, %agent, %pr_number, %payload_sha, %action,
|
||||
"webhook/config-pr: queuing MergeConfigPr approval"
|
||||
);
|
||||
|
||||
// Queue the approval. The description surfaces the action and PR number
|
||||
// on the dashboard card so the operator has context without opening the
|
||||
// forge PR.
|
||||
let description = format!("PR #{pr_number} on {full_name} ({action})");
|
||||
if let Err(e) = crate::socket_server::submit_merge_config_pr(
|
||||
&state.coord,
|
||||
agent,
|
||||
pr_number,
|
||||
Some(&description),
|
||||
"forge", // submitter — identifies the webhook path in the audit trail
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
%agent, %pr_number, error = ?e,
|
||||
"webhook/config-pr: failed to queue MergeConfigPr approval"
|
||||
);
|
||||
}
|
||||
|
||||
(StatusCode::OK, "ok").into_response()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::State,
|
||||
http::{HeaderMap, HeaderValue, StatusCode},
|
||||
};
|
||||
|
||||
use super::{AppState, post_webhook_config_pr, verify_hmac};
|
||||
|
||||
const SECRET: &str = "s3cr3t";
|
||||
|
||||
/// The `TempDir` holds the coordinator's sqlite files; keep it alive as
|
||||
/// long as the state.
|
||||
fn state(webhook_secret: Option<&str>) -> (tempfile::TempDir, AppState) {
|
||||
let (dir, coord) = crate::socket_server::coordinator();
|
||||
let state = AppState {
|
||||
coord,
|
||||
webhook_secret: webhook_secret.map(str::to_owned),
|
||||
};
|
||||
(dir, state)
|
||||
}
|
||||
|
||||
/// The `X-Hub-Signature-256` header Forgejo would send for `secret` + `body`.
|
||||
fn signed(secret: &str, body: &[u8]) -> HeaderMap {
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use hmac::{Hmac, KeyInit, Mac};
|
||||
use sha2::Sha256;
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
|
||||
mac.update(body);
|
||||
let mut hex = String::new();
|
||||
for b in mac.finalize().into_bytes() {
|
||||
write!(hex, "{b:02x}").unwrap();
|
||||
}
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-hub-signature-256",
|
||||
HeaderValue::from_str(&format!("sha256={hex}")).unwrap(),
|
||||
);
|
||||
headers
|
||||
}
|
||||
|
||||
/// With no secret loaded, nothing verifies — including a delivery signed
|
||||
/// with the empty key. The message must say "unavailable": the handler
|
||||
/// maps on that word to 503.
|
||||
#[test]
|
||||
fn verify_hmac_rejects_every_delivery_when_no_secret_is_loaded() {
|
||||
let (_dir, state) = state(None);
|
||||
let body = Bytes::from_static(b"{}");
|
||||
for (label, headers) in [
|
||||
("signed with the empty key", signed("", &body)),
|
||||
("signed with some key", signed(SECRET, &body)),
|
||||
("unsigned", HeaderMap::new()),
|
||||
] {
|
||||
let err = verify_hmac(&state, &headers, &body).expect_err(label);
|
||||
assert!(err.contains("unavailable"), "{label}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An absent header, an empty one and one that is not valid UTF-8 all
|
||||
/// read as "no signature", and are refused before any HMAC is computed.
|
||||
#[test]
|
||||
fn verify_hmac_rejects_a_delivery_with_no_usable_signature_header() {
|
||||
let (_dir, state) = state(Some(SECRET));
|
||||
let body = Bytes::from_static(b"{}");
|
||||
let mut empty = HeaderMap::new();
|
||||
empty.insert("x-hub-signature-256", HeaderValue::from_static(""));
|
||||
let mut not_utf8 = HeaderMap::new();
|
||||
not_utf8.insert(
|
||||
"x-hub-signature-256",
|
||||
HeaderValue::from_bytes(b"sha256=\xff").unwrap(),
|
||||
);
|
||||
for (label, headers) in [
|
||||
("absent", HeaderMap::new()),
|
||||
("empty", empty),
|
||||
("not UTF-8", not_utf8),
|
||||
] {
|
||||
assert_eq!(
|
||||
verify_hmac(&state, &headers, &body),
|
||||
Err("missing X-Hub-Signature-256 header".to_owned()),
|
||||
"{label}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The other side of both guards: a loaded secret and a matching
|
||||
/// signature pass.
|
||||
#[test]
|
||||
fn verify_hmac_accepts_a_correctly_signed_delivery() {
|
||||
let (_dir, state) = state(Some(SECRET));
|
||||
let body = Bytes::from_static(b"{}");
|
||||
assert_eq!(verify_hmac(&state, &signed(SECRET, &body), &body), Ok(()));
|
||||
}
|
||||
|
||||
/// 503 means "this hive cannot verify any delivery", 401 means "this
|
||||
/// delivery is not authentic".
|
||||
#[tokio::test]
|
||||
async fn config_pr_answers_503_when_no_secret_is_loaded() {
|
||||
let (_dir, state) = state(None);
|
||||
let body = Bytes::from_static(b"{}");
|
||||
let headers = signed(SECRET, &body);
|
||||
let resp = post_webhook_config_pr(State(state), headers, body).await;
|
||||
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_pr_answers_401_for_a_missing_or_wrong_signature() {
|
||||
let body = Bytes::from_static(b"{}");
|
||||
for (label, headers) in [
|
||||
("missing", HeaderMap::new()),
|
||||
("wrong secret", signed("different-secret", &body)),
|
||||
("other body", signed(SECRET, b"tampered")),
|
||||
] {
|
||||
let (_dir, state) = state(Some(SECRET));
|
||||
let resp = post_webhook_config_pr(State(state), headers, body.clone()).await;
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED, "{label}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A verified delivery reaches payload handling: an action the handler
|
||||
/// ignores comes back 200, and an unparseable body 400, neither of which
|
||||
/// is an auth status.
|
||||
#[tokio::test]
|
||||
async fn config_pr_lets_a_correctly_signed_delivery_through() {
|
||||
for (body, expected) in [
|
||||
(&br#"{"action":"closed"}"#[..], StatusCode::OK),
|
||||
(&b"not json"[..], StatusCode::BAD_REQUEST),
|
||||
] {
|
||||
let (_dir, state) = state(Some(SECRET));
|
||||
let body = Bytes::from_static(body);
|
||||
let headers = signed(SECRET, &body);
|
||||
let resp = post_webhook_config_pr(State(state), headers, body).await;
|
||||
assert_eq!(resp.status(), expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue