CI / Check (pull_request) Successful in 5m40s
CI / Detect Changes (pull_request) Has been skipped
CI / Deploy Agent (pull_request) Has been skipped
CI / Deploy Dashboard (pull_request) Has been skipped
CI / Deploy Docs (pull_request) Has been skipped
CI / Deploy MCP (pull_request) Has been skipped
Closes the gap between "endpoints exist" and "a runner can actually run a job":
- POST /api/v1/werkbank/jobs/enqueue {tenant, target_id} — the control-plane
"enqueue" half: extract the target's control-logic program, stash the source as
a content-addressed blob, and queue a plc-provision job referencing it by hash.
- GET /api/v1/werkbank/artifacts/{hash} — serve a blob so the runner can fetch
the program (traversal-safe: the hash is validated). Both runner-token gated.
- blob.rs: store_bytes / read_blob helpers; ingest::blob is now pub(crate).
- .env.example documents WERKBANK_RUNNER_TOKEN.
With the runner-side auth + blob fetch (werkbank repo), the loop runs end to end:
enqueue -> lease -> Docker executor fetches the program by hash, provisions,
probes, DASTs -> completes -> findings persisted against the target.
Tests: a new integration test enqueues from a PlcSps target, confirms the job
carries a program blob, and serves it back. All 4 werkbank_api tests pass; clippy
+ fmt clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
292 lines
9.1 KiB
Rust
292 lines
9.1 KiB
Rust
//! Integration tests for the Werkbank runner endpoints (WB-05).
|
|
//!
|
|
//! Drives the real HTTP handlers (lease/heartbeat/complete) against a live Mongo:
|
|
//! a runner leases a seeded job, completes it, and the result's findings are
|
|
//! persisted against the job's target. Also checks the bearer-token gate. Skips
|
|
//! cleanly when no Mongo is reachable.
|
|
|
|
#![allow(clippy::expect_used, clippy::unwrap_used)]
|
|
|
|
mod common;
|
|
|
|
use std::sync::Arc;
|
|
|
|
use axum::routing::{get, post};
|
|
use axum::{middleware, Extension, Router};
|
|
|
|
use compliance_agent::agent::ComplianceAgent;
|
|
use compliance_agent::api::handlers::werkbank_jobs;
|
|
use compliance_agent::database::DatabasePool;
|
|
use compliance_agent::werkbank::JobQueue;
|
|
use compliance_core::models::werkbank::{InputRef, Job, JobResult, JobStatus, LeasedJob};
|
|
use compliance_core::models::{
|
|
Artifact, Finding, OnboardedTarget, PlcFormat, ScanType, Severity, TargetType,
|
|
};
|
|
|
|
use common::{dev_config, TEST_RUNNER_TOKEN};
|
|
|
|
const TENANT: &str = "dev";
|
|
|
|
/// A running werkbank API on a random port, or `None` if no Mongo.
|
|
struct Harness {
|
|
base_url: String,
|
|
client: reqwest::Client,
|
|
pool: DatabasePool,
|
|
db_name: String,
|
|
}
|
|
|
|
async fn start() -> Option<Harness> {
|
|
let uri = std::env::var("TEST_MONGODB_URI")
|
|
.unwrap_or_else(|_| "mongodb://root:example@localhost:27017/?authSource=admin".into());
|
|
let db_name = format!("wba_{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
|
|
let pool = match DatabasePool::connect(&uri, &db_name).await {
|
|
Ok(p) => p,
|
|
Err(_) => {
|
|
eprintln!("SKIP werkbank_api: no MongoDB reachable at {uri}");
|
|
return None;
|
|
}
|
|
};
|
|
// Touch the tenant DB so indexes are ensured before the queue is used.
|
|
pool.for_tenant_id(TENANT).await.expect("tenant db");
|
|
|
|
let agent = ComplianceAgent::new(dev_config(uri, db_name.clone()), pool.clone());
|
|
let app = Router::new()
|
|
.route("/api/v1/werkbank/jobs/lease", post(werkbank_jobs::lease))
|
|
.route(
|
|
"/api/v1/werkbank/jobs/heartbeat",
|
|
post(werkbank_jobs::heartbeat),
|
|
)
|
|
.route(
|
|
"/api/v1/werkbank/jobs/complete",
|
|
post(werkbank_jobs::complete),
|
|
)
|
|
.route(
|
|
"/api/v1/werkbank/jobs/enqueue",
|
|
post(werkbank_jobs::enqueue),
|
|
)
|
|
.route(
|
|
"/api/v1/werkbank/artifacts/{hash}",
|
|
get(werkbank_jobs::serve_artifact),
|
|
)
|
|
.layer(middleware::from_fn(werkbank_jobs::require_runner_token))
|
|
.layer(Extension(Arc::new(agent)));
|
|
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let port = listener.local_addr().unwrap().port();
|
|
tokio::spawn(async move {
|
|
axum::serve(listener, app).await.ok();
|
|
});
|
|
|
|
Some(Harness {
|
|
base_url: format!("http://127.0.0.1:{port}"),
|
|
client: reqwest::Client::new(),
|
|
pool,
|
|
db_name,
|
|
})
|
|
}
|
|
|
|
impl Harness {
|
|
fn post(
|
|
&self,
|
|
path: &str,
|
|
token: Option<&str>,
|
|
body: serde_json::Value,
|
|
) -> reqwest::RequestBuilder {
|
|
let mut r = self
|
|
.client
|
|
.post(format!("{}{path}", self.base_url))
|
|
.json(&body);
|
|
if let Some(t) = token {
|
|
r = r.bearer_auth(t);
|
|
}
|
|
r
|
|
}
|
|
async fn cleanup(&self) {
|
|
let _ = self
|
|
.pool
|
|
.client()
|
|
.database(&format!("{}_{TENANT}", self.db_name))
|
|
.drop()
|
|
.await;
|
|
}
|
|
}
|
|
|
|
fn finding_for(target: &str, fp: &str) -> Finding {
|
|
let mut f = Finding::new(
|
|
target.to_string(),
|
|
fp.to_string(),
|
|
"ics-probe".to_string(),
|
|
ScanType::IcsProbe,
|
|
"Modbus exposed".to_string(),
|
|
"unauthenticated".to_string(),
|
|
Severity::Critical,
|
|
);
|
|
f.rule_id = Some("ics-modbus-exposed".to_string());
|
|
f
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn lease_complete_persists_findings_against_the_target() {
|
|
let Some(h) = start().await else { return };
|
|
let db = h.pool.for_tenant_id(TENANT).await.unwrap();
|
|
let queue = JobQueue::new(&db);
|
|
|
|
// Seed a queued job.
|
|
let job = Job::plc_provision("job-1", TENANT, "target-1", InputRef::blob("sha256:x"), 180);
|
|
assert!(queue.enqueue(job, chrono::Utc::now()).await.unwrap());
|
|
|
|
// Lease it over HTTP.
|
|
let resp = h
|
|
.post(
|
|
"/api/v1/werkbank/jobs/lease",
|
|
Some(TEST_RUNNER_TOKEN),
|
|
serde_json::json!({
|
|
"tenant": TENANT, "runner_id": "r1", "executor": "docker",
|
|
"labels": [], "lease_ttl_secs": 60
|
|
}),
|
|
)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 200, "lease should return a job");
|
|
let leased: LeasedJob = resp.json().await.unwrap();
|
|
assert_eq!(leased.job.id, "job-1");
|
|
|
|
// Complete it with a finding.
|
|
let mut result = JobResult::succeeded("job-1");
|
|
result.findings = vec![finding_for("target-1", "fp-abc")];
|
|
let resp = h
|
|
.post(
|
|
"/api/v1/werkbank/jobs/complete",
|
|
Some(TEST_RUNNER_TOKEN),
|
|
serde_json::json!({
|
|
"tenant": TENANT, "job_id": "job-1",
|
|
"lease_token": leased.lease_token, "result": result
|
|
}),
|
|
)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 200);
|
|
assert!(resp.json::<serde_json::Value>().await.unwrap()["recorded"]
|
|
.as_bool()
|
|
.unwrap());
|
|
|
|
// The job is now succeeded, and the finding was persisted to the target.
|
|
assert_eq!(
|
|
queue.get("job-1").await.unwrap().unwrap().status,
|
|
JobStatus::Succeeded
|
|
);
|
|
let stored = db
|
|
.findings()
|
|
.find_one(mongodb::bson::doc! { "fingerprint": "fp-abc" })
|
|
.await
|
|
.unwrap();
|
|
assert!(stored.is_some(), "finding should be persisted");
|
|
|
|
h.cleanup().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn enqueue_extracts_program_stores_a_blob_and_serves_it() {
|
|
let Some(h) = start().await else { return };
|
|
let db = h.pool.for_tenant_id(TENANT).await.unwrap();
|
|
|
|
// A PlcSps target with a single complete ST program uploaded.
|
|
let dir = std::env::temp_dir().join(format!("wbq-prog-{}", uuid::Uuid::new_v4()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let st = dir.join("main.st");
|
|
std::fs::write(
|
|
&st,
|
|
"PROGRAM Main\nEND_PROGRAM\nCONFIGURATION C\n RESOURCE R\nEND_CONFIGURATION\n",
|
|
)
|
|
.unwrap();
|
|
let mut target = OnboardedTarget::new("plc".into(), TargetType::PlcSps);
|
|
let mut art = Artifact::plc_project("main.st", PlcFormat::StructuredText);
|
|
art.stored_path = Some(st.to_string_lossy().to_string());
|
|
target.artifacts.push(art);
|
|
let ins = db.onboarded_targets().insert_one(&target).await.unwrap();
|
|
let target_id = ins.inserted_id.as_object_id().unwrap().to_hex();
|
|
|
|
// Enqueue → a plc-provision job whose program is a content-addressed blob.
|
|
let resp = h
|
|
.post(
|
|
"/api/v1/werkbank/jobs/enqueue",
|
|
Some(TEST_RUNNER_TOKEN),
|
|
serde_json::json!({ "tenant": TENANT, "target_id": target_id }),
|
|
)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 200, "enqueue should succeed");
|
|
let body: serde_json::Value = resp.json().await.unwrap();
|
|
let job_id = body["job_id"].as_str().unwrap().to_string();
|
|
|
|
let rec = JobQueue::new(&db).get(&job_id).await.unwrap().unwrap();
|
|
let hash = rec
|
|
.job
|
|
.inputs
|
|
.get("program")
|
|
.and_then(|i| i.blob.clone())
|
|
.expect("program blob");
|
|
|
|
// Serve the blob back and confirm it's the program source (what the runner
|
|
// would fetch).
|
|
let served = h
|
|
.client
|
|
.get(format!("{}/api/v1/werkbank/artifacts/{hash}", h.base_url))
|
|
.bearer_auth(TEST_RUNNER_TOKEN)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(served.status(), 200);
|
|
assert!(served.text().await.unwrap().contains("CONFIGURATION"));
|
|
|
|
h.cleanup().await;
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn empty_queue_leases_nothing() {
|
|
let Some(h) = start().await else { return };
|
|
let resp = h
|
|
.post(
|
|
"/api/v1/werkbank/jobs/lease",
|
|
Some(TEST_RUNNER_TOKEN),
|
|
serde_json::json!({
|
|
"tenant": TENANT, "runner_id": "r1", "executor": "docker",
|
|
"labels": [], "lease_ttl_secs": 60
|
|
}),
|
|
)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 204, "no job → 204");
|
|
h.cleanup().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn runner_endpoints_require_the_bearer_token() {
|
|
let Some(h) = start().await else { return };
|
|
let body = serde_json::json!({
|
|
"tenant": TENANT, "runner_id": "r1", "executor": "docker",
|
|
"labels": [], "lease_ttl_secs": 60
|
|
});
|
|
|
|
let no_token = h
|
|
.post("/api/v1/werkbank/jobs/lease", None, body.clone())
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(no_token.status(), 401, "missing token → 401");
|
|
|
|
let bad_token = h
|
|
.post("/api/v1/werkbank/jobs/lease", Some("wrong"), body)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(bad_token.status(), 401, "wrong token → 401");
|
|
|
|
h.cleanup().await;
|
|
}
|