From 931e27d09c5d43a8f159821ad02d84f1b5843545 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 20 Jul 2026 19:52:51 +0200 Subject: [PATCH] fix(resources): wire missing created_by and updated_by --- examples/bench_resource_row_map.rs | 4 +++ .../src/lib/components/ResourceList.svelte | 1 + frontend/src/lib/utils/folderAccess.ts | 1 - frontend/src/routes/favorites/+page.svelte | 3 +- frontend/src/routes/favorites/page.test.ts | 1 - frontend/src/routes/recent/+page.svelte | 15 +++++---- frontend/src/routes/recent/page.test.ts | 1 - frontend/src/routes/trash/+page.svelte | 22 +++++++++---- src/application/dtos/favorites_dto.rs | 5 +++ src/application/dtos/folder_dto.rs | 7 ++++ src/application/dtos/recent_dto.rs | 10 ++++++ src/application/dtos/trash_dto.rs | 6 ++++ src/application/services/trash_service.rs | 10 +++--- .../pg/favorites_pg_repository.rs | 7 +++- .../repositories/pg/folder_db_repository.rs | 16 ++++++++-- .../pg/recent_items_pg_repository.rs | 7 +++- .../repositories/pg/trash_db_repository.rs | 9 +++++- .../api/handlers/favorites_handler.rs | 10 +++--- src/interfaces/api/handlers/folder_handler.rs | 10 +++--- src/interfaces/api/handlers/recent_handler.rs | 10 +++--- tests/api/grants.hurl | 32 +++++++++++++++++++ 21 files changed, 140 insertions(+), 47 deletions(-) diff --git a/examples/bench_resource_row_map.rs b/examples/bench_resource_row_map.rs index ae069dba..64077274 100644 --- a/examples/bench_resource_row_map.rs +++ b/examples/bench_resource_row_map.rs @@ -95,6 +95,8 @@ fn rows(n: usize) -> Vec { } else { Some("a".repeat(64)) }, + created_by: Some(Uuid::new_v4()), + updated_by: Some(Uuid::new_v4()), sort_str: format!("row {i}"), type_order: 0, folder_first: if is_folder { 0 } else { 1 }, @@ -262,6 +264,8 @@ fn fav_rows(n: usize) -> Vec { } else { Some("a".repeat(64)) }, + created_by: Some(Uuid::new_v4()), + updated_by: Some(Uuid::new_v4()), is_owner: true, favorited_at: ts, path: Some(format!("Documents/Work/item-{i:05}")), diff --git a/frontend/src/lib/components/ResourceList.svelte b/frontend/src/lib/components/ResourceList.svelte index c190eb0b..83e78d9d 100644 --- a/frontend/src/lib/components/ResourceList.svelte +++ b/frontend/src/lib/components/ResourceList.svelte @@ -1570,6 +1570,7 @@ .rl-ctx-item--disabled { opacity: 0.5; } + .rl-ctx-item--disabled:hover { background: transparent; } diff --git a/frontend/src/lib/utils/folderAccess.ts b/frontend/src/lib/utils/folderAccess.ts index b75d3f66..4fe58c80 100644 --- a/frontend/src/lib/utils/folderAccess.ts +++ b/frontend/src/lib/utils/folderAccess.ts @@ -74,4 +74,3 @@ export async function probeFolderAccess(id: string): Promise { inflight.set(id, p); return p; } - diff --git a/frontend/src/routes/favorites/+page.svelte b/frontend/src/routes/favorites/+page.svelte index d51d78f6..1f85ba71 100644 --- a/frontend/src/routes/favorites/+page.svelte +++ b/frontend/src/routes/favorites/+page.svelte @@ -366,8 +366,7 @@ sel.forEach(unfavorite)}>{t('files.unfavorite', 'Remove favorite')} {/snippet} diff --git a/frontend/src/routes/favorites/page.test.ts b/frontend/src/routes/favorites/page.test.ts index 989a45bc..54fd3ea2 100644 --- a/frontend/src/routes/favorites/page.test.ts +++ b/frontend/src/routes/favorites/page.test.ts @@ -26,7 +26,6 @@ vi.mock('$lib/api/endpoints/folders', () => ({ renameFolder: vi.fn(), deleteFold vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog, promptDialog })); import { fetchFavoritesPage, removeFavorite } from '$lib/api/endpoints/favorites'; -import { deleteFile } from '$lib/api/endpoints/files'; import FavoritesPage from './+page.svelte'; const m = (fn: unknown) => fn as ReturnType; diff --git a/frontend/src/routes/recent/+page.svelte b/frontend/src/routes/recent/+page.svelte index 6a634709..d500a134 100644 --- a/frontend/src/routes/recent/+page.svelte +++ b/frontend/src/routes/recent/+page.svelte @@ -48,9 +48,12 @@ let reversed = $state(false); const owners = useOwnerCache(resolveOwnerName); - // Envelope shape: `accessed_at` → `ctx.date`, `updated_by` → `ctx.ownerId` - // (Recent's provenance semantic — "who touched this recently" — differs - // from Favorites'/Files' `created_by`). + // Envelope shape: `accessed_at` → `ctx.date`, `created_by` → `ctx.ownerId`. + // Recent is a per-user view of items the caller accessed; the "who + // touched this last" (`updated_by`) semantic is real but adds noise + // (mostly the current user), so we align with Files / Favorites and + // show the original author instead. Cross-surface consistency wins + // over the finer-grained signal. // // Dotfile hiding is delegated to ResourceList via `showDotfileToggle` // — the component reads `preferences.hideDotfiles` and drops matching @@ -117,10 +120,10 @@ raw = reset ? page.items : [...raw, ...page.items]; primeContextPage(contextMap, reset, page.items, (it) => [ it.resource.id, - { date: it.accessed_at, ownerId: it.resource.updated_by ?? null } + { date: it.accessed_at, ownerId: it.resource.created_by ?? null } ]); cursor = page.next_cursor; - void owners.resolve(page.items.map((i) => i.resource.updated_by)); + void owners.resolve(page.items.map((i) => i.resource.created_by)); } catch (e) { console.error('recent: load error', e); error = t('errors_loadFailed', 'Failed to load items'); @@ -184,7 +187,7 @@ raw = [...raw.slice(0, idx), snapshot, ...raw.slice(idx)]; contextMap.set(item.id, { date: snapshot.accessed_at, - ownerId: snapshot.resource.updated_by ?? null + ownerId: snapshot.resource.created_by ?? null }); errorToast(e); } diff --git a/frontend/src/routes/recent/page.test.ts b/frontend/src/routes/recent/page.test.ts index 1aae3136..a9f7f73c 100644 --- a/frontend/src/routes/recent/page.test.ts +++ b/frontend/src/routes/recent/page.test.ts @@ -25,7 +25,6 @@ vi.mock('$lib/api/endpoints/folders', () => ({ renameFolder: vi.fn(), deleteFold vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog, promptDialog })); import { fetchRecentPage, clearRecent, removeFromRecent } from '$lib/api/endpoints/recent'; -import { deleteFile } from '$lib/api/endpoints/files'; import RecentPage from './+page.svelte'; const m = (fn: unknown) => fn as ReturnType; diff --git a/frontend/src/routes/trash/+page.svelte b/frontend/src/routes/trash/+page.svelte index beb4c2af..bc565334 100644 --- a/frontend/src/routes/trash/+page.svelte +++ b/frontend/src/routes/trash/+page.svelte @@ -300,17 +300,14 @@ standard action-bar sizing and reads consistently with `/recent` and `/favorites` batch clusters. --> - sel.forEach(restore)} + >{t('trash.restore', 'Restore')} sel.forEach(purge)}>{t('trash.delete', 'Delete permanently')} {/snippet} {#snippet rowBadge(_item, ctx)} @@ -414,4 +411,17 @@ :global(.files-grid-view .file-item .action-cell .btn-action--delete:hover) { color: var(--color-error-text); } + + /* List view: hide the expiry chip that ResourceList paints inside + `.file-icon__badge`. In list mode the same info is already in + the "Expires at" column (`dateCell` snippet above) — showing + the chip on the tiny row icon crops it and duplicates the + signal. Grid view keeps the chip: no dedicated column exists + there and the badge is the ONLY expiration surface on the + card. Scoped to trash because trash is the only section + emitting a rowBadge today; if another section starts using it, + this rule stays inert for them. */ + :global(.files-list-view .file-item .file-icon__badge) { + display: none; + } diff --git a/src/application/dtos/favorites_dto.rs b/src/application/dtos/favorites_dto.rs index 01f9b18b..5a28b6ad 100644 --- a/src/application/dtos/favorites_dto.rs +++ b/src/application/dtos/favorites_dto.rs @@ -127,6 +127,11 @@ pub struct FavoriteResourceRow { /// folder rows. Routes into `FileDto::content_hash` and feeds /// `File::compute_etag` to populate `FileDto::etag`. pub blob_hash: Option, + /// §14 provenance — who created the row. `None` when the creator + /// was deleted (FK `ON DELETE SET NULL`). + pub created_by: Option, + /// §14 provenance — who last touched the row. + pub updated_by: Option, /// `true` when `owner_id == requesting user_id`. pub is_owner: bool, pub favorited_at: DateTime, diff --git a/src/application/dtos/folder_dto.rs b/src/application/dtos/folder_dto.rs index a5207c0b..620fb78d 100644 --- a/src/application/dtos/folder_dto.rs +++ b/src/application/dtos/folder_dto.rs @@ -219,6 +219,13 @@ pub struct FolderResourceRow { /// on the REST `/api/folders/{id}/resources` listing so API /// consumers can issue conditional requests against listed files. pub blob_hash: Option, + /// §14 provenance — who created the row. `None` when the creator was + /// deleted (FK `ON DELETE SET NULL`). Populates + /// `FileDto::created_by` / `FolderDto::created_by` on the listing so + /// the UI can render the owner column without a follow-up query. + pub created_by: Option, + /// §14 provenance — who last touched the row. + pub updated_by: Option, // Pre-computed sort fields — returned by the SQL for cursor construction. /// `LOWER(name)` used by `name`/`type` sorts. pub sort_str: String, diff --git a/src/application/dtos/recent_dto.rs b/src/application/dtos/recent_dto.rs index cad3d101..635b4235 100644 --- a/src/application/dtos/recent_dto.rs +++ b/src/application/dtos/recent_dto.rs @@ -112,6 +112,16 @@ pub struct RecentResourceRow { /// folder rows. Feeds `File::compute_etag` so this listing's /// `etag` matches GET/HEAD/PROPFIND for the same file. pub blob_hash: Option, + /// §14 provenance — who created the row. `None` when the creator + /// was deleted (FK `ON DELETE SET NULL`). Powers the owner column + /// on the `/recent` UI (aligned with `/files` and `/favorites` + /// for cross-surface consistency, rather than the finer-grained + /// but noisier "who touched this last" signal). + pub created_by: Option, + /// §14 provenance — who last touched the row. Not currently + /// consumed by the UI but surfaced for API parity with the other + /// listing endpoints. + pub updated_by: Option, /// `true` when `owner_id == requesting user_id`. pub is_owner: bool, pub accessed_at: DateTime, diff --git a/src/application/dtos/trash_dto.rs b/src/application/dtos/trash_dto.rs index e431f5c6..9c9c5a1f 100644 --- a/src/application/dtos/trash_dto.rs +++ b/src/application/dtos/trash_dto.rs @@ -71,6 +71,12 @@ pub struct TrashResourceRow { /// same file (restorable trash items are conditional-request /// targets too). pub blob_hash: Option, + /// §14 provenance — who created the row. `None` when the creator + /// was deleted (FK `ON DELETE SET NULL`). + pub created_by: Option, + /// §14 provenance — who last touched the row (includes the trash + /// action itself, which stamps `updated_by = caller_id`). + pub updated_by: Option, pub trashed_at: DateTime, pub deletion_date: DateTime, /// Original location path (for folders: `path`; for files: `parent.path || '/' || name`). diff --git a/src/application/services/trash_service.rs b/src/application/services/trash_service.rs index 6fa56bae..728a1fb1 100644 --- a/src/application/services/trash_service.rs +++ b/src/application/services/trash_service.rs @@ -888,9 +888,8 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto { icon_class: intern_display("fas fa-folder"), icon_special_class: intern_display("folder-icon"), category: intern_display("Folder"), - // §14 provenance not selected by the trash listing query. - created_by: None, - updated_by: None, + created_by: row.created_by, + updated_by: row.updated_by, }; TrashResourceItemDto { resource_type: ResourceTypeDto::Folder, @@ -932,9 +931,8 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto { sort_date: None, content_hash, etag, - // §14 provenance not selected by the trash listing query. - created_by: None, - updated_by: None, + created_by: row.created_by, + updated_by: row.updated_by, }; TrashResourceItemDto { resource_type: ResourceTypeDto::File, diff --git a/src/infrastructure/repositories/pg/favorites_pg_repository.rs b/src/infrastructure/repositories/pg/favorites_pg_repository.rs index 392f7d60..0e735d82 100644 --- a/src/infrastructure/repositories/pg/favorites_pg_repository.rs +++ b/src/infrastructure/repositories/pg/favorites_pg_repository.rs @@ -318,6 +318,7 @@ impl FavoritesRepositoryPort for FavoritesPgRepository { fld.drive_id AS drive_id, NULL::text AS blob_hash, fld.created_by AS created_by, + fld.updated_by AS updated_by, EXISTS ( SELECT 1 FROM storage.role_grants g WHERE g.resource_type = 'drive' @@ -350,6 +351,7 @@ impl FavoritesRepositoryPort for FavoritesPgRepository { f.drive_id AS drive_id, f.blob_hash, f.created_by AS created_by, + f.updated_by AS updated_by, EXISTS ( SELECT 1 FROM storage.role_grants g WHERE g.resource_type = 'drive' @@ -529,7 +531,8 @@ impl FavoritesRepositoryPort for FavoritesPgRepository { SELECT r.resource_type, r.resource_id, r.name, r.parent_id, r.mime_type, r.size, r.resource_created_at, r.modified_at, - r.drive_id, r.is_owner, r.favorited_at, r.resource_path, + r.drive_id, r.blob_hash, r.created_by, r.updated_by, + r.is_owner, r.favorited_at, r.resource_path, r.sort_str, r.type_order, r.folder_first{username_col} FROM resources r {user_join} @@ -602,6 +605,8 @@ LIMIT $6" modified_at: row.get("modified_at"), drive_id: row.get("drive_id"), blob_hash: row.try_get("blob_hash").ok(), + created_by: row.try_get("created_by").ok(), + updated_by: row.try_get("updated_by").ok(), is_owner: row.try_get("is_owner").unwrap_or(false), favorited_at: row.get("favorited_at"), path: row.try_get("resource_path").ok(), diff --git a/src/infrastructure/repositories/pg/folder_db_repository.rs b/src/infrastructure/repositories/pg/folder_db_repository.rs index d59ae393..03d4ab4b 100644 --- a/src/infrastructure/repositories/pg/folder_db_repository.rs +++ b/src/infrastructure/repositories/pg/folder_db_repository.rs @@ -1446,6 +1446,8 @@ impl FolderDbRepository { f.updated_at AS modified_at, f.drive_id, NULL::text AS blob_hash, + f.created_by, + f.updated_by, LOWER(f.name) AS sort_str, 0::bigint AS type_order, 0::int AS folder_first @@ -1465,6 +1467,8 @@ impl FolderDbRepository { fm.updated_at AS modified_at, fm.drive_id, fm.blob_hash, + fm.created_by, + fm.updated_by, LOWER(fm.name) AS sort_str, fm.category_order::bigint AS type_order, 1::int AS folder_first @@ -1655,6 +1659,7 @@ impl FolderDbRepository { let sql = format!( "SELECT resource_type, id, name, folder_id, mime_type, size, \ created_at, modified_at, drive_id, blob_hash, \ + created_by, updated_by, \ sort_str, type_order, folder_first \ FROM ({inner}) r \ {outer_order} \ @@ -1663,6 +1668,7 @@ impl FolderDbRepository { // Row: (resource_type, id, name, folder_id, mime_type, size, // created_at, modified_at, drive_id, blob_hash, + // created_by, updated_by, // sort_str, type_order, folder_first) type Row = ( String, @@ -1675,6 +1681,8 @@ impl FolderDbRepository { chrono::DateTime, Uuid, // drive_id Option, + Option, // created_by + Option, // updated_by String, i64, i32, @@ -1706,9 +1714,11 @@ impl FolderDbRepository { modified_at: r.7, drive_id: r.8, blob_hash: r.9, - sort_str: r.10, - type_order: r.11, - folder_first: r.12, + created_by: r.10, + updated_by: r.11, + sort_str: r.12, + type_order: r.13, + folder_first: r.14, }) .collect()) } diff --git a/src/infrastructure/repositories/pg/recent_items_pg_repository.rs b/src/infrastructure/repositories/pg/recent_items_pg_repository.rs index d7b04a68..1475bf8d 100644 --- a/src/infrastructure/repositories/pg/recent_items_pg_repository.rs +++ b/src/infrastructure/repositories/pg/recent_items_pg_repository.rs @@ -244,6 +244,7 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository { fld.drive_id AS drive_id, NULL::text AS blob_hash, fld.created_by AS created_by, + fld.updated_by AS updated_by, EXISTS ( SELECT 1 FROM storage.role_grants g WHERE g.resource_type = 'drive' @@ -276,6 +277,7 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository { f.drive_id AS drive_id, f.blob_hash, f.created_by AS created_by, + f.updated_by AS updated_by, EXISTS ( SELECT 1 FROM storage.role_grants g WHERE g.resource_type = 'drive' @@ -454,7 +456,8 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository { SELECT r.resource_type, r.resource_id, r.name, r.parent_id, r.mime_type, r.size, r.resource_created_at, r.modified_at, - r.drive_id, r.is_owner, r.accessed_at, r.resource_path, + r.drive_id, r.blob_hash, r.created_by, r.updated_by, + r.is_owner, r.accessed_at, r.resource_path, r.sort_str, r.type_order, r.folder_first{username_col} FROM resources r {user_join} @@ -531,6 +534,8 @@ LIMIT $6" modified_at: row.get("modified_at"), drive_id: row.get("drive_id"), blob_hash: row.try_get("blob_hash").ok(), + created_by: row.try_get("created_by").ok(), + updated_by: row.try_get("updated_by").ok(), is_owner: row.try_get("is_owner").unwrap_or(false), accessed_at: row.get("accessed_at"), path: row.try_get("resource_path").ok(), diff --git a/src/infrastructure/repositories/pg/trash_db_repository.rs b/src/infrastructure/repositories/pg/trash_db_repository.rs index a1a9e07f..bcb6d844 100644 --- a/src/infrastructure/repositories/pg/trash_db_repository.rs +++ b/src/infrastructure/repositories/pg/trash_db_repository.rs @@ -367,6 +367,8 @@ impl TrashDbRepository { fld.updated_at AS modified_at, fld.drive_id AS drive_id, NULL::text AS blob_hash, + fld.created_by AS created_by, + fld.updated_by AS updated_by, fld.trashed_at AS trashed_at, (fld.trashed_at + ($7::int * INTERVAL '1 day')) AS deletion_date, fld.path::text AS resource_path, @@ -393,6 +395,8 @@ impl TrashDbRepository { f.updated_at AS modified_at, f.drive_id AS drive_id, f.blob_hash, + f.created_by AS created_by, + f.updated_by AS updated_by, f.trashed_at AS trashed_at, (f.trashed_at + ($7::int * INTERVAL '1 day')) AS deletion_date, COALESCE(pfld.path::text || '/' || f.name, f.name) AS resource_path, @@ -522,7 +526,8 @@ impl TrashDbRepository { SELECT r.resource_type, r.resource_id, r.name, r.parent_id, r.mime_type, r.size, r.resource_created_at, r.modified_at, - r.drive_id, r.trashed_at, r.deletion_date, r.resource_path, + r.drive_id, r.blob_hash, r.created_by, r.updated_by, + r.trashed_at, r.deletion_date, r.resource_path, r.sort_str, r.type_order, r.folder_first FROM resources r {keyset} @@ -581,6 +586,8 @@ LIMIT $6" modified_at: row.get("modified_at"), drive_id: row.get("drive_id"), blob_hash: row.try_get("blob_hash").ok(), + created_by: row.try_get("created_by").ok(), + updated_by: row.try_get("updated_by").ok(), trashed_at, deletion_date, path: row.try_get("resource_path").ok(), diff --git a/src/interfaces/api/handlers/favorites_handler.rs b/src/interfaces/api/handlers/favorites_handler.rs index 9c66a523..538cc151 100644 --- a/src/interfaces/api/handlers/favorites_handler.rs +++ b/src/interfaces/api/handlers/favorites_handler.rs @@ -217,9 +217,8 @@ pub async fn list_favorites_resources( icon_class: intern_display("fas fa-folder"), icon_special_class: intern_display("folder-icon"), category: intern_display("Folder"), - // §14 provenance not selected by the favorites query. - created_by: None, - updated_by: None, + created_by: row.created_by, + updated_by: row.updated_by, }; FavoritesResourceItemDto { resource_type: ResourceTypeDto::Folder, @@ -266,9 +265,8 @@ pub async fn list_favorites_resources( sort_date: None, content_hash, etag, - // §14 provenance not selected by the favorites query. - created_by: None, - updated_by: None, + created_by: row.created_by, + updated_by: row.updated_by, }; FavoritesResourceItemDto { resource_type: ResourceTypeDto::File, diff --git a/src/interfaces/api/handlers/folder_handler.rs b/src/interfaces/api/handlers/folder_handler.rs index b6d1e964..5956adab 100644 --- a/src/interfaces/api/handlers/folder_handler.rs +++ b/src/interfaces/api/handlers/folder_handler.rs @@ -487,9 +487,8 @@ pub async fn list_folder_resources( icon_class: intern_display("fas fa-folder"), icon_special_class: intern_display("folder-icon"), category: intern_display("Folder"), - // §14 provenance not selected by the resources query. - created_by: None, - updated_by: None, + created_by: row.created_by, + updated_by: row.updated_by, }; FolderResourceItemDto { resource_type: ResourceTypeDto::Folder, @@ -539,9 +538,8 @@ pub async fn list_folder_resources( sort_date: None, content_hash, etag, - // §14 provenance not selected by the resources query. - created_by: None, - updated_by: None, + created_by: row.created_by, + updated_by: row.updated_by, }; FolderResourceItemDto { resource_type: ResourceTypeDto::File, diff --git a/src/interfaces/api/handlers/recent_handler.rs b/src/interfaces/api/handlers/recent_handler.rs index 106d0f32..10ad9f43 100644 --- a/src/interfaces/api/handlers/recent_handler.rs +++ b/src/interfaces/api/handlers/recent_handler.rs @@ -237,9 +237,8 @@ pub async fn list_recent_resources( icon_class: intern_display("fas fa-folder"), icon_special_class: intern_display("folder-icon"), category: intern_display("Folder"), - // §14 provenance not selected by the recents query. - created_by: None, - updated_by: None, + created_by: row.created_by, + updated_by: row.updated_by, }; RecentResourceItemDto { resource_type: ResourceTypeDto::Folder, @@ -284,9 +283,8 @@ pub async fn list_recent_resources( sort_date: None, content_hash, etag, - // §14 provenance not selected by the recents query. - created_by: None, - updated_by: None, + created_by: row.created_by, + updated_by: row.updated_by, }; RecentResourceItemDto { resource_type: ResourceTypeDto::File, diff --git a/tests/api/grants.hurl b/tests/api/grants.hurl index a292b7fe..941a5091 100644 --- a/tests/api/grants.hurl +++ b/tests/api/grants.hurl @@ -707,6 +707,38 @@ HTTP 200 jsonpath "$.created_by" == "{{alice_user_id}}" jsonpath "$.updated_by" == "{{adam_user_id}}" +# ── D0 §14 provenance survives on the LISTING endpoint too ── +# The rename-response asserts above cover the mutation DTO, but +# /api/folders/{id}/resources has its own DTO-build path that +# used to hardcode created_by/updated_by = None (silent bug — +# owner column rendered "—" on /files for everyone). Hit the +# listing and re-assert both the untouched folder (both = alice) +# AND the Adam-renamed file (created_by=alice, updated_by=adam) +# on the same page — two shapes, one round-trip. +# +# Fixed indices are safe because at this point perm_folder_id +# holds exactly two rows and the default order_by=name puts +# 'perm-test-child' (folder) at [0] and 'adam-renamed-logo.jpg' +# (file) at [1]. Anything appended to this folder later in the +# scenario would break these indices — hence the assertion runs +# BEFORE the subsequent thumbnail/create/upload steps. +GET {{base_url}}/api/folders/{{perm_folder_id}}/resources +Authorization: Bearer {{alice_token}} + +HTTP 200 +[Asserts] +jsonpath "$.items" count == 2 +# [0] — untouched folder inherits Alice on both fields. +jsonpath "$.items[0].resource.name" == "perm-test-child" +jsonpath "$.items[0].resource.created_by" == "{{alice_user_id}}" +jsonpath "$.items[0].resource.updated_by" == "{{alice_user_id}}" +# [1] — file Adam renamed. created_by stays alice (original +# uploader), updated_by is adam (last mutator). Canonical +# listing-side cross-user split. +jsonpath "$.items[1].resource.name" == "adam-renamed-logo.jpg" +jsonpath "$.items[1].resource.created_by" == "{{alice_user_id}}" +jsonpath "$.items[1].resource.updated_by" == "{{adam_user_id}}" + # ── Thumbnail push (Update) succeeds ──────────────────────── PUT {{base_url}}/api/files/{{perm_file_id}}/thumbnail/preview Authorization: Bearer {{adam_token}}