rtb-forge 0.7.1

Release-provider abstractions and backends (GitHub, GitLab, Bitbucket, Gitea, Codeberg, Direct) plus an async git-operations Repo built on gix. Part of the phpboyscout Rust toolkit.
Documentation
//! Unit tests for v0.5 commit 5b — unified auth on clone + fetch.
//!
//! Strategy: file:// remotes don't actually authenticate, so we can't
//! prove the helper script *is* invoked here. What we can verify
//! cleanly:
//!
//! - The builder methods compose `CloneOptions` / `FetchOptions`
//!   with a credential.
//! - When the credential is unresolvable (env var missing,
//!   refused-under-CI literal), the op short-circuits with
//!   `RepoError::Auth`.
//! - When the credential resolves to a token, the op against a
//!   file:// remote still succeeds — proving the auth path doesn't
//!   break anonymous-tolerant flows.
//!
//! End-to-end auth against a real authenticating server is an
//! integration concern, deferred to a future integration-tests
//! slice (file:// fixtures don't exercise transport auth).

#![cfg(feature = "git")]
#![allow(missing_docs)]
// Tests need Rust 2024's `unsafe { std::env::set_var }` to seed
// fixtures. Each test uses a disjoint env-var name so cross-test
// collisions don't occur; cleanup is per-test.
#![allow(unsafe_code)]

use std::path::Path;
use std::process::Command;

use rtb_credentials::CredentialRef;
use rtb_forge::git::{CloneOptions, FetchOptions, Repo, RepoError};

// ---------------------------------------------------------------------
// A1 — builder methods compose options with a credential
// ---------------------------------------------------------------------

#[test]
fn a1_clone_options_with_credential_builder() {
    let cref = CredentialRef { env: Some("RTB_TEST_TOKEN".to_string()), ..Default::default() };
    let opts = CloneOptions::default().with_credential(cref);
    assert!(opts.credential.is_some(), "credential should be Some after builder");
}

#[test]
fn a1_fetch_options_with_credential_builder() {
    let cref = CredentialRef { env: Some("RTB_TEST_TOKEN".to_string()), ..Default::default() };
    let opts = FetchOptions::default().with_credential(cref);
    assert!(opts.credential.is_some(), "credential should be Some after builder");
}

// ---------------------------------------------------------------------
// A2 — clone with unresolvable credential → RepoError::Auth
// ---------------------------------------------------------------------

#[tokio::test]
async fn a2_clone_with_unresolvable_credential_errors() {
    let upstream = build_three_commit_repo();
    let dst_holder = tempfile::tempdir().expect("dst tempdir");
    let dst = dst_holder.path().join("cloned");
    let url = format!("file://{}", upstream.path().display());

    // env var deliberately not set; fallback_env also unset.
    let cref = CredentialRef { env: Some("RTB_UNSET_TOKEN_A2".to_string()), ..Default::default() };
    // Make sure host env is clean.
    // SAFETY: removing an env var that this test owns is safe under
    // standard test isolation.
    unsafe {
        std::env::remove_var("RTB_UNSET_TOKEN_A2");
    }

    let opts = CloneOptions::default().with_credential(cref);
    let err = Repo::clone(&url, &dst, opts).await.expect_err("must fail");
    assert!(matches!(err, RepoError::Auth(_)), "got {err:?}");
}

// ---------------------------------------------------------------------
// A3 — fetch with unresolvable credential → RepoError::Auth
// ---------------------------------------------------------------------

#[tokio::test]
async fn a3_fetch_with_unresolvable_credential_errors() {
    let upstream = build_three_commit_repo();
    let dst_holder = tempfile::tempdir().expect("dst tempdir");
    let dst = dst_holder.path().join("cloned");
    let url = format!("file://{}", upstream.path().display());
    let cloned = Repo::clone(&url, &dst, CloneOptions::default()).await.expect("clone");

    let cref = CredentialRef { env: Some("RTB_UNSET_TOKEN_A3".to_string()), ..Default::default() };
    unsafe {
        std::env::remove_var("RTB_UNSET_TOKEN_A3");
    }

    let opts = FetchOptions::default().with_credential(cref);
    let err = cloned.fetch("origin", opts).await.expect_err("must fail");
    assert!(matches!(err, RepoError::Auth(_)), "got {err:?}");
}

