Merge pull request #534 from EdouardVanbelle/feat/drive
This commit is contained in:
@@ -3,13 +3,28 @@
|
|||||||
import { listFolder, moveFolder } from '$lib/api/endpoints/folders';
|
import { listFolder, moveFolder } from '$lib/api/endpoints/folders';
|
||||||
import { moveFile } from '$lib/api/endpoints/files';
|
import { moveFile } from '$lib/api/endpoints/files';
|
||||||
import { copyFiles, copyFolders } from '$lib/api/endpoints/batch';
|
import { copyFiles, copyFolders } from '$lib/api/endpoints/batch';
|
||||||
import type { FolderItem } from '$lib/api/types';
|
import type { Drive, DriveRole, FolderItem } from '$lib/api/types';
|
||||||
import Icon from '$lib/icons/Icon.svelte';
|
import Icon from '$lib/icons/Icon.svelte';
|
||||||
import Modal from '$lib/components/Modal.svelte';
|
import Modal from '$lib/components/Modal.svelte';
|
||||||
import { t } from '$lib/i18n/index.svelte';
|
import { t } from '$lib/i18n/index.svelte';
|
||||||
import { session } from '$lib/stores/session.svelte';
|
import { drives as drivesStore, driveIcon } from '$lib/stores/drives.svelte';
|
||||||
import { ui } from '$lib/stores/ui.svelte';
|
import { ui } from '$lib/stores/ui.svelte';
|
||||||
|
|
||||||
|
// A drive accepts new items only if the caller can Create on its root.
|
||||||
|
// Owner / Editor / Contributor cover that; Commenter + Viewer cannot.
|
||||||
|
const WRITABLE_ROLES: readonly DriveRole[] = ['owner', 'editor', 'contributor'] as const;
|
||||||
|
function isWritable(d: Drive): boolean {
|
||||||
|
return d.caller_role != null && WRITABLE_ROLES.includes(d.caller_role);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default-personal first, then secondary personals, then shared; within
|
||||||
|
// a group, alphabetical. Mirrors DrivePicker so the sidebar and this
|
||||||
|
// dialog rank drives identically.
|
||||||
|
function driveRank(d: Drive): number {
|
||||||
|
if (d.default_for_user) return 0;
|
||||||
|
return d.kind === 'personal' ? 1 : 2;
|
||||||
|
}
|
||||||
|
|
||||||
interface Target {
|
interface Target {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -34,9 +49,21 @@
|
|||||||
let crumbs = $state<Array<{ id: string; name: string }>>([]);
|
let crumbs = $state<Array<{ id: string; name: string }>>([]);
|
||||||
let folders = $state<FolderItem[]>([]);
|
let folders = $state<FolderItem[]>([]);
|
||||||
let currentId = $state<string | null>(null);
|
let currentId = $state<string | null>(null);
|
||||||
|
let selectedDriveId = $state<string | null>(null);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let working = $state(false);
|
let working = $state(false);
|
||||||
|
|
||||||
|
const writableDrives = $derived(
|
||||||
|
[...drivesStore.drives].filter(isWritable).sort((a, b) => {
|
||||||
|
const r = driveRank(a) - driveRank(b);
|
||||||
|
return r !== 0 ? r : a.name.localeCompare(b.name);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// The chip strip only earns its vertical space when there's a real
|
||||||
|
// choice. One writable drive → identical to the single-drive UI.
|
||||||
|
const showDriveSwitcher = $derived(writableDrives.length > 1);
|
||||||
|
|
||||||
async function loadInto(id: string) {
|
async function loadInto(id: string) {
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
@@ -50,10 +77,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
const home = await session.loadHomeFolder();
|
await drivesStore.load();
|
||||||
if (!home) return;
|
const home = drivesStore.findDefault();
|
||||||
crumbs = [{ id: home, name: session.homeFolderName ?? t('nav.files', 'Files') }];
|
// Prefer the user's home drive when it's writable (covers the
|
||||||
await loadInto(home);
|
// common case: moving stuff around inside Personal). Otherwise
|
||||||
|
// fall back to the first writable drive, sorted as above.
|
||||||
|
const start = home && isWritable(home) ? home : writableDrives[0];
|
||||||
|
if (!start) return;
|
||||||
|
selectedDriveId = start.id;
|
||||||
|
crumbs = [{ id: start.root_folder_id, name: start.name }];
|
||||||
|
await loadInto(start.root_folder_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchDrive(d: Drive) {
|
||||||
|
if (d.id === selectedDriveId) return;
|
||||||
|
selectedDriveId = d.id;
|
||||||
|
crumbs = [{ id: d.root_folder_id, name: d.name }];
|
||||||
|
await loadInto(d.root_folder_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function enter(f: FolderItem) {
|
function enter(f: FolderItem) {
|
||||||
@@ -124,6 +164,30 @@
|
|||||||
|
|
||||||
<Modal bind:open title={moveTitle}>
|
<Modal bind:open title={moveTitle}>
|
||||||
<div data-testid="move-dialog">
|
<div data-testid="move-dialog">
|
||||||
|
{#if showDriveSwitcher}
|
||||||
|
<div
|
||||||
|
class="mv-drives"
|
||||||
|
role="tablist"
|
||||||
|
aria-label={t('drive.picker', 'Drives')}
|
||||||
|
data-testid="move-dialog-drives"
|
||||||
|
>
|
||||||
|
{#each writableDrives as d (d.id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={d.id === selectedDriveId}
|
||||||
|
class="mv-drive"
|
||||||
|
class:mv-drive--active={d.id === selectedDriveId}
|
||||||
|
data-testid={`move-dialog-drive-${d.id}`}
|
||||||
|
onclick={() => switchDrive(d)}
|
||||||
|
>
|
||||||
|
<Icon name={driveIcon(d)} />
|
||||||
|
<span>{d.name}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="mv-nav">
|
<div class="mv-nav">
|
||||||
<button
|
<button
|
||||||
class="mv-nav-btn"
|
class="mv-nav-btn"
|
||||||
@@ -196,6 +260,49 @@
|
|||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
/* Drive switcher: a chip strip across the top of the dialog. Hidden
|
||||||
|
when only one writable drive is in scope (single-drive UX). */
|
||||||
|
.mv-drives {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.375rem;
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
padding-bottom: var(--space-3);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-drive {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.3rem 0.625rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-bg-input);
|
||||||
|
color: var(--color-text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
max-width: 14rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-drive:hover:not(.mv-drive--active) {
|
||||||
|
background: var(--color-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-drive--active {
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-on-accent);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-drive span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.mv-nav {
|
.mv-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,11 +1,38 @@
|
|||||||
import { it, expect, vi, beforeEach } from 'vitest';
|
import { it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||||
|
|
||||||
const { session, ui } = vi.hoisted(() => ({
|
// The dialog now sources its starting folder from the drives store
|
||||||
session: { loadHomeFolder: vi.fn(async () => 'home'), homeFolderName: 'Files' },
|
// (D6 drive switcher), not from `session.loadHomeFolder`. We mock a
|
||||||
ui: { notify: vi.fn() }
|
// single default-personal drive whose `root_folder_id` is 'home' so
|
||||||
}));
|
// the existing assertions (listFolder('home'), moveFile('f1', 'home'))
|
||||||
vi.mock('$lib/stores/session.svelte', () => ({ session }));
|
// stay valid without test churn.
|
||||||
|
const { ui, drives, driveIcon } = vi.hoisted(() => {
|
||||||
|
const homeDrive = {
|
||||||
|
id: 'drive-home',
|
||||||
|
root_folder_id: 'home',
|
||||||
|
name: 'Personal',
|
||||||
|
kind: 'personal' as const,
|
||||||
|
default_for_user: 'user-1',
|
||||||
|
caller_role: 'owner' as const,
|
||||||
|
used_bytes: 0,
|
||||||
|
quota_bytes: null
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
ui: { notify: vi.fn() },
|
||||||
|
drives: {
|
||||||
|
drives: [homeDrive],
|
||||||
|
loaded: true,
|
||||||
|
load: vi.fn(async () => [homeDrive]),
|
||||||
|
findDefault: vi.fn(() => homeDrive),
|
||||||
|
findById: vi.fn((id: string) => (id === homeDrive.id ? homeDrive : null)),
|
||||||
|
findByRootFolderId: vi.fn((id: string) =>
|
||||||
|
id === homeDrive.root_folder_id ? homeDrive : null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
driveIcon: vi.fn(() => 'home')
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock('$lib/stores/drives.svelte', () => ({ drives, driveIcon }));
|
||||||
vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
|
vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
|
||||||
vi.mock('$lib/utils/errors', () => ({ errorToast: vi.fn() }));
|
vi.mock('$lib/utils/errors', () => ({ errorToast: vi.fn() }));
|
||||||
vi.mock('$lib/api/endpoints/folders', () => ({ listFolder: vi.fn(), moveFolder: vi.fn() }));
|
vi.mock('$lib/api/endpoints/folders', () => ({ listFolder: vi.fn(), moveFolder: vi.fn() }));
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
-- D6: cross-drive folder moves must propagate `drive_id` to the moved
|
||||||
|
-- folder's subtree (descendant folders + files), not just `lpath`.
|
||||||
|
--
|
||||||
|
-- Today's `cascade_folder_path()` trigger only rewrites `path` + `lpath`
|
||||||
|
-- on descendants — it leaves `drive_id` untouched. That worked when
|
||||||
|
-- moves were intra-drive (drive_id never changed), but after D5 the
|
||||||
|
-- `forbid_cross_drive_move` policy gate exposed the gap: a successful
|
||||||
|
-- cross-drive move (gate off OR not yet enforced) leaves the subtree
|
||||||
|
-- in an inconsistent state — lpath rooted in drive B but `drive_id`
|
||||||
|
-- column still drive A on every descendant row. Any drive-id-scoped
|
||||||
|
-- query then returns the wrong drive's content.
|
||||||
|
--
|
||||||
|
-- The fix is to extend the cascade trigger so a change in the parent
|
||||||
|
-- folder's `drive_id` (the only thing that changes drive_id during a
|
||||||
|
-- move) cascades to every descendant folder + every descendant file.
|
||||||
|
-- Files cascade too because `storage.files.drive_id` is the canonical
|
||||||
|
-- per-file drive-membership signal (D0 dual-write).
|
||||||
|
--
|
||||||
|
-- Migration is idempotent via `CREATE OR REPLACE FUNCTION`.
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION storage.cascade_folder_path()
|
||||||
|
RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
IF pg_trigger_depth() > 1 THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF OLD.path IS DISTINCT FROM NEW.path OR OLD.lpath IS DISTINCT FROM NEW.lpath THEN
|
||||||
|
-- Single batch update: rewrite path/lpath for every descendant
|
||||||
|
-- folder at once via the GiST lpath index.
|
||||||
|
UPDATE storage.folders
|
||||||
|
SET path = NEW.path || substr(path, length(OLD.path) + 1),
|
||||||
|
lpath = NEW.lpath || subpath(lpath, nlevel(OLD.lpath))
|
||||||
|
WHERE lpath <@ OLD.lpath
|
||||||
|
AND id != NEW.id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- D6: cascade `drive_id` to every descendant folder + file when the
|
||||||
|
-- moved row's drive_id has changed (cross-drive move). The GiST
|
||||||
|
-- index covers the folder predicate; `storage.files.drive_id` is
|
||||||
|
-- updated through the folder→file FK relation since files only
|
||||||
|
-- carry `folder_id` directly (drive_id is a denormalised dual-write).
|
||||||
|
--
|
||||||
|
-- Triggered on the column-list `AFTER UPDATE OF path, lpath, drive_id`
|
||||||
|
-- registration below — so this branch only runs when the explicit
|
||||||
|
-- move statement on the moved row sets `drive_id` to a new value.
|
||||||
|
-- The descendant batch UPDATE that fires from the path/lpath branch
|
||||||
|
-- above doesn't touch drive_id, so the trigger doesn't recurse on
|
||||||
|
-- the per-descendant rewrite.
|
||||||
|
IF OLD.drive_id IS DISTINCT FROM NEW.drive_id THEN
|
||||||
|
UPDATE storage.folders
|
||||||
|
SET drive_id = NEW.drive_id
|
||||||
|
WHERE lpath <@ NEW.lpath
|
||||||
|
AND drive_id = OLD.drive_id;
|
||||||
|
|
||||||
|
UPDATE storage.files f
|
||||||
|
SET drive_id = NEW.drive_id
|
||||||
|
FROM storage.folders fo
|
||||||
|
WHERE f.folder_id = fo.id
|
||||||
|
AND fo.lpath <@ NEW.lpath
|
||||||
|
AND f.drive_id = OLD.drive_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Re-register the trigger with `drive_id` added to the column list so the
|
||||||
|
-- trigger fires when a move sets a new drive_id on the moved row. (CREATE
|
||||||
|
-- OR REPLACE TRIGGER replaces the same name in place; no DROP needed.)
|
||||||
|
CREATE OR REPLACE TRIGGER trg_folders_cascade_path
|
||||||
|
AFTER UPDATE OF path, lpath, drive_id ON storage.folders
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION storage.cascade_folder_path();
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
-- ════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- D6 — storage.copy_folder_tree cross-drive support
|
||||||
|
-- ════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- D0/M5 (`20260802100004_copy_folder_tree_drive_id.sql`) introduced drive_id
|
||||||
|
-- into this function but pulled it from the SOURCE folder for every level —
|
||||||
|
-- a deliberate "intra-drive only" limitation called out in that migration's
|
||||||
|
-- header. After D6 landed cross-drive moves end-to-end (cascade trigger +
|
||||||
|
-- WITH dest CTE on file_move/folder_move) copies were the lone holdout: a
|
||||||
|
-- batch-copy of a folder tree into another drive left every new row with
|
||||||
|
-- the SOURCE's drive_id while parent_id pointed into the DESTINATION drive.
|
||||||
|
-- Net effect: the per-drive quota sweep (`SUM(size) WHERE drive_id = d.id`)
|
||||||
|
-- charged the SOURCE drive for size physically living under the dest tree.
|
||||||
|
--
|
||||||
|
-- The fix mirrors `copy_file` SQL in
|
||||||
|
-- `infrastructure/repositories/pg/file_blob_write_repository.rs::copy_file`
|
||||||
|
-- (the single-file copy path already gets drive_id from the destination via
|
||||||
|
-- a `dest_folder` CTE) — here we resolve the destination drive ONCE at the
|
||||||
|
-- top of the function and bind it for every level of folders + every file.
|
||||||
|
--
|
||||||
|
-- Provenance contract: `created_by` / `updated_by` on the copied rows STAY
|
||||||
|
-- as the source row's values. A copy is a duplicate, not a new authoring
|
||||||
|
-- event; preserving the original author across copies is the correct
|
||||||
|
-- semantic. Subsequent edits to the copy bump `updated_by` through the
|
||||||
|
-- normal write path. This makes the previously-deferred caller_id thread
|
||||||
|
-- (memory: project_copy_folder_tree_caller_id.md) unnecessary — drive_id
|
||||||
|
-- is the only field that needs the destination's perspective.
|
||||||
|
--
|
||||||
|
-- Preserved semantics from the prior body:
|
||||||
|
-- - level-by-level folder INSERTs so trg_folders_path can resolve
|
||||||
|
-- parent's path/lpath from rows inserted in the previous level.
|
||||||
|
-- - One batched file INSERT (zero-copy via blob hash) at the end.
|
||||||
|
-- - Returns the same shape: (new_root_id::text, folders_copied, files_copied).
|
||||||
|
-- - Error codes (P0002 missing source, 23505 duplicate name) unchanged.
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION storage.copy_folder_tree(
|
||||||
|
p_source_id UUID,
|
||||||
|
p_target_parent_id UUID, -- NULL = copy to root (keeps source drive)
|
||||||
|
p_dest_name TEXT DEFAULT NULL -- NULL = keep source folder name
|
||||||
|
) RETURNS TABLE(new_root_id TEXT, folders_copied BIGINT, files_copied BIGINT) AS $$
|
||||||
|
DECLARE
|
||||||
|
v_root_lpath ltree;
|
||||||
|
v_root_depth INT;
|
||||||
|
v_max_depth INT;
|
||||||
|
v_level INT;
|
||||||
|
v_folders BIGINT := 0;
|
||||||
|
v_files BIGINT := 0;
|
||||||
|
v_inserted BIGINT;
|
||||||
|
v_new_root UUID;
|
||||||
|
v_dest_drive_id UUID;
|
||||||
|
BEGIN
|
||||||
|
-- Validate source exists
|
||||||
|
SELECT fo.lpath, nlevel(fo.lpath)
|
||||||
|
INTO v_root_lpath, v_root_depth
|
||||||
|
FROM storage.folders fo
|
||||||
|
WHERE fo.id = p_source_id AND NOT fo.is_trashed;
|
||||||
|
|
||||||
|
IF v_root_lpath IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'Source folder not found: %', p_source_id
|
||||||
|
USING ERRCODE = 'P0002'; -- no_data_found
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Resolve the destination drive_id ONCE up front. The whole copied
|
||||||
|
-- subtree lands in this drive; pulling it per-row from `fo.drive_id`
|
||||||
|
-- (the previous body) was the cross-drive bug.
|
||||||
|
--
|
||||||
|
-- When p_target_parent_id is NULL the caller asked for "copy to
|
||||||
|
-- root" — there is no global root in the multi-drive world, so we
|
||||||
|
-- preserve the source's drive_id (legacy behaviour, defensive).
|
||||||
|
-- Real API call sites always pass a concrete target folder.
|
||||||
|
IF p_target_parent_id IS NULL THEN
|
||||||
|
SELECT fo.drive_id INTO v_dest_drive_id
|
||||||
|
FROM storage.folders fo
|
||||||
|
WHERE fo.id = p_source_id;
|
||||||
|
ELSE
|
||||||
|
SELECT fo.drive_id INTO v_dest_drive_id
|
||||||
|
FROM storage.folders fo
|
||||||
|
WHERE fo.id = p_target_parent_id AND NOT fo.is_trashed;
|
||||||
|
IF v_dest_drive_id IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'Target parent folder not found: %', p_target_parent_id
|
||||||
|
USING ERRCODE = 'P0002'; -- no_data_found
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Temp mapping: every folder in the subtree → new UUID
|
||||||
|
CREATE TEMP TABLE IF NOT EXISTS _copy_map(
|
||||||
|
old_id UUID PRIMARY KEY,
|
||||||
|
new_id UUID NOT NULL DEFAULT gen_random_uuid()
|
||||||
|
) ON COMMIT DROP;
|
||||||
|
TRUNCATE _copy_map;
|
||||||
|
|
||||||
|
INSERT INTO _copy_map(old_id)
|
||||||
|
SELECT fo.id
|
||||||
|
FROM storage.folders fo
|
||||||
|
WHERE NOT fo.is_trashed
|
||||||
|
AND fo.lpath <@ v_root_lpath;
|
||||||
|
|
||||||
|
-- Remember new root ID
|
||||||
|
SELECT cm.new_id INTO v_new_root
|
||||||
|
FROM _copy_map cm WHERE cm.old_id = p_source_id;
|
||||||
|
|
||||||
|
-- Max depth for level iteration
|
||||||
|
SELECT MAX(nlevel(fo.lpath))
|
||||||
|
INTO v_max_depth
|
||||||
|
FROM storage.folders fo
|
||||||
|
JOIN _copy_map cm ON fo.id = cm.old_id;
|
||||||
|
|
||||||
|
-- ── Insert folders level by level ──
|
||||||
|
-- Each level is a separate INSERT so the BEFORE INSERT trigger
|
||||||
|
-- (trg_folders_path) can resolve the parent's path/lpath from rows
|
||||||
|
-- inserted in the previous level. drive_id is the destination's
|
||||||
|
-- (resolved once above); user_id + provenance preserved from source.
|
||||||
|
FOR v_level IN v_root_depth .. v_max_depth LOOP
|
||||||
|
INSERT INTO storage.folders(
|
||||||
|
id, name, parent_id, user_id,
|
||||||
|
drive_id, created_by, updated_by
|
||||||
|
)
|
||||||
|
SELECT cm.new_id,
|
||||||
|
CASE WHEN fo.id = p_source_id AND p_dest_name IS NOT NULL
|
||||||
|
THEN p_dest_name ELSE fo.name END,
|
||||||
|
CASE WHEN fo.id = p_source_id THEN p_target_parent_id
|
||||||
|
ELSE pm.new_id END,
|
||||||
|
fo.user_id,
|
||||||
|
v_dest_drive_id,
|
||||||
|
fo.created_by,
|
||||||
|
fo.updated_by
|
||||||
|
FROM storage.folders fo
|
||||||
|
JOIN _copy_map cm ON fo.id = cm.old_id
|
||||||
|
LEFT JOIN _copy_map pm ON fo.parent_id = pm.old_id
|
||||||
|
WHERE NOT fo.is_trashed
|
||||||
|
AND nlevel(fo.lpath) = v_level;
|
||||||
|
|
||||||
|
GET DIAGNOSTICS v_inserted = ROW_COUNT;
|
||||||
|
v_folders := v_folders + v_inserted;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
-- ── Batch copy all files (zero-copy: same blob_hash) ──
|
||||||
|
-- drive_id from destination; everything else (user_id, created_by,
|
||||||
|
-- updated_by) preserved from source so authorship survives the copy.
|
||||||
|
INSERT INTO storage.files(
|
||||||
|
name, folder_id, user_id, blob_hash, size, mime_type,
|
||||||
|
media_sort_date, drive_id, created_by, updated_by
|
||||||
|
)
|
||||||
|
SELECT f.name, cm.new_id, f.user_id, f.blob_hash, f.size, f.mime_type,
|
||||||
|
f.media_sort_date, v_dest_drive_id, f.created_by, f.updated_by
|
||||||
|
FROM storage.files f
|
||||||
|
JOIN _copy_map cm ON f.folder_id = cm.old_id
|
||||||
|
WHERE NOT f.is_trashed;
|
||||||
|
|
||||||
|
GET DIAGNOSTICS v_files = ROW_COUNT;
|
||||||
|
|
||||||
|
-- ── Batch increment blob ref_counts ──
|
||||||
|
IF v_files > 0 THEN
|
||||||
|
UPDATE storage.blobs b
|
||||||
|
SET ref_count = ref_count + hc.cnt
|
||||||
|
FROM (
|
||||||
|
SELECT f.blob_hash, COUNT(*)::int AS cnt
|
||||||
|
FROM storage.files f
|
||||||
|
JOIN _copy_map cm ON f.folder_id = cm.new_id
|
||||||
|
WHERE NOT f.is_trashed
|
||||||
|
GROUP BY f.blob_hash
|
||||||
|
) hc
|
||||||
|
WHERE b.hash = hc.blob_hash;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN QUERY SELECT v_new_root::text, v_folders, v_files;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
@@ -125,6 +125,11 @@ pub struct FavoriteResourceRow {
|
|||||||
pub resource_created_at: DateTime<Utc>,
|
pub resource_created_at: DateTime<Utc>,
|
||||||
pub modified_at: DateTime<Utc>,
|
pub modified_at: DateTime<Utc>,
|
||||||
pub owner_id: Uuid,
|
pub owner_id: Uuid,
|
||||||
|
/// Drive that owns this row. Surfaced on the favorites listing
|
||||||
|
/// so a UI can tell when a favorited item lives in a different
|
||||||
|
/// drive than the user's home (post-D6 cross-drive moves +
|
||||||
|
/// copies make this reachable).
|
||||||
|
pub drive_id: Uuid,
|
||||||
/// Raw BLAKE3 content hash. `Some(_)` for file rows, `None` for
|
/// Raw BLAKE3 content hash. `Some(_)` for file rows, `None` for
|
||||||
/// folder rows. Routes into `FileDto::content_hash` and feeds
|
/// folder rows. Routes into `FileDto::content_hash` and feeds
|
||||||
/// `File::compute_etag` to populate `FileDto::etag`.
|
/// `File::compute_etag` to populate `FileDto::etag`.
|
||||||
|
|||||||
@@ -206,6 +206,12 @@ pub struct FolderResourceRow {
|
|||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub modified_at: DateTime<Utc>,
|
pub modified_at: DateTime<Utc>,
|
||||||
pub owner_id: Uuid,
|
pub owner_id: Uuid,
|
||||||
|
/// Drive that owns this row. Same column as
|
||||||
|
/// `storage.folders.drive_id` / `storage.files.drive_id`. Surfaced
|
||||||
|
/// on the listing so a UI can tell when a child lives in a
|
||||||
|
/// different drive than its parent (post-D6 cross-drive moves +
|
||||||
|
/// copies make this reachable).
|
||||||
|
pub drive_id: Uuid,
|
||||||
/// Raw BLAKE3 content hash. `Some(_)` for file rows, `None` for
|
/// Raw BLAKE3 content hash. `Some(_)` for file rows, `None` for
|
||||||
/// folder rows. Populates `FileDto::content_hash` + `FileDto::etag`
|
/// folder rows. Populates `FileDto::content_hash` + `FileDto::etag`
|
||||||
/// on the REST `/api/folders/{id}/resources` listing so API
|
/// on the REST `/api/folders/{id}/resources` listing so API
|
||||||
|
|||||||
@@ -105,6 +105,11 @@ pub struct RecentResourceRow {
|
|||||||
pub resource_created_at: DateTime<Utc>,
|
pub resource_created_at: DateTime<Utc>,
|
||||||
pub modified_at: DateTime<Utc>,
|
pub modified_at: DateTime<Utc>,
|
||||||
pub owner_id: Uuid,
|
pub owner_id: Uuid,
|
||||||
|
/// Drive that owns this row. Surfaced on the recent listing
|
||||||
|
/// so a UI can tell when a recently-accessed item lives in a
|
||||||
|
/// different drive than the user's home (post-D6 cross-drive
|
||||||
|
/// moves + copies make this reachable).
|
||||||
|
pub drive_id: Uuid,
|
||||||
/// Raw BLAKE3 content hash. `Some(_)` for file rows, `None` for
|
/// Raw BLAKE3 content hash. `Some(_)` for file rows, `None` for
|
||||||
/// folder rows. Feeds `File::compute_etag` so this listing's
|
/// folder rows. Feeds `File::compute_etag` so this listing's
|
||||||
/// `etag` matches GET/HEAD/PROPFIND for the same file.
|
/// `etag` matches GET/HEAD/PROPFIND for the same file.
|
||||||
|
|||||||
@@ -155,6 +155,10 @@ pub struct SearchFolderResultDto {
|
|||||||
pub path: String,
|
pub path: String,
|
||||||
/// Parent folder ID
|
/// Parent folder ID
|
||||||
pub parent_id: Option<String>,
|
pub parent_id: Option<String>,
|
||||||
|
/// Drive that owns this folder. Same column as `storage.folders.drive_id`,
|
||||||
|
/// carried through so downstream callers (e.g. the NC search REPORT
|
||||||
|
/// handler) can populate `FolderDto::drive_id` without a fallback sentinel.
|
||||||
|
pub drive_id: uuid::Uuid,
|
||||||
/// Creation timestamp
|
/// Creation timestamp
|
||||||
pub created_at: u64,
|
pub created_at: u64,
|
||||||
/// Last modification timestamp
|
/// Last modification timestamp
|
||||||
|
|||||||
@@ -249,18 +249,25 @@ impl DriveManagementService {
|
|||||||
.set_role(caller_id, subject, role, resource, expires_at)
|
.set_role(caller_id, subject, role, resource, expires_at)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if caller_is_admin {
|
// D6 §11: canonical `drive.member_added` audit event covers
|
||||||
tracing::info!(
|
// every successful membership write (add + role-refresh, since
|
||||||
target: "audit",
|
// the underlying `set_role` is UPSERT — distinguishing the two
|
||||||
event = "drive_membership.set_via_admin",
|
// would require an additional read and bring no extra ops
|
||||||
drive_id = %drive_id,
|
// value). `via_admin` carries the bypass signal that used to
|
||||||
subject_type = subject.type_str(),
|
// live in a separate `drive_membership.set_via_admin` event;
|
||||||
subject_id = %subject.id(),
|
// log aggregators now have one canonical name per operation.
|
||||||
role = role.as_str(),
|
tracing::info!(
|
||||||
by = %caller_id,
|
target: "audit",
|
||||||
"👮🏻♂️ admin set drive member role bypassing Manage check",
|
event = "drive.member_added",
|
||||||
);
|
drive_id = %drive_id,
|
||||||
}
|
subject_type = subject.type_str(),
|
||||||
|
subject_id = %subject.id(),
|
||||||
|
role = role.as_str(),
|
||||||
|
via_admin = caller_is_admin,
|
||||||
|
by = %caller_id,
|
||||||
|
expires_at = ?expires_at,
|
||||||
|
"🤝 drive member added",
|
||||||
|
);
|
||||||
Ok(grant)
|
Ok(grant)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,17 +312,21 @@ impl DriveManagementService {
|
|||||||
|
|
||||||
self.authz.clear_role(subject, resource).await?;
|
self.authz.clear_role(subject, resource).await?;
|
||||||
|
|
||||||
if caller_is_admin {
|
// D6 §11: canonical `drive.member_removed` audit event covers
|
||||||
tracing::info!(
|
// every successful removal (owner-driven or admin bypass).
|
||||||
target: "audit",
|
// `via_admin` replaces the separate
|
||||||
event = "drive_membership.removed_via_admin",
|
// `drive_membership.removed_via_admin` event — single name,
|
||||||
drive_id = %drive_id,
|
// one boolean field for the bypass signal.
|
||||||
subject_type = subject.type_str(),
|
tracing::info!(
|
||||||
subject_id = %subject.id(),
|
target: "audit",
|
||||||
by = %caller_id,
|
event = "drive.member_removed",
|
||||||
"👮🏻♂️ admin removed drive member bypassing Manage check",
|
drive_id = %drive_id,
|
||||||
);
|
subject_type = subject.type_str(),
|
||||||
}
|
subject_id = %subject.id(),
|
||||||
|
via_admin = caller_is_admin,
|
||||||
|
by = %caller_id,
|
||||||
|
"👋 drive member removed",
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -302,13 +302,13 @@ impl FileManagementUseCase for FileManagementService {
|
|||||||
self.require_target_folder_perm(folder_id.as_deref(), Permission::Create, caller_id)
|
self.require_target_folder_perm(folder_id.as_deref(), Permission::Create, caller_id)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// D5 `forbid_cross_drive_move`: refuse when the destination
|
// D5 `forbid_cross_drive_move` + D6 `resource.moved_between_drives` audit
|
||||||
// folder belongs to a different drive than the source file and
|
// share the same src/dst drive_id lookup: the gate refuses
|
||||||
// the source drive's policy is on. Silently skipped if the
|
// before the move; the audit fires after a successful move
|
||||||
// drive repo isn't wired (stub builders) or the move target is
|
// when the two drives differ. Silently skipped if the drive
|
||||||
// None (root namespace — same-drive semantics). Source policy
|
// repo isn't wired (stub builders) or the move target is None
|
||||||
// is canonical per §8: the drive that owns the content
|
// (root namespace — same-drive semantics).
|
||||||
// controls outbound moves.
|
let mut cross_drive: Option<(Uuid, Uuid)> = None;
|
||||||
if let Some(drive_repo) = &self.drive_repo
|
if let Some(drive_repo) = &self.drive_repo
|
||||||
&& let Some(target_folder_id) = folder_id.as_deref()
|
&& let Some(target_folder_id) = folder_id.as_deref()
|
||||||
{
|
{
|
||||||
@@ -338,10 +338,29 @@ impl FileManagementUseCase for FileManagementService {
|
|||||||
dst_drive_id,
|
dst_drive_id,
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
|
cross_drive = Some((src_drive_id, dst_drive_id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.move_file(file_id, folder_id, caller_id).await
|
let dto = self.move_file(file_id, folder_id, caller_id).await?;
|
||||||
|
|
||||||
|
// D6 §11 audit: emit only when the move actually crossed a
|
||||||
|
// drive boundary. Same-drive moves are too noisy to audit at
|
||||||
|
// info — operators care about the cross-drive case for
|
||||||
|
// exfiltration / quota tracking.
|
||||||
|
if let Some((src_drive_id, dst_drive_id)) = cross_drive {
|
||||||
|
tracing::info!(
|
||||||
|
target: "audit",
|
||||||
|
event = "resource.moved_between_drives",
|
||||||
|
resource_type = "file",
|
||||||
|
resource_id = %dto.id,
|
||||||
|
src_drive_id = %src_drive_id,
|
||||||
|
dst_drive_id = %dst_drive_id,
|
||||||
|
by = %caller_id,
|
||||||
|
"📦 file moved between drives",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(dto)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn copy_file_with_perms(
|
async fn copy_file_with_perms(
|
||||||
|
|||||||
@@ -558,11 +558,13 @@ impl FolderUseCase for FolderService {
|
|||||||
// TODO: full descendant-cycle check (moving a folder into one of its own descendants)
|
// TODO: full descendant-cycle check (moving a folder into one of its own descendants)
|
||||||
}
|
}
|
||||||
|
|
||||||
// D5 `forbid_cross_drive_move`: refuse when src and dst sit in
|
// D5 `forbid_cross_drive_move` + D6 `resource.moved_between_drives`
|
||||||
// different drives and the source drive's policy is on.
|
// audit share the same src/dst lookup. Gate before the move,
|
||||||
|
// audit after a successful move when the two drives differ.
|
||||||
// Skipped for parent_id=None (root namespace, same-drive
|
// Skipped for parent_id=None (root namespace, same-drive
|
||||||
// semantics) and when drive_repo isn't wired (stubs/tests) —
|
// semantics) and when drive_repo isn't wired (stubs/tests) —
|
||||||
// same shape as `move_file_with_perms`.
|
// same shape as `move_file_with_perms`.
|
||||||
|
let mut cross_drive: Option<(Uuid, Uuid)> = None;
|
||||||
if let Some(drive_repo) = &self.drive_repo
|
if let Some(drive_repo) = &self.drive_repo
|
||||||
&& let Some(parent_id) = &dto.parent_id
|
&& let Some(parent_id) = &dto.parent_id
|
||||||
{
|
{
|
||||||
@@ -592,6 +594,7 @@ impl FolderUseCase for FolderService {
|
|||||||
dst_drive_id,
|
dst_drive_id,
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
|
cross_drive = Some((src_drive_id, dst_drive_id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -607,6 +610,23 @@ impl FolderUseCase for FolderService {
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
// D6 audit: only emit when the move crossed a drive boundary.
|
||||||
|
// The cascade trigger has already propagated drive_id to the
|
||||||
|
// subtree at this point (see migration
|
||||||
|
// `20260807000000_cascade_drive_id_on_folder_move.sql`).
|
||||||
|
if let Some((src_drive_id, dst_drive_id)) = cross_drive {
|
||||||
|
tracing::info!(
|
||||||
|
target: "audit",
|
||||||
|
event = "resource.moved_between_drives",
|
||||||
|
resource_type = "folder",
|
||||||
|
resource_id = %folder.id(),
|
||||||
|
src_drive_id = %src_drive_id,
|
||||||
|
dst_drive_id = %dst_drive_id,
|
||||||
|
by = %caller_id,
|
||||||
|
"📦 folder moved between drives",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(FolderDto::from(folder))
|
Ok(FolderDto::from(folder))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ impl SearchService {
|
|||||||
name: folder.name.clone(),
|
name: folder.name.clone(),
|
||||||
path: folder.path.clone(),
|
path: folder.path.clone(),
|
||||||
parent_id: folder.parent_id.clone(),
|
parent_id: folder.parent_id.clone(),
|
||||||
|
drive_id: folder.drive_id,
|
||||||
created_at: folder.created_at,
|
created_at: folder.created_at,
|
||||||
modified_at: folder.modified_at,
|
modified_at: folder.modified_at,
|
||||||
is_root: folder.is_root,
|
is_root: folder.is_root,
|
||||||
|
|||||||
@@ -310,6 +310,7 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
|
|||||||
fld.created_at AS resource_created_at,
|
fld.created_at AS resource_created_at,
|
||||||
fld.updated_at AS modified_at,
|
fld.updated_at AS modified_at,
|
||||||
fld.user_id AS owner_id,
|
fld.user_id AS owner_id,
|
||||||
|
fld.drive_id AS drive_id,
|
||||||
NULL::text AS blob_hash,
|
NULL::text AS blob_hash,
|
||||||
(fld.user_id = $1::uuid) AS is_owner,
|
(fld.user_id = $1::uuid) AS is_owner,
|
||||||
uf.created_at AS favorited_at,
|
uf.created_at AS favorited_at,
|
||||||
@@ -333,6 +334,7 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
|
|||||||
f.created_at AS resource_created_at,
|
f.created_at AS resource_created_at,
|
||||||
f.updated_at AS modified_at,
|
f.updated_at AS modified_at,
|
||||||
f.user_id AS owner_id,
|
f.user_id AS owner_id,
|
||||||
|
f.drive_id AS drive_id,
|
||||||
f.blob_hash,
|
f.blob_hash,
|
||||||
(f.user_id = $1::uuid) AS is_owner,
|
(f.user_id = $1::uuid) AS is_owner,
|
||||||
uf.created_at AS favorited_at,
|
uf.created_at AS favorited_at,
|
||||||
@@ -504,7 +506,7 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
|
|||||||
SELECT
|
SELECT
|
||||||
r.resource_type, r.resource_id, r.name, r.parent_id,
|
r.resource_type, r.resource_id, r.name, r.parent_id,
|
||||||
r.mime_type, r.size, r.resource_created_at, r.modified_at,
|
r.mime_type, r.size, r.resource_created_at, r.modified_at,
|
||||||
r.owner_id, r.is_owner, r.favorited_at, r.resource_path,
|
r.owner_id, r.drive_id, r.is_owner, r.favorited_at, r.resource_path,
|
||||||
r.sort_str, r.type_order, r.folder_first{username_col}
|
r.sort_str, r.type_order, r.folder_first{username_col}
|
||||||
FROM resources r
|
FROM resources r
|
||||||
{user_join}
|
{user_join}
|
||||||
@@ -576,6 +578,7 @@ LIMIT $6"
|
|||||||
resource_created_at: row.get("resource_created_at"),
|
resource_created_at: row.get("resource_created_at"),
|
||||||
modified_at: row.get("modified_at"),
|
modified_at: row.get("modified_at"),
|
||||||
owner_id: row.get("owner_id"),
|
owner_id: row.get("owner_id"),
|
||||||
|
drive_id: row.get("drive_id"),
|
||||||
blob_hash: row.try_get("blob_hash").ok(),
|
blob_hash: row.try_get("blob_hash").ok(),
|
||||||
is_owner: row.try_get("is_owner").unwrap_or(false),
|
is_owner: row.try_get("is_owner").unwrap_or(false),
|
||||||
favorited_at: row.get("favorited_at"),
|
favorited_at: row.get("favorited_at"),
|
||||||
|
|||||||
@@ -657,17 +657,32 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
// Retried on deadlock vs the tree-ETag flusher (see rename_folder).
|
// Retried on deadlock vs the tree-ETag flusher (see rename_folder).
|
||||||
//
|
//
|
||||||
// §14: `updated_by = $3` (caller_id), see rename_folder.
|
// §14: `updated_by = $3` (caller_id), see rename_folder.
|
||||||
|
//
|
||||||
|
// D6: also sync `drive_id` from the destination parent on
|
||||||
|
// cross-drive moves. The CTE-derived `dest.drive_id` is
|
||||||
|
// assigned via COALESCE so a root-level move (no destination —
|
||||||
|
// `new_parent_id = NULL`) keeps the existing drive_id, mirroring
|
||||||
|
// the file move path. The cascade trigger
|
||||||
|
// (`cascade_folder_path`) then propagates the new drive_id to
|
||||||
|
// every descendant folder + file in the subtree — see
|
||||||
|
// `migrations/20260807000000_cascade_drive_id_on_folder_move.sql`.
|
||||||
let row = retry_on_deadlock("folders.move", || {
|
let row = retry_on_deadlock("folders.move", || {
|
||||||
sqlx::query_as::<_, FolderRow>(
|
sqlx::query_as::<_, FolderRow>(
|
||||||
r#"
|
r#"
|
||||||
UPDATE storage.folders
|
WITH dest AS (
|
||||||
SET parent_id = $1::uuid, updated_at = NOW(), updated_by = $3
|
SELECT drive_id FROM storage.folders WHERE id = $1::uuid
|
||||||
WHERE id = $2::uuid AND NOT is_trashed
|
)
|
||||||
RETURNING id::text, name, path, parent_id::text, user_id, drive_id,
|
UPDATE storage.folders f
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
SET parent_id = $1::uuid,
|
||||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
drive_id = COALESCE((SELECT drive_id FROM dest), f.drive_id),
|
||||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
updated_at = NOW(),
|
||||||
created_by, updated_by
|
updated_by = $3
|
||||||
|
WHERE f.id = $2::uuid AND NOT f.is_trashed
|
||||||
|
RETURNING f.id::text, f.name, f.path, f.parent_id::text, f.user_id, f.drive_id,
|
||||||
|
EXTRACT(EPOCH FROM f.created_at)::bigint,
|
||||||
|
EXTRACT(EPOCH FROM f.updated_at)::bigint,
|
||||||
|
EXTRACT(EPOCH FROM f.tree_modified_at)::bigint,
|
||||||
|
f.created_by, f.updated_by
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(new_parent_id)
|
.bind(new_parent_id)
|
||||||
@@ -1420,6 +1435,7 @@ impl FolderDbRepository {
|
|||||||
f.created_at,
|
f.created_at,
|
||||||
f.updated_at AS modified_at,
|
f.updated_at AS modified_at,
|
||||||
f.user_id,
|
f.user_id,
|
||||||
|
f.drive_id,
|
||||||
NULL::text AS blob_hash,
|
NULL::text AS blob_hash,
|
||||||
LOWER(f.name) AS sort_str,
|
LOWER(f.name) AS sort_str,
|
||||||
0::bigint AS type_order,
|
0::bigint AS type_order,
|
||||||
@@ -1439,6 +1455,7 @@ impl FolderDbRepository {
|
|||||||
fm.created_at,
|
fm.created_at,
|
||||||
fm.updated_at AS modified_at,
|
fm.updated_at AS modified_at,
|
||||||
fm.user_id,
|
fm.user_id,
|
||||||
|
fm.drive_id,
|
||||||
fm.blob_hash,
|
fm.blob_hash,
|
||||||
LOWER(fm.name) AS sort_str,
|
LOWER(fm.name) AS sort_str,
|
||||||
fm.category_order::bigint AS type_order,
|
fm.category_order::bigint AS type_order,
|
||||||
@@ -1563,7 +1580,8 @@ impl FolderDbRepository {
|
|||||||
let sql = format!(
|
let sql = format!(
|
||||||
"WITH resources AS ({cte_inner}) \
|
"WITH resources AS ({cte_inner}) \
|
||||||
SELECT resource_type, id, name, folder_id, mime_type, size, \
|
SELECT resource_type, id, name, folder_id, mime_type, size, \
|
||||||
created_at, modified_at, user_id, blob_hash, sort_str, type_order, folder_first \
|
created_at, modified_at, user_id, drive_id, blob_hash, \
|
||||||
|
sort_str, type_order, folder_first \
|
||||||
FROM resources \
|
FROM resources \
|
||||||
{where_clause} \
|
{where_clause} \
|
||||||
{order_clause} \
|
{order_clause} \
|
||||||
@@ -1571,7 +1589,7 @@ impl FolderDbRepository {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Row: (resource_type, id, name, folder_id, mime_type, size,
|
// Row: (resource_type, id, name, folder_id, mime_type, size,
|
||||||
// created_at, modified_at, user_id, blob_hash,
|
// created_at, modified_at, user_id, drive_id, blob_hash,
|
||||||
// sort_str, type_order, folder_first)
|
// sort_str, type_order, folder_first)
|
||||||
type Row = (
|
type Row = (
|
||||||
String,
|
String,
|
||||||
@@ -1583,6 +1601,7 @@ impl FolderDbRepository {
|
|||||||
chrono::DateTime<chrono::Utc>,
|
chrono::DateTime<chrono::Utc>,
|
||||||
chrono::DateTime<chrono::Utc>,
|
chrono::DateTime<chrono::Utc>,
|
||||||
Uuid,
|
Uuid,
|
||||||
|
Uuid,
|
||||||
Option<String>,
|
Option<String>,
|
||||||
String,
|
String,
|
||||||
i64,
|
i64,
|
||||||
@@ -1614,10 +1633,11 @@ impl FolderDbRepository {
|
|||||||
created_at: r.6,
|
created_at: r.6,
|
||||||
modified_at: r.7,
|
modified_at: r.7,
|
||||||
owner_id: r.8,
|
owner_id: r.8,
|
||||||
blob_hash: r.9,
|
drive_id: r.9,
|
||||||
sort_str: r.10,
|
blob_hash: r.10,
|
||||||
type_order: r.11,
|
sort_str: r.11,
|
||||||
folder_first: r.12,
|
type_order: r.12,
|
||||||
|
folder_first: r.13,
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -217,6 +217,7 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
|
|||||||
fld.created_at AS resource_created_at,
|
fld.created_at AS resource_created_at,
|
||||||
fld.updated_at AS modified_at,
|
fld.updated_at AS modified_at,
|
||||||
fld.user_id AS owner_id,
|
fld.user_id AS owner_id,
|
||||||
|
fld.drive_id AS drive_id,
|
||||||
NULL::text AS blob_hash,
|
NULL::text AS blob_hash,
|
||||||
(fld.user_id = $1::uuid) AS is_owner,
|
(fld.user_id = $1::uuid) AS is_owner,
|
||||||
ur.accessed_at AS accessed_at,
|
ur.accessed_at AS accessed_at,
|
||||||
@@ -240,6 +241,7 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
|
|||||||
f.created_at AS resource_created_at,
|
f.created_at AS resource_created_at,
|
||||||
f.updated_at AS modified_at,
|
f.updated_at AS modified_at,
|
||||||
f.user_id AS owner_id,
|
f.user_id AS owner_id,
|
||||||
|
f.drive_id AS drive_id,
|
||||||
f.blob_hash,
|
f.blob_hash,
|
||||||
(f.user_id = $1::uuid) AS is_owner,
|
(f.user_id = $1::uuid) AS is_owner,
|
||||||
ur.accessed_at AS accessed_at,
|
ur.accessed_at AS accessed_at,
|
||||||
@@ -409,7 +411,7 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
|
|||||||
SELECT
|
SELECT
|
||||||
r.resource_type, r.resource_id, r.name, r.parent_id,
|
r.resource_type, r.resource_id, r.name, r.parent_id,
|
||||||
r.mime_type, r.size, r.resource_created_at, r.modified_at,
|
r.mime_type, r.size, r.resource_created_at, r.modified_at,
|
||||||
r.owner_id, r.is_owner, r.accessed_at, r.resource_path,
|
r.owner_id, r.drive_id, r.is_owner, r.accessed_at, r.resource_path,
|
||||||
r.sort_str, r.type_order, r.folder_first{username_col}
|
r.sort_str, r.type_order, r.folder_first{username_col}
|
||||||
FROM resources r
|
FROM resources r
|
||||||
{user_join}
|
{user_join}
|
||||||
@@ -485,6 +487,7 @@ LIMIT $6"
|
|||||||
resource_created_at: row.get("resource_created_at"),
|
resource_created_at: row.get("resource_created_at"),
|
||||||
modified_at: row.get("modified_at"),
|
modified_at: row.get("modified_at"),
|
||||||
owner_id: row.get("owner_id"),
|
owner_id: row.get("owner_id"),
|
||||||
|
drive_id: row.get("drive_id"),
|
||||||
blob_hash: row.try_get("blob_hash").ok(),
|
blob_hash: row.try_get("blob_hash").ok(),
|
||||||
is_owner: row.try_get("is_owner").unwrap_or(false),
|
is_owner: row.try_get("is_owner").unwrap_or(false),
|
||||||
accessed_at: row.get("accessed_at"),
|
accessed_at: row.get("accessed_at"),
|
||||||
|
|||||||
@@ -216,11 +216,7 @@ pub async fn list_favorites_resources(
|
|||||||
path,
|
path,
|
||||||
parent_id: row.parent_id.map(|u| u.to_string()),
|
parent_id: row.parent_id.map(|u| u.to_string()),
|
||||||
owner_id: Some(row.owner_id.to_string()),
|
owner_id: Some(row.owner_id.to_string()),
|
||||||
// Listing handler — drive_id is informational
|
drive_id: row.drive_id,
|
||||||
// and the favorites row doesn't currently
|
|
||||||
// SELECT it. Path-based lookups never enter
|
|
||||||
// this code path.
|
|
||||||
drive_id: uuid::Uuid::nil(),
|
|
||||||
created_at: row.resource_created_at.timestamp() as u64,
|
created_at: row.resource_created_at.timestamp() as u64,
|
||||||
modified_at: row.modified_at.timestamp() as u64,
|
modified_at: row.modified_at.timestamp() as u64,
|
||||||
is_root: false,
|
is_root: false,
|
||||||
|
|||||||
@@ -502,10 +502,7 @@ pub async fn list_folder_resources(
|
|||||||
path: String::new(), // cleared — share recipients must not see hierarchy
|
path: String::new(), // cleared — share recipients must not see hierarchy
|
||||||
parent_id: row.parent_id.map(|u| u.to_string()),
|
parent_id: row.parent_id.map(|u| u.to_string()),
|
||||||
owner_id: Some(row.owner_id.to_string()),
|
owner_id: Some(row.owner_id.to_string()),
|
||||||
// Resources listing — drive_id is informational
|
drive_id: row.drive_id,
|
||||||
// here; not selected by the underlying query.
|
|
||||||
// Path-based lookups never enter this code path.
|
|
||||||
drive_id: uuid::Uuid::nil(),
|
|
||||||
created_at: row.created_at.timestamp() as u64,
|
created_at: row.created_at.timestamp() as u64,
|
||||||
modified_at: row.modified_at.timestamp() as u64,
|
modified_at: row.modified_at.timestamp() as u64,
|
||||||
is_root: false,
|
is_root: false,
|
||||||
|
|||||||
@@ -247,11 +247,7 @@ pub async fn list_recent_resources(
|
|||||||
path,
|
path,
|
||||||
parent_id: row.parent_id.map(|u| u.to_string()),
|
parent_id: row.parent_id.map(|u| u.to_string()),
|
||||||
owner_id: Some(row.owner_id.to_string()),
|
owner_id: Some(row.owner_id.to_string()),
|
||||||
// Listing handler — drive_id is informational
|
drive_id: row.drive_id,
|
||||||
// and the recents row doesn't currently SELECT
|
|
||||||
// it. Path-based lookups never enter this code
|
|
||||||
// path.
|
|
||||||
drive_id: uuid::Uuid::nil(),
|
|
||||||
created_at: row.resource_created_at.timestamp() as u64,
|
created_at: row.resource_created_at.timestamp() as u64,
|
||||||
modified_at: row.modified_at.timestamp() as u64,
|
modified_at: row.modified_at.timestamp() as u64,
|
||||||
is_root: false,
|
is_root: false,
|
||||||
|
|||||||
@@ -361,10 +361,7 @@ fn folder_dto_from_search(
|
|||||||
path: sr.path.clone(),
|
path: sr.path.clone(),
|
||||||
parent_id: sr.parent_id.clone(),
|
parent_id: sr.parent_id.clone(),
|
||||||
owner_id: None,
|
owner_id: None,
|
||||||
// Search result — drive_id is informational. The search row
|
drive_id: sr.drive_id,
|
||||||
// doesn't currently SELECT it, and path-based lookups never
|
|
||||||
// enter this code path.
|
|
||||||
drive_id: uuid::Uuid::nil(),
|
|
||||||
created_at: sr.created_at,
|
created_at: sr.created_at,
|
||||||
modified_at: sr.modified_at,
|
modified_at: sr.modified_at,
|
||||||
is_root: sr.is_root,
|
is_root: sr.is_root,
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ use axum::{
|
|||||||
response::Response,
|
response::Response,
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::application::ports::file_ports::{FileRetrievalUseCase, FileUploadUseCase};
|
use crate::application::ports::file_ports::{FileRetrievalUseCase, FileUploadUseCase};
|
||||||
|
use crate::application::ports::storage_ports::StorageUsagePort;
|
||||||
use crate::common::di::AppState;
|
use crate::common::di::AppState;
|
||||||
use crate::common::mime_detect::filename_from_path;
|
use crate::common::mime_detect::filename_from_path;
|
||||||
use crate::interfaces::errors::AppError;
|
use crate::interfaces::errors::AppError;
|
||||||
@@ -13,6 +15,79 @@ use crate::interfaces::upload_ingest::{
|
|||||||
discard_ingested, ingest_stream_to_cas, stream_body_to_path, stream_from_files,
|
discard_ingested, ingest_stream_to_cas, stream_body_to_path, stream_from_files,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Per-chunk quota gate (D4 / project_drive_quota_timing).
|
||||||
|
///
|
||||||
|
/// Pre-D4 the NC chunked path never declared a total size up front, so
|
||||||
|
/// quota only fired at the final MOVE — meaning a client could waste GB
|
||||||
|
/// of upload bandwidth before learning it was over. The drive is known
|
||||||
|
/// from the session's chroot and the user is on the session, so we can
|
||||||
|
/// gate at every wire moment now:
|
||||||
|
///
|
||||||
|
/// - MKCOL: refuse if either the drive or the user envelope is
|
||||||
|
/// already at quota (call with `additional = 0`).
|
||||||
|
/// - PUT : refuse if `used + already_uploaded_for_session +
|
||||||
|
/// content-length` would breach either cap.
|
||||||
|
/// `already_uploaded_for_session` is the sum of chunk sizes the
|
||||||
|
/// session already holds on disk.
|
||||||
|
/// - MOVE : defence in depth via `file_upload_service`'s own gates.
|
||||||
|
///
|
||||||
|
/// Both checks run because the two caps cover different cases:
|
||||||
|
/// `check_drive_quota` is the per-drive `drives.quota_bytes` cap
|
||||||
|
/// (shared drives carry a value; personal drives are `NULL` and
|
||||||
|
/// short-circuit to OK). `check_storage_quota` is the user envelope
|
||||||
|
/// `users.storage_quota_bytes` that caps the SUM across the caller's
|
||||||
|
/// personal drives (shared-drive uploads short-circuit because the
|
||||||
|
/// envelope only sums personal drives — see
|
||||||
|
/// `project_user_envelope_quota_model`). Mirrors what every other
|
||||||
|
/// upload entry point (multipart, native chunked, delta, instant)
|
||||||
|
/// already does.
|
||||||
|
async fn refuse_if_over_quota(
|
||||||
|
state: &AppState,
|
||||||
|
user_id: Uuid,
|
||||||
|
drive_id: Uuid,
|
||||||
|
additional: u64,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let Some(svc) = state.storage_usage_service.as_ref() else {
|
||||||
|
// Quota tracking disabled in this config; MOVE-time gate
|
||||||
|
// remains authoritative.
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
svc.check_storage_quota(user_id, additional)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::from)?;
|
||||||
|
svc.check_drive_quota(drive_id, additional)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sum of bytes already accepted into a chunked-upload session.
|
||||||
|
///
|
||||||
|
/// Reads the session directory once via `list_chunks` and totals every
|
||||||
|
/// chunk's on-disk size. O(N) stat calls per check, but N is the chunk
|
||||||
|
/// count (NC clients use 10 MB chunks by default — a 10 GB upload sits
|
||||||
|
/// around 1000 entries; PUT throughput dominates the cost). A
|
||||||
|
/// per-session counter file would amortise it to O(1) but adds a
|
||||||
|
/// separate write-and-sync path with its own crash semantics — defer
|
||||||
|
/// until profiling actually demands it.
|
||||||
|
async fn session_bytes_so_far(
|
||||||
|
nc: &crate::common::di::NextcloudServices,
|
||||||
|
username: &str,
|
||||||
|
upload_id: &str,
|
||||||
|
) -> Result<u64, AppError> {
|
||||||
|
let listing = nc
|
||||||
|
.chunked_uploads
|
||||||
|
.list_chunks(username, upload_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::internal_error(format!("Failed to list chunks: {}", e)))?;
|
||||||
|
let Some(listing) = listing else {
|
||||||
|
// Missing session — handler maps this elsewhere; treat as zero
|
||||||
|
// here so the gate doesn't fire spuriously on the very first
|
||||||
|
// chunk after MKCOL (race-tolerant).
|
||||||
|
return Ok(0);
|
||||||
|
};
|
||||||
|
Ok(listing.chunks.iter().map(|c| c.size).sum())
|
||||||
|
}
|
||||||
|
|
||||||
/// Dispatch Nextcloud chunked upload WebDAV requests.
|
/// Dispatch Nextcloud chunked upload WebDAV requests.
|
||||||
///
|
///
|
||||||
/// Routes:
|
/// Routes:
|
||||||
@@ -145,6 +220,13 @@ fn xml_escape(s: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// MKCOL — create upload session directory.
|
/// MKCOL — create upload session directory.
|
||||||
|
///
|
||||||
|
/// Quota gate (D4): refuse 507 if the bound drive is already at quota,
|
||||||
|
/// before allocating the session directory. The chunked path doesn't
|
||||||
|
/// declare a total size up front — `additional = 0` so the gate only
|
||||||
|
/// fires when the drive is already exactly full (or beyond, after a
|
||||||
|
/// burst of concurrent writes). Subsequent PUTs run the proper
|
||||||
|
/// "used + session_so_far + chunk" projection.
|
||||||
async fn handle_mkcol(
|
async fn handle_mkcol(
|
||||||
state: Arc<AppState>,
|
state: Arc<AppState>,
|
||||||
session: &crate::interfaces::nextcloud::session::NcSession,
|
session: &crate::interfaces::nextcloud::session::NcSession,
|
||||||
@@ -156,6 +238,9 @@ async fn handle_mkcol(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| AppError::internal_error("Nextcloud services unavailable"))?;
|
.ok_or_else(|| AppError::internal_error("Nextcloud services unavailable"))?;
|
||||||
|
|
||||||
|
let chroot = session.require_chroot()?;
|
||||||
|
refuse_if_over_quota(&state, user.id, chroot.drive_id, 0).await?;
|
||||||
|
|
||||||
nc.chunked_uploads
|
nc.chunked_uploads
|
||||||
.create_session(&user.username, upload_id)
|
.create_session(&user.username, upload_id)
|
||||||
.await
|
.await
|
||||||
@@ -194,6 +279,22 @@ async fn handle_put_chunk(
|
|||||||
return Err(AppError::bad_request("Missing chunk name"));
|
return Err(AppError::bad_request("Missing chunk name"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-chunk quota gate (D4): refuse 507 BEFORE accepting body
|
||||||
|
// bytes when `drive.used_bytes + session_so_far + chunk_size`
|
||||||
|
// would cross the drive cap. Closes the wasted-bandwidth wart
|
||||||
|
// where over-quota clients only learned at MOVE.
|
||||||
|
//
|
||||||
|
// Without a Content-Length we can't project ahead — fall back to
|
||||||
|
// the assemble-time check. NC desktop / Android / iOS clients
|
||||||
|
// always send CL on PUT chunks (they read the chunk file into a
|
||||||
|
// length-known body), so this branch is rare in practice.
|
||||||
|
let chroot = session.require_chroot()?;
|
||||||
|
if let Some(chunk_size) = content_length_from(&req) {
|
||||||
|
let so_far = session_bytes_so_far(nc, &user.username, upload_id).await?;
|
||||||
|
let projected = so_far.saturating_add(chunk_size);
|
||||||
|
refuse_if_over_quota(&state, user.id, chroot.drive_id, projected).await?;
|
||||||
|
}
|
||||||
|
|
||||||
let chunk_path = nc
|
let chunk_path = nc
|
||||||
.chunked_uploads
|
.chunked_uploads
|
||||||
.safe_chunk_path(&user.username, upload_id, chunk_name)
|
.safe_chunk_path(&user.username, upload_id, chunk_name)
|
||||||
@@ -384,6 +485,17 @@ async fn handle_abort(
|
|||||||
.unwrap())
|
.unwrap())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read `Content-Length` off a request as a `u64`. Returns `None` if
|
||||||
|
/// the header is absent or malformed — the PUT-chunk quota gate
|
||||||
|
/// (`handle_put_chunk`) treats that as "skip the early gate, the
|
||||||
|
/// stream cap + MOVE-time check will still catch over-quota writes".
|
||||||
|
fn content_length_from(req: &Request<Body>) -> Option<u64> {
|
||||||
|
req.headers()
|
||||||
|
.get(header::CONTENT_LENGTH)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|s| s.parse::<u64>().ok())
|
||||||
|
}
|
||||||
|
|
||||||
/// Extract the file subpath from a Destination header pointing to the files DAV namespace.
|
/// Extract the file subpath from a Destination header pointing to the files DAV namespace.
|
||||||
///
|
///
|
||||||
/// For full URLs the host is ignored — only the path component is used.
|
/// For full URLs the host is ignored — only the path component is used.
|
||||||
|
|||||||
@@ -0,0 +1,380 @@
|
|||||||
|
# =============================================================
|
||||||
|
# OxiCloud — D6 cross-drive COPY + drive_id resolution
|
||||||
|
# =============================================================
|
||||||
|
# Run:
|
||||||
|
# hurl --variables-file tests/api/test.env --file-root tests \
|
||||||
|
# --test tests/api/cross_drive_copy.hurl
|
||||||
|
#
|
||||||
|
# Companion to `cross_drive_move.hurl`. The MOVE path was fixed
|
||||||
|
# in D6 via the WITH dest CTE + cascade trigger; the COPY path
|
||||||
|
# was the lone holdout, fixed by migration
|
||||||
|
# `20260808000000_copy_folder_tree_cross_drive.sql` which makes
|
||||||
|
# `storage.copy_folder_tree` resolve drive_id from the
|
||||||
|
# destination once, instead of pulling source's drive_id per row.
|
||||||
|
#
|
||||||
|
# Verifies:
|
||||||
|
# 1. Single-file batch copy across drives lands in the
|
||||||
|
# destination drive (source unchanged because copy ≠ move).
|
||||||
|
# Already-correct path via `copy_file` SQL — guarded here
|
||||||
|
# so a regression on the file path is caught.
|
||||||
|
# 2. Folder-tree batch copy across drives. Two layers of
|
||||||
|
# assertion:
|
||||||
|
# a) DIRECT — the copied folder's `drive_id` field reads
|
||||||
|
# as the destination drive (FolderDto exposes it).
|
||||||
|
# This is the load-bearing check for the migration.
|
||||||
|
# b) INDIRECT — per-drive sweep totals: source unchanged,
|
||||||
|
# destination grew by the descendant file's size.
|
||||||
|
# Pre-fix this would have left destination = 0 and
|
||||||
|
# the nested file's size mis-attributed to source.
|
||||||
|
# The folder + file INSERTs in the migration share the
|
||||||
|
# same `v_dest_drive_id` variable, so (a) passing implies
|
||||||
|
# file rows used the same value and (b) cross-checks it.
|
||||||
|
#
|
||||||
|
# Sweep convergence: `/api/admin/internal/trigger-sweep` is the
|
||||||
|
# deterministic synchronisation point — without it the
|
||||||
|
# fire-and-forget delta hook may not yet have updated the cached
|
||||||
|
# `used_bytes` when we read it.
|
||||||
|
# =============================================================
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 1 — Admin login.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "username": "{{username}}", "password": "{{password}}" }
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
admin_token: jsonpath "$.access_token"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 2 — Provision `dc_owner`.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/admin/users
|
||||||
|
Authorization: Bearer {{admin_token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"username": "dc_owner",
|
||||||
|
"password": "DcOwnerPwd1!",
|
||||||
|
"email": "dc_owner@example.com",
|
||||||
|
"role": "user"
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
|
||||||
|
POST {{base_url}}/api/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "username": "dc_owner", "password": "DcOwnerPwd1!" }
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
owner_token: jsonpath "$.access_token"
|
||||||
|
owner_user_id: jsonpath "$.user.id"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 3 — Capture the user's default Personal drive + root.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
GET {{base_url}}/api/folders
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
personal_root_id: jsonpath "$[0].id"
|
||||||
|
|
||||||
|
GET {{base_url}}/api/drives
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
personal_drive_id: jsonpath "$[0].id"
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$[0].kind" == "personal"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 4 — Admin creates a shared drive owned by dc_owner.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/drives
|
||||||
|
Authorization: Bearer {{admin_token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"kind": "shared",
|
||||||
|
"name": "dc-shared",
|
||||||
|
"owner": { "type": "user", "id": "{{owner_user_id}}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
[Captures]
|
||||||
|
shared_drive_id: jsonpath "$.id"
|
||||||
|
shared_root_id: jsonpath "$.root_folder_id"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 5 — Upload hello.txt (32 B) into the personal drive root.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/files/upload
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
[MultipartFormData]
|
||||||
|
folder_id: {{personal_root_id}}
|
||||||
|
file: file,fixtures/hello.txt; text/plain
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
[Captures]
|
||||||
|
file_id: jsonpath "$.id"
|
||||||
|
|
||||||
|
# Baseline used_bytes after the upload settles.
|
||||||
|
POST {{base_url}}/api/admin/internal/trigger-sweep
|
||||||
|
Authorization: Bearer {{admin_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
|
||||||
|
GET {{base_url}}/api/drives
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$[?(@.id=='{{personal_drive_id}}')].used_bytes" == 32
|
||||||
|
jsonpath "$[?(@.id=='{{shared_drive_id}}')].used_bytes" == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 6 — Single-file batch COPY across drives.
|
||||||
|
#
|
||||||
|
# `copy_file` SQL already binds dest drive_id via the dest_folder
|
||||||
|
# CTE; this step guards that path so a regression is caught.
|
||||||
|
# After sweep: source keeps its 32 (copy ≠ move), dest gains 32.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/batch/files/copy
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"file_ids": ["{{file_id}}"],
|
||||||
|
"target_folder_id": "{{shared_root_id}}"
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
shared_file_id: jsonpath "$.successful[0].id"
|
||||||
|
|
||||||
|
POST {{base_url}}/api/admin/internal/trigger-sweep
|
||||||
|
Authorization: Bearer {{admin_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
|
||||||
|
GET {{base_url}}/api/drives
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$[?(@.id=='{{personal_drive_id}}')].used_bytes" == 32
|
||||||
|
jsonpath "$[?(@.id=='{{shared_drive_id}}')].used_bytes" == 32
|
||||||
|
|
||||||
|
|
||||||
|
# Confirm the duplicate is visible under the shared drive's root.
|
||||||
|
GET {{base_url}}/api/folders/{{shared_root_id}}/resources?limit=50
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.items[?(@.resource_type=='file')].resource.name" contains "hello.txt"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 7 — Folder-tree COPY across drives, with a nested file.
|
||||||
|
# Pre-migration this was the broken path: source's
|
||||||
|
# drive_id leaked into every descendant of the copied
|
||||||
|
# subtree because `storage.copy_folder_tree` used
|
||||||
|
# `fo.drive_id` per row instead of resolving the
|
||||||
|
# destination drive once.
|
||||||
|
#
|
||||||
|
# Create a folder under personal root with hello-copy.txt inside,
|
||||||
|
# then batch-copy the whole subtree to the shared drive.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/folders
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "name": "dc-subtree", "parent_id": "{{personal_root_id}}" }
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
[Captures]
|
||||||
|
subtree_id: jsonpath "$.id"
|
||||||
|
|
||||||
|
POST {{base_url}}/api/files/upload
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
[MultipartFormData]
|
||||||
|
folder_id: {{subtree_id}}
|
||||||
|
file: file,fixtures/hello-copy.txt; text/plain
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
|
||||||
|
# Add one more level of nesting so the cascade-through-levels in
|
||||||
|
# copy_folder_tree gets exercised — the level-by-level INSERT
|
||||||
|
# loop is where the previous body's bug compounded.
|
||||||
|
POST {{base_url}}/api/folders
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "name": "dc-subtree-inner", "parent_id": "{{subtree_id}}" }
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
[Captures]
|
||||||
|
inner_id: jsonpath "$.id"
|
||||||
|
|
||||||
|
POST {{base_url}}/api/files/upload
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
[MultipartFormData]
|
||||||
|
folder_id: {{inner_id}}
|
||||||
|
file: file,fixtures/hello.txt; text/plain
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
|
||||||
|
|
||||||
|
# Baseline post-creation. Personal holds:
|
||||||
|
# - hello.txt at root (32 B)
|
||||||
|
# - hello-copy.txt nested in dc-subtree (32 B)
|
||||||
|
# - hello.txt nested in dc-subtree-inner (32 B)
|
||||||
|
# = 96 total. Shared still has the file-copy from Step 6 (32 B).
|
||||||
|
#
|
||||||
|
# Delay: the file-upload service fires the per-drive used_bytes
|
||||||
|
# delta via `tokio::spawn` (file_upload_service.rs ~372). With two
|
||||||
|
# uploads back-to-back the spawned hooks race the sweep: if the
|
||||||
|
# hook lands AFTER `trigger-sweep`'s recompute, the additive
|
||||||
|
# UPDATE clobbers the SUM with `used_bytes += delta`, doubling
|
||||||
|
# the file's size into the cached counter. 200 ms is well above
|
||||||
|
# the tokio task latency on any reasonable box; the deterministic
|
||||||
|
# fix would be intra-transaction hooks, deferred until D7.
|
||||||
|
POST {{base_url}}/api/admin/internal/trigger-sweep
|
||||||
|
Authorization: Bearer {{admin_token}}
|
||||||
|
[Options]
|
||||||
|
delay: 200ms
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
|
||||||
|
GET {{base_url}}/api/drives
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$[?(@.id=='{{personal_drive_id}}')].used_bytes" == 96
|
||||||
|
jsonpath "$[?(@.id=='{{shared_drive_id}}')].used_bytes" == 32
|
||||||
|
|
||||||
|
|
||||||
|
# Copy the SUBTREE FOLDER (with its nested file + nested folder
|
||||||
|
# + nested-nested file) into the shared drive's root. The
|
||||||
|
# response's `new_root_folder_id` lets us follow up with a
|
||||||
|
# direct drive_id assertion on the copy.
|
||||||
|
POST {{base_url}}/api/batch/folders/copy
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"folder_ids": ["{{subtree_id}}"],
|
||||||
|
"target_folder_id": "{{shared_root_id}}"
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
new_root_folder_id: jsonpath "$.successful[0].new_root_folder_id"
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.successful[0].folders_copied" == 2
|
||||||
|
jsonpath "$.successful[0].files_copied" == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ── (a) DIRECT drive_id assertion on the copied root. ──
|
||||||
|
# FolderDto exposes drive_id, so we can read it back end-to-end
|
||||||
|
# without touching SQL. Pre-fix this would equal personal_drive_id
|
||||||
|
# instead of shared_drive_id.
|
||||||
|
GET {{base_url}}/api/folders/{{new_root_folder_id}}
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.drive_id" == "{{shared_drive_id}}"
|
||||||
|
jsonpath "$.name" == "dc-subtree"
|
||||||
|
|
||||||
|
|
||||||
|
# ── (a') DIRECT drive_id assertion on the descendant folder. ──
|
||||||
|
# Walk into the copied root and verify its child folder also
|
||||||
|
# inherited the destination drive_id. This is the level-by-level
|
||||||
|
# loop's correctness guard — pre-fix the inner folder would have
|
||||||
|
# kept personal_drive_id and the cascade trigger doesn't fire on
|
||||||
|
# INSERT (it only handles UPDATE OF drive_id).
|
||||||
|
#
|
||||||
|
# The `/resources` listing now surfaces the real drive_id (the
|
||||||
|
# handler used to stub Uuid::nil because the row didn't project
|
||||||
|
# drive_id; the underlying query was extended alongside this
|
||||||
|
# migration to project f.drive_id / fm.drive_id). We can assert
|
||||||
|
# directly on the listing AND cross-check via GET /api/folders/{id}.
|
||||||
|
GET {{base_url}}/api/folders/{{new_root_folder_id}}/resources?limit=50
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
# Single-match filter — Hurl unwraps to scalar; do NOT use `nth N`
|
||||||
|
# here (see feedback_hurl_jsonpath_filter_empty.md: filters with
|
||||||
|
# nth fail on a single-match result).
|
||||||
|
new_inner_id: jsonpath "$.items[?(@.resource_type=='folder')].resource.id"
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.items[?(@.resource_type=='folder')].resource.drive_id" == "{{shared_drive_id}}"
|
||||||
|
jsonpath "$.items[?(@.resource_type=='file')].resource.name" == "hello-copy.txt"
|
||||||
|
|
||||||
|
GET {{base_url}}/api/folders/{{new_inner_id}}
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.drive_id" == "{{shared_drive_id}}"
|
||||||
|
jsonpath "$.name" == "dc-subtree-inner"
|
||||||
|
|
||||||
|
|
||||||
|
# ── (b) INDIRECT cross-check via per-drive sweep. ──
|
||||||
|
# Source unchanged (copy ≠ move): personal still 96.
|
||||||
|
# Destination grew by the two descendant files (32 + 32 = 64) +
|
||||||
|
# the Step 6 file copy (32) = 96. Anything other than (96, 96)
|
||||||
|
# would mean the file INSERT in copy_folder_tree used the wrong
|
||||||
|
# drive_id.
|
||||||
|
POST {{base_url}}/api/admin/internal/trigger-sweep
|
||||||
|
Authorization: Bearer {{admin_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
|
||||||
|
GET {{base_url}}/api/drives
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$[?(@.id=='{{personal_drive_id}}')].used_bytes" == 96
|
||||||
|
jsonpath "$[?(@.id=='{{shared_drive_id}}')].used_bytes" == 96
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 8 — Cleanup. Drain the shared drive (it isn't covered by
|
||||||
|
# the user-delete cascade), delete the shared drive,
|
||||||
|
# drain the source subtree from the personal drive,
|
||||||
|
# then delete the test user.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
DELETE {{base_url}}/api/files/{{shared_file_id}}
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
|
||||||
|
HTTP 204
|
||||||
|
|
||||||
|
DELETE {{base_url}}/api/folders/{{new_root_folder_id}}
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
|
||||||
|
HTTP 204
|
||||||
|
|
||||||
|
DELETE {{base_url}}/api/drives/{{shared_drive_id}}
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
|
||||||
|
HTTP 204
|
||||||
|
|
||||||
|
DELETE {{base_url}}/api/folders/{{subtree_id}}
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
|
||||||
|
HTTP 204
|
||||||
|
|
||||||
|
DELETE {{base_url}}/api/admin/users/{{owner_user_id}}
|
||||||
|
Authorization: Bearer {{admin_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
# =============================================================
|
||||||
|
# OxiCloud — D6 cross-drive move + drive_id cascade
|
||||||
|
# =============================================================
|
||||||
|
# Run:
|
||||||
|
# hurl --variables-file tests/api/test.env --file-root tests \
|
||||||
|
# --test tests/api/cross_drive_move.hurl
|
||||||
|
#
|
||||||
|
# Verifies:
|
||||||
|
# 1. File moved across drives lands in the destination drive's
|
||||||
|
# subtree AND the file row's `drive_id` syncs to the
|
||||||
|
# destination (observed via the per-drive quota sweep:
|
||||||
|
# source `used_bytes` drops, target rises).
|
||||||
|
# 2. Folder moved across drives ALSO syncs `drive_id` on every
|
||||||
|
# descendant — the cascade trigger added by migration
|
||||||
|
# `20260807000000_cascade_drive_id_on_folder_move.sql` is
|
||||||
|
# the load-bearing piece. Verified by moving a folder with
|
||||||
|
# a file inside and watching the destination drive's
|
||||||
|
# `used_bytes` jump by the descendant's size (not 0).
|
||||||
|
#
|
||||||
|
# Sweep convergence: `/api/admin/internal/trigger-sweep` is the
|
||||||
|
# deterministic synchronisation point — it recomputes every
|
||||||
|
# drive's cached `used_bytes` from `SUM(file.size) WHERE
|
||||||
|
# drive_id = d.id`. If the file/folder move didn't update
|
||||||
|
# `drive_id`, the sweep would re-attribute size to the WRONG
|
||||||
|
# drive (or none), and the assertion below would fail.
|
||||||
|
#
|
||||||
|
# `forbid_cross_drive_move` policy refusal is covered by
|
||||||
|
# `tests/api/drive_policies.hurl` Step 11b — this scenario uses
|
||||||
|
# the policy OFF (the default) to exercise the happy path.
|
||||||
|
#
|
||||||
|
# Self-contained: provisions `dm_owner` and a fresh shared drive
|
||||||
|
# so it can run alongside the rest of the suite.
|
||||||
|
# =============================================================
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 1 — Admin login.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "username": "{{username}}", "password": "{{password}}" }
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
admin_token: jsonpath "$.access_token"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 2 — Provision `dm_owner`.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/admin/users
|
||||||
|
Authorization: Bearer {{admin_token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"username": "dm_owner",
|
||||||
|
"password": "DmOwnerPwd1!",
|
||||||
|
"email": "dm_owner@example.com",
|
||||||
|
"role": "user"
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
|
||||||
|
POST {{base_url}}/api/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "username": "dm_owner", "password": "DmOwnerPwd1!" }
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
owner_token: jsonpath "$.access_token"
|
||||||
|
owner_user_id: jsonpath "$.user.id"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 3 — Capture the user's default Personal drive + root.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
GET {{base_url}}/api/folders
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
personal_root_id: jsonpath "$[0].id"
|
||||||
|
|
||||||
|
GET {{base_url}}/api/drives
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
personal_drive_id: jsonpath "$[0].id"
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$[0].kind" == "personal"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 4 — Admin creates a shared drive owned by dm_owner.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/drives
|
||||||
|
Authorization: Bearer {{admin_token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"kind": "shared",
|
||||||
|
"name": "dm-shared",
|
||||||
|
"owner": { "type": "user", "id": "{{owner_user_id}}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
[Captures]
|
||||||
|
shared_drive_id: jsonpath "$.id"
|
||||||
|
shared_root_id: jsonpath "$.root_folder_id"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 5 — Upload hello.txt (32 B) into the personal drive root.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/files/upload
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
[MultipartFormData]
|
||||||
|
folder_id: {{personal_root_id}}
|
||||||
|
file: file,fixtures/hello.txt; text/plain
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
[Captures]
|
||||||
|
file_id: jsonpath "$.id"
|
||||||
|
|
||||||
|
|
||||||
|
# Baseline used_bytes after the upload settles. Trigger-sweep is
|
||||||
|
# the deterministic sync point — without it the fire-and-forget
|
||||||
|
# delta hook may not yet have landed in the row when we read it.
|
||||||
|
POST {{base_url}}/api/admin/internal/trigger-sweep
|
||||||
|
Authorization: Bearer {{admin_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
|
||||||
|
GET {{base_url}}/api/drives
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$[?(@.id=='{{personal_drive_id}}')].used_bytes" == 32
|
||||||
|
jsonpath "$[?(@.id=='{{shared_drive_id}}')].used_bytes" == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 6 — Move hello.txt across drives → shared root.
|
||||||
|
#
|
||||||
|
# Observable behaviour: after the sweep, the source drive's
|
||||||
|
# used_bytes drops to 0 and the destination's rises to 32. The
|
||||||
|
# only way this happens is if `storage.files.drive_id` was
|
||||||
|
# updated on the move (the sweep recomputes from
|
||||||
|
# `SUM(size) WHERE drive_id = d.id`). The move_file SQL already
|
||||||
|
# syncs drive_id from the destination — this asserts it still
|
||||||
|
# does post-D6.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
PUT {{base_url}}/api/files/{{file_id}}/move
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"folder_id": "{{shared_root_id}}"
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
|
||||||
|
POST {{base_url}}/api/admin/internal/trigger-sweep
|
||||||
|
Authorization: Bearer {{admin_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
|
||||||
|
GET {{base_url}}/api/drives
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$[?(@.id=='{{personal_drive_id}}')].used_bytes" == 0
|
||||||
|
jsonpath "$[?(@.id=='{{shared_drive_id}}')].used_bytes" == 32
|
||||||
|
|
||||||
|
|
||||||
|
# Confirm the file is now visible under the shared drive's root
|
||||||
|
# (cross-drive Read is fine — dm_owner is Owner on both).
|
||||||
|
GET {{base_url}}/api/folders/{{shared_root_id}}/resources?limit=50
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.items[?(@.resource.id=='{{file_id}}')].resource_type" == "file"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 7 — Folder move across drives, with a child file inside.
|
||||||
|
# The cascade trigger MUST propagate the new drive_id
|
||||||
|
# to the moved folder AND every descendant (folder +
|
||||||
|
# file). Verified by moving the folder, then sweeping —
|
||||||
|
# if the trigger doesn't fire, the descendant file's
|
||||||
|
# drive_id stays at the source drive and the sweep
|
||||||
|
# attributes its size to the wrong drive.
|
||||||
|
#
|
||||||
|
# First, move hello.txt back to the personal drive so the
|
||||||
|
# baseline for the next case is clean (and so the source-drive
|
||||||
|
# `used_bytes` reflects only what we're about to nest below).
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
PUT {{base_url}}/api/files/{{file_id}}/move
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"folder_id": "{{personal_root_id}}"
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
|
||||||
|
|
||||||
|
# Create a folder under personal root, with hello-copy.txt inside.
|
||||||
|
POST {{base_url}}/api/folders
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "name": "dm-subtree", "parent_id": "{{personal_root_id}}" }
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
[Captures]
|
||||||
|
subtree_id: jsonpath "$.id"
|
||||||
|
|
||||||
|
POST {{base_url}}/api/files/upload
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
[MultipartFormData]
|
||||||
|
folder_id: {{subtree_id}}
|
||||||
|
file: file,fixtures/hello-copy.txt; text/plain
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
[Captures]
|
||||||
|
nested_file_id: jsonpath "$.id"
|
||||||
|
|
||||||
|
|
||||||
|
# Baseline post-creation. Personal holds both hello.txt (32 B) +
|
||||||
|
# nested hello-copy.txt (32 B) = 64. Shared is empty.
|
||||||
|
POST {{base_url}}/api/admin/internal/trigger-sweep
|
||||||
|
Authorization: Bearer {{admin_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
|
||||||
|
GET {{base_url}}/api/drives
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$[?(@.id=='{{personal_drive_id}}')].used_bytes" == 64
|
||||||
|
jsonpath "$[?(@.id=='{{shared_drive_id}}')].used_bytes" == 0
|
||||||
|
|
||||||
|
|
||||||
|
# Move the SUBTREE FOLDER (with its nested file) into the shared
|
||||||
|
# drive's root.
|
||||||
|
PUT {{base_url}}/api/folders/{{subtree_id}}/move
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"parent_id": "{{shared_root_id}}"
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
|
||||||
|
|
||||||
|
# The load-bearing assertion. After sweep:
|
||||||
|
# personal: hello.txt remains (32)
|
||||||
|
# shared: nested hello-copy.txt now charged here (32)
|
||||||
|
# Anything other than (32, 32) means the descendant file's
|
||||||
|
# drive_id wasn't cascaded by the trigger.
|
||||||
|
POST {{base_url}}/api/admin/internal/trigger-sweep
|
||||||
|
Authorization: Bearer {{admin_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
|
||||||
|
GET {{base_url}}/api/drives
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$[?(@.id=='{{personal_drive_id}}')].used_bytes" == 32
|
||||||
|
jsonpath "$[?(@.id=='{{shared_drive_id}}')].used_bytes" == 32
|
||||||
|
|
||||||
|
|
||||||
|
# Folder is visible in shared's listing.
|
||||||
|
GET {{base_url}}/api/folders/{{shared_root_id}}/resources?limit=50
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.items[?(@.resource.id=='{{subtree_id}}')].resource_type" == "folder"
|
||||||
|
|
||||||
|
|
||||||
|
# Descendant file is still inside the moved subtree (subtree
|
||||||
|
# integrity preserved). Drive_id sync is invisible at this
|
||||||
|
# endpoint, but the used_bytes assertion above already
|
||||||
|
# established it.
|
||||||
|
GET {{base_url}}/api/folders/{{subtree_id}}/resources?limit=50
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.items[?(@.resource.id=='{{nested_file_id}}')].resource_type" == "file"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 8 — Cleanup. Move both files back to the personal drive's
|
||||||
|
# root + delete the subtree folder + delete the shared
|
||||||
|
# drive (must be empty), then the test user.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
PUT {{base_url}}/api/files/{{nested_file_id}}/move
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"folder_id": "{{personal_root_id}}"
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
|
||||||
|
DELETE {{base_url}}/api/folders/{{subtree_id}}
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
|
||||||
|
HTTP 204
|
||||||
|
|
||||||
|
DELETE {{base_url}}/api/drives/{{shared_drive_id}}
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
|
||||||
|
HTTP 204
|
||||||
|
|
||||||
|
DELETE {{base_url}}/api/admin/users/{{owner_user_id}}
|
||||||
|
Authorization: Bearer {{admin_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
+3
-1
@@ -164,7 +164,9 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
|
|||||||
"$API_DIR/trash_per_drive.hurl" \
|
"$API_DIR/trash_per_drive.hurl" \
|
||||||
"$API_DIR/drive_quota.hurl" \
|
"$API_DIR/drive_quota.hurl" \
|
||||||
"$API_DIR/user_envelope_quota.hurl" \
|
"$API_DIR/user_envelope_quota.hurl" \
|
||||||
"$API_DIR/drive_policies.hurl"
|
"$API_DIR/drive_policies.hurl" \
|
||||||
|
"$API_DIR/cross_drive_move.hurl" \
|
||||||
|
"$API_DIR/cross_drive_copy.hurl"
|
||||||
|
|
||||||
#bash "$API_DIR/dedup_bulk_upload.sh"
|
#bash "$API_DIR/dedup_bulk_upload.sh"
|
||||||
|
|
||||||
|
|||||||
+12
-11
@@ -109,24 +109,23 @@ header "Location" contains "redirect_uri="
|
|||||||
# {frontend_url}/login?oidc_code=…
|
# {frontend_url}/login?oidc_code=…
|
||||||
#
|
#
|
||||||
# With `location: true` Hurl follows the whole chain and
|
# With `location: true` Hurl follows the whole chain and
|
||||||
# lands on the SPA login URL. The test server config
|
# lands on the SPA login URL. The SvelteKit SPA serves
|
||||||
# (server-with-oidc.env) points `OXICLOUD_STATIC_PATH`
|
# `/login` from `static-dist/login.html` with 200 — this
|
||||||
# at ./static — which is the legacy vanilla frontend, NOT
|
# is the production contract. The runner (`tests/oidc/run.sh`)
|
||||||
# static-dist/ — so /login returns 404. That 404 is the
|
# builds `static-dist/` before launching the server so
|
||||||
# test signal: it proves we landed AT /login (i.e. the
|
# local and CI both see the production behaviour. Without
|
||||||
# d1bbe8ba contract held). The URL we end at is the
|
# that build the route would 404 via the ServeDir fallback.
|
||||||
# actual assertion.
|
|
||||||
#
|
#
|
||||||
# A pre-d1bbe8ba server would have redirected to
|
# A pre-d1bbe8ba server would have redirected to
|
||||||
# `http://localhost:8087/?oidc_code=…` instead — same
|
# `http://localhost:8087/?oidc_code=…` instead — the
|
||||||
# 404, but the `landed_at` assertion would catch it.
|
# `landed_at` regex below catches that regardless.
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
GET {{idp_url}}
|
GET {{idp_url}}
|
||||||
[Options]
|
[Options]
|
||||||
location: true
|
location: true
|
||||||
location-trusted: true
|
location-trusted: true
|
||||||
|
|
||||||
HTTP 404
|
HTTP 200
|
||||||
[Captures]
|
[Captures]
|
||||||
landed_at: url
|
landed_at: url
|
||||||
oidc_code: url regex "oidc_code=([a-f0-9]+)"
|
oidc_code: url regex "oidc_code=([a-f0-9]+)"
|
||||||
@@ -289,7 +288,9 @@ GET {{relogin_idp_url}}
|
|||||||
location: true
|
location: true
|
||||||
location-trusted: true
|
location-trusted: true
|
||||||
|
|
||||||
HTTP 404
|
# Same contract as Step 4 — the SPA serves /login with 200 (the
|
||||||
|
# runner ensures static-dist/ is built before the server starts).
|
||||||
|
HTTP 200
|
||||||
[Captures]
|
[Captures]
|
||||||
relogin_oidc_code: url regex "oidc_code=([a-f0-9]+)"
|
relogin_oidc_code: url regex "oidc_code=([a-f0-9]+)"
|
||||||
[Asserts]
|
[Asserts]
|
||||||
|
|||||||
@@ -143,6 +143,21 @@ set +a
|
|||||||
source "$COMMON/wipe-storage.sh"
|
source "$COMMON/wipe-storage.sh"
|
||||||
wipe_storage "$OXICLOUD_STORAGE_PATH"
|
wipe_storage "$OXICLOUD_STORAGE_PATH"
|
||||||
|
|
||||||
|
# ── 3.5. Ensure the SPA is built (static-dist/) ────────────────────────────
|
||||||
|
# The OIDC suite's Step 4 + Step 9 walk the full redirect chain and assert
|
||||||
|
# they land on `/login?oidc_code=…` with HTTP 200 — the production contract,
|
||||||
|
# where the container ships `static-dist/login.html`. Without that bundle
|
||||||
|
# `resolve_static_path` falls back to `OXICLOUD_STATIC_PATH=./static`, which
|
||||||
|
# was removed in commit 54639d46 — so ServeDir 404s the route and Step 4
|
||||||
|
# fails. Build here so local + CI both exercise the production layout.
|
||||||
|
DIST_DIR="$REPO_ROOT/static-dist"
|
||||||
|
if [[ ! -f "$DIST_DIR/login.html" ]]; then
|
||||||
|
log "Building SvelteKit SPA (static-dist/login.html missing)..."
|
||||||
|
(cd "$REPO_ROOT/frontend" \
|
||||||
|
&& npm ci --silent --no-audit --no-fund \
|
||||||
|
&& npm run build) || die "Frontend build failed; static-dist/ is required for the OIDC tests"
|
||||||
|
fi
|
||||||
|
|
||||||
# ── 4. Start OxiCloud server with OIDC enabled ─────────────────────────────
|
# ── 4. Start OxiCloud server with OIDC enabled ─────────────────────────────
|
||||||
BUILD_TARGET="${BUILD_TARGET:-debug}"
|
BUILD_TARGET="${BUILD_TARGET:-debug}"
|
||||||
OXICLOUD_BIN="$REPO_ROOT/target/$BUILD_TARGET/oxicloud"
|
OXICLOUD_BIN="$REPO_ROOT/target/$BUILD_TARGET/oxicloud"
|
||||||
|
|||||||
@@ -19,6 +19,17 @@
|
|||||||
# MOVE → verify the assembled file's BLAKE3 over REST.
|
# MOVE → verify the assembled file's BLAKE3 over REST.
|
||||||
# 2. CAP REJECTION — MKCOL a fresh session → PUT a 5 MiB chunk →
|
# 2. CAP REJECTION — MKCOL a fresh session → PUT a 5 MiB chunk →
|
||||||
# 413 Payload Too Large.
|
# 413 Payload Too Large.
|
||||||
|
# 3. QUOTA REJECTION (D4 / per-chunk gate) — tighten the caller's
|
||||||
|
# storage envelope to 100 B, MKCOL a fresh session (still under
|
||||||
|
# cap, used=0), then PUT a 200 B chunk → 507 Insufficient
|
||||||
|
# Storage. Pre-D4 the chunked path never gated until the final
|
||||||
|
# MOVE — clients could waste GB of upload before learning they
|
||||||
|
# were over. Validates `refuse_if_over_quota` in
|
||||||
|
# `uploads_handler::handle_put_chunk` runs the
|
||||||
|
# `used + session_so_far + content_length` projection
|
||||||
|
# ahead of accepting body bytes. Admin's original quota is
|
||||||
|
# restored on exit so subsequent tests in the suite are
|
||||||
|
# unaffected.
|
||||||
#
|
#
|
||||||
# Prerequisites:
|
# Prerequisites:
|
||||||
# - Server running at $base_url with admin credentials (test.env).
|
# - Server running at $base_url with admin credentials (test.env).
|
||||||
@@ -88,6 +99,12 @@ EXPECTED_BLAKE3="b2208c5dc33ff951227bd0c139f5eccb04105d6da6a7519ee23f7bc00a17bb5
|
|||||||
REMOTE_NAME="nc-chunked-cap-test.txt"
|
REMOTE_NAME="nc-chunked-cap-test.txt"
|
||||||
UPLOAD_ID_OK="oxi-cap-ok-$(date +%s)"
|
UPLOAD_ID_OK="oxi-cap-ok-$(date +%s)"
|
||||||
UPLOAD_ID_BIG="oxi-cap-big-$(date +%s)"
|
UPLOAD_ID_BIG="oxi-cap-big-$(date +%s)"
|
||||||
|
UPLOAD_ID_QUOTA="oxi-cap-quota-$(date +%s)"
|
||||||
|
# 200 B fixture for the D4 quota-gate case. Lives in $TMPDIR so it
|
||||||
|
# never lands in `tests/fixtures/` — generated on the fly, deleted
|
||||||
|
# by the EXIT trap. mktemp keeps the path race-free across parallel
|
||||||
|
# runs.
|
||||||
|
FIXTURE_200B=""
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "=== NextCloud chunked upload: cap + streaming ==="
|
echo "=== NextCloud chunked upload: cap + streaming ==="
|
||||||
@@ -110,8 +127,34 @@ APP_PASSWORD_ID=$(jq -r '.id' <<<"$APP_PASSWORD_RESPONSE")
|
|||||||
|| fail "Failed to mint NC app password: $APP_PASSWORD_RESPONSE"
|
|| fail "Failed to mint NC app password: $APP_PASSWORD_RESPONSE"
|
||||||
echo " app password minted (id=$APP_PASSWORD_ID)"
|
echo " app password minted (id=$APP_PASSWORD_ID)"
|
||||||
|
|
||||||
# Clean up the app password when the script exits (success or fail).
|
# Capture admin's id + current envelope quota up front so Case 3
|
||||||
trap '[[ -n "${APP_PASSWORD_ID:-}" ]] && rest_delete "/api/auth/app-passwords/$APP_PASSWORD_ID" > /dev/null || true' EXIT
|
# can tighten the cap and the EXIT trap can restore it on any
|
||||||
|
# failure path. `storage_quota_bytes == 0` is the unlimited
|
||||||
|
# sentinel (see `check_storage_quota`); we read it back here in
|
||||||
|
# case a prior test set a real value.
|
||||||
|
ADMIN_ID=$(rest_get "/api/auth/me" | jq -r '.id')
|
||||||
|
[[ -n "$ADMIN_ID" && "$ADMIN_ID" != "null" ]] || fail "Failed to read admin user id"
|
||||||
|
ORIGINAL_ADMIN_QUOTA=$(rest_get "/api/auth/me" | jq -r '.storage_quota_bytes // 0')
|
||||||
|
|
||||||
|
# Single cleanup on exit:
|
||||||
|
# - restore admin's original storage envelope (in case Case 3
|
||||||
|
# fired and we exited before its own restore),
|
||||||
|
# - revoke the test app password,
|
||||||
|
# - drop the on-the-fly 200 B fixture.
|
||||||
|
cleanup_test() {
|
||||||
|
if [[ -n "${ADMIN_ID:-}" ]]; then
|
||||||
|
curl -s -X PUT \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"quota_bytes\":${ORIGINAL_ADMIN_QUOTA}}" \
|
||||||
|
"$base_url/api/admin/users/$ADMIN_ID/quota" > /dev/null || true
|
||||||
|
fi
|
||||||
|
if [[ -n "${APP_PASSWORD_ID:-}" ]]; then
|
||||||
|
rest_delete "/api/auth/app-passwords/$APP_PASSWORD_ID" > /dev/null || true
|
||||||
|
fi
|
||||||
|
[[ -n "${FIXTURE_200B:-}" && -f "$FIXTURE_200B" ]] && rm -f "$FIXTURE_200B"
|
||||||
|
}
|
||||||
|
trap cleanup_test EXIT
|
||||||
|
|
||||||
# Idempotent cleanup of any leftover file from a previous failed run.
|
# Idempotent cleanup of any leftover file from a previous failed run.
|
||||||
HOME_FOLDER_ID=$(rest_get "/api/folders" | jq -r '.[0].id')
|
HOME_FOLDER_ID=$(rest_get "/api/folders" | jq -r '.[0].id')
|
||||||
@@ -126,7 +169,7 @@ fi
|
|||||||
# ── Case 1: SUCCESS path ──────────────────────────────────────────────────────
|
# ── Case 1: SUCCESS path ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "[1/2] SUCCESS path — MKCOL → PUT → MOVE → verify BLAKE3"
|
echo "[1/3] SUCCESS path — MKCOL → PUT → MOVE → verify BLAKE3"
|
||||||
|
|
||||||
# 1a. Create chunked-upload session.
|
# 1a. Create chunked-upload session.
|
||||||
STATUS=$(nc_req MKCOL "/remote.php/dav/uploads/$username/$UPLOAD_ID_OK")
|
STATUS=$(nc_req MKCOL "/remote.php/dav/uploads/$username/$UPLOAD_ID_OK")
|
||||||
@@ -167,7 +210,7 @@ purge_from_trash "$REMOTE_NAME"
|
|||||||
# ── Case 2: CAP REJECTION ─────────────────────────────────────────────────────
|
# ── Case 2: CAP REJECTION ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "[2/2] CAP REJECTION — 5 MiB chunk on a 4 MiB cap → 413"
|
echo "[2/3] CAP REJECTION — 5 MiB chunk on a 4 MiB cap → 413"
|
||||||
|
|
||||||
# 2a. Fresh session.
|
# 2a. Fresh session.
|
||||||
STATUS=$(nc_req MKCOL "/remote.php/dav/uploads/$username/$UPLOAD_ID_BIG")
|
STATUS=$(nc_req MKCOL "/remote.php/dav/uploads/$username/$UPLOAD_ID_BIG")
|
||||||
@@ -192,6 +235,74 @@ STATUS=$(nc_req DELETE "/remote.php/dav/uploads/$username/$UPLOAD_ID_BIG")
|
|||||||
[[ "$STATUS" =~ ^(204|404)$ ]] || fail "DELETE abandoned session: got $STATUS"
|
[[ "$STATUS" =~ ^(204|404)$ ]] || fail "DELETE abandoned session: got $STATUS"
|
||||||
pass "DELETE abandoned session (status=$STATUS)"
|
pass "DELETE abandoned session (status=$STATUS)"
|
||||||
|
|
||||||
|
# ── Case 3: QUOTA REJECTION (D4 per-chunk gate) ───────────────────────────────
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "[3/3] QUOTA REJECTION — envelope tightened to (used + 100 B), PUT 200 B chunk → 507"
|
||||||
|
|
||||||
|
# 3a. Compute the tight quota dynamically: admin has accumulated
|
||||||
|
# `used_bytes` from every earlier test in the suite, so a hard-
|
||||||
|
# coded "100 B" cap would trip MKCOL (`used + 0 > 100`). Read
|
||||||
|
# the current cached envelope and set the cap to
|
||||||
|
# `current + 100` — leaves enough headroom that MKCOL passes
|
||||||
|
# (`used + 0 = used < used + 100`) while a 200 B chunk PUT
|
||||||
|
# overflows by exactly 100 (`used + 0 + 200 > used + 100`).
|
||||||
|
CURRENT_USED=$(rest_get "/api/auth/me" | jq -r '.storage_used_bytes')
|
||||||
|
[[ -n "$CURRENT_USED" && "$CURRENT_USED" != "null" ]] || fail "Failed to read current used_bytes"
|
||||||
|
TIGHT_QUOTA=$(( CURRENT_USED + 100 ))
|
||||||
|
|
||||||
|
# 3b. Tighten admin's storage envelope. The pre-existing
|
||||||
|
# `cleanup_test` EXIT trap restores `ORIGINAL_ADMIN_QUOTA` so a
|
||||||
|
# mid-test failure doesn't leave the suite running under a
|
||||||
|
# barely-headroom cap.
|
||||||
|
curl -s -X PUT \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"quota_bytes\":${TIGHT_QUOTA}}" \
|
||||||
|
"$base_url/api/admin/users/$ADMIN_ID/quota" > /dev/null
|
||||||
|
|
||||||
|
# 3c. Generate the 200 B fixture in $TMPDIR — never lands in
|
||||||
|
# `tests/fixtures/` (avoids polluting the committed dir + the
|
||||||
|
# gitignore list).
|
||||||
|
FIXTURE_200B=$(mktemp -t nc-chunked-quota-200b.XXXXXX.bin)
|
||||||
|
dd if=/dev/zero of="$FIXTURE_200B" bs=1 count=200 status=none
|
||||||
|
|
||||||
|
# 3d. Fresh session. MKCOL gate projects `used + 0` against the
|
||||||
|
# tight cap; with quota = used + 100 the projection sits 100 B
|
||||||
|
# under the limit so MKCOL passes. The real gate fires at the
|
||||||
|
# PUT below.
|
||||||
|
STATUS=$(nc_req MKCOL "/remote.php/dav/uploads/$username/$UPLOAD_ID_QUOTA")
|
||||||
|
[[ "$STATUS" =~ ^(201|204)$ ]] || fail "MKCOL quota session: got $STATUS"
|
||||||
|
pass "MKCOL quota session (status=$STATUS)"
|
||||||
|
|
||||||
|
# 3e. PUT a 200 B chunk → expect 507. The handler reads
|
||||||
|
# Content-Length (200), sums on-disk chunks for this session
|
||||||
|
# (0), and runs `check_storage_quota(admin_id, 200)`:
|
||||||
|
# used + 200 > used + 100 → QuotaExceeded → 507.
|
||||||
|
# Pre-D4 this would have returned 201 and the whole upload
|
||||||
|
# would have wasted bandwidth until the final MOVE.
|
||||||
|
STATUS=$(nc_req PUT \
|
||||||
|
"/remote.php/dav/uploads/$username/$UPLOAD_ID_QUOTA/00001" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary "@$FIXTURE_200B")
|
||||||
|
[[ "$STATUS" == "507" ]] || fail "PUT over-quota chunk: got $STATUS, expected 507"
|
||||||
|
pass "PUT over-quota chunk rejected (status=$STATUS)"
|
||||||
|
|
||||||
|
# 3e. Abort the leftover session.
|
||||||
|
STATUS=$(nc_req DELETE "/remote.php/dav/uploads/$username/$UPLOAD_ID_QUOTA")
|
||||||
|
[[ "$STATUS" =~ ^(204|404)$ ]] || fail "DELETE quota session: got $STATUS"
|
||||||
|
pass "DELETE quota session (status=$STATUS)"
|
||||||
|
|
||||||
|
# 3f. Restore admin's original envelope immediately — keeps the rest
|
||||||
|
# of the suite running under the right cap. The EXIT trap also
|
||||||
|
# restores it as belt-and-braces.
|
||||||
|
curl -s -X PUT \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"quota_bytes\":${ORIGINAL_ADMIN_QUOTA}}" \
|
||||||
|
"$base_url/api/admin/users/$ADMIN_ID/quota" > /dev/null
|
||||||
|
pass "Admin envelope restored to ${ORIGINAL_ADMIN_QUOTA}"
|
||||||
|
|
||||||
# ── summary ──────────────────────────────────────────────────────────────────
|
# ── summary ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
echo
|
echo
|
||||||
|
|||||||
Reference in New Issue
Block a user