feat: support audio & vidéo — lecteurs HTML5 intégrés #109
This commit is contained in:
@@ -106,6 +106,16 @@ def test_vault_dir(tmp_path: Path) -> str:
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Audio/video media (roadmap #109) — indexed as metadata-only binaries,
|
||||
# streamed via /api/media. Prefer the committed E2E fixture when present.
|
||||
repo_media = Path(__file__).resolve().parent.parent / "test_vault" / "sample-audio.mp3"
|
||||
if repo_media.exists():
|
||||
(vault / "sample-audio.mp3").write_bytes(repo_media.read_bytes())
|
||||
else:
|
||||
(vault / "sample-audio.mp3").write_bytes(
|
||||
b"ID3\x03\x00\x00\x00\x00\x00\x00" + bytes(range(256)) * 16
|
||||
)
|
||||
|
||||
# File with accents in title
|
||||
(vault / "café_crème.md").write_text(
|
||||
"---\ntitle: Café Crème\n---\n# Café Crème\nUn bon café.\n",
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* E2E tests for the ObsiGate audio/video viewers (roadmap #109).
|
||||
*
|
||||
* Fixtures : `test_vault/sample-audio.mp3` (1 s sine, ~5 ko) +
|
||||
* `test_vault/sample-video.webm` (64x64 VP8, ~1 ko).
|
||||
*
|
||||
* Run (local):
|
||||
* BASE_URL=http://localhost:2029 npx playwright test tests/e2e/media-viewer.spec.js
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://localhost:2029';
|
||||
|
||||
const CREDS = {
|
||||
username: process.env.OBSIGATE_USER || 'admin',
|
||||
password: process.env.OBSIGATE_PASS || 'test123',
|
||||
};
|
||||
|
||||
async function login(page) {
|
||||
await page.goto(BASE);
|
||||
const loginForm = page.locator('#login-screen');
|
||||
await expect(loginForm).toBeVisible({ timeout: 5000 }).catch(() => {});
|
||||
if (await loginForm.isVisible()) {
|
||||
await page.fill('#login-username', CREDS.username);
|
||||
await page.fill('#login-password', CREDS.password);
|
||||
await page.click('#login-btn');
|
||||
}
|
||||
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
|
||||
}
|
||||
|
||||
async function openFile(page, vault, filePath) {
|
||||
const treeItem = page.locator(`.tree-item[data-vault="${vault}"][data-path="${filePath}"]`);
|
||||
if (!(await treeItem.count())) {
|
||||
await page.locator(`.tree-item.vault-item[data-vault="${vault}"]`).first().click();
|
||||
await treeItem.waitFor({ state: 'attached', timeout: 8000 });
|
||||
}
|
||||
await treeItem.dblclick({ timeout: 5000 });
|
||||
}
|
||||
|
||||
test.describe('Media viewers — HTML5 audio/video (#109)', () => {
|
||||
|
||||
test('rend un lecteur audio natif branché sur /api/media', async ({ page }) => {
|
||||
await login(page);
|
||||
await openFile(page, 'TestVault', 'sample-audio.mp3');
|
||||
|
||||
const audio = page.locator('#content-area .audio-viewer-container audio.audio-player');
|
||||
await expect(audio).toBeVisible({ timeout: 10000 });
|
||||
await expect(audio).toHaveAttribute('src', /\/api\/media\/TestVault\?path=/);
|
||||
await expect(audio).toHaveAttribute('controls', '');
|
||||
// La durée est lue côté client via loadedmetadata.
|
||||
await expect(page.locator('#content-area .media-duration-badge')).not.toHaveText('--:--', { timeout: 10000 });
|
||||
});
|
||||
|
||||
test('rend un lecteur vidéo natif branché sur /api/media', async ({ page }) => {
|
||||
await login(page);
|
||||
await openFile(page, 'TestVault', 'sample-video.webm');
|
||||
|
||||
const video = page.locator('#content-area .video-viewer-container video.video-player');
|
||||
await expect(video).toBeVisible({ timeout: 10000 });
|
||||
await expect(video).toHaveAttribute('src', /\/api\/media\/TestVault\?path=/);
|
||||
await expect(video).toHaveAttribute('playsinline', '');
|
||||
});
|
||||
|
||||
test('le streaming média honore les requêtes Range (206)', async ({ page }) => {
|
||||
await login(page);
|
||||
const resp = await page.request.get(
|
||||
`${BASE}/api/media/TestVault?path=${encodeURIComponent('sample-video.webm')}`,
|
||||
{ headers: { Range: 'bytes=0-99' } },
|
||||
);
|
||||
expect(resp.status()).toBe(206);
|
||||
expect(resp.headers()['content-range']).toMatch(/^bytes 0-99\/\d+$/);
|
||||
expect(resp.headers()['accept-ranges']).toBe('bytes');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate — Audio/video viewer tests (roadmap #109).
|
||||
*
|
||||
* Pure helpers (formatMediaDuration / buildMediaUrl) are evaluated in a VM
|
||||
* sandbox to avoid importing the full browser-only viewer chain. The rest are
|
||||
* static checks on the source, CSS and backend wiring.
|
||||
*
|
||||
* Usage: node tests/frontend/media-viewer.test.mjs
|
||||
*/
|
||||
|
||||
import { strict as assert } from "node:assert";
|
||||
import { readFileSync } from "node:fs";
|
||||
import vm from "node:vm";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
|
||||
const viewer = readFileSync(path.join(ROOT, "frontend", "js", "viewer.js"), "utf8");
|
||||
const utils = readFileSync(path.join(ROOT, "frontend", "js", "utils.js"), "utf8");
|
||||
const css = readFileSync(path.join(ROOT, "frontend", "style.css"), "utf8");
|
||||
const sw = readFileSync(path.join(ROOT, "frontend", "sw.js"), "utf8");
|
||||
const main = readFileSync(path.join(ROOT, "backend", "main.py"), "utf8");
|
||||
const fr = readFileSync(path.join(ROOT, "frontend", "locales", "fr.json"), "utf8");
|
||||
const en = readFileSync(path.join(ROOT, "frontend", "locales", "en.json"), "utf8");
|
||||
|
||||
function test(label, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(" \u2713 " + label);
|
||||
} catch (err) {
|
||||
console.error(" \u2717 " + label + "\n " + String(err.message).slice(0, 300));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pure helpers (VM sandbox) ──────────────────────────────────────────────
|
||||
const begin = viewer.indexOf("export function formatMediaDuration");
|
||||
const end = viewer.indexOf("function buildDownloadUrl");
|
||||
assert.ok(begin !== -1 && end > begin, "media viewer helper block not found");
|
||||
const sandbox = {};
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(
|
||||
`${viewer.slice(begin, end).replace(/\bexport /g, "")}\nglobalThis.formatMediaDuration = formatMediaDuration; globalThis.buildMediaUrl = buildMediaUrl;`,
|
||||
sandbox,
|
||||
);
|
||||
const { formatMediaDuration, buildMediaUrl } = sandbox;
|
||||
|
||||
test("formatMediaDuration renders m:ss and h:mm:ss", () => {
|
||||
assert.equal(formatMediaDuration(0), "0:00");
|
||||
assert.equal(formatMediaDuration(65), "1:05");
|
||||
assert.equal(formatMediaDuration(3725), "1:02:05");
|
||||
assert.equal(formatMediaDuration(NaN), "");
|
||||
assert.equal(formatMediaDuration(-1), "");
|
||||
});
|
||||
|
||||
test("buildMediaUrl encodes vault and path", () => {
|
||||
const url = buildMediaUrl("My Vault", "café clip.mp4");
|
||||
assert.equal(url, "/api/media/My%20Vault?path=caf%C3%A9%20clip.mp4");
|
||||
});
|
||||
|
||||
// ── Static checks: viewer wiring ───────────────────────────────────────────
|
||||
test("viewer.js dispatches is_audio / is_video to the media viewers", () => {
|
||||
assert.match(viewer, /if \(data\.is_audio\) \{\s*renderAudioViewer\(area, data\);/);
|
||||
assert.match(viewer, /if \(data\.is_video\) \{\s*renderVideoViewer\(area, data\);/);
|
||||
});
|
||||
|
||||
test("audio viewer uses a native <audio> element and stream URL", () => {
|
||||
const start = viewer.indexOf("export function renderAudioViewer");
|
||||
const stop = viewer.indexOf("export function renderVideoViewer");
|
||||
assert.ok(start !== -1 && stop > start, "renderAudioViewer block not found");
|
||||
const block = viewer.slice(start, stop);
|
||||
assert.match(block, /el\("audio"/);
|
||||
assert.match(block, /controls: ""/);
|
||||
assert.match(block, /data\.stream_url \|\| buildMediaUrl/);
|
||||
});
|
||||
|
||||
test("video viewer uses a native <video> element with playsinline", () => {
|
||||
const start = viewer.indexOf("export function renderVideoViewer");
|
||||
const stop = viewer.indexOf("export function renderFile");
|
||||
assert.ok(start !== -1 && stop > start, "renderVideoViewer block not found");
|
||||
const block = viewer.slice(start, stop);
|
||||
assert.match(block, /el\("video"/);
|
||||
assert.match(block, /playsinline: ""/);
|
||||
});
|
||||
|
||||
test("unsupported codec triggers the binary fallback UI", () => {
|
||||
assert.match(viewer, /audio\.addEventListener\("error",\s*\(\)\s*=>\s*\{\s*renderMediaFallback/);
|
||||
assert.match(viewer, /video\.addEventListener\("error",\s*\(\)\s*=>\s*\{\s*renderMediaFallback/);
|
||||
assert.match(viewer, /export function renderMediaFallback/);
|
||||
});
|
||||
|
||||
test("playback is stopped when the content area is re-rendered", () => {
|
||||
assert.match(viewer, /_mediaViewerCleanup/);
|
||||
assert.match(viewer, /_mediaViewerCleanup\(\); _mediaViewerCleanup = null;/);
|
||||
});
|
||||
|
||||
// ── Static checks: CSS ─────────────────────────────────────────────────────
|
||||
test("style.css defines the audio/video viewer layout", () => {
|
||||
assert.match(css, /\.audio-viewer-container/);
|
||||
assert.match(css, /\.audio-stage\s*\{/);
|
||||
assert.match(css, /\.audio-artwork\s*\{/);
|
||||
assert.match(css, /\.video-stage\s*\{/);
|
||||
assert.match(css, /\.video-player\s*\{/);
|
||||
});
|
||||
|
||||
// ── Static checks: icons, i18n, service worker ─────────────────────────────
|
||||
test("utils.js maps media extensions to audio-lines / video icons", () => {
|
||||
assert.match(utils, /"\.mp3": "audio-lines"/);
|
||||
assert.match(utils, /"\.flac": "audio-lines"/);
|
||||
assert.match(utils, /"\.mp4": "video"/);
|
||||
assert.match(utils, /"\.webm": "video"/);
|
||||
});
|
||||
|
||||
test("media viewer strings exist in FR and EN", () => {
|
||||
for (const key of ["viewer.media_unsupported", "viewer.media_too_large"]) {
|
||||
assert.match(fr, new RegExp(`"${key}"`));
|
||||
assert.match(en, new RegExp(`"${key}"`));
|
||||
}
|
||||
});
|
||||
|
||||
test("service worker never caches streamed media", () => {
|
||||
assert.match(sw, /startsWith\('\/api\/media\/'\)/);
|
||||
assert.match(sw, /endsWith\('\/thumb'\)/);
|
||||
});
|
||||
|
||||
// ── Static checks: backend ─────────────────────────────────────────────────
|
||||
test("backend exposes /api/media and the shared Range helper", () => {
|
||||
assert.match(main, /@app\.get\("\/api\/media\/\{vault_name\}"/);
|
||||
assert.match(main, /def _stream_file_with_range\(/);
|
||||
assert.match(main, /AUDIO_EXTENSIONS|is_audio/);
|
||||
assert.match(main, /is_audio\(ext\) or is_video\(ext\)/);
|
||||
});
|
||||
|
||||
if (process.exitCode) {
|
||||
console.error("\nMedia viewer tests FAILED");
|
||||
} else {
|
||||
console.log("\nAll media viewer tests passed.");
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
# tests/test_media_indexing.py — Audio/video in the tree & index (roadmap #109-A/F2)
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from backend.indexer import _index_single_file_sync, _scan_vault
|
||||
from backend.services.vaults import browse_directory
|
||||
from backend.watcher import VaultEventHandler
|
||||
|
||||
MEDIA_BYTES = b"ID3\x03\x00\x00\x00\x00\x00\x00" + bytes(range(256)) * 16
|
||||
|
||||
|
||||
def _make_vault(tmp_path: Path) -> Path:
|
||||
vault = tmp_path / "Vault"
|
||||
vault.mkdir()
|
||||
(vault / "note.md").write_text("# Note\n", encoding="utf-8")
|
||||
(vault / "note-vocale.mp3").write_bytes(MEDIA_BYTES)
|
||||
(vault / "clip.mp4").write_bytes(MEDIA_BYTES)
|
||||
assets = vault / "assets"
|
||||
assets.mkdir()
|
||||
(assets / "ambiance.flac").write_bytes(MEDIA_BYTES)
|
||||
return vault
|
||||
|
||||
|
||||
class TestScanVaultMedia:
|
||||
def test_audio_video_indexed_without_content(self, tmp_path):
|
||||
vault = _make_vault(tmp_path)
|
||||
result = _scan_vault("V", str(vault))
|
||||
by_path = {f["path"]: f for f in result["files"]}
|
||||
for path in ("note-vocale.mp3", "clip.mp4", "assets/ambiance.flac"):
|
||||
assert path in by_path, path
|
||||
entry = by_path[path]
|
||||
assert entry["content"] == "" # never read the bytes (#109-A1)
|
||||
assert entry["content_preview"] == ""
|
||||
assert entry["size"] > 0
|
||||
|
||||
def test_media_present_in_paths(self, tmp_path):
|
||||
vault = _make_vault(tmp_path)
|
||||
result = _scan_vault("V", str(vault))
|
||||
files = {p["path"] for p in result["paths"] if p["type"] == "file"}
|
||||
assert "note-vocale.mp3" in files
|
||||
assert "clip.mp4" in files
|
||||
assert "assets/ambiance.flac" in files
|
||||
|
||||
def test_no_binary_content_leaks(self, tmp_path):
|
||||
vault = _make_vault(tmp_path)
|
||||
result = _scan_vault("V", str(vault))
|
||||
for f in result["files"]:
|
||||
if f["extension"] in (".mp3", ".mp4", ".flac"):
|
||||
assert f["content"] == ""
|
||||
|
||||
|
||||
class TestSingleFileMedia:
|
||||
def test_metadata_only(self, tmp_path):
|
||||
vault = _make_vault(tmp_path)
|
||||
info = _index_single_file_sync("V", str(vault), str(vault / "note-vocale.mp3"))
|
||||
assert info is not None
|
||||
assert info["content"] == ""
|
||||
assert info["extension"] == ".mp3"
|
||||
assert info["path"] == "note-vocale.mp3"
|
||||
|
||||
|
||||
class TestWatcherRelevance:
|
||||
def test_media_extensions_relevant(self, tmp_path):
|
||||
handler = VaultEventHandler("V", asyncio.Queue(), asyncio.new_event_loop())
|
||||
for name in ("a.mp3", "a.flac", "a.opus", "a.mp4", "a.webm", "a.mov"):
|
||||
assert handler._is_relevant(str(tmp_path / name)) is True, name
|
||||
assert handler._is_relevant(str(tmp_path / "a.mkv")) is False
|
||||
|
||||
|
||||
class TestBrowseAndSearch:
|
||||
def test_browse_lists_media(self, client):
|
||||
resp = client.get("/api/browse/TestVault")
|
||||
assert resp.status_code == 200
|
||||
items = resp.json()["items"]
|
||||
names = {i["name"]: i for i in items}
|
||||
assert "sample-audio.mp3" in names
|
||||
assert names["sample-audio.mp3"]["type"] == "file"
|
||||
assert names["sample-audio.mp3"]["extension"] == ".mp3"
|
||||
|
||||
def test_ext_filter_returns_audio(self, client):
|
||||
resp = client.get("/api/search/advanced", params={"q": "ext:mp3"})
|
||||
assert resp.status_code == 200
|
||||
paths = [r["path"] for r in resp.json()["results"]]
|
||||
assert any(p.endswith(".mp3") for p in paths)
|
||||
|
||||
|
||||
class TestBrowseService:
|
||||
def test_browse_directory_service(self, app_with_vault, test_vault_dir):
|
||||
data = browse_directory("TestVault", "")
|
||||
names = [i["name"] for i in data["items"]]
|
||||
assert "sample-audio.mp3" in names
|
||||
@@ -0,0 +1,168 @@
|
||||
# tests/test_media_stream.py — Audio/video streaming & HTTP Range (roadmap #109-A/F1)
|
||||
import base64
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# 4 KiB of pseudo-ID3 bytes — the endpoint never decodes the payload, it only
|
||||
# has to serve bytes with the right MIME and honour Range.
|
||||
MEDIA_BYTES = b"ID3\x03\x00\x00\x00\x00\x00\x00" + bytes(range(256)) * 16
|
||||
PDF_BYTES = b"%PDF-1.4\n" + b"x" * 2048 + b"\n%%EOF"
|
||||
|
||||
|
||||
def _write(vault_dir: str, name: str, content: bytes) -> None:
|
||||
(Path(vault_dir) / name).write_bytes(content)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# /api/file — audio/video metadata returned before read_text (#109-A4)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestMediaFileView:
|
||||
def test_audio_metadata(self, client, test_vault_dir):
|
||||
_write(test_vault_dir, "note-vocale.mp3", MEDIA_BYTES)
|
||||
resp = client.get("/api/file/TestVault", params={"path": "note-vocale.mp3"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["is_audio"] is True
|
||||
assert data["is_video"] is not True
|
||||
assert data["media_mime"] == "audio/mpeg"
|
||||
assert data["stream_url"] == "/api/media/TestVault?path=note-vocale.mp3"
|
||||
assert data["size_bytes"] == len(MEDIA_BYTES)
|
||||
assert data["unsupported"] is not True
|
||||
|
||||
def test_video_metadata(self, client, test_vault_dir):
|
||||
_write(test_vault_dir, "clip.mp4", MEDIA_BYTES)
|
||||
resp = client.get("/api/file/TestVault", params={"path": "clip.mp4"})
|
||||
data = resp.json()
|
||||
assert data["is_video"] is True
|
||||
assert data["is_audio"] is not True
|
||||
assert data["media_mime"] == "video/mp4"
|
||||
assert data["stream_url"] == "/api/media/TestVault?path=clip.mp4"
|
||||
|
||||
def test_large_media_falls_back_to_download(self, client, test_vault_dir, monkeypatch):
|
||||
_write(test_vault_dir, "huge.mp3", MEDIA_BYTES)
|
||||
monkeypatch.setenv("OBSIGATE_MEDIA_MAX_INLINE_MB", "0.000001")
|
||||
resp = client.get("/api/file/TestVault", params={"path": "huge.mp3"})
|
||||
data = resp.json()
|
||||
assert data["unsupported"] is True
|
||||
assert data["media_too_large"] is True
|
||||
assert not data.get("is_audio")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# /api/media — byte-range streaming (#109-A2/F1)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestMediaStreamRange:
|
||||
def test_no_range_returns_200_and_accept_ranges(self, client, test_vault_dir):
|
||||
_write(test_vault_dir, "song.mp3", MEDIA_BYTES)
|
||||
resp = client.get("/api/media/TestVault", params={"path": "song.mp3"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["accept-ranges"] == "bytes"
|
||||
assert resp.headers["content-type"].startswith("audio/mpeg")
|
||||
assert resp.content == MEDIA_BYTES
|
||||
|
||||
def test_first_kibibyte_returns_206(self, client, test_vault_dir):
|
||||
_write(test_vault_dir, "song.mp3", MEDIA_BYTES)
|
||||
resp = client.get(
|
||||
"/api/media/TestVault",
|
||||
params={"path": "song.mp3"},
|
||||
headers={"Range": "bytes=0-1023"},
|
||||
)
|
||||
assert resp.status_code == 206
|
||||
assert resp.headers["accept-ranges"] == "bytes"
|
||||
assert resp.headers["content-range"] == f"bytes 0-1023/{len(MEDIA_BYTES)}"
|
||||
assert len(resp.content) == 1024
|
||||
assert resp.content == MEDIA_BYTES[:1024]
|
||||
|
||||
def test_suffix_range(self, client, test_vault_dir):
|
||||
_write(test_vault_dir, "song.mp3", MEDIA_BYTES)
|
||||
resp = client.get(
|
||||
"/api/media/TestVault",
|
||||
params={"path": "song.mp3"},
|
||||
headers={"Range": "bytes=-100"},
|
||||
)
|
||||
assert resp.status_code == 206
|
||||
assert resp.content == MEDIA_BYTES[-100:]
|
||||
|
||||
def test_unsatisfiable_range_returns_416(self, client, test_vault_dir):
|
||||
_write(test_vault_dir, "song.mp3", MEDIA_BYTES)
|
||||
resp = client.get(
|
||||
"/api/media/TestVault",
|
||||
params={"path": "song.mp3"},
|
||||
headers={"Range": f"bytes={len(MEDIA_BYTES) + 10}-"},
|
||||
)
|
||||
assert resp.status_code == 416
|
||||
assert resp.headers["content-range"] == f"bytes */{len(MEDIA_BYTES)}"
|
||||
|
||||
def test_mov_mime_override(self, client, test_vault_dir):
|
||||
_write(test_vault_dir, "clip.mov", MEDIA_BYTES)
|
||||
resp = client.get("/api/media/TestVault", params={"path": "clip.mov"})
|
||||
assert resp.headers["content-type"].startswith("video/quicktime")
|
||||
|
||||
def test_rejects_non_media(self, client, test_vault_dir):
|
||||
resp = client.get("/api/media/TestVault", params={"path": "note1.md"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_missing_media_404(self, client):
|
||||
resp = client.get("/api/media/TestVault", params={"path": "nope.mp3"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_unknown_vault_404(self, client):
|
||||
resp = client.get("/api/media/NoSuchVault", params={"path": "song.mp3"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_path_traversal_rejected(self, client, test_vault_dir):
|
||||
_write(test_vault_dir, "song.mp3", MEDIA_BYTES)
|
||||
resp = client.get("/api/media/TestVault", params={"path": "../../../etc/passwd.mp3"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_media_too_large_returns_413(self, client, test_vault_dir, monkeypatch):
|
||||
_write(test_vault_dir, "huge.mp3", MEDIA_BYTES)
|
||||
monkeypatch.setenv("OBSIGATE_MEDIA_MAX_INLINE_MB", "0.000001")
|
||||
resp = client.get("/api/media/TestVault", params={"path": "huge.mp3"})
|
||||
assert resp.status_code == 413
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Vault access control (#109-A3)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestMediaAccess:
|
||||
def test_normaluser_cannot_stream_other_vault(self, admin_client):
|
||||
resp = admin_client.post(
|
||||
"/api/auth/login", json={"username": "normaluser", "password": "normal123"}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
token = resp.json()["access_token"]
|
||||
resp = admin_client.get(
|
||||
"/api/media/OtherVault",
|
||||
params={"path": "song.mp3"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Regression: pdf/stream unchanged after extracting the shared helper (#109-A2)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestPdfStreamRegression:
|
||||
def test_pdf_range_still_works(self, client, test_vault_dir):
|
||||
_write(test_vault_dir, "doc.pdf", PDF_BYTES)
|
||||
resp = client.get(
|
||||
"/api/file/TestVault/pdf/stream",
|
||||
params={"path": "doc.pdf"},
|
||||
headers={"Range": "bytes=0-9"},
|
||||
)
|
||||
assert resp.status_code == 206
|
||||
assert resp.headers["content-type"].startswith("application/pdf")
|
||||
assert resp.headers["content-range"] == f"bytes 0-9/{len(PDF_BYTES)}"
|
||||
assert resp.content == PDF_BYTES[:10]
|
||||
|
||||
def test_pdf_without_range_returns_200(self, client, test_vault_dir):
|
||||
_write(test_vault_dir, "doc.pdf", PDF_BYTES)
|
||||
resp = client.get("/api/file/TestVault/pdf/stream", params={"path": "doc.pdf"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["accept-ranges"] == "bytes"
|
||||
Reference in New Issue
Block a user