···100# Comma-separated list of available user domains
101# AVAILABLE_USER_DOMAINS=example.com
102# =============================================================================
000000000103# Rate Limiting
104# =============================================================================
105# Disable all rate limiting (testing only, NEVER in production)
106# DISABLE_RATE_LIMITING=1
00000107# =============================================================================
108# Miscellaneous
109# =============================================================================
···100# Comma-separated list of available user domains
101# AVAILABLE_USER_DOMAINS=example.com
102# =============================================================================
103+# Server Metadata (returned by describeServer)
104+# =============================================================================
105+# Privacy policy URL (optional)
106+# PRIVACY_POLICY_URL=https://example.com/privacy
107+# Terms of service URL (optional)
108+# TERMS_OF_SERVICE_URL=https://example.com/terms
109+# Contact email address (optional)
110+# CONTACT_EMAIL=admin@example.com
111+# =============================================================================
112# Rate Limiting
113# =============================================================================
114# Disable all rate limiting (testing only, NEVER in production)
115# DISABLE_RATE_LIMITING=1
116+# =============================================================================
117+# Account Deletion
118+# =============================================================================
119+# How often to check for scheduled account deletions (default: 3600 = 1 hour)
120+# SCHEDULED_DELETE_CHECK_INTERVAL_SECS=3600
121# =============================================================================
122# Miscellaneous
123# =============================================================================
···1+ALTER TABLE users ADD COLUMN IF NOT EXISTS delete_after TIMESTAMPTZ;
+7
migrations/20251240_add_block_count.sql
···0000000
···1+CREATE TABLE IF NOT EXISTS user_blocks (
2+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
3+ block_cid BYTEA NOT NULL,
4+ PRIMARY KEY (user_id, block_cid)
5+);
6+7+CREATE INDEX IF NOT EXISTS idx_user_blocks_user_id ON user_blocks(user_id);
···1+mod common;
2+mod helpers;
3+use common::*;
4+use reqwest::StatusCode;
5+use serde_json::{Value, json};
6+7+#[tokio::test]
8+async fn test_check_account_status_returns_correct_block_count() {
9+ let client = client();
10+ let base = base_url().await;
11+ let (access_jwt, did) = create_account_and_login(&client).await;
12+13+ let status1 = client
14+ .get(format!("{}/xrpc/com.atproto.server.checkAccountStatus", base))
15+ .bearer_auth(&access_jwt)
16+ .send()
17+ .await
18+ .unwrap();
19+ assert_eq!(status1.status(), StatusCode::OK);
20+ let body1: Value = status1.json().await.unwrap();
21+ let initial_blocks = body1["repoBlocks"].as_i64().unwrap();
22+ assert!(initial_blocks >= 2, "New account should have at least 2 blocks (commit + empty MST)");
23+24+ let create_res = client
25+ .post(format!("{}/xrpc/com.atproto.repo.createRecord", base))
26+ .bearer_auth(&access_jwt)
27+ .json(&json!({
28+ "repo": did,
29+ "collection": "app.bsky.feed.post",
30+ "record": {
31+ "$type": "app.bsky.feed.post",
32+ "text": "Test post for block counting",
33+ "createdAt": chrono::Utc::now().to_rfc3339()
34+ }
35+ }))
36+ .send()
37+ .await
38+ .unwrap();
39+ assert_eq!(create_res.status(), StatusCode::OK);
40+ let create_body: Value = create_res.json().await.unwrap();
41+ let rkey = create_body["uri"].as_str().unwrap().split('/').last().unwrap().to_string();
42+43+ let status2 = client
44+ .get(format!("{}/xrpc/com.atproto.server.checkAccountStatus", base))
45+ .bearer_auth(&access_jwt)
46+ .send()
47+ .await
48+ .unwrap();
49+ let body2: Value = status2.json().await.unwrap();
50+ let after_create_blocks = body2["repoBlocks"].as_i64().unwrap();
51+ assert!(after_create_blocks > initial_blocks, "Block count should increase after creating a record");
52+53+ let delete_res = client
54+ .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base))
55+ .bearer_auth(&access_jwt)
56+ .json(&json!({
57+ "repo": did,
58+ "collection": "app.bsky.feed.post",
59+ "rkey": rkey
60+ }))
61+ .send()
62+ .await
63+ .unwrap();
64+ assert_eq!(delete_res.status(), StatusCode::OK);
65+66+ let status3 = client
67+ .get(format!("{}/xrpc/com.atproto.server.checkAccountStatus", base))
68+ .bearer_auth(&access_jwt)
69+ .send()
70+ .await
71+ .unwrap();
72+ let body3: Value = status3.json().await.unwrap();
73+ let after_delete_blocks = body3["repoBlocks"].as_i64().unwrap();
74+ assert!(
75+ after_delete_blocks >= after_create_blocks,
76+ "Block count should not decrease after deleting a record (was {}, now {})",
77+ after_create_blocks,
78+ after_delete_blocks
79+ );
80+}
81+82+#[tokio::test]
83+async fn test_check_account_status_returns_valid_repo_rev() {
84+ let client = client();
85+ let base = base_url().await;
86+ let (access_jwt, _) = create_account_and_login(&client).await;
87+88+ let status = client
89+ .get(format!("{}/xrpc/com.atproto.server.checkAccountStatus", base))
90+ .bearer_auth(&access_jwt)
91+ .send()
92+ .await
93+ .unwrap();
94+ assert_eq!(status.status(), StatusCode::OK);
95+ let body: Value = status.json().await.unwrap();
96+97+ let repo_rev = body["repoRev"].as_str().unwrap();
98+ assert!(!repo_rev.is_empty(), "repoRev should not be empty");
99+ assert!(repo_rev.chars().all(|c| c.is_alphanumeric()), "repoRev should be alphanumeric TID");
100+}
101+102+#[tokio::test]
103+async fn test_check_account_status_valid_did_is_true_for_active_account() {
104+ let client = client();
105+ let base = base_url().await;
106+ let (access_jwt, _) = create_account_and_login(&client).await;
107+108+ let status = client
109+ .get(format!("{}/xrpc/com.atproto.server.checkAccountStatus", base))
110+ .bearer_auth(&access_jwt)
111+ .send()
112+ .await
113+ .unwrap();
114+ assert_eq!(status.status(), StatusCode::OK);
115+ let body: Value = status.json().await.unwrap();
116+117+ assert_eq!(body["validDid"], true, "validDid should be true for active account with correct DID document");
118+ assert_eq!(body["activated"], true, "activated should be true for active account");
119+}
120+121+#[tokio::test]
122+async fn test_deactivate_account_with_delete_after() {
123+ let client = client();
124+ let base = base_url().await;
125+ let (access_jwt, _) = create_account_and_login(&client).await;
126+127+ let future_time = chrono::Utc::now() + chrono::Duration::hours(24);
128+ let delete_after = future_time.to_rfc3339();
129+130+ let deactivate = client
131+ .post(format!("{}/xrpc/com.atproto.server.deactivateAccount", base))
132+ .bearer_auth(&access_jwt)
133+ .json(&json!({
134+ "deleteAfter": delete_after
135+ }))
136+ .send()
137+ .await
138+ .unwrap();
139+ assert_eq!(deactivate.status(), StatusCode::OK);
140+141+ let status = client
142+ .get(format!("{}/xrpc/com.atproto.server.checkAccountStatus", base))
143+ .bearer_auth(&access_jwt)
144+ .send()
145+ .await
146+ .unwrap();
147+ assert_eq!(status.status(), StatusCode::OK);
148+ let body: Value = status.json().await.unwrap();
149+ assert_eq!(body["activated"], false, "Account should be deactivated");
150+}
151+152+#[tokio::test]
153+async fn test_create_account_returns_did_doc() {
154+ let client = client();
155+ let base = base_url().await;
156+157+ let handle = format!("diddoctest-{}", uuid::Uuid::new_v4());
158+ let payload = json!({
159+ "handle": handle,
160+ "email": format!("{}@example.com", handle),
161+ "password": "Testpass123!"
162+ });
163+164+ let create_res = client
165+ .post(format!("{}/xrpc/com.atproto.server.createAccount", base))
166+ .json(&payload)
167+ .send()
168+ .await
169+ .unwrap();
170+ assert_eq!(create_res.status(), StatusCode::OK);
171+ let body: Value = create_res.json().await.unwrap();
172+173+ assert!(body["accessJwt"].is_string(), "accessJwt should always be returned");
174+ assert!(body["refreshJwt"].is_string(), "refreshJwt should always be returned");
175+ assert!(body["did"].is_string(), "did should be returned");
176+177+ if body["didDoc"].is_object() {
178+ let did_doc = &body["didDoc"];
179+ assert!(did_doc["id"].is_string(), "didDoc should have id field");
180+ }
181+}
182+183+#[tokio::test]
184+async fn test_create_account_always_returns_tokens() {
185+ let client = client();
186+ let base = base_url().await;
187+188+ let handle = format!("tokentest-{}", uuid::Uuid::new_v4());
189+ let payload = json!({
190+ "handle": handle,
191+ "email": format!("{}@example.com", handle),
192+ "password": "Testpass123!"
193+ });
194+195+ let create_res = client
196+ .post(format!("{}/xrpc/com.atproto.server.createAccount", base))
197+ .json(&payload)
198+ .send()
199+ .await
200+ .unwrap();
201+ assert_eq!(create_res.status(), StatusCode::OK);
202+ let body: Value = create_res.json().await.unwrap();
203+204+ let access_jwt = body["accessJwt"].as_str().expect("accessJwt should be present");
205+ let refresh_jwt = body["refreshJwt"].as_str().expect("refreshJwt should be present");
206+207+ assert!(!access_jwt.is_empty(), "accessJwt should not be empty");
208+ assert!(!refresh_jwt.is_empty(), "refreshJwt should not be empty");
209+210+ let parts: Vec<&str> = access_jwt.split('.').collect();
211+ assert_eq!(parts.len(), 3, "accessJwt should be a valid JWT with 3 parts");
212+}
213+214+#[tokio::test]
215+async fn test_describe_server_has_links_and_contact() {
216+ let client = client();
217+ let base = base_url().await;
218+219+ let describe = client
220+ .get(format!("{}/xrpc/com.atproto.server.describeServer", base))
221+ .send()
222+ .await
223+ .unwrap();
224+ assert_eq!(describe.status(), StatusCode::OK);
225+ let body: Value = describe.json().await.unwrap();
226+227+ assert!(body.get("links").is_some(), "describeServer should include links object");
228+ assert!(body.get("contact").is_some(), "describeServer should include contact object");
229+230+ let links = &body["links"];
231+ assert!(links.get("privacyPolicy").is_some() || links["privacyPolicy"].is_null(),
232+ "links should have privacyPolicy field (can be null)");
233+ assert!(links.get("termsOfService").is_some() || links["termsOfService"].is_null(),
234+ "links should have termsOfService field (can be null)");
235+236+ let contact = &body["contact"];
237+ assert!(contact.get("email").is_some() || contact["email"].is_null(),
238+ "contact should have email field (can be null)");
239+}
240+241+#[tokio::test]
242+async fn test_delete_account_password_max_length() {
243+ let client = client();
244+ let base = base_url().await;
245+246+ let handle = format!("pwdlentest-{}", uuid::Uuid::new_v4());
247+ let payload = json!({
248+ "handle": handle,
249+ "email": format!("{}@example.com", handle),
250+ "password": "Testpass123!"
251+ });
252+253+ let create_res = client
254+ .post(format!("{}/xrpc/com.atproto.server.createAccount", base))
255+ .json(&payload)
256+ .send()
257+ .await
258+ .unwrap();
259+ assert_eq!(create_res.status(), StatusCode::OK);
260+ let body: Value = create_res.json().await.unwrap();
261+ let did = body["did"].as_str().unwrap();
262+263+ let too_long_password = "a".repeat(600);
264+ let delete_res = client
265+ .post(format!("{}/xrpc/com.atproto.server.deleteAccount", base))
266+ .json(&json!({
267+ "did": did,
268+ "password": too_long_password,
269+ "token": "fake-token"
270+ }))
271+ .send()
272+ .await
273+ .unwrap();
274+275+ assert_eq!(delete_res.status(), StatusCode::BAD_REQUEST);
276+ let error_body: Value = delete_res.json().await.unwrap();
277+ assert!(error_body["message"].as_str().unwrap().contains("password length")
278+ || error_body["error"].as_str().unwrap() == "InvalidRequest");
279+}
+4-1
tests/common/mod.rs
···466 .await
467 .expect("Failed to mark user as admin");
468 }
0469 if let Some(access_jwt) = body["accessJwt"].as_str() {
470- return (access_jwt.to_string(), did);
00471 }
472 let body_text: String = sqlx::query_scalar!(
473 "SELECT body FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'email_verification' ORDER BY created_at DESC LIMIT 1",
···466 .await
467 .expect("Failed to mark user as admin");
468 }
469+ let verification_required = body["verificationRequired"].as_bool().unwrap_or(true);
470 if let Some(access_jwt) = body["accessJwt"].as_str() {
471+ if !verification_required {
472+ return (access_jwt.to_string(), did);
473+ }
474 }
475 let body_text: String = sqlx::query_scalar!(
476 "SELECT body FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'email_verification' ORDER BY created_at DESC LIMIT 1",