feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
@@ -0,0 +1,555 @@
|
||||
//! End-to-end git-over-object-storage tests.
|
||||
//!
|
||||
//! Drives the real `git` binary (clone / push / fetch / force-push / tag,
|
||||
//! plus a best-effort concurrent push race) against a running relay backed by
|
||||
//! S3/MinIO, exercising the full manifest-pointer CAS commit path described in
|
||||
//! `docs/git-on-object-storage.md`.
|
||||
//!
|
||||
//! Requires: relay at localhost:3000 with git + S3/MinIO configured, `git` on
|
||||
//! PATH, and the `git-credential-nostr` helper built. All tests are `#[ignore]`
|
||||
//! so they don't run in CI by default.
|
||||
//!
|
||||
//! # Running
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo build --release -p git-credential-nostr
|
||||
//! GIT_CREDENTIAL_NOSTR_BIN=$PWD/target/release/git-credential-nostr \
|
||||
//! cargo test -p buzz-test-client --test e2e_git -- --ignored --nocapture
|
||||
//! ```
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
use buzz_media::S3AddressingStyle;
|
||||
use nostr::{EventBuilder, Keys, Kind, Tag};
|
||||
use s3::creds::Credentials;
|
||||
use s3::{Bucket, Region};
|
||||
|
||||
fn relay_http_url() -> String {
|
||||
std::env::var("RELAY_HTTP_URL").unwrap_or_else(|_| "http://localhost:3000".to_string())
|
||||
}
|
||||
|
||||
/// Path to the compiled credential helper. Defaults to the workspace release
|
||||
/// build; override with `GIT_CREDENTIAL_NOSTR_BIN`.
|
||||
fn credential_helper() -> PathBuf {
|
||||
if let Ok(p) = std::env::var("GIT_CREDENTIAL_NOSTR_BIN") {
|
||||
return PathBuf::from(p);
|
||||
}
|
||||
// tests run from the crate dir; the workspace target is two levels up.
|
||||
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
p.pop();
|
||||
p.pop();
|
||||
p.push("target/release/git-credential-nostr");
|
||||
p
|
||||
}
|
||||
|
||||
/// Submit a signed event to the relay's HTTP bridge (`POST /events`).
|
||||
async fn post_event(event: &nostr::Event) {
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(format!("{}/events", relay_http_url()))
|
||||
.header("X-Pubkey", event.pubkey.to_hex())
|
||||
.header("Content-Type", "application/json")
|
||||
.body(serde_json::to_string(event).unwrap())
|
||||
.send()
|
||||
.await
|
||||
.expect("post event");
|
||||
assert!(
|
||||
resp.status().is_success(),
|
||||
"event rejected: {}",
|
||||
resp.text().await.unwrap_or_default()
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a channel (kind:9007) owned by `keys` and return its UUID.
|
||||
///
|
||||
/// The git read gate (SEC-005) authorizes against membership in the channel
|
||||
/// named by the announcement's `buzz-channel` tag, so every repo these tests
|
||||
/// announce must be bound to a channel its owner belongs to — creating the
|
||||
/// channel makes the creator its owner-member.
|
||||
async fn create_test_channel(keys: &Keys) -> String {
|
||||
let channel_uuid = uuid::Uuid::new_v4().to_string();
|
||||
let event = EventBuilder::new(Kind::Custom(9007), "")
|
||||
.tags(vec![
|
||||
Tag::parse(["h", &channel_uuid]).unwrap(),
|
||||
Tag::parse(["name", &format!("git-e2e-{channel_uuid}")]).unwrap(),
|
||||
Tag::parse(["channel_type", "stream"]).unwrap(),
|
||||
Tag::parse(["visibility", "open"]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(keys)
|
||||
.unwrap();
|
||||
post_event(&event).await;
|
||||
channel_uuid
|
||||
}
|
||||
|
||||
/// Run `git` with the Buzz credential helper and isolated config.
|
||||
fn git_status(args: &[&str], cwd: &Path, owner_nsec: &str) -> std::process::Output {
|
||||
let helper = credential_helper();
|
||||
Command::new("git")
|
||||
.args([
|
||||
"-c",
|
||||
"credential.useHttpPath=true",
|
||||
"-c",
|
||||
&format!("credential.helper={}", helper.display()),
|
||||
"-c",
|
||||
"commit.gpgsign=false",
|
||||
"-c",
|
||||
"tag.gpgsign=false",
|
||||
"-c",
|
||||
"user.name=E2E",
|
||||
"-c",
|
||||
"user.email=e2e@example.com",
|
||||
])
|
||||
.args(args)
|
||||
.current_dir(cwd)
|
||||
// Isolate from any machine/agent git config (signing, etc.).
|
||||
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||
.env_remove("GIT_CONFIG_COUNT")
|
||||
.env("NOSTR_PRIVATE_KEY", owner_nsec)
|
||||
.output()
|
||||
.expect("spawn git")
|
||||
}
|
||||
|
||||
/// Run `git` with the Buzz credential helper and isolated config. Asserts the
|
||||
/// command succeeds; returns stdout.
|
||||
fn git(args: &[&str], cwd: &Path, owner_nsec: &str) -> String {
|
||||
let out = git_status(args, cwd, owner_nsec);
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git {:?} failed:\nstdout: {}\nstderr: {}",
|
||||
args,
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
String::from_utf8_lossy(&out.stdout).into_owned()
|
||||
}
|
||||
|
||||
struct GitS3Probe {
|
||||
bucket: Box<Bucket>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct PointerSnapshot {
|
||||
etag: String,
|
||||
digest: String,
|
||||
}
|
||||
|
||||
impl GitS3Probe {
|
||||
fn bucket(
|
||||
endpoint: String,
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
bucket_name: &str,
|
||||
region_name: String,
|
||||
addressing_style: S3AddressingStyle,
|
||||
) -> Box<Bucket> {
|
||||
let region = Region::Custom {
|
||||
region: region_name,
|
||||
endpoint,
|
||||
};
|
||||
let creds = Credentials::new(Some(access_key), Some(secret_key), None, None, None)
|
||||
.expect("S3 credentials");
|
||||
let bucket = Bucket::new(bucket_name, region, creds).expect("S3 bucket");
|
||||
match addressing_style {
|
||||
S3AddressingStyle::Path => bucket.with_path_style(),
|
||||
S3AddressingStyle::Virtual => bucket,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_env() -> Self {
|
||||
// These E2E assertions inspect the relay's backing bucket directly, so
|
||||
// they must receive the same provider connection and URL style as the
|
||||
// relay. Unit/live MinIO probes in buzz-relay keep explicit local
|
||||
// fixtures and do not need provider overrides.
|
||||
let endpoint = std::env::var("BUZZ_S3_ENDPOINT")
|
||||
.unwrap_or_else(|_| "http://localhost:9000".to_string());
|
||||
let access_key =
|
||||
std::env::var("BUZZ_S3_ACCESS_KEY").unwrap_or_else(|_| "buzz_dev".to_string());
|
||||
let secret_key =
|
||||
std::env::var("BUZZ_S3_SECRET_KEY").unwrap_or_else(|_| "buzz_dev_secret".to_string());
|
||||
let bucket_name =
|
||||
std::env::var("BUZZ_S3_BUCKET").unwrap_or_else(|_| "buzz-media".to_string());
|
||||
let region_name =
|
||||
std::env::var("BUZZ_S3_REGION").unwrap_or_else(|_| "us-east-1".to_string());
|
||||
let addressing_style = std::env::var("BUZZ_S3_ADDRESSING_STYLE")
|
||||
.unwrap_or_else(|_| "path".to_string())
|
||||
.parse::<S3AddressingStyle>()
|
||||
.expect("BUZZ_S3_ADDRESSING_STYLE must be 'path' or 'virtual'");
|
||||
|
||||
let bucket = Self::bucket(
|
||||
endpoint,
|
||||
&access_key,
|
||||
&secret_key,
|
||||
&bucket_name,
|
||||
region_name,
|
||||
addressing_style,
|
||||
);
|
||||
Self { bucket }
|
||||
}
|
||||
|
||||
fn pointer_key(owner: &str, repo: &str) -> String {
|
||||
let repo = repo.strip_suffix(".git").unwrap_or(repo);
|
||||
if let Ok(community) = std::env::var("BUZZ_E2E_GIT_COMMUNITY_ID") {
|
||||
return format!("repos/{community}/{owner}/{repo}/pointer");
|
||||
}
|
||||
format!("repos/{owner}/{repo}/pointer")
|
||||
}
|
||||
|
||||
async fn pointer(&self, owner: &str, repo: &str) -> Option<PointerSnapshot> {
|
||||
let key = Self::pointer_key(owner, repo);
|
||||
match self.bucket.get_object(&key).await {
|
||||
Ok(resp) => {
|
||||
let etag = resp
|
||||
.headers()
|
||||
.iter()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case("etag"))
|
||||
.map(|(_, v)| v.to_string())
|
||||
.expect("pointer GET must include ETag");
|
||||
let digest = String::from_utf8(resp.to_vec()).expect("pointer body utf-8");
|
||||
assert_eq!(digest.len(), 64, "pointer body is manifest digest");
|
||||
assert!(digest.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
Some(PointerSnapshot { etag, digest })
|
||||
}
|
||||
Err(s3::error::S3Error::HttpFailWithBody(404, _)) => None,
|
||||
Err(e) => panic!("GET S3 pointer {key} failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn require_pointer(&self, owner: &str, repo: &str) -> PointerSnapshot {
|
||||
for _ in 0..40 {
|
||||
if let Some(p) = self.pointer(owner, repo).await {
|
||||
self.assert_manifest_exists(&p.digest).await;
|
||||
return p;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
panic!(
|
||||
"S3 manifest pointer {} never appeared; git may have fallen back to disk",
|
||||
Self::pointer_key(owner, repo)
|
||||
);
|
||||
}
|
||||
|
||||
async fn assert_manifest_exists(&self, digest: &str) {
|
||||
let key = format!("manifests/{digest}");
|
||||
match self.bucket.get_object(&key).await {
|
||||
Ok(_) => {}
|
||||
Err(e) => panic!("pointer named manifest {key}, but GET failed: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_s3_probe_builds_both_addressing_styles() {
|
||||
let path = GitS3Probe::bucket(
|
||||
"https://storage.example".to_string(),
|
||||
"access",
|
||||
"secret",
|
||||
"buzz-media",
|
||||
"us-east-1".to_string(),
|
||||
S3AddressingStyle::Path,
|
||||
);
|
||||
assert!(path.is_path_style());
|
||||
assert_eq!(path.url(), "https://storage.example/buzz-media");
|
||||
|
||||
let virtual_hosted = GitS3Probe::bucket(
|
||||
"https://storage.example".to_string(),
|
||||
"access",
|
||||
"secret",
|
||||
"buzz-media",
|
||||
"auto".to_string(),
|
||||
S3AddressingStyle::Virtual,
|
||||
);
|
||||
assert!(virtual_hosted.is_subdomain_style());
|
||||
assert_eq!(virtual_hosted.url(), "https://buzz-media.storage.example");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires live relay + MinIO + git"]
|
||||
async fn git_clone_push_fetch_force_roundtrip() {
|
||||
use nostr::ToBech32;
|
||||
|
||||
let owner = Keys::generate();
|
||||
let owner_hex = owner.public_key().to_hex();
|
||||
let owner_nsec = owner.secret_key().to_bech32().unwrap();
|
||||
let repo = format!("e2e-git-{}", std::process::id());
|
||||
let s3 = GitS3Probe::from_env();
|
||||
|
||||
// Announce the repo (kind:30617) so the relay creates the bare repo + hook.
|
||||
// The `buzz-channel` binding is the repo's ACL: without it the read gate
|
||||
// 404s even for the owner (issue #3527), so bind to a channel the owner
|
||||
// just created (and therefore belongs to).
|
||||
let channel = create_test_channel(&owner).await;
|
||||
let announce = EventBuilder::new(Kind::from(30617), "")
|
||||
.tags(vec![
|
||||
Tag::parse(["d", &repo]).unwrap(),
|
||||
Tag::parse(["name", "e2e git repo"]).unwrap(),
|
||||
Tag::parse(["buzz-channel", &channel]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&owner)
|
||||
.unwrap();
|
||||
post_event(&announce).await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
|
||||
let tmp = tempdir();
|
||||
let url = format!("{}/git/{}/{}", relay_http_url(), owner_hex, repo);
|
||||
|
||||
// 1. Clone the empty repo.
|
||||
git(
|
||||
&["clone", "--quiet", &url, "clone1"],
|
||||
tmp.path(),
|
||||
&owner_nsec,
|
||||
);
|
||||
let clone1 = tmp.path().join("clone1");
|
||||
assert!(clone1.exists(), "clone1 created");
|
||||
let empty_pointer = s3.require_pointer(&owner_hex, &repo).await;
|
||||
|
||||
// 2. Push an initial commit.
|
||||
std::fs::write(clone1.join("README.md"), "hello\n").unwrap();
|
||||
git(&["add", "."], &clone1, &owner_nsec);
|
||||
git(
|
||||
&["commit", "--quiet", "-m", "initial"],
|
||||
&clone1,
|
||||
&owner_nsec,
|
||||
);
|
||||
git(&["branch", "-M", "main"], &clone1, &owner_nsec);
|
||||
git(&["push", "--quiet", "origin", "main"], &clone1, &owner_nsec);
|
||||
let p1 = s3.require_pointer(&owner_hex, &repo).await;
|
||||
assert_ne!(
|
||||
p1, empty_pointer,
|
||||
"initial push must advance S3 manifest pointer"
|
||||
);
|
||||
let sha1 = git(&["rev-parse", "main"], &clone1, &owner_nsec)
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
// 3. A fresh clone observes the pushed content and exact SHA.
|
||||
git(
|
||||
&["clone", "--quiet", &url, "clone2"],
|
||||
tmp.path(),
|
||||
&owner_nsec,
|
||||
);
|
||||
let clone2 = tmp.path().join("clone2");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(clone2.join("README.md")).unwrap(),
|
||||
"hello\n",
|
||||
"fresh clone sees pushed content"
|
||||
);
|
||||
assert_eq!(
|
||||
git(&["rev-parse", "main"], &clone2, &owner_nsec).trim(),
|
||||
sha1,
|
||||
"fresh clone main == pushed SHA"
|
||||
);
|
||||
|
||||
// 4. Second commit, push, pull into the other clone.
|
||||
std::fs::write(clone1.join("README.md"), "hello\nmore\n").unwrap();
|
||||
git(
|
||||
&["commit", "--quiet", "-am", "second"],
|
||||
&clone1,
|
||||
&owner_nsec,
|
||||
);
|
||||
git(&["push", "--quiet", "origin", "main"], &clone1, &owner_nsec);
|
||||
let p2 = s3.require_pointer(&owner_hex, &repo).await;
|
||||
assert_ne!(p2, p1, "second push must advance S3 manifest pointer");
|
||||
let sha2 = git(&["rev-parse", "main"], &clone1, &owner_nsec)
|
||||
.trim()
|
||||
.to_string();
|
||||
git(&["pull", "--quiet", "origin", "main"], &clone2, &owner_nsec);
|
||||
assert_eq!(
|
||||
git(&["rev-parse", "main"], &clone2, &owner_nsec).trim(),
|
||||
sha2,
|
||||
"clone2 fetched second commit"
|
||||
);
|
||||
|
||||
// 5. Force-push a rewritten history.
|
||||
git(&["reset", "--quiet", "--hard", &sha1], &clone1, &owner_nsec);
|
||||
std::fs::write(clone1.join("README.md"), "forced\n").unwrap();
|
||||
git(
|
||||
&["commit", "--quiet", "-am", "forced"],
|
||||
&clone1,
|
||||
&owner_nsec,
|
||||
);
|
||||
let sha_f = git(&["rev-parse", "main"], &clone1, &owner_nsec)
|
||||
.trim()
|
||||
.to_string();
|
||||
git(
|
||||
&["push", "--quiet", "--force", "origin", "main"],
|
||||
&clone1,
|
||||
&owner_nsec,
|
||||
);
|
||||
let p3 = s3.require_pointer(&owner_hex, &repo).await;
|
||||
assert_ne!(p3, p2, "force push must advance S3 manifest pointer");
|
||||
assert_ne!(sha_f, sha2);
|
||||
|
||||
// 6. A new clone after the force-push gets the rewritten history.
|
||||
git(
|
||||
&["clone", "--quiet", &url, "clone3"],
|
||||
tmp.path(),
|
||||
&owner_nsec,
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(tmp.path().join("clone3/README.md")).unwrap(),
|
||||
"forced\n",
|
||||
"clone3 has force-pushed content"
|
||||
);
|
||||
|
||||
// 7. Tag push survives the round-trip.
|
||||
git(&["tag", "v1.0"], &clone1, &owner_nsec);
|
||||
git(&["push", "--quiet", "origin", "v1.0"], &clone1, &owner_nsec);
|
||||
let p4 = s3.require_pointer(&owner_hex, &repo).await;
|
||||
assert_ne!(p4, p3, "tag push must advance S3 manifest pointer");
|
||||
git(
|
||||
&["clone", "--quiet", &url, "clone4"],
|
||||
tmp.path(),
|
||||
&owner_nsec,
|
||||
);
|
||||
let tags = git(&["tag"], &tmp.path().join("clone4"), &owner_nsec);
|
||||
assert!(tags.contains("v1.0"), "tag v1.0 cloned back: {tags}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires live relay + MinIO + git"]
|
||||
async fn git_concurrent_push_one_wins_and_repo_recovers() {
|
||||
use nostr::ToBech32;
|
||||
|
||||
let owner = Keys::generate();
|
||||
let owner_hex = owner.public_key().to_hex();
|
||||
let owner_nsec = owner.secret_key().to_bech32().unwrap();
|
||||
let repo = format!("e2e-git-concurrent-{}", std::process::id());
|
||||
let s3 = GitS3Probe::from_env();
|
||||
|
||||
let channel = create_test_channel(&owner).await;
|
||||
let announce = EventBuilder::new(Kind::from(30617), "")
|
||||
.tags(vec![
|
||||
Tag::parse(["d", &repo]).unwrap(),
|
||||
Tag::parse(["name", "e2e concurrent git repo"]).unwrap(),
|
||||
Tag::parse(["buzz-channel", &channel]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&owner)
|
||||
.unwrap();
|
||||
post_event(&announce).await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
|
||||
let tmp = tempdir_named("buzz-e2e-git-concurrent");
|
||||
let url = format!("{}/git/{}/{}", relay_http_url(), owner_hex, repo);
|
||||
|
||||
git(&["clone", "--quiet", &url, "seed"], tmp.path(), &owner_nsec);
|
||||
let seed = tmp.path().join("seed");
|
||||
std::fs::write(seed.join("README.md"), "base\n").unwrap();
|
||||
git(&["add", "."], &seed, &owner_nsec);
|
||||
git(&["commit", "--quiet", "-m", "base"], &seed, &owner_nsec);
|
||||
git(&["branch", "-M", "main"], &seed, &owner_nsec);
|
||||
git(&["push", "--quiet", "origin", "main"], &seed, &owner_nsec);
|
||||
let base_pointer = s3.require_pointer(&owner_hex, &repo).await;
|
||||
let base_sha = git(&["rev-parse", "main"], &seed, &owner_nsec)
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let contenders = 8usize;
|
||||
let mut contenders_info = Vec::new();
|
||||
for i in 0..contenders {
|
||||
let dir = format!("c{i}");
|
||||
git(&["clone", "--quiet", &url, &dir], tmp.path(), &owner_nsec);
|
||||
let worktree = tmp.path().join(&dir);
|
||||
std::fs::write(
|
||||
worktree.join(format!("file-{i}.txt")),
|
||||
format!("winner? {i}\n"),
|
||||
)
|
||||
.unwrap();
|
||||
git(&["add", "."], &worktree, &owner_nsec);
|
||||
git(
|
||||
&["commit", "--quiet", "-m", &format!("contender {i}")],
|
||||
&worktree,
|
||||
&owner_nsec,
|
||||
);
|
||||
let sha = git(&["rev-parse", "main"], &worktree, &owner_nsec)
|
||||
.trim()
|
||||
.to_string();
|
||||
contenders_info.push((i, sha));
|
||||
}
|
||||
|
||||
let mut children = Vec::new();
|
||||
for i in 0..contenders {
|
||||
let worktree = tmp.path().join(format!("c{i}"));
|
||||
let owner_nsec = owner_nsec.clone();
|
||||
children.push(std::thread::spawn(move || {
|
||||
git_status(
|
||||
&["push", "--quiet", "origin", "main"],
|
||||
&worktree,
|
||||
&owner_nsec,
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
let mut successes = Vec::new();
|
||||
let mut failures = 0usize;
|
||||
for (i, child) in children.into_iter().enumerate() {
|
||||
let out = child.join().expect("push thread panicked");
|
||||
if out.status.success() {
|
||||
successes.push(i);
|
||||
} else {
|
||||
failures += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(successes.len(), 1, "exactly one concurrent push should win");
|
||||
assert_eq!(failures, contenders - 1, "the rest should lose cleanly");
|
||||
let winner_index = successes[0];
|
||||
let winner_sha = contenders_info
|
||||
.iter()
|
||||
.find_map(|(i, sha)| (*i == winner_index).then_some(sha.clone()))
|
||||
.expect("winner sha recorded");
|
||||
|
||||
let after_pointer = s3.require_pointer(&owner_hex, &repo).await;
|
||||
assert_ne!(
|
||||
after_pointer, base_pointer,
|
||||
"winning push must advance S3 manifest pointer"
|
||||
);
|
||||
|
||||
git(
|
||||
&["clone", "--quiet", &url, "after"],
|
||||
tmp.path(),
|
||||
&owner_nsec,
|
||||
);
|
||||
let after = tmp.path().join("after");
|
||||
let after_sha = git(&["rev-parse", "main"], &after, &owner_nsec)
|
||||
.trim()
|
||||
.to_string();
|
||||
assert_ne!(after_sha, base_sha, "winner advanced main");
|
||||
assert_eq!(
|
||||
after_sha, winner_sha,
|
||||
"published head must equal the successful contender's tip"
|
||||
);
|
||||
let log = git(
|
||||
&["log", "--oneline", "--decorate", "-1"],
|
||||
&after,
|
||||
&owner_nsec,
|
||||
);
|
||||
assert!(
|
||||
log.contains("contender"),
|
||||
"published head is one contender: {log}"
|
||||
);
|
||||
}
|
||||
|
||||
struct TempDir(PathBuf);
|
||||
impl TempDir {
|
||||
fn path(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
fn tempdir() -> TempDir {
|
||||
tempdir_named("buzz-e2e-git")
|
||||
}
|
||||
|
||||
fn tempdir_named(prefix: &str) -> TempDir {
|
||||
let mut p = std::env::temp_dir();
|
||||
p.push(format!("{prefix}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&p);
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
TempDir(p)
|
||||
}
|
||||
Reference in New Issue
Block a user