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,
83940d53 Andy McCall 1 month ago ↶
53
19bb31ff Andy McCall 1 month ago ↶
54
cookie : Option< String> ,
55
location : Option< String> ,
83940d53 Andy McCall 1 month ago ↶
56
57
raw_cookies : Vec< String> ,
19bb31ff Andy McCall 1 month ago ↶
58
}
59
60
async fn send ( app : & Shared, method : & str , path : & str , cookie : Option< & str > , form : Option< & str > ) -> Res {
83940d53 Andy McCall 1 month ago ↶
61
let header_value = cookie.map ( | c | format! ( " oxyhub={c} " ) ) ;
62
send_with ( app, method, path, header_value.as_deref ( ) , form) .await
63
}
64
65
66
async fn send_with ( app : & Shared, method : & str , path : & str , cookie_header : Option< & str > , form : Option< & str > ) -> Res {
19bb31ff Andy McCall 1 month ago ↶
67
let mut req = Request:: builder( ) .method ( method) .uri ( path) ;
83940d53 Andy McCall 1 month ago ↶
68
if let Some ( c) = cookie_header {
69
req = req.header ( header:: COOKIE , c.to_string ( ) ) ;
19bb31ff Andy McCall 1 month ago ↶
70
}
71
let body = match form {
72
Some ( f) => {
73
req = req.header ( header:: CONTENT_TYPE , " application/x-www-form-urlencoded" ) ;
74
Body:: from( f.to_string ( ) )
75
}
76
None => Body:: empty( ) ,
77
} ;
78
let res = oxygenhub:: router( app.clone ( ) ) .oneshot ( req.body ( body) .unwrap ( ) ) .await.unwrap ( ) ;
79
let status = res.status ( ) ;
83940d53 Andy McCall 1 month ago ↶
80
let raw_cookies: Vec< String> =
81
res.headers ( ) .get_all ( header:: SET_COOKIE ) .iter ( ) .filter_map ( | v | v.to_str ( ) .ok ( ) ) .map ( String :: from) .collect ( ) ;
82
let cookie =
83
raw_cookies.iter ( ) .filter_map ( | c | c.split ( ' ;' ) .next ( ) ) .filter_map ( | kv | kv.strip_prefix ( " oxyhub=" ) ) .map ( String :: from) .next ( ) ;
19bb31ff Andy McCall 1 month ago ↶
84
let location = res.headers ( ) .get ( header:: LOCATION ) .and_then ( | v | v.to_str ( ) .ok ( ) ) .map ( String :: from) ;
85
let bytes = res.into_body ( ) .collect ( ) .await.unwrap ( ) .to_bytes ( ) ;
83940d53 Andy McCall 1 month ago ↶
86
Res { status, body: String :: from_utf8_lossy( & bytes) .into_owned ( ) , cookie, location, raw_cookies }
19bb31ff Andy McCall 1 month ago ↶
87
}
88
89
async fn get ( app : & Shared, path : & str , cookie : Option< & str > ) -> Res {
90
send ( app, " GET" , path, cookie, None ) .await
91
}
92
93
async fn post ( app : & Shared, path : & str , cookie : Option< & str > , form : & str ) -> Res {
94
send ( app, " POST" , path, cookie, Some ( form) ) .await
95
}
96
97
98
async fn register ( app : & Shared, user : & str , invite : & str ) -> String {
99
let r =
100
post ( app, " /register" , None , & format! ( " username={user} &email={user} %40example.com&password=correct-horse-battery&invite={invite} " ) )
101
.await;
102
assert_eq! ( r.status, StatusCode:: SEE_OTHER , " {}" , r.body) ;
103
r.cookie.expect ( " session cookie" )
104
}
105
106
fn csrf_of ( page : & str ) -> String {
107
let i = page.find ( " name=\" _csrf\" value=\" " ) .expect ( " csrf field" ) + " name=\" _csrf\" value=\" " .len ( ) ;
108
page[ i.. ] .split ( ' "' ) .next ( ) .unwrap ( ) .to_string ( )
109
}
110
111
# [ tokio ::test ( flavor = " multi_thread" ) ]
112
async fn accounts_roles_tokens_and_attribution ( ) {
113
let h = hub ( ) .await;
114
let app = & h.app;
115
116
117
let andy = register ( app, " andy" , " " ) .await;
118
let me = db:: accounts:: account_by_name( & app.db, " andy" ) .await.unwrap ( ) .unwrap ( ) ;
119
assert! ( me.is_admin) ;
120
121
let r = post ( app, " /register" , None , " username=mallory&email=m%40example.com&password=correct-horse-battery&invite=" ) .await;
122
assert_eq! ( r.status, StatusCode:: OK ) ;
123
assert! ( r.body.contains ( " invitation" ) , " {}" , r.body) ;
124
let admin_page = get ( app, " /admin" , Some ( & andy) ) .await;
125
assert_eq! ( admin_page.status, StatusCode:: OK ) ;
126
let csrf = csrf_of ( & admin_page.body) ;
127
let r = post ( app, " /admin/invite" , Some ( & andy) , & format! ( " _csrf={csrf} " ) ) .await;
128
let code = r.body.split ( " register?invite=" ) .nth ( 1 ) .unwrap ( ) .split ( ' <' ) .next ( ) .unwrap ( ) .trim ( ) .to_string ( ) ;
129
let bob = register ( app, " bob" , & code) .await;
130
assert! ( ! db:: accounts:: account_by_name( & app.db, " bob" ) .await.unwrap ( ) .unwrap ( ) .is_admin) ;
131
132
assert_eq! ( post ( app, " /login" , None , " username=andy&password=nope" ) .await.status, StatusCode:: OK ) ;
133
assert_eq! ( post ( app, " /settings" , Some ( & andy) , " _csrf=bogus&display_name=x&email=y" ) .await.status, StatusCode:: BAD_REQUEST ) ;
134
assert_eq! ( get ( app, " /settings" , None ) .await.status, StatusCode:: SEE_OTHER , " anonymous settings redirects to sign in" ) ;
135
136
137
let new_page = get ( app, " /new" , Some ( & andy) ) .await;
138
let csrf = csrf_of ( & new_page.body) ;
139
let r = post ( app, " /new" , Some ( & andy) , & format! ( " _csrf={csrf} &owner=andy&name=secret&description=hush&visibility=private" ) ) .await;
140
assert_eq! ( r.status, StatusCode:: SEE_OTHER , " {}" , r.body) ;
141
assert_eq! ( get ( app, " /andy/secret" , Some ( & andy) ) .await.status, StatusCode:: OK ) ;
142
assert_eq! ( get ( app, " /andy/secret" , Some ( & bob) ) .await.status, StatusCode:: NOT_FOUND ) ;
143
assert_eq! ( get ( app, " /andy/secret" , None ) .await.status, StatusCode:: NOT_FOUND ) ;
144
assert! ( ! get ( app, " /" , Some ( & bob) ) .await.body.contains ( " secret" ) ) ;
145
assert! ( get ( app, " /" , Some ( & andy) ) .await.body.contains ( " secret" ) ) ;
146
147
148
let tokens_page = get ( app, " /settings/tokens" , Some ( & andy) ) .await;
149
let csrf = csrf_of ( & tokens_page.body) ;
150
let r = post ( app, " /settings/tokens" , Some ( & andy) , & format! ( " _csrf={csrf} &name=ro&scopes=repo%3Aread" ) ) .await;
151
let ro = r.body.split ( " oxyhub_" ) .nth ( 1 ) .map ( | s | format! ( " oxyhub_{} " , s.split ( ' <' ) .next ( ) .unwrap ( ) .trim ( ) ) ) .unwrap ( ) ;
152
let r = post ( app, " /settings/tokens" , Some ( & andy) , & format! ( " _csrf={csrf} &name=rw&scopes=repo%3Aread&scopes=repo%3Awrite" ) ) .await;
153
let rw = r.body.split ( " oxyhub_" ) .nth ( 1 ) .map ( | s | format! ( " oxyhub_{} " , s.split ( ' <' ) .next ( ) .unwrap ( ) .trim ( ) ) ) .unwrap ( ) ;
154
let work = tempfile:: tempdir( ) .unwrap ( ) ;
155
let mut client = Repo:: init( work.path ( ) ) .unwrap ( ) ;
156
client.create_identity ( " Andy McCall" , " [email protected] " , oxy_core:: repo:: IdentityScope:: User) .unwrap ( ) ;
157
client.config.set ( " remote.origin.url" , & format! ( " {} /andy/secret" , h.base) ) .unwrap ( ) ;
158
client.config.set ( " core.sign" , " change" ) .unwrap ( ) ;
159
client.save_config ( ) .unwrap ( ) ;
160
write ( work.path ( ) , " a.txt" , b " a\n " ) ;
161
client.snapshot ( " t" ) .unwrap ( ) ;
162
let c1 = client.commit ( " first" , & [ ] ) .unwrap ( ) ;
163
let err = client.push ( " origin" , false ) .err ( ) .unwrap ( ) .to_string ( ) ;
164
assert! ( err.contains ( " 404" ) , " anonymous cannot even see it: {err}" ) ;
165
client.config.set ( " remote.origin.token" , & ro) .unwrap ( ) ;
166
let err = client.push ( " origin" , false ) .err ( ) .unwrap ( ) .to_string ( ) ;
167
assert! ( err.contains ( " 403" ) , " read-only token cannot push: {err}" ) ;
168
client.config.set ( " remote.origin.token" , & rw) .unwrap ( ) ;
169
assert! ( client.push ( " origin" , false ) .unwrap ( ) .iter ( ) .all ( | r | r.ok ) ) ;
170
171
let bob_tokens = get ( app, " /settings/tokens" , Some ( & bob) ) .await;
172
let csrf_b = csrf_of ( & bob_tokens.body) ;
173
let r = post ( app, " /settings/tokens" , Some ( & bob) , & format! ( " _csrf={csrf_b} &name=b&scopes=repo%3Aread&scopes=repo%3Awrite" ) ) .await;
174
let bob_tok = r.body.split ( " oxyhub_" ) .nth ( 1 ) .map ( | s | format! ( " oxyhub_{} " , s.split ( ' <' ) .next ( ) .unwrap ( ) .trim ( ) ) ) .unwrap ( ) ;
175
std:: env:: set_var( " OXY_TOKEN" , & bob_tok) ;
176
let err = Repo:: clone( & format! ( " {} /andy/secret" , h.base) , & work.path ( ) .join ( " bobclone" ) ) .err ( ) .unwrap ( ) .to_string ( ) ;
177
std:: env:: remove_var( " OXY_TOKEN" ) ;
178
assert! ( err.contains ( " 404" ) , " {err}" ) ;
179
180
tokio:: time:: sleep( std:: time:: Duration:: from_millis( 150 ) ) .await;
181
let pushes = get ( app, " /andy/secret/pushes" , Some ( & andy) ) .await.body;
182
assert! ( pushes.contains ( " andy" ) , " {pushes}" ) ;
183
184
185
let settings = get ( app, " /andy/secret/settings" , Some ( & andy) ) .await;
186
assert_eq! ( settings.status, StatusCode:: OK ) ;
187
let csrf = csrf_of ( & settings.body) ;
188
let r = post ( app, " /andy/secret/settings/collaborators" , Some ( & andy) , & format! ( " _csrf={csrf} &username=bob&role=write" ) ) .await;
189
assert_eq! ( r.status, StatusCode:: OK , " {}" , r.body) ;
190
assert_eq! ( get ( app, " /andy/secret" , Some ( & bob) ) .await.status, StatusCode:: OK ) ;
191
assert_eq! ( get ( app, " /andy/secret/settings" , Some ( & bob) ) .await.status, StatusCode:: NOT_FOUND , " write is not maintain" ) ;
192
std:: env:: set_var( " OXY_TOKEN" , & bob_tok) ;
193
let ok = Repo:: clone( & format! ( " {} /andy/secret" , h.base) , & work.path ( ) .join ( " bobclone2" ) ) .is_ok ( ) ;
194
std:: env:: remove_var( " OXY_TOKEN" ) ;
195
assert! ( ok) ;
196
2bbe88a8 Andy McCall 1 month ago ↶
197
198
199
let store = oxy_core:: credentials:: Credentials:: user( ) ;
200
let hub = oxy_core:: credentials:: hub_of( & h.base) .unwrap ( ) ;
201
store.save ( & hub, & rw, Some ( " andy" ) , None ) .unwrap ( ) ;
202
let signed_in = Repo:: clone( & format! ( " {} /andy/secret" , h.base) , & work.path ( ) .join ( " signedin" ) ) ;
203
store.remove ( & hub) .unwrap ( ) ;
204
let signed_in = signed_in.unwrap ( ) ;
205
assert! ( signed_in.config.remote_tokens.is_empty ( ) ) ;
206
assert_eq! ( signed_in.bookmark_heads ( ) .unwrap ( ) , vec! [ c1] ) ;
207
19bb31ff Andy McCall 1 month ago ↶
208
209
let ( key, identity) = {
210
let k = client.config.author_key.unwrap ( ) ;
211
( k, client.identity ( & k) .unwrap ( ) )
212
} ;
213
let keys_page = get ( app, " /settings/keys" , Some ( & andy) ) .await;
214
let csrf = csrf_of ( & keys_page.body) ;
215
let boundary = " XYZ" ;
216
let mut multipart = Vec :: new( ) ;
217
multipart.extend_from_slice ( format! ( " --{boundary} \r \n Content-Disposition: form-data; name=\" _csrf\" \r \n \r \n {csrf} \r \n " ) .as_bytes ( ) ) ;
218
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 ( ) ) ;
219
multipart.extend_from_slice ( & identity.encode ( ) ) ;
220
multipart.extend_from_slice ( format! ( " \r \n --{boundary} --\r \n " ) .as_bytes ( ) ) ;
221
let req = Request:: builder( )
222
.method ( " POST" )
223
.uri ( " /settings/keys/upload" )
224
.header ( header:: COOKIE , format! ( " oxyhub={andy} " ) )
225
.header ( header:: CONTENT_TYPE , format! ( " multipart/form-data; boundary={boundary} " ) )
226
.body ( Body:: from( multipart) )
227
.unwrap ( ) ;
228
let res = oxygenhub:: router( app.clone ( ) ) .oneshot ( req) .await.unwrap ( ) ;
229
assert_eq! ( res.status ( ) , StatusCode:: OK ) ;
230
let body = String :: from_utf8_lossy( & res.into_body ( ) .collect ( ) .await.unwrap ( ) .to_bytes ( ) ) .into_owned ( ) ;
231
let challenge_hex = body.split ( " sign-challenge " ) .nth ( 1 ) .unwrap ( ) .split ( ' <' ) .next ( ) .unwrap ( ) .trim ( ) .to_string ( ) ;
232
let challenge_id = body.split ( " name=\" challenge\" value=\" " ) .nth ( 1 ) .unwrap ( ) .split ( ' "' ) .next ( ) .unwrap ( ) .to_string ( ) ;
233
let ( _ , sk) = client.secret_key ( ) .unwrap ( ) .unwrap ( ) ;
234
let sig = sk.sign_challenge ( & key, & hex:: decode( & challenge_hex) .unwrap ( ) ) .unwrap ( ) ;
235
let sig_hex = hex:: encode( oxy_core:: identity:: Signature:: encode_list( & [ sig] ) ) ;
236
let r = post ( app, " /settings/keys/verify" , Some ( & andy) , & format! ( " _csrf={csrf} &challenge={challenge_id} &signature={sig_hex} " ) ) .await;
237
assert! ( r.body.contains ( " Key linked" ) , " {}" , r.body) ;
238
let hist = get ( app, " /andy/secret/changes/main" , Some ( & andy) ) .await.body;
239
assert! ( hist.contains ( " href=\" /andy\" >Andy McCall</a>" ) , " author linked to the account: {hist}" ) ;
240
let change = get ( app, & format! ( " /andy/secret/change/{} " , c1.to_hex ( ) ) , Some ( & andy) ) .await.body;
241
assert! ( change.contains ( " @andy" ) , " {change}" ) ;
242
243
let r = post ( app, " /settings/keys/verify" , Some ( & andy) , & format! ( " _csrf={csrf} &challenge=999&signature={sig_hex} " ) ) .await;
244
assert! ( r.body.contains ( " expired" ) , " {}" , r.body) ;
245
246
247
let org_page = get ( app, " /orgs/new" , Some ( & andy) ) .await;
248
let csrf = csrf_of ( & org_page.body) ;
249
let r = post ( app, " /orgs/new" , Some ( & andy) , & format! ( " _csrf={csrf} &name=acme&display_name=Acme" ) ) .await;
250
assert_eq! ( r.status, StatusCode:: SEE_OTHER , " {}" , r.body) ;
251
let new_page = get ( app, " /new" , Some ( & andy) ) .await;
252
let csrf = csrf_of ( & new_page.body) ;
253
assert_eq! (
254
post ( app, " /new" , Some ( & andy) , & format! ( " _csrf={csrf} &owner=acme&name=plans&visibility=private" ) ) .await.status,
255
StatusCode:: SEE_OTHER
256
) ;
257
assert_eq! ( get ( app, " /acme/plans" , Some ( & bob) ) .await.status, StatusCode:: NOT_FOUND ) ;
258
let org_settings = get ( app, " /orgs/acme/settings" , Some ( & andy) ) .await;
259
let csrf = csrf_of ( & org_settings.body) ;
260
assert_eq! (
261
post ( app, " /orgs/acme/settings/members" , Some ( & andy) , & format! ( " _csrf={csrf} &username=bob&role=member" ) ) .await.status,
262
StatusCode:: OK
263
) ;
264
assert_eq! ( get ( app, " /acme/plans" , Some ( & bob) ) .await.status, StatusCode:: OK , " members read by default" ) ;
265
assert_eq! ( get ( app, " /orgs/acme/settings" , Some ( & bob) ) .await.status, StatusCode:: NOT_FOUND , " members do not administer" ) ;
266
267
let r = post (
268
app,
269
" /new" ,
270
Some ( & bob) ,
271
& format! ( " _csrf={} &owner=acme&name=bobs&visibility=public" , csrf_of ( & get ( app, " /new" , Some ( & bob) ) .await.body) ) ,
272
)
273
.await;
274
assert_eq! ( r.status, StatusCode:: SEE_OTHER , " {}" , r.body) ;
275
276
277
let sec = get ( app, " /settings/security" , Some ( & andy) ) .await;
278
let csrf = csrf_of ( & sec.body) ;
279
let r = post ( app, " /settings/security/sessions" , Some ( & andy) , & format! ( " _csrf={csrf} " ) ) .await;
280
assert_eq! ( r.status, StatusCode:: SEE_OTHER ) ;
281
assert_eq! ( get ( app, " /settings" , Some ( & andy) ) .await.status, StatusCode:: SEE_OTHER , " old session is gone" ) ;
282
let fresh = r.cookie.unwrap ( ) ;
283
assert_eq! ( get ( app, " /settings" , Some ( & fresh) ) .await.status, StatusCode:: OK ) ;
284
285
286
let sec = get ( app, " /settings/security" , Some ( & fresh) ) .await;
287
let csrf = csrf_of ( & sec.body) ;
288
let r = post ( app, " /settings/security/totp/begin" , Some ( & fresh) , & format! ( " _csrf={csrf} " ) ) .await;
289
let secret = r.body.split ( " name=\" secret\" value=\" " ) .nth ( 1 ) .unwrap ( ) .split ( ' "' ) .next ( ) .unwrap ( ) .to_string ( ) ;
290
let now = ( db:: now_ms( ) / 1000 ) as u64 ;
291
let code = oxygenhub:: auth:: totp:: current( & secret, now) .unwrap ( ) ;
292
let r = post ( app, " /settings/security/totp/enable" , Some ( & fresh) , & format! ( " _csrf={csrf} &secret={secret} &code={code} " ) ) .await;
293
assert! ( r.body.contains ( " Recovery codes" ) , " {}" , r.body) ;
294
let r = post ( app, " /login" , None , " username=andy&password=correct-horse-battery" ) .await;
295
assert! ( r.body.contains ( " authenticator" ) , " second step asked: {}" , r.body) ;
296
let code = oxygenhub:: auth:: totp:: current( & secret, ( db:: now_ms( ) / 1000 ) as u64 ) .unwrap ( ) ;
297
let r = post ( app, " /login" , None , & format! ( " username=andy&password=correct-horse-battery&code={code} " ) ) .await;
298
assert_eq! ( r.status, StatusCode:: SEE_OTHER , " {}" , r.body) ;
299
assert! ( r.cookie.is_some ( ) ) ;
300
assert! ( r.location.as_deref ( ) = = Some ( " /" ) ) ;
301
}
a160b98f Andy McCall 1 month ago ↶
302
303
304
fn picture_form ( csrf : & str , action : & str , file : & [u8 ]) -> ( String, Vec< u8 > ) {
305
let boundary = " picture-boundary-1234" ;
306
let mut body = Vec :: new( ) ;
307
body.extend_from_slice ( format! ( " --{boundary} \r \n Content-Disposition: form-data; name=\" _csrf\" \r \n \r \n {csrf} \r \n " ) .as_bytes ( ) ) ;
308
body.extend_from_slice ( format! ( " --{boundary} \r \n Content-Disposition: form-data; name=\" action\" \r \n \r \n {action} \r \n " ) .as_bytes ( ) ) ;
309
body.extend_from_slice (
310
format! ( " --{boundary} \r \n Content-Disposition: form-data; name=\" picture\" ; filename=\" me.png\" \r \n Content-Type: image/png\r \n \r \n " )
311
.as_bytes ( ) ,
312
) ;
313
body.extend_from_slice ( file) ;
314
body.extend_from_slice ( format! ( " \r \n --{boundary} --\r \n " ) .as_bytes ( ) ) ;
315
( format! ( " multipart/form-data; boundary={boundary} " ) , body)
316
}
317
318
async fn upload ( app : & Shared, path : & str , cookie : & str , csrf : & str , action : & str , file : & [u8 ]) -> Res {
319
let ( ct, body) = picture_form ( csrf, action, file) ;
320
let req = Request:: post( path)
321
.header ( header:: COOKIE , format! ( " oxyhub={cookie} " ) )
322
.header ( header:: CONTENT_TYPE , ct)
323
.body ( Body:: from( body) )
324
.unwrap ( ) ;
325
let res = oxygenhub:: router( app.clone ( ) ) .oneshot ( req) .await.unwrap ( ) ;
326
let status = res.status ( ) ;
327
let bytes = res.into_body ( ) .collect ( ) .await.unwrap ( ) .to_bytes ( ) ;
83940d53 Andy McCall 1 month ago ↶
328
Res { status, body: String :: from_utf8_lossy( & bytes) .into_owned ( ) , cookie: None , location: None , raw_cookies: vec! [ ] }
a160b98f Andy McCall 1 month ago ↶
329
}
330
331
332
async fn picture ( app : & Shared, name : & str ) -> ( StatusCode, String, Vec< u8 > ) {
333
let res = oxygenhub:: router( app.clone ( ) ) .oneshot ( Request:: get( format! ( " /avatar/{name} " ) ) .body ( Body:: empty( ) ) .unwrap ( ) ) .await.unwrap ( ) ;
334
let ct = res.headers ( ) .get ( header:: CONTENT_TYPE ) .and_then ( | v | v.to_str ( ) .ok ( ) ) .unwrap_or ( " " ) .to_string ( ) ;
335
let status = res.status ( ) ;
336
( status, ct, res.into_body ( ) .collect ( ) .await.unwrap ( ) .to_bytes ( ) .to_vec ( ) )
337
}
338
339
fn png ( w : u32 , h : u32 , alpha : bool ) -> Vec< u8 > {
340
let img = if alpha {
341
image:: DynamicImage:: ImageRgba8( image:: RgbaImage:: from_fn( w, h, | x , _| {
342
image:: Rgba( [ 0 , 114 , 255 , if x % 2 = = 0 { 0 } else { 255 } ] )
343
} ) )
344
} else {
345
image:: DynamicImage:: ImageRgb8( image:: RgbImage:: from_fn( w, h, | x , y | image:: Rgb( [ x as u8 , y as u8 , 128 ] ) ) )
346
} ;
347
let mut out = Vec :: new( ) ;
348
img.write_to ( & mut std:: io:: Cursor:: new( & mut out) , image:: ImageFormat:: Png) .unwrap ( ) ;
349
out
350
}
351
352
# [ tokio ::test ( flavor = " multi_thread" ) ]
353
async fn profile_pictures_and_logos ( ) {
354
let h = hub ( ) .await;
355
let app = & h.app;
356
let andy = register ( app, " andy" , " " ) .await;
357
assert_eq! ( picture ( app, " andy" ) .await.1 , " image/svg+xml" , " the identicon first" ) ;
358
let csrf = csrf_of ( & get ( app, " /settings" , Some ( & andy) ) .await.body) ;
359
assert! ( get ( app, " /settings" , Some ( & andy) ) .await.body.contains ( " id=\" picture\" " ) ) ;
360
361
362
let r = upload ( app, " /settings/avatar" , & andy, & csrf, " upload" , & png ( 640 , 480 , false ) ) .await;
363
assert! ( r.body.contains ( " Picture saved" ) , " {}" , r.body) ;
364
let ( st, ct, bytes) = picture ( app, " andy" ) .await;
365
assert_eq! ( ( st, ct.as_str ( ) ) , ( StatusCode:: OK , " image/jpeg" ) ) ;
366
let img = image:: load_from_memory( & bytes) .unwrap ( ) ;
367
assert_eq! ( ( img.width ( ) , img.height ( ) ) , ( 400 , 400 ) ) ;
368
let stored = db:: accounts:: account_by_name( & app.db, " andy" ) .await.unwrap ( ) .unwrap ( ) .avatar;
369
assert! ( stored.starts_with ( " jpeg:" ) , " {stored}" ) ;
370
371
372
let r = upload (
373
app,
374
" /settings/avatar" ,
375
& andy,
376
& csrf,
377
" upload" ,
378
b " <svg xmlns=\" http://www.w3.org/2000/svg\" ><script>alert(1)</script></svg>" ,
379
)
380
.await;
381
assert! ( r.body.contains ( " not a PNG, JPEG or WebP" ) , " {}" , r.body) ;
382
let r = upload ( app, " /settings/avatar" , & andy, " wrong" , " upload" , & png ( 10 , 10 , false ) ) .await;
383
assert_ne! ( r.status, StatusCode:: OK ) ;
384
assert_eq! ( picture ( app, " andy" ) .await.1 , " image/jpeg" ) ;
385
386
let r = upload ( app, " /settings/avatar" , & andy, & csrf, " remove" , b " " ) .await;
387
assert! ( r.body.contains ( " Picture removed" ) ) ;
388
assert_eq! ( picture ( app, " andy" ) .await.1 , " image/svg+xml" ) ;
389
390
391
let me = db:: accounts:: account_by_name( & app.db, " andy" ) .await.unwrap ( ) .unwrap ( ) ;
392
db:: accounts:: create_org( & app.db, " acme" , " Acme" , me.id) .await.unwrap ( ) ;
393
let org_csrf = csrf_of ( & get ( app, " /orgs/acme/settings" , Some ( & andy) ) .await.body) ;
394
let r = upload ( app, " /orgs/acme/settings/avatar" , & andy, & org_csrf, " upload" , & png ( 120 , 120 , true ) ) .await;
395
assert! ( r.body.contains ( " Picture saved" ) , " {}" , r.body) ;
396
let ( _ , ct, bytes) = picture ( app, " acme" ) .await;
397
assert_eq! ( ct, " image/png" ) ;
398
assert! ( image:: load_from_memory( & bytes) .unwrap ( ) .color ( ) .has_alpha ( ) ) ;
399
let invite = {
400
let admin = get ( app, " /admin" , Some ( & andy) ) .await;
401
let r = post ( app, " /admin/invite" , Some ( & andy) , & format! ( " _csrf={} " , csrf_of ( & admin.body) ) ) .await;
402
let i = r.body.find ( " invite=" ) .map ( | i | i + 7 ) .expect ( " an invitation" ) ;
403
r.body[ i.. ] .chars ( ) .take_while ( | c | c.is_ascii_alphanumeric ( ) ) .collect:: < String> ( )
404
} ;
405
let bob = register ( app, " bob" , & invite) .await;
406
let bob_csrf = csrf_of ( & get ( app, " /settings" , Some ( & bob) ) .await.body) ;
407
let r = upload ( app, " /orgs/acme/settings/avatar" , & bob, & bob_csrf, " remove" , b " " ) .await;
408
assert_eq! ( r.status, StatusCode:: NOT_FOUND , " not an owner" ) ;
409
assert_eq! ( picture ( app, " acme" ) .await.1 , " image/png" ) ;
410
}
8fb35df7 Andy McCall 1 month ago ↶
411
412
# [ tokio ::test ( flavor = " multi_thread" ) ]
413
async fn organisation_webhooks_and_storage ( ) {
414
let h = hub ( ) .await;
415
let app = & h.app;
416
let andy = register ( app, " andy" , " " ) .await;
417
let me = db:: accounts:: account_by_name( & app.db, " andy" ) .await.unwrap ( ) .unwrap ( ) ;
418
let org = db:: accounts:: create_org( & app.db, " acme" , " Acme" , me.id) .await.unwrap ( ) ;
419
420
421
let page = get ( app, " /orgs/acme/settings" , Some ( & andy) ) .await;
422
assert! ( page.body.contains ( " id=\" webhooks\" " ) & & page.body.contains ( " href=\" #webhooks\" " ) ) ;
423
let csrf = csrf_of ( & page.body) ;
424
let r = post ( app, " /orgs/acme/settings/hooks" , Some ( & andy) , & format! ( " _csrf={csrf} &url=ftp%3A%2F%2Fx&events=*" ) ) .await;
425
assert! ( r.body.contains ( " starts with http" ) , " {}" , r.body) ;
426
let r = post (
427
app,
428
" /orgs/acme/settings/hooks" ,
429
Some ( & andy) ,
430
& format! ( " _csrf={csrf} &url=https%3A%2F%2Fhooks.invalid%2Facme&secret=s&events=push" ) ,
431
)
432
.await;
433
assert! ( r.body.contains ( " Webhook added" ) ) ;
434
let hooks = db:: hooks:: hooks_for_org( & app.db, org.id) .await.unwrap ( ) ;
435
assert_eq! ( hooks.len ( ) , 1 ) ;
436
let id = hooks[ 0 ] .id;
437
assert! ( post ( app, " /orgs/acme/settings/hooks" , Some ( & andy) , & format! ( " _csrf={csrf} &id={id} &action=ping" ) )
438
.await
439
.body
440
.contains ( " Ping queued" ) ) ;
441
let deliveries = get ( app, & format! ( " /orgs/acme/settings/hooks/{id} /deliveries" ) , Some ( & andy) ) .await;
442
assert_eq! ( deliveries.status, StatusCode:: OK ) ;
443
assert! ( deliveries.body.contains ( " >ping<" ) & & deliveries.body.contains ( " /orgs/acme/settings#webhooks" ) ) ;
444
445
let repo = oxygenhub:: repos:: RepoName:: parse( " andy/demo" ) .unwrap ( ) ;
446
app.repos.create ( & repo, None ) .unwrap ( ) ;
447
let row = db:: repos:: create( & app.db, " andy" , " demo" , " " , hub_api:: Visibility:: Public) .await.unwrap ( ) ;
448
let repo_hook = db:: hooks:: create_hook( & app.db, Some ( row.id) , None , " https://hooks.invalid/repo" , " " , " *" ) .await.unwrap ( ) .id;
449
post ( app, " /orgs/acme/settings/hooks" , Some ( & andy) , & format! ( " _csrf={csrf} &id={repo_hook} &action=delete" ) ) .await;
450
assert! ( db:: hooks:: hook( & app.db, repo_hook) .await.unwrap ( ) .is_some ( ) , " not the organisation's" ) ;
451
assert_eq! ( get ( app, & format! ( " /orgs/acme/settings/hooks/{repo_hook} /deliveries" ) , Some ( & andy) ) .await.status, StatusCode:: NOT_FOUND ) ;
452
453
let rs = get ( app, " /andy/demo/settings" , Some ( & andy) ) .await;
454
assert! ( rs.body.contains ( " https://hooks.invalid/repo" ) & & rs.body.contains ( " action=\" /andy/demo/settings/hooks\" " ) ) ;
455
456
457
let work = tempfile:: tempdir( ) .unwrap ( ) ;
458
let mut client = Repo:: init( work.path ( ) ) .unwrap ( ) ;
459
client.config.set ( " remote.origin.url" , & format! ( " {} /andy/demo" , h.base) ) .unwrap ( ) ;
460
let ( token, hash) = oxygenhub:: auth:: new_token( ) ;
461
db:: accounts:: create_token( & app.db, me.id, " push" , & hash, " repo:write" , " " , None ) .await.unwrap ( ) ;
462
client.config.set ( " remote.origin.token" , & token) .unwrap ( ) ;
463
client.save_config ( ) .unwrap ( ) ;
464
write ( work.path ( ) , " a.txt" , b " a\n " ) ;
465
client.snapshot ( " t" ) .unwrap ( ) ;
466
client.commit ( " one" , & [ ] ) .unwrap ( ) ;
467
assert! ( client.push ( " origin" , false ) .unwrap ( ) .iter ( ) .all ( | r | r.ok ) ) ;
468
let storage = get ( app, " /admin/storage" , Some ( & andy) ) .await;
469
assert_eq! ( storage.status, StatusCode:: OK ) ;
470
assert! ( storage.body.contains ( " href=\" /andy/demo\" " ) ) ;
471
let loose_before = std:: fs:: read_dir( app.repos.dir ( & repo) .join ( " .oxy/objects/loose" ) ) .map ( | d | d.count ( ) ) .unwrap_or ( 0 ) ;
472
assert! ( loose_before > 0 , " the push left loose objects" ) ;
473
let r = post ( app, " /admin/storage" , Some ( & andy) , & format! ( " _csrf={} &repo=andy%2Fdemo" , csrf_of ( & storage.body) ) ) .await;
474
assert! ( r.body.contains ( " Maintained 1 repository" ) & & r.body.contains ( " loose objects packed" ) , " {}" , r.body) ;
475
let loose_after: usize = walk_files ( & app.repos.dir ( & repo) .join ( " .oxy/objects/loose" ) ) ;
476
assert_eq! ( loose_after, 0 , " packed" ) ;
477
let audit = db:: accounts:: audit_rows( & app.db, 20 ) .await.unwrap ( ) ;
478
assert! ( audit.iter ( ) .any ( | a | a.action = = " admin.gc" & & a.target = = " andy/demo" ) ) ;
479
let r = post ( app, " /admin/storage" , Some ( & andy) , & format! ( " _csrf={} &repo=nobody%2Fnone" , csrf_of ( & storage.body) ) ) .await;
480
assert! ( r.body.contains ( " No repository nobody/none" ) ) ;
481
}
482
483
fn walk_files ( dir : & Path) -> usize {
484
let Ok ( rd) = std:: fs:: read_dir( dir) else { return 0 } ;
485
rd.flatten ( ) .map ( | e | if e.path ( ) .is_dir ( ) { walk_files ( & e.path ( ) ) } else { 1 } ) .sum ( )
486
}
6a178055 Andy McCall 1 month ago ↶
487
488
489
490
491
# [ tokio ::test ( flavor = " multi_thread" ) ]
492
async fn admins_disable_enable_and_delete_accounts ( ) {
493
let h = hub ( ) .await;
494
let app = & h.app;
495
let andy = register ( app, " andy" , " " ) .await;
496
let invite = | app : Shared, andy : String| async move {
497
let csrf = csrf_of ( & get ( & app, " /admin" , Some ( & andy) ) .await.body) ;
498
let r = post ( & app, " /admin/invite" , Some ( & andy) , & format! ( " _csrf={csrf} " ) ) .await;
499
r.body.split ( " register?invite=" ) .nth ( 1 ) .unwrap ( ) .split ( ' <' ) .next ( ) .unwrap ( ) .trim ( ) .to_string ( )
500
} ;
501
let bob = register ( app, " bob" , & invite ( app.clone ( ) , andy.clone ( ) ) .await) .await;
502
let act = | who : & 'static str , action : & 'static str , confirm : & 'static str | {
503
let ( app, andy) = ( app.clone ( ) , andy.clone ( ) ) ;
504
async move {
505
let csrf = csrf_of ( & get ( & app, " /admin" , Some ( & andy) ) .await.body) ;
506
post ( & app, " /admin/account" , Some ( & andy) , & format! ( " _csrf={csrf} &username={who} &action={action} &confirm={confirm} " ) ) .await.body
507
}
508
} ;
509
let login = | user : & 'static str | {
510
let app = app.clone ( ) ;
511
async move { post ( & app, " /login" , None , & format! ( " username={user} &password=correct-horse-battery" ) ) .await }
512
} ;
513
514
515
act ( " bob" , " disable" , " " ) .await;
516
assert! ( db:: accounts:: account_by_name( & app.db, " bob" ) .await.unwrap ( ) .unwrap ( ) .disabled) ;
517
let r = login ( " bob" ) .await;
518
assert! ( r.body.contains ( " Your account has been disabled" ) , " {}" , r.body) ;
519
assert! ( r.cookie.is_none ( ) ) ;
520
assert_eq! ( get ( app, " /settings" , Some ( & bob) ) .await.status, StatusCode:: SEE_OTHER , " the session is refused" ) ;
521
assert! ( act ( " andy" , " disable" , " " ) .await.contains ( " Not on yourself" ) ) ;
522
523
act ( " bob" , " enable" , " " ) .await;
524
assert_eq! ( login ( " bob" ) .await.status, StatusCode:: SEE_OTHER ) ;
525
526
527
let bob = login ( " bob" ) .await.cookie.unwrap ( ) ;
528
let csrf = csrf_of ( & get ( app, " /new" , Some ( & bob) ) .await.body) ;
529
assert_eq! (
530
post ( app, " /new" , Some ( & bob) , & format! ( " _csrf={csrf} &owner=bob&name=keep&visibility=public" ) ) .await.status,
531
StatusCode:: SEE_OTHER
532
) ;
533
assert! ( act ( " bob" , " delete" , " bo" ) .await.contains ( " type bob to confirm" ) ) ;
534
let page = act ( " bob" , " delete" , " bob" ) .await;
535
assert! ( page.contains ( " still owns bob/keep" ) , " {page}" ) ;
536
assert! ( db:: accounts:: account_by_name( & app.db, " bob" ) .await.unwrap ( ) .is_some ( ) ) ;
537
let row = db:: repos:: get( & app.db, " bob" , " keep" ) .await.unwrap ( ) .unwrap ( ) ;
538
db:: repos:: delete( & app.db, row.id) .await.unwrap ( ) ;
539
let page = act ( " bob" , " delete" , " bob" ) .await;
540
assert! ( page.contains ( " bob deleted" ) , " {page}" ) ;
541
assert! ( db:: accounts:: account_by_name( & app.db, " bob" ) .await.unwrap ( ) .is_none ( ) ) ;
542
assert_eq! ( get ( app, " /settings" , Some ( & bob) ) .await.status, StatusCode:: SEE_OTHER ) ;
543
544
let code = invite ( app.clone ( ) , andy.clone ( ) ) .await;
545
let r = post ( app, " /register" , None , & format! ( " username=bob&email=b%40example.com&password=correct-horse-battery&invite={code} " ) ) .await;
546
assert! ( r.body.contains ( " That name is taken" ) , " {}" , r.body) ;
547
548
assert! ( act ( " andy" , " delete" , " andy" ) .await.contains ( " Not on yourself" ) ) ;
549
}
5dead1f4 Andy McCall 1 month ago ↶
550
551
552
553
# [ tokio ::test ( flavor = " multi_thread" ) ]
554
async fn admins_set_the_job_time_limits ( ) {
555
let h = hub ( ) .await;
556
let app = & h.app;
557
let andy = register ( app, " andy" , " " ) .await;
558
let page = get ( app, " /admin/runners/settings" , Some ( & andy) ) .await;
559
assert_eq! ( page.status, StatusCode:: OK ) ;
560
assert! ( page.body.contains ( " Job time limits" ) & & page.body.contains ( " value=\" 60\" " ) , " the hub.toml default shows" ) ;
561
let csrf = csrf_of ( & page.body) ;
562
let r = post ( app, " /admin/runners/settings" , Some ( & andy) , & format! ( " _csrf={csrf} &default_minutes=200&max_minutes=120&grace_minutes=5" ) )
563
.await;
564
assert! ( r.body.contains ( " cannot be longer than the maximum" ) , " {}" , r.body) ;
565
let r = post ( app, " /admin/runners/settings" , Some ( & andy) , & format! ( " _csrf={csrf} &default_minutes=90&max_minutes=240&grace_minutes=10" ) )
566
.await;
567
assert! ( r.body.contains ( " Saved." ) , " {}" , r.body) ;
568
let l = oxygenhub:: flow:: limits( app) .await;
569
assert_eq! ( ( l.default_s, l.max_s, l.grace_ms) , ( 5400 , 14400 , 600_000 ) ) ;
570
assert! ( get ( app, " /admin/runners" , Some ( & andy) ) .await.body.contains ( " href=\" /admin/runners/settings\" " ) ) ;
571
let code = {
572
let csrf = csrf_of ( & get ( app, " /admin" , Some ( & andy) ) .await.body) ;
573
let r = post ( app, " /admin/invite" , Some ( & andy) , & format! ( " _csrf={csrf} " ) ) .await;
574
r.body.split ( " register?invite=" ) .nth ( 1 ) .unwrap ( ) .split ( ' <' ) .next ( ) .unwrap ( ) .trim ( ) .to_string ( )
575
} ;
576
let bob = register ( app, " bob" , & code) .await;
577
assert_ne! ( get ( app, " /admin/runners/settings" , Some ( & bob) ) .await.status, StatusCode:: OK , " not for others" ) ;
578
}
3556d08e Andy McCall 1 month ago ↶
579
580
581
582
# [ tokio ::test ( flavor = " multi_thread" ) ]
583
async fn mail_settings_are_saved_masked_and_tested_from_the_admin_page ( ) {
584
let h = hub ( ) .await;
585
let app = & h.app;
586
let andy = register ( app, " andy" , " " ) .await;
587
let page = get ( app, " /admin" , Some ( & andy) ) .await.body;
588
assert! ( page.contains ( " Mail is off" ) , " {page}" ) ;
589
let csrf = csrf_of ( & page) ;
590
591
let outbox = tempfile:: tempdir( ) .unwrap ( ) ;
592
let r = post (
593
app,
594
" /admin/mail" ,
595
Some ( & andy) ,
596
& format! ( " _csrf={csrf} &smtp=file%3A%2F%2F{} &from=Hub+%3Chub%40example.org%3E" , outbox.path ( ) .display ( ) ) ,
597
)
598
.await;
599
assert! ( r.body.contains ( " Mail settings saved" ) & & r.body.contains ( " Mail is on" ) , " {}" , r.body) ;
600
assert! ( oxygenhub:: mail:: enabled( app) ) ;
601
let r = post ( app, " /admin/mail/test" , Some ( & andy) , & format! ( " _csrf={csrf} " ) ) .await;
602
assert! ( r.body.contains ( " Sent a test message to" ) , " {}" , r.body) ;
603
let sent: Vec< _ > = std:: fs:: read_dir( outbox.path ( ) ) .unwrap ( ) .flatten ( ) .collect ( ) ;
604
assert_eq! ( sent.len ( ) , 1 ) ;
605
let text = std:: fs:: read_to_string( sent[ 0 ] .path ( ) ) .unwrap ( ) ;
606
assert! ( text.contains ( " Subject: [" ) & & text.contains ( " Test message" ) & & text.contains ( " From: Hub <[email protected] >" ) , " {text}" ) ;
607
608
let r = post (
609
app,
610
" /admin/mail" ,
611
Some ( & andy) ,
612
& format! ( " _csrf={csrf} &smtp=smtps%3A%2F%2Fhub%3Asecret%40mail.example.org%3A465&from=Hub+%3Chub%40example.org%3E" ) ,
613
)
614
.await;
2a83f530 Andy McCall 1 month ago ↶
615
assert! ( r.body.contains ( " smtps://hub:***@mail.example.org:465" ) & & ! r.body.contains ( " hub:secret" ) , " {}" , r.body) ;
3556d08e Andy McCall 1 month ago ↶
616
let r = post (
617
app,
618
" /admin/mail" ,
619
Some ( & andy) ,
620
& format! ( " _csrf={csrf} &smtp=smtps%3A%2F%2Fhub%3A***%40mail.example.org%3A465&from=Hub+%3Chub%40example.org%3E" ) ,
621
)
622
.await;
623
assert! ( r.body.contains ( " Mail settings saved" ) , " {}" , r.body) ;
624
assert_eq! ( oxygenhub:: mail:: settings( app) .smtp, " smtps://hub:[email protected] :465" ) ;
625
626
let r = post ( app, " /admin/mail" , Some ( & andy) , & format! ( " _csrf={csrf} &smtp=mail.example.org&from=x" ) ) .await;
627
assert! ( r.body.contains ( " starts with smtp://" ) , " {}" , r.body) ;
628
let r = post ( app, " /admin/mail" , Some ( & andy) , & format! ( " _csrf={csrf} &smtp=&from=" ) ) .await;
629
assert! ( r.body.contains ( " Mail is off" ) , " {}" , r.body) ;
630
assert! ( ! oxygenhub:: mail:: enabled( app) ) ;
631
632
let r = post ( app, " /admin/mail" , None , & format! ( " _csrf={csrf} &smtp=&from=" ) ) .await;
633
assert_ne! ( r.status, StatusCode:: OK ) ;
634
}
e71846aa Andy McCall 1 month ago ↶
635
636
637
638
async fn mock_assistant ( ) -> ( String, std:: sync:: Arc< std:: sync:: Mutex< Vec< String> > > ) {
639
use std:: sync:: { Arc, Mutex} ;
640
let seen: Arc< Mutex< Vec< String> > > = Arc:: new( Mutex:: new( vec! [ ] ) ) ;
641
let s2 = seen.clone ( ) ;
642
let router = axum:: Router:: new( ) .route (
643
" /v1/chat/completions" ,
644
axum:: routing:: post( move | headers: axum:: http:: HeaderMap, body: String | {
645
let s2 = s2.clone ( ) ;
646
async move {
647
let auth = headers.get ( " authorization" ) .and_then ( | v | v.to_str ( ) .ok ( ) ) .unwrap_or ( " " ) .to_string ( ) ;
648
s2.lock ( ) .unwrap ( ) .push ( format! ( " {auth} {body} " ) ) ;
649
axum:: Json( serde_json:: json! ( { " choices" : [ { " message" : { " role" : " assistant" , " content" : " OK from mock" } } ] } ) )
650
}
651
} ) ,
652
) ;
653
let listener = tokio:: net:: TcpListener:: bind( " 127.0.0.1:0" ) .await.unwrap ( ) ;
654
let port = listener.local_addr ( ) .unwrap ( ) .port ( ) ;
655
tokio:: spawn( async move { axum:: serve( listener, router) .await.unwrap ( ) } ) ;
656
( format! ( " http://127.0.0.1:{port} /v1/chat/completions" ) , seen)
657
}
658
659
fn enc ( s : & str ) -> String {
660
s.replace ( ' %' , " %25" ) .replace ( ' :' , " %3A" ) .replace ( ' /' , " %2F" ) .replace ( ' ' , " +" ) .replace ( ' \n ' , " %0A" ) .replace ( ' &' , " %26" )
661
}
662
663
# [ tokio ::test ( flavor = " multi_thread" ) ]
664
async fn ai_assistants_are_kept_per_level_with_sealed_tokens_and_combined_downwards ( ) {
665
use oxygenhub:: assistants:: { self , Scope} ;
666
let h = hub ( ) .await;
667
let app = & h.app;
668
let andy = register ( app, " andy" , " " ) .await;
669
let ( endpoint, seen) = mock_assistant ( ) .await;
670
671
672
let page = get ( app, " /admin" , Some ( & andy) ) .await.body;
673
assert! ( page.contains ( " AI assistants" ) & & page.contains ( " Add an assistant" ) , " {page}" ) ;
674
let csrf = csrf_of ( & page) ;
675
let add = | name : & str , extra : & str | {
676
format! (
677
" _csrf={csrf} &action=add&name={name} &endpoint={} &api=chat-completions&model=claude-opus-5-5&token=sk-test-secret&enabled=1&budget=50000{extra} " ,
678
enc ( & endpoint)
679
)
680
} ;
681
let r = post ( app, " /admin/assistants" , Some ( & andy) , & add ( " Claude" , " " ) ) .await;
206c1fc1 Andy McCall 1 month ago ↶
682
assert! ( r.body.contains ( " Claude added and answered: OK from mock" ) & & r.body.contains ( " token set" ) , " {}" , r.body) ;
e71846aa Andy McCall 1 month ago ↶
683
assert! ( ! r.body.contains ( " sk-test-secret" ) , " the token never comes back" ) ;
684
let site = assistants:: list( & app.db, Scope:: Site) .await.unwrap ( ) ;
685
assert_eq! ( site.len ( ) , 1 ) ;
686
let claude = & site[ 0 ] ;
687
assert! ( ! String :: from_utf8_lossy( & claude.token) .contains ( " sk-test" ) , " sealed at rest" ) ;
688
assert_eq! ( app.assistant_key.open ( & claude.token) .as_deref ( ) , Some ( " sk-test-secret" ) ) ;
689
690
let r = post ( app, " /admin/assistants" , Some ( & andy) , & add ( " claude" , " " ) ) .await;
691
assert! ( r.body.contains ( " already an assistant called" ) , " {}" , r.body) ;
692
let r = post (
693
app,
694
" /admin/assistants" ,
695
Some ( & andy) ,
696
& format! ( " _csrf={csrf} &action=add&name=Bad&endpoint=ftp%3A%2F%2Fx&api=chat-completions&model=m" ) ,
697
)
698
.await;
699
assert! ( r.body.contains ( " starting with https://" ) , " {}" , r.body) ;
700
701
702
let r = post ( app, " /admin/assistants" , Some ( & andy) , & format! ( " _csrf={csrf} &action=test&id={} " , claude.id) ) .await;
703
assert! ( r.body.contains ( " Claude answered: OK from mock" ) , " {}" , r.body) ;
704
{
705
let calls = seen.lock ( ) .unwrap ( ) ;
206c1fc1 Andy McCall 1 month ago ↶
706
assert_eq! ( calls.len ( ) , 2 , " once on add, once on test" ) ;
e71846aa Andy McCall 1 month ago ↶
707
assert! ( calls[ 0 ] .starts_with ( " Bearer sk-test-secret " ) , " {}" , calls[ 0 ] ) ;
708
assert! ( calls[ 0 ] .contains ( " \" model\" :\" claude-opus-5-5\" " ) & & calls[ 0 ] .contains ( " Reply with the single word OK" ) , " {}" , calls[ 0 ] ) ;
709
}
710
711
let r = post (
712
app,
713
" /admin/assistants" ,
714
Some ( & andy) ,
715
& 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) ) ,
716
)
717
.await;
718
assert! ( r.body.contains ( " Claude updated." ) & & r.body.contains ( " context 60000 characters" ) , " {}" , r.body) ;
719
assert_eq! (
720
app.assistant_key.open ( & assistants:: get( & app.db, claude.id) .await.unwrap ( ) .unwrap ( ) .token) .as_deref ( ) ,
721
Some ( " sk-test-secret" )
722
) ;
723
724
let r =
725
post ( app, " /admin/assistants" , Some ( & andy) , & format! ( " _csrf={csrf} &action=prompt&prompt={} " , enc ( " Review for correctness first." ) ) )
726
.await;
727
assert! ( r.body.contains ( " Prompt saved." ) & & r.body.contains ( " Review for correctness first." ) , " {}" , r.body) ;
728
729
730
let r = post ( app, " /orgs/new" , Some ( & andy) , & format! ( " _csrf={csrf} &name=acme&display_name=Acme" ) ) .await;
731
assert_eq! ( r.status, StatusCode:: SEE_OTHER , " {}" , r.body) ;
732
let r = post ( app, " /new" , Some ( & andy) , & format! ( " _csrf={csrf} &owner=acme&name=plans&visibility=public" ) ) .await;
733
assert_eq! ( r.status, StatusCode:: SEE_OTHER , " {}" , r.body) ;
734
let org_page = get ( app, " /orgs/acme/settings" , Some ( & andy) ) .await.body;
735
assert! ( org_page.contains ( " Inherited" ) & & org_page.contains ( " from the site" ) & & org_page.contains ( " None of its own" ) , " {org_page}" ) ;
736
assert! ( org_page.contains ( " From the site" ) & & org_page.contains ( " Review for correctness first." ) , " {org_page}" ) ;
737
let repo = db:: repos:: get( & app.db, " acme" , " plans" ) .await.unwrap ( ) .unwrap ( ) ;
738
let on = assistants:: enabled_for_repo( & app.db, & repo) .await.unwrap ( ) ;
739
assert_eq! ( on.iter ( ) .map ( | a | a.name.as_str ( ) ) .collect:: < Vec< _ > > ( ) , [ " Claude" ] ) ;
740
741
742
743
let r = post (
744
app,
745
" /orgs/acme/settings/assistants" ,
746
Some ( & andy) ,
747
& format! ( " _csrf={csrf} &action=prompt&prompt={} " , enc ( " We write 6502 assembly." ) ) ,
748
)
749
.await;
750
assert! ( r.body.contains ( " Prompt saved." ) , " {}" , r.body) ;
751
let r = post ( app, " /orgs/acme/settings/assistants" , Some ( & andy) , & add ( " DeepSeek" , " " ) ) .await;
206c1fc1 Andy McCall 1 month ago ↶
752
assert! ( r.body.contains ( " DeepSeek added and answered" ) , " {}" , r.body) ;
e71846aa Andy McCall 1 month ago ↶
753
let prompt = assistants:: prompt_for_repo( & app.db, & repo) .await.unwrap ( ) ;
754
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}" ) ;
755
let repo_page = get ( app, " /acme/plans/settings" , Some ( & andy) ) .await.body;
756
assert! ( repo_page.contains ( " from the acme organisation" ) & & repo_page.contains ( " From the acme organisation" ) , " {repo_page}" ) ;
757
let on = assistants:: enabled_for_repo( & app.db, & repo) .await.unwrap ( ) ;
758
assert_eq! ( on.iter ( ) .map ( | a | a.name.as_str ( ) ) .collect:: < Vec< _ > > ( ) , [ " Claude" , " DeepSeek" ] ) ;
759
760
761
762
763
let r = post ( app, " /admin/assistants" , Some ( & andy) , & format! ( " _csrf={csrf} &action=toggle&id={} " , claude.id) ) .await;
764
assert! ( r.body.contains ( " Claude is off." ) , " {}" , r.body) ;
765
let r = post ( app, " /acme/plans/settings/assistants" , Some ( & andy) , & add ( " Claude" , " " ) ) .await;
206c1fc1 Andy McCall 1 month ago ↶
766
assert! ( r.body.contains ( " Claude added and answered" ) , " {}" , r.body) ;
e71846aa Andy McCall 1 month ago ↶
767
let on = assistants:: enabled_for_repo( & app.db, & repo) .await.unwrap ( ) ;
768
assert_eq! ( on.iter ( ) .map ( | a | a.name.as_str ( ) ) .collect:: < Vec< _ > > ( ) , [ " DeepSeek" ] ) ;
769
let repo_page = get ( app, " /acme/plans/settings" , Some ( & andy) ) .await.body;
770
assert! ( repo_page.contains ( " off above" ) , " {repo_page}" ) ;
771
post ( app, " /admin/assistants" , Some ( & andy) , & format! ( " _csrf={csrf} &action=toggle&id={} " , claude.id) ) .await;
772
let on = assistants:: enabled_for_repo( & app.db, & repo) .await.unwrap ( ) ;
773
assert_eq! ( on.iter ( ) .map ( | a | ( a.name.as_str ( ) , a.scope.as_str ( ) ) ) .collect:: < Vec< _ > > ( ) , [ ( " DeepSeek" , " org" ) , ( " Claude" , " repo" ) ] ) ;
774
775
776
let invite = {
777
let r = post ( app, " /admin/invite" , Some ( & andy) , & format! ( " _csrf={csrf} " ) ) .await;
778
let i = r.body.find ( " invite=" ) .map ( | i | i + 7 ) .expect ( " an invitation" ) ;
779
r.body[ i.. ] .chars ( ) .take_while ( | c | c.is_ascii_alphanumeric ( ) ) .collect:: < String> ( )
780
} ;
781
let bob = register ( app, " bob" , & invite) .await;
782
assert_eq! (
783
post ( app, " /admin/assistants" , Some ( & bob) , & format! ( " _csrf={csrf} &action=prompt&prompt=x" ) ) .await.status,
784
StatusCode:: NOT_FOUND
785
) ;
786
assert_eq! (
787
post ( app, " /orgs/acme/settings/assistants" , Some ( & bob) , & format! ( " _csrf={csrf} &action=prompt&prompt=x" ) ) .await.status,
788
StatusCode:: NOT_FOUND
789
) ;
790
assert_eq! (
791
post ( app, " /acme/plans/settings/assistants" , Some ( & bob) , & format! ( " _csrf={csrf} &action=prompt&prompt=x" ) ) .await.status,
792
StatusCode:: NOT_FOUND
793
) ;
794
795
let r = post ( app, " /acme/plans/settings/delete" , Some ( & andy) , & format! ( " _csrf={csrf} &confirm=acme%2Fplans" ) ) .await;
796
assert! ( r.status = = StatusCode:: SEE_OTHER | | r.status = = StatusCode:: OK , " {} {}" , r.status, r.body) ;
797
assert! ( assistants:: list( & app.db, Scope:: Repo( repo.id) ) .await.unwrap ( ) .is_empty ( ) ) ;
798
}
89e89836 Andy McCall 1 month ago ↶
799
800
# [ tokio ::test ( flavor = " multi_thread" ) ]
801
async fn accounts_elsewhere_are_checked_and_shown_on_the_profile ( ) {
802
let h = hub ( ) .await;
803
let app = & h.app;
804
let andy = register ( app, " andy" , " " ) .await;
805
let page = get ( app, " /settings" , Some ( & andy) ) .await.body;
806
let csrf = csrf_of ( & page) ;
807
let base = format! ( " _csrf={csrf} &display_name=Andy&email=andy%40example.com&bio=&website=&location=" ) ;
808
let r = post ( app, " /settings" , Some ( & andy) , & format! ( " {base} &bluesky=%40Andy.bsky.social&mastodon=https%3A%2F%2FFosstodon.org%2F%40andy&github=https%3A%2F%2Fgithub.com%2Fandymccall%2F" ) ) .await;
809
assert! (
810
r.body.contains ( " value=\" andy.bsky.social\" " )
811
& & r.body.contains ( " value=\" @[email protected] \" " )
812
& & r.body.contains ( " value=\" andymccall\" " ) ,
813
" {}" ,
814
r.body
815
) ;
816
let profile = get ( app, " /andy" , None ) .await.body;
817
assert! ( profile.contains ( " href=\" https://bsky.app/profile/andy.bsky.social\" " ) & & profile.contains ( " @andy.bsky.social" ) , " {profile}" ) ;
818
assert! ( profile.contains ( " href=\" https://fosstodon.org/@andy\" " ) & & profile.contains ( " rel=\" nofollow noopener me\" " ) , " {profile}" ) ;
819
assert! ( profile.contains ( " href=\" https://github.com/andymccall\" " ) & & profile.contains ( " title=\" GitHub\" " ) , " {profile}" ) ;
820
821
let r = post ( app, " /settings" , Some ( & andy) , & format! ( " {base} &bluesky=not+a+handle&mastodon=&github=" ) ) .await;
822
assert! ( r.body.contains ( " not a Bluesky handle" ) , " {}" , r.body) ;
823
assert! ( get ( app, " /andy" , None ) .await.body.contains ( " andy.bsky.social" ) ) ;
824
825
post ( app, " /settings" , Some ( & andy) , & format! ( " {base} &bluesky=&mastodon=&github=" ) ) .await;
826
assert! ( ! get ( app, " /andy" , None ) .await.body.contains ( " class=\" social\" " ) ) ;
83940d53 Andy McCall 1 month ago ↶
827
}
828
829
830
async fn mock_provider ( ) -> ( String, std:: sync:: Arc< std:: sync:: Mutex< Vec< String> > > ) {
831
use std:: sync:: { Arc, Mutex} ;
832
let seen: Arc< Mutex< Vec< String> > > = Arc:: new( Mutex:: new( vec! [ ] ) ) ;
833
let s1 = seen.clone ( ) ;
834
let s2 = seen.clone ( ) ;
835
let router = axum:: Router:: new( )
836
.route (
837
" /github/token" ,
838
axum:: routing:: post( move | body: String | {
839
let s1 = s1.clone ( ) ;
840
async move {
841
s1.lock ( ) .unwrap ( ) .push ( format! ( " github token {body} " ) ) ;
842
axum:: Json( serde_json:: json! ( { " access_token" : " gh-token" , " token_type" : " bearer" } ) )
843
}
844
} ) ,
845
)
846
.route (
847
" /github/userinfo" ,
848
axum:: routing:: get( | headers : axum:: http:: HeaderMap| async move {
849
assert_eq! ( headers.get ( " authorization" ) .and_then ( | v | v.to_str ( ) .ok ( ) ) , Some ( " Bearer gh-token" ) ) ;
850
axum:: Json( serde_json:: json! ( { " id" : 583231 , " login" : " octocat" , " name" : " The Octocat" , " email" : null } ) )
851
} ) ,
852
)
853
.route (
854
" /github/userinfo/emails" ,
855
axum:: routing:: get( | | async {
856
axum:: Json( serde_json:: json! ( [
857
{ " email" : " [email protected] " , " primary" : false , " verified" : false } ,
858
{ " email" : " [email protected] " , " primary" : true , " verified" : true }
859
] ) )
860
} ) ,
861
)
862
.route (
863
" /google/token" ,
864
axum:: routing:: post( move | body: String | {
865
let s2 = s2.clone ( ) ;
866
async move {
867
s2.lock ( ) .unwrap ( ) .push ( format! ( " google token {body} " ) ) ;
868
axum:: Json( serde_json:: json! ( { " access_token" : " g-token" , " id_token" : " x" } ) )
869
}
870
} ) ,
871
)
872
.route (
873
" /google/userinfo" ,
874
axum:: routing:: get( | | async {
875
axum:: Json( serde_json:: json! ( { " sub" : " 1076915035000615071" , " email" : " [email protected] " , " email_verified" : true , " name" : " Andy McCall" } ) )
876
} ) ,
877
) ;
878
let listener = tokio:: net:: TcpListener:: bind( " 127.0.0.1:0" ) .await.unwrap ( ) ;
879
let port = listener.local_addr ( ) .unwrap ( ) .port ( ) ;
880
tokio:: spawn( async move { axum:: serve( listener, router) .await.unwrap ( ) } ) ;
881
( format! ( " http://127.0.0.1:{port} " ) , seen)
882
}
883
884
fn state_of ( res : & Res) -> ( String, String) {
885
let cookie = res.raw_cookies.iter ( ) .find ( | c | c.starts_with ( " oxyhub_signin=" ) ) .expect ( " state cookie" ) .clone ( ) ;
886
let value = cookie.trim_start_matches ( " oxyhub_signin=" ) .split ( ' ;' ) .next ( ) .unwrap ( ) .to_string ( ) ;
887
( cookie, value)
888
}
889
890
# [ tokio ::test ( flavor = " multi_thread" ) ]
891
async fn people_sign_in_with_github_or_google_once_an_admin_sets_it_up ( ) {
892
hermetic ( ) ;
893
let ( mock, seen) = mock_provider ( ) .await;
894
let dir = tempfile:: tempdir( ) .unwrap ( ) ;
895
let mut config = Config:: load( dir.path ( ) ) .unwrap ( ) ;
896
let listener = tokio:: net:: TcpListener:: bind( " 127.0.0.1:0" ) .await.unwrap ( ) ;
897
let port = listener.local_addr ( ) .unwrap ( ) .port ( ) ;
898
config.server.base_url = format! ( " http://127.0.0.1:{port} " ) ;
899
config.registration.mode = " open" .into ( ) ;
900
config.auth.signin_mock = Some ( mock.clone ( ) ) ;
901
let app = oxygenhub:: build( config) .await.unwrap ( ) ;
902
let router = oxygenhub:: router( app.clone ( ) ) ;
903
tokio:: spawn( async move { axum:: serve( listener, router) .await.unwrap ( ) } ) ;
904
let app = & app;
905
let andy = register ( app, " andy" , " " ) .await;
906
let andy_row = db:: accounts:: account_by_name( & app.db, " andy" ) .await.unwrap ( ) .unwrap ( ) ;
907
db:: accounts:: set_email_verified( & app.db, andy_row.id, true ) .await.unwrap ( ) ;
908
909
910
assert! ( ! get ( app, " /login" , None ) .await.body.contains ( " /login/github" ) ) ;
911
let admin = get ( app, " /admin" , Some ( & andy) ) .await.body;
912
assert! ( admin.contains ( " Sign in with" ) & & admin.contains ( & format! ( " http://127.0.0.1:{port} /login/github/callback" ) ) , " {admin}" ) ;
913
let csrf = csrf_of ( & admin) ;
914
let r =
915
post ( app, " /admin/signin" , Some ( & andy) , & format! ( " _csrf={csrf} &provider=github&client_id=gh-cid&secret=gh-sek&enabled=1" ) ) .await;
916
assert! ( r.body.contains ( " Signing in with GitHub is on." ) & & ! r.body.contains ( " gh-sek" ) , " {}" , r.body) ;
917
let r = post ( app, " /admin/signin" , Some ( & andy) , & format! ( " _csrf={csrf} &provider=google&client_id=g-cid&secret=g-sek&enabled=1" ) ) .await;
918
assert! ( r.body.contains ( " Signing in with Google is on." ) , " {}" , r.body) ;
919
let login = get ( app, " /login" , None ) .await.body;
920
assert! ( login.contains ( " href=\" /login/github\" " ) & & login.contains ( " href=\" /login/google\" " ) , " {login}" ) ;
921
922
923
let r = get ( app, " /login/github" , None ) .await;
924
assert_eq! ( r.status, StatusCode:: SEE_OTHER ) ;
925
let to = r.location.clone ( ) .unwrap ( ) ;
926
assert! ( to.starts_with ( & format! ( " {mock} /github/authorize?client_id=gh-cid&redirect_uri=" ) ) & & to.contains ( " &state=" ) , " {to}" ) ;
927
let ( cookie, state) = state_of ( & r) ;
928
let bad = send_with ( app, " GET" , " /login/github/callback?code=c1&state=wrong" , Some ( & cookie) , None ) .await;
929
assert! ( bad.location.as_deref ( ) .unwrap_or ( " " ) .starts_with ( " /login?error=" ) , " {:?}" , bad.location) ;
930
let ok = send_with ( app, " GET" , & format! ( " /login/github/callback?code=c1&state={state} " ) , Some ( & cookie) , None ) .await;
931
assert_eq! ( ok.status, StatusCode:: SEE_OTHER , " {}" , ok.body) ;
932
assert_eq! ( ok.location.as_deref ( ) , Some ( " /" ) ) ;
933
let octo_cookie = ok.cookie.clone ( ) .expect ( " signed in" ) ;
934
let octo = db:: accounts:: account_by_name( & app.db, " octocat" ) .await.unwrap ( ) .expect ( " account made from the GitHub login" ) ;
935
assert! (
936
( octo.email.as_str ( ) , octo.email_verified, octo.display_name.as_str ( ) ) = = ( " [email protected] " , true , " The Octocat" ) ,
937
" {octo:?}"
938
) ;
939
assert! ( seen.lock ( ) .unwrap ( ) .iter ( ) .any ( | s | s.contains ( " github token" ) & & s.contains ( " code=c1" ) & & s.contains ( " client_secret=gh-sek" ) ) ) ;
940
assert! ( get ( app, " /settings" , Some ( & octo_cookie) ) .await.body.contains ( " octocat" ) ) ;
941
942
let r = get ( app, " /login/github" , None ) .await;
943
let ( cookie, state) = state_of ( & r) ;
944
let ok = send_with ( app, " GET" , & format! ( " /login/github/callback?code=c2&state={state} " ) , Some ( & cookie) , None ) .await;
945
assert_eq! ( ok.location.as_deref ( ) , Some ( " /" ) ) ;
946
assert_eq! ( db:: accounts:: account_count( & app.db) .await.unwrap ( ) , 2 ) ;
947
948
949
let r = get ( app, " /login/google" , None ) .await;
950
assert! ( r.location.clone ( ) .unwrap ( ) .contains ( " prompt=select_account" ) ) ;
951
let ( cookie, state) = state_of ( & r) ;
952
let ok = send_with ( app, " GET" , & format! ( " /login/google/callback?code=c3&state={state} " ) , Some ( & cookie) , None ) .await;
953
assert_eq! ( ok.location.as_deref ( ) , Some ( " /" ) , " {}" , ok.body) ;
954
let as_andy = ok.cookie.clone ( ) .unwrap ( ) ;
955
let sec = get ( app, " /settings/security" , Some ( & as_andy) ) .await.body;
956
assert! (
957
sec.contains ( " Connected accounts" )
958
& & sec.contains ( " Google: <strong>andy</strong>" )
959
& & sec.contains ( " GitHub: <a href=\" /settings/security/connect/github\" >connect</a>" ) ,
960
" {sec}"
961
) ;
962
assert_eq! ( db:: accounts:: account_count( & app.db) .await.unwrap ( ) , 2 , " no third account" ) ;
963
964
let csrf_s = csrf_of ( & sec) ;
965
let r = post ( app, " /settings/security/disconnect/google" , Some ( & as_andy) , & format! ( " _csrf={csrf_s} " ) ) .await;
966
assert! ( r.location.as_deref ( ) .unwrap_or ( " " ) .contains ( " notice=Google%20disconnected" ) , " {:?}" , r.location) ;
967
assert! ( ! get ( app, " /settings/security" , Some ( & as_andy) ) .await.body.contains ( " Google: <strong>andy</strong>" ) ) ;
968
let r = get ( app, " /settings/security/connect/google" , Some ( & as_andy) ) .await;
969
assert_eq! ( r.status, StatusCode:: SEE_OTHER ) ;
970
let ( cookie, state) = state_of ( & r) ;
971
let both = format! ( " {cookie} ; oxyhub={as_andy} " ) ;
972
let ok = send_with ( app, " GET" , & format! ( " /login/google/callback?code=c4&state={state} " ) , Some ( & both) , None ) .await;
973
assert! ( ok.location.as_deref ( ) .unwrap_or ( " " ) .contains ( " notice=Google%20connected%20as%20andy" ) , " {:?} {}" , ok.location, ok.body) ;
974
975
976
db:: accounts:: set_setting( & app.db, " registration_mode" , " closed" ) .await.unwrap ( ) ;
977
sqlx:: query( " DELETE FROM account_logins WHERE provider = 'github'" ) .execute ( & app.db) .await.unwrap ( ) ;
978
sqlx:: query( " UPDATE accounts SET email_verified = 0 WHERE username = 'octocat'" ) .execute ( & app.db) .await.unwrap ( ) ;
979
let r = get ( app, " /login/github" , None ) .await;
980
let ( cookie, state) = state_of ( & r) ;
981
let no = send_with ( app, " GET" , & format! ( " /login/github/callback?code=c5&state={state} " ) , Some ( & cookie) , None ) .await;
982
assert! ( no.location.as_deref ( ) .unwrap_or ( " " ) .contains ( " registration%20is%20closed" ) , " {:?}" , no.location) ;
983
assert! ( no.cookie.is_none ( ) ) ;
89e89836 Andy McCall 1 month ago ↶
984
}
e8e7158b Andy McCall 1 month ago ↶
985
986
# [ tokio ::test ( flavor = " multi_thread" ) ]
987
async fn administration_is_sectioned_and_its_audit_log_pages ( ) {
988
let h = hub ( ) .await;
989
let app = & h.app;
990
let andy = register ( app, " andy" , " " ) .await;
991
let andy_row = db:: accounts:: account_by_name( & app.db, " andy" ) .await.unwrap ( ) .unwrap ( ) ;
992
for i in 0 .. 60 {
993
db:: accounts:: audit( & app.db, Some ( & andy_row) , " test.event" , & format! ( " t{i} " ) , " " ) .await.unwrap ( ) ;
994
}
995
let page = get ( app, " /admin" , Some ( & andy) ) .await.body;
996
for id in [ " accounts" , " registration" , " organisations" , " signin" , " mail" , " variables" , " secrets" , " assistants" , " audit" ] {
997
assert! ( page.contains ( & format! ( " href=\" #{id} \" " ) ) & & page.contains ( & format! ( " id=\" {id} \" " ) ) , " section {id}: {page}" ) ;
998
}
999
assert! ( page.contains ( " class=\" section-nav\" " ) ) ;
1000
assert! ( page.contains ( " >t59<" ) & & ! page.contains ( " >t5<" ) , " the newest page only" ) ;
1001
let i = page.find ( " audit_before=" ) .expect ( " an older link" ) + " audit_before=" .len ( ) ;
1002
let before: String = page[ i.. ] .chars ( ) .take_while ( | c | c.is_ascii_digit ( ) ) .collect ( ) ;
1003
let older = get ( app, & format! ( " /admin?audit_before={before} " ) , Some ( & andy) ) .await.body;
1004
assert! ( older.contains ( " >t5<" ) & & ! older.contains ( " >t59<" ) , " the next page: {older}" ) ;
1005
}
155cab14 Andy McCall 1 month ago ↶
1006
1007
# [ tokio ::test ( flavor = " multi_thread" ) ]
1008
async fn adding_an_assistant_offers_providers_and_only_models_the_endpoint_lists ( ) {
1009
let h = hub ( ) .await;
1010
let app = & h.app;
1011
let andy = register ( app, " andy" , " " ) .await;
1012
let router = axum:: Router:: new( )
1013
.route (
1014
" /v1/models" ,
1015
axum:: routing:: get( | headers : axum:: http:: HeaderMap| async move {
1016
assert_eq! ( headers.get ( " authorization" ) .and_then ( | v | v.to_str ( ) .ok ( ) ) , Some ( " Bearer sk-m" ) ) ;
1017
axum:: Json( serde_json:: json! ( { " data" : [
1018
{ " id" : " models/gemini-3-pro" } , { " id" : " gemini-3-flash" } , { " id" : " text-embedding-004" }
1019
] } ) )
1020
} ) ,
1021
)
1022
.route (
1023
" /v1/chat/completions" ,
1024
axum:: routing:: post( | | async { axum:: Json( serde_json:: json! ( { " choices" : [ { " message" : { " content" : " OK" } } ] } ) ) } ) ,
1025
) ;
1026
let listener = tokio:: net:: TcpListener:: bind( " 127.0.0.1:0" ) .await.unwrap ( ) ;
1027
let port = listener.local_addr ( ) .unwrap ( ) .port ( ) ;
1028
tokio:: spawn( async move { axum:: serve( listener, router) .await.unwrap ( ) } ) ;
1029
let ep = format! ( " http%3A%2F%2F127.0.0.1%3A{port} %2Fv1%2Fchat%2Fcompletions" ) ;
1030
1031
let page = get ( app, " /admin" , Some ( & andy) ) .await.body;
1032
assert! ( page.contains ( " data-preset-select" ) & & page.contains ( " Google Gemini" ) & & page.contains ( " Other (enter the endpoint)" ) , " {page}" ) ;
1033
assert! ( page.contains ( " LIST MODELS" ) ) ;
1034
let csrf = csrf_of ( & page) ;
1035
1036
let r =
1037
post ( app, " /admin/assistants/models" , Some ( & andy) , & format! ( " _csrf={csrf} &endpoint={ep} &api=chat-completions&token=sk-m" ) ) .await;
1038
let v: serde_json:: Value = serde_json:: from_str( & r.body) .unwrap ( ) ;
1039
assert_eq! ( v[ " models" ] , serde_json:: json! ( [ " gemini-3-flash" , " gemini-3-pro" ] ) , " {}" , r.body) ;
1040
1041
let r = post (
1042
app,
1043
" /admin/assistants" ,
1044
Some ( & andy) ,
1045
& format! ( " _csrf={csrf} &action=add&preset=gemini&endpoint={ep} &model=gemini-2.5-pro&token=sk-m&enabled=1" ) ,
1046
)
1047
.await;
1048
assert! ( r.body.contains ( " gemini-2.5-pro is not a model this endpoint offers" ) & & r.body.contains ( " gemini-3-pro" ) , " {}" , r.body) ;
1049
1050
let r = post (
1051
app,
1052
" /admin/assistants" ,
1053
Some ( & andy) ,
1054
& format! ( " _csrf={csrf} &action=add&preset=gemini&endpoint={ep} &model=gemini-3-pro&token=sk-m&enabled=1" ) ,
1055
)
1056
.await;
1057
assert! ( r.body.contains ( " Gemini added and answered: OK" ) , " {}" , r.body) ;
1058
let a = & oxygenhub:: assistants:: list( & app.db, oxygenhub:: levels:: Scope:: Site) .await.unwrap ( ) [ 0 ] ;
1059
assert_eq! ( ( a.name.as_str ( ) , a.api.as_str ( ) , a.model.as_str ( ) ) , ( " Gemini" , " chat-completions" , " gemini-3-pro" ) ) ;
1060
1061
let r =
1062
post ( app, " /admin/assistants/models" , Some ( & andy) , & format! ( " _csrf={csrf} &id={} &endpoint={ep} &api=chat-completions" , a.id) ) .await;
1063
assert! ( r.body.contains ( " gemini-3-flash" ) , " {}" , r.body) ;
1064
}