Radish alpha
r
rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5
Radicle web interface
Radicle
Git
radicle-explorer radicle-httpd src test.rs
use std::collections::BTreeSet;
use std::fs;
use std::path::Path;
use std::str::FromStr;
use std::sync::Arc;

use axum::body::{Body, Bytes};
use axum::http::{Method, Request};
use axum::Router;
use serde_json::Value;
use tower::ServiceExt;

use radicle::cob::migrate;
use radicle::cob::patch::MergeTarget;
use radicle::cob::Title;
use radicle::crypto::signature::Signer;
use radicle::crypto::ssh::Keystore;
use radicle::crypto::{KeyPair, Seed, Signature};
use radicle::git::fmt::RefString;
use radicle::identity::{project, Visibility};
use radicle::node::device::Device;
use radicle::node::{Features, Timestamp, UserAgent};
use radicle::profile::{env, Home};
use radicle::storage::{
    ReadRepository, ReadStorage, SignRepository, WriteRepository, WriteStorage,
};
use radicle::{node, profile};
use radicle::{Node, Storage};

use crate::api::Context;

pub const RID: &str = "rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp";
pub const RID_PRIVATE: &str = "rad:zLuTzcmoWMcdK37xqArS8eckp9vK";
pub const HEAD: &str = "e8c676b9e3b42308dc9d218b70faa5408f8e58ca";
pub const PARENT: &str = "ee8d6a29304623a78ebfa5eeed5af674d0e58f83";
pub const INITIAL_COMMIT: &str = "f604ce9fd5b7cc77b7609beda45ea8760bee78f7";
pub const DID: &str = "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi";
pub const ISSUE_ID: &str = "ca67d195c0b308b51810dedd93157a20764d5db5";
pub const PATCH_ID: &str = "b4084412ea3644d7dd7ae075c1cbbd4d702db0ec";
pub const TIMESTAMP: u64 = 1671125284;
pub const CONTRIBUTOR_ALIAS: &str = "seed";

/// Create a new profile.
pub fn profile(home: &Path, seed: [u8; 32]) -> radicle::Profile {
    let home = Home::new(home).unwrap();
    let keystore = Keystore::new(&home.keys());
    let keypair = KeyPair::from_seed(Seed::from(seed));
    let alias = node::Alias::new("seed");
    let storage = Storage::open(
        home.storage(),
        radicle::git::UserInfo {
            alias: alias.clone(),
            key: keypair.pk.into(),
        },
    )
    .unwrap();

    let mut db = home.policies_mut().unwrap();
    db.follow(&keypair.pk.into(), Some(&alias)).unwrap();

    let node_db = home.database_mut().unwrap();
    node_db
        .init(
            &keypair.pk.into(),
            Features::SEED,
            &alias,
            &UserAgent::default(),
            Timestamp::try_from(TIMESTAMP).unwrap(),
            [],
        )
        .unwrap();

    // Migrate COBs cache.
    let mut cobs = home.cobs_db_mut().unwrap();
    cobs.migrate(migrate::ignore).unwrap();

    radicle::storage::git::transport::local::register(storage.clone());
    keystore.store(keypair.clone(), "radicle", None).unwrap();

    radicle::Profile {
        home,
        storage,
        keystore,
        public_key: keypair.pk.into(),
        config: profile::Config::new(alias),
    }
}

pub fn seed(dir: &Path) -> Context {
    let home = dir.join("radicle");
    let profile = profile(home.as_path(), [0xff; 32]);
    let signer = Device::mock_from_seed([0xff; 32]);

    crate::logger::init().ok();

    seed_with_signer(dir, profile, &signer)
}

