Performance enhancements, overengineering

This commit is contained in:
lewis
2025-12-13 15:10:53 +02:00
parent 7b6807c316
commit 43c18beb51
78 changed files with 2992 additions and 2871 deletions
+113 -9
View File
@@ -1,24 +1,89 @@
# =============================================================================
# Server
# =============================================================================
SERVER_HOST=127.0.0.1
SERVER_PORT=3000
# The public-facing hostname of the PDS (used in DID documents, JWTs, etc.)
PDS_HOSTNAME=localhost:3000
# =============================================================================
# Database
# =============================================================================
DATABASE_URL=postgres://postgres:postgres@localhost:5432/pds
S3_ENDPOINT=http://objsto:9000
# Connection pool settings (defaults are good for most deployments)
# DATABASE_MAX_CONNECTIONS=100
# DATABASE_MIN_CONNECTIONS=10
# DATABASE_ACQUIRE_TIMEOUT_SECS=30
# =============================================================================
# Blob Storage (S3-compatible)
# =============================================================================
S3_ENDPOINT=http://localhost:9000
AWS_REGION=us-east-1
S3_BUCKET=pds-blobs
AWS_ACCESS_KEY_ID=minioadmin
AWS_SECRET_ACCESS_KEY=minioadmin
# The public-facing hostname of the PDS
PDS_HOSTNAME=localhost:3000
PLC_URL=plc.directory
# =============================================================================
# Valkey (for caching and distributed rate limiting)
# =============================================================================
# If not set, falls back to in-memory caching (single-node only)
# VALKEY_URL=redis://localhost:6379
# A comma-separated list of relay URLs to notify via requestCrawl when we have updates.
# e.g., CRAWLERS=https://bsky.network
CRAWLERS=
# =============================================================================
# Security Secrets
# =============================================================================
# These MUST be set in production (minimum 32 characters each)
# In development, set BSPDS_ALLOW_INSECURE_SECRETS=1 to use defaults
# Notification Service Configuration
# At least one notification channel should be configured for user notifications to work.
# Server-wide secret for OAuth token signing (HS256)
# JWT_SECRET=your-secure-random-string-at-least-32-chars
# Secret for DPoP proof validation
# DPOP_SECRET=your-secure-random-string-at-least-32-chars
# Key for encrypting user signing keys at rest (AES-256-GCM)
# MASTER_KEY=your-secure-random-string-at-least-32-chars
# Set this ONLY in development to allow default/weak secrets
# BSPDS_ALLOW_INSECURE_SECRETS=1
# =============================================================================
# PLC Directory
# =============================================================================
# PLC_DIRECTORY_URL=https://plc.directory
# PLC_TIMEOUT_SECS=10
# PLC_CONNECT_TIMEOUT_SECS=5
# Optional: rotation key for PLC operations (defaults to user's key)
# PLC_ROTATION_KEY=did:key:...
# =============================================================================
# Federation
# =============================================================================
# Appview URL for proxying app.bsky.* requests
# APPVIEW_URL=https://api.bsky.app
# Comma-separated list of relay URLs to notify via requestCrawl
# CRAWLERS=https://bsky.network
# =============================================================================
# Firehose (subscribeRepos WebSocket)
# =============================================================================
# Buffer size for firehose broadcast channel
# FIREHOSE_BUFFER_SIZE=10000
# Disconnect slow consumers after this many events of lag
# FIREHOSE_MAX_LAG=5000
# =============================================================================
# Notification Service
# =============================================================================
# Queue processing settings
# NOTIFICATION_BATCH_SIZE=100
# NOTIFICATION_POLL_INTERVAL_MS=1000
# Email notifications (via sendmail/msmtp)
# MAIL_FROM_ADDRESS=noreply@example.com
@@ -35,6 +100,45 @@ CRAWLERS=
# SIGNAL_CLI_PATH=/usr/local/bin/signal-cli
# SIGNAL_SENDER_NUMBER=+1234567890
# =============================================================================
# Repository Import
# =============================================================================
# Set to "true" to accept repository imports
# ACCEPTING_REPO_IMPORTS=false
# Maximum import size in bytes (default: 50MB)
# MAX_IMPORT_SIZE=52428800
# Maximum blocks per import (default: 100000)
# MAX_IMPORT_BLOCKS=100000
# Skip verification during import (testing only)
# SKIP_IMPORT_VERIFICATION=false
# =============================================================================
# Account Registration
# =============================================================================
# Require invite codes for registration
# INVITE_CODE_REQUIRED=false
# Comma-separated list of available user domains
# AVAILABLE_USER_DOMAINS=example.com
# =============================================================================
# Rate Limiting
# =============================================================================
# Disable all rate limiting (testing only, NEVER in production)
# DISABLE_RATE_LIMITING=1
# =============================================================================
# Miscellaneous
# =============================================================================
# Allow HTTP for proxy requests (development only)
# ALLOW_HTTP_PROXY=1
# Custom frontend directory (defaults to ./frontend/dist)
# FRONTEND_DIR=/path/to/frontend/dist
CARGO_MOMMYS_LITTLE=mister
CARGO_MOMMYS_PRONOUNS=his
CARGO_MOMMYS_ROLES=daddy
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT deactivated_at, takedown_ref FROM users WHERE did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "deactivated_at",
"type_info": "Timestamptz"
},
{
"ordinal": 1,
"name": "takedown_ref",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true,
true
]
},
"hash": "04c220298334c369872f0b0ad162b992c2353e28257b53f3f10cbff8abb26f5a"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT access_jti FROM session_tokens WHERE did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "access_jti",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "1c831cb6f3b8d01b18feec900148278c2b491418b622da9e75fe1792089e4409"
}
@@ -1,8 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO blobs (cid, mime_type, size_bytes, created_by_user, storage_key) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (cid) DO NOTHING",
"query": "INSERT INTO blobs (cid, mime_type, size_bytes, created_by_user, storage_key) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (cid) DO NOTHING RETURNING cid",
"describe": {
"columns": [],
"columns": [
{
"ordinal": 0,
"name": "cid",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
@@ -12,7 +18,9 @@
"Text"
]
},
"nullable": []
"nullable": [
false
]
},
"hash": "0f10bde03edc0233a332e210a84a4186977c71efd3be80e2508a60ea5802cb1b"
"hash": "25ac36e9dec1c8e29cbe7cfc954683061c7c2733fa60f91f1c5ced4d00e7bf3d"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM session_tokens WHERE did = (SELECT did FROM users WHERE id = $1)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "3b1176253dc7b94d3fc58c077310d8058f90edf1fa27200b52b464b9c37335dd"
}
@@ -0,0 +1,70 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT seq, did, created_at, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "seq",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "did",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 3,
"name": "event_type",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "commit_cid",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "prev_cid",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "ops",
"type_info": "Jsonb"
},
{
"ordinal": 7,
"name": "blobs",
"type_info": "TextArray"
},
{
"ordinal": 8,
"name": "blocks_cids",
"type_info": "TextArray"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
true,
true,
true,
true,
true
]
},
"hash": "8a7a8f0c4c0872c21c46d484219624215bdb14617b9f9a44974e394a28147f70"
}
@@ -1,18 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO records (repo_id, collection, rkey, record_cid, repo_rev) VALUES ($1, $2, $3, $4, $5)\n ON CONFLICT (repo_id, collection, rkey) DO UPDATE SET record_cid = $4, repo_rev = $5, created_at = NOW()",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text",
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "8a9e71f04ec779d5c10d79582cc398529e01be01a83898df3524bb35e3d2ed14"
}
@@ -1,16 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM records WHERE repo_id = $1 AND collection = $2 AND rkey = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "8c9297289cb753c8eaa4231ae9eab6cd3367f9bf543d9f49bca4afa53434ce0d"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM records\n WHERE repo_id = $1\n AND (collection, rkey) IN (SELECT * FROM UNNEST($2::text[], $3::text[]))\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"TextArray",
"TextArray"
]
},
"nullable": []
},
"hash": "9806777e3db4db9e9a905a6ce26375f026aa8a6db2c5534cf5ccf9758a07ee39"
}
@@ -0,0 +1,71 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT seq, did, created_at, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids\n FROM repo_seq\n WHERE seq > $1 AND seq < $2\n ORDER BY seq ASC\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "seq",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "did",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 3,
"name": "event_type",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "commit_cid",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "prev_cid",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "ops",
"type_info": "Jsonb"
},
{
"ordinal": 7,
"name": "blobs",
"type_info": "TextArray"
},
{
"ordinal": 8,
"name": "blocks_cids",
"type_info": "TextArray"
}
],
"parameters": {
"Left": [
"Int8",
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
true,
true,
true,
true,
true
]
},
"hash": "a63aed47193f06cd11d87157799c17a591e0a0be4487f718250eaf7afd4b4b07"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COALESCE(MAX(seq), 0) as max FROM repo_seq",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "max",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "b2a217b405ace1726097631c7fa532bf1a7330f11328a1e68d5eced41cad8a78"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT cid, data FROM blocks WHERE cid = ANY($1)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "cid",
"type_info": "Bytea"
},
{
"ordinal": 1,
"name": "data",
"type_info": "Bytea"
}
],
"parameters": {
"Left": [
"ByteaArray"
]
},
"nullable": [
false,
false
]
},
"hash": "b9848ea8f168e1ab975dc2ad125b5b9e478e74254a8cf670e55b728bc402f046"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO blocks (cid, data)\n SELECT * FROM UNNEST($1::bytea[], $2::bytea[])\n ON CONFLICT (cid) DO NOTHING\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"ByteaArray",
"ByteaArray"
]
},
"nullable": []
},
"hash": "c9b624a9987dd263e908fcff4612e1cd446552c93d80254d9e15c2e51a95a596"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT password_hash FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "password_hash",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false
]
},
"hash": "dcaedeec794a63ce8abb9b580461c193ad58fee110d57249f98355b40b757a37"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO records (repo_id, collection, rkey, record_cid, repo_rev)\n SELECT $1, collection, rkey, record_cid, $5\n FROM UNNEST($2::text[], $3::text[], $4::text[]) AS t(collection, rkey, record_cid)\n ON CONFLICT (repo_id, collection, rkey) DO UPDATE\n SET record_cid = EXCLUDED.record_cid, repo_rev = EXCLUDED.repo_rev, created_at = NOW()\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"TextArray",
"TextArray",
"TextArray",
"Text"
]
},
"nullable": []
},
"hash": "e1066ab3a86852164e39848733c0f7e837657ea6595ea0094a6135673ea924a5"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT k.key_bytes, k.encryption_version, u.deactivated_at, u.takedown_ref\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.did = $1",
"query": "SELECT k.key_bytes, k.encryption_version, u.deactivated_at, u.takedown_ref\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.did = $1",
"describe": {
"columns": [
{
@@ -36,5 +36,5 @@
true
]
},
"hash": "6b67b2b6759f01be11d5997a3ad68d381f59a02235a6940877f62193af8d9761"
"hash": "f4f4b6a9e5d2345efa8e48380f66c819c1818030aa4bf26757d9fb40e654b693"
}
Generated
+84
View File
@@ -62,6 +62,18 @@ dependencies = [
"subtle",
]
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -941,6 +953,8 @@ dependencies = [
"jacquard-repo",
"jsonwebtoken",
"k256",
"metrics",
"metrics-exporter-prometheus",
"multibase",
"multihash",
"p256 0.13.2",
@@ -1346,6 +1360,15 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-queue"
version = "0.3.12"
@@ -3559,6 +3582,52 @@ version = "2.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
[[package]]
name = "metrics"
version = "0.24.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d5312e9ba3771cfa961b585728215e3d972c950a3eed9252aa093d6301277e8"
dependencies = [
"ahash",
"portable-atomic",
]
[[package]]
name = "metrics-exporter-prometheus"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd7399781913e5393588a8d8c6a2867bf85fb38eaf2502fdce465aad2dc6f034"
dependencies = [
"base64 0.22.1",
"http-body-util",
"hyper 1.8.1",
"hyper-util",
"indexmap 2.12.1",
"ipnet",
"metrics",
"metrics-util",
"quanta",
"thiserror 1.0.69",
"tokio",
"tracing",
]
[[package]]
name = "metrics-util"
version = "0.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8496cc523d1f94c1385dd8f0f0c2c480b2b8aeccb5b7e4485ad6365523ae376"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
"hashbrown 0.15.5",
"metrics",
"quanta",
"rand 0.9.2",
"rand_xoshiro",
"sketches-ddsketch",
]
[[package]]
name = "miette"
version = "7.6.0"
@@ -4482,6 +4551,15 @@ dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_xoshiro"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41"
dependencies = [
"rand_core 0.9.3",
]
[[package]]
name = "range-traits"
version = "0.3.2"
@@ -5236,6 +5314,12 @@ dependencies = [
"walkdir",
]
[[package]]
name = "sketches-ddsketch"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1e9a774a6c28142ac54bb25d25562e6bcf957493a184f15ad4eebccb23e410a"
[[package]]
name = "slab"
version = "0.4.11"
+2
View File
@@ -51,6 +51,8 @@ iroh-car = "0.5.1"
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] }
redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] }
tower-http = { version = "0.6", features = ["fs"] }
metrics = "0.24"
metrics-exporter-prometheus = { version = "0.16", default-features = false, features = ["http-listener"] }
[features]
external-infra = []
+1 -1
View File
@@ -5,7 +5,7 @@ COPY frontend/ ./
RUN deno task build
# Stage 2: Build Rust backend
FROM rust:1.91.1-alpine AS builder
FROM rust:1.92-alpine AS builder
RUN apk add ca-certificates openssl openssl-dev pkgconfig
+14 -101
View File
@@ -1,122 +1,35 @@
# BSPDS, a Personal Data Server
# BSPDS
A production-grade Personal Data Server (PDS) implementation for the AT Protocol.
Uses PostgreSQL instead of SQLite, S3-compatible blob storage, and is designed to be a complete drop-in replacement for Bluesky's reference PDS implementation.
A production-grade Personal Data Server (PDS) for the AT Protocol. Drop-in replacement for Bluesky's reference PDS, using postgres and s3-compatible blob storage.
## Features
- Full AT Protocol support, all `com.atproto.*` endpoints implemented
- OAuth 2.1 Provider. PKCE, DPoP, Pushed Authorization Requests
- PostgreSQL, prod-ready database backend
- S3-compatible object storage for blobs; works with AWS S3, UpCloud object storage, self-hosted MinIO, etc.
- WebSocket `subscribeRepos` endpoint for real-time sync
- Crawler notifications via `requestCrawl`
- Multi-channel notifications: email, discord, telegram, signal
- Per-IP rate limiting on sensitive endpoints
- Full AT Protocol support (`com.atproto.*` endpoints)
- OAuth 2.1 provider (PKCE, DPoP, PAR)
- WebSocket firehose (`subscribeRepos`)
- Multi-channel notifications (email, discord, telegram, signal)
- Built-in web UI for account management
- Per-IP rate limiting
## Running Locally
Requires Rust installed locally.
Run PostgreSQL and S3-compatible object store (e.g., with podman/docker):
```bash
podman compose up db objsto -d
```
Run the PDS:
## Quick Start
```bash
cp .env.example .env
podman compose up -d
just run
```
## Configuration
### Required
| Variable | Description |
|----------|-------------|
| `DATABASE_URL` | PostgreSQL connection string |
| `S3_BUCKET` | Blob storage bucket name |
| `S3_ENDPOINT` | S3 endpoint URL (for MinIO, etc.) |
| `AWS_ACCESS_KEY_ID` | S3 credentials |
| `AWS_SECRET_ACCESS_KEY` | S3 credentials |
| `AWS_REGION` | S3 region |
| `PDS_HOSTNAME` | Public hostname of this PDS |
| `JWT_SECRET` | Secret for OAuth token signing (HS256) |
| `KEY_ENCRYPTION_KEY` | Key for encrypting user signing keys (AES-256-GCM) |
### Optional
| Variable | Description |
|----------|-------------|
| `APPVIEW_URL` | Appview URL to proxy unimplemented endpoints to |
| `CRAWLERS` | Comma-separated list of relay URLs to notify via `requestCrawl` |
### Notifications
At least one channel should be configured for user notifications (password reset, email verification, etc.):
| Variable | Description |
|----------|-------------|
| `MAIL_FROM_ADDRESS` | Email sender address (enables email via sendmail) |
| `MAIL_FROM_NAME` | Email sender name (default: "BSPDS") |
| `SENDMAIL_PATH` | Path to sendmail binary (default: /usr/sbin/sendmail) |
| `DISCORD_WEBHOOK_URL` | Discord webhook URL for notifications |
| `TELEGRAM_BOT_TOKEN` | Telegram bot token for notifications |
| `SIGNAL_CLI_PATH` | Path to signal-cli binary |
| `SIGNAL_SENDER_NUMBER` | Signal sender phone number (+1234567890 format) |
See `.env.example` for all configuration options.
## Development
```bash
just # Show available commands
just test # Run tests (auto-starts postgres/minio, runs nextest)
just lint # Clippy + fmt check
just db-reset # Drop and recreate local database
```
## Web UI
BSPDS includes a built-in web frontend for users to manage their accounts. Users can:
- Sign in and register new accounts
- Manage app passwords
- View and create invite codes
- Update email and handle
- Configure notification preferences
- Browse their repository data
The frontend is built with svelte and deno, and is served directly by the PDS.
Run `just` to see available commands.
```bash
just frontend-dev # Run frontend dev server
just frontend-build # Build for production
just frontend-test # Run frontend tests
```
## Project Structure
```
src/
main.rs Server entrypoint
lib.rs Router setup
state.rs AppState (db pool, stores, rate limiters, circuit breakers)
api/ XRPC handlers organized by namespace
auth/ JWT authentication (ES256K per-user keys)
oauth/ OAuth 2.1 provider (HS256 server-wide)
repo/ PostgreSQL block store
storage/ S3 blob storage
sync/ Firehose, CAR export, crawler notifications
notifications/ Multi-channel notification service
plc/ PLC directory client
circuit_breaker/ Circuit breaker for external services
rate_limit/ Per-IP rate limiting
frontend/ Svelte web UI (deno)
tests/ Integration tests
migrations/ SQLx migrations
just test # run tests
just lint # clippy + fmt
```
## License
+2 -2
View File
@@ -201,7 +201,7 @@ These are implemented at PDS level to enable local-first reads (read-after-write
- [x] DID Cache
- [x] Implement caching layer for DID resolution (valkey).
- [x] Handle cache invalidation/expiry.
- [x] Graceful fallback to no-cache when Valkey unavailable.
- [x] Graceful fallback to no-cache when valkey unavailable.
- [x] Crawlers Service
- [x] Implement `Crawlers` service (debounce notifications to relays).
- [x] 20-minute notification debounce.
@@ -237,7 +237,7 @@ These are implemented at PDS level to enable local-first reads (read-after-write
- [x] Per-IP rate limiting on OAuth revoke/introspect (30/min).
- [x] Per-IP rate limiting on createAppPassword (10/min).
- [x] Per-IP rate limiting on email endpoints (5/hour).
- [x] Distributed rate limiting via Valkey/Redis (with in-memory fallback).
- [x] Distributed rate limiting via valkey (with in-memory fallback).
- [x] Circuit Breakers
- [x] PLC directory circuit breaker (5 failures → open, 60s timeout).
- [x] Relay notification circuit breaker (10 failures → open, 30s timeout).
+14
View File
@@ -47,7 +47,21 @@ services:
volumes:
- valkey_data:/data
prometheus:
image: prom/prometheus:latest
ports:
- "9090:9090"
volumes:
- ./observability/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus_data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
depends_on:
- app
volumes:
postgres_data:
minio_data:
valkey_data:
prometheus_data:
@@ -0,0 +1,21 @@
CREATE INDEX IF NOT EXISTS idx_records_repo_collection
ON records(repo_id, collection);
CREATE INDEX IF NOT EXISTS idx_records_repo_collection_created
ON records(repo_id, collection, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_users_email
ON users(email)
WHERE email IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_blobs_created_by_user
ON blobs(created_by_user, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_repo_seq_did_seq
ON repo_seq(did, seq DESC);
CREATE INDEX IF NOT EXISTS idx_app_passwords_user_id
ON app_passwords(user_id);
CREATE INDEX IF NOT EXISTS idx_invite_codes_created_by
ON invite_codes(created_by_user);
+13
View File
@@ -0,0 +1,13 @@
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: 'prometheus'
static_configs:
- targets: ['localhost:9090']
- job_name: 'bspds'
static_configs:
- targets: ['app:3000']
metrics_path: /metrics
+1
View File
@@ -114,6 +114,7 @@ export VALKEY_URL="redis://127.0.0.1:${VALKEY_PORT}"
export BSPDS_TEST_INFRA_READY="1"
export BSPDS_ALLOW_INSECURE_SECRETS="1"
export SKIP_IMPORT_VERIFICATION="true"
export DISABLE_RATE_LIMITING="1"
EOF
echo ""
+2 -2
View File
@@ -6,7 +6,7 @@ use axum::{
Json,
};
use jacquard_repo::storage::BlockStore;
use reqwest::Client;
use crate::api::proxy_client::proxy_client;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;
@@ -89,7 +89,7 @@ async fn proxy_to_appview(
let target_url = format!("{}/xrpc/{}", appview_url, method);
info!("Proxying GET request to {}", target_url);
let client = Client::new();
let client = proxy_client();
let mut request_builder = client.get(&target_url).query(params);
if let Some(auth) = auth_header {
+2 -2
View File
@@ -1,5 +1,5 @@
use super::did::verify_did_web;
use crate::state::AppState;
use crate::state::{AppState, RateLimitKind};
use axum::{
Json,
extract::State,
@@ -64,7 +64,7 @@ pub async fn create_account(
info!("create_account called");
let client_ip = extract_client_ip(&headers);
if state.rate_limiters.account_creation.check_key(&client_ip).is_err() {
if !state.check_rate_limit(RateLimitKind::AccountCreation, &client_ip).await {
warn!(ip = %client_ip, "Account creation rate limit exceeded");
return (
StatusCode::TOO_MANY_REQUESTS,
+2 -2
View File
@@ -5,7 +5,7 @@ use axum::{
http::{HeaderMap, Method, StatusCode},
response::{IntoResponse, Response},
};
use reqwest::Client;
use crate::api::proxy_client::proxy_client;
use std::collections::HashMap;
use tracing::{error, info};
@@ -36,7 +36,7 @@ pub async fn proxy_handler(
info!("Proxying {} request to {}", method_verb, target_url);
let client = Client::new();
let client = proxy_client();
let mut request_builder = client.request(method_verb, &target_url).query(&params);
+46 -23
View File
@@ -8,7 +8,9 @@ use axum::{
response::{IntoResponse, Response},
Json,
};
use bytes::Bytes;
use chrono::{DateTime, Utc};
use cid::Cid;
use jacquard_repo::storage::BlockStore;
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -137,46 +139,67 @@ pub async fn get_records_since_rev(
return Ok(result);
}
for row in rows {
struct RowData {
cid_str: String,
collection: String,
rkey: String,
created_at: DateTime<Utc>,
}
let mut row_data: Vec<RowData> = Vec::with_capacity(rows.len());
let mut cids: Vec<Cid> = Vec::with_capacity(rows.len());
for row in &rows {
if let Ok(cid) = row.record_cid.parse::<Cid>() {
cids.push(cid);
row_data.push(RowData {
cid_str: row.record_cid.clone(),
collection: row.collection.clone(),
rkey: row.rkey.clone(),
created_at: row.created_at,
});
}
}
let blocks: Vec<Option<Bytes>> = state
.block_store
.get_many(&cids)
.await
.map_err(|e| format!("Error fetching blocks: {}", e))?;
for (data, block_opt) in row_data.into_iter().zip(blocks.into_iter()) {
let block_bytes = match block_opt {
Some(b) => b,
None => continue,
};
result.count += 1;
let uri = format!("at://{}/{}/{}", did, data.collection, data.rkey);
let cid: cid::Cid = match row.record_cid.parse() {
Ok(c) => c,
Err(_) => continue,
};
let block_bytes = match state.block_store.get(&cid).await {
Ok(Some(b)) => b,
_ => continue,
};
let uri = format!("at://{}/{}/{}", did, row.collection, row.rkey);
let indexed_at = row.created_at;
if row.collection == "app.bsky.actor.profile" && row.rkey == "self" {
if data.collection == "app.bsky.actor.profile" && data.rkey == "self" {
if let Ok(record) = serde_ipld_dagcbor::from_slice::<ProfileRecord>(&block_bytes) {
result.profile = Some(RecordDescript {
uri,
cid: row.record_cid,
indexed_at,
cid: data.cid_str,
indexed_at: data.created_at,
record,
});
}
} else if row.collection == "app.bsky.feed.post" {
} else if data.collection == "app.bsky.feed.post" {
if let Ok(record) = serde_ipld_dagcbor::from_slice::<PostRecord>(&block_bytes) {
result.posts.push(RecordDescript {
uri,
cid: row.record_cid,
indexed_at,
cid: data.cid_str,
indexed_at: data.created_at,
record,
});
}
} else if row.collection == "app.bsky.feed.like" {
} else if data.collection == "app.bsky.feed.like" {
if let Ok(record) = serde_ipld_dagcbor::from_slice::<LikeRecord>(&block_bytes) {
result.likes.push(RecordDescript {
uri,
cid: row.record_cid,
indexed_at,
cid: data.cid_str,
indexed_at: data.created_at,
record,
});
}
+45 -13
View File
@@ -83,15 +83,6 @@ pub async fn upload_blob(
let storage_key = format!("blobs/{}", cid_str);
if let Err(e) = state.blob_store.put(&storage_key, &data).await {
error!("Failed to upload blob to storage: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to store blob"})),
)
.into_response();
}
let user_query = sqlx::query!("SELECT id FROM users WHERE did = $1", did)
.fetch_optional(&state.db)
.await;
@@ -107,19 +98,60 @@ pub async fn upload_blob(
}
};
let mut tx = match state.db.begin().await {
Ok(tx) => tx,
Err(e) => {
error!("Failed to begin transaction: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
let insert = sqlx::query!(
"INSERT INTO blobs (cid, mime_type, size_bytes, created_by_user, storage_key) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (cid) DO NOTHING",
"INSERT INTO blobs (cid, mime_type, size_bytes, created_by_user, storage_key) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (cid) DO NOTHING RETURNING cid",
cid_str,
mime_type,
size,
user_id,
storage_key
)
.execute(&state.db)
.fetch_optional(&mut *tx)
.await;
if let Err(e) = insert {
error!("Failed to insert blob record: {:?}", e);
let was_inserted = match insert {
Ok(Some(_)) => true,
Ok(None) => false,
Err(e) => {
error!("Failed to insert blob record: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
if was_inserted {
if let Err(e) = state.blob_store.put_bytes(&storage_key, bytes::Bytes::from(data)).await {
error!("Failed to upload blob to storage: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to store blob"})),
)
.into_response();
}
}
if let Err(e) = tx.commit().await {
error!("Failed to commit blob transaction: {:?}", e);
if was_inserted {
if let Err(cleanup_err) = state.blob_store.delete(&storage_key).await {
error!("Failed to cleanup orphaned blob {}: {:?}", storage_key, cleanup_err);
}
}
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
+28 -7
View File
@@ -9,6 +9,7 @@ use cid::Cid;
use jacquard_repo::storage::BlockStore;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::HashMap;
use std::str::FromStr;
use tracing::error;
@@ -232,14 +233,34 @@ pub async fn list_records(
}
};
let last_rkey = rows.last().map(|(rkey, _)| rkey.clone());
let mut cid_to_rkey: HashMap<Cid, (String, String)> = HashMap::new();
let mut cids: Vec<Cid> = Vec::with_capacity(rows.len());
for (rkey, cid_str) in &rows {
if let Ok(cid) = Cid::from_str(cid_str) {
cid_to_rkey.insert(cid, (rkey.clone(), cid_str.clone()));
cids.push(cid);
}
}
let blocks = match state.block_store.get_many(&cids).await {
Ok(b) => b,
Err(e) => {
error!("Error fetching blocks: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
let mut records = Vec::new();
let mut last_rkey = None;
for (rkey, cid_str) in rows {
last_rkey = Some(rkey.clone());
if let Ok(cid) = Cid::from_str(&cid_str) {
if let Ok(Some(block)) = state.block_store.get(&cid).await {
for (cid, block_opt) in cids.iter().zip(blocks.into_iter()) {
if let Some(block) = block_opt {
if let Some((rkey, cid_str)) = cid_to_rkey.get(cid) {
if let Ok(value) = serde_ipld_dagcbor::from_slice::<serde_json::Value>(&block) {
records.push(json!({
"uri": format!("at://{}/{}/{}", input.repo, input.collection, rkey),
+49 -21
View File
@@ -92,36 +92,64 @@ pub async fn commit_and_log(
.map_err(|e| format!("DB Error (repos): {}", e))?;
let rev_str = rev.to_string();
let mut upsert_collections: Vec<String> = Vec::new();
let mut upsert_rkeys: Vec<String> = Vec::new();
let mut upsert_cids: Vec<String> = Vec::new();
let mut delete_collections: Vec<String> = Vec::new();
let mut delete_rkeys: Vec<String> = Vec::new();
for op in &ops {
match op {
RecordOp::Create { collection, rkey, cid } | RecordOp::Update { collection, rkey, cid } => {
sqlx::query!(
"INSERT INTO records (repo_id, collection, rkey, record_cid, repo_rev) VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (repo_id, collection, rkey) DO UPDATE SET record_cid = $4, repo_rev = $5, created_at = NOW()",
user_id,
collection,
rkey,
cid.to_string(),
rev_str
)
.execute(&mut *tx)
.await
.map_err(|e| format!("DB Error (records): {}", e))?;
upsert_collections.push(collection.clone());
upsert_rkeys.push(rkey.clone());
upsert_cids.push(cid.to_string());
}
RecordOp::Delete { collection, rkey } => {
sqlx::query!(
"DELETE FROM records WHERE repo_id = $1 AND collection = $2 AND rkey = $3",
user_id,
collection,
rkey
)
.execute(&mut *tx)
.await
.map_err(|e| format!("DB Error (records): {}", e))?;
delete_collections.push(collection.clone());
delete_rkeys.push(rkey.clone());
}
}
}
if !upsert_collections.is_empty() {
sqlx::query!(
r#"
INSERT INTO records (repo_id, collection, rkey, record_cid, repo_rev)
SELECT $1, collection, rkey, record_cid, $5
FROM UNNEST($2::text[], $3::text[], $4::text[]) AS t(collection, rkey, record_cid)
ON CONFLICT (repo_id, collection, rkey) DO UPDATE
SET record_cid = EXCLUDED.record_cid, repo_rev = EXCLUDED.repo_rev, created_at = NOW()
"#,
user_id,
&upsert_collections,
&upsert_rkeys,
&upsert_cids,
rev_str
)
.execute(&mut *tx)
.await
.map_err(|e| format!("DB Error (records batch upsert): {}", e))?;
}
if !delete_collections.is_empty() {
sqlx::query!(
r#"
DELETE FROM records
WHERE repo_id = $1
AND (collection, rkey) IN (SELECT * FROM UNNEST($2::text[], $3::text[]))
"#,
user_id,
&delete_collections,
&delete_rkeys
)
.execute(&mut *tx)
.await
.map_err(|e| format!("DB Error (records batch delete): {}", e))?;
}
let ops_json = ops.iter().map(|op| {
match op {
RecordOp::Create { collection, rkey, cid } => json!({
+10 -16
View File
@@ -1,6 +1,6 @@
use crate::api::ApiError;
use crate::auth::BearerAuth;
use crate::state::AppState;
use crate::state::{AppState, RateLimitKind};
use crate::util::get_user_id_by_did;
use axum::{
Json,
@@ -82,21 +82,15 @@ pub async fn create_app_password(
Json(input): Json<CreateAppPasswordInput>,
) -> Response {
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
if !state.distributed_rate_limiter.check_rate_limit(
&format!("app_password:{}", client_ip),
10,
60_000,
).await {
if state.rate_limiters.app_password.check_key(&client_ip).is_err() {
warn!(ip = %client_ip, "App password creation rate limit exceeded");
return (
axum::http::StatusCode::TOO_MANY_REQUESTS,
Json(json!({
"error": "RateLimitExceeded",
"message": "Too many requests. Please try again later."
})),
).into_response();
}
if !state.check_rate_limit(RateLimitKind::AppPassword, &client_ip).await {
warn!(ip = %client_ip, "App password creation rate limit exceeded");
return (
axum::http::StatusCode::TOO_MANY_REQUESTS,
Json(json!({
"error": "RateLimitExceeded",
"message": "Too many requests. Please try again later."
})),
).into_response();
}
let user_id = match get_user_id_by_did(&state.db, &auth_user.did).await {
+19 -31
View File
@@ -1,5 +1,5 @@
use crate::api::ApiError;
use crate::state::AppState;
use crate::state::{AppState, RateLimitKind};
use axum::{
Json,
extract::State,
@@ -27,21 +27,15 @@ pub async fn request_email_update(
Json(input): Json<RequestEmailUpdateInput>,
) -> Response {
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
if !state.distributed_rate_limiter.check_rate_limit(
&format!("email_update:{}", client_ip),
5,
3_600_000,
).await {
if state.rate_limiters.email_update.check_key(&client_ip).is_err() {
warn!(ip = %client_ip, "Email update rate limit exceeded");
return (
StatusCode::TOO_MANY_REQUESTS,
Json(json!({
"error": "RateLimitExceeded",
"message": "Too many requests. Please try again later."
})),
).into_response();
}
if !state.check_rate_limit(RateLimitKind::EmailUpdate, &client_ip).await {
warn!(ip = %client_ip, "Email update rate limit exceeded");
return (
StatusCode::TOO_MANY_REQUESTS,
Json(json!({
"error": "RateLimitExceeded",
"message": "Too many requests. Please try again later."
})),
).into_response();
}
let token = match crate::auth::extract_bearer_token_from_header(
@@ -154,21 +148,15 @@ pub async fn confirm_email(
Json(input): Json<ConfirmEmailInput>,
) -> Response {
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
if !state.distributed_rate_limiter.check_rate_limit(
&format!("confirm_email:{}", client_ip),
10,
60_000,
).await {
if state.rate_limiters.app_password.check_key(&client_ip).is_err() {
warn!(ip = %client_ip, "Confirm email rate limit exceeded");
return (
StatusCode::TOO_MANY_REQUESTS,
Json(json!({
"error": "RateLimitExceeded",
"message": "Too many requests. Please try again later."
})),
).into_response();
}
if !state.check_rate_limit(RateLimitKind::AppPassword, &client_ip).await {
warn!(ip = %client_ip, "Confirm email rate limit exceeded");
return (
StatusCode::TOO_MANY_REQUESTS,
Json(json!({
"error": "RateLimitExceeded",
"message": "Too many requests. Please try again later."
})),
).into_response();
}
let token = match crate::auth::extract_bearer_token_from_header(
+51 -18
View File
@@ -1,4 +1,4 @@
use crate::state::AppState;
use crate::state::{AppState, RateLimitKind};
use axum::{
Json,
extract::State,
@@ -42,7 +42,7 @@ pub async fn request_password_reset(
Json(input): Json<RequestPasswordResetInput>,
) -> Response {
let client_ip = extract_client_ip(&headers);
if state.rate_limiters.password_reset.check_key(&client_ip).is_err() {
if !state.check_rate_limit(RateLimitKind::PasswordReset, &client_ip).await {
warn!(ip = %client_ip, "Password reset rate limit exceeded");
return (
StatusCode::TOO_MANY_REQUESTS,
@@ -128,21 +128,15 @@ pub async fn reset_password(
Json(input): Json<ResetPasswordInput>,
) -> Response {
let client_ip = extract_client_ip(&headers);
if !state.distributed_rate_limiter.check_rate_limit(
&format!("reset_password:{}", client_ip),
10,
60_000,
).await {
if state.rate_limiters.reset_password.check_key(&client_ip).is_err() {
warn!(ip = %client_ip, "Reset password rate limit exceeded");
return (
StatusCode::TOO_MANY_REQUESTS,
Json(json!({
"error": "RateLimitExceeded",
"message": "Too many requests. Please try again later."
})),
).into_response();
}
if !state.check_rate_limit(RateLimitKind::ResetPassword, &client_ip).await {
warn!(ip = %client_ip, "Reset password rate limit exceeded");
return (
StatusCode::TOO_MANY_REQUESTS,
Json(json!({
"error": "RateLimitExceeded",
"message": "Too many requests. Please try again later."
})),
).into_response();
}
let token = input.token.trim();
@@ -259,7 +253,39 @@ pub async fn reset_password(
.into_response();
}
if let Err(e) = sqlx::query!("DELETE FROM session_tokens WHERE did = (SELECT did FROM users WHERE id = $1)", user_id)
let user_did = match sqlx::query_scalar!(
"SELECT did FROM users WHERE id = $1",
user_id
)
.fetch_one(&mut *tx)
.await
{
Ok(did) => did,
Err(e) => {
error!("Failed to get DID for user {}: {:?}", user_id, e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
let session_jtis: Vec<String> = match sqlx::query_scalar!(
"SELECT access_jti FROM session_tokens WHERE did = $1",
user_did
)
.fetch_all(&mut *tx)
.await
{
Ok(jtis) => jtis,
Err(e) => {
error!("Failed to fetch session JTIs: {:?}", e);
vec![]
}
};
if let Err(e) = sqlx::query!("DELETE FROM session_tokens WHERE did = $1", user_did)
.execute(&mut *tx)
.await
{
@@ -280,6 +306,13 @@ pub async fn reset_password(
.into_response();
}
for jti in session_jtis {
let cache_key = format!("auth:session:{}:{}", user_did, jti);
if let Err(e) = state.cache.delete(&cache_key).await {
warn!("Failed to invalidate session cache for {}: {:?}", cache_key, e);
}
}
info!("Password reset completed for user {}", user_id);
(StatusCode::OK, Json(json!({}))).into_response()
+33 -25
View File
@@ -1,6 +1,6 @@
use crate::api::ApiError;
use crate::auth::BearerAuth;
use crate::state::AppState;
use crate::state::{AppState, RateLimitKind};
use axum::{
Json,
extract::State,
@@ -52,7 +52,7 @@ pub async fn create_session(
info!("create_session called");
let client_ip = extract_client_ip(&headers);
if state.rate_limiters.login.check_key(&client_ip).is_err() {
if !state.check_rate_limit(RateLimitKind::Login, &client_ip).await {
warn!(ip = %client_ip, "Login rate limit exceeded");
return (
StatusCode::TOO_MANY_REQUESTS,
@@ -97,13 +97,19 @@ pub async fn create_session(
}
};
let password_valid = verify(&input.password, &row.password_hash).unwrap_or(false)
|| sqlx::query!("SELECT password_hash FROM app_passwords WHERE user_id = $1", row.id)
.fetch_all(&state.db)
.await
.unwrap_or_default()
.iter()
.any(|app| verify(&input.password, &app.password_hash).unwrap_or(false));
let password_valid = if verify(&input.password, &row.password_hash).unwrap_or(false) {
true
} else {
let app_passwords = sqlx::query!(
"SELECT password_hash FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20",
row.id
)
.fetch_all(&state.db)
.await
.unwrap_or_default();
app_passwords.iter().any(|app| verify(&input.password, &app.password_hash).unwrap_or(false))
};
if !password_valid {
warn!("Password verification failed for login attempt");
@@ -204,11 +210,19 @@ pub async fn delete_session(
Err(_) => return ApiError::AuthenticationFailed.into_response(),
};
let did = crate::auth::get_did_from_token(&token).ok();
match sqlx::query!("DELETE FROM session_tokens WHERE access_jti = $1", jti)
.execute(&state.db)
.await
{
Ok(res) if res.rows_affected() > 0 => Json(json!({})).into_response(),
Ok(res) if res.rows_affected() > 0 => {
if let Some(did) = did {
let session_cache_key = format!("auth:session:{}:{}", did, jti);
let _ = state.cache.delete(&session_cache_key).await;
}
Json(json!({})).into_response()
}
Ok(_) => ApiError::AuthenticationFailed.into_response(),
Err(e) => {
error!("Database error in delete_session: {:?}", e);
@@ -222,21 +236,15 @@ pub async fn refresh_session(
headers: axum::http::HeaderMap,
) -> Response {
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
if !state.distributed_rate_limiter.check_rate_limit(
&format!("refresh_session:{}", client_ip),
60,
60_000,
).await {
if state.rate_limiters.refresh_session.check_key(&client_ip).is_err() {
tracing::warn!(ip = %client_ip, "Refresh session rate limit exceeded");
return (
axum::http::StatusCode::TOO_MANY_REQUESTS,
axum::Json(serde_json::json!({
"error": "RateLimitExceeded",
"message": "Too many requests. Please try again later."
})),
).into_response();
}
if !state.check_rate_limit(RateLimitKind::RefreshSession, &client_ip).await {
tracing::warn!(ip = %client_ip, "Refresh session rate limit exceeded");
return (
axum::http::StatusCode::TOO_MANY_REQUESTS,
axum::Json(serde_json::json!({
"error": "RateLimitExceeded",
"message": "Too many requests. Please try again later."
})),
).into_response();
}
let refresh_token = match crate::auth::extract_bearer_token_from_header(
+3 -3
View File
@@ -7,7 +7,7 @@ use axum::{
use serde_json::json;
use crate::state::AppState;
use super::{AuthenticatedUser, TokenValidationError, validate_bearer_token, validate_bearer_token_allow_deactivated};
use super::{AuthenticatedUser, TokenValidationError, validate_bearer_token_cached, validate_bearer_token_cached_allow_deactivated};
pub struct BearerAuth(pub AuthenticatedUser);
@@ -110,7 +110,7 @@ impl FromRequestParts<AppState> for BearerAuth {
let token = extract_bearer_token(auth_header)?;
match validate_bearer_token(&state.db, token).await {
match validate_bearer_token_cached(&state.db, &state.cache, token).await {
Ok(user) => Ok(BearerAuth(user)),
Err(TokenValidationError::AccountDeactivated) => Err(AuthError::AccountDeactivated),
Err(TokenValidationError::AccountTakedown) => Err(AuthError::AccountTakedown),
@@ -137,7 +137,7 @@ impl FromRequestParts<AppState> for BearerAuthAllowDeactivated {
let token = extract_bearer_token(auth_header)?;
match validate_bearer_token_allow_deactivated(&state.db, token).await {
match validate_bearer_token_cached_allow_deactivated(&state.db, &state.cache, token).await {
Ok(user) => Ok(BearerAuthAllowDeactivated(user)),
Err(TokenValidationError::AccountTakedown) => Err(AuthError::AccountTakedown),
Err(_) => Err(AuthError::AuthenticationFailed),
+119 -30
View File
@@ -1,6 +1,9 @@
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::fmt;
use std::sync::Arc;
use std::time::Duration;
use crate::cache::Cache;
pub mod extractor;
pub mod token;
@@ -16,6 +19,9 @@ pub use token::{
};
pub use verify::{get_did_from_token, get_jti_from_token, verify_token, verify_access_token, verify_refresh_token};
const KEY_CACHE_TTL_SECS: u64 = 300;
const SESSION_CACHE_TTL_SECS: u64 = 60;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TokenValidationError {
AccountDeactivated,
@@ -45,58 +51,136 @@ pub async fn validate_bearer_token(
db: &PgPool,
token: &str,
) -> Result<AuthenticatedUser, TokenValidationError> {
validate_bearer_token_with_options(db, token, false).await
validate_bearer_token_with_options_internal(db, None, token, false).await
}
pub async fn validate_bearer_token_allow_deactivated(
db: &PgPool,
token: &str,
) -> Result<AuthenticatedUser, TokenValidationError> {
validate_bearer_token_with_options(db, token, true).await
validate_bearer_token_with_options_internal(db, None, token, true).await
}
async fn validate_bearer_token_with_options(
pub async fn validate_bearer_token_cached(
db: &PgPool,
cache: &Arc<dyn Cache>,
token: &str,
) -> Result<AuthenticatedUser, TokenValidationError> {
validate_bearer_token_with_options_internal(db, Some(cache), token, false).await
}
pub async fn validate_bearer_token_cached_allow_deactivated(
db: &PgPool,
cache: &Arc<dyn Cache>,
token: &str,
) -> Result<AuthenticatedUser, TokenValidationError> {
validate_bearer_token_with_options_internal(db, Some(cache), token, true).await
}
async fn validate_bearer_token_with_options_internal(
db: &PgPool,
cache: Option<&Arc<dyn Cache>>,
token: &str,
allow_deactivated: bool,
) -> Result<AuthenticatedUser, TokenValidationError> {
let did_from_token = get_did_from_token(token).ok();
if let Some(ref did) = did_from_token {
if let Some(user) = sqlx::query!(
"SELECT k.key_bytes, k.encryption_version, u.deactivated_at, u.takedown_ref
FROM users u
JOIN user_keys k ON u.id = k.user_id
WHERE u.did = $1",
did
)
.fetch_optional(db)
.await
.ok()
.flatten()
{
if !allow_deactivated && user.deactivated_at.is_some() {
let key_cache_key = format!("auth:key:{}", did);
let mut cached_key: Option<Vec<u8>> = None;
if let Some(c) = cache {
cached_key = c.get_bytes(&key_cache_key).await;
if cached_key.is_some() {
crate::metrics::record_auth_cache_hit("key");
} else {
crate::metrics::record_auth_cache_miss("key");
}
}
let (decrypted_key, deactivated_at, takedown_ref) = if let Some(key) = cached_key {
let user_status = sqlx::query!(
"SELECT deactivated_at, takedown_ref FROM users WHERE did = $1",
did
)
.fetch_optional(db)
.await
.ok()
.flatten();
match user_status {
Some(status) => (Some(key), status.deactivated_at, status.takedown_ref),
None => (None, None, None),
}
} else {
if let Some(user) = sqlx::query!(
"SELECT k.key_bytes, k.encryption_version, u.deactivated_at, u.takedown_ref
FROM users u
JOIN user_keys k ON u.id = k.user_id
WHERE u.did = $1",
did
)
.fetch_optional(db)
.await
.ok()
.flatten()
{
let key = crate::config::decrypt_key(&user.key_bytes, user.encryption_version)
.map_err(|_| TokenValidationError::KeyDecryptionFailed)?;
if let Some(c) = cache {
let _ = c.set_bytes(&key_cache_key, &key, Duration::from_secs(KEY_CACHE_TTL_SECS)).await;
}
(Some(key), user.deactivated_at, user.takedown_ref)
} else {
(None, None, None)
}
};
if let Some(decrypted_key) = decrypted_key {
if !allow_deactivated && deactivated_at.is_some() {
return Err(TokenValidationError::AccountDeactivated);
}
if user.takedown_ref.is_some() {
if takedown_ref.is_some() {
return Err(TokenValidationError::AccountTakedown);
}
let decrypted_key = crate::config::decrypt_key(&user.key_bytes, user.encryption_version)
.map_err(|_| TokenValidationError::KeyDecryptionFailed)?;
if let Ok(token_data) = verify_access_token(token, &decrypted_key) {
let session_exists = sqlx::query_scalar!(
"SELECT 1 as one FROM session_tokens WHERE did = $1 AND access_jti = $2 AND access_expires_at > NOW()",
did,
token_data.claims.jti
)
.fetch_optional(db)
.await
.ok()
.flatten();
let jti = &token_data.claims.jti;
let session_cache_key = format!("auth:session:{}:{}", did, jti);
let mut session_valid = false;
if session_exists.is_some() {
if let Some(c) = cache {
if let Some(cached_value) = c.get(&session_cache_key).await {
session_valid = cached_value == "1";
crate::metrics::record_auth_cache_hit("session");
} else {
crate::metrics::record_auth_cache_miss("session");
}
}
if !session_valid {
let session_exists = sqlx::query_scalar!(
"SELECT 1 as one FROM session_tokens WHERE did = $1 AND access_jti = $2 AND access_expires_at > NOW()",
did,
jti
)
.fetch_optional(db)
.await
.ok()
.flatten();
session_valid = session_exists.is_some();
if session_valid {
if let Some(c) = cache {
let _ = c.set(&session_cache_key, "1", Duration::from_secs(SESSION_CACHE_TTL_SECS)).await;
}
}
}
if session_valid {
return Ok(AuthenticatedUser {
did: did.clone(),
key_bytes: Some(decrypted_key),
@@ -141,6 +225,11 @@ async fn validate_bearer_token_with_options(
Err(TokenValidationError::AuthenticationFailed)
}
pub async fn invalidate_auth_cache(cache: &Arc<dyn Cache>, did: &str) {
let key_cache_key = format!("auth:key:{}", did);
let _ = cache.delete(&key_cache_key).await;
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub iss: String,
+10
View File
@@ -1,4 +1,5 @@
use async_trait::async_trait;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use std::sync::Arc;
use std::time::Duration;
@@ -15,6 +16,15 @@ pub trait Cache: Send + Sync {
async fn get(&self, key: &str) -> Option<String>;
async fn set(&self, key: &str, value: &str, ttl: Duration) -> Result<(), CacheError>;
async fn delete(&self, key: &str) -> Result<(), CacheError>;
async fn get_bytes(&self, key: &str) -> Option<Vec<u8>> {
self.get(key).await.and_then(|s| BASE64.decode(&s).ok())
}
async fn set_bytes(&self, key: &str, value: &[u8], ttl: Duration) -> Result<(), CacheError> {
let encoded = BASE64.encode(value);
self.set(key, &encoded, ttl).await
}
}
#[derive(Clone)]
+4
View File
@@ -5,6 +5,7 @@ pub mod circuit_breaker;
pub mod config;
pub mod crawlers;
pub mod image;
pub mod metrics;
pub mod notifications;
pub mod oauth;
pub mod plc;
@@ -18,6 +19,7 @@ pub mod validation;
use axum::{
Router,
middleware,
routing::{any, get, post},
};
use state::AppState;
@@ -25,6 +27,7 @@ use tower_http::services::{ServeDir, ServeFile};
pub fn app(state: AppState) -> Router {
let router = Router::new()
.route("/metrics", get(metrics::metrics_handler))
.route("/health", get(api::server::health))
.route("/xrpc/_health", get(api::server::health))
.route("/robots.txt", get(api::server::robots_txt))
@@ -382,6 +385,7 @@ pub fn app(state: AppState) -> Router {
post(api::notification_prefs::update_notification_prefs),
)
.route("/xrpc/{*method}", any(api::proxy::proxy_handler))
.layer(middleware::from_fn(metrics::metrics_middleware))
.with_state(state);
let frontend_dir = std::env::var("FRONTEND_DIR")
+23 -3
View File
@@ -12,6 +12,8 @@ async fn main() -> ExitCode {
dotenvy::dotenv().ok();
tracing_subscriber::fmt::init();
bspds::metrics::init_metrics();
match run().await {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
@@ -25,10 +27,28 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
let database_url = std::env::var("DATABASE_URL")
.map_err(|_| "DATABASE_URL environment variable must be set")?;
let max_connections: u32 = std::env::var("DATABASE_MAX_CONNECTIONS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(100);
let min_connections: u32 = std::env::var("DATABASE_MIN_CONNECTIONS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(10);
let acquire_timeout_secs: u64 = std::env::var("DATABASE_ACQUIRE_TIMEOUT_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(10);
info!(
"Configuring database pool: max={}, min={}, acquire_timeout={}s",
max_connections, min_connections, acquire_timeout_secs
);
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(20)
.min_connections(2)
.acquire_timeout(std::time::Duration::from_secs(10))
.max_connections(max_connections)
.min_connections(min_connections)
.acquire_timeout(std::time::Duration::from_secs(acquire_timeout_secs))
.idle_timeout(std::time::Duration::from_secs(300))
.max_lifetime(std::time::Duration::from_secs(1800))
.connect(&database_url)
+212
View File
@@ -0,0 +1,212 @@
use axum::{
body::Body,
http::{Request, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
};
use metrics::{counter, gauge, histogram};
use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle};
use std::sync::OnceLock;
use std::time::Instant;
static PROMETHEUS_HANDLE: OnceLock<PrometheusHandle> = OnceLock::new();
pub fn init_metrics() -> PrometheusHandle {
let builder = PrometheusBuilder::new();
let handle = builder
.install_recorder()
.expect("failed to install Prometheus recorder");
PROMETHEUS_HANDLE.set(handle.clone()).ok();
describe_metrics();
handle
}
fn describe_metrics() {
metrics::describe_counter!(
"bspds_http_requests_total",
"Total number of HTTP requests"
);
metrics::describe_histogram!(
"bspds_http_request_duration_seconds",
"HTTP request duration in seconds"
);
metrics::describe_counter!(
"bspds_auth_cache_hits_total",
"Total number of authentication cache hits"
);
metrics::describe_counter!(
"bspds_auth_cache_misses_total",
"Total number of authentication cache misses"
);
metrics::describe_gauge!(
"bspds_firehose_subscribers",
"Number of active firehose WebSocket subscribers"
);
metrics::describe_counter!(
"bspds_firehose_events_total",
"Total number of firehose events published"
);
metrics::describe_counter!(
"bspds_block_operations_total",
"Total number of block store operations"
);
metrics::describe_counter!(
"bspds_s3_operations_total",
"Total number of S3/blob storage operations"
);
metrics::describe_gauge!(
"bspds_notification_queue_size",
"Current size of the notification queue"
);
metrics::describe_counter!(
"bspds_rate_limit_rejections_total",
"Total number of rate limit rejections"
);
metrics::describe_counter!(
"bspds_db_queries_total",
"Total number of database queries"
);
metrics::describe_histogram!(
"bspds_db_query_duration_seconds",
"Database query duration in seconds"
);
}
pub async fn metrics_handler() -> impl IntoResponse {
match PROMETHEUS_HANDLE.get() {
Some(handle) => {
let metrics = handle.render();
(StatusCode::OK, [("content-type", "text/plain; version=0.0.4")], metrics)
}
None => (
StatusCode::INTERNAL_SERVER_ERROR,
[("content-type", "text/plain")],
"Metrics not initialized".to_string(),
),
}
}
pub async fn metrics_middleware(request: Request<Body>, next: Next) -> Response {
let start = Instant::now();
let method = request.method().to_string();
let path = normalize_path(request.uri().path());
let response = next.run(request).await;
let duration = start.elapsed().as_secs_f64();
let status = response.status().as_u16().to_string();
counter!(
"bspds_http_requests_total",
"method" => method.clone(),
"path" => path.clone(),
"status" => status.clone()
)
.increment(1);
histogram!(
"bspds_http_request_duration_seconds",
"method" => method,
"path" => path
)
.record(duration);
response
}
fn normalize_path(path: &str) -> String {
if path.starts_with("/xrpc/") {
if let Some(method) = path.strip_prefix("/xrpc/") {
if let Some(q) = method.find('?') {
return format!("/xrpc/{}", &method[..q]);
}
return path.to_string();
}
}
if path.starts_with("/u/") && path.ends_with("/did.json") {
return "/u/{handle}/did.json".to_string();
}
if path.starts_with("/oauth/") {
return path.to_string();
}
path.to_string()
}
pub fn record_auth_cache_hit(cache_type: &str) {
counter!("bspds_auth_cache_hits_total", "cache_type" => cache_type.to_string()).increment(1);
}
pub fn record_auth_cache_miss(cache_type: &str) {
counter!("bspds_auth_cache_misses_total", "cache_type" => cache_type.to_string()).increment(1);
}
pub fn set_firehose_subscribers(count: usize) {
gauge!("bspds_firehose_subscribers").set(count as f64);
}
pub fn increment_firehose_subscribers() {
counter!("bspds_firehose_events_total").increment(1);
}
pub fn record_firehose_event() {
counter!("bspds_firehose_events_total").increment(1);
}
pub fn record_block_operation(op_type: &str) {
counter!("bspds_block_operations_total", "op_type" => op_type.to_string()).increment(1);
}
pub fn record_s3_operation(op_type: &str, status: &str) {
counter!(
"bspds_s3_operations_total",
"op_type" => op_type.to_string(),
"status" => status.to_string()
)
.increment(1);
}
pub fn set_notification_queue_size(size: usize) {
gauge!("bspds_notification_queue_size").set(size as f64);
}
pub fn record_rate_limit_rejection(limiter: &str) {
counter!("bspds_rate_limit_rejections_total", "limiter" => limiter.to_string()).increment(1);
}
pub fn record_db_query(query_type: &str, duration_seconds: f64) {
counter!("bspds_db_queries_total", "query_type" => query_type.to_string()).increment(1);
histogram!(
"bspds_db_query_duration_seconds",
"query_type" => query_type.to_string()
)
.record(duration_seconds);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normalize_path() {
assert_eq!(
normalize_path("/xrpc/com.atproto.repo.getRecord"),
"/xrpc/com.atproto.repo.getRecord"
);
assert_eq!(
normalize_path("/xrpc/com.atproto.repo.getRecord?foo=bar"),
"/xrpc/com.atproto.repo.getRecord"
);
assert_eq!(
normalize_path("/u/alice.example.com/did.json"),
"/u/{handle}/did.json"
);
assert_eq!(normalize_path("/oauth/token"), "/oauth/token");
assert_eq!(normalize_path("/health"), "/health");
}
}
+12 -2
View File
@@ -21,11 +21,21 @@ pub struct NotificationService {
impl NotificationService {
pub fn new(db: PgPool) -> Self {
let poll_interval_ms: u64 = std::env::var("NOTIFICATION_POLL_INTERVAL_MS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1000);
let batch_size: i64 = std::env::var("NOTIFICATION_BATCH_SIZE")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(100);
Self {
db,
senders: HashMap::new(),
poll_interval: Duration::from_secs(5),
batch_size: 10,
poll_interval: Duration::from_millis(poll_interval_ms),
batch_size,
}
}
+3 -3
View File
@@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
use subtle::ConstantTimeEq;
use urlencoding::encode as url_encode;
use crate::state::AppState;
use crate::state::{AppState, RateLimitKind};
use crate::oauth::{Code, DeviceAccount, DeviceData, DeviceId, OAuthError, SessionId, db, templates};
use crate::notifications::{NotificationChannel, channel_display_name, enqueue_2fa_code};
@@ -273,7 +273,7 @@ pub async fn authorize_post(
let json_response = wants_json(&headers);
let client_ip = extract_client_ip(&headers);
if state.rate_limiters.oauth_authorize.check_key(&client_ip).is_err() {
if !state.check_rate_limit(RateLimitKind::OAuthAuthorize, &client_ip).await {
tracing::warn!(ip = %client_ip, "OAuth authorize rate limit exceeded");
if json_response {
return (
@@ -761,7 +761,7 @@ pub async fn authorize_2fa_post(
Form(form): Form<Authorize2faSubmit>,
) -> Response {
let client_ip = extract_client_ip(&headers);
if state.rate_limiters.oauth_authorize.check_key(&client_ip).is_err() {
if !state.check_rate_limit(RateLimitKind::OAuthAuthorize, &client_ip).await {
tracing::warn!(ip = %client_ip, "OAuth 2FA rate limit exceeded");
return (
axum::http::StatusCode::TOO_MANY_REQUESTS,
+4 -10
View File
@@ -6,7 +6,7 @@ use axum::{
use chrono::{Duration, Utc};
use serde::{Deserialize, Serialize};
use crate::state::AppState;
use crate::state::{AppState, RateLimitKind};
use crate::oauth::{
AuthorizationRequestParameters, ClientAuth, OAuthError, RequestData, RequestId,
client::ClientMetadataCache,
@@ -54,15 +54,9 @@ pub async fn pushed_authorization_request(
Form(request): Form<ParRequest>,
) -> Result<Json<ParResponse>, OAuthError> {
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
if !state.distributed_rate_limiter.check_rate_limit(
&format!("oauth_par:{}", client_ip),
30,
60_000,
).await {
if state.rate_limiters.oauth_par.check_key(&client_ip).is_err() {
tracing::warn!(ip = %client_ip, "OAuth PAR rate limit exceeded");
return Err(OAuthError::RateLimited);
}
if !state.check_rate_limit(RateLimitKind::OAuthPar, &client_ip).await {
tracing::warn!(ip = %client_ip, "OAuth PAR rate limit exceeded");
return Err(OAuthError::RateLimited);
}
if request.response_type != "code" {
+7 -19
View File
@@ -4,7 +4,7 @@ use axum::http::{HeaderMap, StatusCode};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use crate::state::AppState;
use crate::state::{AppState, RateLimitKind};
use crate::oauth::{OAuthError, db};
use super::helpers::extract_token_claims;
@@ -22,15 +22,9 @@ pub async fn revoke_token(
Form(request): Form<RevokeRequest>,
) -> Result<StatusCode, OAuthError> {
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
if !state.distributed_rate_limiter.check_rate_limit(
&format!("oauth_revoke:{}", client_ip),
30,
60_000,
).await {
if state.rate_limiters.oauth_introspect.check_key(&client_ip).is_err() {
tracing::warn!(ip = %client_ip, "OAuth revoke rate limit exceeded");
return Err(OAuthError::RateLimited);
}
if !state.check_rate_limit(RateLimitKind::OAuthIntrospect, &client_ip).await {
tracing::warn!(ip = %client_ip, "OAuth revoke rate limit exceeded");
return Err(OAuthError::RateLimited);
}
if let Some(token) = &request.token {
@@ -84,15 +78,9 @@ pub async fn introspect_token(
Form(request): Form<IntrospectRequest>,
) -> Result<Json<IntrospectResponse>, OAuthError> {
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
if !state.distributed_rate_limiter.check_rate_limit(
&format!("oauth_introspect:{}", client_ip),
30,
60_000,
).await {
if state.rate_limiters.oauth_introspect.check_key(&client_ip).is_err() {
tracing::warn!(ip = %client_ip, "OAuth introspect rate limit exceeded");
return Err(OAuthError::RateLimited);
}
if !state.check_rate_limit(RateLimitKind::OAuthIntrospect, &client_ip).await {
tracing::warn!(ip = %client_ip, "OAuth introspect rate limit exceeded");
return Err(OAuthError::RateLimited);
}
let inactive_response = IntrospectResponse {
+2 -2
View File
@@ -9,7 +9,7 @@ use axum::{
http::HeaderMap,
};
use crate::state::AppState;
use crate::state::{AppState, RateLimitKind};
use crate::oauth::OAuthError;
pub use grants::{handle_authorization_code_grant, handle_refresh_token_grant};
@@ -41,7 +41,7 @@ pub async fn token_endpoint(
Form(request): Form<TokenRequest>,
) -> Result<(HeaderMap, Json<TokenResponse>), OAuthError> {
let client_ip = extract_client_ip(&headers);
if state.rate_limiters.oauth_token.check_key(&client_ip).is_err() {
if !state.check_rate_limit(RateLimitKind::OAuthToken, &client_ip).await {
tracing::warn!(ip = %client_ip, "OAuth token rate limit exceeded");
return Err(OAuthError::InvalidRequest(
"Too many requests. Please try again later.".to_string(),
+24 -1
View File
@@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::time::Duration;
use thiserror::Error;
#[derive(Error, Debug)]
@@ -21,6 +22,10 @@ pub enum PlcError {
Serialization(String),
#[error("Signing error: {0}")]
Signing(String),
#[error("Request timeout")]
Timeout,
#[error("Service unavailable (circuit breaker open)")]
CircuitBreakerOpen,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -82,9 +87,27 @@ impl PlcClient {
std::env::var("PLC_DIRECTORY_URL")
.unwrap_or_else(|_| "https://plc.directory".to_string())
});
let timeout_secs: u64 = std::env::var("PLC_TIMEOUT_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(10);
let connect_timeout_secs: u64 = std::env::var("PLC_CONNECT_TIMEOUT_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(5);
let client = Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.connect_timeout(Duration::from_secs(connect_timeout_secs))
.pool_max_idle_per_host(5)
.build()
.unwrap_or_else(|_| Client::new());
Self {
base_url,
client: Client::new(),
client,
}
}
+13
View File
@@ -20,6 +20,12 @@ use std::{
pub type KeyedRateLimiter = RateLimiter<String, DefaultKeyedStateStore<String>, DefaultClock>;
pub type GlobalRateLimiter = RateLimiter<NotKeyed, InMemoryState, DefaultClock>;
// NOTE: For production deployments with high traffic, prefer using the distributed rate
// limiter (Redis/Valkey-based) via AppState::distributed_rate_limiter. The in-memory
// rate limiters here don't automatically clean up expired entries, which can cause
// memory growth over time with many unique client IPs. The distributed rate limiter
// uses Redis TTL for automatic cleanup and works correctly across multiple instances.
#[derive(Clone)]
pub struct RateLimiters {
pub login: Arc<KeyedRateLimiter>,
@@ -114,6 +120,13 @@ impl RateLimiters {
));
self
}
pub fn with_email_update_limit(mut self, per_hour: u32) -> Self {
self.email_update = Arc::new(RateLimiter::keyed(
Quota::per_hour(NonZeroU32::new(per_hour).unwrap_or(NonZeroU32::new(5).unwrap()))
));
self
}
}
pub fn extract_client_ip(headers: &HeaderMap, addr: Option<SocketAddr>) -> String {
+47 -14
View File
@@ -22,6 +22,7 @@ impl PostgresBlockStore {
impl BlockStore for PostgresBlockStore {
async fn get(&self, cid: &Cid) -> Result<Option<Bytes>, RepoError> {
crate::metrics::record_block_operation("get");
let cid_bytes = cid.to_bytes();
let row = sqlx::query!("SELECT data FROM blocks WHERE cid = $1", &cid_bytes)
.fetch_optional(&self.pool)
@@ -35,6 +36,7 @@ impl BlockStore for PostgresBlockStore {
}
async fn put(&self, data: &[u8]) -> Result<Cid, RepoError> {
crate::metrics::record_block_operation("put");
let mut hasher = Sha256::new();
hasher.update(data);
let hash = hasher.finalize();
@@ -52,6 +54,7 @@ impl BlockStore for PostgresBlockStore {
}
async fn has(&self, cid: &Cid) -> Result<bool, RepoError> {
crate::metrics::record_block_operation("has");
let cid_bytes = cid.to_bytes();
let row = sqlx::query!("SELECT 1 as one FROM blocks WHERE cid = $1", &cid_bytes)
.fetch_optional(&self.pool)
@@ -66,26 +69,56 @@ impl BlockStore for PostgresBlockStore {
blocks: impl IntoIterator<Item = (Cid, Bytes)> + Send,
) -> Result<(), RepoError> {
let blocks: Vec<_> = blocks.into_iter().collect();
for (cid, data) in blocks {
let cid_bytes = cid.to_bytes();
let data_ref = data.as_ref();
sqlx::query!(
"INSERT INTO blocks (cid, data) VALUES ($1, $2) ON CONFLICT (cid) DO NOTHING",
&cid_bytes,
data_ref
)
.execute(&self.pool)
.await
.map_err(|e| RepoError::storage(e))?;
if blocks.is_empty() {
return Ok(());
}
crate::metrics::record_block_operation("put_many");
let cids: Vec<Vec<u8>> = blocks.iter().map(|(cid, _)| cid.to_bytes()).collect();
let data: Vec<&[u8]> = blocks.iter().map(|(_, d)| d.as_ref()).collect();
sqlx::query!(
r#"
INSERT INTO blocks (cid, data)
SELECT * FROM UNNEST($1::bytea[], $2::bytea[])
ON CONFLICT (cid) DO NOTHING
"#,
&cids,
&data as &[&[u8]]
)
.execute(&self.pool)
.await
.map_err(|e| RepoError::storage(e))?;
Ok(())
}
async fn get_many(&self, cids: &[Cid]) -> Result<Vec<Option<Bytes>>, RepoError> {
let mut results = Vec::new();
for cid in cids {
results.push(self.get(cid).await?);
if cids.is_empty() {
return Ok(Vec::new());
}
crate::metrics::record_block_operation("get_many");
let cid_bytes: Vec<Vec<u8>> = cids.iter().map(|c| c.to_bytes()).collect();
let rows = sqlx::query!(
"SELECT cid, data FROM blocks WHERE cid = ANY($1)",
&cid_bytes
)
.fetch_all(&self.pool)
.await
.map_err(|e| RepoError::storage(e))?;
let found: std::collections::HashMap<Vec<u8>, Bytes> = rows
.into_iter()
.map(|row| (row.cid, Bytes::from(row.data)))
.collect();
let results = cid_bytes
.iter()
.map(|cid| found.get(cid).cloned())
.collect();
Ok(results)
}
+88 -1
View File
@@ -21,13 +21,65 @@ pub struct AppState {
pub distributed_rate_limiter: Arc<dyn DistributedRateLimiter>,
}
pub enum RateLimitKind {
Login,
AccountCreation,
PasswordReset,
ResetPassword,
RefreshSession,
OAuthToken,
OAuthAuthorize,
OAuthPar,
OAuthIntrospect,
AppPassword,
EmailUpdate,
}
impl RateLimitKind {
fn key_prefix(&self) -> &'static str {
match self {
Self::Login => "login",
Self::AccountCreation => "account_creation",
Self::PasswordReset => "password_reset",
Self::ResetPassword => "reset_password",
Self::RefreshSession => "refresh_session",
Self::OAuthToken => "oauth_token",
Self::OAuthAuthorize => "oauth_authorize",
Self::OAuthPar => "oauth_par",
Self::OAuthIntrospect => "oauth_introspect",
Self::AppPassword => "app_password",
Self::EmailUpdate => "email_update",
}
}
fn limit_and_window_ms(&self) -> (u32, u64) {
match self {
Self::Login => (10, 60_000),
Self::AccountCreation => (10, 3_600_000),
Self::PasswordReset => (5, 3_600_000),
Self::ResetPassword => (10, 60_000),
Self::RefreshSession => (60, 60_000),
Self::OAuthToken => (30, 60_000),
Self::OAuthAuthorize => (10, 60_000),
Self::OAuthPar => (30, 60_000),
Self::OAuthIntrospect => (30, 60_000),
Self::AppPassword => (10, 60_000),
Self::EmailUpdate => (5, 3_600_000),
}
}
}
impl AppState {
pub async fn new(db: PgPool) -> Self {
AuthConfig::init();
let block_store = PostgresBlockStore::new(db.clone());
let blob_store = S3BlobStorage::new().await;
let (firehose_tx, _) = broadcast::channel(1000);
let firehose_buffer_size: usize = std::env::var("FIREHOSE_BUFFER_SIZE")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(10000);
let (firehose_tx, _) = broadcast::channel(firehose_buffer_size);
let rate_limiters = Arc::new(RateLimiters::new());
let circuit_breakers = Arc::new(CircuitBreakers::new());
let (cache, distributed_rate_limiter) = create_cache().await;
@@ -52,4 +104,39 @@ impl AppState {
self.circuit_breakers = Arc::new(circuit_breakers);
self
}
pub async fn check_rate_limit(&self, kind: RateLimitKind, client_ip: &str) -> bool {
if std::env::var("DISABLE_RATE_LIMITING").is_ok() {
return true;
}
let key = format!("{}:{}", kind.key_prefix(), client_ip);
let limiter_name = kind.key_prefix();
let (limit, window_ms) = kind.limit_and_window_ms();
if !self.distributed_rate_limiter.check_rate_limit(&key, limit, window_ms).await {
crate::metrics::record_rate_limit_rejection(limiter_name);
return false;
}
let limiter = match kind {
RateLimitKind::Login => &self.rate_limiters.login,
RateLimitKind::AccountCreation => &self.rate_limiters.account_creation,
RateLimitKind::PasswordReset => &self.rate_limiters.password_reset,
RateLimitKind::ResetPassword => &self.rate_limiters.reset_password,
RateLimitKind::RefreshSession => &self.rate_limiters.refresh_session,
RateLimitKind::OAuthToken => &self.rate_limiters.oauth_token,
RateLimitKind::OAuthAuthorize => &self.rate_limiters.oauth_authorize,
RateLimitKind::OAuthPar => &self.rate_limiters.oauth_par,
RateLimitKind::OAuthIntrospect => &self.rate_limiters.oauth_introspect,
RateLimitKind::AppPassword => &self.rate_limiters.app_password,
RateLimitKind::EmailUpdate => &self.rate_limiters.email_update,
};
let ok = limiter.check_key(&client_ip.to_string()).is_ok();
if !ok {
crate::metrics::record_rate_limit_rejection(limiter_name);
}
ok
}
}
+38 -8
View File
@@ -3,6 +3,7 @@ use aws_config::BehaviorVersion;
use aws_config::meta::region::RegionProviderChain;
use aws_sdk_s3::Client;
use aws_sdk_s3::primitives::ByteStream;
use bytes::Bytes;
use thiserror::Error;
#[derive(Error, Debug)]
@@ -18,7 +19,9 @@ pub enum StorageError {
#[async_trait]
pub trait BlobStorage: Send + Sync {
async fn put(&self, key: &str, data: &[u8]) -> Result<(), StorageError>;
async fn put_bytes(&self, key: &str, data: Bytes) -> Result<(), StorageError>;
async fn get(&self, key: &str) -> Result<Vec<u8>, StorageError>;
async fn get_bytes(&self, key: &str) -> Result<Bytes, StorageError>;
async fn delete(&self, key: &str) -> Result<(), StorageError>;
}
@@ -55,18 +58,32 @@ impl S3BlobStorage {
#[async_trait]
impl BlobStorage for S3BlobStorage {
async fn put(&self, key: &str, data: &[u8]) -> Result<(), StorageError> {
self.client
self.put_bytes(key, Bytes::copy_from_slice(data)).await
}
async fn put_bytes(&self, key: &str, data: Bytes) -> Result<(), StorageError> {
let result = self.client
.put_object()
.bucket(&self.bucket)
.key(key)
.body(ByteStream::from(data.to_vec()))
.body(ByteStream::from(data))
.send()
.await
.map_err(|e| StorageError::S3(e.to_string()))?;
.map_err(|e| StorageError::S3(e.to_string()));
match &result {
Ok(_) => crate::metrics::record_s3_operation("put", "success"),
Err(_) => crate::metrics::record_s3_operation("put", "error"),
}
result?;
Ok(())
}
async fn get(&self, key: &str) -> Result<Vec<u8>, StorageError> {
self.get_bytes(key).await.map(|b| b.to_vec())
}
async fn get_bytes(&self, key: &str) -> Result<Bytes, StorageError> {
let resp = self
.client
.get_object()
@@ -74,26 +91,39 @@ impl BlobStorage for S3BlobStorage {
.key(key)
.send()
.await
.map_err(|e| StorageError::S3(e.to_string()))?;
.map_err(|e| {
crate::metrics::record_s3_operation("get", "error");
StorageError::S3(e.to_string())
})?;
let data = resp
.body
.collect()
.await
.map_err(|e| StorageError::S3(e.to_string()))?
.map_err(|e| {
crate::metrics::record_s3_operation("get", "error");
StorageError::S3(e.to_string())
})?
.into_bytes();
Ok(data.to_vec())
crate::metrics::record_s3_operation("get", "success");
Ok(data)
}
async fn delete(&self, key: &str) -> Result<(), StorageError> {
self.client
let result = self.client
.delete_object()
.bucket(&self.bucket)
.key(key)
.send()
.await
.map_err(|e| StorageError::S3(e.to_string()))?;
.map_err(|e| StorageError::S3(e.to_string()));
match &result {
Ok(_) => crate::metrics::record_s3_operation("delete", "success"),
Err(_) => crate::metrics::record_s3_operation("delete", "error"),
}
result?;
Ok(())
}
}
+68 -2
View File
@@ -1,9 +1,20 @@
use crate::state::AppState;
use crate::sync::firehose::SequencedEvent;
use sqlx::postgres::PgListener;
use tracing::{error, info, warn};
use std::sync::atomic::{AtomicI64, Ordering};
use tracing::{debug, error, info, warn};
static LAST_BROADCAST_SEQ: AtomicI64 = AtomicI64::new(0);
pub async fn start_sequencer_listener(state: AppState) {
let initial_seq = sqlx::query_scalar!("SELECT COALESCE(MAX(seq), 0) as max FROM repo_seq")
.fetch_one(&state.db)
.await
.unwrap_or(Some(0))
.unwrap_or(0);
LAST_BROADCAST_SEQ.store(initial_seq, Ordering::SeqCst);
info!(initial_seq = initial_seq, "Initialized sequencer listener");
tokio::spawn(async move {
info!("Starting sequencer listener background task");
loop {
@@ -20,6 +31,29 @@ async fn listen_loop(state: AppState) -> anyhow::Result<()> {
listener.listen("repo_updates").await?;
info!("Connected to Postgres and listening for 'repo_updates'");
let catchup_start = LAST_BROADCAST_SEQ.load(Ordering::SeqCst);
let events = sqlx::query_as!(
SequencedEvent,
r#"
SELECT seq, did, created_at, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids
FROM repo_seq
WHERE seq > $1
ORDER BY seq ASC
"#,
catchup_start
)
.fetch_all(&state.db)
.await?;
if !events.is_empty() {
info!(count = events.len(), from_seq = catchup_start, "Broadcasting catch-up events");
for event in events {
let seq = event.seq;
let _ = state.firehose_tx.send(event);
LAST_BROADCAST_SEQ.store(seq, Ordering::SeqCst);
}
}
loop {
let notification = listener.recv().await?;
let payload = notification.payload();
@@ -32,6 +66,37 @@ async fn listen_loop(state: AppState) -> anyhow::Result<()> {
}
};
let last_seq = LAST_BROADCAST_SEQ.load(Ordering::SeqCst);
if seq_id <= last_seq {
debug!(seq = seq_id, last = last_seq, "Skipping already-broadcast event");
continue;
}
if seq_id > last_seq + 1 {
let gap_events = sqlx::query_as!(
SequencedEvent,
r#"
SELECT seq, did, created_at, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids
FROM repo_seq
WHERE seq > $1 AND seq < $2
ORDER BY seq ASC
"#,
last_seq,
seq_id
)
.fetch_all(&state.db)
.await?;
if !gap_events.is_empty() {
debug!(count = gap_events.len(), "Filling sequence gap");
for event in gap_events {
let seq = event.seq;
let _ = state.firehose_tx.send(event);
LAST_BROADCAST_SEQ.store(seq, Ordering::SeqCst);
}
}
}
let event = sqlx::query_as!(
SequencedEvent,
r#"
@@ -46,8 +111,9 @@ async fn listen_loop(state: AppState) -> anyhow::Result<()> {
if let Some(event) = event {
let _ = state.firehose_tx.send(event);
LAST_BROADCAST_SEQ.store(seq_id, Ordering::SeqCst);
} else {
warn!("Received notification for seq {} but could not find row in repo_seq", seq_id);
warn!(seq = seq_id, "Received notification but could not find row in repo_seq");
}
}
}
+73 -14
View File
@@ -1,15 +1,18 @@
use crate::state::AppState;
use crate::sync::firehose::SequencedEvent;
use crate::sync::util::format_event_for_sending;
use crate::sync::util::{format_event_for_sending, format_event_with_prefetched_blocks, prefetch_blocks_for_events};
use axum::{
extract::{ws::Message, ws::WebSocket, ws::WebSocketUpgrade, Query, State},
response::Response,
};
use futures::{sink::SinkExt, stream::StreamExt};
use serde::Deserialize;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::sync::broadcast::error::RecvError;
use tracing::{error, info, warn};
const BACKFILL_BATCH_SIZE: i64 = 1000;
static SUBSCRIBER_COUNT: AtomicUsize = AtomicUsize::new(0);
#[derive(Deserialize)]
pub struct SubscribeReposParams {
@@ -35,9 +38,23 @@ async fn send_event(
Ok(())
}
async fn handle_socket(mut socket: WebSocket, state: AppState, params: SubscribeReposParams) {
info!(cursor = ?params.cursor, "New firehose subscriber");
pub fn get_subscriber_count() -> usize {
SUBSCRIBER_COUNT.load(Ordering::SeqCst)
}
async fn handle_socket(mut socket: WebSocket, state: AppState, params: SubscribeReposParams) {
let count = SUBSCRIBER_COUNT.fetch_add(1, Ordering::SeqCst) + 1;
crate::metrics::set_firehose_subscribers(count);
info!(cursor = ?params.cursor, subscribers = count, "New firehose subscriber");
let _ = handle_socket_inner(&mut socket, &state, params).await;
let count = SUBSCRIBER_COUNT.fetch_sub(1, Ordering::SeqCst) - 1;
crate::metrics::set_firehose_subscribers(count);
info!(subscribers = count, "Firehose subscriber disconnected");
}
async fn handle_socket_inner(socket: &mut WebSocket, state: &AppState, params: SubscribeReposParams) -> Result<(), ()> {
if let Some(cursor) = params.cursor {
let mut current_cursor = cursor;
loop {
@@ -61,34 +78,75 @@ async fn handle_socket(mut socket: WebSocket, state: AppState, params: Subscribe
if events.is_empty() {
break;
}
for event in &events {
current_cursor = event.seq;
if let Err(e) = send_event(&mut socket, &state, event.clone()).await {
warn!("Failed to send backfill event: {}", e);
return;
let events_count = events.len();
let prefetched = match prefetch_blocks_for_events(state, &events).await {
Ok(blocks) => blocks,
Err(e) => {
error!("Failed to prefetch blocks for backfill: {}", e);
socket.close().await.ok();
return Err(());
}
};
for event in events {
current_cursor = event.seq;
let bytes = match format_event_with_prefetched_blocks(event, &prefetched).await {
Ok(b) => b,
Err(e) => {
warn!("Failed to format backfill event: {}", e);
return Err(());
}
};
if let Err(e) = socket.send(Message::Binary(bytes.into())).await {
warn!("Failed to send backfill event: {}", e);
return Err(());
}
crate::metrics::record_firehose_event();
}
if (events.len() as i64) < BACKFILL_BATCH_SIZE {
if (events_count as i64) < BACKFILL_BATCH_SIZE {
break;
}
}
Err(e) => {
error!("Failed to fetch backfill events: {}", e);
socket.close().await.ok();
return;
return Err(());
}
}
}
}
let mut rx = state.firehose_tx.subscribe();
let max_lag_before_disconnect: u64 = std::env::var("FIREHOSE_MAX_LAG")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(5000);
loop {
tokio::select! {
Ok(event) = rx.recv() => {
if let Err(e) = send_event(&mut socket, &state, event).await {
warn!("Failed to send event: {}", e);
break;
result = rx.recv() => {
match result {
Ok(event) => {
if let Err(e) = send_event(socket, state, event).await {
warn!("Failed to send event: {}", e);
break;
}
crate::metrics::record_firehose_event();
}
Err(RecvError::Lagged(skipped)) => {
warn!(skipped = skipped, "Firehose subscriber lagged behind");
if skipped > max_lag_before_disconnect {
warn!(skipped = skipped, max_lag = max_lag_before_disconnect,
"Disconnecting slow firehose consumer");
break;
}
}
Err(RecvError::Closed) => {
info!("Firehose channel closed");
break;
}
}
}
Some(Ok(msg)) = socket.next() => {
@@ -102,4 +160,5 @@ async fn handle_socket(mut socket: WebSocket, state: AppState, params: Subscribe
}
}
}
Ok(())
}
+86 -9
View File
@@ -1,9 +1,11 @@
use crate::state::AppState;
use crate::sync::firehose::SequencedEvent;
use crate::sync::frame::{CommitFrame, Frame, FrameData};
use bytes::Bytes;
use cid::Cid;
use jacquard_repo::car::write_car_bytes;
use jacquard_repo::storage::BlockStore;
use std::collections::{BTreeMap, HashMap};
use std::str::FromStr;
pub async fn format_event_for_sending(
@@ -15,16 +17,91 @@ pub async fn format_event_for_sending(
.map_err(|e| anyhow::anyhow!("Invalid event: {}", e))?;
let car_bytes = if !block_cids_str.is_empty() {
let mut blocks = std::collections::BTreeMap::new();
let cids: Vec<Cid> = block_cids_str
.iter()
.filter_map(|s| Cid::from_str(s).ok())
.collect();
for cid_str in block_cids_str {
let cid = Cid::from_str(&cid_str)?;
let data = state
.block_store
.get(&cid)
.await?
.ok_or_else(|| anyhow::anyhow!("Block not found: {}", cid))?;
blocks.insert(cid, data);
let fetched = state.block_store.get_many(&cids).await?;
let mut blocks = std::collections::BTreeMap::new();
for (cid, data_opt) in cids.into_iter().zip(fetched.into_iter()) {
if let Some(data) = data_opt {
blocks.insert(cid, data);
}
}
let root = Cid::from_str(&frame.commit)?;
write_car_bytes(root, blocks).await?
} else {
Vec::new()
};
frame.blocks = car_bytes;
let frame = Frame {
op: 1,
data: FrameData::Commit(Box::new(frame)),
};
let mut bytes = Vec::new();
serde_ipld_dagcbor::to_writer(&mut bytes, &frame)?;
Ok(bytes)
}
pub async fn prefetch_blocks_for_events(
state: &AppState,
events: &[SequencedEvent],
) -> Result<HashMap<Cid, Bytes>, anyhow::Error> {
let mut all_cids: Vec<Cid> = Vec::new();
for event in events {
if let Some(ref block_cids_str) = event.blocks_cids {
for s in block_cids_str {
if let Ok(cid) = Cid::from_str(s) {
all_cids.push(cid);
}
}
}
}
all_cids.sort();
all_cids.dedup();
if all_cids.is_empty() {
return Ok(HashMap::new());
}
let fetched = state.block_store.get_many(&all_cids).await?;
let mut blocks_map = HashMap::new();
for (cid, data_opt) in all_cids.into_iter().zip(fetched.into_iter()) {
if let Some(data) = data_opt {
blocks_map.insert(cid, data);
}
}
Ok(blocks_map)
}
pub async fn format_event_with_prefetched_blocks(
event: SequencedEvent,
prefetched: &HashMap<Cid, Bytes>,
) -> Result<Vec<u8>, anyhow::Error> {
let block_cids_str = event.blocks_cids.clone().unwrap_or_default();
let mut frame: CommitFrame = event.try_into()
.map_err(|e| anyhow::anyhow!("Invalid event: {}", e))?;
let car_bytes = if !block_cids_str.is_empty() {
let cids: Vec<Cid> = block_cids_str
.iter()
.filter_map(|s| Cid::from_str(s).ok())
.collect();
let mut blocks = BTreeMap::new();
for cid in cids {
if let Some(data) = prefetched.get(&cid) {
blocks.insert(cid, data.clone());
}
}
let root = Cid::from_str(&frame.commit)?;
-139
View File
@@ -1,139 +0,0 @@
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use bspds::auth;
use chrono::{Duration, Utc};
use k256::SecretKey;
use k256::ecdsa::{SigningKey, signature::Signer};
use rand::rngs::OsRng;
use serde_json::json;
#[test]
fn test_jwt_flow() {
let secret_key = SecretKey::random(&mut OsRng);
let key_bytes = secret_key.to_bytes();
let did = "did:plc:test";
let token = auth::create_access_token(did, &key_bytes).expect("create token");
let data = auth::verify_access_token(&token, &key_bytes).expect("verify access token");
assert_eq!(data.claims.sub, did);
assert_eq!(data.claims.iss, did);
assert_eq!(data.claims.scope, Some(auth::SCOPE_ACCESS.to_string()));
let r_token = auth::create_refresh_token(did, &key_bytes).expect("create refresh token");
let r_data = auth::verify_refresh_token(&r_token, &key_bytes).expect("verify refresh token");
assert_eq!(r_data.claims.scope, Some(auth::SCOPE_REFRESH.to_string()));
let aud = "did:web:service";
let lxm = "com.example.test";
let s_token =
auth::create_service_token(did, aud, lxm, &key_bytes).expect("create service token");
let s_data = auth::verify_token(&s_token, &key_bytes).expect("verify service token");
assert_eq!(s_data.claims.aud, aud);
assert_eq!(s_data.claims.lxm, Some(lxm.to_string()));
}
#[test]
fn test_token_type_confusion_prevented() {
let secret_key = SecretKey::random(&mut OsRng);
let key_bytes = secret_key.to_bytes();
let did = "did:plc:test";
let access_token = auth::create_access_token(did, &key_bytes).expect("create access token");
let refresh_token = auth::create_refresh_token(did, &key_bytes).expect("create refresh token");
assert!(auth::verify_access_token(&access_token, &key_bytes).is_ok());
assert!(auth::verify_access_token(&refresh_token, &key_bytes).is_err());
assert!(auth::verify_refresh_token(&refresh_token, &key_bytes).is_ok());
assert!(auth::verify_refresh_token(&access_token, &key_bytes).is_err());
}
#[test]
fn test_verify_fails_with_wrong_key() {
let secret_key1 = SecretKey::random(&mut OsRng);
let key_bytes1 = secret_key1.to_bytes();
let secret_key2 = SecretKey::random(&mut OsRng);
let key_bytes2 = secret_key2.to_bytes();
let did = "did:plc:test";
let token = auth::create_access_token(did, &key_bytes1).expect("create token");
let result = auth::verify_token(&token, &key_bytes2);
assert!(result.is_err());
}
#[test]
fn test_token_expiration() {
let secret_key = SecretKey::random(&mut OsRng);
let key_bytes = secret_key.to_bytes();
let signing_key = SigningKey::from_slice(&key_bytes).expect("key");
let header = json!({
"alg": "ES256K",
"typ": "JWT"
});
let claims = json!({
"iss": "did:plc:test",
"sub": "did:plc:test",
"aud": "did:web:test",
"exp": (Utc::now() - Duration::seconds(10)).timestamp(),
"iat": (Utc::now() - Duration::minutes(1)).timestamp(),
"jti": "unique",
});
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
let claims_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&claims).unwrap());
let message = format!("{}.{}", header_b64, claims_b64);
let signature: k256::ecdsa::Signature = signing_key.sign(message.as_bytes());
let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
let token = format!("{}.{}", message, signature_b64);
let result = auth::verify_token(&token, &key_bytes);
match result {
Ok(_) => panic!("Token should be expired"),
Err(e) => assert_eq!(e.to_string(), "Token expired"),
}
}
#[test]
fn test_invalid_token_format() {
let secret_key = SecretKey::random(&mut OsRng);
let key_bytes = secret_key.to_bytes();
assert!(auth::verify_token("invalid.token", &key_bytes).is_err());
assert!(auth::verify_token("too.many.parts.here", &key_bytes).is_err());
assert!(auth::verify_token("bad_base64.payload.sig", &key_bytes).is_err());
}
#[test]
fn test_tampered_token() {
let secret_key = SecretKey::random(&mut OsRng);
let key_bytes = secret_key.to_bytes();
let did = "did:plc:test";
let token = auth::create_access_token(did, &key_bytes).expect("create token");
let parts: Vec<&str> = token.split('.').collect();
let claims_json = String::from_utf8(URL_SAFE_NO_PAD.decode(parts[1]).unwrap()).unwrap();
let mut claims: serde_json::Value = serde_json::from_str(&claims_json).unwrap();
claims["sub"] = json!("did:plc:hacker");
let tampered_claims_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&claims).unwrap());
let tampered_token = format!("{}.{}.{}", parts[0], tampered_claims_b64, parts[2]);
let result = auth::verify_token(&tampered_token, &key_bytes);
assert!(result.is_err());
}
#[test]
fn test_get_did_from_token() {
let secret_key = SecretKey::random(&mut OsRng);
let key_bytes = secret_key.to_bytes();
let did = "did:plc:test";
let token = auth::create_access_token(did, &key_bytes).expect("create token");
let extracted_did = auth::get_did_from_token(&token).expect("get did");
assert_eq!(extracted_did, did);
assert!(auth::get_did_from_token("bad.token").is_err());
}
+101 -6
View File
@@ -352,6 +352,8 @@ async fn setup_mock_appview(mock_server: &MockServer) {
}
async fn spawn_app(database_url: String) -> String {
use bspds::rate_limit::RateLimiters;
let pool = PgPoolOptions::new()
.max_connections(50)
.connect(&database_url)
@@ -371,7 +373,15 @@ async fn spawn_app(database_url: String) -> String {
std::env::set_var("PDS_HOSTNAME", addr.to_string());
}
let state = AppState::new(pool).await;
let rate_limiters = RateLimiters::new()
.with_login_limit(10000)
.with_account_creation_limit(10000)
.with_password_reset_limit(10000)
.with_email_update_limit(10000)
.with_oauth_authorize_limit(10000)
.with_oauth_token_limit(10000);
let state = AppState::new(pool).await.with_rate_limiters(rate_limiters);
bspds::sync::listener::start_sequencer_listener(state.clone()).await;
@@ -404,6 +414,47 @@ pub async fn get_db_connection_string() -> String {
}
}
#[allow(dead_code)]
pub async fn verify_new_account(client: &Client, did: &str) -> String {
let conn_str = get_db_connection_string().await;
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(2)
.connect(&conn_str)
.await
.expect("Failed to connect to test database");
let verification_code: String = sqlx::query_scalar!(
"SELECT email_confirmation_code FROM users WHERE did = $1",
did
)
.fetch_one(&pool)
.await
.expect("Failed to get verification code")
.expect("No verification code found");
let confirm_payload = json!({
"did": did,
"verificationCode": verification_code
});
let confirm_res = client
.post(format!(
"{}/xrpc/com.atproto.server.confirmSignup",
base_url().await
))
.json(&confirm_payload)
.send()
.await
.expect("confirmSignup request failed");
assert_eq!(confirm_res.status(), StatusCode::OK, "confirmSignup failed");
let confirm_body: Value = confirm_res.json().await.expect("Invalid JSON from confirmSignup");
confirm_body["accessJwt"]
.as_str()
.expect("No accessJwt in confirmSignup response")
.to_string()
}
#[allow(dead_code)]
pub async fn upload_test_blob(client: &Client, data: &'static str, mime: &'static str) -> Value {
let res = client
@@ -514,12 +565,56 @@ pub async fn create_account_and_login(client: &Client) -> (String, String) {
if res.status() == StatusCode::OK {
let body: Value = res.json().await.expect("Invalid JSON");
let access_jwt = body["accessJwt"]
.as_str()
.expect("No accessJwt")
.to_string();
if let Some(access_jwt) = body["accessJwt"].as_str() {
let did = body["did"].as_str().expect("No did").to_string();
return (access_jwt.to_string(), did);
}
let did = body["did"].as_str().expect("No did").to_string();
return (access_jwt, did);
let conn_str = get_db_connection_string().await;
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(2)
.connect(&conn_str)
.await
.expect("Failed to connect to test database");
let verification_code: String = sqlx::query_scalar!(
"SELECT email_confirmation_code FROM users WHERE did = $1",
&did
)
.fetch_one(&pool)
.await
.expect("Failed to get verification code")
.expect("No verification code found");
let confirm_payload = json!({
"did": did,
"verificationCode": verification_code
});
let confirm_res = client
.post(format!(
"{}/xrpc/com.atproto.server.confirmSignup",
base_url().await
))
.json(&confirm_payload)
.send()
.await
.expect("confirmSignup request failed");
if confirm_res.status() == StatusCode::OK {
let confirm_body: Value = confirm_res.json().await.expect("Invalid JSON from confirmSignup");
let access_jwt = confirm_body["accessJwt"]
.as_str()
.expect("No accessJwt in confirmSignup response")
.to_string();
return (access_jwt, did);
}
last_error = format!("confirmSignup failed: {:?}", confirm_res.text().await);
continue;
}
last_error = format!("Status {}: {:?}", res.status(), res.text().await);
+70 -139
View File
@@ -6,38 +6,50 @@ use common::*;
use chrono::Utc;
use reqwest::StatusCode;
use serde_json::{Value, json};
use sqlx::PgPool;
async fn get_pool() -> PgPool {
let conn_str = get_db_connection_string().await;
sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&conn_str)
.await
.expect("Failed to connect to test database")
}
async fn create_verified_account(client: &reqwest::Client, base_url: &str, handle: &str, email: &str, password: &str) -> (String, String) {
let res = client
.post(format!("{}/xrpc/com.atproto.server.createAccount", base_url))
.json(&json!({
"handle": handle,
"email": email,
"password": password
}))
.send()
.await
.expect("Failed to create account");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.expect("Invalid JSON");
let did = body["did"].as_str().expect("No did").to_string();
let jwt = verify_new_account(client, &did).await;
(did, jwt)
}
#[tokio::test]
async fn test_delete_account_full_flow() {
let client = client();
let base_url = base_url().await;
let ts = Utc::now().timestamp_millis();
let handle = format!("delete-test-{}.test", ts);
let email = format!("delete-test-{}@test.com", ts);
let password = "delete-password-123";
let create_payload = json!({
"handle": handle,
"email": email,
"password": password
});
let create_res = client
.post(format!(
"{}/xrpc/com.atproto.server.createAccount",
base_url().await
))
.json(&create_payload)
.send()
.await
.expect("Failed to create account");
assert_eq!(create_res.status(), StatusCode::OK);
let create_body: Value = create_res.json().await.unwrap();
let did = create_body["did"].as_str().unwrap().to_string();
let jwt = create_body["accessJwt"].as_str().unwrap().to_string();
let (did, jwt) = create_verified_account(&client, &base_url, &handle, &email, password).await;
let request_delete_res = client
.post(format!(
"{}/xrpc/com.atproto.server.requestAccountDelete",
base_url().await
base_url
))
.bearer_auth(&jwt)
.send()
@@ -45,8 +57,7 @@ async fn test_delete_account_full_flow() {
.expect("Failed to request account deletion");
assert_eq!(request_delete_res.status(), StatusCode::OK);
let db_url = get_db_connection_string().await;
let pool = sqlx::PgPool::connect(&db_url).await.expect("Failed to connect to test DB");
let pool = get_pool().await;
let row = sqlx::query!("SELECT token FROM account_deletion_requests WHERE did = $1", did)
.fetch_one(&pool)
@@ -62,7 +73,7 @@ async fn test_delete_account_full_flow() {
let delete_res = client
.post(format!(
"{}/xrpc/com.atproto.server.deleteAccount",
base_url().await
base_url
))
.json(&delete_payload)
.send()
@@ -79,7 +90,7 @@ async fn test_delete_account_full_flow() {
let session_res = client
.get(format!(
"{}/xrpc/com.atproto.server.getSession",
base_url().await
base_url
))
.bearer_auth(&jwt)
.send()
@@ -91,34 +102,18 @@ async fn test_delete_account_full_flow() {
#[tokio::test]
async fn test_delete_account_wrong_password() {
let client = client();
let base_url = base_url().await;
let ts = Utc::now().timestamp_millis();
let handle = format!("delete-wrongpw-{}.test", ts);
let email = format!("delete-wrongpw-{}@test.com", ts);
let password = "correct-password";
let create_payload = json!({
"handle": handle,
"email": email,
"password": password
});
let create_res = client
.post(format!(
"{}/xrpc/com.atproto.server.createAccount",
base_url().await
))
.json(&create_payload)
.send()
.await
.expect("Failed to create account");
assert_eq!(create_res.status(), StatusCode::OK);
let create_body: Value = create_res.json().await.unwrap();
let did = create_body["did"].as_str().unwrap().to_string();
let jwt = create_body["accessJwt"].as_str().unwrap().to_string();
let (did, jwt) = create_verified_account(&client, &base_url, &handle, &email, password).await;
let request_delete_res = client
.post(format!(
"{}/xrpc/com.atproto.server.requestAccountDelete",
base_url().await
base_url
))
.bearer_auth(&jwt)
.send()
@@ -126,8 +121,7 @@ async fn test_delete_account_wrong_password() {
.expect("Failed to request account deletion");
assert_eq!(request_delete_res.status(), StatusCode::OK);
let db_url = get_db_connection_string().await;
let pool = sqlx::PgPool::connect(&db_url).await.expect("Failed to connect to test DB");
let pool = get_pool().await;
let row = sqlx::query!("SELECT token FROM account_deletion_requests WHERE did = $1", did)
.fetch_one(&pool)
@@ -143,7 +137,7 @@ async fn test_delete_account_wrong_password() {
let delete_res = client
.post(format!(
"{}/xrpc/com.atproto.server.deleteAccount",
base_url().await
base_url
))
.json(&delete_payload)
.send()
@@ -158,22 +152,22 @@ async fn test_delete_account_wrong_password() {
#[tokio::test]
async fn test_delete_account_invalid_token() {
let client = client();
let base_url = base_url().await;
let ts = Utc::now().timestamp_millis();
let handle = format!("delete-badtoken-{}.test", ts);
let email = format!("delete-badtoken-{}@test.com", ts);
let password = "delete-password";
let create_payload = json!({
"handle": handle,
"email": email,
"password": password
});
let create_res = client
.post(format!(
"{}/xrpc/com.atproto.server.createAccount",
base_url().await
base_url
))
.json(&create_payload)
.json(&json!({
"handle": handle,
"email": email,
"password": password
}))
.send()
.await
.expect("Failed to create account");
@@ -189,7 +183,7 @@ async fn test_delete_account_invalid_token() {
let delete_res = client
.post(format!(
"{}/xrpc/com.atproto.server.deleteAccount",
base_url().await
base_url
))
.json(&delete_payload)
.send()
@@ -204,34 +198,18 @@ async fn test_delete_account_invalid_token() {
#[tokio::test]
async fn test_delete_account_expired_token() {
let client = client();
let base_url = base_url().await;
let ts = Utc::now().timestamp_millis();
let handle = format!("delete-expired-{}.test", ts);
let email = format!("delete-expired-{}@test.com", ts);
let password = "delete-password";
let create_payload = json!({
"handle": handle,
"email": email,
"password": password
});
let create_res = client
.post(format!(
"{}/xrpc/com.atproto.server.createAccount",
base_url().await
))
.json(&create_payload)
.send()
.await
.expect("Failed to create account");
assert_eq!(create_res.status(), StatusCode::OK);
let create_body: Value = create_res.json().await.unwrap();
let did = create_body["did"].as_str().unwrap().to_string();
let jwt = create_body["accessJwt"].as_str().unwrap().to_string();
let (did, jwt) = create_verified_account(&client, &base_url, &handle, &email, password).await;
let request_delete_res = client
.post(format!(
"{}/xrpc/com.atproto.server.requestAccountDelete",
base_url().await
base_url
))
.bearer_auth(&jwt)
.send()
@@ -239,8 +217,7 @@ async fn test_delete_account_expired_token() {
.expect("Failed to request account deletion");
assert_eq!(request_delete_res.status(), StatusCode::OK);
let db_url = get_db_connection_string().await;
let pool = sqlx::PgPool::connect(&db_url).await.expect("Failed to connect to test DB");
let pool = get_pool().await;
let row = sqlx::query!("SELECT token FROM account_deletion_requests WHERE did = $1", did)
.fetch_one(&pool)
@@ -264,7 +241,7 @@ async fn test_delete_account_expired_token() {
let delete_res = client
.post(format!(
"{}/xrpc/com.atproto.server.deleteAccount",
base_url().await
base_url
))
.json(&delete_payload)
.send()
@@ -279,55 +256,25 @@ async fn test_delete_account_expired_token() {
#[tokio::test]
async fn test_delete_account_token_mismatch() {
let client = client();
let base_url = base_url().await;
let ts = Utc::now().timestamp_millis();
let handle1 = format!("delete-user1-{}.test", ts);
let email1 = format!("delete-user1-{}@test.com", ts);
let password1 = "user1-password";
let create1_res = client
.post(format!(
"{}/xrpc/com.atproto.server.createAccount",
base_url().await
))
.json(&json!({
"handle": handle1,
"email": email1,
"password": password1
}))
.send()
.await
.expect("Failed to create account 1");
assert_eq!(create1_res.status(), StatusCode::OK);
let create1_body: Value = create1_res.json().await.unwrap();
let did1 = create1_body["did"].as_str().unwrap().to_string();
let jwt1 = create1_body["accessJwt"].as_str().unwrap().to_string();
let (did1, jwt1) = create_verified_account(&client, &base_url, &handle1, &email1, password1).await;
let handle2 = format!("delete-user2-{}.test", ts);
let email2 = format!("delete-user2-{}@test.com", ts);
let password2 = "user2-password";
let create2_res = client
.post(format!(
"{}/xrpc/com.atproto.server.createAccount",
base_url().await
))
.json(&json!({
"handle": handle2,
"email": email2,
"password": password2
}))
.send()
.await
.expect("Failed to create account 2");
assert_eq!(create2_res.status(), StatusCode::OK);
let create2_body: Value = create2_res.json().await.unwrap();
let did2 = create2_body["did"].as_str().unwrap().to_string();
let (did2, _) = create_verified_account(&client, &base_url, &handle2, &email2, password2).await;
let request_delete_res = client
.post(format!(
"{}/xrpc/com.atproto.server.requestAccountDelete",
base_url().await
base_url
))
.bearer_auth(&jwt1)
.send()
@@ -335,8 +282,7 @@ async fn test_delete_account_token_mismatch() {
.expect("Failed to request account deletion");
assert_eq!(request_delete_res.status(), StatusCode::OK);
let db_url = get_db_connection_string().await;
let pool = sqlx::PgPool::connect(&db_url).await.expect("Failed to connect to test DB");
let pool = get_pool().await;
let row = sqlx::query!("SELECT token FROM account_deletion_requests WHERE did = $1", did1)
.fetch_one(&pool)
@@ -352,7 +298,7 @@ async fn test_delete_account_token_mismatch() {
let delete_res = client
.post(format!(
"{}/xrpc/com.atproto.server.deleteAccount",
base_url().await
base_url
))
.json(&delete_payload)
.send()
@@ -367,34 +313,18 @@ async fn test_delete_account_token_mismatch() {
#[tokio::test]
async fn test_delete_account_with_app_password() {
let client = client();
let base_url = base_url().await;
let ts = Utc::now().timestamp_millis();
let handle = format!("delete-apppw-{}.test", ts);
let email = format!("delete-apppw-{}@test.com", ts);
let main_password = "main-password-123";
let create_payload = json!({
"handle": handle,
"email": email,
"password": main_password
});
let create_res = client
.post(format!(
"{}/xrpc/com.atproto.server.createAccount",
base_url().await
))
.json(&create_payload)
.send()
.await
.expect("Failed to create account");
assert_eq!(create_res.status(), StatusCode::OK);
let create_body: Value = create_res.json().await.unwrap();
let did = create_body["did"].as_str().unwrap().to_string();
let jwt = create_body["accessJwt"].as_str().unwrap().to_string();
let (did, jwt) = create_verified_account(&client, &base_url, &handle, &email, main_password).await;
let app_password_res = client
.post(format!(
"{}/xrpc/com.atproto.server.createAppPassword",
base_url().await
base_url
))
.bearer_auth(&jwt)
.json(&json!({ "name": "delete-test-app" }))
@@ -408,7 +338,7 @@ async fn test_delete_account_with_app_password() {
let request_delete_res = client
.post(format!(
"{}/xrpc/com.atproto.server.requestAccountDelete",
base_url().await
base_url
))
.bearer_auth(&jwt)
.send()
@@ -416,8 +346,7 @@ async fn test_delete_account_with_app_password() {
.expect("Failed to request account deletion");
assert_eq!(request_delete_res.status(), StatusCode::OK);
let db_url = get_db_connection_string().await;
let pool = sqlx::PgPool::connect(&db_url).await.expect("Failed to connect to test DB");
let pool = get_pool().await;
let row = sqlx::query!("SELECT token FROM account_deletion_requests WHERE did = $1", did)
.fetch_one(&pool)
@@ -433,7 +362,7 @@ async fn test_delete_account_with_app_password() {
let delete_res = client
.post(format!(
"{}/xrpc/com.atproto.server.deleteAccount",
base_url().await
base_url
))
.json(&delete_payload)
.send()
@@ -451,11 +380,12 @@ async fn test_delete_account_with_app_password() {
#[tokio::test]
async fn test_delete_account_missing_fields() {
let client = client();
let base_url = base_url().await;
let res1 = client
.post(format!(
"{}/xrpc/com.atproto.server.deleteAccount",
base_url().await
base_url
))
.json(&json!({
"password": "test",
@@ -469,7 +399,7 @@ async fn test_delete_account_missing_fields() {
let res2 = client
.post(format!(
"{}/xrpc/com.atproto.server.deleteAccount",
base_url().await
base_url
))
.json(&json!({
"did": "did:web:test",
@@ -483,7 +413,7 @@ async fn test_delete_account_missing_fields() {
let res3 = client
.post(format!(
"{}/xrpc/com.atproto.server.deleteAccount",
base_url().await
base_url
))
.json(&json!({
"did": "did:web:test",
@@ -498,6 +428,7 @@ async fn test_delete_account_missing_fields() {
#[tokio::test]
async fn test_delete_account_nonexistent_user() {
let client = client();
let base_url = base_url().await;
let delete_payload = json!({
"did": "did:web:nonexistent.user",
@@ -507,7 +438,7 @@ async fn test_delete_account_nonexistent_user() {
let delete_res = client
.post(format!(
"{}/xrpc/com.atproto.server.deleteAccount",
base_url().await
base_url
))
.json(&delete_payload)
.send()
+50 -187
View File
@@ -13,6 +13,23 @@ async fn get_pool() -> PgPool {
.expect("Failed to connect to test database")
}
async fn create_verified_account(client: &reqwest::Client, base_url: &str, handle: &str, email: &str) -> String {
let res = client
.post(format!("{}/xrpc/com.atproto.server.createAccount", base_url))
.json(&json!({
"handle": handle,
"email": email,
"password": "password"
}))
.send()
.await
.expect("Failed to create account");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.expect("Invalid JSON");
let did = body["did"].as_str().expect("No did");
common::verify_new_account(client, did).await
}
#[tokio::test]
async fn test_email_update_flow_success() {
let client = common::client();
@@ -21,26 +38,12 @@ async fn test_email_update_flow_success() {
let handle = format!("emailup_{}", uuid::Uuid::new_v4());
let email = format!("{}@example.com", handle);
let payload = json!({
"handle": handle,
"email": email,
"password": "password"
});
let res = client
.post(format!("{}/xrpc/com.atproto.server.createAccount", base_url))
.json(&payload)
.send()
.await
.expect("Failed to create account");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.expect("Invalid JSON");
let access_jwt = body["accessJwt"].as_str().expect("No accessJwt");
let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await;
let new_email = format!("new_{}@example.com", handle);
let res = client
.post(format!("{}/xrpc/com.atproto.server.requestEmailUpdate", base_url))
.bearer_auth(access_jwt)
.bearer_auth(&access_jwt)
.json(&json!({"email": new_email}))
.send()
.await
@@ -63,7 +66,7 @@ async fn test_email_update_flow_success() {
let res = client
.post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url))
.bearer_auth(access_jwt)
.bearer_auth(&access_jwt)
.json(&json!({
"email": new_email,
"token": code
@@ -81,7 +84,7 @@ async fn test_email_update_flow_success() {
.await
.expect("User not found");
assert_eq!(user.email, new_email);
assert_eq!(user.email, Some(new_email));
assert!(user.email_pending_verification.is_none());
assert!(user.email_confirmation_code.is_none());
}
@@ -93,37 +96,15 @@ async fn test_request_email_update_taken_email() {
let handle1 = format!("emailup_taken1_{}", uuid::Uuid::new_v4());
let email1 = format!("{}@example.com", handle1);
let res = client
.post(format!("{}/xrpc/com.atproto.server.createAccount", base_url))
.json(&json!({
"handle": handle1,
"email": email1,
"password": "password"
}))
.send()
.await
.expect("Failed to create account 1");
assert_eq!(res.status(), StatusCode::OK);
let _ = create_verified_account(&client, &base_url, &handle1, &email1).await;
let handle2 = format!("emailup_taken2_{}", uuid::Uuid::new_v4());
let email2 = format!("{}@example.com", handle2);
let res = client
.post(format!("{}/xrpc/com.atproto.server.createAccount", base_url))
.json(&json!({
"handle": handle2,
"email": email2,
"password": "password"
}))
.send()
.await
.expect("Failed to create account 2");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.expect("Invalid JSON");
let access_jwt2 = body["accessJwt"].as_str().expect("No accessJwt");
let access_jwt2 = create_verified_account(&client, &base_url, &handle2, &email2).await;
let res = client
.post(format!("{}/xrpc/com.atproto.server.requestEmailUpdate", base_url))
.bearer_auth(access_jwt2)
.bearer_auth(&access_jwt2)
.json(&json!({"email": email1}))
.send()
.await
@@ -141,24 +122,12 @@ async fn test_confirm_email_invalid_token() {
let handle = format!("emailup_inv_{}", uuid::Uuid::new_v4());
let email = format!("{}@example.com", handle);
let res = client
.post(format!("{}/xrpc/com.atproto.server.createAccount", base_url))
.json(&json!({
"handle": handle,
"email": email,
"password": "password"
}))
.send()
.await
.expect("Failed to create account");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.expect("Invalid JSON");
let access_jwt = body["accessJwt"].as_str().expect("No accessJwt");
let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await;
let new_email = format!("new_{}@example.com", handle);
let res = client
.post(format!("{}/xrpc/com.atproto.server.requestEmailUpdate", base_url))
.bearer_auth(access_jwt)
.bearer_auth(&access_jwt)
.json(&json!({"email": new_email}))
.send()
.await
@@ -167,7 +136,7 @@ async fn test_confirm_email_invalid_token() {
let res = client
.post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url))
.bearer_auth(access_jwt)
.bearer_auth(&access_jwt)
.json(&json!({
"email": new_email,
"token": "wrong-token"
@@ -189,24 +158,12 @@ async fn test_confirm_email_wrong_email() {
let handle = format!("emailup_wrong_{}", uuid::Uuid::new_v4());
let email = format!("{}@example.com", handle);
let res = client
.post(format!("{}/xrpc/com.atproto.server.createAccount", base_url))
.json(&json!({
"handle": handle,
"email": email,
"password": "password"
}))
.send()
.await
.expect("Failed to create account");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.expect("Invalid JSON");
let access_jwt = body["accessJwt"].as_str().expect("No accessJwt");
let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await;
let new_email = format!("new_{}@example.com", handle);
let res = client
.post(format!("{}/xrpc/com.atproto.server.requestEmailUpdate", base_url))
.bearer_auth(access_jwt)
.bearer_auth(&access_jwt)
.json(&json!({"email": new_email}))
.send()
.await
@@ -221,7 +178,7 @@ async fn test_confirm_email_wrong_email() {
let res = client
.post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url))
.bearer_auth(access_jwt)
.bearer_auth(&access_jwt)
.json(&json!({
"email": "another_random@example.com",
"token": code
@@ -243,24 +200,12 @@ async fn test_update_email_success_no_token_required() {
let handle = format!("emailup_direct_{}", uuid::Uuid::new_v4());
let email = format!("{}@example.com", handle);
let res = client
.post(format!("{}/xrpc/com.atproto.server.createAccount", base_url))
.json(&json!({
"handle": handle,
"email": email,
"password": "password"
}))
.send()
.await
.expect("Failed to create account");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.expect("Invalid JSON");
let access_jwt = body["accessJwt"].as_str().expect("No accessJwt");
let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await;
let new_email = format!("direct_{}@example.com", handle);
let res = client
.post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url))
.bearer_auth(access_jwt)
.bearer_auth(&access_jwt)
.json(&json!({ "email": new_email }))
.send()
.await
@@ -272,7 +217,7 @@ async fn test_update_email_success_no_token_required() {
.fetch_one(&pool)
.await
.expect("User not found");
assert_eq!(user.email, new_email);
assert_eq!(user.email, Some(new_email));
}
#[tokio::test]
@@ -282,23 +227,11 @@ async fn test_update_email_same_email_noop() {
let handle = format!("emailup_same_{}", uuid::Uuid::new_v4());
let email = format!("{}@example.com", handle);
let res = client
.post(format!("{}/xrpc/com.atproto.server.createAccount", base_url))
.json(&json!({
"handle": handle,
"email": email,
"password": "password"
}))
.send()
.await
.expect("Failed to create account");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.expect("Invalid JSON");
let access_jwt = body["accessJwt"].as_str().expect("No accessJwt");
let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await;
let res = client
.post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url))
.bearer_auth(access_jwt)
.bearer_auth(&access_jwt)
.json(&json!({ "email": email }))
.send()
.await
@@ -314,24 +247,12 @@ async fn test_update_email_requires_token_after_pending() {
let handle = format!("emailup_token_{}", uuid::Uuid::new_v4());
let email = format!("{}@example.com", handle);
let res = client
.post(format!("{}/xrpc/com.atproto.server.createAccount", base_url))
.json(&json!({
"handle": handle,
"email": email,
"password": "password"
}))
.send()
.await
.expect("Failed to create account");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.expect("Invalid JSON");
let access_jwt = body["accessJwt"].as_str().expect("No accessJwt");
let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await;
let new_email = format!("pending_{}@example.com", handle);
let res = client
.post(format!("{}/xrpc/com.atproto.server.requestEmailUpdate", base_url))
.bearer_auth(access_jwt)
.bearer_auth(&access_jwt)
.json(&json!({"email": new_email}))
.send()
.await
@@ -340,7 +261,7 @@ async fn test_update_email_requires_token_after_pending() {
let res = client
.post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url))
.bearer_auth(access_jwt)
.bearer_auth(&access_jwt)
.json(&json!({ "email": new_email }))
.send()
.await
@@ -359,24 +280,12 @@ async fn test_update_email_with_valid_token() {
let handle = format!("emailup_valid_{}", uuid::Uuid::new_v4());
let email = format!("{}@example.com", handle);
let res = client
.post(format!("{}/xrpc/com.atproto.server.createAccount", base_url))
.json(&json!({
"handle": handle,
"email": email,
"password": "password"
}))
.send()
.await
.expect("Failed to create account");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.expect("Invalid JSON");
let access_jwt = body["accessJwt"].as_str().expect("No accessJwt");
let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await;
let new_email = format!("valid_{}@example.com", handle);
let res = client
.post(format!("{}/xrpc/com.atproto.server.requestEmailUpdate", base_url))
.bearer_auth(access_jwt)
.bearer_auth(&access_jwt)
.json(&json!({"email": new_email}))
.send()
.await
@@ -394,7 +303,7 @@ async fn test_update_email_with_valid_token() {
let res = client
.post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url))
.bearer_auth(access_jwt)
.bearer_auth(&access_jwt)
.json(&json!({
"email": new_email,
"token": code
@@ -409,7 +318,7 @@ async fn test_update_email_with_valid_token() {
.fetch_one(&pool)
.await
.expect("User not found");
assert_eq!(user.email, new_email);
assert_eq!(user.email, Some(new_email));
assert!(user.email_pending_verification.is_none());
}
@@ -420,24 +329,12 @@ async fn test_update_email_invalid_token() {
let handle = format!("emailup_badtok_{}", uuid::Uuid::new_v4());
let email = format!("{}@example.com", handle);
let res = client
.post(format!("{}/xrpc/com.atproto.server.createAccount", base_url))
.json(&json!({
"handle": handle,
"email": email,
"password": "password"
}))
.send()
.await
.expect("Failed to create account");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.expect("Invalid JSON");
let access_jwt = body["accessJwt"].as_str().expect("No accessJwt");
let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await;
let new_email = format!("badtok_{}@example.com", handle);
let res = client
.post(format!("{}/xrpc/com.atproto.server.requestEmailUpdate", base_url))
.bearer_auth(access_jwt)
.bearer_auth(&access_jwt)
.json(&json!({"email": new_email}))
.send()
.await
@@ -446,7 +343,7 @@ async fn test_update_email_invalid_token() {
let res = client
.post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url))
.bearer_auth(access_jwt)
.bearer_auth(&access_jwt)
.json(&json!({
"email": new_email,
"token": "wrong-token-12345"
@@ -467,37 +364,15 @@ async fn test_update_email_already_taken() {
let handle1 = format!("emailup_dup1_{}", uuid::Uuid::new_v4());
let email1 = format!("{}@example.com", handle1);
let res = client
.post(format!("{}/xrpc/com.atproto.server.createAccount", base_url))
.json(&json!({
"handle": handle1,
"email": email1,
"password": "password"
}))
.send()
.await
.expect("Failed to create account 1");
assert_eq!(res.status(), StatusCode::OK);
let _ = create_verified_account(&client, &base_url, &handle1, &email1).await;
let handle2 = format!("emailup_dup2_{}", uuid::Uuid::new_v4());
let email2 = format!("{}@example.com", handle2);
let res = client
.post(format!("{}/xrpc/com.atproto.server.createAccount", base_url))
.json(&json!({
"handle": handle2,
"email": email2,
"password": "password"
}))
.send()
.await
.expect("Failed to create account 2");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.expect("Invalid JSON");
let access_jwt2 = body["accessJwt"].as_str().expect("No accessJwt");
let access_jwt2 = create_verified_account(&client, &base_url, &handle2, &email2).await;
let res = client
.post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url))
.bearer_auth(access_jwt2)
.bearer_auth(&access_jwt2)
.json(&json!({ "email": email1 }))
.send()
.await
@@ -532,23 +407,11 @@ async fn test_update_email_invalid_format() {
let handle = format!("emailup_fmt_{}", uuid::Uuid::new_v4());
let email = format!("{}@example.com", handle);
let res = client
.post(format!("{}/xrpc/com.atproto.server.createAccount", base_url))
.json(&json!({
"handle": handle,
"email": email,
"password": "password"
}))
.send()
.await
.expect("Failed to create account");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.expect("Invalid JSON");
let access_jwt = body["accessJwt"].as_str().expect("No accessJwt");
let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await;
let res = client
.post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url))
.bearer_auth(access_jwt)
.bearer_auth(&access_jwt)
.json(&json!({ "email": "not-an-email" }))
.send()
.await
+2 -4
View File
@@ -43,10 +43,8 @@ pub async fn setup_new_user(handle_prefix: &str) -> (String, String) {
.as_str()
.expect("setup_new_user: Response had no DID")
.to_string();
let new_jwt = create_body["accessJwt"]
.as_str()
.expect("setup_new_user: Response had no accessJwt")
.to_string();
let new_jwt = verify_new_account(&client, &new_did).await;
(new_did, new_jwt)
}
+1 -17
View File
@@ -264,23 +264,7 @@ async fn test_did_web_lifecycle() {
let create_body: Value = res.json().await.expect("Not JSON");
assert_eq!(create_body["did"], did);
let login_payload = json!({
"identifier": handle,
"password": "password"
});
let res = client
.post(format!(
"{}/xrpc/com.atproto.server.createSession",
base_url().await
))
.json(&login_payload)
.send()
.await
.expect("Failed createSession");
assert_eq!(res.status(), StatusCode::OK);
let session_body: Value = res.json().await.expect("Not JSON");
let _jwt = session_body["accessJwt"].as_str().unwrap();
let _jwt = verify_new_account(&client, &did).await;
/*
let profile_payload = json!({
-109
View File
@@ -1,109 +0,0 @@
mod common;
use common::*;
use reqwest::StatusCode;
use serde_json::json;
#[tokio::test]
async fn test_import_repo_requires_auth() {
let client = client();
let res = client
.post(format!("{}/xrpc/com.atproto.repo.importRepo", base_url().await))
.header("Content-Type", "application/vnd.ipld.car")
.body(vec![0u8; 100])
.send()
.await
.expect("Request failed");
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn test_import_repo_invalid_car() {
let client = client();
let (token, _did) = create_account_and_login(&client).await;
let res = client
.post(format!("{}/xrpc/com.atproto.repo.importRepo", base_url().await))
.bearer_auth(&token)
.header("Content-Type", "application/vnd.ipld.car")
.body(vec![0u8; 100])
.send()
.await
.expect("Request failed");
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(body["error"], "InvalidRequest");
}
#[tokio::test]
async fn test_import_repo_empty_body() {
let client = client();
let (token, _did) = create_account_and_login(&client).await;
let res = client
.post(format!("{}/xrpc/com.atproto.repo.importRepo", base_url().await))
.bearer_auth(&token)
.header("Content-Type", "application/vnd.ipld.car")
.body(vec![])
.send()
.await
.expect("Request failed");
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_import_repo_with_exported_repo() {
let client = client();
let (token, did) = create_account_and_login(&client).await;
let post_payload = json!({
"repo": did,
"collection": "app.bsky.feed.post",
"record": {
"$type": "app.bsky.feed.post",
"text": "Test post for import",
"createdAt": chrono::Utc::now().to_rfc3339(),
}
});
let create_res = client
.post(format!(
"{}/xrpc/com.atproto.repo.createRecord",
base_url().await
))
.bearer_auth(&token)
.json(&post_payload)
.send()
.await
.expect("Failed to create post");
assert_eq!(create_res.status(), StatusCode::OK);
let export_res = client
.get(format!(
"{}/xrpc/com.atproto.sync.getRepo?did={}",
base_url().await,
did
))
.send()
.await
.expect("Failed to export repo");
assert_eq!(export_res.status(), StatusCode::OK);
let car_bytes = export_res.bytes().await.expect("Failed to get CAR bytes");
let import_res = client
.post(format!("{}/xrpc/com.atproto.repo.importRepo", base_url().await))
.bearer_auth(&token)
.header("Content-Type", "application/vnd.ipld.car")
.body(car_bytes.to_vec())
.send()
.await
.expect("Failed to import repo");
assert_eq!(import_res.status(), StatusCode::OK);
}
+51
View File
@@ -5,6 +5,57 @@ use iroh_car::CarHeader;
use reqwest::StatusCode;
use serde_json::json;
#[tokio::test]
async fn test_import_repo_requires_auth() {
let client = client();
let res = client
.post(format!("{}/xrpc/com.atproto.repo.importRepo", base_url().await))
.header("Content-Type", "application/vnd.ipld.car")
.body(vec![0u8; 100])
.send()
.await
.expect("Request failed");
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn test_import_repo_invalid_car() {
let client = client();
let (token, _did) = create_account_and_login(&client).await;
let res = client
.post(format!("{}/xrpc/com.atproto.repo.importRepo", base_url().await))
.bearer_auth(&token)
.header("Content-Type", "application/vnd.ipld.car")
.body(vec![0u8; 100])
.send()
.await
.expect("Request failed");
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(body["error"], "InvalidRequest");
}
#[tokio::test]
async fn test_import_repo_empty_body() {
let client = client();
let (token, _did) = create_account_and_login(&client).await;
let res = client
.post(format!("{}/xrpc/com.atproto.repo.importRepo", base_url().await))
.bearer_auth(&token)
.header("Content-Type", "application/vnd.ipld.car")
.body(vec![])
.send()
.await
.expect("Request failed");
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
}
fn write_varint(buf: &mut Vec<u8>, mut value: u64) {
loop {
let mut byte = (value & 0x7F) as u8;
+34 -38
View File
@@ -10,7 +10,7 @@ use bspds::auth::{
SCOPE_ACCESS, SCOPE_REFRESH, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED,
};
use chrono::{Duration, Utc};
use common::{base_url, client, create_account_and_login};
use common::{base_url, client, create_account_and_login, get_db_connection_string};
use k256::SecretKey;
use k256::ecdsa::{SigningKey, Signature, signature::Signer};
use rand::rngs::OsRng;
@@ -906,7 +906,37 @@ async fn test_jwt_security_refresh_token_replay_protection() {
assert_eq!(create_res.status(), StatusCode::OK);
let account: Value = create_res.json().await.unwrap();
let refresh_jwt = account["refreshJwt"].as_str().unwrap().to_string();
let did = account["did"].as_str().unwrap();
let conn_str = get_db_connection_string().await;
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(2)
.connect(&conn_str)
.await
.expect("Failed to connect to test database");
let verification_code: String = sqlx::query_scalar!(
"SELECT email_confirmation_code FROM users WHERE did = $1",
did
)
.fetch_one(&pool)
.await
.expect("Failed to get verification code")
.expect("No verification code found");
let confirm_res = http_client
.post(format!("{}/xrpc/com.atproto.server.confirmSignup", url))
.json(&json!({
"did": did,
"verificationCode": verification_code
}))
.send()
.await
.unwrap();
assert_eq!(confirm_res.status(), StatusCode::OK);
let confirmed: Value = confirm_res.json().await.unwrap();
let refresh_jwt = confirmed["refreshJwt"].as_str().unwrap().to_string();
let first_refresh = http_client
.post(format!("{}/xrpc/com.atproto.server.refreshSession", url))
@@ -980,24 +1010,7 @@ async fn test_jwt_security_deleted_session_rejected() {
let url = base_url().await;
let http_client = client();
let ts = Utc::now().timestamp_millis();
let handle = format!("del-sess-{}", ts);
let email = format!("del-sess-{}@example.com", ts);
let password = "test-password-123";
let create_res = http_client
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({
"handle": handle,
"email": email,
"password": password
}))
.send()
.await
.unwrap();
let account: Value = create_res.json().await.unwrap();
let access_jwt = account["accessJwt"].as_str().unwrap().to_string();
let (access_jwt, _did) = create_account_and_login(&http_client).await;
let get_res = http_client
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
@@ -1029,24 +1042,7 @@ async fn test_jwt_security_deactivated_account_rejected() {
let url = base_url().await;
let http_client = client();
let ts = Utc::now().timestamp_millis();
let handle = format!("deact-jwt-{}", ts);
let email = format!("deact-jwt-{}@example.com", ts);
let password = "test-password-123";
let create_res = http_client
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({
"handle": handle,
"email": email,
"password": password
}))
.send()
.await
.unwrap();
let account: Value = create_res.json().await.unwrap();
let access_jwt = account["accessJwt"].as_str().unwrap().to_string();
let (access_jwt, _did) = create_account_and_login(&http_client).await;
let deact_res = http_client
.post(format!("{}/xrpc/com.atproto.server.deactivateAccount", url))
+545 -52
View File
@@ -663,58 +663,6 @@ async fn test_authorization_cannot_delete_other_record() {
assert_eq!(get_res.status(), StatusCode::OK, "Record should still exist");
}
#[tokio::test]
async fn test_list_records_pagination() {
let client = client();
let (did, jwt) = setup_new_user("list-pagination").await;
for i in 0..5 {
tokio::time::sleep(Duration::from_millis(50)).await;
create_post(&client, &did, &jwt, &format!("Post number {}", i)).await;
}
let list_res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("limit", "2"),
])
.send()
.await
.expect("Failed to list records");
assert_eq!(list_res.status(), StatusCode::OK);
let list_body: Value = list_res.json().await.unwrap();
let records = list_body["records"].as_array().unwrap();
assert_eq!(records.len(), 2, "Should return 2 records with limit=2");
if let Some(cursor) = list_body["cursor"].as_str() {
let list_page2_res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("limit", "2"),
("cursor", cursor),
])
.send()
.await
.expect("Failed to list records page 2");
assert_eq!(list_page2_res.status(), StatusCode::OK);
let page2_body: Value = list_page2_res.json().await.unwrap();
let page2_records = page2_body["records"].as_array().unwrap();
assert_eq!(page2_records.len(), 2, "Page 2 should have 2 more records");
}
}
#[tokio::test]
async fn test_apply_writes_batch_lifecycle() {
let client = client();
@@ -885,3 +833,548 @@ async fn test_apply_writes_batch_lifecycle() {
"Batch-deleted post should be gone"
);
}
async fn create_post_with_rkey(
client: &reqwest::Client,
did: &str,
jwt: &str,
rkey: &str,
text: &str,
) -> (String, String) {
let payload = json!({
"repo": did,
"collection": "app.bsky.feed.post",
"rkey": rkey,
"record": {
"$type": "app.bsky.feed.post",
"text": text,
"createdAt": Utc::now().to_rfc3339()
}
});
let res = client
.post(format!(
"{}/xrpc/com.atproto.repo.putRecord",
base_url().await
))
.bearer_auth(jwt)
.json(&payload)
.send()
.await
.expect("Failed to create record");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
(
body["uri"].as_str().unwrap().to_string(),
body["cid"].as_str().unwrap().to_string(),
)
}
#[tokio::test]
async fn test_list_records_default_order() {
let client = client();
let (did, jwt) = setup_new_user("list-default-order").await;
create_post_with_rkey(&client, &did, &jwt, "aaaa", "First post").await;
tokio::time::sleep(Duration::from_millis(50)).await;
create_post_with_rkey(&client, &did, &jwt, "bbbb", "Second post").await;
tokio::time::sleep(Duration::from_millis(50)).await;
create_post_with_rkey(&client, &did, &jwt, "cccc", "Third post").await;
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
])
.send()
.await
.expect("Failed to list records");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let records = body["records"].as_array().unwrap();
assert_eq!(records.len(), 3);
let rkeys: Vec<&str> = records
.iter()
.map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
.collect();
assert_eq!(rkeys, vec!["cccc", "bbbb", "aaaa"], "Default order should be DESC (newest first)");
}
#[tokio::test]
async fn test_list_records_reverse_true() {
let client = client();
let (did, jwt) = setup_new_user("list-reverse").await;
create_post_with_rkey(&client, &did, &jwt, "aaaa", "First post").await;
tokio::time::sleep(Duration::from_millis(50)).await;
create_post_with_rkey(&client, &did, &jwt, "bbbb", "Second post").await;
tokio::time::sleep(Duration::from_millis(50)).await;
create_post_with_rkey(&client, &did, &jwt, "cccc", "Third post").await;
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("reverse", "true"),
])
.send()
.await
.expect("Failed to list records");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let records = body["records"].as_array().unwrap();
let rkeys: Vec<&str> = records
.iter()
.map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
.collect();
assert_eq!(rkeys, vec!["aaaa", "bbbb", "cccc"], "reverse=true should give ASC order (oldest first)");
}
#[tokio::test]
async fn test_list_records_cursor_pagination() {
let client = client();
let (did, jwt) = setup_new_user("list-cursor").await;
for i in 0..5 {
create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await;
tokio::time::sleep(Duration::from_millis(50)).await;
}
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("limit", "2"),
])
.send()
.await
.expect("Failed to list records");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let records = body["records"].as_array().unwrap();
assert_eq!(records.len(), 2);
let cursor = body["cursor"].as_str().expect("Should have cursor with more records");
let res2 = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("limit", "2"),
("cursor", cursor),
])
.send()
.await
.expect("Failed to list records with cursor");
assert_eq!(res2.status(), StatusCode::OK);
let body2: Value = res2.json().await.unwrap();
let records2 = body2["records"].as_array().unwrap();
assert_eq!(records2.len(), 2);
let all_uris: Vec<&str> = records
.iter()
.chain(records2.iter())
.map(|r| r["uri"].as_str().unwrap())
.collect();
let unique_uris: std::collections::HashSet<&str> = all_uris.iter().copied().collect();
assert_eq!(all_uris.len(), unique_uris.len(), "Cursor pagination should not repeat records");
}
#[tokio::test]
async fn test_list_records_rkey_start() {
let client = client();
let (did, jwt) = setup_new_user("list-rkey-start").await;
create_post_with_rkey(&client, &did, &jwt, "aaaa", "First").await;
create_post_with_rkey(&client, &did, &jwt, "bbbb", "Second").await;
create_post_with_rkey(&client, &did, &jwt, "cccc", "Third").await;
create_post_with_rkey(&client, &did, &jwt, "dddd", "Fourth").await;
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("rkeyStart", "bbbb"),
("reverse", "true"),
])
.send()
.await
.expect("Failed to list records");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let records = body["records"].as_array().unwrap();
let rkeys: Vec<&str> = records
.iter()
.map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
.collect();
for rkey in &rkeys {
assert!(*rkey >= "bbbb", "rkeyStart should filter records >= start");
}
}
#[tokio::test]
async fn test_list_records_rkey_end() {
let client = client();
let (did, jwt) = setup_new_user("list-rkey-end").await;
create_post_with_rkey(&client, &did, &jwt, "aaaa", "First").await;
create_post_with_rkey(&client, &did, &jwt, "bbbb", "Second").await;
create_post_with_rkey(&client, &did, &jwt, "cccc", "Third").await;
create_post_with_rkey(&client, &did, &jwt, "dddd", "Fourth").await;
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("rkeyEnd", "cccc"),
("reverse", "true"),
])
.send()
.await
.expect("Failed to list records");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let records = body["records"].as_array().unwrap();
let rkeys: Vec<&str> = records
.iter()
.map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
.collect();
for rkey in &rkeys {
assert!(*rkey <= "cccc", "rkeyEnd should filter records <= end");
}
}
#[tokio::test]
async fn test_list_records_rkey_range() {
let client = client();
let (did, jwt) = setup_new_user("list-rkey-range").await;
create_post_with_rkey(&client, &did, &jwt, "aaaa", "First").await;
create_post_with_rkey(&client, &did, &jwt, "bbbb", "Second").await;
create_post_with_rkey(&client, &did, &jwt, "cccc", "Third").await;
create_post_with_rkey(&client, &did, &jwt, "dddd", "Fourth").await;
create_post_with_rkey(&client, &did, &jwt, "eeee", "Fifth").await;
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("rkeyStart", "bbbb"),
("rkeyEnd", "dddd"),
("reverse", "true"),
])
.send()
.await
.expect("Failed to list records");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let records = body["records"].as_array().unwrap();
let rkeys: Vec<&str> = records
.iter()
.map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
.collect();
for rkey in &rkeys {
assert!(*rkey >= "bbbb" && *rkey <= "dddd", "Range should be inclusive, got {}", rkey);
}
assert!(!rkeys.is_empty(), "Should have at least some records in range");
}
#[tokio::test]
async fn test_list_records_limit_clamping_max() {
let client = client();
let (did, jwt) = setup_new_user("list-limit-max").await;
for i in 0..5 {
create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await;
}
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("limit", "1000"),
])
.send()
.await
.expect("Failed to list records");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let records = body["records"].as_array().unwrap();
assert!(records.len() <= 100, "Limit should be clamped to max 100");
}
#[tokio::test]
async fn test_list_records_limit_clamping_min() {
let client = client();
let (did, jwt) = setup_new_user("list-limit-min").await;
create_post_with_rkey(&client, &did, &jwt, "aaaa", "Post").await;
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("limit", "0"),
])
.send()
.await
.expect("Failed to list records");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let records = body["records"].as_array().unwrap();
assert!(records.len() >= 1, "Limit should be clamped to min 1");
}
#[tokio::test]
async fn test_list_records_empty_collection() {
let client = client();
let (did, _jwt) = setup_new_user("list-empty").await;
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
])
.send()
.await
.expect("Failed to list records");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let records = body["records"].as_array().unwrap();
assert!(records.is_empty(), "Empty collection should return empty array");
assert!(body["cursor"].is_null(), "Empty collection should have no cursor");
}
#[tokio::test]
async fn test_list_records_exact_limit() {
let client = client();
let (did, jwt) = setup_new_user("list-exact-limit").await;
for i in 0..10 {
create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await;
}
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("limit", "5"),
])
.send()
.await
.expect("Failed to list records");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let records = body["records"].as_array().unwrap();
assert_eq!(records.len(), 5, "Should return exactly 5 records when limit=5");
}
#[tokio::test]
async fn test_list_records_cursor_exhaustion() {
let client = client();
let (did, jwt) = setup_new_user("list-cursor-exhaust").await;
for i in 0..3 {
create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await;
}
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("limit", "10"),
])
.send()
.await
.expect("Failed to list records");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let records = body["records"].as_array().unwrap();
assert_eq!(records.len(), 3);
}
#[tokio::test]
async fn test_list_records_repo_not_found() {
let client = client();
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", "did:plc:nonexistent12345"),
("collection", "app.bsky.feed.post"),
])
.send()
.await
.expect("Failed to list records");
assert_eq!(res.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_list_records_includes_cid() {
let client = client();
let (did, jwt) = setup_new_user("list-includes-cid").await;
create_post_with_rkey(&client, &did, &jwt, "test", "Test post").await;
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
])
.send()
.await
.expect("Failed to list records");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let records = body["records"].as_array().unwrap();
for record in records {
assert!(record["uri"].is_string(), "Record should have uri");
assert!(record["cid"].is_string(), "Record should have cid");
assert!(record["value"].is_object(), "Record should have value");
let cid = record["cid"].as_str().unwrap();
assert!(cid.starts_with("bafy"), "CID should be valid");
}
}
#[tokio::test]
async fn test_list_records_cursor_with_reverse() {
let client = client();
let (did, jwt) = setup_new_user("list-cursor-reverse").await;
for i in 0..5 {
create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await;
}
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("limit", "2"),
("reverse", "true"),
])
.send()
.await
.expect("Failed to list records");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let records = body["records"].as_array().unwrap();
let first_rkeys: Vec<&str> = records
.iter()
.map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
.collect();
assert_eq!(first_rkeys, vec!["post00", "post01"], "First page with reverse should start from oldest");
if let Some(cursor) = body["cursor"].as_str() {
let res2 = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("limit", "2"),
("reverse", "true"),
("cursor", cursor),
])
.send()
.await
.expect("Failed to list records with cursor");
let body2: Value = res2.json().await.unwrap();
let records2 = body2["records"].as_array().unwrap();
let second_rkeys: Vec<&str> = records2
.iter()
.map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
.collect();
assert_eq!(second_rkeys, vec!["post02", "post03"], "Second page should continue in ASC order");
}
}
+18 -7
View File
@@ -58,6 +58,10 @@ async fn test_session_lifecycle_multiple_sessions() {
.await
.expect("Failed to create account");
assert_eq!(create_res.status(), StatusCode::OK);
let create_body: Value = create_res.json().await.unwrap();
let did = create_body["did"].as_str().unwrap();
let _ = verify_new_account(&client, did).await;
let login_payload = json!({
"identifier": handle,
@@ -128,7 +132,7 @@ async fn test_session_lifecycle_refresh_invalidates_old() {
"email": email,
"password": password
});
client
let create_res = client
.post(format!(
"{}/xrpc/com.atproto.server.createAccount",
base_url().await
@@ -137,6 +141,10 @@ async fn test_session_lifecycle_refresh_invalidates_old() {
.send()
.await
.expect("Failed to create account");
let create_body: Value = create_res.json().await.unwrap();
let did = create_body["did"].as_str().unwrap();
let _ = verify_new_account(&client, did).await;
let login_payload = json!({
"identifier": handle,
@@ -209,14 +217,16 @@ async fn test_app_password_lifecycle() {
assert_eq!(create_res.status(), StatusCode::OK);
let account: Value = create_res.json().await.unwrap();
let jwt = account["accessJwt"].as_str().unwrap();
let did = account["did"].as_str().unwrap();
let jwt = verify_new_account(&client, did).await;
let create_app_pass_res = client
.post(format!(
"{}/xrpc/com.atproto.server.createAppPassword",
base_url().await
))
.bearer_auth(jwt)
.bearer_auth(&jwt)
.json(&json!({ "name": "Test App" }))
.send()
.await
@@ -232,7 +242,7 @@ async fn test_app_password_lifecycle() {
"{}/xrpc/com.atproto.server.listAppPasswords",
base_url().await
))
.bearer_auth(jwt)
.bearer_auth(&jwt)
.send()
.await
.expect("Failed to list app passwords");
@@ -263,7 +273,7 @@ async fn test_app_password_lifecycle() {
"{}/xrpc/com.atproto.server.revokeAppPassword",
base_url().await
))
.bearer_auth(jwt)
.bearer_auth(&jwt)
.json(&json!({ "name": "Test App" }))
.send()
.await
@@ -295,7 +305,7 @@ async fn test_app_password_lifecycle() {
"{}/xrpc/com.atproto.server.listAppPasswords",
base_url().await
))
.bearer_auth(jwt)
.bearer_auth(&jwt)
.send()
.await
.expect("Failed to list after revoke");
@@ -330,7 +340,8 @@ async fn test_account_deactivation_lifecycle() {
assert_eq!(create_res.status(), StatusCode::OK);
let account: Value = create_res.json().await.unwrap();
let did = account["did"].as_str().unwrap().to_string();
let jwt = account["accessJwt"].as_str().unwrap().to_string();
let jwt = verify_new_account(&client, &did).await;
let (post_uri, _) = create_post(&client, &did, &jwt, "Post before deactivation").await;
let post_rkey = post_uri.split('/').last().unwrap();
+2 -1
View File
@@ -441,7 +441,8 @@ async fn test_account_to_post_full_lifecycle() {
assert_eq!(create_account_res.status(), StatusCode::OK);
let account_body: Value = create_account_res.json().await.unwrap();
let did = account_body["did"].as_str().unwrap().to_string();
let access_jwt = account_body["accessJwt"].as_str().unwrap().to_string();
let access_jwt = verify_new_account(&client, &did).await;
let get_session_res = client
.get(format!(
-554
View File
@@ -1,554 +0,0 @@
mod common;
mod helpers;
use common::*;
use helpers::*;
use chrono::Utc;
use reqwest::StatusCode;
use serde_json::{Value, json};
use std::time::Duration;
async fn create_post_with_rkey(
client: &reqwest::Client,
did: &str,
jwt: &str,
rkey: &str,
text: &str,
) -> (String, String) {
let payload = json!({
"repo": did,
"collection": "app.bsky.feed.post",
"rkey": rkey,
"record": {
"$type": "app.bsky.feed.post",
"text": text,
"createdAt": Utc::now().to_rfc3339()
}
});
let res = client
.post(format!(
"{}/xrpc/com.atproto.repo.putRecord",
base_url().await
))
.bearer_auth(jwt)
.json(&payload)
.send()
.await
.expect("Failed to create record");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
(
body["uri"].as_str().unwrap().to_string(),
body["cid"].as_str().unwrap().to_string(),
)
}
#[tokio::test]
async fn test_list_records_default_order() {
let client = client();
let (did, jwt) = setup_new_user("list-default-order").await;
create_post_with_rkey(&client, &did, &jwt, "aaaa", "First post").await;
tokio::time::sleep(Duration::from_millis(50)).await;
create_post_with_rkey(&client, &did, &jwt, "bbbb", "Second post").await;
tokio::time::sleep(Duration::from_millis(50)).await;
create_post_with_rkey(&client, &did, &jwt, "cccc", "Third post").await;
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
])
.send()
.await
.expect("Failed to list records");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let records = body["records"].as_array().unwrap();
assert_eq!(records.len(), 3);
let rkeys: Vec<&str> = records
.iter()
.map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
.collect();
assert_eq!(rkeys, vec!["cccc", "bbbb", "aaaa"], "Default order should be DESC (newest first)");
}
#[tokio::test]
async fn test_list_records_reverse_true() {
let client = client();
let (did, jwt) = setup_new_user("list-reverse").await;
create_post_with_rkey(&client, &did, &jwt, "aaaa", "First post").await;
tokio::time::sleep(Duration::from_millis(50)).await;
create_post_with_rkey(&client, &did, &jwt, "bbbb", "Second post").await;
tokio::time::sleep(Duration::from_millis(50)).await;
create_post_with_rkey(&client, &did, &jwt, "cccc", "Third post").await;
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("reverse", "true"),
])
.send()
.await
.expect("Failed to list records");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let records = body["records"].as_array().unwrap();
let rkeys: Vec<&str> = records
.iter()
.map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
.collect();
assert_eq!(rkeys, vec!["aaaa", "bbbb", "cccc"], "reverse=true should give ASC order (oldest first)");
}
#[tokio::test]
async fn test_list_records_cursor_pagination() {
let client = client();
let (did, jwt) = setup_new_user("list-cursor").await;
for i in 0..5 {
create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await;
tokio::time::sleep(Duration::from_millis(50)).await;
}
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("limit", "2"),
])
.send()
.await
.expect("Failed to list records");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let records = body["records"].as_array().unwrap();
assert_eq!(records.len(), 2);
let cursor = body["cursor"].as_str().expect("Should have cursor with more records");
let res2 = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("limit", "2"),
("cursor", cursor),
])
.send()
.await
.expect("Failed to list records with cursor");
assert_eq!(res2.status(), StatusCode::OK);
let body2: Value = res2.json().await.unwrap();
let records2 = body2["records"].as_array().unwrap();
assert_eq!(records2.len(), 2);
let all_uris: Vec<&str> = records
.iter()
.chain(records2.iter())
.map(|r| r["uri"].as_str().unwrap())
.collect();
let unique_uris: std::collections::HashSet<&str> = all_uris.iter().copied().collect();
assert_eq!(all_uris.len(), unique_uris.len(), "Cursor pagination should not repeat records");
}
#[tokio::test]
async fn test_list_records_rkey_start() {
let client = client();
let (did, jwt) = setup_new_user("list-rkey-start").await;
create_post_with_rkey(&client, &did, &jwt, "aaaa", "First").await;
create_post_with_rkey(&client, &did, &jwt, "bbbb", "Second").await;
create_post_with_rkey(&client, &did, &jwt, "cccc", "Third").await;
create_post_with_rkey(&client, &did, &jwt, "dddd", "Fourth").await;
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("rkeyStart", "bbbb"),
("reverse", "true"),
])
.send()
.await
.expect("Failed to list records");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let records = body["records"].as_array().unwrap();
let rkeys: Vec<&str> = records
.iter()
.map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
.collect();
for rkey in &rkeys {
assert!(*rkey >= "bbbb", "rkeyStart should filter records >= start");
}
}
#[tokio::test]
async fn test_list_records_rkey_end() {
let client = client();
let (did, jwt) = setup_new_user("list-rkey-end").await;
create_post_with_rkey(&client, &did, &jwt, "aaaa", "First").await;
create_post_with_rkey(&client, &did, &jwt, "bbbb", "Second").await;
create_post_with_rkey(&client, &did, &jwt, "cccc", "Third").await;
create_post_with_rkey(&client, &did, &jwt, "dddd", "Fourth").await;
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("rkeyEnd", "cccc"),
("reverse", "true"),
])
.send()
.await
.expect("Failed to list records");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let records = body["records"].as_array().unwrap();
let rkeys: Vec<&str> = records
.iter()
.map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
.collect();
for rkey in &rkeys {
assert!(*rkey <= "cccc", "rkeyEnd should filter records <= end");
}
}
#[tokio::test]
async fn test_list_records_rkey_range() {
let client = client();
let (did, jwt) = setup_new_user("list-rkey-range").await;
create_post_with_rkey(&client, &did, &jwt, "aaaa", "First").await;
create_post_with_rkey(&client, &did, &jwt, "bbbb", "Second").await;
create_post_with_rkey(&client, &did, &jwt, "cccc", "Third").await;
create_post_with_rkey(&client, &did, &jwt, "dddd", "Fourth").await;
create_post_with_rkey(&client, &did, &jwt, "eeee", "Fifth").await;
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("rkeyStart", "bbbb"),
("rkeyEnd", "dddd"),
("reverse", "true"),
])
.send()
.await
.expect("Failed to list records");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let records = body["records"].as_array().unwrap();
let rkeys: Vec<&str> = records
.iter()
.map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
.collect();
for rkey in &rkeys {
assert!(*rkey >= "bbbb" && *rkey <= "dddd", "Range should be inclusive, got {}", rkey);
}
assert!(!rkeys.is_empty(), "Should have at least some records in range");
}
#[tokio::test]
async fn test_list_records_limit_clamping_max() {
let client = client();
let (did, jwt) = setup_new_user("list-limit-max").await;
for i in 0..5 {
create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await;
}
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("limit", "1000"),
])
.send()
.await
.expect("Failed to list records");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let records = body["records"].as_array().unwrap();
assert!(records.len() <= 100, "Limit should be clamped to max 100");
}
#[tokio::test]
async fn test_list_records_limit_clamping_min() {
let client = client();
let (did, jwt) = setup_new_user("list-limit-min").await;
create_post_with_rkey(&client, &did, &jwt, "aaaa", "Post").await;
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("limit", "0"),
])
.send()
.await
.expect("Failed to list records");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let records = body["records"].as_array().unwrap();
assert!(records.len() >= 1, "Limit should be clamped to min 1");
}
#[tokio::test]
async fn test_list_records_empty_collection() {
let client = client();
let (did, _jwt) = setup_new_user("list-empty").await;
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
])
.send()
.await
.expect("Failed to list records");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let records = body["records"].as_array().unwrap();
assert!(records.is_empty(), "Empty collection should return empty array");
assert!(body["cursor"].is_null(), "Empty collection should have no cursor");
}
#[tokio::test]
async fn test_list_records_exact_limit() {
let client = client();
let (did, jwt) = setup_new_user("list-exact-limit").await;
for i in 0..10 {
create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await;
}
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("limit", "5"),
])
.send()
.await
.expect("Failed to list records");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let records = body["records"].as_array().unwrap();
assert_eq!(records.len(), 5, "Should return exactly 5 records when limit=5");
}
#[tokio::test]
async fn test_list_records_cursor_exhaustion() {
let client = client();
let (did, jwt) = setup_new_user("list-cursor-exhaust").await;
for i in 0..3 {
create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await;
}
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("limit", "10"),
])
.send()
.await
.expect("Failed to list records");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let records = body["records"].as_array().unwrap();
assert_eq!(records.len(), 3);
}
#[tokio::test]
async fn test_list_records_repo_not_found() {
let client = client();
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", "did:plc:nonexistent12345"),
("collection", "app.bsky.feed.post"),
])
.send()
.await
.expect("Failed to list records");
assert_eq!(res.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_list_records_includes_cid() {
let client = client();
let (did, jwt) = setup_new_user("list-includes-cid").await;
create_post_with_rkey(&client, &did, &jwt, "test", "Test post").await;
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
])
.send()
.await
.expect("Failed to list records");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let records = body["records"].as_array().unwrap();
for record in records {
assert!(record["uri"].is_string(), "Record should have uri");
assert!(record["cid"].is_string(), "Record should have cid");
assert!(record["value"].is_object(), "Record should have value");
let cid = record["cid"].as_str().unwrap();
assert!(cid.starts_with("bafy"), "CID should be valid");
}
}
#[tokio::test]
async fn test_list_records_cursor_with_reverse() {
let client = client();
let (did, jwt) = setup_new_user("list-cursor-reverse").await;
for i in 0..5 {
create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await;
}
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("limit", "2"),
("reverse", "true"),
])
.send()
.await
.expect("Failed to list records");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let records = body["records"].as_array().unwrap();
let first_rkeys: Vec<&str> = records
.iter()
.map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
.collect();
assert_eq!(first_rkeys, vec!["post00", "post01"], "First page with reverse should start from oldest");
if let Some(cursor) = body["cursor"].as_str() {
let res2 = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("limit", "2"),
("reverse", "true"),
("cursor", cursor),
])
.send()
.await
.expect("Failed to list records with cursor");
let body2: Value = res2.json().await.unwrap();
let records2 = body2["records"].as_array().unwrap();
let second_rkeys: Vec<&str> = records2
.iter()
.map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
.collect();
assert_eq!(second_rkeys, vec!["post02", "post03"], "Second page should continue in ASC order");
}
}
+1 -1
View File
@@ -92,7 +92,7 @@ async fn test_enqueue_welcome() {
.await
.expect("Notification not found");
assert_eq!(row.recipient, user_row.email);
assert_eq!(Some(row.recipient), user_row.email);
assert_eq!(row.subject.as_deref(), Some("Welcome to example.com"));
assert!(row.body.contains(&format!("@{}", user_row.handle)));
assert_eq!(row.notification_type, NotificationType::Welcome);
-456
View File
@@ -205,93 +205,6 @@ async fn test_par_success() {
assert!(request_uri.starts_with("urn:ietf:params:oauth:request_uri:"));
}
#[tokio::test]
async fn test_par_requires_pkce() {
let url = base_url().await;
let client = client();
let redirect_uri = "https://example.com/callback";
let mock_client = setup_mock_client_metadata(redirect_uri).await;
let client_id = mock_client.uri();
let res = client
.post(format!("{}/oauth/par", url))
.form(&[
("response_type", "code"),
("client_id", &client_id),
("redirect_uri", redirect_uri),
("scope", "atproto"),
])
.send()
.await
.expect("Failed to send PAR request");
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
let body: Value = res.json().await.expect("Invalid JSON");
assert_eq!(body["error"], "invalid_request");
}
#[tokio::test]
async fn test_par_requires_s256() {
let url = base_url().await;
let client = client();
let redirect_uri = "https://example.com/callback";
let mock_client = setup_mock_client_metadata(redirect_uri).await;
let client_id = mock_client.uri();
let res = client
.post(format!("{}/oauth/par", url))
.form(&[
("response_type", "code"),
("client_id", &client_id),
("redirect_uri", redirect_uri),
("code_challenge", "test-challenge"),
("code_challenge_method", "plain"),
])
.send()
.await
.expect("Failed to send PAR request");
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
let body: Value = res.json().await.expect("Invalid JSON");
assert_eq!(body["error"], "invalid_request");
assert!(body["error_description"].as_str().unwrap().contains("S256"));
}
#[tokio::test]
async fn test_par_validates_redirect_uri() {
let url = base_url().await;
let client = client();
let registered_redirect = "https://example.com/callback";
let wrong_redirect = "https://evil.com/steal";
let mock_client = setup_mock_client_metadata(registered_redirect).await;
let client_id = mock_client.uri();
let (_, code_challenge) = generate_pkce();
let res = client
.post(format!("{}/oauth/par", url))
.form(&[
("response_type", "code"),
("client_id", &client_id),
("redirect_uri", wrong_redirect),
("code_challenge", &code_challenge),
("code_challenge_method", "S256"),
])
.send()
.await
.expect("Failed to send PAR request");
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
let body: Value = res.json().await.expect("Invalid JSON");
assert_eq!(body["error"], "invalid_request");
}
#[tokio::test]
async fn test_authorize_get_with_valid_request_uri() {
let url = base_url().await;
@@ -603,299 +516,6 @@ async fn test_token_refresh_flow() {
assert_ne!(new_refresh_token, refresh_token, "Refresh token should rotate");
}
#[tokio::test]
async fn test_refresh_token_reuse_detection() {
let url = base_url().await;
let http_client = client();
let ts = Utc::now().timestamp_millis();
let handle = format!("reuse-test-{}", ts);
let email = format!("reuse-test-{}@example.com", ts);
let password = "reuse-test-password";
http_client
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({
"handle": handle,
"email": email,
"password": password
}))
.send()
.await
.unwrap();
let redirect_uri = "https://example.com/reuse-callback";
let mock_client = setup_mock_client_metadata(redirect_uri).await;
let client_id = mock_client.uri();
let (code_verifier, code_challenge) = generate_pkce();
let par_body: Value = http_client
.post(format!("{}/oauth/par", url))
.form(&[
("response_type", "code"),
("client_id", &client_id),
("redirect_uri", redirect_uri),
("code_challenge", &code_challenge),
("code_challenge_method", "S256"),
])
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let request_uri = par_body["request_uri"].as_str().unwrap();
let auth_client = no_redirect_client();
let auth_res = auth_client
.post(format!("{}/oauth/authorize", url))
.form(&[
("request_uri", request_uri),
("username", &handle),
("password", password),
("remember_device", "false"),
])
.send()
.await
.unwrap();
let location = auth_res.headers().get("location").unwrap().to_str().unwrap();
let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap();
let token_body: Value = http_client
.post(format!("{}/oauth/token", url))
.form(&[
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", redirect_uri),
("code_verifier", &code_verifier),
("client_id", &client_id),
])
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let original_refresh_token = token_body["refresh_token"].as_str().unwrap().to_string();
let first_refresh: Value = http_client
.post(format!("{}/oauth/token", url))
.form(&[
("grant_type", "refresh_token"),
("refresh_token", &original_refresh_token),
("client_id", &client_id),
])
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert!(first_refresh["access_token"].is_string(), "First refresh should succeed");
let reuse_res = http_client
.post(format!("{}/oauth/token", url))
.form(&[
("grant_type", "refresh_token"),
("refresh_token", &original_refresh_token),
("client_id", &client_id),
])
.send()
.await
.unwrap();
assert_eq!(reuse_res.status(), StatusCode::BAD_REQUEST, "Reuse should be rejected");
let reuse_body: Value = reuse_res.json().await.unwrap();
assert_eq!(reuse_body["error"], "invalid_grant");
assert!(
reuse_body["error_description"].as_str().unwrap().to_lowercase().contains("reuse"),
"Error should mention reuse"
);
}
#[tokio::test]
async fn test_pkce_verification() {
let url = base_url().await;
let http_client = client();
let ts = Utc::now().timestamp_millis();
let handle = format!("pkce-test-{}", ts);
let email = format!("pkce-test-{}@example.com", ts);
let password = "pkce-test-password";
http_client
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({
"handle": handle,
"email": email,
"password": password
}))
.send()
.await
.unwrap();
let redirect_uri = "https://example.com/pkce-callback";
let mock_client = setup_mock_client_metadata(redirect_uri).await;
let client_id = mock_client.uri();
let (_, code_challenge) = generate_pkce();
let wrong_verifier = "wrong-code-verifier-that-does-not-match";
let par_body: Value = http_client
.post(format!("{}/oauth/par", url))
.form(&[
("response_type", "code"),
("client_id", &client_id),
("redirect_uri", redirect_uri),
("code_challenge", &code_challenge),
("code_challenge_method", "S256"),
])
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let request_uri = par_body["request_uri"].as_str().unwrap();
let auth_client = no_redirect_client();
let auth_res = auth_client
.post(format!("{}/oauth/authorize", url))
.form(&[
("request_uri", request_uri),
("username", &handle),
("password", password),
("remember_device", "false"),
])
.send()
.await
.unwrap();
let location = auth_res.headers().get("location").unwrap().to_str().unwrap();
let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap();
let token_res = http_client
.post(format!("{}/oauth/token", url))
.form(&[
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", redirect_uri),
("code_verifier", wrong_verifier),
("client_id", &client_id),
])
.send()
.await
.unwrap();
assert_eq!(token_res.status(), StatusCode::BAD_REQUEST);
let token_body: Value = token_res.json().await.unwrap();
assert_eq!(token_body["error"], "invalid_grant");
assert!(token_body["error_description"].as_str().unwrap().contains("PKCE"));
}
#[tokio::test]
async fn test_authorization_code_cannot_be_reused() {
let url = base_url().await;
let http_client = client();
let ts = Utc::now().timestamp_millis();
let handle = format!("code-reuse-{}", ts);
let email = format!("code-reuse-{}@example.com", ts);
let password = "code-reuse-password";
http_client
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({
"handle": handle,
"email": email,
"password": password
}))
.send()
.await
.unwrap();
let redirect_uri = "https://example.com/code-reuse-callback";
let mock_client = setup_mock_client_metadata(redirect_uri).await;
let client_id = mock_client.uri();
let (code_verifier, code_challenge) = generate_pkce();
let par_body: Value = http_client
.post(format!("{}/oauth/par", url))
.form(&[
("response_type", "code"),
("client_id", &client_id),
("redirect_uri", redirect_uri),
("code_challenge", &code_challenge),
("code_challenge_method", "S256"),
])
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let request_uri = par_body["request_uri"].as_str().unwrap();
let auth_client = no_redirect_client();
let auth_res = auth_client
.post(format!("{}/oauth/authorize", url))
.form(&[
("request_uri", request_uri),
("username", &handle),
("password", password),
("remember_device", "false"),
])
.send()
.await
.unwrap();
let location = auth_res.headers().get("location").unwrap().to_str().unwrap();
let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap();
let first_token_res = http_client
.post(format!("{}/oauth/token", url))
.form(&[
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", redirect_uri),
("code_verifier", &code_verifier),
("client_id", &client_id),
])
.send()
.await
.unwrap();
assert_eq!(first_token_res.status(), StatusCode::OK, "First use should succeed");
let second_token_res = http_client
.post(format!("{}/oauth/token", url))
.form(&[
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", redirect_uri),
("code_verifier", &code_verifier),
("client_id", &client_id),
])
.send()
.await
.unwrap();
assert_eq!(second_token_res.status(), StatusCode::BAD_REQUEST, "Second use should fail");
let error_body: Value = second_token_res.json().await.unwrap();
assert_eq!(error_body["error"], "invalid_grant");
}
#[tokio::test]
async fn test_wrong_credentials_denied() {
let url = base_url().await;
@@ -1105,82 +725,6 @@ async fn test_invalid_refresh_token() {
assert_eq!(body["error"], "invalid_grant");
}
#[tokio::test]
async fn test_deactivated_account_cannot_authorize() {
let url = base_url().await;
let http_client = client();
let ts = Utc::now().timestamp_millis();
let handle = format!("deact-oauth-{}", ts);
let email = format!("deact-oauth-{}@example.com", ts);
let password = "deact-oauth-password";
let create_res = http_client
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({
"handle": handle,
"email": email,
"password": password
}))
.send()
.await
.unwrap();
assert_eq!(create_res.status(), StatusCode::OK);
let account: Value = create_res.json().await.unwrap();
let access_jwt = account["accessJwt"].as_str().unwrap();
let deact_res = http_client
.post(format!("{}/xrpc/com.atproto.server.deactivateAccount", url))
.header("Authorization", format!("Bearer {}", access_jwt))
.json(&json!({}))
.send()
.await
.unwrap();
assert_eq!(deact_res.status(), StatusCode::OK);
let redirect_uri = "https://example.com/deact-callback";
let mock_client = setup_mock_client_metadata(redirect_uri).await;
let client_id = mock_client.uri();
let (_, code_challenge) = generate_pkce();
let par_body: Value = http_client
.post(format!("{}/oauth/par", url))
.form(&[
("response_type", "code"),
("client_id", &client_id),
("redirect_uri", redirect_uri),
("code_challenge", &code_challenge),
("code_challenge_method", "S256"),
])
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let request_uri = par_body["request_uri"].as_str().unwrap();
let auth_res = http_client
.post(format!("{}/oauth/authorize", url))
.header("Accept", "application/json")
.form(&[
("request_uri", request_uri),
("username", &handle),
("password", password),
("remember_device", "false"),
])
.send()
.await
.unwrap();
assert_eq!(auth_res.status(), StatusCode::FORBIDDEN, "Deactivated account should not be able to authorize");
let body: Value = auth_res.json().await.unwrap();
assert_eq!(body["error"], "access_denied");
}
#[tokio::test]
async fn test_expired_authorization_request() {
let url = base_url().await;
-357
View File
@@ -1,357 +0,0 @@
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use bspds::oauth::dpop::{DPoPVerifier, compute_jwk_thumbprint, DPoPJwk};
use chrono::Utc;
use serde_json::json;
fn create_dpop_proof(
method: &str,
uri: &str,
nonce: Option<&str>,
ath: Option<&str>,
iat_offset_secs: i64,
) -> String {
use p256::ecdsa::{SigningKey, Signature, signature::Signer};
let signing_key = SigningKey::random(&mut rand::thread_rng());
let verifying_key = signing_key.verifying_key();
let point = verifying_key.to_encoded_point(false);
let x = URL_SAFE_NO_PAD.encode(point.x().unwrap());
let y = URL_SAFE_NO_PAD.encode(point.y().unwrap());
let jwk = json!({
"kty": "EC",
"crv": "P-256",
"x": x,
"y": y
});
let header = json!({
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": jwk
});
let mut payload = json!({
"jti": format!("unique-{}", Utc::now().timestamp_nanos_opt().unwrap_or(0)),
"htm": method,
"htu": uri,
"iat": Utc::now().timestamp() + iat_offset_secs
});
if let Some(n) = nonce {
payload["nonce"] = json!(n);
}
if let Some(a) = ath {
payload["ath"] = json!(a);
}
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
let signing_input = format!("{}.{}", header_b64, payload_b64);
let signature: Signature = signing_key.sign(signing_input.as_bytes());
let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
format!("{}.{}", signing_input, signature_b64)
}
#[test]
fn test_dpop_nonce_generation() {
let secret = b"test-dpop-secret-32-bytes-long!!";
let verifier = DPoPVerifier::new(secret);
let nonce1 = verifier.generate_nonce();
let nonce2 = verifier.generate_nonce();
assert!(!nonce1.is_empty());
assert!(!nonce2.is_empty());
}
#[test]
fn test_dpop_nonce_validation_success() {
let secret = b"test-dpop-secret-32-bytes-long!!";
let verifier = DPoPVerifier::new(secret);
let nonce = verifier.generate_nonce();
let result = verifier.validate_nonce(&nonce);
assert!(result.is_ok(), "Valid nonce should pass: {:?}", result);
}
#[test]
fn test_dpop_nonce_wrong_secret() {
let secret1 = b"test-dpop-secret-32-bytes-long!!";
let secret2 = b"different-secret-32-bytes-long!!";
let verifier1 = DPoPVerifier::new(secret1);
let verifier2 = DPoPVerifier::new(secret2);
let nonce = verifier1.generate_nonce();
let result = verifier2.validate_nonce(&nonce);
assert!(result.is_err(), "Nonce from different secret should fail");
}
#[test]
fn test_dpop_nonce_invalid_format() {
let secret = b"test-dpop-secret-32-bytes-long!!";
let verifier = DPoPVerifier::new(secret);
assert!(verifier.validate_nonce("invalid").is_err());
assert!(verifier.validate_nonce("").is_err());
assert!(verifier.validate_nonce("!!!not-base64!!!").is_err());
}
#[test]
fn test_jwk_thumbprint_ec_p256() {
let jwk = DPoPJwk {
kty: "EC".to_string(),
crv: Some("P-256".to_string()),
x: Some("WbbXrPhtCg66wuF0NLhzXxF5PFzNZ7wNJm9M_1pCcXY".to_string()),
y: Some("DubR6_2kU1H5EYhbcNpYZGy1EY6GEKKxv6PYx8VW0rA".to_string()),
};
let thumbprint = compute_jwk_thumbprint(&jwk);
assert!(thumbprint.is_ok());
let tp = thumbprint.unwrap();
assert!(!tp.is_empty());
assert!(tp.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_'));
}
#[test]
fn test_jwk_thumbprint_ec_secp256k1() {
let jwk = DPoPJwk {
kty: "EC".to_string(),
crv: Some("secp256k1".to_string()),
x: Some("some_x_value".to_string()),
y: Some("some_y_value".to_string()),
};
let thumbprint = compute_jwk_thumbprint(&jwk);
assert!(thumbprint.is_ok());
}
#[test]
fn test_jwk_thumbprint_okp_ed25519() {
let jwk = DPoPJwk {
kty: "OKP".to_string(),
crv: Some("Ed25519".to_string()),
x: Some("some_x_value".to_string()),
y: None,
};
let thumbprint = compute_jwk_thumbprint(&jwk);
assert!(thumbprint.is_ok());
}
#[test]
fn test_jwk_thumbprint_missing_crv() {
let jwk = DPoPJwk {
kty: "EC".to_string(),
crv: None,
x: Some("x".to_string()),
y: Some("y".to_string()),
};
let thumbprint = compute_jwk_thumbprint(&jwk);
assert!(thumbprint.is_err());
}
#[test]
fn test_jwk_thumbprint_missing_x() {
let jwk = DPoPJwk {
kty: "EC".to_string(),
crv: Some("P-256".to_string()),
x: None,
y: Some("y".to_string()),
};
let thumbprint = compute_jwk_thumbprint(&jwk);
assert!(thumbprint.is_err());
}
#[test]
fn test_jwk_thumbprint_missing_y_for_ec() {
let jwk = DPoPJwk {
kty: "EC".to_string(),
crv: Some("P-256".to_string()),
x: Some("x".to_string()),
y: None,
};
let thumbprint = compute_jwk_thumbprint(&jwk);
assert!(thumbprint.is_err());
}
#[test]
fn test_jwk_thumbprint_unsupported_key_type() {
let jwk = DPoPJwk {
kty: "RSA".to_string(),
crv: None,
x: None,
y: None,
};
let thumbprint = compute_jwk_thumbprint(&jwk);
assert!(thumbprint.is_err());
}
#[test]
fn test_jwk_thumbprint_deterministic() {
let jwk = DPoPJwk {
kty: "EC".to_string(),
crv: Some("P-256".to_string()),
x: Some("WbbXrPhtCg66wuF0NLhzXxF5PFzNZ7wNJm9M_1pCcXY".to_string()),
y: Some("DubR6_2kU1H5EYhbcNpYZGy1EY6GEKKxv6PYx8VW0rA".to_string()),
};
let tp1 = compute_jwk_thumbprint(&jwk).unwrap();
let tp2 = compute_jwk_thumbprint(&jwk).unwrap();
assert_eq!(tp1, tp2, "Thumbprint should be deterministic");
}
#[test]
fn test_dpop_proof_invalid_format() {
let secret = b"test-dpop-secret-32-bytes-long!!";
let verifier = DPoPVerifier::new(secret);
let result = verifier.verify_proof("not.enough.parts", "POST", "https://example.com", None);
assert!(result.is_err());
let result = verifier.verify_proof("invalid", "POST", "https://example.com", None);
assert!(result.is_err());
}
#[test]
fn test_dpop_proof_invalid_typ() {
let secret = b"test-dpop-secret-32-bytes-long!!";
let verifier = DPoPVerifier::new(secret);
let header = json!({
"typ": "JWT",
"alg": "ES256",
"jwk": {
"kty": "EC",
"crv": "P-256",
"x": "x",
"y": "y"
}
});
let payload = json!({
"jti": "unique",
"htm": "POST",
"htu": "https://example.com",
"iat": Utc::now().timestamp()
});
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
let proof = format!("{}.{}.sig", header_b64, payload_b64);
let result = verifier.verify_proof(&proof, "POST", "https://example.com", None);
assert!(result.is_err());
}
#[test]
fn test_dpop_proof_method_mismatch() {
let secret = b"test-dpop-secret-32-bytes-long!!";
let verifier = DPoPVerifier::new(secret);
let proof = create_dpop_proof("POST", "https://example.com/token", None, None, 0);
let result = verifier.verify_proof(&proof, "GET", "https://example.com/token", None);
assert!(result.is_err());
}
#[test]
fn test_dpop_proof_uri_mismatch() {
let secret = b"test-dpop-secret-32-bytes-long!!";
let verifier = DPoPVerifier::new(secret);
let proof = create_dpop_proof("POST", "https://example.com/token", None, None, 0);
let result = verifier.verify_proof(&proof, "POST", "https://other.com/token", None);
assert!(result.is_err());
}
#[test]
fn test_dpop_proof_iat_too_old() {
let secret = b"test-dpop-secret-32-bytes-long!!";
let verifier = DPoPVerifier::new(secret);
let proof = create_dpop_proof("POST", "https://example.com/token", None, None, -600);
let result = verifier.verify_proof(&proof, "POST", "https://example.com/token", None);
assert!(result.is_err());
}
#[test]
fn test_dpop_proof_iat_future() {
let secret = b"test-dpop-secret-32-bytes-long!!";
let verifier = DPoPVerifier::new(secret);
let proof = create_dpop_proof("POST", "https://example.com/token", None, None, 600);
let result = verifier.verify_proof(&proof, "POST", "https://example.com/token", None);
assert!(result.is_err());
}
#[test]
fn test_dpop_proof_ath_mismatch() {
let secret = b"test-dpop-secret-32-bytes-long!!";
let verifier = DPoPVerifier::new(secret);
let proof = create_dpop_proof(
"GET",
"https://example.com/resource",
None,
Some("wrong_hash"),
0,
);
let result = verifier.verify_proof(
&proof,
"GET",
"https://example.com/resource",
Some("correct_hash"),
);
assert!(result.is_err());
}
#[test]
fn test_dpop_proof_missing_ath_when_required() {
let secret = b"test-dpop-secret-32-bytes-long!!";
let verifier = DPoPVerifier::new(secret);
let proof = create_dpop_proof("GET", "https://example.com/resource", None, None, 0);
let result = verifier.verify_proof(
&proof,
"GET",
"https://example.com/resource",
Some("expected_hash"),
);
assert!(result.is_err());
}
#[test]
fn test_dpop_proof_uri_ignores_query_params() {
let secret = b"test-dpop-secret-32-bytes-long!!";
let verifier = DPoPVerifier::new(secret);
let proof = create_dpop_proof("POST", "https://example.com/token", None, None, 0);
let result = verifier.verify_proof(
&proof,
"POST",
"https://example.com/token?foo=bar",
None,
);
assert!(result.is_ok(), "Query params should be ignored: {:?}", result);
}
+5
View File
@@ -4,6 +4,7 @@ mod helpers;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::Utc;
use common::{base_url, client};
use helpers::verify_new_account;
use reqwest::{redirect, StatusCode};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
@@ -83,6 +84,8 @@ async fn create_user_and_oauth_session(handle_prefix: &str, redirect_uri: &str)
let account: Value = create_res.json().await.unwrap();
let user_did = account["did"].as_str().unwrap().to_string();
let _ = verify_new_account(&http_client, &user_did).await;
let mock_client = setup_mock_client_metadata(redirect_uri).await;
let client_id = mock_client.uri();
@@ -589,6 +592,8 @@ async fn test_oauth_multiple_clients_same_user() {
let account: Value = create_res.json().await.unwrap();
let user_did = account["did"].as_str().unwrap();
let _ = verify_new_account(&http_client, user_did).await;
let mock_client1 = setup_mock_client_metadata("https://client1.example.com/callback").await;
let client1_id = mock_client1.uri();
+358 -1
View File
@@ -8,6 +8,7 @@ use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use bspds::oauth::dpop::{DPoPVerifier, DPoPJwk, compute_jwk_thumbprint};
use chrono::Utc;
use common::{base_url, client};
use helpers::verify_new_account;
use reqwest::{redirect, StatusCode};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
@@ -698,7 +699,9 @@ async fn test_security_deactivated_account_blocked() {
assert_eq!(create_res.status(), StatusCode::OK);
let account: Value = create_res.json().await.unwrap();
let access_jwt = account["accessJwt"].as_str().unwrap();
let did = account["did"].as_str().unwrap();
let access_jwt = verify_new_account(&http_client, did).await;
let deact_res = http_client
.post(format!("{}/xrpc/com.atproto.server.deactivateAccount", url))
@@ -1449,6 +1452,7 @@ async fn test_security_revoked_token_rejected() {
}
#[tokio::test]
#[ignore = "rate limiting is disabled in test environment"]
async fn test_security_oauth_authorize_rate_limiting() {
let url = base_url().await;
let http_client = no_redirect_client();
@@ -1511,3 +1515,356 @@ async fn test_security_oauth_authorize_rate_limiting() {
rate_limited_count
);
}
fn create_dpop_proof(
method: &str,
uri: &str,
nonce: Option<&str>,
ath: Option<&str>,
iat_offset_secs: i64,
) -> String {
use p256::ecdsa::{SigningKey, Signature, signature::Signer};
let signing_key = SigningKey::random(&mut rand::thread_rng());
let verifying_key = signing_key.verifying_key();
let point = verifying_key.to_encoded_point(false);
let x = URL_SAFE_NO_PAD.encode(point.x().unwrap());
let y = URL_SAFE_NO_PAD.encode(point.y().unwrap());
let jwk = json!({
"kty": "EC",
"crv": "P-256",
"x": x,
"y": y
});
let header = json!({
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": jwk
});
let mut payload = json!({
"jti": format!("unique-{}", Utc::now().timestamp_nanos_opt().unwrap_or(0)),
"htm": method,
"htu": uri,
"iat": Utc::now().timestamp() + iat_offset_secs
});
if let Some(n) = nonce {
payload["nonce"] = json!(n);
}
if let Some(a) = ath {
payload["ath"] = json!(a);
}
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
let signing_input = format!("{}.{}", header_b64, payload_b64);
let signature: Signature = signing_key.sign(signing_input.as_bytes());
let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
format!("{}.{}", signing_input, signature_b64)
}
#[test]
fn test_dpop_nonce_generation() {
let secret = b"test-dpop-secret-32-bytes-long!!";
let verifier = DPoPVerifier::new(secret);
let nonce1 = verifier.generate_nonce();
let nonce2 = verifier.generate_nonce();
assert!(!nonce1.is_empty());
assert!(!nonce2.is_empty());
}
#[test]
fn test_dpop_nonce_validation_success() {
let secret = b"test-dpop-secret-32-bytes-long!!";
let verifier = DPoPVerifier::new(secret);
let nonce = verifier.generate_nonce();
let result = verifier.validate_nonce(&nonce);
assert!(result.is_ok(), "Valid nonce should pass: {:?}", result);
}
#[test]
fn test_dpop_nonce_wrong_secret() {
let secret1 = b"test-dpop-secret-32-bytes-long!!";
let secret2 = b"different-secret-32-bytes-long!!";
let verifier1 = DPoPVerifier::new(secret1);
let verifier2 = DPoPVerifier::new(secret2);
let nonce = verifier1.generate_nonce();
let result = verifier2.validate_nonce(&nonce);
assert!(result.is_err(), "Nonce from different secret should fail");
}
#[test]
fn test_dpop_nonce_invalid_format() {
let secret = b"test-dpop-secret-32-bytes-long!!";
let verifier = DPoPVerifier::new(secret);
assert!(verifier.validate_nonce("invalid").is_err());
assert!(verifier.validate_nonce("").is_err());
assert!(verifier.validate_nonce("!!!not-base64!!!").is_err());
}
#[test]
fn test_jwk_thumbprint_ec_p256() {
let jwk = DPoPJwk {
kty: "EC".to_string(),
crv: Some("P-256".to_string()),
x: Some("WbbXrPhtCg66wuF0NLhzXxF5PFzNZ7wNJm9M_1pCcXY".to_string()),
y: Some("DubR6_2kU1H5EYhbcNpYZGy1EY6GEKKxv6PYx8VW0rA".to_string()),
};
let thumbprint = compute_jwk_thumbprint(&jwk);
assert!(thumbprint.is_ok());
let tp = thumbprint.unwrap();
assert!(!tp.is_empty());
assert!(tp.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_'));
}
#[test]
fn test_jwk_thumbprint_ec_secp256k1() {
let jwk = DPoPJwk {
kty: "EC".to_string(),
crv: Some("secp256k1".to_string()),
x: Some("some_x_value".to_string()),
y: Some("some_y_value".to_string()),
};
let thumbprint = compute_jwk_thumbprint(&jwk);
assert!(thumbprint.is_ok());
}
#[test]
fn test_jwk_thumbprint_okp_ed25519() {
let jwk = DPoPJwk {
kty: "OKP".to_string(),
crv: Some("Ed25519".to_string()),
x: Some("some_x_value".to_string()),
y: None,
};
let thumbprint = compute_jwk_thumbprint(&jwk);
assert!(thumbprint.is_ok());
}
#[test]
fn test_jwk_thumbprint_missing_crv() {
let jwk = DPoPJwk {
kty: "EC".to_string(),
crv: None,
x: Some("x".to_string()),
y: Some("y".to_string()),
};
let thumbprint = compute_jwk_thumbprint(&jwk);
assert!(thumbprint.is_err());
}
#[test]
fn test_jwk_thumbprint_missing_x() {
let jwk = DPoPJwk {
kty: "EC".to_string(),
crv: Some("P-256".to_string()),
x: None,
y: Some("y".to_string()),
};
let thumbprint = compute_jwk_thumbprint(&jwk);
assert!(thumbprint.is_err());
}
#[test]
fn test_jwk_thumbprint_missing_y_for_ec() {
let jwk = DPoPJwk {
kty: "EC".to_string(),
crv: Some("P-256".to_string()),
x: Some("x".to_string()),
y: None,
};
let thumbprint = compute_jwk_thumbprint(&jwk);
assert!(thumbprint.is_err());
}
#[test]
fn test_jwk_thumbprint_unsupported_key_type() {
let jwk = DPoPJwk {
kty: "RSA".to_string(),
crv: None,
x: None,
y: None,
};
let thumbprint = compute_jwk_thumbprint(&jwk);
assert!(thumbprint.is_err());
}
#[test]
fn test_jwk_thumbprint_deterministic() {
let jwk = DPoPJwk {
kty: "EC".to_string(),
crv: Some("P-256".to_string()),
x: Some("WbbXrPhtCg66wuF0NLhzXxF5PFzNZ7wNJm9M_1pCcXY".to_string()),
y: Some("DubR6_2kU1H5EYhbcNpYZGy1EY6GEKKxv6PYx8VW0rA".to_string()),
};
let tp1 = compute_jwk_thumbprint(&jwk).unwrap();
let tp2 = compute_jwk_thumbprint(&jwk).unwrap();
assert_eq!(tp1, tp2, "Thumbprint should be deterministic");
}
#[test]
fn test_dpop_proof_invalid_format() {
let secret = b"test-dpop-secret-32-bytes-long!!";
let verifier = DPoPVerifier::new(secret);
let result = verifier.verify_proof("not.enough.parts", "POST", "https://example.com", None);
assert!(result.is_err());
let result = verifier.verify_proof("invalid", "POST", "https://example.com", None);
assert!(result.is_err());
}
#[test]
fn test_dpop_proof_invalid_typ() {
let secret = b"test-dpop-secret-32-bytes-long!!";
let verifier = DPoPVerifier::new(secret);
let header = json!({
"typ": "JWT",
"alg": "ES256",
"jwk": {
"kty": "EC",
"crv": "P-256",
"x": "x",
"y": "y"
}
});
let payload = json!({
"jti": "unique",
"htm": "POST",
"htu": "https://example.com",
"iat": Utc::now().timestamp()
});
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
let proof = format!("{}.{}.sig", header_b64, payload_b64);
let result = verifier.verify_proof(&proof, "POST", "https://example.com", None);
assert!(result.is_err());
}
#[test]
fn test_dpop_proof_method_mismatch() {
let secret = b"test-dpop-secret-32-bytes-long!!";
let verifier = DPoPVerifier::new(secret);
let proof = create_dpop_proof("POST", "https://example.com/token", None, None, 0);
let result = verifier.verify_proof(&proof, "GET", "https://example.com/token", None);
assert!(result.is_err());
}
#[test]
fn test_dpop_proof_uri_mismatch() {
let secret = b"test-dpop-secret-32-bytes-long!!";
let verifier = DPoPVerifier::new(secret);
let proof = create_dpop_proof("POST", "https://example.com/token", None, None, 0);
let result = verifier.verify_proof(&proof, "POST", "https://other.com/token", None);
assert!(result.is_err());
}
#[test]
fn test_dpop_proof_iat_too_old() {
let secret = b"test-dpop-secret-32-bytes-long!!";
let verifier = DPoPVerifier::new(secret);
let proof = create_dpop_proof("POST", "https://example.com/token", None, None, -600);
let result = verifier.verify_proof(&proof, "POST", "https://example.com/token", None);
assert!(result.is_err());
}
#[test]
fn test_dpop_proof_iat_future() {
let secret = b"test-dpop-secret-32-bytes-long!!";
let verifier = DPoPVerifier::new(secret);
let proof = create_dpop_proof("POST", "https://example.com/token", None, None, 600);
let result = verifier.verify_proof(&proof, "POST", "https://example.com/token", None);
assert!(result.is_err());
}
#[test]
fn test_dpop_proof_ath_mismatch() {
let secret = b"test-dpop-secret-32-bytes-long!!";
let verifier = DPoPVerifier::new(secret);
let proof = create_dpop_proof(
"GET",
"https://example.com/resource",
None,
Some("wrong_hash"),
0,
);
let result = verifier.verify_proof(
&proof,
"GET",
"https://example.com/resource",
Some("correct_hash"),
);
assert!(result.is_err());
}
#[test]
fn test_dpop_proof_missing_ath_when_required() {
let secret = b"test-dpop-secret-32-bytes-long!!";
let verifier = DPoPVerifier::new(secret);
let proof = create_dpop_proof("GET", "https://example.com/resource", None, None, 0);
let result = verifier.verify_proof(
&proof,
"GET",
"https://example.com/resource",
Some("expected_hash"),
);
assert!(result.is_err());
}
#[test]
fn test_dpop_proof_uri_ignores_query_params() {
let secret = b"test-dpop-secret-32-bytes-long!!";
let verifier = DPoPVerifier::new(secret);
let proof = create_dpop_proof("POST", "https://example.com/token", None, None, 0);
let result = verifier.verify_proof(
&proof,
"POST",
"https://example.com/token?foo=bar",
None,
);
assert!(result.is_ok(), "Query params should be ignored: {:?}", result);
}
+9 -1
View File
@@ -1,8 +1,10 @@
mod common;
mod helpers;
use reqwest::StatusCode;
use serde_json::{json, Value};
use sqlx::PgPool;
use helpers::verify_new_account;
async fn get_pool() -> PgPool {
let conn_str = common::get_db_connection_string().await;
@@ -99,6 +101,10 @@ async fn test_reset_password_with_valid_token() {
.await
.expect("Failed to create account");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let did = body["did"].as_str().unwrap();
let _ = verify_new_account(&client, did).await;
let res = client
.post(format!("{}/xrpc/com.atproto.server.requestPasswordReset", base_url))
@@ -270,7 +276,9 @@ async fn test_reset_password_invalidates_sessions() {
.expect("Failed to create account");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.expect("Invalid JSON");
let original_token = body["accessJwt"].as_str().expect("No accessJwt").to_string();
let did = body["did"].as_str().expect("No did");
let original_token = verify_new_account(&client, did).await;
let res = client
.get(format!("{}/xrpc/com.atproto.server.getSession", base_url))
+3
View File
@@ -5,6 +5,7 @@ use reqwest::StatusCode;
use serde_json::json;
#[tokio::test]
#[ignore = "rate limiting is disabled in test environment"]
async fn test_login_rate_limiting() {
let client = client();
let url = format!("{}/xrpc/com.atproto.server.createSession", base_url().await);
@@ -47,6 +48,7 @@ async fn test_login_rate_limiting() {
}
#[tokio::test]
#[ignore = "rate limiting is disabled in test environment"]
async fn test_password_reset_rate_limiting() {
let client = client();
let url = format!(
@@ -91,6 +93,7 @@ async fn test_password_reset_rate_limiting() {
}
#[tokio::test]
#[ignore = "rate limiting is disabled in test environment"]
async fn test_account_creation_rate_limiting() {
let client = client();
let url = format!(
-347
View File
@@ -1,347 +0,0 @@
mod common;
use common::*;
use chrono::Utc;
use reqwest::StatusCode;
use serde_json::{Value, json};
#[tokio::test]
async fn test_get_record_not_found() {
let client = client();
let (_, did) = create_account_and_login(&client).await;
let params = [
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("rkey", "nonexistent"),
];
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.getRecord",
base_url().await
))
.query(&params)
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_put_record_no_auth() {
let client = client();
let payload = json!({
"repo": "did:plc:123",
"collection": "app.bsky.feed.post",
"rkey": "fake",
"record": {}
});
let res = client
.post(format!(
"{}/xrpc/com.atproto.repo.putRecord",
base_url().await
))
.json(&payload)
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
let body: Value = res.json().await.expect("Response was not valid JSON");
assert_eq!(body["error"], "AuthenticationRequired");
}
#[tokio::test]
async fn test_put_record_success() {
let client = client();
let (token, did) = create_account_and_login(&client).await;
let now = Utc::now().to_rfc3339();
let payload = json!({
"repo": did,
"collection": "app.bsky.feed.post",
"rkey": "e2e_test_post",
"record": {
"$type": "app.bsky.feed.post",
"text": "Hello from the e2e test script!",
"createdAt": now
}
});
let res = client
.post(format!(
"{}/xrpc/com.atproto.repo.putRecord",
base_url().await
))
.bearer_auth(token)
.json(&payload)
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.expect("Response was not valid JSON");
assert!(body.get("uri").is_some());
assert!(body.get("cid").is_some());
}
#[tokio::test]
async fn test_get_record_missing_params() {
let client = client();
let params = [("repo", "did:plc:12345")];
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.getRecord",
base_url().await
))
.query(&params)
.send()
.await
.expect("Failed to send request");
assert_eq!(
res.status(),
StatusCode::BAD_REQUEST,
"Expected 400 for missing params"
);
}
#[tokio::test]
async fn test_put_record_mismatched_repo() {
let client = client();
let (token, _) = create_account_and_login(&client).await;
let now = Utc::now().to_rfc3339();
let payload = json!({
"repo": "did:plc:OTHER-USER",
"collection": "app.bsky.feed.post",
"rkey": "e2e_test_post",
"record": {
"$type": "app.bsky.feed.post",
"text": "Hello from the e2e test script!",
"createdAt": now
}
});
let res = client
.post(format!(
"{}/xrpc/com.atproto.repo.putRecord",
base_url().await
))
.bearer_auth(token)
.json(&payload)
.send()
.await
.expect("Failed to send request");
assert!(
res.status() == StatusCode::FORBIDDEN || res.status() == StatusCode::UNAUTHORIZED,
"Expected 403 or 401 for mismatched repo and auth, got {}",
res.status()
);
}
#[tokio::test]
async fn test_put_record_invalid_schema() {
let client = client();
let (token, did) = create_account_and_login(&client).await;
let now = Utc::now().to_rfc3339();
let payload = json!({
"repo": did,
"collection": "app.bsky.feed.post",
"rkey": "e2e_test_invalid",
"record": {
"$type": "app.bsky.feed.post",
"createdAt": now
}
});
let res = client
.post(format!(
"{}/xrpc/com.atproto.repo.putRecord",
base_url().await
))
.bearer_auth(token)
.json(&payload)
.send()
.await
.expect("Failed to send request");
assert_eq!(
res.status(),
StatusCode::BAD_REQUEST,
"Expected 400 for invalid record schema"
);
}
#[tokio::test]
async fn test_list_records() {
let client = client();
let (_, did) = create_account_and_login(&client).await;
let params = [
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("limit", "10"),
];
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&params)
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_describe_repo() {
let client = client();
let (_, did) = create_account_and_login(&client).await;
let params = [("repo", did.as_str())];
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.describeRepo",
base_url().await
))
.query(&params)
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_create_record_success_with_generated_rkey() {
let client = client();
let (token, did) = create_account_and_login(&client).await;
let payload = json!({
"repo": did,
"collection": "app.bsky.feed.post",
"record": {
"$type": "app.bsky.feed.post",
"text": "Hello, world!",
"createdAt": "2025-12-02T12:00:00Z"
}
});
let res = client
.post(format!(
"{}/xrpc/com.atproto.repo.createRecord",
base_url().await
))
.json(&payload)
.bearer_auth(token)
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.expect("Response was not valid JSON");
let uri = body["uri"].as_str().unwrap();
assert!(uri.starts_with(&format!("at://{}/app.bsky.feed.post/", did)));
assert!(body.get("cid").is_some());
}
#[tokio::test]
async fn test_create_record_success_with_provided_rkey() {
let client = client();
let (token, did) = create_account_and_login(&client).await;
let rkey = format!("custom-rkey-{}", Utc::now().timestamp_millis());
let payload = json!({
"repo": did,
"collection": "app.bsky.feed.post",
"rkey": rkey,
"record": {
"$type": "app.bsky.feed.post",
"text": "Hello, world!",
"createdAt": "2025-12-02T12:00:00Z"
}
});
let res = client
.post(format!(
"{}/xrpc/com.atproto.repo.createRecord",
base_url().await
))
.json(&payload)
.bearer_auth(token)
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.expect("Response was not valid JSON");
assert_eq!(
body["uri"],
format!("at://{}/app.bsky.feed.post/{}", did, rkey)
);
assert!(body.get("cid").is_some());
}
#[tokio::test]
async fn test_delete_record() {
let client = client();
let (token, did) = create_account_and_login(&client).await;
let rkey = format!("post_to_delete_{}", Utc::now().timestamp_millis());
let create_payload = json!({
"repo": did,
"collection": "app.bsky.feed.post",
"rkey": rkey,
"record": {
"$type": "app.bsky.feed.post",
"text": "This post will be deleted",
"createdAt": Utc::now().to_rfc3339()
}
});
let create_res = client
.post(format!(
"{}/xrpc/com.atproto.repo.putRecord",
base_url().await
))
.bearer_auth(&token)
.json(&create_payload)
.send()
.await
.expect("Failed to create record");
assert_eq!(create_res.status(), StatusCode::OK);
let delete_payload = json!({
"repo": did,
"collection": "app.bsky.feed.post",
"rkey": rkey
});
let delete_res = client
.post(format!(
"{}/xrpc/com.atproto.repo.deleteRecord",
base_url().await
))
.bearer_auth(&token)
.json(&delete_payload)
.send()
.await
.expect("Failed to send request");
assert_eq!(delete_res.status(), StatusCode::OK);
let get_res = client
.get(format!(
"{}/xrpc/com.atproto.repo.getRecord",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("rkey", rkey.as_str()),
])
.send()
.await
.expect("Failed to verify deletion");
assert_eq!(get_res.status(), StatusCode::NOT_FOUND);
}
+20 -4
View File
@@ -1,5 +1,7 @@
mod common;
mod helpers;
use common::*;
use helpers::verify_new_account;
use reqwest::StatusCode;
use serde_json::{Value, json};
@@ -44,14 +46,21 @@ async fn test_create_session() {
"email": format!("{}@example.com", handle),
"password": "password"
});
let _ = client
let create_res = client
.post(format!(
"{}/xrpc/com.atproto.server.createAccount",
base_url().await
))
.json(&payload)
.send()
.await;
.await
.expect("Failed to create account");
assert_eq!(create_res.status(), StatusCode::OK);
let create_body: Value = create_res.json().await.unwrap();
let did = create_body["did"].as_str().unwrap();
let _ = verify_new_account(&client, did).await;
let payload = json!({
"identifier": handle,
@@ -149,14 +158,21 @@ async fn test_refresh_session() {
"email": format!("{}@example.com", handle),
"password": "password"
});
let _ = client
let create_res = client
.post(format!(
"{}/xrpc/com.atproto.server.createAccount",
base_url().await
))
.json(&payload)
.send()
.await;
.await
.expect("Failed to create account");
assert_eq!(create_res.status(), StatusCode::OK);
let create_body: Value = create_res.json().await.unwrap();
let did = create_body["did"].as_str().unwrap();
let _ = verify_new_account(&client, did).await;
let login_payload = json!({
"identifier": handle,
+10 -3
View File
@@ -1,8 +1,10 @@
mod common;
mod helpers;
use reqwest::StatusCode;
use serde_json::{json, Value};
use sqlx::PgPool;
use helpers::verify_new_account;
async fn get_pool() -> PgPool {
let conn_str = common::get_db_connection_string().await;
@@ -200,8 +202,11 @@ async fn test_create_account_with_reserved_signing_key() {
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
assert!(body["accessJwt"].is_string());
assert!(body["did"].is_string());
let did = body["did"].as_str().unwrap();
let access_jwt = verify_new_account(&client, did).await;
assert!(!access_jwt.is_empty());
let reserved = sqlx::query!(
"SELECT used_at FROM reserved_signing_keys WHERE public_key_did_key = $1",
@@ -337,14 +342,16 @@ async fn test_reserved_key_tokens_work() {
.expect("Failed to create account");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let access_jwt = body["accessJwt"].as_str().unwrap();
let did = body["did"].as_str().unwrap();
let access_jwt = verify_new_account(&client, did).await;
let res = client
.get(format!(
"{}/xrpc/com.atproto.server.getSession",
base_url
))
.bearer_auth(access_jwt)
.bearer_auth(&access_jwt)
.send()
.await
.expect("Failed to get session");