e3823ce470
Add an end-to-end and unit test suite for the SvelteKit frontend:
- Playwright e2e specs (tests/e2e/spa) with a throwaway container stack,
codegen scenarios, and an Istanbul-based coverage report pipeline.
- Vitest unit tests across API endpoints, components, stores and composables.
- `data-testid` hooks on interactive elements (AppShell, FileViewer,
ShareDialog, search, photos, files breadcrumbs, login/Nextcloud flows,
public share pages) so the e2e suite can target them deterministically.
- Serve the SPA app-shell CSP from a <meta> policy (svelte.config.js) plus a
middleware that skips the CSP header on HTML; move the Nextcloud Login Flow
v2 grant page to the SvelteKit /nextcloud/login route.
- `just front-codegen` recipe and start-server-spa.sh harness.
Make the test environment robust and consistent:
- Install a deterministic in-memory localStorage/sessionStorage in the Vitest
setup so storage behaves identically across Node versions (Node 26 ships a
native Web Storage global that otherwise shadows jsdom's).
- Pin devenv to Node 26 + PostgreSQL 18 and pin every CI job to Node 26.3.0
so the dev shell and CI run the same toolchain versions.
Repair the API/WebDAV (hurl) suite, which had drifted from the backend:
- Migrate the removed `/api/folders/{id}/listing` endpoint to `/resources`
(cursor-paginated `{items:[{resource_type,resource}]}` shape) across the
batch-copy, grants, nested-group, and WebDAV NC tests + the dav_helpers
wipe routine.
- Stop photos_etag from uploading the dedup-tracked fixture so the dedup
blob-lifecycle test can own its content-addressed blob exclusively.
- dedup_create now asserts the idempotent same-content re-upload (201 +
existing file id) instead of the stale 409 expectation.
Generated coverage reports, nyc output and the e2e server runtime data dir
are gitignored rather than committed.
303 lines
14 KiB
Plaintext
303 lines
14 KiB
Plaintext
# =============================================================
|
|
# OxiCloud — Upload precheck (`POST /api/files/by-hash`)
|
|
# =============================================================
|
|
# Validates the content_hash precheck that lets a client skip a
|
|
# body upload when the server already has the content for THIS
|
|
# caller. The request shape mirrors `FileDto.content_hash` — the
|
|
# same 64-char lowercase BLAKE3 hex digest the server returned
|
|
# on a previous upload.
|
|
#
|
|
# Scope-critical security rule (server-enforced): the precheck
|
|
# returns 201 only when the calling user already owns at least
|
|
# one file with the same BLAKE3 hash. Cross-user matches are
|
|
# invisible — hash probing cannot leak other tenants' content.
|
|
#
|
|
# Coverage:
|
|
# 1. Upload hello.txt normally → 201, content_hash captured
|
|
# 2. Precheck with the matching content_hash → 201, new
|
|
# file_id but SAME content_hash (dedup hit)
|
|
# 3. Precheck with a fake/random hash → 404
|
|
# 4. Malformed content_hash (non-hex chars) → 400
|
|
# 5. Wrong-length content_hash → 400
|
|
#
|
|
# Fixture: tests/fixtures/hello-copy.txt (32 bytes; same content as
|
|
# hello.txt). We seed with hello-copy.txt — NOT hello.txt — because
|
|
# several earlier tests in run.sh (trash, chunked_upload_cap, …)
|
|
# upload hello.txt into the home folder and don't reliably purge it,
|
|
# which would collide with our seed step here (409). The filename
|
|
# `hello-copy.txt` is unique to this test.
|
|
# BLAKE3 hex: b2208c5dc33ff951227bd0c139f5eccb04105d6da6a7519ee23f7bc00a17bb5a
|
|
# =============================================================
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 1 — Login
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/auth/login
|
|
Content-Type: application/json
|
|
{
|
|
"username": "{{username}}",
|
|
"password": "{{password}}"
|
|
}
|
|
|
|
HTTP 200
|
|
[Captures]
|
|
token: jsonpath "$.access_token"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 2 — Resolve home folder
|
|
# ─────────────────────────────────────────────────────────────
|
|
GET {{base_url}}/api/folders
|
|
Authorization: Bearer {{token}}
|
|
|
|
HTTP 200
|
|
[Captures]
|
|
home_folder_id: jsonpath "$[0].id"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 3 — Seed: upload hello-copy.txt via the normal multipart
|
|
# path. Captures the BLAKE3 server-side `content_hash`
|
|
# that the precheck step then matches against.
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/files/upload
|
|
Authorization: Bearer {{token}}
|
|
[MultipartFormData]
|
|
folder_id: {{home_folder_id}}
|
|
file: file,fixtures/hello-copy.txt; text/plain
|
|
|
|
HTTP 201
|
|
[Captures]
|
|
seed_file_id: jsonpath "$.id"
|
|
seed_content_hash: jsonpath "$.content_hash"
|
|
[Asserts]
|
|
jsonpath "$.name" == "hello-copy.txt"
|
|
jsonpath "$.size" == 32
|
|
jsonpath "$.content_hash" == "b2208c5dc33ff951227bd0c139f5eccb04105d6da6a7519ee23f7bc00a17bb5a"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 4 — Idempotent re-upload: re-POSTing the same fixture (same
|
|
# folder, same name, IDENTICAL content/BLAKE3) is a no-op
|
|
# that returns the EXISTING file row, not a 409. See the
|
|
# idempotency branch in save_file_with_blob
|
|
# (file_blob_write_repository.rs): a (folder_id, name)
|
|
# unique clash whose content hash matches resolves to the
|
|
# existing file so re-uploading a partially-transferred
|
|
# folder is clean; only a same-name upload with DIFFERENT
|
|
# content still returns 409.
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/files/upload
|
|
Authorization: Bearer {{token}}
|
|
[MultipartFormData]
|
|
folder_id: {{home_folder_id}}
|
|
file: file,fixtures/hello-copy.txt; text/plain
|
|
|
|
HTTP 201
|
|
[Asserts]
|
|
jsonpath "$.id" == "{{seed_file_id}}"
|
|
jsonpath "$.content_hash" == "{{seed_content_hash}}"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 5 — Precheck HIT: caller now owns the BLAKE3 of
|
|
# hello-copy.txt, so by-hash with that hash must
|
|
# 201 and return a NEW file_id (the metadata row is
|
|
# new — the blob is shared).
|
|
#
|
|
# Expectations:
|
|
# - status: 201
|
|
# - file_id differs from the seed
|
|
# - content_hash matches the seed (proves dedup hit)
|
|
# - filename is the one we requested
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/files/by-hash
|
|
Authorization: Bearer {{token}}
|
|
Content-Type: application/json
|
|
{
|
|
"name": "hello-precheck-bypass.txt",
|
|
"folder_id": "{{home_folder_id}}",
|
|
"hash": "b2208c5dc33ff951227bd0c139f5eccb04105d6da6a7519ee23f7bc00a17bb5a"
|
|
}
|
|
|
|
HTTP 201
|
|
[Captures]
|
|
copy_file_id: jsonpath "$.id"
|
|
copy_content_hash: jsonpath "$.content_hash"
|
|
[Asserts]
|
|
jsonpath "$.name" == "hello-precheck-bypass.txt"
|
|
jsonpath "$.size" == 32
|
|
jsonpath "$.content_hash" == "b2208c5dc33ff951227bd0c139f5eccb04105d6da6a7519ee23f7bc00a17bb5a"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 6 — Same hash, different filename, in a NEW request →
|
|
# should hit again. Confirms the precheck isn't a
|
|
# one-shot bound to the seed file.
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/files/by-hash
|
|
Authorization: Bearer {{token}}
|
|
Content-Type: application/json
|
|
{
|
|
"name": "hello-precheck-bypass-2.txt",
|
|
"folder_id": "{{home_folder_id}}",
|
|
"hash": "b2208c5dc33ff951227bd0c139f5eccb04105d6da6a7519ee23f7bc00a17bb5a"
|
|
}
|
|
|
|
HTTP 201
|
|
[Asserts]
|
|
jsonpath "$.name" == "hello-precheck-bypass-2.txt"
|
|
jsonpath "$.content_hash" == "b2208c5dc33ff951227bd0c139f5eccb04105d6da6a7519ee23f7bc00a17bb5a"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 7 — Precheck MISS with a random/never-uploaded hash →
|
|
# 404 with `error: blob_not_owned_by_caller` and an
|
|
# `upload_path` hint. The 404 shape is the
|
|
# anti-enumeration boundary: same response whether the
|
|
# hash doesn't exist anywhere or exists but is owned
|
|
# by another user.
|
|
#
|
|
# The fake hash below is just 32 bytes of 0xFF (well-
|
|
# formed BLAKE3 shape, vanishingly unlikely to collide).
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/files/by-hash
|
|
Authorization: Bearer {{token}}
|
|
Content-Type: application/json
|
|
{
|
|
"name": "should-not-create.bin",
|
|
"folder_id": "{{home_folder_id}}",
|
|
"hash": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
|
|
}
|
|
|
|
HTTP 404
|
|
[Asserts]
|
|
jsonpath "$.error" == "blob_not_owned_by_caller"
|
|
jsonpath "$.upload_path" == "/api/files/upload"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 8 — Malformed content_hash (non-hex chars) → 400.
|
|
# Defensive parse: typos shouldn't silently disable
|
|
# the integrity check.
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/files/by-hash
|
|
Authorization: Bearer {{token}}
|
|
Content-Type: application/json
|
|
{
|
|
"name": "should-not-create.bin",
|
|
"folder_id": "{{home_folder_id}}",
|
|
"hash": "this-is-not-hex-content-hash-zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"
|
|
}
|
|
|
|
HTTP 400
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 9 — Wrong-length content_hash → 400. A 64-char string
|
|
# is required (32-byte BLAKE3); anything else (here a
|
|
# SHA-1-sized 40-char hex) is rejected at the input
|
|
# boundary before the lookup happens.
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/files/by-hash
|
|
Authorization: Bearer {{token}}
|
|
Content-Type: application/json
|
|
{
|
|
"name": "should-not-create.bin",
|
|
"folder_id": "{{home_folder_id}}",
|
|
"hash": "a94a8fef8c5d539e7e0f9c4b4d2c93e88f44f3f8"
|
|
}
|
|
|
|
HTTP 400
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 10 — Trashed-blob precheck setup. Upload a fixture whose
|
|
# BLAKE3 is GUARANTEED unique to this test (the
|
|
# `hello-trashed.txt` fixture isn't referenced anywhere
|
|
# else under tests/), so the only file owning the blob
|
|
# after step 11 will be the one we trash.
|
|
#
|
|
# Captures `trashed_seed_id` + `trashed_seed_hash`
|
|
# which steps 11 and 12 consume.
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/files/upload
|
|
Authorization: Bearer {{token}}
|
|
[MultipartFormData]
|
|
folder_id: {{home_folder_id}}
|
|
file: file,fixtures/hello-trashed.txt; text/plain
|
|
|
|
HTTP 201
|
|
[Captures]
|
|
trashed_seed_id: jsonpath "$.id"
|
|
trashed_seed_hash: jsonpath "$.content_hash"
|
|
[Asserts]
|
|
jsonpath "$.name" == "hello-trashed.txt"
|
|
jsonpath "$.size" == 35
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 11 — Trash the seed (soft-delete). The blob's ref_count
|
|
# stays > 0 because the file row still exists with
|
|
# is_trashed = true. Pre-change behaviour would have
|
|
# hidden the blob from `/api/files/by-hash`; the new
|
|
# behaviour treats trashed-file ownership as ownership.
|
|
# ─────────────────────────────────────────────────────────────
|
|
DELETE {{base_url}}/api/files/{{trashed_seed_id}}
|
|
Authorization: Bearer {{token}}
|
|
|
|
# Either 200 or 204 depending on whether the server returns a
|
|
# body. The trash service uses soft-delete (UPDATE is_trashed = true),
|
|
# not a hard delete, so the blob row is untouched.
|
|
HTTP *
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 12 — Precheck HIT on a trashed-only owner. Must succeed
|
|
# (201) and return a NEW live file pointing at the same
|
|
# blob — even though the only OTHER reference to the
|
|
# blob is a trashed file. Verifies the dropped
|
|
# `AND NOT f.is_trashed` filter in
|
|
# `user_owns_blob_reference` and `pin_claimable_chunks`.
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/files/by-hash
|
|
Authorization: Bearer {{token}}
|
|
Content-Type: application/json
|
|
{
|
|
"name": "restored-via-by-hash.txt",
|
|
"folder_id": "{{home_folder_id}}",
|
|
"hash": "{{trashed_seed_hash}}"
|
|
}
|
|
|
|
HTTP 201
|
|
[Captures]
|
|
trashed_copy_id: jsonpath "$.id"
|
|
[Asserts]
|
|
jsonpath "$.name" == "restored-via-by-hash.txt"
|
|
jsonpath "$.size" == 35
|
|
jsonpath "$.content_hash" == "{{trashed_seed_hash}}"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 13 — Cleanup. Trash every file created by this script;
|
|
# the next run starts clean. Seed (step 4) + copies
|
|
# (steps 5+6) share one blob; trashed seed (step 10)
|
|
# + by-hash restore (step 12) share another. Trashing
|
|
# all four lets the GC ref-count both blobs to zero.
|
|
# ─────────────────────────────────────────────────────────────
|
|
DELETE {{base_url}}/api/files/{{seed_file_id}}
|
|
Authorization: Bearer {{token}}
|
|
|
|
HTTP *
|
|
|
|
DELETE {{base_url}}/api/files/{{copy_file_id}}
|
|
Authorization: Bearer {{token}}
|
|
|
|
HTTP *
|
|
|
|
DELETE {{base_url}}/api/files/{{trashed_copy_id}}
|
|
Authorization: Bearer {{token}}
|
|
|
|
HTTP *
|