diff --git a/server/src/at_record_server/handlers/crate_overlap.gleam b/server/src/at_record_server/handlers/crate_overlap.gleam index 540d9db..3e6d0a1 100644 --- a/server/src/at_record_server/handlers/crate_overlap.gleam +++ b/server/src/at_record_server/handlers/crate_overlap.gleam @@ -7,6 +7,7 @@ import at_record_server/crate.{type CrateEntry} import at_record_server/event_log import at_record_server/graph_follows import at_record_server/oauth/sessions.{type OauthSession} +import at_record_server/shelf_index import at_record_server/shelf_owner import atproto/xrpc.{type Client} import gleam/json @@ -41,26 +42,23 @@ fn do_get_crate_overlap( use client, session <- with_pds_client(ctx, id, session) case shelf_owner.resolve_actor(ctx, actor) { Error(Nil) -> error_json(404, "could not resolve that user") - Ok(#(did, _handle, pds)) -> + Ok(#(did, _handle, _pds)) -> case event_log.load(client, session) { Error(_) -> error_json(502, "could not load your crate from PDS") - Ok(own_stored) -> - // Live on every call (E3 of the appview-first roadmap): C6, the - // foreign side reading from the index instead, was left optional - // in lane C and hasn't been picked up. - case shelf_owner.fetch_public_entries(ctx, pds, did) { - Error(shelf_owner.FetchFailed) -> - error_json(502, "could not load that user's crate from their PDS") - Error(shelf_owner.RepoNotFound) -> - error_json(404, "that user's crate could not be found") - Error(shelf_owner.TooManyPages) -> - error_json(413, "that user's crate is too large to page through") - Ok(their_stored) -> - overlap(crate.fold(own_stored), crate.fold(their_stored)) - |> encode_overlap(viewer_follow_fields(client, session, did)) - |> json.to_string - |> wisp.json_response(200) - } + Ok(own_stored) -> { + // C6 of the appview-first roadmap: the foreign side reads from + // the shelf index instead of paging the actor's PDS live, so + // there is no PDS to fail or run away on -- an actor who resolves + // but has nothing indexed yet just folds to an empty crate, the + // same as a genuinely empty one. + let their_entries = + ctx.shelf_index.entries.list_for_did(did) + |> list.map(shelf_index.to_crate_entry) + overlap(crate.fold(own_stored), their_entries) + |> encode_overlap(viewer_follow_fields(client, session, did)) + |> json.to_string + |> wisp.json_response(200) + } } } } diff --git a/server/test/crate_overlap_test.gleam b/server/test/crate_overlap_test.gleam index 17d1748..eb28707 100644 --- a/server/test/crate_overlap_test.gleam +++ b/server/test/crate_overlap_test.gleam @@ -1,7 +1,13 @@ //// Pure overlap-computation tests plus end-to-end wiring tests for the -//// authed `shelf.getCrateOverlap` handler. +//// authed `shelf.getCrateOverlap` handler. Post-C6 (appview-first roadmap), +//// the foreign side reads `ctx.shelf_index` directly (no PDS fetch, no +//// seeding exception): the actor-side fixtures below seed the index the +//// same way `actor_shelf_test.gleam` does for the other index-served +//// handlers, and the actor's PDS host is never wired into the fake client, +//// so a stray live fetch would panic. import at_record/gen/defs.{CatalogRef, Snapshot} +import at_record/gen/shelf/entry.{ShelfEntry} import at_record_server/context.{type Context} import at_record_server/crate.{type CrateEntry, CrateEntry} import at_record_server/handlers/crate_overlap @@ -114,11 +120,10 @@ const actor_did = "did:plc:pub" const actor_handle = "pub.test" -// url_guard now resolves the host for real (fail-closed on nxdomain), so -// this can't be an RFC 2606 reserved, non-resolving name; a TEST-NET-3 -// (RFC 5737) literal resolves locally with no DNS and stays outside every -// private range, so the suite stays offline. The xrpc client below is -// still a stub, no real fetch happens. +// `resolve_actor` still checks `is_safe_pds_url` on the resolved identity's +// pds even though nothing ever fetches from it now, so this has to be a real +// (url_guard-resolvable), non-private address; a TEST-NET-3 (RFC 5737) +// literal fits and keeps the suite offline. const actor_pds_host = "203.0.113.10" const session_cookie = "ar_oauth_sid" @@ -181,14 +186,6 @@ fn own_record(rkey: String, title: String, release_rkey: String) -> json.Json { shelf_record(own_did, rkey, title, release_rkey) } -fn actor_record( - rkey: String, - title: String, - release_rkey: String, -) -> json.Json { - shelf_record(actor_did, rkey, title, release_rkey) -} - fn list_records_body(records: List(json.Json)) -> String { json.object([#("records", json.preprocessed_array(records))]) |> json.to_string @@ -204,20 +201,59 @@ fn resolve_body() -> String { |> json.to_string } -fn network_client(own_body: String, actor_body: String) -> xrpc.Client { +/// Resolves the actor and answers the caller's own PDS; the actor's own pds +/// host is deliberately left unwired, so the foreign side ever reaching for +/// it (a C6 regression) panics the test instead of silently passing. +fn network_client(own_body: String) -> xrpc.Client { xrpc.Client(send: fn(req) { case req.host { "resolver.test" -> Ok(response.Response(200, [], bit_array.from_string(resolve_body()))) "pds.example" -> Ok(response.Response(200, [], bit_array.from_string(own_body))) - host if host == actor_pds_host -> - Ok(response.Response(200, [], bit_array.from_string(actor_body))) - _ -> panic as "unexpected host" + _ -> panic as "the foreign side must read the shelf index, not a PDS" } }) } +fn actor_entry_uri(rkey: String) -> String { + "at://" <> actor_did <> "/dev.mokkenstorm.crate.shelf.entry/" <> rkey +} + +/// Seeds one genesis entry straight into `ctx.shelf_index` for the actor did, +/// the way `actor_shelf_test.gleam` seeds the other index-served handlers' +/// fixtures -- the foreign side has no PDS to fetch from post-C6. +fn seed_actor_entry( + ctx: Context, + rkey: String, + title: String, + release_rkey: String, +) -> Nil { + support.seed_shelf_entry( + ctx, + actor_did, + actor_entry_uri(rkey), + rkey, + ShelfEntry( + ..support.blank_shelf_entry(), + snapshot: Some(Snapshot( + title:, + artist_display: "", + year: None, + format: None, + thumb_url: None, + cover: None, + )), + release: Some(CatalogRef( + cid: "bafyrel", + uri: "at://shared/dev.mokkenstorm.crate.catalog.release/" + <> release_rkey, + external_ids: None, + )), + ), + ) +} + fn follow_records_body(records: List(json.Json)) -> String { list_records_body(records) } @@ -246,7 +282,6 @@ fn follow_record_json(rkey: String, subject: String) -> json.Json { /// `collection=` query param, since both hit the same own-pds host). fn network_client_with_follows( own_body: String, - actor_body: String, follows_body: String, ) -> xrpc.Client { xrpc.Client(send: fn(req) { @@ -260,9 +295,7 @@ fn network_client_with_follows( False -> Ok(response.Response(200, [], bit_array.from_string(own_body))) } - host if host == actor_pds_host -> - Ok(response.Response(200, [], bit_array.from_string(actor_body))) - _ -> panic as "unexpected host" + _ -> panic as "the foreign side must read the shelf index, not a PDS" } }) } @@ -293,8 +326,7 @@ fn authed_request(path: String, cfg: config.Config) -> wisp.Request { } pub fn get_crate_overlap_requires_a_session_test() { - let #(ctx, _cfg) = - test_context(network_client(list_records_body([]), list_records_body([]))) + let #(ctx, _cfg) = test_context(network_client(list_records_body([]))) let resp = crate_overlap.get_crate_overlap( simulate.request( @@ -307,8 +339,7 @@ pub fn get_crate_overlap_requires_a_session_test() { } pub fn get_crate_overlap_requires_an_actor_test() { - let #(ctx, cfg) = - test_context(network_client(list_records_body([]), list_records_body([]))) + let #(ctx, cfg) = test_context(network_client(list_records_body([]))) let req = authed_request(support.xrpc("shelf.getCrateOverlap"), cfg) let resp = crate_overlap.get_crate_overlap(req, ctx) assert resp.status == 400 @@ -320,12 +351,9 @@ pub fn get_crate_overlap_combines_both_crates_test() { own_record("3aaa", "Spiderland", "shared-spiderland"), own_record("3bbb", "Loveless", "own-only-loveless"), ]) - let actor_body = - list_records_body([ - actor_record("4aaa", "Spiderland", "shared-spiderland"), - actor_record("4bbb", "Isn't Anything", "actor-only-isnt"), - ]) - let #(ctx, cfg) = test_context(network_client(own_body, actor_body)) + let #(ctx, cfg) = test_context(network_client(own_body)) + seed_actor_entry(ctx, "4aaa", "Spiderland", "shared-spiderland") + seed_actor_entry(ctx, "4bbb", "Isn't Anything", "actor-only-isnt") let req = authed_request(support.xrpc("shelf.getCrateOverlap?actor=pub.test"), cfg) let resp = crate_overlap.get_crate_overlap(req, ctx) @@ -334,10 +362,25 @@ pub fn get_crate_overlap_combines_both_crates_test() { assert support.field_int(body, ["commonCount"]) == Ok(1) } +/// C6: an actor who resolves but has nothing in the shelf index (never +/// visited, or genuinely crate-less) folds to an empty crate rather than a +/// 404/502/413 -- there's no PDS fetch left to fail or run away on. +pub fn get_crate_overlap_treats_an_unindexed_actor_as_an_empty_crate_test() { + let own_body = + list_records_body([own_record("3aaa", "Spiderland", "shared-spiderland")]) + let #(ctx, cfg) = test_context(network_client(own_body)) + let req = + authed_request(support.xrpc("shelf.getCrateOverlap?actor=pub.test"), cfg) + let resp = crate_overlap.get_crate_overlap(req, ctx) + assert resp.status == 200 + let body = simulate.read_body(resp) + assert support.field_int(body, ["commonCount"]) == Ok(0) + assert support.field_string(body, ["bothWantedTitle"]) == Error(Nil) +} + pub fn get_crate_overlap_sets_viewer_follows_when_a_follow_record_exists_test() { let client = network_client_with_follows( - list_records_body([]), list_records_body([]), follow_records_body([follow_record_json("3eee", actor_did)]), ) @@ -354,11 +397,7 @@ pub fn get_crate_overlap_sets_viewer_follows_when_a_follow_record_exists_test() pub fn get_crate_overlap_omits_follow_uri_when_not_following_test() { let client = - network_client_with_follows( - list_records_body([]), - list_records_body([]), - follow_records_body([]), - ) + network_client_with_follows(list_records_body([]), follow_records_body([])) let #(ctx, cfg) = test_context(client) let req = authed_request(support.xrpc("shelf.getCrateOverlap?actor=pub.test"), cfg)