- PWA (§16): hand-rolled 60-line service worker (network-first pages with offline fallback, cache-first hashed statics, /api NEVER touched — SSE and approvals stay live), app manifest with §2 identity, stdlib- generated coral claw icons, prod-only registration. E2E asserts manifest, real PNG icons, an ACTIVATED service worker, and the /api bypass. (Serwist was tried and dropped: its webpack plugin fights Next 16's Turbopack builds; sixty lines we own beat a plugin we fight.) - Route motion (§3): (workspace) template re-mounts per navigation with a quiet fade-rise, zeroed under prefers-reduced-motion. The a11y sweep now settles running animations before scanning — axe was reading mid-fade opacity as contrast failures - OAuth browser flow vs REAL dex: the e2e harness boots dexidp/dex with static client + password; the journey drives the actual dex login form from /api/apps/oauth/start through the callback 303 and asserts the app reads connected (closing the P4 deferral honestly) - release.yml: tag-triggered — builds all four images + postgres, saves tarballs, assembles the SIGNED air-gapped bundle (compose, config, migrations, seccomp profile, installer, bundler binary), derives the public key via the new Could not find command "pubkey". subcommand (tested), verifies the bundle customer-style with the public half only, attaches tarball + public key to the GitHub release 153 Rust + 63 frontend tests + 29 Playwright journeys. Co-Authored-By: Claude Fable 5 <[email protected]>
114 lines
3.6 KiB
Rust
114 lines
3.6 KiB
Rust
use std::fs;
|
|
use std::path::PathBuf;
|
|
|
|
use teamclaw_bundler::{assemble, generate_keypair, verify, BundleError};
|
|
|
|
struct Fixture {
|
|
root: PathBuf,
|
|
public_key: PathBuf,
|
|
bundle: PathBuf,
|
|
}
|
|
|
|
fn fixture() -> Fixture {
|
|
let root = std::env::temp_dir().join(format!("tc-bundle-{}", uuid::Uuid::now_v7()));
|
|
fs::create_dir_all(&root).unwrap();
|
|
let private_key = root.join("release.key");
|
|
let public_key = root.join("release.pub");
|
|
generate_keypair(&private_key, &public_key).unwrap();
|
|
|
|
fs::write(root.join("server.tar"), b"pretend image bytes").unwrap();
|
|
fs::write(root.join("compose.yml"), b"services: {}").unwrap();
|
|
fs::write(root.join("0001_init.sql"), b"CREATE TABLE t ();").unwrap();
|
|
|
|
let bundle = root.join("bundle");
|
|
assemble(
|
|
&bundle,
|
|
"1.2.3",
|
|
&[
|
|
(root.join("server.tar"), "images/server.tar".into()),
|
|
(
|
|
root.join("compose.yml"),
|
|
"compose/docker-compose.yml".into(),
|
|
),
|
|
(
|
|
root.join("0001_init.sql"),
|
|
"migrations/0001_init.sql".into(),
|
|
),
|
|
],
|
|
&private_key,
|
|
)
|
|
.unwrap();
|
|
Fixture {
|
|
root,
|
|
public_key,
|
|
bundle,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn assembled_bundles_verify_offline() {
|
|
let fx = fixture();
|
|
// 3 artifacts + manifest.json.
|
|
assert_eq!(verify(&fx.bundle, &fx.public_key).unwrap(), 4);
|
|
|
|
let manifest: serde_json::Value =
|
|
serde_json::from_str(&fs::read_to_string(fx.bundle.join("manifest.json")).unwrap())
|
|
.unwrap();
|
|
assert_eq!(manifest["version"], "1.2.3");
|
|
assert_eq!(manifest["artifacts"].as_array().unwrap().len(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn a_tampered_artifact_is_detected() {
|
|
let fx = fixture();
|
|
fs::write(fx.bundle.join("images/server.tar"), b"EVIL image bytes").unwrap();
|
|
let err = verify(&fx.bundle, &fx.public_key).unwrap_err();
|
|
assert!(matches!(err, BundleError::ChecksumMismatch(p) if p == "images/server.tar"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_tampered_checksum_list_fails_the_signature() {
|
|
let fx = fixture();
|
|
// Attacker edits both the artifact AND its checksum line — the
|
|
// signature over checksums.txt catches it.
|
|
let listing = fs::read_to_string(fx.bundle.join("checksums.txt")).unwrap();
|
|
fs::write(
|
|
fx.bundle.join("checksums.txt"),
|
|
listing.replace("images/server.tar", "images/sneaky.tar"),
|
|
)
|
|
.unwrap();
|
|
let err = verify(&fx.bundle, &fx.public_key).unwrap_err();
|
|
assert!(matches!(err, BundleError::BadSignature));
|
|
}
|
|
|
|
#[test]
|
|
fn the_wrong_public_key_is_refused() {
|
|
let fx = fixture();
|
|
let other_private = fx.root.join("other.key");
|
|
let other_public = fx.root.join("other.pub");
|
|
generate_keypair(&other_private, &other_public).unwrap();
|
|
let err = verify(&fx.bundle, &other_public).unwrap_err();
|
|
assert!(matches!(err, BundleError::BadSignature));
|
|
}
|
|
|
|
#[test]
|
|
fn a_missing_artifact_is_reported() {
|
|
let fx = fixture();
|
|
fs::remove_file(fx.bundle.join("migrations/0001_init.sql")).unwrap();
|
|
let err = verify(&fx.bundle, &fx.public_key).unwrap_err();
|
|
assert!(matches!(err, BundleError::Missing(p) if p.contains("0001_init.sql")));
|
|
}
|
|
|
|
#[test]
|
|
fn the_public_key_rederives_from_the_signing_key() {
|
|
let fx = fixture();
|
|
let derived = fx.root.join("derived.pub");
|
|
teamclaw_bundler::derive_public_key(&fx.root.join("release.key"), &derived).unwrap();
|
|
assert_eq!(
|
|
fs::read_to_string(&derived).unwrap(),
|
|
fs::read_to_string(&fx.public_key).unwrap()
|
|
);
|
|
// And it verifies the bundle, same as the original.
|
|
assert_eq!(verify(&fx.bundle, &derived).unwrap(), 4);
|
|
}
|