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 ( ) ;
c8684d44 Andy McCall 20 days ago
40
41
config.registration.setup_code = " test-setup" .into ( ) ;
19bb31ff Andy McCall 1 month ago ↶
42
let listener = tokio:: net:: TcpListener:: bind( " 127.0.0.1:0" ) .await.unwrap ( ) ;
43
let port = listener.local_addr ( ) .unwrap ( ) .port ( ) ;
44
config.server.base_url = format! ( " http://127.0.0.1:{port} " ) ;
9a777fec Andy McCall 21 days ago ↶
45
46
config.outbound.allow = vec! [ " http://127.0.0.1" .into ( ) ] ;
19bb31ff Andy McCall 1 month ago ↶
47
config.registration.mode = " invite" .into ( ) ;
48
let app = oxygenhub:: build( config) .await.unwrap ( ) ;
49
let router = oxygenhub:: router( app.clone ( ) ) ;
50
tokio:: spawn( async move { axum:: serve( listener, router) .await.unwrap ( ) } ) ;
51
Hub { app, base: format! ( " http://127.0.0.1:{port} " ) , _dir: dir }
52
}
53
54
struct Res {
55
status : StatusCode,
56
body : String,
83940d53 Andy McCall 1 month ago ↶
57
19bb31ff Andy McCall 1 month ago ↶
58
cookie : Option< String> ,
59
location : Option< String> ,
83940d53 Andy McCall 1 month ago ↶
60
61
raw_cookies : Vec< String> ,
19bb31ff Andy McCall 1 month ago ↶
62
}
63
64
async fn send ( app : & Shared, method : & str , path : & str , cookie : Option< & str > , form : Option< & str > ) -> Res {
83940d53 Andy McCall 1 month ago ↶
65
let header_value = cookie.map ( | c | format! ( " oxyhub={c} " ) ) ;
66
send_with ( app, method, path, header_value.as_deref ( ) , form) .await
67
}
68
69
70
async fn send_with ( app : & Shared, method : & str , path : & str , cookie_header : Option< & str > , form : Option< & str > ) -> Res {
19bb31ff Andy McCall 1 month ago ↶
71
let mut req = Request:: builder( ) .method ( method) .uri ( path) ;
83940d53 Andy McCall 1 month ago ↶
72
if let Some ( c) = cookie_header {
73
req = req.header ( header:: COOKIE , c.to_string ( ) ) ;
19bb31ff Andy McCall 1 month ago ↶
74
}
75
let body = match form {
76
Some ( f) => {
77
req = req.header ( header:: CONTENT_TYPE , " application/x-www-form-urlencoded" ) ;
78
Body:: from( f.to_string ( ) )
79
}
80
None => Body:: empty( ) ,
81
} ;
82
let res = oxygenhub:: router( app.clone ( ) ) .oneshot ( req.body ( body) .unwrap ( ) ) .await.unwrap ( ) ;
83
let status = res.status ( ) ;
83940d53 Andy McCall 1 month ago ↶
84
let raw_cookies: Vec< String> =
85
res.headers ( ) .get_all ( header:: SET_COOKIE ) .iter ( ) .filter_map ( | v | v.to_str ( ) .ok ( ) ) .map ( String :: from) .collect ( ) ;
86
let cookie =
87
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 ↶
88
let location = res.headers ( ) .get ( header:: LOCATION ) .and_then ( | v | v.to_str ( ) .ok ( ) ) .map ( String :: from) ;
89
let bytes = res.into_body ( ) .collect ( ) .await.unwrap ( ) .to_bytes ( ) ;
83940d53 Andy McCall 1 month ago ↶
90
Res { status, body: String :: from_utf8_lossy( & bytes) .into_owned ( ) , cookie, location, raw_cookies }
19bb31ff Andy McCall 1 month ago ↶
91
}
92
93
async fn get ( app : & Shared, path : & str , cookie : Option< & str > ) -> Res {
94
send ( app, " GET" , path, cookie, None ) .await
95
}
96
97
async fn post ( app : & Shared, path : & str , cookie : Option< & str > , form : & str ) -> Res {
98
send ( app, " POST" , path, cookie, Some ( form) ) .await
99
}
100
101
102
async fn register ( app : & Shared, user : & str , invite : & str ) -> String {
c8684d44 Andy McCall 20 days ago
103
let r = post (
104
app,
105
" /register" ,
106
None ,
107
& format! ( " username={user} &email={user} %40example.com&password=correct-horse-battery&setup=test-setup&invite={invite} " ) ,
108
)
109
.await;
19bb31ff Andy McCall 1 month ago ↶
110
assert_eq! ( r.status, StatusCode:: SEE_OTHER , " {}" , r.body) ;
111
r.cookie.expect ( " session cookie" )
112
}
113
114
fn csrf_of ( page : & str ) -> String {
115
let i = page.find ( " name=\" _csrf\" value=\" " ) .expect ( " csrf field" ) + " name=\" _csrf\" value=\" " .len ( ) ;
116
page[ i.. ] .split ( ' "' ) .next ( ) .unwrap ( ) .to_string ( )
117
}
118
119
# [ tokio ::test ( flavor = " multi_thread" ) ]
120
async fn accounts_roles_tokens_and_attribution ( ) {
121
let h = hub ( ) .await;
122
let app = & h.app;
123
124
125
let andy = register ( app, " andy" , " " ) .await;
126
let me = db:: accounts:: account_by_name( & app.db, " andy" ) .await.unwrap ( ) .unwrap ( ) ;
127
assert! ( me.is_admin) ;
128
c8684d44 Andy McCall 20 days ago
129
let r = post ( app, " /register" , None , " username=mallory&email=m%40example.com&password=correct-horse-battery&setup=test-setup&invite=" )
130
.await;
19bb31ff Andy McCall 1 month ago ↶
131
assert_eq! ( r.status, StatusCode:: OK ) ;
132
assert! ( r.body.contains ( " invitation" ) , " {}" , r.body) ;
133
let admin_page = get ( app, " /admin" , Some ( & andy) ) .await;
134
assert_eq! ( admin_page.status, StatusCode:: OK ) ;
135
let csrf = csrf_of ( & admin_page.body) ;
136
let r = post ( app, " /admin/invite" , Some ( & andy) , & format! ( " _csrf={csrf} " ) ) .await;
137
let code = r.body.split ( " register?invite=" ) .nth ( 1 ) .unwrap ( ) .split ( ' <' ) .next ( ) .unwrap ( ) .trim ( ) .to_string ( ) ;
138
let bob = register ( app, " bob" , & code) .await;
139
assert! ( ! db:: accounts:: account_by_name( & app.db, " bob" ) .await.unwrap ( ) .unwrap ( ) .is_admin) ;
140
141
assert_eq! ( post ( app, " /login" , None , " username=andy&password=nope" ) .await.status, StatusCode:: OK ) ;
142
assert_eq! ( post ( app, " /settings" , Some ( & andy) , " _csrf=bogus&display_name=x&email=y" ) .await.status, StatusCode:: BAD_REQUEST ) ;
143
assert_eq! ( get ( app, " /settings" , None ) .await.status, StatusCode:: SEE_OTHER , " anonymous settings redirects to sign in" ) ;
144
145
146
let new_page = get ( app, " /new" , Some ( & andy) ) .await;
147
let csrf = csrf_of ( & new_page.body) ;
148
let r = post ( app, " /new" , Some ( & andy) , & format! ( " _csrf={csrf} &owner=andy&name=secret&description=hush&visibility=private" ) ) .await;
149
assert_eq! ( r.status, StatusCode:: SEE_OTHER , " {}" , r.body) ;
150
assert_eq! ( get ( app, " /andy/secret" , Some ( & andy) ) .await.status, StatusCode:: OK ) ;
151
assert_eq! ( get ( app, " /andy/secret" , Some ( & bob) ) .await.status, StatusCode:: NOT_FOUND ) ;
152
assert_eq! ( get ( app, " /andy/secret" , None ) .await.status, StatusCode:: NOT_FOUND ) ;
153
assert! ( ! get ( app, " /" , Some ( & bob) ) .await.body.contains ( " secret" ) ) ;
154
assert! ( get ( app, " /" , Some ( & andy) ) .await.body.contains ( " secret" ) ) ;
155
156
157
let tokens_page = get ( app, " /settings/tokens" , Some ( & andy) ) .await;
158
let csrf = csrf_of ( & tokens_page.body) ;
159
let r = post ( app, " /settings/tokens" , Some ( & andy) , & format! ( " _csrf={csrf} &name=ro&scopes=repo%3Aread" ) ) .await;
160
let ro = r.body.split ( " oxyhub_" ) .nth ( 1 ) .map ( | s | format! ( " oxyhub_{} " , s.split ( ' <' ) .next ( ) .unwrap ( ) .trim ( ) ) ) .unwrap ( ) ;
161
let r = post ( app, " /settings/tokens" , Some ( & andy) , & format! ( " _csrf={csrf} &name=rw&scopes=repo%3Aread&scopes=repo%3Awrite" ) ) .await;
162
let rw = r.body.split ( " oxyhub_" ) .nth ( 1 ) .map ( | s | format! ( " oxyhub_{} " , s.split ( ' <' ) .next ( ) .unwrap ( ) .trim ( ) ) ) .unwrap ( ) ;
163
let work = tempfile:: tempdir( ) .unwrap ( ) ;
164
let mut client = Repo:: init( work.path ( ) ) .unwrap ( ) ;
165
client.create_identity ( " Andy McCall" , " [email protected] " , oxy_core:: repo:: IdentityScope:: User) .unwrap ( ) ;
166
client.config.set ( " remote.origin.url" , & format! ( " {} /andy/secret" , h.base) ) .unwrap ( ) ;
167
client.config.set ( " core.sign" , " change" ) .unwrap ( ) ;
168
client.save_config ( ) .unwrap ( ) ;
169
write ( work.path ( ) , " a.txt" , b " a\n " ) ;
170
client.snapshot ( " t" ) .unwrap ( ) ;
171
let c1 = client.commit ( " first" , & [ ] ) .unwrap ( ) ;
172
let err = client.push ( " origin" , false ) .err ( ) .unwrap ( ) .to_string ( ) ;
173
assert! ( err.contains ( " 404" ) , " anonymous cannot even see it: {err}" ) ;
174
client.config.set ( " remote.origin.token" , & ro) .unwrap ( ) ;
175
let err = client.push ( " origin" , false ) .err ( ) .unwrap ( ) .to_string ( ) ;
176
assert! ( err.contains ( " 403" ) , " read-only token cannot push: {err}" ) ;
177
client.config.set ( " remote.origin.token" , & rw) .unwrap ( ) ;
178
assert! ( client.push ( " origin" , false ) .unwrap ( ) .iter ( ) .all ( | r | r.ok ) ) ;
179
180
let bob_tokens = get ( app, " /settings/tokens" , Some ( & bob) ) .await;
181
let csrf_b = csrf_of ( & bob_tokens.body) ;
182
let r = post ( app, " /settings/tokens" , Some ( & bob) , & format! ( " _csrf={csrf_b} &name=b&scopes=repo%3Aread&scopes=repo%3Awrite" ) ) .await;
183
let bob_tok = r.body.split ( " oxyhub_" ) .nth ( 1 ) .map ( | s | format! ( " oxyhub_{} " , s.split ( ' <' ) .next ( ) .unwrap ( ) .trim ( ) ) ) .unwrap ( ) ;
184
std:: env:: set_var( " OXY_TOKEN" , & bob_tok) ;
185
let err = Repo:: clone( & format! ( " {} /andy/secret" , h.base) , & work.path ( ) .join ( " bobclone" ) ) .err ( ) .unwrap ( ) .to_string ( ) ;
186
std:: env:: remove_var( " OXY_TOKEN" ) ;
187
assert! ( err.contains ( " 404" ) , " {err}" ) ;
188
189
tokio:: time:: sleep( std:: time:: Duration:: from_millis( 150 ) ) .await;
190
let pushes = get ( app, " /andy/secret/pushes" , Some ( & andy) ) .await.body;
191
assert! ( pushes.contains ( " andy" ) , " {pushes}" ) ;
192
193
194
let settings = get ( app, " /andy/secret/settings" , Some ( & andy) ) .await;
195
assert_eq! ( settings.status, StatusCode:: OK ) ;
196
let csrf = csrf_of ( & settings.body) ;
197
let r = post ( app, " /andy/secret/settings/collaborators" , Some ( & andy) , & format! ( " _csrf={csrf} &username=bob&role=write" ) ) .await;
198
assert_eq! ( r.status, StatusCode:: OK , " {}" , r.body) ;
199
assert_eq! ( get ( app, " /andy/secret" , Some ( & bob) ) .await.status, StatusCode:: OK ) ;
200
assert_eq! ( get ( app, " /andy/secret/settings" , Some ( & bob) ) .await.status, StatusCode:: NOT_FOUND , " write is not maintain" ) ;
201
std:: env:: set_var( " OXY_TOKEN" , & bob_tok) ;
202
let ok = Repo:: clone( & format! ( " {} /andy/secret" , h.base) , & work.path ( ) .join ( " bobclone2" ) ) .is_ok ( ) ;
203
std:: env:: remove_var( " OXY_TOKEN" ) ;
204
assert! ( ok) ;
205
2bbe88a8 Andy McCall 1 month ago ↶
206
207
208
let store = oxy_core:: credentials:: Credentials:: user( ) ;
209
let hub = oxy_core:: credentials:: hub_of( & h.base) .unwrap ( ) ;
210
store.save ( & hub, & rw, Some ( " andy" ) , None ) .unwrap ( ) ;
211
let signed_in = Repo:: clone( & format! ( " {} /andy/secret" , h.base) , & work.path ( ) .join ( " signedin" ) ) ;
212
store.remove ( & hub) .unwrap ( ) ;
213
let signed_in = signed_in.unwrap ( ) ;
214
assert! ( signed_in.config.remote_tokens.is_empty ( ) ) ;
215
assert_eq! ( signed_in.bookmark_heads ( ) .unwrap ( ) , vec! [ c1] ) ;
216
19bb31ff Andy McCall 1 month ago ↶
217
218
let ( key, identity) = {
219
let k = client.config.author_key.unwrap ( ) ;
220
( k, client.identity ( & k) .unwrap ( ) )
221
} ;
222
let keys_page = get ( app, " /settings/keys" , Some ( & andy) ) .await;
223
let csrf = csrf_of ( & keys_page.body) ;
224
let boundary = " XYZ" ;
225
let mut multipart = Vec :: new( ) ;
226
multipart.extend_from_slice ( format! ( " --{boundary} \r \n Content-Disposition: form-data; name=\" _csrf\" \r \n \r \n {csrf} \r \n " ) .as_bytes ( ) ) ;
227
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 ( ) ) ;
228
multipart.extend_from_slice ( & identity.encode ( ) ) ;
229
multipart.extend_from_slice ( format! ( " \r \n --{boundary} --\r \n " ) .as_bytes ( ) ) ;
230
let req = Request:: builder( )
231
.method ( " POST" )
232
.uri ( " /settings/keys/upload" )
233
.header ( header:: COOKIE , format! ( " oxyhub={andy} " ) )
234
.header ( header:: CONTENT_TYPE , format! ( " multipart/form-data; boundary={boundary} " ) )
235
.body ( Body:: from( multipart) )
236
.unwrap ( ) ;
237
let res = oxygenhub:: router( app.clone ( ) ) .oneshot ( req) .await.unwrap ( ) ;
238
assert_eq! ( res.status ( ) , StatusCode:: OK ) ;
239
let body = String :: from_utf8_lossy( & res.into_body ( ) .collect ( ) .await.unwrap ( ) .to_bytes ( ) ) .into_owned ( ) ;
240
let challenge_hex = body.split ( " sign-challenge " ) .nth ( 1 ) .unwrap ( ) .split ( ' <' ) .next ( ) .unwrap ( ) .trim ( ) .to_string ( ) ;
241
let challenge_id = body.split ( " name=\" challenge\" value=\" " ) .nth ( 1 ) .unwrap ( ) .split ( ' "' ) .next ( ) .unwrap ( ) .to_string ( ) ;
242
let ( _ , sk) = client.secret_key ( ) .unwrap ( ) .unwrap ( ) ;
243
let sig = sk.sign_challenge ( & key, & hex:: decode( & challenge_hex) .unwrap ( ) ) .unwrap ( ) ;
244
let sig_hex = hex:: encode( oxy_core:: identity:: Signature:: encode_list( & [ sig] ) ) ;
245
let r = post ( app, " /settings/keys/verify" , Some ( & andy) , & format! ( " _csrf={csrf} &challenge={challenge_id} &signature={sig_hex} " ) ) .await;
246
assert! ( r.body.contains ( " Key linked" ) , " {}" , r.body) ;
247
let hist = get ( app, " /andy/secret/changes/main" , Some ( & andy) ) .await.body;
248
assert! ( hist.contains ( " href=\" /andy\" >Andy McCall</a>" ) , " author linked to the account: {hist}" ) ;
249
let change = get ( app, & format! ( " /andy/secret/change/{} " , c1.to_hex ( ) ) , Some ( & andy) ) .await.body;
250
assert! ( change.contains ( " @andy" ) , " {change}" ) ;
251
252
let r = post ( app, " /settings/keys/verify" , Some ( & andy) , & format! ( " _csrf={csrf} &challenge=999&signature={sig_hex} " ) ) .await;
253
assert! ( r.body.contains ( " expired" ) , " {}" , r.body) ;
254
255
256
let org_page = get ( app, " /orgs/new" , Some ( & andy) ) .await;
257
let csrf = csrf_of ( & org_page.body) ;
258
let r = post ( app, " /orgs/new" , Some ( & andy) , & format! ( " _csrf={csrf} &name=acme&display_name=Acme" ) ) .await;
259
assert_eq! ( r.status, StatusCode:: SEE_OTHER , " {}" , r.body) ;
260
let new_page = get ( app, " /new" , Some ( & andy) ) .await;
261
let csrf = csrf_of ( & new_page.body) ;
262
assert_eq! (
263
post ( app, " /new" , Some ( & andy) , & format! ( " _csrf={csrf} &owner=acme&name=plans&visibility=private" ) ) .await.status,
264
StatusCode:: SEE_OTHER
265
) ;
266
assert_eq! ( get ( app, " /acme/plans" , Some ( & bob) ) .await.status, StatusCode:: NOT_FOUND ) ;
267
let org_settings = get ( app, " /orgs/acme/settings" , Some ( & andy) ) .await;
268
let csrf = csrf_of ( & org_settings.body) ;
269
assert_eq! (
270
post ( app, " /orgs/acme/settings/members" , Some ( & andy) , & format! ( " _csrf={csrf} &username=bob&role=member" ) ) .await.status,
271
StatusCode:: OK
272
) ;
273
assert_eq! ( get ( app, " /acme/plans" , Some ( & bob) ) .await.status, StatusCode:: OK , " members read by default" ) ;
274
assert_eq! ( get ( app, " /orgs/acme/settings" , Some ( & bob) ) .await.status, StatusCode:: NOT_FOUND , " members do not administer" ) ;
275
276
let r = post (
277
app,
278
" /new" ,
279
Some ( & bob) ,
280
& format! ( " _csrf={} &owner=acme&name=bobs&visibility=public" , csrf_of ( & get ( app, " /new" , Some ( & bob) ) .await.body) ) ,
281
)
282
.await;
283
assert_eq! ( r.status, StatusCode:: SEE_OTHER , " {}" , r.body) ;
284
285
286
let sec = get ( app, " /settings/security" , Some ( & andy) ) .await;
287
let csrf = csrf_of ( & sec.body) ;
288
let r = post ( app, " /settings/security/sessions" , Some ( & andy) , & format! ( " _csrf={csrf} " ) ) .await;
289
assert_eq! ( r.status, StatusCode:: SEE_OTHER ) ;
290
assert_eq! ( get ( app, " /settings" , Some ( & andy) ) .await.status, StatusCode:: SEE_OTHER , " old session is gone" ) ;
291
let fresh = r.cookie.unwrap ( ) ;
292
assert_eq! ( get ( app, " /settings" , Some ( & fresh) ) .await.status, StatusCode:: OK ) ;
293
294
295
let sec = get ( app, " /settings/security" , Some ( & fresh) ) .await;
296
let csrf = csrf_of ( & sec.body) ;
297
let r = post ( app, " /settings/security/totp/begin" , Some ( & fresh) , & format! ( " _csrf={csrf} " ) ) .await;
298
let secret = r.body.split ( " name=\" secret\" value=\" " ) .nth ( 1 ) .unwrap ( ) .split ( ' "' ) .next ( ) .unwrap ( ) .to_string ( ) ;
299
let now = ( db:: now_ms( ) / 1000 ) as u64 ;
300
let code = oxygenhub:: auth:: totp:: current( & secret, now) .unwrap ( ) ;
301
let r = post ( app, " /settings/security/totp/enable" , Some ( & fresh) , & format! ( " _csrf={csrf} &secret={secret} &code={code} " ) ) .await;
302
assert! ( r.body.contains ( " Recovery codes" ) , " {}" , r.body) ;
c8684d44 Andy McCall 20 days ago
303
let r = post ( app, " /login" , None , " username=andy&password=correct-horse-battery&setup=test-setup" ) .await;
19bb31ff Andy McCall 1 month ago ↶
304
assert! ( r.body.contains ( " authenticator" ) , " second step asked: {}" , r.body) ;
1b3a0098 Andy McCall 1 month ago ↶
305
306
307
let form = & r.body[ r.body.find ( " <form class=\" card\" method=\" post\" action=\" /login\" >" ) .unwrap ( ) .. ] ;
308
let form = & form[ .. form.find ( " </form>" ) .unwrap ( ) ] ;
309
let names: Vec< & str > = form.split ( " name=\" " ) .skip ( 1 ) .map ( | t | t.split ( ' "' ) .next ( ) .unwrap ( ) ) .collect ( ) ;
310
let mut unique = names.clone ( ) ;
311
unique.sort ( ) ;
312
unique.dedup ( ) ;
313
assert_eq! ( unique.len ( ) , names.len ( ) , " a field named twice: {names:?}" ) ;
bff9106e Andy McCall 23 days ago ↶
314
315
316
assert! ( ! names.contains ( & " password" ) , " {names:?}" ) ;
317
let pending = form.split ( " name=\" pending\" value=\" " ) .nth ( 1 ) .unwrap ( ) .split ( ' "' ) .next ( ) .unwrap ( ) .to_string ( ) ;
19bb31ff Andy McCall 1 month ago ↶
318
let code = oxygenhub:: auth:: totp:: current( & secret, ( db:: now_ms( ) / 1000 ) as u64 ) .unwrap ( ) ;
bff9106e Andy McCall 23 days ago ↶
319
let fill = | pending : & str | -> String {
320
names
321
.iter ( )
322
.map ( | n | match * n {
323
" username" => " username=andy" .to_string ( ) ,
324
" pending" => format! ( " pending={pending} " ) ,
325
" code" => format! ( " code={code} " ) ,
326
other => format! ( " {other} =" ) ,
327
} )
328
.collect:: < Vec< _ > > ( )
329
.join ( " &" )
330
} ;
331
332
let r = post ( app, " /login" , None , & fill ( " 1.forged" ) ) .await;
333
assert! ( r.body.contains ( " sign in again" ) & & r.cookie.is_none ( ) , " {}" , r.body) ;
334
let r = post ( app, " /login" , None , & fill ( & pending) ) .await;
19bb31ff Andy McCall 1 month ago ↶
335
assert_eq! ( r.status, StatusCode:: SEE_OTHER , " {}" , r.body) ;
336
assert! ( r.cookie.is_some ( ) ) ;
337
assert! ( r.location.as_deref ( ) = = Some ( " /" ) ) ;
338
}
a160b98f Andy McCall 1 month ago ↶
339
340
341
fn picture_form ( csrf : & str , action : & str , file : & [u8 ]) -> ( String, Vec< u8 > ) {
342
let boundary = " picture-boundary-1234" ;
343
let mut body = Vec :: new( ) ;
344
body.extend_from_slice ( format! ( " --{boundary} \r \n Content-Disposition: form-data; name=\" _csrf\" \r \n \r \n {csrf} \r \n " ) .as_bytes ( ) ) ;
345
body.extend_from_slice ( format! ( " --{boundary} \r \n Content-Disposition: form-data; name=\" action\" \r \n \r \n {action} \r \n " ) .as_bytes ( ) ) ;
346
body.extend_from_slice (
347
format! ( " --{boundary} \r \n Content-Disposition: form-data; name=\" picture\" ; filename=\" me.png\" \r \n Content-Type: image/png\r \n \r \n " )
348
.as_bytes ( ) ,
349
) ;
350
body.extend_from_slice ( file) ;
351
body.extend_from_slice ( format! ( " \r \n --{boundary} --\r \n " ) .as_bytes ( ) ) ;
352
( format! ( " multipart/form-data; boundary={boundary} " ) , body)
353
}
354
355
async fn upload ( app : & Shared, path : & str , cookie : & str , csrf : & str , action : & str , file : & [u8 ]) -> Res {
356
let ( ct, body) = picture_form ( csrf, action, file) ;
357
let req = Request:: post( path)
358
.header ( header:: COOKIE , format! ( " oxyhub={cookie} " ) )
359
.header ( header:: CONTENT_TYPE , ct)
360
.body ( Body:: from( body) )
361
.unwrap ( ) ;
362
let res = oxygenhub:: router( app.clone ( ) ) .oneshot ( req) .await.unwrap ( ) ;
363
let status = res.status ( ) ;
364
let bytes = res.into_body ( ) .collect ( ) .await.unwrap ( ) .to_bytes ( ) ;
83940d53 Andy McCall 1 month ago ↶
365
Res { status, body: String :: from_utf8_lossy( & bytes) .into_owned ( ) , cookie: None , location: None , raw_cookies: vec! [ ] }
a160b98f Andy McCall 1 month ago ↶
366
}
367
368
369
async fn picture ( app : & Shared, name : & str ) -> ( StatusCode, String, Vec< u8 > ) {
370
let res = oxygenhub:: router( app.clone ( ) ) .oneshot ( Request:: get( format! ( " /avatar/{name} " ) ) .body ( Body:: empty( ) ) .unwrap ( ) ) .await.unwrap ( ) ;
371
let ct = res.headers ( ) .get ( header:: CONTENT_TYPE ) .and_then ( | v | v.to_str ( ) .ok ( ) ) .unwrap_or ( " " ) .to_string ( ) ;
372
let status = res.status ( ) ;
373
( status, ct, res.into_body ( ) .collect ( ) .await.unwrap ( ) .to_bytes ( ) .to_vec ( ) )
374
}
375
376
fn png ( w : u32 , h : u32 , alpha : bool ) -> Vec< u8 > {
377
let img = if alpha {
378
image:: DynamicImage:: ImageRgba8( image:: RgbaImage:: from_fn( w, h, | x , _| {
379
image:: Rgba( [ 0 , 114 , 255 , if x % 2 = = 0 { 0 } else { 255 } ] )
380
} ) )
381
} else {
382
image:: DynamicImage:: ImageRgb8( image:: RgbImage:: from_fn( w, h, | x , y | image:: Rgb( [ x as u8 , y as u8 , 128 ] ) ) )
383
} ;
384
let mut out = Vec :: new( ) ;
385
img.write_to ( & mut std:: io:: Cursor:: new( & mut out) , image:: ImageFormat:: Png) .unwrap ( ) ;
386
out
387
}
388
389
# [ tokio ::test ( flavor = " multi_thread" ) ]
390
async fn profile_pictures_and_logos ( ) {
391
let h = hub ( ) .await;
392
let app = & h.app;
393
let andy = register ( app, " andy" , " " ) .await;
394
assert_eq! ( picture ( app, " andy" ) .await.1 , " image/svg+xml" , " the identicon first" ) ;
395
let csrf = csrf_of ( & get ( app, " /settings" , Some ( & andy) ) .await.body) ;
396
assert! ( get ( app, " /settings" , Some ( & andy) ) .await.body.contains ( " id=\" picture\" " ) ) ;
397
398
399
let r = upload ( app, " /settings/avatar" , & andy, & csrf, " upload" , & png ( 640 , 480 , false ) ) .await;
400
assert! ( r.body.contains ( " Picture saved" ) , " {}" , r.body) ;
401
let ( st, ct, bytes) = picture ( app, " andy" ) .await;
402
assert_eq! ( ( st, ct.as_str ( ) ) , ( StatusCode:: OK , " image/jpeg" ) ) ;
403
let img = image:: load_from_memory( & bytes) .unwrap ( ) ;
404
assert_eq! ( ( img.width ( ) , img.height ( ) ) , ( 400 , 400 ) ) ;
405
let stored = db:: accounts:: account_by_name( & app.db, " andy" ) .await.unwrap ( ) .unwrap ( ) .avatar;
406
assert! ( stored.starts_with ( " jpeg:" ) , " {stored}" ) ;
407
408
409
let r = upload (
410
app,
411
" /settings/avatar" ,
412
& andy,
413
& csrf,
414
" upload" ,
415
b " <svg xmlns=\" http://www.w3.org/2000/svg\" ><script>alert(1)</script></svg>" ,
416
)
417
.await;
418
assert! ( r.body.contains ( " not a PNG, JPEG or WebP" ) , " {}" , r.body) ;
419
let r = upload ( app, " /settings/avatar" , & andy, " wrong" , " upload" , & png ( 10 , 10 , false ) ) .await;
420
assert_ne! ( r.status, StatusCode:: OK ) ;
421
assert_eq! ( picture ( app, " andy" ) .await.1 , " image/jpeg" ) ;
422
423
let r = upload ( app, " /settings/avatar" , & andy, & csrf, " remove" , b " " ) .await;
424
assert! ( r.body.contains ( " Picture removed" ) ) ;
425
assert_eq! ( picture ( app, " andy" ) .await.1 , " image/svg+xml" ) ;
426
427
428
let me = db:: accounts:: account_by_name( & app.db, " andy" ) .await.unwrap ( ) .unwrap ( ) ;
429
db:: accounts:: create_org( & app.db, " acme" , " Acme" , me.id) .await.unwrap ( ) ;
430
let org_csrf = csrf_of ( & get ( app, " /orgs/acme/settings" , Some ( & andy) ) .await.body) ;
431
let r = upload ( app, " /orgs/acme/settings/avatar" , & andy, & org_csrf, " upload" , & png ( 120 , 120 , true ) ) .await;
432
assert! ( r.body.contains ( " Picture saved" ) , " {}" , r.body) ;
433
let ( _ , ct, bytes) = picture ( app, " acme" ) .await;
434
assert_eq! ( ct, " image/png" ) ;
435
assert! ( image:: load_from_memory( & bytes) .unwrap ( ) .color ( ) .has_alpha ( ) ) ;
436
let invite = {
437
let admin = get ( app, " /admin" , Some ( & andy) ) .await;
438
let r = post ( app, " /admin/invite" , Some ( & andy) , & format! ( " _csrf={} " , csrf_of ( & admin.body) ) ) .await;
439
let i = r.body.find ( " invite=" ) .map ( | i | i + 7 ) .expect ( " an invitation" ) ;
440
r.body[ i.. ] .chars ( ) .take_while ( | c | c.is_ascii_alphanumeric ( ) ) .collect:: < String> ( )
441
} ;
442
let bob = register ( app, " bob" , & invite) .await;
443
let bob_csrf = csrf_of ( & get ( app, " /settings" , Some ( & bob) ) .await.body) ;
444
let r = upload ( app, " /orgs/acme/settings/avatar" , & bob, & bob_csrf, " remove" , b " " ) .await;
445
assert_eq! ( r.status, StatusCode:: NOT_FOUND , " not an owner" ) ;
446
assert_eq! ( picture ( app, " acme" ) .await.1 , " image/png" ) ;
447
}
8fb35df7 Andy McCall 1 month ago ↶
448
449
# [ tokio ::test ( flavor = " multi_thread" ) ]
450
async fn organisation_webhooks_and_storage ( ) {
451
let h = hub ( ) .await;
452
let app = & h.app;
453
let andy = register ( app, " andy" , " " ) .await;
454
let me = db:: accounts:: account_by_name( & app.db, " andy" ) .await.unwrap ( ) .unwrap ( ) ;
455
let org = db:: accounts:: create_org( & app.db, " acme" , " Acme" , me.id) .await.unwrap ( ) ;
456
ff656953 Andy McCall 1 month ago ↶
457
458
459
460
let alone = db:: repos:: create( & app.db, " acme" , " alone" , " " , hub_api:: Visibility:: Public) .await.unwrap ( ) ;
461
let solo = db:: repos:: create( & app.db, " andy" , " solo" , " " , hub_api:: Visibility:: Public) .await.unwrap ( ) ;
462
assert_eq! ( db:: proposals:: requirements( & app.db, alone.id) .await.unwrap ( ) .required_approvals, 0 ) ;
463
assert_eq! ( db:: proposals:: requirements( & app.db, solo.id) .await.unwrap ( ) .required_approvals, 0 ) ;
464
let bobby = db:: accounts:: create_account( & app.db, " bobby" , " [email protected] " , " x" , " salt" , false ) .await.unwrap ( ) ;
465
db:: accounts:: set_org_member( & app.db, org.id, bobby.id, " member" ) .await.unwrap ( ) ;
462f88d1 Andy McCall 1 month ago ↶
466
let tools = db:: repos:: create( & app.db, " acme" , " tools" , " " , hub_api:: Visibility:: Public) .await.unwrap ( ) ;
467
assert_eq! ( db:: proposals:: requirements( & app.db, tools.id) .await.unwrap ( ) .required_approvals, 1 ) ;
468
8fb35df7 Andy McCall 1 month ago ↶
469
470
let page = get ( app, " /orgs/acme/settings" , Some ( & andy) ) .await;
471
assert! ( page.body.contains ( " id=\" webhooks\" " ) & & page.body.contains ( " href=\" #webhooks\" " ) ) ;
472
let csrf = csrf_of ( & page.body) ;
473
let r = post ( app, " /orgs/acme/settings/hooks" , Some ( & andy) , & format! ( " _csrf={csrf} &url=ftp%3A%2F%2Fx&events=*" ) ) .await;
474
assert! ( r.body.contains ( " starts with http" ) , " {}" , r.body) ;
475
let r = post (
476
app,
477
" /orgs/acme/settings/hooks" ,
478
Some ( & andy) ,
479
& format! ( " _csrf={csrf} &url=https%3A%2F%2Fhooks.invalid%2Facme&secret=s&events=push" ) ,
480
)
481
.await;
482
assert! ( r.body.contains ( " Webhook added" ) ) ;
483
let hooks = db:: hooks:: hooks_for_org( & app.db, org.id) .await.unwrap ( ) ;
484
assert_eq! ( hooks.len ( ) , 1 ) ;
485
let id = hooks[ 0 ] .id;
486
assert! ( post ( app, " /orgs/acme/settings/hooks" , Some ( & andy) , & format! ( " _csrf={csrf} &id={id} &action=ping" ) )
487
.await
488
.body
489
.contains ( " Ping queued" ) ) ;
490
let deliveries = get ( app, & format! ( " /orgs/acme/settings/hooks/{id} /deliveries" ) , Some ( & andy) ) .await;
491
assert_eq! ( deliveries.status, StatusCode:: OK ) ;
492
assert! ( deliveries.body.contains ( " >ping<" ) & & deliveries.body.contains ( " /orgs/acme/settings#webhooks" ) ) ;
493
494
let repo = oxygenhub:: repos:: RepoName:: parse( " andy/demo" ) .unwrap ( ) ;
495
app.repos.create ( & repo, None ) .unwrap ( ) ;
496
let row = db:: repos:: create( & app.db, " andy" , " demo" , " " , hub_api:: Visibility:: Public) .await.unwrap ( ) ;
497
let repo_hook = db:: hooks:: create_hook( & app.db, Some ( row.id) , None , " https://hooks.invalid/repo" , " " , " *" ) .await.unwrap ( ) .id;
498
post ( app, " /orgs/acme/settings/hooks" , Some ( & andy) , & format! ( " _csrf={csrf} &id={repo_hook} &action=delete" ) ) .await;
499
assert! ( db:: hooks:: hook( & app.db, repo_hook) .await.unwrap ( ) .is_some ( ) , " not the organisation's" ) ;
500
assert_eq! ( get ( app, & format! ( " /orgs/acme/settings/hooks/{repo_hook} /deliveries" ) , Some ( & andy) ) .await.status, StatusCode:: NOT_FOUND ) ;
501
502
let rs = get ( app, " /andy/demo/settings" , Some ( & andy) ) .await;
503
assert! ( rs.body.contains ( " https://hooks.invalid/repo" ) & & rs.body.contains ( " action=\" /andy/demo/settings/hooks\" " ) ) ;
504
505
506
let work = tempfile:: tempdir( ) .unwrap ( ) ;
507
let mut client = Repo:: init( work.path ( ) ) .unwrap ( ) ;
508
client.config.set ( " remote.origin.url" , & format! ( " {} /andy/demo" , h.base) ) .unwrap ( ) ;
509
let ( token, hash) = oxygenhub:: auth:: new_token( ) ;
510
db:: accounts:: create_token( & app.db, me.id, " push" , & hash, " repo:write" , " " , None ) .await.unwrap ( ) ;
511
client.config.set ( " remote.origin.token" , & token) .unwrap ( ) ;
512
client.save_config ( ) .unwrap ( ) ;
513
write ( work.path ( ) , " a.txt" , b " a\n " ) ;
514
client.snapshot ( " t" ) .unwrap ( ) ;
515
client.commit ( " one" , & [ ] ) .unwrap ( ) ;
516
assert! ( client.push ( " origin" , false ) .unwrap ( ) .iter ( ) .all ( | r | r.ok ) ) ;
517
let storage = get ( app, " /admin/storage" , Some ( & andy) ) .await;
518
assert_eq! ( storage.status, StatusCode:: OK ) ;
519
assert! ( storage.body.contains ( " href=\" /andy/demo\" " ) ) ;
520
let loose_before = std:: fs:: read_dir( app.repos.dir ( & repo) .join ( " .oxy/objects/loose" ) ) .map ( | d | d.count ( ) ) .unwrap_or ( 0 ) ;
521
assert! ( loose_before > 0 , " the push left loose objects" ) ;
522
let r = post ( app, " /admin/storage" , Some ( & andy) , & format! ( " _csrf={} &repo=andy%2Fdemo" , csrf_of ( & storage.body) ) ) .await;
523
assert! ( r.body.contains ( " Maintained 1 repository" ) & & r.body.contains ( " loose objects packed" ) , " {}" , r.body) ;
524
let loose_after: usize = walk_files ( & app.repos.dir ( & repo) .join ( " .oxy/objects/loose" ) ) ;
525
assert_eq! ( loose_after, 0 , " packed" ) ;
526
let audit = db:: accounts:: audit_rows( & app.db, 20 ) .await.unwrap ( ) ;
527
assert! ( audit.iter ( ) .any ( | a | a.action = = " admin.gc" & & a.target = = " andy/demo" ) ) ;
528
let r = post ( app, " /admin/storage" , Some ( & andy) , & format! ( " _csrf={} &repo=nobody%2Fnone" , csrf_of ( & storage.body) ) ) .await;
529
assert! ( r.body.contains ( " No repository nobody/none" ) ) ;
530
}
531
532
fn walk_files ( dir : & Path) -> usize {
533
let Ok ( rd) = std:: fs:: read_dir( dir) else { return 0 } ;
534
rd.flatten ( ) .map ( | e | if e.path ( ) .is_dir ( ) { walk_files ( & e.path ( ) ) } else { 1 } ) .sum ( )
535
}
6a178055 Andy McCall 1 month ago ↶
536
537
538
539
540
# [ tokio ::test ( flavor = " multi_thread" ) ]
541
async fn admins_disable_enable_and_delete_accounts ( ) {
542
let h = hub ( ) .await;
543
let app = & h.app;
544
let andy = register ( app, " andy" , " " ) .await;
545
let invite = | app : Shared, andy : String| async move {
546
let csrf = csrf_of ( & get ( & app, " /admin" , Some ( & andy) ) .await.body) ;
547
let r = post ( & app, " /admin/invite" , Some ( & andy) , & format! ( " _csrf={csrf} " ) ) .await;
548
r.body.split ( " register?invite=" ) .nth ( 1 ) .unwrap ( ) .split ( ' <' ) .next ( ) .unwrap ( ) .trim ( ) .to_string ( )
549
} ;
550
let bob = register ( app, " bob" , & invite ( app.clone ( ) , andy.clone ( ) ) .await) .await;
551
let act = | who : & 'static str , action : & 'static str , confirm : & 'static str | {
552
let ( app, andy) = ( app.clone ( ) , andy.clone ( ) ) ;
553
async move {
554
let csrf = csrf_of ( & get ( & app, " /admin" , Some ( & andy) ) .await.body) ;
555
post ( & app, " /admin/account" , Some ( & andy) , & format! ( " _csrf={csrf} &username={who} &action={action} &confirm={confirm} " ) ) .await.body
556
}
557
} ;
558
let login = | user : & 'static str | {
559
let app = app.clone ( ) ;
c8684d44 Andy McCall 20 days ago
560
async move { post ( & app, " /login" , None , & format! ( " username={user} &password=correct-horse-battery&setup=test-setup" ) ) .await }
6a178055 Andy McCall 1 month ago ↶
561
} ;
562
563
564
act ( " bob" , " disable" , " " ) .await;
565
assert! ( db:: accounts:: account_by_name( & app.db, " bob" ) .await.unwrap ( ) .unwrap ( ) .disabled) ;
566
let r = login ( " bob" ) .await;
567
assert! ( r.body.contains ( " Your account has been disabled" ) , " {}" , r.body) ;
568
assert! ( r.cookie.is_none ( ) ) ;
569
assert_eq! ( get ( app, " /settings" , Some ( & bob) ) .await.status, StatusCode:: SEE_OTHER , " the session is refused" ) ;
570
assert! ( act ( " andy" , " disable" , " " ) .await.contains ( " Not on yourself" ) ) ;
571
572
act ( " bob" , " enable" , " " ) .await;
573
assert_eq! ( login ( " bob" ) .await.status, StatusCode:: SEE_OTHER ) ;
574
575
576
let bob = login ( " bob" ) .await.cookie.unwrap ( ) ;
577
let csrf = csrf_of ( & get ( app, " /new" , Some ( & bob) ) .await.body) ;
578
assert_eq! (
579
post ( app, " /new" , Some ( & bob) , & format! ( " _csrf={csrf} &owner=bob&name=keep&visibility=public" ) ) .await.status,
580
StatusCode:: SEE_OTHER
581
) ;
582
assert! ( act ( " bob" , " delete" , " bo" ) .await.contains ( " type bob to confirm" ) ) ;
583
let page = act ( " bob" , " delete" , " bob" ) .await;
584
assert! ( page.contains ( " still owns bob/keep" ) , " {page}" ) ;
585
assert! ( db:: accounts:: account_by_name( & app.db, " bob" ) .await.unwrap ( ) .is_some ( ) ) ;
586
let row = db:: repos:: get( & app.db, " bob" , " keep" ) .await.unwrap ( ) .unwrap ( ) ;
587
db:: repos:: delete( & app.db, row.id) .await.unwrap ( ) ;
588
let page = act ( " bob" , " delete" , " bob" ) .await;
589
assert! ( page.contains ( " bob deleted" ) , " {page}" ) ;
590
assert! ( db:: accounts:: account_by_name( & app.db, " bob" ) .await.unwrap ( ) .is_none ( ) ) ;
591
assert_eq! ( get ( app, " /settings" , Some ( & bob) ) .await.status, StatusCode:: SEE_OTHER ) ;
592
593
let code = invite ( app.clone ( ) , andy.clone ( ) ) .await;
c8684d44 Andy McCall 20 days ago
594
let r = post (
595
app,
596
" /register" ,
597
None ,
598
& format! ( " username=bob&email=b%40example.com&password=correct-horse-battery&setup=test-setup&invite={code} " ) ,
599
)
600
.await;
6a178055 Andy McCall 1 month ago ↶
601
assert! ( r.body.contains ( " That name is taken" ) , " {}" , r.body) ;
602
603
assert! ( act ( " andy" , " delete" , " andy" ) .await.contains ( " Not on yourself" ) ) ;
604
}
5dead1f4 Andy McCall 1 month ago ↶
605
606
607
608
# [ tokio ::test ( flavor = " multi_thread" ) ]
609
async fn admins_set_the_job_time_limits ( ) {
610
let h = hub ( ) .await;
611
let app = & h.app;
612
let andy = register ( app, " andy" , " " ) .await;
613
let page = get ( app, " /admin/runners/settings" , Some ( & andy) ) .await;
614
assert_eq! ( page.status, StatusCode:: OK ) ;
615
assert! ( page.body.contains ( " Job time limits" ) & & page.body.contains ( " value=\" 60\" " ) , " the hub.toml default shows" ) ;
616
let csrf = csrf_of ( & page.body) ;
617
let r = post ( app, " /admin/runners/settings" , Some ( & andy) , & format! ( " _csrf={csrf} &default_minutes=200&max_minutes=120&grace_minutes=5" ) )
618
.await;
619
assert! ( r.body.contains ( " cannot be longer than the maximum" ) , " {}" , r.body) ;
620
let r = post ( app, " /admin/runners/settings" , Some ( & andy) , & format! ( " _csrf={csrf} &default_minutes=90&max_minutes=240&grace_minutes=10" ) )
621
.await;
622
assert! ( r.body.contains ( " Saved." ) , " {}" , r.body) ;
623
let l = oxygenhub:: flow:: limits( app) .await;
624
assert_eq! ( ( l.default_s, l.max_s, l.grace_ms) , ( 5400 , 14400 , 600_000 ) ) ;
625
assert! ( get ( app, " /admin/runners" , Some ( & andy) ) .await.body.contains ( " href=\" /admin/runners/settings\" " ) ) ;
626
let code = {
627
let csrf = csrf_of ( & get ( app, " /admin" , Some ( & andy) ) .await.body) ;
628
let r = post ( app, " /admin/invite" , Some ( & andy) , & format! ( " _csrf={csrf} " ) ) .await;
629
r.body.split ( " register?invite=" ) .nth ( 1 ) .unwrap ( ) .split ( ' <' ) .next ( ) .unwrap ( ) .trim ( ) .to_string ( )
630
} ;
631
let bob = register ( app, " bob" , & code) .await;
632
assert_ne! ( get ( app, " /admin/runners/settings" , Some ( & bob) ) .await.status, StatusCode:: OK , " not for others" ) ;
633
}
3556d08e Andy McCall 1 month ago ↶
634
635
636
637
# [ tokio ::test ( flavor = " multi_thread" ) ]
638
async fn mail_settings_are_saved_masked_and_tested_from_the_admin_page ( ) {
639
let h = hub ( ) .await;
640
let app = & h.app;
641
let andy = register ( app, " andy" , " " ) .await;
642
let page = get ( app, " /admin" , Some ( & andy) ) .await.body;
643
assert! ( page.contains ( " Mail is off" ) , " {page}" ) ;
644
let csrf = csrf_of ( & page) ;
645
646
let outbox = tempfile:: tempdir( ) .unwrap ( ) ;
647
let r = post (
648
app,
649
" /admin/mail" ,
650
Some ( & andy) ,
651
& format! ( " _csrf={csrf} &smtp=file%3A%2F%2F{} &from=Hub+%3Chub%40example.org%3E" , outbox.path ( ) .display ( ) ) ,
652
)
653
.await;
654
assert! ( r.body.contains ( " Mail settings saved" ) & & r.body.contains ( " Mail is on" ) , " {}" , r.body) ;
655
assert! ( oxygenhub:: mail:: enabled( app) ) ;
656
let r = post ( app, " /admin/mail/test" , Some ( & andy) , & format! ( " _csrf={csrf} " ) ) .await;
657
assert! ( r.body.contains ( " Sent a test message to" ) , " {}" , r.body) ;
658
let sent: Vec< _ > = std:: fs:: read_dir( outbox.path ( ) ) .unwrap ( ) .flatten ( ) .collect ( ) ;
659
assert_eq! ( sent.len ( ) , 1 ) ;
660
let text = std:: fs:: read_to_string( sent[ 0 ] .path ( ) ) .unwrap ( ) ;
661
assert! ( text.contains ( " Subject: [" ) & & text.contains ( " Test message" ) & & text.contains ( " From: Hub <[email protected] >" ) , " {text}" ) ;
662
663
let r = post (
664
app,
665
" /admin/mail" ,
666
Some ( & andy) ,
667
& format! ( " _csrf={csrf} &smtp=smtps%3A%2F%2Fhub%3Asecret%40mail.example.org%3A465&from=Hub+%3Chub%40example.org%3E" ) ,
668
)
669
.await;
2a83f530 Andy McCall 1 month ago ↶
670
assert! ( r.body.contains ( " smtps://hub:***@mail.example.org:465" ) & & ! r.body.contains ( " hub:secret" ) , " {}" , r.body) ;
3556d08e Andy McCall 1 month ago ↶
671
let r = post (
672
app,
673
" /admin/mail" ,
674
Some ( & andy) ,
675
& format! ( " _csrf={csrf} &smtp=smtps%3A%2F%2Fhub%3A***%40mail.example.org%3A465&from=Hub+%3Chub%40example.org%3E" ) ,
676
)
677
.await;
678
assert! ( r.body.contains ( " Mail settings saved" ) , " {}" , r.body) ;
679
assert_eq! ( oxygenhub:: mail:: settings( app) .smtp, " smtps://hub:[email protected] :465" ) ;
680
681
let r = post ( app, " /admin/mail" , Some ( & andy) , & format! ( " _csrf={csrf} &smtp=mail.example.org&from=x" ) ) .await;
682
assert! ( r.body.contains ( " starts with smtp://" ) , " {}" , r.body) ;
683
let r = post ( app, " /admin/mail" , Some ( & andy) , & format! ( " _csrf={csrf} &smtp=&from=" ) ) .await;
684
assert! ( r.body.contains ( " Mail is off" ) , " {}" , r.body) ;
685
assert! ( ! oxygenhub:: mail:: enabled( app) ) ;
686
687
let r = post ( app, " /admin/mail" , None , & format! ( " _csrf={csrf} &smtp=&from=" ) ) .await;
688
assert_ne! ( r.status, StatusCode:: OK ) ;
689
}
e71846aa Andy McCall 1 month ago ↶
690
691
692
693
async fn mock_assistant ( ) -> ( String, std:: sync:: Arc< std:: sync:: Mutex< Vec< String> > > ) {
694
use std:: sync:: { Arc, Mutex} ;
695
let seen: Arc< Mutex< Vec< String> > > = Arc:: new( Mutex:: new( vec! [ ] ) ) ;
696
let s2 = seen.clone ( ) ;
697
let router = axum:: Router:: new( ) .route (
698
" /v1/chat/completions" ,
699
axum:: routing:: post( move | headers: axum:: http:: HeaderMap, body: String | {
700
let s2 = s2.clone ( ) ;
701
async move {
702
let auth = headers.get ( " authorization" ) .and_then ( | v | v.to_str ( ) .ok ( ) ) .unwrap_or ( " " ) .to_string ( ) ;
703
s2.lock ( ) .unwrap ( ) .push ( format! ( " {auth} {body} " ) ) ;
704
axum:: Json( serde_json:: json! ( { " choices" : [ { " message" : { " role" : " assistant" , " content" : " OK from mock" } } ] } ) )
705
}
706
} ) ,
707
) ;
708
let listener = tokio:: net:: TcpListener:: bind( " 127.0.0.1:0" ) .await.unwrap ( ) ;
709
let port = listener.local_addr ( ) .unwrap ( ) .port ( ) ;
710
tokio:: spawn( async move { axum:: serve( listener, router) .await.unwrap ( ) } ) ;
711
( format! ( " http://127.0.0.1:{port} /v1/chat/completions" ) , seen)
712
}
713
714
fn enc ( s : & str ) -> String {
715
s.replace ( ' %' , " %25" ) .replace ( ' :' , " %3A" ) .replace ( ' /' , " %2F" ) .replace ( ' ' , " +" ) .replace ( ' \n ' , " %0A" ) .replace ( ' &' , " %26" )
716
}
717
718
# [ tokio ::test ( flavor = " multi_thread" ) ]
719
async fn ai_assistants_are_kept_per_level_with_sealed_tokens_and_combined_downwards ( ) {
720
use oxygenhub:: assistants:: { self , Scope} ;
721
let h = hub ( ) .await;
722
let app = & h.app;
723
let andy = register ( app, " andy" , " " ) .await;
724
let ( endpoint, seen) = mock_assistant ( ) .await;
725
726
727
let page = get ( app, " /admin" , Some ( & andy) ) .await.body;
728
assert! ( page.contains ( " AI assistants" ) & & page.contains ( " Add an assistant" ) , " {page}" ) ;
729
let csrf = csrf_of ( & page) ;
730
let add = | name : & str , extra : & str | {
731
format! (
732
" _csrf={csrf} &action=add&name={name} &endpoint={} &api=chat-completions&model=claude-opus-5-5&token=sk-test-secret&enabled=1&budget=50000{extra} " ,
733
enc ( & endpoint)
734
)
735
} ;
736
let r = post ( app, " /admin/assistants" , Some ( & andy) , & add ( " Claude" , " " ) ) .await;
206c1fc1 Andy McCall 1 month ago ↶
737
assert! ( r.body.contains ( " Claude added and answered: OK from mock" ) & & r.body.contains ( " token set" ) , " {}" , r.body) ;
e71846aa Andy McCall 1 month ago ↶
738
assert! ( ! r.body.contains ( " sk-test-secret" ) , " the token never comes back" ) ;
739
let site = assistants:: list( & app.db, Scope:: Site) .await.unwrap ( ) ;
740
assert_eq! ( site.len ( ) , 1 ) ;
741
let claude = & site[ 0 ] ;
742
assert! ( ! String :: from_utf8_lossy( & claude.token) .contains ( " sk-test" ) , " sealed at rest" ) ;
743
assert_eq! ( app.assistant_key.open ( & claude.token) .as_deref ( ) , Some ( " sk-test-secret" ) ) ;
744
745
let r = post ( app, " /admin/assistants" , Some ( & andy) , & add ( " claude" , " " ) ) .await;
746
assert! ( r.body.contains ( " already an assistant called" ) , " {}" , r.body) ;
747
let r = post (
748
app,
749
" /admin/assistants" ,
750
Some ( & andy) ,
751
& format! ( " _csrf={csrf} &action=add&name=Bad&endpoint=ftp%3A%2F%2Fx&api=chat-completions&model=m" ) ,
752
)
753
.await;
754
assert! ( r.body.contains ( " starting with https://" ) , " {}" , r.body) ;
755
756
757
let r = post ( app, " /admin/assistants" , Some ( & andy) , & format! ( " _csrf={csrf} &action=test&id={} " , claude.id) ) .await;
758
assert! ( r.body.contains ( " Claude answered: OK from mock" ) , " {}" , r.body) ;
759
{
760
let calls = seen.lock ( ) .unwrap ( ) ;
206c1fc1 Andy McCall 1 month ago ↶
761
assert_eq! ( calls.len ( ) , 2 , " once on add, once on test" ) ;
e71846aa Andy McCall 1 month ago ↶
762
assert! ( calls[ 0 ] .starts_with ( " Bearer sk-test-secret " ) , " {}" , calls[ 0 ] ) ;
763
assert! ( calls[ 0 ] .contains ( " \" model\" :\" claude-opus-5-5\" " ) & & calls[ 0 ] .contains ( " Reply with the single word OK" ) , " {}" , calls[ 0 ] ) ;
764
}
765
766
let r = post (
767
app,
768
" /admin/assistants" ,
769
Some ( & andy) ,
770
& 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) ) ,
771
)
772
.await;
773
assert! ( r.body.contains ( " Claude updated." ) & & r.body.contains ( " context 60000 characters" ) , " {}" , r.body) ;
774
assert_eq! (
775
app.assistant_key.open ( & assistants:: get( & app.db, claude.id) .await.unwrap ( ) .unwrap ( ) .token) .as_deref ( ) ,
776
Some ( " sk-test-secret" )
777
) ;
778
779
let r =
780
post ( app, " /admin/assistants" , Some ( & andy) , & format! ( " _csrf={csrf} &action=prompt&prompt={} " , enc ( " Review for correctness first." ) ) )
781
.await;
782
assert! ( r.body.contains ( " Prompt saved." ) & & r.body.contains ( " Review for correctness first." ) , " {}" , r.body) ;
783
784
785
let r = post ( app, " /orgs/new" , Some ( & andy) , & format! ( " _csrf={csrf} &name=acme&display_name=Acme" ) ) .await;
786
assert_eq! ( r.status, StatusCode:: SEE_OTHER , " {}" , r.body) ;
787
let r = post ( app, " /new" , Some ( & andy) , & format! ( " _csrf={csrf} &owner=acme&name=plans&visibility=public" ) ) .await;
788
assert_eq! ( r.status, StatusCode:: SEE_OTHER , " {}" , r.body) ;
789
let org_page = get ( app, " /orgs/acme/settings" , Some ( & andy) ) .await.body;
790
assert! ( org_page.contains ( " Inherited" ) & & org_page.contains ( " from the site" ) & & org_page.contains ( " None of its own" ) , " {org_page}" ) ;
791
assert! ( org_page.contains ( " From the site" ) & & org_page.contains ( " Review for correctness first." ) , " {org_page}" ) ;
792
let repo = db:: repos:: get( & app.db, " acme" , " plans" ) .await.unwrap ( ) .unwrap ( ) ;
793
let on = assistants:: enabled_for_repo( & app.db, & repo) .await.unwrap ( ) ;
794
assert_eq! ( on.iter ( ) .map ( | a | a.name.as_str ( ) ) .collect:: < Vec< _ > > ( ) , [ " Claude" ] ) ;
795
796
797
798
let r = post (
799
app,
800
" /orgs/acme/settings/assistants" ,
801
Some ( & andy) ,
802
& format! ( " _csrf={csrf} &action=prompt&prompt={} " , enc ( " We write 6502 assembly." ) ) ,
803
)
804
.await;
805
assert! ( r.body.contains ( " Prompt saved." ) , " {}" , r.body) ;
806
let r = post ( app, " /orgs/acme/settings/assistants" , Some ( & andy) , & add ( " DeepSeek" , " " ) ) .await;
206c1fc1 Andy McCall 1 month ago ↶
807
assert! ( r.body.contains ( " DeepSeek added and answered" ) , " {}" , r.body) ;
e71846aa Andy McCall 1 month ago ↶
808
let prompt = assistants:: prompt_for_repo( & app.db, & repo) .await.unwrap ( ) ;
809
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}" ) ;
810
let repo_page = get ( app, " /acme/plans/settings" , Some ( & andy) ) .await.body;
811
assert! ( repo_page.contains ( " from the acme organisation" ) & & repo_page.contains ( " From the acme organisation" ) , " {repo_page}" ) ;
812
let on = assistants:: enabled_for_repo( & app.db, & repo) .await.unwrap ( ) ;
813
assert_eq! ( on.iter ( ) .map ( | a | a.name.as_str ( ) ) .collect:: < Vec< _ > > ( ) , [ " Claude" , " DeepSeek" ] ) ;
814
815
816
817
818
let r = post ( app, " /admin/assistants" , Some ( & andy) , & format! ( " _csrf={csrf} &action=toggle&id={} " , claude.id) ) .await;
819
assert! ( r.body.contains ( " Claude is off." ) , " {}" , r.body) ;
820
let r = post ( app, " /acme/plans/settings/assistants" , Some ( & andy) , & add ( " Claude" , " " ) ) .await;
206c1fc1 Andy McCall 1 month ago ↶
821
assert! ( r.body.contains ( " Claude added and answered" ) , " {}" , r.body) ;
e71846aa Andy McCall 1 month ago ↶
822
let on = assistants:: enabled_for_repo( & app.db, & repo) .await.unwrap ( ) ;
823
assert_eq! ( on.iter ( ) .map ( | a | a.name.as_str ( ) ) .collect:: < Vec< _ > > ( ) , [ " DeepSeek" ] ) ;
824
let repo_page = get ( app, " /acme/plans/settings" , Some ( & andy) ) .await.body;
825
assert! ( repo_page.contains ( " off above" ) , " {repo_page}" ) ;
826
post ( app, " /admin/assistants" , Some ( & andy) , & format! ( " _csrf={csrf} &action=toggle&id={} " , claude.id) ) .await;
827
let on = assistants:: enabled_for_repo( & app.db, & repo) .await.unwrap ( ) ;
828
assert_eq! ( on.iter ( ) .map ( | a | ( a.name.as_str ( ) , a.scope.as_str ( ) ) ) .collect:: < Vec< _ > > ( ) , [ ( " DeepSeek" , " org" ) , ( " Claude" , " repo" ) ] ) ;
829
830
831
let invite = {
832
let r = post ( app, " /admin/invite" , Some ( & andy) , & format! ( " _csrf={csrf} " ) ) .await;
833
let i = r.body.find ( " invite=" ) .map ( | i | i + 7 ) .expect ( " an invitation" ) ;
834
r.body[ i.. ] .chars ( ) .take_while ( | c | c.is_ascii_alphanumeric ( ) ) .collect:: < String> ( )
835
} ;
836
let bob = register ( app, " bob" , & invite) .await;
837
assert_eq! (
838
post ( app, " /admin/assistants" , Some ( & bob) , & format! ( " _csrf={csrf} &action=prompt&prompt=x" ) ) .await.status,
839
StatusCode:: NOT_FOUND
840
) ;
841
assert_eq! (
842
post ( app, " /orgs/acme/settings/assistants" , Some ( & bob) , & format! ( " _csrf={csrf} &action=prompt&prompt=x" ) ) .await.status,
843
StatusCode:: NOT_FOUND
844
) ;
845
assert_eq! (
846
post ( app, " /acme/plans/settings/assistants" , Some ( & bob) , & format! ( " _csrf={csrf} &action=prompt&prompt=x" ) ) .await.status,
847
StatusCode:: NOT_FOUND
848
) ;
849
850
let r = post ( app, " /acme/plans/settings/delete" , Some ( & andy) , & format! ( " _csrf={csrf} &confirm=acme%2Fplans" ) ) .await;
851
assert! ( r.status = = StatusCode:: SEE_OTHER | | r.status = = StatusCode:: OK , " {} {}" , r.status, r.body) ;
852
assert! ( assistants:: list( & app.db, Scope:: Repo( repo.id) ) .await.unwrap ( ) .is_empty ( ) ) ;
853
}
89e89836 Andy McCall 1 month ago ↶
854
855
# [ tokio ::test ( flavor = " multi_thread" ) ]
856
async fn accounts_elsewhere_are_checked_and_shown_on_the_profile ( ) {
857
let h = hub ( ) .await;
858
let app = & h.app;
859
let andy = register ( app, " andy" , " " ) .await;
860
let page = get ( app, " /settings" , Some ( & andy) ) .await.body;
861
let csrf = csrf_of ( & page) ;
862
let base = format! ( " _csrf={csrf} &display_name=Andy&email=andy%40example.com&bio=&website=&location=" ) ;
863
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;
864
assert! (
865
r.body.contains ( " value=\" andy.bsky.social\" " )
866
& & r.body.contains ( " value=\" @[email protected] \" " )
867
& & r.body.contains ( " value=\" andymccall\" " ) ,
868
" {}" ,
869
r.body
870
) ;
871
let profile = get ( app, " /andy" , None ) .await.body;
872
assert! ( profile.contains ( " href=\" https://bsky.app/profile/andy.bsky.social\" " ) & & profile.contains ( " @andy.bsky.social" ) , " {profile}" ) ;
873
assert! ( profile.contains ( " href=\" https://fosstodon.org/@andy\" " ) & & profile.contains ( " rel=\" nofollow noopener me\" " ) , " {profile}" ) ;
874
assert! ( profile.contains ( " href=\" https://github.com/andymccall\" " ) & & profile.contains ( " title=\" GitHub\" " ) , " {profile}" ) ;
875
876
let r = post ( app, " /settings" , Some ( & andy) , & format! ( " {base} &bluesky=not+a+handle&mastodon=&github=" ) ) .await;
877
assert! ( r.body.contains ( " not a Bluesky handle" ) , " {}" , r.body) ;
878
assert! ( get ( app, " /andy" , None ) .await.body.contains ( " andy.bsky.social" ) ) ;
879
880
post ( app, " /settings" , Some ( & andy) , & format! ( " {base} &bluesky=&mastodon=&github=" ) ) .await;
881
assert! ( ! get ( app, " /andy" , None ) .await.body.contains ( " class=\" social\" " ) ) ;
83940d53 Andy McCall 1 month ago ↶
882
}
883
884
885
async fn mock_provider ( ) -> ( String, std:: sync:: Arc< std:: sync:: Mutex< Vec< String> > > ) {
886
use std:: sync:: { Arc, Mutex} ;
887
let seen: Arc< Mutex< Vec< String> > > = Arc:: new( Mutex:: new( vec! [ ] ) ) ;
888
let s1 = seen.clone ( ) ;
889
let s2 = seen.clone ( ) ;
890
let router = axum:: Router:: new( )
891
.route (
892
" /github/token" ,
893
axum:: routing:: post( move | body: String | {
894
let s1 = s1.clone ( ) ;
895
async move {
896
s1.lock ( ) .unwrap ( ) .push ( format! ( " github token {body} " ) ) ;
897
axum:: Json( serde_json:: json! ( { " access_token" : " gh-token" , " token_type" : " bearer" } ) )
898
}
899
} ) ,
900
)
901
.route (
902
" /github/userinfo" ,
903
axum:: routing:: get( | headers : axum:: http:: HeaderMap| async move {
904
assert_eq! ( headers.get ( " authorization" ) .and_then ( | v | v.to_str ( ) .ok ( ) ) , Some ( " Bearer gh-token" ) ) ;
905
axum:: Json( serde_json:: json! ( { " id" : 583231 , " login" : " octocat" , " name" : " The Octocat" , " email" : null } ) )
906
} ) ,
907
)
908
.route (
909
" /github/userinfo/emails" ,
910
axum:: routing:: get( | | async {
911
axum:: Json( serde_json:: json! ( [
912
{ " email" : " [email protected] " , " primary" : false , " verified" : false } ,
913
{ " email" : " [email protected] " , " primary" : true , " verified" : true }
914
] ) )
915
} ) ,
916
)
917
.route (
918
" /google/token" ,
919
axum:: routing:: post( move | body: String | {
920
let s2 = s2.clone ( ) ;
921
async move {
922
s2.lock ( ) .unwrap ( ) .push ( format! ( " google token {body} " ) ) ;
923
axum:: Json( serde_json:: json! ( { " access_token" : " g-token" , " id_token" : " x" } ) )
924
}
925
} ) ,
926
)
927
.route (
810896d4 Andy McCall 22 days ago ↶
928
" /gitlab/token" ,
929
axum:: routing:: post( | | async { axum:: Json( serde_json:: json! ( { " access_token" : " gl-token" , " token_type" : " Bearer" } ) ) } ) ,
930
)
931
.route (
932
" /gitlab/userinfo" ,
933
axum:: routing:: get( | headers : axum:: http:: HeaderMap| async move {
934
assert_eq! ( headers.get ( " authorization" ) .and_then ( | v | v.to_str ( ) .ok ( ) ) , Some ( " Bearer gl-token" ) ) ;
935
axum:: Json( serde_json:: json! ( { " sub" : " 1234567" , " nickname" : " tanuki" , " name" : " The Tanuki" , " email" : " [email protected] " , " email_verified" : true } ) )
936
} ) ,
937
)
938
.route (
83940d53 Andy McCall 1 month ago ↶
939
" /google/userinfo" ,
940
axum:: routing:: get( | | async {
941
axum:: Json( serde_json:: json! ( { " sub" : " 1076915035000615071" , " email" : " [email protected] " , " email_verified" : true , " name" : " Andy McCall" } ) )
942
} ) ,
943
) ;
944
let listener = tokio:: net:: TcpListener:: bind( " 127.0.0.1:0" ) .await.unwrap ( ) ;
945
let port = listener.local_addr ( ) .unwrap ( ) .port ( ) ;
946
tokio:: spawn( async move { axum:: serve( listener, router) .await.unwrap ( ) } ) ;
947
( format! ( " http://127.0.0.1:{port} " ) , seen)
948
}
949
950
fn state_of ( res : & Res) -> ( String, String) {
951
let cookie = res.raw_cookies.iter ( ) .find ( | c | c.starts_with ( " oxyhub_signin=" ) ) .expect ( " state cookie" ) .clone ( ) ;
952
let value = cookie.trim_start_matches ( " oxyhub_signin=" ) .split ( ' ;' ) .next ( ) .unwrap ( ) .to_string ( ) ;
953
( cookie, value)
954
}
955
956
# [ tokio ::test ( flavor = " multi_thread" ) ]
957
async fn people_sign_in_with_github_or_google_once_an_admin_sets_it_up ( ) {
958
hermetic ( ) ;
959
let ( mock, seen) = mock_provider ( ) .await;
960
let dir = tempfile:: tempdir( ) .unwrap ( ) ;
961
let mut config = Config:: load( dir.path ( ) ) .unwrap ( ) ;
c8684d44 Andy McCall 20 days ago
962
963
config.registration.setup_code = " test-setup" .into ( ) ;
83940d53 Andy McCall 1 month ago ↶
964
let listener = tokio:: net:: TcpListener:: bind( " 127.0.0.1:0" ) .await.unwrap ( ) ;
965
let port = listener.local_addr ( ) .unwrap ( ) .port ( ) ;
966
config.server.base_url = format! ( " http://127.0.0.1:{port} " ) ;
967
config.registration.mode = " open" .into ( ) ;
968
config.auth.signin_mock = Some ( mock.clone ( ) ) ;
969
let app = oxygenhub:: build( config) .await.unwrap ( ) ;
970
let router = oxygenhub:: router( app.clone ( ) ) ;
971
tokio:: spawn( async move { axum:: serve( listener, router) .await.unwrap ( ) } ) ;
972
let app = & app;
973
let andy = register ( app, " andy" , " " ) .await;
974
let andy_row = db:: accounts:: account_by_name( & app.db, " andy" ) .await.unwrap ( ) .unwrap ( ) ;
975
db:: accounts:: set_email_verified( & app.db, andy_row.id, true ) .await.unwrap ( ) ;
976
977
978
assert! ( ! get ( app, " /login" , None ) .await.body.contains ( " /login/github" ) ) ;
979
let admin = get ( app, " /admin" , Some ( & andy) ) .await.body;
980
assert! ( admin.contains ( " Sign in with" ) & & admin.contains ( & format! ( " http://127.0.0.1:{port} /login/github/callback" ) ) , " {admin}" ) ;
981
let csrf = csrf_of ( & admin) ;
982
let r =
983
post ( app, " /admin/signin" , Some ( & andy) , & format! ( " _csrf={csrf} &provider=github&client_id=gh-cid&secret=gh-sek&enabled=1" ) ) .await;
984
assert! ( r.body.contains ( " Signing in with GitHub is on." ) & & ! r.body.contains ( " gh-sek" ) , " {}" , r.body) ;
985
let r = post ( app, " /admin/signin" , Some ( & andy) , & format! ( " _csrf={csrf} &provider=google&client_id=g-cid&secret=g-sek&enabled=1" ) ) .await;
986
assert! ( r.body.contains ( " Signing in with Google is on." ) , " {}" , r.body) ;
987
let login = get ( app, " /login" , None ) .await.body;
988
assert! ( login.contains ( " href=\" /login/github\" " ) & & login.contains ( " href=\" /login/google\" " ) , " {login}" ) ;
989
990
991
let r = get ( app, " /login/github" , None ) .await;
992
assert_eq! ( r.status, StatusCode:: SEE_OTHER ) ;
993
let to = r.location.clone ( ) .unwrap ( ) ;
994
assert! ( to.starts_with ( & format! ( " {mock} /github/authorize?client_id=gh-cid&redirect_uri=" ) ) & & to.contains ( " &state=" ) , " {to}" ) ;
995
let ( cookie, state) = state_of ( & r) ;
996
let bad = send_with ( app, " GET" , " /login/github/callback?code=c1&state=wrong" , Some ( & cookie) , None ) .await;
997
assert! ( bad.location.as_deref ( ) .unwrap_or ( " " ) .starts_with ( " /login?error=" ) , " {:?}" , bad.location) ;
998
let ok = send_with ( app, " GET" , & format! ( " /login/github/callback?code=c1&state={state} " ) , Some ( & cookie) , None ) .await;
999
assert_eq! ( ok.status, StatusCode:: SEE_OTHER , " {}" , ok.body) ;
1000
assert_eq! ( ok.location.as_deref ( ) , Some ( " /" ) ) ;
1001
let octo_cookie = ok.cookie.clone ( ) .expect ( " signed in" ) ;
1002
let octo = db:: accounts:: account_by_name( & app.db, " octocat" ) .await.unwrap ( ) .expect ( " account made from the GitHub login" ) ;
1003
assert! (
1004
( octo.email.as_str ( ) , octo.email_verified, octo.display_name.as_str ( ) ) = = ( " [email protected] " , true , " The Octocat" ) ,
1005
" {octo:?}"
1006
) ;
1007
assert! ( seen.lock ( ) .unwrap ( ) .iter ( ) .any ( | s | s.contains ( " github token" ) & & s.contains ( " code=c1" ) & & s.contains ( " client_secret=gh-sek" ) ) ) ;
1008
assert! ( get ( app, " /settings" , Some ( & octo_cookie) ) .await.body.contains ( " octocat" ) ) ;
1009
1010
let r = get ( app, " /login/github" , None ) .await;
1011
let ( cookie, state) = state_of ( & r) ;
1012
let ok = send_with ( app, " GET" , & format! ( " /login/github/callback?code=c2&state={state} " ) , Some ( & cookie) , None ) .await;
1013
assert_eq! ( ok.location.as_deref ( ) , Some ( " /" ) ) ;
1014
assert_eq! ( db:: accounts:: account_count( & app.db) .await.unwrap ( ) , 2 ) ;
810896d4 Andy McCall 22 days ago ↶
1015
1016
1017
1018
let r = post (
1019
app,
1020
" /admin/signin" ,
1021
Some ( & andy) ,
1022
& format! ( " _csrf={csrf} &provider=gitlab&client_id=gl-cid&secret=gl-sek&enabled=1&base=http%3A%2F%2Fgitlab.example.org" ) ,
1023
)
1024
.await;
1025
assert! ( r.body.contains ( " is an https:// address" ) , " {}" , r.body) ;
1026
let r = post (
1027
app,
1028
" /admin/signin" ,
1029
Some ( & andy) ,
1030
& format! ( " _csrf={csrf} &provider=gitlab&client_id=gl-cid&secret=gl-sek&enabled=1&base=https%3A%2F%2Fgitlab.example.org%2F" ) ,
1031
)
1032
.await;
1033
assert! ( r.body.contains ( " Signing in with GitLab is on." ) & & r.body.contains ( " value=\" https://gitlab.example.org\" " ) , " {}" , r.body) ;
1034
let r = post (
1035
app,
1036
" /admin/signin" ,
1037
Some ( & andy) ,
1038
& format! ( " _csrf={csrf} &provider=github&client_id=gh-cid&enabled=1&base=https%3A%2F%2Fx.org" ) ,
1039
)
1040
.await;
1041
assert! ( r.body.contains ( " GitHub has no address to set." ) , " {}" , r.body) ;
1042
let login = get ( app, " /login" , None ) .await.body;
1043
assert! ( login.contains ( " signin-btn gitlab" ) & & login.contains ( " Sign in with GitLab" ) , " {login}" ) ;
1044
let r = get ( app, " /login/gitlab" , None ) .await;
1045
assert! ( r.location.clone ( ) .unwrap ( ) .contains ( " scope=openid%20profile%20email" ) , " {:?}" , r.location) ;
1046
let ( cookie, state) = state_of ( & r) ;
1047
let ok = send_with ( app, " GET" , & format! ( " /login/gitlab/callback?code=c9&state={state} " ) , Some ( & cookie) , None ) .await;
1048
assert_eq! ( ok.location.as_deref ( ) , Some ( " /" ) , " {}" , ok.body) ;
1049
let tanuki = db:: accounts:: account_by_name( & app.db, " tanuki" ) .await.unwrap ( ) .expect ( " account made from the GitLab username" ) ;
1050
assert! ( ( tanuki.email.as_str ( ) , tanuki.email_verified) = = ( " [email protected] " , true ) , " {tanuki:?}" ) ;
1051
assert_eq! ( db:: accounts:: account_count( & app.db) .await.unwrap ( ) , 3 ) ;
83940d53 Andy McCall 1 month ago ↶
1052
1053
1054
let r = get ( app, " /login/google" , None ) .await;
1055
assert! ( r.location.clone ( ) .unwrap ( ) .contains ( " prompt=select_account" ) ) ;
1056
let ( cookie, state) = state_of ( & r) ;
1057
let ok = send_with ( app, " GET" , & format! ( " /login/google/callback?code=c3&state={state} " ) , Some ( & cookie) , None ) .await;
1058
assert_eq! ( ok.location.as_deref ( ) , Some ( " /" ) , " {}" , ok.body) ;
1059
let as_andy = ok.cookie.clone ( ) .unwrap ( ) ;
1060
let sec = get ( app, " /settings/security" , Some ( & as_andy) ) .await.body;
1061
assert! (
1062
sec.contains ( " Connected accounts" )
1063
& & sec.contains ( " Google: <strong>andy</strong>" )
1064
& & sec.contains ( " GitHub: <a href=\" /settings/security/connect/github\" >connect</a>" ) ,
1065
" {sec}"
1066
) ;
810896d4 Andy McCall 22 days ago ↶
1067
assert_eq! ( db:: accounts:: account_count( & app.db) .await.unwrap ( ) , 3 , " no fourth account" ) ;
83940d53 Andy McCall 1 month ago ↶
1068
1069
let csrf_s = csrf_of ( & sec) ;
1070
let r = post ( app, " /settings/security/disconnect/google" , Some ( & as_andy) , & format! ( " _csrf={csrf_s} " ) ) .await;
1071
assert! ( r.location.as_deref ( ) .unwrap_or ( " " ) .contains ( " notice=Google%20disconnected" ) , " {:?}" , r.location) ;
1072
assert! ( ! get ( app, " /settings/security" , Some ( & as_andy) ) .await.body.contains ( " Google: <strong>andy</strong>" ) ) ;
1073
let r = get ( app, " /settings/security/connect/google" , Some ( & as_andy) ) .await;
1074
assert_eq! ( r.status, StatusCode:: SEE_OTHER ) ;
1075
let ( cookie, state) = state_of ( & r) ;
1076
let both = format! ( " {cookie} ; oxyhub={as_andy} " ) ;
1077
let ok = send_with ( app, " GET" , & format! ( " /login/google/callback?code=c4&state={state} " ) , Some ( & both) , None ) .await;
1078
assert! ( ok.location.as_deref ( ) .unwrap_or ( " " ) .contains ( " notice=Google%20connected%20as%20andy" ) , " {:?} {}" , ok.location, ok.body) ;
1079
1080
1081
db:: accounts:: set_setting( & app.db, " registration_mode" , " closed" ) .await.unwrap ( ) ;
1082
sqlx:: query( " DELETE FROM account_logins WHERE provider = 'github'" ) .execute ( & app.db) .await.unwrap ( ) ;
3b1bc27f Andy McCall 21 days ago
1083
sqlx:: query( " UPDATE accounts SET email_verified = FALSE WHERE username = 'octocat'" ) .execute ( & app.db) .await.unwrap ( ) ;
83940d53 Andy McCall 1 month ago ↶
1084
let r = get ( app, " /login/github" , None ) .await;
1085
let ( cookie, state) = state_of ( & r) ;
1086
let no = send_with ( app, " GET" , & format! ( " /login/github/callback?code=c5&state={state} " ) , Some ( & cookie) , None ) .await;
1087
assert! ( no.location.as_deref ( ) .unwrap_or ( " " ) .contains ( " registration%20is%20closed" ) , " {:?}" , no.location) ;
1088
assert! ( no.cookie.is_none ( ) ) ;
89e89836 Andy McCall 1 month ago ↶
1089
}
e8e7158b Andy McCall 1 month ago ↶
1090
1091
# [ tokio ::test ( flavor = " multi_thread" ) ]
1092
async fn administration_is_sectioned_and_its_audit_log_pages ( ) {
1093
let h = hub ( ) .await;
1094
let app = & h.app;
1095
let andy = register ( app, " andy" , " " ) .await;
1096
let andy_row = db:: accounts:: account_by_name( & app.db, " andy" ) .await.unwrap ( ) .unwrap ( ) ;
1097
for i in 0 .. 60 {
1098
db:: accounts:: audit( & app.db, Some ( & andy_row) , " test.event" , & format! ( " t{i} " ) , " " ) .await.unwrap ( ) ;
1099
}
1100
let page = get ( app, " /admin" , Some ( & andy) ) .await.body;
1101
for id in [ " accounts" , " registration" , " organisations" , " signin" , " mail" , " variables" , " secrets" , " assistants" , " audit" ] {
1102
assert! ( page.contains ( & format! ( " href=\" #{id} \" " ) ) & & page.contains ( & format! ( " id=\" {id} \" " ) ) , " section {id}: {page}" ) ;
1103
}
1104
assert! ( page.contains ( " class=\" section-nav\" " ) ) ;
1105
assert! ( page.contains ( " >t59<" ) & & ! page.contains ( " >t5<" ) , " the newest page only" ) ;
1106
let i = page.find ( " audit_before=" ) .expect ( " an older link" ) + " audit_before=" .len ( ) ;
1107
let before: String = page[ i.. ] .chars ( ) .take_while ( | c | c.is_ascii_digit ( ) ) .collect ( ) ;
1108
let older = get ( app, & format! ( " /admin?audit_before={before} " ) , Some ( & andy) ) .await.body;
1109
assert! ( older.contains ( " >t5<" ) & & ! older.contains ( " >t59<" ) , " the next page: {older}" ) ;
1110
}
155cab14 Andy McCall 1 month ago ↶
1111
1112
# [ tokio ::test ( flavor = " multi_thread" ) ]
1113
async fn adding_an_assistant_offers_providers_and_only_models_the_endpoint_lists ( ) {
1114
let h = hub ( ) .await;
1115
let app = & h.app;
1116
let andy = register ( app, " andy" , " " ) .await;
1117
let router = axum:: Router:: new( )
1118
.route (
1119
" /v1/models" ,
1120
axum:: routing:: get( | headers : axum:: http:: HeaderMap| async move {
1121
assert_eq! ( headers.get ( " authorization" ) .and_then ( | v | v.to_str ( ) .ok ( ) ) , Some ( " Bearer sk-m" ) ) ;
1122
axum:: Json( serde_json:: json! ( { " data" : [
1123
{ " id" : " models/gemini-3-pro" } , { " id" : " gemini-3-flash" } , { " id" : " text-embedding-004" }
1124
] } ) )
1125
} ) ,
1126
)
1127
.route (
1128
" /v1/chat/completions" ,
1129
axum:: routing:: post( | | async { axum:: Json( serde_json:: json! ( { " choices" : [ { " message" : { " content" : " OK" } } ] } ) ) } ) ,
1130
) ;
1131
let listener = tokio:: net:: TcpListener:: bind( " 127.0.0.1:0" ) .await.unwrap ( ) ;
1132
let port = listener.local_addr ( ) .unwrap ( ) .port ( ) ;
1133
tokio:: spawn( async move { axum:: serve( listener, router) .await.unwrap ( ) } ) ;
1134
let ep = format! ( " http%3A%2F%2F127.0.0.1%3A{port} %2Fv1%2Fchat%2Fcompletions" ) ;
1135
1136
let page = get ( app, " /admin" , Some ( & andy) ) .await.body;
1137
assert! ( page.contains ( " data-preset-select" ) & & page.contains ( " Google Gemini" ) & & page.contains ( " Other (enter the endpoint)" ) , " {page}" ) ;
1138
assert! ( page.contains ( " LIST MODELS" ) ) ;
1139
let csrf = csrf_of ( & page) ;
1140
1141
let r =
1142
post ( app, " /admin/assistants/models" , Some ( & andy) , & format! ( " _csrf={csrf} &endpoint={ep} &api=chat-completions&token=sk-m" ) ) .await;
1143
let v: serde_json:: Value = serde_json:: from_str( & r.body) .unwrap ( ) ;
1144
assert_eq! ( v[ " models" ] , serde_json:: json! ( [ " gemini-3-flash" , " gemini-3-pro" ] ) , " {}" , r.body) ;
1145
1146
let r = post (
1147
app,
1148
" /admin/assistants" ,
1149
Some ( & andy) ,
1150
& format! ( " _csrf={csrf} &action=add&preset=gemini&endpoint={ep} &model=gemini-2.5-pro&token=sk-m&enabled=1" ) ,
1151
)
1152
.await;
1153
assert! ( r.body.contains ( " gemini-2.5-pro is not a model this endpoint offers" ) & & r.body.contains ( " gemini-3-pro" ) , " {}" , r.body) ;
1154
1155
let r = post (
1156
app,
1157
" /admin/assistants" ,
1158
Some ( & andy) ,
1159
& format! ( " _csrf={csrf} &action=add&preset=gemini&endpoint={ep} &model=gemini-3-pro&token=sk-m&enabled=1" ) ,
1160
)
1161
.await;
1162
assert! ( r.body.contains ( " Gemini added and answered: OK" ) , " {}" , r.body) ;
1163
let a = & oxygenhub:: assistants:: list( & app.db, oxygenhub:: levels:: Scope:: Site) .await.unwrap ( ) [ 0 ] ;
1164
assert_eq! ( ( a.name.as_str ( ) , a.api.as_str ( ) , a.model.as_str ( ) ) , ( " Gemini" , " chat-completions" , " gemini-3-pro" ) ) ;
1165
1166
let r =
1167
post ( app, " /admin/assistants/models" , Some ( & andy) , & format! ( " _csrf={csrf} &id={} &endpoint={ep} &api=chat-completions" , a.id) ) .await;
1168
assert! ( r.body.contains ( " gemini-3-flash" ) , " {}" , r.body) ;
1169
}
2a0c09c0 Andy McCall 21 days ago ↶
1170
1171
1172
1173
1174
1175
# [ tokio ::test ( flavor = " multi_thread" ) ]
1176
async fn a_profile_shows_the_year_as_a_signal ( ) {
1177
let h = hub ( ) .await;
1178
let app = & h.app;
1179
register ( app, " andy" , " " ) .await;
1180
let andy = db:: accounts:: account_by_name( & app.db, " andy" ) .await.unwrap ( ) .unwrap ( ) ;
c0095389 Andy McCall 21 days ago ↶
1181
3b1bc27f Andy McCall 21 days ago
1182
sqlx:: query( " UPDATE accounts SET created_ms = $1 WHERE id = $2" )
a2fbad48 Andy McCall 21 days ago ↶
1183
.bind ( db:: now_ms( ) - 400 * 86_400_000 )
1184
.bind ( andy.id)
1185
.execute ( & app.db)
1186
.await
1187
.unwrap ( ) ;
2a0c09c0 Andy McCall 21 days ago ↶
1188
let game = db:: repos:: create( & app.db, " andy" , " game" , " " , hub_api:: Visibility:: Public) .await.unwrap ( ) ;
1189
let secret = db:: repos:: create( & app.db, " andy" , " secret" , " " , hub_api:: Visibility:: Private) .await.unwrap ( ) ;
1190
let now = db:: now_ms( ) ;
1191
for ( repo, ago_days, changes, reason, via) in [
1192
( game.id, 3 , 5 , " " , " " ) ,
1193
( game.id, 3 , 2 , " " , " Claude" ) ,
1194
( game.id, 40 , 4 , " " , " " ) ,
1195
( game.id, 2 , 9 , " landed #1 (web)" , " " ) ,
1196
( secret.id, 1 , 50 , " " , " " ) ,
1197
] {
3b1bc27f Andy McCall 21 days ago
1198
sqlx:: query( " INSERT INTO pushes (repo_id, at_ms, reference, ok, reason, changes, account_id, pusher, via) VALUES ($1, $2, 'main', TRUE, $3, $4, $5, 'andy', $6)" )
2a0c09c0 Andy McCall 21 days ago ↶
1199
.bind ( repo)
1200
.bind ( now - ago_days * 86_400_000 )
1201
.bind ( reason)
1202
.bind ( changes)
1203
.bind ( andy.id)
1204
.bind ( via)
1205
.execute ( & app.db)
1206
.await
1207
.unwrap ( ) ;
1208
}
3b1bc27f Andy McCall 21 days ago
1209
sqlx:: query( " INSERT INTO releases (repo_id, tag, title, author_id, author, created_ms) VALUES ($1, 'v1.0', 'One', $2, 'andy', $3)" )
2a0c09c0 Andy McCall 21 days ago ↶
1210
.bind ( game.id)
1211
.bind ( andy.id)
1212
.bind ( now - 3 * 86_400_000 )
1213
.execute ( & app.db)
1214
.await
1215
.unwrap ( ) ;
1216
1217
let page = get ( app, " /andy" , None ) .await.body;
1218
let start = page.find ( " id=\" pulse-data\" >" ) .expect ( " the pulse is on the profile" ) + " id=\" pulse-data\" >" .len ( ) ;
1219
let json: serde_json:: Value = serde_json:: from_str( & page[ start.. start + page[ start.. ] .find ( " </script>" ) .unwrap ( ) ] ) .unwrap ( ) ;
1220
assert_eq! ( json[ " repos" ] , serde_json:: json! ( [ " andy/game" ] ) ) ;
1221
let days = json[ " days" ] .as_array ( ) .unwrap ( ) ;
c0095389 Andy McCall 21 days ago ↶
1222
1223
assert_eq! ( ( days.len ( ) , json[ " months" ] .as_i64 ( ) ) , ( 91 , Some ( 3 ) ) ) ;
2a0c09c0 Andy McCall 21 days ago ↶
1224
let total: i64 = days.iter ( ) .map ( | d | d[ 0 ] .as_i64 ( ) .unwrap ( ) ) .sum ( ) ;
1225
let via: i64 = days.iter ( ) .map ( | d | d[ 1 ] .as_i64 ( ) .unwrap ( ) ) .sum ( ) ;
1226
assert_eq! ( ( total, via) , ( 11 , 2 ) , " 5 + 2 + 4 changes, 2 via an agent; the landing and the private push left out" ) ;
c0095389 Andy McCall 21 days ago ↶
1227
assert_eq! ( days[ 87 ] , serde_json:: json! ( [ 7 , 2 , [ [ 0 , 7 ] ] ] ) , " three days ago" ) ;
1228
assert_eq! ( json[ " peaks" ] [ 0 ] , serde_json:: json! ( [ 87 , " game v1.0" ] ) ) ;
a2fbad48 Andy McCall 21 days ago ↶
1229
db:: accounts:: create_account( & app.db, " bea" , " [email protected] " , " x" , " salt" , false ) .await.unwrap ( ) ;
1230
assert! ( get ( app, " /bea" , None ) .await.body.contains ( " No changes pushed" ) , " a new account with no pushes says so" ) ;
2a0c09c0 Andy McCall 21 days ago ↶
1231
assert! (
1232
page.contains ( " data-total=\" 11\" " ) & & page.find ( " pulse-box" ) .unwrap ( ) < page.find ( " <h2>Repositories</h2>" ) .unwrap ( ) ,
1233
" above the repositories"
1234
) ;
1235
}
9a8e3b44 Andy McCall 21 days ago ↶
1236
1237
1238
1239
1240
1241
# [ tokio ::test ( flavor = " multi_thread" ) ]
1242
async fn an_https_site_takes_nothing_over_http ( ) {
1243
hermetic ( ) ;
1244
let dir = tempfile:: tempdir( ) .unwrap ( ) ;
1245
let mut config = Config:: load( dir.path ( ) ) .unwrap ( ) ;
c8684d44 Andy McCall 20 days ago
1246
1247
config.registration.setup_code = " test-setup" .into ( ) ;
9a8e3b44 Andy McCall 21 days ago ↶
1248
config.server.base_url = " https://hub.example.org" .into ( ) ;
1249
config.registration.mode = " open" .into ( ) ;
1250
let app = oxygenhub:: build( config) .await.unwrap ( ) ;
1251
let call = | method : & 'static str , path : & 'static str , header : Option< ( & 'static str , & 'static str ) > , body : Option< & 'static str > | {
1252
let app = app.clone ( ) ;
1253
async move {
1254
let mut req = Request:: builder( ) .method ( method) .uri ( path) ;
1255
if let Some ( ( k, v) ) = header {
1256
req = req.header ( k, v) ;
1257
}
1258
if body.is_some ( ) {
1259
req = req.header ( header:: CONTENT_TYPE , " application/x-www-form-urlencoded" ) ;
1260
}
1261
oxygenhub:: router( app) .oneshot ( req.body ( body.map ( Body:: from) .unwrap_or_else ( Body:: empty) ) .unwrap ( ) ) .await.unwrap ( )
1262
}
1263
} ;
1264
1265
let r = call ( " GET" , " /login?next=%2Fx" , Some ( ( " x-forwarded-proto" , " http" ) ) , None ) .await;
1266
assert! ( r.status ( ) .is_redirection ( ) , " {}" , r.status ( ) ) ;
1267
assert_eq! ( r.headers ( ) .get ( header:: LOCATION ) .unwrap ( ) , " https://hub.example.org/login?next=%2Fx" ) ;
1268
1269
let r = call ( " POST" , " /login" , Some ( ( " cf-visitor" , r # "{"scheme":"http"}"# ) ) , Some ( " username=a&password=b" ) ) .await;
1270
assert_eq! ( r.status ( ) , StatusCode:: BAD_REQUEST ) ;
1271
1272
assert_eq! ( call ( " GET" , " /login" , Some ( ( " x-forwarded-proto" , " https" ) ) , None ) .await.status ( ) , StatusCode:: OK ) ;
1273
assert_eq! ( call ( " GET" , " /login" , None , None ) .await.status ( ) , StatusCode:: OK ) ;
1274
1275
let r = call (
1276
" POST" ,
1277
" /register" ,
1278
Some ( ( " x-forwarded-proto" , " https" ) ) ,
c8684d44 Andy McCall 20 days ago
1279
Some ( " username=andy&email=a%40example.org&password=correct-horse-battery&setup=test-setup" ) ,
9a8e3b44 Andy McCall 21 days ago ↶
1280
)
1281
.await;
1282
let cookie = r.headers ( ) .get ( header:: SET_COOKIE ) .expect ( " signed in" ) .to_str ( ) .unwrap ( ) ;
1283
assert! ( cookie.contains ( " ; Secure" ) & & cookie.contains ( " HttpOnly" ) , " {cookie}" ) ;
1284
}
aa0bfbd1 Andy McCall 21 days ago ↶
1285
1286
# [ tokio ::test ]
1287
async fn guessing_passwords_is_slowed_per_account_and_per_address ( ) {
1288
hermetic ( ) ;
1289
let dir = tempfile:: tempdir( ) .unwrap ( ) ;
1290
let mut config = Config:: load( dir.path ( ) ) .unwrap ( ) ;
c8684d44 Andy McCall 20 days ago
1291
1292
config.registration.setup_code = " test-setup" .into ( ) ;
aa0bfbd1 Andy McCall 21 days ago ↶
1293
config.server.behind_proxy = true ;
1294
config.registration.mode = " open" .into ( ) ;
1295
let app = oxygenhub:: build( config) .await.unwrap ( ) ;
1296
let from = | addr : & 'static str , form : String| {
1297
let app = app.clone ( ) ;
1298
async move {
1299
let req = Request:: builder( )
1300
.method ( " POST" )
1301
.uri ( " /login" )
1302
.header ( " x-forwarded-for" , addr)
1303
.header ( header:: CONTENT_TYPE , " application/x-www-form-urlencoded" ) ;
1304
let res = oxygenhub:: router( app) .oneshot ( req.body ( Body:: from( form) ) .unwrap ( ) ) .await.unwrap ( ) ;
1305
let status = res.status ( ) ;
1306
( status, String :: from_utf8_lossy( & res.into_body ( ) .collect ( ) .await.unwrap ( ) .to_bytes ( ) ) .into_owned ( ) )
1307
}
1308
} ;
1309
register ( & app, " andy" , " " ) .await;
c8684d44 Andy McCall 20 days ago
1310
let right = | | " username=andy&password=correct-horse-battery&setup=test-setup" .to_string ( ) ;
aa0bfbd1 Andy McCall 21 days ago ↶
1311
1312
1313
1314
for i in 0 .. 6 {
1315
let ( _ , body) = from ( " 198.51.100.1" , " username=andy&password=wrong" .into ( ) ) .await;
1316
assert! ( body.contains ( " Wrong username or password." ) , " try {i}" ) ;
1317
}
1318
let ( status, body) = from ( " 198.51.100.2" , right ( ) ) .await;
1319
assert_eq! ( status, StatusCode:: OK , " not signed in" ) ;
1320
assert! ( body.contains ( " Too many tries: try again in" ) , " {body}" ) ;
1321
let audit = db:: accounts:: audit_rows( & app.db, 50 ) .await.unwrap ( ) ;
1322
assert! ( audit.iter ( ) .any ( | r | r.action = = " login.throttled" & & r.target = = " andy" & & r.detail.contains ( " 198.51.100.1" ) ) ) ;
1323
app.throttle.lock ( ) .unwrap ( ) .cleared ( & oxygenhub:: throttle:: Key:: account( " login" , " andy" ) ) ;
1324
1325
1326
1327
for i in 0 .. 21 {
1328
let ( _ , body) = from ( " 203.0.113.7" , format! ( " username=nobody{i} &password=wrong" ) ) .await;
1329
assert! ( body.contains ( " Wrong username or password." ) , " try {i}" ) ;
1330
}
1331
let ( _ , body) = from ( " 203.0.113.7" , right ( ) ) .await;
1332
assert! ( body.contains ( " Too many tries" ) , " {body}" ) ;
1333
let ( status, _ ) = from ( " 198.51.100.3" , right ( ) ) .await;
1334
assert_eq! ( status, StatusCode:: SEE_OTHER , " another address signs in" ) ;
1335
}
d74ce22f Andy McCall 21 days ago ↶
1336
1337
# [ tokio ::test ]
1338
async fn every_page_carries_the_security_headers_and_violations_are_reported ( ) {
1339
let h = hub ( ) .await;
1340
let res = oxygenhub:: router( h.app.clone ( ) ) .oneshot ( Request:: get( " /login" ) .body ( Body:: empty( ) ) .unwrap ( ) ) .await.unwrap ( ) ;
1341
let hd = res.headers ( ) ;
1342
assert_eq! ( hd[ header:: X_CONTENT_TYPE_OPTIONS ] , " nosniff" ) ;
1343
assert_eq! ( hd[ header:: REFERRER_POLICY ] , " strict-origin-when-cross-origin" ) ;
1344
assert_eq! ( hd[ header:: X_FRAME_OPTIONS ] , " DENY" ) ;
1345
assert! ( hd[ " permissions-policy" ] .to_str ( ) .unwrap ( ) .contains ( " camera=()" ) ) ;
1346
1347
let csp = hd[ header:: CONTENT_SECURITY_POLICY_REPORT_ONLY ] .to_str ( ) .unwrap ( ) ;
1348
for part in [
1349
" default-src 'self'" ,
1350
" object-src 'none'" ,
1351
" frame-ancestors 'none'" ,
1352
" form-action 'self'" ,
1353
" 'wasm-unsafe-eval'" ,
1354
" report-uri /csp-report" ,
1355
] {
1356
assert! ( csp.contains ( part) , " {part} in {csp}" ) ;
1357
}
1358
assert! ( hd.get ( header:: CONTENT_SECURITY_POLICY ) .is_none ( ) & & hd.get ( header:: STRICT_TRANSPORT_SECURITY ) .is_none ( ) ) ;
1359
1360
let res = oxygenhub:: router( h.app.clone ( ) ) .oneshot ( Request:: get( " /no/such/page" ) .body ( Body:: empty( ) ) .unwrap ( ) ) .await.unwrap ( ) ;
1361
assert_eq! ( res.headers ( ) [ header:: X_CONTENT_TYPE_OPTIONS ] , " nosniff" ) ;
1362
1363
let report =
1364
r # "{"csp-report":{"document-uri":"http://127.0.0.1/login","violated-directive":"script-src-elem","blocked-uri":"inline"}}"# ;
1365
let req = Request:: post( " /csp-report" ) .header ( header:: CONTENT_TYPE , " application/csp-report" ) .body ( Body:: from( report) ) .unwrap ( ) ;
1366
assert_eq! ( oxygenhub:: router( h.app.clone ( ) ) .oneshot ( req) .await.unwrap ( ) .status ( ) , StatusCode:: NO_CONTENT ) ;
1367
let big = Request:: post( " /csp-report" ) .body ( Body:: from( vec! [ b ' ' ; 64 * 1024 ] ) ) .unwrap ( ) ;
1368
assert_eq! ( oxygenhub:: router( h.app.clone ( ) ) .oneshot ( big) .await.unwrap ( ) .status ( ) , StatusCode:: PAYLOAD_TOO_LARGE ) ;
1369
}
3dd6582d Andy McCall 21 days ago ↶
1370
1371
# [ tokio ::test ]
1372
async fn security_txt_names_the_contact_an_administrator_sets ( ) {
1373
let h = hub ( ) .await;
1374
let app = & h.app;
1375
assert_eq! ( get ( app, " /.well-known/security.txt" , None ) .await.status, StatusCode:: NOT_FOUND , " no contact, no file" ) ;
1376
let andy = register ( app, " andy" , " " ) .await;
1377
let page = get ( app, " /admin" , Some ( & andy) ) .await.body;
1378
assert! ( page.contains ( " No contact yet" ) , " the panel" ) ;
1379
let csrf = csrf_of ( & page) ;
1380
let set = | contact : & 'static str , policy : & 'static str | {
1381
let ( andy, csrf) = ( andy.clone ( ) , csrf.clone ( ) ) ;
1382
async move { post ( app, " /admin/security" , Some ( & andy) , & format! ( " _csrf={csrf} &contact={contact} &policy={policy} " ) ) .await }
1383
} ;
1384
assert_eq! ( set ( " javascript%3Aalert(1)" , " " ) .await.status, StatusCode:: BAD_REQUEST ) ;
1385
assert_eq! ( set ( " sec%40example.org" , " http%3A%2F%2Fexample.org" ) .await.status, StatusCode:: BAD_REQUEST , " policy over https only" ) ;
1386
let r = set ( " sec%40example.org" , " https%3A%2F%2Fexample.org%2Fpolicy" ) .await;
1387
assert! ( r.body.contains ( " Security contact saved" ) , " {}" , r.body) ;
1388
for path in [ " /.well-known/security.txt" , " /security.txt" ] {
1389
let r = get ( app, path, None ) .await;
1390
assert_eq! ( r.status, StatusCode:: OK ) ;
1391
assert! ( r.body.starts_with ( " Contact: mailto:[email protected] \n Expires: 20" ) , " {}" , r.body) ;
1392
assert! ( r.body.contains ( " Policy: https://example.org/policy\n " ) & & r.body.contains ( " /.well-known/security.txt\n " ) , " {}" , r.body) ;
1393
}
1394
1395
let r = post ( app, " /admin/invite" , Some ( & andy) , & format! ( " _csrf={csrf} " ) ) .await;
1396
let code = r.body.split ( " register?invite=" ) .nth ( 1 ) .unwrap ( ) .split ( ' <' ) .next ( ) .unwrap ( ) .trim ( ) .to_string ( ) ;
1397
let bob = register ( app, " bob" , & code) .await;
1398
let r = post ( app, " /admin/security" , Some ( & bob) , & format! ( " _csrf={csrf} &contact=x%40evil.example" ) ) .await;
1399
assert_ne! ( r.status, StatusCode:: OK ) ;
1400
1401
set ( " " , " " ) .await;
1402
assert_eq! ( get ( app, " /security.txt" , None ) .await.status, StatusCode:: NOT_FOUND ) ;
1403
}
0024ccb1 Andy McCall 21 days ago ↶
1404
1405
# [ tokio ::test ]
1406
async fn sessions_are_records_that_can_be_listed_ended_and_expire ( ) {
1407
let h = hub ( ) .await;
1408
let app = & h.app;
1409
let a = register ( app, " andy" , " " ) .await;
1410
assert_eq! ( a.len ( ) , 64 , " a random id, not the account's name" ) ;
c8684d44 Andy McCall 20 days ago
1411
let b =
1412
post ( app, " /login" , None , " username=andy&password=correct-horse-battery&setup=test-setup" ) .await.cookie.expect ( " signed in again" ) ;
0024ccb1 Andy McCall 21 days ago ↶
1413
assert_ne! ( a, b, " each sign-in is a new session" ) ;
1414
1415
let page = get ( app, " /settings/security" , Some ( & a) ) .await.body;
1416
assert_eq! ( page.matches ( " /end\" " ) .count ( ) , 2 , " both browsers listed" ) ;
1417
1418
let entries: Vec< & str > = page.split ( " <li><strong>" ) .skip ( 1 ) .filter ( | e | e.contains ( " /end\" " ) ) .collect ( ) ;
1419
assert_eq! ( entries.iter ( ) .filter ( | e | e.contains ( " <span class=\" tag\" >this browser</span>" ) ) .count ( ) , 1 ) ;
1420
let other = entries.iter ( ) .find ( | e | ! e.contains ( " class=\" tag\" >this browser" ) ) .unwrap ( ) ;
1421
let other: i64 = other.split ( " /settings/security/sessions/" ) .nth ( 1 ) .unwrap ( ) .split ( ' /' ) .next ( ) .unwrap ( ) .parse ( ) .unwrap ( ) ;
1422
let csrf = csrf_of ( & page) ;
1423
let r = post ( app, & format! ( " /settings/security/sessions/{other} /end" ) , Some ( & a) , & format! ( " _csrf={csrf} " ) ) .await;
1424
assert_eq! ( r.status, StatusCode:: SEE_OTHER ) ;
1425
let signed_in = | c : String| async move { get ( app, " /settings/security" , Some ( & c) ) .await.body.contains ( " SIGN OUT EVERYWHERE" ) } ;
1426
assert! ( signed_in ( a.clone ( ) ) .await, " this browser stays" ) ;
1427
assert! ( ! signed_in ( b.clone ( ) ) .await, " that browser is signed out" ) ;
1428
1429
1430
sqlx:: query( " UPDATE sessions SET seen_ms = seen_ms - 15 * 86400000" ) .execute ( & app.db) .await.unwrap ( ) ;
1431
assert! ( ! signed_in ( a.clone ( ) ) .await, " idle too long" ) ;
1432
1433
c8684d44 Andy McCall 20 days ago
1434
let c = post ( app, " /login" , None , " username=andy&password=correct-horse-battery&setup=test-setup" ) .await.cookie.unwrap ( ) ;
0024ccb1 Andy McCall 21 days ago ↶
1435
let csrf = csrf_of ( & get ( app, " /settings/security" , Some ( & c) ) .await.body) ;
1436
post ( app, " /logout" , Some ( & c) , & format! ( " _csrf={csrf} " ) ) .await;
1437
assert! ( ! signed_in ( c) .await, " the old cookie no longer works" ) ;
1438
1439
assert! ( ! signed_in ( " andy.0123456789abcdef" .into ( ) ) .await) ;
1440
}
3ff52534 Andy McCall 21 days ago ↶
1441
1442
# [ tokio ::test ]
1443
async fn an_invitation_signs_up_through_a_provider_once ( ) {
1444
hermetic ( ) ;
1445
let ( mock, _ ) = mock_provider ( ) .await;
1446
let dir = tempfile:: tempdir( ) .unwrap ( ) ;
1447
let mut config = Config:: load( dir.path ( ) ) .unwrap ( ) ;
c8684d44 Andy McCall 20 days ago
1448
1449
config.registration.setup_code = " test-setup" .into ( ) ;
3ff52534 Andy McCall 21 days ago ↶
1450
let listener = tokio:: net:: TcpListener:: bind( " 127.0.0.1:0" ) .await.unwrap ( ) ;
1451
let port = listener.local_addr ( ) .unwrap ( ) .port ( ) ;
1452
config.server.base_url = format! ( " http://127.0.0.1:{port} " ) ;
1453
config.registration.mode = " invite" .into ( ) ;
1454
config.auth.signin_mock = Some ( mock.clone ( ) ) ;
1455
let app = oxygenhub:: build( config) .await.unwrap ( ) ;
1456
let router = oxygenhub:: router( app.clone ( ) ) ;
1457
tokio:: spawn( async move { axum:: serve( listener, router) .await.unwrap ( ) } ) ;
1458
let app = & app;
1459
let andy = register ( app, " andy" , " " ) .await;
1460
let admin = get ( app, " /admin" , Some ( & andy) ) .await.body;
1461
let csrf = csrf_of ( & admin) ;
1462
post ( app, " /admin/signin" , Some ( & andy) , & format! ( " _csrf={csrf} &provider=github&client_id=gh-cid&secret=gh-sek&enabled=1" ) ) .await;
1463
1464
1465
assert! ( ! get ( app, " /register" , None ) .await.body.contains ( " /login/github" ) ) ;
1466
let r = get ( app, " /login/github" , None ) .await;
1467
let ( cookie, state) = state_of ( & r) ;
1468
let no = send_with ( app, " GET" , & format! ( " /login/github/callback?code=c1&state={state} " ) , Some ( & cookie) , None ) .await;
1469
assert! ( no.location.as_deref ( ) .unwrap_or ( " " ) .contains ( " by%20invitation" ) , " {:?}" , no.location) ;
1470
assert_eq! ( db:: accounts:: account_count( & app.db) .await.unwrap ( ) , 1 ) ;
1471
1472
1473
1474
let r = post ( app, " /admin/invite" , Some ( & andy) , & format! ( " _csrf={csrf} " ) ) .await;
1475
let code = r.body.split ( " register?invite=" ) .nth ( 1 ) .unwrap ( ) .split ( ' <' ) .next ( ) .unwrap ( ) .trim ( ) .to_string ( ) ;
1476
let page = get ( app, & format! ( " /register?invite={code} " ) , None ) .await.body;
1477
assert! ( page.contains ( & format! ( " href=\" /login/github?invite={code} \" " ) ) & & page.contains ( " Sign up with GitHub" ) , " {page}" ) ;
1478
let r = get ( app, & format! ( " /login/github?invite={code} " ) , None ) .await;
1479
assert_eq! ( r.status, StatusCode:: SEE_OTHER ) ;
1480
let ( cookie, state) = state_of ( & r) ;
1481
let ok = send_with ( app, " GET" , & format! ( " /login/github/callback?code=c2&state={state} " ) , Some ( & cookie) , None ) .await;
1482
assert_eq! ( ok.location.as_deref ( ) , Some ( " /" ) , " {}" , ok.body) ;
1483
let octo = db:: accounts:: account_by_name( & app.db, " octocat" ) .await.unwrap ( ) .expect ( " signed up through the invitation" ) ;
1484
assert! ( ! db:: accounts:: invitation_unused( & app.db, & code) .await.unwrap ( ) , " the invitation is spent" ) ;
1485
let used_by: Option< i64 > =
3b1bc27f Andy McCall 21 days ago
1486
sqlx:: query_scalar( " SELECT used_by FROM invitations WHERE code = $1" ) .bind ( & code) .fetch_one ( & app.db) .await.unwrap ( ) ;
3ff52534 Andy McCall 21 days ago ↶
1487
assert_eq! ( used_by, Some ( octo.id) ) ;
1488
1489
1490
assert! ( ! get ( app, & format! ( " /register?invite={code} " ) , None ) .await.body.contains ( " /login/github" ) ) ;
1491
let r = get ( app, & format! ( " /login/github?invite={code} " ) , None ) .await;
1492
assert_eq! ( r.status, StatusCode:: BAD_REQUEST ) ;
1493
assert! ( r.body.contains ( " That invitation has been used or does not exist." ) , " {}" , r.body) ;
1494
assert_eq! ( get ( app, " /login/github?invite=not-a.code" , None ) .await.status, StatusCode:: BAD_REQUEST ) ;
c8684d44 Andy McCall 20 days ago
1495
}
1496
1497
1498
1499
1500
# [ tokio ::test ( flavor = " multi_thread" ) ]
1501
async fn the_first_account_needs_the_setup_code_the_hub_made ( ) {
1502
hermetic ( ) ;
1503
let dir = tempfile:: tempdir( ) .unwrap ( ) ;
1504
let config = Config:: load( dir.path ( ) ) .unwrap ( ) ;
1505
let app = oxygenhub:: build( config) .await.unwrap ( ) ;
1506
let file = dir.path ( ) .join ( " setup-code" ) ;
1507
let code = std:: fs:: read_to_string( & file) .expect ( " an empty hub makes a setup code" ) .trim ( ) .to_string ( ) ;
1508
assert_eq! ( code.len ( ) , 24 ) ;
1509
# [ cfg ( unix ) ]
1510
{
1511
use std:: os:: unix:: fs:: PermissionsExt;
1512
assert_eq! ( std:: fs:: metadata( & file) .unwrap ( ) .permissions ( ) .mode ( ) & 0o777 , 0o600 ) ;
1513
}
1514
let page = get ( & app, " /register" , None ) .await.body;
1515
assert! ( page.contains ( " name=\" setup\" " ) & & ! page.contains ( & code) , " asks for the code, never shows it" ) ;
1516
1517
let form = | setup : & str | format! ( " username=mallory&email=m%40example.com&password=correct-horse-battery&setup={setup} " ) ;
1518
for wrong in [ " " , " 0123456789abcdef01234567" ] {
1519
let r = post ( & app, " /register" , None , & form ( wrong) ) .await;
1520
assert! ( r.cookie.is_none ( ) & & r.body.contains ( " needs the setup code" ) , " {}" , r.body) ;
1521
}
1522
assert_eq! ( db:: accounts:: account_count( & app.db) .await.unwrap ( ) , 0 ) ;
1523
1524
let r = post ( & app, " /register" , None , & form ( & code) .replace ( " mallory" , " andy" ) ) .await;
1525
assert_eq! ( r.status, StatusCode:: SEE_OTHER , " {}" , r.body) ;
1526
assert! ( db:: accounts:: account_by_name( & app.db, " andy" ) .await.unwrap ( ) .unwrap ( ) .is_admin) ;
1527
assert! ( ! file.exists ( ) , " the code is spent" ) ;
1528
1529
let r = post ( & app, " /register" , None , & form ( & code) ) .await;
1530
assert! ( r.body.contains ( " invitation" ) , " {}" , r.body) ;
3ff52534 Andy McCall 21 days ago ↶
1531
}