diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml index 145a6fc..666f0f5 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/commitlint.yml @@ -2,7 +2,7 @@ name: Commit Lint on: pull_request: - types: [opened, synchronize, reopened, edited] + types: [opened, synchronize, reopened, edited, ready_for_review] push: branches: - develop @@ -23,8 +23,27 @@ jobs: fetch-depth: 0 - name: Run commitlint + if: github.event_name == 'push' uses: wagoid/commitlint-github-action@v6 with: configFile: .commitlintrc.json failOnWarnings: false - helpURL: https://github.com/FastR-D/FastRead/blob/develop/CONTRIBUTING.md#5-提交规范 + helpURL: https://github.com/FastR-D/FastRead/blob/main/docs/CONTRIBUTING.md + + - name: Set up Node + if: github.event_name == 'pull_request' + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install commitlint + if: github.event_name == 'pull_request' + run: npm install --prefix "$RUNNER_TEMP/commitlint" --no-package-lock --ignore-scripts @commitlint/cli@19.8.1 @commitlint/config-conventional@19.8.1 + + - name: Lint squash commit title + if: github.event_name == 'pull_request' + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + export NODE_PATH="$RUNNER_TEMP/commitlint/node_modules" + printf '%s\n' "$PR_TITLE" | "$RUNNER_TEMP/commitlint/node_modules/.bin/commitlint" --config .commitlintrc.json diff --git a/.github/workflows/fastcas-release-contract.yml b/.github/workflows/fastcas-release-contract.yml new file mode 100644 index 0000000..bc49490 --- /dev/null +++ b/.github/workflows/fastcas-release-contract.yml @@ -0,0 +1,40 @@ +name: FastCAS published SDK contract +on: + pull_request: + push: + branches: [main, master] + tags: ['v*'] + workflow_dispatch: +permissions: + contents: read +jobs: + cas-enabled: + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + CHAT_VECTOR_INDEX_ENABLED: 'false' + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: pip + cache-dependency-path: | + backend/requirements.txt + backend/requirements-dev.txt + requirements-fastcas.txt + - run: python -m pip install -r backend/requirements-dev.txt -r requirements-fastcas.txt + - run: python -m pip check + - name: Verify published wheel identity and adapters + run: python -I -c "from importlib.metadata import version; from fastcas import FastCAS; from fastcas.fastapi import receive_event, receive_logout; assert version('fastcas-sdk') == '0.1.2'; print(version('fastcas-sdk'))" + - name: Real SDK and product HTTP routes + run: python -m pytest --noconftest backend/tests/fastcas_release_contract.py backend/tests/test_research_intake.py backend/tests/test_fastcas_store.py backend/tests/test_fastcas_service.py backend/tests/test_local_accounts.py + cas-image: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - name: Build CAS-enabled backend from this single checkout + run: docker build -f backend/Dockerfile --build-arg PYTHON_BASE_IMAGE=python:3.11-slim --build-arg APT_MIRROR=deb.debian.org --build-arg PIP_INDEX=https://pypi.org/simple --build-arg FASTCAS_ENABLED=true -t fastread-cas:contract . + - name: Verify installed runtime SDK without starting application services + run: docker run --rm --network none fastread-cas:contract python -I -c "from importlib.metadata import version; from fastcas.fastapi import receive_event, receive_logout; assert version('fastcas-sdk') == '0.1.2'" diff --git a/.github/workflows/tag-ci.yml b/.github/workflows/tag-ci.yml index d03981f..54dd1df 100644 --- a/.github/workflows/tag-ci.yml +++ b/.github/workflows/tag-ci.yml @@ -61,7 +61,7 @@ jobs: deploy: needs: validate - if: startsWith(github.ref, 'refs/tags/v') + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') uses: FastR-D/FastDeploy/.github/workflows/deploy.yml@9a2c51dcd63f47a0e6c8f6bb07bd22ea589abbd2 with: app: fastread diff --git a/Dockerfile.complete b/Dockerfile.complete index 4a48b26..d6a867b 100644 --- a/Dockerfile.complete +++ b/Dockerfile.complete @@ -11,6 +11,12 @@ WORKDIR /tmp/backend # 先复制 requirements.txt 利用层缓存 COPY ./backend/requirements.txt /tmp/backend/requirements.txt RUN pip install --no-cache-dir -i ${PIP_INDEX} -r requirements.txt +COPY ./requirements-fastcas.txt /tmp/backend/requirements-fastcas.txt +ARG FASTCAS_ENABLED=false +RUN if [ "$FASTCAS_ENABLED" = "true" ]; then \ + pip install --no-cache-dir -i ${PIP_INDEX} -r requirements.txt -r requirements-fastcas.txt && \ + python -c "from fastcas import FastCAS; from fastcas.fastapi import receive_event, receive_logout"; \ + fi COPY ./backend /tmp/backend diff --git a/backend/Dockerfile b/backend/Dockerfile index 056fcb1..e602313 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -21,6 +21,12 @@ WORKDIR /app # 先复制 requirements.txt 利用层缓存 COPY ./backend/requirements.txt /app/requirements.txt RUN pip install --no-cache-dir -i ${PIP_INDEX} -r requirements.txt +COPY ./requirements-fastcas.txt /app/requirements-fastcas.txt +ARG FASTCAS_ENABLED=false +RUN if [ "$FASTCAS_ENABLED" = "true" ]; then \ + pip install --no-cache-dir -i ${PIP_INDEX} -r requirements.txt -r requirements-fastcas.txt && \ + python -c "from fastcas import FastCAS; from fastcas.fastapi import receive_event, receive_logout"; \ + fi # 再复制应用代码(频繁变动不影响 pip 缓存层) COPY ./backend /app diff --git a/backend/Dockerfile.gpu b/backend/Dockerfile.gpu index 7f2aaae..c8e7c38 100644 --- a/backend/Dockerfile.gpu +++ b/backend/Dockerfile.gpu @@ -20,6 +20,12 @@ WORKDIR /app COPY ./backend/requirements.txt /app/requirements.txt RUN pip install --no-cache-dir -i ${PIP_INDEX} -r requirements.txt && \ pip install --no-cache-dir -i ${PIP_INDEX} 'transformers[torch]>=4.23' +COPY ./requirements-fastcas.txt /app/requirements-fastcas.txt +ARG FASTCAS_ENABLED=false +RUN if [ "$FASTCAS_ENABLED" = "true" ]; then \ + pip install --no-cache-dir -i ${PIP_INDEX} -r requirements.txt -r requirements-fastcas.txt && \ + python3 -c "from fastcas import FastCAS; from fastcas.fastapi import receive_event, receive_logout"; \ + fi # 再复制应用代码 COPY ./backend /app diff --git a/backend/app/web/api.py b/backend/app/web/api.py index d87650d..5a4f4d6 100644 --- a/backend/app/web/api.py +++ b/backend/app/web/api.py @@ -468,9 +468,13 @@ def owned_conversation(cid, user): return row @api.get("/conversations/{cid}/messages") - def messages(cid: str, user: User, limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0)): + def messages(cid: str, request: Request, user: User, limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0)): owned_conversation(cid, user) - rows = store.all("SELECT * FROM messages WHERE conversation_id=? ORDER BY created,id LIMIT ? OFFSET ?", (cid, limit, offset)) + if "offset" in request.query_params: + rows = store.all("SELECT * FROM messages WHERE conversation_id=? ORDER BY created,id LIMIT ? OFFSET ?", (cid, limit, offset)) + else: + rows = store.all("SELECT * FROM messages WHERE conversation_id=? ORDER BY created DESC,id DESC LIMIT ?", (cid, limit)) + rows.reverse() for row in rows: row["evidence"] = json.loads(row["evidence"]) return {"items": rows} diff --git a/backend/tests/fastcas_release_contract.py b/backend/tests/fastcas_release_contract.py new file mode 100644 index 0000000..1a43ab2 --- /dev/null +++ b/backend/tests/fastcas_release_contract.py @@ -0,0 +1,164 @@ +"""Explicit CAS-enabled gate: installed wheel -> real routes -> SQLite. + +Not collected by the local-mode suite, which intentionally has no optional SDK. +CI invokes this file by name; missing SDK/dependencies is an error, never a skip. +The loopback OIDC peer is disposable, not a deployed FastCAS integration claim. +""" +import base64 +import hashlib +import json +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from importlib.metadata import version +from urllib.parse import parse_qs, urlencode, urlparse + +from authlib.jose import JsonWebKey, jwt +from fastapi.testclient import TestClient + +from app.web.api import create_web_app +from app.web.auth import create_user + + +def test_published_sdk_link_login_sid_logout_and_signed_revocation(tmp_path, monkeypatch): + assert version('fastcas-sdk') == '0.1.2' + key = JsonWebKey.generate_key('RSA', 2048, {'kid': 'fixture'}, is_private=True) + peer = {'codes': {}, 'counter': 0, 'link': None} + read_origin = 'http://127.0.0.1:8483' + + def sign(claims, typ='JWT'): + return jwt.encode({'alg': 'RS256', 'kid': 'fixture', 'typ': typ}, + {'iss': issuer, 'aud': 'read', 'sub': 'subject-a', + 'iat': int(time.time()), 'exp': int(time.time()) + 120, **claims}, key).decode() + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *_): + pass + + def reply(self, data, status=200): + self.send_response(status) + self.send_header('Content-Type', 'application/json') + self.end_headers() + self.wfile.write(json.dumps(data).encode()) + + def do_GET(self): + path = urlparse(self.path).path + if path == '/.well-known/openid-configuration': + self.reply({'issuer': issuer, 'authorization_endpoint': issuer + '/authorize', + 'token_endpoint': issuer + '/token', 'jwks_uri': issuer + '/jwks', + 'userinfo_endpoint': issuer + '/userinfo'}) + elif path == '/jwks': + self.reply({'keys': [key.as_dict(is_private=False)]}) + elif path == '/userinfo': + self.reply({'sub': 'subject-a', 'email': 'local@example.org'}) + elif path == '/api/v1/account-links/resolve': + self.reply(peer['link']) + else: + self.reply({'error': 'not_found'}, 404) + + def do_POST(self): + raw = self.rfile.read(int(self.headers.get('Content-Length', '0'))) + if self.path == '/token': + form = parse_qs(raw.decode()) + grant = peer['codes'].pop(form.get('code', [''])[0], None) + verifier = form.get('code_verifier', [''])[0] + challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b'=').decode() + if not grant or challenge != grant['code_challenge'][0] or form.get('grant_type') != ['authorization_code']: + self.reply({'error': 'invalid_grant'}, 400) + return + peer['counter'] += 1 + self.reply({'access_token': 'disposable-access', 'token_type': 'Bearer', 'expires_in': 120, + 'id_token': sign({'nonce': grant['nonce'][0], 'sid': 'sid-' + str(peer['counter'])})}) + elif self.path == '/api/v1/link-intents': + body = json.loads(raw) + peer['local_ref'] = body['local_account_ref'] + self.reply({'id': 'intent-a', 'nonce': 'intent-nonce', 'client_id': 'read', + 'local_account_ref': body['local_account_ref'], 'state': 'pending', + 'expires_at': '2030-01-01T00:00:00Z'}) + elif self.path == '/api/v1/link-intents/intent-a/prepare': + body = json.loads(raw) + if body != {'nonce': 'intent-nonce', 'subject': 'subject-a'}: + self.reply({'error': 'invalid_intent'}, 400) + return + peer['link'] = {'id': 'link-a', 'client_id': 'read', 'subject': 'subject-a', + 'local_account_ref': peer['local_ref'], 'state': 'prepared', + 'version': 1, 'verified_at': '2026-11-10T00:00:00Z'} + self.reply(peer['link']) + elif self.path == '/api/v1/link-intents/link-a/activate': + peer['link'] = {**peer['link'], 'state': 'active', 'version': 2} + self.reply(peer['link']) + else: + self.reply({'error': 'not_found'}, 404) + + server = ThreadingHTTPServer(('127.0.0.1', 0), Handler) + issuer = f'http://127.0.0.1:{server.server_port}' + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + for suffix, value in {'ISSUER': issuer, 'CLIENT_ID': 'read', 'CLIENT_SECRET': 'disposable-secret', + 'REDIRECT_URI': read_origin + '/api/auth/fastcas/callback', + 'ALLOW_LOOPBACK_HTTP': 'true'}.items(): + monkeypatch.setenv('FASTREAD_FASTCAS_' + suffix, value) + monkeypatch.setenv('FASTREAD_COOKIE_SECURE', 'false') + monkeypatch.setenv('CHAT_VECTOR_INDEX_ENABLED', 'false') + app = create_web_app(tmp_path) + service = app.state.fastcas + + def finish(client, location): + query = parse_qs(urlparse(location).query) + code = 'code-' + str(len(peer['codes'])) + '-' + str(peer['counter']) + peer['codes'][code] = query + callback = '/api/auth/fastcas/callback?' + urlencode({'code': code, 'state': query['state'][0]}) + response = client.get(callback, follow_redirects=False) + assert response.status_code == 302 and response.headers['location'].endswith('fastcas=complete') + return callback + + def cas_login(): + client = TestClient(app, base_url=read_origin) + response = client.get('/api/auth/fastcas/login', follow_redirects=False) + assert response.status_code == 302 + callback = finish(client, response.headers['location']) + assert client.get('/api/auth/me').status_code == 200 + assert client.get(callback, follow_redirects=False).headers['location'].endswith('fastcas=failed') + return client + + try: + user = create_user(app.state.store, 'local@example.org', 'original-password') + local = TestClient(app, base_url=read_origin) + preauth = local.get('/api/auth/session-init').json()['csrf'] + result = local.post('/api/auth/login', json={'email': user['email'], 'password': 'original-password'}, headers={'X-CSRF-Token': preauth}) + assert result.status_code == 200 + link = local.post('/api/auth/fastcas/link', json={'password': 'original-password'}, + headers={'Origin': read_origin, 'X-CSRF-Token': result.json()['csrf']}) + assert link.status_code == 200 + finish(local, link.json()['url']) + assert local.get('/api/auth/me').status_code == 200 + first, second = cas_login(), cas_login() + notify = TestClient(app, base_url=read_origin) + notice = {'jti': 'logout-one', 'sid': 'sid-2', + 'events': {'http://schemas.openid.net/event/backchannel-logout': {}}} + invalid = notify.post('/api/auth/fastcas/backchannel-logout', data={'logout_token': sign({**notice, 'aud': 'other'}, 'logout+jwt')}) + assert invalid.status_code == 401 + assert first.get('/api/auth/me').status_code == 200 + token = sign(notice, 'logout+jwt') + for _ in range(2): + assert notify.post('/api/auth/fastcas/backchannel-logout', data={'logout_token': token}).status_code == 200 + assert first.get('/api/auth/me').status_code == 401 + assert second.get('/api/auth/me').status_code == 200 + assert local.get('/api/auth/me').status_code == 200 + global_notice = {'jti': 'logout-all', 'events': notice['events']} + assert notify.post('/api/auth/fastcas/backchannel-logout', data={'logout_token': sign(global_notice, 'logout+jwt')}).status_code == 200 + assert second.get('/api/auth/me').status_code == 401 + third = cas_login() + revoked = {**peer['link'], 'state': 'revoked', 'version': 3} + event = sign({'jti': 'revoke-one', 'event': {'type': 'account_link.revoked', 'link': revoked}}, 'fastcas-event+jwt') + for _ in range(2): + assert notify.post('/api/auth/fastcas/events', content=event, headers={'Content-Type': 'application/jwt'}).status_code == 200 + assert third.get('/api/auth/me').status_code == 401 + assert local.get('/api/auth/me').status_code == 200 + assert local.get('/api/auth/me').json()['workspace_id'] == user['workspace_id'] + finally: + if service._sdk is not None: + service.sdk.close() + server.shutdown() + server.server_close() + thread.join(timeout=5) diff --git a/backend/tests/test_research_intake.py b/backend/tests/test_research_intake.py index c366426..382443b 100644 --- a/backend/tests/test_research_intake.py +++ b/backend/tests/test_research_intake.py @@ -1,6 +1,10 @@ from fastapi import HTTPException from fastapi.testclient import TestClient from tests.test_web_platform import setup, document +import json +from pathlib import Path + +CONTRACT = json.loads((Path(__file__).resolve().parents[2] / 'contracts/fastnews-intake/v1/cases.json').read_text(encoding='utf-8')) def test_sso_rotates_session_and_preserves_workspace(setup): @@ -31,12 +35,19 @@ def reject(*args, **kwargs): def test_fastnews_intake_pdf_attachment_and_workspace_isolation(setup): store, a, b, ca, cb = setup - data = {'title': 'A metadata-only paper', 'authors': 'Alice', 'abstract': 'A recorded abstract', - 'url': 'https://example.org/paper', 'external_id': 'fixture-1', 'year': '2026'} + data = CONTRACT['intake'] first = ca.post('/api/integrations/fastnews/intake', json=data) assert first.status_code in (200, 201) + assert all(first.json()[key] == value for key, value in CONTRACT['first_receipt'].items()) pid = first.json()['id'] - assert ca.post('/api/integrations/fastnews/intake', json=data).json()['id'] == pid + assert pid and first.json()['title'] == data['title'] + repeated = ca.post('/api/integrations/fastnews/intake', json=data).json() + assert repeated['id'] == pid + assert all(repeated[key] == value for key, value in CONTRACT['repeat_receipt'].items()) + updated = ca.post('/api/integrations/fastnews/intake', json={**data, 'title': 'Updated title'}) + assert updated.json()['id'] == pid and updated.json()['created'] is False + assert ca.get(f'/api/papers/{pid}').json()['title'] == 'Updated title' + assert ca.post('/api/integrations/fastnews/intake', json=data).json()['title'] == data['title'] assert cb.post('/api/integrations/fastnews/intake', json=data).json()['id'] != pid assert ca.get(f'/api/papers/{pid}/pages/1').status_code == 409 assert cb.get(f'/api/papers/{pid}').status_code == 404 @@ -50,3 +61,36 @@ def test_fastnews_intake_pdf_attachment_and_workspace_isolation(setup): assert result['paper_id'] == pid paper = ca.get(f'/api/papers/{pid}').json() assert paper['active_version'] and paper['title'] == data['title'] + again = ca.post('/api/integrations/fastnews/intake', json=data) + assert again.status_code == 200 and again.json()['awaiting_file'] is False + assert ca.get(f'/api/papers/{pid}').json()['active_version'] == paper['active_version'] + + +def test_intake_wire_errors_and_normalization(setup): + _, _, _, ca, _ = setup + endpoint = '/api/integrations/fastnews/intake' + anonymous = TestClient(ca.app) + assert anonymous.post(endpoint, json=CONTRACT['intake']).status_code == 401 + assert ca.post(endpoint, json=CONTRACT['intake'], headers={'X-CSRF-Token': 'wrong'}).status_code == 403 + for case in CONTRACT['invalid']: + assert ca.post(endpoint, json=case['body']).status_code == case['status'] + for field, limit in [('title', 300), ('authors', 400), ('abstract', 800), ('venue', 80), ('year', 8), ('external_id', 200)]: + assert ca.post(endpoint, json={**CONTRACT['intake'], field: 'x' * (limit + 1)}).status_code == 422 + for url in CONTRACT['url_normalization']: + response = ca.post(endpoint, json={**CONTRACT['intake'], 'url': url}) + assert response.status_code == 200 + paper = ca.get('/api/papers/' + response.json()['id']).json() + assert paper['metadata']['url'] == '' + + +def test_intake_without_external_id_uses_url_then_title_deduplication(setup): + _, _, _, ca, _ = setup + endpoint = '/api/integrations/fastnews/intake' + for payload in [{'title': 'URL identity', 'url': 'https://example.org/keyless'}, {'title': 'Title identity'}]: + first = ca.post(endpoint, json=payload) + assert first.status_code == 200 and first.json()['created'] is True + repeated = ca.post(endpoint, json={**payload, 'authors': 'Updated author'}) + assert repeated.status_code == 200 + assert repeated.json()['id'] == first.json()['id'] and repeated.json()['created'] is False + paper = ca.get('/api/papers/' + first.json()['id']).json() + assert paper['metadata']['authors'] == ['Updated author'] diff --git a/backend/tests/test_web_platform.py b/backend/tests/test_web_platform.py index 7daf867..bce133a 100644 --- a/backend/tests/test_web_platform.py +++ b/backend/tests/test_web_platform.py @@ -1,13 +1,15 @@ import json import sqlite3 +import threading import time from concurrent.futures import ThreadPoolExecutor +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import pytest from fastapi.testclient import TestClient from app.web.api import create_web_app -from app.web.auth import create_user +from app.web.auth import create_user, login from app.web.store import Store from app.web.worker import execute @@ -154,6 +156,104 @@ def test_paginated_summaries_and_immutable_versions(setup): assert forbidden not in listing["items"][0] +@pytest.mark.parametrize("count", [0, 1, 49, 50, 51, 60, 121]) +def test_messages_default_latest_window_and_explicit_history(tmp_path, monkeypatch, count): + # Gap: the desk omits offset, so a historical first page hides new messages. + monkeypatch.setenv("FASTREAD_COOKIE_SECURE", "false") + app = create_web_app(tmp_path) + store = app.state.store + user = create_user(store, "window@example.test", "disposable-password") + other = create_user(store, "other@example.test", "disposable-password") + client = TestClient(app) + token, csrf = login(store, user["email"], "disposable-password", "127.0.0.1") + client.cookies.set("fastread_session", token) + client.headers["X-CSRF-Token"] = csrf + paper = store.ingest(user["workspace_id"], document()) + cid = client.post(f"/api/papers/{paper['paper_id']}/conversations").json()["id"] + path = f"/api/conversations/{cid}/messages" + ids = [f"m{i:03}" for i in range(count)] + with store.connect(write=True) as db: + # Reverse insertion and tied timestamps expose incomplete ordering keys. + db.executemany("INSERT INTO messages VALUES(?,?,?,?,?,?,?)", [ + (mid, cid, "assistant", mid, '{"sources":[]}', None, i // 4) + for i, mid in reversed(list(enumerate(ids))) + ]) + db.execute("INSERT INTO providers VALUES(?,?,?,?,?,?,?)", ( + "window-provider", user["workspace_id"], "Fixture", "https://example.test/v1", + "unused", '["window-model"]', time.time())) + windows = {} + for query, expected in [ + ("", ids[-50:]), ("?limit=10", ids[-10:]), + ("?limit=1", ids[-1:]), ("?limit=100", ids[-100:]), + ("?offset=0", ids[:50]), ("?limit=10&offset=0", ids[:10]), + ("?limit=10&offset=50", ids[50:60]), (f"?offset={count}", []), + ]: + response = client.get(path + query) + assert response.status_code == 200 + items = response.json()["items"] + assert [row["id"] for row in items] == expected, query + assert all(row["evidence"] == {"sources": []} for row in items) + windows[query or "default"] = [row["id"] for row in items] + for query in ["?limit=0", "?limit=101", "?offset=-1", "?offset="]: + assert client.get(path + query).status_code == 422 + sent = client.post(path, json={"content": "latest question", "provider_id": "window-provider", + "model": "window-model"}, headers={"Idempotency-Key": "latest"}) + assert sent.status_code == 202 + for _ in range(2): + assert client.get(path).json()["items"][-1]["content"] == "latest question" + if count == 60: + requests = [] + class ModelHandler(BaseHTTPRequestHandler): + def do_POST(self): + requests.append(json.loads(self.rfile.read(int(self.headers["Content-Length"])))) + completion = {"answer": "Latest grounded answer", "citations": [ + {"page": 1, "exact_quote": document()["pages"][0]["text"]}]} + body = json.dumps({"id": "fixture", "object": "chat.completion", "created": 1, + "model": "window-model", "choices": [{"index": 0, "finish_reason": "stop", + "message": {"role": "assistant", "content": json.dumps(completion)}}]}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + server = ThreadingHTTPServer(("127.0.0.1", 0), ModelHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + monkeypatch.setenv("CHAT_VECTOR_INDEX_ENABLED", "false") + with store.connect(write=True) as db: + db.execute("UPDATE providers SET base_url=? WHERE id='window-provider'", + (f"http://127.0.0.1:{server.server_port}/v1",)) + claimed = store.claim("window-worker") + assert claimed["id"] == sent.json()["id"] + execute(store, claimed, "window-worker") + assert len(requests) == 1 + assert requests[0]["messages"][-1]["content"] == "latest question" + assert client.get(f"/api/jobs/{claimed['id']}").json()["state"] == "succeeded" + for _ in range(2): + latest = client.get(path).json()["items"] + assert len(latest) == 50 + assert [row["role"] for row in latest[-2:]] == ["user", "assistant"] + assert latest[-1]["content"] == "Latest grounded answer" + assert latest[-1]["evidence"]["sources"][0]["exact_quote"] == document()["pages"][0]["text"] + finally: + server.shutdown(); server.server_close(); thread.join(timeout=5) + assert client.get("/api/conversations/missing/messages").status_code == 404 + assert TestClient(app).get(path).status_code == 401 + unrelated = store.ingest(user["workspace_id"], document() | {"doi": "10.1234/other"}) + unrelated_cid = client.post(f"/api/papers/{unrelated['paper_id']}/conversations").json()["id"] + for query in ["", "?offset=0"]: + assert client.get(f"/api/conversations/{unrelated_cid}/messages" + query).json()["items"] == [] + other_token, _ = login(store, other["email"], "disposable-password", "127.0.0.1") + client.cookies.set("fastread_session", other_token) + for query in ["", "?offset=0"]: + assert client.get(path + query).status_code == 404 + (tmp_path / "message-window.json").write_text(json.dumps({ + "initial_count": count, "windows": windows, "posted_question_visible": True, + "worker_answer_verified": count == 60, "anonymous": 401, "other_workspace": 404, + }, sort_keys=True, indent=2), encoding="utf-8") + + def test_atomic_dedupe_and_concurrency_limits(setup): store, a, b, _, _ = setup def submit(_): diff --git a/contracts/fastnews-intake/v1/README.md b/contracts/fastnews-intake/v1/README.md new file mode 100644 index 0000000..9b46d25 --- /dev/null +++ b/contracts/fastnews-intake/v1/README.md @@ -0,0 +1,34 @@ +# FastNews → FastRead intake v1 + +Owner: FastRead. Receiver: `POST /api/integrations/fastnews/intake` with a +FastRead session cookie and matching `X-CSRF-Token`. Anonymous=401, invalid +CSRF=403, invalid title/oversized normalized text=422, success=200. Receipt is +metadata persistence, not a PDF download or automatic job submission. + +Schemas cover canonical intake and receipt. `cases.json` records existing wire +semantics, including URL sanitation (invalid URLs become empty, not 422). +Same workspace + external_id deduplicates, else URL hash, else title hash. +Duplicates update metadata and return created=false. Different workspaces are +isolated. A subsequent PDF attachment retains the paper ID and active_version; +another metadata intake must not clear that version. There is no intake +Idempotency-Key contract or cross-workspace shared identity. + +## Browser handoff boundary for the UI owner + +News `serve.py:build_fastread_handoff` currently returns +`{readBase}/?sso=#fastnews=`; without a ticket, +`{readBase}/#fastnews=...`. readBase may include `/read`. Decode the fragment +once, normalize `handoff.id` to `intake.external_id`, and send only canonical +intake fields. DOI is present in News' carrier but is not an intake API field. +Do not claim DOI was stored. The fixture deliberately keeps both forms visible. + +Keep a pending handoff across ticket consumption and local-login UI, clear the +ticket URL promptly, and clear the pending payload only after an authenticated, +CSRF-authorized successful receipt. Failed 401/403/422/network requests must not +display success. Root/subpath carrier decoding and login restoration need a real +browser scenario in the UI change. The SDK/CI work does not edit frontend source. + +Run: `python -m pytest --noconftest backend/tests/test_research_intake.py`. +The HTTP contract test reads this fixture directly; News is the confirmed sending +product. A second independent sending product has not been identified. No shared +generated client or FastCommon package is introduced. diff --git a/contracts/fastnews-intake/v1/cases.json b/contracts/fastnews-intake/v1/cases.json new file mode 100644 index 0000000..b77bb93 --- /dev/null +++ b/contracts/fastnews-intake/v1/cases.json @@ -0,0 +1,30 @@ +{ + "handoff": { + "title": "共享契约 / scientific evidence", + "source": "fastnews", + "authors": "Alice; Bob", + "abstract": "A synthetic abstract.", + "url": "https://example.org/paper", + "venue": "Fixture Conference", + "year": "2026", + "id": "news-fixture-1", + "doi": "10.1234/fixture" + }, + "intake": { + "title": "共享契约 / scientific evidence", + "authors": "Alice; Bob", + "abstract": "A synthetic abstract.", + "url": "https://example.org/paper", + "venue": "Fixture Conference", + "year": "2026", + "external_id": "news-fixture-1" + }, + "first_receipt": {"awaiting_file": true, "created": true}, + "repeat_receipt": {"awaiting_file": true, "created": false}, + "invalid": [ + {"body": {}, "status": 422}, + {"body": {"title": " "}, "status": 422}, + {"body": {"title": null}, "status": 422} + ], + "url_normalization": ["javascript:alert(1)", "https://user:secret@example.org/paper", "not-a-url"] +} diff --git a/contracts/fastnews-intake/v1/request.schema.json b/contracts/fastnews-intake/v1/request.schema.json new file mode 100644 index 0000000..a947da6 --- /dev/null +++ b/contracts/fastnews-intake/v1/request.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "FastRead FastNews normalized intake", + "type": "object", + "required": ["title"], + "properties": { + "title": {"type": "string", "minLength": 1, "maxLength": 300, "pattern": "\\S"}, + "authors": {"type": "string", "maxLength": 400, "default": ""}, + "abstract": {"type": "string", "maxLength": 800, "default": ""}, + "url": {"type": "string", "maxLength": 2048, "default": ""}, + "venue": {"type": "string", "maxLength": 80, "default": ""}, + "year": {"type": "string", "maxLength": 8, "default": ""}, + "external_id": {"type": "string", "maxLength": 200, "default": ""} + }, + "description": "Canonical input after text whitespace normalization. Raw text fields are stringified, null becomes empty. Invalid/non-HTTP(S)/credential-bearing URLs become empty; URLs truncate to 2048. Extra fields are ignored, including id and doi: browser handoff must map id to external_id." +} diff --git a/contracts/fastnews-intake/v1/response.schema.json b/contracts/fastnews-intake/v1/response.schema.json new file mode 100644 index 0000000..ddaed7a --- /dev/null +++ b/contracts/fastnews-intake/v1/response.schema.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "FastRead FastNews intake receipt", + "type": "object", + "required": ["id", "title", "awaiting_file", "created"], + "properties": { + "id": {"type": "string", "minLength": 1}, + "title": {"type": "string"}, + "awaiting_file": {"type": "boolean"}, + "created": {"type": "boolean"} + } +} diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md new file mode 100644 index 0000000..759331a --- /dev/null +++ b/docs/CONTRIBUTING.md @@ -0,0 +1,14 @@ +# Contributing + +Use Conventional Commits for changes, for example `fix(auth): restore pending paper after login`. +The accepted types and formatting rules are defined in `.commitlintrc.json`. + +Pull requests are squash-merged using their title as the commit title. CI validates +that title with the same commitlint configuration. This lets a branch retain its +working and upstream integration history while ensuring the resulting main-branch +commit follows the convention. Edit the PR title to correct a failed title check; +do not rewrite published integration history to satisfy the check. + +Direct pushes to `develop` and `master` still validate their individual commit +messages. Before merging a PR, wait for its functional checks and title check to +pass, and use the validated title for the squash commit. diff --git a/docs/FASTCAS.md b/docs/FASTCAS.md index 155281a..edaee24 100644 --- a/docs/FASTCAS.md +++ b/docs/FASTCAS.md @@ -4,7 +4,9 @@ ## 开发配置 -在 FastRead 根目录运行 `python -m pip install -r requirements-fastcas.txt`,安装 PyPI 的 `fastcas-sdk[fastapi]==0.1.2`,不需要同级 FastCAS 源码目录。本地模式不需要 SDK,不会访问 FastCAS。 +在 FastRead 根目录运行 `python -m pip install -r backend/requirements.txt -r requirements-fastcas.txt`,从 PyPI 安装固定发行版 `fastcas-sdk[fastapi]==0.1.2`(导入名 `fastcas`),不需要同级 FastCAS 源码目录。本地模式不需要 SDK,不会访问 FastCAS。 + +三个后端镜像入口 `backend/Dockerfile`、`backend/Dockerfile.gpu`、`Dockerfile.complete` 均通过 `--build-arg FASTCAS_ENABLED=true` 安装同一可选依赖并检查 FastAPI 适配层导入。例如在仓库根执行 `docker build -f backend/Dockerfile --build-arg FASTCAS_ENABLED=true -t fastread-cas:local .`。默认构建保留无 SDK 的本地模式;运行时启用下面四项连接配置前必须选择 CAS-enabled 构建。Compose 用户可在本地 override 的 backend.build.args 中指定 `FASTCAS_ENABLED: "true"`,连接配置放入 backend.environment;这不是自动启用 CAS 登录的开关。 连接配置四项必须同时提供: @@ -29,6 +31,18 @@ ## 验证和剩余工作 +发行包门禁 `.github/workflows/fastcas-release-contract.yml` 在单仓 checkout +安装完整后端依赖与可选集,执行依赖兼容检查及 +`backend/tests/fastcas_release_contract.py`。该场景通过安装的 0.1.2 wheel +访问可丢弃的 HTTP OIDC 签名对端,并经过 FastRead 实际认证路由与 SQLite, +覆盖绑定、PKCE/nonce 回调、单次消费、SID/全局登出、签名撤销与本地会话保留。 +它验证发行包与产品 adapter,不代表部署中的 Go FastCAS 提供方联调。 +普通质量门仍只安装基础依赖;CAS 镜像 job 单独构建并检查运行时导入。 + +FastNews intake 的 receiver-owned wire 合同及浏览器交接边界见 +`contracts/fastnews-intake/v1/README.md`。HTTP intake 回归读取同目录 +`cases.json`,覆盖 CSRF、校验、去重、工作区隔离及已附 PDF 保留。 + `PYTHONPATH=backend python3 -m pytest --noconftest backend/tests/test_local_accounts.py backend/tests/test_fastcas_store.py backend/tests/test_fastcas_service.py -q`:16 项通过。跳过全局桌面数据库 fixture,仅验证账号和接入服务;不代表全仓库回归。 FastCAS 根目录运行 `FASTCAS_PROJECT_CONTRACT=1 go test ./internal/httpapi -run '^TestFastReadAgainstProvider$' -count=1 -v`,需要测试 PostgreSQL 和可导入 FastAPI、HTTPX、SDK 的 Python(可用 `FASTCAS_PROJECT_PYTHON` 指定)。已通过真实 Go 提供方 + PostgreSQL + Python SDK + FastRead 真实认证路由契约,覆盖同邮箱不合并、本地证明绑定、CAS 登录、回调重放拒绝、解除后保留本地会话和原论文工作区。 diff --git a/fastread-frontend/.gitignore b/fastread-frontend/.gitignore index 88b6244..62ca580 100644 --- a/fastread-frontend/.gitignore +++ b/fastread-frontend/.gitignore @@ -23,3 +23,4 @@ dist-ssr *.sln *.sw? /src-tauri/bin/ +/test-results/ diff --git a/fastread-frontend/package.json b/fastread-frontend/package.json index 7c572cd..b505b02 100644 --- a/fastread-frontend/package.json +++ b/fastread-frontend/package.json @@ -11,9 +11,15 @@ "typecheck": "tsc -b --pretty false", "lint": "eslint .", "preview": "vite preview", - "test": "vitest run" + "test": "vitest run", + "test:fastui:browser": "node scripts/fastui-browser.mjs", + "test:fastcas:browser": "node scripts/fastcas-modes-browser.mjs", + "test:fastnews:browser": "node scripts/fastnews-browser.mjs", + "test:fastnews:auth:browser": "node scripts/fastnews-auth-browser.mjs" }, "dependencies": { + "@fastrd/ui-foundation": "0.1.2", + "@fastrd/ui-react": "0.1.2", "@ant-design/x": "1.6.1", "@hookform/resolvers": "^5.0.1", "@lobehub/icons": "^1.97.1", @@ -72,6 +78,7 @@ "zustand": "^5.0.3" }, "devDependencies": { + "@playwright/test": "^1.63.0", "@eslint/js": "^9.21.0", "@tailwindcss/postcss": "^4.1.3", "@tauri-apps/cli": "2.5.0", diff --git a/fastread-frontend/pnpm-lock.yaml b/fastread-frontend/pnpm-lock.yaml index 6220403..0cebcfb 100644 --- a/fastread-frontend/pnpm-lock.yaml +++ b/fastread-frontend/pnpm-lock.yaml @@ -11,6 +11,12 @@ importers: '@ant-design/x': specifier: 1.6.1 version: 1.6.1(antd@5.29.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@fastrd/ui-foundation': + specifier: 0.1.2 + version: 0.1.2 + '@fastrd/ui-react': + specifier: 0.1.2 + version: 0.1.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@hookform/resolvers': specifier: ^5.0.1 version: 5.2.2(react-hook-form@7.72.0(react@19.2.4)) @@ -180,6 +186,9 @@ importers: '@eslint/js': specifier: ^9.21.0 version: 9.39.4 + '@playwright/test': + specifier: ^1.63.0 + version: 1.65.0 '@tailwindcss/postcss': specifier: ^4.1.3 version: 4.2.2 @@ -679,6 +688,15 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@fastrd/ui-foundation@0.1.2': + resolution: {integrity: sha512-Jobx7DanQOejVRFfsT5DFXND7rtcvSUzZ5Zui+lY3oAuretz8vwkzqHh1Gd0+ezJubw82WeFsnsXdvbWB4t1YA==} + + '@fastrd/ui-react@0.1.2': + resolution: {integrity: sha512-/7WrTu/LrsMWcJKQglQBmpe+sXWj0hGUt66mgrZ3f6B/5dZfb03xdrbPSKRRGFmCA8H6O8stmU9oOFMSuW7YHQ==} + peerDependencies: + react: ^18.3.0 || ^19.0.0 + react-dom: ^18.3.0 || ^19.0.0 + '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} @@ -814,6 +832,11 @@ packages: '@mermaid-js/parser@1.0.1': resolution: {integrity: sha512-opmV19kN1JsK0T6HhhokHpcVkqKpF+x2pPDKKM2ThHtZAB5F4PROopk0amuVYK5qMrIA4erzpNm8gmPNJgMDxQ==} + '@playwright/test@1.65.0': + resolution: {integrity: sha512-iGURLGuKDLakSEl7Pgfv8h7f+XLT+V861Wfhz0zlEl5nyFuKUopdkFBPKqTllTUp06aOwPuH8iuUEb3yp3MpbQ==} + engines: {node: '>=20'} + hasBin: true + '@primer/octicons@19.23.1': resolution: {integrity: sha512-CzjGmxkmNhyst6EekrS3SJPdtzgIkUMP/LSJch65y99/kmiFXbO1a+q7zoYe3hnI9NaOM0IN+ydDIbOmd8YqcA==} @@ -1313,79 +1336,66 @@ packages: resolution: {integrity: sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.0': resolution: {integrity: sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.0': resolution: {integrity: sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.0': resolution: {integrity: sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.0': resolution: {integrity: sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.0': resolution: {integrity: sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.0': resolution: {integrity: sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.0': resolution: {integrity: sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.0': resolution: {integrity: sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.0': resolution: {integrity: sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.0': resolution: {integrity: sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.0': resolution: {integrity: sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.0': resolution: {integrity: sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.60.0': resolution: {integrity: sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==} @@ -1490,28 +1500,24 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.2': resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.2': resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.2': resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.2': resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} @@ -1575,35 +1581,30 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-arm64-musl@2.5.0': resolution: {integrity: sha512-rQO1HhRUQqyEaal5dUVOQruTRda/TD36s9kv1hTxZiFuSq3558lsTjAcUEnMAtBcBkps20sbyTJNMT0AwYIk8Q==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@tauri-apps/cli-linux-riscv64-gnu@2.5.0': resolution: {integrity: sha512-7oS18FN46yDxyw1zX/AxhLAd7T3GrLj3Ai6s8hZKd9qFVzrAn36ESL7d3G05s8wEtsJf26qjXnVF4qleS3dYsA==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-x64-gnu@2.5.0': resolution: {integrity: sha512-SG5sFNL7VMmDBdIg3nO3EzNRT306HsiEQ0N90ILe3ZABYAVoPDO/ttpCO37ApLInTzrq/DLN+gOlC/mgZvLw1w==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-x64-musl@2.5.0': resolution: {integrity: sha512-QXDM8zp/6v05PNWju5ELsVwF0VH1n6b5pk2E6W/jFbbiwz80Vs1lACl9pv5kEHkrxBj+aWU/03JzGuIj2g3SkQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@tauri-apps/cli-win32-arm64-msvc@2.5.0': resolution: {integrity: sha512-pFSHFK6b+o9y4Un8w0gGLwVyFTZaC3P0kQ7umRt/BLDkzD5RnQ4vBM7CF8BCU5nkwmEBUCZd7Wt3TWZxe41o6Q==} @@ -2555,6 +2556,7 @@ packages: eslint@9.39.4: resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -3137,28 +3139,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -3288,24 +3286,29 @@ packages: markmap-common@0.18.9: resolution: {integrity: sha512-MV2HQO7IGIm3jWEJXSG8vmdpqf4WIDXcEyAEN52lrWR1qD53Zg5l81JwjXoZ2l0rY5mofKYqUFlmdM2fqTGMVg==} + deprecated: Replaced by @markmap/* in 1.0.0. See https://markmap.js.org/docs/migration markmap-html-parser@0.18.11: resolution: {integrity: sha512-+kC5C4sCGntGUhGvTa5VIb5rtM75cSy/VCy3tzZoNAcn2qZGdgYvljN0WvjsOzrEzp+V6XKgwzO0u2TdzNAiOg==} + deprecated: Replaced by @markmap/* in 1.0.0. See https://markmap.js.org/docs/migration peerDependencies: markmap-common: '*' markmap-lib@0.18.12: resolution: {integrity: sha512-WCA4OT+b71jYg0e4PS/6NRKqihod5OpPsvw1jEGHQwCtqQrY/yXXCeRyuL3axOS5cMy5pV8BSl4CwKfJU1LxJg==} + deprecated: Replaced by @markmap/* in 1.0.0. See https://markmap.js.org/docs/migration peerDependencies: markmap-common: '*' markmap-toolbar@0.18.12: resolution: {integrity: sha512-+MQ/95ywl/ZLhVKb0tKjRa3p/AuUSYsuJRs1MPHmkxcxBaumXgzLNHUUnLjKmsbNxeZUSQGe3QJaFyRQYcHZJg==} + deprecated: Replaced by @markmap/* in 1.0.0. See https://markmap.js.org/docs/migration peerDependencies: markmap-common: '*' markmap-view@0.18.12: resolution: {integrity: sha512-D8bzT1YwIC/8rkbwm6WzigVUrpOAGv7ioEGTi1Lj+Oo8gO5sAm6hhli27jvTgUcZ9TwBeIWZ+dSUP+AupYUGlQ==} + deprecated: Replaced by @markmap/* in 1.0.0. See https://markmap.js.org/docs/migration peerDependencies: markmap-common: '*' @@ -3790,6 +3793,16 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + playwright-core@1.65.0: + resolution: {integrity: sha512-vNSKgH6YqtN7cdT/L334zo0a+YwkrpicyrHJlWWPIm/Q+Nn2SWInil01oT0mbu96U4GgoRtCYQD9CzJQgju+lA==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.65.0: + resolution: {integrity: sha512-F3AMWcR44qMBt4kq+6Kb6mX7bjGXshktKFOPULd7reED6ZJWevjbNRqysP7q+AATE9kdPEZBibex1BYsvpVoeA==} + engines: {node: '>=20'} + hasBin: true + points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -5545,6 +5558,14 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@fastrd/ui-foundation@0.1.2': {} + + '@fastrd/ui-react@0.1.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@fastrd/ui-foundation': 0.1.2 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + '@floating-ui/core@1.7.5': dependencies: '@floating-ui/utils': 0.2.11 @@ -5830,6 +5851,10 @@ snapshots: dependencies: langium: 4.2.1 + '@playwright/test@1.65.0': + dependencies: + playwright: 1.65.0 + '@primer/octicons@19.23.1': dependencies: object-assign: 4.1.1 @@ -9510,6 +9535,12 @@ snapshots: mlly: 1.8.2 pathe: 2.0.3 + playwright-core@1.65.0: {} + + playwright@1.65.0: + dependencies: + playwright-core: 1.65.0 + points-on-curve@0.2.0: {} points-on-path@0.2.1: diff --git a/fastread-frontend/scripts/fastcas-modes-browser.mjs b/fastread-frontend/scripts/fastcas-modes-browser.mjs new file mode 100644 index 0000000..731ba4a --- /dev/null +++ b/fastread-frontend/scripts/fastcas-modes-browser.mjs @@ -0,0 +1,98 @@ +import { chromium, expect } from '@playwright/test' +import { spawn } from 'node:child_process' +import { createServer } from 'node:net' +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { once } from 'node:events' +import assert from 'node:assert/strict' + +const python = process.env.FASTREAD_TEST_PYTHON +if (!python) throw new Error('Set FASTREAD_TEST_PYTHON to the isolated backend Python.') +const artifacts = 'test-results/fastcas-modes' +await mkdir(artifacts, { recursive: true }) +const browser = await chromium.launch({ headless: true }) +const scenarios = [], errors = [] +try { + for (const mode of [ + { name: 'default-no-sdk-config', configured: false, enabled: false, login: false, link: false, state: 'none' }, + { name: 'global-off-partial-config', configured: false, global: 'false', enabled: false, login: false, link: false, state: 'none' }, + { name: 'login-off-link-on', configured: true, enabled: true, login: false, link: true, state: 'none' }, + { name: 'login-on-link-off', configured: true, enabled: true, login: true, link: false, state: 'none' }, + { name: 'prepared-link-off', configured: true, enabled: true, login: true, link: false, state: 'prepared' }, + { name: 'active-link-off', configured: true, enabled: true, login: false, link: false, state: 'active' }, + ]) { + const listener = createServer() + await new Promise(resolve => listener.listen(0, '127.0.0.1', resolve)) + const port = listener.address().port + await new Promise(resolve => listener.close(resolve)) + const base = `http://127.0.0.1:${port}` + const root = await mkdtemp(join(tmpdir(), 'fastread-cas-modes-')) + const env = { ...process.env } + for (const key of Object.keys(env)) if (key.startsWith('FASTREAD_FASTCAS_')) delete env[key] + Object.assign(env, { + PYTHONUTF8: '1', + ...(mode.global ? { FASTREAD_FASTCAS_ENABLED: mode.global } : {}), + ...(mode.configured ? { FASTREAD_FASTCAS_LOGIN_ENABLED: String(mode.login), FASTREAD_FASTCAS_LINK_ENABLED: String(mode.link) } : {}), + ...(mode.configured ? { FASTREAD_FASTCAS_ISSUER: 'https://cas.example.test', FASTREAD_FASTCAS_CLIENT_ID: 'read', + FASTREAD_FASTCAS_CLIENT_SECRET: 'disposable-secret', FASTREAD_FASTCAS_REDIRECT_URI: base + '/api/auth/fastcas/callback' } + : mode.global === 'false' ? { FASTREAD_FASTCAS_ISSUER: 'https://cas.example.test' } : {}), + }) + const receiver = spawn(python, ['scripts/fastcas-modes-receiver.py', '--root', root, '--port', String(port), '--frontend', resolve('dist'), '--link-state', mode.state], { env, stdio: ['ignore', 'pipe', 'pipe'] }) + let log = '' + for (const stream of [receiver.stdout, receiver.stderr]) stream.on('data', data => { log += data }) + const context = await browser.newContext({ serviceWorkers: 'block' }) + try { + await expect.poll(async () => { try { return (await fetch(base + '/api/health')).ok } catch { return false } }, { timeout: 20000, message: mode.name }).toBe(true) + const page = await context.newPage() + page.on('pageerror', error => errors.push(error.message)) + await page.goto(base) + await expect(page.getByRole('heading', { name: '登录资料库' })).toBeVisible() + const available = await (await context.request.get(base + '/api/auth/fastcas/available')).json() + assert.deepEqual(available, { enabled: mode.enabled, login_enabled: mode.login, link_enabled: mode.link }) + await expect(page.getByRole('link', { name: '使用 FastCAS 登录' })).toHaveCount(mode.login ? 1 : 0) + if (!mode.login) assert.equal((await context.request.get(base + '/api/auth/fastcas/login')).status(), mode.enabled ? 403 : 404) + await page.getByLabel('邮箱', { exact: true }).fill('reader@example.test') + await page.getByLabel('密码', { exact: true }).fill('fixture-password-1234') + await page.getByRole('button', { name: '确认', exact: true }).click() + await page.getByRole('button', { name: '账号设置', exact: true }).click() + const account = page.getByRole('region', { name: '账号设置' }) + const status = await (await context.request.get(base + '/api/auth/fastcas/status')).json() + assert.equal(status.link_enabled, mode.link) + if (!mode.enabled) { + await expect(account.getByText('此实例未启用 FastCAS,本地登录照常可用。')).toBeVisible() + } else { + await account.getByLabel('当前账号密码', { exact: true }).fill('fixture-password-1234') + if (mode.state === 'none') { + const link = account.getByRole('button', { name: '认证当前账号', exact: true }) + if (mode.link) await expect(link).toBeEnabled() + else await expect(link).toBeDisabled() + } else { + if (mode.state === 'prepared') await expect(account.getByRole('button', { name: '重试完成认证', exact: true })).toBeDisabled() + await expect(account.getByRole('button', { name: '解除认证', exact: true })).toBeEnabled() + } + if (!mode.link) { + const me = await (await context.request.get(base + '/api/auth/me')).json() + const denied = await context.request.post(base + '/api/auth/fastcas/link', { headers: { Origin: base, 'X-CSRF-Token': me.csrf }, data: { password: 'fixture-password-1234' } }) + assert.equal(denied.status(), 403) + } + } + await account.getByText('设置本地登录邮箱和密码', { exact: true }).click() + await expect(account.getByLabel('新密码', { exact: true })).toBeVisible() + await page.screenshot({ path: `${artifacts}/${mode.name}.png`, fullPage: true }) + scenarios.push({ name: mode.name, status: 'passed', available, link_state: status.link?.state || null }) + } catch (error) { + throw new Error(`${mode.name}: ${error.message}\n${log}`, { cause: error }) + } finally { + await context.close() + if (receiver.exitCode === null && receiver.signalCode === null) { + const exited = once(receiver, 'exit') + receiver.kill() + await exited + } + } + } + assert.deepEqual(errors, []) + await writeFile(`${artifacts}/results.json`, JSON.stringify({ status: 'passed', backend: 'create_web_app + SQLite + real local cookie/CSRF; no HTTP interception', scenarios }, null, 2)) + console.log(`PASS ${scenarios.length} real FastRead backend + Chromium account/login mode scenarios`) +} finally { await browser.close() } diff --git a/fastread-frontend/scripts/fastcas-modes-receiver.py b/fastread-frontend/scripts/fastcas-modes-receiver.py new file mode 100644 index 0000000..e891d64 --- /dev/null +++ b/fastread-frontend/scripts/fastcas-modes-receiver.py @@ -0,0 +1,30 @@ +"""Isolated browser fixture using the production app, auth and link store.""" +import argparse +import os +import sys +from pathlib import Path + +parser = argparse.ArgumentParser() +parser.add_argument('--root', required=True) +parser.add_argument('--port', required=True, type=int) +parser.add_argument('--frontend', required=True) +parser.add_argument('--link-state', choices=['none', 'prepared', 'active'], default='none') +args = parser.parse_args() +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / 'backend')) +os.environ['FASTREAD_COOKIE_SECURE'] = 'false' +os.environ['CHAT_VECTOR_INDEX_ENABLED'] = 'false' + +import uvicorn +from app.web.api import create_web_app +from app.web.auth import create_user + +app = create_web_app(args.root, frontend=args.frontend) +user = create_user(app.state.store, 'reader@example.test', 'fixture-password-1234') +if args.link_state != 'none': + app.state.fastcas.links.save({ + 'id': 'browser-link', 'client_id': 'read', 'subject': 'browser-subject', + 'local_account_ref': user['user_id'], 'state': args.link_state, + 'version': 1, 'verified_at': '2026-11-11T00:00:00Z', + }) +print(f'FastRead source: {sys.modules[create_web_app.__module__].__file__}', flush=True) +uvicorn.run(app, host='127.0.0.1', port=args.port, log_level='warning') diff --git a/fastread-frontend/scripts/fastnews-auth-browser.mjs b/fastread-frontend/scripts/fastnews-auth-browser.mjs new file mode 100644 index 0000000..802426f --- /dev/null +++ b/fastread-frontend/scripts/fastnews-auth-browser.mjs @@ -0,0 +1,168 @@ +import assert from 'node:assert/strict' +import { createServer } from 'node:net' +import { spawn } from 'node:child_process' +import { once } from 'node:events' +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { chromium, expect } from '@playwright/test' + +const python = process.env.FASTREAD_TEST_PYTHON +if (!python) throw new Error('Set FASTREAD_TEST_PYTHON to Python with the published fastcas-sdk 0.1.2.') +const artifacts = process.env.FASTREAD_AUTH_ARTIFACTS || 'test-results/fastnews-auth' +await mkdir(artifacts, { recursive: true }) +const listener = createServer() +await new Promise(resolve => listener.listen(0, '127.0.0.1', resolve)) +const port = listener.address().port +await new Promise(resolve => listener.close(resolve)) +const origin = `http://127.0.0.1:${port}` +const root = await mkdtemp(join(tmpdir(), 'fastread-news-auth-')) +const child = spawn(python, ['-B', 'scripts/fastnews-auth-receiver.py', '--root', root, '--port', String(port)], + { env: { ...process.env, PYTHONUTF8: '1' }, stdio: ['ignore', 'pipe', 'pipe'] }) +let log = '', browser +const results = [], errors = [] +for (const stream of [child.stdout, child.stderr]) stream.on('data', chunk => { log += chunk }) +try { + await expect.poll(async () => { try { return (await fetch(origin+'/api/health')).ok } catch { return false } }, { timeout: 20000 }).toBe(true) + browser = await chromium.launch({ headless: true }) + const cases = ['cas', 'local', 'research'].flatMap(flow => ['none', 'other-account', 'same-account'].map(oldSession => ({ flow, oldSession }))) + cases.unshift({ flow: 'cas', oldSession: 'none', direct: true }) + cases.push(...['baseline', 'proof'].map(failure => ({ flow: 'cas', oldSession: 'other-account', failure }))) + for (const { flow, oldSession, failure, direct } of cases) { + const context = await browser.newContext({ serviceWorkers: 'block' }) + const page = await context.newPage() + page.on('pageerror', error => errors.push(error.message)) + const posts = [] + page.on('request', request => { if (new URL(request.url()).pathname === '/api/integrations/fastnews/intake') posts.push(request.postDataJSON()) }) + const pending = () => page.evaluate(() => Object.entries(sessionStorage).filter(([key]) => key.startsWith('fastread:fastnews:')).map(([, value]) => JSON.parse(value))) + async function login(email) { + const init = await context.request.get(origin+'/api/auth/session-init') + const response = await context.request.post(origin+'/api/auth/login', { headers: { 'X-CSRF-Token': (await init.json()).csrf }, data: { email, password: 'fixture-password-1234' } }) + assert.equal(response.status(), 200) + return (await (await context.request.get(origin+'/api/auth/me')).json()) + } + let old = null + if (flow === 'research' && oldSession === 'same-account') { + const response = await context.request.post(origin+'/api/auth/research', { data: { ticket: 'valid-research' } }) + assert.equal(response.status(), 200) + old = await (await context.request.get(origin+'/api/auth/me')).json() + } else if (oldSession !== 'none') old = await login(oldSession === 'same-account' ? 'cas@example.test' : 'old@example.test') + const oldContext = old ? await browser.newContext({ storageState: await context.storageState() }) : null + const caseId = `${flow}-${oldSession}${direct ? '-direct' : ''}${failure ? '-'+failure : ''}` + const carrier = { source: 'fastnews', id: `auth-${caseId}`, title: `Pending via ${caseId}`, authors: '', abstract: '', url: 'https://example.test/paper', venue: '', year: '2026' } + const arrival = origin+'/?sso=expired-ticket#fastnews='+encodeURIComponent(JSON.stringify(carrier)) + if (failure === 'baseline') await page.route('**/api/auth/me', route => route.fulfill({ status: 503, json: { detail: 'Unavailable' } }), { times: 1 }) + await page.goto(arrival) + if (failure === 'baseline') { + await expect(page.getByRole('heading', { name: '连接暂时中断' })).toBeVisible() + assert.equal(posts.length, 0) + assert.equal((await pending())[0].status, 'login-required') + assert.equal(new URL(page.url()).searchParams.has('sso'), false) + await page.goto(origin) + } + await expect(page.getByRole('heading', { name: '登录资料库', exact: true })).toBeVisible() + assert.equal(posts.length, 0) + assert.equal((await pending())[0].status, 'login-required') + if (!direct) { + await page.reload() + await expect(page.getByRole('heading', { name: '登录资料库', exact: true })).toBeVisible() + assert.equal(posts.length, 0, 'failed ticket must not use an old cookie after reload') + // Callback query text is not authentication proof, even with /me=200 from an old account. + await page.goto(origin+'/?fastcas=complete') + await expect(page.getByRole('heading', { name: '登录资料库', exact: true })).toBeVisible() + assert.equal(posts.length, 0) + } + let releaseProof + if (flow === 'cas') { + if (!direct) { + // An invalid production callback must also leave the old session blocked. + await page.goto(origin+'/api/auth/fastcas/callback?code=invalid&state=invalid') + await expect(page.getByRole('heading', { name: '登录资料库', exact: true })).toBeVisible() + assert.equal(posts.length, 0) + } + // Hold the actual /me response after CAS, never synthesize authentication. + // The new cookie and callback query alone must not dispatch intake. + let proofStarted = false + const gate = new Promise(resolve => { releaseProof = resolve }) + await page.route('**/api/auth/me', async route => { + proofStarted = true + await gate + if (failure === 'proof') await route.fulfill({ status: 503, json: { detail: 'Unavailable' } }) + else await route.continue() + }, { times: 1 }) + await page.getByRole('link', { name: '使用 FastCAS 登录', exact: true }).click() + await page.waitForURL(url => url.origin === origin && url.searchParams.get('fastcas') === 'complete') + await expect.poll(() => proofStarted).toBe(true) + assert.equal(posts.length, 0) + assert.equal((await pending())[0].status, 'login-required') + } else if (flow === 'local') { + await page.getByLabel('邮箱', { exact: true }).fill('cas@example.test') + await page.getByLabel('密码', { exact: true }).fill('wrong-password') + await page.getByRole('button', { name: '确认', exact: true }).click() + await expect(page.getByRole('status').filter({ hasText: '邮箱或密码错误' })).toBeVisible() + assert.equal(posts.length, 0) + await page.getByLabel('密码', { exact: true }).fill('fixture-password-1234') + await page.getByRole('button', { name: '确认', exact: true }).click() + } else { + await page.goto(origin+'/?sso=valid-research') + } + await expect.poll(async () => { + const response = await context.request.get(origin+'/api/auth/me') + if (!response.ok()) return false + const user = await response.json() + return !!user.csrf && user.csrf !== old?.csrf + }, { message: `${flow}: newly issued authenticated session` }).toBe(true) + const me = await context.request.get(origin+'/api/auth/me') + assert.equal(me.status(), 200) + const user = await me.json() + releaseProof?.() + if (failure === 'proof') { + await expect(page.getByRole('heading', { name: '连接暂时中断' })).toBeVisible() + assert.equal(posts.length, 0) + assert.equal((await pending())[0].status, 'login-required') + await page.reload() + } + try { + await expect(page.getByRole('status').filter({ hasText: '已收入' })).toContainText(carrier.title, { timeout: 10000 }) + } catch (error) { + const observed = { flow, oldSession, backendMeStatus: me.status(), frontendLoginVisible: await page.getByRole('heading', { name: '登录资料库', exact: true }).isVisible(), pending: await pending(), intakePosts: posts.length } + await writeFile(`${artifacts}/failure.json`, JSON.stringify(observed, null, 2)) + await page.screenshot({ path: `${artifacts}/failure.png`, fullPage: true }) + throw error + } + assert.equal(posts.length, 1) + assert.equal(posts[0].external_id, carrier.id) + assert.deepEqual(await pending(), []) + if (old) { + assert.notEqual(user.csrf, old.csrf) + if (oldSession === 'same-account') assert.equal(user.workspace_id, old.workspace_id) + else assert.notEqual(user.workspace_id, old.workspace_id) + } + const listing = await (await context.request.get(origin+'/api/papers')).json() + const stored = listing.items.find(paper => paper.title === carrier.title) + assert.ok(stored) + if (oldContext && user.workspace_id !== old.workspace_id) { + assert.equal((await oldContext.request.get(origin+'/api/papers/'+stored.id)).status(), 404) + const oldListing = await (await oldContext.request.get(origin+'/api/papers')).json() + assert.ok(!oldListing.items.some(paper => paper.title === carrier.title)) + } + await page.reload() + await expect(page.getByRole('button', { name: '模型设置', exact: true })).toBeVisible() + assert.equal(posts.length, 1) + await page.screenshot({ path: `${artifacts}/${caseId}.png` }) + results.push({ flow, oldSession, ...(direct ? { direct } : {}), ...(failure ? { failure } : {}), status: 'passed', intakePosts: posts.length, backendMeStatus: me.status() }) + await context.close() + await oldContext?.close() + } + assert.deepEqual(errors, []) + await writeFile(`${artifacts}/results.json`, JSON.stringify({ status: 'passed', boundary: 'published SDK 0.1.2 + OIDC HTTP PKCE/nonce/signature + production callback + SQLite + built UI', scenarios: results }, null, 2)) + console.log(`PASS ${results.length} failed News ticket → fresh authentication → persisted intake browser scenarios`) +} finally { + await browser?.close() + if (child.exitCode === null && child.signalCode === null) { + const exited = once(child, 'exit') + child.kill() + await exited + } + await writeFile(`${artifacts}/receiver.log`, log) +} diff --git a/fastread-frontend/scripts/fastnews-auth-receiver.py b/fastread-frontend/scripts/fastnews-auth-receiver.py new file mode 100644 index 0000000..2700354 --- /dev/null +++ b/fastread-frontend/scripts/fastnews-auth-receiver.py @@ -0,0 +1,103 @@ +"""Production Read + published SDK; disposable HTTP OIDC/Research peers.""" +import argparse +import base64 +import hashlib +import json +import os +import sys +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from importlib.metadata import version +from pathlib import Path +from urllib.parse import parse_qs, urlencode, urlparse + +parser = argparse.ArgumentParser() +parser.add_argument('--root', required=True) +parser.add_argument('--port', type=int, required=True) +args = parser.parse_args() +repo = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(repo / 'backend')) +from authlib.jose import JsonWebKey, jwt +import uvicorn +from app.web.api import create_web_app +from app.web.auth import create_user + +assert version('fastcas-sdk') == '0.1.2' +key = JsonWebKey.generate_key('RSA', 2048, {'kid': 'browser'}, is_private=True) +codes = {} +link = None +origin = f'http://127.0.0.1:{args.port}' + +class Provider(BaseHTTPRequestHandler): + def log_message(self, *_): + pass + + def reply(self, value, status=200): + self.send_response(status) + self.send_header('Content-Type', 'application/json') + self.end_headers() + self.wfile.write(json.dumps(value).encode()) + + def do_GET(self): + url = urlparse(self.path) + if url.path == '/.well-known/openid-configuration': + self.reply(dict(issuer=issuer, authorization_endpoint=issuer+'/authorize', token_endpoint=issuer+'/token', + jwks_uri=issuer+'/jwks', userinfo_endpoint=issuer+'/userinfo')) + elif url.path == '/jwks': + self.reply({'keys': [key.as_dict(is_private=False)]}) + elif url.path == '/userinfo': + self.reply({'sub': 'browser-subject', 'email': 'cas@example.test'}) + elif url.path == '/api/v1/account-links/resolve': + self.reply(link) + elif url.path == '/authorize': + params = parse_qs(url.query) + code = str(time.time_ns()) + codes[code] = params + self.send_response(302) + self.send_header('Location', params['redirect_uri'][0]+'?'+urlencode({'code': code, 'state': params['state'][0]})) + self.end_headers() + else: + self.reply({'error': 'not_found'}, 404) + + def do_POST(self): + raw = self.rfile.read(int(self.headers.get('Content-Length', '0'))) + if self.path == '/api/sso/consume': + body = json.loads(raw) + assert body['audience'] == 'fast-read' + if body['ticket'] == 'valid-research': + self.reply({'keyId': 'research-reader', 'person': 'Research reader'}) + else: + self.reply({'error': 'Expired ticket'}, 401) + elif self.path == '/token': + form = parse_qs(raw.decode()) + grant = codes.pop(form['code'][0]) + challenge = base64.urlsafe_b64encode(hashlib.sha256(form['code_verifier'][0].encode()).digest()).rstrip(b'=').decode() + assert challenge == grant['code_challenge'][0] + assert form['redirect_uri'] == grant['redirect_uri'] + assert form['grant_type'] == ['authorization_code'] + now = int(time.time()) + token = jwt.encode({'alg': 'RS256', 'kid': 'browser', 'typ': 'JWT'}, + {'iss': issuer, 'aud': 'read', 'sub': 'browser-subject', 'iat': now, 'exp': now+120, + 'nonce': grant['nonce'][0], 'sid': 'browser-sid'}, key).decode() + self.reply({'access_token': 'disposable', 'token_type': 'Bearer', 'expires_in': 120, 'id_token': token}) + else: + self.reply({'error': 'not_found'}, 404) + +provider = ThreadingHTTPServer(('127.0.0.1', 0), Provider) +issuer = f'http://127.0.0.1:{provider.server_port}' +threading.Thread(target=provider.serve_forever, daemon=True).start() +for name in list(os.environ): + if name.startswith('FASTREAD_FASTCAS_'): + del os.environ[name] +os.environ.update(FASTREAD_FASTCAS_ISSUER=issuer, FASTREAD_FASTCAS_CLIENT_ID='read', + FASTREAD_FASTCAS_CLIENT_SECRET='disposable-secret', FASTREAD_FASTCAS_REDIRECT_URI=origin+'/api/auth/fastcas/callback', + FASTREAD_FASTCAS_ALLOW_LOOPBACK_HTTP='true', FASTREAD_COOKIE_SECURE='false', + CHAT_VECTOR_INDEX_ENABLED='false', FASTRESEARCH_API_URL=issuer) +app = create_web_app(args.root, frontend=repo / 'fastread-frontend/dist') +user = create_user(app.state.store, 'cas@example.test', 'fixture-password-1234') +create_user(app.state.store, 'old@example.test', 'fixture-password-1234') +link = dict(id='browser-link', client_id='read', local_account_ref=user['user_id'], subject='browser-subject', state='active', version=1) +app.state.fastcas.links.save(link) +print(f'SDK {version("fastcas-sdk")}; source {sys.modules[create_web_app.__module__].__file__}', flush=True) +uvicorn.run(app, host='127.0.0.1', port=args.port, log_level='warning') diff --git a/fastread-frontend/scripts/fastnews-browser.mjs b/fastread-frontend/scripts/fastnews-browser.mjs new file mode 100644 index 0000000..d85b004 --- /dev/null +++ b/fastread-frontend/scripts/fastnews-browser.mjs @@ -0,0 +1,249 @@ +import { chromium, expect } from '@playwright/test' +import { preview } from 'vite' +import { createServer } from 'node:http' +import { spawn, spawnSync } from 'node:child_process' +import { mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import assert from 'node:assert/strict' + +const python = process.env.FASTREAD_TEST_PYTHON +if (!python) throw new Error('Set FASTREAD_TEST_PYTHON to a Python with Read backend requirements and News werkzeug installed.') +const contract = JSON.parse(await readFile('../contracts/fastnews-intake/v1/cases.json', 'utf8')) +const news = resolve(process.env.FASTNEWS_TEST_SOURCE || '../../fast-news/serve.py') +const artifacts = 'test-results/fastnews' +await mkdir(artifacts, { recursive: true }) +const work = await mkdtemp(join(tmpdir(), 'fastread-ui-wire-')) +const ticketCalls = [], results = [], errors = [] +const research = createServer(async (request, response) => { + let raw = ''; for await (const chunk of request) raw += chunk + const body = JSON.parse(raw || '{}') + assert.equal(request.url, '/api/sso/consume') + assert.equal(body.audience, 'fast-read') + ticketCalls.push(body.ticket) + response.setHeader('Content-Type', 'application/json') + if (body.ticket === 'expired-ticket') { response.writeHead(401).end(JSON.stringify({ error: 'Expired ticket' })); return } + response.end(JSON.stringify({ keyId: body.ticket, person: 'News reader' })) +}) +await new Promise(resolve => research.listen(0, '127.0.0.1', resolve)) +const listener = createServer() +await new Promise(resolve => listener.listen(0, '127.0.0.1', resolve)) +const port = listener.address().port +await new Promise(resolve => listener.close(resolve)) +const backend = `http://127.0.0.1:${port}` +const receiver = spawn(python, ['scripts/fastnews-receiver.py', '--root', work, '--port', String(port), '--research', `http://127.0.0.1:${research.address().port}`], { env: { ...process.env, PYTHONUTF8: '1' }, stdio: ['ignore', 'pipe', 'pipe'] }) +let serverLog = '' +for (const stream of [receiver.stdout, receiver.stderr]) stream.on('data', bytes => { serverLog += bytes }) +let server, browser +try { + await expect.poll(async () => { try { return (await fetch(`${backend}/api/health`)).ok } catch { return false } }, { timeout: 20000, message: 'Read fixture must start: ' + serverLog }).toBe(true) + server = await preview({ configFile: false, plugins: [{ name: 'subpath-deployment', configurePreviewServer(server) { + server.middlewares.use((request, _response, next) => { if (request.url.startsWith('/read/')) request.url = request.url.slice('/read'.length); next() }) + } }], preview: { host: '127.0.0.1', port: 0, proxy: { '/api': { target: backend, changeOrigin: false } } } }) + const origin = `http://127.0.0.1:${server.httpServer.address().port}` + browser = await chromium.launch({ headless: true }) + function handoff(base, ticket = '', paper = contract.handoff) { + const result = spawnSync(python, ['-c', 'import json,os,runpy,sys; sys.path.insert(0,os.path.dirname(sys.argv[1])); os.environ["FASTREAD_URL"]=sys.argv[2]; app=runpy.run_path(sys.argv[1],run_name="browser_sender"); status,payload=app["build_fastread_handoff"](json.loads(sys.stdin.read()),ticket=sys.argv[3]); assert status==200; print(json.dumps(payload))', news, base.replace(/\/$/, ''), ticket], { env: { ...process.env, PYTHONUTF8: '1' }, input: JSON.stringify(paper), encoding: 'utf8' }) + assert.equal(result.status, 0, result.stderr) + return JSON.parse(result.stdout).redirect + } + async function login(context, base) { + const init = await context.request.get(base + 'api/auth/session-init') + const result = await context.request.post(base + 'api/auth/login', { headers: { 'X-CSRF-Token': (await init.json()).csrf }, data: { email: 'reader@example.test', password: 'fixture-password-1234' } }) + assert.equal(result.status(), 200) + } + async function session(prefix) { + const base = origin + prefix + const context = await browser.newContext({ serviceWorkers: 'block', viewport: { width: 1440, height: 1000 } }) + const page = await context.newPage(), posts = [], receipts = [] + page.on('pageerror', error => errors.push(error.message)) + page.on('request', request => { if (new URL(request.url()).pathname === prefix + 'api/integrations/fastnews/intake') posts.push(request.postDataJSON()) }) + page.on('response', async response => { + if (new URL(response.url()).pathname === prefix + 'api/integrations/fastnews/intake' && response.ok()) { + try { receipts.push(await response.json()) } + catch (error) { errors.push(`intake receipt: ${error.message}`) } + } + }) + const saved = page.getByRole('status').filter({ hasText: '已收入' }) + return { base, context, page, posts, receipts, saved } + } + for (const prefix of ['/', '/read/']) { + for (const ticket of ['', `ticket-${prefix === '/' ? 'root' : 'subpath'}`]) { + const { base, context, page, posts, receipts, saved } = await session(prefix) + if (!ticket) await login(context, base) + await page.goto(handoff(base, ticket)) + await expect(saved).toContainText(contract.intake.title) + assert.deepEqual(posts, [contract.intake]) + await expect.poll(() => receipts.length).toBe(1) + const receipt = receipts[0] + if (ticket) for (const [key, value] of Object.entries(contract.first_receipt)) assert.equal(receipt[key], value) + const stored = await (await context.request.get(base + 'api/papers/' + receipt.id)).json() + assert.equal(stored.title, contract.intake.title) + assert.equal(stored.metadata.source, 'fastnews') + assert.equal(stored.metadata.url, contract.intake.url) + assert.equal((await (await context.request.get(base + 'api/jobs')).json()).items.length, 0, 'metadata intake must not start a PDF/model job') + assert.ok(!page.url().includes('sso=') && !page.url().includes('#fastnews=')) + assert.equal(await page.evaluate(() => Object.keys(sessionStorage).some(key => key.startsWith('fastread:fastnews:'))), false) + if (ticket) assert.equal(ticketCalls.filter(value => value === ticket).length, 1) + await page.reload() + await expect(page.getByRole('button', { name: '模型设置', exact: true })).toBeVisible() + assert.equal(posts.length, 1, 'successful handoff must not repeat after refresh') + await page.screenshot({ path: `${artifacts}/${prefix === '/' ? 'root' : 'subpath'}-${ticket ? 'ticket' : 'session'}.png` }) + results.push({ prefix, scenario: ticket ? 'actual News fragment + ticket + real persisted receipt' : 'actual News fragment + existing session', status: 'passed' }) + await context.close() + } + { + const { base, context, page, posts, receipts, saved } = await session(prefix) + await login(context, base) + await page.goto(handoff(base)) + await expect(saved).toContainText(contract.intake.title) + await expect.poll(() => receipts.length).toBe(1) + const id = receipts[0].id + await page.goto(handoff(base)) + await expect(saved).toContainText(contract.intake.title) + await expect.poll(() => receipts.length).toBe(2) + assert.equal(receipts[1].id, id) + for (const [key, value] of Object.entries(contract.repeat_receipt)) assert.equal(receipts[1][key], value) + assert.deepEqual(posts, [contract.intake, contract.intake]) + results.push({ prefix, scenario: 'explicit repeat handoff follows receiver deduplication receipt', status: 'passed' }) + await context.close() + } + { + const { base, context, page, posts, saved } = await session(prefix) + const ticket = `held-ticket-${prefix === '/' ? 'root' : 'subpath'}` + let started = false, release + const gate = new Promise(resolve => { release = resolve }) + await page.route('**/api/auth/research', async route => { started = true; await gate; await route.continue() }) + try { + await page.goto(handoff(base, ticket)) + await expect.poll(() => started).toBe(true) + assert.equal(new URL(page.url()).searchParams.has('sso'), false, 'remove the ticket before the exchange completes') + assert.equal(await page.evaluate(ticket => JSON.stringify(Object.values(sessionStorage)).includes(ticket), ticket), false) + assert.equal(posts.length, 0) + await expect(saved).toHaveCount(0) + } finally { release() } + await expect(saved).toContainText(contract.intake.title) + assert.equal(ticketCalls.filter(value => value === ticket).length, 1) + results.push({ prefix, scenario: 'ticket removed promptly, never persisted, consumed once before intake', status: 'passed' }) + await context.close() + } + { + const { base, context, page, posts, saved } = await session(prefix) + await login(context, base) + let release + const gate = new Promise(resolve => { release = resolve }) + await page.route('**/api/integrations/fastnews/intake', async route => { await gate; await route.continue() }) + try { + await page.goto(handoff(base)) + await expect.poll(() => posts.length).toBe(1) + await expect(saved).toHaveCount(0) + } finally { release() } + await expect(saved).toContainText(contract.intake.title) + results.push({ prefix, scenario: 'no success before the actual POST receipt', status: 'passed' }) + await context.close() + } + { + const { base, context, page, posts, saved } = await session(prefix) + await login(context, base) + const title = '字面 + %2F # & 科学' + await page.goto(handoff(base, '', { ...contract.handoff, title })) + await expect(saved).toContainText(title) + assert.deepEqual(posts, [{ ...contract.intake, title }], 'decode percent-encoded JSON once, retaining literal plus/percent characters') + results.push({ prefix, scenario: 'actual sender Unicode/plus/percent fragment decoded exactly once', status: 'passed' }) + await context.close() + } + { + const { base, context, page, posts, saved } = await session(prefix) + await page.goto(handoff(base)) + await expect(page.getByRole('heading', { name: '登录资料库' })).toBeVisible() + await expect(saved).toHaveCount(0) + assert.equal(posts.length, 0) + // Navigate away from the original fragment; the pending payload must survive local login. + await page.goto(base) + await page.getByLabel('邮箱', { exact: true }).fill('reader@example.test') + await page.getByLabel('密码', { exact: true }).fill('fixture-password-1234') + await page.getByRole('button', { name: '确认', exact: true }).click() + await expect(saved).toContainText(contract.intake.title) + assert.deepEqual(posts, [contract.intake]) + results.push({ prefix, scenario: 'anonymous arrival, persisted pending, actual local login and intake', status: 'passed' }) + await context.close() + } + for (const failure of ['401', '403', '422', 'network', 'receipt']) { + const { base, context, page, posts, saved } = await session(prefix) + await login(context, base) + let failing = true + await page.route('**/api/integrations/fastnews/intake', async route => { + if (!failing) return route.continue() + const headers = { ...route.request().headers() } + if (failure === 'network') return route.abort('failed') + if (failure === 'receipt') return route.fulfill({ json: { id: 'not-a-receipt' } }) + if (failure === '401') headers.cookie = '' + if (failure === '403') headers['x-csrf-token'] = 'wrong' + const response = await route.fetch({ headers, ...(failure === '422' ? { postData: JSON.stringify({ ...contract.intake, title: 'x'.repeat(301) }) } : {}) }) + assert.equal(response.status(), Number(failure), 'exercise the actual receiver rejection') + return route.fulfill({ response }) + }) + await page.goto(handoff(base)) + const retry = page.getByRole('button', { name: '重试收入', exact: true }) + await expect(retry).toBeVisible() + await expect(saved).toHaveCount(0) + assert.ok(page.url().includes('#fastnews=')) + await page.reload() + await expect(retry).toBeVisible() + await expect(saved).toHaveCount(0) + failing = false + await retry.click() + await expect(saved).toContainText(contract.intake.title) + assert.deepEqual(posts.at(-1), contract.intake) + results.push({ prefix, scenario: `retained payload and explicit retry after ${failure}`, status: 'passed' }) + await context.close() + } + { + const { base, context, page, posts, saved } = await session(prefix) + await login(context, base) + const title = ' ' + contract.intake.title.replaceAll(' ', ' ') + ' ' + const query = new URLSearchParams({ source: 'fastnews', ...contract.intake, title }) + await page.goto(base + '?' + query) + await expect(saved).toContainText(contract.intake.title) + assert.deepEqual(posts, [{ ...contract.intake, title }]) + results.push({ prefix, scenario: 'existing explicit query intake, canonical external_id, receiver-normalized title', status: 'passed' }) + await context.close() + } + { + const { base, context, page, posts, saved } = await session(prefix) + await login(context, base) + await page.goto(handoff(base, 'expired-ticket')) + await expect(page.getByRole('heading', { name: '登录资料库' })).toBeVisible() + assert.equal(posts.length, 0) + assert.equal(new URL(page.url()).searchParams.has('sso'), false) + await expect(saved).toHaveCount(0) + await page.reload() + await expect(page.getByRole('heading', { name: '登录资料库' })).toBeVisible() + assert.equal(posts.length, 0, 'reload after a failed ticket must not use the previous account session') + await page.getByLabel('邮箱', { exact: true }).fill('reader@example.test') + await page.getByLabel('密码', { exact: true }).fill('fixture-password-1234') + await page.getByRole('button', { name: '确认', exact: true }).click() + await expect(saved).toContainText(contract.intake.title) + results.push({ prefix, scenario: 'expired ticket cleaned; no intake until local authentication', status: 'passed' }) + await context.close() + } + { + const { base, context, page, posts, saved } = await session(prefix) + await login(context, base) + await page.goto(base + '?' + new URLSearchParams({ source: 'fastnews', ...contract.intake }) + '#fastnews=%invalid') + await expect(page.getByRole('alert').filter({ hasText: 'FastNews' })).toBeVisible() + await expect(saved).toHaveCount(0) + assert.equal(posts.length, 0, 'malformed fragment must not fall back to the query shape') + results.push({ prefix, scenario: 'malformed fragment fails explicitly without query fallback', status: 'passed' }) + await context.close() + } + } + assert.deepEqual(errors, []) + await writeFile(`${artifacts}/results.json`, JSON.stringify({ status: 'passed', sender: 'News serve.py build_fastread_handoff', receiver: 'Read create_web_app + isolated SQLite + real cookie/CSRF', scenarios: results }, null, 2)) + console.log(`PASS ${results.length} actual News → browser → Read wire scenarios`) +} finally { + await browser?.close() + if (server) await new Promise(resolve => server.httpServer.close(resolve)) + receiver.kill() + await new Promise(resolve => research.close(resolve)) +} diff --git a/fastread-frontend/scripts/fastnews-receiver.py b/fastread-frontend/scripts/fastnews-receiver.py new file mode 100644 index 0000000..41f9b55 --- /dev/null +++ b/fastread-frontend/scripts/fastnews-receiver.py @@ -0,0 +1,23 @@ +"""Browser fixture: run the unchanged Read HTTP application with an isolated store.""" +import argparse +import os +import sys +from pathlib import Path + +parser = argparse.ArgumentParser() +parser.add_argument('--root', required=True) +parser.add_argument('--port', required=True, type=int) +parser.add_argument('--research', required=True) +args = parser.parse_args() +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / 'backend')) +os.environ['FASTREAD_COOKIE_SECURE'] = 'false' +os.environ['FASTRESEARCH_API_URL'] = args.research +os.environ['CHAT_VECTOR_INDEX_ENABLED'] = 'false' + +import uvicorn +from app.web.api import create_web_app +from app.web.auth import create_user + +app = create_web_app(args.root) +create_user(app.state.store, 'reader@example.test', 'fixture-password-1234') +uvicorn.run(app, host='127.0.0.1', port=args.port, log_level='warning') diff --git a/fastread-frontend/scripts/fastui-browser.mjs b/fastread-frontend/scripts/fastui-browser.mjs new file mode 100644 index 0000000..776fbad --- /dev/null +++ b/fastread-frontend/scripts/fastui-browser.mjs @@ -0,0 +1,144 @@ +import { chromium, expect } from '@playwright/test' +import { preview } from 'vite' +import assert from 'node:assert/strict' +import { mkdir, writeFile } from 'node:fs/promises' + +// Run after build. Only the HTTP service is substituted; the production UI stays real. +const artifacts = 'test-results/fastui' +await mkdir(artifacts, { recursive: true }) +const server = await preview({ configFile: false, preview: { host: '127.0.0.1', port: 0 } }) +const base = `http://127.0.0.1:${server.httpServer.address().port}` +const browser = await chromium.launch({ headless: true }) +const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, serviceWorkers: 'block', colorScheme: 'light' }) +const writes = [], errors = [], scenarios = [] +let providers = [{ id: 'provider-1', name: 'Workspace model', models: ['model-a', 'model-b'] }] +let signedIn = true +let availability = { enabled: true, login_enabled: true, link_enabled: true } +let accountStatus = { enabled: true, link_enabled: true, link: { id: 'link', state: 'prepared', subject: 'reader', version: 1 } } +await context.route('**/api/**', async route => { + const request = route.request(), path = new URL(request.url()).pathname + if (request.method() === 'POST') writes.push({ path, body: request.postDataJSON() }) + let value = { items: [] }, status = 200 + if (path === '/api/auth/me') { + status = signedIn ? 200 : 401 + value = signedIn ? { id: 'reader', display_name: 'Reader', role: 'owner', workspace_id: 'workspace', csrf: 'fixture-csrf', workspace: { name: 'Workspace', daily_limit: 24 } } : { detail: 'Login required' } + } + else if (path === '/api/auth/fastcas/available') value = availability + else if (path === '/api/providers') { + if (request.method() === 'POST') providers = [...providers, { id: 'provider-2', ...request.postDataJSON() }] + value = { items: providers } + } else if (path === '/api/auth/fastcas/status') value = accountStatus + else if (path === '/api/auth/local-credential') { status = 422; value = { detail: '原密码未通过校验' } } + else if (path === '/api/papers') value = { items: [], total: 0 } + await route.fulfill({ status, json: value }) +}) +try { + const page = await context.newPage() + page.on('pageerror', error => errors.push(error.message)) + await page.goto(base) + await page.getByRole('button', { name: '模型设置', exact: true }).click() + await page.getByRole('button', { name: '账号设置', exact: true }).click() + const model = page.getByRole('region', { name: '模型设置' }) + const account = page.getByRole('region', { name: '账号设置' }) + const name = model.getByLabel('名称', { exact: true }) + await name.fill('Research endpoint draft') + await account.getByText('设置本地登录邮箱和密码', { exact: true }).click() + await account.getByLabel('登录邮箱', { exact: true }).fill('reader@example.test') + await account.getByLabel('新密码', { exact: true }).fill('long-password-draft') + await name.focus() + const peer = await context.newPage() + await peer.goto(base) + await peer.evaluate(() => localStorage.setItem('fastread.theme', 'dark')) + await expect(page.locator('html')).toHaveAttribute('data-fastui-theme', 'dark') + await expect(name).toBeFocused() + await expect(name).toHaveValue('Research endpoint draft') + await expect(account.getByLabel('新密码', { exact: true })).toHaveValue('long-password-draft') + await expect(name).toHaveClass(/fastui-input/) + assert.equal(await name.evaluate(el => getComputedStyle(el).backgroundColor), 'rgb(23, 30, 39)') + scenarios.push('cross-tab theme preserves both forms and focused input') + await model.getByLabel('设置页颜色模式').selectOption('system') + await page.emulateMedia({ colorScheme: 'light' }) + await expect(page.locator('html')).toHaveAttribute('data-fastui-theme', 'light') + await page.emulateMedia({ colorScheme: 'dark' }) + await expect(page.locator('html')).toHaveAttribute('data-fastui-theme', 'dark') + scenarios.push('system follows browser preference without remount') + const picker = model.getByLabel('当前使用的模型') + await picker.selectOption('provider-1|model-b') + await expect(picker).toHaveValue('provider-1|model-b') + await name.focus(); await page.keyboard.press('Tab') + await expect(model.getByLabel('API 地址', { exact: true })).toBeFocused() + const save = model.getByRole('button', { name: '保存供应商' }) + await save.click() + assert.equal(writes.length, 0, 'required fields must block submission') + await model.getByLabel('API 地址', { exact: true }).fill('https://model.example.test/v1') + await model.getByLabel('API 密钥', { exact: true }).fill('fixture-key') + await save.click() + await expect(page.getByRole('status').filter({ hasText: '供应商已保存' })).toBeVisible() + assert.deepEqual(writes[0], { path: '/api/providers', body: { name: 'Research endpoint draft', base_url: 'https://model.example.test/v1', api_key: 'fixture-key', models: ['qwen3.8-27b', 'glm-5.2'] } }) + await expect(name).toHaveValue('') + await expect(picker).toHaveValue('provider-1|model-b') + scenarios.push('native select, Tab order, validation, FormData submit and reset') + await account.getByRole('button', { name: '保存本地登录方式' }).click() + await expect(account.getByRole('alert')).toHaveText('原密码未通过校验') + await expect(account.getByLabel('新密码', { exact: true })).toHaveValue('') + await expect(account.getByLabel('登录邮箱', { exact: true })).toHaveValue('reader@example.test') + scenarios.push('account failure feedback and existing credential cleanup') + for (const width of [1440, 1024, 390]) for (const mode of ['light', 'dark']) { + await page.setViewportSize({ width, height: 1000 }) + await model.getByLabel('设置页颜色模式').selectOption(mode) + for (const surface of [model, account]) { + assert.ok(await surface.evaluate(el => el.scrollWidth <= el.clientWidth), 'settings surface overflows') + await expect(surface.locator('.fastui-input').first()).toHaveCSS('background-color', mode === 'dark' ? 'rgb(23, 30, 39)' : 'rgb(255, 255, 255)') + } + await page.evaluate(() => window.scrollTo(0, 0)) + await page.screenshot({ path: `${artifacts}/${mode}-${width}.png`, fullPage: true }) + } + // Merge regression: independent server modes must survive the real FastUI account surface. + for (const link of [null, { id: 'link', state: 'prepared', subject: 'reader', version: 1 }]) { + accountStatus = { enabled: true, link_enabled: false, link } + await page.reload() + await page.getByRole('button', { name: '账号设置', exact: true }).click() + await account.getByLabel('当前账号密码', { exact: true }).fill('original-password') + const blocked = account.getByRole('button', { name: link ? '重试完成认证' : '认证当前账号', exact: true }) + await expect(blocked).toBeDisabled() + if (link) await expect(account.getByRole('button', { name: '解除认证', exact: true })).toBeEnabled() + const count = writes.length + await blocked.evaluate(button => button.click()) + assert.equal(writes.length, count) + } + accountStatus = { enabled: true, link_enabled: true, link: null } + await page.reload() + await page.getByRole('button', { name: '账号设置', exact: true }).click() + await account.getByLabel('当前账号密码', { exact: true }).fill('original-password') + await expect(account.getByRole('button', { name: '认证当前账号', exact: true })).toBeEnabled() + accountStatus = { enabled: false, link_enabled: false, link: null } + await page.reload() + await page.getByRole('button', { name: '账号设置', exact: true }).click() + await expect(account.getByText('此实例未启用 FastCAS,本地登录照常可用。')).toBeVisible() + await expect(account.getByRole('button', { name: '认证当前账号', exact: true })).toHaveCount(0) + scenarios.push('link-off blocks new and prepared activation, keeps revoke; enabled and provider-off account states') + signedIn = false + for (const flags of [ + { enabled: true, login_enabled: true, link_enabled: false }, + { enabled: true, login_enabled: false, link_enabled: true }, + { enabled: false, login_enabled: false, link_enabled: false }, + ]) { + availability = flags + await page.reload() + await expect(page.getByRole('heading', { name: '登录资料库' })).toBeVisible() + await expect.poll(() => page.getByRole('link', { name: '使用 FastCAS 登录' }).count()).toBe(flags.enabled && flags.login_enabled ? 1 : 0) + if (flags.login_enabled) await expect(page.getByRole('link', { name: '使用 FastCAS 登录' })).toHaveAttribute('href', '/api/auth/fastcas/login') + await page.getByRole('button', { name: '使用邀请注册', exact: true }).click() + await expect(page.getByRole('heading', { name: '邀请注册' })).toBeVisible() + await expect(page.getByRole('link', { name: '使用 FastCAS 登录' })).toHaveCount(0) + await page.getByRole('button', { name: '恢复账号', exact: true }).click() + await expect(page.getByRole('heading', { name: '恢复账号' })).toBeVisible() + } + scenarios.push('independent login visibility with local invitation/recovery always usable') + assert.deepEqual(errors, []) + await writeFile(`${artifacts}/results.json`, JSON.stringify({ status: 'passed', scenarios, viewports: [1440, 1024, 390], modes: ['light', 'dark'] }, null, 2)) + console.log('PASS FastRead FastUI production settings/account browser scenarios') +} finally { + await browser.close() + await new Promise(resolve => server.httpServer.close(resolve)) +} diff --git a/fastread-frontend/src/main.tsx b/fastread-frontend/src/main.tsx index 883dbbe..c3d14cd 100644 --- a/fastread-frontend/src/main.tsx +++ b/fastread-frontend/src/main.tsx @@ -1,14 +1,16 @@ import { start } from './web/sso-bootstrap' +import '@fastrd/ui-react/styles.css' +import './web/theme' // Fetch the UI in parallel with authentication, without making authentication // wait for React/Markdown to download and execute first. const app = Promise.all([import('./web/App'), import('react'), import('react-dom/client')]) void app.catch(() => {}) -void start(async initialUser => { +void start(async (initialUser, initialHandoff) => { let timer: ReturnType | undefined const deadline = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('UI load timeout')), 15000) }) const [{ default: App }, { createElement, StrictMode }, { createRoot }] = await Promise.race([app, deadline]).finally(() => clearTimeout(timer)) - createRoot(document.getElementById('root')!).render(createElement(StrictMode, null, createElement(App, { initialUser }))) + createRoot(document.getElementById('root')!).render(createElement(StrictMode, null, createElement(App, { initialUser, initialHandoff }))) }).catch(() => { const root = document.getElementById('root')! root.replaceChildren() diff --git a/fastread-frontend/src/web/App.tsx b/fastread-frontend/src/web/App.tsx index 617319a..30de7fe 100644 --- a/fastread-frontend/src/web/App.tsx +++ b/fastread-frontend/src/web/App.tsx @@ -12,6 +12,10 @@ import PdfReader from './PdfReader' import Neighbors from './Neighbors' import NotebookDesk from './NotebookDesk' import { PanelLeft } from 'lucide-react' +import { Alert, Button, Select, TextField } from '@fastrd/ui-react' +import { submitFastNews, type FastNewsHandoff } from './sso-bootstrap' +import { colorMode } from './theme' +import './settings.css' type Row = Record let csrf = '' @@ -38,7 +42,20 @@ async function request(path: string, method = 'GET', body?: unknown, key?: strin return value } -export default function WebApp({ initialUser }: { initialUser?: Row | null }) { +function FastNewsNotice({ handoff, onRetry }: { handoff: FastNewsHandoff | null; onRetry?: () => void }) { + if (!handoff) return null + if (handoff.status === 'invalid') return {handoff.error} + if (handoff.status === 'received') return 来自 FastNews,已收入「{handoff.receipt.title}」。{handoff.receipt.awaiting_file ? '请自行上传 PDF。' : '原有文档已保留。'} + if (handoff.status === 'login-required') return FastNews 登录票据未完成验证,「{handoff.body.title}」尚未收入。请重新登录后继续。 + return +

