Compare commits

..

21 Commits

Author SHA1 Message Date
agent-company 2f2b6382fa Expand JWT auth integration tests from 17 to 33 cases
Add comprehensive edge-case coverage for issue #1624:

- Admin delete user endpoint (5 tests): successful delete, self-delete
  prevention, nonexistent user 404, non-admin 403, missing token rejection
- Admin role change gaps (2 tests): nonexistent user 404, non-admin 403
- Input validation (3 tests): invalid email 422, short password 422,
  missing fields 422 for both register and login
- Token edge cases (4 tests): malformed token, wrong-secret token,
  deleted user token, deleted user refresh
- Token claim verification (1 test): login tokens contain correct claims

All tests use mocked DB fixtures and require no live database.

Closes leeworks-agents/SPARC#1624

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 06:05:54 +00:00
AI-Manager 1319530f04 Merge pull request 'ci: enable ruff linting and pytest in CI pipeline' (#1568) from feature/1559-1560-enable-ci-linting-and-tests into main
Merge PR #1568: ci: enable ruff linting and pytest in CI pipeline

Closes #1559
Closes #1560
2026-04-19 23:08:07 +00:00
agent-company b32eebff8a ci: enable ruff linting and pytest in CI pipeline
Uncomment the ruff check and pytest steps in the Gitea Actions build
workflow so that linting violations and test failures block image builds.
Fix all pre-existing ruff violations (E402 import ordering in analyzer.py,
F821 undefined name in api.py, I001 unsorted imports in test files, F401
unused import in test_rate_limit.py).

Closes leeworks-agents/SPARC#1559
Closes leeworks-agents/SPARC#1560

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 20:06:10 +00:00
0xWheatyz 68ee19025a ci(build): use docker.io package instead of docker-ce in build jobs
The Debian Bullseye runner image doesn't have the Docker CE
repository configured. docker.io is available from default repos.
2026-04-02 21:28:26 -04:00
0xWheatyz ef97710d1c ci(build): another docker install candiate 2026-04-02 21:21:22 -04:00
0xWheatyz 88812b5967 ci(build): updated the apt command 2026-04-02 21:15:41 -04:00
0xWheatyz 90e58949fc ci: updated the docker install canidate 2026-04-02 21:11:09 -04:00
0xWheatyz bd10925c97 chore: updated package-lock.json 2026-04-02 21:06:34 -04:00
0xWheatyz 89fec43aa2 ci(build): use apt-get with correct Ubuntu package names
Replace apt with apt-get, add -y flag, fix Alpine-style package names
(py3-pip → python3-pip, docker-cli → docker.io), and drop musl-dev.
2026-04-02 20:59:11 -04:00
0xWheatyz 02e1c41126 ci(linters): removed ruff requirement, as causing working builds to fail 2026-04-02 20:57:17 -04:00
0xWheatyz c17a0d006a ci: fix pip install 2026-04-02 20:49:15 -04:00
0xWheatyz c6760a39a1 ci(test): use apt-get with correct Ubuntu packages in workflow
Replace Alpine-style commands (apk, py3-pip, musl-dev) and incorrect
apt usage with proper apt-get invocations and Debian package names for
the ubuntu-latest runner.
2026-04-02 20:47:46 -04:00
0xWheatyz 2ae6280566 ci: fix test to use apt instead of apk 2026-04-02 20:45:41 -04:00
0xWheatyz 9745ed75a8 feat(docker): add registry images to compose services
Add gitea.leeworks.dev image references alongside build directives so
`docker compose up` pulls pre-built images while `--build` still builds
from local sources.
2026-04-02 20:27:56 -04:00
0xWheatyz c649eaf343 fix(proxy): remove double slash in nginx API proxy_pass
API_URL already includes a trailing slash, so the extra slash in
proxy_pass produced //auth/login paths, causing 404s. Also clear
ROOT_PATH since nginx strips /api/ before proxying.
2026-04-02 20:21:47 -04:00
0xWheatyz 7e66d0e7e0 Merge pull request 'deploy: security hardening, multi-model support, S3 storage, analytics, CI improvements (70 commits)' (#4) from leeworks-agents/SPARC:main into main
Reviewed-on: http://gitea.leeworks.dev/0xWheatyz/SPARC/pulls/4
2026-03-31 11:53:44 +00:00
AI-Manager 71465401c6 Merge pull request 'docs: document patent PDF volume mount requirement' (#1374) from feature/docs-patent-volume-mount into main 2026-03-30 17:03:36 +00:00
agent-company 97048917f2 docs: document patent PDF volume mount for containerized deployments
Switch docker-compose.yml from bind mount to a named volume (patent_data)
so downloaded PDFs survive container recreation. Add a "Patent PDF Storage"
section to DEPLOYMENT.md covering Docker Compose, Kubernetes PVC, and S3
alternatives.

Closes leeworks-agents/SPARC#1360

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:08:02 +00:00
AI-Manager 88abd9574b Merge pull request 'feat: theme-aware chart colors for dark/light mode' (#1348) from feature/1324-dark-mode-variants into main 2026-03-30 15:03:43 +00:00
agent-company e0ed39908e feat: add theme-aware chart colors for dark/light mode support
Replace hardcoded dark-theme hex colors in recharts components
(tooltips, axes) with a useChartTheme hook that reads the current
theme from ThemeContext. Charts now render correctly in both light
and dark mode.

Closes leeworks-agents/SPARC#1324

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 14:11:16 +00:00
AI-Manager 87e09b365b Merge pull request 'Add model allow-list validation to analysis endpoints' (#1015) from feature/1013-multi-model into main 2026-03-29 17:03:25 +00:00
14 changed files with 638 additions and 63 deletions
+6 -5
View File
@@ -15,7 +15,7 @@ jobs:
- name: Install system dependencies
shell: sh
run: |
apk add --no-cache git python3 py3-pip gcc musl-dev libpq-dev python3-dev
apt-get update && apt-get install -y git python3 python3-pip gcc libpq-dev python3-dev
- name: Checkout code
shell: sh
@@ -26,7 +26,7 @@ jobs:
- name: Install Python dependencies
shell: sh
run: |
pip3 install --break-system-packages -r requirements.txt ruff
pip3 install -r requirements.txt ruff
- name: Run ruff linter
shell: sh
@@ -36,7 +36,7 @@ jobs:
- name: Install Node.js and check TypeScript types
shell: sh
run: |
apk add --no-cache nodejs npm
apt-get install -y nodejs npm
cd frontend
npm ci
npm run generate:local
@@ -56,6 +56,7 @@ jobs:
JWT_SECRET: "test-secret-for-ci"
APP_ENV: "development"
run: |
pip3 install pytest
python3 -m pytest tests/ -v --tb=short -x
build-api:
@@ -65,7 +66,7 @@ jobs:
- name: Install dependencies
shell: sh
run: |
apk add --no-cache git docker-cli
apt-get update && apt-get install -y git docker.io
- name: Checkout code
shell: sh
@@ -137,7 +138,7 @@ jobs:
- name: Install dependencies
shell: sh
run: |
apk add --no-cache git docker-cli
apt-get update && apt-get install -y git docker.io
- name: Checkout code
shell: sh
+3 -3
View File
@@ -16,7 +16,7 @@ jobs:
- name: Install system dependencies
shell: sh
run: |
apk add --no-cache git python3 py3-pip gcc musl-dev libpq-dev python3-dev
apt-get update && apt-get install -y git python3 python3-pip gcc libpq-dev python3-dev
- name: Checkout code
shell: sh
@@ -27,7 +27,7 @@ jobs:
- name: Install Python dependencies
shell: sh
run: |
pip3 install --break-system-packages -r requirements.txt ruff
pip3 install -r requirements.txt ruff
- name: Run ruff linter
shell: sh
@@ -37,7 +37,7 @@ jobs:
- name: Install Node.js and frontend dependencies
shell: sh
run: |
apk add --no-cache nodejs npm
apt-get install -y nodejs npm
cd frontend && npm ci
- name: Verify generated API types are up to date
+2 -2
View File
@@ -10,13 +10,13 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Callable
from SPARC import config
logger = logging.getLogger(__name__)
from SPARC.database import DatabaseClient
from SPARC.llm import LLMAnalyzer
from SPARC.serp_api import SERP
from SPARC.types import BatchAnalysisResult, CompanyAnalysisResult, Patent, Patents
logger = logging.getLogger(__name__)
class CompanyAnalyzer:
"""Orchestrates end-to-end company performance analysis via patents."""
+6 -2
View File
@@ -3,9 +3,14 @@
Provides REST API endpoints for analyzing company patent portfolios.
"""
from __future__ import annotations
from contextlib import asynccontextmanager
from datetime import datetime
from typing import Annotated, List
from typing import TYPE_CHECKING, Annotated, List
if TYPE_CHECKING:
from SPARC.database import DatabaseClient
from fastapi import BackgroundTasks, Depends, FastAPI, HTTPException, Query, Request
from fastapi.middleware.cors import CORSMiddleware
@@ -653,7 +658,6 @@ async def export_company_pdf(
PDF file download
"""
import io
import textwrap
from reportlab.lib import colors
from reportlab.lib.pagesizes import letter
+6 -2
View File
@@ -18,6 +18,7 @@ services:
restart: unless-stopped
init-db:
image: gitea.leeworks.dev/0xwheatyz/sparc:latest
build: .
container_name: sparc-init-db
command: python scripts/init_database.py
@@ -29,6 +30,7 @@ services:
restart: "no"
api:
image: gitea.leeworks.dev/0xwheatyz/sparc:latest
build: .
container_name: sparc-api
command: uvicorn SPARC.api:app --host 0.0.0.0 --port 8000
@@ -40,7 +42,7 @@ services:
JWT_SECRET: ${JWT_SECRET:-sparc-secret-key-change-in-production}
CORS_ORIGINS: ${CORS_ORIGINS:-}
APP_ENV: ${APP_ENV:-development}
ROOT_PATH: /api
ROOT_PATH: ""
ports:
- "8000:8000"
depends_on:
@@ -49,7 +51,7 @@ services:
init-db:
condition: service_completed_successfully
volumes:
- ./patents:/app/patents
- patent_data:/app/patents
restart: unless-stopped
# Optional: MinIO for S3-compatible local object storage
@@ -76,6 +78,7 @@ services:
- s3
dashboard:
image: gitea.leeworks.dev/0xwheatyz/sparc:frontend-latest
build: ./frontend
container_name: sparc-dashboard
ports:
@@ -86,4 +89,5 @@ services:
volumes:
postgres_data:
patent_data:
minio_data:
+76 -1
View File
@@ -276,7 +276,7 @@ The `docker-compose.yml` includes all services needed for production:
|---------|-----------|------|-------------|
| `postgres` | sparc-postgres | 5432 | PostgreSQL database |
| `init-db` | sparc-init-db | - | One-time database initialization (seeds admin user) |
| `api` | sparc-api | 8000 | FastAPI REST API with JWT auth |
| `api` | sparc-api | 8000 | FastAPI REST API with JWT auth (patent PDFs stored in `patent_data` volume) |
| `dashboard` | sparc-dashboard | 8080 | React TypeScript web UI |
### Common Docker Compose Commands
@@ -307,6 +307,81 @@ docker-compose restart api
---
## Patent PDF Storage
The SPARC API downloads patent PDFs during analysis and stores them at `/app/patents` inside the container. These files are used for subsequent single-patent analysis requests and as a local cache to avoid re-downloading. If this directory is not persisted, all downloaded PDFs are lost when the container is recreated.
### Docker Compose (default)
The default `docker-compose.yml` declares a named volume called `patent_data` that is mounted at `/app/patents`:
```yaml
# In the api service:
volumes:
- patent_data:/app/patents
# At the top-level volumes section:
volumes:
patent_data:
```
This means PDFs survive `docker compose down` and `docker compose up` cycles. To remove patent data intentionally, run:
```bash
docker compose down -v # WARNING: also removes postgres_data
# or selectively:
docker volume rm sparc_patent_data
```
If you prefer a bind mount (e.g., for easy host-side access during development), replace the volume with:
```yaml
volumes:
- ./patents:/app/patents
```
### Kubernetes
For Kubernetes deployments, create a PersistentVolumeClaim and mount it into the API pod:
```yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: sparc-patent-data
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: sparc-api
spec:
template:
spec:
containers:
- name: api
volumeMounts:
- name: patent-data
mountPath: /app/patents
volumes:
- name: patent-data
persistentVolumeClaim:
claimName: sparc-patent-data
```
Adjust the storage size based on expected patent volume. Each patent PDF is typically 1-5 MB.
### S3 Object Storage (alternative)
For production deployments that need shared or highly durable storage, set `STORAGE_BACKEND=s3` in your `.env` file. This stores patent PDFs in an S3-compatible bucket (AWS S3 or MinIO) instead of the local filesystem, eliminating the need for a persistent volume. See the S3/MinIO section in `.env.example` for configuration details.
---
## Troubleshooting
### Database Connection Issues
+1 -1
View File
@@ -15,7 +15,7 @@ server {
# Proxy API requests to backend
location /api/ {
proxy_pass ${API_URL}/;
proxy_pass ${API_URL};
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
+257
View File
@@ -26,6 +26,7 @@
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.7",
"globals": "^15.8.0",
"openapi-typescript": "^7.0.0",
"postcss": "^8.4.39",
"tailwindcss": "^3.4.4",
"typescript": "~5.5.3",
@@ -1025,6 +1026,82 @@
"node": ">= 8"
}
},
"node_modules/@redocly/ajv": {
"version": "8.11.2",
"resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz",
"integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2",
"uri-js-replace": "^1.0.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/@redocly/ajv/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true,
"license": "MIT"
},
"node_modules/@redocly/config": {
"version": "0.22.0",
"resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz",
"integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@redocly/openapi-core": {
"version": "1.34.11",
"resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.11.tgz",
"integrity": "sha512-V09ayfnb5GyysmvARbt+voFZAjGcf7hSYxOYxSkCc4fbH/DTfq5YWoec8cflvmHHqyIFbqvmGKmYFzqhr9zxDg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@redocly/ajv": "8.11.2",
"@redocly/config": "0.22.0",
"colorette": "1.4.0",
"https-proxy-agent": "7.0.6",
"js-levenshtein": "1.1.6",
"js-yaml": "4.1.1",
"minimatch": "5.1.9",
"pluralize": "8.0.0",
"yaml-ast-parser": "0.0.43"
},
"engines": {
"node": ">=18.17.0",
"npm": ">=9.5.0"
}
},
"node_modules/@redocly/openapi-core/node_modules/brace-expansion": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/@redocly/openapi-core/node_modules/minimatch": {
"version": "5.1.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@remix-run/router": {
"version": "1.23.2",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
@@ -1906,6 +1983,16 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/ajv": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
@@ -1923,6 +2010,16 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ansi-colors": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
"integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
@@ -2190,6 +2287,13 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/change-case": {
"version": "5.4.4",
"resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz",
"integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==",
"dev": true,
"license": "MIT"
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -2257,6 +2361,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/colorette": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz",
"integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
"dev": true,
"license": "MIT"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -3165,6 +3276,20 @@
"node": ">= 0.4"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -3202,6 +3327,19 @@
"node": ">=0.8.19"
}
},
"node_modules/index-to-position": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz",
"integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
@@ -3290,6 +3428,16 @@
"jiti": "bin/jiti.js"
}
},
"node_modules/js-levenshtein": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",
"integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -3608,6 +3756,40 @@
"node": ">= 6"
}
},
"node_modules/openapi-typescript": {
"version": "7.13.0",
"resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz",
"integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@redocly/openapi-core": "^1.34.6",
"ansi-colors": "^4.1.3",
"change-case": "^5.4.4",
"parse-json": "^8.3.0",
"supports-color": "^10.2.2",
"yargs-parser": "^21.1.1"
},
"bin": {
"openapi-typescript": "bin/cli.js"
},
"peerDependencies": {
"typescript": "^5.x"
}
},
"node_modules/openapi-typescript/node_modules/supports-color": {
"version": "10.2.2",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
"integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -3671,6 +3853,24 @@
"node": ">=6"
}
},
"node_modules/parse-json": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz",
"integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.26.2",
"index-to-position": "^1.1.0",
"type-fest": "^4.39.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -3738,6 +3938,16 @@
"node": ">= 6"
}
},
"node_modules/pluralize": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
"integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
@@ -4124,6 +4334,16 @@
"decimal.js-light": "^2.4.1"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -4510,6 +4730,19 @@
"node": ">= 0.8.0"
}
},
"node_modules/type-fest": {
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
"dev": true,
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/typescript": {
"version": "5.5.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
@@ -4589,6 +4822,13 @@
"punycode": "^2.1.0"
}
},
"node_modules/uri-js-replace": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz",
"integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==",
"dev": true,
"license": "MIT"
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -4711,6 +4951,23 @@
"dev": true,
"license": "ISC"
},
"node_modules/yaml-ast-parser": {
"version": "0.0.43",
"resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz",
"integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+41
View File
@@ -0,0 +1,41 @@
import { useTheme } from './ThemeContext';
/**
* Returns theme-aware color values for recharts components.
*
* Recharts accepts only raw color strings (not CSS variables),
* so this hook bridges the Tailwind/CSS-variable theme system
* to the imperative recharts API.
*/
export function useChartTheme() {
const { theme } = useTheme();
const isDark = theme === 'dark';
return {
/** Axis tick and grid line stroke color */
axisStroke: isDark ? '#94a3b8' : '#64748b',
/** Tooltip container background */
tooltipBg: isDark ? '#1e293b' : '#ffffff',
/** Tooltip container border */
tooltipBorder: isDark
? '1px solid rgba(99, 102, 241, 0.3)'
: '1px solid rgba(99, 102, 241, 0.2)',
/** Tooltip label text color */
tooltipLabelColor: isDark ? '#f8fafc' : '#0f172a',
/** Tooltip item text color */
tooltipItemColor: isDark ? '#e2e8f0' : '#334155',
/** Convenience: full contentStyle object for recharts Tooltip */
tooltipContentStyle: {
backgroundColor: isDark ? '#1e293b' : '#ffffff',
border: isDark
? '1px solid rgba(99, 102, 241, 0.3)'
: '1px solid rgba(99, 102, 241, 0.2)',
borderRadius: '8px',
color: isDark ? '#f8fafc' : '#0f172a',
},
/** Convenience: labelStyle for recharts Tooltip */
tooltipLabelStyle: {
color: isDark ? '#f8fafc' : '#0f172a',
},
};
}
+15 -29
View File
@@ -3,11 +3,13 @@ import { useQuery } from '@tanstack/react-query';
import { analyticsApi } from '../api/client';
import { AlertCircle, Database } from 'lucide-react';
import { PieChart, Pie, Cell, BarChart, Bar, LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend } from 'recharts';
import { useChartTheme } from '../context/useChartTheme';
const COLORS = ['#6366f1', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'];
export function AnalyticsPage() {
const [days, setDays] = useState(30);
const chartTheme = useChartTheme();
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['analytics', days],
@@ -160,11 +162,7 @@ export function AnalyticsPage() {
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: '#1e293b',
border: '1px solid rgba(99, 102, 241, 0.3)',
borderRadius: '8px',
}}
contentStyle={chartTheme.tooltipContentStyle}
/>
<Legend />
</PieChart>
@@ -178,15 +176,11 @@ export function AnalyticsPage() {
<h3 className="text-lg font-semibold text-text-primary mb-4">Analysis Types</h3>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={typeData}>
<XAxis dataKey="name" stroke="#94a3b8" fontSize={12} />
<YAxis stroke="#94a3b8" fontSize={12} />
<XAxis dataKey="name" stroke={chartTheme.axisStroke} fontSize={12} />
<YAxis stroke={chartTheme.axisStroke} fontSize={12} />
<Tooltip
contentStyle={{
backgroundColor: '#1e293b',
border: '1px solid rgba(99, 102, 241, 0.3)',
borderRadius: '8px',
}}
labelStyle={{ color: '#f8fafc' }}
contentStyle={chartTheme.tooltipContentStyle}
labelStyle={chartTheme.tooltipLabelStyle}
/>
<Bar dataKey="count" fill="#6366f1" radius={[4, 4, 0, 0]} />
</BarChart>
@@ -222,15 +216,11 @@ export function AnalyticsPage() {
<h4 className="text-md font-semibold text-text-primary mb-4">Analyses per Company Over Time</h4>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={pivoted}>
<XAxis dataKey="month" stroke="#94a3b8" fontSize={12} />
<YAxis stroke="#94a3b8" fontSize={12} />
<XAxis dataKey="month" stroke={chartTheme.axisStroke} fontSize={12} />
<YAxis stroke={chartTheme.axisStroke} fontSize={12} />
<Tooltip
contentStyle={{
backgroundColor: '#1e293b',
border: '1px solid rgba(99, 102, 241, 0.3)',
borderRadius: '8px',
}}
labelStyle={{ color: '#f8fafc' }}
contentStyle={chartTheme.tooltipContentStyle}
labelStyle={chartTheme.tooltipLabelStyle}
/>
<Legend />
{companies.map((company, idx) => (
@@ -268,15 +258,11 @@ export function AnalyticsPage() {
<h4 className="text-md font-semibold text-text-primary mb-4">Analysis Types Over Time</h4>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={pivoted}>
<XAxis dataKey="month" stroke="#94a3b8" fontSize={12} />
<YAxis stroke="#94a3b8" fontSize={12} />
<XAxis dataKey="month" stroke={chartTheme.axisStroke} fontSize={12} />
<YAxis stroke={chartTheme.axisStroke} fontSize={12} />
<Tooltip
contentStyle={{
backgroundColor: '#1e293b',
border: '1px solid rgba(99, 102, 241, 0.3)',
borderRadius: '8px',
}}
labelStyle={{ color: '#f8fafc' }}
contentStyle={chartTheme.tooltipContentStyle}
labelStyle={chartTheme.tooltipLabelStyle}
/>
<Legend />
{types.map((type, idx) => (
+7 -8
View File
@@ -3,6 +3,7 @@ import { useMutation, useQuery } from '@tanstack/react-query';
import { analysisApi } from '../api/client';
import { Rocket, CheckCircle, AlertCircle, ChevronDown, ChevronUp, RefreshCw, Inbox } from 'lucide-react';
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts';
import { useChartTheme } from '../context/useChartTheme';
import type { BatchAnalysisResult } from '../types';
export function Batch() {
@@ -12,6 +13,8 @@ export function Batch() {
const [result, setResult] = useState<BatchAnalysisResult | null>(null);
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
const chartTheme = useChartTheme();
const modelsQuery = useQuery({
queryKey: ['models'],
queryFn: () => analysisApi.listModels(),
@@ -210,15 +213,11 @@ export function Batch() {
<div className="bg-bg-card/60 border border-primary/15 rounded-2xl p-6">
<ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData}>
<XAxis dataKey="name" stroke="#94a3b8" fontSize={12} />
<YAxis stroke="#94a3b8" fontSize={12} />
<XAxis dataKey="name" stroke={chartTheme.axisStroke} fontSize={12} />
<YAxis stroke={chartTheme.axisStroke} fontSize={12} />
<Tooltip
contentStyle={{
backgroundColor: '#1e293b',
border: '1px solid rgba(99, 102, 241, 0.3)',
borderRadius: '8px',
}}
labelStyle={{ color: '#f8fafc' }}
contentStyle={chartTheme.tooltipContentStyle}
labelStyle={chartTheme.tooltipLabelStyle}
/>
<Bar dataKey="patents" radius={[4, 4, 0, 0]}>
{chartData.map((entry, index) => (
+209 -9
View File
@@ -1,13 +1,29 @@
"""Tests for JWT authentication flow: register, login, protected routes, refresh, admin access."""
"""Tests for JWT authentication flow: register, login, protected routes, refresh, admin access.
from datetime import datetime, timezone
Covers all five scenarios required by issue #1624:
1. Registration (POST /auth/register)
2. Login (POST /auth/login)
3. Protected route access (GET /auth/me) -- valid, missing, expired, wrong-type tokens
4. Token refresh (POST /auth/refresh)
5. Admin-only endpoints (GET /admin/users, PATCH role, DELETE user)
All tests use mocked DB fixtures and require no live database.
"""
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock, patch
import jwt as pyjwt
import pytest
from fastapi.testclient import TestClient
from SPARC.api import app
from SPARC.auth import create_access_token, create_refresh_token
from SPARC.auth import (
JWT_ALGORITHM,
JWT_SECRET,
create_access_token,
create_refresh_token,
)
@pytest.fixture
@@ -171,12 +187,6 @@ class TestGetMe:
def test_expired_token_returns_401(self, client, mock_db):
"""An expired token should return 401."""
# Create a token that has already expired
from datetime import timedelta
import jwt as pyjwt
from SPARC.auth import JWT_ALGORITHM, JWT_SECRET
payload = {
"sub": "1",
"email": "user@test.com",
@@ -300,3 +310,193 @@ class TestAdminUsers:
assert response.status_code == 400
assert "own role" in response.json()["detail"].lower()
def test_role_change_nonexistent_user_returns_404(self, client, mock_db):
"""Changing role for a user that does not exist should return 404."""
admin = _make_admin_user()
mock_db.get_user_by_id.return_value = admin
mock_db.update_user_role.return_value = None
response = client.patch(
"/admin/users/999/role",
json={"role": "admin"},
headers=_auth_header(admin),
)
assert response.status_code == 404
assert "not found" in response.json()["detail"].lower()
def test_regular_user_cannot_change_role(self, client, mock_db):
"""Non-admin user should receive 403 when trying to change roles."""
user = _make_regular_user()
mock_db.get_user_by_id.return_value = user
response = client.patch(
"/admin/users/1/role",
json={"role": "admin"},
headers=_auth_header(user),
)
assert response.status_code == 403
class TestAdminDeleteUser:
"""DELETE /admin/users/{user_id}"""
def test_admin_can_delete_user(self, client, mock_db):
"""Admin should be able to delete another user."""
admin = _make_admin_user()
mock_db.get_user_by_id.return_value = admin
mock_db.delete_user.return_value = True
response = client.delete(
"/admin/users/2",
headers=_auth_header(admin),
)
assert response.status_code == 200
assert "deleted" in response.json()["message"].lower()
mock_db.delete_user.assert_called_once_with(2)
def test_admin_cannot_delete_self(self, client, mock_db):
"""Admin should not be able to delete themselves."""
admin = _make_admin_user()
mock_db.get_user_by_id.return_value = admin
response = client.delete(
"/admin/users/1",
headers=_auth_header(admin),
)
assert response.status_code == 400
assert "yourself" in response.json()["detail"].lower()
def test_delete_nonexistent_user_returns_404(self, client, mock_db):
"""Deleting a user that does not exist should return 404."""
admin = _make_admin_user()
mock_db.get_user_by_id.return_value = admin
mock_db.delete_user.return_value = False
response = client.delete(
"/admin/users/999",
headers=_auth_header(admin),
)
assert response.status_code == 404
assert "not found" in response.json()["detail"].lower()
def test_regular_user_cannot_delete_user(self, client, mock_db):
"""Non-admin user should receive 403 when trying to delete users."""
user = _make_regular_user()
mock_db.get_user_by_id.return_value = user
response = client.delete(
"/admin/users/1",
headers=_auth_header(user),
)
assert response.status_code == 403
def test_no_token_cannot_delete_user(self, client):
"""Missing token should be rejected for delete endpoint."""
response = client.delete("/admin/users/1")
assert response.status_code in (401, 403)
class TestEdgeCases:
"""Additional edge-case tests for auth robustness."""
def test_register_invalid_email_returns_422(self, client, mock_db):
"""Registration with an invalid email format should return 422."""
response = client.post(
"/auth/register",
json={"email": "not-an-email", "password": "securepass123"},
)
assert response.status_code == 422
def test_register_short_password_returns_422(self, client, mock_db):
"""Registration with a password shorter than 8 chars should return 422."""
response = client.post(
"/auth/register",
json={"email": "user@test.com", "password": "short"},
)
assert response.status_code == 422
def test_register_missing_fields_returns_422(self, client, mock_db):
"""Registration with missing fields should return 422."""
response = client.post("/auth/register", json={})
assert response.status_code == 422
def test_login_missing_fields_returns_422(self, client, mock_db):
"""Login with missing fields should return 422."""
response = client.post("/auth/login", json={"email": "user@test.com"})
assert response.status_code == 422
def test_malformed_token_returns_401(self, client, mock_db):
"""A completely malformed token string should return 401."""
response = client.get(
"/auth/me",
headers={"Authorization": "Bearer not.a.valid.jwt.token"},
)
assert response.status_code == 401
def test_token_with_wrong_secret_returns_401(self, client, mock_db):
"""A token signed with a different secret should return 401."""
payload = {
"sub": "1",
"email": "user@test.com",
"role": "user",
"exp": datetime.now(timezone.utc) + timedelta(hours=1),
"type": "access",
}
wrong_secret_token = pyjwt.encode(payload, "wrong-secret", algorithm=JWT_ALGORITHM)
response = client.get(
"/auth/me",
headers={"Authorization": f"Bearer {wrong_secret_token}"},
)
assert response.status_code == 401
def test_token_for_deleted_user_returns_401(self, client, mock_db):
"""A valid token for a user no longer in the DB should return 401."""
user = _make_regular_user()
mock_db.get_user_by_id.return_value = None # user was deleted
response = client.get("/auth/me", headers=_auth_header(user))
assert response.status_code == 401
def test_refresh_for_deleted_user_returns_401(self, client, mock_db):
"""Refreshing a token for a deleted user should return 401."""
user = _make_regular_user()
mock_db.get_user_by_id.return_value = None
refresh = create_refresh_token(user["id"], user["email"], user["role"])
response = client.post(
"/auth/refresh", json={"refresh_token": refresh}
)
assert response.status_code == 401
def test_login_returns_decodable_tokens(self, client, mock_db):
"""Tokens returned by login should be decodable and contain expected claims."""
user = _make_regular_user()
mock_db.authenticate_user.return_value = user
response = client.post(
"/auth/login",
json={"email": "user@test.com", "password": "correctpassword"},
)
data = response.json()
access_payload = pyjwt.decode(
data["access_token"], JWT_SECRET, algorithms=[JWT_ALGORITHM]
)
assert access_payload["sub"] == str(user["id"])
assert access_payload["email"] == user["email"]
assert access_payload["type"] == "access"
refresh_payload = pyjwt.decode(
data["refresh_token"], JWT_SECRET, algorithms=[JWT_ALGORITHM]
)
assert refresh_payload["type"] == "refresh"
+2 -1
View File
@@ -1,7 +1,8 @@
"""Tests for rate limiting on auth endpoints."""
from unittest.mock import MagicMock, patch
import pytest
from unittest.mock import Mock, patch, MagicMock
from fastapi.testclient import TestClient
from SPARC.api import app
+7
View File
@@ -14,6 +14,7 @@ class TestJWTSecretStartupCheck:
with patch.dict(os.environ, {"APP_ENV": "production"}):
# Reload config to pick up the new APP_ENV
import importlib
import SPARC.config
importlib.reload(SPARC.config)
@@ -31,6 +32,7 @@ class TestJWTSecretStartupCheck:
"""Starting with default secret and APP_ENV=development must not raise."""
with patch.dict(os.environ, {"APP_ENV": "development"}):
import importlib
import SPARC.config
importlib.reload(SPARC.config)
@@ -46,6 +48,7 @@ class TestJWTSecretStartupCheck:
"""Starting with a custom secret in production must not raise."""
with patch.dict(os.environ, {"APP_ENV": "production"}):
import importlib
import SPARC.config
importlib.reload(SPARC.config)
@@ -65,6 +68,7 @@ class TestJWTSecretStartupCheck:
env.pop("APP_ENV", None)
with patch.dict(os.environ, env, clear=True):
import importlib
import SPARC.config
importlib.reload(SPARC.config)
@@ -84,6 +88,7 @@ class TestCORSConfig:
"""When CORS_ORIGINS is unset, defaults to localhost origins."""
with patch.dict(os.environ, {"CORS_ORIGINS": ""}):
import importlib
import SPARC.config
importlib.reload(SPARC.config)
assert SPARC.config.cors_origins == [
@@ -95,6 +100,7 @@ class TestCORSConfig:
"""Setting CORS_ORIGINS configures allowed origins."""
with patch.dict(os.environ, {"CORS_ORIGINS": "https://sparc.example.com,https://app.example.com"}):
import importlib
import SPARC.config
importlib.reload(SPARC.config)
assert SPARC.config.cors_origins == [
@@ -109,6 +115,7 @@ class TestCORSConfig:
"""A single origin without comma works correctly."""
with patch.dict(os.environ, {"CORS_ORIGINS": "https://sparc.example.com"}):
import importlib
import SPARC.config
importlib.reload(SPARC.config)
assert SPARC.config.cors_origins == ["https://sparc.example.com"]