import asyncio import base64 import json from pathlib import Path import httpx import imagefind.api as api_module from imagefind import __version__ from imagefind.config import Settings from imagefind.database import utcnow from imagefind.main import create_app from imagefind.speech import SPEECH_INDEX_REVISION from imagefind.text import search_tokens def test_setup_login_csrf_and_token(tmp_path: Path): app = create_app(Settings(data_dir=tmp_path, embedding_backend="hash", scan_interval_seconds=86400)) async def scenario(): transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: assert (await client.get("/api/v1/status")).json()["configured"] is False response = await client.post("/api/v1/setup", json={"password": "a secure test password"}) assert response.status_code == 200 csrf = response.json()["csrf_token"] assert (await client.get("/api/v1/auth/me")).status_code == 200 assert (await client.post("/api/v1/tokens", json={"name": "test"})).status_code == 403 token = await client.post( "/api/v1/tokens", json={"name": "test"}, headers={"X-CSRF-Token": csrf}, ) assert token.status_code == 201 api_token = token.json()["token"] assert ( await client.get("/api/v1/sources", headers={"Authorization": f"Bearer {api_token}"}) ).status_code == 200 asyncio.run(scenario()) def test_status_frontend_fallback_and_openapi(tmp_path: Path): settings = Settings( data_dir=tmp_path, frontend_dir=tmp_path / "frontend-not-built", embedding_backend="hash", scan_interval_seconds=86400, ) app = create_app(settings) async def scenario(): transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: status = await client.get("/api/v1/status") assert status.status_code == 200 assert status.json() == {"configured": False, "version": __version__, "access_mode": "direct"} root = await client.get("/") assert root.status_code == 200 assert "ImageFind API 正在运行" in root.text openapi = await client.get("/api/openapi.json") assert openapi.status_code == 200 assert "/api/v1/search" in openapi.json()["paths"] asyncio.run(scenario()) def test_security_headers_token_scope_payload_and_diagnostics(tmp_path: Path): app = create_app(Settings(data_dir=tmp_path, embedding_backend="hash", scan_interval_seconds=86400)) async def scenario(): transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: status = await client.get("/api/v1/status") assert status.headers["x-content-type-options"] == "nosniff" assert status.headers["referrer-policy"] == "same-origin" assert "camera=()" in status.headers["permissions-policy"] assert "default-src 'self'" in status.headers["content-security-policy"] setup = await client.post("/api/v1/setup", json={"password": "a secure test password"}) csrf = setup.json()["csrf_token"] created = await client.post( "/api/v1/tokens", headers={"X-CSRF-Token": csrf}, json={"name": "DAV automation", "scopes": ["webdav"]}, ) assert created.status_code == 201 assert created.json()["scopes"] == ["webdav"] listed = await client.get("/api/v1/tokens") assert listed.json()[0]["scopes"] == ["webdav"] assert "token" not in listed.json()[0] diagnostics = await client.get("/api/v1/system/diagnostics") assert diagnostics.status_code == 200 payload = diagnostics.json() assert payload["process_rss_bytes"] >= 0 assert payload["database"]["engine"] == "postgresql" assert payload["database"]["pool_max"] >= 1 assert isinstance(payload["database"]["activity"]["states"], dict) assert "running" in payload["inference"] assert payload["events"]["subscribers"] == 0 asyncio.run(scenario()) def test_fnos_gateway_auth_is_bound_to_unix_socket_and_prefix(tmp_path: Path): socket_path = tmp_path / "imagefind.sock" settings = Settings( data_dir=tmp_path / "data", gateway_socket=socket_path, gateway_prefix="/app/imagefind", embedding_backend="hash", scan_interval_seconds=86400, ) app = create_app(settings) app.state.services.auth.setup("gateway fallback password") class UnixSocketScope: def __init__(self, target): self.target = target async def __call__(self, scope, receive, send): mounted = dict(scope) mounted["server"] = (str(socket_path), None) await self.target(mounted, receive, send) async def scenario(): # Header spoofing over TCP must never enable gateway SSO. direct_transport = httpx.ASGITransport(app=app) spoofed = { "X-Trim-Isadmin": "true", "X-Trim-Userid": "nas-admin", "X-Trim-Username": "Administrator", } async with httpx.AsyncClient(transport=direct_transport, base_url="http://test") as direct: status = await direct.get("/api/v1/status", headers=spoofed) assert status.json()["access_mode"] == "direct" assert (await direct.post("/api/v1/auth/gateway", headers=spoofed)).status_code == 404 assert (await direct.get("/api/v1/sources", headers=spoofed)).status_code == 401 gateway_transport = httpx.ASGITransport(app=UnixSocketScope(app)) async with httpx.AsyncClient( transport=gateway_transport, base_url="http://test", headers=spoofed, follow_redirects=False, ) as gateway: redirect = await gateway.get("/app/imagefind") assert redirect.status_code == 307 assert redirect.headers["location"] == "/app/imagefind/" assert (await gateway.get("/outside-prefix")).status_code == 404 status = await gateway.get("/app/imagefind/api/v1/status") assert status.json()["access_mode"] == "gateway" login = await gateway.post("/app/imagefind/api/v1/auth/gateway") assert login.status_code == 200 csrf = login.json()["csrf_token"] gateway_token = login.json()["gateway_session_token"] assert gateway_token assert login.json()["nas_username"] == "Administrator" cookie = login.headers["set-cookie"] assert "imagefind_gateway_session=" in cookie assert "Path=/app/imagefind/" in cookie me = await gateway.get("/app/imagefind/api/v1/auth/me") assert me.status_code == 200 assert me.json()["kind"] == "gateway" assert (await gateway.post("/app/imagefind/api/v1/tokens", json={"name": "blocked"})).status_code == 403 created = await gateway.post( "/app/imagefind/api/v1/tokens", json={"name": "gateway"}, headers={**spoofed, "X-CSRF-Token": csrf}, ) assert created.status_code == 201 changed_user = await gateway.get( "/app/imagefind/api/v1/auth/me", headers={**spoofed, "X-Trim-Userid": "another-admin"}, ) assert changed_user.status_code == 401 # The fnOS WebView may omit the scoped cookie. The short-lived session # header keeps SSO working, but only inside the trusted Unix gateway. async with httpx.AsyncClient( transport=gateway_transport, base_url="http://test", headers={**spoofed, "X-ImageFind-Gateway-Session": gateway_token}, ) as header_only: me = await header_only.get("/app/imagefind/api/v1/auth/me") assert me.status_code == 200 assert me.json()["nas_user_id"] == "nas-admin" async with httpx.AsyncClient( transport=gateway_transport, base_url="http://test", headers={ **spoofed, "X-Trim-Userid": "another-admin", "X-ImageFind-Gateway-Session": gateway_token, }, ) as wrong_identity: assert (await wrong_identity.get("/app/imagefind/api/v1/auth/me")).status_code == 401 other_headers = { "X-Trim-Isadmin": "true", "X-Trim-Userid": "other-admin", "X-Trim-Username": "Other administrator", } async with httpx.AsyncClient( transport=gateway_transport, base_url="http://test", headers=other_headers, ) as other_gateway: other_login = await other_gateway.post("/app/imagefind/api/v1/auth/gateway") other_token = other_login.json()["gateway_session_token"] async with httpx.AsyncClient( transport=gateway_transport, base_url="http://test", headers={ **spoofed, "Cookie": f"imagefind_gateway_session={other_token}", "X-ImageFind-Gateway-Session": gateway_token, }, ) as stale_cookie: me = await stale_cookie.get("/app/imagefind/api/v1/auth/me") assert me.status_code == 200 assert me.json()["nas_user_id"] == "nas-admin" async with httpx.AsyncClient(transport=direct_transport, base_url="http://test") as direct: response = await direct.get( "/api/v1/auth/me", headers={**spoofed, "X-ImageFind-Gateway-Session": gateway_token}, ) assert response.status_code == 401 async with httpx.AsyncClient( transport=gateway_transport, base_url="http://test", headers={"X-Trim-Isadmin": "false", "X-Trim-Userid": "ordinary-user"}, ) as ordinary: assert (await ordinary.post("/app/imagefind/api/v1/auth/gateway")).status_code == 403 async with httpx.AsyncClient(transport=gateway_transport, base_url="http://test") as anonymous: assert (await anonymous.post("/app/imagefind/api/v1/auth/gateway")).status_code == 401 asyncio.run(scenario()) def test_frontend_runtime_base_is_injected_for_gateway_and_direct_access(tmp_path: Path): frontend = tmp_path / "frontend" frontend.mkdir() (frontend / "index.html").write_text("ImageFind") socket_path = tmp_path / "imagefind.sock" settings = Settings( data_dir=tmp_path / "data", frontend_dir=frontend, gateway_socket=socket_path, embedding_backend="hash", scan_interval_seconds=86400, ) app = create_app(settings) class UnixSocketScope: async def __call__(self, scope, receive, send): mounted = dict(scope) mounted["server"] = (str(socket_path), None) await app(mounted, receive, send) async def scenario(): async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as direct: index = await direct.get("/") assert 'window.__IMAGEFIND_BASE__="/"' in index.text assert 'dataset.imagefindAccess="direct"' in index.text assert index.headers["cache-control"] == "no-store" async with httpx.AsyncClient( transport=httpx.ASGITransport(app=UnixSocketScope()), base_url="http://test" ) as gateway: index = await gateway.get("/app/imagefind/") assert 'window.__IMAGEFIND_BASE__="/app/imagefind/"' in index.text assert 'dataset.imagefindAccess="gateway"' in index.text asyncio.run(scenario()) def _seed_local_search(app, tmp_path: Path) -> tuple[str, str, bytes]: service = app.state.services source_id = "local-source" video_id = "local-video" frame_id = "local-frame" video_bytes = b"0123456789abcdef" video_path = tmp_path / "sample.mp4" video_path.write_bytes(video_bytes) now = utcnow() with service.db.transaction() as conn: conn.execute( "INSERT INTO sources(id,kind,name,config_json,created_at,updated_at) VALUES(?,?,?,?,?,?)", (source_id, "local", "本地资料库", json.dumps({"path": str(tmp_path)}), now, now), ) conn.execute( "INSERT INTO videos(id,source_id,source_key,display_name,location,fingerprint,duration_ms,status," "available,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)", ( video_id, source_id, "sample.mp4", "海边假期.mp4", str(video_path), "fingerprint", 120_000, "ready", 1, now, now, ), ) conn.execute( "INSERT INTO frames(id,video_id,timestamp_ms,segment_start_ms,segment_end_ms,thumbnail_path,created_at) " "VALUES(?,?,?,?,?,?,?)", (frame_id, video_id, 42_000, 40_000, 48_000, str(tmp_path / "frame.webp"), now), ) tokens = " ".join(search_tokens("海边日落")) for kind in ("subtitle", "filename"): entry_id = f"text-{kind}" conn.execute( "INSERT INTO text_entries(id,video_id,frame_id,kind,start_ms,end_ms,raw_text,tokens,created_at) " "VALUES(?,?,?,?,?,?,?,?,?)", (entry_id, video_id, frame_id, kind, 40_000, 48_000, "海边日落", tokens, now), ) conn.execute("INSERT INTO text_fts(entry_id,tokens) VALUES(?,?)", (entry_id, tokens)) audio_tokens = " ".join(search_tokens("海浪声音")) conn.execute( "INSERT INTO text_entries(id,video_id,frame_id,kind,start_ms,end_ms,raw_text,tokens,created_at) " "VALUES('text-audio',?,?, 'audio',?,?,?,?,?)", (video_id, frame_id, 65_000, 68_000, "远处传来海浪声音", audio_tokens, now), ) conn.execute("INSERT INTO text_fts(entry_id,tokens) VALUES('text-audio',?)", (audio_tokens,)) _, token = service.auth.create_api_token("test") return token, video_id, video_bytes def test_gateway_media_token_reads_native_media_without_cookie_or_session_header(tmp_path: Path): socket_path = tmp_path / "imagefind.sock" settings = Settings( data_dir=tmp_path / "data", gateway_socket=socket_path, gateway_prefix="/app/imagefind", embedding_backend="hash", scan_interval_seconds=86400, ) settings.prepare() app = create_app(settings) service = app.state.services service.auth.setup("gateway native media test password") _, video_id, video_bytes = _seed_local_search(app, tmp_path) thumbnail_path = tmp_path / "frame.webp" thumbnail_path.write_bytes(b"webp-thumbnail") preview = settings.preview_dir / "media123" preview.mkdir(parents=True) (preview / "index.m3u8").write_text("#EXTM3U\nsegment-00001.ts\n", encoding="utf-8") (preview / "segment-00001.ts").write_bytes(b"gateway-segment") class UnixSocketScope: async def __call__(self, scope, receive, send): mounted = dict(scope) mounted["server"] = (str(socket_path), None) await app(mounted, receive, send) identity = { "X-Trim-Isadmin": "true", "X-Trim-Userid": "nas-admin", "X-Trim-Username": "Administrator", } gateway_transport = httpx.ASGITransport(app=UnixSocketScope()) async def scenario(): async with httpx.AsyncClient( transport=gateway_transport, base_url="http://test", headers=identity, ) as login_client: login = await login_client.post("/app/imagefind/api/v1/auth/gateway") assert login.status_code == 200 session_token = login.json()["gateway_session_token"] media_token = login.json()["gateway_media_token"] assert media_token and media_token != session_token refreshed = await login_client.post( "/app/imagefind/api/v1/auth/gateway/media-token", headers={"X-CSRF-Token": login.json()["csrf_token"]}, ) assert refreshed.status_code == 200 refreshed_token = refreshed.json()["gateway_media_token"] query = f"media_token={media_token}" async with httpx.AsyncClient( transport=gateway_transport, base_url="http://test", headers=identity, ) as native: thumbnail = await native.get(f"/app/imagefind/api/v1/frames/local-frame/thumbnail?{query}") assert thumbnail.status_code == 200 assert thumbnail.content == b"webp-thumbnail" stream = await native.get( f"/app/imagefind/api/v1/videos/{video_id}/stream?{query}", headers={"Range": "bytes=2-5"}, ) assert stream.status_code == 206 assert stream.content == video_bytes[2:6] download = await native.get(f"/app/imagefind/api/v1/videos/{video_id}/download?{query}") assert download.status_code == 200 assert download.content == video_bytes playlist = await native.get(f"/app/imagefind/api/v1/previews/media123/index.m3u8?{query}") assert playlist.status_code == 200 assert f"segment-00001.ts?media_token={media_token}" in playlist.text segment = await native.get( f"/app/imagefind/api/v1/previews/media123/segment-00001.ts?{query}" ) assert segment.status_code == 200 assert segment.content == b"gateway-segment" assert (await native.get(f"/app/imagefind/api/v1/sources?{query}")).status_code == 401 async with httpx.AsyncClient( transport=gateway_transport, base_url="http://test", headers={**identity, "X-Trim-Userid": "another-admin"}, ) as wrong_user: assert ( await wrong_user.get(f"/app/imagefind/api/v1/videos/{video_id}/stream?{query}") ).status_code == 401 async with httpx.AsyncClient( transport=httpx.ASGITransport(app=app), base_url="http://test" ) as direct: assert (await direct.get(f"/api/v1/videos/{video_id}/stream?{query}")).status_code == 401 with service.db.transaction() as conn: conn.execute( "UPDATE gateway_media_tokens SET expires_at='2000-01-01T00:00:00+00:00' WHERE token_hash=?", (service.auth._digest(refreshed_token),), ) async with httpx.AsyncClient( transport=gateway_transport, base_url="http://test", headers=identity, ) as expired: assert ( await expired.get( f"/app/imagefind/api/v1/videos/{video_id}/stream?media_token={refreshed_token}" ) ).status_code == 401 service.auth.logout(session_token) async with httpx.AsyncClient( transport=gateway_transport, base_url="http://test", headers=identity, ) as revoked: assert ( await revoked.get(f"/app/imagefind/api/v1/videos/{video_id}/stream?{query}") ).status_code == 401 asyncio.run(scenario()) def test_search_match_sources_local_range_and_hls_paths(tmp_path: Path): settings = Settings(data_dir=tmp_path, embedding_backend="hash", scan_interval_seconds=86400) settings.prepare() app = create_app(settings) token, video_id, video_bytes = _seed_local_search(app, tmp_path) headers = {"Authorization": f"Bearer {token}"} preview = settings.preview_dir / "abc123" preview.mkdir(parents=True) (preview / "index.m3u8").write_bytes(b"#EXTM3U\n") (preview / "segment-00001.ts").write_bytes(b"segment") async def scenario(): transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: search = await client.post("/api/v1/search", headers=headers, json={"text": "海边日落"}) assert search.status_code == 200 item = search.json()["items"][0] assert item["video_id"] == video_id assert item["timestamp_ms"] == 40_000 assert item["match_sources"] == ["filename", "subtitle"] assert {detail["type"] for detail in item["match_details"]} == {"metadata", "subtitle"} audio = await client.post( "/api/v1/search", headers=headers, json={"text": "海浪声音", "recognition_types": ["audio"]}, ) audio_item = audio.json()["items"][0] assert audio_item["match_sources"] == ["audio"] assert audio_item["segment_start_ms"] == 65_000 assert audio_item["match_details"][0]["text"] == "远处传来海浪声音" excluded = await client.post( "/api/v1/search", headers=headers, json={"text": "海浪声音", "recognition_types": ["ocr"]}, ) assert excluded.json()["items"] == [] partial = await client.get( f"/api/v1/videos/{video_id}/stream", headers={**headers, "Range": "bytes=2-5"}, ) assert partial.status_code == 206 assert partial.content == video_bytes[2:6] assert partial.headers["content-range"] == f"bytes 2-5/{len(video_bytes)}" assert partial.headers["accept-ranges"] == "bytes" suffix = await client.get( f"/api/v1/videos/{video_id}/stream", headers={**headers, "Range": "bytes=-3"}, ) assert suffix.status_code == 206 assert suffix.content == video_bytes[-3:] invalid = await client.get( f"/api/v1/videos/{video_id}/stream", headers={**headers, "Range": "bytes=99-100"}, ) assert invalid.status_code == 416 playlist = await client.get("/api/v1/previews/abc123/index.m3u8", headers=headers) assert playlist.status_code == 200 assert playlist.content == b"#EXTM3U\n" segment = await client.get("/api/v1/previews/abc123/segment-00001.ts", headers=headers) assert segment.status_code == 200 assert segment.content == b"segment" assert (await client.get("/api/v1/previews/not-safe/index.m3u8", headers=headers)).status_code == 404 assert (await client.get("/api/v1/previews/abc123/metadata.json", headers=headers)).status_code == 404 asyncio.run(scenario()) def test_audio_search_has_an_independent_candidate_budget_and_simplified_traditional_variants( tmp_path: Path, ): settings = Settings(data_dir=tmp_path, embedding_backend="hash", scan_interval_seconds=86400) settings.prepare() app = create_app(settings) token, video_id, _ = _seed_local_search(app, tmp_path) service = app.state.services now = utcnow() simplified = "繁体关键词" traditional = "繁體關鍵詞" with service.db.transaction() as conn: for index in range(520): tokens = " ".join(search_tokens(traditional)) entry_id = f"crowding-ocr-{index:03d}" conn.execute( "INSERT INTO text_entries(id,video_id,frame_id,kind,start_ms,end_ms,raw_text,tokens,created_at) " "VALUES(?,?,?,'ocr',0,1000,?,?,?)", (entry_id, video_id, "local-frame", traditional, tokens, now), ) tokens = " ".join(search_tokens(simplified)) conn.execute( "INSERT INTO text_entries(id,video_id,frame_id,kind,start_ms,end_ms,raw_text,tokens,created_at) " "VALUES('audio-simplified',?,?, 'audio',66000,69000,?,?,?)", (video_id, "local-frame", f"这里说的是{simplified}", tokens, now), ) async def scenario(): async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: response = await client.post( "/api/v1/search", headers={"Authorization": f"Bearer {token}"}, json={"text": traditional}, ) assert response.status_code == 200 item = response.json()["items"][0] assert "audio" in item["match_sources"] assert any(detail["type"] == "audio" for detail in item["match_details"]) asyncio.run(scenario()) def test_transcript_coverage_pagination_and_manual_reindex(tmp_path: Path, monkeypatch): settings = Settings(data_dir=tmp_path, embedding_backend="hash", scan_interval_seconds=86400) settings.prepare() app = create_app(settings) token, video_id, _ = _seed_local_search(app, tmp_path) service = app.state.services monkeypatch.setattr(service.models, "runnable_component_versions", lambda: {"audio": "audio-v1"}) now = utcnow() with service.db.transaction() as conn: conn.execute( "UPDATE videos SET audio_model_version='audio-v1',audio_index_revision=?," "audio_detected_language='zh',audio_quality_score=.94,audio_rejected_segments=1," "audio_quality_flags_json='not-json' WHERE id=?", (SPEECH_INDEX_REVISION, video_id), ) conn.execute("DELETE FROM text_entries WHERE video_id=? AND kind='audio'", (video_id,)) for index in range(35): text = f"第{index + 1}个中文音频片段" conn.execute( "INSERT INTO text_entries(id,video_id,frame_id,kind,start_ms,end_ms,raw_text,tokens,created_at) " "VALUES(?,?,NULL,'audio',?,?,?,?,?)", ( f"transcript-{index:02d}", video_id, index * 1000, index * 1000 + 900, text, " ".join(search_tokens(text)), now, ), ) # A trusted legacy transcript remains searchable while the current model reindex is queued. conn.execute("UPDATE videos SET audio_model_version='audio-legacy' WHERE id=?", (video_id,)) async def scenario(): headers = {"Authorization": f"Bearer {token}"} async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: coverage = await client.get("/api/v1/search/coverage", headers=headers) assert coverage.status_code == 200 coverage_payload = coverage.json() assert coverage_payload["indexed"] == 0 assert coverage_payload["searchable"] == 1 assert coverage_payload["empty"] == 0 assert coverage_payload["percent"] == 100.0 assert coverage_payload["current_model_percent"] == 0.0 with service.db.transaction() as conn: conn.execute( "UPDATE videos SET audio_model_version='audio-v1' WHERE id=?", (video_id,) ) current_coverage = await client.get("/api/v1/search/coverage", headers=headers) assert current_coverage.status_code == 200 assert current_coverage.json()["indexed"] == 1 assert current_coverage.json()["searchable"] == 1 assert current_coverage.json()["current_model_percent"] == 100.0 transcript = await client.get( f"/api/v1/videos/{video_id}/transcript?page=2&page_size=10", headers=headers ) assert transcript.status_code == 200 payload = transcript.json() assert payload["status"] == "ready" assert payload["page"] == 2 and payload["pages"] == 4 and payload["total"] == 35 assert payload["items"][0]["start_ms"] == 10_000 assert payload["detected_language"] == "zh" assert payload["quality_state"] == "ready" assert payload["aggregate_risk"] is False assert payload["repeated_phrases"] == [] assert payload["quality_score_semantics"] == "rule_check" assert payload["quality_flags"] == [] speech = await client.get("/api/v1/speech/config", headers=headers) assert speech.status_code == 200 assert speech.json()["language_policy"] == "zh_priority" configured = await client.patch( "/api/v1/speech/config", headers=headers, json={"language_policy": "auto", "quality_profile": "balanced"}, ) assert configured.status_code == 200 assert configured.json()["quality_profile"] == "balanced" queued = await client.post( f"/api/v1/videos/{video_id}/transcript/reindex", headers=headers, json={"language": "zh"} ) assert queued.status_code == 202 with service.db.read() as conn: job = conn.execute( "SELECT kind,priority,payload_json FROM jobs WHERE id=?", (queued.json()["job_id"],) ).fetchone() assert {"kind": job["kind"], "priority": job["priority"]} == { "kind": "transcribe_audio", "priority": 0, } assert json.loads(job["payload_json"]) == {"video_id": video_id, "language": "zh"} asyncio.run(scenario()) def test_webdav_range_proxy_forwards_headers_and_hides_credentials(tmp_path: Path, monkeypatch): socket_path = tmp_path / "imagefind.sock" settings = Settings( data_dir=tmp_path, gateway_socket=socket_path, gateway_prefix="/app/imagefind", embedding_backend="hash", scan_interval_seconds=86400, ) settings.prepare() app = create_app(settings) service = app.state.services service.auth.setup("gateway WebDAV media test password") now = utcnow() secret_blob = service.secrets.encrypt_json({"password": "remote-password"}) with service.db.transaction() as conn: conn.execute( "INSERT INTO sources(id,kind,name,config_json,secret_blob,created_at,updated_at) " "VALUES(?,?,?,?,?,?,?)", ( "dav-source", "webdav", "远程资料库", json.dumps( { "base_url": "https://dav.example/videos/", "username": "remote-user", "verify_tls": True, } ), secret_blob, now, now, ), ) conn.execute( "INSERT INTO videos(id,source_id,source_key,display_name,location,fingerprint,status,available," "created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)", ( "dav-video", "dav-source", "movie.mp4", "movie.mp4", "https://dav.example/videos/movie.mp4", "remote-fingerprint", "ready", 1, now, now, ), ) _, token = service.auth.create_api_token("test") service.db.set_setting( "webdav_server", {"enabled": True, "source_id": "dav-source", "relative_path": ""} ) webdav_auth = { "Authorization": "Basic " + base64.b64encode(f"imagefind:{token}".encode()).decode() } observed: dict[str, object] = {"requests": []} class UnixSocketScope: async def __call__(self, scope, receive, send): mounted = dict(scope) mounted["server"] = (str(socket_path), None) await app(mounted, receive, send) identity = { "X-Trim-Isadmin": "true", "X-Trim-Userid": "nas-admin", "X-Trim-Username": "Administrator", } class FakeAsyncClient: def __init__(self, **kwargs): observed["client_kwargs"] = kwargs def build_request(self, method, url, headers): request = httpx.Request(method, url, headers=headers) observed["requests"].append(request) return request async def send(self, request, stream=False): assert stream is True if request.method == "HEAD": return httpx.Response(405, request=request) if request.headers.get("range") == "bytes=0-0": return httpx.Response( 206, request=request, content=b"0", headers={ "Content-Length": "1", "Content-Range": "bytes 0-0/10", "Accept-Ranges": "bytes", "Content-Type": "application/octet-stream", }, ) return httpx.Response( 206, request=request, content=b"2345", headers={ "Content-Length": "4", "Content-Range": "bytes 2-5/10", "Accept-Ranges": "bytes", "Content-Type": "application/octet-stream", }, ) async def aclose(self): observed["closed"] = True async def scenario(): transport = httpx.ASGITransport(app=app) gateway_transport = httpx.ASGITransport(app=UnixSocketScope()) async with ( httpx.AsyncClient(transport=transport, base_url="http://test") as client, httpx.AsyncClient( transport=gateway_transport, base_url="http://test", headers=identity, ) as gateway, ): login = await gateway.post("/app/imagefind/api/v1/auth/gateway") assert login.status_code == 200 media_token = login.json()["gateway_media_token"] monkeypatch.setattr(api_module.httpx, "AsyncClient", FakeAsyncClient) response = await client.get( "/api/v1/videos/dav-video/stream", headers={"Authorization": f"Bearer {token}", "Range": "bytes=2-5", "If-Range": '"etag"'}, ) assert response.status_code == 206 assert response.content == b"2345" assert response.headers["content-range"] == "bytes 2-5/10" assert response.headers["content-type"] == "video/mp4" gateway_response = await gateway.get( f"/app/imagefind/api/v1/videos/dav-video/stream?media_token={media_token}", headers={"Range": "bytes=2-5", "If-Range": '"etag"'}, ) assert gateway_response.status_code == 206 assert gateway_response.content == b"2345" assert gateway_response.headers["content-type"] == "video/mp4" head = await client.request( "HEAD", "/webdav/movie.mp4", headers=webdav_auth, ) assert head.status_code == 200 assert head.headers["content-length"] == "10" assert "content-range" not in head.headers assert head.headers["accept-ranges"] == "bytes" assert head.headers["content-type"] == "video/mp4" ranged_head = await client.request( "HEAD", "/webdav/movie.mp4", headers={**webdav_auth, "Range": "bytes=2-5"}, ) assert ranged_head.status_code == 206 assert ranged_head.headers["content-length"] == "4" assert ranged_head.headers["content-range"] == "bytes 2-5/10" asyncio.run(scenario()) requests = observed["requests"] assert isinstance(requests, list) assert [request.method for request in requests] == ["GET", "GET", "HEAD", "GET", "HEAD", "GET"] upstream = requests[0] assert isinstance(upstream, httpx.Request) assert upstream.url == "https://dav.example/videos/movie.mp4" assert upstream.headers["range"] == "bytes=2-5" assert upstream.headers["if-range"] == '"etag"' assert "remote-password" not in str(upstream.url) assert observed["closed"] is True