fn seed_with_signer<G: Signer<Signature>>(
    dir: &Path,
    profile: radicle::Profile,
    signer: &Device<G>,
) -> Context {
    const DEFAULT_BRANCH: &str = "master";

    crate::logger::init().ok();

    profile.policies_mut().unwrap();
    profile.database_mut().unwrap(); // Create the database.

    let mut policies = profile.policies_mut().unwrap();
    let workdir = dir.join("hello-world-private");
    fs::create_dir_all(&workdir).unwrap();

    // add commits to workdir (repo)
    let mut opts = radicle::git::raw::RepositoryInitOptions::new();
    opts.initial_head(DEFAULT_BRANCH);
    let repo = radicle::git::raw::Repository::init_opts(&workdir, &opts).unwrap();
    let tree = radicle::git::write_tree(
        Path::new("README"),
        "Hello Private World!\n".as_bytes(),
        &repo,
    )
    .unwrap();

    let sig_time = radicle::git::raw::Time::new(1673001014, 0);
    let sig =
        radicle::git::raw::Signature::new("Alice Liddell", "alice@radicle.xyz", &sig_time).unwrap();

    repo.commit(Some("HEAD"), &sig, &sig, "Initial commit\n", &tree, &[])
        .unwrap();

    // rad init
    let repo = radicle::git::raw::Repository::open(&workdir).unwrap();
    let name = project::ProjectName::from_str("hello-world-private").unwrap();
    let description = "Private Rad repository for tests".to_string();
    let branch = RefString::try_from(DEFAULT_BRANCH).unwrap();
    let visibility = Visibility::Private {
        allow: BTreeSet::default(),
    };
    let (rid, _, _) = radicle::rad::init(
        &repo,
        name,
        &description,
        branch,
        visibility,
        signer,
        &profile.storage,
    )
    .unwrap();

    policies
        .set_seed_policy(&rid, node::policy::Policy::Block)
        .unwrap();

    let workdir = dir.join("hello-world");

    env::set_var(env::GIT_COMMITTER_DATE, TIMESTAMP.to_string());

    fs::create_dir_all(&workdir).unwrap();

    // add commits to workdir (repo)
    let mut opts = radicle::git::raw::RepositoryInitOptions::new();
    opts.initial_head(DEFAULT_BRANCH);
    let repo = radicle::git::raw::Repository::init_opts(&workdir, &opts).unwrap();
    let tree =
        radicle::git::write_tree(Path::new("README"), "Hello World!\n".as_bytes(), &repo).unwrap();

    let sig_time = radicle::git::raw::Time::new(1673001014, 0);
    let sig =
        radicle::git::raw::Signature::new("Alice Liddell", "alice@radicle.xyz", &sig_time).unwrap();

    let oid = repo
        .commit(Some("HEAD"), &sig, &sig, "Initial commit\n", &tree, &[])
        .unwrap();
    let commit = repo.find_commit(oid).unwrap();

    repo.checkout_tree(tree.as_object(), None).unwrap();

    let tree = radicle::git::write_tree(
        Path::new("CONTRIBUTING"),
        "Thank you very much!\n".as_bytes(),
        &repo,
    )
    .unwrap();
    let sig_time = radicle::git::raw::Time::new(1673002014, 0);
    let sig =
        radicle::git::raw::Signature::new("Alice Liddell", "alice@radicle.xyz", &sig_time).unwrap();

    let oid2 = repo
        .commit(
            Some("HEAD"),
            &sig,
            &sig,
            "Add contributing file\n",
            &tree,
            &[&commit],
        )
        .unwrap();
    let commit2 = repo.find_commit(oid2).unwrap();

    repo.checkout_tree(tree.as_object(), None).unwrap();

    fs::create_dir(workdir.join("dir1")).unwrap();
    fs::write(
        workdir.join("dir1").join("README"),
        "Hello World from dir1!\n",
    )
    .unwrap();
    let mut index = repo.index().unwrap();
    index.add_path(Path::new("dir1/README")).unwrap();
    index.write().unwrap();

    let oid = index.write_tree().unwrap();
    let tree = repo.find_tree(oid).unwrap();

    let sig_time = radicle::git::raw::Time::new(1673003014, 0);
    let sig =
        radicle::git::raw::Signature::new("Alice Liddell", "alice@radicle.xyz", &sig_time).unwrap();
    repo.commit(
        Some("HEAD"),
        &sig,
        &sig,
        "Add another folder\n",
        &tree,
        &[&commit2],
    )
    .unwrap();

    // rad init
    let repo = radicle::git::raw::Repository::open(&workdir).unwrap();
    let name = project::ProjectName::from_str("hello-world").unwrap();
    let description = "Rad repository for tests".to_string();
    let branch = RefString::try_from(DEFAULT_BRANCH).unwrap();
    let visibility = Visibility::default();
    let (rid, _, _) = radicle::rad::init(
        &repo,
        name,
        &description,
        branch,
        visibility,
        signer,
        &profile.storage,
    )
    .unwrap();
    policies.seed(&rid, node::policy::Scope::All).unwrap();
    let node_handle = &mut Node::new(profile.socket());
    profile
        .seed(rid, node::policy::Scope::All, node_handle)
        .unwrap();
    profile.add_inventory(rid, node_handle).unwrap();

    let storage = &profile.storage;
    let repo = storage.repository(rid).unwrap();
    let mut issues = profile.issues_mut(&repo).unwrap();
    let issue = issues
        .create(
            Title::new("Issue #1").unwrap(),
            "Change 'hello world' to 'hello everyone'".to_string(),
            &[],
            &[],
            [],
            signer,
        )
        .unwrap();
    tracing::debug!(target: "test", "Contributor issue: {}", issue.id());

    // eq. rad patch open
    let mut patches = profile.patches_mut(&repo).unwrap();
    let oid = radicle::git::Oid::from_str(HEAD).unwrap();
    let base = radicle::git::Oid::from_str(PARENT).unwrap();
    let patch = patches
        .create(
            Title::new("A new `hello world`").unwrap(),
            "change `hello world` in README to something else",
            MergeTarget::Delegates,
            base,
            oid,
            &[],
            signer,
        )
        .unwrap();
    tracing::debug!(target: "test", "Contributor patch: {}", patch.id());

    let workdir = dir.join("again-hello-world");

    env::set_var(env::GIT_COMMITTER_DATE, TIMESTAMP.to_string());

    fs::create_dir_all(&workdir).unwrap();

    // add commits to workdir (repo)
    let mut opts = radicle::git::raw::RepositoryInitOptions::new();
    opts.initial_head(DEFAULT_BRANCH);
    let repo = radicle::git::raw::Repository::init_opts(&workdir, &opts).unwrap();
    let tree = radicle::git::write_tree(
        Path::new("README"),
        "Hello World Again!\n".as_bytes(),
        &repo,
    )
    .unwrap();

    let sig_time = radicle::git::raw::Time::new(1673001014, 0);
    let sig =
        radicle::git::raw::Signature::new("Alice Liddell", "alice@radicle.xyz", &sig_time).unwrap();

    repo.commit(Some("HEAD"), &sig, &sig, "Initial commit\n", &tree, &[])
        .unwrap();

    repo.checkout_tree(tree.as_object(), None).unwrap();

    // rad init
    let repo = radicle::git::raw::Repository::open(&workdir).unwrap();
    let name = project::ProjectName::from_str("again-hello-world").unwrap();
    let description = "Rad repository for sorting".to_string();
    let branch = RefString::try_from(DEFAULT_BRANCH).unwrap();
    let visibility = Visibility::default();
    let (rid, _, _) = radicle::rad::init(
        &repo,
        name,
        &description,
        branch,
        visibility,
        signer,
        &profile.storage,
    )
    .unwrap();
    policies.seed(&rid, node::policy::Scope::Followed).unwrap();
    profile
        .seed(rid, node::policy::Scope::Followed, node_handle)
        .unwrap();
    profile.add_inventory(rid, node_handle).unwrap();

    let options = crate::Options {
        aliases: std::collections::HashMap::new(),
        listen: axum_listener::DualAddr::Tcp(std::net::SocketAddr::from(([0, 0, 0, 0], 8080))),
        cache: Some(crate::DEFAULT_CACHE_SIZE),
    };

    let web_config = crate::api::WebConfig::from_profile(&profile);
    Context::new(Arc::new(profile), web_config, &options)
}

