Our Personal Data Server from scratch! tranquil.farm
pds rust database fun oauth atproto
238
fork

Configure Feed

Select the types of activity you want to include in your feed.

Invite codes conf. vs ref

+349 -298
-40
.sqlx/query-2ff22a8c39914689d6cf215ba201fa4ced50b7a003ce01bf7603a7f125113447.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n SELECT code, available_uses, created_at, disabled\n FROM invite_codes\n WHERE created_by_user = $1\n ORDER BY created_at DESC\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "code", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "available_uses", 14 - "type_info": "Int4" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "created_at", 19 - "type_info": "Timestamptz" 20 - }, 21 - { 22 - "ordinal": 3, 23 - "name": "disabled", 24 - "type_info": "Bool" 25 - } 26 - ], 27 - "parameters": { 28 - "Left": [ 29 - "Uuid" 30 - ] 31 - }, 32 - "nullable": [ 33 - false, 34 - false, 35 - false, 36 - true 37 - ] 38 - }, 39 - "hash": "2ff22a8c39914689d6cf215ba201fa4ced50b7a003ce01bf7603a7f125113447" 40 - }
+17
.sqlx/query-59678fbb756d46bb5f51c9a52800a8d203ed52129b1fae65145df92d145d18de.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "INSERT INTO invite_codes (code, available_uses, created_by_user, for_account) VALUES ($1, $2, $3, $4)", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Int4", 10 + "Uuid", 11 + "Text" 12 + ] 13 + }, 14 + "nullable": [] 15 + }, 16 + "hash": "59678fbb756d46bb5f51c9a52800a8d203ed52129b1fae65145df92d145d18de" 17 + }
+52
.sqlx/query-704b32d9ae2234ae12dad87f5f86230e16acaa1c0c229c66b39024bf9662f1e5.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT\n ic.code,\n ic.available_uses,\n ic.created_at,\n ic.disabled,\n ic.for_account,\n (SELECT COUNT(*) FROM invite_code_uses icu WHERE icu.code = ic.code)::int as \"use_count!\"\n FROM invite_codes ic\n WHERE ic.for_account = $1\n ORDER BY ic.created_at DESC\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "code", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "available_uses", 14 + "type_info": "Int4" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "created_at", 19 + "type_info": "Timestamptz" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "disabled", 24 + "type_info": "Bool" 25 + }, 26 + { 27 + "ordinal": 4, 28 + "name": "for_account", 29 + "type_info": "Text" 30 + }, 31 + { 32 + "ordinal": 5, 33 + "name": "use_count!", 34 + "type_info": "Int4" 35 + } 36 + ], 37 + "parameters": { 38 + "Left": [ 39 + "Text" 40 + ] 41 + }, 42 + "nullable": [ 43 + false, 44 + false, 45 + false, 46 + true, 47 + false, 48 + null 49 + ] 50 + }, 51 + "hash": "704b32d9ae2234ae12dad87f5f86230e16acaa1c0c229c66b39024bf9662f1e5" 52 + }
+16
.sqlx/query-b3d44806b6351d788048e6afe7a6623882fac70b466bf09596cad8eae1fc9dac.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "INSERT INTO invite_codes (code, available_uses, created_by_user, for_account)\n SELECT $1, $2, id, $3 FROM users WHERE is_admin = true LIMIT 1", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Int4", 10 + "Text" 11 + ] 12 + }, 13 + "nullable": [] 14 + }, 15 + "hash": "b3d44806b6351d788048e6afe7a6623882fac70b466bf09596cad8eae1fc9dac" 16 + }
-16
.sqlx/query-bbe639bb24cc1bb3cc144baae263e7e3411e185bf7c91751ee1046c64a81df52.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "INSERT INTO invite_codes (code, available_uses, created_by_user) VALUES ($1, $2, $3)", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Text", 9 - "Int4", 10 - "Uuid" 11 - ] 12 - }, 13 - "nullable": [] 14 - }, 15 - "hash": "bbe639bb24cc1bb3cc144baae263e7e3411e185bf7c91751ee1046c64a81df52" 16 - }
+20
.sqlx/query-ce50221e621d89f7f7d315b0ccc7893b2c344e3612b56116a785248dda296424.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT id FROM users WHERE is_admin = true LIMIT 1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [] 14 + }, 15 + "nullable": [ 16 + false 17 + ] 18 + }, 19 + "hash": "ce50221e621d89f7f7d315b0ccc7893b2c344e3612b56116a785248dda296424" 20 + }
-22
.sqlx/query-da0e9a9edad3895ed5015b52335f5a0256e7bdc6c79e6faa927414d68800404c.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT invites_disabled FROM users WHERE did = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "invites_disabled", 9 - "type_info": "Bool" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Text" 15 - ] 16 - }, 17 - "nullable": [ 18 - true 19 - ] 20 - }, 21 - "hash": "da0e9a9edad3895ed5015b52335f5a0256e7bdc6c79e6faa927414d68800404c" 22 - }
+1 -1
frontend/src/routes/Dashboard.svelte
··· 164 164 <h3>{$_('dashboard.navSessions')}</h3> 165 165 <p>{$_('dashboard.navSessionsDesc')}</p> 166 166 </a> 167 - {#if inviteCodesEnabled} 167 + {#if inviteCodesEnabled && auth.session.isAdmin} 168 168 <a href="#/invite-codes" class="nav-card"> 169 169 <h3>{$_('dashboard.navInviteCodes')}</h3> 170 170 <p>{$_('dashboard.navInviteCodesDesc')}</p>
+7 -5
frontend/src/routes/InviteCodes.svelte
··· 91 91 <button onclick={dismissCreated}>{$_('common.done')}</button> 92 92 </div> 93 93 {/if} 94 - <section class="create-section"> 95 - <button onclick={handleCreate} disabled={creating}> 96 - {creating ? $_('inviteCodes.creating') : $_('inviteCodes.createNew')} 97 - </button> 98 - </section> 94 + {#if auth.session?.isAdmin} 95 + <section class="create-section"> 96 + <button onclick={handleCreate} disabled={creating}> 97 + {creating ? $_('inviteCodes.creating') : $_('inviteCodes.createNew')} 98 + </button> 99 + </section> 100 + {/if} 99 101 <section class="list-section"> 100 102 <h2>{$_('inviteCodes.yourCodes')}</h2> 101 103 {#if loading}
+2
migrations/20251242_invite_code_for_account.sql
··· 1 + ALTER TABLE invite_codes ADD COLUMN IF NOT EXISTS for_account TEXT NOT NULL DEFAULT 'admin'; 2 + CREATE INDEX IF NOT EXISTS idx_invite_codes_for_account ON invite_codes(for_account);
+99 -112
src/api/server/invite.rs
··· 1 1 use crate::api::ApiError; 2 + use crate::auth::extractor::BearerAuthAdmin; 2 3 use crate::auth::BearerAuth; 3 4 use crate::state::AppState; 4 - use crate::util::get_user_id_by_did; 5 5 use axum::{ 6 6 Json, 7 7 extract::State, 8 8 response::{IntoResponse, Response}, 9 9 }; 10 + use rand::Rng; 10 11 use serde::{Deserialize, Serialize}; 11 12 use tracing::error; 12 - use uuid::Uuid; 13 + 14 + const BASE32_ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz234567"; 15 + 16 + fn gen_random_token() -> String { 17 + let mut rng = rand::thread_rng(); 18 + let mut token = String::with_capacity(11); 19 + for i in 0..10 { 20 + if i == 5 { 21 + token.push('-'); 22 + } 23 + let idx = rng.gen_range(0..32); 24 + token.push(BASE32_ALPHABET[idx] as char); 25 + } 26 + token 27 + } 28 + 29 + fn gen_invite_code() -> String { 30 + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 31 + let hostname_prefix = hostname.replace('.', "-"); 32 + format!("{}-{}", hostname_prefix, gen_random_token()) 33 + } 13 34 14 35 #[derive(Deserialize)] 15 36 #[serde(rename_all = "camelCase")] ··· 25 46 26 47 pub async fn create_invite_code( 27 48 State(state): State<AppState>, 28 - BearerAuth(auth_user): BearerAuth, 49 + BearerAuthAdmin(_auth_user): BearerAuthAdmin, 29 50 Json(input): Json<CreateInviteCodeInput>, 30 51 ) -> Response { 31 52 if input.use_count < 1 { 32 53 return ApiError::InvalidRequest("useCount must be at least 1".into()).into_response(); 33 54 } 34 - let user_id = match get_user_id_by_did(&state.db, &auth_user.did).await { 35 - Ok(id) => id, 36 - Err(e) => return ApiError::from(e).into_response(), 37 - }; 38 - let creator_user_id = if let Some(for_account) = &input.for_account { 39 - match sqlx::query!("SELECT id FROM users WHERE did = $1", for_account) 40 - .fetch_optional(&state.db) 41 - .await 42 - { 43 - Ok(Some(row)) => row.id, 44 - Ok(None) => return ApiError::AccountNotFound.into_response(), 45 - Err(e) => { 46 - error!("DB error looking up target account: {:?}", e); 47 - return ApiError::InternalError.into_response(); 48 - } 49 - } 50 - } else { 51 - user_id 52 - }; 53 - let user_invites_disabled = sqlx::query_scalar!( 54 - "SELECT invites_disabled FROM users WHERE did = $1", 55 - auth_user.did 56 - ) 57 - .fetch_optional(&state.db) 58 - .await 59 - .map_err(|e| { 60 - error!("DB error checking invites_disabled: {:?}", e); 61 - ApiError::InternalError 62 - }) 63 - .ok() 64 - .flatten() 65 - .flatten() 66 - .unwrap_or(false); 67 - if user_invites_disabled { 68 - return ApiError::InvitesDisabled.into_response(); 69 - } 70 - let code = Uuid::new_v4().to_string(); 55 + 56 + let for_account = input.for_account.unwrap_or_else(|| "admin".to_string()); 57 + let code = gen_invite_code(); 58 + 71 59 match sqlx::query!( 72 - "INSERT INTO invite_codes (code, available_uses, created_by_user) VALUES ($1, $2, $3)", 60 + "INSERT INTO invite_codes (code, available_uses, created_by_user, for_account) 61 + SELECT $1, $2, id, $3 FROM users WHERE is_admin = true LIMIT 1", 73 62 code, 74 63 input.use_count, 75 - creator_user_id 64 + for_account 76 65 ) 77 66 .execute(&state.db) 78 67 .await 79 68 { 80 - Ok(_) => Json(CreateInviteCodeOutput { code }).into_response(), 69 + Ok(result) => { 70 + if result.rows_affected() == 0 { 71 + error!("No admin user found to create invite code"); 72 + return ApiError::InternalError.into_response(); 73 + } 74 + Json(CreateInviteCodeOutput { code }).into_response() 75 + } 81 76 Err(e) => { 82 77 error!("DB error creating invite code: {:?}", e); 83 78 ApiError::InternalError.into_response() ··· 106 101 107 102 pub async fn create_invite_codes( 108 103 State(state): State<AppState>, 109 - BearerAuth(auth_user): BearerAuth, 104 + BearerAuthAdmin(_auth_user): BearerAuthAdmin, 110 105 Json(input): Json<CreateInviteCodesInput>, 111 106 ) -> Response { 112 107 if input.use_count < 1 { 113 108 return ApiError::InvalidRequest("useCount must be at least 1".into()).into_response(); 114 109 } 115 - let user_id = match get_user_id_by_did(&state.db, &auth_user.did).await { 116 - Ok(id) => id, 117 - Err(e) => return ApiError::from(e).into_response(), 110 + 111 + let code_count = input.code_count.unwrap_or(1).max(1); 112 + let for_accounts = input 113 + .for_accounts 114 + .filter(|v| !v.is_empty()) 115 + .unwrap_or_else(|| vec!["admin".to_string()]); 116 + 117 + let admin_user_id = match sqlx::query_scalar!( 118 + "SELECT id FROM users WHERE is_admin = true LIMIT 1" 119 + ) 120 + .fetch_optional(&state.db) 121 + .await 122 + { 123 + Ok(Some(id)) => id, 124 + Ok(None) => { 125 + error!("No admin user found to create invite codes"); 126 + return ApiError::InternalError.into_response(); 127 + } 128 + Err(e) => { 129 + error!("DB error looking up admin user: {:?}", e); 130 + return ApiError::InternalError.into_response(); 131 + } 118 132 }; 119 - let code_count = input.code_count.unwrap_or(1).max(1); 120 - let for_accounts = input.for_accounts.unwrap_or_default(); 133 + 121 134 let mut result_codes = Vec::new(); 122 - if for_accounts.is_empty() { 135 + 136 + for account in for_accounts { 123 137 let mut codes = Vec::new(); 124 138 for _ in 0..code_count { 125 - let code = Uuid::new_v4().to_string(); 139 + let code = gen_invite_code(); 126 140 if let Err(e) = sqlx::query!( 127 - "INSERT INTO invite_codes (code, available_uses, created_by_user) VALUES ($1, $2, $3)", 141 + "INSERT INTO invite_codes (code, available_uses, created_by_user, for_account) VALUES ($1, $2, $3, $4)", 128 142 code, 129 143 input.use_count, 130 - user_id 144 + admin_user_id, 145 + account 131 146 ) 132 147 .execute(&state.db) 133 148 .await ··· 137 152 } 138 153 codes.push(code); 139 154 } 140 - result_codes.push(AccountCodes { 141 - account: "admin".to_string(), 142 - codes, 143 - }); 144 - } else { 145 - for account_did in for_accounts { 146 - let target_user_id = 147 - match sqlx::query!("SELECT id FROM users WHERE did = $1", account_did) 148 - .fetch_optional(&state.db) 149 - .await 150 - { 151 - Ok(Some(row)) => row.id, 152 - Ok(None) => continue, 153 - Err(e) => { 154 - error!("DB error looking up target account: {:?}", e); 155 - return ApiError::InternalError.into_response(); 156 - } 157 - }; 158 - let mut codes = Vec::new(); 159 - for _ in 0..code_count { 160 - let code = Uuid::new_v4().to_string(); 161 - if let Err(e) = sqlx::query!( 162 - "INSERT INTO invite_codes (code, available_uses, created_by_user) VALUES ($1, $2, $3)", 163 - code, 164 - input.use_count, 165 - target_user_id 166 - ) 167 - .execute(&state.db) 168 - .await 169 - { 170 - error!("DB error creating invite code: {:?}", e); 171 - return ApiError::InternalError.into_response(); 172 - } 173 - codes.push(code); 174 - } 175 - result_codes.push(AccountCodes { 176 - account: account_did, 177 - codes, 178 - }); 179 - } 155 + result_codes.push(AccountCodes { account, codes }); 180 156 } 157 + 181 158 Json(CreateInviteCodesOutput { 182 159 codes: result_codes, 183 160 }) ··· 220 197 BearerAuth(auth_user): BearerAuth, 221 198 axum::extract::Query(params): axum::extract::Query<GetAccountInviteCodesParams>, 222 199 ) -> Response { 223 - let user_id = match get_user_id_by_did(&state.db, &auth_user.did).await { 224 - Ok(id) => id, 225 - Err(e) => return ApiError::from(e).into_response(), 226 - }; 227 200 let include_used = params.include_used.unwrap_or(true); 201 + 228 202 let codes_rows = match sqlx::query!( 229 203 r#" 230 - SELECT code, available_uses, created_at, disabled 231 - FROM invite_codes 232 - WHERE created_by_user = $1 233 - ORDER BY created_at DESC 204 + SELECT 205 + ic.code, 206 + ic.available_uses, 207 + ic.created_at, 208 + ic.disabled, 209 + ic.for_account, 210 + (SELECT COUNT(*) FROM invite_code_uses icu WHERE icu.code = ic.code)::int as "use_count!" 211 + FROM invite_codes ic 212 + WHERE ic.for_account = $1 213 + ORDER BY ic.created_at DESC 234 214 "#, 235 - user_id 215 + auth_user.did 236 216 ) 237 217 .fetch_all(&state.db) 238 218 .await 239 219 { 240 - Ok(rows) => { 241 - if include_used { 242 - rows 243 - } else { 244 - rows.into_iter().filter(|r| r.available_uses > 0).collect() 245 - } 246 - } 220 + Ok(rows) => rows, 247 221 Err(e) => { 248 222 error!("DB error fetching invite codes: {:?}", e); 249 223 return ApiError::InternalError.into_response(); 250 224 } 251 225 }; 226 + 252 227 let mut codes = Vec::new(); 253 228 for row in codes_rows { 229 + let disabled = row.disabled.unwrap_or(false); 230 + if disabled { 231 + continue; 232 + } 233 + 234 + let use_count = row.use_count; 235 + if !include_used && use_count >= row.available_uses { 236 + continue; 237 + } 238 + 254 239 let uses = sqlx::query!( 255 240 r#" 256 241 SELECT u.did, icu.used_at ··· 273 258 .collect() 274 259 }) 275 260 .unwrap_or_default(); 261 + 276 262 codes.push(InviteCode { 277 263 code: row.code, 278 264 available: row.available_uses, 279 - disabled: row.disabled.unwrap_or(false), 280 - for_account: auth_user.did.clone(), 281 - created_by: auth_user.did.clone(), 265 + disabled, 266 + for_account: row.for_account, 267 + created_by: "admin".to_string(), 282 268 created_at: row.created_at.to_rfc3339(), 283 269 uses, 284 270 }); 285 271 } 272 + 286 273 Json(GetAccountInviteCodesOutput { codes }).into_response() 287 274 }
+11 -88
tests/admin_invite.rs
··· 84 84 } 85 85 86 86 #[tokio::test] 87 - async fn test_disable_account_invites_success() { 88 - let client = client(); 89 - let (access_jwt, did) = create_admin_account_and_login(&client).await; 90 - let payload = json!({ 91 - "account": did 92 - }); 93 - let res = client 94 - .post(format!( 95 - "{}/xrpc/com.atproto.admin.disableAccountInvites", 96 - base_url().await 97 - )) 98 - .bearer_auth(&access_jwt) 99 - .json(&payload) 100 - .send() 101 - .await 102 - .expect("Failed to send request"); 103 - assert_eq!(res.status(), StatusCode::OK); 104 - let create_payload = json!({ 105 - "useCount": 1 106 - }); 107 - let res = client 108 - .post(format!( 109 - "{}/xrpc/com.atproto.server.createInviteCode", 110 - base_url().await 111 - )) 112 - .bearer_auth(&access_jwt) 113 - .json(&create_payload) 114 - .send() 115 - .await 116 - .expect("Failed to send request"); 117 - assert_eq!(res.status(), StatusCode::FORBIDDEN); 118 - let body: Value = res.json().await.expect("Response was not valid JSON"); 119 - assert_eq!(body["error"], "InvitesDisabled"); 120 - } 121 - 122 - #[tokio::test] 123 - async fn test_enable_account_invites_success() { 124 - let client = client(); 125 - let (access_jwt, did) = create_admin_account_and_login(&client).await; 126 - let disable_payload = json!({ 127 - "account": did 128 - }); 129 - let _ = client 130 - .post(format!( 131 - "{}/xrpc/com.atproto.admin.disableAccountInvites", 132 - base_url().await 133 - )) 134 - .bearer_auth(&access_jwt) 135 - .json(&disable_payload) 136 - .send() 137 - .await; 138 - let enable_payload = json!({ 139 - "account": did 140 - }); 141 - let res = client 142 - .post(format!( 143 - "{}/xrpc/com.atproto.admin.enableAccountInvites", 144 - base_url().await 145 - )) 146 - .bearer_auth(&access_jwt) 147 - .json(&enable_payload) 148 - .send() 149 - .await 150 - .expect("Failed to send request"); 151 - assert_eq!(res.status(), StatusCode::OK); 152 - let create_payload = json!({ 153 - "useCount": 1 154 - }); 155 - let res = client 156 - .post(format!( 157 - "{}/xrpc/com.atproto.server.createInviteCode", 158 - base_url().await 159 - )) 160 - .bearer_auth(&access_jwt) 161 - .json(&create_payload) 162 - .send() 163 - .await 164 - .expect("Failed to send request"); 165 - assert_eq!(res.status(), StatusCode::OK); 166 - } 167 - 168 - #[tokio::test] 169 87 async fn test_disable_account_invites_no_auth() { 170 88 let client = client(); 171 89 let payload = json!({ ··· 206 124 #[tokio::test] 207 125 async fn test_disable_invite_codes_by_code() { 208 126 let client = client(); 209 - let (access_jwt, _did) = create_admin_account_and_login(&client).await; 127 + let (access_jwt, admin_did) = create_admin_account_and_login(&client).await; 210 128 let create_payload = json!({ 211 - "useCount": 5 129 + "useCount": 5, 130 + "forAccount": admin_did 212 131 }); 213 132 let create_res = client 214 133 .post(format!( ··· 236 155 .await 237 156 .expect("Failed to send request"); 238 157 assert_eq!(res.status(), StatusCode::OK); 158 + 239 159 let list_res = client 240 160 .get(format!( 241 - "{}/xrpc/com.atproto.server.getAccountInviteCodes", 161 + "{}/xrpc/com.atproto.admin.getInviteCodes", 242 162 base_url().await 243 163 )) 244 164 .bearer_auth(&access_jwt) ··· 258 178 let (access_jwt, did) = create_admin_account_and_login(&client).await; 259 179 for _ in 0..3 { 260 180 let create_payload = json!({ 261 - "useCount": 1 181 + "useCount": 1, 182 + "forAccount": did 262 183 }); 263 184 let _ = client 264 185 .post(format!( ··· 284 205 .await 285 206 .expect("Failed to send request"); 286 207 assert_eq!(res.status(), StatusCode::OK); 208 + 287 209 let list_res = client 288 210 .get(format!( 289 - "{}/xrpc/com.atproto.server.getAccountInviteCodes", 211 + "{}/xrpc/com.atproto.admin.getInviteCodes", 290 212 base_url().await 291 213 )) 292 214 .bearer_auth(&access_jwt) ··· 295 217 .expect("Failed to get invite codes"); 296 218 let list_body: Value = list_res.json().await.unwrap(); 297 219 let codes = list_body["codes"].as_array().unwrap(); 298 - for code in codes { 220 + let admin_codes: Vec<_> = codes.iter().filter(|c| c["forAccount"].as_str() == Some(&did)).collect(); 221 + for code in admin_codes { 299 222 assert_eq!(code["disabled"], true); 300 223 } 301 224 }
+124 -14
tests/invite.rs
··· 6 6 #[tokio::test] 7 7 async fn test_create_invite_code_success() { 8 8 let client = client(); 9 - let (access_jwt, _did) = create_account_and_login(&client).await; 9 + let (access_jwt, _did) = create_admin_account_and_login(&client).await; 10 10 let payload = json!({ 11 11 "useCount": 5 12 12 }); ··· 25 25 assert!(body["code"].is_string()); 26 26 let code = body["code"].as_str().unwrap(); 27 27 assert!(!code.is_empty()); 28 - assert!(code.contains('-'), "Code should be a UUID format"); 28 + assert!(code.contains('-'), "Code should be in hostname-xxxxx-xxxxx format"); 29 + let parts: Vec<&str> = code.split('-').collect(); 30 + assert!(parts.len() >= 3, "Code should have at least 3 parts (hostname + 2 random parts)"); 29 31 } 30 32 31 33 #[tokio::test] ··· 49 51 } 50 52 51 53 #[tokio::test] 54 + async fn test_create_invite_code_non_admin() { 55 + let client = client(); 56 + let (access_jwt, _did) = create_account_and_login(&client).await; 57 + let payload = json!({ 58 + "useCount": 5 59 + }); 60 + let res = client 61 + .post(format!( 62 + "{}/xrpc/com.atproto.server.createInviteCode", 63 + base_url().await 64 + )) 65 + .bearer_auth(&access_jwt) 66 + .json(&payload) 67 + .send() 68 + .await 69 + .expect("Failed to send request"); 70 + assert_eq!(res.status(), StatusCode::FORBIDDEN); 71 + let body: Value = res.json().await.expect("Response was not valid JSON"); 72 + assert_eq!(body["error"], "AdminRequired"); 73 + } 74 + 75 + #[tokio::test] 52 76 async fn test_create_invite_code_invalid_use_count() { 53 77 let client = client(); 54 - let (access_jwt, _did) = create_account_and_login(&client).await; 78 + let (access_jwt, _did) = create_admin_account_and_login(&client).await; 55 79 let payload = json!({ 56 80 "useCount": 0 57 81 }); ··· 73 97 #[tokio::test] 74 98 async fn test_create_invite_code_for_another_account() { 75 99 let client = client(); 76 - let (access_jwt1, _did1) = create_account_and_login(&client).await; 100 + let (access_jwt1, _did1) = create_admin_account_and_login(&client).await; 77 101 let (_access_jwt2, did2) = create_account_and_login(&client).await; 78 102 let payload = json!({ 79 103 "useCount": 3, ··· 97 121 #[tokio::test] 98 122 async fn test_create_invite_codes_success() { 99 123 let client = client(); 100 - let (access_jwt, _did) = create_account_and_login(&client).await; 124 + let (access_jwt, _did) = create_admin_account_and_login(&client).await; 101 125 let payload = json!({ 102 126 "useCount": 2, 103 127 "codeCount": 3 ··· 117 141 assert!(body["codes"].is_array()); 118 142 let codes = body["codes"].as_array().unwrap(); 119 143 assert_eq!(codes.len(), 1); 144 + assert_eq!(codes[0]["account"], "admin"); 120 145 assert_eq!(codes[0]["codes"].as_array().unwrap().len(), 3); 121 146 } 122 147 123 148 #[tokio::test] 124 149 async fn test_create_invite_codes_for_multiple_accounts() { 125 150 let client = client(); 126 - let (access_jwt1, did1) = create_account_and_login(&client).await; 151 + let (access_jwt1, did1) = create_admin_account_and_login(&client).await; 127 152 let (_access_jwt2, did2) = create_account_and_login(&client).await; 128 153 let payload = json!({ 129 154 "useCount": 1, ··· 169 194 } 170 195 171 196 #[tokio::test] 172 - async fn test_get_account_invite_codes_success() { 197 + async fn test_create_invite_codes_non_admin() { 173 198 let client = client(); 174 199 let (access_jwt, _did) = create_account_and_login(&client).await; 200 + let payload = json!({ 201 + "useCount": 2 202 + }); 203 + let res = client 204 + .post(format!( 205 + "{}/xrpc/com.atproto.server.createInviteCodes", 206 + base_url().await 207 + )) 208 + .bearer_auth(&access_jwt) 209 + .json(&payload) 210 + .send() 211 + .await 212 + .expect("Failed to send request"); 213 + assert_eq!(res.status(), StatusCode::FORBIDDEN); 214 + let body: Value = res.json().await.expect("Response was not valid JSON"); 215 + assert_eq!(body["error"], "AdminRequired"); 216 + } 217 + 218 + #[tokio::test] 219 + async fn test_get_account_invite_codes_success() { 220 + let client = client(); 221 + let (admin_jwt, _admin_did) = create_admin_account_and_login(&client).await; 222 + let (user_jwt, user_did) = create_account_and_login(&client).await; 223 + 175 224 let create_payload = json!({ 176 - "useCount": 5 225 + "useCount": 5, 226 + "forAccount": user_did 177 227 }); 178 228 let _ = client 179 229 .post(format!( 180 230 "{}/xrpc/com.atproto.server.createInviteCode", 181 231 base_url().await 182 232 )) 183 - .bearer_auth(&access_jwt) 233 + .bearer_auth(&admin_jwt) 184 234 .json(&create_payload) 185 235 .send() 186 236 .await 187 237 .expect("Failed to create invite code"); 238 + 188 239 let res = client 189 240 .get(format!( 190 241 "{}/xrpc/com.atproto.server.getAccountInviteCodes", 191 242 base_url().await 192 243 )) 193 - .bearer_auth(&access_jwt) 244 + .bearer_auth(&user_jwt) 194 245 .send() 195 246 .await 196 247 .expect("Failed to send request"); ··· 205 256 assert!(code["disabled"].is_boolean()); 206 257 assert!(code["createdAt"].is_string()); 207 258 assert!(code["uses"].is_array()); 259 + assert_eq!(code["forAccount"], user_did); 260 + assert_eq!(code["createdBy"], "admin"); 208 261 } 209 262 210 263 #[tokio::test] ··· 224 277 #[tokio::test] 225 278 async fn test_get_account_invite_codes_include_used_filter() { 226 279 let client = client(); 227 - let (access_jwt, _did) = create_account_and_login(&client).await; 280 + let (admin_jwt, _admin_did) = create_admin_account_and_login(&client).await; 281 + let (user_jwt, user_did) = create_account_and_login(&client).await; 282 + 228 283 let create_payload = json!({ 229 - "useCount": 5 284 + "useCount": 5, 285 + "forAccount": user_did 230 286 }); 231 287 let _ = client 232 288 .post(format!( 233 289 "{}/xrpc/com.atproto.server.createInviteCode", 234 290 base_url().await 235 291 )) 236 - .bearer_auth(&access_jwt) 292 + .bearer_auth(&admin_jwt) 237 293 .json(&create_payload) 238 294 .send() 239 295 .await 240 296 .expect("Failed to create invite code"); 297 + 241 298 let res = client 242 299 .get(format!( 243 300 "{}/xrpc/com.atproto.server.getAccountInviteCodes", 244 301 base_url().await 245 302 )) 246 - .bearer_auth(&access_jwt) 303 + .bearer_auth(&user_jwt) 247 304 .query(&[("includeUsed", "false")]) 248 305 .send() 249 306 .await ··· 255 312 assert!(code["available"].as_i64().unwrap() > 0); 256 313 } 257 314 } 315 + 316 + #[tokio::test] 317 + async fn test_get_account_invite_codes_filters_disabled() { 318 + let client = client(); 319 + let (admin_jwt, admin_did) = create_admin_account_and_login(&client).await; 320 + 321 + let create_payload = json!({ 322 + "useCount": 5, 323 + "forAccount": admin_did 324 + }); 325 + let create_res = client 326 + .post(format!( 327 + "{}/xrpc/com.atproto.server.createInviteCode", 328 + base_url().await 329 + )) 330 + .bearer_auth(&admin_jwt) 331 + .json(&create_payload) 332 + .send() 333 + .await 334 + .expect("Failed to create invite code"); 335 + let create_body: Value = create_res.json().await.unwrap(); 336 + let code = create_body["code"].as_str().unwrap(); 337 + 338 + let disable_payload = json!({ 339 + "codes": [code] 340 + }); 341 + let _ = client 342 + .post(format!( 343 + "{}/xrpc/com.atproto.admin.disableInviteCodes", 344 + base_url().await 345 + )) 346 + .bearer_auth(&admin_jwt) 347 + .json(&disable_payload) 348 + .send() 349 + .await 350 + .expect("Failed to disable invite code"); 351 + 352 + let res = client 353 + .get(format!( 354 + "{}/xrpc/com.atproto.server.getAccountInviteCodes", 355 + base_url().await 356 + )) 357 + .bearer_auth(&admin_jwt) 358 + .send() 359 + .await 360 + .expect("Failed to send request"); 361 + assert_eq!(res.status(), StatusCode::OK); 362 + let body: Value = res.json().await.expect("Response was not valid JSON"); 363 + let codes = body["codes"].as_array().unwrap(); 364 + for c in codes { 365 + assert_ne!(c["code"].as_str().unwrap(), code, "Disabled code should be filtered out"); 366 + } 367 + }