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
|
|
@ -100,6 +100,7 @@ pub struct CollectionSummary {
|
|||
pub name: String,
|
||||
pub slug: String,
|
||||
pub default_visibility_bits: u32,
|
||||
pub requires_auth: bool,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
|
|
@ -405,6 +406,7 @@ pub fn list_collections(conn: &rusqlite::Connection) -> Result<Vec<CollectionSum
|
|||
name: r.name,
|
||||
slug: r.slug,
|
||||
default_visibility_bits: r.default_visibility_bits,
|
||||
requires_auth: r.requires_auth,
|
||||
created_at: r.created_at,
|
||||
})
|
||||
.collect())
|
||||
|
|
@ -414,6 +416,7 @@ pub fn list_collections(conn: &rusqlite::Connection) -> Result<Vec<CollectionSum
|
|||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct EntryCollectionMembership {
|
||||
pub collection_uid: String,
|
||||
pub name: String,
|
||||
pub visibility_bits: u32,
|
||||
}
|
||||
|
||||
|
|
@ -437,8 +440,9 @@ pub fn get_entry_collections(
|
|||
Ok(Some(
|
||||
memberships
|
||||
.into_iter()
|
||||
.map(|(_, uid, bits)| EntryCollectionMembership {
|
||||
.map(|(_, uid, name, bits)| EntryCollectionMembership {
|
||||
collection_uid: uid,
|
||||
name,
|
||||
visibility_bits: bits,
|
||||
})
|
||||
.collect(),
|
||||
|
|
@ -642,6 +646,7 @@ pub struct SearchEntriesQuery {
|
|||
/// Role bits of the caller for visibility filtering. Admins (bits 4/8) bypass all filters.
|
||||
/// Pass `u32::MAX` internally to bypass all visibility. Pass 0 for unauthenticated guests only.
|
||||
pub caller_bits: u32,
|
||||
pub collection_id: Option<i64>,
|
||||
}
|
||||
|
||||
impl Default for SearchEntriesQuery {
|
||||
|
|
@ -656,6 +661,7 @@ impl Default for SearchEntriesQuery {
|
|||
before: None,
|
||||
tag: None,
|
||||
caller_bits: u32::MAX,
|
||||
collection_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -787,14 +793,29 @@ pub fn search_entries(
|
|||
params.push(b.clone());
|
||||
}
|
||||
|
||||
// Visibility filter
|
||||
let n = params.len() + 1;
|
||||
sql.push_str(&format!(
|
||||
" AND (CAST(?{n} AS INTEGER) & 12 != 0 \
|
||||
OR EXISTS (SELECT 1 FROM collection_entries ce \
|
||||
WHERE ce.entry_id = e.id AND ce.visibility_bits & CAST(?{n} AS INTEGER) != 0))"
|
||||
));
|
||||
params.push(query.caller_bits.to_string());
|
||||
// Collection scope + visibility filter.
|
||||
if let Some(coll_id) = query.collection_id {
|
||||
let cn = params.len() + 1;
|
||||
let vn = params.len() + 2;
|
||||
sql.push_str(&format!(
|
||||
" AND EXISTS (\
|
||||
SELECT 1 FROM collection_entries cef \
|
||||
WHERE cef.entry_id = e.id AND cef.collection_id = ?{cn} \
|
||||
AND (CAST(?{vn} AS INTEGER) & 12 != 0 \
|
||||
OR cef.visibility_bits & CAST(?{vn} AS INTEGER) != 0)\
|
||||
)"
|
||||
));
|
||||
params.push(coll_id.to_string());
|
||||
params.push(query.caller_bits.to_string());
|
||||
} else {
|
||||
let n = params.len() + 1;
|
||||
sql.push_str(&format!(
|
||||
" AND (CAST(?{n} AS INTEGER) & 12 != 0 \
|
||||
OR EXISTS (SELECT 1 FROM collection_entries ce \
|
||||
WHERE ce.entry_id = e.id AND ce.visibility_bits & CAST(?{n} AS INTEGER) != 0))"
|
||||
));
|
||||
params.push(query.caller_bits.to_string());
|
||||
}
|
||||
|
||||
sql.push_str(" GROUP BY e.id ORDER BY e.archived_at DESC, e.id DESC");
|
||||
|
||||
|
|
@ -2021,7 +2042,7 @@ mod tests {
|
|||
"Video 1", "https://example.com/pl/v1");
|
||||
|
||||
// Enroll the container (but NOT the child) in a USER-visible collection (bits=2).
|
||||
let coll = database::create_collection(&conn, "My List", "my-list", 2).unwrap();
|
||||
let coll = database::create_collection(&conn, "My List", "my-list", 2, true).unwrap();
|
||||
database::add_entry_to_collection(&conn, coll.id, container.id, 2).unwrap();
|
||||
|
||||
// USER caller (bits=2): child must be visible through parent's collection.
|
||||
|
|
|
|||
|
|
@ -163,6 +163,7 @@ pub struct CollectionRecord {
|
|||
pub name: String,
|
||||
pub slug: String,
|
||||
pub default_visibility_bits: u32,
|
||||
pub requires_auth: bool,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
|
|
@ -339,6 +340,7 @@ pub fn initialize_schema(conn: &Connection) -> Result<()> {
|
|||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
default_visibility_bits INTEGER NOT NULL DEFAULT 2,
|
||||
requires_auth INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
|
|
@ -434,6 +436,12 @@ pub fn initialize_schema(conn: &Connection) -> Result<()> {
|
|||
// Migration: add notes_json column to existing capture_jobs tables.
|
||||
// Silently ignored when the column already exists (idempotent).
|
||||
let _ = conn.execute("ALTER TABLE capture_jobs ADD COLUMN notes_json TEXT", []);
|
||||
// Migration: add requires_auth column to existing collections tables.
|
||||
// Silently ignored when the column already exists (idempotent).
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE collections ADD COLUMN requires_auth INTEGER NOT NULL DEFAULT 1",
|
||||
[],
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -2349,6 +2357,7 @@ pub fn create_collection(
|
|||
name: &str,
|
||||
slug: &str,
|
||||
default_visibility_bits: u32,
|
||||
requires_auth: bool,
|
||||
) -> Result<CollectionRecord> {
|
||||
if slug.is_empty() || slug.starts_with('_') {
|
||||
anyhow::bail!("collection slug must be non-empty and not start with underscore");
|
||||
|
|
@ -2356,13 +2365,14 @@ pub fn create_collection(
|
|||
let collection_uid = public_id("coll");
|
||||
let now = now_timestamp();
|
||||
conn.execute(
|
||||
"INSERT INTO collections (collection_uid, name, slug, default_visibility_bits, created_at) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
"INSERT INTO collections (collection_uid, name, slug, default_visibility_bits, requires_auth, created_at) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![
|
||||
collection_uid,
|
||||
name,
|
||||
slug,
|
||||
default_visibility_bits as i64,
|
||||
requires_auth as i64,
|
||||
now
|
||||
],
|
||||
)?;
|
||||
|
|
@ -2373,6 +2383,7 @@ pub fn create_collection(
|
|||
name: name.to_string(),
|
||||
slug: slug.to_string(),
|
||||
default_visibility_bits,
|
||||
requires_auth,
|
||||
created_at: now,
|
||||
})
|
||||
}
|
||||
|
|
@ -2380,7 +2391,7 @@ pub fn create_collection(
|
|||
/// Lists all collections ordered by creation date.
|
||||
pub fn list_collections(conn: &Connection) -> Result<Vec<CollectionRecord>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, collection_uid, name, slug, default_visibility_bits, created_at \
|
||||
"SELECT id, collection_uid, name, slug, default_visibility_bits, created_at, requires_auth \
|
||||
FROM collections ORDER BY created_at ASC",
|
||||
)?;
|
||||
stmt.query_map([], |row| {
|
||||
|
|
@ -2391,6 +2402,7 @@ pub fn list_collections(conn: &Connection) -> Result<Vec<CollectionRecord>> {
|
|||
slug: row.get(3)?,
|
||||
default_visibility_bits: row.get::<_, i64>(4)? as u32,
|
||||
created_at: row.get(5)?,
|
||||
requires_auth: row.get::<_, i64>(6)? != 0,
|
||||
})
|
||||
})?
|
||||
.collect::<Result<_, _>>()
|
||||
|
|
@ -2400,7 +2412,7 @@ pub fn list_collections(conn: &Connection) -> Result<Vec<CollectionRecord>> {
|
|||
/// Returns a collection by its uid, or None if not found.
|
||||
pub fn get_collection_by_uid(conn: &Connection, uid: &str) -> Result<Option<CollectionRecord>> {
|
||||
conn.query_row(
|
||||
"SELECT id, collection_uid, name, slug, default_visibility_bits, created_at \
|
||||
"SELECT id, collection_uid, name, slug, default_visibility_bits, created_at, requires_auth \
|
||||
FROM collections WHERE collection_uid = ?1",
|
||||
[uid],
|
||||
|row| {
|
||||
|
|
@ -2411,6 +2423,7 @@ pub fn get_collection_by_uid(conn: &Connection, uid: &str) -> Result<Option<Coll
|
|||
slug: row.get(3)?,
|
||||
default_visibility_bits: row.get::<_, i64>(4)? as u32,
|
||||
created_at: row.get(5)?,
|
||||
requires_auth: row.get::<_, i64>(6)? != 0,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
|
@ -2418,6 +2431,24 @@ pub fn get_collection_by_uid(conn: &Connection, uid: &str) -> Result<Option<Coll
|
|||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Returns a collection by its slug, or None if not found.
|
||||
pub fn get_collection_by_slug(conn: &Connection, slug: &str) -> Result<Option<CollectionRecord>> {
|
||||
conn.query_row(
|
||||
"SELECT id, collection_uid, name, slug, default_visibility_bits, created_at, requires_auth \
|
||||
FROM collections WHERE slug = ?1",
|
||||
[slug],
|
||||
|row| Ok(CollectionRecord {
|
||||
id: row.get(0)?,
|
||||
collection_uid: row.get(1)?,
|
||||
name: row.get(2)?,
|
||||
slug: row.get(3)?,
|
||||
default_visibility_bits: row.get::<_, i64>(4)? as u32,
|
||||
created_at: row.get(5)?,
|
||||
requires_auth: row.get::<_, i64>(6)? != 0,
|
||||
}),
|
||||
).optional().map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Adds an entry to a collection with given visibility_bits. Idempotent (INSERT OR IGNORE).
|
||||
pub fn add_entry_to_collection(
|
||||
conn: &Connection,
|
||||
|
|
@ -2462,24 +2493,55 @@ pub fn remove_entry_from_collection(
|
|||
Ok(n > 0)
|
||||
}
|
||||
|
||||
/// Returns (collection_id, collection_uid, visibility_bits) for all collections containing an entry.
|
||||
/// Returns (collection_id, collection_uid, name, visibility_bits) for all collections containing an entry.
|
||||
pub fn get_entry_collection_memberships(
|
||||
conn: &Connection,
|
||||
entry_id: i64,
|
||||
) -> Result<Vec<(i64, String, u32)>> {
|
||||
) -> Result<Vec<(i64, String, String, u32)>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT ce.collection_id, c.collection_uid, ce.visibility_bits \
|
||||
"SELECT ce.collection_id, c.collection_uid, c.name, ce.visibility_bits \
|
||||
FROM collection_entries ce \
|
||||
JOIN collections c ON c.id = ce.collection_id \
|
||||
WHERE ce.entry_id = ?1",
|
||||
)?;
|
||||
stmt.query_map([entry_id], |row| {
|
||||
Ok((row.get(0)?, row.get(1)?, row.get::<_, i64>(2)? as u32))
|
||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get::<_, i64>(3)? as u32))
|
||||
})?
|
||||
.collect::<Result<_, _>>()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Returns true if this entry (or its direct parent, for child entries) is in at least one
|
||||
/// collection with `requires_auth = false` AND `collection_entries.visibility_bits & ROLE_GUEST (1) != 0`.
|
||||
///
|
||||
/// Child entries are not directly assigned to collections; they inherit visibility from their
|
||||
/// parent's collection membership, matching the logic in `list_child_entries`.
|
||||
pub fn is_entry_publicly_accessible(conn: &Connection, entry_uid: &str) -> Result<bool> {
|
||||
let count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM archived_entries e \
|
||||
WHERE e.entry_uid = ?1 \
|
||||
AND (\
|
||||
EXISTS (\
|
||||
SELECT 1 FROM collection_entries ce \
|
||||
JOIN collections c ON c.id = ce.collection_id \
|
||||
WHERE ce.entry_id = e.id \
|
||||
AND c.requires_auth = 0 \
|
||||
AND (ce.visibility_bits & 1) != 0\
|
||||
) \
|
||||
OR (e.parent_entry_id IS NOT NULL AND EXISTS (\
|
||||
SELECT 1 FROM collection_entries ce_p \
|
||||
JOIN collections c ON c.id = ce_p.collection_id \
|
||||
WHERE ce_p.entry_id = e.parent_entry_id \
|
||||
AND c.requires_auth = 0 \
|
||||
AND (ce_p.visibility_bits & 1) != 0\
|
||||
))\
|
||||
)",
|
||||
[entry_uid],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
/// Renames a collection and/or updates its default_visibility_bits.
|
||||
/// Returns true if updated, false if not found.
|
||||
/// Refuses to rename the '_default_' collection but allows changing its
|
||||
|
|
@ -2489,6 +2551,7 @@ pub fn update_collection(
|
|||
collection_uid: &str,
|
||||
new_name: Option<&str>,
|
||||
new_visibility_bits: Option<u32>,
|
||||
requires_auth: Option<bool>,
|
||||
) -> Result<bool> {
|
||||
let coll = get_collection_by_uid(conn, collection_uid)?;
|
||||
let Some(coll) = coll else { return Ok(false) };
|
||||
|
|
@ -2497,9 +2560,10 @@ pub fn update_collection(
|
|||
}
|
||||
let name = new_name.unwrap_or(&coll.name);
|
||||
let vbits = new_visibility_bits.unwrap_or(coll.default_visibility_bits);
|
||||
let auth = requires_auth.unwrap_or(coll.requires_auth);
|
||||
conn.execute(
|
||||
"UPDATE collections SET name = ?1, default_visibility_bits = ?2 WHERE id = ?3",
|
||||
params![name, vbits as i64, coll.id],
|
||||
"UPDATE collections SET name = ?1, default_visibility_bits = ?2, requires_auth = ?3 WHERE id = ?4",
|
||||
params![name, vbits as i64, auth as i64, coll.id],
|
||||
)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
|
@ -2962,7 +3026,7 @@ mod tests {
|
|||
};
|
||||
|
||||
// Changing default_visibility_bits on _default_ must succeed.
|
||||
let updated = update_collection(&conn, &coll_uid, None, Some(3)).unwrap();
|
||||
let updated = update_collection(&conn, &coll_uid, None, Some(3), None).unwrap();
|
||||
assert!(updated, "visibility change on _default_ should succeed");
|
||||
let bits: u32 = conn
|
||||
.query_row(
|
||||
|
|
@ -2974,7 +3038,7 @@ mod tests {
|
|||
assert_eq!(bits, 3, "default_visibility_bits should be updated to 3");
|
||||
|
||||
// Renaming _default_ must still be rejected.
|
||||
let err = update_collection(&conn, &coll_uid, Some("My Archive"), None);
|
||||
let err = update_collection(&conn, &coll_uid, Some("My Archive"), None, None);
|
||||
assert!(err.is_err(), "renaming _default_ must be rejected");
|
||||
assert!(
|
||||
err.unwrap_err().to_string().contains("cannot rename"),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue