this repo has no description

Invite codes conf. vs ref

lewis daa358e7 266d3721

-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 <h3>{$_('dashboard.navSessions')}</h3> 165 <p>{$_('dashboard.navSessionsDesc')}</p> 166 </a> 167 - {#if inviteCodesEnabled} 168 <a href="#/invite-codes" class="nav-card"> 169 <h3>{$_('dashboard.navInviteCodes')}</h3> 170 <p>{$_('dashboard.navInviteCodesDesc')}</p>
··· 164 <h3>{$_('dashboard.navSessions')}</h3> 165 <p>{$_('dashboard.navSessionsDesc')}</p> 166 </a> 167 + {#if inviteCodesEnabled && auth.session.isAdmin} 168 <a href="#/invite-codes" class="nav-card"> 169 <h3>{$_('dashboard.navInviteCodes')}</h3> 170 <p>{$_('dashboard.navInviteCodesDesc')}</p>
+7 -5
frontend/src/routes/InviteCodes.svelte
··· 91 <button onclick={dismissCreated}>{$_('common.done')}</button> 92 </div> 93 {/if} 94 - <section class="create-section"> 95 - <button onclick={handleCreate} disabled={creating}> 96 - {creating ? $_('inviteCodes.creating') : $_('inviteCodes.createNew')} 97 - </button> 98 - </section> 99 <section class="list-section"> 100 <h2>{$_('inviteCodes.yourCodes')}</h2> 101 {#if loading}
··· 91 <button onclick={dismissCreated}>{$_('common.done')}</button> 92 </div> 93 {/if} 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} 101 <section class="list-section"> 102 <h2>{$_('inviteCodes.yourCodes')}</h2> 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 use crate::api::ApiError; 2 use crate::auth::BearerAuth; 3 use crate::state::AppState; 4 - use crate::util::get_user_id_by_did; 5 use axum::{ 6 Json, 7 extract::State, 8 response::{IntoResponse, Response}, 9 }; 10 use serde::{Deserialize, Serialize}; 11 use tracing::error; 12 - use uuid::Uuid; 13 14 #[derive(Deserialize)] 15 #[serde(rename_all = "camelCase")] ··· 25 26 pub async fn create_invite_code( 27 State(state): State<AppState>, 28 - BearerAuth(auth_user): BearerAuth, 29 Json(input): Json<CreateInviteCodeInput>, 30 ) -> Response { 31 if input.use_count < 1 { 32 return ApiError::InvalidRequest("useCount must be at least 1".into()).into_response(); 33 } 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(); 71 match sqlx::query!( 72 - "INSERT INTO invite_codes (code, available_uses, created_by_user) VALUES ($1, $2, $3)", 73 code, 74 input.use_count, 75 - creator_user_id 76 ) 77 .execute(&state.db) 78 .await 79 { 80 - Ok(_) => Json(CreateInviteCodeOutput { code }).into_response(), 81 Err(e) => { 82 error!("DB error creating invite code: {:?}", e); 83 ApiError::InternalError.into_response() ··· 106 107 pub async fn create_invite_codes( 108 State(state): State<AppState>, 109 - BearerAuth(auth_user): BearerAuth, 110 Json(input): Json<CreateInviteCodesInput>, 111 ) -> Response { 112 if input.use_count < 1 { 113 return ApiError::InvalidRequest("useCount must be at least 1".into()).into_response(); 114 } 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(), 118 }; 119 - let code_count = input.code_count.unwrap_or(1).max(1); 120 - let for_accounts = input.for_accounts.unwrap_or_default(); 121 let mut result_codes = Vec::new(); 122 - if for_accounts.is_empty() { 123 let mut codes = Vec::new(); 124 for _ in 0..code_count { 125 - let code = Uuid::new_v4().to_string(); 126 if let Err(e) = sqlx::query!( 127 - "INSERT INTO invite_codes (code, available_uses, created_by_user) VALUES ($1, $2, $3)", 128 code, 129 input.use_count, 130 - user_id 131 ) 132 .execute(&state.db) 133 .await ··· 137 } 138 codes.push(code); 139 } 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 - } 180 } 181 Json(CreateInviteCodesOutput { 182 codes: result_codes, 183 }) ··· 220 BearerAuth(auth_user): BearerAuth, 221 axum::extract::Query(params): axum::extract::Query<GetAccountInviteCodesParams>, 222 ) -> 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 let include_used = params.include_used.unwrap_or(true); 228 let codes_rows = match sqlx::query!( 229 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 234 "#, 235 - user_id 236 ) 237 .fetch_all(&state.db) 238 .await 239 { 240 - Ok(rows) => { 241 - if include_used { 242 - rows 243 - } else { 244 - rows.into_iter().filter(|r| r.available_uses > 0).collect() 245 - } 246 - } 247 Err(e) => { 248 error!("DB error fetching invite codes: {:?}", e); 249 return ApiError::InternalError.into_response(); 250 } 251 }; 252 let mut codes = Vec::new(); 253 for row in codes_rows { 254 let uses = sqlx::query!( 255 r#" 256 SELECT u.did, icu.used_at ··· 273 .collect() 274 }) 275 .unwrap_or_default(); 276 codes.push(InviteCode { 277 code: row.code, 278 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(), 282 created_at: row.created_at.to_rfc3339(), 283 uses, 284 }); 285 } 286 Json(GetAccountInviteCodesOutput { codes }).into_response() 287 }
··· 1 use crate::api::ApiError; 2 + use crate::auth::extractor::BearerAuthAdmin; 3 use crate::auth::BearerAuth; 4 use crate::state::AppState; 5 use axum::{ 6 Json, 7 extract::State, 8 response::{IntoResponse, Response}, 9 }; 10 + use rand::Rng; 11 use serde::{Deserialize, Serialize}; 12 use tracing::error; 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 + } 34 35 #[derive(Deserialize)] 36 #[serde(rename_all = "camelCase")] ··· 46 47 pub async fn create_invite_code( 48 State(state): State<AppState>, 49 + BearerAuthAdmin(_auth_user): BearerAuthAdmin, 50 Json(input): Json<CreateInviteCodeInput>, 51 ) -> Response { 52 if input.use_count < 1 { 53 return ApiError::InvalidRequest("useCount must be at least 1".into()).into_response(); 54 } 55 + 56 + let for_account = input.for_account.unwrap_or_else(|| "admin".to_string()); 57 + let code = gen_invite_code(); 58 + 59 match sqlx::query!( 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", 62 code, 63 input.use_count, 64 + for_account 65 ) 66 .execute(&state.db) 67 .await 68 { 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 + } 76 Err(e) => { 77 error!("DB error creating invite code: {:?}", e); 78 ApiError::InternalError.into_response() ··· 101 102 pub async fn create_invite_codes( 103 State(state): State<AppState>, 104 + BearerAuthAdmin(_auth_user): BearerAuthAdmin, 105 Json(input): Json<CreateInviteCodesInput>, 106 ) -> Response { 107 if input.use_count < 1 { 108 return ApiError::InvalidRequest("useCount must be at least 1".into()).into_response(); 109 } 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 + } 132 }; 133 + 134 let mut result_codes = Vec::new(); 135 + 136 + for account in for_accounts { 137 let mut codes = Vec::new(); 138 for _ in 0..code_count { 139 + let code = gen_invite_code(); 140 if let Err(e) = sqlx::query!( 141 + "INSERT INTO invite_codes (code, available_uses, created_by_user, for_account) VALUES ($1, $2, $3, $4)", 142 code, 143 input.use_count, 144 + admin_user_id, 145 + account 146 ) 147 .execute(&state.db) 148 .await ··· 152 } 153 codes.push(code); 154 } 155 + result_codes.push(AccountCodes { account, codes }); 156 } 157 + 158 Json(CreateInviteCodesOutput { 159 codes: result_codes, 160 }) ··· 197 BearerAuth(auth_user): BearerAuth, 198 axum::extract::Query(params): axum::extract::Query<GetAccountInviteCodesParams>, 199 ) -> Response { 200 let include_used = params.include_used.unwrap_or(true); 201 + 202 let codes_rows = match sqlx::query!( 203 r#" 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 214 "#, 215 + auth_user.did 216 ) 217 .fetch_all(&state.db) 218 .await 219 { 220 + Ok(rows) => rows, 221 Err(e) => { 222 error!("DB error fetching invite codes: {:?}", e); 223 return ApiError::InternalError.into_response(); 224 } 225 }; 226 + 227 let mut codes = Vec::new(); 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 + 239 let uses = sqlx::query!( 240 r#" 241 SELECT u.did, icu.used_at ··· 258 .collect() 259 }) 260 .unwrap_or_default(); 261 + 262 codes.push(InviteCode { 263 code: row.code, 264 available: row.available_uses, 265 + disabled, 266 + for_account: row.for_account, 267 + created_by: "admin".to_string(), 268 created_at: row.created_at.to_rfc3339(), 269 uses, 270 }); 271 } 272 + 273 Json(GetAccountInviteCodesOutput { codes }).into_response() 274 }
+11 -88
tests/admin_invite.rs
··· 84 } 85 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 async fn test_disable_account_invites_no_auth() { 170 let client = client(); 171 let payload = json!({ ··· 206 #[tokio::test] 207 async fn test_disable_invite_codes_by_code() { 208 let client = client(); 209 - let (access_jwt, _did) = create_admin_account_and_login(&client).await; 210 let create_payload = json!({ 211 - "useCount": 5 212 }); 213 let create_res = client 214 .post(format!( ··· 236 .await 237 .expect("Failed to send request"); 238 assert_eq!(res.status(), StatusCode::OK); 239 let list_res = client 240 .get(format!( 241 - "{}/xrpc/com.atproto.server.getAccountInviteCodes", 242 base_url().await 243 )) 244 .bearer_auth(&access_jwt) ··· 258 let (access_jwt, did) = create_admin_account_and_login(&client).await; 259 for _ in 0..3 { 260 let create_payload = json!({ 261 - "useCount": 1 262 }); 263 let _ = client 264 .post(format!( ··· 284 .await 285 .expect("Failed to send request"); 286 assert_eq!(res.status(), StatusCode::OK); 287 let list_res = client 288 .get(format!( 289 - "{}/xrpc/com.atproto.server.getAccountInviteCodes", 290 base_url().await 291 )) 292 .bearer_auth(&access_jwt) ··· 295 .expect("Failed to get invite codes"); 296 let list_body: Value = list_res.json().await.unwrap(); 297 let codes = list_body["codes"].as_array().unwrap(); 298 - for code in codes { 299 assert_eq!(code["disabled"], true); 300 } 301 }
··· 84 } 85 86 #[tokio::test] 87 async fn test_disable_account_invites_no_auth() { 88 let client = client(); 89 let payload = json!({ ··· 124 #[tokio::test] 125 async fn test_disable_invite_codes_by_code() { 126 let client = client(); 127 + let (access_jwt, admin_did) = create_admin_account_and_login(&client).await; 128 let create_payload = json!({ 129 + "useCount": 5, 130 + "forAccount": admin_did 131 }); 132 let create_res = client 133 .post(format!( ··· 155 .await 156 .expect("Failed to send request"); 157 assert_eq!(res.status(), StatusCode::OK); 158 + 159 let list_res = client 160 .get(format!( 161 + "{}/xrpc/com.atproto.admin.getInviteCodes", 162 base_url().await 163 )) 164 .bearer_auth(&access_jwt) ··· 178 let (access_jwt, did) = create_admin_account_and_login(&client).await; 179 for _ in 0..3 { 180 let create_payload = json!({ 181 + "useCount": 1, 182 + "forAccount": did 183 }); 184 let _ = client 185 .post(format!( ··· 205 .await 206 .expect("Failed to send request"); 207 assert_eq!(res.status(), StatusCode::OK); 208 + 209 let list_res = client 210 .get(format!( 211 + "{}/xrpc/com.atproto.admin.getInviteCodes", 212 base_url().await 213 )) 214 .bearer_auth(&access_jwt) ··· 217 .expect("Failed to get invite codes"); 218 let list_body: Value = list_res.json().await.unwrap(); 219 let codes = list_body["codes"].as_array().unwrap(); 220 + let admin_codes: Vec<_> = codes.iter().filter(|c| c["forAccount"].as_str() == Some(&did)).collect(); 221 + for code in admin_codes { 222 assert_eq!(code["disabled"], true); 223 } 224 }
+124 -14
tests/invite.rs
··· 6 #[tokio::test] 7 async fn test_create_invite_code_success() { 8 let client = client(); 9 - let (access_jwt, _did) = create_account_and_login(&client).await; 10 let payload = json!({ 11 "useCount": 5 12 }); ··· 25 assert!(body["code"].is_string()); 26 let code = body["code"].as_str().unwrap(); 27 assert!(!code.is_empty()); 28 - assert!(code.contains('-'), "Code should be a UUID format"); 29 } 30 31 #[tokio::test] ··· 49 } 50 51 #[tokio::test] 52 async fn test_create_invite_code_invalid_use_count() { 53 let client = client(); 54 - let (access_jwt, _did) = create_account_and_login(&client).await; 55 let payload = json!({ 56 "useCount": 0 57 }); ··· 73 #[tokio::test] 74 async fn test_create_invite_code_for_another_account() { 75 let client = client(); 76 - let (access_jwt1, _did1) = create_account_and_login(&client).await; 77 let (_access_jwt2, did2) = create_account_and_login(&client).await; 78 let payload = json!({ 79 "useCount": 3, ··· 97 #[tokio::test] 98 async fn test_create_invite_codes_success() { 99 let client = client(); 100 - let (access_jwt, _did) = create_account_and_login(&client).await; 101 let payload = json!({ 102 "useCount": 2, 103 "codeCount": 3 ··· 117 assert!(body["codes"].is_array()); 118 let codes = body["codes"].as_array().unwrap(); 119 assert_eq!(codes.len(), 1); 120 assert_eq!(codes[0]["codes"].as_array().unwrap().len(), 3); 121 } 122 123 #[tokio::test] 124 async fn test_create_invite_codes_for_multiple_accounts() { 125 let client = client(); 126 - let (access_jwt1, did1) = create_account_and_login(&client).await; 127 let (_access_jwt2, did2) = create_account_and_login(&client).await; 128 let payload = json!({ 129 "useCount": 1, ··· 169 } 170 171 #[tokio::test] 172 - async fn test_get_account_invite_codes_success() { 173 let client = client(); 174 let (access_jwt, _did) = create_account_and_login(&client).await; 175 let create_payload = json!({ 176 - "useCount": 5 177 }); 178 let _ = client 179 .post(format!( 180 "{}/xrpc/com.atproto.server.createInviteCode", 181 base_url().await 182 )) 183 - .bearer_auth(&access_jwt) 184 .json(&create_payload) 185 .send() 186 .await 187 .expect("Failed to create invite code"); 188 let res = client 189 .get(format!( 190 "{}/xrpc/com.atproto.server.getAccountInviteCodes", 191 base_url().await 192 )) 193 - .bearer_auth(&access_jwt) 194 .send() 195 .await 196 .expect("Failed to send request"); ··· 205 assert!(code["disabled"].is_boolean()); 206 assert!(code["createdAt"].is_string()); 207 assert!(code["uses"].is_array()); 208 } 209 210 #[tokio::test] ··· 224 #[tokio::test] 225 async fn test_get_account_invite_codes_include_used_filter() { 226 let client = client(); 227 - let (access_jwt, _did) = create_account_and_login(&client).await; 228 let create_payload = json!({ 229 - "useCount": 5 230 }); 231 let _ = client 232 .post(format!( 233 "{}/xrpc/com.atproto.server.createInviteCode", 234 base_url().await 235 )) 236 - .bearer_auth(&access_jwt) 237 .json(&create_payload) 238 .send() 239 .await 240 .expect("Failed to create invite code"); 241 let res = client 242 .get(format!( 243 "{}/xrpc/com.atproto.server.getAccountInviteCodes", 244 base_url().await 245 )) 246 - .bearer_auth(&access_jwt) 247 .query(&[("includeUsed", "false")]) 248 .send() 249 .await ··· 255 assert!(code["available"].as_i64().unwrap() > 0); 256 } 257 }
··· 6 #[tokio::test] 7 async fn test_create_invite_code_success() { 8 let client = client(); 9 + let (access_jwt, _did) = create_admin_account_and_login(&client).await; 10 let payload = json!({ 11 "useCount": 5 12 }); ··· 25 assert!(body["code"].is_string()); 26 let code = body["code"].as_str().unwrap(); 27 assert!(!code.is_empty()); 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)"); 31 } 32 33 #[tokio::test] ··· 51 } 52 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] 76 async fn test_create_invite_code_invalid_use_count() { 77 let client = client(); 78 + let (access_jwt, _did) = create_admin_account_and_login(&client).await; 79 let payload = json!({ 80 "useCount": 0 81 }); ··· 97 #[tokio::test] 98 async fn test_create_invite_code_for_another_account() { 99 let client = client(); 100 + let (access_jwt1, _did1) = create_admin_account_and_login(&client).await; 101 let (_access_jwt2, did2) = create_account_and_login(&client).await; 102 let payload = json!({ 103 "useCount": 3, ··· 121 #[tokio::test] 122 async fn test_create_invite_codes_success() { 123 let client = client(); 124 + let (access_jwt, _did) = create_admin_account_and_login(&client).await; 125 let payload = json!({ 126 "useCount": 2, 127 "codeCount": 3 ··· 141 assert!(body["codes"].is_array()); 142 let codes = body["codes"].as_array().unwrap(); 143 assert_eq!(codes.len(), 1); 144 + assert_eq!(codes[0]["account"], "admin"); 145 assert_eq!(codes[0]["codes"].as_array().unwrap().len(), 3); 146 } 147 148 #[tokio::test] 149 async fn test_create_invite_codes_for_multiple_accounts() { 150 let client = client(); 151 + let (access_jwt1, did1) = create_admin_account_and_login(&client).await; 152 let (_access_jwt2, did2) = create_account_and_login(&client).await; 153 let payload = json!({ 154 "useCount": 1, ··· 194 } 195 196 #[tokio::test] 197 + async fn test_create_invite_codes_non_admin() { 198 let client = client(); 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 + 224 let create_payload = json!({ 225 + "useCount": 5, 226 + "forAccount": user_did 227 }); 228 let _ = client 229 .post(format!( 230 "{}/xrpc/com.atproto.server.createInviteCode", 231 base_url().await 232 )) 233 + .bearer_auth(&admin_jwt) 234 .json(&create_payload) 235 .send() 236 .await 237 .expect("Failed to create invite code"); 238 + 239 let res = client 240 .get(format!( 241 "{}/xrpc/com.atproto.server.getAccountInviteCodes", 242 base_url().await 243 )) 244 + .bearer_auth(&user_jwt) 245 .send() 246 .await 247 .expect("Failed to send request"); ··· 256 assert!(code["disabled"].is_boolean()); 257 assert!(code["createdAt"].is_string()); 258 assert!(code["uses"].is_array()); 259 + assert_eq!(code["forAccount"], user_did); 260 + assert_eq!(code["createdBy"], "admin"); 261 } 262 263 #[tokio::test] ··· 277 #[tokio::test] 278 async fn test_get_account_invite_codes_include_used_filter() { 279 let client = client(); 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 + 283 let create_payload = json!({ 284 + "useCount": 5, 285 + "forAccount": user_did 286 }); 287 let _ = client 288 .post(format!( 289 "{}/xrpc/com.atproto.server.createInviteCode", 290 base_url().await 291 )) 292 + .bearer_auth(&admin_jwt) 293 .json(&create_payload) 294 .send() 295 .await 296 .expect("Failed to create invite code"); 297 + 298 let res = client 299 .get(format!( 300 "{}/xrpc/com.atproto.server.getAccountInviteCodes", 301 base_url().await 302 )) 303 + .bearer_auth(&user_jwt) 304 .query(&[("includeUsed", "false")]) 305 .send() 306 .await ··· 312 assert!(code["available"].as_i64().unwrap() > 0); 313 } 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 + }