diff --git a/.env.example b/.env.example index 44bcedd..328fb69 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.sqlx/query-04c220298334c369872f0b0ad162b992c2353e28257b53f3f10cbff8abb26f5a.json b/.sqlx/query-04c220298334c369872f0b0ad162b992c2353e28257b53f3f10cbff8abb26f5a.json new file mode 100644 index 0000000..a7fb9e8 --- /dev/null +++ b/.sqlx/query-04c220298334c369872f0b0ad162b992c2353e28257b53f3f10cbff8abb26f5a.json @@ -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" +} diff --git a/.sqlx/query-1c831cb6f3b8d01b18feec900148278c2b491418b622da9e75fe1792089e4409.json b/.sqlx/query-1c831cb6f3b8d01b18feec900148278c2b491418b622da9e75fe1792089e4409.json new file mode 100644 index 0000000..a3483ad --- /dev/null +++ b/.sqlx/query-1c831cb6f3b8d01b18feec900148278c2b491418b622da9e75fe1792089e4409.json @@ -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" +} diff --git a/.sqlx/query-0f10bde03edc0233a332e210a84a4186977c71efd3be80e2508a60ea5802cb1b.json b/.sqlx/query-25ac36e9dec1c8e29cbe7cfc954683061c7c2733fa60f91f1c5ced4d00e7bf3d.json similarity index 54% rename from .sqlx/query-0f10bde03edc0233a332e210a84a4186977c71efd3be80e2508a60ea5802cb1b.json rename to .sqlx/query-25ac36e9dec1c8e29cbe7cfc954683061c7c2733fa60f91f1c5ced4d00e7bf3d.json index 5875b3e..8a663b0 100644 --- a/.sqlx/query-0f10bde03edc0233a332e210a84a4186977c71efd3be80e2508a60ea5802cb1b.json +++ b/.sqlx/query-25ac36e9dec1c8e29cbe7cfc954683061c7c2733fa60f91f1c5ced4d00e7bf3d.json @@ -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" } diff --git a/.sqlx/query-3b1176253dc7b94d3fc58c077310d8058f90edf1fa27200b52b464b9c37335dd.json b/.sqlx/query-3b1176253dc7b94d3fc58c077310d8058f90edf1fa27200b52b464b9c37335dd.json deleted file mode 100644 index 5bf483c..0000000 --- a/.sqlx/query-3b1176253dc7b94d3fc58c077310d8058f90edf1fa27200b52b464b9c37335dd.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-8a7a8f0c4c0872c21c46d484219624215bdb14617b9f9a44974e394a28147f70.json b/.sqlx/query-8a7a8f0c4c0872c21c46d484219624215bdb14617b9f9a44974e394a28147f70.json new file mode 100644 index 0000000..d1f930d --- /dev/null +++ b/.sqlx/query-8a7a8f0c4c0872c21c46d484219624215bdb14617b9f9a44974e394a28147f70.json @@ -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" +} diff --git a/.sqlx/query-8a9e71f04ec779d5c10d79582cc398529e01be01a83898df3524bb35e3d2ed14.json b/.sqlx/query-8a9e71f04ec779d5c10d79582cc398529e01be01a83898df3524bb35e3d2ed14.json deleted file mode 100644 index 8d08950..0000000 --- a/.sqlx/query-8a9e71f04ec779d5c10d79582cc398529e01be01a83898df3524bb35e3d2ed14.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-8c9297289cb753c8eaa4231ae9eab6cd3367f9bf543d9f49bca4afa53434ce0d.json b/.sqlx/query-8c9297289cb753c8eaa4231ae9eab6cd3367f9bf543d9f49bca4afa53434ce0d.json deleted file mode 100644 index ecc9ad9..0000000 --- a/.sqlx/query-8c9297289cb753c8eaa4231ae9eab6cd3367f9bf543d9f49bca4afa53434ce0d.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-9806777e3db4db9e9a905a6ce26375f026aa8a6db2c5534cf5ccf9758a07ee39.json b/.sqlx/query-9806777e3db4db9e9a905a6ce26375f026aa8a6db2c5534cf5ccf9758a07ee39.json new file mode 100644 index 0000000..f6e6c4e --- /dev/null +++ b/.sqlx/query-9806777e3db4db9e9a905a6ce26375f026aa8a6db2c5534cf5ccf9758a07ee39.json @@ -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" +} diff --git a/.sqlx/query-a63aed47193f06cd11d87157799c17a591e0a0be4487f718250eaf7afd4b4b07.json b/.sqlx/query-a63aed47193f06cd11d87157799c17a591e0a0be4487f718250eaf7afd4b4b07.json new file mode 100644 index 0000000..a8cbca7 --- /dev/null +++ b/.sqlx/query-a63aed47193f06cd11d87157799c17a591e0a0be4487f718250eaf7afd4b4b07.json @@ -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" +} diff --git a/.sqlx/query-b2a217b405ace1726097631c7fa532bf1a7330f11328a1e68d5eced41cad8a78.json b/.sqlx/query-b2a217b405ace1726097631c7fa532bf1a7330f11328a1e68d5eced41cad8a78.json new file mode 100644 index 0000000..a16963b --- /dev/null +++ b/.sqlx/query-b2a217b405ace1726097631c7fa532bf1a7330f11328a1e68d5eced41cad8a78.json @@ -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" +} diff --git a/.sqlx/query-b9848ea8f168e1ab975dc2ad125b5b9e478e74254a8cf670e55b728bc402f046.json b/.sqlx/query-b9848ea8f168e1ab975dc2ad125b5b9e478e74254a8cf670e55b728bc402f046.json new file mode 100644 index 0000000..dc506bf --- /dev/null +++ b/.sqlx/query-b9848ea8f168e1ab975dc2ad125b5b9e478e74254a8cf670e55b728bc402f046.json @@ -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" +} diff --git a/.sqlx/query-c9b624a9987dd263e908fcff4612e1cd446552c93d80254d9e15c2e51a95a596.json b/.sqlx/query-c9b624a9987dd263e908fcff4612e1cd446552c93d80254d9e15c2e51a95a596.json new file mode 100644 index 0000000..cb3e585 --- /dev/null +++ b/.sqlx/query-c9b624a9987dd263e908fcff4612e1cd446552c93d80254d9e15c2e51a95a596.json @@ -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" +} diff --git a/.sqlx/query-dcaedeec794a63ce8abb9b580461c193ad58fee110d57249f98355b40b757a37.json b/.sqlx/query-dcaedeec794a63ce8abb9b580461c193ad58fee110d57249f98355b40b757a37.json new file mode 100644 index 0000000..81649b0 --- /dev/null +++ b/.sqlx/query-dcaedeec794a63ce8abb9b580461c193ad58fee110d57249f98355b40b757a37.json @@ -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" +} diff --git a/.sqlx/query-e1066ab3a86852164e39848733c0f7e837657ea6595ea0094a6135673ea924a5.json b/.sqlx/query-e1066ab3a86852164e39848733c0f7e837657ea6595ea0094a6135673ea924a5.json new file mode 100644 index 0000000..c05e02b --- /dev/null +++ b/.sqlx/query-e1066ab3a86852164e39848733c0f7e837657ea6595ea0094a6135673ea924a5.json @@ -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" +} diff --git a/.sqlx/query-6b67b2b6759f01be11d5997a3ad68d381f59a02235a6940877f62193af8d9761.json b/.sqlx/query-f4f4b6a9e5d2345efa8e48380f66c819c1818030aa4bf26757d9fb40e654b693.json similarity index 75% rename from .sqlx/query-6b67b2b6759f01be11d5997a3ad68d381f59a02235a6940877f62193af8d9761.json rename to .sqlx/query-f4f4b6a9e5d2345efa8e48380f66c819c1818030aa4bf26757d9fb40e654b693.json index 344cdc0..e717a04 100644 --- a/.sqlx/query-6b67b2b6759f01be11d5997a3ad68d381f59a02235a6940877f62193af8d9761.json +++ b/.sqlx/query-f4f4b6a9e5d2345efa8e48380f66c819c1818030aa4bf26757d9fb40e654b693.json @@ -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" } diff --git a/Cargo.lock b/Cargo.lock index 1ae7493..23417ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index d4e21e7..0e08350 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 = [] diff --git a/Dockerfile b/Dockerfile index d2c6487..7aedc1c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index cd6406a..be8ed97 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/TODO.md b/TODO.md index de347d2..ac997cb 100644 --- a/TODO.md +++ b/TODO.md @@ -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). diff --git a/docker-compose.yaml b/docker-compose.yaml index c858f11..1973c8c 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -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: diff --git a/migrations/20251213_performance_indexes.sql b/migrations/20251213_performance_indexes.sql new file mode 100644 index 0000000..139a919 --- /dev/null +++ b/migrations/20251213_performance_indexes.sql @@ -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); diff --git a/observability/prometheus.yml b/observability/prometheus.yml new file mode 100644 index 0000000..32d9a44 --- /dev/null +++ b/observability/prometheus.yml @@ -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 diff --git a/scripts/test-infra.sh b/scripts/test-infra.sh index 19163c4..6ef044d 100755 --- a/scripts/test-infra.sh +++ b/scripts/test-infra.sh @@ -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 "" diff --git a/src/api/actor/profile.rs b/src/api/actor/profile.rs index 247f275..fb535cd 100644 --- a/src/api/actor/profile.rs +++ b/src/api/actor/profile.rs @@ -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 { diff --git a/src/api/identity/account.rs b/src/api/identity/account.rs index 53c75e9..3b61562 100644 --- a/src/api/identity/account.rs +++ b/src/api/identity/account.rs @@ -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, diff --git a/src/api/proxy.rs b/src/api/proxy.rs index 89e8c2f..24d40e5 100644 --- a/src/api/proxy.rs +++ b/src/api/proxy.rs @@ -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); diff --git a/src/api/read_after_write.rs b/src/api/read_after_write.rs index 939d115..d096571 100644 --- a/src/api/read_after_write.rs +++ b/src/api/read_after_write.rs @@ -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, + } + + let mut row_data: Vec = Vec::with_capacity(rows.len()); + let mut cids: Vec = Vec::with_capacity(rows.len()); + + for row in &rows { + if let Ok(cid) = row.record_cid.parse::() { + 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> = 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::(&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::(&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::(&block_bytes) { result.likes.push(RecordDescript { uri, - cid: row.record_cid, - indexed_at, + cid: data.cid_str, + indexed_at: data.created_at, record, }); } diff --git a/src/api/repo/blob.rs b/src/api/repo/blob.rs index 37fc3ed..ae937e4 100644 --- a/src/api/repo/blob.rs +++ b/src/api/repo/blob.rs @@ -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"})), diff --git a/src/api/repo/record/read.rs b/src/api/repo/record/read.rs index 7f0701e..b4fc74b 100644 --- a/src/api/repo/record/read.rs +++ b/src/api/repo/record/read.rs @@ -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 = HashMap::new(); + let mut cids: Vec = 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::(&block) { records.push(json!({ "uri": format!("at://{}/{}/{}", input.repo, input.collection, rkey), diff --git a/src/api/repo/record/utils.rs b/src/api/repo/record/utils.rs index d74d7ff..459f0e6 100644 --- a/src/api/repo/record/utils.rs +++ b/src/api/repo/record/utils.rs @@ -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 = Vec::new(); + let mut upsert_rkeys: Vec = Vec::new(); + let mut upsert_cids: Vec = Vec::new(); + + let mut delete_collections: Vec = Vec::new(); + let mut delete_rkeys: Vec = 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!({ diff --git a/src/api/server/app_password.rs b/src/api/server/app_password.rs index da69b86..275b631 100644 --- a/src/api/server/app_password.rs +++ b/src/api/server/app_password.rs @@ -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, ) -> 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 { diff --git a/src/api/server/email.rs b/src/api/server/email.rs index af47514..83dd2bb 100644 --- a/src/api/server/email.rs +++ b/src/api/server/email.rs @@ -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, ) -> 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, ) -> 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( diff --git a/src/api/server/password.rs b/src/api/server/password.rs index 30ff31a..b10164d 100644 --- a/src/api/server/password.rs +++ b/src/api/server/password.rs @@ -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, ) -> 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, ) -> 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 = 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() diff --git a/src/api/server/session.rs b/src/api/server/session.rs index 39160a3..55eb640 100644 --- a/src/api/server/session.rs +++ b/src/api/server/session.rs @@ -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( diff --git a/src/auth/extractor.rs b/src/auth/extractor.rs index 29d6e67..c5f6114 100644 --- a/src/auth/extractor.rs +++ b/src/auth/extractor.rs @@ -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 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 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), diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 039c4db..c4037e6 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -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 { - 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 { - 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, + token: &str, +) -> Result { + 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, + token: &str, +) -> Result { + 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>, token: &str, allow_deactivated: bool, ) -> Result { 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> = 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, 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, diff --git a/src/cache/mod.rs b/src/cache/mod.rs index 017732b..62770dd 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -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; 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> { + 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)] diff --git a/src/lib.rs b/src/lib.rs index 9e8f192..ea43c94 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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") diff --git a/src/main.rs b/src/main.rs index 97cba57..57c2817 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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> { 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) diff --git a/src/metrics.rs b/src/metrics.rs new file mode 100644 index 0000000..7323944 --- /dev/null +++ b/src/metrics.rs @@ -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 = 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, 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"); + } +} diff --git a/src/notifications/service.rs b/src/notifications/service.rs index 2f4d80b..87c31a9 100644 --- a/src/notifications/service.rs +++ b/src/notifications/service.rs @@ -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, } } diff --git a/src/oauth/endpoints/authorize.rs b/src/oauth/endpoints/authorize.rs index d70f15a..f5e7bed 100644 --- a/src/oauth/endpoints/authorize.rs +++ b/src/oauth/endpoints/authorize.rs @@ -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, ) -> 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, diff --git a/src/oauth/endpoints/par.rs b/src/oauth/endpoints/par.rs index 8267258..487ce26 100644 --- a/src/oauth/endpoints/par.rs +++ b/src/oauth/endpoints/par.rs @@ -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, ) -> Result, 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" { diff --git a/src/oauth/endpoints/token/introspect.rs b/src/oauth/endpoints/token/introspect.rs index dc8d2be..d764f73 100644 --- a/src/oauth/endpoints/token/introspect.rs +++ b/src/oauth/endpoints/token/introspect.rs @@ -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, ) -> Result { 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, ) -> Result, 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 { diff --git a/src/oauth/endpoints/token/mod.rs b/src/oauth/endpoints/token/mod.rs index 2273cb7..04062a2 100644 --- a/src/oauth/endpoints/token/mod.rs +++ b/src/oauth/endpoints/token/mod.rs @@ -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, ) -> Result<(HeaderMap, Json), 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(), diff --git a/src/plc/mod.rs b/src/plc/mod.rs index 07206dc..ce499c8 100644 --- a/src/plc/mod.rs +++ b/src/plc/mod.rs @@ -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, } } diff --git a/src/rate_limit.rs b/src/rate_limit.rs index e139f99..6fc7a0b 100644 --- a/src/rate_limit.rs +++ b/src/rate_limit.rs @@ -20,6 +20,12 @@ use std::{ pub type KeyedRateLimiter = RateLimiter, DefaultClock>; pub type GlobalRateLimiter = RateLimiter; +// 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, @@ -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) -> String { diff --git a/src/repo/mod.rs b/src/repo/mod.rs index 9415c80..c880c10 100644 --- a/src/repo/mod.rs +++ b/src/repo/mod.rs @@ -22,6 +22,7 @@ impl PostgresBlockStore { impl BlockStore for PostgresBlockStore { async fn get(&self, cid: &Cid) -> Result, 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 { + 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 { + 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 + 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> = 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>, 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> = 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, 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) } diff --git a/src/state.rs b/src/state.rs index 4355dfa..cc98b63 100644 --- a/src/state.rs +++ b/src/state.rs @@ -21,13 +21,65 @@ pub struct AppState { pub distributed_rate_limiter: Arc, } +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 + } } diff --git a/src/storage/mod.rs b/src/storage/mod.rs index 20fde38..7d20ea0 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -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, StorageError>; + async fn get_bytes(&self, key: &str) -> Result; 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, StorageError> { + self.get_bytes(key).await.map(|b| b.to_vec()) + } + + async fn get_bytes(&self, key: &str) -> Result { 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(()) } } diff --git a/src/sync/listener.rs b/src/sync/listener.rs index 05636be..016b6e1 100644 --- a/src/sync/listener.rs +++ b/src/sync/listener.rs @@ -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"); } } } diff --git a/src/sync/subscribe_repos.rs b/src/sync/subscribe_repos.rs index 5f526cc..01f7b56 100644 --- a/src/sync/subscribe_repos.rs +++ b/src/sync/subscribe_repos.rs @@ -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(()) } diff --git a/src/sync/util.rs b/src/sync/util.rs index 34e4b80..771d906 100644 --- a/src/sync/util.rs +++ b/src/sync/util.rs @@ -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 = 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, anyhow::Error> { + let mut all_cids: Vec = 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, +) -> Result, 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 = 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)?; diff --git a/tests/auth.rs b/tests/auth.rs deleted file mode 100644 index a921fea..0000000 --- a/tests/auth.rs +++ /dev/null @@ -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()); -} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 7d03bc5..1beae3d 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -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); diff --git a/tests/delete_account.rs b/tests/delete_account.rs index 68ae36a..24618f1 100644 --- a/tests/delete_account.rs +++ b/tests/delete_account.rs @@ -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() diff --git a/tests/email_update.rs b/tests/email_update.rs index 085a7d4..0e7e0b7 100644 --- a/tests/email_update.rs +++ b/tests/email_update.rs @@ -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 diff --git a/tests/helpers/mod.rs b/tests/helpers/mod.rs index d7c4a0b..0c2a9e0 100644 --- a/tests/helpers/mod.rs +++ b/tests/helpers/mod.rs @@ -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) } diff --git a/tests/identity.rs b/tests/identity.rs index dfafa6a..6b514d4 100644 --- a/tests/identity.rs +++ b/tests/identity.rs @@ -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!({ diff --git a/tests/import_repo.rs b/tests/import_repo.rs deleted file mode 100644 index 61de0a4..0000000 --- a/tests/import_repo.rs +++ /dev/null @@ -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); -} - diff --git a/tests/import_verification.rs b/tests/import_verification.rs index a8d91ed..9efc629 100644 --- a/tests/import_verification.rs +++ b/tests/import_verification.rs @@ -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, mut value: u64) { loop { let mut byte = (value & 0x7F) as u8; diff --git a/tests/jwt_security.rs b/tests/jwt_security.rs index 94f2378..700310f 100644 --- a/tests/jwt_security.rs +++ b/tests/jwt_security.rs @@ -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)) diff --git a/tests/lifecycle_record.rs b/tests/lifecycle_record.rs index e810098..abb8936 100644 --- a/tests/lifecycle_record.rs +++ b/tests/lifecycle_record.rs @@ -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"); + } +} diff --git a/tests/lifecycle_session.rs b/tests/lifecycle_session.rs index a6580b3..18e28fa 100644 --- a/tests/lifecycle_session.rs +++ b/tests/lifecycle_session.rs @@ -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(); diff --git a/tests/lifecycle_social.rs b/tests/lifecycle_social.rs index fcfb68c..7cd8577 100644 --- a/tests/lifecycle_social.rs +++ b/tests/lifecycle_social.rs @@ -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!( diff --git a/tests/list_records_pagination.rs b/tests/list_records_pagination.rs deleted file mode 100644 index 8a67e70..0000000 --- a/tests/list_records_pagination.rs +++ /dev/null @@ -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"); - } -} diff --git a/tests/notifications.rs b/tests/notifications.rs index 7582aa2..c83a2e1 100644 --- a/tests/notifications.rs +++ b/tests/notifications.rs @@ -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); diff --git a/tests/oauth.rs b/tests/oauth.rs index 3b58987..4240140 100644 --- a/tests/oauth.rs +++ b/tests/oauth.rs @@ -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; diff --git a/tests/oauth_dpop.rs b/tests/oauth_dpop.rs deleted file mode 100644 index 3f1f079..0000000 --- a/tests/oauth_dpop.rs +++ /dev/null @@ -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); -} diff --git a/tests/oauth_lifecycle.rs b/tests/oauth_lifecycle.rs index b7cdcaa..add737b 100644 --- a/tests/oauth_lifecycle.rs +++ b/tests/oauth_lifecycle.rs @@ -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(); diff --git a/tests/oauth_security.rs b/tests/oauth_security.rs index 86d2566..29ccdfb 100644 --- a/tests/oauth_security.rs +++ b/tests/oauth_security.rs @@ -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); +} diff --git a/tests/password_reset.rs b/tests/password_reset.rs index 0c8cc02..06c219c 100644 --- a/tests/password_reset.rs +++ b/tests/password_reset.rs @@ -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)) diff --git a/tests/rate_limit.rs b/tests/rate_limit.rs index 3dc2df1..a650b33 100644 --- a/tests/rate_limit.rs +++ b/tests/rate_limit.rs @@ -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!( diff --git a/tests/repo_record.rs b/tests/repo_record.rs deleted file mode 100644 index 1c61872..0000000 --- a/tests/repo_record.rs +++ /dev/null @@ -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); -} diff --git a/tests/server.rs b/tests/server.rs index 490ef95..72cfc5b 100644 --- a/tests/server.rs +++ b/tests/server.rs @@ -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, diff --git a/tests/signing_key.rs b/tests/signing_key.rs index b72183e..b81c03c 100644 --- a/tests/signing_key.rs +++ b/tests/signing_key.rs @@ -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");