don't
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor(knot): remove unused definitions and move tests module into separate file

Signed-off-by: tjh <x@tjh.dev>

tjh de193e79 07aa9645

+592 -646
+5 -646
crates/gordian-knot/src/lib.rs
··· 1 - use gordian_types::Nsid; 2 - 3 1 pub mod command; 4 2 pub mod extractors; 5 3 pub mod model; 4 + pub mod nsid; 6 5 pub mod private; 7 6 pub mod public; 8 7 pub mod services; 9 8 pub mod sync; 10 9 pub mod types; 11 - mod util; 12 10 13 11 #[cfg(test)] 14 12 pub(crate) mod mock; 13 + #[cfg(test)] 14 + mod tests; 15 15 16 - pub mod nsid { 17 - use gordian_types::Nsid; 18 - 19 - macro_rules! nsid { 20 - ($nsid:literal) => { 21 - unsafe { Nsid::from_static_unchecked($nsid) } 22 - }; 23 - } 24 - 25 - pub const SH_TANGLED_KNOT_MEMBER: &Nsid = nsid!("sh.tangled.knot.member"); 26 - pub const SH_TANGLED_PUBLICKEY: &Nsid = nsid!("sh.tangled.publicKey"); 27 - pub const SH_TANGLED_REPO: &Nsid = nsid!("sh.tangled.repo"); 28 - pub const SH_TANGLED_REPO_COLLABORATOR: &Nsid = nsid!("sh.tangled.repo.collaborator"); 29 - pub const SH_TANGLED_REPO_CREATE: &Nsid = nsid!("sh.tangled.repo.create"); 30 - pub const SH_TANGLED_REPO_DELETE: &Nsid = nsid!("sh.tangled.repo.delete"); 31 - pub const SH_TANGLED_REPO_GITRECEIVEPACK: &Nsid = nsid!("sh.tangled.repo.gitReceivePack"); 32 - pub const SH_TANGLED_REPO_SETDEFAULTBRANCH: &Nsid = nsid!("sh.tangled.repo.setDefaultBranch"); 33 - } 16 + mod macros; 34 17 35 18 pub use gordian_lexicon as lexicon; 19 + pub use model::Knot; 36 20 37 21 /// Atmosphere lexicon for the records relevant to knot server. 38 22 #[derive(Debug, serde::Deserialize, serde::Serialize)] ··· 32 48 Repo(lexicon::sh_tangled::repo::Repo<'a>), 33 49 #[serde(rename = "sh.tangled.repo.collaborator")] 34 50 RepoCollaborator(lexicon::sh_tangled::repo::Collaborator<'a>), 35 - } 36 - 37 - /// NSIDs of interest to a knot server. 38 - pub const NSIDS: &[&Nsid] = { 39 - &[ 40 - nsid::SH_TANGLED_KNOT_MEMBER, 41 - nsid::SH_TANGLED_PUBLICKEY, 42 - nsid::SH_TANGLED_REPO, 43 - nsid::SH_TANGLED_REPO_COLLABORATOR, 44 - ] 45 - }; 46 - 47 - pub use model::Knot; 48 - 49 - #[cfg(test)] 50 - mod tests { 51 - use gordian_auth::jwt::Claims; 52 - use gordian_lexicon::sh_tangled; 53 - use gordian_types::{Did, Tid}; 54 - 55 - use axum::{ 56 - body::Body, 57 - http::{Request, StatusCode}, 58 - }; 59 - use time::{OffsetDateTime, format_description::well_known::Rfc3339}; 60 - use tower::ServiceExt; 61 - 62 - use crate::model::Knot; 63 - 64 - const TEST_DID: &str = "did:plc:65gha4t3avpfpzmvpbwovss7"; 65 - const TEST_INSTANCE: &str = "lib-knot-test"; 66 - 67 - fn get(uri: &str) -> Request<Body> { 68 - Request::builder().uri(uri).body(Body::empty()).unwrap() 69 - } 70 - 71 - #[tokio::test] 72 - async fn can_query_knot_owner() { 73 - let (_, _, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 74 - let response = super::public::router() 75 - .with_state(knot) 76 - .oneshot(get("/xrpc/sh.tangled.owner")) 77 - .await 78 - .unwrap(); 79 - 80 - assert_eq!(response.status(), StatusCode::OK); 81 - let body = axum::body::to_bytes(response.into_body(), 1000) 82 - .await 83 - .unwrap(); 84 - 85 - assert_eq!( 86 - body.as_ref(), 87 - format!("{{\"owner\":\"{TEST_DID}\"}}").as_bytes() 88 - ); 89 - 90 - let resp: sh_tangled::owner::Output = serde_json::from_slice(&body).unwrap(); 91 - assert_eq!(resp.owner.as_str(), TEST_DID); 92 - } 93 - 94 - #[tokio::test] 95 - async fn xrpc_sh_tangled_repo_missing_repo() { 96 - let (_, _, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 97 - for particle in ["tree", "log", "tags", "branches"] { 98 - let response = super::public::router() 99 - .with_state(knot.clone()) 100 - .oneshot(get(&format!("/xrpc/sh.tangled.repo.{particle}"))) 101 - .await 102 - .unwrap(); 103 - 104 - assert_eq!(response.status(), StatusCode::BAD_REQUEST); 105 - } 106 - } 107 - 108 - #[tokio::test] 109 - async fn xrpc_sh_tangled_repo_bad_repo_format() { 110 - let (_, _, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 111 - for particle in ["tree", "log", "tags", "branches"] { 112 - // Missing repo name 113 - let response = super::public::router() 114 - .with_state(knot.clone()) 115 - .oneshot(get(&format!( 116 - "/xrpc/sh.tangled.repo.{particle}?repo=did:web:example.com" 117 - ))) 118 - .await 119 - .unwrap(); 120 - 121 - assert_eq!(response.status(), StatusCode::BAD_REQUEST); 122 - 123 - // Bad repo names '..' 124 - 125 - for repo_name in ["", "..", "../../secret-data", ".hidden", "/etc/passwd"] { 126 - let response = super::public::router() 127 - .with_state(knot.clone()) 128 - .oneshot(get(&format!( 129 - "/xrpc/sh.tangled.repo.{particle}?repo=did:web:example.com/{repo_name}" 130 - ))) 131 - .await 132 - .unwrap(); 133 - 134 - assert_eq!(response.status(), StatusCode::BAD_REQUEST); 135 - } 136 - } 137 - } 138 - 139 - #[tokio::test] 140 - async fn xrpc_sh_tangled_repo_not_found() { 141 - let (_, _, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 142 - for particle in ["tree", "log", "tags", "branches"] { 143 - let response = super::public::router() 144 - .with_state(knot.clone()) 145 - .oneshot(get(&format!( 146 - "/xrpc/sh.tangled.repo.{particle}?repo=did:web:example.com/non-existent-repo" 147 - ))) 148 - .await 149 - .unwrap(); 150 - 151 - assert_eq!(response.status(), StatusCode::NOT_FOUND); 152 - } 153 - } 154 - 155 - mod sh_tangled_repo_create { 156 - use crate::nsid::{SH_TANGLED_REPO_CREATE, SH_TANGLED_REPO_DELETE}; 157 - 158 - use super::super::public; 159 - use super::*; 160 - use axum::http::{HeaderValue, Method, Response, header}; 161 - use gordian_pds::Pds; 162 - 163 - fn make_claims<F>(iss: &Did, aud: &Did, modify_claims: F) -> Claims 164 - where 165 - F: FnOnce(&mut Claims), 166 - { 167 - let jti: [u8; 16] = rand::random(); 168 - let jti = data_encoding::BASE32_NOPAD_VISUAL 169 - .encode(&jti) 170 - .to_lowercase(); 171 - 172 - let mut claims = Claims { 173 - iss: iss.into(), 174 - aud: aud.into(), 175 - iat: OffsetDateTime::now_utc().unix_timestamp(), 176 - exp: OffsetDateTime::now_utc().unix_timestamp() + 10, 177 - lxm: None, 178 - jti: jti.into(), 179 - }; 180 - 181 - modify_claims(&mut claims); 182 - claims 183 - } 184 - 185 - async fn service_auth_with<F>( 186 - pds: &Pds, 187 - iss: &Did, 188 - aud: &Did, 189 - modify_claims: F, 190 - ) -> HeaderValue 191 - where 192 - F: FnOnce(&mut Claims), 193 - { 194 - let claims = make_claims(iss, aud, modify_claims); 195 - let authorization = pds.service_auth(&claims).await; 196 - HeaderValue::from_str(&authorization).unwrap() 197 - } 198 - 199 - #[tokio::test] 200 - async fn reject_wrong_method() { 201 - let (_, _, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 202 - let response = public::router() 203 - .with_state(knot.clone()) 204 - .oneshot(get("/xrpc/sh.tangled.repo.create")) 205 - .await 206 - .unwrap(); 207 - 208 - assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); 209 - } 210 - 211 - async fn create_repo_with<F>( 212 - knot: &Knot, 213 - pds: Pds, 214 - did: &Did, 215 - rkey: &str, 216 - repo_name: &str, 217 - source: Option<&str>, 218 - modify_claims: F, 219 - ) -> Response<Body> 220 - where 221 - F: Fn(&mut Claims) + Copy, 222 - { 223 - // Create fake PDS record for our new repository. 224 - pds.insert_record( 225 - did, 226 - "sh.tangled.repo", 227 - rkey, 228 - &serde_json::json!({ 229 - "name": repo_name, 230 - "knot": knot.instance_ident(), 231 - "source": source, 232 - "createdAt": OffsetDateTime::now_utc().format(&Rfc3339).unwrap() 233 - }), 234 - ) 235 - .await; 236 - 237 - // Generate the body of the 'sh.tangled.repo.create' request. 238 - let create = sh_tangled::repo::create::Input { 239 - rkey: rkey.to_string(), 240 - default_branch: Some("main".into()), 241 - source: None, 242 - }; 243 - 244 - let auth = service_auth_with(&pds, &did, &knot.instance, |claims| { 245 - claims.lxm = Some(SH_TANGLED_REPO_CREATE.into_boxed()); 246 - modify_claims(claims); 247 - }) 248 - .await; 249 - 250 - let response = public::router() 251 - .with_state(knot.clone()) 252 - .oneshot( 253 - Request::post("/xrpc/sh.tangled.repo.create") 254 - .header(header::AUTHORIZATION, auth) 255 - .header(header::CONTENT_TYPE, "application/json") 256 - .body(Body::new(serde_json::to_string(&create).unwrap())) 257 - .expect("sh.tangled.repo.create request"), 258 - ) 259 - .await 260 - .expect("xrpc response"); 261 - 262 - response 263 - } 264 - 265 - async fn create_repo( 266 - knot: &Knot, 267 - pds: Pds, 268 - did: &Did, 269 - rkey: &str, 270 - repo_name: &str, 271 - source: Option<&str>, 272 - ) -> Response<Body> { 273 - create_repo_with(knot, pds, did, rkey, repo_name, source, |_| {}).await 274 - } 275 - 276 - async fn repo_exists_in_db(knot: &Knot, did: &Did, rkey: &str) -> bool { 277 - knot.resolve_repo_key(&crate::types::repository_path::RepositoryPath { 278 - owner: did.into_boxed().into(), 279 - name: rkey.into(), 280 - }) 281 - .await 282 - .is_ok() 283 - } 284 - 285 - #[tokio::test] 286 - async fn can_create_repo() { 287 - let (_base, pds, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 288 - 289 - let did = Did::from_static(TEST_DID); 290 - pds.insert_identity(did, "tjh.dev").await; 291 - knot.add_member( 292 - "", 293 - "", 294 - "", 295 - &sh_tangled::knot::Member::new( 296 - &did, 297 - knot.instance_ident(), 298 - OffsetDateTime::now_utc(), 299 - ), 300 - ) 301 - .await 302 - .unwrap(); 303 - 304 - let rkey = Tid::from_datetime(OffsetDateTime::now_utc(), 0).to_string(); 305 - assert_eq!( 306 - create_repo(&knot, pds, did, &rkey, "test-repo", None) 307 - .await 308 - .status(), 309 - StatusCode::OK 310 - ); 311 - 312 - assert!(repo_exists_in_db(&knot, &did, &rkey).await); 313 - } 314 - 315 - #[tokio::test] 316 - async fn can_create_fork_from_at() { 317 - let (_base, pds, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 318 - 319 - let did = Did::from_static(TEST_DID); 320 - pds.insert_identity(did, "tjh.dev").await; 321 - knot.add_member( 322 - "", 323 - "", 324 - "", 325 - &sh_tangled::knot::Member::new( 326 - &did, 327 - knot.instance_ident(), 328 - OffsetDateTime::now_utc(), 329 - ), 330 - ) 331 - .await 332 - .unwrap(); 333 - 334 - // Create a record for the repository to fork from. 335 - // <https://pdsls.dev/at://did:plc:65gha4t3avpfpzmvpbwovss7/sh.tangled.repo/3m24udbjajf22#record> 336 - let aturi = pds 337 - .insert_record( 338 - did, 339 - "sh.tangled.repo", 340 - "3m24udbjajf22", 341 - &serde_json::json!({ 342 - "name": "gordian", 343 - "knot": "gordian.tjh.dev", 344 - "createdAt": "2025-10-01T10:45:52Z" 345 - }), 346 - ) 347 - .await; 348 - 349 - let rkey = Tid::from_datetime(OffsetDateTime::now_utc(), 0).to_string(); 350 - assert_eq!( 351 - create_repo(&knot, pds, did, &rkey, "test-repo", Some(&aturi)) 352 - .await 353 - .status(), 354 - StatusCode::OK 355 - ); 356 - 357 - assert!(repo_exists_in_db(&knot, &did, &rkey).await); 358 - } 359 - 360 - #[tokio::test] 361 - async fn can_create_fork_from_http() { 362 - let (_base, pds, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 363 - 364 - let did = Did::from_static(TEST_DID); 365 - pds.insert_identity(did, "tjh.dev").await; 366 - knot.add_member( 367 - "", 368 - "", 369 - "", 370 - &sh_tangled::knot::Member::new( 371 - &did, 372 - knot.instance_ident(), 373 - OffsetDateTime::now_utc(), 374 - ), 375 - ) 376 - .await 377 - .unwrap(); 378 - 379 - let rkey = Tid::from_datetime(OffsetDateTime::now_utc(), 0).to_string(); 380 - let source = 381 - Some("https://gordian.tjh.dev/did:plc:65gha4t3avpfpzmvpbwovss7/3m24udbjajf22"); 382 - assert_eq!( 383 - create_repo(&knot, pds, did, &rkey, "test-repo", source) 384 - .await 385 - .status(), 386 - StatusCode::OK 387 - ); 388 - 389 - assert!(repo_exists_in_db(&knot, &did, &rkey).await); 390 - } 391 - 392 - #[tokio::test] 393 - async fn can_create_fork_from_http_fail() { 394 - let (base, pds, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 395 - 396 - let did = Did::from_static(TEST_DID); 397 - pds.insert_identity(did, "tjh.dev").await; 398 - knot.add_member( 399 - "", 400 - "", 401 - "", 402 - &sh_tangled::knot::Member::new( 403 - &did, 404 - knot.instance_ident(), 405 - OffsetDateTime::now_utc(), 406 - ), 407 - ) 408 - .await 409 - .unwrap(); 410 - 411 - let rkey = Tid::from_datetime(OffsetDateTime::now_utc(), 0).to_string(); 412 - let source = 413 - Some("https://gordian.tjh.dev/did:plc:65gha4t3avpfpmvpbwovss7/3m24udbjajf22"); 414 - 415 - assert_ne!( 416 - create_repo(&knot, pds, did, &rkey, "test-repo", source) 417 - .await 418 - .status(), 419 - StatusCode::OK 420 - ); 421 - 422 - // Verifiy the repository wasn't created on disk. 423 - assert!( 424 - std::fs::exists(base.path().join(did.as_str()).join(&rkey)).is_ok_and(|val| !val), 425 - ); 426 - 427 - assert!(!repo_exists_in_db(&knot, &did, &rkey).await); 428 - } 429 - 430 - #[tokio::test] 431 - async fn rejects_if_owner_is_not_a_member() { 432 - let (_base, pds, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 433 - 434 - let did = Did::from_static(TEST_DID); 435 - pds.insert_identity(did, "tjh.dev").await; 436 - 437 - let rkey = Tid::from_datetime(OffsetDateTime::now_utc(), 0).to_string(); 438 - assert_ne!( 439 - create_repo_with(&knot, pds, did, &rkey, "test-repo", None, |_| {}) 440 - .await 441 - .status(), 442 - StatusCode::OK, 443 - ); 444 - 445 - assert!(!repo_exists_in_db(&knot, &did, &rkey).await); 446 - } 447 - 448 - #[tokio::test] 449 - async fn rejects_auth_issued_in_future() { 450 - let (_base, pds, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 451 - 452 - let did = Did::from_static(TEST_DID); 453 - pds.insert_identity(did, "tjh.dev").await; 454 - knot.add_member( 455 - "", 456 - "", 457 - "", 458 - &sh_tangled::knot::Member::new( 459 - &did, 460 - knot.instance_ident(), 461 - OffsetDateTime::now_utc(), 462 - ), 463 - ) 464 - .await 465 - .unwrap(); 466 - 467 - let rkey = Tid::from_datetime(OffsetDateTime::now_utc(), 0).to_string(); 468 - assert_eq!( 469 - create_repo_with(&knot, pds, did, &rkey, "test-repo", None, |claims| { 470 - // 471 - claims.iat = OffsetDateTime::now_utc().unix_timestamp() + 60; 472 - }) 473 - .await 474 - .status(), 475 - StatusCode::FORBIDDEN, 476 - "iat > now => should be 403 Forbidden" 477 - ); 478 - 479 - assert!(!repo_exists_in_db(&knot, &did, &rkey).await); 480 - } 481 - 482 - #[tokio::test] 483 - async fn rejects_auth_expired() { 484 - let (_base, pds, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 485 - 486 - let did = Did::from_static(TEST_DID); 487 - pds.insert_identity(did, "tjh.dev").await; 488 - knot.add_member( 489 - "", 490 - "", 491 - "", 492 - &sh_tangled::knot::Member::new( 493 - &did, 494 - knot.instance_ident(), 495 - OffsetDateTime::now_utc(), 496 - ), 497 - ) 498 - .await 499 - .unwrap(); 500 - 501 - let rkey = Tid::from_datetime(OffsetDateTime::now_utc(), 0).to_string(); 502 - assert_eq!( 503 - create_repo_with(&knot, pds, did, &rkey, "test-repo", None, |claims| { 504 - // 505 - claims.exp = OffsetDateTime::now_utc().unix_timestamp() - 1; 506 - }) 507 - .await 508 - .status(), 509 - StatusCode::FORBIDDEN, 510 - "exp < now => should be 403 Forbidden" 511 - ); 512 - } 513 - 514 - #[tokio::test] 515 - async fn can_delete_repo() { 516 - let (base, pds, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 517 - 518 - let did = Did::from_static(TEST_DID); 519 - pds.insert_identity(did, "tjh.dev").await; 520 - knot.add_member( 521 - "", 522 - "", 523 - "", 524 - &sh_tangled::knot::Member::new( 525 - &did, 526 - knot.instance_ident(), 527 - OffsetDateTime::now_utc(), 528 - ), 529 - ) 530 - .await 531 - .unwrap(); 532 - 533 - let rkey = Tid::from_datetime(OffsetDateTime::now_utc(), 0).to_string(); 534 - let name = "another-test-repo"; 535 - assert_eq!( 536 - create_repo(&knot, pds.clone(), did, &rkey, name, None) 537 - .await 538 - .status(), 539 - StatusCode::OK 540 - ); 541 - 542 - gix::open(base.path().join(did.as_str()).join(&rkey)) 543 - .expect("new repository should exist"); 544 - assert!(repo_exists_in_db(&knot, &did, &rkey).await); 545 - 546 - let delete = sh_tangled::repo::delete::Input { 547 - did: did.to_owned(), 548 - rkey: rkey.clone(), 549 - name: "another-test-repo".to_string(), 550 - }; 551 - 552 - // First check we cannot delete without auth. 553 - assert_eq!( 554 - public::router() 555 - .with_state(knot.clone()) 556 - .oneshot( 557 - Request::builder() 558 - .method(Method::POST) 559 - .uri("/xrpc/sh.tangled.repo.delete") 560 - .header(header::CONTENT_TYPE, "application/json") 561 - .body(Body::new(serde_json::to_string(&delete).unwrap())) 562 - .expect("sh.tangled.repo.delete request"), 563 - ) 564 - .await 565 - .expect("xrpc response") 566 - .status(), 567 - StatusCode::UNAUTHORIZED 568 - ); 569 - 570 - // Check repository has not been deleted. 571 - gix::open(base.path().join(did.as_str()).join(&rkey)).expect("repository should exist"); 572 - assert!(repo_exists_in_db(&knot, &did, &rkey).await); 573 - 574 - // Or with the wrong lxm. 575 - let auth = service_auth_with(&pds, &did, &knot.instance(), |claims| { 576 - claims.lxm = Some(SH_TANGLED_REPO_CREATE.into_boxed()); 577 - }) 578 - .await; 579 - 580 - assert_eq!( 581 - public::router() 582 - .with_state(knot.clone()) 583 - .oneshot( 584 - Request::builder() 585 - .method(Method::POST) 586 - .uri("/xrpc/sh.tangled.repo.delete") 587 - .header(header::CONTENT_TYPE, "application/json") 588 - .header(header::AUTHORIZATION, auth) 589 - .body(Body::new(serde_json::to_string(&delete).unwrap())) 590 - .expect("sh.tangled.repo.delete request"), 591 - ) 592 - .await 593 - .expect("xrpc response") 594 - .status(), 595 - StatusCode::FORBIDDEN 596 - ); 597 - 598 - // Check repository has not been deleted. 599 - gix::open(base.path().join(did.as_str()).join(&rkey)).expect("repository should exist"); 600 - assert!(repo_exists_in_db(&knot, &did, &rkey).await); 601 - 602 - // Valid auth, empty request body. 603 - // Or with the wrong auth. 604 - let auth = service_auth_with(&pds, &did, &knot.instance(), |claims| { 605 - claims.lxm = Some(SH_TANGLED_REPO_DELETE.into_boxed()); 606 - }) 607 - .await; 608 - assert_eq!( 609 - public::router() 610 - .with_state(knot.clone()) 611 - .oneshot( 612 - Request::builder() 613 - .method(Method::POST) 614 - .uri("/xrpc/sh.tangled.repo.delete") 615 - .header(header::CONTENT_TYPE, "application/json") 616 - .header(header::AUTHORIZATION, auth) 617 - .body(Body::empty()) 618 - .expect("sh.tangled.repo.delete request"), 619 - ) 620 - .await 621 - .expect("xrpc response") 622 - .status(), 623 - StatusCode::BAD_REQUEST 624 - ); 625 - 626 - // Check repository has not been deleted. 627 - gix::open(base.path().join(did.as_str()).join(&rkey)).expect("repository should exist"); 628 - assert!(repo_exists_in_db(&knot, &did, &rkey).await); 629 - 630 - // Or with the wrong auth. 631 - let auth = service_auth_with(&pds, &did, &knot.instance(), |claims| { 632 - claims.lxm = Some("sh.tangled.repo.delete".try_into().unwrap()); 633 - }) 634 - .await; 635 - 636 - assert_eq!( 637 - public::router() 638 - .with_state(knot.clone()) 639 - .oneshot( 640 - Request::builder() 641 - .method(Method::POST) 642 - .uri("/xrpc/sh.tangled.repo.delete") 643 - .header(header::CONTENT_TYPE, "application/json") 644 - .header(header::AUTHORIZATION, auth) 645 - .body(Body::new(serde_json::to_string(&delete).unwrap())) 646 - .expect("sh.tangled.repo.delete request"), 647 - ) 648 - .await 649 - .expect("xrpc response") 650 - .status(), 651 - StatusCode::OK 652 - ); 653 - 654 - // Check repository has been deleted. 655 - gix::open(base.path().join(did.as_str()).join(&rkey)) 656 - .expect_err("deleted repository should not exist"); 657 - assert!(!repo_exists_in_db(&knot, &did, &rkey).await); 658 - } 659 - } 660 51 }
+16
crates/gordian-knot/src/nsid.rs
··· 1 + use gordian_types::Nsid; 2 + 3 + macro_rules! nsid { 4 + ($nsid:literal) => { 5 + unsafe { Nsid::from_static_unchecked($nsid) } 6 + }; 7 + } 8 + 9 + pub const SH_TANGLED_KNOT_MEMBER: &Nsid = nsid!("sh.tangled.knot.member"); 10 + pub const SH_TANGLED_PUBLICKEY: &Nsid = nsid!("sh.tangled.publicKey"); 11 + pub const SH_TANGLED_REPO: &Nsid = nsid!("sh.tangled.repo"); 12 + pub const SH_TANGLED_REPO_COLLABORATOR: &Nsid = nsid!("sh.tangled.repo.collaborator"); 13 + pub const SH_TANGLED_REPO_CREATE: &Nsid = nsid!("sh.tangled.repo.create"); 14 + pub const SH_TANGLED_REPO_DELETE: &Nsid = nsid!("sh.tangled.repo.delete"); 15 + pub const SH_TANGLED_REPO_GITRECEIVEPACK: &Nsid = nsid!("sh.tangled.repo.gitReceivePack"); 16 + pub const SH_TANGLED_REPO_SETDEFAULTBRANCH: &Nsid = nsid!("sh.tangled.repo.setDefaultBranch");
+571
crates/gordian-knot/src/tests.rs
··· 1 + use gordian_auth::jwt::Claims; 2 + use gordian_lexicon::sh_tangled; 3 + use gordian_types::{Did, Tid}; 4 + 5 + use axum::{ 6 + body::Body, 7 + http::{Request, StatusCode}, 8 + }; 9 + use time::{OffsetDateTime, format_description::well_known::Rfc3339}; 10 + use tower::ServiceExt; 11 + 12 + use crate::model::Knot; 13 + 14 + const TEST_DID: &str = "did:plc:65gha4t3avpfpzmvpbwovss7"; 15 + const TEST_INSTANCE: &str = "lib-knot-test"; 16 + 17 + fn get(uri: &str) -> Request<Body> { 18 + Request::builder().uri(uri).body(Body::empty()).unwrap() 19 + } 20 + 21 + #[tokio::test] 22 + async fn can_query_knot_owner() { 23 + let (_, _, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 24 + let response = super::public::router() 25 + .with_state(knot) 26 + .oneshot(get("/xrpc/sh.tangled.owner")) 27 + .await 28 + .unwrap(); 29 + 30 + assert_eq!(response.status(), StatusCode::OK); 31 + let body = axum::body::to_bytes(response.into_body(), 1000) 32 + .await 33 + .unwrap(); 34 + 35 + assert_eq!( 36 + body.as_ref(), 37 + format!("{{\"owner\":\"{TEST_DID}\"}}").as_bytes() 38 + ); 39 + 40 + let resp: sh_tangled::owner::Output = serde_json::from_slice(&body).unwrap(); 41 + assert_eq!(resp.owner.as_str(), TEST_DID); 42 + } 43 + 44 + #[tokio::test] 45 + async fn xrpc_sh_tangled_repo_missing_repo() { 46 + let (_, _, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 47 + for particle in ["tree", "log", "tags", "branches"] { 48 + let response = super::public::router() 49 + .with_state(knot.clone()) 50 + .oneshot(get(&format!("/xrpc/sh.tangled.repo.{particle}"))) 51 + .await 52 + .unwrap(); 53 + 54 + assert_eq!(response.status(), StatusCode::BAD_REQUEST); 55 + } 56 + } 57 + 58 + #[tokio::test] 59 + async fn xrpc_sh_tangled_repo_bad_repo_format() { 60 + let (_, _, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 61 + for particle in ["tree", "log", "tags", "branches"] { 62 + // Missing repo name 63 + let response = super::public::router() 64 + .with_state(knot.clone()) 65 + .oneshot(get(&format!( 66 + "/xrpc/sh.tangled.repo.{particle}?repo=did:web:example.com" 67 + ))) 68 + .await 69 + .unwrap(); 70 + 71 + assert_eq!(response.status(), StatusCode::BAD_REQUEST); 72 + 73 + // Bad repo names '..' 74 + 75 + for repo_name in ["", "..", "../../secret-data", ".hidden", "/etc/passwd"] { 76 + let response = super::public::router() 77 + .with_state(knot.clone()) 78 + .oneshot(get(&format!( 79 + "/xrpc/sh.tangled.repo.{particle}?repo=did:web:example.com/{repo_name}" 80 + ))) 81 + .await 82 + .unwrap(); 83 + 84 + assert_eq!(response.status(), StatusCode::BAD_REQUEST); 85 + } 86 + } 87 + } 88 + 89 + #[tokio::test] 90 + async fn xrpc_sh_tangled_repo_not_found() { 91 + let (_, _, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 92 + for particle in ["tree", "log", "tags", "branches"] { 93 + let response = super::public::router() 94 + .with_state(knot.clone()) 95 + .oneshot(get(&format!( 96 + "/xrpc/sh.tangled.repo.{particle}?repo=did:web:example.com/non-existent-repo" 97 + ))) 98 + .await 99 + .unwrap(); 100 + 101 + assert_eq!(response.status(), StatusCode::NOT_FOUND); 102 + } 103 + } 104 + 105 + mod sh_tangled_repo_create { 106 + use crate::nsid::{SH_TANGLED_REPO_CREATE, SH_TANGLED_REPO_DELETE}; 107 + 108 + use super::super::public; 109 + use super::*; 110 + use axum::http::{HeaderValue, Method, Response, header}; 111 + use gordian_pds::Pds; 112 + 113 + fn make_claims<F>(iss: &Did, aud: &Did, modify_claims: F) -> Claims 114 + where 115 + F: FnOnce(&mut Claims), 116 + { 117 + let jti: [u8; 16] = rand::random(); 118 + let jti = data_encoding::BASE32_NOPAD_VISUAL 119 + .encode(&jti) 120 + .to_lowercase(); 121 + 122 + let mut claims = Claims { 123 + iss: iss.into(), 124 + aud: aud.into(), 125 + iat: OffsetDateTime::now_utc().unix_timestamp(), 126 + exp: OffsetDateTime::now_utc().unix_timestamp() + 10, 127 + lxm: None, 128 + jti: jti.into(), 129 + }; 130 + 131 + modify_claims(&mut claims); 132 + claims 133 + } 134 + 135 + async fn service_auth_with<F>(pds: &Pds, iss: &Did, aud: &Did, modify_claims: F) -> HeaderValue 136 + where 137 + F: FnOnce(&mut Claims), 138 + { 139 + let claims = make_claims(iss, aud, modify_claims); 140 + let authorization = pds.service_auth(&claims).await; 141 + HeaderValue::from_str(&authorization).unwrap() 142 + } 143 + 144 + #[tokio::test] 145 + async fn reject_wrong_method() { 146 + let (_, _, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 147 + let response = public::router() 148 + .with_state(knot.clone()) 149 + .oneshot(get("/xrpc/sh.tangled.repo.create")) 150 + .await 151 + .unwrap(); 152 + 153 + assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); 154 + } 155 + 156 + async fn create_repo_with<F>( 157 + knot: &Knot, 158 + pds: Pds, 159 + did: &Did, 160 + rkey: &str, 161 + repo_name: &str, 162 + source: Option<&str>, 163 + modify_claims: F, 164 + ) -> Response<Body> 165 + where 166 + F: Fn(&mut Claims) + Copy, 167 + { 168 + // Create fake PDS record for our new repository. 169 + pds.insert_record( 170 + did, 171 + "sh.tangled.repo", 172 + rkey, 173 + &serde_json::json!({ 174 + "name": repo_name, 175 + "knot": knot.instance_ident(), 176 + "source": source, 177 + "createdAt": OffsetDateTime::now_utc().format(&Rfc3339).unwrap() 178 + }), 179 + ) 180 + .await; 181 + 182 + // Generate the body of the 'sh.tangled.repo.create' request. 183 + let create = sh_tangled::repo::create::Input { 184 + rkey: rkey.to_string(), 185 + default_branch: Some("main".into()), 186 + source: None, 187 + }; 188 + 189 + let auth = service_auth_with(&pds, &did, &knot.instance, |claims| { 190 + claims.lxm = Some(SH_TANGLED_REPO_CREATE.into_boxed()); 191 + modify_claims(claims); 192 + }) 193 + .await; 194 + 195 + let response = public::router() 196 + .with_state(knot.clone()) 197 + .oneshot( 198 + Request::post("/xrpc/sh.tangled.repo.create") 199 + .header(header::AUTHORIZATION, auth) 200 + .header(header::CONTENT_TYPE, "application/json") 201 + .body(Body::new(serde_json::to_string(&create).unwrap())) 202 + .expect("sh.tangled.repo.create request"), 203 + ) 204 + .await 205 + .expect("xrpc response"); 206 + 207 + response 208 + } 209 + 210 + async fn create_repo( 211 + knot: &Knot, 212 + pds: Pds, 213 + did: &Did, 214 + rkey: &str, 215 + repo_name: &str, 216 + source: Option<&str>, 217 + ) -> Response<Body> { 218 + create_repo_with(knot, pds, did, rkey, repo_name, source, |_| {}).await 219 + } 220 + 221 + async fn repo_exists_in_db(knot: &Knot, did: &Did, rkey: &str) -> bool { 222 + knot.resolve_repo_key(&crate::types::repository_path::RepositoryPath { 223 + owner: did.into_boxed().into(), 224 + name: rkey.into(), 225 + }) 226 + .await 227 + .is_ok() 228 + } 229 + 230 + #[tokio::test] 231 + async fn can_create_repo() { 232 + let (_base, pds, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 233 + 234 + let did = Did::from_static(TEST_DID); 235 + pds.insert_identity(did, "tjh.dev").await; 236 + knot.add_member( 237 + "", 238 + "", 239 + "", 240 + &sh_tangled::knot::Member::new(&did, knot.instance_ident(), OffsetDateTime::now_utc()), 241 + ) 242 + .await 243 + .unwrap(); 244 + 245 + let rkey = Tid::from_datetime(OffsetDateTime::now_utc(), 0).to_string(); 246 + assert_eq!( 247 + create_repo(&knot, pds, did, &rkey, "test-repo", None) 248 + .await 249 + .status(), 250 + StatusCode::OK 251 + ); 252 + 253 + assert!(repo_exists_in_db(&knot, &did, &rkey).await); 254 + } 255 + 256 + #[tokio::test] 257 + async fn can_create_fork_from_at() { 258 + let (_base, pds, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 259 + 260 + let did = Did::from_static(TEST_DID); 261 + pds.insert_identity(did, "tjh.dev").await; 262 + knot.add_member( 263 + "", 264 + "", 265 + "", 266 + &sh_tangled::knot::Member::new(&did, knot.instance_ident(), OffsetDateTime::now_utc()), 267 + ) 268 + .await 269 + .unwrap(); 270 + 271 + // Create a record for the repository to fork from. 272 + // <https://pdsls.dev/at://did:plc:65gha4t3avpfpzmvpbwovss7/sh.tangled.repo/3m24udbjajf22#record> 273 + let aturi = pds 274 + .insert_record( 275 + did, 276 + "sh.tangled.repo", 277 + "3m24udbjajf22", 278 + &serde_json::json!({ 279 + "name": "gordian", 280 + "knot": "gordian.tjh.dev", 281 + "createdAt": "2025-10-01T10:45:52Z" 282 + }), 283 + ) 284 + .await; 285 + 286 + let rkey = Tid::from_datetime(OffsetDateTime::now_utc(), 0).to_string(); 287 + assert_eq!( 288 + create_repo(&knot, pds, did, &rkey, "test-repo", Some(&aturi)) 289 + .await 290 + .status(), 291 + StatusCode::OK 292 + ); 293 + 294 + assert!(repo_exists_in_db(&knot, &did, &rkey).await); 295 + } 296 + 297 + #[tokio::test] 298 + async fn can_create_fork_from_http() { 299 + let (_base, pds, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 300 + 301 + let did = Did::from_static(TEST_DID); 302 + pds.insert_identity(did, "tjh.dev").await; 303 + knot.add_member( 304 + "", 305 + "", 306 + "", 307 + &sh_tangled::knot::Member::new(&did, knot.instance_ident(), OffsetDateTime::now_utc()), 308 + ) 309 + .await 310 + .unwrap(); 311 + 312 + let rkey = Tid::from_datetime(OffsetDateTime::now_utc(), 0).to_string(); 313 + let source = Some("https://gordian.tjh.dev/did:plc:65gha4t3avpfpzmvpbwovss7/3m24udbjajf22"); 314 + assert_eq!( 315 + create_repo(&knot, pds, did, &rkey, "test-repo", source) 316 + .await 317 + .status(), 318 + StatusCode::OK 319 + ); 320 + 321 + assert!(repo_exists_in_db(&knot, &did, &rkey).await); 322 + } 323 + 324 + #[tokio::test] 325 + async fn can_create_fork_from_http_fail() { 326 + let (base, pds, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 327 + 328 + let did = Did::from_static(TEST_DID); 329 + pds.insert_identity(did, "tjh.dev").await; 330 + knot.add_member( 331 + "", 332 + "", 333 + "", 334 + &sh_tangled::knot::Member::new(&did, knot.instance_ident(), OffsetDateTime::now_utc()), 335 + ) 336 + .await 337 + .unwrap(); 338 + 339 + let rkey = Tid::from_datetime(OffsetDateTime::now_utc(), 0).to_string(); 340 + let source = Some("https://gordian.tjh.dev/did:plc:65gha4t3avpfpmvpbwovss7/3m24udbjajf22"); 341 + 342 + assert_ne!( 343 + create_repo(&knot, pds, did, &rkey, "test-repo", source) 344 + .await 345 + .status(), 346 + StatusCode::OK 347 + ); 348 + 349 + // Verifiy the repository wasn't created on disk. 350 + assert!(std::fs::exists(base.path().join(did.as_str()).join(&rkey)).is_ok_and(|val| !val),); 351 + 352 + assert!(!repo_exists_in_db(&knot, &did, &rkey).await); 353 + } 354 + 355 + #[tokio::test] 356 + async fn rejects_if_owner_is_not_a_member() { 357 + let (_base, pds, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 358 + 359 + let did = Did::from_static(TEST_DID); 360 + pds.insert_identity(did, "tjh.dev").await; 361 + 362 + let rkey = Tid::from_datetime(OffsetDateTime::now_utc(), 0).to_string(); 363 + assert_ne!( 364 + create_repo_with(&knot, pds, did, &rkey, "test-repo", None, |_| {}) 365 + .await 366 + .status(), 367 + StatusCode::OK, 368 + ); 369 + 370 + assert!(!repo_exists_in_db(&knot, &did, &rkey).await); 371 + } 372 + 373 + #[tokio::test] 374 + async fn rejects_auth_issued_in_future() { 375 + let (_base, pds, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 376 + 377 + let did = Did::from_static(TEST_DID); 378 + pds.insert_identity(did, "tjh.dev").await; 379 + knot.add_member( 380 + "", 381 + "", 382 + "", 383 + &sh_tangled::knot::Member::new(&did, knot.instance_ident(), OffsetDateTime::now_utc()), 384 + ) 385 + .await 386 + .unwrap(); 387 + 388 + let rkey = Tid::from_datetime(OffsetDateTime::now_utc(), 0).to_string(); 389 + assert_eq!( 390 + create_repo_with(&knot, pds, did, &rkey, "test-repo", None, |claims| { 391 + // 392 + claims.iat = OffsetDateTime::now_utc().unix_timestamp() + 60; 393 + }) 394 + .await 395 + .status(), 396 + StatusCode::FORBIDDEN, 397 + "iat > now => should be 403 Forbidden" 398 + ); 399 + 400 + assert!(!repo_exists_in_db(&knot, &did, &rkey).await); 401 + } 402 + 403 + #[tokio::test] 404 + async fn rejects_auth_expired() { 405 + let (_base, pds, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 406 + 407 + let did = Did::from_static(TEST_DID); 408 + pds.insert_identity(did, "tjh.dev").await; 409 + knot.add_member( 410 + "", 411 + "", 412 + "", 413 + &sh_tangled::knot::Member::new(&did, knot.instance_ident(), OffsetDateTime::now_utc()), 414 + ) 415 + .await 416 + .unwrap(); 417 + 418 + let rkey = Tid::from_datetime(OffsetDateTime::now_utc(), 0).to_string(); 419 + assert_eq!( 420 + create_repo_with(&knot, pds, did, &rkey, "test-repo", None, |claims| { 421 + // 422 + claims.exp = OffsetDateTime::now_utc().unix_timestamp() - 1; 423 + }) 424 + .await 425 + .status(), 426 + StatusCode::FORBIDDEN, 427 + "exp < now => should be 403 Forbidden" 428 + ); 429 + } 430 + 431 + #[tokio::test] 432 + async fn can_delete_repo() { 433 + let (base, pds, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 434 + 435 + let did = Did::from_static(TEST_DID); 436 + pds.insert_identity(did, "tjh.dev").await; 437 + knot.add_member( 438 + "", 439 + "", 440 + "", 441 + &sh_tangled::knot::Member::new(&did, knot.instance_ident(), OffsetDateTime::now_utc()), 442 + ) 443 + .await 444 + .unwrap(); 445 + 446 + let rkey = Tid::from_datetime(OffsetDateTime::now_utc(), 0).to_string(); 447 + let name = "another-test-repo"; 448 + assert_eq!( 449 + create_repo(&knot, pds.clone(), did, &rkey, name, None) 450 + .await 451 + .status(), 452 + StatusCode::OK 453 + ); 454 + 455 + gix::open(base.path().join(did.as_str()).join(&rkey)).expect("new repository should exist"); 456 + assert!(repo_exists_in_db(&knot, &did, &rkey).await); 457 + 458 + let delete = sh_tangled::repo::delete::Input { 459 + did: did.to_owned(), 460 + rkey: rkey.clone(), 461 + name: "another-test-repo".to_string(), 462 + }; 463 + 464 + // First check we cannot delete without auth. 465 + assert_eq!( 466 + public::router() 467 + .with_state(knot.clone()) 468 + .oneshot( 469 + Request::builder() 470 + .method(Method::POST) 471 + .uri("/xrpc/sh.tangled.repo.delete") 472 + .header(header::CONTENT_TYPE, "application/json") 473 + .body(Body::new(serde_json::to_string(&delete).unwrap())) 474 + .expect("sh.tangled.repo.delete request"), 475 + ) 476 + .await 477 + .expect("xrpc response") 478 + .status(), 479 + StatusCode::UNAUTHORIZED 480 + ); 481 + 482 + // Check repository has not been deleted. 483 + gix::open(base.path().join(did.as_str()).join(&rkey)).expect("repository should exist"); 484 + assert!(repo_exists_in_db(&knot, &did, &rkey).await); 485 + 486 + // Or with the wrong lxm. 487 + let auth = service_auth_with(&pds, &did, &knot.instance(), |claims| { 488 + claims.lxm = Some(SH_TANGLED_REPO_CREATE.into_boxed()); 489 + }) 490 + .await; 491 + 492 + assert_eq!( 493 + public::router() 494 + .with_state(knot.clone()) 495 + .oneshot( 496 + Request::builder() 497 + .method(Method::POST) 498 + .uri("/xrpc/sh.tangled.repo.delete") 499 + .header(header::CONTENT_TYPE, "application/json") 500 + .header(header::AUTHORIZATION, auth) 501 + .body(Body::new(serde_json::to_string(&delete).unwrap())) 502 + .expect("sh.tangled.repo.delete request"), 503 + ) 504 + .await 505 + .expect("xrpc response") 506 + .status(), 507 + StatusCode::FORBIDDEN 508 + ); 509 + 510 + // Check repository has not been deleted. 511 + gix::open(base.path().join(did.as_str()).join(&rkey)).expect("repository should exist"); 512 + assert!(repo_exists_in_db(&knot, &did, &rkey).await); 513 + 514 + // Valid auth, empty request body. 515 + // Or with the wrong auth. 516 + let auth = service_auth_with(&pds, &did, &knot.instance(), |claims| { 517 + claims.lxm = Some(SH_TANGLED_REPO_DELETE.into_boxed()); 518 + }) 519 + .await; 520 + assert_eq!( 521 + public::router() 522 + .with_state(knot.clone()) 523 + .oneshot( 524 + Request::builder() 525 + .method(Method::POST) 526 + .uri("/xrpc/sh.tangled.repo.delete") 527 + .header(header::CONTENT_TYPE, "application/json") 528 + .header(header::AUTHORIZATION, auth) 529 + .body(Body::empty()) 530 + .expect("sh.tangled.repo.delete request"), 531 + ) 532 + .await 533 + .expect("xrpc response") 534 + .status(), 535 + StatusCode::BAD_REQUEST 536 + ); 537 + 538 + // Check repository has not been deleted. 539 + gix::open(base.path().join(did.as_str()).join(&rkey)).expect("repository should exist"); 540 + assert!(repo_exists_in_db(&knot, &did, &rkey).await); 541 + 542 + // Or with the wrong auth. 543 + let auth = service_auth_with(&pds, &did, &knot.instance(), |claims| { 544 + claims.lxm = Some("sh.tangled.repo.delete".try_into().unwrap()); 545 + }) 546 + .await; 547 + 548 + assert_eq!( 549 + public::router() 550 + .with_state(knot.clone()) 551 + .oneshot( 552 + Request::builder() 553 + .method(Method::POST) 554 + .uri("/xrpc/sh.tangled.repo.delete") 555 + .header(header::CONTENT_TYPE, "application/json") 556 + .header(header::AUTHORIZATION, auth) 557 + .body(Body::new(serde_json::to_string(&delete).unwrap())) 558 + .expect("sh.tangled.repo.delete request"), 559 + ) 560 + .await 561 + .expect("xrpc response") 562 + .status(), 563 + StatusCode::OK 564 + ); 565 + 566 + // Check repository has been deleted. 567 + gix::open(base.path().join(did.as_str()).join(&rkey)) 568 + .expect_err("deleted repository should not exist"); 569 + assert!(!repo_exists_in_db(&knot, &did, &rkey).await); 570 + } 571 + }
crates/gordian-knot/src/util.rs crates/gordian-knot/src/macros.rs