Skip to content

Commit e2ba8a8

Browse files
committed
test(review): execute the MCP dispatch and gl->node signature seams (#94)
Close the last two reasoned-only paths by execution: - MCP webhook_list: a call_tool("webhook_list", ...) dispatch test against a mock node asserts the /hooks request carries a signature (get_signed). RED-proven: flipping the arm back to get() fails the header assertion. - gl->node end-to-end: a real RFC-9421 signature produced exactly as the gl client's get_signed does (gitlawb_core::http_sig::sign_request over GET + empty body) is run through the node's actual optional_signature middleware, which verifies it and authorizes the owner (200 + webhook body). RED-proven: signing over POST while sending GET makes the node reject it. This stitches the gl signing side and the node verifying side in one test rather than mocking one end.
1 parent 069b336 commit e2ba8a8

2 files changed

Lines changed: 108 additions & 0 deletions

File tree

crates/gitlawb-node/src/test_support.rs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1462,6 +1462,79 @@ mod tests {
14621462
);
14631463
}
14641464

1465+
/// #94 end-to-end seam: a REAL RFC-9421 signature produced exactly as the gl
1466+
/// client's get_signed does (gitlawb_core::http_sig::sign_request over GET +
1467+
/// empty body) is accepted by the node's actual optional_signature middleware,
1468+
/// which verifies it and injects AuthenticatedDid, so the owner's signed
1469+
/// `gl webhook list` resolves to 200. This stitches the gl signing side and
1470+
/// the node verifying side in one test (not mockito on one end and a unit
1471+
/// verify on the other).
1472+
#[sqlx::test]
1473+
async fn list_webhooks_accepts_a_real_gl_signature_e2e(pool: PgPool) {
1474+
use gitlawb_core::http_sig::sign_request;
1475+
use gitlawb_core::identity::Keypair;
1476+
1477+
let kp = Keypair::generate();
1478+
let owner_did = kp.did().to_string();
1479+
// Short owner form in the URL path: no colons (so the signed @path and the
1480+
// node's path_and_query() match byte-for-byte), and get_repo's owner LIKE
1481+
// match + did_matches still authorize the full-DID signer as the owner.
1482+
let short = owner_did.split(':').next_back().unwrap().to_string();
1483+
let state = test_state(pool).await;
1484+
let repo = seed_repo(&owner_did, "real-sig-repo");
1485+
state.db.create_repo(&repo).await.expect("seed repo");
1486+
let url = "https://hooks.example.com/e2e";
1487+
state
1488+
.db
1489+
.create_webhook(&crate::db::Webhook {
1490+
id: uuid::Uuid::new_v4().to_string(),
1491+
repo_id: repo.id.clone(),
1492+
url: url.to_string(),
1493+
secret: None,
1494+
events: vec!["*".to_string()],
1495+
created_by_did: owner_did.clone(),
1496+
created_at: Utc::now().to_rfc3339(),
1497+
active: true,
1498+
})
1499+
.await
1500+
.expect("seed webhook");
1501+
1502+
let path = format!("/api/v1/repos/{short}/real-sig-repo/hooks");
1503+
let signed = sign_request(&kp, "GET", &path, b"");
1504+
let req = Request::builder()
1505+
.method(Method::GET)
1506+
.uri(&path)
1507+
.header("content-digest", signed.content_digest)
1508+
.header("signature-input", signed.signature_input)
1509+
.header("signature", signed.signature)
1510+
.body(Body::empty())
1511+
.unwrap();
1512+
1513+
// Mount the handler UNDER the production optional_signature middleware so
1514+
// the node actually verifies the signature (not the injected-DID shortcut).
1515+
let router = Router::new()
1516+
.route(
1517+
"/api/v1/repos/{owner}/{repo}/hooks",
1518+
axum::routing::get(crate::api::webhooks::list_webhooks),
1519+
)
1520+
.layer(axum::middleware::from_fn(crate::auth::optional_signature))
1521+
.with_state(state);
1522+
1523+
let resp = router.oneshot(req).await.unwrap();
1524+
assert_eq!(
1525+
resp.status(),
1526+
StatusCode::OK,
1527+
"the node must verify a real gl-style signature and authorize the owner"
1528+
);
1529+
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1530+
.await
1531+
.unwrap();
1532+
assert!(
1533+
String::from_utf8_lossy(&bytes).contains(url),
1534+
"the verified owner sees the webhook list"
1535+
);
1536+
}
1537+
14651538
/// Issue #6 / jatmn finding 2: `/api/v1/stats` counts logical repos, not raw
14661539
/// rows. With a mirror+canonical pair and a standalone repo present, the
14671540
/// `repos` count is 2.

crates/gl/src/mcp.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1826,6 +1826,41 @@ mod tests {
18261826
assert_eq!(parsed["body"], "looks good");
18271827
}
18281828

1829+
#[tokio::test]
1830+
async fn test_webhook_list_via_mcp_signs_the_request() {
1831+
let mut server = mockito::Server::new_async().await;
1832+
let dir = tempfile::TempDir::new().unwrap();
1833+
let kp = gitlawb_core::identity::Keypair::generate();
1834+
std::fs::write(
1835+
dir.path().join("identity.pem"),
1836+
kp.to_pem().unwrap().as_bytes(),
1837+
)
1838+
.unwrap();
1839+
1840+
// /hooks is owner-gated, so the MCP webhook_list tool must send a SIGNED
1841+
// request (get_signed). Requiring the header catches a regression to get().
1842+
// Passing `owner` in args makes resolve_owner skip the node-root lookup.
1843+
let _m = server
1844+
.mock("GET", mockito::Matcher::Regex(r"/hooks$".to_string()))
1845+
.match_header("signature", mockito::Matcher::Any)
1846+
.match_header("signature-input", mockito::Matcher::Any)
1847+
.with_status(200)
1848+
.with_header("content-type", "application/json")
1849+
.with_body(r#"{"webhooks":[],"count":0}"#)
1850+
.create_async()
1851+
.await;
1852+
1853+
let result = call_tool(
1854+
"webhook_list",
1855+
json!({"owner": "alice", "repo": "myrepo"}),
1856+
&server.url(),
1857+
Some(dir.path()),
1858+
)
1859+
.await
1860+
.unwrap();
1861+
assert!(result.contains("webhooks"), "got: {result}");
1862+
}
1863+
18291864
#[test]
18301865
fn test_tool_count_is_42() {
18311866
let tools = tool_definitions();

0 commit comments

Comments
 (0)