Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions .github/workflows/commitlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
40 changes: 40 additions & 0 deletions .github/workflows/fastcas-release-contract.yml
Original file line number Diff line number Diff line change
@@ -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'"
2 changes: 1 addition & 1 deletion .github/workflows/tag-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions Dockerfile.complete
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions backend/Dockerfile.gpu
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions backend/app/web/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
164 changes: 164 additions & 0 deletions backend/tests/fastcas_release_contract.py
Original file line number Diff line number Diff line change
@@ -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)
50 changes: 47 additions & 3 deletions backend/tests/test_research_intake.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -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']
Loading
Loading