blob: af9fca5ad47b42944b4f976515c3898f1e82f48d [file]
//! The GitHub issues tap, end to end: the real component, built from `taps/` for
//! `wasm32-wasip2`, run by the host against a fake of the GitHub API on loopback. What
//! it proves: issues become proposed tasks with their origin, a replay files nothing
//! twice, pull requests are skipped, a second page is followed, a malformed item costs
//! only itself, the credential the grant names reaches the API and never the tap, never
//! in the clear and never another port, a label above the ceiling stops the tap, a host
//! the grant does not name is refused, and a run ends when its wall clock does.
// An integration test is test code; a panic here is the failure being reported.
#![allow(clippy::unwrap_used)]
use axum::extract::State;
use axum::http::HeaderMap;
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Json, Router};
use dm_core::Label;
use dm_policy::{Grants, TapGrant};
use dm_server::Memory;
use dm_taps::Limits;
use serde_json::json;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
mod common;
/// The GitHub issues component, built once per test binary.
fn component() -> &'static Path {
static BUILT: OnceLock<PathBuf> = OnceLock::new();
BUILT.get_or_init(|| common::build("tap-github-issues"))
}
#[derive(Clone, Default)]
struct Seen {
authorization: Arc<Mutex<Vec<String>>>,
paths: Arc<Mutex<Vec<String>>>,
/// Whether the `slow` owner has answered, twenty seconds after it was asked.
slow_answered: Arc<AtomicBool>,
}
impl Seen {
fn authorization(&self) -> Vec<String> {
self.authorization.lock().unwrap().clone()
}
fn paths(&self) -> Vec<String> {
self.paths.lock().unwrap().clone()
}
}
/// Two pages of issues under a `Link` header, a pull request on the first, a malformed
/// item on the second; the `slow` owner answers after twenty seconds.
async fn issues(
State(seen): State<Seen>,
axum::extract::Path((owner, repo)): axum::extract::Path<(String, String)>,
uri: axum::http::Uri,
headers: HeaderMap,
) -> Response {
seen.authorization.lock().unwrap().push(
headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string(),
);
seen.paths.lock().unwrap().push(uri.to_string());
if owner == "slow" {
tokio::time::sleep(Duration::from_secs(20)).await;
seen.slow_answered.store(true, Ordering::SeqCst);
return Json(json!([])).into_response();
}
if uri.query().is_some_and(|q| q.contains("page=2")) {
return Json(json!([
{
"number": 14, "title": "Document the manifest", "state": "open",
"updated_at": "2026-10-03T09:00:00Z",
"html_url": "https://github.com/acme/widgets/issues/14",
"body": null
},
{
"number": 15, "title": "Malformed: no updated_at", "state": "open",
"updated_at": null, "html_url": "https://github.com/acme/widgets/issues/15"
}
]))
.into_response();
}
let host = headers
.get("host")
.and_then(|v| v.to_str().ok())
.unwrap_or("127.0.0.1")
.to_string();
let page2 = format!("http://{host}/repos/{owner}/{repo}/issues?state=open&per_page=100&page=2");
let link = format!("<{page2}>; rel=\"next\", <{page2}>; rel=\"last\"");
(
[("link", link)],
Json(json!([
{
"number": 12, "title": "Import fails on step four", "state": "open",
"updated_at": "2026-10-01T09:00:00Z",
"html_url": "https://github.com/acme/widgets/issues/12",
"body": "The fourth step times out on large bundles."
},
{
"number": 13, "title": "Add a retry to the import", "state": "open",
"updated_at": "2026-10-02T09:00:00Z",
"html_url": "https://github.com/acme/widgets/pull/13",
"body": "Retries with backoff.",
"pull_request": { "url": "https://api.github.com/repos/acme/widgets/pulls/13" }
}
])),
)
.into_response()
}
/// A fake GitHub on loopback; returns its authority and what it saw.
async fn fake_github() -> (String, Seen) {
let seen = Seen::default();
let app = Router::new()
.route("/repos/{owner}/{repo}/issues", get(issues))
.with_state(seen.clone());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let authority = listener.local_addr().unwrap().to_string();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
(authority, seen)
}
/// A root whose grants name the tap, and a memory opened under the tap's author.
fn root_with_tap(dir: &Path, hosts: Vec<String>) -> (Arc<Memory>, TapGrant) {
Memory::init(dir, "human:test:me", "dom-widgets").unwrap();
let grants_path = dir.join(dm_server::GRANTS_FILE);
let mut grants = Grants::load(&grants_path).unwrap();
let grant = TapGrant {
domain: "dom-widgets".into(),
hosts,
ceiling: Label::Public,
credential: Some("GH_TOKEN".into()),
..TapGrant::default()
};
grants.taps.insert("github-issues".into(), grant.clone());
std::fs::write(&grants_path, grants.render().unwrap()).unwrap();
let memory = Memory::open(dir)
.unwrap()
.with_author("tap:github-issues")
.without_read_log();
(Arc::new(memory), grant)
}
fn config_for(authority: &str, repo: &str) -> String {
json!({ "repo": repo, "api": format!("http://{authority}") }).to_string()
}
async fn run(
memory: &Arc<Memory>,
grant: &TapGrant,
credential: Option<&str>,
config: &str,
limits: Limits,
) -> Result<dm_taps::Outcome, dm_taps::Error> {
dm_taps::run(
memory.clone(),
component(),
grant,
credential,
config,
limits,
)
.await
}
/// The proposed tasks, listed by a session of the owner's own: a listing is a read and
/// raises the label of the session that lists, so the tap's session does not list.
async fn proposed(memory: &Memory) -> Vec<dm_ledger::Task> {
Memory::open(memory.root())
.unwrap()
.without_read_log()
.tasks(Some("dom-widgets"), dm_ledger::Status::Proposed)
.await
.unwrap()
}
/// The two issues as tasks: origin, event, author, body, and the domain's label.
fn check_filed(tasks: &[dm_ledger::Task]) {
let mut origins: Vec<String> = tasks.iter().filter_map(|t| t.spec.origin.clone()).collect();
origins.sort();
assert_eq!(
origins,
["github:acme/widgets#12", "github:acme/widgets#14"]
);
let twelve = tasks
.iter()
.find(|t| t.spec.origin.as_deref() == Some("github:acme/widgets#12"))
.unwrap();
assert_eq!(twelve.spec.title, "#12: Import fails on step four");
assert!(twelve.spec.body.contains("issues/12"));
assert!(twelve.spec.body.contains("times out"));
assert_eq!(
twelve.spec.upstream_event.as_deref(),
Some("open:2026-10-01T09:00:00Z")
);
assert_eq!(twelve.spec.created_by, "tap:github-issues");
assert_eq!(
twelve.spec.classification,
Label::Internal,
"a task from upstream takes the domain's default, not public"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn issues_become_proposed_tasks_once_with_the_credential_kept_from_the_tap() {
let (authority, seen) = fake_github().await;
let dir = tempfile::tempdir().unwrap();
let (memory, grant) = root_with_tap(dir.path(), vec![authority.clone()]);
let config = config_for(&authority, "acme/widgets");
let report = run(
&memory,
&grant,
Some("test-token"),
&config,
Limits::default(),
)
.await
.unwrap();
assert_eq!(report.filed, 2, "{report:?}");
assert_eq!(report.deduplicated, 0);
assert!(
report.note.contains("2 pages"),
"the Link header was followed: {report:?}"
);
assert!(
report.note.contains("1 pull requests skipped"),
"{report:?}"
);
assert!(
report.note.contains("1 items unreadable"),
"a malformed item costs itself only: {report:?}"
);
assert!(report.refused.is_empty());
check_filed(&proposed(&memory).await);
assert_eq!(
seen.authorization(),
["Bearer test-token", "Bearer test-token"],
"the host put the credential on the wire, on both pages"
);
let paths = seen.paths();
assert!(paths[0].starts_with("/repos/acme/widgets/issues?state=open"));
assert!(paths[1].contains("page=2"));
// a replay files nothing twice
let again = run(
&memory,
&grant,
Some("test-token"),
&config,
Limits::default(),
)
.await
.unwrap();
assert_eq!((again.filed, again.deduplicated), (0, 2), "{again:?}");
assert_eq!(proposed(&memory).await.len(), 2);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_host_the_grant_does_not_name_is_refused_and_nothing_is_filed() {
let (authority, seen) = fake_github().await;
let dir = tempfile::tempdir().unwrap();
let (memory, grant) = root_with_tap(dir.path(), vec!["api.github.com".into()]);
let config = config_for(&authority, "acme/widgets");
let err = run(&memory, &grant, None, &config, Limits::default())
.await
.unwrap_err();
assert!(
matches!(err, dm_taps::Error::Refused(ref why, _) if why.contains("does not allow")),
"{err}"
);
assert!(seen.paths().is_empty(), "nothing reached the fake");
assert!(proposed(&memory).await.is_empty());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn the_credential_never_goes_in_the_clear_or_to_another_port() {
let (authority, seen) = fake_github().await;
// plaintext to a host that is not loopback: refused before anything is sent
let dir = tempfile::tempdir().unwrap();
let (memory, grant) = root_with_tap(dir.path(), vec!["api.github.com".into()]);
let config = json!({ "repo": "acme/widgets", "api": "http://api.github.com" }).to_string();
let err = run(&memory, &grant, Some("t"), &config, Limits::default())
.await
.unwrap_err();
assert!(
matches!(err, dm_taps::Error::Refused(ref why, _) if why.contains("https")),
"{err}"
);
// a grant that names a port is that port only
let dir2 = tempfile::tempdir().unwrap();
let (memory, grant) = root_with_tap(dir2.path(), vec!["127.0.0.1:1".into()]);
let config = config_for(&authority, "acme/widgets");
let err = run(&memory, &grant, Some("t"), &config, Limits::default())
.await
.unwrap_err();
assert!(
matches!(err, dm_taps::Error::Refused(ref why, _) if why.contains("does not allow")),
"{err}"
);
assert!(seen.paths().is_empty(), "nothing reached the fake");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_label_above_the_ceiling_stops_the_tap_s_outbound_calls() {
let (authority, seen) = fake_github().await;
let dir = tempfile::tempdir().unwrap();
let (memory, grant) = root_with_tap(dir.path(), vec![authority.clone()]);
// the session reads something internal; the grant's ceiling is public
memory
.write(dm_server::WriteRequest {
body: "An internal note the tap's session has now read.\n".into(),
..dm_server::WriteRequest::default()
})
.await
.unwrap();
memory.search("internal note", None, 5).await.unwrap();
assert_eq!(memory.label(), Label::Internal);
let config = config_for(&authority, "acme/widgets");
let err = run(&memory, &grant, None, &config, Limits::default())
.await
.unwrap_err();
assert!(
matches!(err, dm_taps::Error::Refused(ref why, _) if why.contains("ceiling")),
"{err}"
);
assert!(seen.paths().is_empty());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_run_is_bounded_by_its_wall_clock() {
let (authority, seen) = fake_github().await;
let dir = tempfile::tempdir().unwrap();
let (memory, grant) = root_with_tap(dir.path(), vec![authority.clone()]);
let config = config_for(&authority, "slow/repo");
let outcome = run(
&memory,
&grant,
None,
&config,
Limits {
wall: Duration::from_millis(500),
..Limits::default()
},
)
.await;
// the tap asked, and the run was over before the answer came: it was cut off, not
// waited out, however long compiling the component took first
assert_eq!(seen.paths().len(), 1);
assert!(
!seen.slow_answered.load(Ordering::SeqCst),
"the run waited for the slow answer: {outcome:?}"
);
let err = outcome.unwrap_err();
assert!(
matches!(err, dm_taps::Error::Runtime(ref m) if m.contains("wall clock")),
"{err}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_bad_config_is_the_tap_s_error_not_a_trap() {
let dir = tempfile::tempdir().unwrap();
let (memory, grant) = root_with_tap(dir.path(), vec![]);
let err = run(
&memory,
&grant,
None,
"{\"repo\": \"nope\"}",
Limits::default(),
)
.await
.unwrap_err();
assert!(
matches!(err, dm_taps::Error::Tap(ref m) if m.contains("owner/name")),
"{err}"
);
}