1018 lines
34 KiB
Plaintext
1018 lines
34 KiB
Plaintext
# =============================================================
|
||
# OxiCloud – D5 drive policies: `forbid_public_links`
|
||
# =============================================================
|
||
# Run:
|
||
# hurl --variables-file tests/api/test.env --file-root tests \
|
||
# --test tests/api/drive_policies.hurl
|
||
#
|
||
# The model under test (`docs/plan/drive.md` §8):
|
||
# Each drive carries a `policies` JSONB. Five known keys, all
|
||
# default-false. The first key shipped is `forbid_public_links`,
|
||
# which blocks anonymous token-share creation on every resource
|
||
# in the drive. Enforced at `share_service::create_shared_link`;
|
||
# mutated by `PATCH /api/drives/{id}/policies` (OxiCloud-admin
|
||
# only — the carve-out closes the self-policing-soft-cap hole
|
||
# where an owner could disable a policy, share, and re-enable).
|
||
#
|
||
# Cases:
|
||
# 1. Baseline — policy off → POST /api/shares succeeds (201).
|
||
# 2. Owner flips `forbid_public_links` via PATCH → 200,
|
||
# response echoes the merged bag.
|
||
# 3. Policy on → POST /api/shares refused with
|
||
# OperationNotSupported (405) and the share row is NOT created.
|
||
# 4. Owner flips the policy back off → POST /api/shares succeeds
|
||
# again (proves merge semantics; the typed write doesn't
|
||
# clobber unrelated keys).
|
||
#
|
||
# Self-contained: provisions `dp_owner` so it can run alongside
|
||
# the rest of the suite. The user's default Personal drive is
|
||
# the test surface — the policy applies equally to personal and
|
||
# shared drives (`Owner` bundle includes "edit policies").
|
||
# =============================================================
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# 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 `dp_owner`.
|
||
# ─────────────────────────────────────────────────────────────
|
||
POST {{base_url}}/api/admin/users
|
||
Authorization: Bearer {{admin_token}}
|
||
Content-Type: application/json
|
||
{
|
||
"username": "dp_owner",
|
||
"password": "DpOwnerPwd1!",
|
||
"email": "dp_owner@example.com",
|
||
"role": "user"
|
||
}
|
||
|
||
HTTP 201
|
||
|
||
POST {{base_url}}/api/auth/login
|
||
Content-Type: application/json
|
||
{ "username": "dp_owner", "password": "DpOwnerPwd1!" }
|
||
|
||
HTTP 200
|
||
[Captures]
|
||
owner_token: jsonpath "$.access_token"
|
||
owner_user_id: jsonpath "$.user.full.user.id"
|
||
|
||
|
||
# Provision `dp_intruder` — a second internal user used only to
|
||
# exercise the negative side of the policy-PATCH authz gate.
|
||
# A separate user (not bob, who's external) keeps internal/external
|
||
# semantics out of the assertion.
|
||
POST {{base_url}}/api/admin/users
|
||
Authorization: Bearer {{admin_token}}
|
||
Content-Type: application/json
|
||
{
|
||
"username": "dp_intruder",
|
||
"password": "DpIntruderPwd1!",
|
||
"email": "dp_intruder@example.com",
|
||
"role": "user"
|
||
}
|
||
|
||
HTTP 201
|
||
|
||
POST {{base_url}}/api/auth/login
|
||
Content-Type: application/json
|
||
{ "username": "dp_intruder", "password": "DpIntruderPwd1!" }
|
||
|
||
HTTP 200
|
||
[Captures]
|
||
intruder_token: jsonpath "$.access_token"
|
||
intruder_user_id: jsonpath "$.user.full.user.id"
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# Step 3 — Find 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 — Seed a file to share.
|
||
# ─────────────────────────────────────────────────────────────
|
||
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"
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# Step 5 — Case 1: baseline. Policy off → POST /api/shares OK.
|
||
# ─────────────────────────────────────────────────────────────
|
||
POST {{base_url}}/api/shares
|
||
Authorization: Bearer {{owner_token}}
|
||
Content-Type: application/json
|
||
{
|
||
"item_id": "{{file_id}}",
|
||
"item_type": "file"
|
||
}
|
||
|
||
HTTP 201
|
||
[Captures]
|
||
baseline_share_id: jsonpath "$.id"
|
||
|
||
|
||
# Clean up the baseline share so the policy-on case starts fresh.
|
||
DELETE {{base_url}}/api/shares/{{baseline_share_id}}
|
||
Authorization: Bearer {{owner_token}}
|
||
|
||
HTTP 204
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# Step 6 — Case 2: flip `forbid_public_links` on.
|
||
# PATCH returns the merged bag.
|
||
# ─────────────────────────────────────────────────────────────
|
||
PATCH {{base_url}}/api/drives/{{personal_drive_id}}/policies
|
||
Authorization: Bearer {{admin_token}}
|
||
Content-Type: application/json
|
||
{
|
||
"forbid_public_links": true
|
||
}
|
||
|
||
HTTP 200
|
||
[Asserts]
|
||
jsonpath "$.forbid_public_links" == true
|
||
jsonpath "$.forbid_sharing" == false
|
||
jsonpath "$.forbid_external_sharing" == false
|
||
jsonpath "$.forbid_cross_drive_move" == false
|
||
|
||
|
||
# Authz gate — negative case. The PATCH is OxiCloud-admin only.
|
||
# Anything below admin role gets a 404 (anti-enum — same shape as
|
||
# "drive does not exist", so a probe can't tell apart "no such
|
||
# drive" from "policies are admin-managed").
|
||
#
|
||
# The most important assertion: even the drive's OWNER can no
|
||
# longer mutate policies. Before this change the policies were
|
||
# owner-mutable, which made them self-policing soft caps (an
|
||
# owner could disable forbid_external_sharing, share, re-enable).
|
||
# Mirroring `drives.quota_bytes` and `users.storage_quota_bytes`
|
||
# admin-only carve-outs.
|
||
PATCH {{base_url}}/api/drives/{{personal_drive_id}}/policies
|
||
Authorization: Bearer {{owner_token}}
|
||
Content-Type: application/json
|
||
{
|
||
"forbid_public_links": false
|
||
}
|
||
|
||
HTTP 404
|
||
|
||
# And a non-member also gets 404 (same anti-enum shape).
|
||
PATCH {{base_url}}/api/drives/{{personal_drive_id}}/policies
|
||
Authorization: Bearer {{intruder_token}}
|
||
Content-Type: application/json
|
||
{
|
||
"forbid_public_links": false
|
||
}
|
||
|
||
HTTP 404
|
||
|
||
|
||
# Belt-and-braces: the policy that admin set is unchanged
|
||
# (no partial write happened under the failed authz).
|
||
GET {{base_url}}/api/drives
|
||
Authorization: Bearer {{owner_token}}
|
||
|
||
HTTP 200
|
||
[Asserts]
|
||
jsonpath "$[0].policies.forbid_public_links" == true
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# Step 7 — Case 3: policy on → POST /api/shares refused (405).
|
||
# DomainError::operation_not_supported maps to HTTP 405
|
||
# (Method Not Allowed) per the interface error map.
|
||
# ─────────────────────────────────────────────────────────────
|
||
POST {{base_url}}/api/shares
|
||
Authorization: Bearer {{owner_token}}
|
||
Content-Type: application/json
|
||
{
|
||
"item_id": "{{file_id}}",
|
||
"item_type": "file"
|
||
}
|
||
|
||
HTTP 405
|
||
|
||
|
||
# Closing the bypass: `POST /api/grants` with `subject.type=token`
|
||
# would otherwise mint an anonymous-link grant — same effect as a
|
||
# token share, different surface. `grant_handler` now routes
|
||
# Token subjects through `DrivePolicies::refuse_public_links`,
|
||
# so the policy gates both surfaces. The token UUID is invented
|
||
# (no validation up to this point) — the refusal must fire from
|
||
# the policy check, not from a missing-token lookup.
|
||
POST {{base_url}}/api/grants
|
||
Authorization: Bearer {{owner_token}}
|
||
Content-Type: application/json
|
||
{
|
||
"subject": { "type": "token", "id": "00000000-0000-0000-0000-000000000bad" },
|
||
"resource": { "type": "file", "id": "{{file_id}}" },
|
||
"role": "viewer"
|
||
}
|
||
|
||
HTTP 405
|
||
|
||
|
||
# Confirm no share row was created — the listing on this file
|
||
# is empty.
|
||
GET {{base_url}}/api/shares?item_id={{file_id}}&item_type=file
|
||
Authorization: Bearer {{owner_token}}
|
||
|
||
HTTP 200
|
||
[Asserts]
|
||
jsonpath "$" count == 0
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# Step 8 — Case 4: flip the policy back off → share succeeds.
|
||
# Proves the partial-merge: setting `forbid_public_links`
|
||
# to false doesn't touch unrelated keys (still false here,
|
||
# but the round-trip exercises the merge path).
|
||
# ─────────────────────────────────────────────────────────────
|
||
PATCH {{base_url}}/api/drives/{{personal_drive_id}}/policies
|
||
Authorization: Bearer {{admin_token}}
|
||
Content-Type: application/json
|
||
{
|
||
"forbid_public_links": false
|
||
}
|
||
|
||
HTTP 200
|
||
[Asserts]
|
||
jsonpath "$.forbid_public_links" == false
|
||
|
||
|
||
POST {{base_url}}/api/shares
|
||
Authorization: Bearer {{owner_token}}
|
||
Content-Type: application/json
|
||
{
|
||
"item_id": "{{file_id}}",
|
||
"item_type": "file"
|
||
}
|
||
|
||
HTTP 201
|
||
[Captures]
|
||
final_share_id: jsonpath "$.id"
|
||
|
||
DELETE {{base_url}}/api/shares/{{final_share_id}}
|
||
Authorization: Bearer {{owner_token}}
|
||
|
||
HTTP 204
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# Step 9 — `forbid_external_sharing` baseline + early refuse.
|
||
# Owner shares a folder by email — succeeds, lazily
|
||
# provisions the external user. Then toggle the policy
|
||
# on and try a fresh email — refused BEFORE the
|
||
# external user is created (early gate prevents the
|
||
# side-effect leak).
|
||
# ─────────────────────────────────────────────────────────────
|
||
POST {{base_url}}/api/folders
|
||
Authorization: Bearer {{owner_token}}
|
||
Content-Type: application/json
|
||
{ "name": "dp-ext-share", "parent_id": "{{personal_root_id}}" }
|
||
|
||
HTTP 201
|
||
[Captures]
|
||
ext_folder_id: jsonpath "$.id"
|
||
|
||
|
||
# Baseline: email grant succeeds with policy off. Captures the
|
||
# resolved bob_user_id so the LATE gate can be exercised below.
|
||
POST {{base_url}}/api/grants
|
||
Authorization: Bearer {{owner_token}}
|
||
Content-Type: application/json
|
||
{
|
||
"subject": { "type": "email", "email": "dp_bob@externalcompany.com" },
|
||
"resource": { "type": "folder", "id": "{{ext_folder_id}}" },
|
||
"role": "viewer"
|
||
}
|
||
|
||
HTTP 201
|
||
[Captures]
|
||
bob_user_id: jsonpath "$.grants[0].subject.id"
|
||
|
||
|
||
# Toggle `forbid_external_sharing` on.
|
||
PATCH {{base_url}}/api/drives/{{personal_drive_id}}/policies
|
||
Authorization: Bearer {{admin_token}}
|
||
Content-Type: application/json
|
||
{
|
||
"forbid_external_sharing": true
|
||
}
|
||
|
||
HTTP 200
|
||
[Asserts]
|
||
jsonpath "$.forbid_external_sharing" == true
|
||
jsonpath "$.forbid_public_links" == false
|
||
|
||
|
||
# Early gate: email subject refused before any user row is created.
|
||
# The grant.rejected audit line fires with reason=forbid_external_sharing
|
||
# stage=early_email.
|
||
POST {{base_url}}/api/grants
|
||
Authorization: Bearer {{owner_token}}
|
||
Content-Type: application/json
|
||
{
|
||
"subject": { "type": "email", "email": "dp_alice@externalcompany.com" },
|
||
"resource": { "type": "folder", "id": "{{ext_folder_id}}" },
|
||
"role": "viewer"
|
||
}
|
||
|
||
HTTP 405
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# Step 10 — `forbid_external_sharing` late refuse: even passing
|
||
# an existing external user by id is refused (closes
|
||
# the user-by-id loophole).
|
||
# ─────────────────────────────────────────────────────────────
|
||
POST {{base_url}}/api/grants
|
||
Authorization: Bearer {{owner_token}}
|
||
Content-Type: application/json
|
||
{
|
||
"subject": { "type": "user", "id": "{{bob_user_id}}" },
|
||
"resource": { "type": "folder", "id": "{{ext_folder_id}}" },
|
||
"role": "viewer"
|
||
}
|
||
|
||
HTTP 405
|
||
|
||
|
||
# Flip the policy back off — same subject now succeeds, proving
|
||
# the refusal was policy-driven and not a permanent block.
|
||
PATCH {{base_url}}/api/drives/{{personal_drive_id}}/policies
|
||
Authorization: Bearer {{admin_token}}
|
||
Content-Type: application/json
|
||
{
|
||
"forbid_external_sharing": false
|
||
}
|
||
|
||
HTTP 200
|
||
[Asserts]
|
||
jsonpath "$.forbid_external_sharing" == false
|
||
|
||
|
||
POST {{base_url}}/api/grants
|
||
Authorization: Bearer {{owner_token}}
|
||
Content-Type: application/json
|
||
{
|
||
"subject": { "type": "user", "id": "{{bob_user_id}}" },
|
||
"resource": { "type": "folder", "id": "{{ext_folder_id}}" },
|
||
"role": "viewer"
|
||
}
|
||
|
||
HTTP 201
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# Step 10b — `forbid_sharing` on a personal drive: per-resource
|
||
# grants on resources inside the drive are refused;
|
||
# drive-level membership stays unaffected (covered by
|
||
# the shared-drive positive control in Step 11 below).
|
||
#
|
||
# This is the broadest D5 policy — toggling it on locks the drive
|
||
# to "drive membership only" sharing semantics (§8: "no fine-
|
||
# grained sharing of individual files; access happens through
|
||
# drive membership only").
|
||
# ─────────────────────────────────────────────────────────────
|
||
PATCH {{base_url}}/api/drives/{{personal_drive_id}}/policies
|
||
Authorization: Bearer {{admin_token}}
|
||
Content-Type: application/json
|
||
{
|
||
"forbid_sharing": true
|
||
}
|
||
|
||
HTTP 200
|
||
[Asserts]
|
||
jsonpath "$.forbid_sharing" == true
|
||
jsonpath "$.forbid_external_sharing" == false
|
||
jsonpath "$.forbid_public_links" == false
|
||
|
||
|
||
# File-grant refused. `grant.rejected reason=forbid_sharing`.
|
||
POST {{base_url}}/api/grants
|
||
Authorization: Bearer {{owner_token}}
|
||
Content-Type: application/json
|
||
{
|
||
"subject": { "type": "user", "id": "{{bob_user_id}}" },
|
||
"resource": { "type": "file", "id": "{{file_id}}" },
|
||
"role": "viewer"
|
||
}
|
||
|
||
HTTP 405
|
||
|
||
|
||
# Folder-grant refused with the same shape.
|
||
POST {{base_url}}/api/grants
|
||
Authorization: Bearer {{owner_token}}
|
||
Content-Type: application/json
|
||
{
|
||
"subject": { "type": "user", "id": "{{bob_user_id}}" },
|
||
"resource": { "type": "folder", "id": "{{ext_folder_id}}" },
|
||
"role": "viewer"
|
||
}
|
||
|
||
HTTP 405
|
||
|
||
|
||
# Flip the policy off — the same folder-grant now succeeds, proving
|
||
# refusal was policy-driven.
|
||
PATCH {{base_url}}/api/drives/{{personal_drive_id}}/policies
|
||
Authorization: Bearer {{admin_token}}
|
||
Content-Type: application/json
|
||
{
|
||
"forbid_sharing": false
|
||
}
|
||
|
||
HTTP 200
|
||
|
||
POST {{base_url}}/api/grants
|
||
Authorization: Bearer {{owner_token}}
|
||
Content-Type: application/json
|
||
{
|
||
"subject": { "type": "user", "id": "{{bob_user_id}}" },
|
||
"resource": { "type": "folder", "id": "{{ext_folder_id}}" },
|
||
"role": "viewer"
|
||
}
|
||
|
||
HTTP 201
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# Step 10c — partial-merge regression guard.
|
||
#
|
||
# The `PATCH /api/drives/{id}/policies` handler documents that
|
||
# omitting a field means "leave it alone", not "set it to false".
|
||
# Prior implementation round-tripped the wire body through the
|
||
# typed `DrivePolicies` struct (which has `#[serde(default)]`, so
|
||
# every omitted field defaults to `false`) and then serialised the
|
||
# whole struct into the JSONB `||` merge — silently clobbering
|
||
# every unmentioned flag back to `false`. This step exercises
|
||
# multi-flag interaction so that regression can't creep back:
|
||
#
|
||
# 1. Set `forbid_sharing = true`, assert the bag.
|
||
# 2. In a SEPARATE PATCH, set only `forbid_public_links = true`.
|
||
# 3. Assert `forbid_sharing` STILL reads `true` in the response
|
||
# — proving the merge honoured "leave omitted keys alone".
|
||
#
|
||
# Reset both back to false at the end so the shared-drive steps
|
||
# below start from a clean state.
|
||
# ─────────────────────────────────────────────────────────────
|
||
PATCH {{base_url}}/api/drives/{{personal_drive_id}}/policies
|
||
Authorization: Bearer {{admin_token}}
|
||
Content-Type: application/json
|
||
{
|
||
"forbid_sharing": true
|
||
}
|
||
|
||
HTTP 200
|
||
[Asserts]
|
||
jsonpath "$.forbid_sharing" == true
|
||
jsonpath "$.forbid_public_links" == false
|
||
|
||
PATCH {{base_url}}/api/drives/{{personal_drive_id}}/policies
|
||
Authorization: Bearer {{admin_token}}
|
||
Content-Type: application/json
|
||
{
|
||
"forbid_public_links": true
|
||
}
|
||
|
||
HTTP 200
|
||
[Asserts]
|
||
# The load-bearing assertion — `forbid_sharing` must NOT have been
|
||
# clobbered by the omitted-key regression.
|
||
jsonpath "$.forbid_sharing" == true
|
||
jsonpath "$.forbid_public_links" == true
|
||
|
||
# Reset both.
|
||
PATCH {{base_url}}/api/drives/{{personal_drive_id}}/policies
|
||
Authorization: Bearer {{admin_token}}
|
||
Content-Type: application/json
|
||
{
|
||
"forbid_sharing": false,
|
||
"forbid_public_links": false
|
||
}
|
||
|
||
HTTP 200
|
||
[Asserts]
|
||
jsonpath "$.forbid_sharing" == false
|
||
jsonpath "$.forbid_public_links" == false
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# Step 11 — `forbid_external_sharing` on a SHARED drive, via
|
||
# `POST /api/drives/{id}/members`.
|
||
#
|
||
# Coverage gap closed: the earlier steps exercise the
|
||
# grant_handler path (File/Folder grants in dp_owner's personal
|
||
# drive). The drive-membership route bypasses grant_handler and
|
||
# calls `DriveManagementService::set_member_role` directly —
|
||
# `refuse_if_forbid_external_sharing` enforces the same gate at
|
||
# the service layer (`docs/plan/drive.md` §8). This step proves
|
||
# the route is gated.
|
||
#
|
||
# Personal drives refuse `add_member` regardless of policy (§2),
|
||
# so a shared drive is required. Admin provisions one with
|
||
# dp_owner as direct user-Owner.
|
||
# ─────────────────────────────────────────────────────────────
|
||
POST {{base_url}}/api/drives
|
||
Authorization: Bearer {{admin_token}}
|
||
Content-Type: application/json
|
||
{
|
||
"kind": "shared",
|
||
"name": "dp-shared",
|
||
"owner": { "type": "user", "id": "{{owner_user_id}}" }
|
||
}
|
||
|
||
HTTP 201
|
||
[Captures]
|
||
shared_drive_id: jsonpath "$.id"
|
||
shared_root_id: jsonpath "$.root_folder_id"
|
||
|
||
|
||
# Toggle `forbid_external_sharing` on the SHARED drive (dp_owner
|
||
# is Owner → carries Manage in the role bundle).
|
||
PATCH {{base_url}}/api/drives/{{shared_drive_id}}/policies
|
||
Authorization: Bearer {{admin_token}}
|
||
Content-Type: application/json
|
||
{
|
||
"forbid_external_sharing": true
|
||
}
|
||
|
||
HTTP 200
|
||
[Asserts]
|
||
jsonpath "$.forbid_external_sharing" == true
|
||
|
||
|
||
# Adding bob (existing external user from Step 9) as a Viewer
|
||
# via the drive-membership route is refused by
|
||
# `set_member_role`'s `refuse_if_forbid_external_sharing` —
|
||
# `grant.rejected reason=forbid_external_sharing stage=drive_member`.
|
||
POST {{base_url}}/api/drives/{{shared_drive_id}}/members
|
||
Authorization: Bearer {{owner_token}}
|
||
Content-Type: application/json
|
||
{
|
||
"subject": { "type": "user", "id": "{{bob_user_id}}" },
|
||
"role": "viewer"
|
||
}
|
||
|
||
HTTP 405
|
||
|
||
|
||
# Flip the policy off — same call succeeds, proving the refusal
|
||
# was policy-driven (not a permanent block) and that the gate at
|
||
# the service layer can be lifted by the drive owner.
|
||
PATCH {{base_url}}/api/drives/{{shared_drive_id}}/policies
|
||
Authorization: Bearer {{admin_token}}
|
||
Content-Type: application/json
|
||
{
|
||
"forbid_external_sharing": false
|
||
}
|
||
|
||
HTTP 200
|
||
|
||
POST {{base_url}}/api/drives/{{shared_drive_id}}/members
|
||
Authorization: Bearer {{owner_token}}
|
||
Content-Type: application/json
|
||
{
|
||
"subject": { "type": "user", "id": "{{bob_user_id}}" },
|
||
"role": "viewer"
|
||
}
|
||
|
||
HTTP 201
|
||
|
||
|
||
# Authz gate — non-Owner role on a SHARED drive still can't change
|
||
# policies. Add `dp_intruder` as Editor (bundle includes Update on
|
||
# resources in the drive but NOT Manage), then have them try to
|
||
# flip a policy → 404. Proves the PATCH endpoint requires Manage
|
||
# specifically, not just any drive role.
|
||
POST {{base_url}}/api/drives/{{shared_drive_id}}/members
|
||
Authorization: Bearer {{owner_token}}
|
||
Content-Type: application/json
|
||
{
|
||
"subject": { "type": "user", "id": "{{intruder_user_id}}" },
|
||
"role": "editor"
|
||
}
|
||
|
||
HTTP 201
|
||
|
||
PATCH {{base_url}}/api/drives/{{shared_drive_id}}/policies
|
||
Authorization: Bearer {{intruder_token}}
|
||
Content-Type: application/json
|
||
{
|
||
"forbid_external_sharing": true
|
||
}
|
||
|
||
HTTP 404
|
||
|
||
|
||
# Belt-and-braces: dp_intruder's failed PATCH didn't side-effect.
|
||
# dp_owner reads the drive's policies (canonical owner view) and
|
||
# `forbid_external_sharing` stays at the value the owner last set
|
||
# (false — flipped back two requests ago).
|
||
GET {{base_url}}/api/drives
|
||
Authorization: Bearer {{owner_token}}
|
||
[QueryStringParams]
|
||
|
||
HTTP 200
|
||
[Asserts]
|
||
jsonpath "$[?(@.id=='{{shared_drive_id}}')].policies.forbid_external_sharing" == false
|
||
|
||
|
||
# `forbid_sharing` carve-out positive control. The policy locks
|
||
# per-resource sharing but leaves drive-level membership working
|
||
# (§8 — "access happens through drive membership only"). Toggle
|
||
# it on, then add a new drive member: must succeed (201). This is
|
||
# the assertion that grant_handler skips the gate for
|
||
# `Resource::Drive(_)`.
|
||
PATCH {{base_url}}/api/drives/{{shared_drive_id}}/policies
|
||
Authorization: Bearer {{admin_token}}
|
||
Content-Type: application/json
|
||
{
|
||
"forbid_sharing": true
|
||
}
|
||
|
||
HTTP 200
|
||
[Asserts]
|
||
jsonpath "$.forbid_sharing" == true
|
||
|
||
# `dp_owner` is already Owner; bob is Viewer; dp_intruder is
|
||
# Editor. Re-grant dp_intruder Editor — UPSERT through
|
||
# `set_member_role` — under `forbid_sharing=true`. The carve-out
|
||
# means this still works.
|
||
POST {{base_url}}/api/drives/{{shared_drive_id}}/members
|
||
Authorization: Bearer {{owner_token}}
|
||
Content-Type: application/json
|
||
{
|
||
"subject": { "type": "user", "id": "{{intruder_user_id}}" },
|
||
"role": "editor"
|
||
}
|
||
|
||
HTTP 201
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# Step 11b — `forbid_cross_drive_move` on the SOURCE drive
|
||
# refuses moves to a different drive. dp_owner is
|
||
# Owner of both the personal and shared drives, so
|
||
# authz on both ends passes — the refusal must come
|
||
# from the policy gate, not a permission failure.
|
||
#
|
||
# The policy lives on the SOURCE drive (the one losing the
|
||
# content). It's also fetched into the service via
|
||
# `get_drive_id_and_policies_for_file`, so the same call site
|
||
# proves the lookup works end-to-end.
|
||
#
|
||
# Clean up `forbid_sharing` first — it would refuse the per-
|
||
# resource-grant-style mutations the move tests don't actually
|
||
# do, but the test should isolate one policy at a time.
|
||
# ─────────────────────────────────────────────────────────────
|
||
PATCH {{base_url}}/api/drives/{{personal_drive_id}}/policies
|
||
Authorization: Bearer {{admin_token}}
|
||
Content-Type: application/json
|
||
{
|
||
"forbid_cross_drive_move": true
|
||
}
|
||
|
||
HTTP 200
|
||
[Asserts]
|
||
jsonpath "$.forbid_cross_drive_move" == true
|
||
|
||
|
||
# Attempt to move the file from dp_owner's personal drive into
|
||
# the shared drive's root folder. Both Update (file) and Create
|
||
# (folder) authz pass — dp_owner is Owner of both drives. The
|
||
# gate fires `move.rejected reason=forbid_cross_drive_move`.
|
||
PUT {{base_url}}/api/files/{{file_id}}/move
|
||
Authorization: Bearer {{owner_token}}
|
||
Content-Type: application/json
|
||
{
|
||
"folder_id": "{{shared_root_id}}"
|
||
}
|
||
|
||
HTTP 405
|
||
|
||
|
||
# Confirm the file stayed put on the source drive (no partial
|
||
# move under the failed gate).
|
||
GET {{base_url}}/api/files?folder_id={{personal_root_id}}
|
||
Authorization: Bearer {{owner_token}}
|
||
|
||
HTTP 200
|
||
[Asserts]
|
||
jsonpath "$[?(@.id=='{{file_id}}')].folder_id" == "{{personal_root_id}}"
|
||
|
||
|
||
# Flip the policy off — same call now succeeds and the file
|
||
# lands in the shared drive's root.
|
||
PATCH {{base_url}}/api/drives/{{personal_drive_id}}/policies
|
||
Authorization: Bearer {{admin_token}}
|
||
Content-Type: application/json
|
||
{
|
||
"forbid_cross_drive_move": false
|
||
}
|
||
|
||
HTTP 200
|
||
|
||
PUT {{base_url}}/api/files/{{file_id}}/move
|
||
Authorization: Bearer {{owner_token}}
|
||
Content-Type: application/json
|
||
{
|
||
"folder_id": "{{shared_root_id}}"
|
||
}
|
||
|
||
HTTP 200
|
||
|
||
|
||
# Move the file back to dp_owner's personal drive so the shared
|
||
# drive cleanup's empty-before-delete guard passes.
|
||
PUT {{base_url}}/api/files/{{file_id}}/move
|
||
Authorization: Bearer {{owner_token}}
|
||
Content-Type: application/json
|
||
{
|
||
"folder_id": "{{personal_root_id}}"
|
||
}
|
||
|
||
HTTP 200
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# Step 11c — `forbid_owner_role_change` locks the Owner roster
|
||
# against owner mutation. Only OxiCloud admin can
|
||
# change the Owner set when this policy is on.
|
||
#
|
||
# Fixture at this point: dp_owner is Owner on the shared drive,
|
||
# dp_intruder is Editor (from Step 11), bob is Viewer
|
||
# (re-granted earlier). Admin enables the policy; dp_owner is
|
||
# refused on every Owner-touching mutation; non-Owner mutations
|
||
# still work; admin override always succeeds.
|
||
# ─────────────────────────────────────────────────────────────
|
||
PATCH {{base_url}}/api/drives/{{shared_drive_id}}/policies
|
||
Authorization: Bearer {{admin_token}}
|
||
Content-Type: application/json
|
||
{
|
||
"forbid_owner_role_change": true
|
||
}
|
||
|
||
HTTP 200
|
||
[Asserts]
|
||
jsonpath "$.forbid_owner_role_change" == true
|
||
|
||
|
||
# dp_owner attempts to promote dp_intruder Editor → Owner.
|
||
# Refused by `refuse_if_forbid_owner_role_change` —
|
||
# `drive_membership.rejected reason=forbid_owner_role_change`.
|
||
POST {{base_url}}/api/drives/{{shared_drive_id}}/members
|
||
Authorization: Bearer {{owner_token}}
|
||
Content-Type: application/json
|
||
{
|
||
"subject": { "type": "user", "id": "{{intruder_user_id}}" },
|
||
"role": "owner"
|
||
}
|
||
|
||
HTTP 405
|
||
|
||
|
||
# dp_owner can still mutate non-Owner roles. Re-grant bob as
|
||
# Viewer (UPSERT) under the policy → 201. Proves the carve-out
|
||
# is narrow — only Owner-roster writes are gated.
|
||
POST {{base_url}}/api/drives/{{shared_drive_id}}/members
|
||
Authorization: Bearer {{owner_token}}
|
||
Content-Type: application/json
|
||
{
|
||
"subject": { "type": "user", "id": "{{bob_user_id}}" },
|
||
"role": "viewer"
|
||
}
|
||
|
||
HTTP 201
|
||
|
||
|
||
# Admin override: admin promotes dp_intruder to Owner. Same
|
||
# call shape, just admin's token — must succeed (admin is the
|
||
# tenant operator and the only one who can change the roster).
|
||
POST {{base_url}}/api/admin/drives/{{shared_drive_id}}/members
|
||
Authorization: Bearer {{admin_token}}
|
||
Content-Type: application/json
|
||
{
|
||
"subject": { "type": "user", "id": "{{intruder_user_id}}" },
|
||
"role": "owner"
|
||
}
|
||
|
||
HTTP 201
|
||
|
||
|
||
# Now dp_intruder IS an Owner. dp_owner attempts to demote them
|
||
# back to Editor — refused, even though dp_owner is also an
|
||
# Owner (the policy is roster-wide, not per-owner).
|
||
POST {{base_url}}/api/drives/{{shared_drive_id}}/members
|
||
Authorization: Bearer {{owner_token}}
|
||
Content-Type: application/json
|
||
{
|
||
"subject": { "type": "user", "id": "{{intruder_user_id}}" },
|
||
"role": "editor"
|
||
}
|
||
|
||
HTTP 405
|
||
|
||
|
||
# dp_owner attempts to remove dp_intruder entirely — refused
|
||
# (the subject IS currently Owner, so removal counts as Owner
|
||
# roster mutation).
|
||
DELETE {{base_url}}/api/drives/{{shared_drive_id}}/members/user/{{intruder_user_id}}
|
||
Authorization: Bearer {{owner_token}}
|
||
|
||
HTTP 405
|
||
|
||
|
||
# Admin override: admin removes dp_intruder. Cleans up the
|
||
# Owner roster back to {dp_owner} so the empty-before-delete
|
||
# guard below succeeds.
|
||
DELETE {{base_url}}/api/admin/drives/{{shared_drive_id}}/members/user/{{intruder_user_id}}
|
||
Authorization: Bearer {{admin_token}}
|
||
|
||
HTTP 204
|
||
|
||
|
||
# Disable the policy so the shared-drive cleanup below isn't
|
||
# distorted by lingering owner-lock state.
|
||
PATCH {{base_url}}/api/drives/{{shared_drive_id}}/policies
|
||
Authorization: Bearer {{admin_token}}
|
||
Content-Type: application/json
|
||
{
|
||
"forbid_owner_role_change": false
|
||
}
|
||
|
||
HTTP 200
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# Step 11d — `include_in_photo_index` scope opt-in (§15).
|
||
#
|
||
# Default personal drives are seeded with the flag = true by the
|
||
# `PersonalDriveLifecycleHook` + backfill migration
|
||
# (20260901000000_default_personal_photo_music_flags.sql). Non-
|
||
# default drives (shared, secondary personals) start opted-out
|
||
# and only surface in `/api/photos` after an admin flips the
|
||
# flag on via PATCH.
|
||
#
|
||
# Coverage:
|
||
# a. Upload a PNG into dp_owner's default Personal drive →
|
||
# surfaces in `/api/photos` (default-personal auto-opted in).
|
||
# b. Upload a PNG into the shared drive → does NOT surface
|
||
# (flag omitted).
|
||
# c. Admin flips `include_in_photo_index=true` on the shared
|
||
# drive → the shared-drive PNG surfaces in `/api/photos`.
|
||
#
|
||
# `/api/photos` returns a flat array of PhotoDto — each carries
|
||
# the file's `id`. Assertions use `jsonpath "$[*].id" contains
|
||
# "…"` to sidestep the single-match filter quirks
|
||
# (feedback_hurl_jsonpath_filter_empty).
|
||
# ─────────────────────────────────────────────────────────────
|
||
POST {{base_url}}/api/files/upload
|
||
Authorization: Bearer {{owner_token}}
|
||
[MultipartFormData]
|
||
folder_id: {{personal_root_id}}
|
||
file: file,fixtures/blue-image.png; image/png
|
||
|
||
HTTP 201
|
||
[Captures]
|
||
personal_photo_id: jsonpath "$.id"
|
||
|
||
|
||
# Baseline — personal-drive photo is visible in the timeline.
|
||
GET {{base_url}}/api/photos
|
||
Authorization: Bearer {{owner_token}}
|
||
|
||
HTTP 200
|
||
[Asserts]
|
||
jsonpath "$[*].id" contains "{{personal_photo_id}}"
|
||
|
||
|
||
# Upload a PNG into the SHARED drive's root. dp_owner is Owner
|
||
# on the shared drive from earlier steps, so Create passes.
|
||
POST {{base_url}}/api/files/upload
|
||
Authorization: Bearer {{owner_token}}
|
||
[MultipartFormData]
|
||
folder_id: {{shared_root_id}}
|
||
file: file,fixtures/red-image.png; image/png
|
||
|
||
HTTP 201
|
||
[Captures]
|
||
shared_photo_id: jsonpath "$.id"
|
||
|
||
|
||
# Shared drive is NOT opted-in yet — the shared photo must be
|
||
# absent from `/api/photos`. The personal photo stays visible.
|
||
GET {{base_url}}/api/photos
|
||
Authorization: Bearer {{owner_token}}
|
||
|
||
HTTP 200
|
||
[Asserts]
|
||
jsonpath "$[*].id" not contains "{{shared_photo_id}}"
|
||
jsonpath "$[*].id" contains "{{personal_photo_id}}"
|
||
|
||
|
||
# Flip `include_in_photo_index=true` on the shared drive.
|
||
PATCH {{base_url}}/api/drives/{{shared_drive_id}}/policies
|
||
Authorization: Bearer {{admin_token}}
|
||
Content-Type: application/json
|
||
{
|
||
"include_in_photo_index": true
|
||
}
|
||
|
||
HTTP 200
|
||
[Asserts]
|
||
jsonpath "$.include_in_photo_index" == true
|
||
|
||
|
||
# Shared-drive photo now surfaces in `/api/photos`. Personal
|
||
# photo remains visible — no regression on the always-in-scope
|
||
# default drive.
|
||
GET {{base_url}}/api/photos
|
||
Authorization: Bearer {{owner_token}}
|
||
|
||
HTTP 200
|
||
[Asserts]
|
||
jsonpath "$[*].id" contains "{{shared_photo_id}}"
|
||
jsonpath "$[*].id" contains "{{personal_photo_id}}"
|
||
|
||
|
||
# Cleanup — both photos so the shared-drive delete below finds
|
||
# an empty drive. The personal-drive photo cascade-deletes with
|
||
# dp_owner in Step 12; we still remove it here so the delete
|
||
# path is exercised explicitly (deletes don't affect the flag).
|
||
DELETE {{base_url}}/api/files/{{shared_photo_id}}
|
||
Authorization: Bearer {{owner_token}}
|
||
|
||
HTTP 204
|
||
|
||
DELETE {{base_url}}/api/files/{{personal_photo_id}}
|
||
Authorization: Bearer {{owner_token}}
|
||
|
||
HTTP 204
|
||
|
||
|
||
# Cleanup the shared drive: empty (no content was added) → delete
|
||
# via DELETE /api/drives/{id}. dp_owner is Owner so the call
|
||
# carries Manage; the per-drive empty-before-delete guard passes
|
||
# trivially (the drive holds only its root folder).
|
||
DELETE {{base_url}}/api/drives/{{shared_drive_id}}
|
||
Authorization: Bearer {{owner_token}}
|
||
|
||
HTTP 204
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# Step 12 — Final cleanup. Admin deletes bob, dp_intruder, and
|
||
# dp_owner. Each cascade reaps that user's default
|
||
# personal drive + their grant rows.
|
||
# ─────────────────────────────────────────────────────────────
|
||
DELETE {{base_url}}/api/admin/users/{{bob_user_id}}
|
||
Authorization: Bearer {{admin_token}}
|
||
|
||
HTTP 200
|
||
|
||
DELETE {{base_url}}/api/admin/users/{{intruder_user_id}}
|
||
Authorization: Bearer {{admin_token}}
|
||
|
||
HTTP 200
|
||
|
||
DELETE {{base_url}}/api/admin/users/{{owner_user_id}}
|
||
Authorization: Bearer {{admin_token}}
|
||
|
||
HTTP 200
|