from __future__ import annotations from urllib.error import URLError import pytest from codexpi import app_store_service from codexpi.app_store_service import AppStoreService from codexpi.portfolio_catalog import CATALOG def test_app_store_assigns_one_stable_endpoint_per_catalog_app(logged_in): client, app, *_ = logged_in response = client.get("/api/app-store/apps") assert response.status_code == 200 apps = response.json() assert len(apps) == 32 managed = [item for item in apps if item["managed"]] deployed = [item for item in apps if not item["managed"]] assert len(managed) == 18 assert len(deployed) == 14 assert len({item["endpoint"] for item in managed}) == 18 assert all(item["endpoint"] == f"/{item['slug']}/" for item in managed) assert {item["slug"] for item in deployed} >= {"nasflix", "jellyfin", "youtube-carplay", "gameflix", "recette"} assert next(item for item in apps if item["slug"] == "loutre-app-store")["launch_path"] == "/loutre-app-store/" assert next(item for item in apps if item["slug"] == "pwa-factory")["open_url"] == "/pwa-factory/" assert next(item for item in apps if item["slug"] == "pi-doctor")["open_url"] == "/pi-doctor/" assert next(item for item in apps if item["slug"] == "codex-commander")["open_url"] == "/codex-commander/" assert next(item for item in apps if item["slug"] == "autonomous-dev-studio")["open_url"] == "/autonomous-dev-studio/" assert next(item for item in apps if item["slug"] == "loutre-finance")["open_url"] == "/loutre-finance/" assert next(item for item in apps if item["slug"] == "smart-inventory")["open_url"] == "/smart-inventory/" assert next(item for item in apps if item["slug"] == "auto-care")["open_url"] == "/auto-care/" assert next(item for item in apps if item["slug"] == "print-pilot")["open_url"] == "/print-pilot/" assert next(item for item in apps if item["slug"] == "loutre-home")["open_url"] == "/loutre-home/" assert next(item for item in apps if item["slug"] == "network-guardian")["open_url"] == "/network-guardian/" assert next(item for item in apps if item["slug"] == "reverse-proxy-manager")["open_url"] == "/reverse-proxy-manager/" assert next(item for item in apps if item["slug"] == "loutre-brain")["open_url"] == "/loutre-brain/" assert next(item for item in apps if item["slug"] == "knowledge-graph")["open_url"] == "/knowledge-graph/" assert next(item for item in apps if item["slug"] == "trip-organizer")["open_url"] == "/trip-organizer/" assert app.state.db.one("SELECT COUNT(*) AS n FROM app_store_apps")["n"] == 18 def test_app_store_is_a_codexpi_module(): project = next(item for item in CATALOG if item.slug == "loutre-app-store") assert project.module_of == "CodexPi" assert project.target_directory == "/home/config/CodexPi" assert project.repository == "CodexPi" def test_app_store_application_page_and_manifest_are_specific(logged_in): client, *_ = logged_in page = client.get("/loutre-os/", follow_redirects=False) manifest = client.get("/loutre-os/manifest.webmanifest") assert page.status_code == 200 assert 'rel="manifest" href="/loutre-os/manifest.webmanifest"' in page.text assert "Interface de LoutreOS" in page.text assert "Santé CPU, RAM, disque et température" in page.text assert "Ouvrir" not in page.text assert "data-pwa-tutorial hidden" in page.text assert 'src="/loutre-app-store/pwa-status.js?v=1"' in page.text assert manifest.status_code == 200 assert manifest.json()["start_url"] == "/loutre-os/" assert manifest.json()["scope"] == "/loutre-os/" assert manifest.json()["icons"][0]["src"] == "/loutre-app-store/app-icons/loutre-os-192.png" def test_installed_pwa_stays_on_its_public_application_endpoint(logged_in): client, app, *_ = logged_in page = client.get("/pi-doctor/") launched = client.get("/pi-doctor/?launch=1", follow_redirects=False) manifest = client.get("/pi-doctor/manifest.webmanifest").json() script = client.get("/pi-doctor/app.js") assert "Centre de diagnostic" in page.text assert 'src="/pi-doctor/app.js"' in page.text assert "Activité de développement" not in page.text assert ">Tâches<" not in page.text assert launched.status_code == 200 assert "Centre de diagnostic" in launched.text assert "État général" in launched.text assert "Incidents" in launched.text assert "Diagnostic Codex" in launched.text assert "Réparation" in launched.text assert "Historique" in launched.text assert script.status_code == 200 assert script.headers["content-type"].startswith("application/javascript") assert "'/api/pi-doctor'" in script.text assert "textContent" in script.text assert "innerHTML" not in script.text assert manifest["start_url"] == "/pi-doctor/" def test_autonomous_dev_studio_bookmark_opens_the_real_workflow(logged_in): client, *_ = logged_in page = client.get("/autonomous-dev-studio/") script = client.get("/autonomous-dev-studio/app.js") assert page.status_code == 200 assert "DU BESOIN À LA REVUE" in page.text assert "Créer le plan à valider" in page.text assert 'src="/autonomous-dev-studio/app.js"' in page.text assert "Interface de Autonomous Dev Studio" not in page.text assert ">Tâches<" not in page.text assert script.status_code == 200 assert script.headers["content-type"].startswith("application/javascript") assert "'/api/dev-studio/projects'" in script.text assert "'/api/dev-studio/requests'" in script.text assert "textContent" in script.text assert "innerHTML" not in script.text def test_codex_commander_bookmark_uses_real_codexpi_sessions(logged_in): client, *_ = logged_in page = client.get("/codex-commander/") script = client.get("/codex-commander/app.js") assert page.status_code == 200 assert "SESSIONS CODEX RÉELLES" in page.text assert "Créer et démarrer la session Codex" in page.text assert 'src="/codex-commander/app.js"' in page.text assert "Interface de Codex Commander" not in page.text assert script.status_code == 200 assert script.headers["content-type"].startswith("application/javascript") assert "'/api/projects'" in script.text assert "/conversations`" in script.text assert "/queue`" in script.text assert "/stop`" in script.text assert "textContent" in script.text assert "innerHTML" not in script.text @pytest.mark.parametrize( ("slug", "destination"), [ ("nasflix-media-health", "https://loutrenas.myqnapcloud.com/#admin-integrity"), ("nasflix-ai-media-curator", "https://loutrenas.myqnapcloud.com/#curator"), ], ) def test_nasflix_modules_open_their_real_existing_interfaces(logged_in, slug, destination): client, *_ = logged_in response = client.get(f"/{slug}/", follow_redirects=False) assert response.status_code == 307 assert response.headers["location"] == destination def test_nasflix_modules_are_installable_only_after_real_route_is_registered(logged_in): client, *_ = logged_in apps = client.get("/api/app-store/apps").json() assert next(item for item in apps if item["slug"] == "nasflix-media-health")["installable"] == 1 assert next(item for item in apps if item["slug"] == "nasflix-ai-media-curator")["installable"] == 1 def test_codex_commander_business_flow_creates_and_queues_real_session(logged_in, monkeypatch): client, app, projects, _, headers = logged_in folder = projects / "commander-real" folder.mkdir() project = client.post( "/api/projects", headers=headers, json={"name": "Commander real", "path": str(folder)} ).json() monkeypatch.setattr(app.state.codex, "detect", lambda: "/bin/true") monkeypatch.setattr(app.state.codex, "_spawn", lambda *_args: 4242) session = client.post( f"/api/projects/{project['id']}/conversations", headers=headers, json={"title": "Agent Loutre · tester", "engine": "codex"}, ) assert session.status_code == 201 queued = client.post( f"/api/conversations/{session.json()['id']}/queue", headers=headers, json={"content": "RÔLE COMMANDER : tester. Vérifie réellement les tests métier."}, ) assert queued.status_code == 202 persisted = client.get(f"/api/conversations/{session.json()['id']}/queue") assert persisted.status_code == 200 assert persisted.json()[0]["content"].startswith("RÔLE COMMANDER : tester") def test_app_store_share_button_uses_public_bookmark_endpoint(logged_in): client, app, *_ = logged_in app.state.app_store.launch_base_url = "https://codexpi.tail5a36dc.ts.net" apps = client.get("/api/app-store/apps").json() loutre_os = next(item for item in apps if item["slug"] == "loutre-os") codexpi = next(item for item in apps if item["slug"] == "codexpi") catalog = client.get("/loutre-app-store/") expected = "https://codexpi.tail5a36dc.ts.net/loutre-os/" assert loutre_os["launch_url"] == expected assert codexpi["share_url"] == "https://codexpi.tail5a36dc.ts.net/" assert 'data-url="/loutre-os/"' in catalog.text assert 'aria-label="Partager LoutreOS pour l’ajouter à l’écran d’accueil"' in catalog.text assert "PARTAGER" in catalog.text assert ">OUVRIR<" not in catalog.text assert client.get("/loutre-os/?launch=1", follow_redirects=False).status_code == 200 @pytest.mark.parametrize("value", [ "http://codexpi.example.test", "https://user:password@codexpi.example.test", "https://codexpi.example.test/untrusted/path", "https://codexpi.example.test?next=https://evil.invalid", ]) def test_app_store_rejects_unsafe_launch_origins(app_env, value): _, app, *_ = app_env with pytest.raises(ValueError, match="origine HTTPS sûre"): AppStoreService(app.state.db, value) def test_app_store_serves_project_specific_icons(logged_in): client, app, *_ = logged_in icon_dir = app.state.settings.frontend_dist / "app-icons" icon_dir.mkdir(parents=True) (icon_dir / "loutre-os-192.png").write_bytes(b"project-icon") (icon_dir / "loutre-os-apple.png").write_bytes(b"apple-icon") icon = client.get("/loutre-os/icon-192.png") apple = client.get("/loutre-os/apple-touch-icon.png") assert icon.status_code == 200 assert icon.headers["content-type"] == "image/png" assert icon.content == b"project-icon" assert apple.status_code == 200 assert apple.content == b"apple-icon" assert client.get("/loutre-os/icon-128.png").status_code == 404 stable = client.get("/loutre-app-store/app-icons/loutre-os-192.png") assert stable.status_code == 200 assert stable.headers["content-type"] == "image/png" assert stable.content == b"project-icon" assert client.get("/loutre-app-store/app-icons/unknown-192.png").status_code == 404 def test_app_store_bookmark_requires_authentication(app_env): client, app, *_ = app_env icon_dir = app.state.settings.frontend_dist / "app-icons" icon_dir.mkdir(parents=True) (icon_dir / "loutre-app-store-apple.png").write_bytes(b"public-apple-icon") assert client.get("/loutre-os/").status_code == 401 icon = client.get("/loutre-app-store/apple-touch-icon.png") assert icon.status_code == 200 assert icon.headers["content-type"] == "image/png" assert icon.content == b"public-apple-icon" def test_loutre_app_store_renders_read_only_catalog(logged_in): client, *_ = logged_in response = client.get("/loutre-app-store/") assert response.status_code == 200 assert response.headers["x-robots-tag"] == "noindex, nofollow" assert "Loutre App Store" in response.text assert 'data-url="/pi-doctor/"' in response.text assert 'data-url="/loutre-os/"' in response.text assert 'src="/loutre-app-store/app-icons/pi-doctor-192.png"' in response.text assert 'src="/loutre-app-store/known-icons/nasflix.png"' in response.text assert 'src="/loutre-app-store/share.js?v=2"' in response.text assert 'class="featured"' in response.text def test_app_store_does_not_label_unpublished_apps_as_local(logged_in): client, *_ = logged_in response = client.get("/loutre-app-store/") assert response.status_code == 200 assert ">LOCAL<" not in response.text assert "NON DÉPLOYÉE" in response.text def test_app_store_share_script_guides_ios_to_safaris_own_share_button(logged_in): client, *_ = logged_in response = client.get("/loutre-app-store/share.js") assert response.status_code == 200 assert response.headers["content-type"].startswith("application/javascript") assert response.headers["x-content-type-options"] == "nosniff" assert "navigator.share" not in response.text assert "Ouvrir dans Safari" in response.text assert "Sur l’écran d’accueil" in response.text assert "window.open" in response.text def test_application_page_hides_install_tutorial_in_standalone_mode(logged_in): client, *_ = logged_in response = client.get("/loutre-app-store/pwa-status.js") assert response.status_code == 200 assert response.headers["content-type"].startswith("application/javascript") assert "(display-mode: standalone)" in response.text assert "navigator.standalone" in response.text assert "tutorial.remove()" in response.text def test_external_session_validation_fails_closed(monkeypatch): class Response: status = 200 def __enter__(self): return self def __exit__(self, *_args): return None class Opener: def __init__(self, response=None, error=None): self.response = response self.error = error def open(self, _request, timeout): assert timeout == 3 if self.error: raise self.error return self.response monkeypatch.setattr(app_store_service, "build_opener", lambda: Opener(Response())) assert app_store_service.external_session_is_valid("http://nasflix/auth/me", "session=safe") is True monkeypatch.setattr(app_store_service, "build_opener", lambda: Opener(error=URLError("offline"))) assert app_store_service.external_session_is_valid("http://nasflix/auth/me", "session=safe") is False assert app_store_service.external_session_is_valid("http://nasflix/auth/me", "") is False def test_app_store_rejects_external_or_colliding_launch_paths(logged_in): client, *_rest, headers = logged_in app = next(item for item in client.get("/api/app-store/apps").json() if item["slug"] == "loutre-os") external = client.put( f"/api/app-store/apps/{app['id']}", headers=headers, json={"launch_path": "https://example.invalid/steal"}, ) colliding = client.put( f"/api/app-store/apps/{app['id']}", headers=headers, json={"launch_path": "/network-guardian/"}, ) assert external.status_code == 422 assert colliding.status_code == 422 def test_app_store_sync_preserves_admin_customization(logged_in): client, *_rest, headers = logged_in app = next(item for item in client.get("/api/app-store/apps").json() if item["slug"] == "loutre-os") updated = client.put( f"/api/app-store/apps/{app['id']}", headers=headers, json={"launch_path": "/?portfolio=custom-dashboard", "installable": False}, ) synced = client.post("/api/app-store/sync", headers=headers) refreshed = next(item for item in client.get("/api/app-store/apps").json() if item["id"] == app["id"]) assert updated.status_code == 200 assert synced.status_code == 200 assert refreshed["launch_path"] == "/?portfolio=custom-dashboard" assert refreshed["installable"] == 0