| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
use axum::body::Body; |
| 5 |
use axum::http::{header, Request, StatusCode}; |
| 6 |
use http_body_util::BodyExt; |
| 7 |
use oxy_core::repo::Repo; |
| 8 |
use oxygenhub::config::Config; |
| 9 |
use oxygenhub::db; |
| 10 |
use oxygenhub::Shared; |
| 11 |
use std::path::Path; |
| 12 |
use tower::ServiceExt; |
| 13 |
|
| 14 |
fn hermetic() { |
| 15 |
static ONCE: std::sync::Once = std::sync::Once::new(); |
| 16 |
static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new(); |
| 17 |
ONCE.call_once(|| { |
| 18 |
let d = tempfile::tempdir().unwrap(); |
| 19 |
std::env::set_var("OXY_HOME", d.path()); |
| 20 |
DIR.set(d).ok(); |
| 21 |
}); |
| 22 |
} |
| 23 |
|
| 24 |
fn write(root: &Path, rel: &str, content: &[u8]) { |
| 25 |
let p = root.join(rel); |
| 26 |
std::fs::create_dir_all(p.parent().unwrap()).unwrap(); |
| 27 |
std::fs::write(p, content).unwrap(); |
| 28 |
} |
| 29 |
|
| 30 |
struct Hub { |
| 31 |
app: Shared, |
| 32 |
base: String, |
| 33 |
_dir: tempfile::TempDir, |
| 34 |
} |
| 35 |
|
| 36 |
async fn hub() -> Hub { |
| 37 |
hermetic(); |
| 38 |
let dir = tempfile::tempdir().unwrap(); |
| 39 |
let mut config = Config::load(dir.path()).unwrap(); |
| 40 |
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
| 41 |
let port = listener.local_addr().unwrap().port(); |
| 42 |
config.server.base_url = format!("http://127.0.0.1:{port}"); |
| 43 |
config.registration.mode = "invite".into(); |
| 44 |
let app = oxygenhub::build(config).await.unwrap(); |
| 45 |
let router = oxygenhub::router(app.clone()); |
| 46 |
tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); |
| 47 |
Hub { app, base: format!("http://127.0.0.1:{port}"), _dir: dir } |
| 48 |
} |
| 49 |
|
| 50 |
struct Res { |
| 51 |
status: StatusCode, |
| 52 |
body: String, |
| 53 |
|
| 54 |
cookie: Option<String>, |
| 55 |
location: Option<String>, |
| 56 |
|
| 57 |
raw_cookies: Vec<String>, |
| 58 |
} |
| 59 |
|
| 60 |
async fn send(app: &Shared, method: &str, path: &str, cookie: Option<&str>, form: Option<&str>) -> Res { |
| 61 |
let header_value = cookie.map(|c| format!("oxyhub={c}")); |
| 62 |
send_with(app, method, path, header_value.as_deref(), form).await |
| 63 |
} |
| 64 |
|
| 65 |
|
| 66 |
async fn send_with(app: &Shared, method: &str, path: &str, cookie_header: Option<&str>, form: Option<&str>) -> Res { |
| 67 |
let mut req = Request::builder().method(method).uri(path); |
| 68 |
if let Some(c) = cookie_header { |
| 69 |
req = req.header(header::COOKIE, c.to_string()); |
| 70 |
} |
| 71 |
let body = match form { |
| 72 |
Some(f) => { |
| 73 |
req = req.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"); |
| 74 |
Body::from(f.to_string()) |
| 75 |
} |
| 76 |
None => Body::empty(), |
| 77 |
}; |
| 78 |
let res = oxygenhub::router(app.clone()).oneshot(req.body(body).unwrap()).await.unwrap(); |
| 79 |
let status = res.status(); |
| 80 |
let raw_cookies: Vec<String> = |
| 81 |
res.headers().get_all(header::SET_COOKIE).iter().filter_map(|v| v.to_str().ok()).map(String::from).collect(); |
| 82 |
let cookie = |
| 83 |
raw_cookies.iter().filter_map(|c| c.split(';').next()).filter_map(|kv| kv.strip_prefix("oxyhub=")).map(String::from).next(); |
| 84 |
let location = res.headers().get(header::LOCATION).and_then(|v| v.to_str().ok()).map(String::from); |
| 85 |
let bytes = res.into_body().collect().await.unwrap().to_bytes(); |
| 86 |
Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie, location, raw_cookies } |
| 87 |
} |
| 88 |
|
| 89 |
async fn get(app: &Shared, path: &str, cookie: Option<&str>) -> Res { |
| 90 |
send(app, "GET", path, cookie, None).await |
| 91 |
} |
| 92 |
|
| 93 |
async fn post(app: &Shared, path: &str, cookie: Option<&str>, form: &str) -> Res { |
| 94 |
send(app, "POST", path, cookie, Some(form)).await |
| 95 |
} |
| 96 |
|
| 97 |
|
| 98 |
async fn register(app: &Shared, user: &str, invite: &str) -> String { |
| 99 |
let r = |
| 100 |
post(app, "/register", None, &format!("username={user}&email={user}%40example.com&password=correct-horse-battery&invite={invite}")) |
| 101 |
.await; |
| 102 |
assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
| 103 |
r.cookie.expect("session cookie") |
| 104 |
} |
| 105 |
|
| 106 |
fn csrf_of(page: &str) -> String { |
| 107 |
let i = page.find("name=\"_csrf\" value=\"").expect("csrf field") + "name=\"_csrf\" value=\"".len(); |
| 108 |
page[i..].split('"').next().unwrap().to_string() |
| 109 |
} |
| 110 |
|
| 111 |
#[tokio::test(flavor = "multi_thread")] |
| 112 |
async fn accounts_roles_tokens_and_attribution() { |
| 113 |
let h = hub().await; |
| 114 |
let app = &h.app; |
| 115 |
|
| 116 |
|
| 117 |
let andy = register(app, "andy", "").await; |
| 118 |
let me = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap(); |
| 119 |
assert!(me.is_admin); |
| 120 |
|
| 121 |
let r = post(app, "/register", None, "username=mallory&email=m%40example.com&password=correct-horse-battery&invite=").await; |
| 122 |
assert_eq!(r.status, StatusCode::OK); |
| 123 |
assert!(r.body.contains("invitation"), "{}", r.body); |
| 124 |
let admin_page = get(app, "/admin", Some(&andy)).await; |
| 125 |
assert_eq!(admin_page.status, StatusCode::OK); |
| 126 |
let csrf = csrf_of(&admin_page.body); |
| 127 |
let r = post(app, "/admin/invite", Some(&andy), &format!("_csrf={csrf}")).await; |
| 128 |
let code = r.body.split("register?invite=").nth(1).unwrap().split('<').next().unwrap().trim().to_string(); |
| 129 |
let bob = register(app, "bob", &code).await; |
| 130 |
assert!(!db::accounts::account_by_name(&app.db, "bob").await.unwrap().unwrap().is_admin); |
| 131 |
|
| 132 |
assert_eq!(post(app, "/login", None, "username=andy&password=nope").await.status, StatusCode::OK); |
| 133 |
assert_eq!(post(app, "/settings", Some(&andy), "_csrf=bogus&display_name=x&email=y").await.status, StatusCode::BAD_REQUEST); |
| 134 |
assert_eq!(get(app, "/settings", None).await.status, StatusCode::SEE_OTHER, "anonymous settings redirects to sign in"); |
| 135 |
|
| 136 |
|
| 137 |
let new_page = get(app, "/new", Some(&andy)).await; |
| 138 |
let csrf = csrf_of(&new_page.body); |
| 139 |
let r = post(app, "/new", Some(&andy), &format!("_csrf={csrf}&owner=andy&name=secret&description=hush&visibility=private")).await; |
| 140 |
assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
| 141 |
assert_eq!(get(app, "/andy/secret", Some(&andy)).await.status, StatusCode::OK); |
| 142 |
assert_eq!(get(app, "/andy/secret", Some(&bob)).await.status, StatusCode::NOT_FOUND); |
| 143 |
assert_eq!(get(app, "/andy/secret", None).await.status, StatusCode::NOT_FOUND); |
| 144 |
assert!(!get(app, "/", Some(&bob)).await.body.contains("secret")); |
| 145 |
assert!(get(app, "/", Some(&andy)).await.body.contains("secret")); |
| 146 |
|
| 147 |
|
| 148 |
let tokens_page = get(app, "/settings/tokens", Some(&andy)).await; |
| 149 |
let csrf = csrf_of(&tokens_page.body); |
| 150 |
let r = post(app, "/settings/tokens", Some(&andy), &format!("_csrf={csrf}&name=ro&scopes=repo%3Aread")).await; |
| 151 |
let ro = r.body.split("oxyhub_").nth(1).map(|s| format!("oxyhub_{}", s.split('<').next().unwrap().trim())).unwrap(); |
| 152 |
let r = post(app, "/settings/tokens", Some(&andy), &format!("_csrf={csrf}&name=rw&scopes=repo%3Aread&scopes=repo%3Awrite")).await; |
| 153 |
let rw = r.body.split("oxyhub_").nth(1).map(|s| format!("oxyhub_{}", s.split('<').next().unwrap().trim())).unwrap(); |
| 154 |
let work = tempfile::tempdir().unwrap(); |
| 155 |
let mut client = Repo::init(work.path()).unwrap(); |
| 156 |
client.create_identity("Andy McCall", "[email protected]", oxy_core::repo::IdentityScope::User).unwrap(); |
| 157 |
client.config.set("remote.origin.url", &format!("{}/andy/secret", h.base)).unwrap(); |
| 158 |
client.config.set("core.sign", "change").unwrap(); |
| 159 |
client.save_config().unwrap(); |
| 160 |
write(work.path(), "a.txt", b"a\n"); |
| 161 |
client.snapshot("t").unwrap(); |
| 162 |
let c1 = client.commit("first", &[]).unwrap(); |
| 163 |
let err = client.push("origin", false).err().unwrap().to_string(); |
| 164 |
assert!(err.contains("404"), "anonymous cannot even see it: {err}"); |
| 165 |
client.config.set("remote.origin.token", &ro).unwrap(); |
| 166 |
let err = client.push("origin", false).err().unwrap().to_string(); |
| 167 |
assert!(err.contains("403"), "read-only token cannot push: {err}"); |
| 168 |
client.config.set("remote.origin.token", &rw).unwrap(); |
| 169 |
assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); |
| 170 |
|
| 171 |
let bob_tokens = get(app, "/settings/tokens", Some(&bob)).await; |
| 172 |
let csrf_b = csrf_of(&bob_tokens.body); |
| 173 |
let r = post(app, "/settings/tokens", Some(&bob), &format!("_csrf={csrf_b}&name=b&scopes=repo%3Aread&scopes=repo%3Awrite")).await; |
| 174 |
let bob_tok = r.body.split("oxyhub_").nth(1).map(|s| format!("oxyhub_{}", s.split('<').next().unwrap().trim())).unwrap(); |
| 175 |
std::env::set_var("OXY_TOKEN", &bob_tok); |
| 176 |
let err = Repo::clone(&format!("{}/andy/secret", h.base), &work.path().join("bobclone")).err().unwrap().to_string(); |
| 177 |
std::env::remove_var("OXY_TOKEN"); |
| 178 |
assert!(err.contains("404"), "{err}"); |
| 179 |
|
| 180 |
tokio::time::sleep(std::time::Duration::from_millis(150)).await; |
| 181 |
let pushes = get(app, "/andy/secret/pushes", Some(&andy)).await.body; |
| 182 |
assert!(pushes.contains("andy"), "{pushes}"); |
| 183 |
|
| 184 |
|
| 185 |
let settings = get(app, "/andy/secret/settings", Some(&andy)).await; |
| 186 |
assert_eq!(settings.status, StatusCode::OK); |
| 187 |
let csrf = csrf_of(&settings.body); |
| 188 |
let r = post(app, "/andy/secret/settings/collaborators", Some(&andy), &format!("_csrf={csrf}&username=bob&role=write")).await; |
| 189 |
assert_eq!(r.status, StatusCode::OK, "{}", r.body); |
| 190 |
assert_eq!(get(app, "/andy/secret", Some(&bob)).await.status, StatusCode::OK); |
| 191 |
assert_eq!(get(app, "/andy/secret/settings", Some(&bob)).await.status, StatusCode::NOT_FOUND, "write is not maintain"); |
| 192 |
std::env::set_var("OXY_TOKEN", &bob_tok); |
| 193 |
let ok = Repo::clone(&format!("{}/andy/secret", h.base), &work.path().join("bobclone2")).is_ok(); |
| 194 |
std::env::remove_var("OXY_TOKEN"); |
| 195 |
assert!(ok); |
| 196 |
|
| 197 |
|
| 198 |
|
| 199 |
let store = oxy_core::credentials::Credentials::user(); |
| 200 |
let hub = oxy_core::credentials::hub_of(&h.base).unwrap(); |
| 201 |
store.save(&hub, &rw, Some("andy"), None).unwrap(); |
| 202 |
let signed_in = Repo::clone(&format!("{}/andy/secret", h.base), &work.path().join("signedin")); |
| 203 |
store.remove(&hub).unwrap(); |
| 204 |
let signed_in = signed_in.unwrap(); |
| 205 |
assert!(signed_in.config.remote_tokens.is_empty()); |
| 206 |
assert_eq!(signed_in.bookmark_heads().unwrap(), vec![c1]); |
| 207 |
|
| 208 |
|
| 209 |
let (key, identity) = { |
| 210 |
let k = client.config.author_key.unwrap(); |
| 211 |
(k, client.identity(&k).unwrap()) |
| 212 |
}; |
| 213 |
let keys_page = get(app, "/settings/keys", Some(&andy)).await; |
| 214 |
let csrf = csrf_of(&keys_page.body); |
| 215 |
let boundary = "XYZ"; |
| 216 |
let mut multipart = Vec::new(); |
| 217 |
multipart.extend_from_slice(format!("--{boundary}\r\nContent-Disposition: form-data; name=\"_csrf\"\r\n\r\n{csrf}\r\n").as_bytes()); |
| 218 |
multipart.extend_from_slice(format!("--{boundary}\r\nContent-Disposition: form-data; name=\"identity\"; filename=\"me.identity\"\r\nContent-Type: application/octet-stream\r\n\r\n").as_bytes()); |
| 219 |
multipart.extend_from_slice(&identity.encode()); |
| 220 |
multipart.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes()); |
| 221 |
let req = Request::builder() |
| 222 |
.method("POST") |
| 223 |
.uri("/settings/keys/upload") |
| 224 |
.header(header::COOKIE, format!("oxyhub={andy}")) |
| 225 |
.header(header::CONTENT_TYPE, format!("multipart/form-data; boundary={boundary}")) |
| 226 |
.body(Body::from(multipart)) |
| 227 |
.unwrap(); |
| 228 |
let res = oxygenhub::router(app.clone()).oneshot(req).await.unwrap(); |
| 229 |
assert_eq!(res.status(), StatusCode::OK); |
| 230 |
let body = String::from_utf8_lossy(&res.into_body().collect().await.unwrap().to_bytes()).into_owned(); |
| 231 |
let challenge_hex = body.split("sign-challenge ").nth(1).unwrap().split('<').next().unwrap().trim().to_string(); |
| 232 |
let challenge_id = body.split("name=\"challenge\" value=\"").nth(1).unwrap().split('"').next().unwrap().to_string(); |
| 233 |
let (_, sk) = client.secret_key().unwrap().unwrap(); |
| 234 |
let sig = sk.sign_challenge(&key, &hex::decode(&challenge_hex).unwrap()).unwrap(); |
| 235 |
let sig_hex = hex::encode(oxy_core::identity::Signature::encode_list(&[sig])); |
| 236 |
let r = post(app, "/settings/keys/verify", Some(&andy), &format!("_csrf={csrf}&challenge={challenge_id}&signature={sig_hex}")).await; |
| 237 |
assert!(r.body.contains("Key linked"), "{}", r.body); |
| 238 |
let hist = get(app, "/andy/secret/changes/main", Some(&andy)).await.body; |
| 239 |
assert!(hist.contains("href=\"/andy\">Andy McCall</a>"), "author linked to the account: {hist}"); |
| 240 |
let change = get(app, &format!("/andy/secret/change/{}", c1.to_hex()), Some(&andy)).await.body; |
| 241 |
assert!(change.contains("@andy"), "{change}"); |
| 242 |
|
| 243 |
let r = post(app, "/settings/keys/verify", Some(&andy), &format!("_csrf={csrf}&challenge=999&signature={sig_hex}")).await; |
| 244 |
assert!(r.body.contains("expired"), "{}", r.body); |
| 245 |
|
| 246 |
|
| 247 |
let org_page = get(app, "/orgs/new", Some(&andy)).await; |
| 248 |
let csrf = csrf_of(&org_page.body); |
| 249 |
let r = post(app, "/orgs/new", Some(&andy), &format!("_csrf={csrf}&name=acme&display_name=Acme")).await; |
| 250 |
assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
| 251 |
let new_page = get(app, "/new", Some(&andy)).await; |
| 252 |
let csrf = csrf_of(&new_page.body); |
| 253 |
assert_eq!( |
| 254 |
post(app, "/new", Some(&andy), &format!("_csrf={csrf}&owner=acme&name=plans&visibility=private")).await.status, |
| 255 |
StatusCode::SEE_OTHER |
| 256 |
); |
| 257 |
assert_eq!(get(app, "/acme/plans", Some(&bob)).await.status, StatusCode::NOT_FOUND); |
| 258 |
let org_settings = get(app, "/orgs/acme/settings", Some(&andy)).await; |
| 259 |
let csrf = csrf_of(&org_settings.body); |
| 260 |
assert_eq!( |
| 261 |
post(app, "/orgs/acme/settings/members", Some(&andy), &format!("_csrf={csrf}&username=bob&role=member")).await.status, |
| 262 |
StatusCode::OK |
| 263 |
); |
| 264 |
assert_eq!(get(app, "/acme/plans", Some(&bob)).await.status, StatusCode::OK, "members read by default"); |
| 265 |
assert_eq!(get(app, "/orgs/acme/settings", Some(&bob)).await.status, StatusCode::NOT_FOUND, "members do not administer"); |
| 266 |
|
| 267 |
let r = post( |
| 268 |
app, |
| 269 |
"/new", |
| 270 |
Some(&bob), |
| 271 |
&format!("_csrf={}&owner=acme&name=bobs&visibility=public", csrf_of(&get(app, "/new", Some(&bob)).await.body)), |
| 272 |
) |
| 273 |
.await; |
| 274 |
assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
| 275 |
|
| 276 |
|
| 277 |
let sec = get(app, "/settings/security", Some(&andy)).await; |
| 278 |
let csrf = csrf_of(&sec.body); |
| 279 |
let r = post(app, "/settings/security/sessions", Some(&andy), &format!("_csrf={csrf}")).await; |
| 280 |
assert_eq!(r.status, StatusCode::SEE_OTHER); |
| 281 |
assert_eq!(get(app, "/settings", Some(&andy)).await.status, StatusCode::SEE_OTHER, "old session is gone"); |
| 282 |
let fresh = r.cookie.unwrap(); |
| 283 |
assert_eq!(get(app, "/settings", Some(&fresh)).await.status, StatusCode::OK); |
| 284 |
|
| 285 |
|
| 286 |
let sec = get(app, "/settings/security", Some(&fresh)).await; |
| 287 |
let csrf = csrf_of(&sec.body); |
| 288 |
let r = post(app, "/settings/security/totp/begin", Some(&fresh), &format!("_csrf={csrf}")).await; |
| 289 |
let secret = r.body.split("name=\"secret\" value=\"").nth(1).unwrap().split('"').next().unwrap().to_string(); |
| 290 |
let now = (db::now_ms() / 1000) as u64; |
| 291 |
let code = oxygenhub::auth::totp::current(&secret, now).unwrap(); |
| 292 |
let r = post(app, "/settings/security/totp/enable", Some(&fresh), &format!("_csrf={csrf}&secret={secret}&code={code}")).await; |
| 293 |
assert!(r.body.contains("Recovery codes"), "{}", r.body); |
| 294 |
let r = post(app, "/login", None, "username=andy&password=correct-horse-battery").await; |
| 295 |
assert!(r.body.contains("authenticator"), "second step asked: {}", r.body); |
| 296 |
|
| 297 |
|
| 298 |
let form = &r.body[r.body.find("<form class=\"card\" method=\"post\" action=\"/login\">").unwrap()..]; |
| 299 |
let form = &form[..form.find("</form>").unwrap()]; |
| 300 |
let names: Vec<&str> = form.split("name=\"").skip(1).map(|t| t.split('"').next().unwrap()).collect(); |
| 301 |
let mut unique = names.clone(); |
| 302 |
unique.sort(); |
| 303 |
unique.dedup(); |
| 304 |
assert_eq!(unique.len(), names.len(), "a field named twice: {names:?}"); |
| 305 |
let code = oxygenhub::auth::totp::current(&secret, (db::now_ms() / 1000) as u64).unwrap(); |
| 306 |
let body: Vec<String> = names |
| 307 |
.iter() |
| 308 |
.map(|n| match *n { |
| 309 |
"username" => "username=andy".to_string(), |
| 310 |
"password" => "password=correct-horse-battery".to_string(), |
| 311 |
"code" => format!("code={code}"), |
| 312 |
other => format!("{other}="), |
| 313 |
}) |
| 314 |
.collect(); |
| 315 |
let r = post(app, "/login", None, &body.join("&")).await; |
| 316 |
assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
| 317 |
assert!(r.cookie.is_some()); |
| 318 |
assert!(r.location.as_deref() == Some("/")); |
| 319 |
} |
| 320 |
|
| 321 |
|
| 322 |
fn picture_form(csrf: &str, action: &str, file: &[u8]) -> (String, Vec<u8>) { |
| 323 |
let boundary = "picture-boundary-1234"; |
| 324 |
let mut body = Vec::new(); |
| 325 |
body.extend_from_slice(format!("--{boundary}\r\nContent-Disposition: form-data; name=\"_csrf\"\r\n\r\n{csrf}\r\n").as_bytes()); |
| 326 |
body.extend_from_slice(format!("--{boundary}\r\nContent-Disposition: form-data; name=\"action\"\r\n\r\n{action}\r\n").as_bytes()); |
| 327 |
body.extend_from_slice( |
| 328 |
format!("--{boundary}\r\nContent-Disposition: form-data; name=\"picture\"; filename=\"me.png\"\r\nContent-Type: image/png\r\n\r\n") |
| 329 |
.as_bytes(), |
| 330 |
); |
| 331 |
body.extend_from_slice(file); |
| 332 |
body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes()); |
| 333 |
(format!("multipart/form-data; boundary={boundary}"), body) |
| 334 |
} |
| 335 |
|
| 336 |
async fn upload(app: &Shared, path: &str, cookie: &str, csrf: &str, action: &str, file: &[u8]) -> Res { |
| 337 |
let (ct, body) = picture_form(csrf, action, file); |
| 338 |
let req = Request::post(path) |
| 339 |
.header(header::COOKIE, format!("oxyhub={cookie}")) |
| 340 |
.header(header::CONTENT_TYPE, ct) |
| 341 |
.body(Body::from(body)) |
| 342 |
.unwrap(); |
| 343 |
let res = oxygenhub::router(app.clone()).oneshot(req).await.unwrap(); |
| 344 |
let status = res.status(); |
| 345 |
let bytes = res.into_body().collect().await.unwrap().to_bytes(); |
| 346 |
Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie: None, location: None, raw_cookies: vec![] } |
| 347 |
} |
| 348 |
|
| 349 |
|
| 350 |
async fn picture(app: &Shared, name: &str) -> (StatusCode, String, Vec<u8>) { |
| 351 |
let res = oxygenhub::router(app.clone()).oneshot(Request::get(format!("/avatar/{name}")).body(Body::empty()).unwrap()).await.unwrap(); |
| 352 |
let ct = res.headers().get(header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or("").to_string(); |
| 353 |
let status = res.status(); |
| 354 |
(status, ct, res.into_body().collect().await.unwrap().to_bytes().to_vec()) |
| 355 |
} |
| 356 |
|
| 357 |
fn png(w: u32, h: u32, alpha: bool) -> Vec<u8> { |
| 358 |
let img = if alpha { |
| 359 |
image::DynamicImage::ImageRgba8(image::RgbaImage::from_fn(w, h, |x, _| { |
| 360 |
image::Rgba([0, 114, 255, if x % 2 == 0 { 0 } else { 255 }]) |
| 361 |
})) |
| 362 |
} else { |
| 363 |
image::DynamicImage::ImageRgb8(image::RgbImage::from_fn(w, h, |x, y| image::Rgb([x as u8, y as u8, 128]))) |
| 364 |
}; |
| 365 |
let mut out = Vec::new(); |
| 366 |
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); |
| 367 |
out |
| 368 |
} |
| 369 |
|
| 370 |
#[tokio::test(flavor = "multi_thread")] |
| 371 |
async fn profile_pictures_and_logos() { |
| 372 |
let h = hub().await; |
| 373 |
let app = &h.app; |
| 374 |
let andy = register(app, "andy", "").await; |
| 375 |
assert_eq!(picture(app, "andy").await.1, "image/svg+xml", "the identicon first"); |
| 376 |
let csrf = csrf_of(&get(app, "/settings", Some(&andy)).await.body); |
| 377 |
assert!(get(app, "/settings", Some(&andy)).await.body.contains("id=\"picture\"")); |
| 378 |
|
| 379 |
|
| 380 |
let r = upload(app, "/settings/avatar", &andy, &csrf, "upload", &png(640, 480, false)).await; |
| 381 |
assert!(r.body.contains("Picture saved"), "{}", r.body); |
| 382 |
let (st, ct, bytes) = picture(app, "andy").await; |
| 383 |
assert_eq!((st, ct.as_str()), (StatusCode::OK, "image/jpeg")); |
| 384 |
let img = image::load_from_memory(&bytes).unwrap(); |
| 385 |
assert_eq!((img.width(), img.height()), (400, 400)); |
| 386 |
let stored = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().avatar; |
| 387 |
assert!(stored.starts_with("jpeg:"), "{stored}"); |
| 388 |
|
| 389 |
|
| 390 |
let r = upload( |
| 391 |
app, |
| 392 |
"/settings/avatar", |
| 393 |
&andy, |
| 394 |
&csrf, |
| 395 |
"upload", |
| 396 |
b"<svg xmlns=\"http://www.w3.org/2000/svg\"><script>alert(1)</script></svg>", |
| 397 |
) |
| 398 |
.await; |
| 399 |
assert!(r.body.contains("not a PNG, JPEG or WebP"), "{}", r.body); |
| 400 |
let r = upload(app, "/settings/avatar", &andy, "wrong", "upload", &png(10, 10, false)).await; |
| 401 |
assert_ne!(r.status, StatusCode::OK); |
| 402 |
assert_eq!(picture(app, "andy").await.1, "image/jpeg"); |
| 403 |
|
| 404 |
let r = upload(app, "/settings/avatar", &andy, &csrf, "remove", b"").await; |
| 405 |
assert!(r.body.contains("Picture removed")); |
| 406 |
assert_eq!(picture(app, "andy").await.1, "image/svg+xml"); |
| 407 |
|
| 408 |
|
| 409 |
let me = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap(); |
| 410 |
db::accounts::create_org(&app.db, "acme", "Acme", me.id).await.unwrap(); |
| 411 |
let org_csrf = csrf_of(&get(app, "/orgs/acme/settings", Some(&andy)).await.body); |
| 412 |
let r = upload(app, "/orgs/acme/settings/avatar", &andy, &org_csrf, "upload", &png(120, 120, true)).await; |
| 413 |
assert!(r.body.contains("Picture saved"), "{}", r.body); |
| 414 |
let (_, ct, bytes) = picture(app, "acme").await; |
| 415 |
assert_eq!(ct, "image/png"); |
| 416 |
assert!(image::load_from_memory(&bytes).unwrap().color().has_alpha()); |
| 417 |
let invite = { |
| 418 |
let admin = get(app, "/admin", Some(&andy)).await; |
| 419 |
let r = post(app, "/admin/invite", Some(&andy), &format!("_csrf={}", csrf_of(&admin.body))).await; |
| 420 |
let i = r.body.find("invite=").map(|i| i + 7).expect("an invitation"); |
| 421 |
r.body[i..].chars().take_while(|c| c.is_ascii_alphanumeric()).collect::<String>() |
| 422 |
}; |
| 423 |
let bob = register(app, "bob", &invite).await; |
| 424 |
let bob_csrf = csrf_of(&get(app, "/settings", Some(&bob)).await.body); |
| 425 |
let r = upload(app, "/orgs/acme/settings/avatar", &bob, &bob_csrf, "remove", b"").await; |
| 426 |
assert_eq!(r.status, StatusCode::NOT_FOUND, "not an owner"); |
| 427 |
assert_eq!(picture(app, "acme").await.1, "image/png"); |
| 428 |
} |
| 429 |
|
| 430 |
#[tokio::test(flavor = "multi_thread")] |
| 431 |
async fn organisation_webhooks_and_storage() { |
| 432 |
let h = hub().await; |
| 433 |
let app = &h.app; |
| 434 |
let andy = register(app, "andy", "").await; |
| 435 |
let me = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap(); |
| 436 |
let org = db::accounts::create_org(&app.db, "acme", "Acme", me.id).await.unwrap(); |
| 437 |
|
| 438 |
|
| 439 |
|
| 440 |
|
| 441 |
let alone = db::repos::create(&app.db, "acme", "alone", "", hub_api::Visibility::Public).await.unwrap(); |
| 442 |
let solo = db::repos::create(&app.db, "andy", "solo", "", hub_api::Visibility::Public).await.unwrap(); |
| 443 |
assert_eq!(db::proposals::requirements(&app.db, alone.id).await.unwrap().required_approvals, 0); |
| 444 |
assert_eq!(db::proposals::requirements(&app.db, solo.id).await.unwrap().required_approvals, 0); |
| 445 |
let bobby = db::accounts::create_account(&app.db, "bobby", "[email protected]", "x", "salt", false).await.unwrap(); |
| 446 |
db::accounts::set_org_member(&app.db, org.id, bobby.id, "member").await.unwrap(); |
| 447 |
let tools = db::repos::create(&app.db, "acme", "tools", "", hub_api::Visibility::Public).await.unwrap(); |
| 448 |
assert_eq!(db::proposals::requirements(&app.db, tools.id).await.unwrap().required_approvals, 1); |
| 449 |
|
| 450 |
|
| 451 |
let page = get(app, "/orgs/acme/settings", Some(&andy)).await; |
| 452 |
assert!(page.body.contains("id=\"webhooks\"") && page.body.contains("href=\"#webhooks\"")); |
| 453 |
let csrf = csrf_of(&page.body); |
| 454 |
let r = post(app, "/orgs/acme/settings/hooks", Some(&andy), &format!("_csrf={csrf}&url=ftp%3A%2F%2Fx&events=*")).await; |
| 455 |
assert!(r.body.contains("starts with http"), "{}", r.body); |
| 456 |
let r = post( |
| 457 |
app, |
| 458 |
"/orgs/acme/settings/hooks", |
| 459 |
Some(&andy), |
| 460 |
&format!("_csrf={csrf}&url=https%3A%2F%2Fhooks.invalid%2Facme&secret=s&events=push"), |
| 461 |
) |
| 462 |
.await; |
| 463 |
assert!(r.body.contains("Webhook added")); |
| 464 |
let hooks = db::hooks::hooks_for_org(&app.db, org.id).await.unwrap(); |
| 465 |
assert_eq!(hooks.len(), 1); |
| 466 |
let id = hooks[0].id; |
| 467 |
assert!(post(app, "/orgs/acme/settings/hooks", Some(&andy), &format!("_csrf={csrf}&id={id}&action=ping")) |
| 468 |
.await |
| 469 |
.body |
| 470 |
.contains("Ping queued")); |
| 471 |
let deliveries = get(app, &format!("/orgs/acme/settings/hooks/{id}/deliveries"), Some(&andy)).await; |
| 472 |
assert_eq!(deliveries.status, StatusCode::OK); |
| 473 |
assert!(deliveries.body.contains(">ping<") && deliveries.body.contains("/orgs/acme/settings#webhooks")); |
| 474 |
|
| 475 |
let repo = oxygenhub::repos::RepoName::parse("andy/demo").unwrap(); |
| 476 |
app.repos.create(&repo, None).unwrap(); |
| 477 |
let row = db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap(); |
| 478 |
let repo_hook = db::hooks::create_hook(&app.db, Some(row.id), None, "https://hooks.invalid/repo", "", "*").await.unwrap().id; |
| 479 |
post(app, "/orgs/acme/settings/hooks", Some(&andy), &format!("_csrf={csrf}&id={repo_hook}&action=delete")).await; |
| 480 |
assert!(db::hooks::hook(&app.db, repo_hook).await.unwrap().is_some(), "not the organisation's"); |
| 481 |
assert_eq!(get(app, &format!("/orgs/acme/settings/hooks/{repo_hook}/deliveries"), Some(&andy)).await.status, StatusCode::NOT_FOUND); |
| 482 |
|
| 483 |
let rs = get(app, "/andy/demo/settings", Some(&andy)).await; |
| 484 |
assert!(rs.body.contains("https://hooks.invalid/repo") && rs.body.contains("action=\"/andy/demo/settings/hooks\"")); |
| 485 |
|
| 486 |
|
| 487 |
let work = tempfile::tempdir().unwrap(); |
| 488 |
let mut client = Repo::init(work.path()).unwrap(); |
| 489 |
client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap(); |
| 490 |
let (token, hash) = oxygenhub::auth::new_token(); |
| 491 |
db::accounts::create_token(&app.db, me.id, "push", &hash, "repo:write", "", None).await.unwrap(); |
| 492 |
client.config.set("remote.origin.token", &token).unwrap(); |
| 493 |
client.save_config().unwrap(); |
| 494 |
write(work.path(), "a.txt", b"a\n"); |
| 495 |
client.snapshot("t").unwrap(); |
| 496 |
client.commit("one", &[]).unwrap(); |
| 497 |
assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); |
| 498 |
let storage = get(app, "/admin/storage", Some(&andy)).await; |
| 499 |
assert_eq!(storage.status, StatusCode::OK); |
| 500 |
assert!(storage.body.contains("href=\"/andy/demo\"")); |
| 501 |
let loose_before = std::fs::read_dir(app.repos.dir(&repo).join(".oxy/objects/loose")).map(|d| d.count()).unwrap_or(0); |
| 502 |
assert!(loose_before > 0, "the push left loose objects"); |
| 503 |
let r = post(app, "/admin/storage", Some(&andy), &format!("_csrf={}&repo=andy%2Fdemo", csrf_of(&storage.body))).await; |
| 504 |
assert!(r.body.contains("Maintained 1 repository") && r.body.contains("loose objects packed"), "{}", r.body); |
| 505 |
let loose_after: usize = walk_files(&app.repos.dir(&repo).join(".oxy/objects/loose")); |
| 506 |
assert_eq!(loose_after, 0, "packed"); |
| 507 |
let audit = db::accounts::audit_rows(&app.db, 20).await.unwrap(); |
| 508 |
assert!(audit.iter().any(|a| a.action == "admin.gc" && a.target == "andy/demo")); |
| 509 |
let r = post(app, "/admin/storage", Some(&andy), &format!("_csrf={}&repo=nobody%2Fnone", csrf_of(&storage.body))).await; |
| 510 |
assert!(r.body.contains("No repository nobody/none")); |
| 511 |
} |
| 512 |
|
| 513 |
fn walk_files(dir: &Path) -> usize { |
| 514 |
let Ok(rd) = std::fs::read_dir(dir) else { return 0 }; |
| 515 |
rd.flatten().map(|e| if e.path().is_dir() { walk_files(&e.path()) } else { 1 }).sum() |
| 516 |
} |
| 517 |
|
| 518 |
|
| 519 |
|
| 520 |
|
| 521 |
#[tokio::test(flavor = "multi_thread")] |
| 522 |
async fn admins_disable_enable_and_delete_accounts() { |
| 523 |
let h = hub().await; |
| 524 |
let app = &h.app; |
| 525 |
let andy = register(app, "andy", "").await; |
| 526 |
let invite = |app: Shared, andy: String| async move { |
| 527 |
let csrf = csrf_of(&get(&app, "/admin", Some(&andy)).await.body); |
| 528 |
let r = post(&app, "/admin/invite", Some(&andy), &format!("_csrf={csrf}")).await; |
| 529 |
r.body.split("register?invite=").nth(1).unwrap().split('<').next().unwrap().trim().to_string() |
| 530 |
}; |
| 531 |
let bob = register(app, "bob", &invite(app.clone(), andy.clone()).await).await; |
| 532 |
let act = |who: &'static str, action: &'static str, confirm: &'static str| { |
| 533 |
let (app, andy) = (app.clone(), andy.clone()); |
| 534 |
async move { |
| 535 |
let csrf = csrf_of(&get(&app, "/admin", Some(&andy)).await.body); |
| 536 |
post(&app, "/admin/account", Some(&andy), &format!("_csrf={csrf}&username={who}&action={action}&confirm={confirm}")).await.body |
| 537 |
} |
| 538 |
}; |
| 539 |
let login = |user: &'static str| { |
| 540 |
let app = app.clone(); |
| 541 |
async move { post(&app, "/login", None, &format!("username={user}&password=correct-horse-battery")).await } |
| 542 |
}; |
| 543 |
|
| 544 |
|
| 545 |
act("bob", "disable", "").await; |
| 546 |
assert!(db::accounts::account_by_name(&app.db, "bob").await.unwrap().unwrap().disabled); |
| 547 |
let r = login("bob").await; |
| 548 |
assert!(r.body.contains("Your account has been disabled"), "{}", r.body); |
| 549 |
assert!(r.cookie.is_none()); |
| 550 |
assert_eq!(get(app, "/settings", Some(&bob)).await.status, StatusCode::SEE_OTHER, "the session is refused"); |
| 551 |
assert!(act("andy", "disable", "").await.contains("Not on yourself")); |
| 552 |
|
| 553 |
act("bob", "enable", "").await; |
| 554 |
assert_eq!(login("bob").await.status, StatusCode::SEE_OTHER); |
| 555 |
|
| 556 |
|
| 557 |
let bob = login("bob").await.cookie.unwrap(); |
| 558 |
let csrf = csrf_of(&get(app, "/new", Some(&bob)).await.body); |
| 559 |
assert_eq!( |
| 560 |
post(app, "/new", Some(&bob), &format!("_csrf={csrf}&owner=bob&name=keep&visibility=public")).await.status, |
| 561 |
StatusCode::SEE_OTHER |
| 562 |
); |
| 563 |
assert!(act("bob", "delete", "bo").await.contains("type bob to confirm")); |
| 564 |
let page = act("bob", "delete", "bob").await; |
| 565 |
assert!(page.contains("still owns bob/keep"), "{page}"); |
| 566 |
assert!(db::accounts::account_by_name(&app.db, "bob").await.unwrap().is_some()); |
| 567 |
let row = db::repos::get(&app.db, "bob", "keep").await.unwrap().unwrap(); |
| 568 |
db::repos::delete(&app.db, row.id).await.unwrap(); |
| 569 |
let page = act("bob", "delete", "bob").await; |
| 570 |
assert!(page.contains("bob deleted"), "{page}"); |
| 571 |
assert!(db::accounts::account_by_name(&app.db, "bob").await.unwrap().is_none()); |
| 572 |
assert_eq!(get(app, "/settings", Some(&bob)).await.status, StatusCode::SEE_OTHER); |
| 573 |
|
| 574 |
let code = invite(app.clone(), andy.clone()).await; |
| 575 |
let r = post(app, "/register", None, &format!("username=bob&email=b%40example.com&password=correct-horse-battery&invite={code}")).await; |
| 576 |
assert!(r.body.contains("That name is taken"), "{}", r.body); |
| 577 |
|
| 578 |
assert!(act("andy", "delete", "andy").await.contains("Not on yourself")); |
| 579 |
} |
| 580 |
|
| 581 |
|
| 582 |
|
| 583 |
#[tokio::test(flavor = "multi_thread")] |
| 584 |
async fn admins_set_the_job_time_limits() { |
| 585 |
let h = hub().await; |
| 586 |
let app = &h.app; |
| 587 |
let andy = register(app, "andy", "").await; |
| 588 |
let page = get(app, "/admin/runners/settings", Some(&andy)).await; |
| 589 |
assert_eq!(page.status, StatusCode::OK); |
| 590 |
assert!(page.body.contains("Job time limits") && page.body.contains("value=\"60\""), "the hub.toml default shows"); |
| 591 |
let csrf = csrf_of(&page.body); |
| 592 |
let r = post(app, "/admin/runners/settings", Some(&andy), &format!("_csrf={csrf}&default_minutes=200&max_minutes=120&grace_minutes=5")) |
| 593 |
.await; |
| 594 |
assert!(r.body.contains("cannot be longer than the maximum"), "{}", r.body); |
| 595 |
let r = post(app, "/admin/runners/settings", Some(&andy), &format!("_csrf={csrf}&default_minutes=90&max_minutes=240&grace_minutes=10")) |
| 596 |
.await; |
| 597 |
assert!(r.body.contains("Saved."), "{}", r.body); |
| 598 |
let l = oxygenhub::flow::limits(app).await; |
| 599 |
assert_eq!((l.default_s, l.max_s, l.grace_ms), (5400, 14400, 600_000)); |
| 600 |
assert!(get(app, "/admin/runners", Some(&andy)).await.body.contains("href=\"/admin/runners/settings\"")); |
| 601 |
let code = { |
| 602 |
let csrf = csrf_of(&get(app, "/admin", Some(&andy)).await.body); |
| 603 |
let r = post(app, "/admin/invite", Some(&andy), &format!("_csrf={csrf}")).await; |
| 604 |
r.body.split("register?invite=").nth(1).unwrap().split('<').next().unwrap().trim().to_string() |
| 605 |
}; |
| 606 |
let bob = register(app, "bob", &code).await; |
| 607 |
assert_ne!(get(app, "/admin/runners/settings", Some(&bob)).await.status, StatusCode::OK, "not for others"); |
| 608 |
} |
| 609 |
|
| 610 |
|
| 611 |
|
| 612 |
#[tokio::test(flavor = "multi_thread")] |
| 613 |
async fn mail_settings_are_saved_masked_and_tested_from_the_admin_page() { |
| 614 |
let h = hub().await; |
| 615 |
let app = &h.app; |
| 616 |
let andy = register(app, "andy", "").await; |
| 617 |
let page = get(app, "/admin", Some(&andy)).await.body; |
| 618 |
assert!(page.contains("Mail is off"), "{page}"); |
| 619 |
let csrf = csrf_of(&page); |
| 620 |
|
| 621 |
let outbox = tempfile::tempdir().unwrap(); |
| 622 |
let r = post( |
| 623 |
app, |
| 624 |
"/admin/mail", |
| 625 |
Some(&andy), |
| 626 |
&format!("_csrf={csrf}&smtp=file%3A%2F%2F{}&from=Hub+%3Chub%40example.org%3E", outbox.path().display()), |
| 627 |
) |
| 628 |
.await; |
| 629 |
assert!(r.body.contains("Mail settings saved") && r.body.contains("Mail is on"), "{}", r.body); |
| 630 |
assert!(oxygenhub::mail::enabled(app)); |
| 631 |
let r = post(app, "/admin/mail/test", Some(&andy), &format!("_csrf={csrf}")).await; |
| 632 |
assert!(r.body.contains("Sent a test message to"), "{}", r.body); |
| 633 |
let sent: Vec<_> = std::fs::read_dir(outbox.path()).unwrap().flatten().collect(); |
| 634 |
assert_eq!(sent.len(), 1); |
| 635 |
let text = std::fs::read_to_string(sent[0].path()).unwrap(); |
| 636 |
assert!(text.contains("Subject: [") && text.contains("Test message") && text.contains("From: Hub <[email protected]>"), "{text}"); |
| 637 |
|
| 638 |
let r = post( |
| 639 |
app, |
| 640 |
"/admin/mail", |
| 641 |
Some(&andy), |
| 642 |
&format!("_csrf={csrf}&smtp=smtps%3A%2F%2Fhub%3Asecret%40mail.example.org%3A465&from=Hub+%3Chub%40example.org%3E"), |
| 643 |
) |
| 644 |
.await; |
| 645 |
assert!(r.body.contains("smtps://hub:***@mail.example.org:465") && !r.body.contains("hub:secret"), "{}", r.body); |
| 646 |
let r = post( |
| 647 |
app, |
| 648 |
"/admin/mail", |
| 649 |
Some(&andy), |
| 650 |
&format!("_csrf={csrf}&smtp=smtps%3A%2F%2Fhub%3A***%40mail.example.org%3A465&from=Hub+%3Chub%40example.org%3E"), |
| 651 |
) |
| 652 |
.await; |
| 653 |
assert!(r.body.contains("Mail settings saved"), "{}", r.body); |
| 654 |
assert_eq!(oxygenhub::mail::settings(app).smtp, "smtps://hub:[email protected]:465"); |
| 655 |
|
| 656 |
let r = post(app, "/admin/mail", Some(&andy), &format!("_csrf={csrf}&smtp=mail.example.org&from=x")).await; |
| 657 |
assert!(r.body.contains("starts with smtp://"), "{}", r.body); |
| 658 |
let r = post(app, "/admin/mail", Some(&andy), &format!("_csrf={csrf}&smtp=&from=")).await; |
| 659 |
assert!(r.body.contains("Mail is off"), "{}", r.body); |
| 660 |
assert!(!oxygenhub::mail::enabled(app)); |
| 661 |
|
| 662 |
let r = post(app, "/admin/mail", None, &format!("_csrf={csrf}&smtp=&from=")).await; |
| 663 |
assert_ne!(r.status, StatusCode::OK); |
| 664 |
} |
| 665 |
|
| 666 |
|
| 667 |
|
| 668 |
async fn mock_assistant() -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) { |
| 669 |
use std::sync::{Arc, Mutex}; |
| 670 |
let seen: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(vec![])); |
| 671 |
let s2 = seen.clone(); |
| 672 |
let router = axum::Router::new().route( |
| 673 |
"/v1/chat/completions", |
| 674 |
axum::routing::post(move |headers: axum::http::HeaderMap, body: String| { |
| 675 |
let s2 = s2.clone(); |
| 676 |
async move { |
| 677 |
let auth = headers.get("authorization").and_then(|v| v.to_str().ok()).unwrap_or("").to_string(); |
| 678 |
s2.lock().unwrap().push(format!("{auth} {body}")); |
| 679 |
axum::Json(serde_json::json!({ "choices": [{ "message": { "role": "assistant", "content": "OK from mock" } }] })) |
| 680 |
} |
| 681 |
}), |
| 682 |
); |
| 683 |
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
| 684 |
let port = listener.local_addr().unwrap().port(); |
| 685 |
tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); |
| 686 |
(format!("http://127.0.0.1:{port}/v1/chat/completions"), seen) |
| 687 |
} |
| 688 |
|
| 689 |
fn enc(s: &str) -> String { |
| 690 |
s.replace('%', "%25").replace(':', "%3A").replace('/', "%2F").replace(' ', "+").replace('\n', "%0A").replace('&', "%26") |
| 691 |
} |
| 692 |
|
| 693 |
#[tokio::test(flavor = "multi_thread")] |
| 694 |
async fn ai_assistants_are_kept_per_level_with_sealed_tokens_and_combined_downwards() { |
| 695 |
use oxygenhub::assistants::{self, Scope}; |
| 696 |
let h = hub().await; |
| 697 |
let app = &h.app; |
| 698 |
let andy = register(app, "andy", "").await; |
| 699 |
let (endpoint, seen) = mock_assistant().await; |
| 700 |
|
| 701 |
|
| 702 |
let page = get(app, "/admin", Some(&andy)).await.body; |
| 703 |
assert!(page.contains("AI assistants") && page.contains("Add an assistant"), "{page}"); |
| 704 |
let csrf = csrf_of(&page); |
| 705 |
let add = |name: &str, extra: &str| { |
| 706 |
format!( |
| 707 |
"_csrf={csrf}&action=add&name={name}&endpoint={}&api=chat-completions&model=claude-opus-5-5&token=sk-test-secret&enabled=1&budget=50000{extra}", |
| 708 |
enc(&endpoint) |
| 709 |
) |
| 710 |
}; |
| 711 |
let r = post(app, "/admin/assistants", Some(&andy), &add("Claude", "")).await; |
| 712 |
assert!(r.body.contains("Claude added and answered: OK from mock") && r.body.contains("token set"), "{}", r.body); |
| 713 |
assert!(!r.body.contains("sk-test-secret"), "the token never comes back"); |
| 714 |
let site = assistants::list(&app.db, Scope::Site).await.unwrap(); |
| 715 |
assert_eq!(site.len(), 1); |
| 716 |
let claude = &site[0]; |
| 717 |
assert!(!String::from_utf8_lossy(&claude.token).contains("sk-test"), "sealed at rest"); |
| 718 |
assert_eq!(app.assistant_key.open(&claude.token).as_deref(), Some("sk-test-secret")); |
| 719 |
|
| 720 |
let r = post(app, "/admin/assistants", Some(&andy), &add("claude", "")).await; |
| 721 |
assert!(r.body.contains("already an assistant called"), "{}", r.body); |
| 722 |
let r = post( |
| 723 |
app, |
| 724 |
"/admin/assistants", |
| 725 |
Some(&andy), |
| 726 |
&format!("_csrf={csrf}&action=add&name=Bad&endpoint=ftp%3A%2F%2Fx&api=chat-completions&model=m"), |
| 727 |
) |
| 728 |
.await; |
| 729 |
assert!(r.body.contains("starting with https://"), "{}", r.body); |
| 730 |
|
| 731 |
|
| 732 |
let r = post(app, "/admin/assistants", Some(&andy), &format!("_csrf={csrf}&action=test&id={}", claude.id)).await; |
| 733 |
assert!(r.body.contains("Claude answered: OK from mock"), "{}", r.body); |
| 734 |
{ |
| 735 |
let calls = seen.lock().unwrap(); |
| 736 |
assert_eq!(calls.len(), 2, "once on add, once on test"); |
| 737 |
assert!(calls[0].starts_with("Bearer sk-test-secret "), "{}", calls[0]); |
| 738 |
assert!(calls[0].contains("\"model\":\"claude-opus-5-5\"") && calls[0].contains("Reply with the single word OK"), "{}", calls[0]); |
| 739 |
} |
| 740 |
|
| 741 |
let r = post( |
| 742 |
app, |
| 743 |
"/admin/assistants", |
| 744 |
Some(&andy), |
| 745 |
&format!("_csrf={csrf}&action=update&id={}&name=Claude&endpoint={}&api=chat-completions&model=claude-opus-5-5&token=&enabled=1&budget=60000", claude.id, enc(&endpoint)), |
| 746 |
) |
| 747 |
.await; |
| 748 |
assert!(r.body.contains("Claude updated.") && r.body.contains("context 60000 characters"), "{}", r.body); |
| 749 |
assert_eq!( |
| 750 |
app.assistant_key.open(&assistants::get(&app.db, claude.id).await.unwrap().unwrap().token).as_deref(), |
| 751 |
Some("sk-test-secret") |
| 752 |
); |
| 753 |
|
| 754 |
let r = |
| 755 |
post(app, "/admin/assistants", Some(&andy), &format!("_csrf={csrf}&action=prompt&prompt={}", enc("Review for correctness first."))) |
| 756 |
.await; |
| 757 |
assert!(r.body.contains("Prompt saved.") && r.body.contains("Review for correctness first."), "{}", r.body); |
| 758 |
|
| 759 |
|
| 760 |
let r = post(app, "/orgs/new", Some(&andy), &format!("_csrf={csrf}&name=acme&display_name=Acme")).await; |
| 761 |
assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
| 762 |
let r = post(app, "/new", Some(&andy), &format!("_csrf={csrf}&owner=acme&name=plans&visibility=public")).await; |
| 763 |
assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
| 764 |
let org_page = get(app, "/orgs/acme/settings", Some(&andy)).await.body; |
| 765 |
assert!(org_page.contains("Inherited") && org_page.contains("from the site") && org_page.contains("None of its own"), "{org_page}"); |
| 766 |
assert!(org_page.contains("From the site") && org_page.contains("Review for correctness first."), "{org_page}"); |
| 767 |
let repo = db::repos::get(&app.db, "acme", "plans").await.unwrap().unwrap(); |
| 768 |
let on = assistants::enabled_for_repo(&app.db, &repo).await.unwrap(); |
| 769 |
assert_eq!(on.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(), ["Claude"]); |
| 770 |
|
| 771 |
|
| 772 |
|
| 773 |
let r = post( |
| 774 |
app, |
| 775 |
"/orgs/acme/settings/assistants", |
| 776 |
Some(&andy), |
| 777 |
&format!("_csrf={csrf}&action=prompt&prompt={}", enc("We write 6502 assembly.")), |
| 778 |
) |
| 779 |
.await; |
| 780 |
assert!(r.body.contains("Prompt saved."), "{}", r.body); |
| 781 |
let r = post(app, "/orgs/acme/settings/assistants", Some(&andy), &add("DeepSeek", "")).await; |
| 782 |
assert!(r.body.contains("DeepSeek added and answered"), "{}", r.body); |
| 783 |
let prompt = assistants::prompt_for_repo(&app.db, &repo).await.unwrap(); |
| 784 |
assert!(prompt.starts_with("## Instructions from the site\n\nReview for correctness first.\n\n## Instructions from the acme organisation\n\nWe write 6502 assembly."), "{prompt}"); |
| 785 |
let repo_page = get(app, "/acme/plans/settings", Some(&andy)).await.body; |
| 786 |
assert!(repo_page.contains("from the acme organisation") && repo_page.contains("From the acme organisation"), "{repo_page}"); |
| 787 |
let on = assistants::enabled_for_repo(&app.db, &repo).await.unwrap(); |
| 788 |
assert_eq!(on.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(), ["Claude", "DeepSeek"]); |
| 789 |
|
| 790 |
|
| 791 |
|
| 792 |
|
| 793 |
let r = post(app, "/admin/assistants", Some(&andy), &format!("_csrf={csrf}&action=toggle&id={}", claude.id)).await; |
| 794 |
assert!(r.body.contains("Claude is off."), "{}", r.body); |
| 795 |
let r = post(app, "/acme/plans/settings/assistants", Some(&andy), &add("Claude", "")).await; |
| 796 |
assert!(r.body.contains("Claude added and answered"), "{}", r.body); |
| 797 |
let on = assistants::enabled_for_repo(&app.db, &repo).await.unwrap(); |
| 798 |
assert_eq!(on.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(), ["DeepSeek"]); |
| 799 |
let repo_page = get(app, "/acme/plans/settings", Some(&andy)).await.body; |
| 800 |
assert!(repo_page.contains("off above"), "{repo_page}"); |
| 801 |
post(app, "/admin/assistants", Some(&andy), &format!("_csrf={csrf}&action=toggle&id={}", claude.id)).await; |
| 802 |
let on = assistants::enabled_for_repo(&app.db, &repo).await.unwrap(); |
| 803 |
assert_eq!(on.iter().map(|a| (a.name.as_str(), a.scope.as_str())).collect::<Vec<_>>(), [("DeepSeek", "org"), ("Claude", "repo")]); |
| 804 |
|
| 805 |
|
| 806 |
let invite = { |
| 807 |
let r = post(app, "/admin/invite", Some(&andy), &format!("_csrf={csrf}")).await; |
| 808 |
let i = r.body.find("invite=").map(|i| i + 7).expect("an invitation"); |
| 809 |
r.body[i..].chars().take_while(|c| c.is_ascii_alphanumeric()).collect::<String>() |
| 810 |
}; |
| 811 |
let bob = register(app, "bob", &invite).await; |
| 812 |
assert_eq!( |
| 813 |
post(app, "/admin/assistants", Some(&bob), &format!("_csrf={csrf}&action=prompt&prompt=x")).await.status, |
| 814 |
StatusCode::NOT_FOUND |
| 815 |
); |
| 816 |
assert_eq!( |
| 817 |
post(app, "/orgs/acme/settings/assistants", Some(&bob), &format!("_csrf={csrf}&action=prompt&prompt=x")).await.status, |
| 818 |
StatusCode::NOT_FOUND |
| 819 |
); |
| 820 |
assert_eq!( |
| 821 |
post(app, "/acme/plans/settings/assistants", Some(&bob), &format!("_csrf={csrf}&action=prompt&prompt=x")).await.status, |
| 822 |
StatusCode::NOT_FOUND |
| 823 |
); |
| 824 |
|
| 825 |
let r = post(app, "/acme/plans/settings/delete", Some(&andy), &format!("_csrf={csrf}&confirm=acme%2Fplans")).await; |
| 826 |
assert!(r.status == StatusCode::SEE_OTHER || r.status == StatusCode::OK, "{} {}", r.status, r.body); |
| 827 |
assert!(assistants::list(&app.db, Scope::Repo(repo.id)).await.unwrap().is_empty()); |
| 828 |
} |
| 829 |
|
| 830 |
#[tokio::test(flavor = "multi_thread")] |
| 831 |
async fn accounts_elsewhere_are_checked_and_shown_on_the_profile() { |
| 832 |
let h = hub().await; |
| 833 |
let app = &h.app; |
| 834 |
let andy = register(app, "andy", "").await; |
| 835 |
let page = get(app, "/settings", Some(&andy)).await.body; |
| 836 |
let csrf = csrf_of(&page); |
| 837 |
let base = format!("_csrf={csrf}&display_name=Andy&email=andy%40example.com&bio=&website=&location="); |
| 838 |
let r = post(app, "/settings", Some(&andy), &format!("{base}&bluesky=%40Andy.bsky.social&mastodon=https%3A%2F%2FFosstodon.org%2F%40andy&github=https%3A%2F%2Fgithub.com%2Fandymccall%2F")).await; |
| 839 |
assert!( |
| 840 |
r.body.contains("value=\"andy.bsky.social\"") |
| 841 |
&& r.body.contains("value=\"@[email protected]\"") |
| 842 |
&& r.body.contains("value=\"andymccall\""), |
| 843 |
"{}", |
| 844 |
r.body |
| 845 |
); |
| 846 |
let profile = get(app, "/andy", None).await.body; |
| 847 |
assert!(profile.contains("href=\"https://bsky.app/profile/andy.bsky.social\"") && profile.contains("@andy.bsky.social"), "{profile}"); |
| 848 |
assert!(profile.contains("href=\"https://fosstodon.org/@andy\"") && profile.contains("rel=\"nofollow noopener me\""), "{profile}"); |
| 849 |
assert!(profile.contains("href=\"https://github.com/andymccall\"") && profile.contains("title=\"GitHub\""), "{profile}"); |
| 850 |
|
| 851 |
let r = post(app, "/settings", Some(&andy), &format!("{base}&bluesky=not+a+handle&mastodon=&github=")).await; |
| 852 |
assert!(r.body.contains("not a Bluesky handle"), "{}", r.body); |
| 853 |
assert!(get(app, "/andy", None).await.body.contains("andy.bsky.social")); |
| 854 |
|
| 855 |
post(app, "/settings", Some(&andy), &format!("{base}&bluesky=&mastodon=&github=")).await; |
| 856 |
assert!(!get(app, "/andy", None).await.body.contains("class=\"social\"")); |
| 857 |
} |
| 858 |
|
| 859 |
|
| 860 |
async fn mock_provider() -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) { |
| 861 |
use std::sync::{Arc, Mutex}; |
| 862 |
let seen: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(vec![])); |
| 863 |
let s1 = seen.clone(); |
| 864 |
let s2 = seen.clone(); |
| 865 |
let router = axum::Router::new() |
| 866 |
.route( |
| 867 |
"/github/token", |
| 868 |
axum::routing::post(move |body: String| { |
| 869 |
let s1 = s1.clone(); |
| 870 |
async move { |
| 871 |
s1.lock().unwrap().push(format!("github token {body}")); |
| 872 |
axum::Json(serde_json::json!({ "access_token": "gh-token", "token_type": "bearer" })) |
| 873 |
} |
| 874 |
}), |
| 875 |
) |
| 876 |
.route( |
| 877 |
"/github/userinfo", |
| 878 |
axum::routing::get(|headers: axum::http::HeaderMap| async move { |
| 879 |
assert_eq!(headers.get("authorization").and_then(|v| v.to_str().ok()), Some("Bearer gh-token")); |
| 880 |
axum::Json(serde_json::json!({ "id": 583231, "login": "octocat", "name": "The Octocat", "email": null })) |
| 881 |
}), |
| 882 |
) |
| 883 |
.route( |
| 884 |
"/github/userinfo/emails", |
| 885 |
axum::routing::get(|| async { |
| 886 |
axum::Json(serde_json::json!([ |
| 887 |
{ "email": "[email protected]", "primary": false, "verified": false }, |
| 888 |
{ "email": "[email protected]", "primary": true, "verified": true } |
| 889 |
])) |
| 890 |
}), |
| 891 |
) |
| 892 |
.route( |
| 893 |
"/google/token", |
| 894 |
axum::routing::post(move |body: String| { |
| 895 |
let s2 = s2.clone(); |
| 896 |
async move { |
| 897 |
s2.lock().unwrap().push(format!("google token {body}")); |
| 898 |
axum::Json(serde_json::json!({ "access_token": "g-token", "id_token": "x" })) |
| 899 |
} |
| 900 |
}), |
| 901 |
) |
| 902 |
.route( |
| 903 |
"/google/userinfo", |
| 904 |
axum::routing::get(|| async { |
| 905 |
axum::Json(serde_json::json!({ "sub": "1076915035000615071", "email": "[email protected]", "email_verified": true, "name": "Andy McCall" })) |
| 906 |
}), |
| 907 |
); |
| 908 |
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
| 909 |
let port = listener.local_addr().unwrap().port(); |
| 910 |
tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); |
| 911 |
(format!("http://127.0.0.1:{port}"), seen) |
| 912 |
} |
| 913 |
|
| 914 |
fn state_of(res: &Res) -> (String, String) { |
| 915 |
let cookie = res.raw_cookies.iter().find(|c| c.starts_with("oxyhub_signin=")).expect("state cookie").clone(); |
| 916 |
let value = cookie.trim_start_matches("oxyhub_signin=").split(';').next().unwrap().to_string(); |
| 917 |
(cookie, value) |
| 918 |
} |
| 919 |
|
| 920 |
#[tokio::test(flavor = "multi_thread")] |
| 921 |
async fn people_sign_in_with_github_or_google_once_an_admin_sets_it_up() { |
| 922 |
hermetic(); |
| 923 |
let (mock, seen) = mock_provider().await; |
| 924 |
let dir = tempfile::tempdir().unwrap(); |
| 925 |
let mut config = Config::load(dir.path()).unwrap(); |
| 926 |
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
| 927 |
let port = listener.local_addr().unwrap().port(); |
| 928 |
config.server.base_url = format!("http://127.0.0.1:{port}"); |
| 929 |
config.registration.mode = "open".into(); |
| 930 |
config.auth.signin_mock = Some(mock.clone()); |
| 931 |
let app = oxygenhub::build(config).await.unwrap(); |
| 932 |
let router = oxygenhub::router(app.clone()); |
| 933 |
tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); |
| 934 |
let app = &app; |
| 935 |
let andy = register(app, "andy", "").await; |
| 936 |
let andy_row = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap(); |
| 937 |
db::accounts::set_email_verified(&app.db, andy_row.id, true).await.unwrap(); |
| 938 |
|
| 939 |
|
| 940 |
assert!(!get(app, "/login", None).await.body.contains("/login/github")); |
| 941 |
let admin = get(app, "/admin", Some(&andy)).await.body; |
| 942 |
assert!(admin.contains("Sign in with") && admin.contains(&format!("http://127.0.0.1:{port}/login/github/callback")), "{admin}"); |
| 943 |
let csrf = csrf_of(&admin); |
| 944 |
let r = |
| 945 |
post(app, "/admin/signin", Some(&andy), &format!("_csrf={csrf}&provider=github&client_id=gh-cid&secret=gh-sek&enabled=1")).await; |
| 946 |
assert!(r.body.contains("Signing in with GitHub is on.") && !r.body.contains("gh-sek"), "{}", r.body); |
| 947 |
let r = post(app, "/admin/signin", Some(&andy), &format!("_csrf={csrf}&provider=google&client_id=g-cid&secret=g-sek&enabled=1")).await; |
| 948 |
assert!(r.body.contains("Signing in with Google is on."), "{}", r.body); |
| 949 |
let login = get(app, "/login", None).await.body; |
| 950 |
assert!(login.contains("href=\"/login/github\"") && login.contains("href=\"/login/google\""), "{login}"); |
| 951 |
|
| 952 |
|
| 953 |
let r = get(app, "/login/github", None).await; |
| 954 |
assert_eq!(r.status, StatusCode::SEE_OTHER); |
| 955 |
let to = r.location.clone().unwrap(); |
| 956 |
assert!(to.starts_with(&format!("{mock}/github/authorize?client_id=gh-cid&redirect_uri=")) && to.contains("&state="), "{to}"); |
| 957 |
let (cookie, state) = state_of(&r); |
| 958 |
let bad = send_with(app, "GET", "/login/github/callback?code=c1&state=wrong", Some(&cookie), None).await; |
| 959 |
assert!(bad.location.as_deref().unwrap_or("").starts_with("/login?error="), "{:?}", bad.location); |
| 960 |
let ok = send_with(app, "GET", &format!("/login/github/callback?code=c1&state={state}"), Some(&cookie), None).await; |
| 961 |
assert_eq!(ok.status, StatusCode::SEE_OTHER, "{}", ok.body); |
| 962 |
assert_eq!(ok.location.as_deref(), Some("/")); |
| 963 |
let octo_cookie = ok.cookie.clone().expect("signed in"); |
| 964 |
let octo = db::accounts::account_by_name(&app.db, "octocat").await.unwrap().expect("account made from the GitHub login"); |
| 965 |
assert!( |
| 966 |
(octo.email.as_str(), octo.email_verified, octo.display_name.as_str()) == ("[email protected]", true, "The Octocat"), |
| 967 |
"{octo:?}" |
| 968 |
); |
| 969 |
assert!(seen.lock().unwrap().iter().any(|s| s.contains("github token") && s.contains("code=c1") && s.contains("client_secret=gh-sek"))); |
| 970 |
assert!(get(app, "/settings", Some(&octo_cookie)).await.body.contains("octocat")); |
| 971 |
|
| 972 |
let r = get(app, "/login/github", None).await; |
| 973 |
let (cookie, state) = state_of(&r); |
| 974 |
let ok = send_with(app, "GET", &format!("/login/github/callback?code=c2&state={state}"), Some(&cookie), None).await; |
| 975 |
assert_eq!(ok.location.as_deref(), Some("/")); |
| 976 |
assert_eq!(db::accounts::account_count(&app.db).await.unwrap(), 2); |
| 977 |
|
| 978 |
|
| 979 |
let r = get(app, "/login/google", None).await; |
| 980 |
assert!(r.location.clone().unwrap().contains("prompt=select_account")); |
| 981 |
let (cookie, state) = state_of(&r); |
| 982 |
let ok = send_with(app, "GET", &format!("/login/google/callback?code=c3&state={state}"), Some(&cookie), None).await; |
| 983 |
assert_eq!(ok.location.as_deref(), Some("/"), "{}", ok.body); |
| 984 |
let as_andy = ok.cookie.clone().unwrap(); |
| 985 |
let sec = get(app, "/settings/security", Some(&as_andy)).await.body; |
| 986 |
assert!( |
| 987 |
sec.contains("Connected accounts") |
| 988 |
&& sec.contains("Google: <strong>andy</strong>") |
| 989 |
&& sec.contains("GitHub: <a href=\"/settings/security/connect/github\">connect</a>"), |
| 990 |
"{sec}" |
| 991 |
); |
| 992 |
assert_eq!(db::accounts::account_count(&app.db).await.unwrap(), 2, "no third account"); |
| 993 |
|
| 994 |
let csrf_s = csrf_of(&sec); |
| 995 |
let r = post(app, "/settings/security/disconnect/google", Some(&as_andy), &format!("_csrf={csrf_s}")).await; |
| 996 |
assert!(r.location.as_deref().unwrap_or("").contains("notice=Google%20disconnected"), "{:?}", r.location); |
| 997 |
assert!(!get(app, "/settings/security", Some(&as_andy)).await.body.contains("Google: <strong>andy</strong>")); |
| 998 |
let r = get(app, "/settings/security/connect/google", Some(&as_andy)).await; |
| 999 |
assert_eq!(r.status, StatusCode::SEE_OTHER); |
| 1000 |
let (cookie, state) = state_of(&r); |
| 1001 |
let both = format!("{cookie}; oxyhub={as_andy}"); |
| 1002 |
let ok = send_with(app, "GET", &format!("/login/google/callback?code=c4&state={state}"), Some(&both), None).await; |
| 1003 |
assert!(ok.location.as_deref().unwrap_or("").contains("notice=Google%20connected%20as%20andy"), "{:?} {}", ok.location, ok.body); |
| 1004 |
|
| 1005 |
|
| 1006 |
db::accounts::set_setting(&app.db, "registration_mode", "closed").await.unwrap(); |
| 1007 |
sqlx::query("DELETE FROM account_logins WHERE provider = 'github'").execute(&app.db).await.unwrap(); |
| 1008 |
sqlx::query("UPDATE accounts SET email_verified = 0 WHERE username = 'octocat'").execute(&app.db).await.unwrap(); |
| 1009 |
let r = get(app, "/login/github", None).await; |
| 1010 |
let (cookie, state) = state_of(&r); |
| 1011 |
let no = send_with(app, "GET", &format!("/login/github/callback?code=c5&state={state}"), Some(&cookie), None).await; |
| 1012 |
assert!(no.location.as_deref().unwrap_or("").contains("registration%20is%20closed"), "{:?}", no.location); |
| 1013 |
assert!(no.cookie.is_none()); |
| 1014 |
} |
| 1015 |
|
| 1016 |
#[tokio::test(flavor = "multi_thread")] |
| 1017 |
async fn administration_is_sectioned_and_its_audit_log_pages() { |
| 1018 |
let h = hub().await; |
| 1019 |
let app = &h.app; |
| 1020 |
let andy = register(app, "andy", "").await; |
| 1021 |
let andy_row = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap(); |
| 1022 |
for i in 0..60 { |
| 1023 |
db::accounts::audit(&app.db, Some(&andy_row), "test.event", &format!("t{i}"), "").await.unwrap(); |
| 1024 |
} |
| 1025 |
let page = get(app, "/admin", Some(&andy)).await.body; |
| 1026 |
for id in ["accounts", "registration", "organisations", "signin", "mail", "variables", "secrets", "assistants", "audit"] { |
| 1027 |
assert!(page.contains(&format!("href=\"#{id}\"")) && page.contains(&format!("id=\"{id}\"")), "section {id}: {page}"); |
| 1028 |
} |
| 1029 |
assert!(page.contains("class=\"section-nav\"")); |
| 1030 |
assert!(page.contains(">t59<") && !page.contains(">t5<"), "the newest page only"); |
| 1031 |
let i = page.find("audit_before=").expect("an older link") + "audit_before=".len(); |
| 1032 |
let before: String = page[i..].chars().take_while(|c| c.is_ascii_digit()).collect(); |
| 1033 |
let older = get(app, &format!("/admin?audit_before={before}"), Some(&andy)).await.body; |
| 1034 |
assert!(older.contains(">t5<") && !older.contains(">t59<"), "the next page: {older}"); |
| 1035 |
} |
| 1036 |
|
| 1037 |
#[tokio::test(flavor = "multi_thread")] |
| 1038 |
async fn adding_an_assistant_offers_providers_and_only_models_the_endpoint_lists() { |
| 1039 |
let h = hub().await; |
| 1040 |
let app = &h.app; |
| 1041 |
let andy = register(app, "andy", "").await; |
| 1042 |
let router = axum::Router::new() |
| 1043 |
.route( |
| 1044 |
"/v1/models", |
| 1045 |
axum::routing::get(|headers: axum::http::HeaderMap| async move { |
| 1046 |
assert_eq!(headers.get("authorization").and_then(|v| v.to_str().ok()), Some("Bearer sk-m")); |
| 1047 |
axum::Json(serde_json::json!({ "data": [ |
| 1048 |
{ "id": "models/gemini-3-pro" }, { "id": "gemini-3-flash" }, { "id": "text-embedding-004" } |
| 1049 |
] })) |
| 1050 |
}), |
| 1051 |
) |
| 1052 |
.route( |
| 1053 |
"/v1/chat/completions", |
| 1054 |
axum::routing::post(|| async { axum::Json(serde_json::json!({ "choices": [{ "message": { "content": "OK" } }] })) }), |
| 1055 |
); |
| 1056 |
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
| 1057 |
let port = listener.local_addr().unwrap().port(); |
| 1058 |
tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); |
| 1059 |
let ep = format!("http%3A%2F%2F127.0.0.1%3A{port}%2Fv1%2Fchat%2Fcompletions"); |
| 1060 |
|
| 1061 |
let page = get(app, "/admin", Some(&andy)).await.body; |
| 1062 |
assert!(page.contains("data-preset-select") && page.contains("Google Gemini") && page.contains("Other (enter the endpoint)"), "{page}"); |
| 1063 |
assert!(page.contains("LIST MODELS")); |
| 1064 |
let csrf = csrf_of(&page); |
| 1065 |
|
| 1066 |
let r = |
| 1067 |
post(app, "/admin/assistants/models", Some(&andy), &format!("_csrf={csrf}&endpoint={ep}&api=chat-completions&token=sk-m")).await; |
| 1068 |
let v: serde_json::Value = serde_json::from_str(&r.body).unwrap(); |
| 1069 |
assert_eq!(v["models"], serde_json::json!(["gemini-3-flash", "gemini-3-pro"]), "{}", r.body); |
| 1070 |
|
| 1071 |
let r = post( |
| 1072 |
app, |
| 1073 |
"/admin/assistants", |
| 1074 |
Some(&andy), |
| 1075 |
&format!("_csrf={csrf}&action=add&preset=gemini&endpoint={ep}&model=gemini-2.5-pro&token=sk-m&enabled=1"), |
| 1076 |
) |
| 1077 |
.await; |
| 1078 |
assert!(r.body.contains("gemini-2.5-pro is not a model this endpoint offers") && r.body.contains("gemini-3-pro"), "{}", r.body); |
| 1079 |
|
| 1080 |
let r = post( |
| 1081 |
app, |
| 1082 |
"/admin/assistants", |
| 1083 |
Some(&andy), |
| 1084 |
&format!("_csrf={csrf}&action=add&preset=gemini&endpoint={ep}&model=gemini-3-pro&token=sk-m&enabled=1"), |
| 1085 |
) |
| 1086 |
.await; |
| 1087 |
assert!(r.body.contains("Gemini added and answered: OK"), "{}", r.body); |
| 1088 |
let a = &oxygenhub::assistants::list(&app.db, oxygenhub::levels::Scope::Site).await.unwrap()[0]; |
| 1089 |
assert_eq!((a.name.as_str(), a.api.as_str(), a.model.as_str()), ("Gemini", "chat-completions", "gemini-3-pro")); |
| 1090 |
|
| 1091 |
let r = |
| 1092 |
post(app, "/admin/assistants/models", Some(&andy), &format!("_csrf={csrf}&id={}&endpoint={ep}&api=chat-completions", a.id)).await; |
| 1093 |
assert!(r.body.contains("gemini-3-flash"), "{}", r.body); |
| 1094 |
} |