-40
.sqlx/query-2ff22a8c39914689d6cf215ba201fa4ced50b7a003ce01bf7603a7f125113447.json
-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
+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
+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
+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
-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
+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
-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
+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
+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
+2
migrations/20251242_invite_code_for_account.sql
+99
-112
src/api/server/invite.rs
+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
+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
+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
+
}