mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-08-18 23:36:06 +00:00
Performance enhancements, overengineering
This commit is contained in:
+113
-9
@@ -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
|
||||
|
||||
+28
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+12
-4
@@ -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"
|
||||
}
|
||||
-14
@@ -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"
|
||||
}
|
||||
+70
@@ -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"
|
||||
}
|
||||
-18
@@ -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"
|
||||
}
|
||||
-16
@@ -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"
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
+71
@@ -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"
|
||||
}
|
||||
+20
@@ -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"
|
||||
}
|
||||
+28
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+18
@@ -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"
|
||||
}
|
||||
+2
-2
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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(¶ms);
|
||||
|
||||
|
||||
+46
-23
@@ -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
@@ -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"})),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Vendored
+10
@@ -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)]
|
||||
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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!({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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(¶ms)
|
||||
.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(¶ms)
|
||||
.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(¶ms)
|
||||
.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(¶ms)
|
||||
.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
@@ -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
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user