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