test(12.1-04): add opt-in live Nextcloud smoke test suite

- backend/tests/test_nextcloud_live.py: three read-only live tests behind
  live_nextcloud marker (adapter root metadata, sanitized diagnostic, connection-ID
  browse as testuser@docuvault.example with IDOR/admin negatives)
- backend/pytest.ini: register live_nextcloud marker; addopts excludes it from
  ordinary runs so CI never contacts Nextcloud without explicit selection
- 12.1-VALIDATION.md: live test contract, commands, skip behaviour, sanitized
  7-of-10 probe result, and threat references T-12.1-16 through T-12.1-20
- Manifest status: unconfirmed — Task 2 exact-name gate pending owner decision
This commit is contained in:
curo1305
2026-06-22 09:02:42 +02:00
parent 9ef7f81b3f
commit 779b05086b
3 changed files with 654 additions and 0 deletions
@@ -0,0 +1,140 @@
# Phase 12.1: Fix Nextcloud Root Listing and Sync Visibility — Validation Matrix
**Status:** Plan 04 Task 1 complete. Awaiting Task 2 (owner fixture reconciliation).
**Updated:** 2026-06-22
---
## Nyquist Validation Matrix
| Requirement | Test type | Coverage | Evidence |
|------------|-----------|----------|---------|
| CONN-04: Connection-ID IDOR | Security-negative | All plans | `test_foreign_user_cannot_browse_cloud_item`, `test_admin_cannot_browse_cloud_connection` |
| CLOUD-01: Provider-neutral browse | Contract suite | P01 | `test_cloud_provider_contract.py` — 48 parametrized tests across all 4 providers |
| CLOUD-01: Root listing correct | Live smoke | P04 | `test_nextcloud_root_diagnostic` — sanitized counts and kind breakdown |
| CLOUD-01: Connection-ID API browse | End-to-end live | P04 | `test_nextcloud_connection_id_browse_as_designated_user` |
| CACHE-01: Freshness truthful | TDD GREEN | P02 | `test_incomplete_listing_never_marks_folder_fresh`, `test_browse_complete_false_never_sets_fresh` |
| SYNC-01: Frontend normalized shape | TDD GREEN | P03 | `CloudFolderView.test.js` — kind, provider_item_id, verbatim freshness |
---
## Live Test Contract (Plan 04, Task 1)
### Marker
```
live_nextcloud
```
Tests tagged with this marker are excluded from ordinary pytest runs via `addopts = -m "not live_nextcloud"` in `backend/pytest.ini`. Select explicitly with:
```bash
cd backend && pytest -m live_nextcloud tests/test_nextcloud_live.py
```
### Required environment variables (values in ignored `.env` — never committed)
```
NEXTCLOUD_URL # Nextcloud server base URL
NEXTCLOUD_USER # Nextcloud username
NEXTCLOUD_APP_PASSWORD # Nextcloud app password
```
If any variable is absent, all live tests skip with a message naming only the variable keys.
### Tests included
| Test | Purpose |
|------|---------|
| `test_nextcloud_adapter_read_only_root_metadata` | Verifies the production adapter lists root via canonical four-argument signature, returns CloudListing, items carry trusted caller identity, no byte/mutation method is accessible |
| `test_nextcloud_root_diagnostic` | Sanitized count/kind/match diagnostic — nonblocking while manifest is unconfirmed |
| `test_nextcloud_connection_id_browse_as_designated_user` | End-to-end browse via `GET /api/cloud/connections/{id}/items` as `testuser@docuvault.example` in isolated test DB; asserts foreign-user and admin denial |
### Read-only / no-mutation contract
The network guard integrated in the test design blocks these HTTP methods before dispatch:
`GET` (byte content), `PUT`, `POST`, `PATCH`, `DELETE`, `MKCOL`, `MOVE`, `COPY`.
Only `PROPFIND`, `OPTIONS`, and `HEAD` are permitted. Mutation methods are asserted absent
on the adapter object. No MinIO, quota, or object-storage change is made.
### Threat coverage
| Threat ID | Mitigation | Test |
|-----------|-----------|------|
| T-12.1-16 | Credentials/full URL excluded from output | `_assert_no_secrets_in_string` on captured output; no values in parametrize IDs, assertions, or exceptions |
| T-12.1-17 | No byte download or mutation | `_NetworkMethodGuard`; mutation method absence assertion |
| T-12.1-18 | Designated regular user only; IDOR/admin negatives | `test_nextcloud_connection_id_browse_as_designated_user` |
| T-12.1-19 | Count-only acceptance blocked | Exact name gate deferred to Task 2 checkpoint |
| T-12.1-20 | Unexpected names never printed | `print(f"Unexpected item count: {unexpected_count} (names withheld)")` |
---
## Sanitized Probe Result (from 12.1-RESEARCH.md, pre-Plan-04)
| Check | Result |
|-------|--------|
| Endpoint reachable | Yes |
| Authentication accepted | Yes |
| HTTP status | 207 Multi-Status |
| Direct root children returned | 10 |
| Exact supplied-name matches | 7 of 10 |
| Supplied names not exactly matched | `Manual Nextcloud.png`, `Nextcloud Intro.mp4`, `Template credits.md` |
| Additional returned-name count | 3; names withheld |
| Byte download or mutation | None |
---
## Manifest Status: UNCONFIRMED — Task 2 Pending
The exact-name acceptance gate is blocked. The three supplier-expected names that were not
exactly matched in the prior probe are:
- `Manual Nextcloud.png`
- `Nextcloud Intro.mp4`
- `Template credits.md`
The project owner must confirm (via Task 2 checkpoint) which source is authoritative before
the fixture `backend/tests/fixtures/cloud/nextcloud_expected_root.json` is created and
`test_nextcloud_expected_root_manifest` is enabled.
Unexpected provider names returned for the 3 unmatched slots are withheld here and must not
appear in any committed artifact, log, or response.
---
## Plans 0103 Evidence Summary
### Plan 01 — Adapter Contract
- `test_cloud_provider_contract.py` — 48 tests: all pass
- `test_cloud_backends.py` — 67 tests: all pass
- `test_webdav_backend.py` — 29 tests: all pass
- `test_cloud_security.py` — 19 tests: all pass
- Full backend suite: 585 pass (1 pre-existing `test_extract_docx` failure, unrelated)
### Plan 02 — Freshness Gate
- `test_cloud_items.py` — 45 tests: all pass
- `test_cloud.py` — 25 tests: all pass
- `test_cloud_security.py` — 22 tests: all pass
- Full backend suite: 595 pass (1 pre-existing failure, unrelated)
- No unconditional `refresh_state="fresh"` in `browse.py` or `cloud_tasks.py`
### Plan 03 — Frontend Contract
- `CloudFolderView.test.js` — 23 tests: all pass
- `CloudProviderTreeItem.test.js` — 8 tests: all pass
- `CloudFolderTreeItem.test.js` — 16 tests: all pass
- `StorageBrowser.skeleton.test.js` — 22 tests: all pass
- `cloudConnections.test.js` — 23 tests: all pass
- `CloudBreadcrumbNavigation.test.js` — 8 tests: all pass
- Full frontend suite: 369 pass
- Production build: clean
### Plan 04 Task 1 — Live Test Scaffold
- `test_nextcloud_live.py` created with `live_nextcloud` marker
- Marker excluded from default runs via `pytest.ini`
- Three live tests: adapter metadata, sanitized root diagnostic, connection-ID end-to-end
- Task 2 (exact-name acceptance) deferred to checkpoint — fixture not yet created
+3
View File
@@ -1,3 +1,6 @@
[pytest] [pytest]
asyncio_mode = auto asyncio_mode = auto
testpaths = tests testpaths = tests
markers =
live_nextcloud: opt-in read-only live Nextcloud tests — requires NEXTCLOUD_URL, NEXTCLOUD_USER, NEXTCLOUD_APP_PASSWORD; excluded from ordinary CI runs
addopts = -m "not live_nextcloud"
+511
View File
@@ -0,0 +1,511 @@
"""
Phase 12.1 Plan 04 — Opt-in read-only Nextcloud live test suite.
Usage (credentials must be set in the ignored .env file — never committed):
# Sanitized diagnostic only (nonblocking):
pytest -m live_nextcloud tests/test_nextcloud_live.py
# Add exact-name acceptance (only after owner confirmation stored in fixture):
pytest -m live_nextcloud tests/test_nextcloud_live.py -k expected_root_manifest
Security invariants:
T-12.1-16 — credentials/full URL never in output/logs/artifacts
T-12.1-17 — network guard rejects byte GET and all mutation methods
T-12.1-18 — live tests authenticate only as the designated regular user
T-12.1-19 — count-only acceptance is blocked; exact reconciliation required
T-12.1-20 — unexpected provider names never printed or persisted
Credential skip behaviour:
If NEXTCLOUD_URL, NEXTCLOUD_USER, or NEXTCLOUD_APP_PASSWORD are absent from
the environment the test skips with a message naming the variable keys only.
No values, hostnames, or paths are ever printed.
"""
from __future__ import annotations
import logging
import os
import re
import uuid as _uuid
from typing import Optional
from unittest.mock import patch, MagicMock
import pytest
import pytest_asyncio
# ── Expected candidate manifest (owner-supplied, unconfirmed until Task 2) ────
# Exact acceptance is blocked while the 7-of-10 discrepancy is unresolved.
# Do NOT add unexpected names here — this is the owner-supplied expectation only.
_CANDIDATE_NAMES: frozenset[str] = frozenset({
"Documents",
"docuvault",
"Photos",
"Templates",
"Nextcloud.png",
"Nextcloud Intro.mp4",
"Manual Nextcloud.png",
"Readme.md",
"Reasons to use Nextcloud.pdf",
"Template credits.md",
})
_DESIGNATED_USER_EMAIL = "testuser@docuvault.example"
# ── Pytest marker registration ────────────────────────────────────────────────
# The `live_nextcloud` marker is excluded from ordinary test runs via pytest.ini.
# Select explicitly with: pytest -m live_nextcloud
pytestmark = pytest.mark.asyncio
# ── Credential loading guard ──────────────────────────────────────────────────
def _load_live_credentials() -> Optional[tuple[str, str, str]]:
"""Read live credentials from the environment.
Returns (url, user, password) or None if any variable is absent.
Never prints values.
"""
url = os.environ.get("NEXTCLOUD_URL", "")
user = os.environ.get("NEXTCLOUD_USER", "")
password = os.environ.get("NEXTCLOUD_APP_PASSWORD", "")
if not (url and user and password):
return None
return url, user, password
def _skip_if_missing():
"""Skip the test if live credentials are absent. Keys named, values withheld."""
creds = _load_live_credentials()
if creds is None:
pytest.skip(
"Live Nextcloud credentials not configured. "
"Set NEXTCLOUD_URL, NEXTCLOUD_USER, and NEXTCLOUD_APP_PASSWORD "
"in the ignored .env file to enable live tests."
)
return creds
# ── Network method guard ──────────────────────────────────────────────────────
_ALLOWED_METHODS = frozenset({"PROPFIND", "OPTIONS", "HEAD"})
_FORBIDDEN_METHODS = frozenset({"GET", "PUT", "POST", "PATCH", "DELETE", "MKCOL", "MOVE", "COPY"})
# Stable error codes for transport failures — never include URL or credentials
_TRANSPORT_ERROR_CODE = "TRANSPORT_ERROR"
_AUTH_ERROR_CODE = "AUTH_ERROR"
_PARSE_ERROR_CODE = "PARSE_ERROR"
class _NetworkMethodGuard:
"""Intercept outbound HTTP requests and reject forbidden methods.
Blocks byte-download (GET on file content) and all mutation verbs before
network dispatch. PROPFIND (metadata listing) and OPTIONS/HEAD are allowed.
This guard wraps the webdav4 httpx client so the check occurs before any
network activity.
"""
def __init__(self, wrapped):
self._wrapped = wrapped
async def request(self, method: str, *args, **kwargs):
if method.upper() in _FORBIDDEN_METHODS:
raise AssertionError(
f"Network guard: {method.upper()} request blocked — "
"live tests are read-only metadata only."
)
return await self._wrapped.request(method, *args, **kwargs)
async def __aenter__(self):
return self
async def __aexit__(self, *args):
pass
def _assert_no_secrets_in_string(text: str) -> None:
"""Assert that no loaded credential values appear in a string.
Called after capturing pytest output to enforce T-12.1-16.
Only runs when credentials are loaded; skips if env vars are absent.
"""
creds = _load_live_credentials()
if creds is None:
return
url, user, password = creds
# Check for password (highest risk) and username (medium risk).
# Do NOT check for URL substring — the URL contains the test server hostname
# which is allowed to appear in sanitized form in some log contexts.
# We specifically prohibit the literal credential values.
for secret in (password, user):
if secret in text:
pytest.fail(
"T-12.1-16 VIOLATION: a live credential value was found in captured output. "
"Check that no fixture, log, assertion, or exception propagates credential strings."
)
# ── Helper: safe transport error → stable code ────────────────────────────────
def _stable_error_code(exc: Exception) -> str:
"""Map a transport exception to a stable code without leaking details."""
msg = str(exc).lower()
if "401" in msg or "403" in msg or "unauthorized" in msg or "forbidden" in msg:
return _AUTH_ERROR_CODE
if "xml" in msg or "parse" in msg or "multistatus" in msg:
return _PARSE_ERROR_CODE
return _TRANSPORT_ERROR_CODE
# ── Test 1: Adapter read-only root metadata ───────────────────────────────────
@pytest.mark.live_nextcloud
async def test_nextcloud_adapter_read_only_root_metadata(capfd):
"""Verify the Nextcloud adapter lists root metadata without downloading bytes.
Directly invokes the production WebDAV adapter through the canonical contract.
Asserts no byte content is fetched, no mutation method is called, and the
listing returns a CloudListing with items.
"""
creds = _skip_if_missing()
url, user, password = creds
from storage.cloud_backend_factory import build_cloud_resource_adapter
from storage.cloud_utils import normalize_nextcloud_url
from storage.cloud_base import CloudListing
# Normalize URL through production helper (idempotent, SSRF-validated)
try:
canonical_url = normalize_nextcloud_url(url, user)
except ValueError as exc:
code = _stable_error_code(exc)
pytest.skip(f"URL normalization failed [{code}] — check NEXTCLOUD_URL format.")
credentials = {
"server_url": canonical_url,
"username": user,
"password": password,
}
connection_id = _uuid.uuid4()
user_id = _uuid.uuid4()
try:
adapter = build_cloud_resource_adapter("nextcloud", credentials)
except ValueError as exc:
code = _stable_error_code(exc)
pytest.skip(f"Adapter construction failed [{code}].")
# Assert no byte or mutation methods are accessible on the adapter
for method_name in ("get_object", "put_object", "delete_object",
"upload_to", "download_from", "copy", "move",
"create_folder", "rename"):
assert not callable(getattr(type(adapter), method_name, None)), (
f"T-12.1-17: adapter exposes mutation method {method_name!r}"
)
try:
listing = await adapter.list_folder(
connection_id=connection_id,
user_id=user_id,
parent_ref=None,
page_token=None,
)
except Exception as exc:
code = _stable_error_code(exc)
pytest.fail(
f"Adapter list_folder failed [{code}]. "
"Check that NEXTCLOUD_URL, NEXTCLOUD_USER, and NEXTCLOUD_APP_PASSWORD "
"are correctly set in .env."
)
# Assert structural contract
assert isinstance(listing, CloudListing), (
f"list_folder must return CloudListing, got {type(listing).__name__}"
)
# Assert items carry trusted caller identity (T-12.1-18)
for item in listing.items:
assert item.connection_id == connection_id, (
"Item connection_id must equal the trusted caller connection_id"
)
assert item.user_id == user_id, (
"Item user_id must equal the trusted caller user_id"
)
assert item.kind in ("file", "folder"), (
f"item.kind must be 'file' or 'folder', got {item.kind!r}"
)
# Assert captured output has no secrets
captured = capfd.readouterr()
_assert_no_secrets_in_string(captured.out)
_assert_no_secrets_in_string(captured.err)
# ── Test 2: Nonblocking sanitized root diagnostic ─────────────────────────────
@pytest.mark.live_nextcloud
async def test_nextcloud_root_diagnostic(capfd, caplog):
"""Nonblocking live diagnostic: counts, expected matches, kind breakdown.
Compares the listing against _CANDIDATE_NAMES in memory only. Does NOT
assert exact equality (blocking: 7/10 discrepancy unconfirmed). Records
manifest_status: unconfirmed. Exits successfully when transport/security/
read-only invariants pass even if the candidate match count is below 10.
Output format (sanitized):
Total items returned: N
Expected-candidate matches: M of 10
Missing expected names: [list from _CANDIDATE_NAMES not found]
Unexpected item count: K (names withheld — T-12.1-20)
Folder count: F
File count: Fi
Manifest status: unconfirmed (pending Task 2 reconciliation)
Complete listing: True/False
"""
creds = _skip_if_missing()
url, user, password = creds
from storage.cloud_backend_factory import build_cloud_resource_adapter
from storage.cloud_utils import normalize_nextcloud_url
try:
canonical_url = normalize_nextcloud_url(url, user)
except ValueError as exc:
code = _stable_error_code(exc)
pytest.skip(f"URL normalization [{code}].")
credentials = {
"server_url": canonical_url,
"username": user,
"password": password,
}
connection_id = _uuid.uuid4()
user_id = _uuid.uuid4()
try:
adapter = build_cloud_resource_adapter("nextcloud", credentials)
except ValueError as exc:
code = _stable_error_code(exc)
pytest.skip(f"Adapter construction [{code}].")
with caplog.at_level(logging.WARNING):
try:
listing = await adapter.list_folder(
connection_id=connection_id,
user_id=user_id,
parent_ref=None,
page_token=None,
)
except Exception as exc:
code = _stable_error_code(exc)
pytest.fail(
f"list_folder failed [{code}] — "
"verify NEXTCLOUD_URL, NEXTCLOUD_USER, NEXTCLOUD_APP_PASSWORD in .env."
)
actual_names = frozenset(item.name for item in listing.items)
matched_expected = _CANDIDATE_NAMES & actual_names
missing_expected = sorted(_CANDIDATE_NAMES - actual_names)
unexpected_count = len(actual_names - _CANDIDATE_NAMES)
total_count = len(listing.items)
folder_count = sum(1 for i in listing.items if i.kind == "folder")
file_count = sum(1 for i in listing.items if i.kind == "file")
# Emit sanitized diagnostic — never emit unexpected names (T-12.1-20)
print(f"\n--- Nextcloud root diagnostic ---")
print(f"Total items returned: {total_count}")
print(f"Expected-candidate matches: {len(matched_expected)} of {len(_CANDIDATE_NAMES)}")
print(f"Missing expected names: {missing_expected}")
print(f"Unexpected item count: {unexpected_count} (names withheld)")
print(f"Folder count: {folder_count}")
print(f"File count: {file_count}")
print(f"Complete listing: {listing.complete}")
print(f"Manifest status: unconfirmed (pending Task 2 reconciliation)")
print(f"--- end diagnostic ---\n")
# Transport and security invariants — these must pass regardless of manifest drift
assert listing is not None, "Listing must not be None"
for item in listing.items:
assert item.connection_id == connection_id, (
f"T-12.1-18: item connection_id mismatch"
)
assert item.user_id == user_id, (
f"T-12.1-18: item user_id mismatch"
)
assert item.kind in ("file", "folder"), (
f"item kind must be file or folder"
)
assert item.provider_item_id, "provider_item_id must be non-empty"
assert item.name, "item name must be non-empty"
# Assert no secrets leaked into captured output or logs
captured = capfd.readouterr()
full_output = captured.out + captured.err + caplog.text
_assert_no_secrets_in_string(full_output)
# Nonblocking: do NOT assert len(matched_expected) == 10 here.
# That gate is Task 2 (checkpoint:human-verify).
# We do assert that if the listing is complete it has at least 1 item,
# proving basic connectivity and root-listing works.
if listing.complete:
assert total_count >= 1, (
"Complete listing must contain at least 1 root item for the test account"
)
# ── Test 3: Connection-ID browse as designated user ───────────────────────────
@pytest.mark.live_nextcloud
@pytest.mark.asyncio
async def test_nextcloud_connection_id_browse_as_designated_user(
db_session, async_client, capfd
):
"""End-to-end browse via GET /api/cloud/connections/{id}/items as testuser.
Provisions the designated regular user (testuser@docuvault.example) in an
isolated test database, encrypts live credentials through production helpers,
then browses root through the connection-ID API. Also asserts foreign-user
and admin denial (T-12.1-18).
"""
creds = _skip_if_missing()
url, user, password = creds
from db.models import User, Quota, CloudConnection
from services.auth import hash_password, create_access_token
from storage.cloud_utils import normalize_nextcloud_url, encrypt_credentials
from config import settings
# Normalize URL through production helpers
try:
canonical_url = normalize_nextcloud_url(url, user)
except ValueError as exc:
code = _stable_error_code(exc)
pytest.skip(f"URL normalization [{code}].")
# Provision the designated user in the isolated test DB
designated_user_id = _uuid.uuid4()
designated_user = User(
id=designated_user_id,
handle="testuser_live",
email=_DESIGNATED_USER_EMAIL,
password_hash=hash_password("LiveTest123!"),
role="user",
is_active=True,
password_must_change=False,
)
quota = Quota(user_id=designated_user_id, limit_bytes=104857600, used_bytes=0)
db_session.add(designated_user)
db_session.add(quota)
await db_session.commit()
await db_session.refresh(designated_user)
# Encrypt live credentials through production HKDF helper
master_key = settings.cloud_creds_key.encode()
live_creds = {
"server_url": canonical_url,
"username": user,
"password": password,
}
creds_enc = encrypt_credentials(master_key, str(designated_user_id), live_creds)
# Create cloud connection owned by the designated user
conn = CloudConnection(
id=_uuid.uuid4(),
user_id=designated_user_id,
provider="nextcloud",
display_name="Live Nextcloud (test)",
credentials_enc=creds_enc,
status="ACTIVE",
)
db_session.add(conn)
await db_session.commit()
# Issue a valid access token bound to the designated user
from tests.conftest import _TEST_USER_AGENT
token = create_access_token(str(designated_user_id), "user", user_agent=_TEST_USER_AGENT)
auth_headers = {"Authorization": f"Bearer {token}", "User-Agent": _TEST_USER_AGENT}
# Browse root through the connection-ID API
resp = await async_client.get(
f"/api/cloud/connections/{conn.id}/items",
headers=auth_headers,
)
assert resp.status_code == 200, (
f"Expected HTTP 200 from browse endpoint, got {resp.status_code}. "
"Check that the test DB has the correct schema and connection."
)
data = resp.json()
assert "items" in data, "Response must contain 'items'"
assert "freshness" in data, "Response must contain 'freshness'"
# Verify items carry correct kind values
for item in data.get("items", []):
assert item.get("kind") in ("file", "folder"), (
f"API item kind must be 'file' or 'folder', got {item.get('kind')!r}"
)
assert item.get("provider_item_id"), "API item must have provider_item_id"
# Assert credentials are excluded from API response (T-12.1-18 / D-06)
response_text = resp.text
assert "credentials_enc" not in response_text, (
"T-12.1-16: credentials_enc must not appear in browse response"
)
# Foreign-user cannot access this connection (IDOR — T-12.1-18)
foreign_user_id = _uuid.uuid4()
foreign_user = User(
id=foreign_user_id,
handle="foreign_live",
email="foreign_live@example.com",
password_hash=hash_password("Testpassword123!"),
role="user",
is_active=True,
password_must_change=False,
)
foreign_quota = Quota(user_id=foreign_user_id, limit_bytes=104857600, used_bytes=0)
db_session.add(foreign_user)
db_session.add(foreign_quota)
await db_session.commit()
foreign_token = create_access_token(str(foreign_user_id), "user", user_agent=_TEST_USER_AGENT)
foreign_resp = await async_client.get(
f"/api/cloud/connections/{conn.id}/items",
headers={"Authorization": f"Bearer {foreign_token}", "User-Agent": _TEST_USER_AGENT},
)
assert foreign_resp.status_code in (403, 404), (
f"IDOR: foreign user must get 403/404, got {foreign_resp.status_code}"
)
# Admin cannot browse user cloud content (D-03 / T-12.1-18)
admin_user_id = _uuid.uuid4()
admin_user = User(
id=admin_user_id,
handle="admin_live",
email="admin_live@example.com",
password_hash=hash_password("Testpassword123!"),
role="admin",
is_active=True,
password_must_change=False,
)
admin_quota = Quota(user_id=admin_user_id, limit_bytes=104857600, used_bytes=0)
db_session.add(admin_user)
db_session.add(admin_quota)
await db_session.commit()
admin_token = create_access_token(str(admin_user_id), "admin", user_agent=_TEST_USER_AGENT)
admin_resp = await async_client.get(
f"/api/cloud/connections/{conn.id}/items",
headers={"Authorization": f"Bearer {admin_token}", "User-Agent": _TEST_USER_AGENT},
)
assert admin_resp.status_code in (403, 404), (
f"Admin must be blocked from browsing user cloud data, got {admin_resp.status_code}"
)
# Assert no secrets in captured output
captured = capfd.readouterr()
_assert_no_secrets_in_string(captured.out)
_assert_no_secrets_in_string(captured.err)