mirror of
https://github.com/thegeneralist01/archivr
synced 2026-10-09 12:55:00 +02:00
feat(collections): public collections, per-collection auth, UX improvements (#34)
* feat(core): add requires_auth to collections; include name in entry-collection memberships
- Add `requires_auth INTEGER NOT NULL DEFAULT 1` column to the
collections DDL and as an idempotent ALTER TABLE migration in
initialize_schema (archive DB), not initialize_auth_schema.
- CollectionRecord and CollectionSummary gain `requires_auth: bool`.
- create_collection() and update_collection() accept the new field.
- get_entry_collection_memberships() now returns collection name as the
third tuple element; EntryCollectionMembership gains a `name` field
so the sidebar can show human-readable names instead of raw UIDs.
* feat(server): conditional auth for public collections; add requires_auth + original_url to API
- CreateCollectionBody gains requires_auth (default true).
- PatchCollectionBody gains requires_auth: Option<bool>.
- get_collection_handler: load record first, then skip auth.require_auth()
when record.requires_auth == false so public collections are accessible
to unauthenticated callers; caller_bits falls back to ROLE_GUEST (1)
so only visibility_bits=3 entries are returned to guests.
- Collection JSON response includes requires_auth and each entry now
includes original_url for use by the public collection page.
- list_collections_handler keeps require_auth (management UI).
* feat(frontend): public collection page at /c/:archiveId/:collUid
- Detect PUBLIC_COLL_ROUTE at module load time (like PREVIEW_ROUTE) and
return <PublicCollectionPage> before any auth checks so unauthenticated
users can view public collections without hitting the login gate.
- PublicCollectionPage fetches via getCollection() and renders the
server-filtered entry list (no client-side bitmask filtering - the
server already applies caller_bits=GUEST for unauthenticated requests).
Entry titles link to original_url when present; fall back to plain
text when original_url is null.
- api.js createCollection() gains requiresAuth param (default true),
sent as requires_auth in the request body.
- Storybook story covers WithEntries, Empty, and LoadError states.
* feat(frontend): collections view improvements
- addVis in the 'Add entry' form now syncs to the selected collection's
default_visibility_bits via useEffect on collDetail, so the default
matches the collection's configured entry visibility.
- Rename 'Default visibility' label to 'Entries\' default visibility'
in both the detail pane and the create form to distinguish it from
the new collection-level access setting.
- Add 'Require authentication to view' checkbox in the detail pane
backed by a PATCH to requires_auth; reads collDetail?.requires_auth
with fallback to the list-level selected record.
- Create form gains a matching requires_auth checkbox (default: true),
passed as 5th arg to createCollection().
* feat(frontend): context rail collection improvements
- Show collection name (c.name) instead of raw UID in the sidebar
Collections section; names now come from the updated
EntryCollectionMembership API response.
- Fix horizontal overflow on long collection names: coll-name gains
overflow:hidden + text-overflow:ellipsis + white-space:nowrap +
min-width:0; coll-row gets overflow:hidden.
- Single-entry 'Add to collection' UI: dropdown + button inside the
Collections rail section lets users add the current entry to any
non-default collection without multi-selecting. After add, the
membership list refreshes automatically.
- Collections section now shows even when entryCollections is empty,
as long as non-default collections exist (so the add form is
accessible for un-membered entries).
- Bulk 'Add to collection' now uses the target collection's
default_visibility_bits instead of hardcoded 2 (Users only).
- Both bulk and single-entry dropdowns filter out slug='_default_'
to match the backend rejection in add_entry_to_collection_handler.
- Collections list is now fetched on archiveId change (not just on
bulk mode entry) so it is available for single-entry mode too.
* build(frontend): update static assets
* feat(frontend): public collection link UX + app-styled public page
CollectionsView:
- When a collection has requires_auth=false, show a read-only URL input
and Copy button below the auth checkbox so the public link is
immediately discoverable. The input auto-selects on focus so manual
copy always works. Copy button tries navigator.clipboard.writeText
first; falls back to execCommand('copy') for HTTP deployments where
the Clipboard API is unavailable in non-secure contexts.
PublicCollectionPage:
- Rewritten to use the app's CSS classes and variables instead of
bare inline styles, so it visually matches the main archive UI.
Dark topbar (.pub-coll-topbar) with brand + collection name, paper
background body, entry list via .coll-entries-list / .coll-entry-row /
.coll-entry-info / .coll-entry-kind — the same classes used in the
authenticated Collections view.
styles.css:
- .coll-public-link-row / -wrap / -input / .coll-copy-btn for the
new link field in CollectionsView detail pane.
- .pub-coll-* classes for the public page layout and typography.
* feat(core): add get_collection_by_slug; scope search to active collection
- get_collection_by_slug(): new function mirroring get_collection_by_uid
but matching on slug, used to resolve the _default_ collection when no
?collection param is supplied.
- SearchEntriesQuery gains collection_id: Option<i64>. When set, the
search SQL adds an EXISTS subquery that checks collection_entries cef
for both membership (cef.collection_id = ?) and visibility bits in
that specific collection — preventing cross-collection visibility
leaks where an entry is public in one collection but private in the
current one. Without collection_id the original cross-collection
visibility fallback is kept.
* feat(server): collection-scoped entries/search with uniform auth gate
All entry listing and search now route through the active collection:
list_entries (?collection=<uid>|main|<omitted>):
- Resolves the target collection; omitted or 'main' resolves to _default_.
- Checks requires_auth on that collection; gates auth conditionally.
- Returns list_entries_for_collection() — same EntrySummary shape.
search_entries_handler:
- Same collection resolution + conditional auth as list_entries.
- Sets search_query.collection_id so SQL scopes membership + visibility
to the specific collection, not cross-collection fallback.
list_collections_handler:
- Dropped require_auth() — collection summaries (name/slug/uid/
requires_auth/default_visibility_bits) are public metadata needed for
the guest collection-switcher dropdown.
Tests:
- list_collections_requires_auth → list_collections_is_public (200).
- list_entries_requires_auth and search coverage still pass.
* feat(frontend): integrate collection switching into main Archive view
Replaces the standalone /c/:archiveId/:collUid public page with a
unified main-view approach where all collection logic lives at /.
URL param:
- ?collection=<uid> selects a collection; omitted or 'main' = default.
- 'main' is normalized to null in parseLocation() so the dropdown shows
'All entries' and the URL stays clean.
Collection switcher (Topbar):
- Dropdown always visible (guests need it to navigate public collections).
- Non-default collections only (All entries = no param = _default_).
- Guest selecting an auth-required collection calls onSignInClick().
- handleCollectionChange checks both named and _default_ requires_auth
before proceeding, redirecting guests to login if needed.
listCollections fetched for all users (guests too) since the endpoint
is now public; used to populate the switcher without auth.
Public-session mode (authenticated state, no currentUser):
- Auth gate: fetchArchives() + fetchEntries() with collection param;
401 falls through to login, 200 proceeds as guest.
- auth:expired suppressed when !currentUser.
- fetchEntryDetail skipped; ContextRail shows entry summary + sign-in prompt.
- ContextRail selection effect skips tag/collection API calls.
- runs/tags not fetched in guest mode.
- Child row expansion disabled in EntryRow (hasChildren = false).
api.js:
- fetchEntries/searchEntries both thread ?collection=<uid> to server.
Deleted: PublicCollectionPage.jsx, PublicCollectionPage.stories.jsx,
copy-link UI from CollectionsView, pub-coll-*/copy-link CSS.
* build(frontend): update static assets
* feat(core): add is_entry_publicly_accessible; checks entry+parent vs public collections
* feat(server): allow guests to fetch detail/children/artifacts for public entries
* feat(frontend): guest collection dropdown filtering; public entry detail without auth wall
* build(frontend): update static assets
* test(server): public entry detail/artifact/children contract for guests
* feat(server): filter auth-required collections from guest list_collections response
This commit is contained in:
parent
1af920eb63
commit
e1ee05bd41
16 changed files with 1218 additions and 185 deletions
|
|
@ -71,6 +71,7 @@ pub struct AppState {
|
|||
pub struct EntrySearchParams {
|
||||
pub q: Option<String>,
|
||||
pub tag: Option<String>,
|
||||
pub collection: Option<String>,
|
||||
}
|
||||
|
||||
/// Tower middleware: returns 503 on all non-exempt routes if setup hasn't been completed.
|
||||
|
|
@ -421,16 +422,31 @@ async fn list_archives(State(state): State<AppState>) -> Json<Vec<MountedArchive
|
|||
Json(state.registry.archives.clone())
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, Default)]
|
||||
struct EntriesFilter {
|
||||
collection: Option<String>,
|
||||
}
|
||||
|
||||
async fn list_entries(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(archive_id): Path<String>,
|
||||
Query(filter): Query<EntriesFilter>,
|
||||
) -> Result<Json<Vec<archive::EntrySummary>>, ApiError> {
|
||||
auth.require_auth()?;
|
||||
let mounted = mounted_archive(&state, &archive_id)?;
|
||||
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
||||
// "main" is a URL-friendly alias for the default collection; no param also resolves to it.
|
||||
let coll = match filter.collection.as_deref() {
|
||||
None | Some("main") => database::get_collection_by_slug(&conn, "_default_")?
|
||||
.ok_or(ApiError::not_found("default collection missing"))?,
|
||||
Some(uid) => database::get_collection_by_uid(&conn, uid)?
|
||||
.ok_or(ApiError::not_found("collection not found"))?,
|
||||
};
|
||||
if coll.requires_auth {
|
||||
auth.require_auth()?;
|
||||
}
|
||||
let caller_bits = auth_to_caller_bits(&auth);
|
||||
Ok(Json(archive::list_root_entries(&conn, caller_bits)?))
|
||||
Ok(Json(archive::list_entries_for_collection(&conn, coll.id, caller_bits)?))
|
||||
}
|
||||
|
||||
async fn list_entry_children(
|
||||
|
|
@ -438,15 +454,19 @@ async fn list_entry_children(
|
|||
auth: AuthUser,
|
||||
Path((archive_id, entry_uid)): Path<(String, String)>,
|
||||
) -> Result<Json<Vec<archive::EntrySummary>>, ApiError> {
|
||||
auth.require_auth()?;
|
||||
let mounted = mounted_archive(&state, &archive_id)?;
|
||||
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
||||
if matches!(auth, AuthUser::Guest) {
|
||||
// list_child_entries checks visibility_bits but not collections.requires_auth;
|
||||
// gate on the parent being publicly accessible before opening the endpoint.
|
||||
if !database::is_entry_publicly_accessible(&conn, &entry_uid)? {
|
||||
return Err(ApiError::unauthorized("login required"));
|
||||
}
|
||||
} else {
|
||||
auth.require_auth()?;
|
||||
}
|
||||
let caller_bits = auth_to_caller_bits(&auth);
|
||||
Ok(Json(archive::list_child_entries(
|
||||
&conn,
|
||||
&entry_uid,
|
||||
caller_bits,
|
||||
)?))
|
||||
Ok(Json(archive::list_child_entries(&conn, &entry_uid, caller_bits)?))
|
||||
}
|
||||
|
||||
async fn search_entries_handler(
|
||||
|
|
@ -455,9 +475,17 @@ async fn search_entries_handler(
|
|||
Path(archive_id): Path<String>,
|
||||
Query(params): Query<EntrySearchParams>,
|
||||
) -> Result<Json<Vec<archive::EntrySummary>>, ApiError> {
|
||||
auth.require_auth()?;
|
||||
let mounted = mounted_archive(&state, &archive_id)?;
|
||||
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
||||
let coll = match params.collection.as_deref() {
|
||||
None | Some("main") => database::get_collection_by_slug(&conn, "_default_")?
|
||||
.ok_or(ApiError::not_found("default collection missing"))?,
|
||||
Some(uid) => database::get_collection_by_uid(&conn, uid)?
|
||||
.ok_or(ApiError::not_found("collection not found"))?,
|
||||
};
|
||||
if coll.requires_auth {
|
||||
auth.require_auth()?;
|
||||
}
|
||||
let raw = params.q.as_deref().unwrap_or("");
|
||||
let mut search_query = archive::parse_search_query(raw)
|
||||
.map_err(|prefix| ApiError::bad_request(&format!("unknown search prefix: {prefix}")))?;
|
||||
|
|
@ -465,6 +493,7 @@ async fn search_entries_handler(
|
|||
search_query.tag = Some(tag);
|
||||
}
|
||||
search_query.caller_bits = auth_to_caller_bits(&auth);
|
||||
search_query.collection_id = Some(coll.id);
|
||||
Ok(Json(archive::search_entries(&conn, &search_query)?))
|
||||
}
|
||||
|
||||
|
|
@ -473,9 +502,13 @@ async fn entry_detail(
|
|||
auth_user: AuthUser,
|
||||
Path((archive_id, entry_uid)): Path<(String, String)>,
|
||||
) -> Result<Json<archive::EntryDetail>, ApiError> {
|
||||
auth_user.require_auth()?;
|
||||
let mounted = mounted_archive(&state, &archive_id)?;
|
||||
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
||||
if matches!(auth_user, AuthUser::Guest) {
|
||||
if !database::is_entry_publicly_accessible(&conn, &entry_uid)? {
|
||||
return Err(ApiError::unauthorized("login required"));
|
||||
}
|
||||
}
|
||||
let detail = archive::get_entry_detail(&conn, &entry_uid)?
|
||||
.ok_or(ApiError::not_found("entry not found"))?;
|
||||
Ok(Json(detail))
|
||||
|
|
@ -511,8 +544,8 @@ async fn serve_artifact(
|
|||
Query(params): Query<ArtifactQuery>,
|
||||
req: Request,
|
||||
) -> Result<Response, ApiError> {
|
||||
// Auth: valid scoped token OR authenticated session (OR both).
|
||||
// A token present but invalid/expired falls back to session auth so that
|
||||
// Auth: valid scoped token OR authenticated session OR publicly accessible entry.
|
||||
// A token present but invalid/expired falls back to session/public check so that
|
||||
// a logged-in browser player keeps working after a token expires.
|
||||
let token_valid = params.token.as_deref().map_or(false, |tok| {
|
||||
let tokens = state.media_tokens.lock();
|
||||
|
|
@ -524,7 +557,15 @@ async fn serve_artifact(
|
|||
})
|
||||
});
|
||||
if !token_valid {
|
||||
auth_user.require_auth()?;
|
||||
if matches!(auth_user, AuthUser::Guest) {
|
||||
let mounted_check = mounted_archive(&state, &archive_id)?;
|
||||
let conn_check = database::open_or_initialize(&mounted_check.archive_path)?;
|
||||
if !database::is_entry_publicly_accessible(&conn_check, &entry_uid)? {
|
||||
return Err(ApiError::unauthorized("login required"));
|
||||
}
|
||||
} else {
|
||||
auth_user.require_auth()?;
|
||||
}
|
||||
}
|
||||
let mounted = mounted_archive(&state, &archive_id)?;
|
||||
let paths = archive::read_archive_paths(&mounted.archive_path)?;
|
||||
|
|
@ -659,12 +700,18 @@ struct CreateCollectionBody {
|
|||
slug: String,
|
||||
#[serde(default = "default_user_visibility")]
|
||||
default_visibility_bits: u32,
|
||||
#[serde(default = "default_requires_auth")]
|
||||
requires_auth: bool,
|
||||
}
|
||||
|
||||
fn default_user_visibility() -> u32 {
|
||||
2
|
||||
}
|
||||
|
||||
fn default_requires_auth() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct AddEntryBody {
|
||||
entry_uid: String,
|
||||
|
|
@ -681,6 +728,7 @@ struct UpdateVisibilityBody {
|
|||
struct PatchCollectionBody {
|
||||
name: Option<String>,
|
||||
default_visibility_bits: Option<u32>,
|
||||
requires_auth: Option<bool>,
|
||||
}
|
||||
|
||||
async fn list_tags(
|
||||
|
|
@ -2275,13 +2323,19 @@ impl IntoResponse for ApiError {
|
|||
|
||||
async fn list_collections_handler(
|
||||
State(state): State<AppState>,
|
||||
auth_user: AuthUser,
|
||||
auth: AuthUser,
|
||||
Path(archive_id): Path<String>,
|
||||
) -> Result<Json<Vec<archive::CollectionSummary>>, ApiError> {
|
||||
auth_user.require_auth()?;
|
||||
let mounted = mounted_archive(&state, &archive_id)?;
|
||||
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
||||
Ok(Json(archive::list_collections(&conn)?))
|
||||
let all = archive::list_collections(&conn)?;
|
||||
// Guests only see public collections; authenticated users see all.
|
||||
let visible = if matches!(auth, AuthUser::Guest) {
|
||||
all.into_iter().filter(|c| !c.requires_auth).collect()
|
||||
} else {
|
||||
all
|
||||
};
|
||||
Ok(Json(visible))
|
||||
}
|
||||
|
||||
async fn create_collection_handler(
|
||||
|
|
@ -2302,7 +2356,7 @@ async fn create_collection_handler(
|
|||
let mounted = mounted_archive(&state, &archive_id)?;
|
||||
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
||||
let record =
|
||||
database::create_collection(&conn, &body.name, &body.slug, body.default_visibility_bits)
|
||||
database::create_collection(&conn, &body.name, &body.slug, body.default_visibility_bits, body.requires_auth)
|
||||
.map_err(|e| ApiError::bad_request(&format!("{e:#}")))?;
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
|
|
@ -2311,6 +2365,7 @@ async fn create_collection_handler(
|
|||
name: record.name,
|
||||
slug: record.slug,
|
||||
default_visibility_bits: record.default_visibility_bits,
|
||||
requires_auth: record.requires_auth,
|
||||
created_at: record.created_at,
|
||||
}),
|
||||
))
|
||||
|
|
@ -2321,11 +2376,13 @@ async fn get_collection_handler(
|
|||
auth: AuthUser,
|
||||
Path((archive_id, coll_uid)): Path<(String, String)>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
auth.require_auth()?;
|
||||
let mounted = mounted_archive(&state, &archive_id)?;
|
||||
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
||||
let record = database::get_collection_by_uid(&conn, &coll_uid)?
|
||||
.ok_or(ApiError::not_found("collection not found"))?;
|
||||
if record.requires_auth {
|
||||
auth.require_auth()?;
|
||||
}
|
||||
let caller_bits = auth_to_caller_bits(&auth);
|
||||
let entries = archive::list_entries_for_collection(&conn, record.id, caller_bits)?;
|
||||
// Collect per-entry visibility bits from collection_entries
|
||||
|
|
@ -2358,6 +2415,7 @@ async fn get_collection_handler(
|
|||
"title": e.title,
|
||||
"source_kind": e.source_kind,
|
||||
"archived_at": e.archived_at,
|
||||
"original_url": e.original_url,
|
||||
"collection_visibility_bits": vis,
|
||||
})
|
||||
})
|
||||
|
|
@ -2367,6 +2425,7 @@ async fn get_collection_handler(
|
|||
"name": record.name,
|
||||
"slug": record.slug,
|
||||
"default_visibility_bits": record.default_visibility_bits,
|
||||
"requires_auth": record.requires_auth,
|
||||
"created_at": record.created_at,
|
||||
"entries": entries_json,
|
||||
})))
|
||||
|
|
@ -2482,7 +2541,7 @@ async fn patch_collection_handler(
|
|||
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
||||
let name_ref: Option<&str> = body.name.as_deref();
|
||||
let updated =
|
||||
database::update_collection(&conn, &coll_uid, name_ref, body.default_visibility_bits)?;
|
||||
database::update_collection(&conn, &coll_uid, name_ref, body.default_visibility_bits, body.requires_auth)?;
|
||||
if updated {
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
} else {
|
||||
|
|
@ -4589,7 +4648,9 @@ mod tests {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_collections_requires_auth() {
|
||||
async fn list_collections_is_public() {
|
||||
// list_collections no longer requires auth — collection summaries are public metadata
|
||||
// needed for the collection switcher in guest mode.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (registry, _, auth_path) = make_test_registry(&dir);
|
||||
let response = app(registry, auth_path)
|
||||
|
|
@ -4601,7 +4662,7 @@ mod tests {
|
|||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -4660,12 +4721,37 @@ mod tests {
|
|||
|
||||
#[tokio::test]
|
||||
async fn get_collection_requires_auth() {
|
||||
// Create a collection (requires_auth defaults to true), then confirm
|
||||
// that an unauthenticated GET returns 401, not 404.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (registry, _, auth_path) = make_test_registry(&dir);
|
||||
let session_cookie = make_test_session(&auth_path);
|
||||
let create_resp = app(registry.clone(), auth_path.clone())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/archives/test/collections")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", &session_cookie)
|
||||
.body(json_body(&serde_json::json!({
|
||||
"name": "Auth Required",
|
||||
"slug": "auth-required",
|
||||
"default_visibility_bits": 2,
|
||||
"requires_auth": true
|
||||
})))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(create_resp.status(), StatusCode::CREATED);
|
||||
let coll = body_json(create_resp).await;
|
||||
let coll_uid = coll["collection_uid"].as_str().unwrap().to_string();
|
||||
// Now GET without auth — must be 401 because requires_auth == true.
|
||||
let uri = format!("/api/archives/test/collections/{coll_uid}");
|
||||
let response = app(registry, auth_path)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/archives/test/collections/coll_notexist")
|
||||
.uri(&uri)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
|
|
@ -4674,6 +4760,50 @@ mod tests {
|
|||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_public_collection_no_auth_returns_ok() {
|
||||
// A collection with requires_auth=false must be reachable by guests.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (registry, _, auth_path) = make_test_registry(&dir);
|
||||
let session_cookie = make_test_session(&auth_path);
|
||||
let create_resp = app(registry.clone(), auth_path.clone())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/archives/test/collections")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", &session_cookie)
|
||||
.body(json_body(&serde_json::json!({
|
||||
"name": "Public Collection",
|
||||
"slug": "public-coll",
|
||||
"default_visibility_bits": 3,
|
||||
"requires_auth": false
|
||||
})))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(create_resp.status(), StatusCode::CREATED);
|
||||
let coll = body_json(create_resp).await;
|
||||
let coll_uid = coll["collection_uid"].as_str().unwrap().to_string();
|
||||
assert_eq!(coll["requires_auth"], false, "requires_auth should be false");
|
||||
// GET without any auth cookie — must be 200.
|
||||
let uri = format!("/api/archives/test/collections/{coll_uid}");
|
||||
let response = app(registry, auth_path)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(&uri)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = body_json(response).await;
|
||||
assert_eq!(body["requires_auth"], false);
|
||||
assert_eq!(body["name"], "Public Collection");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_collection_with_auth_returns_ok() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
|
@ -4771,6 +4901,246 @@ mod tests {
|
|||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
// ── Collection-scoped entries + search security tests ──────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_entries_public_named_collection_allows_guest() {
|
||||
// A named collection with requires_auth=false must be accessible without a cookie.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (registry, _, auth_path) = make_test_registry(&dir);
|
||||
let session_cookie = make_test_session(&auth_path);
|
||||
// Create a public collection.
|
||||
let create = app(registry.clone(), auth_path.clone())
|
||||
.oneshot(Request::builder().method("POST")
|
||||
.uri("/api/archives/test/collections")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", &session_cookie)
|
||||
.body(json_body(&serde_json::json!({
|
||||
"name": "Public Coll", "slug": "public-coll",
|
||||
"default_visibility_bits": 3, "requires_auth": false
|
||||
}))).unwrap()).await.unwrap();
|
||||
assert_eq!(create.status(), StatusCode::CREATED);
|
||||
let coll = body_json(create).await;
|
||||
let uid = coll["collection_uid"].as_str().unwrap().to_string();
|
||||
// Guest access to entries scoped to that collection → 200.
|
||||
let resp = app(registry, auth_path)
|
||||
.oneshot(Request::builder()
|
||||
.uri(format!("/api/archives/test/entries?collection={uid}"))
|
||||
.body(Body::empty()).unwrap()).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_entries_auth_required_named_collection_blocks_guest() {
|
||||
// A named collection with requires_auth=true must block guests.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (registry, _, auth_path) = make_test_registry(&dir);
|
||||
let session_cookie = make_test_session(&auth_path);
|
||||
let create = app(registry.clone(), auth_path.clone())
|
||||
.oneshot(Request::builder().method("POST")
|
||||
.uri("/api/archives/test/collections")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", &session_cookie)
|
||||
.body(json_body(&serde_json::json!({
|
||||
"name": "Private Coll", "slug": "private-coll",
|
||||
"default_visibility_bits": 2, "requires_auth": true
|
||||
}))).unwrap()).await.unwrap();
|
||||
assert_eq!(create.status(), StatusCode::CREATED);
|
||||
let coll = body_json(create).await;
|
||||
let uid = coll["collection_uid"].as_str().unwrap().to_string();
|
||||
// Guest access → 401.
|
||||
let resp = app(registry, auth_path)
|
||||
.oneshot(Request::builder()
|
||||
.uri(format!("/api/archives/test/entries?collection={uid}"))
|
||||
.body(Body::empty()).unwrap()).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_entries_public_default_collection_allows_guest() {
|
||||
// When the _default_ collection is set to requires_auth=false, guests can list entries.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (registry, archive_path, auth_path) = make_test_registry(&dir);
|
||||
let session_cookie = make_test_session(&auth_path);
|
||||
// PATCH the default collection to make it public.
|
||||
let default_uid = {
|
||||
let conn = database::open_or_initialize(&archive_path).unwrap();
|
||||
database::get_collection_by_slug(&conn, "_default_").unwrap().unwrap().collection_uid
|
||||
};
|
||||
let patch = app(registry.clone(), auth_path.clone())
|
||||
.oneshot(Request::builder().method("PATCH")
|
||||
.uri(format!("/api/archives/test/collections/{default_uid}"))
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", &session_cookie)
|
||||
.body(json_body(&serde_json::json!({ "requires_auth": false }))).unwrap())
|
||||
.await.unwrap();
|
||||
assert_eq!(patch.status(), StatusCode::NO_CONTENT);
|
||||
// Guest access to /entries (no collection param → resolves to _default_) → 200.
|
||||
let resp = app(registry, auth_path)
|
||||
.oneshot(Request::builder()
|
||||
.uri("/api/archives/test/entries")
|
||||
.body(Body::empty()).unwrap()).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_entries_public_collection_allows_guest() {
|
||||
// Guest can search within a public collection.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (registry, _, auth_path) = make_test_registry(&dir);
|
||||
let session_cookie = make_test_session(&auth_path);
|
||||
let create = app(registry.clone(), auth_path.clone())
|
||||
.oneshot(Request::builder().method("POST")
|
||||
.uri("/api/archives/test/collections")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", &session_cookie)
|
||||
.body(json_body(&serde_json::json!({
|
||||
"name": "Public Search", "slug": "public-search",
|
||||
"default_visibility_bits": 3, "requires_auth": false
|
||||
}))).unwrap()).await.unwrap();
|
||||
assert_eq!(create.status(), StatusCode::CREATED);
|
||||
let coll = body_json(create).await;
|
||||
let uid = coll["collection_uid"].as_str().unwrap().to_string();
|
||||
// Guest search scoped to that collection → 200.
|
||||
let resp = app(registry, auth_path)
|
||||
.oneshot(Request::builder()
|
||||
.uri(format!("/api/archives/test/entries/search?collection={uid}&q=test"))
|
||||
.body(Body::empty()).unwrap()).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_entries_auth_required_collection_blocks_guest() {
|
||||
// Guest cannot search within an auth-required collection.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (registry, _, auth_path) = make_test_registry(&dir);
|
||||
let session_cookie = make_test_session(&auth_path);
|
||||
let create = app(registry.clone(), auth_path.clone())
|
||||
.oneshot(Request::builder().method("POST")
|
||||
.uri("/api/archives/test/collections")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", &session_cookie)
|
||||
.body(json_body(&serde_json::json!({
|
||||
"name": "Private Search", "slug": "private-search",
|
||||
"default_visibility_bits": 2, "requires_auth": true
|
||||
}))).unwrap()).await.unwrap();
|
||||
assert_eq!(create.status(), StatusCode::CREATED);
|
||||
let coll = body_json(create).await;
|
||||
let uid = coll["collection_uid"].as_str().unwrap().to_string();
|
||||
// Guest search → 401.
|
||||
let resp = app(registry, auth_path)
|
||||
.oneshot(Request::builder()
|
||||
.uri(format!("/api/archives/test/entries/search?collection={uid}&q=test"))
|
||||
.body(Body::empty()).unwrap()).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn collection_scoped_visibility_does_not_leak_across_collections() {
|
||||
// Entry is users-only (visibility_bits=2) in CollA and public (visibility_bits=3)
|
||||
// in CollB. Guest list + search of CollA must return 0 results; CollB must return 1.
|
||||
// This catches the bug where cross-collection visibility check would return the entry
|
||||
// because it is public *somewhere*, regardless of the requested collection.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (registry, archive_path, auth_path) = make_test_registry(&dir);
|
||||
let session_cookie = make_test_session(&auth_path);
|
||||
|
||||
// Two public collections: CollA (default vis=2) and CollB (default vis=3).
|
||||
let coll_a_uid = {
|
||||
let r = app(registry.clone(), auth_path.clone())
|
||||
.oneshot(Request::builder().method("POST")
|
||||
.uri("/api/archives/test/collections")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", &session_cookie)
|
||||
.body(json_body(&serde_json::json!({
|
||||
"name": "CollA", "slug": "coll-a",
|
||||
"default_visibility_bits": 2, "requires_auth": false
|
||||
}))).unwrap()).await.unwrap();
|
||||
assert_eq!(r.status(), StatusCode::CREATED);
|
||||
body_json(r).await["collection_uid"].as_str().unwrap().to_string()
|
||||
};
|
||||
let coll_b_uid = {
|
||||
let r = app(registry.clone(), auth_path.clone())
|
||||
.oneshot(Request::builder().method("POST")
|
||||
.uri("/api/archives/test/collections")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", &session_cookie)
|
||||
.body(json_body(&serde_json::json!({
|
||||
"name": "CollB", "slug": "coll-b",
|
||||
"default_visibility_bits": 3, "requires_auth": false
|
||||
}))).unwrap()).await.unwrap();
|
||||
assert_eq!(r.status(), StatusCode::CREATED);
|
||||
body_json(r).await["collection_uid"].as_str().unwrap().to_string()
|
||||
};
|
||||
|
||||
// Create a fixture entry (title "Test Entry" is searchable).
|
||||
let entry = make_test_entry(&archive_path);
|
||||
|
||||
// Add entry to CollA with visibility_bits=2 (users-only in CollA).
|
||||
let add_a = app(registry.clone(), auth_path.clone())
|
||||
.oneshot(Request::builder().method("POST")
|
||||
.uri(format!("/api/archives/test/collections/{coll_a_uid}/entries"))
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", &session_cookie)
|
||||
.body(json_body(&serde_json::json!({
|
||||
"entry_uid": entry.entry_uid, "visibility_bits": 2
|
||||
}))).unwrap()).await.unwrap();
|
||||
assert_eq!(add_a.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
// Add same entry to CollB with visibility_bits=3 (public in CollB).
|
||||
let add_b = app(registry.clone(), auth_path.clone())
|
||||
.oneshot(Request::builder().method("POST")
|
||||
.uri(format!("/api/archives/test/collections/{coll_b_uid}/entries"))
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", &session_cookie)
|
||||
.body(json_body(&serde_json::json!({
|
||||
"entry_uid": entry.entry_uid, "visibility_bits": 3
|
||||
}))).unwrap()).await.unwrap();
|
||||
assert_eq!(add_b.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
// ── list_entries scoping ──────────────────────────────────────────
|
||||
// Guest list CollA → 0 results (users-only there).
|
||||
let list_a = app(registry.clone(), auth_path.clone())
|
||||
.oneshot(Request::builder()
|
||||
.uri(format!("/api/archives/test/entries?collection={coll_a_uid}"))
|
||||
.body(Body::empty()).unwrap()).await.unwrap();
|
||||
assert_eq!(list_a.status(), StatusCode::OK);
|
||||
let body_a = body_json(list_a).await;
|
||||
assert_eq!(body_a.as_array().unwrap().len(), 0,
|
||||
"guest must not see users-only entry in CollA via list");
|
||||
|
||||
// Guest list CollB → 1 result (public there).
|
||||
let list_b = app(registry.clone(), auth_path.clone())
|
||||
.oneshot(Request::builder()
|
||||
.uri(format!("/api/archives/test/entries?collection={coll_b_uid}"))
|
||||
.body(Body::empty()).unwrap()).await.unwrap();
|
||||
assert_eq!(list_b.status(), StatusCode::OK);
|
||||
let body_b = body_json(list_b).await;
|
||||
assert_eq!(body_b.as_array().unwrap().len(), 1,
|
||||
"guest should see public entry in CollB via list");
|
||||
|
||||
// ── search_entries scoping ────────────────────────────────────────
|
||||
// Guest search CollA → 0 results (entry is users-only there, not leaked by CollB).
|
||||
let search_a = app(registry.clone(), auth_path.clone())
|
||||
.oneshot(Request::builder()
|
||||
.uri(format!("/api/archives/test/entries/search?collection={coll_a_uid}&q=Test"))
|
||||
.body(Body::empty()).unwrap()).await.unwrap();
|
||||
assert_eq!(search_a.status(), StatusCode::OK);
|
||||
let srch_a = body_json(search_a).await;
|
||||
assert_eq!(srch_a.as_array().unwrap().len(), 0,
|
||||
"guest search in CollA must not return entry visible only in CollB");
|
||||
|
||||
// Guest search CollB → 1 result (public there).
|
||||
let search_b = app(registry, auth_path)
|
||||
.oneshot(Request::builder()
|
||||
.uri(format!("/api/archives/test/entries/search?collection={coll_b_uid}&q=Test"))
|
||||
.body(Body::empty()).unwrap()).await.unwrap();
|
||||
assert_eq!(search_b.status(), StatusCode::OK);
|
||||
let srch_b = body_json(search_b).await;
|
||||
assert_eq!(srch_b.as_array().unwrap().len(), 1,
|
||||
"guest search in CollB must return the public entry");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn patch_entry_title_requires_auth() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
|
@ -5418,4 +5788,336 @@ mod tests {
|
|||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// ── Public preview contract tests ──────────────────────────────────────────
|
||||
// entry_detail and serve_artifact are open to guests iff the entry lives in a
|
||||
// public collection (requires_auth=false) with visibility_bits & ROLE_GUEST (1).
|
||||
// list_entry_children is open to guests iff the parent passes the same check.
|
||||
// Children inherit public accessibility from their parent.
|
||||
|
||||
/// Build a collection via API; return its uid.
|
||||
async fn api_make_collection(
|
||||
registry: ServerRegistry, auth_path: std::path::PathBuf,
|
||||
session: &str, name: &str, slug: &str, vis: u32, requires_auth: bool,
|
||||
) -> String {
|
||||
let r = app(registry, auth_path)
|
||||
.oneshot(Request::builder().method("POST")
|
||||
.uri("/api/archives/test/collections")
|
||||
.header("content-type", "application/json").header("cookie", session)
|
||||
.body(json_body(&serde_json::json!({
|
||||
"name": name, "slug": slug,
|
||||
"default_visibility_bits": vis, "requires_auth": requires_auth
|
||||
}))).unwrap()).await.unwrap();
|
||||
assert_eq!(r.status(), StatusCode::CREATED);
|
||||
body_json(r).await["collection_uid"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
/// Add an entry to a collection via API.
|
||||
async fn api_add_to_coll(
|
||||
registry: ServerRegistry, auth_path: std::path::PathBuf,
|
||||
session: &str, coll_uid: &str, entry_uid: &str, vis: u32,
|
||||
) {
|
||||
let r = app(registry, auth_path)
|
||||
.oneshot(Request::builder().method("POST")
|
||||
.uri(format!("/api/archives/test/collections/{coll_uid}/entries"))
|
||||
.header("content-type", "application/json").header("cookie", session)
|
||||
.body(json_body(&serde_json::json!({
|
||||
"entry_uid": entry_uid, "visibility_bits": vis
|
||||
}))).unwrap()).await.unwrap();
|
||||
assert_eq!(r.status(), StatusCode::NO_CONTENT);
|
||||
}
|
||||
|
||||
/// Create an entry with one artifact file; returns (entry, artifact uri component).
|
||||
fn make_entry_with_artifact(
|
||||
archive_path: &std::path::Path,
|
||||
store_path: &std::path::Path,
|
||||
) -> archivr_core::database::ArchivedEntry {
|
||||
let conn = database::open_or_initialize(archive_path).unwrap();
|
||||
let user_id = database::ensure_default_user(&conn).unwrap();
|
||||
let run = database::create_archive_run(&conn, user_id, 1).unwrap();
|
||||
let si = database::upsert_source_identity(
|
||||
&conn, "web", "page", None,
|
||||
Some("https://example.com/artitest"), "https://example.com/artitest",
|
||||
).unwrap();
|
||||
let entry = database::create_archived_entry(&conn, &database::NewEntry {
|
||||
source_identity_id: si, archive_run_id: run.id,
|
||||
parent_entry_id: None, root_entry_id: None,
|
||||
created_by_user_id: user_id, owned_by_user_id: user_id,
|
||||
source_kind: "web".to_string(), entity_kind: "page".to_string(),
|
||||
title: Some("Artifact Test".to_string()), visibility: "private".to_string(),
|
||||
representation_kind: "html".to_string(),
|
||||
source_metadata_json: "{}".to_string(), display_metadata_json: None,
|
||||
}).unwrap();
|
||||
let relpath = "raw/pp/qq/test.html";
|
||||
let file_dir = store_path.join("raw").join("pp").join("qq");
|
||||
std::fs::create_dir_all(&file_dir).unwrap();
|
||||
std::fs::write(file_dir.join("test.html"), b"<html>pub</html>").unwrap();
|
||||
let blob_id = database::upsert_blob(&conn, &database::BlobRecord {
|
||||
sha256: "cccc3333dddd4444eeee5555ffff6666cccc3333dddd4444eeee5555ffff6666".to_string(),
|
||||
byte_size: 16, mime_type: Some("text/html".to_string()),
|
||||
extension: Some("html".to_string()), raw_relpath: relpath.to_string(),
|
||||
}).unwrap();
|
||||
database::add_entry_artifact(&conn, &database::NewArtifact {
|
||||
entry_id: entry.id, artifact_role: "primary_media".to_string(),
|
||||
storage_area: "raw".to_string(), relpath: relpath.to_string(),
|
||||
blob_id: Some(blob_id), logical_path: None, metadata_json: None,
|
||||
}).unwrap();
|
||||
entry
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn guest_entry_detail_public_entry_succeeds() {
|
||||
// Entry in a requires_auth=false collection with vis=3 (ROLE_GUEST) → 200 without cookie.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (registry, archive_path, auth_path) = make_test_registry(&dir);
|
||||
let session = make_test_session(&auth_path);
|
||||
let entry = make_test_entry(&archive_path);
|
||||
let coll = api_make_collection(
|
||||
registry.clone(), auth_path.clone(), &session,
|
||||
"PubD", "pub-d", 3, false,
|
||||
).await;
|
||||
api_add_to_coll(registry.clone(), auth_path.clone(), &session, &coll, &entry.entry_uid, 3).await;
|
||||
let resp = app(registry, auth_path)
|
||||
.oneshot(Request::builder()
|
||||
.uri(format!("/api/archives/test/entries/{}", entry.entry_uid))
|
||||
.body(Body::empty()).unwrap()).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn guest_entry_detail_users_only_entry_blocked() {
|
||||
// Entry in requires_auth=false collection but visibility_bits=2 (no ROLE_GUEST) → 401.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (registry, archive_path, auth_path) = make_test_registry(&dir);
|
||||
let session = make_test_session(&auth_path);
|
||||
let entry = make_test_entry(&archive_path);
|
||||
let coll = api_make_collection(
|
||||
registry.clone(), auth_path.clone(), &session,
|
||||
"SemiPub", "semi-pub", 3, false,
|
||||
).await;
|
||||
// Add with visibility_bits=2: users-only, ROLE_GUEST bit not set.
|
||||
api_add_to_coll(registry.clone(), auth_path.clone(), &session, &coll, &entry.entry_uid, 2).await;
|
||||
let resp = app(registry, auth_path)
|
||||
.oneshot(Request::builder()
|
||||
.uri(format!("/api/archives/test/entries/{}", entry.entry_uid))
|
||||
.body(Body::empty()).unwrap()).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn guest_entry_detail_auth_required_collection_blocked() {
|
||||
// Entry in requires_auth=true collection even with guest visibility bits → 401.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (registry, archive_path, auth_path) = make_test_registry(&dir);
|
||||
let session = make_test_session(&auth_path);
|
||||
let entry = make_test_entry(&archive_path);
|
||||
let coll = api_make_collection(
|
||||
registry.clone(), auth_path.clone(), &session,
|
||||
"AuthColl", "auth-coll", 3, true,
|
||||
).await;
|
||||
api_add_to_coll(registry.clone(), auth_path.clone(), &session, &coll, &entry.entry_uid, 3).await;
|
||||
let resp = app(registry, auth_path)
|
||||
.oneshot(Request::builder()
|
||||
.uri(format!("/api/archives/test/entries/{}", entry.entry_uid))
|
||||
.body(Body::empty()).unwrap()).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn guest_serve_artifact_public_entry_succeeds() {
|
||||
// Artifact of an entry in a public collection is accessible without cookie.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store_path = dir.path().join("store");
|
||||
let paths = archivr_core::archive::initialize_archive(
|
||||
dir.path(), &store_path, "test", false,
|
||||
).unwrap();
|
||||
let auth_path = dir.path().join("auth.sqlite");
|
||||
{
|
||||
let conn = archivr_core::database::open_auth_db(&auth_path).unwrap();
|
||||
archivr_core::database::create_owner(&conn, "testowner", "dummy").unwrap();
|
||||
}
|
||||
let registry = ServerRegistry {
|
||||
archives: vec![MountedArchive {
|
||||
id: "test".to_string(), label: "Test".to_string(),
|
||||
archive_path: paths.archive_path.clone(),
|
||||
}],
|
||||
bind: None, auth_db_path: None,
|
||||
};
|
||||
let session = make_test_session(&auth_path);
|
||||
let entry = make_entry_with_artifact(&paths.archive_path, &store_path);
|
||||
let coll = api_make_collection(
|
||||
registry.clone(), auth_path.clone(), &session,
|
||||
"PubArt", "pub-art", 3, false,
|
||||
).await;
|
||||
api_add_to_coll(registry.clone(), auth_path.clone(), &session, &coll, &entry.entry_uid, 3).await;
|
||||
let resp = app(registry, auth_path)
|
||||
.oneshot(Request::builder()
|
||||
.uri(format!("/api/archives/test/entries/{}/artifacts/0", entry.entry_uid))
|
||||
.body(Body::empty()).unwrap()).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn guest_serve_artifact_users_only_entry_blocked() {
|
||||
// Artifact of a users-only entry is blocked for guests even in a public collection.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store_path = dir.path().join("store");
|
||||
let paths = archivr_core::archive::initialize_archive(
|
||||
dir.path(), &store_path, "test", false,
|
||||
).unwrap();
|
||||
let auth_path = dir.path().join("auth.sqlite");
|
||||
{
|
||||
let conn = archivr_core::database::open_auth_db(&auth_path).unwrap();
|
||||
archivr_core::database::create_owner(&conn, "testowner", "dummy").unwrap();
|
||||
}
|
||||
let registry = ServerRegistry {
|
||||
archives: vec![MountedArchive {
|
||||
id: "test".to_string(), label: "Test".to_string(),
|
||||
archive_path: paths.archive_path.clone(),
|
||||
}],
|
||||
bind: None, auth_db_path: None,
|
||||
};
|
||||
let session = make_test_session(&auth_path);
|
||||
let entry = make_entry_with_artifact(&paths.archive_path, &store_path);
|
||||
let coll = api_make_collection(
|
||||
registry.clone(), auth_path.clone(), &session,
|
||||
"PubArt2", "pub-art2", 3, false,
|
||||
).await;
|
||||
// visibility_bits=2: users-only, guest cannot see.
|
||||
api_add_to_coll(registry.clone(), auth_path.clone(), &session, &coll, &entry.entry_uid, 2).await;
|
||||
let resp = app(registry, auth_path)
|
||||
.oneshot(Request::builder()
|
||||
.uri(format!("/api/archives/test/entries/{}/artifacts/0", entry.entry_uid))
|
||||
.body(Body::empty()).unwrap()).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn guest_list_children_public_parent_succeeds() {
|
||||
// Children of a public parent are accessible to guests.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (registry, archive_path, auth_path) = make_test_registry(&dir);
|
||||
let session = make_test_session(&auth_path);
|
||||
// Create parent entry.
|
||||
let parent = make_test_entry(&archive_path);
|
||||
// Create child entry referencing parent.
|
||||
let child = {
|
||||
let conn = database::open_or_initialize(&archive_path).unwrap();
|
||||
let user_id = database::ensure_default_user(&conn).unwrap();
|
||||
let run = database::create_archive_run(&conn, user_id, 1).unwrap();
|
||||
let si = database::upsert_source_identity(
|
||||
&conn, "web", "page", None,
|
||||
Some("https://example.com/child"), "https://example.com/child",
|
||||
).unwrap();
|
||||
database::create_archived_entry(&conn, &database::NewEntry {
|
||||
source_identity_id: si, archive_run_id: run.id,
|
||||
parent_entry_id: Some(parent.id), root_entry_id: Some(parent.id),
|
||||
created_by_user_id: user_id, owned_by_user_id: user_id,
|
||||
source_kind: "web".to_string(), entity_kind: "page".to_string(),
|
||||
title: Some("Child Entry".to_string()), visibility: "private".to_string(),
|
||||
representation_kind: "html".to_string(),
|
||||
source_metadata_json: "{}".to_string(), display_metadata_json: None,
|
||||
}).unwrap()
|
||||
};
|
||||
// Put parent in a public collection with guest visibility.
|
||||
let coll = api_make_collection(
|
||||
registry.clone(), auth_path.clone(), &session, "PubParent", "pub-parent", 3, false,
|
||||
).await;
|
||||
api_add_to_coll(registry.clone(), auth_path.clone(), &session, &coll, &parent.entry_uid, 3).await;
|
||||
// Guest requests children of the public parent — must see the child, not just 200.
|
||||
let resp = app(registry, auth_path)
|
||||
.oneshot(Request::builder()
|
||||
.uri(format!("/api/archives/test/entries/{}/children", parent.entry_uid))
|
||||
.body(Body::empty()).unwrap()).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = body_json(resp).await;
|
||||
let uids: Vec<&str> = body.as_array().unwrap()
|
||||
.iter().map(|e| e["entry_uid"].as_str().unwrap()).collect();
|
||||
assert!(uids.contains(&child.entry_uid.as_str()),
|
||||
"guest must see child uid in children response; got {:?}", uids);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn guest_list_children_private_parent_blocked() {
|
||||
// Children of a non-public parent are blocked for guests.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (registry, archive_path, auth_path) = make_test_registry(&dir);
|
||||
// Parent only in _default_ (requires_auth=true by default); not in any public collection.
|
||||
let parent = make_test_entry(&archive_path);
|
||||
let resp = app(registry, auth_path)
|
||||
.oneshot(Request::builder()
|
||||
.uri(format!("/api/archives/test/entries/{}/children", parent.entry_uid))
|
||||
.body(Body::empty()).unwrap()).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn guest_child_entry_detail_via_public_parent_succeeds() {
|
||||
// A child entry inherits public accessibility from its parent's collection membership.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (registry, archive_path, auth_path) = make_test_registry(&dir);
|
||||
let session = make_test_session(&auth_path);
|
||||
let parent = make_test_entry(&archive_path);
|
||||
let child = {
|
||||
let conn = database::open_or_initialize(&archive_path).unwrap();
|
||||
let user_id = database::ensure_default_user(&conn).unwrap();
|
||||
let run = database::create_archive_run(&conn, user_id, 1).unwrap();
|
||||
let si = database::upsert_source_identity(
|
||||
&conn, "web", "page", None,
|
||||
Some("https://example.com/child2"), "https://example.com/child2",
|
||||
).unwrap();
|
||||
database::create_archived_entry(&conn, &database::NewEntry {
|
||||
source_identity_id: si, archive_run_id: run.id,
|
||||
parent_entry_id: Some(parent.id), root_entry_id: Some(parent.id),
|
||||
created_by_user_id: user_id, owned_by_user_id: user_id,
|
||||
source_kind: "web".to_string(), entity_kind: "page".to_string(),
|
||||
title: Some("Child Detail Test".to_string()), visibility: "private".to_string(),
|
||||
representation_kind: "html".to_string(),
|
||||
source_metadata_json: "{}".to_string(), display_metadata_json: None,
|
||||
}).unwrap()
|
||||
};
|
||||
// Parent in a public collection with guest visibility.
|
||||
let coll = api_make_collection(
|
||||
registry.clone(), auth_path.clone(), &session, "PubParent2", "pub-parent2", 3, false,
|
||||
).await;
|
||||
api_add_to_coll(registry.clone(), auth_path.clone(), &session, &coll, &parent.entry_uid, 3).await;
|
||||
// Child detail accessible to guest via parent's public membership.
|
||||
let resp = app(registry, auth_path)
|
||||
.oneshot(Request::builder()
|
||||
.uri(format!("/api/archives/test/entries/{}", child.entry_uid))
|
||||
.body(Body::empty()).unwrap()).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn guest_list_collections_returns_only_public() {
|
||||
// GET /api/archives/:id/collections for a guest must omit auth-required collections
|
||||
// so their names are never leaked to unsigned users.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (registry, _, auth_path) = make_test_registry(&dir);
|
||||
let session = make_test_session(&auth_path);
|
||||
// Create one public and one auth-required collection.
|
||||
let _ = api_make_collection(
|
||||
registry.clone(), auth_path.clone(), &session,
|
||||
"PublicColl", "public-coll", 3, false,
|
||||
).await;
|
||||
let auth_coll_name = "SecretColl";
|
||||
let _ = api_make_collection(
|
||||
registry.clone(), auth_path.clone(), &session,
|
||||
auth_coll_name, "secret-coll", 2, true,
|
||||
).await;
|
||||
// Guest fetches the collection list.
|
||||
let resp = app(registry, auth_path)
|
||||
.oneshot(Request::builder()
|
||||
.uri("/api/archives/test/collections")
|
||||
.body(Body::empty()).unwrap()).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = body_json(resp).await;
|
||||
let names: Vec<&str> = body.as_array().unwrap()
|
||||
.iter().map(|c| c["name"].as_str().unwrap()).collect();
|
||||
assert!(!names.contains(&auth_coll_name),
|
||||
"auth-required collection name must not be returned to guests; got {:?}", names);
|
||||
assert!(names.contains(&"PublicColl"),
|
||||
"public collection must be returned to guests; got {:?}", names);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue