19bb31ff Andy McCall 1 month ago ↶
1
2
3
4
use axum:: body:: Body;
5
use axum:: http:: { header, Request, StatusCode} ;
6
use http_body_util:: BodyExt;
7
use oxy_core:: repo:: Repo;
8
use oxygenhub:: config:: Config;
9
use oxygenhub:: db;
10
use oxygenhub:: Shared;
11
use std:: path:: Path;
12
use tower:: ServiceExt;
13
14
fn hermetic ( ) {
15
static ONCE : std:: sync:: Once = std:: sync:: Once:: new( ) ;
16
static DIR : std:: sync:: OnceLock< tempfile:: TempDir> = std:: sync:: OnceLock:: new( ) ;
17
ONCE .call_once ( | | {
18
let d = tempfile:: tempdir( ) .unwrap ( ) ;
19
std:: env:: set_var( " OXY_HOME" , d.path ( ) ) ;
20
DIR .set ( d) .ok ( ) ;
21
} ) ;
22
}
23
24
fn write ( root : & Path, rel : & str , content : & [u8 ]) {
25
let p = root.join ( rel) ;
26
std:: fs:: create_dir_all( p.parent ( ) .unwrap ( ) ) .unwrap ( ) ;
27
std:: fs:: write( p, content) .unwrap ( ) ;
28
}
29
30
struct Hub {
31
app : Shared,
32
base : String,
33
_dir : tempfile:: TempDir,
34
}
35
36
async fn hub ( ) -> Hub {
37
hermetic ( ) ;
38
let dir = tempfile:: tempdir( ) .unwrap ( ) ;
39
let mut config = Config:: load( dir.path ( ) ) .unwrap ( ) ;
40
let listener = tokio:: net:: TcpListener:: bind( " 127.0.0.1:0" ) .await.unwrap ( ) ;
41
let port = listener.local_addr ( ) .unwrap ( ) .port ( ) ;
42
config.server.base_url = format! ( " http://127.0.0.1:{port} " ) ;
43
config.registration.mode = " invite" .into ( ) ;
44
let app = oxygenhub:: build( config) .await.unwrap ( ) ;
45
let router = oxygenhub:: router( app.clone ( ) ) ;
46
tokio:: spawn( async move { axum:: serve( listener, router) .await.unwrap ( ) } ) ;
47
Hub { app, base: format! ( " http://127.0.0.1:{port} " ) , _dir: dir }
48
}
49
50
struct Res {
51
status : StatusCode,
52
body : String,
53
cookie : Option< String> ,
54
location : Option< String> ,
55
}
56
57
async fn send ( app : & Shared, method : & str , path : & str , cookie : Option< & str > , form : Option< & str > ) -> Res {
58
let mut req = Request:: builder( ) .method ( method) .uri ( path) ;
59
if let Some ( c) = cookie {
60
req = req.header ( header:: COOKIE , format! ( " oxyhub={c} " ) ) ;
61
}
62
let body = match form {
63
Some ( f) => {
64
req = req.header ( header:: CONTENT_TYPE , " application/x-www-form-urlencoded" ) ;
65
Body:: from( f.to_string ( ) )
66
}
67
None => Body:: empty( ) ,
68
} ;
69
let res = oxygenhub:: router( app.clone ( ) ) .oneshot ( req.body ( body) .unwrap ( ) ) .await.unwrap ( ) ;
70
let status = res.status ( ) ;
71
let cookie = res
72
.headers ( )
73
.get ( header:: SET_COOKIE )
74
.and_then ( | v | v.to_str ( ) .ok ( ) )
75
.and_then ( | v | v.split ( ' ;' ) .next ( ) )
76
.and_then ( | kv | kv.split_once ( ' =' ) )
77
.map ( | ( _, v ) | v.to_string ( ) ) ;
78
let location = res.headers ( ) .get ( header:: LOCATION ) .and_then ( | v | v.to_str ( ) .ok ( ) ) .map ( String :: from) ;
79
let bytes = res.into_body ( ) .collect ( ) .await.unwrap ( ) .to_bytes ( ) ;
80
Res { status, body: String :: from_utf8_lossy( & bytes) .into_owned ( ) , cookie, location }
81
}
82
83
async fn get ( app : & Shared, path : & str , cookie : Option< & str > ) -> Res {
84
send ( app, " GET" , path, cookie, None ) .await
85
}
86
87
async fn post ( app : & Shared, path : & str , cookie : Option< & str > , form : & str ) -> Res {
88
send ( app, " POST" , path, cookie, Some ( form) ) .await
89
}
90
91
92
async fn register ( app : & Shared, user : & str , invite : & str ) -> String {
93
let r =
94
post ( app, " /register" , None , & format! ( " username={user} &email={user} %40example.com&password=correct-horse-battery&invite={invite} " ) )
95
.await;
96
assert_eq! ( r.status, StatusCode:: SEE_OTHER , " {}" , r.body) ;
97
r.cookie.expect ( " session cookie" )
98
}
99
100
fn csrf_of ( page : & str ) -> String {
101
let i = page.find ( " name=\" _csrf\" value=\" " ) .expect ( " csrf field" ) + " name=\" _csrf\" value=\" " .len ( ) ;
102
page[ i.. ] .split ( ' "' ) .next ( ) .unwrap ( ) .to_string ( )
103
}
104
105
# [ tokio ::test ( flavor = " multi_thread" ) ]
106
async fn accounts_roles_tokens_and_attribution ( ) {
107
let h = hub ( ) .await;
108
let app = & h.app;
109
110
111
let andy = register ( app, " andy" , " " ) .await;
112
let me = db:: accounts:: account_by_name( & app.db, " andy" ) .await.unwrap ( ) .unwrap ( ) ;
113
assert! ( me.is_admin) ;
114
115
let r = post ( app, " /register" , None , " username=mallory&email=m%40example.com&password=correct-horse-battery&invite=" ) .await;
116
assert_eq! ( r.status, StatusCode:: OK ) ;
117
assert! ( r.body.contains ( " invitation" ) , " {}" , r.body) ;
118
let admin_page = get ( app, " /admin" , Some ( & andy) ) .await;
119
assert_eq! ( admin_page.status, StatusCode:: OK ) ;
120
let csrf = csrf_of ( & admin_page.body) ;
121
let r = post ( app, " /admin/invite" , Some ( & andy) , & format! ( " _csrf={csrf} " ) ) .await;
122
let code = r.body.split ( " register?invite=" ) .nth ( 1 ) .unwrap ( ) .split ( ' <' ) .next ( ) .unwrap ( ) .trim ( ) .to_string ( ) ;
123
let bob = register ( app, " bob" , & code) .await;
124
assert! ( ! db:: accounts:: account_by_name( & app.db, " bob" ) .await.unwrap ( ) .unwrap ( ) .is_admin) ;
125
126
assert_eq! ( post ( app, " /login" , None , " username=andy&password=nope" ) .await.status, StatusCode:: OK ) ;
127
assert_eq! ( post ( app, " /settings" , Some ( & andy) , " _csrf=bogus&display_name=x&email=y" ) .await.status, StatusCode:: BAD_REQUEST ) ;
128
assert_eq! ( get ( app, " /settings" , None ) .await.status, StatusCode:: SEE_OTHER , " anonymous settings redirects to sign in" ) ;
129
130
131
let new_page = get ( app, " /new" , Some ( & andy) ) .await;
132
let csrf = csrf_of ( & new_page.body) ;
133
let r = post ( app, " /new" , Some ( & andy) , & format! ( " _csrf={csrf} &owner=andy&name=secret&description=hush&visibility=private" ) ) .await;
134
assert_eq! ( r.status, StatusCode:: SEE_OTHER , " {}" , r.body) ;
135
assert_eq! ( get ( app, " /andy/secret" , Some ( & andy) ) .await.status, StatusCode:: OK ) ;
136
assert_eq! ( get ( app, " /andy/secret" , Some ( & bob) ) .await.status, StatusCode:: NOT_FOUND ) ;
137
assert_eq! ( get ( app, " /andy/secret" , None ) .await.status, StatusCode:: NOT_FOUND ) ;
138
assert! ( ! get ( app, " /" , Some ( & bob) ) .await.body.contains ( " secret" ) ) ;
139
assert! ( get ( app, " /" , Some ( & andy) ) .await.body.contains ( " secret" ) ) ;
140
141
142
let tokens_page = get ( app, " /settings/tokens" , Some ( & andy) ) .await;
143
let csrf = csrf_of ( & tokens_page.body) ;
144
let r = post ( app, " /settings/tokens" , Some ( & andy) , & format! ( " _csrf={csrf} &name=ro&scopes=repo%3Aread" ) ) .await;
145
let ro = r.body.split ( " oxyhub_" ) .nth ( 1 ) .map ( | s | format! ( " oxyhub_{} " , s.split ( ' <' ) .next ( ) .unwrap ( ) .trim ( ) ) ) .unwrap ( ) ;
146
let r = post ( app, " /settings/tokens" , Some ( & andy) , & format! ( " _csrf={csrf} &name=rw&scopes=repo%3Aread&scopes=repo%3Awrite" ) ) .await;
147
let rw = r.body.split ( " oxyhub_" ) .nth ( 1 ) .map ( | s | format! ( " oxyhub_{} " , s.split ( ' <' ) .next ( ) .unwrap ( ) .trim ( ) ) ) .unwrap ( ) ;
148
let work = tempfile:: tempdir( ) .unwrap ( ) ;
149
let mut client = Repo:: init( work.path ( ) ) .unwrap ( ) ;
150
client.create_identity ( " Andy McCall" , " [email protected] " , oxy_core:: repo:: IdentityScope:: User) .unwrap ( ) ;
151
client.config.set ( " remote.origin.url" , & format! ( " {} /andy/secret" , h.base) ) .unwrap ( ) ;
152
client.config.set ( " core.sign" , " change" ) .unwrap ( ) ;
153
client.save_config ( ) .unwrap ( ) ;
154
write ( work.path ( ) , " a.txt" , b " a\n " ) ;
155
client.snapshot ( " t" ) .unwrap ( ) ;
156
let c1 = client.commit ( " first" , & [ ] ) .unwrap ( ) ;
157
let err = client.push ( " origin" , false ) .err ( ) .unwrap ( ) .to_string ( ) ;
158
assert! ( err.contains ( " 404" ) , " anonymous cannot even see it: {err}" ) ;
159
client.config.set ( " remote.origin.token" , & ro) .unwrap ( ) ;
160
let err = client.push ( " origin" , false ) .err ( ) .unwrap ( ) .to_string ( ) ;
161
assert! ( err.contains ( " 403" ) , " read-only token cannot push: {err}" ) ;
162
client.config.set ( " remote.origin.token" , & rw) .unwrap ( ) ;
163
assert! ( client.push ( " origin" , false ) .unwrap ( ) .iter ( ) .all ( | r | r.ok ) ) ;
164
165
let bob_tokens = get ( app, " /settings/tokens" , Some ( & bob) ) .await;
166
let csrf_b = csrf_of ( & bob_tokens.body) ;
167
let r = post ( app, " /settings/tokens" , Some ( & bob) , & format! ( " _csrf={csrf_b} &name=b&scopes=repo%3Aread&scopes=repo%3Awrite" ) ) .await;
168
let bob_tok = r.body.split ( " oxyhub_" ) .nth ( 1 ) .map ( | s | format! ( " oxyhub_{} " , s.split ( ' <' ) .next ( ) .unwrap ( ) .trim ( ) ) ) .unwrap ( ) ;
169
std:: env:: set_var( " OXY_TOKEN" , & bob_tok) ;
170
let err = Repo:: clone( & format! ( " {} /andy/secret" , h.base) , & work.path ( ) .join ( " bobclone" ) ) .err ( ) .unwrap ( ) .to_string ( ) ;
171
std:: env:: remove_var( " OXY_TOKEN" ) ;
172
assert! ( err.contains ( " 404" ) , " {err}" ) ;
173
174
tokio:: time:: sleep( std:: time:: Duration:: from_millis( 150 ) ) .await;
175
let pushes = get ( app, " /andy/secret/pushes" , Some ( & andy) ) .await.body;
176
assert! ( pushes.contains ( " andy" ) , " {pushes}" ) ;
177
178
179
let settings = get ( app, " /andy/secret/settings" , Some ( & andy) ) .await;
180
assert_eq! ( settings.status, StatusCode:: OK ) ;
181
let csrf = csrf_of ( & settings.body) ;
182
let r = post ( app, " /andy/secret/settings/collaborators" , Some ( & andy) , & format! ( " _csrf={csrf} &username=bob&role=write" ) ) .await;
183
assert_eq! ( r.status, StatusCode:: OK , " {}" , r.body) ;
184
assert_eq! ( get ( app, " /andy/secret" , Some ( & bob) ) .await.status, StatusCode:: OK ) ;
185
assert_eq! ( get ( app, " /andy/secret/settings" , Some ( & bob) ) .await.status, StatusCode:: NOT_FOUND , " write is not maintain" ) ;
186
std:: env:: set_var( " OXY_TOKEN" , & bob_tok) ;
187
let ok = Repo:: clone( & format! ( " {} /andy/secret" , h.base) , & work.path ( ) .join ( " bobclone2" ) ) .is_ok ( ) ;
188
std:: env:: remove_var( " OXY_TOKEN" ) ;
189
assert! ( ok) ;
190
2bbe88a8 Andy McCall 1 month ago ↶
191
192
193
let store = oxy_core:: credentials:: Credentials:: user( ) ;
194
let hub = oxy_core:: credentials:: hub_of( & h.base) .unwrap ( ) ;
195
store.save ( & hub, & rw, Some ( " andy" ) , None ) .unwrap ( ) ;
196
let signed_in = Repo:: clone( & format! ( " {} /andy/secret" , h.base) , & work.path ( ) .join ( " signedin" ) ) ;
197
store.remove ( & hub) .unwrap ( ) ;
198
let signed_in = signed_in.unwrap ( ) ;
199
assert! ( signed_in.config.remote_tokens.is_empty ( ) ) ;
200
assert_eq! ( signed_in.bookmark_heads ( ) .unwrap ( ) , vec! [ c1] ) ;
201
19bb31ff Andy McCall 1 month ago ↶
202
203
let ( key, identity) = {
204
let k = client.config.author_key.unwrap ( ) ;
205
( k, client.identity ( & k) .unwrap ( ) )
206
} ;
207
let keys_page = get ( app, " /settings/keys" , Some ( & andy) ) .await;
208
let csrf = csrf_of ( & keys_page.body) ;
209
let boundary = " XYZ" ;
210
let mut multipart = Vec :: new( ) ;
211
multipart.extend_from_slice ( format! ( " --{boundary} \r \n Content-Disposition: form-data; name=\" _csrf\" \r \n \r \n {csrf} \r \n " ) .as_bytes ( ) ) ;
212
multipart.extend_from_slice ( format! ( " --{boundary} \r \n Content-Disposition: form-data; name=\" identity\" ; filename=\" me.identity\" \r \n Content-Type: application/octet-stream\r \n \r \n " ) .as_bytes ( ) ) ;
213
multipart.extend_from_slice ( & identity.encode ( ) ) ;
214
multipart.extend_from_slice ( format! ( " \r \n --{boundary} --\r \n " ) .as_bytes ( ) ) ;
215
let req = Request:: builder( )
216
.method ( " POST" )
217
.uri ( " /settings/keys/upload" )
218
.header ( header:: COOKIE , format! ( " oxyhub={andy} " ) )
219
.header ( header:: CONTENT_TYPE , format! ( " multipart/form-data; boundary={boundary} " ) )
220
.body ( Body:: from( multipart) )
221
.unwrap ( ) ;
222
let res = oxygenhub:: router( app.clone ( ) ) .oneshot ( req) .await.unwrap ( ) ;
223
assert_eq! ( res.status ( ) , StatusCode:: OK ) ;
224
let body = String :: from_utf8_lossy( & res.into_body ( ) .collect ( ) .await.unwrap ( ) .to_bytes ( ) ) .into_owned ( ) ;
225
let challenge_hex = body.split ( " sign-challenge " ) .nth ( 1 ) .unwrap ( ) .split ( ' <' ) .next ( ) .unwrap ( ) .trim ( ) .to_string ( ) ;
226
let challenge_id = body.split ( " name=\" challenge\" value=\" " ) .nth ( 1 ) .unwrap ( ) .split ( ' "' ) .next ( ) .unwrap ( ) .to_string ( ) ;
227
let ( _ , sk) = client.secret_key ( ) .unwrap ( ) .unwrap ( ) ;
228
let sig = sk.sign_challenge ( & key, & hex:: decode( & challenge_hex) .unwrap ( ) ) .unwrap ( ) ;
229
let sig_hex = hex:: encode( oxy_core:: identity:: Signature:: encode_list( & [ sig] ) ) ;
230
let r = post ( app, " /settings/keys/verify" , Some ( & andy) , & format! ( " _csrf={csrf} &challenge={challenge_id} &signature={sig_hex} " ) ) .await;
231
assert! ( r.body.contains ( " Key linked" ) , " {}" , r.body) ;
232
let hist = get ( app, " /andy/secret/changes/main" , Some ( & andy) ) .await.body;
233
assert! ( hist.contains ( " href=\" /andy\" >Andy McCall</a>" ) , " author linked to the account: {hist}" ) ;
234
let change = get ( app, & format! ( " /andy/secret/change/{} " , c1.to_hex ( ) ) , Some ( & andy) ) .await.body;
235
assert! ( change.contains ( " @andy" ) , " {change}" ) ;
236
237
let r = post ( app, " /settings/keys/verify" , Some ( & andy) , & format! ( " _csrf={csrf} &challenge=999&signature={sig_hex} " ) ) .await;
238
assert! ( r.body.contains ( " expired" ) , " {}" , r.body) ;
239
240
241
let org_page = get ( app, " /orgs/new" , Some ( & andy) ) .await;
242
let csrf = csrf_of ( & org_page.body) ;
243
let r = post ( app, " /orgs/new" , Some ( & andy) , & format! ( " _csrf={csrf} &name=acme&display_name=Acme" ) ) .await;
244
assert_eq! ( r.status, StatusCode:: SEE_OTHER , " {}" , r.body) ;
245
let new_page = get ( app, " /new" , Some ( & andy) ) .await;
246
let csrf = csrf_of ( & new_page.body) ;
247
assert_eq! (
248
post ( app, " /new" , Some ( & andy) , & format! ( " _csrf={csrf} &owner=acme&name=plans&visibility=private" ) ) .await.status,
249
StatusCode:: SEE_OTHER
250
) ;
251
assert_eq! ( get ( app, " /acme/plans" , Some ( & bob) ) .await.status, StatusCode:: NOT_FOUND ) ;
252
let org_settings = get ( app, " /orgs/acme/settings" , Some ( & andy) ) .await;
253
let csrf = csrf_of ( & org_settings.body) ;
254
assert_eq! (
255
post ( app, " /orgs/acme/settings/members" , Some ( & andy) , & format! ( " _csrf={csrf} &username=bob&role=member" ) ) .await.status,
256
StatusCode:: OK
257
) ;
258
assert_eq! ( get ( app, " /acme/plans" , Some ( & bob) ) .await.status, StatusCode:: OK , " members read by default" ) ;
259
assert_eq! ( get ( app, " /orgs/acme/settings" , Some ( & bob) ) .await.status, StatusCode:: NOT_FOUND , " members do not administer" ) ;
260
261
let r = post (
262
app,
263
" /new" ,
264
Some ( & bob) ,
265
& format! ( " _csrf={} &owner=acme&name=bobs&visibility=public" , csrf_of ( & get ( app, " /new" , Some ( & bob) ) .await.body) ) ,
266
)
267
.await;
268
assert_eq! ( r.status, StatusCode:: SEE_OTHER , " {}" , r.body) ;
269
270
271
let sec = get ( app, " /settings/security" , Some ( & andy) ) .await;
272
let csrf = csrf_of ( & sec.body) ;
273
let r = post ( app, " /settings/security/sessions" , Some ( & andy) , & format! ( " _csrf={csrf} " ) ) .await;
274
assert_eq! ( r.status, StatusCode:: SEE_OTHER ) ;
275
assert_eq! ( get ( app, " /settings" , Some ( & andy) ) .await.status, StatusCode:: SEE_OTHER , " old session is gone" ) ;
276
let fresh = r.cookie.unwrap ( ) ;
277
assert_eq! ( get ( app, " /settings" , Some ( & fresh) ) .await.status, StatusCode:: OK ) ;
278
279
280
let sec = get ( app, " /settings/security" , Some ( & fresh) ) .await;
281
let csrf = csrf_of ( & sec.body) ;
282
let r = post ( app, " /settings/security/totp/begin" , Some ( & fresh) , & format! ( " _csrf={csrf} " ) ) .await;
283
let secret = r.body.split ( " name=\" secret\" value=\" " ) .nth ( 1 ) .unwrap ( ) .split ( ' "' ) .next ( ) .unwrap ( ) .to_string ( ) ;
284
let now = ( db:: now_ms( ) / 1000 ) as u64 ;
285
let code = oxygenhub:: auth:: totp:: current( & secret, now) .unwrap ( ) ;
286
let r = post ( app, " /settings/security/totp/enable" , Some ( & fresh) , & format! ( " _csrf={csrf} &secret={secret} &code={code} " ) ) .await;
287
assert! ( r.body.contains ( " Recovery codes" ) , " {}" , r.body) ;
288
let r = post ( app, " /login" , None , " username=andy&password=correct-horse-battery" ) .await;
289
assert! ( r.body.contains ( " authenticator" ) , " second step asked: {}" , r.body) ;
290
let code = oxygenhub:: auth:: totp:: current( & secret, ( db:: now_ms( ) / 1000 ) as u64 ) .unwrap ( ) ;
291
let r = post ( app, " /login" , None , & format! ( " username=andy&password=correct-horse-battery&code={code} " ) ) .await;
292
assert_eq! ( r.status, StatusCode:: SEE_OTHER , " {}" , r.body) ;
293
assert! ( r.cookie.is_some ( ) ) ;
294
assert! ( r.location.as_deref ( ) = = Some ( " /" ) ) ;
295
}
a160b98f Andy McCall 1 month ago ↶
296
297
298
fn picture_form ( csrf : & str , action : & str , file : & [u8 ]) -> ( String, Vec< u8 > ) {
299
let boundary = " picture-boundary-1234" ;
300
let mut body = Vec :: new( ) ;
301
body.extend_from_slice ( format! ( " --{boundary} \r \n Content-Disposition: form-data; name=\" _csrf\" \r \n \r \n {csrf} \r \n " ) .as_bytes ( ) ) ;
302
body.extend_from_slice ( format! ( " --{boundary} \r \n Content-Disposition: form-data; name=\" action\" \r \n \r \n {action} \r \n " ) .as_bytes ( ) ) ;
303
body.extend_from_slice (
304
format! ( " --{boundary} \r \n Content-Disposition: form-data; name=\" picture\" ; filename=\" me.png\" \r \n Content-Type: image/png\r \n \r \n " )
305
.as_bytes ( ) ,
306
) ;
307
body.extend_from_slice ( file) ;
308
body.extend_from_slice ( format! ( " \r \n --{boundary} --\r \n " ) .as_bytes ( ) ) ;
309
( format! ( " multipart/form-data; boundary={boundary} " ) , body)
310
}
311
312
async fn upload ( app : & Shared, path : & str , cookie : & str , csrf : & str , action : & str , file : & [u8 ]) -> Res {
313
let ( ct, body) = picture_form ( csrf, action, file) ;
314
let req = Request:: post( path)
315
.header ( header:: COOKIE , format! ( " oxyhub={cookie} " ) )
316
.header ( header:: CONTENT_TYPE , ct)
317
.body ( Body:: from( body) )
318
.unwrap ( ) ;
319
let res = oxygenhub:: router( app.clone ( ) ) .oneshot ( req) .await.unwrap ( ) ;
320
let status = res.status ( ) ;
321
let bytes = res.into_body ( ) .collect ( ) .await.unwrap ( ) .to_bytes ( ) ;
322
Res { status, body: String :: from_utf8_lossy( & bytes) .into_owned ( ) , cookie: None , location: None }
323
}
324
325
326
async fn picture ( app : & Shared, name : & str ) -> ( StatusCode, String, Vec< u8 > ) {
327
let res = oxygenhub:: router( app.clone ( ) ) .oneshot ( Request:: get( format! ( " /avatar/{name} " ) ) .body ( Body:: empty( ) ) .unwrap ( ) ) .await.unwrap ( ) ;
328
let ct = res.headers ( ) .get ( header:: CONTENT_TYPE ) .and_then ( | v | v.to_str ( ) .ok ( ) ) .unwrap_or ( " " ) .to_string ( ) ;
329
let status = res.status ( ) ;
330
( status, ct, res.into_body ( ) .collect ( ) .await.unwrap ( ) .to_bytes ( ) .to_vec ( ) )
331
}
332
333
fn png ( w : u32 , h : u32 , alpha : bool ) -> Vec< u8 > {
334
let img = if alpha {
335
image:: DynamicImage:: ImageRgba8( image:: RgbaImage:: from_fn( w, h, | x , _| {
336
image:: Rgba( [ 0 , 114 , 255 , if x % 2 = = 0 { 0 } else { 255 } ] )
337
} ) )
338
} else {
339
image:: DynamicImage:: ImageRgb8( image:: RgbImage:: from_fn( w, h, | x , y | image:: Rgb( [ x as u8 , y as u8 , 128 ] ) ) )
340
} ;
341
let mut out = Vec :: new( ) ;
342
img.write_to ( & mut std:: io:: Cursor:: new( & mut out) , image:: ImageFormat:: Png) .unwrap ( ) ;
343
out
344
}
345
346
# [ tokio ::test ( flavor = " multi_thread" ) ]
347
async fn profile_pictures_and_logos ( ) {
348
let h = hub ( ) .await;
349
let app = & h.app;
350
let andy = register ( app, " andy" , " " ) .await;
351
assert_eq! ( picture ( app, " andy" ) .await.1 , " image/svg+xml" , " the identicon first" ) ;
352
let csrf = csrf_of ( & get ( app, " /settings" , Some ( & andy) ) .await.body) ;
353
assert! ( get ( app, " /settings" , Some ( & andy) ) .await.body.contains ( " id=\" picture\" " ) ) ;
354
355
356
let r = upload ( app, " /settings/avatar" , & andy, & csrf, " upload" , & png ( 640 , 480 , false ) ) .await;
357
assert! ( r.body.contains ( " Picture saved" ) , " {}" , r.body) ;
358
let ( st, ct, bytes) = picture ( app, " andy" ) .await;
359
assert_eq! ( ( st, ct.as_str ( ) ) , ( StatusCode:: OK , " image/jpeg" ) ) ;
360
let img = image:: load_from_memory( & bytes) .unwrap ( ) ;
361
assert_eq! ( ( img.width ( ) , img.height ( ) ) , ( 400 , 400 ) ) ;
362
let stored = db:: accounts:: account_by_name( & app.db, " andy" ) .await.unwrap ( ) .unwrap ( ) .avatar;
363
assert! ( stored.starts_with ( " jpeg:" ) , " {stored}" ) ;
364
365
366
let r = upload (
367
app,
368
" /settings/avatar" ,
369
& andy,
370
& csrf,
371
" upload" ,
372
b " <svg xmlns=\" http://www.w3.org/2000/svg\" ><script>alert(1)</script></svg>" ,
373
)
374
.await;
375
assert! ( r.body.contains ( " not a PNG, JPEG or WebP" ) , " {}" , r.body) ;
376
let r = upload ( app, " /settings/avatar" , & andy, " wrong" , " upload" , & png ( 10 , 10 , false ) ) .await;
377
assert_ne! ( r.status, StatusCode:: OK ) ;
378
assert_eq! ( picture ( app, " andy" ) .await.1 , " image/jpeg" ) ;
379
380
let r = upload ( app, " /settings/avatar" , & andy, & csrf, " remove" , b " " ) .await;
381
assert! ( r.body.contains ( " Picture removed" ) ) ;
382
assert_eq! ( picture ( app, " andy" ) .await.1 , " image/svg+xml" ) ;
383
384
385
let me = db:: accounts:: account_by_name( & app.db, " andy" ) .await.unwrap ( ) .unwrap ( ) ;
386
db:: accounts:: create_org( & app.db, " acme" , " Acme" , me.id) .await.unwrap ( ) ;
387
let org_csrf = csrf_of ( & get ( app, " /orgs/acme/settings" , Some ( & andy) ) .await.body) ;
388
let r = upload ( app, " /orgs/acme/settings/avatar" , & andy, & org_csrf, " upload" , & png ( 120 , 120 , true ) ) .await;
389
assert! ( r.body.contains ( " Picture saved" ) , " {}" , r.body) ;
390
let ( _ , ct, bytes) = picture ( app, " acme" ) .await;
391
assert_eq! ( ct, " image/png" ) ;
392
assert! ( image:: load_from_memory( & bytes) .unwrap ( ) .color ( ) .has_alpha ( ) ) ;
393
let invite = {
394
let admin = get ( app, " /admin" , Some ( & andy) ) .await;
395
let r = post ( app, " /admin/invite" , Some ( & andy) , & format! ( " _csrf={} " , csrf_of ( & admin.body) ) ) .await;
396
let i = r.body.find ( " invite=" ) .map ( | i | i + 7 ) .expect ( " an invitation" ) ;
397
r.body[ i.. ] .chars ( ) .take_while ( | c | c.is_ascii_alphanumeric ( ) ) .collect:: < String> ( )
398
} ;
399
let bob = register ( app, " bob" , & invite) .await;
400
let bob_csrf = csrf_of ( & get ( app, " /settings" , Some ( & bob) ) .await.body) ;
401
let r = upload ( app, " /orgs/acme/settings/avatar" , & bob, & bob_csrf, " remove" , b " " ) .await;
402
assert_eq! ( r.status, StatusCode:: NOT_FOUND , " not an owner" ) ;
403
assert_eq! ( picture ( app, " acme" ) .await.1 , " image/png" ) ;
404
}
8fb35df7 Andy McCall 1 month ago ↶
405
406
# [ tokio ::test ( flavor = " multi_thread" ) ]
407
async fn organisation_webhooks_and_storage ( ) {
408
let h = hub ( ) .await;
409
let app = & h.app;
410
let andy = register ( app, " andy" , " " ) .await;
411
let me = db:: accounts:: account_by_name( & app.db, " andy" ) .await.unwrap ( ) .unwrap ( ) ;
412
let org = db:: accounts:: create_org( & app.db, " acme" , " Acme" , me.id) .await.unwrap ( ) ;
413
414
415
let page = get ( app, " /orgs/acme/settings" , Some ( & andy) ) .await;
416
assert! ( page.body.contains ( " id=\" webhooks\" " ) & & page.body.contains ( " href=\" #webhooks\" " ) ) ;
417
let csrf = csrf_of ( & page.body) ;
418
let r = post ( app, " /orgs/acme/settings/hooks" , Some ( & andy) , & format! ( " _csrf={csrf} &url=ftp%3A%2F%2Fx&events=*" ) ) .await;
419
assert! ( r.body.contains ( " starts with http" ) , " {}" , r.body) ;
420
let r = post (
421
app,
422
" /orgs/acme/settings/hooks" ,
423
Some ( & andy) ,
424
& format! ( " _csrf={csrf} &url=https%3A%2F%2Fhooks.invalid%2Facme&secret=s&events=push" ) ,
425
)
426
.await;
427
assert! ( r.body.contains ( " Webhook added" ) ) ;
428
let hooks = db:: hooks:: hooks_for_org( & app.db, org.id) .await.unwrap ( ) ;
429
assert_eq! ( hooks.len ( ) , 1 ) ;
430
let id = hooks[ 0 ] .id;
431
assert! ( post ( app, " /orgs/acme/settings/hooks" , Some ( & andy) , & format! ( " _csrf={csrf} &id={id} &action=ping" ) )
432
.await
433
.body
434
.contains ( " Ping queued" ) ) ;
435
let deliveries = get ( app, & format! ( " /orgs/acme/settings/hooks/{id} /deliveries" ) , Some ( & andy) ) .await;
436
assert_eq! ( deliveries.status, StatusCode:: OK ) ;
437
assert! ( deliveries.body.contains ( " >ping<" ) & & deliveries.body.contains ( " /orgs/acme/settings#webhooks" ) ) ;
438
439
let repo = oxygenhub:: repos:: RepoName:: parse( " andy/demo" ) .unwrap ( ) ;
440
app.repos.create ( & repo, None ) .unwrap ( ) ;
441
let row = db:: repos:: create( & app.db, " andy" , " demo" , " " , hub_api:: Visibility:: Public) .await.unwrap ( ) ;
442
let repo_hook = db:: hooks:: create_hook( & app.db, Some ( row.id) , None , " https://hooks.invalid/repo" , " " , " *" ) .await.unwrap ( ) .id;
443
post ( app, " /orgs/acme/settings/hooks" , Some ( & andy) , & format! ( " _csrf={csrf} &id={repo_hook} &action=delete" ) ) .await;
444
assert! ( db:: hooks:: hook( & app.db, repo_hook) .await.unwrap ( ) .is_some ( ) , " not the organisation's" ) ;
445
assert_eq! ( get ( app, & format! ( " /orgs/acme/settings/hooks/{repo_hook} /deliveries" ) , Some ( & andy) ) .await.status, StatusCode:: NOT_FOUND ) ;
446
447
let rs = get ( app, " /andy/demo/settings" , Some ( & andy) ) .await;
448
assert! ( rs.body.contains ( " https://hooks.invalid/repo" ) & & rs.body.contains ( " action=\" /andy/demo/settings/hooks\" " ) ) ;
449
450
451
let work = tempfile:: tempdir( ) .unwrap ( ) ;
452
let mut client = Repo:: init( work.path ( ) ) .unwrap ( ) ;
453
client.config.set ( " remote.origin.url" , & format! ( " {} /andy/demo" , h.base) ) .unwrap ( ) ;
454
let ( token, hash) = oxygenhub:: auth:: new_token( ) ;
455
db:: accounts:: create_token( & app.db, me.id, " push" , & hash, " repo:write" , " " , None ) .await.unwrap ( ) ;
456
client.config.set ( " remote.origin.token" , & token) .unwrap ( ) ;
457
client.save_config ( ) .unwrap ( ) ;
458
write ( work.path ( ) , " a.txt" , b " a\n " ) ;
459
client.snapshot ( " t" ) .unwrap ( ) ;
460
client.commit ( " one" , & [ ] ) .unwrap ( ) ;
461
assert! ( client.push ( " origin" , false ) .unwrap ( ) .iter ( ) .all ( | r | r.ok ) ) ;
462
let storage = get ( app, " /admin/storage" , Some ( & andy) ) .await;
463
assert_eq! ( storage.status, StatusCode:: OK ) ;
464
assert! ( storage.body.contains ( " href=\" /andy/demo\" " ) ) ;
465
let loose_before = std:: fs:: read_dir( app.repos.dir ( & repo) .join ( " .oxy/objects/loose" ) ) .map ( | d | d.count ( ) ) .unwrap_or ( 0 ) ;
466
assert! ( loose_before > 0 , " the push left loose objects" ) ;
467
let r = post ( app, " /admin/storage" , Some ( & andy) , & format! ( " _csrf={} &repo=andy%2Fdemo" , csrf_of ( & storage.body) ) ) .await;
468
assert! ( r.body.contains ( " Maintained 1 repository" ) & & r.body.contains ( " loose objects packed" ) , " {}" , r.body) ;
469
let loose_after: usize = walk_files ( & app.repos.dir ( & repo) .join ( " .oxy/objects/loose" ) ) ;
470
assert_eq! ( loose_after, 0 , " packed" ) ;
471
let audit = db:: accounts:: audit_rows( & app.db, 20 ) .await.unwrap ( ) ;
472
assert! ( audit.iter ( ) .any ( | a | a.action = = " admin.gc" & & a.target = = " andy/demo" ) ) ;
473
let r = post ( app, " /admin/storage" , Some ( & andy) , & format! ( " _csrf={} &repo=nobody%2Fnone" , csrf_of ( & storage.body) ) ) .await;
474
assert! ( r.body.contains ( " No repository nobody/none" ) ) ;
475
}
476
477
fn walk_files ( dir : & Path) -> usize {
478
let Ok ( rd) = std:: fs:: read_dir( dir) else { return 0 } ;
479
rd.flatten ( ) .map ( | e | if e.path ( ) .is_dir ( ) { walk_files ( & e.path ( ) ) } else { 1 } ) .sum ( )
480
}
6a178055 Andy McCall 1 month ago ↶
481
482
483
484
485
# [ tokio ::test ( flavor = " multi_thread" ) ]
486
async fn admins_disable_enable_and_delete_accounts ( ) {
487
let h = hub ( ) .await;
488
let app = & h.app;
489
let andy = register ( app, " andy" , " " ) .await;
490
let invite = | app : Shared, andy : String| async move {
491
let csrf = csrf_of ( & get ( & app, " /admin" , Some ( & andy) ) .await.body) ;
492
let r = post ( & app, " /admin/invite" , Some ( & andy) , & format! ( " _csrf={csrf} " ) ) .await;
493
r.body.split ( " register?invite=" ) .nth ( 1 ) .unwrap ( ) .split ( ' <' ) .next ( ) .unwrap ( ) .trim ( ) .to_string ( )
494
} ;
495
let bob = register ( app, " bob" , & invite ( app.clone ( ) , andy.clone ( ) ) .await) .await;
496
let act = | who : & 'static str , action : & 'static str , confirm : & 'static str | {
497
let ( app, andy) = ( app.clone ( ) , andy.clone ( ) ) ;
498
async move {
499
let csrf = csrf_of ( & get ( & app, " /admin" , Some ( & andy) ) .await.body) ;
500
post ( & app, " /admin/account" , Some ( & andy) , & format! ( " _csrf={csrf} &username={who} &action={action} &confirm={confirm} " ) ) .await.body
501
}
502
} ;
503
let login = | user : & 'static str | {
504
let app = app.clone ( ) ;
505
async move { post ( & app, " /login" , None , & format! ( " username={user} &password=correct-horse-battery" ) ) .await }
506
} ;
507
508
509
act ( " bob" , " disable" , " " ) .await;
510
assert! ( db:: accounts:: account_by_name( & app.db, " bob" ) .await.unwrap ( ) .unwrap ( ) .disabled) ;
511
let r = login ( " bob" ) .await;
512
assert! ( r.body.contains ( " Your account has been disabled" ) , " {}" , r.body) ;
513
assert! ( r.cookie.is_none ( ) ) ;
514
assert_eq! ( get ( app, " /settings" , Some ( & bob) ) .await.status, StatusCode:: SEE_OTHER , " the session is refused" ) ;
515
assert! ( act ( " andy" , " disable" , " " ) .await.contains ( " Not on yourself" ) ) ;
516
517
act ( " bob" , " enable" , " " ) .await;
518
assert_eq! ( login ( " bob" ) .await.status, StatusCode:: SEE_OTHER ) ;
519
520
521
let bob = login ( " bob" ) .await.cookie.unwrap ( ) ;
522
let csrf = csrf_of ( & get ( app, " /new" , Some ( & bob) ) .await.body) ;
523
assert_eq! (
524
post ( app, " /new" , Some ( & bob) , & format! ( " _csrf={csrf} &owner=bob&name=keep&visibility=public" ) ) .await.status,
525
StatusCode:: SEE_OTHER
526
) ;
527
assert! ( act ( " bob" , " delete" , " bo" ) .await.contains ( " type bob to confirm" ) ) ;
528
let page = act ( " bob" , " delete" , " bob" ) .await;
529
assert! ( page.contains ( " still owns bob/keep" ) , " {page}" ) ;
530
assert! ( db:: accounts:: account_by_name( & app.db, " bob" ) .await.unwrap ( ) .is_some ( ) ) ;
531
let row = db:: repos:: get( & app.db, " bob" , " keep" ) .await.unwrap ( ) .unwrap ( ) ;
532
db:: repos:: delete( & app.db, row.id) .await.unwrap ( ) ;
533
let page = act ( " bob" , " delete" , " bob" ) .await;
534
assert! ( page.contains ( " bob deleted" ) , " {page}" ) ;
535
assert! ( db:: accounts:: account_by_name( & app.db, " bob" ) .await.unwrap ( ) .is_none ( ) ) ;
536
assert_eq! ( get ( app, " /settings" , Some ( & bob) ) .await.status, StatusCode:: SEE_OTHER ) ;
537
538
let code = invite ( app.clone ( ) , andy.clone ( ) ) .await;
539
let r = post ( app, " /register" , None , & format! ( " username=bob&email=b%40example.com&password=correct-horse-battery&invite={code} " ) ) .await;
540
assert! ( r.body.contains ( " That name is taken" ) , " {}" , r.body) ;
541
542
assert! ( act ( " andy" , " delete" , " andy" ) .await.contains ( " Not on yourself" ) ) ;
543
}
5dead1f4 Andy McCall 1 month ago ↶
544
545
546
547
# [ tokio ::test ( flavor = " multi_thread" ) ]
548
async fn admins_set_the_job_time_limits ( ) {
549
let h = hub ( ) .await;
550
let app = & h.app;
551
let andy = register ( app, " andy" , " " ) .await;
552
let page = get ( app, " /admin/runners/settings" , Some ( & andy) ) .await;
553
assert_eq! ( page.status, StatusCode:: OK ) ;
554
assert! ( page.body.contains ( " Job time limits" ) & & page.body.contains ( " value=\" 60\" " ) , " the hub.toml default shows" ) ;
555
let csrf = csrf_of ( & page.body) ;
556
let r = post ( app, " /admin/runners/settings" , Some ( & andy) , & format! ( " _csrf={csrf} &default_minutes=200&max_minutes=120&grace_minutes=5" ) )
557
.await;
558
assert! ( r.body.contains ( " cannot be longer than the maximum" ) , " {}" , r.body) ;
559
let r = post ( app, " /admin/runners/settings" , Some ( & andy) , & format! ( " _csrf={csrf} &default_minutes=90&max_minutes=240&grace_minutes=10" ) )
560
.await;
561
assert! ( r.body.contains ( " Saved." ) , " {}" , r.body) ;
562
let l = oxygenhub:: flow:: limits( app) .await;
563
assert_eq! ( ( l.default_s, l.max_s, l.grace_ms) , ( 5400 , 14400 , 600_000 ) ) ;
564
assert! ( get ( app, " /admin/runners" , Some ( & andy) ) .await.body.contains ( " href=\" /admin/runners/settings\" " ) ) ;
565
let code = {
566
let csrf = csrf_of ( & get ( app, " /admin" , Some ( & andy) ) .await.body) ;
567
let r = post ( app, " /admin/invite" , Some ( & andy) , & format! ( " _csrf={csrf} " ) ) .await;
568
r.body.split ( " register?invite=" ) .nth ( 1 ) .unwrap ( ) .split ( ' <' ) .next ( ) .unwrap ( ) .trim ( ) .to_string ( )
569
} ;
570
let bob = register ( app, " bob" , & code) .await;
571
assert_ne! ( get ( app, " /admin/runners/settings" , Some ( & bob) ) .await.status, StatusCode:: OK , " not for others" ) ;
572
}
3556d08e Andy McCall 1 month ago ↶
573
574
575
576
# [ tokio ::test ( flavor = " multi_thread" ) ]
577
async fn mail_settings_are_saved_masked_and_tested_from_the_admin_page ( ) {
578
let h = hub ( ) .await;
579
let app = & h.app;
580
let andy = register ( app, " andy" , " " ) .await;
581
let page = get ( app, " /admin" , Some ( & andy) ) .await.body;
582
assert! ( page.contains ( " Mail is off" ) , " {page}" ) ;
583
let csrf = csrf_of ( & page) ;
584
585
let outbox = tempfile:: tempdir( ) .unwrap ( ) ;
586
let r = post (
587
app,
588
" /admin/mail" ,
589
Some ( & andy) ,
590
& format! ( " _csrf={csrf} &smtp=file%3A%2F%2F{} &from=Hub+%3Chub%40example.org%3E" , outbox.path ( ) .display ( ) ) ,
591
)
592
.await;
593
assert! ( r.body.contains ( " Mail settings saved" ) & & r.body.contains ( " Mail is on" ) , " {}" , r.body) ;
594
assert! ( oxygenhub:: mail:: enabled( app) ) ;
595
let r = post ( app, " /admin/mail/test" , Some ( & andy) , & format! ( " _csrf={csrf} " ) ) .await;
596
assert! ( r.body.contains ( " Sent a test message to" ) , " {}" , r.body) ;
597
let sent: Vec< _ > = std:: fs:: read_dir( outbox.path ( ) ) .unwrap ( ) .flatten ( ) .collect ( ) ;
598
assert_eq! ( sent.len ( ) , 1 ) ;
599
let text = std:: fs:: read_to_string( sent[ 0 ] .path ( ) ) .unwrap ( ) ;
600
assert! ( text.contains ( " Subject: [" ) & & text.contains ( " Test message" ) & & text.contains ( " From: Hub <[email protected] >" ) , " {text}" ) ;
601
602
let r = post (
603
app,
604
" /admin/mail" ,
605
Some ( & andy) ,
606
& format! ( " _csrf={csrf} &smtp=smtps%3A%2F%2Fhub%3Asecret%40mail.example.org%3A465&from=Hub+%3Chub%40example.org%3E" ) ,
607
)
608
.await;
2a83f530 Andy McCall 1 month ago ↶
609
assert! ( r.body.contains ( " smtps://hub:***@mail.example.org:465" ) & & ! r.body.contains ( " hub:secret" ) , " {}" , r.body) ;
3556d08e Andy McCall 1 month ago ↶
610
let r = post (
611
app,
612
" /admin/mail" ,
613
Some ( & andy) ,
614
& format! ( " _csrf={csrf} &smtp=smtps%3A%2F%2Fhub%3A***%40mail.example.org%3A465&from=Hub+%3Chub%40example.org%3E" ) ,
615
)
616
.await;
617
assert! ( r.body.contains ( " Mail settings saved" ) , " {}" , r.body) ;
618
assert_eq! ( oxygenhub:: mail:: settings( app) .smtp, " smtps://hub:[email protected] :465" ) ;
619
620
let r = post ( app, " /admin/mail" , Some ( & andy) , & format! ( " _csrf={csrf} &smtp=mail.example.org&from=x" ) ) .await;
621
assert! ( r.body.contains ( " starts with smtp://" ) , " {}" , r.body) ;
622
let r = post ( app, " /admin/mail" , Some ( & andy) , & format! ( " _csrf={csrf} &smtp=&from=" ) ) .await;
623
assert! ( r.body.contains ( " Mail is off" ) , " {}" , r.body) ;
624
assert! ( ! oxygenhub:: mail:: enabled( app) ) ;
625
626
let r = post ( app, " /admin/mail" , None , & format! ( " _csrf={csrf} &smtp=&from=" ) ) .await;
627
assert_ne! ( r.status, StatusCode:: OK ) ;
628
}
e71846aa Andy McCall 1 month ago ↶
629
630
631
632
async fn mock_assistant ( ) -> ( String, std:: sync:: Arc< std:: sync:: Mutex< Vec< String> > > ) {
633
use std:: sync:: { Arc, Mutex} ;
634
let seen: Arc< Mutex< Vec< String> > > = Arc:: new( Mutex:: new( vec! [ ] ) ) ;
635
let s2 = seen.clone ( ) ;
636
let router = axum:: Router:: new( ) .route (
637
" /v1/chat/completions" ,
638
axum:: routing:: post( move | headers: axum:: http:: HeaderMap, body: String | {
639
let s2 = s2.clone ( ) ;
640
async move {
641
let auth = headers.get ( " authorization" ) .and_then ( | v | v.to_str ( ) .ok ( ) ) .unwrap_or ( " " ) .to_string ( ) ;
642
s2.lock ( ) .unwrap ( ) .push ( format! ( " {auth} {body} " ) ) ;
643
axum:: Json( serde_json:: json! ( { " choices" : [ { " message" : { " role" : " assistant" , " content" : " OK from mock" } } ] } ) )
644
}
645
} ) ,
646
) ;
647
let listener = tokio:: net:: TcpListener:: bind( " 127.0.0.1:0" ) .await.unwrap ( ) ;
648
let port = listener.local_addr ( ) .unwrap ( ) .port ( ) ;
649
tokio:: spawn( async move { axum:: serve( listener, router) .await.unwrap ( ) } ) ;
650
( format! ( " http://127.0.0.1:{port} /v1/chat/completions" ) , seen)
651
}
652
653
fn enc ( s : & str ) -> String {
654
s.replace ( ' %' , " %25" ) .replace ( ' :' , " %3A" ) .replace ( ' /' , " %2F" ) .replace ( ' ' , " +" ) .replace ( ' \n ' , " %0A" ) .replace ( ' &' , " %26" )
655
}
656
657
# [ tokio ::test ( flavor = " multi_thread" ) ]
658
async fn ai_assistants_are_kept_per_level_with_sealed_tokens_and_combined_downwards ( ) {
659
use oxygenhub:: assistants:: { self , Scope} ;
660
let h = hub ( ) .await;
661
let app = & h.app;
662
let andy = register ( app, " andy" , " " ) .await;
663
let ( endpoint, seen) = mock_assistant ( ) .await;
664
665
666
let page = get ( app, " /admin" , Some ( & andy) ) .await.body;
667
assert! ( page.contains ( " AI assistants" ) & & page.contains ( " Add an assistant" ) , " {page}" ) ;
668
let csrf = csrf_of ( & page) ;
669
let add = | name : & str , extra : & str | {
670
format! (
671
" _csrf={csrf} &action=add&name={name} &endpoint={} &api=chat-completions&model=claude-opus-5-5&token=sk-test-secret&enabled=1&budget=50000{extra} " ,
672
enc ( & endpoint)
673
)
674
} ;
675
let r = post ( app, " /admin/assistants" , Some ( & andy) , & add ( " Claude" , " " ) ) .await;
676
assert! ( r.body.contains ( " Claude added." ) & & r.body.contains ( " token set" ) , " {}" , r.body) ;
677
assert! ( ! r.body.contains ( " sk-test-secret" ) , " the token never comes back" ) ;
678
let site = assistants:: list( & app.db, Scope:: Site) .await.unwrap ( ) ;
679
assert_eq! ( site.len ( ) , 1 ) ;
680
let claude = & site[ 0 ] ;
681
assert! ( ! String :: from_utf8_lossy( & claude.token) .contains ( " sk-test" ) , " sealed at rest" ) ;
682
assert_eq! ( app.assistant_key.open ( & claude.token) .as_deref ( ) , Some ( " sk-test-secret" ) ) ;
683
684
let r = post ( app, " /admin/assistants" , Some ( & andy) , & add ( " claude" , " " ) ) .await;
685
assert! ( r.body.contains ( " already an assistant called" ) , " {}" , r.body) ;
686
let r = post (
687
app,
688
" /admin/assistants" ,
689
Some ( & andy) ,
690
& format! ( " _csrf={csrf} &action=add&name=Bad&endpoint=ftp%3A%2F%2Fx&api=chat-completions&model=m" ) ,
691
)
692
.await;
693
assert! ( r.body.contains ( " starting with https://" ) , " {}" , r.body) ;
694
695
696
let r = post ( app, " /admin/assistants" , Some ( & andy) , & format! ( " _csrf={csrf} &action=test&id={} " , claude.id) ) .await;
697
assert! ( r.body.contains ( " Claude answered: OK from mock" ) , " {}" , r.body) ;
698
{
699
let calls = seen.lock ( ) .unwrap ( ) ;
700
assert_eq! ( calls.len ( ) , 1 ) ;
701
assert! ( calls[ 0 ] .starts_with ( " Bearer sk-test-secret " ) , " {}" , calls[ 0 ] ) ;
702
assert! ( calls[ 0 ] .contains ( " \" model\" :\" claude-opus-5-5\" " ) & & calls[ 0 ] .contains ( " Reply with the single word OK" ) , " {}" , calls[ 0 ] ) ;
703
}
704
705
let r = post (
706
app,
707
" /admin/assistants" ,
708
Some ( & andy) ,
709
& format! ( " _csrf={csrf} &action=update&id={} &name=Claude&endpoint={} &api=chat-completions&model=claude-opus-5-5&token=&enabled=1&budget=60000" , claude.id, enc ( & endpoint) ) ,
710
)
711
.await;
712
assert! ( r.body.contains ( " Claude updated." ) & & r.body.contains ( " context 60000 characters" ) , " {}" , r.body) ;
713
assert_eq! (
714
app.assistant_key.open ( & assistants:: get( & app.db, claude.id) .await.unwrap ( ) .unwrap ( ) .token) .as_deref ( ) ,
715
Some ( " sk-test-secret" )
716
) ;
717
718
let r =
719
post ( app, " /admin/assistants" , Some ( & andy) , & format! ( " _csrf={csrf} &action=prompt&prompt={} " , enc ( " Review for correctness first." ) ) )
720
.await;
721
assert! ( r.body.contains ( " Prompt saved." ) & & r.body.contains ( " Review for correctness first." ) , " {}" , r.body) ;
722
723
724
let r = post ( app, " /orgs/new" , Some ( & andy) , & format! ( " _csrf={csrf} &name=acme&display_name=Acme" ) ) .await;
725
assert_eq! ( r.status, StatusCode:: SEE_OTHER , " {}" , r.body) ;
726
let r = post ( app, " /new" , Some ( & andy) , & format! ( " _csrf={csrf} &owner=acme&name=plans&visibility=public" ) ) .await;
727
assert_eq! ( r.status, StatusCode:: SEE_OTHER , " {}" , r.body) ;
728
let org_page = get ( app, " /orgs/acme/settings" , Some ( & andy) ) .await.body;
729
assert! ( org_page.contains ( " Inherited" ) & & org_page.contains ( " from the site" ) & & org_page.contains ( " None of its own" ) , " {org_page}" ) ;
730
assert! ( org_page.contains ( " From the site" ) & & org_page.contains ( " Review for correctness first." ) , " {org_page}" ) ;
731
let repo = db:: repos:: get( & app.db, " acme" , " plans" ) .await.unwrap ( ) .unwrap ( ) ;
732
let on = assistants:: enabled_for_repo( & app.db, & repo) .await.unwrap ( ) ;
733
assert_eq! ( on.iter ( ) .map ( | a | a.name.as_str ( ) ) .collect:: < Vec< _ > > ( ) , [ " Claude" ] ) ;
734
735
736
737
let r = post (
738
app,
739
" /orgs/acme/settings/assistants" ,
740
Some ( & andy) ,
741
& format! ( " _csrf={csrf} &action=prompt&prompt={} " , enc ( " We write 6502 assembly." ) ) ,
742
)
743
.await;
744
assert! ( r.body.contains ( " Prompt saved." ) , " {}" , r.body) ;
745
let r = post ( app, " /orgs/acme/settings/assistants" , Some ( & andy) , & add ( " DeepSeek" , " " ) ) .await;
746
assert! ( r.body.contains ( " DeepSeek added." ) , " {}" , r.body) ;
747
let prompt = assistants:: prompt_for_repo( & app.db, & repo) .await.unwrap ( ) ;
748
assert! ( prompt.starts_with ( " ## Instructions from the site\n \n Review for correctness first.\n \n ## Instructions from the acme organisation\n \n We write 6502 assembly." ) , " {prompt}" ) ;
749
let repo_page = get ( app, " /acme/plans/settings" , Some ( & andy) ) .await.body;
750
assert! ( repo_page.contains ( " from the acme organisation" ) & & repo_page.contains ( " From the acme organisation" ) , " {repo_page}" ) ;
751
let on = assistants:: enabled_for_repo( & app.db, & repo) .await.unwrap ( ) ;
752
assert_eq! ( on.iter ( ) .map ( | a | a.name.as_str ( ) ) .collect:: < Vec< _ > > ( ) , [ " Claude" , " DeepSeek" ] ) ;
753
754
755
756
757
let r = post ( app, " /admin/assistants" , Some ( & andy) , & format! ( " _csrf={csrf} &action=toggle&id={} " , claude.id) ) .await;
758
assert! ( r.body.contains ( " Claude is off." ) , " {}" , r.body) ;
759
let r = post ( app, " /acme/plans/settings/assistants" , Some ( & andy) , & add ( " Claude" , " " ) ) .await;
760
assert! ( r.body.contains ( " Claude added." ) , " {}" , r.body) ;
761
let on = assistants:: enabled_for_repo( & app.db, & repo) .await.unwrap ( ) ;
762
assert_eq! ( on.iter ( ) .map ( | a | a.name.as_str ( ) ) .collect:: < Vec< _ > > ( ) , [ " DeepSeek" ] ) ;
763
let repo_page = get ( app, " /acme/plans/settings" , Some ( & andy) ) .await.body;
764
assert! ( repo_page.contains ( " off above" ) , " {repo_page}" ) ;
765
post ( app, " /admin/assistants" , Some ( & andy) , & format! ( " _csrf={csrf} &action=toggle&id={} " , claude.id) ) .await;
766
let on = assistants:: enabled_for_repo( & app.db, & repo) .await.unwrap ( ) ;
767
assert_eq! ( on.iter ( ) .map ( | a | ( a.name.as_str ( ) , a.scope.as_str ( ) ) ) .collect:: < Vec< _ > > ( ) , [ ( " DeepSeek" , " org" ) , ( " Claude" , " repo" ) ] ) ;
768
769
770
let invite = {
771
let r = post ( app, " /admin/invite" , Some ( & andy) , & format! ( " _csrf={csrf} " ) ) .await;
772
let i = r.body.find ( " invite=" ) .map ( | i | i + 7 ) .expect ( " an invitation" ) ;
773
r.body[ i.. ] .chars ( ) .take_while ( | c | c.is_ascii_alphanumeric ( ) ) .collect:: < String> ( )
774
} ;
775
let bob = register ( app, " bob" , & invite) .await;
776
assert_eq! (
777
post ( app, " /admin/assistants" , Some ( & bob) , & format! ( " _csrf={csrf} &action=prompt&prompt=x" ) ) .await.status,
778
StatusCode:: NOT_FOUND
779
) ;
780
assert_eq! (
781
post ( app, " /orgs/acme/settings/assistants" , Some ( & bob) , & format! ( " _csrf={csrf} &action=prompt&prompt=x" ) ) .await.status,
782
StatusCode:: NOT_FOUND
783
) ;
784
assert_eq! (
785
post ( app, " /acme/plans/settings/assistants" , Some ( & bob) , & format! ( " _csrf={csrf} &action=prompt&prompt=x" ) ) .await.status,
786
StatusCode:: NOT_FOUND
787
) ;
788
789
let r = post ( app, " /acme/plans/settings/delete" , Some ( & andy) , & format! ( " _csrf={csrf} &confirm=acme%2Fplans" ) ) .await;
790
assert! ( r.status = = StatusCode:: SEE_OTHER | | r.status = = StatusCode:: OK , " {} {}" , r.status, r.body) ;
791
assert! ( assistants:: list( & app.db, Scope:: Repo( repo.id) ) .await.unwrap ( ) .is_empty ( ) ) ;
792
}