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 19 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 20 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
c7067c7b Andy McCall 16 days ago
101
102
103
async fn settled ( app : & Shared, r : Res, cookie : Option< & str > , page : & str ) -> Res {
104
assert_eq! ( r.status, StatusCode:: SEE_OTHER , " {}" , r.body) ;
105
let to = r.location.expect ( " a redirect" ) ;
106
assert! ( to.starts_with ( & format! ( " {page} ?" ) ) , " {to} is not {page}" ) ;
107
get ( app, & to, cookie) .await
108
}
109
19bb31ff Andy McCall 1 month ago ↶
110
111
async fn register ( app : & Shared, user : & str , invite : & str ) -> String {
c8684d44 Andy McCall 19 days ago
112
let r = post (
113
app,
114
" /register" ,
115
None ,
116
& format! ( " username={user} &email={user} %40example.com&password=correct-horse-battery&setup=test-setup&invite={invite} " ) ,
117
)
118
.await;
19bb31ff Andy McCall 1 month ago ↶
119
assert_eq! ( r.status, StatusCode:: SEE_OTHER , " {}" , r.body) ;
120
r.cookie.expect ( " session cookie" )
121
}
122
78763257 Andy McCall 16 days ago ↶
123
124
async fn upload_identity ( app : & Shared, cookie : & str , csrf : & str , object : & [u8 ]) -> String {
125
let boundary = " XYZ" ;
126
let mut multipart = Vec :: new( ) ;
127
multipart.extend_from_slice ( format! ( " --{boundary} \r \n Content-Disposition: form-data; name=\" _csrf\" \r \n \r \n {csrf} \r \n " ) .as_bytes ( ) ) ;
128
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 ( ) ) ;
129
multipart.extend_from_slice ( object) ;
130
multipart.extend_from_slice ( format! ( " \r \n --{boundary} --\r \n " ) .as_bytes ( ) ) ;
131
let req = Request:: builder( )
132
.method ( " POST" )
133
.uri ( " /settings/keys/upload" )
134
.header ( header:: COOKIE , format! ( " oxyhub={cookie} " ) )
135
.header ( header:: CONTENT_TYPE , format! ( " multipart/form-data; boundary={boundary} " ) )
136
.body ( Body:: from( multipart) )
137
.unwrap ( ) ;
138
let res = oxygenhub:: router( app.clone ( ) ) .oneshot ( req) .await.unwrap ( ) ;
139
assert_eq! ( res.status ( ) , StatusCode:: OK ) ;
140
String :: from_utf8_lossy( & res.into_body ( ) .collect ( ) .await.unwrap ( ) .to_bytes ( ) ) .into_owned ( )
141
}
142
19bb31ff Andy McCall 1 month ago ↶
143
fn csrf_of ( page : & str ) -> String {
144
let i = page.find ( " name=\" _csrf\" value=\" " ) .expect ( " csrf field" ) + " name=\" _csrf\" value=\" " .len ( ) ;
145
page[ i.. ] .split ( ' "' ) .next ( ) .unwrap ( ) .to_string ( )
146
}
147
148
# [ tokio ::test ( flavor = " multi_thread" ) ]
149
async fn accounts_roles_tokens_and_attribution ( ) {
150
let h = hub ( ) .await;
151
let app = & h.app;
152
153
154
let andy = register ( app, " andy" , " " ) .await;
155
let me = db:: accounts:: account_by_name( & app.db, " andy" ) .await.unwrap ( ) .unwrap ( ) ;
156
assert! ( me.is_admin) ;
157
c8684d44 Andy McCall 19 days ago
158
let r = post ( app, " /register" , None , " username=mallory&email=m%40example.com&password=correct-horse-battery&setup=test-setup&invite=" )
159
.await;
19bb31ff Andy McCall 1 month ago ↶
160
assert_eq! ( r.status, StatusCode:: OK ) ;
161
assert! ( r.body.contains ( " invitation" ) , " {}" , r.body) ;
162
let admin_page = get ( app, " /admin" , Some ( & andy) ) .await;
163
assert_eq! ( admin_page.status, StatusCode:: OK ) ;
164
let csrf = csrf_of ( & admin_page.body) ;
165
let r = post ( app, " /admin/invite" , Some ( & andy) , & format! ( " _csrf={csrf} " ) ) .await;
166
let code = r.body.split ( " register?invite=" ) .nth ( 1 ) .unwrap ( ) .split ( ' <' ) .next ( ) .unwrap ( ) .trim ( ) .to_string ( ) ;
167
let bob = register ( app, " bob" , & code) .await;
168
assert! ( ! db:: accounts:: account_by_name( & app.db, " bob" ) .await.unwrap ( ) .unwrap ( ) .is_admin) ;
169
170
assert_eq! ( post ( app, " /login" , None , " username=andy&password=nope" ) .await.status, StatusCode:: OK ) ;
171
assert_eq! ( post ( app, " /settings" , Some ( & andy) , " _csrf=bogus&display_name=x&email=y" ) .await.status, StatusCode:: BAD_REQUEST ) ;
172
assert_eq! ( get ( app, " /settings" , None ) .await.status, StatusCode:: SEE_OTHER , " anonymous settings redirects to sign in" ) ;
173
174
175
let new_page = get ( app, " /new" , Some ( & andy) ) .await;
176
let csrf = csrf_of ( & new_page.body) ;
177
let r = post ( app, " /new" , Some ( & andy) , & format! ( " _csrf={csrf} &owner=andy&name=secret&description=hush&visibility=private" ) ) .await;
178
assert_eq! ( r.status, StatusCode:: SEE_OTHER , " {}" , r.body) ;
179
assert_eq! ( get ( app, " /andy/secret" , Some ( & andy) ) .await.status, StatusCode:: OK ) ;
180
assert_eq! ( get ( app, " /andy/secret" , Some ( & bob) ) .await.status, StatusCode:: NOT_FOUND ) ;
181
assert_eq! ( get ( app, " /andy/secret" , None ) .await.status, StatusCode:: NOT_FOUND ) ;
182
assert! ( ! get ( app, " /" , Some ( & bob) ) .await.body.contains ( " secret" ) ) ;
183
assert! ( get ( app, " /" , Some ( & andy) ) .await.body.contains ( " secret" ) ) ;
184
185
186
let tokens_page = get ( app, " /settings/tokens" , Some ( & andy) ) .await;
187
let csrf = csrf_of ( & tokens_page.body) ;
188
let r = post ( app, " /settings/tokens" , Some ( & andy) , & format! ( " _csrf={csrf} &name=ro&scopes=repo%3Aread" ) ) .await;
189
let ro = r.body.split ( " oxyhub_" ) .nth ( 1 ) .map ( | s | format! ( " oxyhub_{} " , s.split ( ' <' ) .next ( ) .unwrap ( ) .trim ( ) ) ) .unwrap ( ) ;
190
let r = post ( app, " /settings/tokens" , Some ( & andy) , & format! ( " _csrf={csrf} &name=rw&scopes=repo%3Aread&scopes=repo%3Awrite" ) ) .await;
191
let rw = r.body.split ( " oxyhub_" ) .nth ( 1 ) .map ( | s | format! ( " oxyhub_{} " , s.split ( ' <' ) .next ( ) .unwrap ( ) .trim ( ) ) ) .unwrap ( ) ;
192
let work = tempfile:: tempdir( ) .unwrap ( ) ;
193
let mut client = Repo:: init( work.path ( ) ) .unwrap ( ) ;
194
client.create_identity ( " Andy McCall" , " [email protected] " , oxy_core:: repo:: IdentityScope:: User) .unwrap ( ) ;
195
client.config.set ( " remote.origin.url" , & format! ( " {} /andy/secret" , h.base) ) .unwrap ( ) ;
196
client.config.set ( " core.sign" , " change" ) .unwrap ( ) ;
197
client.save_config ( ) .unwrap ( ) ;
198
write ( work.path ( ) , " a.txt" , b " a\n " ) ;
199
client.snapshot ( " t" ) .unwrap ( ) ;
200
let c1 = client.commit ( " first" , & [ ] ) .unwrap ( ) ;
201
let err = client.push ( " origin" , false ) .err ( ) .unwrap ( ) .to_string ( ) ;
202
assert! ( err.contains ( " 404" ) , " anonymous cannot even see it: {err}" ) ;
203
client.config.set ( " remote.origin.token" , & ro) .unwrap ( ) ;
204
let err = client.push ( " origin" , false ) .err ( ) .unwrap ( ) .to_string ( ) ;
205
assert! ( err.contains ( " 403" ) , " read-only token cannot push: {err}" ) ;
206
client.config.set ( " remote.origin.token" , & rw) .unwrap ( ) ;
207
assert! ( client.push ( " origin" , false ) .unwrap ( ) .iter ( ) .all ( | r | r.ok ) ) ;
208
209
let bob_tokens = get ( app, " /settings/tokens" , Some ( & bob) ) .await;
210
let csrf_b = csrf_of ( & bob_tokens.body) ;
211
let r = post ( app, " /settings/tokens" , Some ( & bob) , & format! ( " _csrf={csrf_b} &name=b&scopes=repo%3Aread&scopes=repo%3Awrite" ) ) .await;
212
let bob_tok = r.body.split ( " oxyhub_" ) .nth ( 1 ) .map ( | s | format! ( " oxyhub_{} " , s.split ( ' <' ) .next ( ) .unwrap ( ) .trim ( ) ) ) .unwrap ( ) ;
213
std:: env:: set_var( " OXY_TOKEN" , & bob_tok) ;
214
let err = Repo:: clone( & format! ( " {} /andy/secret" , h.base) , & work.path ( ) .join ( " bobclone" ) ) .err ( ) .unwrap ( ) .to_string ( ) ;
215
std:: env:: remove_var( " OXY_TOKEN" ) ;
216
assert! ( err.contains ( " 404" ) , " {err}" ) ;
217
218
tokio:: time:: sleep( std:: time:: Duration:: from_millis( 150 ) ) .await;
219
let pushes = get ( app, " /andy/secret/pushes" , Some ( & andy) ) .await.body;
220
assert! ( pushes.contains ( " andy" ) , " {pushes}" ) ;
221
222
223
let settings = get ( app, " /andy/secret/settings" , Some ( & andy) ) .await;
224
assert_eq! ( settings.status, StatusCode:: OK ) ;
225
let csrf = csrf_of ( & settings.body) ;
226
let r = post ( app, " /andy/secret/settings/collaborators" , Some ( & andy) , & format! ( " _csrf={csrf} &username=bob&role=write" ) ) .await;
c7067c7b Andy McCall 16 days ago
227
let r = settled ( app, r, Some ( & andy) , " /andy/secret/settings/collaborators" ) .await;
228
assert! ( r.body.contains ( " bob now has write access." ) , " {}" , r.body) ;
19bb31ff Andy McCall 1 month ago ↶
229
assert_eq! ( get ( app, " /andy/secret" , Some ( & bob) ) .await.status, StatusCode:: OK ) ;
230
assert_eq! ( get ( app, " /andy/secret/settings" , Some ( & bob) ) .await.status, StatusCode:: NOT_FOUND , " write is not maintain" ) ;
231
std:: env:: set_var( " OXY_TOKEN" , & bob_tok) ;
232
let ok = Repo:: clone( & format! ( " {} /andy/secret" , h.base) , & work.path ( ) .join ( " bobclone2" ) ) .is_ok ( ) ;
233
std:: env:: remove_var( " OXY_TOKEN" ) ;
234
assert! ( ok) ;
235
2bbe88a8 Andy McCall 1 month ago ↶
236
237
238
let store = oxy_core:: credentials:: Credentials:: user( ) ;
239
let hub = oxy_core:: credentials:: hub_of( & h.base) .unwrap ( ) ;
240
store.save ( & hub, & rw, Some ( " andy" ) , None ) .unwrap ( ) ;
241
let signed_in = Repo:: clone( & format! ( " {} /andy/secret" , h.base) , & work.path ( ) .join ( " signedin" ) ) ;
242
store.remove ( & hub) .unwrap ( ) ;
243
let signed_in = signed_in.unwrap ( ) ;
244
assert! ( signed_in.config.remote_tokens.is_empty ( ) ) ;
245
assert_eq! ( signed_in.bookmark_heads ( ) .unwrap ( ) , vec! [ c1] ) ;
246
19bb31ff Andy McCall 1 month ago ↶
247
248
let ( key, identity) = {
249
let k = client.config.author_key.unwrap ( ) ;
250
( k, client.identity ( & k) .unwrap ( ) )
251
} ;
252
let keys_page = get ( app, " /settings/keys" , Some ( & andy) ) .await;
253
let csrf = csrf_of ( & keys_page.body) ;
78763257 Andy McCall 16 days ago ↶
254
let body = upload_identity ( app, & andy, & csrf, & identity.encode ( ) ) .await;
19bb31ff Andy McCall 1 month ago ↶
255
let challenge_hex = body.split ( " sign-challenge " ) .nth ( 1 ) .unwrap ( ) .split ( ' <' ) .next ( ) .unwrap ( ) .trim ( ) .to_string ( ) ;
256
let challenge_id = body.split ( " name=\" challenge\" value=\" " ) .nth ( 1 ) .unwrap ( ) .split ( ' "' ) .next ( ) .unwrap ( ) .to_string ( ) ;
257
let ( _ , sk) = client.secret_key ( ) .unwrap ( ) .unwrap ( ) ;
258
let sig = sk.sign_challenge ( & key, & hex:: decode( & challenge_hex) .unwrap ( ) ) .unwrap ( ) ;
259
let sig_hex = hex:: encode( oxy_core:: identity:: Signature:: encode_list( & [ sig] ) ) ;
260
let r = post ( app, " /settings/keys/verify" , Some ( & andy) , & format! ( " _csrf={csrf} &challenge={challenge_id} &signature={sig_hex} " ) ) .await;
261
assert! ( r.body.contains ( " Key linked" ) , " {}" , r.body) ;
b349e15a Andy McCall 15 days ago ↶
262
263
264
let keys = get ( app, " /settings/keys" , Some ( & andy) ) .await.body;
265
assert! ( keys.contains ( " last used never" ) & & keys.contains ( " never expires" ) , " {keys}" ) ;
266
write ( work.path ( ) , " b.txt" , b " b\n " ) ;
267
client.snapshot ( " t" ) .unwrap ( ) ;
268
client.commit ( " second" , & [ ] ) .unwrap ( ) ;
269
assert! ( client.push ( " origin" , false ) .unwrap ( ) .iter ( ) .all ( | r | r.ok ) ) ;
270
let mut used = String :: new( ) ;
271
for _ in 0 .. 100 {
272
used = get ( app, " /settings/keys" , Some ( & andy) ) .await.body;
273
if ! used.contains ( " last used never" ) {
274
break ;
275
}
276
tokio:: time:: sleep( std:: time:: Duration:: from_millis( 50 ) ) .await;
277
}
278
assert! ( used.contains ( " last used <time" ) , " {used}" ) ;
19bb31ff Andy McCall 1 month ago ↶
279
let hist = get ( app, " /andy/secret/changes/main" , Some ( & andy) ) .await.body;
280
assert! ( hist.contains ( " href=\" /andy\" >Andy McCall</a>" ) , " author linked to the account: {hist}" ) ;
281
let change = get ( app, & format! ( " /andy/secret/change/{} " , c1.to_hex ( ) ) , Some ( & andy) ) .await.body;
282
assert! ( change.contains ( " @andy" ) , " {change}" ) ;
283
284
let r = post ( app, " /settings/keys/verify" , Some ( & andy) , & format! ( " _csrf={csrf} &challenge=999&signature={sig_hex} " ) ) .await;
285
assert! ( r.body.contains ( " expired" ) , " {}" , r.body) ;
78763257 Andy McCall 16 days ago ↶
286
287
288
let body = upload_identity ( app, & andy, & csrf, & app.hub_key.identity.encode ( ) ) .await;
289
assert! ( body.contains ( " this hub's own key" ) | | body.contains ( " this hub's own key" ) , " {body}" ) ;
290
assert! ( ! body.contains ( " sign-challenge " ) , " no challenge for the hub's key: {body}" ) ;
291
let other = oxy_core:: identity:: SecretKey:: generate( ) .identity ( " Other" , " [email protected] " , 1 ) ;
292
let body = upload_identity ( app, & andy, & csrf, & other.encode ( ) ) .await;
293
let challenge_hex = body.split ( " sign-challenge " ) .nth ( 1 ) .unwrap ( ) .split ( ' <' ) .next ( ) .unwrap ( ) .trim ( ) .to_string ( ) ;
294
let challenge_id = body.split ( " name=\" challenge\" value=\" " ) .nth ( 1 ) .unwrap ( ) .split ( ' "' ) .next ( ) .unwrap ( ) .to_string ( ) ;
295
let sig = sk.sign_challenge ( & key, & hex:: decode( & challenge_hex) .unwrap ( ) ) .unwrap ( ) ;
296
let sig_hex = hex:: encode( oxy_core:: identity:: Signature:: encode_list( & [ sig] ) ) ;
297
let r = post ( app, " /settings/keys/verify" , Some ( & andy) , & format! ( " _csrf={csrf} &challenge={challenge_id} &signature={sig_hex} " ) ) .await;
298
assert! (
299
r.body.contains ( & format! ( " That signature is by key {} " , key.short ( 12 ) ) ) & & r.body.contains ( & other.hash ( ) .short ( 12 ) ) ,
300
" {}" ,
301
r.body
302
) ;
19bb31ff Andy McCall 1 month ago ↶
303
304
305
let org_page = get ( app, " /orgs/new" , Some ( & andy) ) .await;
306
let csrf = csrf_of ( & org_page.body) ;
307
let r = post ( app, " /orgs/new" , Some ( & andy) , & format! ( " _csrf={csrf} &name=acme&display_name=Acme" ) ) .await;
308
assert_eq! ( r.status, StatusCode:: SEE_OTHER , " {}" , r.body) ;
309
let new_page = get ( app, " /new" , Some ( & andy) ) .await;
310
let csrf = csrf_of ( & new_page.body) ;
311
assert_eq! (
312
post ( app, " /new" , Some ( & andy) , & format! ( " _csrf={csrf} &owner=acme&name=plans&visibility=private" ) ) .await.status,
313
StatusCode:: SEE_OTHER
314
) ;
315
assert_eq! ( get ( app, " /acme/plans" , Some ( & bob) ) .await.status, StatusCode:: NOT_FOUND ) ;
316
let org_settings = get ( app, " /orgs/acme/settings" , Some ( & andy) ) .await;
317
let csrf = csrf_of ( & org_settings.body) ;
318
assert_eq! (
319
post ( app, " /orgs/acme/settings/members" , Some ( & andy) , & format! ( " _csrf={csrf} &username=bob&role=member" ) ) .await.status,
320
StatusCode:: OK
321
) ;
322
assert_eq! ( get ( app, " /acme/plans" , Some ( & bob) ) .await.status, StatusCode:: OK , " members read by default" ) ;
323
assert_eq! ( get ( app, " /orgs/acme/settings" , Some ( & bob) ) .await.status, StatusCode:: NOT_FOUND , " members do not administer" ) ;
324
325
let r = post (
326
app,
327
" /new" ,
328
Some ( & bob) ,
329
& format! ( " _csrf={} &owner=acme&name=bobs&visibility=public" , csrf_of ( & get ( app, " /new" , Some ( & bob) ) .await.body) ) ,
330
)
331
.await;
332
assert_eq! ( r.status, StatusCode:: SEE_OTHER , " {}" , r.body) ;
333
334
335
let sec = get ( app, " /settings/security" , Some ( & andy) ) .await;
336
let csrf = csrf_of ( & sec.body) ;
337
let r = post ( app, " /settings/security/sessions" , Some ( & andy) , & format! ( " _csrf={csrf} " ) ) .await;
338
assert_eq! ( r.status, StatusCode:: SEE_OTHER ) ;
339
assert_eq! ( get ( app, " /settings" , Some ( & andy) ) .await.status, StatusCode:: SEE_OTHER , " old session is gone" ) ;
340
let fresh = r.cookie.unwrap ( ) ;
341
assert_eq! ( get ( app, " /settings" , Some ( & fresh) ) .await.status, StatusCode:: OK ) ;
342
343
344
let sec = get ( app, " /settings/security" , Some ( & fresh) ) .await;
345
let csrf = csrf_of ( & sec.body) ;
346
let r = post ( app, " /settings/security/totp/begin" , Some ( & fresh) , & format! ( " _csrf={csrf} " ) ) .await;
347
let secret = r.body.split ( " name=\" secret\" value=\" " ) .nth ( 1 ) .unwrap ( ) .split ( ' "' ) .next ( ) .unwrap ( ) .to_string ( ) ;
348
let now = ( db:: now_ms( ) / 1000 ) as u64 ;
349
let code = oxygenhub:: auth:: totp:: current( & secret, now) .unwrap ( ) ;
350
let r = post ( app, " /settings/security/totp/enable" , Some ( & fresh) , & format! ( " _csrf={csrf} &secret={secret} &code={code} " ) ) .await;
351
assert! ( r.body.contains ( " Recovery codes" ) , " {}" , r.body) ;
c8684d44 Andy McCall 19 days ago
352
let r = post ( app, " /login" , None , " username=andy&password=correct-horse-battery&setup=test-setup" ) .await;
19bb31ff Andy McCall 1 month ago ↶
353
assert! ( r.body.contains ( " authenticator" ) , " second step asked: {}" , r.body) ;
1b3a0098 Andy McCall 1 month ago ↶
354
355
356
let form = & r.body[ r.body.find ( " <form class=\" card\" method=\" post\" action=\" /login\" >" ) .unwrap ( ) .. ] ;
357
let form = & form[ .. form.find ( " </form>" ) .unwrap ( ) ] ;
358
let names: Vec< & str > = form.split ( " name=\" " ) .skip ( 1 ) .map ( | t | t.split ( ' "' ) .next ( ) .unwrap ( ) ) .collect ( ) ;
359
let mut unique = names.clone ( ) ;
360
unique.sort ( ) ;
361
unique.dedup ( ) ;
362
assert_eq! ( unique.len ( ) , names.len ( ) , " a field named twice: {names:?}" ) ;
bff9106e Andy McCall 22 days ago ↶
363
364
365
assert! ( ! names.contains ( & " password" ) , " {names:?}" ) ;
366
let pending = form.split ( " name=\" pending\" value=\" " ) .nth ( 1 ) .unwrap ( ) .split ( ' "' ) .next ( ) .unwrap ( ) .to_string ( ) ;
19bb31ff Andy McCall 1 month ago ↶
367
let code = oxygenhub:: auth:: totp:: current( & secret, ( db:: now_ms( ) / 1000 ) as u64 ) .unwrap ( ) ;
bff9106e Andy McCall 22 days ago ↶
368
let fill = | pending : & str | -> String {
369
names
370
.iter ( )
371
.map ( | n | match * n {
372
" username" => " username=andy" .to_string ( ) ,
373
" pending" => format! ( " pending={pending} " ) ,
374
" code" => format! ( " code={code} " ) ,
375
other => format! ( " {other} =" ) ,
376
} )
377
.collect:: < Vec< _ > > ( )
378
.join ( " &" )
379
} ;
380
381
let r = post ( app, " /login" , None , & fill ( " 1.forged" ) ) .await;
382
assert! ( r.body.contains ( " sign in again" ) & & r.cookie.is_none ( ) , " {}" , r.body) ;
383
let r = post ( app, " /login" , None , & fill ( & pending) ) .await;
19bb31ff Andy McCall 1 month ago ↶
384
assert_eq! ( r.status, StatusCode:: SEE_OTHER , " {}" , r.body) ;
385
assert! ( r.cookie.is_some ( ) ) ;
386
assert! ( r.location.as_deref ( ) = = Some ( " /" ) ) ;
387
}
a160b98f Andy McCall 1 month ago ↶
388
389
390
fn picture_form ( csrf : & str , action : & str , file : & [u8 ]) -> ( String, Vec< u8 > ) {
391
let boundary = " picture-boundary-1234" ;
392
let mut body = Vec :: new( ) ;
393
body.extend_from_slice ( format! ( " --{boundary} \r \n Content-Disposition: form-data; name=\" _csrf\" \r \n \r \n {csrf} \r \n " ) .as_bytes ( ) ) ;
394
body.extend_from_slice ( format! ( " --{boundary} \r \n Content-Disposition: form-data; name=\" action\" \r \n \r \n {action} \r \n " ) .as_bytes ( ) ) ;
395
body.extend_from_slice (
396
format! ( " --{boundary} \r \n Content-Disposition: form-data; name=\" picture\" ; filename=\" me.png\" \r \n Content-Type: image/png\r \n \r \n " )
397
.as_bytes ( ) ,
398
) ;
399
body.extend_from_slice ( file) ;
400
body.extend_from_slice ( format! ( " \r \n --{boundary} --\r \n " ) .as_bytes ( ) ) ;
401
( format! ( " multipart/form-data; boundary={boundary} " ) , body)
402
}
403
404
async fn upload ( app : & Shared, path : & str , cookie : & str , csrf : & str , action : & str , file : & [u8 ]) -> Res {
405
let ( ct, body) = picture_form ( csrf, action, file) ;
406
let req = Request:: post( path)
407
.header ( header:: COOKIE , format! ( " oxyhub={cookie} " ) )
408
.header ( header:: CONTENT_TYPE , ct)
409
.body ( Body:: from( body) )
410
.unwrap ( ) ;
411
let res = oxygenhub:: router( app.clone ( ) ) .oneshot ( req) .await.unwrap ( ) ;
412
let status = res.status ( ) ;
413
let bytes = res.into_body ( ) .collect ( ) .await.unwrap ( ) .to_bytes ( ) ;
83940d53 Andy McCall 1 month ago ↶
414
Res { status, body: String :: from_utf8_lossy( & bytes) .into_owned ( ) , cookie: None , location: None , raw_cookies: vec! [ ] }
a160b98f Andy McCall 1 month ago ↶
415
}
416
417
418
async fn picture ( app : & Shared, name : & str ) -> ( StatusCode, String, Vec< u8 > ) {
419
let res = oxygenhub:: router( app.clone ( ) ) .oneshot ( Request:: get( format! ( " /avatar/{name} " ) ) .body ( Body:: empty( ) ) .unwrap ( ) ) .await.unwrap ( ) ;
420
let ct = res.headers ( ) .get ( header:: CONTENT_TYPE ) .and_then ( | v | v.to_str ( ) .ok ( ) ) .unwrap_or ( " " ) .to_string ( ) ;
421
let status = res.status ( ) ;
422
( status, ct, res.into_body ( ) .collect ( ) .await.unwrap ( ) .to_bytes ( ) .to_vec ( ) )
423
}
424
425
fn png ( w : u32 , h : u32 , alpha : bool ) -> Vec< u8 > {
426
let img = if alpha {
427
image:: DynamicImage:: ImageRgba8( image:: RgbaImage:: from_fn( w, h, | x , _| {
428
image:: Rgba( [ 0 , 114 , 255 , if x % 2 = = 0 { 0 } else { 255 } ] )
429
} ) )
430
} else {
431
image:: DynamicImage:: ImageRgb8( image:: RgbImage:: from_fn( w, h, | x , y | image:: Rgb( [ x as u8 , y as u8 , 128 ] ) ) )
432
} ;
433
let mut out = Vec :: new( ) ;
434
img.write_to ( & mut std:: io:: Cursor:: new( & mut out) , image:: ImageFormat:: Png) .unwrap ( ) ;
435
out
c7067c7b Andy McCall 16 days ago
436
}
437
438
439
440
441
# [ tokio ::test ( flavor = " multi_thread" ) ]
442
async fn repository_settings_are_a_page_each_by_role ( ) {
443
let h = hub ( ) .await;
444
let app = & h.app;
445
let andy = register ( app, " andy" , " " ) .await;
446
let mut invited = vec! [ ] ;
447
for name in [ " bob" , " carol" ] {
448
let csrf = csrf_of ( & get ( app, " /admin" , Some ( & andy) ) .await.body) ;
449
let r = post ( app, " /admin/invite" , Some ( & andy) , & format! ( " _csrf={csrf} " ) ) .await;
450
let code = r.body.split ( " register?invite=" ) .nth ( 1 ) .unwrap ( ) .split ( ' <' ) .next ( ) .unwrap ( ) .trim ( ) .to_string ( ) ;
451
invited.push ( register ( app, name, & code) .await) ;
452
}
453
let ( bob, carol) = ( invited[ 0 ] .clone ( ) , invited[ 1 ] .clone ( ) ) ;
454
let csrf = csrf_of ( & get ( app, " /new" , Some ( & andy) ) .await.body) ;
455
let r = post ( app, " /new" , Some ( & andy) , & format! ( " _csrf={csrf} &owner=andy&name=demo&visibility=public" ) ) .await;
456
assert_eq! ( r.status, StatusCode:: SEE_OTHER , " {}" , r.body) ;
457
let csrf = csrf_of ( & get ( app, " /andy/demo/settings" , Some ( & andy) ) .await.body) ;
458
for ( who, role) in [ ( " bob" , " maintain" ) , ( " carol" , " write" ) ] {
459
let r = post ( app, " /andy/demo/settings/collaborators" , Some ( & andy) , & format! ( " _csrf={csrf} &username={who} &role={role} " ) ) .await;
460
let page = settled ( app, r, Some ( & andy) , " /andy/demo/settings/collaborators" ) .await;
461
assert! ( page.body.contains ( & format! ( " {who} now has {role} access." ) ) , " {}" , page.body) ;
462
}
463
464
let marked = | slug : & str | format! ( " href=\" /andy/demo/settings/{slug} \" data-slug=\" {slug} \" class=\" on\" aria-current=\" page\" " ) ;
465
let maintainers =
466
[ " general" , " landing" , " checks" , " push-protection" , " flow" , " runs" , " gates" , " coverage" , " git-mirror" , " diffusion" , " backup" ] ;
467
let admins = [ " collaborators" , " runners" , " variables" , " secrets" , " webhooks" , " assistants" , " advanced" ] ;
468
469
let general = get ( app, " /andy/demo/settings" , Some ( & andy) ) .await;
470
assert_eq! ( general.status, StatusCode:: OK ) ;
471
assert! ( general.body.contains ( & marked ( " general" ) ) & & general.body.contains ( " id=\" general\" " ) , " {}" , general.body) ;
472
for group in [ " General" , " Code" , " Flow" , " Integrations" , " Data" , " Danger zone" ] {
473
assert! ( general.body.contains ( & format! ( " <h2>{group} </h2>" ) ) , " group {group}" ) ;
474
}
475
assert! ( general.body.contains ( " data-settings-anchors" ) , " kept #anchor links are sent on from General" ) ;
476
477
for slug in maintainers.iter ( ) .chain ( admins.iter ( ) ) {
478
let page = get ( app, & format! ( " /andy/demo/settings/{slug} " ) , Some ( & andy) ) .await;
479
assert_eq! ( page.status, StatusCode:: OK , " {slug}" ) ;
480
assert! ( page.body.contains ( & marked ( slug) ) , " {slug} is marked: {}" , page.body) ;
481
for other in maintainers.iter ( ) .chain ( admins.iter ( ) ) .filter ( | o | * o ! = slug) {
482
assert! ( ! page.body.contains ( & marked ( other) ) , " {slug} marks {other}" ) ;
483
}
484
}
485
let runs = get ( app, " /andy/demo/settings/runs" , Some ( & andy) ) .await.body;
367f34b8 Andy McCall 16 days ago ↶
486
assert! (
487
runs.contains ( " id=\" flow-priority\" " )
488
& & runs.contains ( " id=\" flow-supersede\" " )
489
& & runs.contains ( " id=\" flow-skip\" " )
490
& & ! runs.contains ( " id=\" general\" " )
491
) ;
c7067c7b Andy McCall 16 days ago
492
493
assert_eq! ( get ( app, " /andy/demo/settings/storage" , Some ( & andy) ) .await.status, StatusCode:: NOT_FOUND ) ;
494
assert! ( ! general.body.contains ( " /andy/demo/settings/storage\" " ) ) ;
495
assert_eq! ( get ( app, " /andy/demo/settings/nope" , Some ( & andy) ) .await.status, StatusCode:: NOT_FOUND ) ;
496
497
498
499
let page = get ( app, " /andy/demo/settings" , Some ( & bob) ) .await;
500
assert_eq! ( page.status, StatusCode:: OK ) ;
501
for slug in maintainers {
502
assert_eq! ( get ( app, & format! ( " /andy/demo/settings/{slug} " ) , Some ( & bob) ) .await.status, StatusCode:: OK , " {slug}" ) ;
503
assert! ( page.body.contains ( & format! ( " data-slug=\" {slug} \" " ) ) , " {slug} listed" ) ;
504
}
505
for slug in admins {
506
assert_eq! ( get ( app, & format! ( " /andy/demo/settings/{slug} " ) , Some ( & bob) ) .await.status, StatusCode:: NOT_FOUND , " {slug}" ) ;
507
assert! ( ! page.body.contains ( & format! ( " data-slug=\" {slug} \" " ) ) , " {slug} not listed" ) ;
508
}
509
assert! ( ! page.body.contains ( " <h2>Danger zone</h2>" ) , " an empty group is left out" ) ;
510
let bob_csrf = csrf_of ( & page.body) ;
511
let r = post ( app, " /andy/demo/settings/flow-priority" , Some ( & bob) , & format! ( " _csrf={bob_csrf} &priority=normal" ) ) .await;
512
let back = settled ( app, r, Some ( & bob) , " /andy/demo/settings/runs" ) .await;
513
assert! ( back.body.contains ( " Its jobs are normal priority." ) & & back.body.contains ( & marked ( " runs" ) ) , " {}" , back.body) ;
514
515
assert_eq! ( get ( app, " /andy/demo/settings" , Some ( & carol) ) .await.status, StatusCode:: NOT_FOUND ) ;
516
for slug in [ " general" , " push-protection" , " advanced" ] {
517
assert_eq! ( get ( app, & format! ( " /andy/demo/settings/{slug} " ) , Some ( & carol) ) .await.status, StatusCode:: NOT_FOUND , " {slug}" ) ;
518
}
519
520
521
for ( path, form, page, says) in [
522
( " " , " description=Demo&default_bookmark=main&visibility=public&topics=" , " general" , " Saved." ) ,
523
( " /requirements" , " required_approvals=1" , " landing" , " Landing requirements saved." ) ,
524
( " /checks" , " required_checks=build" , " checks" , " Required checks saved." ) ,
525
( " /push-protection" , " " , " push-protection" , " Push protection is off" ) ,
526
( " /push-protection" , " protect=on" , " push-protection" , " Push protection is on" ) ,
527
( " /secret-bypass" , " fingerprint=0123456789abcdef&reason=a+test+fixture" , " push-protection" , " is allowed here" ) ,
528
( " /flow" , " flow=sometimes" , " flow" , " Pick on, own runners, off or the site default." ) ,
529
( " /flow-priority" , " priority=low" , " runs" , " Its jobs are low priority." ) ,
530
( " /flow-supersede" , " running=on" , " runs" , " cancels the older runs it supersedes, queued or running" ) ,
367f34b8 Andy McCall 16 days ago ↶
531
( " /flow-skip" , " skip=others" , " runs" , " skip its run on any bookmark but main" ) ,
532
( " /flow-skip" , " skip=sometimes" , " runs" , " Choose where a push may skip its run." ) ,
c7067c7b Andy McCall 16 days ago
533
( " /agent-gates" , " agents=on" , " gates" , " may now approve or reject" ) ,
534
( " /variables" , " action=set&name=RUST_LOG&value=debug" , " variables" , " RUST_LOG set." ) ,
535
( " /coverage" , " min=80&max_drop=" , " coverage" , " Coverage rules saved" ) ,
536
( " /hooks" , " action=delete&id=999" , " webhooks" , " Webhook deleted." ) ,
537
( " /mirror" , " " , " git-mirror" , " The Git mirror is off." ) ,
538
( " /diffusion" , " diffusion=off&land=propose" , " diffusion" , " Diffusion is off" ) ,
539
( " /backup" , " backup=off" , " backup" , " left out of its owner" ) ,
540
( " /archive" , " action=neither" , " advanced" , " Unknown action." ) ,
541
] {
542
let r = post ( app, & format! ( " /andy/demo/settings{path} " ) , Some ( & andy) , & format! ( " _csrf={csrf} &{form} " ) ) .await;
543
let back = settled ( app, r, Some ( & andy) , & format! ( " /andy/demo/settings/{page} " ) ) .await;
544
assert_eq! ( back.status, StatusCode:: OK , " {path}" ) ;
545
assert! ( back.body.contains ( says) & & back.body.contains ( & marked ( page) ) , " {path}: {}" , back.body) ;
546
}
547
548
549
let js = get ( app, " /static/hub.js" , None ) .await.body;
550
assert! ( js.contains ( " [data-settings-anchors]" ) & & js.contains ( " \" flow-priority\" : \" runs\" " ) , " {js}" ) ;
a160b98f Andy McCall 1 month ago ↶
551
}
552
553
# [ tokio ::test ( flavor = " multi_thread" ) ]
554
async fn profile_pictures_and_logos ( ) {
555
let h = hub ( ) .await;
556
let app = & h.app;
557
let andy = register ( app, " andy" , " " ) .await;
558
assert_eq! ( picture ( app, " andy" ) .await.1 , " image/svg+xml" , " the identicon first" ) ;
559
let csrf = csrf_of ( & get ( app, " /settings" , Some ( & andy) ) .await.body) ;
560
assert! ( get ( app, " /settings" , Some ( & andy) ) .await.body.contains ( " id=\" picture\" " ) ) ;
561
562
563
let r = upload ( app, " /settings/avatar" , & andy, & csrf, " upload" , & png ( 640 , 480 , false ) ) .await;
564
assert! ( r.body.contains ( " Picture saved" ) , " {}" , r.body) ;
565
let ( st, ct, bytes) = picture ( app, " andy" ) .await;
566
assert_eq! ( ( st, ct.as_str ( ) ) , ( StatusCode:: OK , " image/jpeg" ) ) ;
567
let img = image:: load_from_memory( & bytes) .unwrap ( ) ;
568
assert_eq! ( ( img.width ( ) , img.height ( ) ) , ( 400 , 400 ) ) ;
569
let stored = db:: accounts:: account_by_name( & app.db, " andy" ) .await.unwrap ( ) .unwrap ( ) .avatar;
570
assert! ( stored.starts_with ( " jpeg:" ) , " {stored}" ) ;
571
572
573
let r = upload (
574
app,
575
" /settings/avatar" ,
576
& andy,
577
& csrf,
578
" upload" ,
579
b " <svg xmlns=\" http://www.w3.org/2000/svg\" ><script>alert(1)</script></svg>" ,
580
)
581
.await;
582
assert! ( r.body.contains ( " not a PNG, JPEG or WebP" ) , " {}" , r.body) ;
583
let r = upload ( app, " /settings/avatar" , & andy, " wrong" , " upload" , & png ( 10 , 10 , false ) ) .await;
584
assert_ne! ( r.status, StatusCode:: OK ) ;
585
assert_eq! ( picture ( app, " andy" ) .await.1 , " image/jpeg" ) ;
586
587
let r = upload ( app, " /settings/avatar" , & andy, & csrf, " remove" , b " " ) .await;
588
assert! ( r.body.contains ( " Picture removed" ) ) ;
589
assert_eq! ( picture ( app, " andy" ) .await.1 , " image/svg+xml" ) ;
590
591
592
let me = db:: accounts:: account_by_name( & app.db, " andy" ) .await.unwrap ( ) .unwrap ( ) ;
593
db:: accounts:: create_org( & app.db, " acme" , " Acme" , me.id) .await.unwrap ( ) ;
594
let org_csrf = csrf_of ( & get ( app, " /orgs/acme/settings" , Some ( & andy) ) .await.body) ;
595
let r = upload ( app, " /orgs/acme/settings/avatar" , & andy, & org_csrf, " upload" , & png ( 120 , 120 , true ) ) .await;
596
assert! ( r.body.contains ( " Picture saved" ) , " {}" , r.body) ;
597
let ( _ , ct, bytes) = picture ( app, " acme" ) .await;
598
assert_eq! ( ct, " image/png" ) ;
599
assert! ( image:: load_from_memory( & bytes) .unwrap ( ) .color ( ) .has_alpha ( ) ) ;
600
let invite = {
601
let admin = get ( app, " /admin" , Some ( & andy) ) .await;
602
let r = post ( app, " /admin/invite" , Some ( & andy) , & format! ( " _csrf={} " , csrf_of ( & admin.body) ) ) .await;
603
let i = r.body.find ( " invite=" ) .map ( | i | i + 7 ) .expect ( " an invitation" ) ;
604
r.body[ i.. ] .chars ( ) .take_while ( | c | c.is_ascii_alphanumeric ( ) ) .collect:: < String> ( )
605
} ;
606
let bob = register ( app, " bob" , & invite) .await;
607
let bob_csrf = csrf_of ( & get ( app, " /settings" , Some ( & bob) ) .await.body) ;
608
let r = upload ( app, " /orgs/acme/settings/avatar" , & bob, & bob_csrf, " remove" , b " " ) .await;
609
assert_eq! ( r.status, StatusCode:: NOT_FOUND , " not an owner" ) ;
610
assert_eq! ( picture ( app, " acme" ) .await.1 , " image/png" ) ;
611
}
8fb35df7 Andy McCall 1 month ago ↶
612
613
# [ tokio ::test ( flavor = " multi_thread" ) ]
614
async fn organisation_webhooks_and_storage ( ) {
615
let h = hub ( ) .await;
616
let app = & h.app;
617
let andy = register ( app, " andy" , " " ) .await;
618
let me = db:: accounts:: account_by_name( & app.db, " andy" ) .await.unwrap ( ) .unwrap ( ) ;
619
let org = db:: accounts:: create_org( & app.db, " acme" , " Acme" , me.id) .await.unwrap ( ) ;
620
ff656953 Andy McCall 1 month ago ↶
621
622
623
624
let alone = db:: repos:: create( & app.db, " acme" , " alone" , " " , hub_api:: Visibility:: Public) .await.unwrap ( ) ;
625
let solo = db:: repos:: create( & app.db, " andy" , " solo" , " " , hub_api:: Visibility:: Public) .await.unwrap ( ) ;
626
assert_eq! ( db:: proposals:: requirements( & app.db, alone.id) .await.unwrap ( ) .required_approvals, 0 ) ;
627
assert_eq! ( db:: proposals:: requirements( & app.db, solo.id) .await.unwrap ( ) .required_approvals, 0 ) ;
628
let bobby = db:: accounts:: create_account( & app.db, " bobby" , " [email protected] " , " x" , " salt" , false ) .await.unwrap ( ) ;
629
db:: accounts:: set_org_member( & app.db, org.id, bobby.id, " member" ) .await.unwrap ( ) ;
462f88d1 Andy McCall 1 month ago ↶
630
let tools = db:: repos:: create( & app.db, " acme" , " tools" , " " , hub_api:: Visibility:: Public) .await.unwrap ( ) ;
631
assert_eq! ( db:: proposals:: requirements( & app.db, tools.id) .await.unwrap ( ) .required_approvals, 1 ) ;
632
8fb35df7 Andy McCall 1 month ago ↶
633
634
let page = get ( app, " /orgs/acme/settings" , Some ( & andy) ) .await;
635
assert! ( page.body.contains ( " id=\" webhooks\" " ) & & page.body.contains ( " href=\" #webhooks\" " ) ) ;
636
let csrf = csrf_of ( & page.body) ;
637
let r = post ( app, " /orgs/acme/settings/hooks" , Some ( & andy) , & format! ( " _csrf={csrf} &url=ftp%3A%2F%2Fx&events=*" ) ) .await;
638
assert! ( r.body.contains ( " starts with http" ) , " {}" , r.body) ;
639
let r = post (
640
app,
641
" /orgs/acme/settings/hooks" ,
642
Some ( & andy) ,
643
& format! ( " _csrf={csrf} &url=https%3A%2F%2Fhooks.invalid%2Facme&secret=s&events=push" ) ,
644
)
645
.await;
646
assert! ( r.body.contains ( " Webhook added" ) ) ;
647
let hooks = db:: hooks:: hooks_for_org( & app.db, org.id) .await.unwrap ( ) ;
648
assert_eq! ( hooks.len ( ) , 1 ) ;
649
let id = hooks[ 0 ] .id;
650
assert! ( post ( app, " /orgs/acme/settings/hooks" , Some ( & andy) , & format! ( " _csrf={csrf} &id={id} &action=ping" ) )
651
.await
652
.body
653
.contains ( " Ping queued" ) ) ;
654
let deliveries = get ( app, & format! ( " /orgs/acme/settings/hooks/{id} /deliveries" ) , Some ( & andy) ) .await;
655
assert_eq! ( deliveries.status, StatusCode:: OK ) ;
656
assert! ( deliveries.body.contains ( " >ping<" ) & & deliveries.body.contains ( " /orgs/acme/settings#webhooks" ) ) ;
657
658
let repo = oxygenhub:: repos:: RepoName:: parse( " andy/demo" ) .unwrap ( ) ;
659
app.repos.create ( & repo, None ) .unwrap ( ) ;
660
let row = db:: repos:: create( & app.db, " andy" , " demo" , " " , hub_api:: Visibility:: Public) .await.unwrap ( ) ;
661
let repo_hook = db:: hooks:: create_hook( & app.db, Some ( row.id) , None , " https://hooks.invalid/repo" , " " , " *" ) .await.unwrap ( ) .id;
662
post ( app, " /orgs/acme/settings/hooks" , Some ( & andy) , & format! ( " _csrf={csrf} &id={repo_hook} &action=delete" ) ) .await;
663
assert! ( db:: hooks:: hook( & app.db, repo_hook) .await.unwrap ( ) .is_some ( ) , " not the organisation's" ) ;
664
assert_eq! ( get ( app, & format! ( " /orgs/acme/settings/hooks/{repo_hook} /deliveries" ) , Some ( & andy) ) .await.status, StatusCode:: NOT_FOUND ) ;
665
c7067c7b Andy McCall 16 days ago
666
let rs = get ( app, " /andy/demo/settings/webhooks" , Some ( & andy) ) .await;
8fb35df7 Andy McCall 1 month ago ↶
667
assert! ( rs.body.contains ( " https://hooks.invalid/repo" ) & & rs.body.contains ( " action=\" /andy/demo/settings/hooks\" " ) ) ;
668
669
670
let work = tempfile:: tempdir( ) .unwrap ( ) ;
671
let mut client = Repo:: init( work.path ( ) ) .unwrap ( ) ;
672
client.config.set ( " remote.origin.url" , & format! ( " {} /andy/demo" , h.base) ) .unwrap ( ) ;
673
let ( token, hash) = oxygenhub:: auth:: new_token( ) ;
674
db:: accounts:: create_token( & app.db, me.id, " push" , & hash, " repo:write" , " " , None ) .await.unwrap ( ) ;
675
client.config.set ( " remote.origin.token" , & token) .unwrap ( ) ;
676
client.save_config ( ) .unwrap ( ) ;
677
write ( work.path ( ) , " a.txt" , b " a\n " ) ;
678
client.snapshot ( " t" ) .unwrap ( ) ;
679
client.commit ( " one" , & [ ] ) .unwrap ( ) ;
680
assert! ( client.push ( " origin" , false ) .unwrap ( ) .iter ( ) .all ( | r | r.ok ) ) ;
681
let storage = get ( app, " /admin/storage" , Some ( & andy) ) .await;
682
assert_eq! ( storage.status, StatusCode:: OK ) ;
683
assert! ( storage.body.contains ( " href=\" /andy/demo\" " ) ) ;
684
let loose_before = std:: fs:: read_dir( app.repos.dir ( & repo) .join ( " .oxy/objects/loose" ) ) .map ( | d | d.count ( ) ) .unwrap_or ( 0 ) ;
685
assert! ( loose_before > 0 , " the push left loose objects" ) ;
686
let r = post ( app, " /admin/storage" , Some ( & andy) , & format! ( " _csrf={} &repo=andy%2Fdemo" , csrf_of ( & storage.body) ) ) .await;
687
assert! ( r.body.contains ( " Maintained 1 repository" ) & & r.body.contains ( " loose objects packed" ) , " {}" , r.body) ;
7c06f5b2 Andy McCall 18 days ago ↶
688
689
690
let loose_after = Repo:: open( & app.repos.dir ( & repo) ) .unwrap ( ) .store ( ) .loose_count ( ) .unwrap ( ) ;
8fb35df7 Andy McCall 1 month ago ↶
691
assert_eq! ( loose_after, 0 , " packed" ) ;
692
let audit = db:: accounts:: audit_rows( & app.db, 20 ) .await.unwrap ( ) ;
693
assert! ( audit.iter ( ) .any ( | a | a.action = = " admin.gc" & & a.target = = " andy/demo" ) ) ;
694
let r = post ( app, " /admin/storage" , Some ( & andy) , & format! ( " _csrf={} &repo=nobody%2Fnone" , csrf_of ( & storage.body) ) ) .await;
695
assert! ( r.body.contains ( " No repository nobody/none" ) ) ;
696
}
697
6a178055 Andy McCall 1 month ago ↶
698
699
700
701
# [ tokio ::test ( flavor = " multi_thread" ) ]
702
async fn admins_disable_enable_and_delete_accounts ( ) {
703
let h = hub ( ) .await;
704
let app = & h.app;
705
let andy = register ( app, " andy" , " " ) .await;
706
let invite = | app : Shared, andy : String| async move {
707
let csrf = csrf_of ( & get ( & app, " /admin" , Some ( & andy) ) .await.body) ;
708
let r = post ( & app, " /admin/invite" , Some ( & andy) , & format! ( " _csrf={csrf} " ) ) .await;
709
r.body.split ( " register?invite=" ) .nth ( 1 ) .unwrap ( ) .split ( ' <' ) .next ( ) .unwrap ( ) .trim ( ) .to_string ( )
710
} ;
711
let bob = register ( app, " bob" , & invite ( app.clone ( ) , andy.clone ( ) ) .await) .await;
712
let act = | who : & 'static str , action : & 'static str , confirm : & 'static str | {
713
let ( app, andy) = ( app.clone ( ) , andy.clone ( ) ) ;
714
async move {
715
let csrf = csrf_of ( & get ( & app, " /admin" , Some ( & andy) ) .await.body) ;
716
post ( & app, " /admin/account" , Some ( & andy) , & format! ( " _csrf={csrf} &username={who} &action={action} &confirm={confirm} " ) ) .await.body
717
}
718
} ;
719
let login = | user : & 'static str | {
720
let app = app.clone ( ) ;
c8684d44 Andy McCall 19 days ago
721
async move { post ( & app, " /login" , None , & format! ( " username={user} &password=correct-horse-battery&setup=test-setup" ) ) .await }
6a178055 Andy McCall 1 month ago ↶
722
} ;
723
724
725
act ( " bob" , " disable" , " " ) .await;
726
assert! ( db:: accounts:: account_by_name( & app.db, " bob" ) .await.unwrap ( ) .unwrap ( ) .disabled) ;
727
let r = login ( " bob" ) .await;
728
assert! ( r.body.contains ( " Your account has been disabled" ) , " {}" , r.body) ;
729
assert! ( r.cookie.is_none ( ) ) ;
730
assert_eq! ( get ( app, " /settings" , Some ( & bob) ) .await.status, StatusCode:: SEE_OTHER , " the session is refused" ) ;
731
assert! ( act ( " andy" , " disable" , " " ) .await.contains ( " Not on yourself" ) ) ;
732
733
act ( " bob" , " enable" , " " ) .await;
734
assert_eq! ( login ( " bob" ) .await.status, StatusCode:: SEE_OTHER ) ;
735
736
737
let bob = login ( " bob" ) .await.cookie.unwrap ( ) ;
738
let csrf = csrf_of ( & get ( app, " /new" , Some ( & bob) ) .await.body) ;
739
assert_eq! (
740
post ( app, " /new" , Some ( & bob) , & format! ( " _csrf={csrf} &owner=bob&name=keep&visibility=public" ) ) .await.status,
741
StatusCode:: SEE_OTHER
742
) ;
743
assert! ( act ( " bob" , " delete" , " bo" ) .await.contains ( " type bob to confirm" ) ) ;
744
let page = act ( " bob" , " delete" , " bob" ) .await;
745
assert! ( page.contains ( " still owns bob/keep" ) , " {page}" ) ;
746
assert! ( db:: accounts:: account_by_name( & app.db, " bob" ) .await.unwrap ( ) .is_some ( ) ) ;
747
let row = db:: repos:: get( & app.db, " bob" , " keep" ) .await.unwrap ( ) .unwrap ( ) ;
748
db:: repos:: delete( & app.db, row.id) .await.unwrap ( ) ;
749
let page = act ( " bob" , " delete" , " bob" ) .await;
750
assert! ( page.contains ( " bob deleted" ) , " {page}" ) ;
751
assert! ( db:: accounts:: account_by_name( & app.db, " bob" ) .await.unwrap ( ) .is_none ( ) ) ;
752
assert_eq! ( get ( app, " /settings" , Some ( & bob) ) .await.status, StatusCode:: SEE_OTHER ) ;
753
754
let code = invite ( app.clone ( ) , andy.clone ( ) ) .await;
c8684d44 Andy McCall 19 days ago
755
let r = post (
756
app,
757
" /register" ,
758
None ,
759
& format! ( " username=bob&email=b%40example.com&password=correct-horse-battery&setup=test-setup&invite={code} " ) ,
760
)
761
.await;
6a178055 Andy McCall 1 month ago ↶
762
assert! ( r.body.contains ( " That name is taken" ) , " {}" , r.body) ;
763
764
assert! ( act ( " andy" , " delete" , " andy" ) .await.contains ( " Not on yourself" ) ) ;
765
}
5dead1f4 Andy McCall 1 month ago ↶
766
767
768
769
# [ tokio ::test ( flavor = " multi_thread" ) ]
770
async fn admins_set_the_job_time_limits ( ) {
771
let h = hub ( ) .await;
772
let app = & h.app;
773
let andy = register ( app, " andy" , " " ) .await;
774
let page = get ( app, " /admin/runners/settings" , Some ( & andy) ) .await;
775
assert_eq! ( page.status, StatusCode:: OK ) ;
776
assert! ( page.body.contains ( " Job time limits" ) & & page.body.contains ( " value=\" 60\" " ) , " the hub.toml default shows" ) ;
777
let csrf = csrf_of ( & page.body) ;
778
let r = post ( app, " /admin/runners/settings" , Some ( & andy) , & format! ( " _csrf={csrf} &default_minutes=200&max_minutes=120&grace_minutes=5" ) )
779
.await;
780
assert! ( r.body.contains ( " cannot be longer than the maximum" ) , " {}" , r.body) ;
781
let r = post ( app, " /admin/runners/settings" , Some ( & andy) , & format! ( " _csrf={csrf} &default_minutes=90&max_minutes=240&grace_minutes=10" ) )
782
.await;
783
assert! ( r.body.contains ( " Saved." ) , " {}" , r.body) ;
784
let l = oxygenhub:: flow:: limits( app) .await;
785
assert_eq! ( ( l.default_s, l.max_s, l.grace_ms) , ( 5400 , 14400 , 600_000 ) ) ;
786
assert! ( get ( app, " /admin/runners" , Some ( & andy) ) .await.body.contains ( " href=\" /admin/runners/settings\" " ) ) ;
787
let code = {
788
let csrf = csrf_of ( & get ( app, " /admin" , Some ( & andy) ) .await.body) ;
789
let r = post ( app, " /admin/invite" , Some ( & andy) , & format! ( " _csrf={csrf} " ) ) .await;
790
r.body.split ( " register?invite=" ) .nth ( 1 ) .unwrap ( ) .split ( ' <' ) .next ( ) .unwrap ( ) .trim ( ) .to_string ( )
791
} ;
792
let bob = register ( app, " bob" , & code) .await;
793
assert_ne! ( get ( app, " /admin/runners/settings" , Some ( & bob) ) .await.status, StatusCode:: OK , " not for others" ) ;
794
}
3556d08e Andy McCall 1 month ago ↶
795
796
797
798
# [ tokio ::test ( flavor = " multi_thread" ) ]
799
async fn mail_settings_are_saved_masked_and_tested_from_the_admin_page ( ) {
800
let h = hub ( ) .await;
801
let app = & h.app;
802
let andy = register ( app, " andy" , " " ) .await;
737de0fa Andy McCall 15 days ago ↶
803
let page = get ( app, " /admin/mail" , Some ( & andy) ) .await.body;
3556d08e Andy McCall 1 month ago ↶
804
assert! ( page.contains ( " Mail is off" ) , " {page}" ) ;
805
let csrf = csrf_of ( & page) ;
806
807
let outbox = tempfile:: tempdir( ) .unwrap ( ) ;
808
let r = post (
809
app,
810
" /admin/mail" ,
811
Some ( & andy) ,
812
& format! ( " _csrf={csrf} &smtp=file%3A%2F%2F{} &from=Hub+%3Chub%40example.org%3E" , outbox.path ( ) .display ( ) ) ,
813
)
814
.await;
815
assert! ( r.body.contains ( " Mail settings saved" ) & & r.body.contains ( " Mail is on" ) , " {}" , r.body) ;
816
assert! ( oxygenhub:: mail:: enabled( app) ) ;
817
let r = post ( app, " /admin/mail/test" , Some ( & andy) , & format! ( " _csrf={csrf} " ) ) .await;
818
assert! ( r.body.contains ( " Sent a test message to" ) , " {}" , r.body) ;
819
let sent: Vec< _ > = std:: fs:: read_dir( outbox.path ( ) ) .unwrap ( ) .flatten ( ) .collect ( ) ;
820
assert_eq! ( sent.len ( ) , 1 ) ;
821
let text = std:: fs:: read_to_string( sent[ 0 ] .path ( ) ) .unwrap ( ) ;
822
assert! ( text.contains ( " Subject: [" ) & & text.contains ( " Test message" ) & & text.contains ( " From: Hub <[email protected] >" ) , " {text}" ) ;
823
824
let r = post (
825
app,
826
" /admin/mail" ,
827
Some ( & andy) ,
828
& format! ( " _csrf={csrf} &smtp=smtps%3A%2F%2Fhub%3Asecret%40mail.example.org%3A465&from=Hub+%3Chub%40example.org%3E" ) ,
829
)
830
.await;
2a83f530 Andy McCall 1 month ago ↶
831
assert! ( r.body.contains ( " smtps://hub:***@mail.example.org:465" ) & & ! r.body.contains ( " hub:secret" ) , " {}" , r.body) ;
3556d08e Andy McCall 1 month ago ↶
832
let r = post (
833
app,
834
" /admin/mail" ,
835
Some ( & andy) ,
836
& format! ( " _csrf={csrf} &smtp=smtps%3A%2F%2Fhub%3A***%40mail.example.org%3A465&from=Hub+%3Chub%40example.org%3E" ) ,
837
)
838
.await;
839
assert! ( r.body.contains ( " Mail settings saved" ) , " {}" , r.body) ;
840
assert_eq! ( oxygenhub:: mail:: settings( app) .smtp, " smtps://hub:[email protected] :465" ) ;
841
842
let r = post ( app, " /admin/mail" , Some ( & andy) , & format! ( " _csrf={csrf} &smtp=mail.example.org&from=x" ) ) .await;
843
assert! ( r.body.contains ( " starts with smtp://" ) , " {}" , r.body) ;
844
let r = post ( app, " /admin/mail" , Some ( & andy) , & format! ( " _csrf={csrf} &smtp=&from=" ) ) .await;
845
assert! ( r.body.contains ( " Mail is off" ) , " {}" , r.body) ;
846
assert! ( ! oxygenhub:: mail:: enabled( app) ) ;
847
848
let r = post ( app, " /admin/mail" , None , & format! ( " _csrf={csrf} &smtp=&from=" ) ) .await;
849
assert_ne! ( r.status, StatusCode:: OK ) ;
cd46e56b Andy McCall 22 days ago ↶
850
851
852
853
let from = " from=OxygenHub+%3Cnoreply%40example.org%3E" ;
854
let r = post ( app, " /admin/mail" , Some ( & andy) , & format! ( " _csrf={csrf} &service=resend&secret=&{from} " ) ) .await;
855
assert! ( r.body.contains ( " Give the Resend API key" ) , " {}" , r.body) ;
856
let r = post ( app, " /admin/mail" , Some ( & andy) , & format! ( " _csrf={csrf} &service=resend&secret=re_test_key_1&{from} " ) ) .await;
857
assert! ( r.body.contains ( " Mail settings saved" ) & & ! r.body.contains ( " re_test_key_1" ) , " {}" , r.body) ;
858
assert! ( r.body.contains ( r # "<option value="resend" selected>"# ) & & r.body.contains ( " saved: leave empty to keep it" ) , " {}" , r.body) ;
859
assert_eq! ( oxygenhub:: mail:: settings( app) .smtp, " smtps://resend:[email protected] :465" ) ;
860
let r = post ( app, " /admin/mail" , Some ( & andy) , & format! ( " _csrf={csrf} &service=resend&secret=&{from} " ) ) .await;
861
assert! ( r.body.contains ( " Mail settings saved" ) , " {}" , r.body) ;
862
assert_eq! ( oxygenhub:: mail:: settings( app) .smtp, " smtps://resend:[email protected] :465" , " kept" ) ;
863
864
let r =
865
post ( app, " /admin/mail" , Some ( & andy) , & format! ( " _csrf={csrf} &service=ses®ion=eu-west-2&user=AKIAEXAMPLE&secret=s3cret&{from} " ) )
866
.await;
867
assert! ( r.body.contains ( r # "<option value="ses" selected>"# ) , " {}" , r.body) ;
868
assert_eq! ( oxygenhub:: mail:: settings( app) .smtp, " smtps://AKIAEXAMPLE:[email protected] :465" ) ;
869
let r = post ( app, " /admin/mail" , Some ( & andy) , & format! ( " _csrf={csrf} &service=off&{from} " ) ) .await;
870
assert! ( r.body.contains ( " Mail is off" ) , " {}" , r.body) ;
3556d08e Andy McCall 1 month ago ↶
871
}
e71846aa Andy McCall 1 month ago ↶
872
873
874
875
async fn mock_assistant ( ) -> ( String, std:: sync:: Arc< std:: sync:: Mutex< Vec< String> > > ) {
876
use std:: sync:: { Arc, Mutex} ;
877
let seen: Arc< Mutex< Vec< String> > > = Arc:: new( Mutex:: new( vec! [ ] ) ) ;
878
let s2 = seen.clone ( ) ;
879
let router = axum:: Router:: new( ) .route (
880
" /v1/chat/completions" ,
881
axum:: routing:: post( move | headers: axum:: http:: HeaderMap, body: String | {
882
let s2 = s2.clone ( ) ;
883
async move {
884
let auth = headers.get ( " authorization" ) .and_then ( | v | v.to_str ( ) .ok ( ) ) .unwrap_or ( " " ) .to_string ( ) ;
885
s2.lock ( ) .unwrap ( ) .push ( format! ( " {auth} {body} " ) ) ;
886
axum:: Json( serde_json:: json! ( { " choices" : [ { " message" : { " role" : " assistant" , " content" : " OK from mock" } } ] } ) )
887
}
888
} ) ,
889
) ;
890
let listener = tokio:: net:: TcpListener:: bind( " 127.0.0.1:0" ) .await.unwrap ( ) ;
891
let port = listener.local_addr ( ) .unwrap ( ) .port ( ) ;
892
tokio:: spawn( async move { axum:: serve( listener, router) .await.unwrap ( ) } ) ;
893
( format! ( " http://127.0.0.1:{port} /v1/chat/completions" ) , seen)
894
}
895
896
fn enc ( s : & str ) -> String {
897
s.replace ( ' %' , " %25" ) .replace ( ' :' , " %3A" ) .replace ( ' /' , " %2F" ) .replace ( ' ' , " +" ) .replace ( ' \n ' , " %0A" ) .replace ( ' &' , " %26" )
898
}
899
900
# [ tokio ::test ( flavor = " multi_thread" ) ]
901
async fn ai_assistants_are_kept_per_level_with_sealed_tokens_and_combined_downwards ( ) {
902
use oxygenhub:: assistants:: { self , Scope} ;
903
let h = hub ( ) .await;
904
let app = & h.app;
905
let andy = register ( app, " andy" , " " ) .await;
906
let ( endpoint, seen) = mock_assistant ( ) .await;
907
908
737de0fa Andy McCall 15 days ago ↶
909
let page = get ( app, " /admin/assistants" , Some ( & andy) ) .await.body;
e71846aa Andy McCall 1 month ago ↶
910
assert! ( page.contains ( " AI assistants" ) & & page.contains ( " Add an assistant" ) , " {page}" ) ;
911
let csrf = csrf_of ( & page) ;
912
let add = | name : & str , extra : & str | {
913
format! (
914
" _csrf={csrf} &action=add&name={name} &endpoint={} &api=chat-completions&model=claude-opus-5-5&token=sk-test-secret&enabled=1&budget=50000{extra} " ,
915
enc ( & endpoint)
916
)
917
} ;
918
let r = post ( app, " /admin/assistants" , Some ( & andy) , & add ( " Claude" , " " ) ) .await;
206c1fc1 Andy McCall 1 month ago ↶
919
assert! ( r.body.contains ( " Claude added and answered: OK from mock" ) & & r.body.contains ( " token set" ) , " {}" , r.body) ;
e71846aa Andy McCall 1 month ago ↶
920
assert! ( ! r.body.contains ( " sk-test-secret" ) , " the token never comes back" ) ;
921
let site = assistants:: list( & app.db, Scope:: Site) .await.unwrap ( ) ;
922
assert_eq! ( site.len ( ) , 1 ) ;
923
let claude = & site[ 0 ] ;
924
assert! ( ! String :: from_utf8_lossy( & claude.token) .contains ( " sk-test" ) , " sealed at rest" ) ;
925
assert_eq! ( app.assistant_key.open ( & claude.token) .as_deref ( ) , Some ( " sk-test-secret" ) ) ;
926
927
let r = post ( app, " /admin/assistants" , Some ( & andy) , & add ( " claude" , " " ) ) .await;
928
assert! ( r.body.contains ( " already an assistant called" ) , " {}" , r.body) ;
929
let r = post (
930
app,
931
" /admin/assistants" ,
932
Some ( & andy) ,
933
& format! ( " _csrf={csrf} &action=add&name=Bad&endpoint=ftp%3A%2F%2Fx&api=chat-completions&model=m" ) ,
934
)
935
.await;
936
assert! ( r.body.contains ( " starting with https://" ) , " {}" , r.body) ;
937
938
939
let r = post ( app, " /admin/assistants" , Some ( & andy) , & format! ( " _csrf={csrf} &action=test&id={} " , claude.id) ) .await;
940
assert! ( r.body.contains ( " Claude answered: OK from mock" ) , " {}" , r.body) ;
941
{
942
let calls = seen.lock ( ) .unwrap ( ) ;
206c1fc1 Andy McCall 1 month ago ↶
943
assert_eq! ( calls.len ( ) , 2 , " once on add, once on test" ) ;
e71846aa Andy McCall 1 month ago ↶
944
assert! ( calls[ 0 ] .starts_with ( " Bearer sk-test-secret " ) , " {}" , calls[ 0 ] ) ;
945
assert! ( calls[ 0 ] .contains ( " \" model\" :\" claude-opus-5-5\" " ) & & calls[ 0 ] .contains ( " Reply with the single word OK" ) , " {}" , calls[ 0 ] ) ;
946
}
947
948
let r = post (
949
app,
950
" /admin/assistants" ,
951
Some ( & andy) ,
952
& 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) ) ,
953
)
954
.await;
955
assert! ( r.body.contains ( " Claude updated." ) & & r.body.contains ( " context 60000 characters" ) , " {}" , r.body) ;
956
assert_eq! (
957
app.assistant_key.open ( & assistants:: get( & app.db, claude.id) .await.unwrap ( ) .unwrap ( ) .token) .as_deref ( ) ,
958
Some ( " sk-test-secret" )
959
) ;
960
961
let r =
962
post ( app, " /admin/assistants" , Some ( & andy) , & format! ( " _csrf={csrf} &action=prompt&prompt={} " , enc ( " Review for correctness first." ) ) )
963
.await;
964
assert! ( r.body.contains ( " Prompt saved." ) & & r.body.contains ( " Review for correctness first." ) , " {}" , r.body) ;
965
966
967
let r = post ( app, " /orgs/new" , Some ( & andy) , & format! ( " _csrf={csrf} &name=acme&display_name=Acme" ) ) .await;
968
assert_eq! ( r.status, StatusCode:: SEE_OTHER , " {}" , r.body) ;
969
let r = post ( app, " /new" , Some ( & andy) , & format! ( " _csrf={csrf} &owner=acme&name=plans&visibility=public" ) ) .await;
970
assert_eq! ( r.status, StatusCode:: SEE_OTHER , " {}" , r.body) ;
971
let org_page = get ( app, " /orgs/acme/settings" , Some ( & andy) ) .await.body;
972
assert! ( org_page.contains ( " Inherited" ) & & org_page.contains ( " from the site" ) & & org_page.contains ( " None of its own" ) , " {org_page}" ) ;
973
assert! ( org_page.contains ( " From the site" ) & & org_page.contains ( " Review for correctness first." ) , " {org_page}" ) ;
974
let repo = db:: repos:: get( & app.db, " acme" , " plans" ) .await.unwrap ( ) .unwrap ( ) ;
975
let on = assistants:: enabled_for_repo( & app.db, & repo) .await.unwrap ( ) ;
976
assert_eq! ( on.iter ( ) .map ( | a | a.name.as_str ( ) ) .collect:: < Vec< _ > > ( ) , [ " Claude" ] ) ;
977
978
979
980
let r = post (
981
app,
982
" /orgs/acme/settings/assistants" ,
983
Some ( & andy) ,
984
& format! ( " _csrf={csrf} &action=prompt&prompt={} " , enc ( " We write 6502 assembly." ) ) ,
985
)
986
.await;
987
assert! ( r.body.contains ( " Prompt saved." ) , " {}" , r.body) ;
988
let r = post ( app, " /orgs/acme/settings/assistants" , Some ( & andy) , & add ( " DeepSeek" , " " ) ) .await;
206c1fc1 Andy McCall 1 month ago ↶
989
assert! ( r.body.contains ( " DeepSeek added and answered" ) , " {}" , r.body) ;
e71846aa Andy McCall 1 month ago ↶
990
let prompt = assistants:: prompt_for_repo( & app.db, & repo) .await.unwrap ( ) ;
991
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}" ) ;
c7067c7b Andy McCall 16 days ago
992
let repo_page = get ( app, " /acme/plans/settings/assistants" , Some ( & andy) ) .await.body;
e71846aa Andy McCall 1 month ago ↶
993
assert! ( repo_page.contains ( " from the acme organisation" ) & & repo_page.contains ( " From the acme organisation" ) , " {repo_page}" ) ;
994
let on = assistants:: enabled_for_repo( & app.db, & repo) .await.unwrap ( ) ;
995
assert_eq! ( on.iter ( ) .map ( | a | a.name.as_str ( ) ) .collect:: < Vec< _ > > ( ) , [ " Claude" , " DeepSeek" ] ) ;
996
997
998
999
1000
let r = post ( app, " /admin/assistants" , Some ( & andy) , & format! ( " _csrf={csrf} &action=toggle&id={} " , claude.id) ) .await;
1001
assert! ( r.body.contains ( " Claude is off." ) , " {}" , r.body) ;
1002
let r = post ( app, " /acme/plans/settings/assistants" , Some ( & andy) , & add ( " Claude" , " " ) ) .await;
c7067c7b Andy McCall 16 days ago
1003
let r = settled ( app, r, Some ( & andy) , " /acme/plans/settings/assistants" ) .await;
206c1fc1 Andy McCall 1 month ago ↶
1004
assert! ( r.body.contains ( " Claude added and answered" ) , " {}" , r.body) ;
e71846aa Andy McCall 1 month ago ↶
1005
let on = assistants:: enabled_for_repo( & app.db, & repo) .await.unwrap ( ) ;
1006
assert_eq! ( on.iter ( ) .map ( | a | a.name.as_str ( ) ) .collect:: < Vec< _ > > ( ) , [ " DeepSeek" ] ) ;
c7067c7b Andy McCall 16 days ago
1007
let repo_page = get ( app, " /acme/plans/settings/assistants" , Some ( & andy) ) .await.body;
e71846aa Andy McCall 1 month ago ↶
1008
assert! ( repo_page.contains ( " off above" ) , " {repo_page}" ) ;
1009
post ( app, " /admin/assistants" , Some ( & andy) , & format! ( " _csrf={csrf} &action=toggle&id={} " , claude.id) ) .await;
1010
let on = assistants:: enabled_for_repo( & app.db, & repo) .await.unwrap ( ) ;
1011
assert_eq! ( on.iter ( ) .map ( | a | ( a.name.as_str ( ) , a.scope.as_str ( ) ) ) .collect:: < Vec< _ > > ( ) , [ ( " DeepSeek" , " org" ) , ( " Claude" , " repo" ) ] ) ;
1012
1013
1014
let invite = {
1015
let r = post ( app, " /admin/invite" , Some ( & andy) , & format! ( " _csrf={csrf} " ) ) .await;
1016
let i = r.body.find ( " invite=" ) .map ( | i | i + 7 ) .expect ( " an invitation" ) ;
1017
r.body[ i.. ] .chars ( ) .take_while ( | c | c.is_ascii_alphanumeric ( ) ) .collect:: < String> ( )
1018
} ;
1019
let bob = register ( app, " bob" , & invite) .await;
1020
assert_eq! (
1021
post ( app, " /admin/assistants" , Some ( & bob) , & format! ( " _csrf={csrf} &action=prompt&prompt=x" ) ) .await.status,
1022
StatusCode:: NOT_FOUND
1023
) ;
1024
assert_eq! (
1025
post ( app, " /orgs/acme/settings/assistants" , Some ( & bob) , & format! ( " _csrf={csrf} &action=prompt&prompt=x" ) ) .await.status,
1026
StatusCode:: NOT_FOUND
1027
) ;
1028
assert_eq! (
1029
post ( app, " /acme/plans/settings/assistants" , Some ( & bob) , & format! ( " _csrf={csrf} &action=prompt&prompt=x" ) ) .await.status,
1030
StatusCode:: NOT_FOUND
1031
) ;
1032
1033
let r = post ( app, " /acme/plans/settings/delete" , Some ( & andy) , & format! ( " _csrf={csrf} &confirm=acme%2Fplans" ) ) .await;
1034
assert! ( r.status = = StatusCode:: SEE_OTHER | | r.status = = StatusCode:: OK , " {} {}" , r.status, r.body) ;
1035
assert! ( assistants:: list( & app.db, Scope:: Repo( repo.id) ) .await.unwrap ( ) .is_empty ( ) ) ;
1036
}
89e89836 Andy McCall 1 month ago ↶
1037
1038
# [ tokio ::test ( flavor = " multi_thread" ) ]
1039
async fn accounts_elsewhere_are_checked_and_shown_on_the_profile ( ) {
1040
let h = hub ( ) .await;
1041
let app = & h.app;
1042
let andy = register ( app, " andy" , " " ) .await;
1043
let page = get ( app, " /settings" , Some ( & andy) ) .await.body;
1044
let csrf = csrf_of ( & page) ;
1045
let base = format! ( " _csrf={csrf} &display_name=Andy&email=andy%40example.com&bio=&website=&location=" ) ;
1046
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;
1047
assert! (
1048
r.body.contains ( " value=\" andy.bsky.social\" " )
1049
& & r.body.contains ( " value=\" @[email protected] \" " )
1050
& & r.body.contains ( " value=\" andymccall\" " ) ,
1051
" {}" ,
1052
r.body
1053
) ;
1054
let profile = get ( app, " /andy" , None ) .await.body;
1055
assert! ( profile.contains ( " href=\" https://bsky.app/profile/andy.bsky.social\" " ) & & profile.contains ( " @andy.bsky.social" ) , " {profile}" ) ;
1056
assert! ( profile.contains ( " href=\" https://fosstodon.org/@andy\" " ) & & profile.contains ( " rel=\" nofollow noopener me\" " ) , " {profile}" ) ;
1057
assert! ( profile.contains ( " href=\" https://github.com/andymccall\" " ) & & profile.contains ( " title=\" GitHub\" " ) , " {profile}" ) ;
1058
1059
let r = post ( app, " /settings" , Some ( & andy) , & format! ( " {base} &bluesky=not+a+handle&mastodon=&github=" ) ) .await;
1060
assert! ( r.body.contains ( " not a Bluesky handle" ) , " {}" , r.body) ;
1061
assert! ( get ( app, " /andy" , None ) .await.body.contains ( " andy.bsky.social" ) ) ;
1062
1063
post ( app, " /settings" , Some ( & andy) , & format! ( " {base} &bluesky=&mastodon=&github=" ) ) .await;
1064
assert! ( ! get ( app, " /andy" , None ) .await.body.contains ( " class=\" social\" " ) ) ;
83940d53 Andy McCall 1 month ago ↶
1065
}
1066
1067
1068
async fn mock_provider ( ) -> ( String, std:: sync:: Arc< std:: sync:: Mutex< Vec< String> > > ) {
1069
use std:: sync:: { Arc, Mutex} ;
1070
let seen: Arc< Mutex< Vec< String> > > = Arc:: new( Mutex:: new( vec! [ ] ) ) ;
1071
let s1 = seen.clone ( ) ;
1072
let s2 = seen.clone ( ) ;
1073
let router = axum:: Router:: new( )
1074
.route (
1075
" /github/token" ,
1076
axum:: routing:: post( move | body: String | {
1077
let s1 = s1.clone ( ) ;
1078
async move {
1079
s1.lock ( ) .unwrap ( ) .push ( format! ( " github token {body} " ) ) ;
1080
axum:: Json( serde_json:: json! ( { " access_token" : " gh-token" , " token_type" : " bearer" } ) )
1081
}
1082
} ) ,
1083
)
1084
.route (
1085
" /github/userinfo" ,
1086
axum:: routing:: get( | headers : axum:: http:: HeaderMap| async move {
1087
assert_eq! ( headers.get ( " authorization" ) .and_then ( | v | v.to_str ( ) .ok ( ) ) , Some ( " Bearer gh-token" ) ) ;
1088
axum:: Json( serde_json:: json! ( { " id" : 583231 , " login" : " octocat" , " name" : " The Octocat" , " email" : null } ) )
1089
} ) ,
1090
)
1091
.route (
1092
" /github/userinfo/emails" ,
1093
axum:: routing:: get( | | async {
1094
axum:: Json( serde_json:: json! ( [
1095
{ " email" : " [email protected] " , " primary" : false , " verified" : false } ,
1096
{ " email" : " [email protected] " , " primary" : true , " verified" : true }
1097
] ) )
1098
} ) ,
1099
)
1100
.route (
1101
" /google/token" ,
1102
axum:: routing:: post( move | body: String | {
1103
let s2 = s2.clone ( ) ;
1104
async move {
1105
s2.lock ( ) .unwrap ( ) .push ( format! ( " google token {body} " ) ) ;
1106
axum:: Json( serde_json:: json! ( { " access_token" : " g-token" , " id_token" : " x" } ) )
1107
}
1108
} ) ,
1109
)
1110
.route (
810896d4 Andy McCall 21 days ago ↶
1111
" /gitlab/token" ,
1112
axum:: routing:: post( | | async { axum:: Json( serde_json:: json! ( { " access_token" : " gl-token" , " token_type" : " Bearer" } ) ) } ) ,
1113
)
1114
.route (
1115
" /gitlab/userinfo" ,
1116
axum:: routing:: get( | headers : axum:: http:: HeaderMap| async move {
1117
assert_eq! ( headers.get ( " authorization" ) .and_then ( | v | v.to_str ( ) .ok ( ) ) , Some ( " Bearer gl-token" ) ) ;
1118
axum:: Json( serde_json:: json! ( { " sub" : " 1234567" , " nickname" : " tanuki" , " name" : " The Tanuki" , " email" : " [email protected] " , " email_verified" : true } ) )
1119
} ) ,
1120
)
1121
.route (
83940d53 Andy McCall 1 month ago ↶
1122
" /google/userinfo" ,
1123
axum:: routing:: get( | | async {
1124
axum:: Json( serde_json:: json! ( { " sub" : " 1076915035000615071" , " email" : " [email protected] " , " email_verified" : true , " name" : " Andy McCall" } ) )
1125
} ) ,
1126
) ;
1127
let listener = tokio:: net:: TcpListener:: bind( " 127.0.0.1:0" ) .await.unwrap ( ) ;
1128
let port = listener.local_addr ( ) .unwrap ( ) .port ( ) ;
1129
tokio:: spawn( async move { axum:: serve( listener, router) .await.unwrap ( ) } ) ;
1130
( format! ( " http://127.0.0.1:{port} " ) , seen)
1131
}
1132
1133
fn state_of ( res : & Res) -> ( String, String) {
1134
let cookie = res.raw_cookies.iter ( ) .find ( | c | c.starts_with ( " oxyhub_signin=" ) ) .expect ( " state cookie" ) .clone ( ) ;
1135
let value = cookie.trim_start_matches ( " oxyhub_signin=" ) .split ( ' ;' ) .next ( ) .unwrap ( ) .to_string ( ) ;
1136
( cookie, value)
1137
}
1138
1139
# [ tokio ::test ( flavor = " multi_thread" ) ]
1140
async fn people_sign_in_with_github_or_google_once_an_admin_sets_it_up ( ) {
1141
hermetic ( ) ;
1142
let ( mock, seen) = mock_provider ( ) .await;
1143
let dir = tempfile:: tempdir( ) .unwrap ( ) ;
1144
let mut config = Config:: load( dir.path ( ) ) .unwrap ( ) ;
c8684d44 Andy McCall 19 days ago
1145
1146
config.registration.setup_code = " test-setup" .into ( ) ;
83940d53 Andy McCall 1 month ago ↶
1147
let listener = tokio:: net:: TcpListener:: bind( " 127.0.0.1:0" ) .await.unwrap ( ) ;
1148
let port = listener.local_addr ( ) .unwrap ( ) .port ( ) ;
1149
config.server.base_url = format! ( " http://127.0.0.1:{port} " ) ;
1150
config.registration.mode = " open" .into ( ) ;
1151
config.auth.signin_mock = Some ( mock.clone ( ) ) ;
1152
let app = oxygenhub:: build( config) .await.unwrap ( ) ;
1153
let router = oxygenhub:: router( app.clone ( ) ) ;
1154
tokio:: spawn( async move { axum:: serve( listener, router) .await.unwrap ( ) } ) ;
1155
let app = & app;
1156
let andy = register ( app, " andy" , " " ) .await;
1157
let andy_row = db:: accounts:: account_by_name( & app.db, " andy" ) .await.unwrap ( ) .unwrap ( ) ;
1158
db:: accounts:: set_email_verified( & app.db, andy_row.id, true ) .await.unwrap ( ) ;
1159
1160
1161
assert! ( ! get ( app, " /login" , None ) .await.body.contains ( " /login/github" ) ) ;
737de0fa Andy McCall 15 days ago ↶
1162
let admin = get ( app, " /admin/signin" , Some ( & andy) ) .await.body;
83940d53 Andy McCall 1 month ago ↶
1163
assert! ( admin.contains ( " Sign in with" ) & & admin.contains ( & format! ( " http://127.0.0.1:{port} /login/github/callback" ) ) , " {admin}" ) ;
1164
let csrf = csrf_of ( & admin) ;
1165
let r =
1166
post ( app, " /admin/signin" , Some ( & andy) , & format! ( " _csrf={csrf} &provider=github&client_id=gh-cid&secret=gh-sek&enabled=1" ) ) .await;
1167
assert! ( r.body.contains ( " Signing in with GitHub is on." ) & & ! r.body.contains ( " gh-sek" ) , " {}" , r.body) ;
1168
let r = post ( app, " /admin/signin" , Some ( & andy) , & format! ( " _csrf={csrf} &provider=google&client_id=g-cid&secret=g-sek&enabled=1" ) ) .await;
1169
assert! ( r.body.contains ( " Signing in with Google is on." ) , " {}" , r.body) ;
1170
let login = get ( app, " /login" , None ) .await.body;
1171
assert! ( login.contains ( " href=\" /login/github\" " ) & & login.contains ( " href=\" /login/google\" " ) , " {login}" ) ;
1172
1173
1174
let r = get ( app, " /login/github" , None ) .await;
1175
assert_eq! ( r.status, StatusCode:: SEE_OTHER ) ;
1176
let to = r.location.clone ( ) .unwrap ( ) ;
1177
assert! ( to.starts_with ( & format! ( " {mock} /github/authorize?client_id=gh-cid&redirect_uri=" ) ) & & to.contains ( " &state=" ) , " {to}" ) ;
1178
let ( cookie, state) = state_of ( & r) ;
1179
let bad = send_with ( app, " GET" , " /login/github/callback?code=c1&state=wrong" , Some ( & cookie) , None ) .await;
1180
assert! ( bad.location.as_deref ( ) .unwrap_or ( " " ) .starts_with ( " /login?error=" ) , " {:?}" , bad.location) ;
1181
let ok = send_with ( app, " GET" , & format! ( " /login/github/callback?code=c1&state={state} " ) , Some ( & cookie) , None ) .await;
1182
assert_eq! ( ok.status, StatusCode:: SEE_OTHER , " {}" , ok.body) ;
1183
assert_eq! ( ok.location.as_deref ( ) , Some ( " /" ) ) ;
1184
let octo_cookie = ok.cookie.clone ( ) .expect ( " signed in" ) ;
1185
let octo = db:: accounts:: account_by_name( & app.db, " octocat" ) .await.unwrap ( ) .expect ( " account made from the GitHub login" ) ;
1186
assert! (
1187
( octo.email.as_str ( ) , octo.email_verified, octo.display_name.as_str ( ) ) = = ( " [email protected] " , true , " The Octocat" ) ,
1188
" {octo:?}"
1189
) ;
1190
assert! ( seen.lock ( ) .unwrap ( ) .iter ( ) .any ( | s | s.contains ( " github token" ) & & s.contains ( " code=c1" ) & & s.contains ( " client_secret=gh-sek" ) ) ) ;
1191
assert! ( get ( app, " /settings" , Some ( & octo_cookie) ) .await.body.contains ( " octocat" ) ) ;
1192
1193
let r = get ( app, " /login/github" , None ) .await;
1194
let ( cookie, state) = state_of ( & r) ;
1195
let ok = send_with ( app, " GET" , & format! ( " /login/github/callback?code=c2&state={state} " ) , Some ( & cookie) , None ) .await;
1196
assert_eq! ( ok.location.as_deref ( ) , Some ( " /" ) ) ;
1197
assert_eq! ( db:: accounts:: account_count( & app.db) .await.unwrap ( ) , 2 ) ;
810896d4 Andy McCall 21 days ago ↶
1198
1199
1200
1201
let r = post (
1202
app,
1203
" /admin/signin" ,
1204
Some ( & andy) ,
1205
& format! ( " _csrf={csrf} &provider=gitlab&client_id=gl-cid&secret=gl-sek&enabled=1&base=http%3A%2F%2Fgitlab.example.org" ) ,
1206
)
1207
.await;
1208
assert! ( r.body.contains ( " is an https:// address" ) , " {}" , r.body) ;
1209
let r = post (
1210
app,
1211
" /admin/signin" ,
1212
Some ( & andy) ,
1213
& format! ( " _csrf={csrf} &provider=gitlab&client_id=gl-cid&secret=gl-sek&enabled=1&base=https%3A%2F%2Fgitlab.example.org%2F" ) ,
1214
)
1215
.await;
1216
assert! ( r.body.contains ( " Signing in with GitLab is on." ) & & r.body.contains ( " value=\" https://gitlab.example.org\" " ) , " {}" , r.body) ;
1217
let r = post (
1218
app,
1219
" /admin/signin" ,
1220
Some ( & andy) ,
1221
& format! ( " _csrf={csrf} &provider=github&client_id=gh-cid&enabled=1&base=https%3A%2F%2Fx.org" ) ,
1222
)
1223
.await;
1224
assert! ( r.body.contains ( " GitHub has no address to set." ) , " {}" , r.body) ;
1225
let login = get ( app, " /login" , None ) .await.body;
1226
assert! ( login.contains ( " signin-btn gitlab" ) & & login.contains ( " Sign in with GitLab" ) , " {login}" ) ;
1227
let r = get ( app, " /login/gitlab" , None ) .await;
1228
assert! ( r.location.clone ( ) .unwrap ( ) .contains ( " scope=openid%20profile%20email" ) , " {:?}" , r.location) ;
1229
let ( cookie, state) = state_of ( & r) ;
1230
let ok = send_with ( app, " GET" , & format! ( " /login/gitlab/callback?code=c9&state={state} " ) , Some ( & cookie) , None ) .await;
1231
assert_eq! ( ok.location.as_deref ( ) , Some ( " /" ) , " {}" , ok.body) ;
1232
let tanuki = db:: accounts:: account_by_name( & app.db, " tanuki" ) .await.unwrap ( ) .expect ( " account made from the GitLab username" ) ;
1233
assert! ( ( tanuki.email.as_str ( ) , tanuki.email_verified) = = ( " [email protected] " , true ) , " {tanuki:?}" ) ;
1234
assert_eq! ( db:: accounts:: account_count( & app.db) .await.unwrap ( ) , 3 ) ;
83940d53 Andy McCall 1 month ago ↶
1235
1236
1237
let r = get ( app, " /login/google" , None ) .await;
1238
assert! ( r.location.clone ( ) .unwrap ( ) .contains ( " prompt=select_account" ) ) ;
1239
let ( cookie, state) = state_of ( & r) ;
1240
let ok = send_with ( app, " GET" , & format! ( " /login/google/callback?code=c3&state={state} " ) , Some ( & cookie) , None ) .await;
1241
assert_eq! ( ok.location.as_deref ( ) , Some ( " /" ) , " {}" , ok.body) ;
1242
let as_andy = ok.cookie.clone ( ) .unwrap ( ) ;
1243
let sec = get ( app, " /settings/security" , Some ( & as_andy) ) .await.body;
1244
assert! (
1245
sec.contains ( " Connected accounts" )
8597fb5c Andy McCall 15 days ago ↶
1246
& & sec.contains ( " <strong>Google</strong> <span class=\" mute\" >andy</span>" )
1247
& & sec.contains ( " <strong>GitHub</strong> <span class=\" small mute\" >not connected</span>" )
1248
& & sec.contains ( " href=\" /settings/security/connect/github\" >Connect</a>" ) ,
83940d53 Andy McCall 1 month ago ↶
1249
" {sec}"
1250
) ;
810896d4 Andy McCall 21 days ago ↶
1251
assert_eq! ( db:: accounts:: account_count( & app.db) .await.unwrap ( ) , 3 , " no fourth account" ) ;
83940d53 Andy McCall 1 month ago ↶
1252
1253
let csrf_s = csrf_of ( & sec) ;
1254
let r = post ( app, " /settings/security/disconnect/google" , Some ( & as_andy) , & format! ( " _csrf={csrf_s} " ) ) .await;
1255
assert! ( r.location.as_deref ( ) .unwrap_or ( " " ) .contains ( " notice=Google%20disconnected" ) , " {:?}" , r.location) ;
8597fb5c Andy McCall 15 days ago ↶
1256
assert! ( ! get ( app, " /settings/security" , Some ( & as_andy) )
1257
.await
1258
.body
1259
.contains ( " <strong>Google</strong> <span class=\" mute\" >andy</span>" ) ) ;
83940d53 Andy McCall 1 month ago ↶
1260
let r = get ( app, " /settings/security/connect/google" , Some ( & as_andy) ) .await;
1261
assert_eq! ( r.status, StatusCode:: SEE_OTHER ) ;
1262
let ( cookie, state) = state_of ( & r) ;
1263
let both = format! ( " {cookie} ; oxyhub={as_andy} " ) ;
1264
let ok = send_with ( app, " GET" , & format! ( " /login/google/callback?code=c4&state={state} " ) , Some ( & both) , None ) .await;
1265
assert! ( ok.location.as_deref ( ) .unwrap_or ( " " ) .contains ( " notice=Google%20connected%20as%20andy" ) , " {:?} {}" , ok.location, ok.body) ;
1266
1267
1268
db:: accounts:: set_setting( & app.db, " registration_mode" , " closed" ) .await.unwrap ( ) ;
1269
sqlx:: query( " DELETE FROM account_logins WHERE provider = 'github'" ) .execute ( & app.db) .await.unwrap ( ) ;
3b1bc27f Andy McCall 20 days ago
1270
sqlx:: query( " UPDATE accounts SET email_verified = FALSE WHERE username = 'octocat'" ) .execute ( & app.db) .await.unwrap ( ) ;
83940d53 Andy McCall 1 month ago ↶
1271
let r = get ( app, " /login/github" , None ) .await;
1272
let ( cookie, state) = state_of ( & r) ;
1273
let no = send_with ( app, " GET" , & format! ( " /login/github/callback?code=c5&state={state} " ) , Some ( & cookie) , None ) .await;
1274
assert! ( no.location.as_deref ( ) .unwrap_or ( " " ) .contains ( " registration%20is%20closed" ) , " {:?}" , no.location) ;
1275
assert! ( no.cookie.is_none ( ) ) ;
89e89836 Andy McCall 1 month ago ↶
1276
}
e8e7158b Andy McCall 1 month ago ↶
1277
1278
# [ tokio ::test ( flavor = " multi_thread" ) ]
1279
async fn administration_is_sectioned_and_its_audit_log_pages ( ) {
1280
let h = hub ( ) .await;
1281
let app = & h.app;
1282
let andy = register ( app, " andy" , " " ) .await;
1283
let andy_row = db:: accounts:: account_by_name( & app.db, " andy" ) .await.unwrap ( ) .unwrap ( ) ;
1284
for i in 0 .. 60 {
1285
db:: accounts:: audit( & app.db, Some ( & andy_row) , " test.event" , & format! ( " t{i} " ) , " " ) .await.unwrap ( ) ;
737de0fa Andy McCall 15 days ago ↶
1286
}
1287
1288
1289
let overview = get ( app, " /admin" , Some ( & andy) ) .await.body;
1290
assert! ( overview.contains ( " class=\" settings-nav\" " ) & & overview.contains ( " data-settings-anchors" ) , " {overview}" ) ;
1291
for ( id, path) in [
1292
( " accounts" , " /admin/accounts" ) ,
1293
( " registration" , " /admin/registration" ) ,
1294
( " organisations" , " /admin/organisations" ) ,
1295
( " signin" , " /admin/signin" ) ,
1296
( " mail" , " /admin/mail" ) ,
1297
( " security" , " /admin/security" ) ,
1298
( " variables" , " /admin/variables" ) ,
1299
( " secrets" , " /admin/secrets" ) ,
1300
( " assistants" , " /admin/assistants" ) ,
1301
( " audit" , " /admin/audit" ) ,
1302
] {
1303
assert! ( overview.contains ( & format! ( " href=\" {path} \" data-slug=\" {id} \" " ) ) , " {id} in the sidebar: {overview}" ) ;
1304
let page = get ( app, path, Some ( & andy) ) .await.body;
1305
assert! ( page.contains ( & format! ( " id=\" {id} \" " ) ) , " section {id}: {page}" ) ;
1306
assert! ( page.contains ( & format! ( " href=\" {path} \" data-slug=\" {id} \" class=\" on\" aria-current=\" page\" " ) ) , " {id} marked: {page}" ) ;
e8e7158b Andy McCall 1 month ago ↶
1307
}
737de0fa Andy McCall 15 days ago ↶
1308
for path in
1309
[ " /admin/runners" , " /admin/runners/images" , " /admin/runners/settings" , " /admin/storage" , " /admin/diffusion" , " /admin/backups" ]
1310
{
1311
let page = get ( app, path, Some ( & andy) ) .await.body;
1312
assert! ( page.contains ( " class=\" settings-nav\" " ) & & page.contains ( & format! ( " href=\" {path} \" " ) ) , " {path} has the sidebar" ) ;
e8e7158b Andy McCall 1 month ago ↶
1313
}
737de0fa Andy McCall 15 days ago ↶
1314
1315
assert_eq! ( get ( app, " /admin/mail" , None ) .await.status, StatusCode:: NOT_FOUND ) ;
1316
assert_eq! ( get ( app, " /admin/audit" , None ) .await.status, StatusCode:: NOT_FOUND ) ;
1317
let page = get ( app, " /admin/audit" , Some ( & andy) ) .await.body;
e8e7158b Andy McCall 1 month ago ↶
1318
assert! ( page.contains ( " >t59<" ) & & ! page.contains ( " >t5<" ) , " the newest page only" ) ;
1319
let i = page.find ( " audit_before=" ) .expect ( " an older link" ) + " audit_before=" .len ( ) ;
1320
let before: String = page[ i.. ] .chars ( ) .take_while ( | c | c.is_ascii_digit ( ) ) .collect ( ) ;
737de0fa Andy McCall 15 days ago ↶
1321
let older = get ( app, & format! ( " /admin/audit?audit_before={before} " ) , Some ( & andy) ) .await.body;
e8e7158b Andy McCall 1 month ago ↶
1322
assert! ( older.contains ( " >t5<" ) & & ! older.contains ( " >t59<" ) , " the next page: {older}" ) ;
1323
}
155cab14 Andy McCall 1 month ago ↶
1324
1325
# [ tokio ::test ( flavor = " multi_thread" ) ]
1326
async fn adding_an_assistant_offers_providers_and_only_models_the_endpoint_lists ( ) {
1327
let h = hub ( ) .await;
1328
let app = & h.app;
1329
let andy = register ( app, " andy" , " " ) .await;
1330
let router = axum:: Router:: new( )
1331
.route (
1332
" /v1/models" ,
1333
axum:: routing:: get( | headers : axum:: http:: HeaderMap| async move {
1334
assert_eq! ( headers.get ( " authorization" ) .and_then ( | v | v.to_str ( ) .ok ( ) ) , Some ( " Bearer sk-m" ) ) ;
1335
axum:: Json( serde_json:: json! ( { " data" : [
1336
{ " id" : " models/gemini-3-pro" } , { " id" : " gemini-3-flash" } , { " id" : " text-embedding-004" }
1337
] } ) )
1338
} ) ,
1339
)
1340
.route (
1341
" /v1/chat/completions" ,
1342
axum:: routing:: post( | | async { axum:: Json( serde_json:: json! ( { " choices" : [ { " message" : { " content" : " OK" } } ] } ) ) } ) ,
1343
) ;
1344
let listener = tokio:: net:: TcpListener:: bind( " 127.0.0.1:0" ) .await.unwrap ( ) ;
1345
let port = listener.local_addr ( ) .unwrap ( ) .port ( ) ;
1346
tokio:: spawn( async move { axum:: serve( listener, router) .await.unwrap ( ) } ) ;
1347
let ep = format! ( " http%3A%2F%2F127.0.0.1%3A{port} %2Fv1%2Fchat%2Fcompletions" ) ;
1348
737de0fa Andy McCall 15 days ago ↶
1349
let page = get ( app, " /admin/assistants" , Some ( & andy) ) .await.body;
155cab14 Andy McCall 1 month ago ↶
1350
assert! ( page.contains ( " data-preset-select" ) & & page.contains ( " Google Gemini" ) & & page.contains ( " Other (enter the endpoint)" ) , " {page}" ) ;
1351
assert! ( page.contains ( " LIST MODELS" ) ) ;
1352
let csrf = csrf_of ( & page) ;
1353
1354
let r =
1355
post ( app, " /admin/assistants/models" , Some ( & andy) , & format! ( " _csrf={csrf} &endpoint={ep} &api=chat-completions&token=sk-m" ) ) .await;
1356
let v: serde_json:: Value = serde_json:: from_str( & r.body) .unwrap ( ) ;
1357
assert_eq! ( v[ " models" ] , serde_json:: json! ( [ " gemini-3-flash" , " gemini-3-pro" ] ) , " {}" , r.body) ;
1358
1359
let r = post (
1360
app,
1361
" /admin/assistants" ,
1362
Some ( & andy) ,
1363
& format! ( " _csrf={csrf} &action=add&preset=gemini&endpoint={ep} &model=gemini-2.5-pro&token=sk-m&enabled=1" ) ,
1364
)
1365
.await;
1366
assert! ( r.body.contains ( " gemini-2.5-pro is not a model this endpoint offers" ) & & r.body.contains ( " gemini-3-pro" ) , " {}" , r.body) ;
1367
1368
let r = post (
1369
app,
1370
" /admin/assistants" ,
1371
Some ( & andy) ,
1372
& format! ( " _csrf={csrf} &action=add&preset=gemini&endpoint={ep} &model=gemini-3-pro&token=sk-m&enabled=1" ) ,
1373
)
1374
.await;
1375
assert! ( r.body.contains ( " Gemini added and answered: OK" ) , " {}" , r.body) ;
1376
let a = & oxygenhub:: assistants:: list( & app.db, oxygenhub:: levels:: Scope:: Site) .await.unwrap ( ) [ 0 ] ;
1377
assert_eq! ( ( a.name.as_str ( ) , a.api.as_str ( ) , a.model.as_str ( ) ) , ( " Gemini" , " chat-completions" , " gemini-3-pro" ) ) ;
1378
1379
let r =
1380
post ( app, " /admin/assistants/models" , Some ( & andy) , & format! ( " _csrf={csrf} &id={} &endpoint={ep} &api=chat-completions" , a.id) ) .await;
1381
assert! ( r.body.contains ( " gemini-3-flash" ) , " {}" , r.body) ;
1382
}
2a0c09c0 Andy McCall 21 days ago ↶
1383
1384
1385
1386
1387
1388
# [ tokio ::test ( flavor = " multi_thread" ) ]
1389
async fn a_profile_shows_the_year_as_a_signal ( ) {
1390
let h = hub ( ) .await;
1391
let app = & h.app;
1392
register ( app, " andy" , " " ) .await;
1393
let andy = db:: accounts:: account_by_name( & app.db, " andy" ) .await.unwrap ( ) .unwrap ( ) ;
c0095389 Andy McCall 21 days ago ↶
1394
3b1bc27f Andy McCall 20 days ago
1395
sqlx:: query( " UPDATE accounts SET created_ms = $1 WHERE id = $2" )
a2fbad48 Andy McCall 21 days ago ↶
1396
.bind ( db:: now_ms( ) - 400 * 86_400_000 )
1397
.bind ( andy.id)
1398
.execute ( & app.db)
1399
.await
1400
.unwrap ( ) ;
2a0c09c0 Andy McCall 21 days ago ↶
1401
let game = db:: repos:: create( & app.db, " andy" , " game" , " " , hub_api:: Visibility:: Public) .await.unwrap ( ) ;
1402
let secret = db:: repos:: create( & app.db, " andy" , " secret" , " " , hub_api:: Visibility:: Private) .await.unwrap ( ) ;
1403
let now = db:: now_ms( ) ;
1404
for ( repo, ago_days, changes, reason, via) in [
1405
( game.id, 3 , 5 , " " , " " ) ,
1406
( game.id, 3 , 2 , " " , " Claude" ) ,
1407
( game.id, 40 , 4 , " " , " " ) ,
1408
( game.id, 2 , 9 , " landed #1 (web)" , " " ) ,
1409
( secret.id, 1 , 50 , " " , " " ) ,
1410
] {
3b1bc27f Andy McCall 20 days ago
1411
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 ↶
1412
.bind ( repo)
1413
.bind ( now - ago_days * 86_400_000 )
1414
.bind ( reason)
1415
.bind ( changes)
1416
.bind ( andy.id)
1417
.bind ( via)
1418
.execute ( & app.db)
1419
.await
1420
.unwrap ( ) ;
1421
}
3b1bc27f Andy McCall 20 days ago
1422
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 ↶
1423
.bind ( game.id)
1424
.bind ( andy.id)
1425
.bind ( now - 3 * 86_400_000 )
1426
.execute ( & app.db)
1427
.await
1428
.unwrap ( ) ;
1429
1430
let page = get ( app, " /andy" , None ) .await.body;
1431
let start = page.find ( " id=\" pulse-data\" >" ) .expect ( " the pulse is on the profile" ) + " id=\" pulse-data\" >" .len ( ) ;
1432
let json: serde_json:: Value = serde_json:: from_str( & page[ start.. start + page[ start.. ] .find ( " </script>" ) .unwrap ( ) ] ) .unwrap ( ) ;
1433
assert_eq! ( json[ " repos" ] , serde_json:: json! ( [ " andy/game" ] ) ) ;
1434
let days = json[ " days" ] .as_array ( ) .unwrap ( ) ;
c0095389 Andy McCall 21 days ago ↶
1435
1436
assert_eq! ( ( days.len ( ) , json[ " months" ] .as_i64 ( ) ) , ( 91 , Some ( 3 ) ) ) ;
2a0c09c0 Andy McCall 21 days ago ↶
1437
let total: i64 = days.iter ( ) .map ( | d | d[ 0 ] .as_i64 ( ) .unwrap ( ) ) .sum ( ) ;
1438
let via: i64 = days.iter ( ) .map ( | d | d[ 1 ] .as_i64 ( ) .unwrap ( ) ) .sum ( ) ;
1439
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 ↶
1440
assert_eq! ( days[ 87 ] , serde_json:: json! ( [ 7 , 2 , [ [ 0 , 7 ] ] ] ) , " three days ago" ) ;
1441
assert_eq! ( json[ " peaks" ] [ 0 ] , serde_json:: json! ( [ 87 , " game v1.0" ] ) ) ;
a2fbad48 Andy McCall 21 days ago ↶
1442
db:: accounts:: create_account( & app.db, " bea" , " [email protected] " , " x" , " salt" , false ) .await.unwrap ( ) ;
1443
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 ↶
1444
assert! (
1445
page.contains ( " data-total=\" 11\" " ) & & page.find ( " pulse-box" ) .unwrap ( ) < page.find ( " <h2>Repositories</h2>" ) .unwrap ( ) ,
1446
" above the repositories"
1447
) ;
1448
}
9a8e3b44 Andy McCall 20 days ago ↶
1449
1450
1451
1452
1453
1454
# [ tokio ::test ( flavor = " multi_thread" ) ]
1455
async fn an_https_site_takes_nothing_over_http ( ) {
1456
hermetic ( ) ;
1457
let dir = tempfile:: tempdir( ) .unwrap ( ) ;
1458
let mut config = Config:: load( dir.path ( ) ) .unwrap ( ) ;
c8684d44 Andy McCall 19 days ago
1459
1460
config.registration.setup_code = " test-setup" .into ( ) ;
9a8e3b44 Andy McCall 20 days ago ↶
1461
config.server.base_url = " https://hub.example.org" .into ( ) ;
1462
config.registration.mode = " open" .into ( ) ;
1463
let app = oxygenhub:: build( config) .await.unwrap ( ) ;
1464
let call = | method : & 'static str , path : & 'static str , header : Option< ( & 'static str , & 'static str ) > , body : Option< & 'static str > | {
1465
let app = app.clone ( ) ;
1466
async move {
1467
let mut req = Request:: builder( ) .method ( method) .uri ( path) ;
1468
if let Some ( ( k, v) ) = header {
1469
req = req.header ( k, v) ;
1470
}
1471
if body.is_some ( ) {
1472
req = req.header ( header:: CONTENT_TYPE , " application/x-www-form-urlencoded" ) ;
1473
}
1474
oxygenhub:: router( app) .oneshot ( req.body ( body.map ( Body:: from) .unwrap_or_else ( Body:: empty) ) .unwrap ( ) ) .await.unwrap ( )
1475
}
1476
} ;
1477
1478
let r = call ( " GET" , " /login?next=%2Fx" , Some ( ( " x-forwarded-proto" , " http" ) ) , None ) .await;
1479
assert! ( r.status ( ) .is_redirection ( ) , " {}" , r.status ( ) ) ;
1480
assert_eq! ( r.headers ( ) .get ( header:: LOCATION ) .unwrap ( ) , " https://hub.example.org/login?next=%2Fx" ) ;
1481
1482
let r = call ( " POST" , " /login" , Some ( ( " cf-visitor" , r # "{"scheme":"http"}"# ) ) , Some ( " username=a&password=b" ) ) .await;
1483
assert_eq! ( r.status ( ) , StatusCode:: BAD_REQUEST ) ;
1484
1485
assert_eq! ( call ( " GET" , " /login" , Some ( ( " x-forwarded-proto" , " https" ) ) , None ) .await.status ( ) , StatusCode:: OK ) ;
1486
assert_eq! ( call ( " GET" , " /login" , None , None ) .await.status ( ) , StatusCode:: OK ) ;
1487
1488
let r = call (
1489
" POST" ,
1490
" /register" ,
1491
Some ( ( " x-forwarded-proto" , " https" ) ) ,
c8684d44 Andy McCall 19 days ago
1492
Some ( " username=andy&email=a%40example.org&password=correct-horse-battery&setup=test-setup" ) ,
9a8e3b44 Andy McCall 20 days ago ↶
1493
)
1494
.await;
1495
let cookie = r.headers ( ) .get ( header:: SET_COOKIE ) .expect ( " signed in" ) .to_str ( ) .unwrap ( ) ;
1496
assert! ( cookie.contains ( " ; Secure" ) & & cookie.contains ( " HttpOnly" ) , " {cookie}" ) ;
2cbf6f34 Andy McCall 20 days ago ↶
1497
1498
assert! ( call ( " GET" , " /login" , Some ( ( " x-forwarded-proto" , " https" ) ) , None )
1499
.await
1500
.headers ( )
1501
.get ( header:: STRICT_TRANSPORT_SECURITY )
1502
.is_none ( ) ) ;
1503
assert_eq! ( oxygenhub:: headers:: hsts( " https://hub.example.org" , 300 ) .unwrap ( ) , " max-age=300" ) ;
1504
assert! ( oxygenhub:: headers:: hsts( " http://127.0.0.1:7300" , 300 ) .is_none ( ) , " never over plain http" ) ;
1505
assert! ( oxygenhub:: headers:: hsts( " https://hub.example.org" , 0 ) .is_none ( ) ) ;
9a8e3b44 Andy McCall 20 days ago ↶
1506
}
aa0bfbd1 Andy McCall 20 days ago ↶
1507
1508
# [ tokio ::test ]
1509
async fn guessing_passwords_is_slowed_per_account_and_per_address ( ) {
1510
hermetic ( ) ;
1511
let dir = tempfile:: tempdir( ) .unwrap ( ) ;
1512
let mut config = Config:: load( dir.path ( ) ) .unwrap ( ) ;
c8684d44 Andy McCall 19 days ago
1513
1514
config.registration.setup_code = " test-setup" .into ( ) ;
aa0bfbd1 Andy McCall 20 days ago ↶
1515
config.server.behind_proxy = true ;
1516
config.registration.mode = " open" .into ( ) ;
1517
let app = oxygenhub:: build( config) .await.unwrap ( ) ;
1518
let from = | addr : & 'static str , form : String| {
1519
let app = app.clone ( ) ;
1520
async move {
1521
let req = Request:: builder( )
1522
.method ( " POST" )
1523
.uri ( " /login" )
1524
.header ( " x-forwarded-for" , addr)
1525
.header ( header:: CONTENT_TYPE , " application/x-www-form-urlencoded" ) ;
1526
let res = oxygenhub:: router( app) .oneshot ( req.body ( Body:: from( form) ) .unwrap ( ) ) .await.unwrap ( ) ;
1527
let status = res.status ( ) ;
1528
( status, String :: from_utf8_lossy( & res.into_body ( ) .collect ( ) .await.unwrap ( ) .to_bytes ( ) ) .into_owned ( ) )
1529
}
1530
} ;
1531
register ( & app, " andy" , " " ) .await;
c8684d44 Andy McCall 19 days ago
1532
let right = | | " username=andy&password=correct-horse-battery&setup=test-setup" .to_string ( ) ;
aa0bfbd1 Andy McCall 20 days ago ↶
1533
1534
1535
1536
for i in 0 .. 6 {
1537
let ( _ , body) = from ( " 198.51.100.1" , " username=andy&password=wrong" .into ( ) ) .await;
1538
assert! ( body.contains ( " Wrong username or password." ) , " try {i}" ) ;
1539
}
1540
let ( status, body) = from ( " 198.51.100.2" , right ( ) ) .await;
1541
assert_eq! ( status, StatusCode:: OK , " not signed in" ) ;
1542
assert! ( body.contains ( " Too many tries: try again in" ) , " {body}" ) ;
1543
let audit = db:: accounts:: audit_rows( & app.db, 50 ) .await.unwrap ( ) ;
1544
assert! ( audit.iter ( ) .any ( | r | r.action = = " login.throttled" & & r.target = = " andy" & & r.detail.contains ( " 198.51.100.1" ) ) ) ;
1545
app.throttle.lock ( ) .unwrap ( ) .cleared ( & oxygenhub:: throttle:: Key:: account( " login" , " andy" ) ) ;
1546
1547
1548
1549
for i in 0 .. 21 {
1550
let ( _ , body) = from ( " 203.0.113.7" , format! ( " username=nobody{i} &password=wrong" ) ) .await;
1551
assert! ( body.contains ( " Wrong username or password." ) , " try {i}" ) ;
1552
}
1553
let ( _ , body) = from ( " 203.0.113.7" , right ( ) ) .await;
1554
assert! ( body.contains ( " Too many tries" ) , " {body}" ) ;
1555
let ( status, _ ) = from ( " 198.51.100.3" , right ( ) ) .await;
1556
assert_eq! ( status, StatusCode:: SEE_OTHER , " another address signs in" ) ;
1557
}
d74ce22f Andy McCall 20 days ago ↶
1558
1559
# [ tokio ::test ]
1560
async fn every_page_carries_the_security_headers_and_violations_are_reported ( ) {
1561
let h = hub ( ) .await;
1562
let res = oxygenhub:: router( h.app.clone ( ) ) .oneshot ( Request:: get( " /login" ) .body ( Body:: empty( ) ) .unwrap ( ) ) .await.unwrap ( ) ;
1563
let hd = res.headers ( ) ;
1564
assert_eq! ( hd[ header:: X_CONTENT_TYPE_OPTIONS ] , " nosniff" ) ;
1565
assert_eq! ( hd[ header:: REFERRER_POLICY ] , " strict-origin-when-cross-origin" ) ;
1566
assert_eq! ( hd[ header:: X_FRAME_OPTIONS ] , " DENY" ) ;
1567
assert! ( hd[ " permissions-policy" ] .to_str ( ) .unwrap ( ) .contains ( " camera=()" ) ) ;
04683947 Andy McCall 9 days ago ↶
1568
1569
let csp = hd[ header:: CONTENT_SECURITY_POLICY ] .to_str ( ) .unwrap ( ) ;
d74ce22f Andy McCall 20 days ago ↶
1570
for part in [
1571
" default-src 'self'" ,
1572
" object-src 'none'" ,
1573
" frame-ancestors 'none'" ,
1574
" form-action 'self'" ,
1575
" 'wasm-unsafe-eval'" ,
1576
" report-uri /csp-report" ,
04683947 Andy McCall 9 days ago ↶
1577
" script-src 'self' 'wasm-unsafe-eval' https://static.cloudflareinsights.com" ,
d74ce22f Andy McCall 20 days ago ↶
1578
] {
1579
assert! ( csp.contains ( part) , " {part} in {csp}" ) ;
1580
}
04683947 Andy McCall 9 days ago ↶
1581
assert! ( hd.get ( header:: CONTENT_SECURITY_POLICY_REPORT_ONLY ) .is_none ( ) & & hd.get ( header:: STRICT_TRANSPORT_SECURITY ) .is_none ( ) ) ;
d74ce22f Andy McCall 20 days ago ↶
1582
1583
let res = oxygenhub:: router( h.app.clone ( ) ) .oneshot ( Request:: get( " /no/such/page" ) .body ( Body:: empty( ) ) .unwrap ( ) ) .await.unwrap ( ) ;
1584
assert_eq! ( res.headers ( ) [ header:: X_CONTENT_TYPE_OPTIONS ] , " nosniff" ) ;
1585
1586
let report =
1587
r # "{"csp-report":{"document-uri":"http://127.0.0.1/login","violated-directive":"script-src-elem","blocked-uri":"inline"}}"# ;
1588
let req = Request:: post( " /csp-report" ) .header ( header:: CONTENT_TYPE , " application/csp-report" ) .body ( Body:: from( report) ) .unwrap ( ) ;
1589
assert_eq! ( oxygenhub:: router( h.app.clone ( ) ) .oneshot ( req) .await.unwrap ( ) .status ( ) , StatusCode:: NO_CONTENT ) ;
1590
let big = Request:: post( " /csp-report" ) .body ( Body:: from( vec! [ b ' ' ; 64 * 1024 ] ) ) .unwrap ( ) ;
1591
assert_eq! ( oxygenhub:: router( h.app.clone ( ) ) .oneshot ( big) .await.unwrap ( ) .status ( ) , StatusCode:: PAYLOAD_TOO_LARGE ) ;
1592
}
3dd6582d Andy McCall 20 days ago ↶
1593
1594
# [ tokio ::test ]
1595
async fn security_txt_names_the_contact_an_administrator_sets ( ) {
1596
let h = hub ( ) .await;
1597
let app = & h.app;
1598
assert_eq! ( get ( app, " /.well-known/security.txt" , None ) .await.status, StatusCode:: NOT_FOUND , " no contact, no file" ) ;
1599
let andy = register ( app, " andy" , " " ) .await;
737de0fa Andy McCall 15 days ago ↶
1600
let page = get ( app, " /admin/security" , Some ( & andy) ) .await.body;
3dd6582d Andy McCall 20 days ago ↶
1601
assert! ( page.contains ( " No contact yet" ) , " the panel" ) ;
1602
let csrf = csrf_of ( & page) ;
1603
let set = | contact : & 'static str , policy : & 'static str | {
1604
let ( andy, csrf) = ( andy.clone ( ) , csrf.clone ( ) ) ;
1605
async move { post ( app, " /admin/security" , Some ( & andy) , & format! ( " _csrf={csrf} &contact={contact} &policy={policy} " ) ) .await }
1606
} ;
1607
assert_eq! ( set ( " javascript%3Aalert(1)" , " " ) .await.status, StatusCode:: BAD_REQUEST ) ;
1608
assert_eq! ( set ( " sec%40example.org" , " http%3A%2F%2Fexample.org" ) .await.status, StatusCode:: BAD_REQUEST , " policy over https only" ) ;
1609
let r = set ( " sec%40example.org" , " https%3A%2F%2Fexample.org%2Fpolicy" ) .await;
1610
assert! ( r.body.contains ( " Security contact saved" ) , " {}" , r.body) ;
1611
for path in [ " /.well-known/security.txt" , " /security.txt" ] {
1612
let r = get ( app, path, None ) .await;
1613
assert_eq! ( r.status, StatusCode:: OK ) ;
1614
assert! ( r.body.starts_with ( " Contact: mailto:[email protected] \n Expires: 20" ) , " {}" , r.body) ;
1615
assert! ( r.body.contains ( " Policy: https://example.org/policy\n " ) & & r.body.contains ( " /.well-known/security.txt\n " ) , " {}" , r.body) ;
1616
}
1617
1618
let r = post ( app, " /admin/invite" , Some ( & andy) , & format! ( " _csrf={csrf} " ) ) .await;
1619
let code = r.body.split ( " register?invite=" ) .nth ( 1 ) .unwrap ( ) .split ( ' <' ) .next ( ) .unwrap ( ) .trim ( ) .to_string ( ) ;
1620
let bob = register ( app, " bob" , & code) .await;
1621
let r = post ( app, " /admin/security" , Some ( & bob) , & format! ( " _csrf={csrf} &contact=x%40evil.example" ) ) .await;
1622
assert_ne! ( r.status, StatusCode:: OK ) ;
1623
1624
set ( " " , " " ) .await;
1625
assert_eq! ( get ( app, " /security.txt" , None ) .await.status, StatusCode:: NOT_FOUND ) ;
2cbf6f34 Andy McCall 20 days ago ↶
1626
1627
1628
f59e9680 Andy McCall 20 days ago ↶
1629
set ( " first%40example.org" , " " ) .await;
2cbf6f34 Andy McCall 20 days ago ↶
1630
let page = get ( app, " /security-contact" , None ) .await;
1631
assert_eq! ( page.status, StatusCode:: OK ) ;
f59e9680 Andy McCall 20 days ago ↶
1632
assert! ( page.body.contains ( " [email protected] " ) , " security.txt's address until the page has its own" ) ;
2cbf6f34 Andy McCall 20 days ago ↶
1633
assert! ( page.body.contains ( " Report a security problem" ) & & page.body.contains ( " tell us privately" ) , " {}" , page.body) ;
1634
let r = post (
1635
app,
1636
" /admin/security" ,
1637
Some ( & andy) ,
1638
& format! (
1639
" _csrf={csrf} &contact=https%3A%2F%2Fhub.example%2Fsecurity-contact&email=security%40example.org&page=Mail+us.+**Thank+you.**"
1640
) ,
1641
)
1642
.await;
1643
assert! ( r.body.contains ( " Security contact saved" ) , " {}" , r.body) ;
1644
let page = get ( app, " /security-contact" , None ) .await.body;
1645
assert! (
1646
page.contains ( " [email protected] " ) & & page.contains ( " <strong>Thank you.</strong>" ) & & ! page.contains ( " tell us privately" ) ,
1647
" {page}"
1648
) ;
1649
let r = post ( app, " /admin/security" , Some ( & andy) , & format! ( " _csrf={csrf} &contact=sec%40example.org&email=not-an-address" ) ) .await;
1650
assert_eq! ( r.status, StatusCode:: BAD_REQUEST ) ;
1651
assert! ( ! oxygenhub:: repos:: names:: valid_owner( " security-contact" ) & & ! oxygenhub:: repos:: names:: valid_owner( " live" ) , " reserved" ) ;
3dd6582d Andy McCall 20 days ago ↶
1652
}
0024ccb1 Andy McCall 20 days ago ↶
1653
1654
# [ tokio ::test ]
1655
async fn sessions_are_records_that_can_be_listed_ended_and_expire ( ) {
1656
let h = hub ( ) .await;
1657
let app = & h.app;
1658
let a = register ( app, " andy" , " " ) .await;
1659
assert_eq! ( a.len ( ) , 64 , " a random id, not the account's name" ) ;
c8684d44 Andy McCall 19 days ago
1660
let b =
1661
post ( app, " /login" , None , " username=andy&password=correct-horse-battery&setup=test-setup" ) .await.cookie.expect ( " signed in again" ) ;
0024ccb1 Andy McCall 20 days ago ↶
1662
assert_ne! ( a, b, " each sign-in is a new session" ) ;
1663
1664
let page = get ( app, " /settings/security" , Some ( & a) ) .await.body;
1665
assert_eq! ( page.matches ( " /end\" " ) .count ( ) , 2 , " both browsers listed" ) ;
1666
8597fb5c Andy McCall 15 days ago ↶
1667
let entries: Vec< & str > = page.split ( " <div class=\" token-name\" ><strong>" ) .skip ( 1 ) .filter ( | e | e.contains ( " /end\" " ) ) .collect ( ) ;
0024ccb1 Andy McCall 20 days ago ↶
1668
assert_eq! ( entries.iter ( ) .filter ( | e | e.contains ( " <span class=\" tag\" >this browser</span>" ) ) .count ( ) , 1 ) ;
1669
let other = entries.iter ( ) .find ( | e | ! e.contains ( " class=\" tag\" >this browser" ) ) .unwrap ( ) ;
1670
let other: i64 = other.split ( " /settings/security/sessions/" ) .nth ( 1 ) .unwrap ( ) .split ( ' /' ) .next ( ) .unwrap ( ) .parse ( ) .unwrap ( ) ;
1671
let csrf = csrf_of ( & page) ;
1672
let r = post ( app, & format! ( " /settings/security/sessions/{other} /end" ) , Some ( & a) , & format! ( " _csrf={csrf} " ) ) .await;
1673
assert_eq! ( r.status, StatusCode:: SEE_OTHER ) ;
8597fb5c Andy McCall 15 days ago ↶
1674
let signed_in = | c : String| async move { get ( app, " /settings/security" , Some ( & c) ) .await.body.contains ( " Sign out everywhere else" ) } ;
0024ccb1 Andy McCall 20 days ago ↶
1675
assert! ( signed_in ( a.clone ( ) ) .await, " this browser stays" ) ;
1676
assert! ( ! signed_in ( b.clone ( ) ) .await, " that browser is signed out" ) ;
1677
1678
1679
sqlx:: query( " UPDATE sessions SET seen_ms = seen_ms - 15 * 86400000" ) .execute ( & app.db) .await.unwrap ( ) ;
1680
assert! ( ! signed_in ( a.clone ( ) ) .await, " idle too long" ) ;
1681
1682
c8684d44 Andy McCall 19 days ago
1683
let c = post ( app, " /login" , None , " username=andy&password=correct-horse-battery&setup=test-setup" ) .await.cookie.unwrap ( ) ;
0024ccb1 Andy McCall 20 days ago ↶
1684
let csrf = csrf_of ( & get ( app, " /settings/security" , Some ( & c) ) .await.body) ;
1685
post ( app, " /logout" , Some ( & c) , & format! ( " _csrf={csrf} " ) ) .await;
1686
assert! ( ! signed_in ( c) .await, " the old cookie no longer works" ) ;
1687
1688
assert! ( ! signed_in ( " andy.0123456789abcdef" .into ( ) ) .await) ;
1689
}
3ff52534 Andy McCall 20 days ago ↶
1690
1691
# [ tokio ::test ]
1692
async fn an_invitation_signs_up_through_a_provider_once ( ) {
1693
hermetic ( ) ;
1694
let ( mock, _ ) = mock_provider ( ) .await;
1695
let dir = tempfile:: tempdir( ) .unwrap ( ) ;
1696
let mut config = Config:: load( dir.path ( ) ) .unwrap ( ) ;
c8684d44 Andy McCall 19 days ago
1697
1698
config.registration.setup_code = " test-setup" .into ( ) ;
3ff52534 Andy McCall 20 days ago ↶
1699
let listener = tokio:: net:: TcpListener:: bind( " 127.0.0.1:0" ) .await.unwrap ( ) ;
1700
let port = listener.local_addr ( ) .unwrap ( ) .port ( ) ;
1701
config.server.base_url = format! ( " http://127.0.0.1:{port} " ) ;
1702
config.registration.mode = " invite" .into ( ) ;
1703
config.auth.signin_mock = Some ( mock.clone ( ) ) ;
1704
let app = oxygenhub:: build( config) .await.unwrap ( ) ;
1705
let router = oxygenhub:: router( app.clone ( ) ) ;
1706
tokio:: spawn( async move { axum:: serve( listener, router) .await.unwrap ( ) } ) ;
1707
let app = & app;
1708
let andy = register ( app, " andy" , " " ) .await;
1709
let admin = get ( app, " /admin" , Some ( & andy) ) .await.body;
1710
let csrf = csrf_of ( & admin) ;
1711
post ( app, " /admin/signin" , Some ( & andy) , & format! ( " _csrf={csrf} &provider=github&client_id=gh-cid&secret=gh-sek&enabled=1" ) ) .await;
1712
1713
1714
assert! ( ! get ( app, " /register" , None ) .await.body.contains ( " /login/github" ) ) ;
1715
let r = get ( app, " /login/github" , None ) .await;
1716
let ( cookie, state) = state_of ( & r) ;
1717
let no = send_with ( app, " GET" , & format! ( " /login/github/callback?code=c1&state={state} " ) , Some ( & cookie) , None ) .await;
1718
assert! ( no.location.as_deref ( ) .unwrap_or ( " " ) .contains ( " by%20invitation" ) , " {:?}" , no.location) ;
1719
assert_eq! ( db:: accounts:: account_count( & app.db) .await.unwrap ( ) , 1 ) ;
1720
1721
1722
1723
let r = post ( app, " /admin/invite" , Some ( & andy) , & format! ( " _csrf={csrf} " ) ) .await;
1724
let code = r.body.split ( " register?invite=" ) .nth ( 1 ) .unwrap ( ) .split ( ' <' ) .next ( ) .unwrap ( ) .trim ( ) .to_string ( ) ;
1725
let page = get ( app, & format! ( " /register?invite={code} " ) , None ) .await.body;
1726
assert! ( page.contains ( & format! ( " href=\" /login/github?invite={code} \" " ) ) & & page.contains ( " Sign up with GitHub" ) , " {page}" ) ;
1727
let r = get ( app, & format! ( " /login/github?invite={code} " ) , None ) .await;
1728
assert_eq! ( r.status, StatusCode:: SEE_OTHER ) ;
1729
let ( cookie, state) = state_of ( & r) ;
1730
let ok = send_with ( app, " GET" , & format! ( " /login/github/callback?code=c2&state={state} " ) , Some ( & cookie) , None ) .await;
1731
assert_eq! ( ok.location.as_deref ( ) , Some ( " /" ) , " {}" , ok.body) ;
1732
let octo = db:: accounts:: account_by_name( & app.db, " octocat" ) .await.unwrap ( ) .expect ( " signed up through the invitation" ) ;
1733
assert! ( ! db:: accounts:: invitation_unused( & app.db, & code) .await.unwrap ( ) , " the invitation is spent" ) ;
1734
let used_by: Option< i64 > =
3b1bc27f Andy McCall 20 days ago
1735
sqlx:: query_scalar( " SELECT used_by FROM invitations WHERE code = $1" ) .bind ( & code) .fetch_one ( & app.db) .await.unwrap ( ) ;
3ff52534 Andy McCall 20 days ago ↶
1736
assert_eq! ( used_by, Some ( octo.id) ) ;
1737
1738
1739
assert! ( ! get ( app, & format! ( " /register?invite={code} " ) , None ) .await.body.contains ( " /login/github" ) ) ;
1740
let r = get ( app, & format! ( " /login/github?invite={code} " ) , None ) .await;
1741
assert_eq! ( r.status, StatusCode:: BAD_REQUEST ) ;
1742
assert! ( r.body.contains ( " That invitation has been used or does not exist." ) , " {}" , r.body) ;
1743
assert_eq! ( get ( app, " /login/github?invite=not-a.code" , None ) .await.status, StatusCode:: BAD_REQUEST ) ;
c8684d44 Andy McCall 19 days ago
1744
}
1745
1746
1747
1748
1749
# [ tokio ::test ( flavor = " multi_thread" ) ]
1750
async fn the_first_account_needs_the_setup_code_the_hub_made ( ) {
1751
hermetic ( ) ;
1752
let dir = tempfile:: tempdir( ) .unwrap ( ) ;
1753
let config = Config:: load( dir.path ( ) ) .unwrap ( ) ;
1754
let app = oxygenhub:: build( config) .await.unwrap ( ) ;
1755
let file = dir.path ( ) .join ( " setup-code" ) ;
1756
let code = std:: fs:: read_to_string( & file) .expect ( " an empty hub makes a setup code" ) .trim ( ) .to_string ( ) ;
1757
assert_eq! ( code.len ( ) , 24 ) ;
1758
# [ cfg ( unix ) ]
1759
{
1760
use std:: os:: unix:: fs:: PermissionsExt;
1761
assert_eq! ( std:: fs:: metadata( & file) .unwrap ( ) .permissions ( ) .mode ( ) & 0o777 , 0o600 ) ;
1762
}
1763
let page = get ( & app, " /register" , None ) .await.body;
1764
assert! ( page.contains ( " name=\" setup\" " ) & & ! page.contains ( & code) , " asks for the code, never shows it" ) ;
1765
1766
let form = | setup : & str | format! ( " username=mallory&email=m%40example.com&password=correct-horse-battery&setup={setup} " ) ;
1767
for wrong in [ " " , " 0123456789abcdef01234567" ] {
1768
let r = post ( & app, " /register" , None , & form ( wrong) ) .await;
1769
assert! ( r.cookie.is_none ( ) & & r.body.contains ( " needs the setup code" ) , " {}" , r.body) ;
1770
}
1771
assert_eq! ( db:: accounts:: account_count( & app.db) .await.unwrap ( ) , 0 ) ;
1772
1773
let r = post ( & app, " /register" , None , & form ( & code) .replace ( " mallory" , " andy" ) ) .await;
1774
assert_eq! ( r.status, StatusCode:: SEE_OTHER , " {}" , r.body) ;
1775
assert! ( db:: accounts:: account_by_name( & app.db, " andy" ) .await.unwrap ( ) .unwrap ( ) .is_admin) ;
1776
assert! ( ! file.exists ( ) , " the code is spent" ) ;
1777
1778
let r = post ( & app, " /register" , None , & form ( & code) ) .await;
1779
assert! ( r.body.contains ( " invitation" ) , " {}" , r.body) ;
3ff52534 Andy McCall 20 days ago ↶
1780
}
8a48dd83 Andy McCall 18 days ago ↶
1781
1782
1783
1784
1785
1786
# [ tokio ::test ( flavor = " multi_thread" ) ]
1787
async fn the_bell_inbox_and_dashboard_listen_on_the_viewer_s_topics ( ) {
1788
let h = hub ( ) .await;
1789
let app = & h.app;
1790
let andy = register ( app, " andy" , " " ) .await;
1791
let id = db:: accounts:: account_by_name( & app.db, " andy" ) .await.unwrap ( ) .unwrap ( ) .id;
1792
let topic = format! ( " user:{id} " ) ;
1793
let page = get ( app, " /explore" , Some ( & andy) ) .await.body;
1794
assert! ( page.contains ( & format! ( " data-live=\" {topic} \" data-fragment=\" bell\" " ) ) , " the bell on every page" ) ;
1795
let inbox = get ( app, " /notifications" , Some ( & andy) ) .await.body;
1796
assert! ( inbox.contains ( & format! ( " data-live=\" {topic} \" data-fragment=\" inbox\" " ) ) , " the inbox list" ) ;
1797
let dash = get ( app, " /" , Some ( & andy) ) .await.body;
1798
assert! ( dash.contains ( " data-fragment=\" cards\" " ) & & dash.contains ( " data-fragment=\" feed\" " ) , " {dash}" ) ;
1799
assert! ( dash.contains ( & format! ( " data-live=\" {topic} " ) ) , " the dashboard listens on the viewer" ) ;
1800
let anon = get ( app, " /explore" , None ) .await.body;
1801
assert! ( ! anon.contains ( " data-fragment=\" bell\" " ) , " nothing to hear signed out" ) ;
1802
}
a22e4aec Andy McCall 4 days ago ↶
1803
1804
1805
async fn bearer ( app : & Shared, method : & str , path : & str , token : & str , json : Option< & str > ) -> ( StatusCode, serde_json:: Value) {
1806
let mut req = Request:: builder( ) .method ( method) .uri ( path) .header ( header:: AUTHORIZATION , format! ( " Bearer {token} " ) ) ;
1807
let body = match json {
1808
Some ( j) => {
1809
req = req.header ( header:: CONTENT_TYPE , " application/json" ) ;
1810
Body:: from( j.to_string ( ) )
1811
}
1812
None => Body:: empty( ) ,
1813
} ;
1814
let res = oxygenhub:: router( app.clone ( ) ) .oneshot ( req.body ( body) .unwrap ( ) ) .await.unwrap ( ) ;
1815
let status = res.status ( ) ;
1816
let bytes = res.into_body ( ) .collect ( ) .await.unwrap ( ) .to_bytes ( ) ;
1817
( status, serde_json:: from_slice( & bytes) .unwrap_or ( serde_json:: Value:: Null) )
1818
}
1819
1820
1821
fn field ( page : & str , name : & str ) -> String {
1822
page.split ( & format! ( " name=\" {name} \" value=\" " ) )
1823
.nth ( 1 )
1824
.unwrap_or_else ( | | panic! ( " no {name} in {page}" ) )
1825
.split ( ' "' )
1826
.next ( )
1827
.unwrap ( )
1828
.to_string ( )
1829
}
1830
1831
1832
1833
1834
1835
# [ tokio ::test ( flavor = " multi_thread" ) ]
1836
async fn a_phone_approves_sign_ins_and_the_sudo_step ( ) {
1837
use ring:: rand:: SystemRandom;
1838
use ring:: signature:: { EcdsaKeyPair, KeyPair, ECDSA_P256_SHA256_ASN1_SIGNING } ;
1839
let h = hub ( ) .await;
1840
let app = & h.app;
1841
let andy = register ( app, " andy" , " " ) .await;
1842
let now_code = | secret : & str | oxygenhub:: auth:: totp:: current( secret, ( db:: now_ms( ) / 1000 ) as u64 ) .unwrap ( ) ;
1843
1844
1845
let page = get ( app, " /settings/tokens" , Some ( & andy) ) .await;
1846
let r = post (
1847
app,
1848
" /settings/tokens" ,
1849
Some ( & andy) ,
1850
& format! ( " _csrf={} &name=Pixel&scopes=user%3Aread&scopes=repo%3Aread" , csrf_of ( & page.body) ) ,
1851
)
1852
.await;
1853
let token = r.body.split ( " oxyhub_" ) .nth ( 1 ) .map ( | s | format! ( " oxyhub_{} " , s.split ( ' <' ) .next ( ) .unwrap ( ) .trim ( ) ) ) .expect ( & r.body) ;
1854
1855
1856
let rng = SystemRandom:: new( ) ;
1857
let pkcs8 = EcdsaKeyPair:: generate_pkcs8( & ECDSA_P256_SHA256_ASN1_SIGNING , & rng) .unwrap ( ) ;
1858
let key = EcdsaKeyPair:: from_pkcs8( & ECDSA_P256_SHA256_ASN1_SIGNING , pkcs8.as_ref ( ) , & rng) .unwrap ( ) ;
1859
let spki = format! ( " 3059301306072a8648ce3d020106082a8648ce3d030107034200{} " , hex:: encode( key.public_key ( ) .as_ref ( ) ) ) ;
1860
let add = | code : & str | format! ( r # "{{ "public_key":"{spki} ","code":"{code} "}} "# ) ;
1861
1862
1863
let ( st, _ ) = bearer ( app, " POST" , " /api/v1/user/authenticators" , & token, Some ( & add ( " 123456" ) ) ) .await;
1864
assert_eq! ( st, StatusCode:: CONFLICT ) ;
1865
let sec = get ( app, " /settings/security" , Some ( & andy) ) .await;
1866
let csrf = csrf_of ( & sec.body) ;
1867
let r = post ( app, " /settings/security/totp/begin" , Some ( & andy) , & format! ( " _csrf={csrf} " ) ) .await;
1868
let secret = field ( & r.body, " secret" ) ;
1869
let r =
1870
post ( app, " /settings/security/totp/enable" , Some ( & andy) , & format! ( " _csrf={csrf} &secret={secret} &code={} " , now_code ( & secret) ) ) .await;
1871
assert! ( r.body.contains ( " Recovery codes" ) , " {}" , r.body) ;
1872
let ( st, _ ) = bearer ( app, " POST" , " /api/v1/user/authenticators" , & token, Some ( & add ( " 000000" ) ) ) .await;
1873
assert_eq! ( st, StatusCode:: FORBIDDEN , " a token alone cannot add a phone" ) ;
1874
let ( st, v) = bearer ( app, " POST" , " /api/v1/user/authenticators" , & token, Some ( & add ( & now_code ( & secret) ) ) ) .await;
1875
assert_eq! ( st, StatusCode:: CREATED , " {v}" ) ;
1876
assert_eq! ( ( v[ " name" ] .as_str ( ) , v[ " this" ] .as_bool ( ) ) , ( Some ( " Pixel" ) , Some ( true ) ) ) ;
1877
assert! ( get ( app, " /settings/security" , Some ( & andy) ) .await.body.contains ( " <strong>Pixel</strong>" ) , " listed under Phones" ) ;
1878
1879
1880
let sign_in = | | post ( app, " /login" , None , " username=andy&password=correct-horse-battery" ) ;
1881
let r = sign_in ( ) .await;
1882
assert! ( r.body.contains ( " Open OxygenHub on your phone" ) , " {}" , r.body) ;
1883
let ( id, pending) = ( field ( & r.body, " approval" ) , field ( & r.body, " pending" ) ) ;
1884
let shown: i64 =
1885
r.body.split ( " class=\" approve-number\" " ) .nth ( 1 ) .unwrap ( ) .split ( ' >' ) .nth ( 1 ) .unwrap ( ) .split ( ' <' ) .next ( ) .unwrap ( ) .parse ( ) .unwrap ( ) ;
1886
let poll = | id : String, pending : String| async move {
1887
let r = post ( app, " /login/approval" , None , & format! ( " username=andy&pending={pending} &approval={id} " ) ) .await;
1888
serde_json:: from_str:: < serde_json:: Value> ( & r.body) .unwrap ( ) [ " state" ] .as_str ( ) .unwrap ( ) .to_string ( )
1889
} ;
1890
assert_eq! ( poll ( id.clone ( ) , pending.clone ( ) ) .await, " pending" ) ;
1891
assert_eq! ( poll ( id.clone ( ) , " 1.forged" .into ( ) ) .await, " unknown" , " only the sign-in that proved the password may ask" ) ;
1892
let ( _ , v) = bearer ( app, " GET" , " /api/v1/user/approvals" , & token, None ) .await;
1893
assert_eq! ( v[ 0 ] [ " id" ] .as_str ( ) , Some ( id.as_str ( ) ) , " {v}" ) ;
1894
assert_eq! ( v[ 0 ] [ " kind" ] , " signin" ) ;
1895
assert! ( v[ 0 ] [ " what" ] .as_str ( ) .unwrap ( ) .starts_with ( " Sign in from" ) , " {v}" ) ;
1896
let choices: Vec< i64 > = v[ 0 ] [ " choices" ] .as_array ( ) .unwrap ( ) .iter ( ) .map ( | n | n.as_i64 ( ) .unwrap ( ) ) .collect ( ) ;
1897
assert! ( choices.contains ( & shown) & & choices.len ( ) = = 3 , " {choices:?} {shown}" ) ;
1898
let sign = | id : & str , n : i64 | hex:: encode( key.sign ( & rng, & oxygenhub:: auth:: phone:: message( id, n, true ) ) .unwrap ( ) .as_ref ( ) ) ;
1899
1900
let other = EcdsaKeyPair:: from_pkcs8(
1901
& ECDSA_P256_SHA256_ASN1_SIGNING ,
1902
EcdsaKeyPair:: generate_pkcs8( & ECDSA_P256_SHA256_ASN1_SIGNING , & rng) .unwrap ( ) .as_ref ( ) ,
1903
& rng,
1904
)
1905
.unwrap ( ) ;
1906
let forged = hex:: encode( other.sign ( & rng, & oxygenhub:: auth:: phone:: message( & id, shown, true ) ) .unwrap ( ) .as_ref ( ) ) ;
1907
let answer = | n : i64 , sig : & str | format! ( r # "{{ "approve":true,"number":{n} ,"signature":"{sig} "}} "# ) ;
1908
let ( st, _ ) = bearer ( app, " POST" , & format! ( " /api/v1/user/approvals/{id} " ) , & token, Some ( & answer ( shown, & forged) ) ) .await;
1909
assert_eq! ( st, StatusCode:: FORBIDDEN ) ;
1910
let ( st, v) = bearer ( app, " POST" , & format! ( " /api/v1/user/approvals/{id} " ) , & token, Some ( & answer ( shown, & sign ( & id, shown) ) ) ) .await;
1911
assert_eq! ( ( st, v[ " state" ] .as_str ( ) ) , ( StatusCode:: OK , Some ( " approved" ) ) , " {v}" ) ;
1912
assert_eq! ( poll ( id.clone ( ) , pending.clone ( ) ) .await, " approved" ) ;
1913
let finish = | id : String, pending : String| async move {
1914
post ( app, " /login" , None , & format! ( " username=andy&pending={pending} &approval={id} &next=/" ) ) .await
1915
} ;
1916
let r = finish ( id.clone ( ) , pending.clone ( ) ) .await;
1917
assert_eq! ( r.status, StatusCode:: SEE_OTHER , " {}" , r.body) ;
1918
let phone_session = r.cookie.expect ( " signed in" ) ;
1919
let r = finish ( id.clone ( ) , pending.clone ( ) ) .await;
1920
assert! ( r.cookie.is_none ( ) & & r.body.contains ( " has not approved" ) , " an approval signs in once: {}" , r.body) ;
1921
1922
1923
let r = sign_in ( ) .await;
1924
let ( id, pending) = ( field ( & r.body, " approval" ) , field ( & r.body, " pending" ) ) ;
1925
let ( _ , v) = bearer ( app, " GET" , " /api/v1/user/approvals" , & token, None ) .await;
1926
let shown2 = r
1927
.body
1928
.split ( " class=\" approve-number\" " )
1929
.nth ( 1 )
1930
.unwrap ( )
1931
.split ( ' >' )
1932
.nth ( 1 )
1933
.unwrap ( )
1934
.split ( ' <' )
1935
.next ( )
1936
.unwrap ( )
1937
.parse:: < i64 > ( )
1938
.unwrap ( ) ;
1939
let wrong = v[ 0 ] [ " choices" ] .as_array ( ) .unwrap ( ) .iter ( ) .map ( | n | n.as_i64 ( ) .unwrap ( ) ) .find ( | n | * n ! = shown2) .unwrap ( ) ;
1940
let ( _ , v) = bearer ( app, " POST" , & format! ( " /api/v1/user/approvals/{id} " ) , & token, Some ( & answer ( wrong, & sign ( & id, wrong) ) ) ) .await;
1941
assert_eq! ( v[ " state" ] , " denied" ) ;
1942
assert_eq! ( poll ( id.clone ( ) , pending.clone ( ) ) .await, " denied" ) ;
1943
assert! ( finish ( id, pending) .await.cookie.is_none ( ) ) ;
1944
1945
let r = post ( app, " /login" , None , " username=andy&password=correct-horse-battery&use_code=1" ) .await;
1946
assert! ( r.body.contains ( " six-digit code" ) & & r.body.contains ( " Approve on my phone instead" ) , " {}" , r.body) ;
1947
1948
1949
let tokens = | c : String| async move {
1950
let page = get ( app, " /settings/tokens" , Some ( & c) ) .await;
1951
post ( app, " /settings/tokens" , Some ( & c) , & format! ( " _csrf={} &name=x&scopes=repo%3Aread" , csrf_of ( & page.body) ) ) .await
1952
} ;
1953
assert_eq! ( tokens ( phone_session.clone ( ) ) .await.status, StatusCode:: OK , " just signed in" ) ;
1954
sqlx:: query( " UPDATE sessions SET second_factor_ms = 0" ) .execute ( & app.db) .await.unwrap ( ) ;
1955
let r = tokens ( phone_session.clone ( ) ) .await;
1956
assert_eq! ( r.status, StatusCode:: SEE_OTHER ) ;
1957
assert! ( r.location.as_deref ( ) .unwrap ( ) .starts_with ( " /sudo?next=" ) , " {:?}" , r.location) ;
1958
let sudo = get ( app, " /sudo?next=/settings/tokens" , Some ( & phone_session) ) .await;
1959
assert! ( sudo.body.contains ( " APPROVE ON MY PHONE" ) , " {}" , sudo.body) ;
1960
let r = post ( app, " /sudo/phone" , Some ( & phone_session) , & format! ( " _csrf={} &next=/settings/tokens" , csrf_of ( & sudo.body) ) ) .await;
1961
let id = r.body.split ( " /sudo/approval/" ) .nth ( 1 ) .unwrap ( ) .split ( ' "' ) .next ( ) .unwrap ( ) .to_string ( ) ;
1962
let shown3: i64 =
1963
r.body.split ( " class=\" approve-number\" " ) .nth ( 1 ) .unwrap ( ) .split ( ' >' ) .nth ( 1 ) .unwrap ( ) .split ( ' <' ) .next ( ) .unwrap ( ) .parse ( ) .unwrap ( ) ;
1964
let state = | c : String, id : String| async move {
1965
serde_json:: from_str:: < serde_json:: Value> ( & get ( app, & format! ( " /sudo/approval/{id} " ) , Some ( & c) ) .await.body) .unwrap ( ) [ " state" ] .clone ( )
1966
} ;
1967
assert_eq! ( state ( phone_session.clone ( ) , id.clone ( ) ) .await, " pending" ) ;
1968
assert_eq! ( state ( andy.clone ( ) , id.clone ( ) ) .await, " unknown" , " another session cannot watch it" ) ;
1969
let ( _ , v) = bearer ( app, " GET" , " /api/v1/user/approvals" , & token, None ) .await;
1970
assert_eq! ( v[ 0 ] [ " kind" ] , " stepup" ) ;
1971
let ( _ , v) = bearer ( app, " POST" , & format! ( " /api/v1/user/approvals/{id} " ) , & token, Some ( & answer ( shown3, & sign ( & id, shown3) ) ) ) .await;
1972
assert_eq! ( v[ " state" ] , " approved" ) ;
1973
assert_eq! ( state ( phone_session.clone ( ) , id) .await, " approved" ) ;
1974
assert_eq! ( tokens ( phone_session.clone ( ) ) .await.status, StatusCode:: OK , " fresh again" ) ;
1975
1976
sqlx:: query( " UPDATE sessions SET second_factor_ms = 0" ) .execute ( & app.db) .await.unwrap ( ) ;
1977
let sudo = get ( app, " /sudo?next=/settings/tokens" , Some ( & phone_session) ) .await;
1978
let r = post ( app, " /sudo" , Some ( & phone_session) , & format! ( " _csrf={} &next=/settings/tokens&code=000000" , csrf_of ( & sudo.body) ) ) .await;
1979
assert! ( r.body.contains ( " not right" ) , " {}" , r.body) ;
1980
let r = post (
1981
app,
1982
" /sudo" ,
1983
Some ( & phone_session) ,
1984
& format! ( " _csrf={} &next=/settings/tokens&code={} " , csrf_of ( & sudo.body) , now_code ( & secret) ) ,
1985
)
1986
.await;
1987
assert_eq! ( ( r.status, r.location.as_deref ( ) ) , ( StatusCode:: SEE_OTHER , Some ( " /settings/tokens" ) ) ) ;
1988
assert_eq! ( tokens ( phone_session.clone ( ) ) .await.status, StatusCode:: OK ) ;
1989
1990
1991
let sec = get ( app, " /settings/security" , Some ( & phone_session) ) .await;
1992
let r = post (
1993
app,
1994
" /settings/security/totp/disable" ,
1995
Some ( & phone_session) ,
1996
& format! ( " _csrf={} &password=correct-horse-battery" , csrf_of ( & sec.body) ) ,
1997
)
1998
.await;
1999
assert! ( r.body.contains ( " no phone approves" ) , " {}" , r.body) ;
2000
let ( _ , v) = bearer ( app, " GET" , " /api/v1/user/authenticators" , & token, None ) .await;
2001
assert_eq! ( v.as_array ( ) .map ( Vec :: len) , Some ( 0 ) , " {v}" ) ;
2002
}