来自 FastNews,「{handoff.body.title}」尚未收入。{handoff.status === 'failed' ? handoff.error : onRetry ? '正在收入论文信息…' : '请登录后继续。'}

+ {onRetry && } +
+} + +export default function WebApp({ initialUser, initialHandoff = null }: { initialUser?: Row | null; initialHandoff?: FastNewsHandoff | null }) { + const [handoff, setHandoff] = useState(initialHandoff) + const handoffBody = handoff && 'body' in handoff ? handoff.body : null const [arrival] = useState(() => new URLSearchParams(window.location.search)) const [deskTarget, setDeskTarget] = useState(arrival.get('book') ? { book_id: arrival.get('book') } : null) const [user, setUser] = useState(() => { if (initialUser) csrf = initialUser.csrf; return initialUser || null }) @@ -67,14 +84,16 @@ export default function WebApp({ initialUser }: { initialUser?: Row | null }) { const [searchResults, setSearchResults] = useState(null) const [searching, setSearching] = useState(false) const [settings, setSettings] = useState(false) + const [themeMode, setThemeMode] = useState(colorMode.mode) + useEffect(() => colorMode.subscribe(() => setThemeMode(colorMode.mode)), []) const [accountSettings, setAccountSettings] = useState(false) const [topic, setTopic] = useState(null) const [books, setBooks] = useState([]) const [importBook, setImportBook] = useState('') const [tagFilter, setTagFilter] = useState('') const [neighborTag, setNeighborTag] = useState('') - const [importUrl, setImportUrl] = useState(arrival.get('url') || '') - const [sidebarOpen, setSidebarOpen] = useState(() => arrival.has('url') || arrival.get('source') === 'fastnews' || arrival.has('pending') || localStorage.getItem('fastread:sidebar') === 'open') + const [importUrl, setImportUrl] = useState(handoffBody?.url || arrival.get('url') || '') + const [sidebarOpen, setSidebarOpen] = useState(() => !!initialHandoff || arrival.has('url') || arrival.get('source') === 'fastnews' || arrival.has('pending') || localStorage.getItem('fastread:sidebar') === 'open') useEffect(() => { localStorage.setItem('fastread:sidebar', sidebarOpen ? 'open' : 'closed') }, [sidebarOpen]) useEffect(() => { const close = (e: KeyboardEvent) => { if (e.key === 'Escape') setSidebarOpen(false) }; window.addEventListener('keydown', close); return () => window.removeEventListener('keydown', close) }, []) @@ -87,6 +106,16 @@ export default function WebApp({ initialUser }: { initialUser?: Row | null }) { setPapers(data.items); setTotal(data.total) setBooks((await api('/knowledge-bases')).items) } + async function retryFastNews() { + if (!handoffBody || handoff?.status === 'pending') return + const body = handoffBody + setHandoff({ status: 'pending', body }) + try { + const receipt = await submitFastNews(body, csrf) + setHandoff({ status: 'received', body, receipt }) + void action(loadLibrary) + } catch (error) { setHandoff({ status: 'failed', body, error: (error as Error).message }) } + } async function loadProviders() { const data = await api('/providers'); setProviders(data.items) if (data.items.length && !providerId) { @@ -160,19 +189,20 @@ export default function WebApp({ initialUser }: { initialUser?: Row | null }) { return job } if (loading) return
正在连接资料库…
- if (!user) return + if (!user) return <> return (
{safeIdentityLabel(user.display_name || user.workspace.name, 'FastRead 用户')}
{!user.public && }
{error &&
{error}
} {notice &&
{notice}
} + void retryFastNews()} /> {accountSettings && } - {settings &&

工作区模型

供应商密钥仅保存在服务器,其他设备登录后可继续使用。当前 24 小时任务上限:{user.workspace.daily_limit}。

{providers.map(p =>

{p.name} · {p.models.join(' / ')}

)} + {settings &&

工作区模型

供应商密钥仅保存在服务器,其他设备登录后可继续使用。当前 24 小时任务上限:{user.workspace.daily_limit}。

{providers.map(p =>

{p.name} · {p.models.join(' / ')}

)} {user.role === 'owner' &&
{ e.preventDefault(); const f = e.currentTarget; const data = Object.fromEntries(new FormData(f)); void action(async () => { await api('/providers', 'POST', { ...data, models: String(data.models).split(',').map(v => v.trim()) }); f.reset(); await loadProviders(); setNotice('供应商已保存') }) }}> - +
}
}
{sidebarOpen &&
{ e.preventDefault(); const query = String(new FormData(e.currentTarget).get('query')); setSearching(true); void action(async () => setSearchResults(await api('/search', 'POST', { query }))).finally(() => setSearching(false)) }}>
{metadataModel && 第一页文本将交给所选模型,计入工作区模型任务额度。} -
导入论文{arrival.get("source") === "fastnews" &&

{arrival.get("title") ? `来自 FastNews,已收入「${arrival.get("title")}」。请自行上传 PDF。` : "来自 FastNews,请自行上传 PDF。"}

}
{ e.preventDefault(); const url = String(new FormData(e.currentTarget).get('url')); void action(() => submitJob('/imports/url', { url, knowledge_id: importBook, source: arrival.get('source') || '', external_id: arrival.get('external_id') || '' })) }}>
+
导入论文
{ e.preventDefault(); const url = String(new FormData(e.currentTarget).get('url')); void action(() => submitJob('/imports/url', { url, knowledge_id: importBook, source: handoffBody ? 'fastnews' : arrival.get('source') || '', external_id: handoffBody?.external_id || arrival.get('external_id') || '' })) }}>
{offset + 1}–{Math.min(offset + 20, total)}
{deskTarget ? { setDeskTarget(null); setSelected(null); window.history.replaceState(null, '', APP_BASE) }} onExternal={(id: string) => { setDeskTarget({ paper_id: id }); window.history.replaceState(null, '', APP_BASE + '?paper=' + encodeURIComponent(id)) }} /> : searchResults ?

搜索结果

公开来源:{Object.entries(searchResults.sources || {}).map(([name, status]) => `${name} ${(status as Row).available ? '可用' : (status as Row).reason || '暂不可用'}`).join(' · ')}

{searchResults.papers.map((p: Row, i: number) =>

{p.title}

{p.authors?.join(', ')} · {p.year || '年份待核实'}

{(p.discovery_sources || []).join(' / ')}{p.doi ? ` · DOI: ${p.doi}` : ''}{p.identity_warning &&

{p.identity_warning}

}

{p.abstract?.slice(0, 500)}

{(p.pdf_url || p.url || p.source_url) && 查看来源}
)}
: topic ?

{topic.question}

{topic.scope}

专题证据

{topic.evidence.map((e: Row) =>
第 {e.page} 页 · {e.quote}

{e.note}

{e.location_status === 'located' ? '逐字引文已在迁移原文定位' : '原文定位待复核'}
)}
: !selected ? { setDeskTarget({ book_id: id }); if(window.innerWidth > 900) setSidebarOpen(true); window.history.replaceState(null, '', APP_BASE + '?book=' + encodeURIComponent(id)) }} api={api} action={action} openPaper={openPaper} submitJob={submitJob} providerId={providerId} model={model} revision={jobs.map(j => `${j.id}:${j.state}`).join(',')} initialTag={tagFilter} /> : <> diff --git a/fastread-frontend/src/web/FastCASAccount.tsx b/fastread-frontend/src/web/FastCASAccount.tsx index 686ece3..ae0220c 100644 --- a/fastread-frontend/src/web/FastCASAccount.tsx +++ b/fastread-frontend/src/web/FastCASAccount.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react' +import { Alert, Button, Loading, TextField } from '@fastrd/ui-react' type Status = { enabled: boolean; link_enabled?: boolean; link: null | { id: string; subject: string; state: string; version: number } } export default function FastCASAccount({ api }: { api: (path: string, method?: string, body?: unknown) => Promise }) { @@ -29,25 +30,25 @@ export default function FastCASAccount({ api }: { api: (path: string, method?: s } catch (e) { setError((e as Error).message) } finally { setBusy(false); setNewPassword(''); setCurrentPassword('') } } - return

FastCAS 账号认证

- {error &&

{error}

} - {!status ?

正在读取账号状态…

: !status.enabled ?

此实例未启用 FastCAS,本地登录照常可用。

: <> + return

FastCAS 账号认证

+ {error && {error}} + {!status ? : !status.enabled ?

此实例未启用 FastCAS,本地登录照常可用。

: <>

{!status.link ? '尚未认证' : status.link.state === 'active' ? '已通过 FastCAS 认证' : '认证待完成'}

{status.link_enabled === false && !status.link &&

管理员已关闭新的账号认证。

}

认证关联当前账号,资料库与权限保持不变。解除后仍可使用本地密码登录。

- - {!status.link ? : <> - {status.link.state === 'prepared' && } - + + {!status.link ? : <> + {status.link.state === 'prepared' && } + } }
设置本地登录邮箱和密码

已有密码时请验证原密码。Research 账号请先从原入口重新登录,并在五分钟内设置;账号和资料库保持不变。

{ e.preventDefault(); void saveCredential() }}> - - - - + + + +
diff --git a/fastread-frontend/src/web/settings.css b/fastread-frontend/src/web/settings.css new file mode 100644 index 0000000..a8f17d9 --- /dev/null +++ b/fastread-frontend/src/web/settings.css @@ -0,0 +1,22 @@ +/* These two surfaces replace the old .settings skin; controls use ui-react styles. */ +.fastread-settings { + margin: 16px clamp(16px, 3vw, 38px); + padding: 24px; + border: 1px solid var(--fastui-border); + border-radius: var(--fastui-radius-panel); + background: var(--fastui-surface); + color: var(--fastui-text); + color-scheme: var(--fastui-color-scheme); + font-family: var(--fastui-font); + display: grid; + gap: 16px; +} +.fastread-settings h2, .fastread-settings p { margin: 0; } +.fastread-settings label { color: var(--fastui-secondary); } +.fastread-settings form { max-width: 640px; } +.fastread-settings :is(.fastui-input, .fastui-select) { width: 100%; } +.fastread-settings > .fastui-button, .fastread-settings form > .fastui-button { justify-self: start; align-self: start; } +.fastread-settings .fastui-button:hover { filter: none; } +.fastread-settings summary { cursor: pointer; } +.fastread-settings details > p { margin: 12px 0; } +@media (max-width: 700px) { .fastread-settings { padding: 16px; } } diff --git a/fastread-frontend/src/web/sso-bootstrap.ts b/fastread-frontend/src/web/sso-bootstrap.ts index 13a3ccf..a6c0e7f 100644 --- a/fastread-frontend/src/web/sso-bootstrap.ts +++ b/fastread-frontend/src/web/sso-bootstrap.ts @@ -1,125 +1,159 @@ -import { API_BASE } from './urls' +import { APP_BASE, API_BASE } from './urls' +type FastNewsIntake = { title: string; authors: string; abstract: string; url: string; venue: string; year: string; external_id: string } +type FastNewsReceipt = { id: string; title: string; awaiting_file: boolean; created: boolean } +type PendingFastNews = { status: 'pending' | 'login-required'; body: FastNewsIntake; rejectedSession?: string | null } +export type FastNewsHandoff = + | PendingFastNews + | { status: 'failed'; body: FastNewsIntake; error: string } + | { status: 'received'; body: FastNewsIntake; receipt: FastNewsReceipt } + | { status: 'invalid'; error: string } -function dropSso(params: URLSearchParams) { - params.delete("sso"); - const query = params.toString(); - history.replaceState(null, "", `${location.pathname}${query ? `?${query}` : ""}${location.hash}`); +const pendingKey = `fastread:fastnews:${APP_BASE}` +const intakeFields = ['title', 'authors', 'abstract', 'url', 'venue', 'year', 'external_id'] as const + +function intakeBody(value: unknown): FastNewsIntake { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('FastNews 论文信息格式无效,请从原入口重新发送。') + const record = value as Record + const body = Object.fromEntries(intakeFields.map(key => [key, String(record[key] ?? '')])) as FastNewsIntake + if (!body.title.trim()) throw new Error('FastNews 论文缺少标题,请从原入口重新发送。') + return body } -function showTicketError(panel: string) { - const root = document.getElementById("root") || document.body; - root.innerHTML = ` -
-
FastRead
-

Panel 登录已过期

-

请从 FastResearch 重新进入。

-

打开 FastResearch

-
`; +function readPending(): PendingFastNews | null { + let raw: string | null = null + try { raw = sessionStorage.getItem(pendingKey) } catch { return null } + if (!raw) return null + const stored = JSON.parse(raw) + if (stored?.status !== 'pending' && stored?.status !== 'login-required') throw new Error('FastNews 待收入信息无效,请从原入口重新发送。') + if (stored.rejectedSession !== undefined && stored.rejectedSession !== null && (typeof stored.rejectedSession !== 'string' || !/^[a-f0-9]{64}$/.test(stored.rejectedSession))) throw new Error('FastNews 登录状态无效,请重新登录。') + return { status: stored.status, body: intakeBody(stored.body), rejectedSession: stored.rejectedSession } } -async function readJson(path: string, options?: RequestInit) { - const response = await fetch(path.replace(/^\/api(?=\/)/, API_BASE), { credentials: "same-origin", cache: "no-store", signal: AbortSignal.timeout(10000), ...options }); - const payload = await response.json().catch(() => ({})); - return { response, payload }; +function savePending(pending: PendingFastNews) { + try { sessionStorage.setItem(pendingKey, JSON.stringify(pending)) } catch { /* Keep the carrier in the URL for reload/login. */ } } -async function panelUrl() { +function pendingHandoff(): FastNewsHandoff | null { try { - const { response, payload } = await readJson("/api/auth/login-info"); - if (response.ok && payload.panel_url) return String(payload.panel_url); - } catch { - /* use fallback below */ + const params = new URLSearchParams(location.search) + const previous = readPending() + let body: FastNewsIntake + if (location.hash.startsWith('#fastnews=')) { + // News' fragment is percent-encoded JSON, not a URLSearchParams value. Decode exactly once. + const carrier = JSON.parse(decodeURIComponent(location.hash.slice('#fastnews='.length))) + if (!carrier || carrier.source !== 'fastnews') throw new Error('FastNews 来源信息无效,请从原入口重新发送。') + body = intakeBody({ ...carrier, external_id: carrier.id }) + } else if (params.get('source') === 'fastnews') { + // Existing documented query entry: fields already use canonical external_id. + body = intakeBody(Object.fromEntries(params)) + } else { + return previous + } + // Persist paper metadata and its login requirement, never a ticket. Keep the URL until receipt. + const pending: PendingFastNews = { ...previous, status: previous?.status ?? 'pending', body } + savePending(pending) + return pending + } catch (error) { + return { status: 'invalid', error: error instanceof URIError || error instanceof SyntaxError ? 'FastNews 论文信息无法解码,请从原入口重新发送。' : (error as Error).message } } - return ""; } -async function currentCsrf(hint: string) { - if (hint) return String(hint); - const { response, payload } = await readJson("/api/auth/me"); - if (!response.ok) return ""; - return String(payload.csrf || ""); +function dropSso() { + const url = new URL(location.href) + url.searchParams.delete('sso') + history.replaceState(null, '', url.pathname + url.search + url.hash) } -async function intakeFastNews(csrf: string) { - const params = new URLSearchParams(location.search); - if ((params.get("source") || "") !== "fastnews") return; - const title = (params.get("title") || "").trim(); - if (!title) return; - let paperId = ""; - try { - const { response, payload } = await readJson("/api/integrations/fastnews/intake", { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-CSRF-Token": csrf || "", - }, - body: JSON.stringify({ - title, - authors: (params.get("authors") || "").trim(), - abstract: (params.get("abstract") || "").trim(), - url: (params.get("url") || "").trim(), - venue: (params.get("venue") || "").trim(), - year: (params.get("year") || "").trim(), - external_id: (params.get("external_id") || "").trim(), - }), - }); - if (response.ok && payload && payload.id) paperId = String(payload.id); - } catch { - /* still slim the query so the library can load */ +async function readJson(path: string, options?: RequestInit) { + const response = await fetch(`${API_BASE}${path}`, { credentials: 'same-origin', cache: 'no-store', signal: AbortSignal.timeout(10000), ...options }) + const payload = await response.json().catch(() => ({})) + return { response, payload } +} + +async function sessionFingerprint(user: Record): Promise { + // CSRF is freshly generated for every local, Research and CAS session. Persist + // only its digest: a verified different session, never URL text, releases a lock. + if (typeof user.csrf !== 'string' || !user.csrf) throw new Error('Login verification failed') + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(user.csrf)) + return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join('') +} + +export async function submitFastNews(body: FastNewsIntake, csrf: string): Promise { + const { response, payload } = await readJson('/integrations/fastnews/intake', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf }, + body: JSON.stringify(body), + }) + if (!response.ok) throw new Error(typeof payload?.detail === 'string' ? payload.detail : `论文信息尚未收入(HTTP ${response.status}),请重试。`) + if (!payload || typeof payload.id !== 'string' || !payload.id || typeof payload.title !== 'string' || typeof payload.awaiting_file !== 'boolean' || typeof payload.created !== 'boolean') { + throw new Error('FastRead 未返回有效收入回执,请重试。') } - const kept = new URLSearchParams(); - kept.set("source", "fastnews"); - kept.set("title", title); - if (paperId) kept.set("pending", paperId); - history.replaceState(null, "", `${location.pathname}?${kept.toString()}${location.hash}`); + try { sessionStorage.removeItem(pendingKey) } catch { /* The server receipt still confirms persistence. */ } + const url = new URL(location.href) + if (url.hash.startsWith('#fastnews=')) url.hash = '' + if (url.searchParams.get('source') === 'fastnews') { + url.searchParams.delete('source') + for (const key of intakeFields) url.searchParams.delete(key) + } + // Existing PDF attachment deep-link consumes pending=paper_id, not a success flag. + url.searchParams.set('pending', payload.id) + history.replaceState(null, '', url.pathname + url.search + url.hash) + return payload } -export async function start(loadApp: (user: Record | null) => Promise) { - const params = new URLSearchParams(location.search); - const ticket = (params.get("sso") || "").trim(); - let csrf = ""; - let user: Record | undefined; +export async function start(loadApp: (user: Record | null, handoff: FastNewsHandoff | null) => Promise) { + let handoff = pendingHandoff() + const ticket = (new URLSearchParams(location.search).get('sso') || '').trim() if (ticket) { - try { - const { response, payload } = await readJson("/api/auth/research", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ticket }), - }); - if (response.status >= 500) throw new Error("service"); - if (!response.ok) { showTicketError((await panelUrl()) || "/"); return; } - csrf = String(payload.csrf || ""); - dropSso(params); - } catch { - throw new Error("Login service unavailable"); - } - } else { - const { response: me, payload } = await readJson("/api/auth/me"); - if (me.status === 401) { - await loadApp(null); - return; - } - if (!me.ok) { - throw new Error('Login service unavailable'); + dropSso() + if (handoff && 'body' in handoff) { + // Lock before any network work: a failed verification must never leave an + // unverified arrival eligible for automatic intake through an old cookie. + handoff = { status: 'login-required', body: handoff.body } + savePending(handoff) + const { response, payload } = await readJson('/auth/me') + if (!response.ok && response.status !== 401) throw new Error('Login verification failed') + handoff = { ...handoff, rejectedSession: response.ok ? await sessionFingerprint(payload) : null } + savePending(handoff) } try { - csrf = String(payload.csrf || ""); - user = payload; + const { response } = await readJson('/auth/research', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ticket }), + }) + if (!response.ok) throw new Error('登录票据无效或已过期,请登录后继续收入论文。') } catch { - csrf = ""; + // An explicit ticket must not silently fall back to an unrelated existing account. + await loadApp(null, handoff) + return } } - if (!csrf) csrf = await currentCsrf(""); - try { - await intakeFastNews(csrf); - } catch { - /* library still loads */ + const { response, payload: user } = await readJson('/auth/me') + if (response.status === 401) { + if (handoff?.status === 'login-required' && handoff.rejectedSession === undefined) { + handoff = { ...handoff, rejectedSession: null } + savePending(handoff) + } + await loadApp(null, handoff) + return + } + if (!response.ok) throw new Error('Login verification failed') + if (handoff?.status === 'login-required') { + const currentSession = await sessionFingerprint(user) + if (handoff.rejectedSession === undefined || handoff.rejectedSession === currentSession) { + // An unrecorded boundary establishes its baseline; it cannot prove a login. + handoff = { ...handoff, rejectedSession: currentSession } + savePending(handoff) + await loadApp(null, handoff) + return + } + handoff = { status: 'pending', body: handoff.body } + savePending(handoff) } - if (!user) { - const result = await readJson('/api/auth/me'); - if (!result.response.ok) throw new Error('Login verification failed'); - user = result.payload; + if (handoff?.status === 'pending') { + const body = handoff.body + try { handoff = { status: 'received', body, receipt: await submitFastNews(body, String(user.csrf || '')) } } + catch (error) { handoff = { status: 'failed', body, error: (error as Error).message } } } - await loadApp(user!); + await loadApp(user, handoff) } diff --git a/fastread-frontend/src/web/theme.ts b/fastread-frontend/src/web/theme.ts new file mode 100644 index 0000000..0b9fe2f --- /dev/null +++ b/fastread-frontend/src/web/theme.ts @@ -0,0 +1,4 @@ +import { initColorMode } from '@fastrd/ui-foundation' + +// Settings/account surfaces opt in; PDF and reading surfaces retain their own styles. +export const colorMode = initColorMode({ storageKey: 'fastread.theme' }) diff --git a/requirements-fastcas.txt b/requirements-fastcas.txt index 5ace4b3..851f802 100644 --- a/requirements-fastcas.txt +++ b/requirements-fastcas.txt @@ -1,2 +1,4 @@ -# Optional FastCAS integration (Python >=3.10); local/Research login remains independent. +# Optional CAS-enabled install (Python >=3.10), from the FastRead repository root: +# python -m pip install -r requirements-fastcas.txt +# Normal local/Research login does not import this SDK. fastcas-sdk[fastapi]==0.1.2