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 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 + }