tangled
alpha
login
or
join now
altagos.dev
/
bspds-sandbox
forked from
lewis.moe/bspds-sandbox
0
fork
atom
PDS software with bells & whistles you didn’t even know you needed. will move this to its own account when ready.
0
fork
atom
overview
issues
pulls
pipelines
Repo conf. vs ref
lewis.moe
2 months ago
2fe3a324
562287ef
+749
-81
6 changed files
expand all
collapse all
unified
split
src
api
repo
record
batch.rs
delete.rs
read.rs
validation.rs
write.rs
tests
repo_conformance.rs
+52
-15
src/api/repo/record/batch.rs
···
1
1
-
use super::validation::validate_record_with_rkey;
1
1
+
use super::validation::validate_record_with_status;
2
2
use super::write::has_verified_comms_channel;
3
3
+
use crate::validation::ValidationStatus;
3
4
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log, extract_blob_cids};
4
5
use crate::delegation::{self, DelegationActionType};
5
6
use crate::repo::tracking::TrackingBlockStore;
···
56
57
#[serde(tag = "$type")]
57
58
pub enum WriteResult {
58
59
#[serde(rename = "com.atproto.repo.applyWrites#createResult")]
59
59
-
CreateResult { uri: String, cid: String },
60
60
+
CreateResult {
61
61
+
uri: String,
62
62
+
cid: String,
63
63
+
#[serde(rename = "validationStatus", skip_serializing_if = "Option::is_none")]
64
64
+
validation_status: Option<String>,
65
65
+
},
60
66
#[serde(rename = "com.atproto.repo.applyWrites#updateResult")]
61
61
-
UpdateResult { uri: String, cid: String },
67
67
+
UpdateResult {
68
68
+
uri: String,
69
69
+
cid: String,
70
70
+
#[serde(rename = "validationStatus", skip_serializing_if = "Option::is_none")]
71
71
+
validation_status: Option<String>,
72
72
+
},
62
73
#[serde(rename = "com.atproto.repo.applyWrites#deleteResult")]
63
74
DeleteResult {},
64
75
}
···
303
314
rkey,
304
315
value,
305
316
} => {
306
306
-
if input.validate.unwrap_or(true)
307
307
-
&& let Err(err_response) =
308
308
-
validate_record_with_rkey(value, collection, rkey.as_deref())
309
309
-
{
310
310
-
return *err_response;
311
311
-
}
317
317
+
let validation_status = if input.validate == Some(false) {
318
318
+
None
319
319
+
} else {
320
320
+
let require_lexicon = input.validate == Some(true);
321
321
+
match validate_record_with_status(
322
322
+
value,
323
323
+
collection,
324
324
+
rkey.as_deref(),
325
325
+
require_lexicon,
326
326
+
) {
327
327
+
Ok(status) => Some(status),
328
328
+
Err(err_response) => return *err_response,
329
329
+
}
330
330
+
};
312
331
all_blob_cids.extend(extract_blob_cids(value));
313
332
let rkey = rkey
314
333
.clone()
···
345
364
results.push(WriteResult::CreateResult {
346
365
uri,
347
366
cid: record_cid.to_string(),
367
367
+
validation_status: validation_status.map(|s| match s {
368
368
+
ValidationStatus::Valid => "valid".to_string(),
369
369
+
ValidationStatus::Unknown => "unknown".to_string(),
370
370
+
ValidationStatus::Invalid => "invalid".to_string(),
371
371
+
}),
348
372
});
349
373
ops.push(RecordOp::Create {
350
374
collection: collection.clone(),
···
357
381
rkey,
358
382
value,
359
383
} => {
360
360
-
if input.validate.unwrap_or(true)
361
361
-
&& let Err(err_response) =
362
362
-
validate_record_with_rkey(value, collection, Some(rkey))
363
363
-
{
364
364
-
return *err_response;
365
365
-
}
384
384
+
let validation_status = if input.validate == Some(false) {
385
385
+
None
386
386
+
} else {
387
387
+
let require_lexicon = input.validate == Some(true);
388
388
+
match validate_record_with_status(
389
389
+
value,
390
390
+
collection,
391
391
+
Some(rkey),
392
392
+
require_lexicon,
393
393
+
) {
394
394
+
Ok(status) => Some(status),
395
395
+
Err(err_response) => return *err_response,
396
396
+
}
397
397
+
};
366
398
all_blob_cids.extend(extract_blob_cids(value));
367
399
let mut record_bytes = Vec::new();
368
400
if serde_ipld_dagcbor::to_writer(&mut record_bytes, value).is_err() {
···
397
429
results.push(WriteResult::UpdateResult {
398
430
uri,
399
431
cid: record_cid.to_string(),
432
432
+
validation_status: validation_status.map(|s| match s {
433
433
+
ValidationStatus::Valid => "valid".to_string(),
434
434
+
ValidationStatus::Unknown => "unknown".to_string(),
435
435
+
ValidationStatus::Invalid => "invalid".to_string(),
436
436
+
}),
400
437
});
401
438
ops.push(RecordOp::Update {
402
439
collection: collection.clone(),
+33
-10
src/api/repo/record/delete.rs
···
1
1
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log};
2
2
-
use crate::api::repo::record::write::prepare_repo_write;
2
2
+
use crate::api::repo::record::write::{CommitInfo, prepare_repo_write};
3
3
use crate::delegation::{self, DelegationActionType};
4
4
use crate::repo::tracking::TrackingBlockStore;
5
5
use crate::state::AppState;
···
12
12
use cid::Cid;
13
13
use jacquard::types::string::Nsid;
14
14
use jacquard_repo::{commit::Commit, mst::Mst, storage::BlockStore};
15
15
-
use serde::Deserialize;
15
15
+
use serde::{Deserialize, Serialize};
16
16
use serde_json::json;
17
17
use std::str::FromStr;
18
18
use std::sync::Arc;
···
27
27
pub swap_record: Option<String>,
28
28
#[serde(rename = "swapCommit")]
29
29
pub swap_commit: Option<String>,
30
30
+
}
31
31
+
32
32
+
#[derive(Serialize)]
33
33
+
#[serde(rename_all = "camelCase")]
34
34
+
pub struct DeleteRecordOutput {
35
35
+
#[serde(skip_serializing_if = "Option::is_none")]
36
36
+
pub commit: Option<CommitInfo>,
30
37
}
31
38
32
39
pub async fn delete_record(
···
106
113
}
107
114
let prev_record_cid = mst.get(&key).await.ok().flatten();
108
115
if prev_record_cid.is_none() {
109
109
-
return (StatusCode::OK, Json(json!({}))).into_response();
116
116
+
return (
117
117
+
StatusCode::OK,
118
118
+
Json(DeleteRecordOutput { commit: None }),
119
119
+
)
120
120
+
.into_response();
110
121
}
111
122
let new_mst = match mst.delete(&key).await {
112
123
Ok(m) => m,
···
158
169
.iter()
159
170
.map(|c| c.to_string())
160
171
.collect::<Vec<_>>();
161
161
-
if let Err(e) = commit_and_log(
172
172
+
let commit_result = match commit_and_log(
162
173
&state,
163
174
CommitParams {
164
175
did: &did,
···
173
184
)
174
185
.await
175
186
{
176
176
-
return (
177
177
-
StatusCode::INTERNAL_SERVER_ERROR,
178
178
-
Json(json!({"error": "InternalError", "message": e})),
179
179
-
)
180
180
-
.into_response();
187
187
+
Ok(res) => res,
188
188
+
Err(e) => {
189
189
+
return (
190
190
+
StatusCode::INTERNAL_SERVER_ERROR,
191
191
+
Json(json!({"error": "InternalError", "message": e})),
192
192
+
)
193
193
+
.into_response();
194
194
+
}
181
195
};
182
196
183
197
if let Some(ref controller) = controller_did {
···
198
212
.await;
199
213
}
200
214
201
201
-
(StatusCode::OK, Json(json!({}))).into_response()
215
215
+
(
216
216
+
StatusCode::OK,
217
217
+
Json(DeleteRecordOutput {
218
218
+
commit: Some(CommitInfo {
219
219
+
cid: commit_result.commit_cid.to_string(),
220
220
+
rev: commit_result.rev,
221
221
+
}),
222
222
+
}),
223
223
+
)
224
224
+
.into_response()
202
225
}
+2
-2
src/api/repo/record/read.rs
···
160
160
_ => {
161
161
return (
162
162
StatusCode::NOT_FOUND,
163
163
-
Json(json!({"error": "NotFound", "message": "Record not found"})),
163
163
+
Json(json!({"error": "RecordNotFound", "message": "Record not found"})),
164
164
)
165
165
.into_response();
166
166
}
···
170
170
{
171
171
return (
172
172
StatusCode::NOT_FOUND,
173
173
-
Json(json!({"error": "NotFound", "message": "Record CID mismatch"})),
173
173
+
Json(json!({"error": "RecordNotFound", "message": "Record CID mismatch"})),
174
174
)
175
175
.into_response();
176
176
}
+82
-29
src/api/repo/record/validation.rs
···
1
1
-
use crate::validation::{RecordValidator, ValidationError};
1
1
+
use crate::validation::{RecordValidator, ValidationError, ValidationStatus};
2
2
use axum::{
3
3
Json,
4
4
http::StatusCode,
···
16
16
rkey: Option<&str>,
17
17
) -> Result<(), Box<Response>> {
18
18
let validator = RecordValidator::new();
19
19
+
validation_error_to_response(validator.validate_with_rkey(record, collection, rkey))
20
20
+
}
21
21
+
22
22
+
pub fn validate_record_with_status(
23
23
+
record: &serde_json::Value,
24
24
+
collection: &str,
25
25
+
rkey: Option<&str>,
26
26
+
require_lexicon: bool,
27
27
+
) -> Result<ValidationStatus, Box<Response>> {
28
28
+
let validator = RecordValidator::new().require_lexicon(require_lexicon);
19
29
match validator.validate_with_rkey(record, collection, rkey) {
30
30
+
Ok(status) => Ok(status),
31
31
+
Err(e) => Err(validation_error_to_box_response(e)),
32
32
+
}
33
33
+
}
34
34
+
35
35
+
fn validation_error_to_response(
36
36
+
result: Result<ValidationStatus, ValidationError>,
37
37
+
) -> Result<(), Box<Response>> {
38
38
+
match result {
20
39
Ok(_) => Ok(()),
21
21
-
Err(ValidationError::MissingType) => Err(Box::new((
22
22
-
StatusCode::BAD_REQUEST,
23
23
-
Json(json!({"error": "InvalidRecord", "message": "Record must have a $type field"})),
24
24
-
).into_response())),
25
25
-
Err(ValidationError::TypeMismatch { expected, actual }) => Err(Box::new((
26
26
-
StatusCode::BAD_REQUEST,
27
27
-
Json(json!({"error": "InvalidRecord", "message": format!("Record $type '{}' does not match collection '{}'", actual, expected)})),
28
28
-
).into_response())),
29
29
-
Err(ValidationError::MissingField(field)) => Err(Box::new((
30
30
-
StatusCode::BAD_REQUEST,
31
31
-
Json(json!({"error": "InvalidRecord", "message": format!("Missing required field: {}", field)})),
32
32
-
).into_response())),
33
33
-
Err(ValidationError::InvalidField { path, message }) => Err(Box::new((
34
34
-
StatusCode::BAD_REQUEST,
35
35
-
Json(json!({"error": "InvalidRecord", "message": format!("Invalid field '{}': {}", path, message)})),
36
36
-
).into_response())),
37
37
-
Err(ValidationError::InvalidDatetime { path }) => Err(Box::new((
38
38
-
StatusCode::BAD_REQUEST,
39
39
-
Json(json!({"error": "InvalidRecord", "message": format!("Invalid datetime format at '{}'", path)})),
40
40
-
).into_response())),
41
41
-
Err(ValidationError::BannedContent { path }) => Err(Box::new((
42
42
-
StatusCode::BAD_REQUEST,
43
43
-
Json(json!({"error": "InvalidRecord", "message": format!("Unacceptable slur in record at '{}'", path)})),
44
44
-
).into_response())),
45
45
-
Err(e) => Err(Box::new((
46
46
-
StatusCode::BAD_REQUEST,
47
47
-
Json(json!({"error": "InvalidRecord", "message": e.to_string()})),
48
48
-
).into_response())),
40
40
+
Err(e) => Err(validation_error_to_box_response(e)),
41
41
+
}
42
42
+
}
43
43
+
44
44
+
fn validation_error_to_box_response(e: ValidationError) -> Box<Response> {
45
45
+
match e {
46
46
+
ValidationError::MissingType => Box::new(
47
47
+
(
48
48
+
StatusCode::BAD_REQUEST,
49
49
+
Json(json!({"error": "InvalidRecord", "message": "Record must have a $type field"})),
50
50
+
)
51
51
+
.into_response(),
52
52
+
),
53
53
+
ValidationError::TypeMismatch { expected, actual } => Box::new(
54
54
+
(
55
55
+
StatusCode::BAD_REQUEST,
56
56
+
Json(json!({"error": "InvalidRecord", "message": format!("Record $type '{}' does not match collection '{}'", actual, expected)})),
57
57
+
)
58
58
+
.into_response(),
59
59
+
),
60
60
+
ValidationError::MissingField(field) => Box::new(
61
61
+
(
62
62
+
StatusCode::BAD_REQUEST,
63
63
+
Json(json!({"error": "InvalidRecord", "message": format!("Missing required field: {}", field)})),
64
64
+
)
65
65
+
.into_response(),
66
66
+
),
67
67
+
ValidationError::InvalidField { path, message } => Box::new(
68
68
+
(
69
69
+
StatusCode::BAD_REQUEST,
70
70
+
Json(json!({"error": "InvalidRecord", "message": format!("Invalid field '{}': {}", path, message)})),
71
71
+
)
72
72
+
.into_response(),
73
73
+
),
74
74
+
ValidationError::InvalidDatetime { path } => Box::new(
75
75
+
(
76
76
+
StatusCode::BAD_REQUEST,
77
77
+
Json(json!({"error": "InvalidRecord", "message": format!("Invalid datetime format at '{}'", path)})),
78
78
+
)
79
79
+
.into_response(),
80
80
+
),
81
81
+
ValidationError::BannedContent { path } => Box::new(
82
82
+
(
83
83
+
StatusCode::BAD_REQUEST,
84
84
+
Json(json!({"error": "InvalidRecord", "message": format!("Unacceptable slur in record at '{}'", path)})),
85
85
+
)
86
86
+
.into_response(),
87
87
+
),
88
88
+
ValidationError::UnknownType(type_name) => Box::new(
89
89
+
(
90
90
+
StatusCode::BAD_REQUEST,
91
91
+
Json(json!({"error": "InvalidRecord", "message": format!("Lexicon not found: lex:{}", type_name)})),
92
92
+
)
93
93
+
.into_response(),
94
94
+
),
95
95
+
e => Box::new(
96
96
+
(
97
97
+
StatusCode::BAD_REQUEST,
98
98
+
Json(json!({"error": "InvalidRecord", "message": e.to_string()})),
99
99
+
)
100
100
+
.into_response(),
101
101
+
),
49
102
}
50
103
}
+96
-25
src/api/repo/record/write.rs
···
1
1
-
use super::validation::validate_record_with_rkey;
1
1
+
use super::validation::validate_record_with_status;
2
2
+
use crate::validation::ValidationStatus;
2
3
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log, extract_blob_cids};
3
4
use crate::delegation::{self, DelegationActionType};
4
5
use crate::repo::tracking::TrackingBlockStore;
···
185
186
}
186
187
#[derive(Serialize)]
187
188
#[serde(rename_all = "camelCase")]
189
189
+
pub struct CommitInfo {
190
190
+
pub cid: String,
191
191
+
pub rev: String,
192
192
+
}
193
193
+
194
194
+
#[derive(Serialize)]
195
195
+
#[serde(rename_all = "camelCase")]
188
196
pub struct CreateRecordOutput {
189
197
pub uri: String,
190
198
pub cid: String,
199
199
+
pub commit: CommitInfo,
200
200
+
#[serde(skip_serializing_if = "Option::is_none")]
201
201
+
pub validation_status: Option<String>,
191
202
}
192
203
pub async fn create_record(
193
204
State(state): State<AppState>,
···
256
267
.into_response();
257
268
}
258
269
};
259
259
-
if input.validate.unwrap_or(true)
260
260
-
&& let Err(err_response) =
261
261
-
validate_record_with_rkey(&input.record, &input.collection, input.rkey.as_deref())
262
262
-
{
263
263
-
return *err_response;
264
264
-
}
270
270
+
let validation_status = if input.validate == Some(false) {
271
271
+
None
272
272
+
} else {
273
273
+
let require_lexicon = input.validate == Some(true);
274
274
+
match validate_record_with_status(
275
275
+
&input.record,
276
276
+
&input.collection,
277
277
+
input.rkey.as_deref(),
278
278
+
require_lexicon,
279
279
+
) {
280
280
+
Ok(status) => Some(status),
281
281
+
Err(err_response) => return *err_response,
282
282
+
}
283
283
+
};
265
284
let rkey = input
266
285
.rkey
267
286
.unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string());
···
336
355
.map(|c| c.to_string())
337
356
.collect::<Vec<_>>();
338
357
let blob_cids = extract_blob_cids(&input.record);
339
339
-
if let Err(e) = commit_and_log(
358
358
+
let commit_result = match commit_and_log(
340
359
&state,
341
360
CommitParams {
342
361
did: &did,
···
351
370
)
352
371
.await
353
372
{
354
354
-
return (
355
355
-
StatusCode::INTERNAL_SERVER_ERROR,
356
356
-
Json(json!({"error": "InternalError", "message": e})),
357
357
-
)
358
358
-
.into_response();
373
373
+
Ok(res) => res,
374
374
+
Err(e) => {
375
375
+
return (
376
376
+
StatusCode::INTERNAL_SERVER_ERROR,
377
377
+
Json(json!({"error": "InternalError", "message": e})),
378
378
+
)
379
379
+
.into_response();
380
380
+
}
359
381
};
360
382
361
383
if let Some(ref controller) = controller_did {
···
381
403
Json(CreateRecordOutput {
382
404
uri: format!("at://{}/{}/{}", did, input.collection, rkey),
383
405
cid: record_cid.to_string(),
406
406
+
commit: CommitInfo {
407
407
+
cid: commit_result.commit_cid.to_string(),
408
408
+
rev: commit_result.rev,
409
409
+
},
410
410
+
validation_status: validation_status.map(|s| match s {
411
411
+
ValidationStatus::Valid => "valid".to_string(),
412
412
+
ValidationStatus::Unknown => "unknown".to_string(),
413
413
+
ValidationStatus::Invalid => "invalid".to_string(),
414
414
+
}),
384
415
}),
385
416
)
386
417
.into_response()
···
403
434
pub struct PutRecordOutput {
404
435
pub uri: String,
405
436
pub cid: String,
437
437
+
#[serde(skip_serializing_if = "Option::is_none")]
438
438
+
pub commit: Option<CommitInfo>,
439
439
+
#[serde(skip_serializing_if = "Option::is_none")]
440
440
+
pub validation_status: Option<String>,
406
441
}
407
442
pub async fn put_record(
408
443
State(state): State<AppState>,
···
480
515
}
481
516
};
482
517
let key = format!("{}/{}", collection_nsid, input.rkey);
483
483
-
if input.validate.unwrap_or(true)
484
484
-
&& let Err(err_response) =
485
485
-
validate_record_with_rkey(&input.record, &input.collection, Some(&input.rkey))
486
486
-
{
487
487
-
return *err_response;
488
488
-
}
518
518
+
let validation_status = if input.validate == Some(false) {
519
519
+
None
520
520
+
} else {
521
521
+
let require_lexicon = input.validate == Some(true);
522
522
+
match validate_record_with_status(
523
523
+
&input.record,
524
524
+
&input.collection,
525
525
+
Some(&input.rkey),
526
526
+
require_lexicon,
527
527
+
) {
528
528
+
Ok(status) => Some(status),
529
529
+
Err(err_response) => return *err_response,
530
530
+
}
531
531
+
};
489
532
if let Some(swap_record_str) = &input.swap_record {
490
533
let expected_cid = Cid::from_str(swap_record_str).ok();
491
534
let actual_cid = mst.get(&key).await.ok().flatten();
···
512
555
.into_response();
513
556
}
514
557
};
558
558
+
if existing_cid == Some(record_cid) {
559
559
+
return (
560
560
+
StatusCode::OK,
561
561
+
Json(PutRecordOutput {
562
562
+
uri: format!("at://{}/{}/{}", did, input.collection, input.rkey),
563
563
+
cid: record_cid.to_string(),
564
564
+
commit: None,
565
565
+
validation_status: validation_status.map(|s| match s {
566
566
+
ValidationStatus::Valid => "valid".to_string(),
567
567
+
ValidationStatus::Unknown => "unknown".to_string(),
568
568
+
ValidationStatus::Invalid => "invalid".to_string(),
569
569
+
}),
570
570
+
}),
571
571
+
)
572
572
+
.into_response();
573
573
+
}
515
574
let new_mst = if existing_cid.is_some() {
516
575
match mst.update(&key, record_cid).await {
517
576
Ok(m) => m,
···
587
646
.collect::<Vec<_>>();
588
647
let is_update = existing_cid.is_some();
589
648
let blob_cids = extract_blob_cids(&input.record);
590
590
-
if let Err(e) = commit_and_log(
649
649
+
let commit_result = match commit_and_log(
591
650
&state,
592
651
CommitParams {
593
652
did: &did,
···
602
661
)
603
662
.await
604
663
{
605
605
-
return (
606
606
-
StatusCode::INTERNAL_SERVER_ERROR,
607
607
-
Json(json!({"error": "InternalError", "message": e})),
608
608
-
)
609
609
-
.into_response();
664
664
+
Ok(res) => res,
665
665
+
Err(e) => {
666
666
+
return (
667
667
+
StatusCode::INTERNAL_SERVER_ERROR,
668
668
+
Json(json!({"error": "InternalError", "message": e})),
669
669
+
)
670
670
+
.into_response();
671
671
+
}
610
672
};
611
673
612
674
if let Some(ref controller) = controller_did {
···
632
694
Json(PutRecordOutput {
633
695
uri: format!("at://{}/{}/{}", did, input.collection, input.rkey),
634
696
cid: record_cid.to_string(),
697
697
+
commit: Some(CommitInfo {
698
698
+
cid: commit_result.commit_cid.to_string(),
699
699
+
rev: commit_result.rev,
700
700
+
}),
701
701
+
validation_status: validation_status.map(|s| match s {
702
702
+
ValidationStatus::Valid => "valid".to_string(),
703
703
+
ValidationStatus::Unknown => "unknown".to_string(),
704
704
+
ValidationStatus::Invalid => "invalid".to_string(),
705
705
+
}),
635
706
}),
636
707
)
637
708
.into_response()
+484
tests/repo_conformance.rs
···
1
1
+
mod common;
2
2
+
mod helpers;
3
3
+
use chrono::Utc;
4
4
+
use common::*;
5
5
+
use helpers::*;
6
6
+
use reqwest::StatusCode;
7
7
+
use serde_json::{Value, json};
8
8
+
9
9
+
#[tokio::test]
10
10
+
async fn test_create_record_response_schema() {
11
11
+
let client = client();
12
12
+
let (did, jwt) = setup_new_user("conform-create").await;
13
13
+
let now = Utc::now().to_rfc3339();
14
14
+
15
15
+
let payload = json!({
16
16
+
"repo": did,
17
17
+
"collection": "app.bsky.feed.post",
18
18
+
"record": {
19
19
+
"$type": "app.bsky.feed.post",
20
20
+
"text": "Testing conformance",
21
21
+
"createdAt": now
22
22
+
}
23
23
+
});
24
24
+
25
25
+
let res = client
26
26
+
.post(format!("{}/xrpc/com.atproto.repo.createRecord", base_url().await))
27
27
+
.bearer_auth(&jwt)
28
28
+
.json(&payload)
29
29
+
.send()
30
30
+
.await
31
31
+
.expect("Failed to create record");
32
32
+
33
33
+
assert_eq!(res.status(), StatusCode::OK);
34
34
+
let body: Value = res.json().await.unwrap();
35
35
+
36
36
+
assert!(body["uri"].is_string(), "response must have uri");
37
37
+
assert!(body["cid"].is_string(), "response must have cid");
38
38
+
assert!(body["cid"].as_str().unwrap().starts_with("bafy"), "cid must be valid");
39
39
+
40
40
+
assert!(body["commit"].is_object(), "response must have commit object");
41
41
+
let commit = &body["commit"];
42
42
+
assert!(commit["cid"].is_string(), "commit must have cid");
43
43
+
assert!(commit["cid"].as_str().unwrap().starts_with("bafy"), "commit.cid must be valid");
44
44
+
assert!(commit["rev"].is_string(), "commit must have rev");
45
45
+
46
46
+
assert!(body["validationStatus"].is_string(), "response must have validationStatus when validate defaults to true");
47
47
+
assert_eq!(body["validationStatus"], "valid", "validationStatus should be 'valid'");
48
48
+
}
49
49
+
50
50
+
#[tokio::test]
51
51
+
async fn test_create_record_no_validation_status_when_validate_false() {
52
52
+
let client = client();
53
53
+
let (did, jwt) = setup_new_user("conform-create-noval").await;
54
54
+
let now = Utc::now().to_rfc3339();
55
55
+
56
56
+
let payload = json!({
57
57
+
"repo": did,
58
58
+
"collection": "app.bsky.feed.post",
59
59
+
"validate": false,
60
60
+
"record": {
61
61
+
"$type": "app.bsky.feed.post",
62
62
+
"text": "Testing without validation",
63
63
+
"createdAt": now
64
64
+
}
65
65
+
});
66
66
+
67
67
+
let res = client
68
68
+
.post(format!("{}/xrpc/com.atproto.repo.createRecord", base_url().await))
69
69
+
.bearer_auth(&jwt)
70
70
+
.json(&payload)
71
71
+
.send()
72
72
+
.await
73
73
+
.expect("Failed to create record");
74
74
+
75
75
+
assert_eq!(res.status(), StatusCode::OK);
76
76
+
let body: Value = res.json().await.unwrap();
77
77
+
78
78
+
assert!(body["uri"].is_string());
79
79
+
assert!(body["commit"].is_object());
80
80
+
assert!(body["validationStatus"].is_null(), "validationStatus should be omitted when validate=false");
81
81
+
}
82
82
+
83
83
+
#[tokio::test]
84
84
+
async fn test_put_record_response_schema() {
85
85
+
let client = client();
86
86
+
let (did, jwt) = setup_new_user("conform-put").await;
87
87
+
let now = Utc::now().to_rfc3339();
88
88
+
89
89
+
let payload = json!({
90
90
+
"repo": did,
91
91
+
"collection": "app.bsky.feed.post",
92
92
+
"rkey": "conformance-put",
93
93
+
"record": {
94
94
+
"$type": "app.bsky.feed.post",
95
95
+
"text": "Testing putRecord conformance",
96
96
+
"createdAt": now
97
97
+
}
98
98
+
});
99
99
+
100
100
+
let res = client
101
101
+
.post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await))
102
102
+
.bearer_auth(&jwt)
103
103
+
.json(&payload)
104
104
+
.send()
105
105
+
.await
106
106
+
.expect("Failed to put record");
107
107
+
108
108
+
assert_eq!(res.status(), StatusCode::OK);
109
109
+
let body: Value = res.json().await.unwrap();
110
110
+
111
111
+
assert!(body["uri"].is_string(), "response must have uri");
112
112
+
assert!(body["cid"].is_string(), "response must have cid");
113
113
+
114
114
+
assert!(body["commit"].is_object(), "response must have commit object");
115
115
+
let commit = &body["commit"];
116
116
+
assert!(commit["cid"].is_string(), "commit must have cid");
117
117
+
assert!(commit["rev"].is_string(), "commit must have rev");
118
118
+
119
119
+
assert_eq!(body["validationStatus"], "valid", "validationStatus should be 'valid'");
120
120
+
}
121
121
+
122
122
+
#[tokio::test]
123
123
+
async fn test_delete_record_response_schema() {
124
124
+
let client = client();
125
125
+
let (did, jwt) = setup_new_user("conform-delete").await;
126
126
+
let now = Utc::now().to_rfc3339();
127
127
+
128
128
+
let create_payload = json!({
129
129
+
"repo": did,
130
130
+
"collection": "app.bsky.feed.post",
131
131
+
"rkey": "to-delete",
132
132
+
"record": {
133
133
+
"$type": "app.bsky.feed.post",
134
134
+
"text": "This will be deleted",
135
135
+
"createdAt": now
136
136
+
}
137
137
+
});
138
138
+
let create_res = client
139
139
+
.post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await))
140
140
+
.bearer_auth(&jwt)
141
141
+
.json(&create_payload)
142
142
+
.send()
143
143
+
.await
144
144
+
.expect("Failed to create record");
145
145
+
assert_eq!(create_res.status(), StatusCode::OK);
146
146
+
147
147
+
let delete_payload = json!({
148
148
+
"repo": did,
149
149
+
"collection": "app.bsky.feed.post",
150
150
+
"rkey": "to-delete"
151
151
+
});
152
152
+
let delete_res = client
153
153
+
.post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base_url().await))
154
154
+
.bearer_auth(&jwt)
155
155
+
.json(&delete_payload)
156
156
+
.send()
157
157
+
.await
158
158
+
.expect("Failed to delete record");
159
159
+
160
160
+
assert_eq!(delete_res.status(), StatusCode::OK);
161
161
+
let body: Value = delete_res.json().await.unwrap();
162
162
+
163
163
+
assert!(body["commit"].is_object(), "response must have commit object when record was deleted");
164
164
+
let commit = &body["commit"];
165
165
+
assert!(commit["cid"].is_string(), "commit must have cid");
166
166
+
assert!(commit["rev"].is_string(), "commit must have rev");
167
167
+
}
168
168
+
169
169
+
#[tokio::test]
170
170
+
async fn test_delete_record_noop_response() {
171
171
+
let client = client();
172
172
+
let (did, jwt) = setup_new_user("conform-delete-noop").await;
173
173
+
174
174
+
let delete_payload = json!({
175
175
+
"repo": did,
176
176
+
"collection": "app.bsky.feed.post",
177
177
+
"rkey": "nonexistent-record"
178
178
+
});
179
179
+
let delete_res = client
180
180
+
.post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base_url().await))
181
181
+
.bearer_auth(&jwt)
182
182
+
.json(&delete_payload)
183
183
+
.send()
184
184
+
.await
185
185
+
.expect("Failed to delete record");
186
186
+
187
187
+
assert_eq!(delete_res.status(), StatusCode::OK);
188
188
+
let body: Value = delete_res.json().await.unwrap();
189
189
+
190
190
+
assert!(body["commit"].is_null(), "commit should be omitted on no-op delete");
191
191
+
}
192
192
+
193
193
+
#[tokio::test]
194
194
+
async fn test_apply_writes_response_schema() {
195
195
+
let client = client();
196
196
+
let (did, jwt) = setup_new_user("conform-apply").await;
197
197
+
let now = Utc::now().to_rfc3339();
198
198
+
199
199
+
let payload = json!({
200
200
+
"repo": did,
201
201
+
"writes": [
202
202
+
{
203
203
+
"$type": "com.atproto.repo.applyWrites#create",
204
204
+
"collection": "app.bsky.feed.post",
205
205
+
"rkey": "apply-test-1",
206
206
+
"value": {
207
207
+
"$type": "app.bsky.feed.post",
208
208
+
"text": "First post",
209
209
+
"createdAt": now
210
210
+
}
211
211
+
},
212
212
+
{
213
213
+
"$type": "com.atproto.repo.applyWrites#create",
214
214
+
"collection": "app.bsky.feed.post",
215
215
+
"rkey": "apply-test-2",
216
216
+
"value": {
217
217
+
"$type": "app.bsky.feed.post",
218
218
+
"text": "Second post",
219
219
+
"createdAt": now
220
220
+
}
221
221
+
}
222
222
+
]
223
223
+
});
224
224
+
225
225
+
let res = client
226
226
+
.post(format!("{}/xrpc/com.atproto.repo.applyWrites", base_url().await))
227
227
+
.bearer_auth(&jwt)
228
228
+
.json(&payload)
229
229
+
.send()
230
230
+
.await
231
231
+
.expect("Failed to apply writes");
232
232
+
233
233
+
assert_eq!(res.status(), StatusCode::OK);
234
234
+
let body: Value = res.json().await.unwrap();
235
235
+
236
236
+
assert!(body["commit"].is_object(), "response must have commit object");
237
237
+
let commit = &body["commit"];
238
238
+
assert!(commit["cid"].is_string(), "commit must have cid");
239
239
+
assert!(commit["rev"].is_string(), "commit must have rev");
240
240
+
241
241
+
assert!(body["results"].is_array(), "response must have results array");
242
242
+
let results = body["results"].as_array().unwrap();
243
243
+
assert_eq!(results.len(), 2, "should have 2 results");
244
244
+
245
245
+
for result in results {
246
246
+
assert!(result["uri"].is_string(), "result must have uri");
247
247
+
assert!(result["cid"].is_string(), "result must have cid");
248
248
+
assert_eq!(result["validationStatus"], "valid", "result must have validationStatus");
249
249
+
assert_eq!(result["$type"], "com.atproto.repo.applyWrites#createResult");
250
250
+
}
251
251
+
}
252
252
+
253
253
+
#[tokio::test]
254
254
+
async fn test_apply_writes_update_and_delete_results() {
255
255
+
let client = client();
256
256
+
let (did, jwt) = setup_new_user("conform-apply-upd").await;
257
257
+
let now = Utc::now().to_rfc3339();
258
258
+
259
259
+
let create_payload = json!({
260
260
+
"repo": did,
261
261
+
"collection": "app.bsky.feed.post",
262
262
+
"rkey": "to-update",
263
263
+
"record": {
264
264
+
"$type": "app.bsky.feed.post",
265
265
+
"text": "Original",
266
266
+
"createdAt": now
267
267
+
}
268
268
+
});
269
269
+
client
270
270
+
.post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await))
271
271
+
.bearer_auth(&jwt)
272
272
+
.json(&create_payload)
273
273
+
.send()
274
274
+
.await
275
275
+
.expect("setup failed");
276
276
+
277
277
+
let payload = json!({
278
278
+
"repo": did,
279
279
+
"writes": [
280
280
+
{
281
281
+
"$type": "com.atproto.repo.applyWrites#update",
282
282
+
"collection": "app.bsky.feed.post",
283
283
+
"rkey": "to-update",
284
284
+
"value": {
285
285
+
"$type": "app.bsky.feed.post",
286
286
+
"text": "Updated",
287
287
+
"createdAt": now
288
288
+
}
289
289
+
},
290
290
+
{
291
291
+
"$type": "com.atproto.repo.applyWrites#delete",
292
292
+
"collection": "app.bsky.feed.post",
293
293
+
"rkey": "to-update"
294
294
+
}
295
295
+
]
296
296
+
});
297
297
+
298
298
+
let res = client
299
299
+
.post(format!("{}/xrpc/com.atproto.repo.applyWrites", base_url().await))
300
300
+
.bearer_auth(&jwt)
301
301
+
.json(&payload)
302
302
+
.send()
303
303
+
.await
304
304
+
.expect("Failed to apply writes");
305
305
+
306
306
+
assert_eq!(res.status(), StatusCode::OK);
307
307
+
let body: Value = res.json().await.unwrap();
308
308
+
309
309
+
let results = body["results"].as_array().unwrap();
310
310
+
assert_eq!(results.len(), 2);
311
311
+
312
312
+
let update_result = &results[0];
313
313
+
assert_eq!(update_result["$type"], "com.atproto.repo.applyWrites#updateResult");
314
314
+
assert!(update_result["uri"].is_string());
315
315
+
assert!(update_result["cid"].is_string());
316
316
+
assert_eq!(update_result["validationStatus"], "valid");
317
317
+
318
318
+
let delete_result = &results[1];
319
319
+
assert_eq!(delete_result["$type"], "com.atproto.repo.applyWrites#deleteResult");
320
320
+
assert!(delete_result["uri"].is_null(), "delete result should not have uri");
321
321
+
assert!(delete_result["cid"].is_null(), "delete result should not have cid");
322
322
+
assert!(delete_result["validationStatus"].is_null(), "delete result should not have validationStatus");
323
323
+
}
324
324
+
325
325
+
#[tokio::test]
326
326
+
async fn test_get_record_error_code() {
327
327
+
let client = client();
328
328
+
let (did, _jwt) = setup_new_user("conform-get-err").await;
329
329
+
330
330
+
let res = client
331
331
+
.get(format!("{}/xrpc/com.atproto.repo.getRecord", base_url().await))
332
332
+
.query(&[
333
333
+
("repo", did.as_str()),
334
334
+
("collection", "app.bsky.feed.post"),
335
335
+
("rkey", "nonexistent"),
336
336
+
])
337
337
+
.send()
338
338
+
.await
339
339
+
.expect("Failed to get record");
340
340
+
341
341
+
assert_eq!(res.status(), StatusCode::NOT_FOUND);
342
342
+
let body: Value = res.json().await.unwrap();
343
343
+
assert_eq!(body["error"], "RecordNotFound", "error code should be RecordNotFound per atproto spec");
344
344
+
}
345
345
+
346
346
+
#[tokio::test]
347
347
+
async fn test_create_record_unknown_lexicon_default_validation() {
348
348
+
let client = client();
349
349
+
let (did, jwt) = setup_new_user("conform-unknown-lex").await;
350
350
+
351
351
+
let payload = json!({
352
352
+
"repo": did,
353
353
+
"collection": "com.example.custom",
354
354
+
"record": {
355
355
+
"$type": "com.example.custom",
356
356
+
"data": "some custom data"
357
357
+
}
358
358
+
});
359
359
+
360
360
+
let res = client
361
361
+
.post(format!("{}/xrpc/com.atproto.repo.createRecord", base_url().await))
362
362
+
.bearer_auth(&jwt)
363
363
+
.json(&payload)
364
364
+
.send()
365
365
+
.await
366
366
+
.expect("Failed to create record");
367
367
+
368
368
+
assert_eq!(res.status(), StatusCode::OK, "unknown lexicon should be allowed with default validation");
369
369
+
let body: Value = res.json().await.unwrap();
370
370
+
371
371
+
assert!(body["uri"].is_string());
372
372
+
assert!(body["cid"].is_string());
373
373
+
assert!(body["commit"].is_object());
374
374
+
assert_eq!(body["validationStatus"], "unknown", "validationStatus should be 'unknown' for unknown lexicons");
375
375
+
}
376
376
+
377
377
+
#[tokio::test]
378
378
+
async fn test_create_record_unknown_lexicon_strict_validation() {
379
379
+
let client = client();
380
380
+
let (did, jwt) = setup_new_user("conform-unknown-strict").await;
381
381
+
382
382
+
let payload = json!({
383
383
+
"repo": did,
384
384
+
"collection": "com.example.custom",
385
385
+
"validate": true,
386
386
+
"record": {
387
387
+
"$type": "com.example.custom",
388
388
+
"data": "some custom data"
389
389
+
}
390
390
+
});
391
391
+
392
392
+
let res = client
393
393
+
.post(format!("{}/xrpc/com.atproto.repo.createRecord", base_url().await))
394
394
+
.bearer_auth(&jwt)
395
395
+
.json(&payload)
396
396
+
.send()
397
397
+
.await
398
398
+
.expect("Failed to send request");
399
399
+
400
400
+
assert_eq!(res.status(), StatusCode::BAD_REQUEST, "unknown lexicon should fail with validate=true");
401
401
+
let body: Value = res.json().await.unwrap();
402
402
+
assert_eq!(body["error"], "InvalidRecord");
403
403
+
assert!(body["message"].as_str().unwrap().contains("Lexicon not found"), "error should mention lexicon not found");
404
404
+
}
405
405
+
406
406
+
#[tokio::test]
407
407
+
async fn test_put_record_noop_same_content() {
408
408
+
let client = client();
409
409
+
let (did, jwt) = setup_new_user("conform-put-noop").await;
410
410
+
let now = Utc::now().to_rfc3339();
411
411
+
412
412
+
let record = json!({
413
413
+
"$type": "app.bsky.feed.post",
414
414
+
"text": "This content will not change",
415
415
+
"createdAt": now
416
416
+
});
417
417
+
418
418
+
let payload = json!({
419
419
+
"repo": did,
420
420
+
"collection": "app.bsky.feed.post",
421
421
+
"rkey": "noop-test",
422
422
+
"record": record.clone()
423
423
+
});
424
424
+
425
425
+
let first_res = client
426
426
+
.post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await))
427
427
+
.bearer_auth(&jwt)
428
428
+
.json(&payload)
429
429
+
.send()
430
430
+
.await
431
431
+
.expect("Failed to put record");
432
432
+
assert_eq!(first_res.status(), StatusCode::OK);
433
433
+
let first_body: Value = first_res.json().await.unwrap();
434
434
+
assert!(first_body["commit"].is_object(), "first put should have commit");
435
435
+
436
436
+
let second_res = client
437
437
+
.post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await))
438
438
+
.bearer_auth(&jwt)
439
439
+
.json(&payload)
440
440
+
.send()
441
441
+
.await
442
442
+
.expect("Failed to put record");
443
443
+
assert_eq!(second_res.status(), StatusCode::OK);
444
444
+
let second_body: Value = second_res.json().await.unwrap();
445
445
+
446
446
+
assert!(second_body["commit"].is_null(), "second put with same content should have no commit (no-op)");
447
447
+
assert_eq!(first_body["cid"], second_body["cid"], "CID should be the same for identical content");
448
448
+
}
449
449
+
450
450
+
#[tokio::test]
451
451
+
async fn test_apply_writes_unknown_lexicon() {
452
452
+
let client = client();
453
453
+
let (did, jwt) = setup_new_user("conform-apply-unknown").await;
454
454
+
455
455
+
let payload = json!({
456
456
+
"repo": did,
457
457
+
"writes": [
458
458
+
{
459
459
+
"$type": "com.atproto.repo.applyWrites#create",
460
460
+
"collection": "com.example.custom",
461
461
+
"rkey": "custom-1",
462
462
+
"value": {
463
463
+
"$type": "com.example.custom",
464
464
+
"data": "custom data"
465
465
+
}
466
466
+
}
467
467
+
]
468
468
+
});
469
469
+
470
470
+
let res = client
471
471
+
.post(format!("{}/xrpc/com.atproto.repo.applyWrites", base_url().await))
472
472
+
.bearer_auth(&jwt)
473
473
+
.json(&payload)
474
474
+
.send()
475
475
+
.await
476
476
+
.expect("Failed to apply writes");
477
477
+
478
478
+
assert_eq!(res.status(), StatusCode::OK);
479
479
+
let body: Value = res.json().await.unwrap();
480
480
+
481
481
+
let results = body["results"].as_array().unwrap();
482
482
+
assert_eq!(results.len(), 1);
483
483
+
assert_eq!(results[0]["validationStatus"], "unknown", "unknown lexicon should have 'unknown' status");
484
484
+
}