// ---------------------------------------------------------------------
// A4 — clone against file:// with a resolvable credential succeeds
// ---------------------------------------------------------------------

#[tokio::test]
async fn a4_clone_with_resolved_credential_against_file_succeeds() {
    let upstream = build_three_commit_repo();
    let dst_holder = tempfile::tempdir().expect("dst tempdir");
    let dst = dst_holder.path().join("cloned");
    let url = format!("file://{}", upstream.path().display());

    // env var set so resolve produces a SecretString.
    unsafe {
        std::env::set_var("RTB_TEST_TOKEN_A4", "dummy-token-value");
    }
    let cref = CredentialRef { env: Some("RTB_TEST_TOKEN_A4".to_string()), ..Default::default() };

    let opts = CloneOptions::default().with_credential(cref);
    let _repo = Repo::clone(&url, &dst, opts).await.expect("clone with auth env set");
    assert!(dst.join(".git").is_dir(), "clone produced a git repository");

    unsafe {
        std::env::remove_var("RTB_TEST_TOKEN_A4");
    }
}

// ---------------------------------------------------------------------
// A5 — fetch against file:// with a resolvable credential succeeds
// ---------------------------------------------------------------------

#[tokio::test]
async fn a5_fetch_with_resolved_credential_against_file_succeeds() {
    let upstream = build_three_commit_repo();
    let dst_holder = tempfile::tempdir().expect("dst tempdir");
    let dst = dst_holder.path().join("cloned");
    let url = format!("file://{}", upstream.path().display());
    let cloned = Repo::clone(&url, &dst, CloneOptions::default()).await.expect("clone");

    unsafe {
        std::env::set_var("RTB_TEST_TOKEN_A5", "dummy-token-value");
    }
    let cref = CredentialRef { env: Some("RTB_TEST_TOKEN_A5".to_string()), ..Default::default() };
    let opts = FetchOptions::default().with_credential(cref);
    cloned.fetch("origin", opts).await.expect("fetch with auth env set");

    unsafe {
        std::env::remove_var("RTB_TEST_TOKEN_A5");
    }
}

// =====================================================================
// Fixture helpers
// =====================================================================

fn build_three_commit_repo() -> tempfile::TempDir {
    let dir = tempfile::tempdir().expect("tempdir");
    let path = dir.path();
    git_init(path);
    write(path, "README.md", "v1\n");
    git_add_all(path);
    git_commit(path, "alice", "initial");
    write(path, "README.md", "v2\n");
    git_add_all(path);
    git_commit(path, "alice", "second");
    write(path, "README.md", "v3\n");
    git_add_all(path);
    git_commit(path, "alice", "third");
    dir
}

fn git_init(path: &Path) {
    Command::new("git").args(["init", "-b", "main"]).current_dir(path).output().expect("git init");
}

fn git_add_all(path: &Path) {
    Command::new("git").args(["add", "-A"]).current_dir(path).output().expect("git add");
}

fn git_commit(path: &Path, author: &str, message: &str) {
    Command::new("git")
        .args(["commit", "--allow-empty", "-m", message])
        .env("GIT_AUTHOR_NAME", author)
        .env("GIT_AUTHOR_EMAIL", format!("{author}@example.test"))
        .env("GIT_COMMITTER_NAME", author)
        .env("GIT_COMMITTER_EMAIL", format!("{author}@example.test"))
        .env("GIT_AUTHOR_DATE", "2026-05-19T00:00:00Z")
        .env("GIT_COMMITTER_DATE", "2026-05-19T00:00:00Z")
        .current_dir(path)
        .output()
        .expect("git commit");
}

fn write(path: &Path, file: &str, contents: &str) {
    std::fs::write(path.join(file), contents).expect("write");
}