//! A new repository's starting files drafted by an assistant (#347): asked //! for over the API, drafted in the background by the owner's assistant, //! committed to the empty repository as the person who asked (the hub as //! their delegate), never over files that are there, and refused plainly //! when no assistant is set up or the person has not delegated to the hub. use axum::body::Body; use axum::http::{header, Request, StatusCode}; use http_body_util::BodyExt; use oxy_core::repo::{IdentityScope, Repo}; use oxygenhub::config::Config; use oxygenhub::db; use oxygenhub::Shared; use std::path::Path; use tower::ServiceExt; fn hermetic() { static ONCE: std::sync::Once = std::sync::Once::new(); static DIR: std::sync::OnceLock = std::sync::OnceLock::new(); ONCE.call_once(|| { let d = tempfile::tempdir().unwrap(); std::env::set_var("OXY_HOME", d.path()); DIR.set(d).ok(); }); } struct Hub { app: Shared, _dir: tempfile::TempDir, } async fn hub() -> Hub { hermetic(); let dir = tempfile::tempdir().unwrap(); let mut config = Config::load(dir.path()).unwrap(); config.registration.setup_code = "test-setup".into(); config.registration.mode = "open".into(); // The stand-in model is on this machine. config.outbound.allow = vec!["http://127.0.0.1".into()]; let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); config.server.base_url = format!("http://127.0.0.1:{port}"); let app = oxygenhub::build(config).await.unwrap(); let router = oxygenhub::router(app.clone()); tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); Hub { app, _dir: dir } } struct Res { status: StatusCode, body: String, cookie: Option, } async fn send(app: &Shared, method: &str, path: &str, cookie: Option<&str>, token: Option<&str>, body: Option<(&str, String)>) -> Res { let mut req = Request::builder().method(method).uri(path); if let Some(c) = cookie { req = req.header(header::COOKIE, format!("oxyhub={c}")); } if let Some(t) = token { req = req.header(header::AUTHORIZATION, format!("Bearer {t}")); } let body = match body { Some((ct, b)) => { req = req.header(header::CONTENT_TYPE, ct); Body::from(b) } None => Body::empty(), }; let res = oxygenhub::router(app.clone()).oneshot(req.body(body).unwrap()).await.unwrap(); let status = res.status(); let cookie = res .headers() .get(header::SET_COOKIE) .and_then(|v| v.to_str().ok()) .and_then(|v| v.split(';').next()) .and_then(|kv| kv.split_once('=')) .map(|(_, v)| v.to_string()); let bytes = res.into_body().collect().await.unwrap().to_bytes(); Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie } } async fn post(app: &Shared, path: &str, cookie: Option<&str>, form: &str) -> Res { send(app, "POST", path, cookie, None, Some(("application/x-www-form-urlencoded", form.to_string()))).await } async fn api(app: &Shared, method: &str, path: &str, token: Option<&str>, json: Option) -> Res { send(app, method, &format!("/api/v1{path}"), None, token, json.map(|j| ("application/json", j.to_string()))).await } async fn register(app: &Shared, user: &str) -> String { let r = post( app, "/register", None, &format!("username={user}&email={user}%40example.com&password=correct-horse-battery&setup=test-setup"), ) .await; assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); r.cookie.unwrap() } /// `user`'s identity, delegating to the hub's key and linked to the /// account, as web edits need; the client checkout that holds it. async fn delegate(app: &Shared, user: &str, work: &Path) -> Repo { let id = db::accounts::account_by_name(&app.db, user).await.unwrap().unwrap().id; let mut client = Repo::init(work).unwrap(); client.create_identity(user, &format!("{user}@example.com"), IdentityScope::Repo).unwrap(); client.delegate_add(app.hub_key.identity.clone(), oxy_core::identity::Scope::CHANGES | oxy_core::identity::Scope::REFS, None).unwrap(); let key = client.config.author_key.unwrap(); let ident = client.identity(&key).unwrap(); db::accounts::link_identity(&app.db, id, &key.to_hex(), &ident.name, &ident.email, &ident.encode()).await.unwrap(); client } /// A stand-in model: files for any request, but no files when asked to make-no-files. async fn model() -> String { let router = axum::Router::new().route( "/v1/chat/completions", axum::routing::post(|axum::Json(req): axum::Json| async move { let asked = req.to_string(); let content = if asked.contains("make-no-files") { "Sure! I can help with that.".to_string() } else { "<<>>\n# Pong\n\nA small pong game.\n<<>>\n<<>>\nfn main() {}\n<<>>\n" .to_string() }; axum::Json(serde_json::json!({ "choices": [{ "message": { "content": content } }] })) }), ); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); format!("http://127.0.0.1:{port}/v1/chat/completions") } async fn admin_token(app: &Shared, user: &str) -> String { let me = db::accounts::account_by_name(&app.db, user).await.unwrap().unwrap(); let (secret, hash) = oxygenhub::auth::new_token(); db::accounts::create_token(&app.db, me.id, "phone", &hash, "repo:read,repo:write,repo:admin", "", None).await.unwrap(); secret } async fn wait_done(app: &Shared, repo: &str, token: &str) -> serde_json::Value { for _ in 0..100 { let r = api(app, "GET", &format!("/repos/{repo}/starter"), Some(token), None).await; let v: serde_json::Value = serde_json::from_str(&r.body).unwrap(); if v["status"] != "running" { return v; } tokio::time::sleep(std::time::Duration::from_millis(100)).await; } panic!("the starter for {repo} is still running"); } #[tokio::test(flavor = "multi_thread")] async fn an_assistant_drafts_an_empty_repositorys_starting_files() { let h = hub().await; let app = &h.app; register(app, "andy").await; let work = tempfile::tempdir().unwrap(); delegate(app, "andy", work.path()).await; let t = admin_token(app, "andy").await; let r = api(app, "POST", "/repos", Some(&t), Some(serde_json::json!({ "owner": "andy", "name": "pong" }))).await; assert_eq!(r.status, StatusCode::CREATED, "{}", r.body); // No assistant yet: none offered, and asking says where to set one up. let r = api(app, "GET", "/user/starter?owner=andy", Some(&t), None).await; assert_eq!(r.body, r#"{"assistants":[]}"#); let ask = serde_json::json!({ "description": "A pong game in Rust" }); let r = api(app, "POST", "/repos/andy/pong/starter", Some(&t), Some(ask.clone())).await; assert_eq!(r.status, StatusCode::UNPROCESSABLE_ENTITY, "{}", r.body); assert!(r.body.contains("no assistant is set up"), "{}", r.body); // The site's assistant. let ep = model().await; let body = serde_json::json!({ "name": "Helper", "endpoint": ep, "api": "chat-completions", "model": "m1" }); let r = api(app, "POST", "/admin/assistants", Some(&t), Some(body)).await; assert_eq!(r.status, StatusCode::OK, "{}", r.body); let r = api(app, "GET", "/user/starter?owner=andy", Some(&t), None).await; let v: serde_json::Value = serde_json::from_str(&r.body).unwrap(); assert_eq!((v["assistants"][0]["name"].as_str(), v["assistants"][0]["from"].as_str()), (Some("Helper"), Some("the site")), "{v}"); // Asked: it works in the background, then the files are the repository's first change. let r = api(app, "POST", "/repos/andy/pong/starter", Some(&t), Some(ask.clone())).await; assert_eq!(r.status, StatusCode::ACCEPTED, "{}", r.body); let v = wait_done(app, "andy/pong", &t).await; assert_eq!(v["status"], "done", "{v}"); assert_eq!(v["files"], serde_json::json!(["README.md", "src/main.rs"])); assert_eq!(v["assistant"], "Helper"); let r = api(app, "GET", "/repos/andy/pong/file/main/README.md", Some(&t), None).await; assert!(r.body.contains("A small pong game."), "{}", r.body); let r = api(app, "GET", "/repos/andy/pong/history/main", Some(&t), None).await; assert!(r.body.contains("Starter files drafted by Helper (m1)") && r.body.contains("A pong game in Rust"), "{}", r.body); // Never over files that are there, and the request must say something. let r = api(app, "POST", "/repos/andy/pong/starter", Some(&t), Some(ask)).await; assert_eq!(r.status, StatusCode::CONFLICT, "{}", r.body); api(app, "POST", "/repos", Some(&t), Some(serde_json::json!({ "owner": "andy", "name": "empty" }))).await; let r = api(app, "POST", "/repos/andy/empty/starter", Some(&t), Some(serde_json::json!({ "description": " " }))).await; assert_eq!(r.status, StatusCode::UNPROCESSABLE_ENTITY, "{}", r.body); // An answer with no files fails, saying so, and the repository stays empty. let r = api(app, "POST", "/repos/andy/empty/starter", Some(&t), Some(serde_json::json!({ "description": "make-no-files please" }))).await; assert_eq!(r.status, StatusCode::ACCEPTED, "{}", r.body); let v = wait_done(app, "andy/empty", &t).await; assert_eq!(v["status"], "failed", "{v}"); assert!(v["error"].as_str().unwrap().contains("without any files"), "{v}"); assert_eq!(api(app, "GET", "/repos/andy/empty/starter", None, None).await.status, StatusCode::OK, "readable as the repository is"); // Someone who has not delegated to the hub is told how. register(app, "bob").await; let b = admin_token(app, "bob").await; assert_eq!( api(app, "GET", "/user/starter?owner=andy", Some(&b), None).await.body, r#"{"assistants":[]}"#, "not an owner bob creates under" ); api(app, "POST", "/repos", Some(&b), Some(serde_json::json!({ "owner": "bob", "name": "game" }))).await; let r = api(app, "POST", "/repos/bob/game/starter", Some(&b), Some(serde_json::json!({ "description": "A game" }))).await; assert_eq!(r.status, StatusCode::UNPROCESSABLE_ENTITY, "{}", r.body); assert!(r.body.contains("delegated"), "{}", r.body); }