Files
FabledCurator/tests/test_api_extension.py
T
bvandeusenandClaude Opus 5.5 83e1382812
CI and images / lint (push) Successful in 3s
CI and images / extension-version (push) Successful in 3s
CI and images / frontend-build (push) Successful in 22s
CI and images / extension-test (push) Successful in 22s
CI and images / backend-lint-and-test (push) Successful in 33s
CI and images / integration (push) Successful in 2m23s
CI and images / build-agent (push) Successful in 5s
CI and images / sign-extension (push) Successful in 2m29s
CI and images / build-web (push) Successful in 1m43s
CI and images / smoke-web (push) Successful in 55s
CI and images / promote (push) Successful in 2s
fix: extension updates install the new build — no 12h-cached "latest" XPI, and the popup's Update opens FC's install page
Operator, 2026-09-25: "the extension update trigger from inside the
extension doesn't work and the manual update seems to not move it to the
most recent version or at least mark it the most recent."

- fabledcurator-latest.xpi was served with Quart's default
  `public, max-age=43200`: one URL whose bytes change every release, so a
  browser that had fetched it reinstalled the previous build for 12 hours
  (measured on the instance). It is now `no-cache` (the ETag keeps an
  unchanged file a 304); versioned XPIs are `immutable`.
- The web Settings card installs/downloads the VERSIONED xpi_url, which can
  only ever be that build's bytes.
- The popup's Update button did tabs.create() on the .xpi, which Firefox
  refuses (NS_ERROR_FAILURE on a 200: it only installs from a user click on
  a web page). It now opens FC's install card (/subscriptions?tab=settings).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
2026-09-25 08:01:02 -04:00

809 lines
30 KiB
Python

"""FC-3g: /api/extension and /extension/<filename> 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/<filename> -----------------------------------------
@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