/// Create a test context with multiple peers and canonical refs configured.
///
/// This sets up:
/// - The hello-world repo from `seed()` as the base
/// - A second peer that forks the repo (same `master` head)
/// - An additional branch (`feature/branch`) on the second peer only
/// - A tag (`v1.0`) on both peers (reaches canonical consensus)
/// - A tag (`v2.0-rc`) on the second peer only (does not reach consensus)
/// - The identity document is updated with:
///   - The second peer added as a delegate
///   - A `xyz.radicle.crefs` payload with rules for `refs/heads/*` and `refs/tags/*`
pub fn seed_multi_peer(dir: &Path) -> Context {
    use radicle::identity::doc::PayloadId;
    use radicle::identity::Identity;

    let ctx = seed(dir);

    let signer1 = Device::mock_from_seed([0xff; 32]);
    let signer2 = Device::mock_from_seed([0xee; 32]);

    let rid = radicle::identity::RepoId::from_str(RID).unwrap();
    let storage = &ctx.profile().storage;

    {
        let mut policies = ctx.profile().policies_mut().unwrap();
        policies
            .follow(signer2.public_key(), Some(&node::Alias::new("peer2")))
            .unwrap();
    }

    {
        let repo = storage.repository_mut(rid).unwrap();
        let mut identity = Identity::load_mut(&repo).unwrap();
        let current_doc = repo.identity_doc().unwrap();

        let new_doc = current_doc
            .doc
            .clone()
            .with_edits(|raw| {
                let did2: radicle::identity::Did = (*signer2.public_key()).into();
                if !raw.delegates.contains(&did2) {
                    raw.delegates.push(did2);
                }
                raw.threshold = 1;
                let crefs_payload: serde_json::Value = serde_json::json!({
                    "rules": {
                        "refs/heads/*": {
                            "allow": "delegates",
                            "threshold": 1
                        },
                        "refs/tags/*": {
                            "allow": "delegates",
                            "threshold": 2
                        }
                    }
                });
                raw.payload.insert(
                    PayloadId::canonical_refs(),
                    radicle::identity::doc::Payload::from(crefs_payload),
                );
            })
            .unwrap();

        identity
            .update(
                Title::new("Add second delegate and crefs").unwrap(),
                "",
                &new_doc,
                &signer1,
            )
            .unwrap();

        let new_head = repo.identity_head_of(signer1.public_key()).unwrap();
        repo.set_identity_head_to(new_head).unwrap();

        repo.sign_refs(&signer1).unwrap();
    }

    radicle::rad::fork(rid, &signer2, storage).unwrap();

    {
        let repo = storage.repository_mut(rid).unwrap();
        let raw = repo.raw();

        let head_oid = radicle::git::raw::Oid::from_str(HEAD).unwrap();
        let parent_oid = radicle::git::raw::Oid::from_str(PARENT).unwrap();

        let pk1 = signer1.public_key();
        let pk2 = signer2.public_key();

        raw.reference(
            &format!("refs/namespaces/{pk1}/refs/tags/v1.0"),
            head_oid,
            true,
            "test: add tag v1.0 for peer1",
        )
        .unwrap();
        raw.reference(
            &format!("refs/namespaces/{pk2}/refs/tags/v1.0"),
            head_oid,
            true,
            "test: add tag v1.0 for peer2",
        )
        .unwrap();

        raw.reference(
            &format!("refs/namespaces/{pk2}/refs/tags/v2.0-rc"),
            parent_oid,
            true,
            "test: add tag v2.0-rc for peer2 only",
        )
        .unwrap();

        raw.reference(
            &format!("refs/namespaces/{pk2}/refs/heads/feature/branch"),
            parent_oid,
            true,
            "test: add feature/branch for peer2",
        )
        .unwrap();

        repo.sign_refs(&signer1).unwrap();
        repo.sign_refs(&signer2).unwrap();

        // Materialize canonical refs at the top level, simulating what the
        // node does via `set_canonical_refs` after a fetch.
        for (refname, oid) in [
            ("refs/heads/master", head_oid),
            ("refs/tags/v1.0", head_oid),
            ("refs/heads/feature/branch", parent_oid),
        ] {
            raw.reference(refname, oid, true, "test: set canonical ref")
                .unwrap();
        }
    }

    ctx
}

pub async fn get(app: &Router, path: impl ToString) -> Response {
    Response(
        app.clone()
            .oneshot(request(path, Method::GET, None))
            .await
            .unwrap(),
    )
}

fn request(path: impl ToString, method: Method, body: Option<Body>) -> Request<Body> {
    let request = Request::builder()
        .method(method)
        .uri(path.to_string())
        .header("Content-Type", "application/json");

    request.body(body.unwrap_or_else(Body::empty)).unwrap()
}

#[derive(Debug)]
pub struct Response(axum::response::Response);

impl Response {
    pub async fn json(self) -> Value {
        let body = self.body().await;
        serde_json::from_slice(&body).unwrap()
    }

    pub fn status(&self) -> axum::http::StatusCode {
        self.0.status()
    }

    pub async fn body(self) -> Bytes {
        axum::body::to_bytes(self.0.into_body(), usize::MAX)
            .await
            .unwrap()
    }
}