fix(test): align worker token fixtures with auth routing

Share CLI integration worker-token issuance through the auth test helper so test tokens carry the same worker kid header as production-issued tokens.
This commit is contained in:
Bryan Helmkamp 2026-05-02 12:08:00 -04:00
parent d10e0f5c56
commit f219987f2a
No known key found for this signature in database
4 changed files with 62 additions and 104 deletions

View file

@ -8,34 +8,26 @@
)]
use std::io::Read;
use std::path::Path;
use std::process::{Child, ExitStatus, Output, Stdio};
use std::time::{Duration, Instant};
use fabro_client::ServerTarget;
use fabro_config::{Storage, envfile};
use fabro_store::EventEnvelope;
use fabro_test::{
assert_reqwest_status, expect_reqwest_json, fabro_json_snapshot, fabro_snapshot, test_context,
};
use fabro_types::{CommandOutputStream, EventBody, FailureReason, RunEvent, StageId};
use hkdf::Hkdf;
use httpmock::MockServer;
use jsonwebtoken::{Algorithm, EncodingKey, Header};
use sha2::Sha256;
use super::support::{
command_log_text, find_run_dir, local_dev_token, output_stderr, run_events, run_state,
server_endpoint, server_target, wait_for_event_names, wait_for_status, write_gated_workflow,
};
use crate::support::{seed_dev_token_auth, unique_run_id};
use crate::support::{issue_test_worker_jwt, seed_dev_token_auth, unique_run_id};
const SHARED_DAEMON_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
const LEAKED_WORKER_PARENT_TOKEN: &str = "leak-worker-parent-token";
const LEAKED_NEW_RELIC_LICENSE: &str = "leak-new-relic-license";
const WORKER_TOKEN_ISSUER: &str = "fabro-server-worker";
const WORKER_TOKEN_SCOPE: &str = "run:worker";
const WORKER_TOKEN_TTL_SECS: u64 = 72 * 60 * 60;
fn auth_context() -> fabro_test::TestContext {
let context = test_context!();
@ -63,48 +55,6 @@ fn assert_worker_succeeded(run_dir: &std::path::Path, stdout: &[u8]) {
)));
}
#[derive(serde::Serialize)]
struct WorkerTokenClaims {
iss: String,
iat: u64,
exp: u64,
run_id: String,
scope: String,
jti: String,
}
fn worker_token_for_run(storage_dir: &Path, run_id: &str) -> String {
let runtime_directory = Storage::new(storage_dir).runtime_directory();
let session_secret = envfile::read_env_file(&runtime_directory.env_path())
.expect("server env should load")
.get("SESSION_SECRET")
.cloned()
.expect("server env should include SESSION_SECRET");
let hkdf = Hkdf::<Sha256>::new(None, session_secret.as_bytes());
let mut key = [0_u8; 32];
hkdf.expand(b"fabro-worker-jwt-v1", &mut key)
.expect("worker jwt hkdf output should fit");
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let claims = WorkerTokenClaims {
iss: WORKER_TOKEN_ISSUER.to_string(),
iat: now,
exp: now + WORKER_TOKEN_TTL_SECS,
run_id: run_id.to_string(),
scope: WORKER_TOKEN_SCOPE.to_string(),
jti: format!("{:032x}", rand::random::<u128>()),
};
jsonwebtoken::encode(
&Header::new(Algorithm::HS256),
&claims,
&EncodingKey::from_secret(&key),
)
.expect("worker token should encode")
}
fn spawn_worker_process(
context: &fabro_test::TestContext,
server: &str,
@ -117,7 +67,7 @@ fn spawn_worker_process(
cmd.current_dir(&context.temp_dir);
cmd.env(
"FABRO_WORKER_TOKEN",
worker_token_for_run(&context.storage_dir, run_id),
issue_test_worker_jwt(&context.storage_dir, run_id),
);
cmd.args([
"__run-worker",
@ -178,7 +128,7 @@ fn worker_command(context: &fabro_test::TestContext, run_id: &str) -> assert_cmd
let mut cmd = context.command();
cmd.env(
"FABRO_WORKER_TOKEN",
worker_token_for_run(&context.storage_dir, run_id),
issue_test_worker_jwt(&context.storage_dir, run_id),
);
cmd
}

View file

@ -21,20 +21,15 @@ use fabro_client::{AuthEntry, AuthStore, OAuthEntry, ServerTarget, StoredSubject
use fabro_config::{Storage, envfile};
use fabro_store::EventEnvelope;
use fabro_test::{apply_test_isolation, expect_reqwest_json, isolated_storage_dir, test_context};
use hkdf::Hkdf;
use jsonwebtoken::{Algorithm, EncodingKey, Header};
use sha2::Sha256;
use super::support::{find_run_dir, output_stderr, output_stdout};
use crate::support::{
TEST_SESSION_SECRET, issue_test_github_jwt, parse_event_envelopes, unique_run_id,
TEST_SESSION_SECRET, issue_test_github_jwt, issue_test_worker_jwt, parse_event_envelopes,
unique_run_id,
};
const COMMAND_TIMEOUT: Duration = Duration::from_secs(30);
const TEST_GITHUB_CLIENT_SECRET: &str = "github-client-secret";
const WORKER_TOKEN_ISSUER: &str = "fabro-server-worker";
const WORKER_TOKEN_SCOPE: &str = "run:worker";
const WORKER_TOKEN_TTL_SECS: u64 = 72 * 60 * 60;
struct RunningGithubOnlyServer {
child: Option<Child>,
@ -157,16 +152,6 @@ impl Drop for RunningGithubOnlyServer {
}
}
#[derive(serde::Serialize)]
struct WorkerTokenClaims {
iss: String,
iat: u64,
exp: u64,
run_id: String,
scope: String,
jti: String,
}
fn reserve_port() -> u16 {
std::net::TcpListener::bind("127.0.0.1:0")
.unwrap()
@ -215,38 +200,6 @@ fn write_probe_workflow(path: &Path) {
.unwrap();
}
fn issue_worker_token_for_run(storage_dir: &Path, run_id: &str) -> String {
let runtime_directory = Storage::new(storage_dir).runtime_directory();
let session_secret = envfile::read_env_file(&runtime_directory.env_path())
.expect("server env should load")
.get("SESSION_SECRET")
.cloned()
.expect("server env should include SESSION_SECRET");
let hkdf = Hkdf::<Sha256>::new(None, session_secret.as_bytes());
let mut key = [0_u8; 32];
hkdf.expand(b"fabro-worker-jwt-v1", &mut key)
.expect("worker jwt hkdf output should fit");
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let claims = WorkerTokenClaims {
iss: WORKER_TOKEN_ISSUER.to_string(),
iat: now,
exp: now + WORKER_TOKEN_TTL_SECS,
run_id: run_id.to_string(),
scope: WORKER_TOKEN_SCOPE.to_string(),
jti: format!("{:032x}", rand::random::<u128>()),
};
jsonwebtoken::encode(
&Header::new(Algorithm::HS256),
&claims,
&EncodingKey::from_secret(&key),
)
.expect("worker token should encode")
}
fn wait_for_run_dir(storage_dir: &Path, run_id: &str) -> PathBuf {
let deadline = Instant::now() + COMMAND_TIMEOUT;
loop {
@ -427,7 +380,7 @@ fn runner_rejects_bogus_worker_token_against_github_only_server() {
let worker_home = worker_root.path().join("fabro-home");
std::fs::create_dir_all(&worker_home).unwrap();
let auth_file = worker_root.path().join("missing").join("auth.json");
let bogus_token = issue_worker_token_for_run(&server.storage_dir, &unique_run_id());
let bogus_token = issue_test_worker_jwt(&server.storage_dir, &unique_run_id());
let mut cmd = Command::new(env!("CARGO_BIN_EXE_fabro"));
apply_test_isolation(&mut cmd, worker_root.path());

View file

@ -1,4 +1,7 @@
use std::path::Path;
use chrono::{Duration as ChronoDuration, Utc};
use fabro_config::{Storage, envfile};
use fabro_types::AuthMethod;
use hkdf::Hkdf;
use jsonwebtoken::{Algorithm, EncodingKey, Header};
@ -9,6 +12,10 @@ pub(crate) const TEST_SESSION_SECRET: &str =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const JWT_AUDIENCE: &str = "fabro-cli";
const WORKER_TOKEN_ISSUER: &str = "fabro-server-worker";
const WORKER_TOKEN_KID: &str = "fabro-worker";
const WORKER_TOKEN_SCOPE: &str = "run:worker";
const WORKER_TOKEN_TTL_SECS: u64 = 72 * 60 * 60;
#[derive(Clone)]
pub(crate) struct TestGithubJwtSubject {
@ -53,6 +60,16 @@ struct TestJwtClaims {
auth_method: AuthMethod,
}
#[derive(serde::Serialize)]
struct WorkerTokenClaims {
iss: String,
iat: u64,
exp: u64,
run_id: String,
scope: String,
jti: String,
}
pub(crate) fn issue_test_github_jwt(issuer: &str) -> String {
let now = Utc::now();
issue_github_jwt(
@ -120,3 +137,41 @@ fn derived_jwt_key() -> [u8; 32] {
.expect("HKDF should derive the fixed-size JWT key");
key
}
pub(crate) fn issue_test_worker_jwt(storage_dir: &Path, run_id: &str) -> String {
let runtime_directory = Storage::new(storage_dir).runtime_directory();
let session_secret = envfile::read_env_file(&runtime_directory.env_path())
.expect("server env should load")
.get("SESSION_SECRET")
.cloned()
.expect("server env should include SESSION_SECRET");
let hkdf = Hkdf::<Sha256>::new(None, session_secret.as_bytes());
let mut key = [0_u8; 32];
hkdf.expand(b"fabro-worker-jwt-v1", &mut key)
.expect("worker jwt hkdf output should fit");
let now = Utc::now()
.timestamp()
.try_into()
.expect("current timestamp should be positive");
let claims = WorkerTokenClaims {
iss: WORKER_TOKEN_ISSUER.to_string(),
iat: now,
exp: now + WORKER_TOKEN_TTL_SECS,
run_id: run_id.to_string(),
scope: WORKER_TOKEN_SCOPE.to_string(),
jti: format!("{:032x}", rand::random::<u128>()),
};
jsonwebtoken::encode(
&worker_token_header(),
&claims,
&EncodingKey::from_secret(&key),
)
.expect("worker token should encode")
}
fn worker_token_header() -> Header {
let mut header = Header::new(Algorithm::HS256);
header.kid = Some(WORKER_TOKEN_KID.to_string());
header
}

View file

@ -6,7 +6,7 @@ pub(crate) use auth_harness::{
RealAuthHarness, TEST_DEV_TOKEN, complete_login_via_browser, expire_saved_access_token,
no_redirect_browser_client, run_detached, saved_auth_entry, seed_dev_token_auth,
};
pub(crate) use auth_tokens::{TEST_SESSION_SECRET, issue_test_github_jwt};
pub(crate) use auth_tokens::{TEST_SESSION_SECRET, issue_test_github_jwt, issue_test_worker_jwt};
use fabro_store::EventEnvelope;
use fabro_test::{EnvVars, TestContext, preserve_coverage_env};
use fabro_types::RunId;