d7d18246 Andy McCall 22 days ago ↶
1
2
3
4
5
6
use oxy_core:: repo:: Repo;
7
use oxygenhub:: backups:: { self , Destination, Directory, Setting} ;
8
use oxygenhub:: config:: Config;
9
use oxygenhub:: repos:: RepoName;
10
use oxygenhub:: { db, Shared} ;
11
use std:: path:: Path;
12
use std:: sync:: Arc;
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
struct Hub {
25
app : Shared,
26
base : String,
27
_dir : tempfile:: TempDir,
28
}
29
30
async fn hub ( backups : & Path) -> Hub {
35fea423 Andy McCall 22 days ago ↶
31
hub_with ( backups, None ) .await
32
}
33
34
async fn hub_with ( backups : & Path, google : Option< & str > ) -> Hub {
d7d18246 Andy McCall 22 days ago ↶
35
hermetic ( ) ;
36
let dir = tempfile:: tempdir( ) .unwrap ( ) ;
37
let mut config = Config:: load( dir.path ( ) ) .unwrap ( ) ;
c8684d44 Andy McCall 19 days ago
38
39
config.registration.setup_code = " test-setup" .into ( ) ;
d7d18246 Andy McCall 22 days ago ↶
40
config.auth.push_tokens = vec! [ " site" .into ( ) ] ;
41
config.backups.directories = vec! [ backups.to_path_buf ( ) ] ;
35fea423 Andy McCall 22 days ago ↶
42
if let Some ( g) = google {
43
config.backups.sts_url = g.to_string ( ) ;
44
config.backups.storage_url = g.to_string ( ) ;
45
}
d7d18246 Andy McCall 22 days ago ↶
46
let listener = tokio:: net:: TcpListener:: bind( " 127.0.0.1:0" ) .await.unwrap ( ) ;
47
let port = listener.local_addr ( ) .unwrap ( ) .port ( ) ;
48
config.server.base_url = format! ( " http://127.0.0.1:{port} " ) ;
49
let app = oxygenhub:: build( config) .await.unwrap ( ) ;
50
let router = oxygenhub:: router( app.clone ( ) ) ;
51
tokio:: spawn( async move { axum:: serve( listener, router) .await.unwrap ( ) } ) ;
52
Hub { app, base: format! ( " http://127.0.0.1:{port} " ) , _dir: dir }
53
}
54
55
fn write ( root : & Path, rel : & str , content : & str ) {
56
let p = root.join ( rel) ;
57
std:: fs:: create_dir_all( p.parent ( ) .unwrap ( ) ) .unwrap ( ) ;
58
std:: fs:: write( p, content) .unwrap ( ) ;
59
}
60
61
async fn create ( app : & Shared, owner : & str , name : & str , visibility : hub_api:: Visibility) {
62
app.repos.create ( & RepoName:: parse( & format! ( " {owner} /{name} " ) ) .unwrap ( ) , None ) .unwrap ( ) ;
63
db:: repos:: create( & app.db, owner, name, " a demo" , visibility) .await.unwrap ( ) ;
64
}
65
66
fn push ( url : & str , work : & Path, files : & [( &str , &str ) ]) {
67
let mut c = Repo:: init( work) .unwrap ( ) ;
68
c.config.set ( " remote.origin.url" , url) .unwrap ( ) ;
69
c.config.set ( " remote.origin.token" , " site" ) .unwrap ( ) ;
70
c.save_config ( ) .unwrap ( ) ;
71
for ( f, t) in files {
72
write ( work, f, t) ;
73
}
74
c.snapshot ( " t" ) .unwrap ( ) ;
75
c.commit ( " Some work" , & [ ] ) .unwrap ( ) ;
76
assert! ( c.push ( " origin" , false ) .unwrap ( ) .iter ( ) .all ( | r | r.ok ) ) ;
77
}
78
79
80
fn files ( dir : & Path) -> Vec< ( String, Vec< u8 > ) > {
81
let mut out = Vec :: new( ) ;
82
let mut stack = vec! [ dir.to_path_buf ( ) ] ;
83
while let Some ( d) = stack.pop ( ) {
84
for e in std:: fs:: read_dir( & d) .unwrap ( ) .flatten ( ) {
85
let p = e.path ( ) ;
86
if p.is_dir ( ) {
87
stack.push ( p) ;
88
} else {
89
out.push ( ( p.strip_prefix ( dir) .unwrap ( ) .to_string_lossy ( ) .into_owned ( ) , std:: fs:: read( & p) .unwrap ( ) ) ) ;
90
}
91
}
92
}
93
out.sort ( ) ;
94
out
95
}
96
97
# [ tokio ::test ( flavor = " multi_thread" ) ]
98
async fn a_backup_restores_the_same_repository_and_sends_only_what_changed ( ) {
99
let store = tempfile:: tempdir( ) .unwrap ( ) ;
100
let h = hub ( store.path ( ) ) .await;
101
db:: accounts:: create_account( & h.app.db, " andy" , " [email protected] " , " x" , " salt" , false ) .await.unwrap ( ) ;
102
db:: accounts:: create_account( & h.app.db, " bob" , " [email protected] " , " x" , " salt" , false ) .await.unwrap ( ) ;
103
create ( & h.app, " andy" , " demo" , hub_api:: Visibility:: Private) .await;
104
create ( & h.app, " bob" , " other" , hub_api:: Visibility:: Public) .await;
105
let work = tempfile:: tempdir( ) .unwrap ( ) ;
106
push ( & format! ( " {} /andy/demo" , h.base) , work.path ( ) , & [ ( " README.md" , " hello\n " ) , ( " src/main.rs" , " fn main() {}\n " ) ] ) ;
107
let other = tempfile:: tempdir( ) .unwrap ( ) ;
108
push ( & format! ( " {} /bob/other" , h.base) , other.path ( ) , & [ ( " x.txt" , " bob's\n " ) ] ) ;
109
110
111
let target = store.path ( ) .display ( ) .to_string ( ) ;
112
let mut s = Setting:: off( " andy" , " user" ) ;
113
s.every = " hourly" .into ( ) ;
114
s.target = target.clone ( ) ;
115
backups:: save( & h.app.db, & s) .await.unwrap ( ) ;
116
let first = backups:: run( & h.app, " andy" , " now" ) .await.unwrap ( ) ;
117
assert_eq! ( ( first.status.as_str ( ) , first.repos) , ( " ok" , 1 ) , " {}" , first.detail) ;
118
assert! ( first.sent > 0 & & first.bytes > 0 ) ;
119
let dest = Directory { root: store.path ( ) .join ( " andy" ) } ;
120
assert! ( dest.exists ( & format! ( " runs/{} /DONE" , first.at) ) .unwrap ( ) ) ;
121
let records: serde_json:: Value = serde_json:: from_slice( & dest.get ( & format! ( " runs/{} /records/demo.json" , first.at) ) .unwrap ( ) ) .unwrap ( ) ;
122
assert_eq! ( records[ " repositories" ] [ 0 ] [ " visibility" ] , " private" ) ;
123
let text = String :: from_utf8_lossy( & dest.get ( & format! ( " runs/{} /records/demo.json" , first.at) ) .unwrap ( ) ) .into_owned ( ) ;
124
assert! ( ! text.contains ( " bob/other" ) & & ! text.contains ( " \" other\" " ) , " nothing of another owner" ) ;
125
assert! ( ! store.path ( ) .join ( " bob" ) .exists ( ) , " bob set up no backup" ) ;
126
127
128
tokio:: time:: sleep( std:: time:: Duration:: from_millis( 1100 ) ) .await;
129
let second = backups:: run( & h.app, " andy" , " now" ) .await.unwrap ( ) ;
130
assert_eq! ( ( second.status.as_str ( ) , second.sent) , ( " ok" , 0 ) , " {}" , second.detail) ;
131
132
133
let empty = tempfile:: tempdir( ) .unwrap ( ) ;
134
let fresh = hub ( empty.path ( ) ) .await;
135
let dest: Arc< dyn Destination> = Arc:: new( Directory { root: store.path ( ) .join ( " andy" ) } ) ;
136
let done = backups:: restore_into_hub( & fresh.app, dest, None ) .await.unwrap ( ) ;
137
assert_eq! ( done, vec! [ " andy/demo (repos)" ] ) ;
138
let name = RepoName:: parse( " andy/demo" ) .unwrap ( ) ;
139
assert_eq! ( files ( & fresh.app.repos.dir ( & name) ) , files ( & h.app.repos.dir ( & name) ) , " byte for byte" ) ;
140
let row = db:: repos:: get( & fresh.app.db, " andy" , " demo" ) .await.unwrap ( ) .expect ( " listed on the hub" ) ;
141
assert_eq! ( ( row.visibility.as_str ( ) , row.description.as_str ( ) ) , ( " private" , " a demo" ) ) ;
142
let ( a, b) = ( h.app.repos.open ( & name) .unwrap ( ) , fresh.app.repos.open ( & name) .unwrap ( ) ) ;
143
assert_eq! (
144
a.bookmarks ( ) .unwrap ( ) .iter ( ) .map ( | b | b.heads.clone ( ) ) .collect:: < Vec< _ > > ( ) ,
145
b.bookmarks ( ) .unwrap ( ) .iter ( ) .map ( | b | b.heads.clone ( ) ) .collect:: < Vec< _ > > ( )
146
) ;
147
148
149
let mut bad = Setting:: off( " andy" , " user" ) ;
150
bad.every = " hourly" .into ( ) ;
151
bad.target = " /tmp/somewhere-else" .into ( ) ;
152
backups:: save( & h.app.db, & bad) .await.unwrap ( ) ;
153
let refused = backups:: run( & h.app, " andy" , " now" ) .await.unwrap ( ) ;
154
assert_eq! ( refused.status, " failed" ) ;
155
assert! ( refused.detail.contains ( " not a backup directory this hub allows" ) , " {}" , refused.detail) ;
156
}
157
9041db4e Andy McCall 15 days ago ↶
158
159
d7d18246 Andy McCall 22 days ago ↶
160
# [ tokio ::test ( flavor = " multi_thread" ) ]
9041db4e Andy McCall 15 days ago ↶
161
async fn drops_are_backed_up_and_restored_as_drops ( ) {
162
let store = tempfile:: tempdir( ) .unwrap ( ) ;
163
let h = hub ( store.path ( ) ) .await;
164
db:: accounts:: create_account( & h.app.db, " andy" , " [email protected] " , " x" , " salt" , false ) .await.unwrap ( ) ;
165
create ( & h.app, " andy" , " demo" , hub_api:: Visibility:: Public) .await;
166
let id = " 0123456789abcdef01234567" ;
167
let name = RepoName:: drop( " andy" , id) .unwrap ( ) ;
168
h.app.repos.create ( & name, None ) .unwrap ( ) ;
169
db:: repos:: create_drop( & h.app.db, " andy" , id, " Notes" , " a secret drop" , hub_api:: Visibility:: Private) .await.unwrap ( ) ;
170
171
let mut s = Setting:: off( " andy" , " user" ) ;
172
s.every = " hourly" .into ( ) ;
173
s.target = store.path ( ) .display ( ) .to_string ( ) ;
174
backups:: save( & h.app.db, & s) .await.unwrap ( ) ;
175
let run = backups:: run( & h.app, " andy" , " now" ) .await.unwrap ( ) ;
176
assert_eq! ( run.status, " ok" , " {}" , run.detail) ;
177
let dest = Directory { root: store.path ( ) .join ( " andy" ) } ;
178
let repos = dest.list ( & format! ( " runs/{} /repos" , run.at) ) .unwrap ( ) ;
179
assert! ( repos.contains ( & format! ( " repos-drops~{id} .json" ) ) , " the drop's manifest, one level deep: {repos:?}" ) ;
180
assert! ( dest.list ( & format! ( " runs/{} /records" , run.at) ) .unwrap ( ) .contains ( & format! ( " drops~{id} .json" ) ) ) ;
181
let ( overview, _ ) = backups:: overview( & h.app.db) .await.unwrap ( ) ;
182
assert_eq! ( overview[ 0 ] .repos, 2 , " the repository and the drop" ) ;
183
184
let empty = tempfile:: tempdir( ) .unwrap ( ) ;
185
let fresh = hub ( empty.path ( ) ) .await;
186
db:: accounts:: create_account( & fresh.app.db, " andy" , " [email protected] " , " x" , " salt" , false ) .await.unwrap ( ) ;
187
let dest: Arc< dyn Destination> = Arc:: new( Directory { root: store.path ( ) .join ( " andy" ) } ) ;
188
let done = backups:: restore_into_hub( & fresh.app, dest, None ) .await.unwrap ( ) ;
189
assert! ( done.contains ( & format! ( " andy/drops/{id} (repos)" ) ) , " {done:?}" ) ;
190
assert! ( fresh.app.repos.exists ( & name) , " the drop's store" ) ;
191
let back = db:: repos:: drop_by_id( & fresh.app.db, id) .await.unwrap ( ) .expect ( " the drop's row" ) ;
192
assert! ( back.is_drop ( ) & & back.visibility = = " private" & & back.title = = " Notes" , " {back:?}" ) ;
193
assert! ( db:: repos:: list( & fresh.app.db) .await.unwrap ( ) .iter ( ) .all ( | r | ! r.is_drop ( ) ) , " still no drop among repositories" ) ;
194
}
195
196
# [ tokio ::test ( flavor = " multi_thread" ) ]
d7d18246 Andy McCall 22 days ago ↶
197
async fn backups_fall_due_on_their_schedule ( ) {
198
let store = tempfile:: tempdir( ) .unwrap ( ) ;
199
let h = hub ( store.path ( ) ) .await;
200
let mut s = Setting:: off( " andy" , " user" ) ;
201
s.target = store.path ( ) .display ( ) .to_string ( ) ;
202
backups:: save( & h.app.db, & s) .await.unwrap ( ) ;
203
assert! ( backups:: due( & h.app.db) .await.unwrap ( ) .is_empty ( ) , " off is never due" ) ;
204
s.every = " hourly" .into ( ) ;
205
backups:: save( & h.app.db, & s) .await.unwrap ( ) ;
206
assert_eq! ( backups:: due( & h.app.db) .await.unwrap ( ) .len ( ) , 1 , " never run: due" ) ;
3b1bc27f Andy McCall 19 days ago
207
sqlx:: query( " INSERT INTO backup_runs (owner, started_ms, status) VALUES ('andy', $1, 'ok')" )
d7d18246 Andy McCall 22 days ago ↶
208
.bind ( db:: now_ms( ) )
209
.execute ( & h.app.db)
210
.await
211
.unwrap ( ) ;
212
assert! ( backups:: due( & h.app.db) .await.unwrap ( ) .is_empty ( ) , " ran just now" ) ;
35fea423 Andy McCall 22 days ago ↶
213
}
214
215
216
217
218
# [ derive ( Default ) ]
219
struct Google {
220
221
objects : std:: collections:: BTreeMap< ( String, String) , Vec< u8 > > ,
222
223
exchanged : Vec< ( String, String) > ,
224
}
225
226
type Shared2 = Arc< std:: sync:: Mutex< Google> > ;
227
228
fn decode ( s : & str ) -> String {
229
let b = s.as_bytes ( ) ;
230
let mut out = Vec :: new( ) ;
231
let mut i = 0 ;
232
while i < b.len ( ) {
233
if b[ i] = = b ' %' & & i + 2 < b.len ( ) {
234
out.push ( u8 :: from_str_radix( & s[ i + 1 .. i + 3 ] , 16 ) .unwrap ( ) ) ;
235
i + = 3 ;
236
} else {
237
out.push ( b[ i] ) ;
238
i + = 1 ;
239
}
240
}
241
String :: from_utf8( out) .unwrap ( )
242
}
243
244
fn query ( uri : & axum:: http:: Uri, key : & str ) -> Option< String> {
245
uri.query ( ) ? .split ( ' &' ) .find_map ( | kv | kv.split_once ( ' =' ) .filter ( | ( k , _) | * k = = key) .map ( | ( _, v ) | decode ( v) ) )
246
}
247
248
249
250
251
async fn fake_google ( ) -> ( String, Shared2) {
252
use axum:: extract:: { Request, State} ;
253
use axum:: http:: StatusCode;
254
use axum:: response:: IntoResponse;
255
let state: Shared2 = Default :: default( ) ;
256
async fn handle ( State( g ) : State< Shared2> , req : Request) -> axum:: response:: Response {
257
let ( parts, body) = req.into_parts ( ) ;
258
let bytes = http_body_util:: BodyExt:: collect( body) .await.unwrap ( ) .to_bytes ( ) ;
259
let path = parts.uri.path ( ) .to_string ( ) ;
260
if path = = " /v1/token" {
261
let v: serde_json:: Value = serde_json:: from_slice( & bytes) .unwrap ( ) ;
262
let jwt = v[ " subjectToken" ] .as_str ( ) .unwrap ( ) ;
263
let payload = jwt.split ( ' .' ) .nth ( 1 ) .unwrap ( ) ;
264
let claims: serde_json:: Value = serde_json:: from_slice( & base64url ( payload) ) .unwrap ( ) ;
265
let ( sub, aud) = ( claims[ " sub" ] .as_str ( ) .unwrap ( ) .to_string ( ) , claims[ " aud" ] .as_str ( ) .unwrap ( ) .to_string ( ) ) ;
266
let provider = v[ " audience" ] .as_str ( ) .unwrap ( ) ;
267
g.lock ( ) .unwrap ( ) .exchanged.push ( ( sub.clone ( ) , aud.clone ( ) ) ) ;
268
if ! sub.starts_with ( " backup:" ) | | aud ! = format! ( " https:{provider} " ) {
269
return (
270
StatusCode:: BAD_REQUEST ,
271
axum:: Json( serde_json:: json! ( { " error" : " invalid_grant" , " error_description" : " not admitted" } ) ) ,
272
)
273
.into_response ( ) ;
274
}
275
return axum:: Json( serde_json:: json! ( { " access_token" : format! ( " tok-{sub} " ) , " expires_in" : 3600 } ) ) .into_response ( ) ;
276
}
277
let auth = parts.headers.get ( " authorization" ) .and_then ( | v | v.to_str ( ) .ok ( ) ) .unwrap_or ( " " ) .to_string ( ) ;
278
let segs: Vec< & str > = path.split ( ' /' ) .collect ( ) ;
279
280
let upload = segs.get ( 1 ) = = Some ( & " upload" ) ;
281
let at = if upload { 5 } else { 4 } ;
282
let bucket = segs.get ( at) .unwrap_or ( & " " ) .to_string ( ) ;
283
let owner = bucket.trim_end_matches ( " -backup" ) ;
284
if auth ! = format! ( " Bearer tok-backup:{owner} " ) {
285
return ( StatusCode:: FORBIDDEN , " caller does not have storage.objects access" ) .into_response ( ) ;
286
}
287
let mut g = g.lock ( ) .unwrap ( ) ;
288
if upload {
0cd11874 Andy McCall 21 days ago ↶
289
290
if ! parts.headers.contains_key ( " content-length" ) {
291
return ( StatusCode:: LENGTH_REQUIRED , " Error 411 (Length Required)" ) .into_response ( ) ;
292
}
35fea423 Andy McCall 22 days ago ↶
293
let name = query ( & parts.uri, " name" ) .unwrap ( ) ;
294
g.objects.insert ( ( bucket, name) , bytes.to_vec ( ) ) ;
295
return axum:: Json( serde_json:: json! ( { } ) ) .into_response ( ) ;
296
}
297
let name = segs.get ( at + 2 ) .map ( | n | decode ( n) ) ;
298
match ( parts.method.as_str ( ) , name) {
299
( " GET" , Some ( n) ) => match g.objects.get ( & ( bucket, n.clone ( ) ) ) {
300
Some ( b) if query ( & parts.uri, " alt" ) .as_deref ( ) = = Some ( " media" ) => b.clone ( ) .into_response ( ) ,
301
Some ( _ ) => axum:: Json( serde_json:: json! ( { " name" : n } ) ) .into_response ( ) ,
302
None => StatusCode:: NOT_FOUND .into_response ( ) ,
303
} ,
304
( " DELETE" , Some ( n) ) => {
305
g.objects.remove ( & ( bucket, n) ) ;
306
StatusCode:: NO_CONTENT .into_response ( )
307
}
308
( " GET" , None ) => {
309
let prefix = query ( & parts.uri, " prefix" ) .unwrap_or_default ( ) ;
310
let delimited = query ( & parts.uri, " delimiter" ) .is_some ( ) ;
311
let ( mut items, mut prefixes) = ( Vec :: new( ) , std:: collections:: BTreeSet:: new( ) ) ;
312
for ( b, n) in g.objects.keys ( ) {
313
if * b ! = bucket | | ! n.starts_with ( & prefix) {
314
continue ;
315
}
316
let rest = & n[ prefix.len ( ) .. ] ;
317
match rest.find ( ' /' ) {
318
Some ( i) if delimited => {
319
prefixes.insert ( format! ( " {prefix} {} " , & rest[ .. = i] ) ) ;
320
}
321
_ => items.push ( serde_json:: json! ( { " name" : n } ) ) ,
322
}
323
}
324
axum:: Json( serde_json:: json! ( { " items" : items, " prefixes" : prefixes } ) ) .into_response ( )
325
}
326
_ => StatusCode:: METHOD_NOT_ALLOWED .into_response ( ) ,
327
}
328
}
329
let app = axum:: Router:: new( ) .fallback ( handle) .with_state ( state.clone ( ) ) ;
330
let listener = tokio:: net:: TcpListener:: bind( " 127.0.0.1:0" ) .await.unwrap ( ) ;
331
let port = listener.local_addr ( ) .unwrap ( ) .port ( ) ;
332
tokio:: spawn( async move { axum:: serve( listener, app) .await.unwrap ( ) } ) ;
333
( format! ( " http://127.0.0.1:{port} " ) , state)
334
}
335
336
fn base64url ( s : & str ) -> Vec< u8 > {
337
let table = b " ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" ;
338
let mut bits = 0 u32 ;
339
let mut n = 0 ;
340
let mut out = Vec :: new( ) ;
341
for c in s.bytes ( ) {
342
let v = table.iter ( ) .position ( | t | * t = = c) .unwrap ( ) as u32 ;
343
bits = ( bits < < 6 ) | v;
344
n + = 6 ;
345
if n > = 8 {
346
n - = 8 ;
347
out.push ( ( bits > > n) as u8 ) ;
348
bits & = ( 1 < < n) - 1 ;
349
}
350
}
351
out
352
}
353
354
const PROVIDER : & str = " //iam.googleapis.com/projects/1/locations/global/workloadIdentityPools/oxygenhub/providers/oxygenhub-io" ;
355
356
fn gcs ( owner : & str , bucket : & str ) -> Setting {
357
let mut s = Setting:: off( owner, " user" ) ;
358
s.every = " hourly" .into ( ) ;
359
s.destination = " gcs" .into ( ) ;
360
s.target = bucket.into ( ) ;
361
s.provider = PROVIDER .into ( ) ;
362
s
363
}
364
365
# [ tokio ::test ( flavor = " multi_thread" ) ]
366
async fn a_gcs_backup_needs_no_key_and_restores_the_same_repository ( ) {
367
let ( google, seen) = fake_google ( ) .await;
368
let store = tempfile:: tempdir( ) .unwrap ( ) ;
369
let h = hub_with ( store.path ( ) , Some ( & google) ) .await;
370
db:: accounts:: create_account( & h.app.db, " andy" , " [email protected] " , " x" , " salt" , false ) .await.unwrap ( ) ;
371
db:: accounts:: create_account( & h.app.db, " bob" , " [email protected] " , " x" , " salt" , false ) .await.unwrap ( ) ;
372
create ( & h.app, " andy" , " demo" , hub_api:: Visibility:: Public) .await;
373
let work = tempfile:: tempdir( ) .unwrap ( ) ;
374
push ( & format! ( " {} /andy/demo" , h.base) , work.path ( ) , & [ ( " README.md" , " hello\n " ) ] ) ;
0cd11874 Andy McCall 21 days ago ↶
375
376
377
std:: fs:: write( h.app.repos.dir ( & RepoName:: parse( " andy/demo" ) .unwrap ( ) ) .join ( " .oxy/empty-marker" ) , b " " ) .unwrap ( ) ;
35fea423 Andy McCall 22 days ago ↶
378
379
backups:: save( & h.app.db, & gcs ( " andy" , " andy-backup" ) ) .await.unwrap ( ) ;
380
let first = backups:: run( & h.app, " andy" , " now" ) .await.unwrap ( ) ;
381
assert_eq! ( ( first.status.as_str ( ) , first.repos) , ( " ok" , 1 ) , " {}" , first.detail) ;
382
{
383
let g = seen.lock ( ) .unwrap ( ) ;
384
assert! ( g.objects.keys ( ) .any ( | ( b , n ) | b = = " andy-backup" & & n = = & format! ( " runs/{} /DONE" , first.at) ) ) ;
385
assert! ( g.objects.keys ( ) .all ( | ( b , _) | b = = " andy-backup" ) ) ;
386
let ( sub, aud) = g.exchanged.first ( ) .unwrap ( ) ;
387
assert_eq! (
388
( sub.as_str ( ) , aud.as_str ( ) ) ,
389
( " backup:andy" , format! ( " https:{PROVIDER} " ) .as_str ( ) ) ,
390
" the hub signs in as the owner, for the provider"
391
) ;
392
}
393
tokio:: time:: sleep( std:: time:: Duration:: from_millis( 1100 ) ) .await;
394
let second = backups:: run( & h.app, " andy" , " now" ) .await.unwrap ( ) ;
395
assert_eq! ( ( second.status.as_str ( ) , second.sent) , ( " ok" , 0 ) , " {}" , second.detail) ;
396
397
let before = seen.lock ( ) .unwrap ( ) .objects.len ( ) ;
398
let dest = backups:: destination( & h.app, & gcs ( " andy" , " andy-backup" ) ) .unwrap ( ) ;
399
tokio:: task:: spawn_blocking( move | | backups:: probe( dest.as_ref ( ) ) ) .await.unwrap ( ) .unwrap ( ) ;
400
assert_eq! ( seen.lock ( ) .unwrap ( ) .objects.len ( ) , before) ;
401
402
403
let empty = tempfile:: tempdir( ) .unwrap ( ) ;
404
let fresh = hub_with ( empty.path ( ) , Some ( & google) ) .await;
405
let dest: Arc< dyn Destination> = backups:: destination( & fresh.app, & gcs ( " andy" , " andy-backup" ) ) .unwrap ( ) .into ( ) ;
406
assert_eq! ( backups:: restore_into_hub( & fresh.app, dest, None ) .await.unwrap ( ) , vec! [ " andy/demo (repos)" ] ) ;
407
let name = RepoName:: parse( " andy/demo" ) .unwrap ( ) ;
408
assert_eq! ( files ( & fresh.app.repos.dir ( & name) ) , files ( & h.app.repos.dir ( & name) ) , " byte for byte" ) ;
409
410
411
412
backups:: save( & h.app.db, & gcs ( " bob" , " andy-backup" ) ) .await.unwrap ( ) ;
413
let refused = backups:: run( & h.app, " bob" , " now" ) .await.unwrap ( ) ;
414
assert_eq! ( refused.status, " failed" ) ;
415
assert! ( refused.detail.contains ( " roles/storage.objectAdmin" ) & & refused.detail.contains ( " backup:bob" ) , " {}" , refused.detail) ;
d7d18246 Andy McCall 22 days ago ↶
416
}
5ef6cf72 Andy McCall 22 days ago ↶
417
418
419
420
421
422
423
# [ tokio ::test ( flavor = " multi_thread" ) ]
424
async fn a_restore_brings_back_issues_proposals_and_releases ( ) {
425
let store = tempfile:: tempdir( ) .unwrap ( ) ;
426
let h = hub ( store.path ( ) ) .await;
427
let andy = db:: accounts:: create_account( & h.app.db, " andy" , " [email protected] " , " x" , " salt" , false ) .await.unwrap ( ) ;
428
let bob = db:: accounts:: create_account( & h.app.db, " bob" , " [email protected] " , " x" , " salt" , false ) .await.unwrap ( ) ;
429
create ( & h.app, " andy" , " demo" , hub_api:: Visibility:: Public) .await;
430
let work = tempfile:: tempdir( ) .unwrap ( ) ;
431
push ( & format! ( " {} /andy/demo" , h.base) , work.path ( ) , & [ ( " README.md" , " hello\n " ) ] ) ;
432
let row = db:: repos:: get( & h.app.db, " andy" , " demo" ) .await.unwrap ( ) .unwrap ( ) ;
433
db:: repos:: set_diffusion( & h.app.db, row.id, " green" ) .await.unwrap ( ) ;
434
435
for n in 0 .. 3 {
436
db:: issues:: upsert_label( & h.app.db, row.id, & format! ( " spare{n} " ) , " #000000" , " " ) .await.unwrap ( ) ;
437
}
438
let bug = db:: issues:: upsert_label( & h.app.db, row.id, " bug" , " #d73a4a" , " Something is wrong" ) .await.unwrap ( ) ;
439
let m = db:: issues:: upsert_milestone( & h.app.db, row.id, " 1.0" , " the first" , None ) .await.unwrap ( ) ;
440
let issue = db:: issues:: create(
441
& h.app.db,
442
db:: issues:: NewIssue { repo_id: row.id, title: " Sprites flicker" , body: " on level two" , author_id: Some ( andy.id) , author: " andy" } ,
443
)
444
.await
445
.unwrap ( ) ;
446
db:: issues:: add_comment( & h.app.db, issue.id, Some ( bob.id) , " bob" , " seen it too" ) .await.unwrap ( ) ;
447
db:: issues:: set_issue_label( & h.app.db, issue.id, bug.id, true ) .await.unwrap ( ) ;
448
db:: issues:: set_milestone( & h.app.db, issue.id, Some ( m.id) ) .await.unwrap ( ) ;
449
db:: issues:: set_assignee( & h.app.db, issue.id, andy.id, true ) .await.unwrap ( ) ;
450
db:: issues:: set_assignee( & h.app.db, issue.id, bob.id, true ) .await.unwrap ( ) ;
451
let p = db:: proposals:: create(
452
& h.app.db,
453
db:: proposals:: NewProposal {
454
repo_id: row.id,
455
title: " Double-buffer the sprites" ,
456
body: " fixes the flicker" ,
457
author_id: Some ( bob.id) ,
458
author: " bob" ,
459
draft: false ,
460
target: " main" ,
461
source_kind: " bookmark" ,
462
source_repo_id: Some ( row.id) ,
463
source_bookmark: " fix" ,
464
source_changes: " " ,
465
} ,
466
)
467
.await
468
.unwrap ( ) ;
469
db:: proposals:: add_event( & h.app.db, p.id, " bob" , " opened" , " " ) .await.unwrap ( ) ;
470
db:: proposals:: add_review( & h.app.db, p.id, andy.id, " andy" , " approve" , " nice" ) .await.unwrap ( ) ;
471
let release = db:: releases:: create( & h.app.db, row.id, " v1.0" , " One" , " notes" , false , Some ( ( andy.id, " andy" ) ) ) .await.unwrap ( ) ;
472
let asset = b " a game image" .to_vec ( ) ;
473
let hash = blake3:: hash( & asset) .to_hex ( ) .to_string ( ) ;
474
let path = h.app.config.data.join ( " assets" ) .join ( & hash[ .. 2 ] ) .join ( & hash) ;
475
std:: fs:: create_dir_all( path.parent ( ) .unwrap ( ) ) .unwrap ( ) ;
476
std:: fs:: write( & path, & asset) .unwrap ( ) ;
477
db:: releases:: add_asset( & h.app.db, release.id, " game.prg" , asset.len ( ) as i64 , & hash, " application/octet-stream" ) .await.unwrap ( ) ;
478
db:: hooks:: create_hook( & h.app.db, Some ( row.id) , None , " https://example.com/hook" , " s3cret-value" , " push" ) .await.unwrap ( ) ;
479
480
let mut s = Setting:: off( " andy" , " user" ) ;
481
s.every = " hourly" .into ( ) ;
482
s.target = store.path ( ) .display ( ) .to_string ( ) ;
483
backups:: save( & h.app.db, & s) .await.unwrap ( ) ;
484
let run = backups:: run( & h.app, " andy" , " now" ) .await.unwrap ( ) ;
485
assert_eq! ( run.status, " ok" , " {}" , run.detail) ;
486
let dest = Directory { root: store.path ( ) .join ( " andy" ) } ;
487
let text = String :: from_utf8_lossy( & dest.get ( & format! ( " runs/{} /records/demo.json" , run.at) ) .unwrap ( ) ) .into_owned ( ) ;
488
assert! ( ! text.contains ( " s3cret-value" ) , " never a secret" ) ;
489
assert! ( text.contains ( " \" _names\" " ) & & text.contains ( " \" bob\" " ) , " accounts by name" ) ;
490
491
492
let empty = tempfile:: tempdir( ) .unwrap ( ) ;
493
let fresh = hub ( empty.path ( ) ) .await;
494
let andy2 = db:: accounts:: create_account( & fresh.app.db, " andy" , " [email protected] " , " x" , " salt" , false ) .await.unwrap ( ) ;
495
let dest: Arc< dyn Destination> = Arc:: new( Directory { root: store.path ( ) .join ( " andy" ) } ) ;
496
let done = backups:: restore_into_hub( & fresh.app, dest, None ) .await.unwrap ( ) ;
497
assert_eq! ( done[ 0 ] , " andy/demo (repos)" ) ;
498
let notes = done[ 1 .. ] .join ( " \n " ) ;
499
assert! ( notes.contains ( " webhook https://example.com/hook: enter its secret again" ) , " {notes}" ) ;
500
assert! ( notes.contains ( " issue_assignees: 1 row(s) left out" ) , " bob is not on this hub: {notes}" ) ;
501
let r2 = db:: repos:: get( & fresh.app.db, " andy" , " demo" ) .await.unwrap ( ) .unwrap ( ) ;
502
assert_eq! ( r2.diffusion, " green" , " settings come back" ) ;
503
3b1bc27f Andy McCall 19 days ago
504
let issues: Vec< ( i64 , i64 , String, Option< i64 > ) > = sqlx:: query_as( " SELECT id, number, title, author_id FROM issues WHERE repo_id = $1" )
5ef6cf72 Andy McCall 22 days ago ↶
505
.bind ( r2.id)
506
.fetch_all ( & fresh.app.db)
507
.await
508
.unwrap ( ) ;
509
assert_eq! ( issues.len ( ) , 1 ) ;
510
let ( iid, number, title, author) = issues[ 0 ] .clone ( ) ;
511
assert_eq! ( ( number, title.as_str ( ) , author) , ( issue.number, " Sprites flicker" , Some ( andy2.id) ) ) ;
512
let comments = db:: issues:: comments( & fresh.app.db, iid) .await.unwrap ( ) ;
513
assert_eq! ( comments.len ( ) , 1 ) ;
514
assert_eq! ( ( comments[ 0 ] .author.as_str ( ) , comments[ 0 ] .body.as_str ( ) ) , ( " bob" , " seen it too" ) ) ;
515
let labels: Vec< String> =
3b1bc27f Andy McCall 19 days ago
516
sqlx:: query_scalar( " SELECT l.name FROM issue_labels il JOIN labels l ON l.id = il.label_id WHERE il.issue_id = $1" )
5ef6cf72 Andy McCall 22 days ago ↶
517
.bind ( iid)
518
.fetch_all ( & fresh.app.db)
519
.await
520
.unwrap ( ) ;
521
assert_eq! ( labels, vec! [ " bug" ] , " the label, its id mapped" ) ;
522
let milestone: Option< String> =
3b1bc27f Andy McCall 19 days ago
523
sqlx:: query_scalar( " SELECT m.title FROM issues i JOIN milestones m ON m.id = i.milestone_id WHERE i.id = $1" )
5ef6cf72 Andy McCall 22 days ago ↶
524
.bind ( iid)
525
.fetch_one ( & fresh.app.db)
526
.await
527
.unwrap ( ) ;
528
assert_eq! ( milestone.as_deref ( ) , Some ( " 1.0" ) ) ;
529
assert_eq! ( db:: issues:: assignees( & fresh.app.db, iid) .await.unwrap ( ) , vec! [ " andy" ] ) ;
530
531
let p2 = db:: proposals:: get( & fresh.app.db, r2.id, p.number) .await.unwrap ( ) .expect ( " the proposal" ) ;
532
assert_eq! ( ( p2.title.as_str ( ) , p2.author.as_str ( ) , p2.author_id) , ( " Double-buffer the sprites" , " bob" , None ) ) ;
3b1bc27f Andy McCall 19 days ago
533
let reviews: Vec< ( String, String) > = sqlx:: query_as( " SELECT author, verdict FROM proposal_reviews WHERE proposal_id = $1" )
5ef6cf72 Andy McCall 22 days ago ↶
534
.bind ( p2.id)
535
.fetch_all ( & fresh.app.db)
536
.await
537
.unwrap ( ) ;
538
assert_eq! ( reviews, vec! [ ( " andy" .to_string ( ) , " approve" .to_string ( ) ) ] ) ;
539
assert! ( db:: proposals:: events( & fresh.app.db, p2.id) .await.unwrap ( ) .iter ( ) .any ( | e | e.event = = " opened" ) ) ;
540
541
let rel = db:: releases:: get( & fresh.app.db, r2.id, " v1.0" ) .await.unwrap ( ) .expect ( " the release" ) ;
542
let assets = db:: releases:: assets( & fresh.app.db, rel.id) .await.unwrap ( ) ;
543
assert_eq! ( assets.len ( ) , 1 ) ;
544
assert_eq! ( std:: fs:: read( fresh.app.config.data.join ( " assets" ) .join ( & hash[ .. 2 ] ) .join ( & hash) ) .unwrap ( ) , asset) ;
545
546
let hits = db:: notify:: search( & fresh.app.db, " flicker" , Some ( r2.id) , 10 ) .await.unwrap ( ) ;
547
assert! ( hits.iter ( ) .any ( | h | h.kind = = " issue" & & h.target_id = = iid ) , " {:?}" , hits.iter ( ) .map ( | h | & h.title) .collect:: < Vec< _ > > ( ) ) ;
548
549
let next = db:: issues:: create(
550
& fresh.app.db,
551
db:: issues:: NewIssue { repo_id: r2.id, title: " After" , body: " " , author_id: Some ( andy2.id) , author: " andy" } ,
552
)
553
.await
554
.unwrap ( ) ;
555
assert! ( next.number > p.number, " {} after {}" , next.number, p.number) ;
556
}
d3b048b2 Andy McCall 22 days ago ↶
557
558
559
560
561
562
# [ tokio ::test ( flavor = " multi_thread" ) ]
563
async fn repositories_choose_their_own_backup ( ) {
564
let store = tempfile:: tempdir( ) .unwrap ( ) ;
565
let h = hub ( store.path ( ) ) .await;
566
db:: accounts:: create_account( & h.app.db, " andy" , " [email protected] " , " x" , " salt" , false ) .await.unwrap ( ) ;
567
for n in [ " game" , " notes" , " secret" ] {
568
create ( & h.app, " andy" , n, hub_api:: Visibility:: Public) .await;
569
let work = tempfile:: tempdir( ) .unwrap ( ) ;
570
push ( & format! ( " {} /andy/{n} " , h.base) , work.path ( ) , & [ ( " README.md" , n) ] ) ;
571
}
572
let id = | n : & 'static str | {
573
let db = h.app.db.clone ( ) ;
574
async move { db:: repos:: get( & db, " andy" , n) .await.unwrap ( ) .unwrap ( ) .id }
575
} ;
576
db:: repos:: set_backup( & h.app.db, id ( " secret" ) .await, " off" ) .await.unwrap ( ) ;
577
let mut s = Setting:: off( " andy" , " user" ) ;
578
s.every = " daily" .into ( ) ;
579
s.target = store.path ( ) .display ( ) .to_string ( ) ;
580
backups:: save( & h.app.db, & s) .await.unwrap ( ) ;
581
let names = | at : String| {
582
let dest = Directory { root: store.path ( ) .join ( " andy" ) } ;
583
let mut v = dest.list ( & format! ( " runs/{at} /records" ) ) .unwrap ( ) ;
584
v.sort ( ) ;
585
v
586
} ;
587
let first = backups:: run( & h.app, " andy" , " schedule" ) .await.unwrap ( ) ;
588
assert_eq! ( ( first.status.as_str ( ) , first.repos) , ( " ok" , 2 ) , " {}" , first.detail) ;
589
assert_eq! ( names ( first.at.clone ( ) ) , vec! [ " game.json" , " notes.json" ] , " the one left out is not in it" ) ;
590
591
assert! ( backups:: due( & h.app.db) .await.unwrap ( ) .is_empty ( ) ) ;
592
db:: repos:: set_backup( & h.app.db, id ( " game" ) .await, " hourly" ) .await.unwrap ( ) ;
593
sqlx:: query( " UPDATE backup_runs SET started_ms = started_ms - 2 * 3600 * 1000" ) .execute ( & h.app.db) .await.unwrap ( ) ;
594
assert_eq! ( backups:: due( & h.app.db) .await.unwrap ( ) .len ( ) , 1 , " hourly for the game" ) ;
595
596
s.every = " off" .into ( ) ;
597
backups:: save( & h.app.db, & s) .await.unwrap ( ) ;
598
assert_eq! ( backups:: due( & h.app.db) .await.unwrap ( ) .len ( ) , 1 ) ;
599
tokio:: time:: sleep( std:: time:: Duration:: from_millis( 1100 ) ) .await;
600
let second = backups:: run( & h.app, " andy" , " schedule" ) .await.unwrap ( ) ;
601
assert_eq! ( names ( second.at.clone ( ) ) , vec! [ " game.json" ] ) ;
602
603
tokio:: time:: sleep( std:: time:: Duration:: from_millis( 1100 ) ) .await;
604
let third = backups:: run( & h.app, " andy" , " now" ) .await.unwrap ( ) ;
605
assert_eq! ( names ( third.at.clone ( ) ) , vec! [ " game.json" , " notes.json" ] ) ;
606
607
let ( owners, uncovered) = backups:: overview( & h.app.db) .await.unwrap ( ) ;
608
assert_eq! ( ( owners[ 0 ] .repos, uncovered.len ( ) ) , ( 1 , 0 ) ) ;
609
}
7b9f4570 Andy McCall 22 days ago ↶
610
611
612
613
614
615
async fn fake_aws ( ) -> ( String, Shared2) {
616
use axum:: extract:: { Request, State} ;
617
use axum:: http:: StatusCode;
618
use axum:: response:: IntoResponse;
619
let state: Shared2 = Default :: default( ) ;
620
async fn handle ( State( g ) : State< Shared2> , req : Request) -> axum:: response:: Response {
621
let ( parts, body) = req.into_parts ( ) ;
622
let bytes = http_body_util:: BodyExt:: collect( body) .await.unwrap ( ) .to_bytes ( ) ;
623
let path = parts.uri.path ( ) .to_string ( ) ;
624
let header = | h : & str | parts.headers.get ( h) .and_then ( | v | v.to_str ( ) .ok ( ) ) .unwrap_or ( " " ) .to_string ( ) ;
625
if path = = " /" & & parts.method = = " POST" {
626
let form: Vec< ( String, String) > = String :: from_utf8_lossy( & bytes)
627
.split ( ' &' )
628
.filter_map ( | kv | kv.split_once ( ' =' ) .map ( | ( k , v ) | ( k.to_string ( ) , decode ( & v.replace ( ' +' , " " ) ) ) ) )
629
.collect ( ) ;
630
let field = | k : & str | form.iter ( ) .find ( | ( f , _) | f = = k ) .map ( | ( _, v ) | v.clone ( ) ) .unwrap_or_default ( ) ;
631
let jwt = field ( " WebIdentityToken" ) ;
632
let claims: serde_json:: Value = serde_json:: from_slice( & base64url ( jwt.split ( ' .' ) .nth ( 1 ) .unwrap ( ) ) ) .unwrap ( ) ;
633
let ( sub, aud) = ( claims[ " sub" ] .as_str ( ) .unwrap ( ) .to_string ( ) , claims[ " aud" ] .as_str ( ) .unwrap ( ) .to_string ( ) ) ;
634
g.lock ( ) .unwrap ( ) .exchanged.push ( ( sub.clone ( ) , aud.clone ( ) ) ) ;
635
if field ( " Action" ) ! = " AssumeRoleWithWebIdentity" | | ! sub.starts_with ( " backup:" ) | | aud ! = " sts.amazonaws.com" {
636
return ( StatusCode:: FORBIDDEN , " <ErrorResponse><Error><Code>AccessDenied</Code><Message>Not authorized to perform sts:AssumeRoleWithWebIdentity</Message></Error></ErrorResponse>" ) .into_response ( ) ;
637
}
638
let owner = sub.trim_start_matches ( " backup:" ) ;
639
return format! (
640
" <AssumeRoleWithWebIdentityResponse><AssumeRoleWithWebIdentityResult><Credentials>\
641
<AccessKeyId>ASIA{owner} </AccessKeyId><SecretAccessKey>secret/{owner} </SecretAccessKey>\
642
<SessionToken>session-{owner} </SessionToken><Expiration>2030-01-01T00:00:00Z</Expiration>\
643
</Credentials></AssumeRoleWithWebIdentityResult></AssumeRoleWithWebIdentityResponse>"
644
)
645
.into_response ( ) ;
646
}
647
648
let rest = path.strip_prefix ( " /s3/" ) .unwrap_or ( " " ) ;
649
let ( bucket, key) = rest.split_once ( ' /' ) .unwrap_or ( ( rest, " " ) ) ;
650
let ( bucket, key) = ( bucket.to_string ( ) , decode ( key) ) ;
651
let owner = bucket.trim_end_matches ( " -backup" ) .to_string ( ) ;
652
653
let session = header ( " x-amz-security-token" ) ;
654
let who = session.trim_start_matches ( " session-" ) .to_string ( ) ;
655
let query: Vec< ( String, String) > = parts
656
.uri
657
.query ( )
658
.unwrap_or ( " " )
659
.split ( ' &' )
660
.filter ( | kv | ! kv.is_empty ( ) )
661
.map ( | kv | kv.split_once ( ' =' ) .map ( | ( k , v ) | ( decode ( k) , decode ( v) ) ) .unwrap_or ( ( decode ( kv) , String :: new( ) ) ) )
662
.collect ( ) ;
663
let signed = vec! [
664
( " host" .to_string ( ) , header ( " host" ) ) ,
665
( " x-amz-content-sha256" .to_string ( ) , header ( " x-amz-content-sha256" ) ) ,
666
( " x-amz-date" .to_string ( ) , header ( " x-amz-date" ) ) ,
667
( " x-amz-security-token" .to_string ( ) , session.clone ( ) ) ,
668
] ;
669
let id = format! ( " ASIA{who} " ) ;
670
let secret = format! ( " secret/{who} " ) ;
671
let want = oxygenhub:: backups:: sigv4(
672
parts.method.as_str ( ) ,
673
& path,
674
& query,
675
& signed,
676
& header ( " x-amz-content-sha256" ) ,
677
( & id, & secret) ,
678
" eu-west-2" ,
679
" s3" ,
680
& header ( " x-amz-date" ) ,
681
) ;
682
use sha2:: Digest;
683
let body_hash = hex:: encode( sha2:: Sha256:: digest( & bytes) ) ;
684
if header ( " authorization" ) ! = want | | header ( " x-amz-content-sha256" ) ! = body_hash {
685
return (
686
StatusCode:: FORBIDDEN ,
687
" <Error><Code>SignatureDoesNotMatch</Code><Message>The request signature we calculated does not match</Message></Error>" ,
688
)
689
.into_response ( ) ;
690
}
691
if who ! = owner {
692
return ( StatusCode:: FORBIDDEN , " <Error><Code>AccessDenied</Code><Message>Access Denied</Message></Error>" ) .into_response ( ) ;
693
}
694
let mut g = g.lock ( ) .unwrap ( ) ;
695
match ( parts.method.as_str ( ) , key.is_empty ( ) ) {
0cd11874 Andy McCall 21 days ago ↶
696
( " PUT" , false ) if ! parts.headers.contains_key ( " content-length" ) => {
697
( StatusCode:: LENGTH_REQUIRED , " <Error><Code>MissingContentLength</Code></Error>" ) .into_response ( )
698
}
7b9f4570 Andy McCall 22 days ago ↶
699
( " PUT" , false ) => {
700
g.objects.insert ( ( bucket, key) , bytes.to_vec ( ) ) ;
701
StatusCode:: OK .into_response ( )
702
}
703
( " GET" , false ) | ( " HEAD" , false ) => match g.objects.get ( & ( bucket, key) ) {
704
Some ( b) => b.clone ( ) .into_response ( ) ,
705
None => ( StatusCode:: NOT_FOUND , " <Error><Code>NoSuchKey</Code></Error>" ) .into_response ( ) ,
706
} ,
707
( " DELETE" , false ) => {
708
g.objects.remove ( & ( bucket, key) ) ;
709
StatusCode:: NO_CONTENT .into_response ( )
710
}
711
( " GET" , true ) => {
712
let prefix = query.iter ( ) .find ( | ( k , _) | k = = " prefix" ) .map ( | ( _, v ) | v.clone ( ) ) .unwrap_or_default ( ) ;
713
let delimited = query.iter ( ) .any ( | ( k , _) | k = = " delimiter" ) ;
714
let ( mut keys, mut prefixes) = ( String :: new( ) , std:: collections:: BTreeSet:: new( ) ) ;
715
for ( b, n) in g.objects.keys ( ) {
716
if * b ! = bucket | | ! n.starts_with ( & prefix) {
717
continue ;
718
}
719
match n[ prefix.len ( ) .. ] .find ( ' /' ) {
720
Some ( i) if delimited => {
721
prefixes.insert ( format! ( " {prefix} {} " , & n[ prefix.len ( ) .. prefix.len ( ) + i + 1 ] ) ) ;
722
}
723
_ => keys.push_str ( & format! ( " <Contents><Key>{n} </Key></Contents>" ) ) ,
724
}
725
}
726
let common: String = prefixes.iter ( ) .map ( | p | format! ( " <CommonPrefixes><Prefix>{p} </Prefix></CommonPrefixes>" ) ) .collect ( ) ;
727
format! ( " <ListBucketResult><Prefix>{prefix} </Prefix><IsTruncated>false</IsTruncated>{keys} {common} </ListBucketResult>" )
728
.into_response ( )
729
}
730
_ => StatusCode:: METHOD_NOT_ALLOWED .into_response ( ) ,
731
}
732
}
733
let app = axum:: Router:: new( ) .fallback ( handle) .with_state ( state.clone ( ) ) ;
734
let listener = tokio:: net:: TcpListener:: bind( " 127.0.0.1:0" ) .await.unwrap ( ) ;
735
let port = listener.local_addr ( ) .unwrap ( ) .port ( ) ;
736
tokio:: spawn( async move { axum:: serve( listener, app) .await.unwrap ( ) } ) ;
737
( format! ( " http://127.0.0.1:{port} " ) , state)
738
}
739
740
async fn hub_aws ( backups : & Path, aws : & str ) -> Hub {
741
hermetic ( ) ;
742
let dir = tempfile:: tempdir( ) .unwrap ( ) ;
743
let mut config = Config:: load( dir.path ( ) ) .unwrap ( ) ;
c8684d44 Andy McCall 19 days ago
744
745
config.registration.setup_code = " test-setup" .into ( ) ;
7b9f4570 Andy McCall 22 days ago ↶
746
config.auth.push_tokens = vec! [ " site" .into ( ) ] ;
747
config.backups.directories = vec! [ backups.to_path_buf ( ) ] ;
748
config.backups.aws_sts_url = aws.to_string ( ) ;
749
config.backups.s3_url = format! ( " {aws} /s3/{{ bucket}} " ) ;
750
let listener = tokio:: net:: TcpListener:: bind( " 127.0.0.1:0" ) .await.unwrap ( ) ;
751
let port = listener.local_addr ( ) .unwrap ( ) .port ( ) ;
752
config.server.base_url = format! ( " http://127.0.0.1:{port} " ) ;
753
let app = oxygenhub:: build( config) .await.unwrap ( ) ;
754
let router = oxygenhub:: router( app.clone ( ) ) ;
755
tokio:: spawn( async move { axum:: serve( listener, router) .await.unwrap ( ) } ) ;
756
Hub { app, base: format! ( " http://127.0.0.1:{port} " ) , _dir: dir }
757
}
758
759
const ROLE : & str = " arn:aws:iam::123456789012:role/oxygenhub-backup" ;
760
761
fn s3 ( owner : & str , bucket : & str ) -> Setting {
762
let mut s = Setting:: off( owner, " user" ) ;
763
s.every = " hourly" .into ( ) ;
764
s.destination = " s3" .into ( ) ;
765
s.target = bucket.into ( ) ;
766
s.provider = ROLE .into ( ) ;
767
s.region = " eu-west-2" .into ( ) ;
768
s.prefix = " oxygen hub" .into ( ) ;
769
s
770
}
771
772
773
774
775
776
777
# [ tokio ::test ( flavor = " multi_thread" ) ]
778
async fn an_s3_backup_needs_no_key_and_restores_the_same_repository ( ) {
779
let ( aws, seen) = fake_aws ( ) .await;
780
let store = tempfile:: tempdir( ) .unwrap ( ) ;
781
let h = hub_aws ( store.path ( ) , & aws) .await;
782
db:: accounts:: create_account( & h.app.db, " andy" , " [email protected] " , " x" , " salt" , false ) .await.unwrap ( ) ;
783
db:: accounts:: create_account( & h.app.db, " bob" , " [email protected] " , " x" , " salt" , false ) .await.unwrap ( ) ;
784
create ( & h.app, " andy" , " demo" , hub_api:: Visibility:: Public) .await;
785
let work = tempfile:: tempdir( ) .unwrap ( ) ;
786
push ( & format! ( " {} /andy/demo" , h.base) , work.path ( ) , & [ ( " README.md" , " hello\n " ) , ( " src/a b+c.rs" , " fn main() {}\n " ) ] ) ;
0cd11874 Andy McCall 21 days ago ↶
787
std:: fs:: write( h.app.repos.dir ( & RepoName:: parse( " andy/demo" ) .unwrap ( ) ) .join ( " .oxy/empty-marker" ) , b " " ) .unwrap ( ) ;
7b9f4570 Andy McCall 22 days ago ↶
788
789
backups:: save( & h.app.db, & s3 ( " andy" , " andy-backup" ) ) .await.unwrap ( ) ;
790
let first = backups:: run( & h.app, " andy" , " now" ) .await.unwrap ( ) ;
791
assert_eq! ( ( first.status.as_str ( ) , first.repos) , ( " ok" , 1 ) , " {}" , first.detail) ;
792
{
793
let g = seen.lock ( ) .unwrap ( ) ;
794
assert! (
795
g.objects.keys ( ) .any ( | ( b , n ) | b = = " andy-backup" & & n = = & format! ( " oxygen hub/runs/{} /DONE" , first.at) ) ,
796
" under the prefix"
797
) ;
798
assert_eq! ( g.exchanged.first ( ) .map ( | ( s , a ) | ( s.as_str ( ) , a.as_str ( ) ) ) , Some ( ( " backup:andy" , " sts.amazonaws.com" ) ) ) ;
799
}
800
tokio:: time:: sleep( std:: time:: Duration:: from_millis( 1100 ) ) .await;
801
let second = backups:: run( & h.app, " andy" , " now" ) .await.unwrap ( ) ;
802
assert_eq! ( ( second.status.as_str ( ) , second.sent) , ( " ok" , 0 ) , " {}" , second.detail) ;
803
let before = seen.lock ( ) .unwrap ( ) .objects.len ( ) ;
804
let dest = backups:: destination( & h.app, & s3 ( " andy" , " andy-backup" ) ) .unwrap ( ) ;
805
tokio:: task:: spawn_blocking( move | | backups:: probe( dest.as_ref ( ) ) ) .await.unwrap ( ) .unwrap ( ) ;
806
assert_eq! ( seen.lock ( ) .unwrap ( ) .objects.len ( ) , before, " the probe cleans up" ) ;
807
808
let empty = tempfile:: tempdir( ) .unwrap ( ) ;
809
let fresh = hub_aws ( empty.path ( ) , & aws) .await;
810
let dest: Arc< dyn Destination> = backups:: destination( & fresh.app, & s3 ( " andy" , " andy-backup" ) ) .unwrap ( ) .into ( ) ;
811
assert_eq! ( backups:: restore_into_hub( & fresh.app, dest, None ) .await.unwrap ( ) , vec! [ " andy/demo (repos)" ] ) ;
812
let name = RepoName:: parse( " andy/demo" ) .unwrap ( ) ;
813
assert_eq! ( files ( & fresh.app.repos.dir ( & name) ) , files ( & h.app.repos.dir ( & name) ) , " byte for byte" ) ;
814
815
backups:: save( & h.app.db, & s3 ( " bob" , " andy-backup" ) ) .await.unwrap ( ) ;
816
let refused = backups:: run( & h.app, " bob" , " now" ) .await.unwrap ( ) ;
817
assert_eq! ( refused.status, " failed" ) ;
818
assert! ( refused.detail.contains ( " s3:PutObject" ) & & refused.detail.contains ( ROLE ) , " {}" , refused.detail) ;
819
}
2e9188ca Andy McCall 21 days ago ↶
820
821
822
async fn api (
823
app : & Shared,
824
method : & str ,
825
path : & str ,
826
token : & str ,
827
body : Option< serde_json:: Value> ,
828
) -> ( axum:: http:: StatusCode, serde_json:: Value) {
829
use axum:: body:: Body;
830
use http_body_util:: BodyExt;
831
use tower:: ServiceExt;
832
let mut req =
833
axum:: http:: Request:: builder( ) .method ( method) .uri ( format! ( " /api/v1{path} " ) ) .header ( " authorization" , format! ( " Bearer {token} " ) ) ;
834
let body = match body {
835
Some ( b) => {
836
req = req.header ( " content-type" , " application/json" ) ;
837
Body:: from( b.to_string ( ) )
838
}
839
None => Body:: empty( ) ,
840
} ;
841
let res = oxygenhub:: router( app.clone ( ) ) .oneshot ( req.body ( body) .unwrap ( ) ) .await.unwrap ( ) ;
842
let st = res.status ( ) ;
843
let bytes = res.into_body ( ) .collect ( ) .await.unwrap ( ) .to_bytes ( ) ;
844
( st, serde_json:: from_slice( & bytes) .unwrap_or ( serde_json:: Value:: Null) )
845
}
846
847
848
849
850
# [ tokio ::test ( flavor = " multi_thread" ) ]
851
async fn backups_are_set_up_and_run_through_the_api ( ) {
852
let store = tempfile:: tempdir( ) .unwrap ( ) ;
853
let h = hub ( store.path ( ) ) .await;
854
let andy = db:: accounts:: create_account( & h.app.db, " andy" , " [email protected] " , " x" , " salt" , false ) .await.unwrap ( ) ;
855
let bob = db:: accounts:: create_account( & h.app.db, " bob" , " [email protected] " , " x" , " salt" , false ) .await.unwrap ( ) ;
856
db:: accounts:: create_org( & h.app.db, " club" , " Club" , andy.id) .await.unwrap ( ) ;
857
create ( & h.app, " andy" , " demo" , hub_api:: Visibility:: Public) .await;
858
create ( & h.app, " club" , " game" , hub_api:: Visibility:: Private) .await;
859
let token = | id : i64 , scopes : & 'static str | {
860
let db = h.app.db.clone ( ) ;
861
async move {
862
let ( t, hash) = oxygenhub:: auth:: new_token( ) ;
863
db:: accounts:: create_token( & db, id, " t" , & hash, scopes, " " , None ) .await.unwrap ( ) ;
864
t
865
}
866
} ;
867
let ( andy_t , bob_t , weak) = ( token ( andy.id, " repo:admin" ) .await, token ( bob.id, " repo:admin" ) .await, token ( andy.id, " repo:read" ) .await) ;
868
let dir = store.path ( ) .display ( ) .to_string ( ) ;
869
870
let ( st, empty) = api ( & h.app, " GET" , " /user/backups" , & andy_t , None ) .await;
871
assert_eq! ( st, 200 ) ;
872
assert! ( empty[ " setting" ] .is_null ( ) & & empty[ " runs" ] .as_array ( ) .unwrap ( ) .is_empty ( ) , " {empty}" ) ;
873
let ( st, _ ) = api ( & h.app, " POST" , " /user/backups/run" , & andy_t , None ) .await;
874
assert_eq! ( st, 409 , " nothing saved yet" ) ;
875
let ( st, _ ) =
876
api ( & h.app, " PUT" , " /user/backups" , & weak, Some ( serde_json:: json! ( { " every" : " hourly" , " destination" : " dir" , " dir" : dir } ) ) ) .await;
877
assert_eq! ( st, 403 , " repo:admin only" ) ;
878
let ( st, bad) = api (
879
& h.app,
880
" PUT" ,
881
" /user/backups" ,
882
& andy_t ,
883
Some ( serde_json:: json! ( { " every" : " hourly" , " destination" : " gcs" , " bucket" : " andy-backup" , " provider" : " nope" } ) ) ,
884
)
885
.await;
886
assert_eq! ( st, 400 , " checked as the page checks it: {bad}" ) ;
887
let ( st, saved) = api (
888
& h.app,
889
" PUT" ,
890
" /user/backups" ,
891
& andy_t ,
892
Some ( serde_json:: json! ( { " every" : " hourly" , " destination" : " dir" , " dir" : dir, " keep_days" : 30 } ) ) ,
893
)
894
.await;
895
assert_eq! ( st, 200 , " {saved}" ) ;
896
assert_eq! ( ( saved[ " setting" ] [ " every" ] .as_str ( ) , saved[ " place" ] .as_str ( ) ) , ( Some ( " hourly" ) , Some ( format! ( " {dir} /andy" ) .as_str ( ) ) ) ) ;
897
let ( st, probe) = api ( & h.app, " POST" , " /user/backups/test" , & andy_t , None ) .await;
898
assert_eq! ( ( st.as_u16 ( ) , probe[ " ok" ] .as_bool ( ) ) , ( 200 , Some ( true ) ) , " {probe}" ) ;
899
let ( st, started) = api ( & h.app, " POST" , " /user/backups/run" , & andy_t , None ) .await;
900
assert_eq! ( st, 202 , " {started}" ) ;
901
let mut done = serde_json:: Value:: Null;
902
for _ in 0 .. 200 {
903
let ( _ , runs) = api ( & h.app, " GET" , " /user/backups/runs" , & andy_t , None ) .await;
904
if runs[ 0 ] [ " status" ] .as_str ( ) .is_some_and ( | s | s ! = " running" ) {
905
done = runs[ 0 ] .clone ( ) ;
906
break ;
907
}
908
tokio:: time:: sleep( std:: time:: Duration:: from_millis( 50 ) ) .await;
909
}
910
assert_eq! ( ( done[ " status" ] .as_str ( ) , done[ " repos" ] .as_i64 ( ) , done[ " trigger" ] .as_str ( ) ) , ( Some ( " ok" ) , Some ( 1 ) , Some ( " now" ) ) , " {done}" ) ;
bff9106e Andy McCall 21 days ago ↶
911
912
913
sqlx:: query( " INSERT INTO backup_runs (owner, started_ms, finished_ms, status, at, trigger, detail) VALUES ('andy', 1, 2, 'failed', 'x', 'schedule', 'GCS refused')" )
914
.execute ( & h.app.db)
915
.await
916
.unwrap ( ) ;
917
tokio:: time:: sleep( std:: time:: Duration:: from_millis( 1100 ) ) .await;
918
let again = backups:: run( & h.app, " andy" , " now" ) .await.unwrap ( ) ;
919
assert_eq! ( again.status, " ok" ) ;
3b1bc27f Andy McCall 19 days ago
920
let told = | db : oxygenhub:: db:: Pool| async move { db:: notify:: inbox( & db, andy.id, 20 ) .await.unwrap ( ) } ;
bff9106e Andy McCall 21 days ago ↶
921
assert! ( told ( h.app.db.clone ( ) ) .await.iter ( ) .any ( | n | n.kind = = " backup.recovered" & & n.title.contains ( " works again" ) ) ) ;
922
assert_eq! ( backups:: daily_summary( & h.app, db:: now_ms( ) , true ) .await.unwrap ( ) , 1 ) ;
923
let summary = told ( h.app.db.clone ( ) ) .await.into_iter ( ) .find ( | n | n.kind = = " backup.summary" ) .expect ( " a summary" ) ;
924
assert! ( summary.title.contains ( " Backups of andy today" ) & & summary.title.contains ( " runs" ) , " {}" , summary.title) ;
2e9188ca Andy McCall 21 days ago ↶
925
926
927
let ( st, _ ) =
928
api ( & h.app, " PUT" , " /orgs/club/backups" , & bob_t , Some ( serde_json:: json! ( { " every" : " hourly" , " destination" : " dir" , " dir" : dir } ) ) )
929
.await;
930
assert_eq! ( st, 404 ) ;
931
let ( st, org) = api (
932
& h.app,
933
" PUT" ,
934
" /orgs/club/backups" ,
935
& andy_t ,
936
Some ( serde_json:: json! ( { " every" : " daily" , " destination" : " dir" , " dir" : dir, " keep_days" : 90 } ) ) ,
937
)
938
.await;
939
assert_eq! ( st, 200 , " {org}" ) ;
940
assert_eq! ( ( org[ " setting" ] [ " kind" ] .as_str ( ) , org[ " setting" ] [ " keep_days" ] .as_i64 ( ) ) , ( Some ( " org" ) , Some ( 90 ) ) ) ;
941
let ( st, _ ) = api ( & h.app, " GET" , " /orgs/club/backups" , & bob_t , None ) .await;
942
assert_eq! ( st, 404 ) ;
943
}