"""FC-3g: /api/extension and /extension/ integration tests.""" import hashlib import pytest import pytest_asyncio from sqlalchemy import func, select from backend.app import frontend as frontend_module from backend.app.api import extension as extension_module from backend.app.models import AppSetting, Artist, Source pytestmark = pytest.mark.integration @pytest_asyncio.fixture async def ext_key(db): db.add(AppSetting(key="extension_api_key", value="test-ext-key")) await db.commit() return "test-ext-key" # --- /api/extension/quick-add-source --------------------------------- @pytest.mark.asyncio async def test_quick_add_source_creates_artist_and_source(client, ext_key, db_sync): resp = await client.post( "/api/extension/quick-add-source", json={"url": "https://www.patreon.com/maewix"}, headers={"X-Extension-Key": ext_key}, ) assert resp.status_code == 201 body = await resp.get_json() assert body["created_artist"] is True assert body["created_source"] is True assert body["artist"]["slug"] == "maewix" assert body["source"]["platform"] == "patreon" assert body["source"]["enabled"] is True # Async Core-DML assertion: column select, not ORM attribute access. artist_count = db_sync.execute( select(func.count(Artist.id)).where(Artist.slug == "maewix") ).scalar_one() assert artist_count == 1 source_count = db_sync.execute( select(func.count(Source.id)).where( Source.platform == "patreon", Source.url == "https://www.patreon.com/maewix", ) ).scalar_one() assert source_count == 1 @pytest.mark.asyncio async def test_quick_add_source_idempotent(client, ext_key): body = {"url": "https://www.subscribestar.com/some-creator"} headers = {"X-Extension-Key": ext_key} r1 = await client.post("/api/extension/quick-add-source", json=body, headers=headers) r2 = await client.post("/api/extension/quick-add-source", json=body, headers=headers) assert r1.status_code == 201 assert r2.status_code == 200 body2 = await r2.get_json() assert body2["created_source"] is False assert body2["created_artist"] is False @pytest.mark.asyncio async def test_quick_add_reuses_source_artist_after_rename(client, ext_key): # #130 identity-by-source: after renaming the artist (slug frozen ≠ new # name-slug), re-adding the SAME source must reuse it — a slug-based lookup # would miss and duplicate the artist. url = "https://www.subscribestar.com/renamed-creator" h = {"X-Extension-Key": ext_key} a1 = (await (await client.post( "/api/extension/quick-add-source", json={"url": url}, headers=h )).get_json())["artist"] await client.patch(f"/api/artists/{a1['id']}", json={"name": "Totally Different"}) b2 = await (await client.post( "/api/extension/quick-add-source", json={"url": url}, headers=h )).get_json() assert b2["created_artist"] is False assert b2["artist"]["id"] == a1["id"] assert b2["artist"]["name"] == "Totally Different" @pytest.mark.asyncio async def test_resolve_artist_name_dispatches_per_platform(db, monkeypatch): # #130: each native platform resolves its real display name at add-time # (patreon=campaigns API, subscribestar=profile page); gallery-dl platforms # and any failure fall back to the URL handle. from backend.app.services import patreon_resolver from backend.app.services.credential_service import CredentialService from backend.app.services.extension_service import ExtensionService from backend.app.services.subscribestar_client import SubscribeStarClient async def _cookies(self, platform): return "/tmp/cookies.txt" monkeypatch.setattr(CredentialService, "get_cookies_path", _cookies) monkeypatch.setattr(patreon_resolver, "resolve_display_name", lambda v, c: "Patreon Name") monkeypatch.setattr(SubscribeStarClient, "resolve_display_name", lambda self, u: "SS Name") svc = ExtensionService(db, crypto=object()) # crypto seam only (calls stubbed) assert await svc._resolve_artist_name( "patreon", "maewix", "https://patreon.com/maewix") == "Patreon Name" assert await svc._resolve_artist_name( "subscribestar", "sabu", "https://subscribestar.adult/sabu") == "SS Name" # gallery-dl platform → readable handle passthrough (no resolver). assert await svc._resolve_artist_name("hentaifoundry", "Foo", "u") == "Foo" # No crypto → no resolution attempt → the raw handle. assert await ExtensionService(db)._resolve_artist_name("patreon", "maewix", "u") == "maewix" # Resolver returns None → fall back to the handle. monkeypatch.setattr(patreon_resolver, "resolve_display_name", lambda v, c: None) assert await svc._resolve_artist_name("patreon", "maewix", "u") == "maewix" @pytest.mark.parametrize("url,platform,slug", [ ("https://www.patreon.com/maewix", "patreon", "maewix"), ("https://patreon.com/maewix", "patreon", "maewix"), ("https://www.subscribestar.com/foobar", "subscribestar", "foobar"), ("https://subscribestar.adult/foobar", "subscribestar", "foobar"), ("https://www.hentai-foundry.com/user/Foo/profile", "hentaifoundry", "Foo"), ]) @pytest.mark.asyncio async def test_quick_add_source_url_patterns(client, ext_key, url, platform, slug): resp = await client.post( "/api/extension/quick-add-source", json={"url": url}, headers={"X-Extension-Key": ext_key}, ) assert resp.status_code == 201, await resp.get_json() body = await resp.get_json() assert body["source"]["platform"] == platform # slugify lowercases — the Artist slug should reflect that. assert body["artist"]["slug"] == slug.lower() @pytest.mark.asyncio async def test_quick_add_source_unknown_url_400(client, ext_key): resp = await client.post( "/api/extension/quick-add-source", json={"url": "https://example.com/foo"}, headers={"X-Extension-Key": ext_key}, ) assert resp.status_code == 400 body = await resp.get_json() assert body["error"] == "unknown_platform" assert "known" in body @pytest.mark.asyncio async def test_quick_add_source_rejects_retired_deviantart(client, ext_key): """#3069: a DeviantArt creator URL used to derive cleanly. Now that the platform is retired, the extension's own gate should never offer the button — but a stale content script on an un-updated browser still can, so the backend has to refuse it rather than create an unusable source.""" resp = await client.post( "/api/extension/quick-add-source", json={"url": "https://www.deviantart.com/baz"}, headers={"X-Extension-Key": ext_key}, ) assert resp.status_code == 400 body = await resp.get_json() assert body["error"] == "unknown_platform" assert "deviantart" not in body["known"] @pytest.mark.asyncio async def test_quick_add_source_rejects_retired_pixiv(client, ext_key): """Milestone #406: the same shape as deviantart's retirement above. An un-updated extension can still offer the button on a pixiv creator page, so the backend refuses rather than creating a source nothing can download.""" resp = await client.post( "/api/extension/quick-add-source", json={"url": "https://www.pixiv.net/users/12345"}, headers={"X-Extension-Key": ext_key}, ) assert resp.status_code == 400 body = await resp.get_json() assert body["error"] == "unknown_platform" assert "pixiv" not in body["known"] @pytest.mark.asyncio async def test_quick_add_source_invalid_url_400(client, ext_key): resp = await client.post( "/api/extension/quick-add-source", json={"url": "not-a-url"}, headers={"X-Extension-Key": ext_key}, ) assert resp.status_code == 400 body = await resp.get_json() assert body["error"] == "invalid_url" @pytest.mark.asyncio async def test_quick_add_source_missing_key_401(client): resp = await client.post( "/api/extension/quick-add-source", json={"url": "https://www.patreon.com/maewix"}, ) assert resp.status_code == 401 body = await resp.get_json() assert body["error"] == "unauthorized" @pytest.mark.asyncio async def test_quick_add_source_wrong_key_401(client, ext_key): resp = await client.post( "/api/extension/quick-add-source", json={"url": "https://www.patreon.com/maewix"}, headers={"X-Extension-Key": "wrong-key"}, ) assert resp.status_code == 401 # --- /api/extension/probe --------------------------------------------- @pytest.mark.asyncio async def test_probe_returns_new_when_nothing_exists(client, ext_key): resp = await client.get( "/api/extension/probe", query_string={"url": "https://www.patreon.com/freshcreator"}, headers={"X-Extension-Key": ext_key}, ) assert resp.status_code == 200 body = await resp.get_json() assert body["state"] == "new" assert body["platform"] == "patreon" assert body["slug"] == "freshcreator" @pytest.mark.asyncio async def test_probe_returns_source_match_for_already_added(client, ext_key, db): artist = Artist(name="Alice", slug="alice", is_subscription=True) db.add(artist) await db.flush() src = Source( artist_id=artist.id, platform="patreon", url="https://www.patreon.com/alice", enabled=True, config_overrides={}, ) db.add(src) await db.commit() resp = await client.get( "/api/extension/probe", query_string={"url": "https://www.patreon.com/alice"}, headers={"X-Extension-Key": ext_key}, ) assert resp.status_code == 200 body = await resp.get_json() assert body["state"] == "source_match" assert body["artist"]["slug"] == "alice" assert body["source"]["url"] == "https://www.patreon.com/alice" assert body["source"]["platform"] == "patreon" @pytest.mark.asyncio async def test_probe_returns_artist_match_when_only_synthetic_anchor_exists( client, ext_key, db, ): """Filesystem-imported artist with only a sidecar synthetic Source for the (artist, platform) — the URL the operator's browsing isn't yet a real Source. The probe should collapse this into artist_match so the chip says '+ Add Patreon source to Dymkens' rather than '+ Add to FabledCurator' (which would re-create the artist).""" artist = Artist(name="Dymkens", slug="dymkens", is_subscription=False) db.add(artist) await db.flush() synthetic = Source( artist_id=artist.id, platform="patreon", url="sidecar:patreon:dymkens", enabled=False, config_overrides={}, ) db.add(synthetic) await db.commit() resp = await client.get( "/api/extension/probe", query_string={"url": "https://www.patreon.com/dymkens"}, headers={"X-Extension-Key": ext_key}, ) assert resp.status_code == 200 body = await resp.get_json() assert body["state"] == "artist_match" assert body["artist"]["slug"] == "dymkens" assert "source" not in body @pytest.mark.asyncio async def test_probe_returns_unknown_platform_for_non_artist_url(client, ext_key): """A patreon URL that isn't an artist page (e.g. /home, /posts/N) shouldn't trigger the button. Sentinel 'unknown_platform' state tells the content script to skip injection.""" resp = await client.get( "/api/extension/probe", query_string={"url": "https://www.patreon.com/posts/12345"}, headers={"X-Extension-Key": ext_key}, ) assert resp.status_code == 200 body = await resp.get_json() assert body["state"] == "unknown_platform" @pytest.mark.asyncio async def test_probe_missing_key_401(client): resp = await client.get( "/api/extension/probe", query_string={"url": "https://www.patreon.com/maewix"}, ) assert resp.status_code == 401 @pytest.mark.asyncio async def test_probe_missing_url_400(client, ext_key): resp = await client.get( "/api/extension/probe", headers={"X-Extension-Key": ext_key}, ) assert resp.status_code == 400 body = await resp.get_json() assert body["error"] == "invalid_body" @pytest.mark.asyncio async def test_quick_add_source_missing_body_400(client, ext_key): resp = await client.post( "/api/extension/quick-add-source", headers={"X-Extension-Key": ext_key}, ) assert resp.status_code == 400 body = await resp.get_json() assert body["error"] == "invalid_body" @pytest.mark.asyncio async def test_quick_add_source_attaches_to_existing_artist(client, ext_key, db, db_sync): db.add(Artist(name="Maewix Original", slug="maewix", is_subscription=False)) await db.commit() resp = await client.post( "/api/extension/quick-add-source", json={"url": "https://www.patreon.com/maewix"}, headers={"X-Extension-Key": ext_key}, ) assert resp.status_code == 201 body = await resp.get_json() assert body["created_artist"] is False assert body["created_source"] is True artist_count = db_sync.execute( select(func.count(Artist.id)).where(Artist.slug == "maewix") ).scalar_one() assert artist_count == 1 # no duplicate created # --- Discord: channels are added to a CHOSEN artist (milestone 429) --- _GUILD = "111111111111111111" _CHAN = "222222222222222222" _OTHER_CHAN = "333333333333333333" async def _discord_source(db, name, url): artist = Artist(name=name, slug=name.lower(), is_subscription=True) db.add(artist) await db.flush() src = Source(artist_id=artist.id, platform="discord", url=url, enabled=True, config_overrides={}) db.add(src) await db.commit() return artist, src @pytest.mark.asyncio async def test_a_discord_channel_is_added_to_the_artist_the_operator_picked( client, ext_key, db, db_sync, ): artist = Artist(name="Tamada", slug="tamada", is_subscription=True) db.add(artist) await db.commit() resp = await client.post( "/api/extension/quick-add-source", # A jump link on the ptb host still names the channel. json={"url": f"https://ptb.discord.com/channels/{_GUILD}/{_CHAN}/999", "artist_id": artist.id}, headers={"X-Extension-Key": ext_key}, ) assert resp.status_code == 201, await resp.get_json() body = await resp.get_json() assert body["artist"]["id"] == artist.id assert body["created_artist"] is False # Stored canonical, so the manual form and the ingester read the same URL. assert body["source"]["url"] == f"https://discord.com/channels/{_GUILD}/{_CHAN}" assert body["source"]["platform"] == "discord" @pytest.mark.asyncio async def test_a_discord_add_can_name_a_new_artist(client, ext_key): resp = await client.post( "/api/extension/quick-add-source", json={"url": f"https://discord.com/channels/{_GUILD}", "artist_name": "Studio Q"}, headers={"X-Extension-Key": ext_key}, ) assert resp.status_code == 201 body = await resp.get_json() assert body["artist"]["name"] == "Studio Q" assert body["created_artist"] is True assert body["source"]["url"] == f"https://discord.com/channels/{_GUILD}" @pytest.mark.asyncio async def test_a_discord_add_with_no_artist_and_no_token_names_the_server(client, ext_key): """No stored token means no server name to read; the fallback still names a readable artist rather than slugifying the ids.""" resp = await client.post( "/api/extension/quick-add-source", json={"url": f"https://discord.com/channels/{_GUILD}/{_CHAN}"}, headers={"X-Extension-Key": ext_key}, ) assert resp.status_code == 201 assert (await resp.get_json())["artist"]["name"] == f"Discord {_GUILD}" @pytest.mark.asyncio async def test_re_adding_a_discord_channel_keeps_its_artist(client, ext_key, db): """Identity by source (#130), compared by ids: a row stored with a trailing slash is the same channel, and naming another artist does not move it.""" owner, src = await _discord_source( db, "Owner", f"https://discord.com/channels/{_GUILD}/{_CHAN}/", ) resp = await client.post( "/api/extension/quick-add-source", json={"url": f"https://discord.com/channels/{_GUILD}/{_CHAN}", "artist_name": "Someone Else"}, headers={"X-Extension-Key": ext_key}, ) assert resp.status_code == 200 body = await resp.get_json() assert body["source"]["id"] == src.id assert body["artist"]["id"] == owner.id @pytest.mark.asyncio async def test_quick_add_with_a_missing_artist_id_is_404(client, ext_key): resp = await client.post( "/api/extension/quick-add-source", json={"url": f"https://discord.com/channels/{_GUILD}/{_CHAN}", "artist_id": 987654}, headers={"X-Extension-Key": ext_key}, ) assert resp.status_code == 404 @pytest.mark.asyncio async def test_quick_add_rejects_a_non_integer_artist_id(client, ext_key): resp = await client.post( "/api/extension/quick-add-source", json={"url": f"https://discord.com/channels/{_GUILD}/{_CHAN}", "artist_id": "7"}, headers={"X-Extension-Key": ext_key}, ) assert resp.status_code == 400 async def _probe(client, ext_key, url): resp = await client.get( "/api/extension/probe", query_string={"url": url}, headers={"X-Extension-Key": ext_key}, ) assert resp.status_code == 200 return await resp.get_json() @pytest.mark.asyncio async def test_probe_a_fresh_discord_channel_is_new_with_both_urls(client, ext_key): body = await _probe(client, ext_key, f"https://discord.com/channels/{_GUILD}/{_CHAN}") assert body["state"] == "new" assert body["platform"] == "discord" d = body["discord"] assert d["server_id"] == _GUILD and d["channel_id"] == _CHAN assert d["server_url"] == f"https://discord.com/channels/{_GUILD}" assert d["channel_url"] == f"https://discord.com/channels/{_GUILD}/{_CHAN}" # No token stored → no names, and no error. assert d["server_name"] is None and d["channel_name"] is None @pytest.mark.asyncio async def test_probe_suggests_the_artist_who_owns_another_channel_on_the_server( client, ext_key, db, ): owner, _ = await _discord_source( db, "Owner", f"https://discord.com/channels/{_GUILD}/{_OTHER_CHAN}", ) body = await _probe(client, ext_key, f"https://discord.com/channels/{_GUILD}/{_CHAN}") assert body["state"] == "artist_match" assert body["artist"]["id"] == owner.id assert "source" not in body @pytest.mark.asyncio async def test_probe_finds_the_channel_under_any_artist(client, ext_key, db): owner, src = await _discord_source( db, "Owner", f"https://discord.com/channels/{_GUILD}/{_CHAN}", ) body = await _probe(client, ext_key, f"https://discord.com/channels/{_GUILD}/{_CHAN}/555") assert body["state"] == "source_match" assert body["source"]["id"] == src.id assert body["artist"]["id"] == owner.id assert body["covered_by_server"] is False @pytest.mark.asyncio async def test_probe_a_channel_is_covered_by_a_whole_server_source(client, ext_key, db): _, src = await _discord_source(db, "Owner", f"https://discord.com/channels/{_GUILD}") body = await _probe(client, ext_key, f"https://discord.com/channels/{_GUILD}/{_CHAN}") assert body["state"] == "source_match" assert body["source"]["id"] == src.id assert body["covered_by_server"] is True @pytest.mark.asyncio async def test_probe_a_discord_dm_is_not_a_source(client, ext_key): body = await _probe(client, ext_key, f"https://discord.com/channels/@me/{_CHAN}") assert body["state"] == "unknown_platform" # --- Patreon is canon: the Add panel's names and the rename (milestone 429) --- @pytest.fixture def platform_names(monkeypatch): """Stub both platforms' display-name lookups (no network in tests).""" from backend.app.services import patreon_resolver from backend.app.services.credential_service import CredentialService from backend.app.services.subscribestar_client import SubscribeStarClient async def _cookies(self, platform): return "/tmp/cookies.txt" names = {"patreon": "Tamada Heijun", "subscribestar": "SS Tamada"} monkeypatch.setattr(CredentialService, "get_cookies_path", _cookies) monkeypatch.setattr( patreon_resolver, "resolve_display_name", lambda v, c: names["patreon"], ) monkeypatch.setattr( SubscribeStarClient, "resolve_display_name", lambda self, u: names["subscribestar"], ) return names @pytest.mark.asyncio async def test_probe_with_names_reads_the_patreon_display_name(client, ext_key, platform_names): resp = await client.get( "/api/extension/probe", query_string={"url": "https://www.patreon.com/tamadaheijun", "names": "1"}, headers={"X-Extension-Key": ext_key}, ) body = await resp.get_json() assert body["state"] == "new" assert body["display_name"] == "Tamada Heijun" @pytest.mark.asyncio async def test_a_plain_probe_does_not_look_the_name_up(client, ext_key, platform_names): resp = await client.get( "/api/extension/probe", query_string={"url": "https://www.patreon.com/tamadaheijun"}, headers={"X-Extension-Key": ext_key}, ) assert "display_name" not in await resp.get_json() async def _artist(db, name, slug): artist = Artist(name=name, slug=slug, is_subscription=True) db.add(artist) await db.commit() return artist @pytest.mark.asyncio async def test_a_patreon_source_renames_the_artist_it_joins_to_the_patreon_name( client, ext_key, db, db_sync, platform_names, ): artist = await _artist(db, "tamada", "tamada") resp = await client.post( "/api/extension/quick-add-source", json={"url": "https://www.patreon.com/tamadaheijun", "artist_id": artist.id, "use_platform_name": True}, headers={"X-Extension-Key": ext_key}, ) assert resp.status_code == 201 body = await resp.get_json() assert body["artist"]["name"] == "Tamada Heijun" assert body["renamed_from"] == "tamada" # Name only: the slug, and every path keyed off it, stays. row = db_sync.execute(select(Artist.name, Artist.slug).where(Artist.id == artist.id)).one() assert tuple(row) == ("Tamada Heijun", "tamada") @pytest.mark.asyncio async def test_no_rename_without_the_flag(client, ext_key, db, platform_names): artist = await _artist(db, "tamada", "tamada") body = await (await client.post( "/api/extension/quick-add-source", json={"url": "https://www.patreon.com/tamadaheijun", "artist_id": artist.id}, headers={"X-Extension-Key": ext_key}, )).get_json() assert body["artist"]["name"] == "tamada" assert "renamed_from" not in body @pytest.mark.asyncio async def test_an_unreadable_patreon_name_never_renames_to_the_handle( client, ext_key, db, platform_names, ): platform_names["patreon"] = None artist = await _artist(db, "Tamada", "tamada") body = await (await client.post( "/api/extension/quick-add-source", json={"url": "https://www.patreon.com/tamadaheijun", "artist_id": artist.id, "use_platform_name": True}, headers={"X-Extension-Key": ext_key}, )).get_json() assert body["artist"]["name"] == "Tamada" assert "renamed_from" not in body @pytest.mark.asyncio async def test_only_patreon_names_are_canon(client, ext_key, db, platform_names): """A SubscribeStar source joins the picked artist under the name it has.""" artist = await _artist(db, "Tamada Heijun", "tamada-heijun") body = await (await client.post( "/api/extension/quick-add-source", json={"url": "https://subscribestar.adult/tamada", "artist_id": artist.id, "use_platform_name": True}, headers={"X-Extension-Key": ext_key}, )).get_json() assert body["artist"]["name"] == "Tamada Heijun" assert "renamed_from" not in body # --- /api/extension/manifest --------------------------------------- @pytest.mark.asyncio async def test_extension_manifest_returns_404_when_dir_missing(client, monkeypatch, tmp_path): monkeypatch.setattr(extension_module, "XPI_DIR", tmp_path / "does-not-exist") resp = await client.get("/api/extension/manifest") assert resp.status_code == 404 body = await resp.get_json() assert body == {"installed": False} @pytest.mark.asyncio async def test_extension_manifest_returns_404_when_no_xpi_files(client, monkeypatch, tmp_path): monkeypatch.setattr(extension_module, "XPI_DIR", tmp_path) resp = await client.get("/api/extension/manifest") assert resp.status_code == 404 @pytest.mark.asyncio async def test_extension_manifest_returns_metadata_when_xpi_present(client, monkeypatch, tmp_path): xpi = tmp_path / "fabledcurator-1.2.3.xpi" xpi.write_bytes(b"fake-xpi-content") monkeypatch.setattr(extension_module, "XPI_DIR", tmp_path) resp = await client.get("/api/extension/manifest") assert resp.status_code == 200 body = await resp.get_json() assert body["installed"] is True assert body["version"] == "1.2.3" assert body["xpi_url"] == "/extension/fabledcurator-1.2.3.xpi" assert body["latest_url"] == "/extension/fabledcurator-latest.xpi" assert body["sha256"] == hashlib.sha256(b"fake-xpi-content").hexdigest() @pytest.mark.asyncio async def test_extension_manifest_reports_the_channel_the_image_declares( client, monkeypatch, tmp_path ): """The channel travels BESIDE the version, never inside it. Folding it in as a `1.0.3499884-dev` suffix is the failure this design exists to avoid: the extension's comparator parses each dotted segment as an integer, so a suffixed segment collapses to 0 and every dev build compares equal to every other — "no update available" and "I cannot read this version" stop being distinguishable. Asserting the two are separate keys is what keeps a future edit from merging them. """ (tmp_path / "fabledcurator-1.2.3.xpi").write_bytes(b"x") monkeypatch.setattr(extension_module, "XPI_DIR", tmp_path) monkeypatch.setattr(extension_module, "FC_CHANNEL", "dev") resp = await client.get("/api/extension/manifest") assert resp.status_code == 200 body = await resp.get_json() assert body["channel"] == "dev" assert body["version"] == "1.2.3" @pytest.mark.asyncio async def test_extension_manifest_omits_the_channel_when_the_image_declares_none( client, monkeypatch, tmp_path ): """A local build, or any image from before the field existed. The key must be ABSENT rather than present-and-empty: absence is the state every consumer already handles (an older image conveys it by not having the key at all), so a blank channel reuses that path instead of introducing a second spelling of "unknown" for each reader to special-case. """ (tmp_path / "fabledcurator-1.2.3.xpi").write_bytes(b"x") monkeypatch.setattr(extension_module, "XPI_DIR", tmp_path) monkeypatch.setattr(extension_module, "FC_CHANNEL", "") resp = await client.get("/api/extension/manifest") assert resp.status_code == 200 body = await resp.get_json() assert "channel" not in body # Everything else still answers — an image with no channel is not a # degraded one, it just cannot say which channel it came from. assert body["installed"] is True assert body["latest_url"] == "/extension/fabledcurator-latest.xpi" # --- /extension/ ----------------------------------------- @pytest.mark.asyncio async def test_serve_extension_rejects_non_xpi_filename(client, monkeypatch, tmp_path): monkeypatch.setattr(frontend_module, "XPI_DIR", tmp_path) resp = await client.get("/extension/passwd") assert resp.status_code == 404 @pytest.mark.asyncio async def test_serve_extension_rejects_path_traversal(client, monkeypatch, tmp_path): monkeypatch.setattr(frontend_module, "XPI_DIR", tmp_path) # Path-traversal attempt — the route regex alone catches this since # `..` characters aren't in [\w.-]+, but cover the case anyway. resp = await client.get("/extension/fabledcurator-..%2Fetc%2Fpasswd.xpi") assert resp.status_code == 404 @pytest.mark.asyncio async def test_serve_extension_404_when_xpi_missing(client, monkeypatch, tmp_path): monkeypatch.setattr(frontend_module, "XPI_DIR", tmp_path) resp = await client.get("/extension/fabledcurator-1.0.0.xpi") assert resp.status_code == 404 @pytest.mark.asyncio async def test_serve_extension_serves_specific_xpi_with_correct_mime( client, monkeypatch, tmp_path, ): xpi = tmp_path / "fabledcurator-1.0.0.xpi" xpi.write_bytes(b"xpi-bytes") monkeypatch.setattr(frontend_module, "XPI_DIR", tmp_path) resp = await client.get("/extension/fabledcurator-1.0.0.xpi") assert resp.status_code == 200 assert resp.headers["Content-Type"].startswith("application/x-xpinstall") @pytest.mark.asyncio async def test_serve_extension_latest_returns_most_recent_xpi( client, monkeypatch, tmp_path, ): import os import time older = tmp_path / "fabledcurator-1.0.0.xpi" newer = tmp_path / "fabledcurator-1.0.1.xpi" older.write_bytes(b"old") newer.write_bytes(b"new") os.utime(older, (time.time() - 10, time.time() - 10)) monkeypatch.setattr(frontend_module, "XPI_DIR", tmp_path) resp = await client.get("/extension/fabledcurator-latest.xpi") assert resp.status_code == 200 data = await resp.get_data() assert data == b"new" @pytest.mark.asyncio async def test_the_latest_alias_is_never_served_from_a_stale_cache( client, monkeypatch, tmp_path, ): """One URL whose bytes change every release: a cached copy reinstalls the previous build (operator-flagged 2026-09-25, when it was max-age=43200).""" (tmp_path / "fabledcurator-1.0.1.xpi").write_bytes(b"new") monkeypatch.setattr(frontend_module, "XPI_DIR", tmp_path) resp = await client.get("/extension/fabledcurator-latest.xpi") assert resp.headers["Cache-Control"] == "no-cache" assert "Expires" not in resp.headers @pytest.mark.asyncio async def test_a_versioned_xpi_is_cached_for_good(client, monkeypatch, tmp_path): """A versioned name is one build's bytes forever.""" (tmp_path / "fabledcurator-1.0.1.xpi").write_bytes(b"new") monkeypatch.setattr(frontend_module, "XPI_DIR", tmp_path) resp = await client.get("/extension/fabledcurator-1.0.1.xpi") assert "immutable" in resp.headers["Cache-Control"] @pytest.mark.asyncio async def test_serve_extension_latest_404_when_dir_empty(client, monkeypatch, tmp_path): monkeypatch.setattr(frontend_module, "XPI_DIR", tmp_path) resp = await client.get("/extension/fabledcurator-latest.xpi") assert resp.status_code == 404