diff --git a/.github/workflows/ui-checks.yml b/.github/workflows/ui-checks.yml new file mode 100644 index 0000000..309b47c --- /dev/null +++ b/.github/workflows/ui-checks.yml @@ -0,0 +1,38 @@ +name: Management UI and identity +on: + pull_request: + paths: + - 'extore/auth.py' + - 'extore/static/**' + - 'tests/**' + - 'scripts/check_management_ui.py' + - '.github/workflows/ui-checks.yml' + workflow_dispatch: +permissions: + contents: read +jobs: + check: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + with: + persist-credentials: false + submodules: recursive + - uses: astral-sh/setup-uv@94527f2e458b27549849d47d273a16bec83a01e9 + with: + version: "0.12.20" + - run: uv sync --frozen --python 3.12 + - run: uv run ruff check extore scripts tests examples + - run: uv run ruff format --check extore scripts tests examples + - run: node --test tests/*.test.cjs + - run: uv run pytest -q tests/test_passkeys.py tests/test_passkey_reauth.py tests/test_shop_account_auth.py tests/test_browser_shop_scope.py + - run: uv run --with playwright==1.57.0 playwright install --with-deps chromium + - run: sudo apt-get install -y --no-install-recommends fonts-noto-cjk + - run: uv run --with playwright==1.57.0 python scripts/check_management_ui.py + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 + if: always() + with: + name: management-ui-review + path: output/ui-review + retention-days: 7 diff --git a/extore/auth.py b/extore/auth.py index 7efe9c8..3892f4d 100644 --- a/extore/auth.py +++ b/extore/auth.py @@ -1,3 +1,4 @@ +import base64 import json import time @@ -22,7 +23,15 @@ from .config import COOKIE_SECURE, DATA, ORIGIN, RP_ID from .db import audit, db, set_setting, setting from .link_access import revoke_session -from .security import create_session, digest, fail, rate_limit, session, token +from .security import ( + authorize_management, + create_session, + digest, + fail, + rate_limit, + session, + token, +) router = APIRouter(prefix="/api/auth") ph = PasswordHasher() @@ -148,17 +157,23 @@ def logout(request: Request, response: Response): def challenge(c, request, response, kind, s=None): key = token() + descriptors = None if s: + rows = c.execute( + "SELECT id FROM credentials WHERE shop_id IS ?", (s.get("shop_id"),) + ).fetchall() + descriptors = [ + PublicKeyCredentialDescriptor( + id=base64.urlsafe_b64decode(r["id"] + "=" * (-len(r["id"]) % 4)) + ) + for r in rows + ] + if s and kind == "register": if ( s["channel"] == "browser" and time.time() - (s.get("auth_at") or s["created"]) > 600 ): fail("添加 Passkey 前请重新登录", 401) - rows = c.execute( - "SELECT id FROM credentials WHERE shop_id IS ?", (s.get("shop_id"),) - ).fetchall() - import base64 - shop_id = s.get("shop_id") if shop_id: from .shops import shop_row @@ -175,20 +190,19 @@ def challenge(c, request, response, kind, s=None): rp_name="Extore · 兑所", user_id=user_id, user_name=user_name, - exclude_credentials=[ - PublicKeyCredentialDescriptor( - id=base64.urlsafe_b64decode(r["id"] + "=" * (-len(r["id"]) % 4)) - ) - for r in rows - ], + exclude_credentials=descriptors, authenticator_selection=AuthenticatorSelectionCriteria( resident_key=ResidentKeyRequirement.REQUIRED, user_verification=UserVerificationRequirement.REQUIRED, ), ) else: + if s and not descriptors: + fail("当前账号没有 Passkey,请使用其他验证方式", 409) opts = generate_authentication_options( - rp_id=RP_ID, user_verification=UserVerificationRequirement.REQUIRED + rp_id=RP_ID, + allow_credentials=descriptors, + user_verification=UserVerificationRequirement.REQUIRED, ) c.execute("DELETE FROM challenges WHERE expires { if (!current()) return; if (typeof passkey !== "function") throw new Error("Passkey 模块未加载,请刷新页面"); - const verified = await passkey(false, null, { signal: controller.signal, isCurrent: current }); - if (!current() || !await checkScope(true, verified) || !current()) return; + const verified = await passkey(false, null, { + signal: controller.signal, isCurrent: current, reauthenticate: true, + expectedScope: auth.shop_id || (rootScope(auth) ? "platform" : undefined), + expectedSessionId: auth.session_id, + }); + if (!current() || !await checkScope(false, verified) || !current()) return; target.innerHTML = ""; await handler(); }); on("#account-fresh-cancel", "click", () => { if (!current()) return; options.onCancel?.(); target.innerHTML = ""; }); + target.scrollIntoView?.({ block: "nearest" }); + $(shopScope(auth) ? "#fresh-password" : "#account-fresh-passkey")?.focus(); } const completed = (message) => { page(tr("操作已完成", "Done"), `

${esc(message)}

${tr("返回登录", "Return to sign in")}
`); }; diff --git a/extore/static/app.js b/extore/static/app.js index 2606505..2da0e31 100644 --- a/extore/static/app.js +++ b/extore/static/app.js @@ -287,7 +287,7 @@ function managementAuthority(auth = authStatus) { return JSON.stringify([auth.role, auth.shop_id, auth.superadmin === true, auth.link_id || auth.staff_id || null]); } function managementHeaders(path, options = {}, headers = {}) { - const bound = /^\/(?:admin|manage|platform|shop)(?:\/|\?|$)/.test(path) || /^\/auth\/(?:passkeys|register\/(?:options|verify)|password\/change|totp|reauth\/password|logout)(?:\/|\?|$)/.test(path); + const bound = /^\/(?:admin|manage|platform|shop)(?:\/|\?|$)/.test(path) || /^\/auth\/(?:passkeys|register\/(?:options|verify)|password\/change|totp|reauth\/(?:password|passkey\/(?:options|verify))|logout)(?:\/|\?|$)/.test(path); if (bound) { const { expectedScope: scope, expectedSessionId: sessionId } = managementOptions(options); if (typeof scope === "string") headers["X-Extore-Shop-Scope"] = scope; @@ -573,6 +573,15 @@ $("#brand").addEventListener("click", () => { }); function syncPreferenceControls() { $("#theme").value = preferences.settings.theme; + $("#accent").value = preferences.settings.accent || "green"; + $("#appearance-label").textContent = tr("外观", "Appearance"); + $("#theme-label").textContent = tr("颜色主题", "Color theme"); + $("#accent-label").textContent = tr("强调色", "Accent color"); + $("#language-label").textContent = tr("语言", "Language"); + $("#appearance-note").textContent = tr("自动保存到此浏览器。", "Saved in this browser."); + $("#accent").setAttribute("aria-label", tr("强调色", "Accent color")); + for (const [value, cn, en] of [["green", "松绿", "Pine"], ["blue", "海蓝", "Ocean"], ["violet", "紫罗兰", "Violet"], ["rose", "玫瑰", "Rose"], ["amber", "琥珀", "Amber"], ["graphite", "石墨", "Graphite"]]) + $(`#accent option[value=${value}]`).textContent = tr(cn, en); $("#language").value = preferences.settings.language; $("#theme").setAttribute("aria-label", tr("主题", "Theme")); $("#language").setAttribute("aria-label", tr("语言", "Language")); @@ -598,6 +607,14 @@ $("#language").addEventListener("change", () => $("#theme").addEventListener("change", () => preferences.setTheme($("#theme").value), ); +$("#accent").addEventListener("change", () => preferences.setAccent($("#accent").value)); +$("#appearance").addEventListener("keydown", (event) => { + if (event.key === "Escape") { $("#appearance").open = false; $("#appearance summary").focus(); } +}); +document.addEventListener?.("click", (event) => { + const appearance = $("#appearance"); + if (appearance.open && !appearance.contains(event.target)) appearance.open = false; +}); window.addEventListener("popstate", start); window.addEventListener("hashchange", () => { if (location.pathname === "/cli/owner") start(); @@ -1384,8 +1401,9 @@ async function passkey(register = false, name = null, options = {}) { options = managementOptions(options); const generation = routeLoadId, pathname = location.pathname; const active = () => (options.isCurrent ? options.isCurrent() : generation === routeLoadId && pathname === location.pathname); + const ceremony = register ? "register" : options.reauthenticate ? "reauth/passkey" : "login"; const publicOptions = await api( - "/auth/" + (register ? "register" : "login") + "/options", + "/auth/" + ceremony + "/options", {}, "POST", options, ); if (!active()) throw new DOMException("Account page changed", "AbortError"); @@ -1409,7 +1427,7 @@ async function passkey(register = false, name = null, options = {}) { ? encode(c.response.userHandle) : null; } - await api("/auth/" + (register ? "register" : "login") + "/verify", { + await api("/auth/" + ceremony + "/verify", { credential: { id: c.id, rawId: encode(c.rawId), @@ -1420,7 +1438,7 @@ async function passkey(register = false, name = null, options = {}) { name: name || $("#key-name")?.value || "我的 Passkey", }, "POST", options); if (!active()) throw new DOMException("Account page changed", "AbortError"); - return api("/auth/status"); + return api("/auth/status", undefined, "GET", options); } async function admin() { stopPoll(); @@ -1521,24 +1539,38 @@ function shell() { { name: tr("店铺设置", "Shop settings"), keys: ["profiles", "commerce", "proxy", "security"] }, { name: tr("平台管理", "Platform settings"), keys: ["shops", "mail"] }, ].map((group) => ({ ...group, items: group.keys.map((key) => items.find(([value]) => value === key)).filter(Boolean) })).filter((group) => group.items.length); - app.innerHTML = `

${role === "staff" ? tr("商品管理", "Product management") : tr("商家后台", "Merchant dashboard")}

${esc(managementIdentity())}

`; + app.innerHTML = `

${role === "staff" ? tr("商品管理", "Product management") : tr("商家后台", "Merchant dashboard")}

${esc(managementIdentity())}

`; on("#logout", async () => { progressBoardController?.dispose(); progressBoardController = null; await api("/auth/logout", {}); navigate("/admin"); }); const choose = (value, mobile = false) => { - if (!items.some(([key]) => key === value) || tab === value) return; + if (!items.some(([key]) => key === value)) return; + if (tab === value) { + if (mobile) { closeMenu(); $("#management-section").focus(); } + return; + } tab = value; shell(); const target = mobile ? $("#management-section") : $(`[data-tab="${value}"]`); target?.focus?.(); perform(renderTab); }; + const menu = $("#management-menu-toggle"), navigation = $("#management-navigation-items"); + const closeMenu = () => { menu.setAttribute("aria-expanded", "false"); navigation.removeAttribute("data-open"); }; + on("#management-menu-toggle", () => { + const open = menu.getAttribute("aria-expanded") !== "true"; + menu.setAttribute("aria-expanded", String(open)); + navigation.toggleAttribute("data-open", open); + }); + navigation.addEventListener("keydown", (event) => { + if (event.key === "Escape") { closeMenu(); menu.focus(); } + }); on("#management-section", () => choose($("#management-section").value, true), "change"); document.querySelectorAll("[data-tab]").forEach((b) => b.addEventListener("click", () => { - choose(b.dataset.tab); + choose(b.dataset.tab, menu.getAttribute("aria-expanded") === "true"); }), ); } diff --git a/extore/static/appearance.css b/extore/static/appearance.css new file mode 100644 index 0000000..f0215f1 --- /dev/null +++ b/extore/static/appearance.css @@ -0,0 +1,50 @@ +/* Accent choices are independent of the light/dark surface colors. */ +:root[data-accent="blue"] { --green: #2457bd; --button-hover: #1b4393; --status-bg: #e5edfc; --selection: #cbddff; } +:root[data-accent="violet"] { --green: #7041b1; --button-hover: #57308e; --status-bg: #eee6f8; --selection: #e1cef8; } +:root[data-accent="rose"] { --green: #a53560; --button-hover: #81284a; --status-bg: #f9e5ed; --selection: #f2cddd; } +:root[data-accent="amber"] { --green: #865006; --button-hover: #653b04; --status-bg: #f8edcf; --selection: #f3dca6; } +:root[data-accent="graphite"] { --green: #46505e; --button-hover: #303945; --status-bg: #e8ebef; --selection: #d3dbe5; } +:root[data-theme="dark"][data-accent="blue"] { --green: #a6c5ff; --button-hover: #c3d8ff; --status-bg: #253650; --selection: #a6c5ff; } +:root[data-theme="dark"][data-accent="violet"] { --green: #d0b1fa; --button-hover: #e0cafa; --status-bg: #3d2e51; --selection: #d0b1fa; } +:root[data-theme="dark"][data-accent="rose"] { --green: #f5aec9; --button-hover: #fac9db; --status-bg: #4d2d3b; --selection: #f5aec9; } +:root[data-theme="dark"][data-accent="amber"] { --green: #f0ca84; --button-hover: #f8dfae; --status-bg: #493b23; --selection: #f0ca84; } +:root[data-theme="dark"][data-accent="graphite"] { --green: #c4cedc; --button-hover: #e1e6ed; --status-bg: #343b46; --selection: #c4cedc; } +.appearance-control { position: relative; } +.appearance-control > summary { display: flex; align-items: center; gap: 8px; min-height: 44px; padding: 8px 10px; cursor: pointer; list-style: none; color: var(--ink); border-radius: 10px; white-space: nowrap; } +.appearance-control > summary::-webkit-details-marker { display: none; } +.appearance-control > summary:hover, .appearance-control[open] > summary { background: var(--warm); } +.appearance-icon { width: 17px; height: 17px; border: 2px solid var(--green); border-radius: 50%; background: linear-gradient(90deg, var(--green) 50%, transparent 50%); } +.appearance-panel { position: absolute; z-index: 30; inset-inline-end: 0; top: calc(100% + 10px); width: 260px; max-width: calc(100vw - 40px); padding: 22px; border-radius: 18px; background: var(--white); box-shadow: 0 12px 45px rgb(0 0 0 / 16%); } +.appearance-panel .field { margin: 0 0 16px; gap: 6px; } +.header-right .appearance-panel select { width: 100%; min-height: 44px; padding: 9px 12px; background: var(--paper); border: 1px solid var(--line); font-size: 16px; } +.appearance-panel .caption { font-size: 12px; } +header .source-link { background: transparent; border: 0; } +body:has(.management-shell, .account-page.narrow) :is(header, footer) { background-image: none; } +/* The dashboard is a workspace, not a stack of torn paper cards. */ +.management-shell .panel, .account-page.narrow .panel { border-radius: 18px; filter: none; padding: clamp(20px, 3vw, 32px); } +.management-shell .panel::after, .account-page.narrow .panel::after { content: none; } +.management-shell .panel > :first-child { margin-top: 0; } +.account-page .panel > .field + button { margin-top: 16px; } +.management-shell :is(.product-list, .product-row, .form-divider, .empty) { border-style: none; } +.management-shell .product-row { padding-block: 24px; gap: 18px; } +.management-shell .product-row + .product-row { border-top: 1px solid var(--line); } +.management-shell .form-divider { padding-top: 28px; margin-top: 28px; } +.management-shell .empty { padding-block: 48px; } +.management-shell .account-stepup { background: var(--status-bg); margin-top: 24px; } +@media (max-width: 760px) { + header { gap: 8px; padding-block: 16px; } + .header-right { width: auto; flex: 1 1 auto; justify-content: flex-end; gap: 4px; } + .header-right .source-label, .header-right #source-label, .header-right .source-stars { display: none; } + header .source-link { padding: 10px; gap: 0; } + .header-home { border: 0; font-size: 12px; } + .appearance-panel { position: fixed; inset-inline: 20px; top: 76px; width: auto; max-width: 360px; margin-inline-start: auto; max-height: calc(100dvh - 96px); overflow: auto; } + .management-shell .product-row { gap: 16px; flex-direction: column; align-items: stretch; } + .management-shell .product-row .actions { justify-content: flex-start; width: auto; } + .management-shell .product-row .actions button { flex: 0 1 auto; } + .management-shell .account-page form > button[type="submit"] { margin-top: 16px; } +} +@media (max-width: 380px) { + .header-right { gap: 0; } + header .source-link { padding-inline: 6px; } + .appearance-control > summary { padding-inline: 8px; } +} diff --git a/extore/static/index.html b/extore/static/index.html index 25572ab..4928948 100644 --- a/extore/static/index.html +++ b/extore/static/index.html @@ -7,9 +7,10 @@ Extore · 兑所 - + - + + @@ -35,7 +36,7 @@ - + @@ -45,7 +46,7 @@ - +
@@ -63,16 +64,29 @@ 源码☆ — - - 兑换与领取 +
+ 外观 +
+
+ +
+
+ +
+
+ +
+

自动保存到此浏览器。

+
+
+ 兑换与领取
diff --git a/extore/static/management-shell.css b/extore/static/management-shell.css index 65c51ee..c2bca8b 100644 --- a/extore/static/management-shell.css +++ b/extore/static/management-shell.css @@ -1,13 +1,13 @@ -.management-shell { --management-gap: 2rem; } -.management-shell .admin-top { padding-bottom: 1.5rem; margin-bottom: 1.75rem; border-bottom: 1px dashed var(--dashed-line); align-items: start; } +.management-shell { --management-gap: 3rem; } +.management-shell .admin-top { padding-bottom: 1.5rem; margin-bottom: 1.75rem; align-items: start; border: 0; } .management-shell .admin-top h1 { margin: 0 0 .35rem; font-size: 2rem; line-height: 1.35; } .management-shell .admin-top p { margin: 0; font-size: .88rem; } .management-shell .admin-top > button { min-height: 44px; flex: 0 0 auto; font-size: .85rem; } .management-layout { display: grid; grid-template-columns: 184px minmax(0, 1fr); align-items: start; gap: var(--management-gap); } -.management-navigation { min-width: 0; position: sticky; top: 1.5rem; } -.management-navigation-group + .management-navigation-group { margin-top: 1.35rem; } -.management-navigation-group h2 { margin: 0 0 .4rem .7rem; color: var(--muted); font-size: .75rem; font-weight: 500; } -.management-navigation-group button { display: block; width: 100%; text-align: start; min-height: 44px; padding: .65rem .7rem; margin: .2rem 0; border-radius: 6px; background: transparent; color: var(--muted); font-size: .9rem; font-weight: 500; line-height: 1.45; } +.management-navigation { min-width: 0; position: sticky; top: 1.5rem; max-height: calc(100dvh - 3rem); overflow: auto; scrollbar-width: thin; } +.management-navigation-group + .management-navigation-group { margin-top: 1.5rem; } +.management-navigation-group h2 { margin: 0 0 .5rem .8rem; color: var(--muted); font-size: .75rem; font-weight: 500; } +.management-navigation-group button { display: block; width: 100%; text-align: start; min-height: 44px; padding: .7rem .8rem; margin: .2rem 0; border-radius: 10px; background: transparent; color: var(--muted); font-size: .9rem; font-weight: 500; line-height: 1.45; } .management-navigation-group button:hover { background: var(--warm); color: var(--ink); } .management-navigation-group button.active { background: var(--status-bg); color: var(--green); font-weight: 600; } .management-mobile-navigation { display: none; } @@ -21,18 +21,22 @@ .management-workspace :is(button, summary, select) { min-height: 44px; } .management-workspace :is(input:not([type="checkbox"]), textarea, select) { font-size: 16px; } @media (max-width: 980px) { - .management-shell { --management-gap: 1.25rem; } + .management-shell { --management-gap: 1.75rem; } .management-layout { grid-template-columns: 160px minmax(0, 1fr); } } @media (max-width: 860px) { - .management-shell .admin-top { margin-bottom: 1.25rem; padding-bottom: 1.25rem; } + .management-shell .admin-top { margin-bottom: .75rem; padding-bottom: .75rem; } .management-shell .admin-top h1 { font-size: 1.7rem; } .management-layout { display: block; } - .management-navigation { position: static; margin-bottom: 1.75rem; } + .management-navigation { position: sticky; top: 0; z-index: 10; margin-bottom: 1.75rem; padding-block: .75rem; background: var(--paper); max-height: calc(100dvh - 1rem); overflow: visible; } .management-desktop-navigation { display: none; } - .management-mobile-navigation { display: block; } + .management-desktop-navigation[data-open] { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px 10px; margin-top: 14px; padding: 20px 12px; max-height: calc(100dvh - 140px); overflow: auto; overscroll-behavior: contain; background: var(--white); border-radius: 16px; box-shadow: 0 10px 30px rgb(0 0 0 / 10%); } + .management-navigation-group + .management-navigation-group { margin-top: 0; } + .management-mobile-navigation { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 12px; } .management-mobile-navigation label { display: block; color: var(--muted); font-size: .8rem; margin-bottom: .4rem; } - .management-mobile-navigation select { width: 100%; font-size: 16px; min-height: 46px; } + .management-mobile-navigation select { width: 100%; font-size: 16px; min-height: 46px; border-color: var(--line); border-radius: 10px; } + #management-menu-toggle { min-height: 46px; padding-inline: 16px; color: var(--green); background: var(--status-bg); border-radius: 10px; } + #management-menu-toggle[aria-expanded="true"] { outline: 2px solid var(--green); outline-offset: -2px; } .management-workspace h2 { font-size: 1.4rem; } } @media (max-width: 420px) { diff --git a/extore/static/preferences.js b/extore/static/preferences.js index ba9cfc2..f6fad9a 100644 --- a/extore/static/preferences.js +++ b/extore/static/preferences.js @@ -3,6 +3,7 @@ "use strict"; const themes = new Set(["auto", "light", "dark"]); + const accents = new Set(["green", "blue", "violet", "rose", "amber", "graphite"]); const languages = new Set(["auto", "zh-CN", "en"]); const listeners = new Set(); let storage = null; @@ -33,10 +34,12 @@ } } const themeValue = (value) => (themes.has(value) ? value : "auto"); + const accentValue = (value) => (accents.has(value) ? value : "green"); const languageValue = (value) => languages.has(value) ? value : "auto"; let settings = { theme: themeValue(read("extore_theme")), + accent: accentValue(read("extore_accent")), language: languageValue(read("extore_language")), }; @@ -70,6 +73,7 @@ let resolved = resolve(); let previous = { theme: settings.theme, + accent: settings.accent, language: settings.language, resolvedTheme: resolved.theme, resolvedLanguage: resolved.language, @@ -79,6 +83,7 @@ const element = root.document?.documentElement; if (!element) return; element.dataset.theme = resolved.theme; + element.dataset.accent = settings.accent; element.lang = resolved.language; } function snapshot() { @@ -89,12 +94,14 @@ apply(); const next = { theme: settings.theme, + accent: settings.accent, language: settings.language, resolvedTheme: resolved.theme, resolvedLanguage: resolved.language, }; if ( previous.theme === next.theme && + previous.accent === next.accent && previous.language === next.language && previous.resolvedTheme === next.resolvedTheme && previous.resolvedLanguage === next.resolvedLanguage @@ -115,11 +122,14 @@ if (event.storageArea && event.storageArea !== storage) return; if (event.key === "extore_theme") settings.theme = themeValue(event.newValue); + else if (event.key === "extore_accent") + settings.accent = accentValue(event.newValue); else if (event.key === "extore_language") settings.language = languageValue(event.newValue); else if (event.key === null) settings = { theme: themeValue(read("extore_theme")), + accent: accentValue(read("extore_accent")), language: languageValue(read("extore_language")), }; else return; @@ -144,6 +154,12 @@ write("extore_theme", value); refresh(); }, + setAccent(value) { + if (!accents.has(value)) throw new TypeError("Invalid accent preference"); + settings.accent = value; + write("extore_accent", value); + refresh(); + }, setLanguage(value) { if (!languages.has(value)) throw new TypeError("Invalid language preference"); diff --git a/scripts/check_management_ui.py b/scripts/check_management_ui.py new file mode 100644 index 0000000..b58ec59 --- /dev/null +++ b/scripts/check_management_ui.py @@ -0,0 +1,262 @@ +"""Render shipped pages with isolated API fixtures; never use a live account. + +Run: uv run --with playwright==1.57.0 python scripts/check_management_ui.py +Install Chromium first with: uv run --with playwright==1.57.0 playwright install chromium +""" + +import argparse +import json +import threading +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import urlparse + +from playwright.sync_api import sync_playwright + +STATIC = Path(__file__).resolve().parents[1] / "extore" / "static" +AUTH = { + "configured": True, + "password_enabled": False, + "registration_enabled": True, + "role": "admin", + "shop_id": None, + "superadmin": True, + "session_id": "isolated-review-session", +} +FIXTURES = { + "/api/source": {}, + "/api/auth/status": AUTH, + "/api/admin/products": [], + "/api/products": [], + "/api/admin/product-templates": [], + "/api/platform/shops": [ + { + "id": "review-shop-one", + "name": "拾光工作室", + "email": "owner@example.test", + "verified": True, + "enabled": True, + }, + { + "id": "review-shop-two", + "name": "文字与设计", + "email": "design@example.test", + "verified": False, + "enabled": True, + }, + ], + "/api/platform/settings": { + "registration_enabled": False, + "smtp": { + "enabled": True, + "host": "smtp.example.test", + "sender": "store@example.test", + "from_name": "Extore", + "port": 587, + "mode": "starttls", + "password_configured": True, + }, + }, + "/api/auth/passkeys": [ + {"id": "review-passkey", "name": "日常使用的设备", "created": 1791547200} + ], + "/api/auth/reauth/passkey/options": { + "challenge": "Y2hhbGxlbmdl", + "userVerification": "required", + "allowCredentials": [{"id": "a2V5", "type": "public-key"}], + }, + "/api/upload-limits": {"max_file_bytes": 20971520}, +} + + +class Handler(SimpleHTTPRequestHandler): + def translate_path(self, path): + relative = urlparse(path).path.removeprefix("/static/") + target = (STATIC / relative).resolve() + return str( + target + if target.is_relative_to(STATIC) and target.is_file() + else STATIC / "index.html" + ) + + def log_message(self, *_args): + pass + + +def review(browser, origin, output, width, theme, language="zh-CN"): + context = browser.new_context( + viewport={"width": width, "height": 900}, + locale=language, + reduced_motion="reduce", + color_scheme=theme, + ) + requests, errors, shots = [], [], [] + anonymous = False + + def route_request(route): + request = route.request + if not request.url.startswith(origin + "/"): + route.abort() + return + path = urlparse(request.url).path + if not path.startswith("/api/"): + route.continue_() + return + requests.append( + {"path": path, "method": request.method, "headers": request.headers} + ) + if path == "/api/auth/reauth/passkey/verify": + route.fulfill( + status=401, json={"detail": "所选 Passkey 不属于当前账号,请重新选择"} + ) + elif path == "/api/auth/status" and anonymous: + route.fulfill( + json={**AUTH, "role": None, "superadmin": False, "session_id": None} + ) + elif path in FIXTURES: + route.fulfill(json=FIXTURES[path]) + else: + errors.append(f"Unexpected API: {path}") + route.fulfill(status=404, json={"detail": "Missing review fixture"}) + + context.route("**/*", route_request) + context.add_init_script("""Object.defineProperty(navigator, 'credentials', {value: {get: async () => ({ + id: 'wrong-key', rawId: new Uint8Array([1]).buffer, type: 'public-key', getClientExtensionResults: () => ({}), + response: {clientDataJSON: new Uint8Array([1]).buffer, authenticatorData: new Uint8Array([2]).buffer, + signature: new Uint8Array([3]).buffer, userHandle: null} + })}});""") + page = context.new_page() + page.on("pageerror", lambda error: errors.append(str(error))) + + def snap(name): + page.evaluate("window.scrollTo(0, 0)") + page.wait_for_timeout(80) + assert not page.evaluate("document.documentElement.scrollWidth > innerWidth"), ( + f"Horizontal overflow: {name}/{width}" + ) + assert not errors, errors + filename = f"{width}-{theme}-{language}-{name}.png" + page.screenshot(path=str(output / filename), full_page=True) + shots.append(filename) + + def tab(name): + if width <= 860: + page.locator("#management-section").select_option(name) + else: + page.locator(f'[data-tab="{name}"]').click() + page.wait_for_timeout(80) + + page.goto(origin + "/admin") + page.locator(".management-shell").wait_for() + snap("products") + if width <= 860: + page.locator("#management-menu-toggle").click() + assert ( + page.locator("#management-menu-toggle").get_attribute("aria-expanded") + == "true" + ) + assert page.locator('[data-tab="mail"]').is_visible() + assert page.locator('[data-tab="security"]').is_visible() + snap("directory") + page.locator('[data-tab="shops"]').click() + assert ( + page.locator("#management-menu-toggle").get_attribute("aria-expanded") + == "false" + ) + else: + tab("shops") + page.locator("#shop-name").fill("待确认的新店铺") + page.locator("#shop-email").fill("new@example.test") + snap("shops") + page.locator("#account-shop-form button[type=submit]").click() + page.locator("#account-fresh-passkey").click() + page.locator("#account-error").filter(has_text="不属于当前账号").wait_for() + assert page.locator("#shop-name").input_value() == "待确认的新店铺" + assert page.locator("#shop-email").input_value() == "new@example.test" + assert not any( + item["path"].startswith("/api/auth/login/") + or (item["path"] == "/api/platform/shops" and item["method"] == "POST") + for item in requests + ) + for item in requests: + if item["path"].startswith("/api/auth/reauth/passkey/"): + assert item["headers"]["x-extore-shop-scope"] == "platform" + assert item["headers"]["x-extore-session-id"] == AUTH["session_id"] + snap("wrong-passkey") + page.locator("#account-fresh-cancel").click() + tab("mail") + page.locator("#mail-host").fill("draft.example.test") + snap("mail") + page.locator("#appearance summary").click() + page.locator("#theme").select_option(theme) + for accent in ["green", "blue", "violet", "rose", "amber", "graphite"]: + page.locator("#accent").select_option(accent) + assert page.locator("html").get_attribute("data-accent") == accent + assert page.locator("#mail-host").input_value() == "draft.example.test" + snap("appearance") + page.locator("#appearance summary").press("Escape") + assert not page.locator("#appearance").evaluate("node => node.open") + tab("security") + page.get_by_text("日常使用的设备", exact=True).wait_for() + snap("security") + page.reload() + assert page.locator("html").get_attribute("data-accent") == "graphite" + anonymous = True + page.goto(origin + "/account/login") + page.locator("#login-email").wait_for() + snap("login") + page.goto(origin + "/") + page.locator("#code").wait_for() + snap("home") + context.close() + return { + "width": width, + "theme": theme, + "language": language, + "screenshots": shots, + "errors": errors, + } + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--output", type=Path, default=Path("output/ui-review")) + args = parser.parse_args() + args.output.mkdir(parents=True, exist_ok=True) + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + try: + with sync_playwright() as playwright: + browser = playwright.chromium.launch() + try: + results = [ + review( + browser, + f"http://127.0.0.1:{server.server_port}", + args.output, + width, + theme, + language, + ) + for width, theme, language in [ + (320, "dark", "zh-CN"), + (390, "light", "zh-CN"), + (768, "dark", "en"), + (1440, "light", "zh-CN"), + ] + ] + finally: + browser.close() + (args.output / "results.json").write_text( + json.dumps(results, ensure_ascii=False, indent=2) + ) + print( + f"Reviewed {sum(len(result['screenshots']) for result in results)} screenshots across {len(results)} viewport/theme combinations." + ) + finally: + server.shutdown() + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/tests/account-ui.test.cjs b/tests/account-ui.test.cjs index 6a7804e..9d8e282 100644 --- a/tests/account-ui.test.cjs +++ b/tests/account-ui.test.cjs @@ -102,7 +102,7 @@ function fixture(options = {}) { const ui = context.window.ExtoreAccount; const instance = ui.mount({ root, auth: options.auth || {}, mode: options.mode || "login", token: options.token || "", isCurrent: () => current, language: options.language || "zh-CN", - navigate: (url) => navigations.push(url), passkey: async () => { passkeyCalls++; return options.verified; }, onAuth: (auth) => adopted.push(auth), + navigate: (url) => navigations.push(url), passkey: async (...args) => { passkeyCalls++; options.onPasskey?.(...args); return options.verified; }, onAuth: (auth) => adopted.push(auth), copyProcessorPrompt: options.copyProcessorPrompt, api(url, body, method, extra) { return new Promise((resolve, reject) => requests.push({ url, body, method, extra, resolve, reject })); }, }); @@ -1101,19 +1101,20 @@ test("password change adopts only a new session for the verified original shop", assert.doesNotMatch(p.root.innerHTML, /old-private-password|new-private-password/); }); -test("fresh Passkey may replace the session but cannot change the target shop", async () => { - const auth = { ...shopAuth, session_id: "old-session" }; - const verified = { ...auth, session_id: "new-session" }; - const p = fixture({ auth, verified, mode: "confirm" }); +test("fresh Passkey keeps the original session and rejects both session and shop changes", async () => { + const auth = { ...shopAuth, session_id: "current-session" }; + const p = fixture({ auth, verified: auth, mode: "confirm" }); let called = 0; p.instance.confirmFresh(async () => { called++; }); await p.node("#account-fresh-passkey").emit("click"); - assert.equal(called, 1); assert.equal(p.adopted[0].session_id, "new-session"); - const q = fixture({ auth, verified: { ...verified, shop_id: "shop-two" }, mode: "confirm" }); - q.instance.confirmFresh(async () => { called++; }); - await q.node("#account-fresh-passkey").emit("click"); - assert.equal(called, 1); assert.equal(q.adopted.length, 0); - assert.match(q.node("#account-error").textContent, /账户或店铺已改变/); + assert.equal(called, 1); assert.equal(p.adopted[0].session_id, "current-session"); + for (const verified of [{ ...auth, session_id: "different-session" }, { ...auth, shop_id: "shop-two" }, rootAuth]) { + const q = fixture({ auth, verified, mode: "confirm" }); + q.instance.confirmFresh(async () => { called++; }); + await q.node("#account-fresh-passkey").emit("click"); + assert.equal(called, 1); assert.equal(q.adopted.length, 0); + assert.match(q.node("#account-error").textContent, /账户或店铺已改变/); + } }); test("fresh confirmation displays the owner shop name and email as escaped, separately wrapping text", () => { @@ -1155,3 +1156,19 @@ test("display metadata changes cannot replace the authenticated shop or affect s assert.equal(q.adopted.length, 0); assert.match(q.node("#account-error").textContent, /账户或店铺已改变/); }); + + +test("root and merchant confirmations bind Passkey to the captured account and session", async () => { + for (const auth of [{ ...rootAuth, session_id: "root-session" }, { ...shopAuth, session_id: "shop-session" }]) { + let captured; + const p = fixture({ auth, verified: auth, mode: "confirm", onPasskey: (...args) => { captured = args; } }); + let writes = 0; + p.instance.confirmFresh(async () => { writes++; }); + await p.node("#account-fresh-passkey").emit("click"); + assert.equal(captured[0], false); + assert.equal(captured[2].reauthenticate, true); + assert.equal(captured[2].expectedScope, auth.shop_id || "platform"); + assert.equal(captured[2].expectedSessionId, auth.session_id); + assert.equal(writes, 1); + } +}); diff --git a/tests/device-login.test.cjs b/tests/device-login.test.cjs index e311d8f..2c0ce85 100644 --- a/tests/device-login.test.cjs +++ b/tests/device-login.test.cjs @@ -321,8 +321,8 @@ test("existing same-key CLI device can be restored with zero remaining bindings" assert.equal(p.instance.state, "approved"); }); -test("real Account Passkey confirmation preserves updated session and authorization lifecycle", async () => { - const fresh = { ...shopAuth, session_id: "fresh-real-account-session" }; +test("real Account Passkey confirmation preserves the current session and authorization lifecycle", async () => { + const fresh = { ...shopAuth }; const p = fixture({ auth: shopAuth, realAccount: true, passkey: async () => fresh }), value = await review(p); await approvePrelude(p, value); assert.equal(p.instance.state, "confirm"); @@ -330,9 +330,9 @@ test("real Account Passkey confirmation preserves updated session and authorizat const pending = p.node("#account-fresh-passkey").emit("click"); await flush(); assert.equal(p.requests[6].url, "/auth/status"); assert.equal(p.requests[6].extra.expectedSessionId, fresh.session_id); - const sessionBoundReview = { ...value, review_digest: "e".repeat(64) }; + const sessionBoundReview = { ...value }; await resolve(p, 6, fresh); await resolve(p, 7, sessionBoundReview); - assert.equal(p.requests[8].body.review_digest, "e".repeat(64)); + assert.equal(p.requests[8].body.review_digest, value.review_digest); await resolve(p, 8, { ok: true, status: "approved" }); await pending; assert.equal(p.instance.state, "approved"); assert.equal(p.accounts[0].disposed, true); @@ -366,8 +366,8 @@ test("real Account cancellation aborts in-flight Passkey without a later approva assert.match(p.node("#device-error").textContent, /已取消/); }); -test("real Passkey session rotation still stops changed device or permission metadata", async () => { - const fresh = { ...shopAuth, session_id: "fresh-changed-session" }; +test("real Passkey confirmation still stops changed device or permission metadata", async () => { + const fresh = { ...shopAuth }; const p = fixture({ auth: shopAuth, realAccount: true, passkey: async () => fresh }), value = await review(p); await approvePrelude(p, value); const pending = p.node("#account-fresh-passkey").emit("click"); await flush(); @@ -457,16 +457,16 @@ test("active shop request freezes current products and allows explicit smaller s }); test("active scope approval requires owner confirmation and only enters awaiting-claim state", async () => { - const fresh = { ...shopAuth, session_id: "active-fresh-session" }; + const fresh = { ...shopAuth }; const p = fixture({ auth: shopAuth, realAccount: true, passkey: async () => fresh }); const value = await activeReview(p, activeOptions()); await approvePrelude(p, value); assert.equal(p.instance.state, "confirm"); const pending = p.node("#account-fresh-passkey").emit("click"); await flush(); - const latest = { ...value, review_digest: "c".repeat(64) }; + const latest = { ...value }; await resolve(p, 6, fresh); await resolve(p, 7, latest); assert.equal(p.requests[8].url, "/manage/device/approve"); - assert.deepEqual(JSON.parse(JSON.stringify(p.requests[8].body)), { user_code: code, product_ids: value.selected.product_ids, permissions: value.selected.permissions, expires: value.selected.expires, review_digest: "c".repeat(64) }); + assert.deepEqual(JSON.parse(JSON.stringify(p.requests[8].body)), { user_code: code, product_ids: value.selected.product_ids, permissions: value.selected.permissions, expires: value.selected.expires, review_digest: value.review_digest }); await resolve(p, 8, { ok: true, status: "approved" }); await pending; assert.equal(p.instance.state, "approved"); assert.match(p.root.innerHTML, /已批准,等待 CLI 领取/); @@ -586,7 +586,7 @@ test("scope review changes to current revision, future products, permissions or }); test("fresh reauthentication cannot silently apply changed scope authorization", async () => { - const fresh = { ...shopAuth, session_id: "scope-new-session" }; + const fresh = { ...shopAuth }; const p = fixture({ auth: shopAuth, realAccount: true, passkey: async () => fresh }), value = await activeReview(p, activeOptions()); await approvePrelude(p, value); const pending = p.node("#account-fresh-passkey").emit("click"); await flush(); @@ -648,9 +648,9 @@ test("scope accepts the backend limit of 500 frozen current products and rejects } }); -test("fresh session rotation cannot hide a changed server snapshot in either approval flow", async () => { +test("fresh confirmation cannot hide a changed server snapshot in either approval flow", async () => { for (const scoped of [false, true]) { - const fresh = { ...shopAuth, session_id: "snapshot-fresh-session" }; + const fresh = { ...shopAuth }; const p = fixture({ auth: shopAuth, realAccount: true, passkey: async () => fresh }); const value = scoped ? await activeReview(p, activeOptions()) : await review(p); await approvePrelude(p, value); diff --git a/tests/management-copy.test.cjs b/tests/management-copy.test.cjs index 92faea1..218f688 100644 --- a/tests/management-copy.test.cjs +++ b/tests/management-copy.test.cjs @@ -143,9 +143,9 @@ function page(options = {}) { function find(selector, root = null) { return findAll(selector, root)[0] || null; } - for (const id of ["app", "brand", "theme", "language", "header-context", "toast", "workspace"]) + for (const id of ["app", "brand", "theme", "language", "accent", "appearance", "appearance-label", "appearance-note", "theme-label", "language-label", "accent-label", "header-context", "toast", "workspace"]) nodes.set(id, element(id)); - for (const [id, values] of [["theme", ["auto", "light", "dark"]], ["language", ["auto"]]]) { + for (const [id, values] of [["theme", ["auto", "light", "dark"]], ["language", ["auto"]], ["accent", ["green", "blue", "violet", "rose", "amber", "graphite"]]]) { for (const value of values) { const option = element(`${id}-${value}`, nodes.get(id)); option.attributes.value = value; @@ -154,7 +154,7 @@ function page(options = {}) { } const originalFind = find; const query = (selector) => { - const option = selector.match(/^#(theme|language) option\[value=(\w+)\]$/); + const option = selector.match(/^#(theme|language|accent) option\[value=(\w+)\]$/); return option ? nodes.get(`${option[1]}-${option[2]}`) : originalFind(selector); }; const location = { diff --git a/tests/owner-cli-integration.test.cjs b/tests/owner-cli-integration.test.cjs index 6e9cf12..4357ec6 100644 --- a/tests/owner-cli-integration.test.cjs +++ b/tests/owner-cli-integration.test.cjs @@ -212,12 +212,12 @@ test("approval and QR modules load before app startup with revised assets", () = assert.ok(owner >= 0 && account > owner && app > account); assert.match(scripts[owner][1], /\bdefer\b/); assert.match(scripts[owner][2], /\?v=20261007-shops$/); - assert.match(scripts[app][2], /\?v=20261008-frontend-commerce$/); + assert.match(scripts[app][2], /\?v=20261010-bound-confirmation$/); const device = scripts.findIndex((script) => script[2].startsWith("/static/device-login.js")); const encoder = scripts.findIndex((script) => script[2].startsWith("/static/vendor/qrcodegen.js")); const qr = scripts.findIndex((script) => script[2].startsWith("/static/totp-qr.js")); assert.ok(encoder >= 0 && qr > encoder && account > qr && device > account && app > device); - assert.match(scripts[account][2], /\?v=20261008-frontend-commerce$/); + assert.match(scripts[account][2], /\?v=20261010-bound-confirmation$/); assert.match(scripts[device][2], /\?v=20261007-queue-design$/); }); diff --git a/tests/passkey-confirmation-ui.test.cjs b/tests/passkey-confirmation-ui.test.cjs new file mode 100644 index 0000000..718baff --- /dev/null +++ b/tests/passkey-confirmation-ui.test.cjs @@ -0,0 +1,58 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { appFixture, flush } = require("./app-fixture.cjs"); + +function browser() { + const page = appFixture(); + page.navigate("/admin"); + let requested; + page.context.navigator.credentials = { async get(options) { + requested = options; + return { getClientExtensionResults: () => ({}), id: "a2V5", rawId: new Uint8Array([107, 101, 121]).buffer, type: "public-key", + response: { clientDataJSON: new Uint8Array([1]).buffer, authenticatorData: new Uint8Array([2]).buffer, + signature: new Uint8Array([3]).buffer, userHandle: null } }; + } }; + return { ...page, requested: () => requested }; +} + +test("confirmation sends only session-bound reauthentication endpoints and retains allowCredentials", async () => { + const page = browser(); + const pending = page.context.passkey(false, null, { reauthenticate: true, expectedScope: "platform", expectedSessionId: "captured-session" }); + assert.equal(page.requests[0].url, "/api/auth/reauth/passkey/options"); + page.requests[0].respond({ challenge: "Y2hhbGxlbmdl", allowCredentials: [{ type: "public-key", id: "a2V5" }], userVerification: "required" }); + await flush(); + assert.equal(page.requested().publicKey.userVerification, "required"); + assert.equal(page.requested().publicKey.allowCredentials[0].id.byteLength, 3); + assert.equal(page.requests[1].url, "/api/auth/reauth/passkey/verify"); + for (const request of page.requests.slice(0, 2)) { + assert.equal(request.options.headers["X-Extore-Shop-Scope"], "platform"); + assert.equal(request.options.headers["X-Extore-Session-ID"], "captured-session"); + } + page.requests[1].respond({ ok: true }); await flush(); + assert.equal(page.requests[2].url, "/api/auth/status"); + page.requests[2].respond({ role: "admin", session_id: "captured-session" }); + assert.equal((await pending).session_id, "captured-session"); + assert.equal(page.requests.some((request) => request.url.includes("/login/")), false); +}); + +test("a failed confirmation stops before status or a business write and never retries as login", async () => { + const page = browser(); + const pending = page.context.passkey(false, null, { reauthenticate: true, expectedScope: "shop-one", expectedSessionId: "current" }); + const rejected = assert.rejects(pending, /wrong account/); + page.requests[0].respond({ challenge: "Y2hhbGxlbmdl", allowCredentials: [{ type: "public-key", id: "a2V5" }] }); await flush(); + page.requests[1].reject(new Error("wrong account")); + await rejected; + assert.equal(page.requests.length, 2); + assert.ok(page.requests.every((request) => request.url.startsWith("/api/auth/reauth/passkey/"))); +}); + +test("explicit sign-in still uses the discoverable login ceremony", async () => { + const page = browser(); + const pending = page.context.passkey(); + assert.equal(page.requests[0].url, "/api/auth/login/options"); + page.requests[0].respond({ challenge: "Y2hhbGxlbmdl" }); await flush(); + assert.equal(page.requests[1].url, "/api/auth/login/verify"); + page.requests[1].respond({ role: "admin" }); await flush(); + page.requests[2].respond({ role: "admin", shop_id: "chosen-account" }); + assert.equal((await pending).shop_id, "chosen-account"); +}); diff --git a/tests/pipeline-authorizations.test.cjs b/tests/pipeline-authorizations.test.cjs index bf5feef..71f8280 100644 --- a/tests/pipeline-authorizations.test.cjs +++ b/tests/pipeline-authorizations.test.cjs @@ -291,8 +291,8 @@ test("an upgrade between reviewing metadata and DELETE is version-pinned and res assert.equal(p.requests.filter((request) => request.method === "DELETE").length, 1); }); -test("real account Passkey verification rotates the session before the pinned revoke requests", async () => { - const fresh = { ...shopAuth, session_id: "real-fresh-session" }; +test("real account Passkey verification keeps the same session for pinned revoke requests", async () => { + const fresh = { ...shopAuth }; const p = fixture({ realAccount: true, passkey: async () => fresh }), value = row(); await confirmation(p, value); const pending = p.node("#account-fresh-passkey").emit("click"); await flush(); assert.equal(p.requests[2].url, "/auth/status"); assert.equal(p.requests[2].extra.expectedSessionId, fresh.session_id); diff --git a/tests/preferences.test.cjs b/tests/preferences.test.cjs index 0efc995..49e11e5 100644 --- a/tests/preferences.test.cjs +++ b/tests/preferences.test.cjs @@ -94,6 +94,7 @@ function browser(options = {}) { test("defaults follow browser appearance and the first supported preferred language", () => { const b = browser({ dark: true, languages: ["fr-FR", "en-GB", "zh-CN"] }); assert.deepEqual(plain(b.preferences.settings), { + accent: "green", theme: "auto", language: "auto", }); @@ -137,7 +138,7 @@ test("automatic settings update live and notify only when preference state chang assert.equal(notifications.length, 2); assert.equal(b.element.lang, "zh-CN"); assert.deepEqual(notifications[1], { - settings: { theme: "auto", language: "auto" }, + settings: { theme: "auto", accent: "green", language: "auto" }, resolved: { theme: "dark", language: "zh-CN" }, }); b.preferences.setTheme("auto"); @@ -192,6 +193,7 @@ test("existing explicit language selections are preserved and invalid stored val languages: ["en-US"], }); assert.deepEqual(plain(b.preferences.settings), { + accent: "green", theme: "auto", language: "auto", }); @@ -221,6 +223,7 @@ test("changes from another tab resolve immediately and unrelated storage is igno b.storageCleared(); assert.equal(notified, 4); assert.deepEqual(plain(b.preferences.settings), { + accent: "green", theme: "auto", language: "auto", }); @@ -238,6 +241,7 @@ test("blocked storage does not prevent selecting preferences for the current pag b.preferences.setTheme("light"); b.preferences.setLanguage("zh-CN"); assert.deepEqual(plain(b.preferences.settings), { + accent: "green", theme: "light", language: "zh-CN", }); @@ -277,3 +281,46 @@ test("getter snapshots cannot mutate preferences and invalid choices are rejecte assert.doesNotThrow(() => b.preferences.setTheme("dark")); assert.equal(notified, 1); }); + + +test("each accent persists independently of theme and language without inline styles", () => { + for (const accent of ["green", "blue", "violet", "rose", "amber", "graphite"]) { + const b = browser({ dark: true, languages: ["en-US"] }); + const resolved = plain(b.preferences.resolved); + let notifications = 0; + b.preferences.subscribe(() => notifications++); + b.preferences.setAccent(accent); + assert.equal(b.preferences.settings.accent, accent); + assert.equal(b.element.dataset.accent, accent); + assert.equal(b.values.get("extore_accent"), accent); + assert.deepEqual(plain(b.preferences.resolved), resolved); + assert.equal(notifications, accent === "green" ? 0 : 1); + b.preferences.setAccent(accent); + assert.equal(notifications, accent === "green" ? 0 : 1); + const reloaded = browser({ stored: Object.fromEntries(b.values) }); + assert.equal(reloaded.element.dataset.accent, accent); + } +}); + +test("accent storage changes and clear are live, invalid values fall back safely", () => { + const b = browser({ stored: { extore_accent: "invalid" } }); + assert.equal(b.element.dataset.accent, "green"); + assert.throws(() => b.preferences.setAccent("url(javascript:bad)"), /Invalid accent/); + assert.equal(b.values.get("extore_accent"), "invalid"); + b.storageChanged("extore_accent", "violet"); + assert.equal(b.element.dataset.accent, "violet"); + b.storageChanged("extore_accent", "blue", {}); + assert.equal(b.element.dataset.accent, "violet"); + b.storageChanged("extore_accent", "unknown"); + assert.equal(b.element.dataset.accent, "green"); + b.preferences.setAccent("rose"); + b.storageCleared(); + assert.equal(b.element.dataset.accent, "green"); + for (const options of [{ denyStorage: true }, { denyRead: true, denyWrite: true }]) { + const privateBrowser = browser(options); + privateBrowser.preferences.setAccent("blue"); + assert.equal(privateBrowser.element.dataset.accent, "blue"); + privateBrowser.themeChanged(true); + assert.equal(privateBrowser.element.dataset.accent, "blue"); + } +}); diff --git a/tests/test_passkey_reauth.py b/tests/test_passkey_reauth.py new file mode 100644 index 0000000..e01bd93 --- /dev/null +++ b/tests/test_passkey_reauth.py @@ -0,0 +1,276 @@ +"""Confirmation must prove the current identity without replacing its session.""" + +import time +import uuid + +import pytest +from fastapi.testclient import TestClient +from starlette.responses import Response +from test_passkeys import Authenticator, register +from webauthn.helpers import bytes_to_base64url + +from extore.app import app +from extore.config import ORIGIN +from extore.db import db +from extore.security import create_session, digest + +OPTIONS = "/api/auth/reauth/passkey/options" +VERIFY = "/api/auth/reauth/passkey/verify" + + +@pytest.fixture +def accounts(owner): + opened = [] + result = [] + for index in range(3): + shop_id = str(uuid.uuid4()) if index else None + if shop_id: + with db() as c: + c.execute( + "INSERT INTO shops(id,name,email,created,verified) VALUES (?,?,?,?,1)", + ( + shop_id, + f"Test shop {index}", + f"{shop_id}@example.test", + time.time(), + ), + ) + cookie = create_session(c, Response(), "admin", shop_id=shop_id) + client = TestClient(app, base_url=ORIGIN, headers={"Origin": ORIGIN}) + client.cookies.set("extore_session", cookie) + opened.append(client) + else: + client = owner + key = Authenticator() + register(client, key) + status = client.get("/api/auth/status").json() + cookie = client.cookies.get("extore_session") + with db() as c: + c.execute( + "UPDATE sessions SET auth_at=? WHERE digest=?", (1, digest(cookie)) + ) + result.append( + dict(client=client, key=key, shop_id=shop_id, status=status, cookie=cookie) + ) + yield result + for client in opened: + client.close() + + +def options(account): + response = account["client"].post(OPTIONS, json={}) + assert response.status_code == 200, response.text + value = response.json() + assert value["userVerification"] == "required" + assert [item["id"] for item in value["allowCredentials"]] == [account["key"].id] + return value + + +def assertion(account, challenge, **kwargs): + credential = account["key"].assertion(challenge, **kwargs) + handle = ( + ("extore-shop:" + account["shop_id"]).encode() + if account["shop_id"] + else b"extore-owner" + ) + credential["response"]["userHandle"] = bytes_to_base64url(handle) + return credential + + +def session_row(account): + with db() as c: + return dict( + c.execute( + "SELECT * FROM sessions WHERE digest=?", (digest(account["cookie"]),) + ).fetchone() + ) + + +def assert_identity(account): + client = account["client"] + assert client.cookies.get("extore_session") == account["cookie"] + assert client.get("/api/auth/status").json() == account["status"] + + +@pytest.mark.parametrize("index", [0, 1, 2]) +def test_correct_passkey_refreshes_only_current_session(accounts, index): + account = accounts[index] + before = session_row(account) + credential = assertion(account, options(account)) + response = account["client"].post(VERIFY, json={"credential": credential}) + assert response.status_code == 200, response.text + assert not any( + value.startswith("extore_session=") + for value in response.headers.get_list("set-cookie") + ) + after = session_row(account) + assert after["auth_at"] > before["auth_at"] + assert after["auth_method"] == "passkey" + for key in before.keys() - {"auth_at", "auth_method"}: + assert after[key] == before[key], key + assert_identity(account) + assert ( + account["client"].post(VERIFY, json={"credential": credential}).status_code + == 400 + ) + + +@pytest.mark.parametrize("current,wrong", [(0, 1), (1, 0), (1, 2), (2, 1)]) +def test_wrong_account_passkey_fails_without_login_or_session_changes( + accounts, current, wrong +): + account = accounts[current] + before = [session_row(item) for item in accounts] + credential = assertion(accounts[wrong], options(account)) + response = account["client"].post(VERIFY, json={"credential": credential}) + assert response.status_code == 401, response.text + assert "不属于当前账号" in response.text + assert [session_row(item) for item in accounts] == before + assert_identity(account) + # A failed signed response cannot be reused, even with the correct key. + assert ( + account["client"].post(VERIFY, json={"credential": credential}).status_code + == 400 + ) + # The next explicit attempt can still confirm the original identity. + credential = assertion(account, options(account)) + assert ( + account["client"].post(VERIFY, json={"credential": credential}).status_code + == 200 + ) + assert_identity(account) + + +@pytest.mark.parametrize( + "failure", + [ + "signature", + "user-verification", + "user-handle", + "malformed-handle", + "origin", + "challenge", + ], +) +def test_invalid_assertions_cannot_refresh_and_are_consumed(accounts, failure): + account = accounts[0] + challenge = options(account) + before = session_row(account) + kwargs = {} + if failure == "signature": + kwargs["signer"] = accounts[1]["key"] + elif failure == "user-verification": + kwargs["verified"] = False + elif failure == "challenge": + challenge = {**challenge, "challenge": "b3RoZXItY2hhbGxlbmdl"} + credential = assertion(account, challenge, **kwargs) + if failure in {"user-handle", "malformed-handle"}: + credential["response"]["userHandle"] = ( + "@@@" if failure == "malformed-handle" else "b3RoZXI" + ) + elif failure == "origin": + import base64 + + value = credential["response"]["clientDataJSON"] + data = base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + credential["response"]["clientDataJSON"] = bytes_to_base64url( + data.replace(ORIGIN.encode(), b"https://different.example.test") + ) + response = account["client"].post(VERIFY, json={"credential": credential}) + assert response.status_code == 401, response.text + assert session_row(account) == before + assert_identity(account) + assert ( + account["client"].post(VERIFY, json={"credential": credential}).status_code + == 400 + ) + + +def test_allow_list_assertion_without_user_handle_is_valid(accounts): + account = accounts[1] + credential = assertion(account, options(account)) + credential["response"]["userHandle"] = None + response = account["client"].post(VERIFY, json={"credential": credential}) + assert response.status_code == 200, response.text + assert_identity(account) + + +@pytest.mark.parametrize( + "replacement", ["session", "expired", "revoked", "disabled-shop"] +) +def test_confirmation_requires_the_original_live_session(accounts, replacement): + account = accounts[1] + credential = assertion(account, options(account)) + with db() as c: + if replacement == "session": + cookie = create_session(c, Response(), "admin", shop_id=account["shop_id"]) + account["client"].cookies.set("extore_session", cookie) + elif replacement == "expired": + c.execute("UPDATE challenges SET expires=0") + elif replacement == "revoked": + c.execute( + "UPDATE sessions SET revoked=1 WHERE digest=?", + (digest(account["cookie"]),), + ) + else: + c.execute("UPDATE shops SET enabled=0 WHERE id=?", (account["shop_id"],)) + response = account["client"].post(VERIFY, json={"credential": credential}) + assert response.status_code in {400, 401}, response.text + assert session_row(account)["auth_at"] == 1 + + +def test_login_and_confirmation_challenges_cannot_be_interchanged(accounts): + account = accounts[0] + credential = assertion(account, options(account)) + response = account["client"].post( + "/api/auth/login/verify", json={"credential": credential} + ) + assert response.status_code == 400 + assert_identity(account) + challenge = account["client"].post("/api/auth/login/options", json={}).json() + assert not challenge.get("allowCredentials") + credential = assertion(account, challenge) + assert ( + account["client"].post(VERIFY, json={"credential": credential}).status_code + == 400 + ) + assert_identity(account) + # Choosing an account remains intentional and supported on the login route. + credential = assertion(accounts[1], challenge) + response = account["client"].post( + "/api/auth/login/verify", json={"credential": credential} + ) + assert response.status_code == 200, response.text + assert response.json()["shop_id"] == accounts[1]["shop_id"] + + +def test_no_passkeys_does_not_offer_an_unrestricted_confirmation(owner): + before = owner.cookies.get("extore_session") + response = owner.post(OPTIONS, json={}) + assert response.status_code == 409, response.text + assert owner.cookies.get("extore_session") == before + with db() as c: + assert c.execute("SELECT count(*) FROM challenges").fetchone()[0] == 0 + + +def test_confirmation_requires_authentication_and_captured_browser_scope(accounts): + with TestClient(app, base_url=ORIGIN, headers={"Origin": ORIGIN}) as anonymous: + assert anonymous.post(OPTIONS, json={}).status_code == 401 + assert anonymous.post(VERIFY, json={"credential": {}}).status_code == 401 + account = accounts[0] + for headers in [ + {"X-Extore-Shop-Scope": accounts[1]["shop_id"]}, + {"X-Extore-Session-ID": "stale-browser-session"}, + ]: + assert ( + account["client"].post(OPTIONS, json={}, headers=headers).status_code == 409 + ) + credential = assertion(account, options(account)) + assert ( + account["client"] + .post(VERIFY, json={"credential": credential}, headers=headers) + .status_code + == 409 + ) + assert session_row(account)["auth_at"] == 1 + assert_identity(account)