From e55fde2618fa83abcf0b1272fed93dbe1395a0fc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 20 Sep 2026 17:10:28 +0000 Subject: [PATCH] fix(desktop): stop local-session 403 from forcing the login wall Packaged WebView retries of POST /api/auth/local-session were returning 403 when the existing ~/.freeos DB had more than one user or OCTOP_DESKTOP was shadowed on Windows, then AuthGuard dropped into register/login. Issue a studio JWT on this device (loopback, *.localhost, Origin, or desktop env), overlay launch env so Windows keeps OCTOP_DESKTOP=1, and remember ?desktop=1 before the SPA replaces / with /projects. Co-authored-by: XYAI Labs --- CHANGELOG.md | 4 + dashboard/src/api/request.ts | 19 ++++- dashboard/src/components/AuthGuard.test.tsx | 27 +++++++ dashboard/src/components/AuthGuard.tsx | 2 +- .../src/hooks/useUnauthorizedRedirect.ts | 2 + dashboard/src/main.tsx | 3 + dashboard/src/routes/index.tsx | 2 +- dashboard/src/utils/desktopShell.test.ts | 7 ++ dashboard/src/utils/desktopShell.ts | 44 +++++++---- desktop/src/process.go | 40 +++++++++- desktop/src/process_env_test.go | 48 ++++++++++++ src/octop/api/routers/auth.py | 29 ++++--- src/octop/api/routers/org_identity.py | 6 +- src/octop/infra/users/local_session.py | 75 +++++++++++++++---- src/octop/modules/org_os/integration.py | 5 +- tests/integration/test_local_session.py | 66 +++++++++++++++- tests/unit/users/test_local_session.py | 40 ++++++++++ 17 files changed, 369 insertions(+), 50 deletions(-) create mode 100644 desktop/src/process_env_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 234c83a6..d3035e52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ - 桌面首次启动不再要求注册/登录。首屏是可选模型配置(云密钥或本机 Ollama,可跳过);跳过或保存后进入默认智能体对话,而不是停在工作台列表。已有提供商或会话的用户不会被再次拦住。账号仍可稍后在头像菜单里领取,供保存/导出/组织房间使用。首次运行与安全条目一致:不预填云密钥,本机 Ollama 优先。 +### 修复 + +- 桌面 `POST /api/auth/local-session` 对已有 `~/.freeos`(多用户 / 组织映射行)或 WebView 非 `127.0.0.1` Host 返回 403,前端重试后掉进注册登录。本机会话在 loopback / `*.localhost` / Origin 为本机时签发 JWT 并选用已有工作室账号;Windows 宿主覆盖父进程残留的 `OCTOP_DESKTOP`;SPA 在 `/` 跳到 `/projects` 丢掉 `?desktop=1` 之前记住桌面壳,不再强制登录。 + ### 文档 - 增加阿拉伯语(`README.ar.md`)与葡萄牙语(`README.pt.md`)项目简介,并在各语言 README 的语言切换链接中列出。 diff --git a/dashboard/src/api/request.ts b/dashboard/src/api/request.ts index 9d8ef80a..6fecd513 100644 --- a/dashboard/src/api/request.ts +++ b/dashboard/src/api/request.ts @@ -95,7 +95,10 @@ function handleSetupRequired(): void { /** Remembered Wails shell flag — keep ``?desktop=1`` if the query was dropped. */ function setupRedirectPath(): string { try { - if (window.sessionStorage.getItem("freeos:desktop-shell") === "1") { + if ( + window.sessionStorage.getItem("freeos:desktop-shell") === "1" || + window.localStorage.getItem("freeos:desktop-shell") === "1" + ) { return "/setup?desktop=1"; } } catch { @@ -204,7 +207,7 @@ function buildHeaders(path: string, extra?: HeadersInit): HeadersInit { // Apply the global JWT first; the caller's `extra` (including a // wizard token) can still override it below. - const token = getAuthToken(); + const token = getAuthToken().trim(); if (token) { headers.Authorization = `Bearer ${token}`; } @@ -242,7 +245,7 @@ function buildAuthHeaders(path: string): Record { const headers: Record = { "Accept-Language": i18n.language?.startsWith("zh") ? "zh" : "en", }; - const token = getAuthToken(); + const token = getAuthToken().trim(); if (token) { headers.Authorization = `Bearer ${token}`; } @@ -271,6 +274,16 @@ function handleUnauthorized(): void { path.startsWith("/invite") ) return; + try { + if ( + window.sessionStorage.getItem("freeos:desktop-shell") === "1" || + window.localStorage.getItem("freeos:desktop-shell") === "1" + ) { + return; + } + } catch { + /* private mode */ + } _redirectingToLogin = true; const takenOver = !window.dispatchEvent( diff --git a/dashboard/src/components/AuthGuard.test.tsx b/dashboard/src/components/AuthGuard.test.tsx index fca54a28..64e6839f 100644 --- a/dashboard/src/components/AuthGuard.test.tsx +++ b/dashboard/src/components/AuthGuard.test.tsx @@ -282,4 +282,31 @@ describe("AuthGuard local session", () => { expect(screen.queryByText("usable app")).toBeNull(); view.unmount(); }); + + it("keeps retrying after the router drops ?desktop=1", async () => { + sessionStorage.setItem("freeos:desktop-shell", "1"); + getAuthStatus.mockResolvedValue({ setup_required: false, desktop: false }); + localSession.mockRejectedValue(new Error("interactive login required")); + + const view = render( + + + +
usable app
+ + } + /> + login wall} /> + setup wizard} /> +
+
, + ); + + await waitFor(() => expect(localSession).toHaveBeenCalled()); + expect(screen.queryByText("login wall")).toBeNull(); + view.unmount(); + }); }); diff --git a/dashboard/src/components/AuthGuard.tsx b/dashboard/src/components/AuthGuard.tsx index 490c578a..9dae20fd 100644 --- a/dashboard/src/components/AuthGuard.tsx +++ b/dashboard/src/components/AuthGuard.tsx @@ -125,7 +125,7 @@ export default function AuthGuard({ children }: AuthGuardProps) { return; } - const token = getAuthToken(); + const token = getAuthToken().trim(); if (!token) { if ( await tryLocalSession( diff --git a/dashboard/src/hooks/useUnauthorizedRedirect.ts b/dashboard/src/hooks/useUnauthorizedRedirect.ts index 73ed31f2..408d8515 100644 --- a/dashboard/src/hooks/useUnauthorizedRedirect.ts +++ b/dashboard/src/hooks/useUnauthorizedRedirect.ts @@ -1,6 +1,7 @@ import { useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { UNAUTHORIZED_EVENT } from "../api/request"; +import { isDesktopShell } from "../utils/desktopShell"; /** * Route an expired session to /login through the router. @@ -15,6 +16,7 @@ export function useUnauthorizedRedirect(): void { useEffect(() => { const handler = (event: Event) => { event.preventDefault(); + if (isDesktopShell()) return; navigate("/login", { replace: true }); }; window.addEventListener(UNAUTHORIZED_EVENT, handler); diff --git a/dashboard/src/main.tsx b/dashboard/src/main.tsx index 359b7757..b787887a 100644 --- a/dashboard/src/main.tsx +++ b/dashboard/src/main.tsx @@ -2,6 +2,7 @@ // registered synchronously before Chrome fires the event (which can happen // before React mounts and useEffect runs). import "./pwa-prompt"; +import { isDesktopShell } from "./utils/desktopShell"; import { createRoot } from "react-dom/client"; import App from "./App.tsx"; @@ -14,6 +15,8 @@ import { } from "./utils/reloadOnStaleChunk"; if (typeof window !== "undefined") { + // Capture `?desktop=1` before React Router replaces `/`. + isDesktopShell(); // Recover from post-deploy stale hashed chunks (white screen → soft reload). installChunkLoadRecovery(); diff --git a/dashboard/src/routes/index.tsx b/dashboard/src/routes/index.tsx index 37b1d57d..ecfd5a37 100644 --- a/dashboard/src/routes/index.tsx +++ b/dashboard/src/routes/index.tsx @@ -476,6 +476,6 @@ export const routeConfigs: RouteConfig[] = [ // Misc { path: "/pwa-debug", element: }, - { path: "/", element: }, + { path: "/", element: }, { path: "*", element: }, ]; diff --git a/dashboard/src/utils/desktopShell.test.ts b/dashboard/src/utils/desktopShell.test.ts index 092f24ac..4984c926 100644 --- a/dashboard/src/utils/desktopShell.test.ts +++ b/dashboard/src/utils/desktopShell.test.ts @@ -4,6 +4,7 @@ import { isDesktopShell } from "./desktopShell"; describe("isDesktopShell", () => { afterEach(() => { sessionStorage.clear(); + localStorage.clear(); }); it("detects the desktop query and remembers it after the query is dropped", () => { @@ -11,6 +12,12 @@ describe("isDesktopShell", () => { expect(isDesktopShell("")).toBe(true); }); + it("survives a dropped query after React Router replaces /", () => { + expect(isDesktopShell("?desktop=1")).toBe(true); + sessionStorage.clear(); + expect(isDesktopShell("")).toBe(true); + }); + it("is false without the query or a remembered flag", () => { expect(isDesktopShell("")).toBe(false); expect(isDesktopShell("?foo=1")).toBe(false); diff --git a/dashboard/src/utils/desktopShell.ts b/dashboard/src/utils/desktopShell.ts index f7538b72..50333b50 100644 --- a/dashboard/src/utils/desktopShell.ts +++ b/dashboard/src/utils/desktopShell.ts @@ -1,25 +1,39 @@ const DESKTOP_FLAG = "freeos:desktop-shell"; +function rememberDesktopFlag(): void { + if (typeof window === "undefined") return; + try { + window.sessionStorage.setItem(DESKTOP_FLAG, "1"); + window.localStorage.setItem(DESKTOP_FLAG, "1"); + } catch { + // Ignore quota / private-mode failures; the query string still counts. + } +} + +function rememberedDesktopFlag(): boolean { + if (typeof window === "undefined") return false; + try { + return ( + window.sessionStorage.getItem(DESKTOP_FLAG) === "1" || + window.localStorage.getItem(DESKTOP_FLAG) === "1" + ); + } catch { + return false; + } +} + /** True when the SPA is inside the Wails shell (`?desktop=1` or remembered). */ export function isDesktopShell(search?: string): boolean { const query = search ?? (typeof window === "undefined" ? "" : window.location.search); if (new URLSearchParams(query).get("desktop") === "1") { - if (typeof window !== "undefined") { - try { - window.sessionStorage.setItem(DESKTOP_FLAG, "1"); - } catch { - // Ignore quota / private-mode failures; the query string still counts. - } - } + rememberDesktopFlag(); return true; } - if (typeof window === "undefined") { - return false; - } - try { - return window.sessionStorage.getItem(DESKTOP_FLAG) === "1"; - } catch { - return false; - } + return rememberedDesktopFlag(); +} + +/** Capture `?desktop=1` before React Router navigates `/` → `/projects`. */ +if (typeof window !== "undefined") { + isDesktopShell(); } diff --git a/desktop/src/process.go b/desktop/src/process.go index 286795d2..f87d82a6 100644 --- a/desktop/src/process.go +++ b/desktop/src/process.go @@ -11,10 +11,46 @@ import ( ) func mustEnv(cmd *exec.Cmd, extra map[string]string) { - cmd.Env = os.Environ() + cmd.Env = mergeLaunchEnv(os.Environ(), extra) +} + +// mergeLaunchEnv overlays extra onto base so later keys win. On Windows the +// environment is case-insensitive and CreateProcess keeps the first duplicate, +// so a leftover OCTOP_DESKTOP=0 from the parent would otherwise shadow "1". +func mergeLaunchEnv(base []string, extra map[string]string) []string { + type entry struct { + key string + value string + } + order := make([]entry, 0, len(base)+len(extra)) + index := map[string]int{} + put := func(key, value string) { + lk := key + if runtime.GOOS == "windows" { + lk = strings.ToLower(key) + } + if i, ok := index[lk]; ok { + order[i] = entry{key: key, value: value} + return + } + index[lk] = len(order) + order = append(order, entry{key: key, value: value}) + } + for _, pair := range base { + key, value, ok := strings.Cut(pair, "=") + if !ok { + continue + } + put(key, value) + } for key, value := range extra { - cmd.Env = append(cmd.Env, key+"="+value) + put(key, value) + } + out := make([]string, 0, len(order)) + for _, item := range order { + out = append(out, item.key+"="+item.value) } + return out } const defaultSidecarPort = 3780 diff --git a/desktop/src/process_env_test.go b/desktop/src/process_env_test.go new file mode 100644 index 00000000..3ccd8881 --- /dev/null +++ b/desktop/src/process_env_test.go @@ -0,0 +1,48 @@ +package main + +import ( + "runtime" + "strings" + "testing" +) + +func TestMergeLaunchEnvOverridesParentDesktopFlags(t *testing.T) { + got := mergeLaunchEnv( + []string{"PATH=/bin", "OCTOP_DESKTOP=0", "FREEOS_DESKTOP=", "OCTOP_GREEN_PACKAGES="}, + map[string]string{ + "OCTOP_DESKTOP": "1", + "FREEOS_DESKTOP": "1", + "OCTOP_GREEN_PACKAGES": `/opt/freeos/packages`, + }, + ) + env := map[string]string{} + counts := map[string]int{} + for _, pair := range got { + key, value, ok := strings.Cut(pair, "=") + if !ok { + t.Fatalf("bad pair %q", pair) + } + lk := key + if runtime.GOOS == "windows" { + lk = strings.ToLower(key) + } + counts[lk]++ + env[lk] = value + } + if counts["OCTOP_DESKTOP"] != 1 && counts["octop_desktop"] != 1 { + t.Fatalf("duplicate or missing OCTOP_DESKTOP: %+v", got) + } + desktop := env["OCTOP_DESKTOP"] + if desktop == "" { + desktop = env["octop_desktop"] + } + if desktop != "1" { + t.Fatalf("OCTOP_DESKTOP=%q, want 1 in %v", desktop, got) + } + if env["FREEOS_DESKTOP"] != "1" && env["freeos_desktop"] != "1" { + t.Fatalf("FREEOS_DESKTOP not overridden: %v", got) + } + if env["OCTOP_GREEN_PACKAGES"] == "" && env["octop_green_packages"] == "" { + t.Fatalf("OCTOP_GREEN_PACKAGES empty: %v", got) + } +} diff --git a/src/octop/api/routers/auth.py b/src/octop/api/routers/auth.py index 1e73eb3b..5e6b66ab 100644 --- a/src/octop/api/routers/auth.py +++ b/src/octop/api/routers/auth.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from typing import Any from fastapi import APIRouter, Depends, Request, Response @@ -13,26 +14,24 @@ claim_local_account, ensure_local_user, is_desktop_process, - is_loopback_host, is_unclaimed_local_user, + request_looks_local, ) from octop.infra.users.permissions import effective_permissions from octop.infra.utils.locale import normalize_locale, resolve_request_locale router = APIRouter() +logger = logging.getLogger(__name__) def _is_local_client(request: Request) -> bool: - if is_desktop_process(): - return True - host = (request.client.host if request.client else "") or "" - if is_loopback_host(host): - return True - forwarded = (request.headers.get("x-forwarded-for") or "").split(",")[0].strip() - if is_loopback_host(forwarded): - return True - req_host = (request.headers.get("host") or "").split(":")[0].lower() - return is_loopback_host(req_host) + return request_looks_local( + client_host=(request.client.host if request.client else "") or "", + forwarded_for=request.headers.get("x-forwarded-for") or "", + http_host=request.headers.get("host") or "", + origin=request.headers.get("origin") or "", + referer=request.headers.get("referer") or "", + ) def _user_json( @@ -99,6 +98,14 @@ class RegisterBody(BaseModel): async def local_session(request: Request, server: Any = Depends(get_server)) -> dict[str, Any]: """Issue a JWT without a login form on desktop / loopback first launch.""" if not _is_local_client(request): + logger.warning( + "local-session denied client=%s host=%s origin=%s forwarded=%s desktop=%s", + request.client.host if request.client else "", + request.headers.get("host"), + request.headers.get("origin"), + request.headers.get("x-forwarded-for"), + is_desktop_process(), + ) raise OctopError(ErrorCode.FORBIDDEN, "local session is only available on this device") locale = normalize_locale(resolve_request_locale(request)) user = await ensure_local_user(server, locale=locale) diff --git a/src/octop/api/routers/org_identity.py b/src/octop/api/routers/org_identity.py index d9271906..21119b0d 100644 --- a/src/octop/api/routers/org_identity.py +++ b/src/octop/api/routers/org_identity.py @@ -34,12 +34,16 @@ async def identity_status() -> dict[str, Any]: async def forward(request: Request, server: Any, path: str) -> Response: require_integrated() + extra_headers: dict[str, str] = {} + authorization = (request.headers.get("authorization") or "").strip() + if authorization: + extra_headers["Authorization"] = authorization try: return await proxy_request( request, base_url=org_module_from_paths(server.paths).sidecar_url(), path=path, - extra_headers={"Authorization": request.headers.get("authorization", "")}, + extra_headers=extra_headers, ) except httpx.HTTPError as exc: raise OctopError( diff --git a/src/octop/infra/users/local_session.py b/src/octop/infra/users/local_session.py index e15c49c2..16bbf0fb 100644 --- a/src/octop/infra/users/local_session.py +++ b/src/octop/infra/users/local_session.py @@ -7,6 +7,7 @@ import os import re from typing import Any +from urllib.parse import urlparse from octop.config import DatabaseConfig from octop.infra.agents.default_agent import SETUP_DEFAULT_AGENT_ID, try_bootstrap_default_agent @@ -32,18 +33,65 @@ def is_desktop_process() -> bool: return bool((os.environ.get("OCTOP_GREEN_PACKAGES") or "").strip()) +def hostname_from_host_header(host: str) -> str: + """Strip ``:port`` from a Host header, including bracketed IPv6.""" + h = (host or "").strip() + if not h: + return "" + if h.startswith("["): + end = h.find("]") + if end != -1: + return h[1:end].lower() + if h.count(":") == 1: + return h.split(":", 1)[0].lower() + return h.lower() + + +def hostname_from_url(url: str) -> str: + """Hostname from an Origin / Referer URL, or empty when missing.""" + raw = (url or "").strip() + if not raw: + return "" + parsed = urlparse(raw if "://" in raw else f"http://{raw}") + host = parsed.hostname or "" + return host.lower() + + def is_loopback_host(host: str) -> bool: - """True for localhost, IPv4/IPv6 loopback, and IPv4-mapped ::ffff:127.0.0.1.""" - h = (host or "").strip().lower() + """True for localhost, ``*.localhost``, IPv4/IPv6 loopback, and mapped IPv4.""" + h = hostname_from_host_header(host) if h.startswith("[") and h.endswith("]"): h = h[1:-1] if h in {"127.0.0.1", "::1", "localhost", "testclient"}: return True + if h.endswith(".localhost"): + return True if h.startswith("::ffff:"): return h.rsplit(":", 1)[-1] in {"127.0.0.1", "localhost"} return False +def request_looks_local( + *, + client_host: str = "", + forwarded_for: str = "", + http_host: str = "", + origin: str = "", + referer: str = "", +) -> bool: + """True when this HTTP request is from the machine that runs the server.""" + if is_desktop_process(): + return True + candidates = ( + client_host, + (forwarded_for or "").split(",")[0].strip(), + hostname_from_host_header(http_host), + hostname_from_url(origin), + hostname_from_url(referer), + ) + return any(is_loopback_host(value) for value in candidates if value) + + def is_unclaimed_local_user(server: Any, user: User) -> bool: """True while the auto-provisioned local user has not registered.""" if server.services is None: @@ -140,17 +188,18 @@ async def ensure_local_user(server: Any, *, locale: str) -> User: user = _single_user(server) if user is not None: return user - if is_desktop_process(): - picked = preferred_existing_user(server) - if picked is not None: - logger.info( - "desktop local-session using existing user %s id=%s", - picked.username, - picked.id, - ) - return picked - return await _provision_local_user(server, locale=loc) - raise OctopError(ErrorCode.FORBIDDEN, "interactive login required") + # Already gated by the HTTP handler as this device (desktop / loopback). + # Returning installs often have extra org-mapped rows, so never demand + # interactive login here — pick a studio principal or mint a guest. + picked = preferred_existing_user(server) + if picked is not None: + logger.info( + "local-session using existing user %s id=%s", + picked.username, + picked.id, + ) + return picked + return await _provision_local_user(server, locale=loc) async def claim_local_account( diff --git a/src/octop/modules/org_os/integration.py b/src/octop/modules/org_os/integration.py index e226f355..01cce2ec 100644 --- a/src/octop/modules/org_os/integration.py +++ b/src/octop/modules/org_os/integration.py @@ -21,12 +21,15 @@ def integrated_organization() -> bool: async def organization_user(server: Any, token: str) -> Any: """Validate at the identity authority on every request (including revocation).""" + bearer = (token or "").strip() + if not bearer: + raise OctopError(ErrorCode.AUTH_FAILED, "organization session expired or revoked") service = org_module_from_paths(server.paths) try: async with httpx.AsyncClient(timeout=10, follow_redirects=False) as client: result = await client.get( f"{service.sidecar_url()}/api/auth/me", - headers={"Authorization": f"Bearer {token}"}, + headers={"Authorization": f"Bearer {bearer}"}, ) except httpx.HTTPError as exc: raise OctopError( diff --git a/tests/integration/test_local_session.py b/tests/integration/test_local_session.py index 1007cdc5..c931694e 100644 --- a/tests/integration/test_local_session.py +++ b/tests/integration/test_local_session.py @@ -66,7 +66,13 @@ async def test_save_requires_account_then_register(fresh_client) -> None: assert me.json()["is_local"] is False -async def test_local_session_refuses_when_multiple_users(app_client) -> None: +async def test_local_session_loopback_picks_admin_when_multiple_users( + app_client, monkeypatch: pytest.MonkeyPatch +) -> None: + """Returning ~/.freeos with extra users still gets a studio session on loopback.""" + monkeypatch.delenv("OCTOP_DESKTOP", raising=False) + monkeypatch.delenv("FREEOS_DESKTOP", raising=False) + monkeypatch.delenv("OCTOP_GREEN_PACKAGES", raising=False) c, _srv, home = app_client await bootstrap_admin(c, home, username="alice", password="TestPass12") tok = ( @@ -75,7 +81,43 @@ async def test_local_session_refuses_when_multiple_users(app_client) -> None: admin_auth = {"Authorization": f"Bearer {tok}"} await create_user(c, admin_auth, username="bob", password="TestPass12") r = await c.post("/api/auth/local-session") - assert r.status_code == 403 + assert r.status_code == 200 + assert r.json()["user"]["username"] == "alice" + assert r.json()["user"]["role"] == "admin" + + +async def test_local_session_rejects_non_loopback_client( + tmp_octop_home: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("OCTOP_DESKTOP", raising=False) + monkeypatch.delenv("FREEOS_DESKTOP", raising=False) + monkeypatch.delenv("OCTOP_GREEN_PACKAGES", raising=False) + async with octop_client(tmp_octop_home, bind_database=False) as (c, _srv): + app = c._octop_app # type: ignore[attr-defined] + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app, client=("8.8.8.8", 43210)), + base_url="http://example.com", + ) as remote: + r = await remote.post("/api/auth/local-session") + assert r.status_code == 403 + assert r.json()["error"]["code"] == "FORBIDDEN" + + +async def test_local_session_accepts_wails_localhost_host( + tmp_octop_home: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("OCTOP_DESKTOP", raising=False) + monkeypatch.delenv("FREEOS_DESKTOP", raising=False) + monkeypatch.delenv("OCTOP_GREEN_PACKAGES", raising=False) + async with octop_client(tmp_octop_home, bind_database=False) as (c, _srv): + app = c._octop_app # type: ignore[attr-defined] + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app, client=("192.168.0.20", 43210)), + base_url="http://wails.localhost:34115", + ) as webview: + r = await webview.post("/api/auth/local-session") + assert r.status_code == 200 + assert r.json()["user"]["username"] == "local" async def test_desktop_first_run_skips_server_wizard( @@ -119,3 +161,23 @@ async def test_local_session_desktop_picks_admin_when_multiple_users( assert r.status_code == 200 assert r.json()["user"]["username"] == "alice" assert r.json()["user"]["role"] == "admin" + + +async def test_desktop_returning_existing_db_without_jwt( + app_client, monkeypatch: pytest.MonkeyPatch +) -> None: + """Existing SQLite + no session cookie still enters via local-session.""" + c, _srv, home = app_client + await bootstrap_admin(c, home, username="owner", password="TestPass12") + monkeypatch.setenv("OCTOP_DESKTOP", "1") + status = await c.get("/api/setup/status") + assert status.json()["setup_required"] is False + assert status.json()["desktop"] is True + r = await c.post("/api/auth/local-session") + assert r.status_code == 200 + assert r.json()["user"]["username"] == "owner" + me = await c.get( + "/api/auth/me", + headers={"Authorization": f"Bearer {r.json()['access_token']}"}, + ) + assert me.status_code == 200 diff --git a/tests/unit/users/test_local_session.py b/tests/unit/users/test_local_session.py index fc3eb39d..06361a02 100644 --- a/tests/unit/users/test_local_session.py +++ b/tests/unit/users/test_local_session.py @@ -12,10 +12,12 @@ from octop.infra.users.local_session import ( LOCAL_SESSION_SETTING, LOCAL_USERNAME, + hostname_from_host_header, is_desktop_process, is_loopback_host, is_unclaimed_local_user, preferred_existing_user, + request_looks_local, require_claimed_account, ) @@ -73,6 +75,44 @@ def test_is_loopback_host_accepts_mapped_ipv4() -> None: assert is_loopback_host("10.0.0.4") is False +def test_is_loopback_host_accepts_localhost_names_and_ports() -> None: + assert is_loopback_host("localhost:8088") is True + assert is_loopback_host("127.0.0.1:8088") is True + assert is_loopback_host("[::1]:8088") is True + assert is_loopback_host("wails.localhost") is True + assert is_loopback_host("wails.localhost:34115") is True + assert hostname_from_host_header("[::1]:8088") == "::1" + + +def test_request_looks_local_uses_origin_when_peer_is_lan( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("OCTOP_DESKTOP", raising=False) + monkeypatch.delenv("FREEOS_DESKTOP", raising=False) + monkeypatch.delenv("OCTOP_GREEN_PACKAGES", raising=False) + assert ( + request_looks_local( + client_host="192.168.1.50", + http_host="192.168.1.50:8088", + origin="http://127.0.0.1:8088", + ) + is True + ) + assert ( + request_looks_local( + client_host="8.8.8.8", + http_host="example.com", + origin="https://example.com", + ) + is False + ) + + +def test_request_looks_local_true_on_desktop_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OCTOP_DESKTOP", "1") + assert request_looks_local(client_host="8.8.8.8", http_host="example.com") is True + + def test_preferred_existing_user_picks_sole_admin() -> None: alice = User(id=2, username="alice", role=Role.ADMIN, display_name="Alice") bob = User(id=3, username="bob", role=Role.USER, display_name="Bob")