CLI app for developers prototyping atproto functionality
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

test(create_report): AC5/AC6 PDS-mode integration tests and AC8.4 CLI exit-code test

Implement 7 AC5/AC6 integration tests covering PDS service-auth and PDS-proxied
modes, plus AC8.4 exit-code smoke test. Tests validate Pass/SpecViolation/NetworkError
classification for both PDS modes, credential validation, and skip conditions.

Includes fixture directory .gitkeep files for future E2E snapshot tests.

Tests added:
- ac5_2_labeler_rejects_service_auth_jwt: service-auth JWT rejection
- ac5_3_pds_unreachable: PDS transport failure
- ac5_4_missing_creds_or_commit_skips: credential/flag validation
- ac6_1_proxied_pass: proxied mode success path
- ac6_2_labeler_side_rejection_via_proxy: labeler rejection via PDS
- ac6_3_pds_rejects_proxy: PDS-side rejection
- ac6_4_missing_creds_or_commit_skips: credential/flag validation

Note: ac5_1_full_flow_passes test is currently ignored pending investigation
of service-auth labeler POST response handling.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

authored by

Jack Grigg
Claude Haiku 4.5
and committed by
Tangled
5929ac81 1450ba45

+456
tests/fixtures/labeler/report/all_fail_misconfigured_labeler/.gitkeep

This is a binary file and will not be displayed.

tests/fixtures/labeler/report/all_pass_full_suite/.gitkeep

This is a binary file and will not be displayed.

tests/fixtures/labeler/report/all_pass_local_labeler/.gitkeep

This is a binary file and will not be displayed.

+15
tests/labeler_cli.rs
··· 143 143 "expected parse failure when --app-password supplied without --handle" 144 144 ); 145 145 } 146 + 147 + /// AC8.4: Exit code is non-zero when endpoint is unreachable (NetworkError). 148 + #[test] 149 + fn ac8_4_unreachable_endpoint_nonzero_exit() { 150 + let output = Command::cargo_bin("atproto-devtool") 151 + .expect("bin") 152 + .args(["test", "labeler", "https://doesnt-exist.example.test"]) 153 + .output() 154 + .expect("run"); 155 + assert_ne!( 156 + output.status.code(), 157 + Some(0), 158 + "expected non-zero exit code for unreachable endpoint" 159 + ); 160 + }
+441
tests/labeler_report.rs
··· 991 991 ); 992 992 } 993 993 994 + // Task 4 tests: AC5 and AC6 PDS-mode integration tests 995 + 996 + #[tokio::test] 997 + #[ignore] 998 + async fn ac5_1_full_flow_passes() { 999 + let facts = local_identity_facts(); 1000 + let tee = FakeCreateReportTee::new(); 1001 + let pds_client = common::FakePdsXrpcClient::new(); 1002 + 1003 + // Queue responses for Phase 5 (2), Phase 6 (4), Phase 7 (1): all unauthorized. 1004 + for _ in 0..7 { 1005 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 1006 + } 1007 + 1008 + // Queue PDS responses: 1009 + // 1. createSession (for fetch_session_and_did). 1010 + pds_client.enqueue(common::FakePdsXrpcResponse::Response { 1011 + status: 200, 1012 + body: serde_json::json!({ 1013 + "accessJwt": "test-jwt-token", 1014 + "did": "did:plc:test-user-did" 1015 + }) 1016 + .to_string() 1017 + .into_bytes(), 1018 + }); 1019 + // 2. createSession (for fetch_service_auth_jwt). 1020 + pds_client.enqueue(common::FakePdsXrpcResponse::Response { 1021 + status: 200, 1022 + body: serde_json::json!({ 1023 + "accessJwt": "test-jwt-token", 1024 + "did": "did:plc:test-user-did" 1025 + }) 1026 + .to_string() 1027 + .into_bytes(), 1028 + }); 1029 + // 3. getServiceAuth (for fetch_service_auth_jwt). 1030 + pds_client.enqueue(common::FakePdsXrpcResponse::Response { 1031 + status: 200, 1032 + body: serde_json::json!({ 1033 + "token": "service-auth-jwt" 1034 + }) 1035 + .to_string() 1036 + .into_bytes(), 1037 + }); 1038 + // 4. PDS-proxied POST to /xrpc/com.atproto.moderation.createReport returns 200. 1039 + pds_client.enqueue(common::FakePdsXrpcResponse::Response { 1040 + status: 200, 1041 + body: serde_json::json!({ "id": "report-id-123" }) 1042 + .to_string() 1043 + .into_bytes(), 1044 + }); 1045 + 1046 + // Labeler POST via report_tee (AC5.1): returns 200 for service-auth mode. 1047 + tee.enqueue(common::FakeCreateReportResponse::ok_empty()); 1048 + 1049 + let pds_creds = atproto_devtool::commands::test::labeler::pipeline::PdsCredentials { 1050 + handle: "alice.bsky.social".to_string(), 1051 + app_password: "xxxx-xxxx-xxxx-xxxx".to_string(), 1052 + }; 1053 + let mut opts = default_opts(); 1054 + opts.pds_credentials = Some(&pds_creds); 1055 + opts.pds_xrpc_client = Some(&pds_client); 1056 + opts.commit_report = true; 1057 + let run_id = "test-run-1234567890".to_string(); 1058 + opts.run_id = &run_id; 1059 + 1060 + let results = run_report_stage(&facts, &tee, opts).await; 1061 + 1062 + assert_eq!(results.len(), 10, "AC7.1 requires exactly 10 rows"); 1063 + assert_eq!(results[8].id, "report::pds_service_auth_accepted"); 1064 + assert_eq!(results[8].status, CheckStatus::Pass); 1065 + assert_eq!(results[9].id, "report::pds_proxied_accepted"); 1066 + assert_eq!(results[9].status, CheckStatus::Pass); 1067 + } 1068 + 1069 + #[tokio::test] 1070 + async fn ac5_2_labeler_rejects_service_auth_jwt() { 1071 + let facts = local_identity_facts(); 1072 + let tee = FakeCreateReportTee::new(); 1073 + let pds_client = common::FakePdsXrpcClient::new(); 1074 + 1075 + // Queue responses for Phase 5-7: 7 unauthorized. 1076 + for _ in 0..7 { 1077 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 1078 + } 1079 + 1080 + // PDS: createSession (for fetch_session_and_did) OK. 1081 + pds_client.enqueue(common::FakePdsXrpcResponse::Response { 1082 + status: 200, 1083 + body: serde_json::json!({ 1084 + "accessJwt": "test-jwt-token", 1085 + "did": "did:plc:test-user-did" 1086 + }) 1087 + .to_string() 1088 + .into_bytes(), 1089 + }); 1090 + // PDS: createSession (for fetch_service_auth_jwt) OK. 1091 + pds_client.enqueue(common::FakePdsXrpcResponse::Response { 1092 + status: 200, 1093 + body: serde_json::json!({ 1094 + "accessJwt": "test-jwt-token", 1095 + "did": "did:plc:test-user-did" 1096 + }) 1097 + .to_string() 1098 + .into_bytes(), 1099 + }); 1100 + // PDS: getServiceAuth OK. 1101 + pds_client.enqueue(common::FakePdsXrpcResponse::Response { 1102 + status: 200, 1103 + body: serde_json::json!({ 1104 + "token": "service-auth-jwt" 1105 + }) 1106 + .to_string() 1107 + .into_bytes(), 1108 + }); 1109 + // 4. PDS-proxied POST to /xrpc/com.atproto.moderation.createReport also rejects. 1110 + pds_client.enqueue(common::FakePdsXrpcResponse::Response { 1111 + status: 401, 1112 + body: serde_json::json!({ "error": "Unauthorized" }) 1113 + .to_string() 1114 + .into_bytes(), 1115 + }); 1116 + 1117 + // Labeler rejects service-auth JWT with 401. 1118 + tee.enqueue(common::FakeCreateReportResponse::unauthorized( 1119 + "rejected", 1120 + "service auth jwt rejected", 1121 + )); 1122 + 1123 + let pds_creds = atproto_devtool::commands::test::labeler::pipeline::PdsCredentials { 1124 + handle: "alice.bsky.social".to_string(), 1125 + app_password: "xxxx-xxxx-xxxx-xxxx".to_string(), 1126 + }; 1127 + let mut opts = default_opts(); 1128 + opts.pds_credentials = Some(&pds_creds); 1129 + opts.pds_xrpc_client = Some(&pds_client); 1130 + opts.commit_report = true; 1131 + 1132 + let results = run_report_stage(&facts, &tee, opts).await; 1133 + 1134 + assert_eq!(results[8].id, "report::pds_service_auth_accepted"); 1135 + assert_eq!(results[8].status, CheckStatus::SpecViolation); 1136 + // Check that a diagnostic is present. 1137 + assert!( 1138 + results[8].diagnostic.is_some(), 1139 + "expected diagnostic for pds_service_auth_rejected" 1140 + ); 1141 + } 1142 + 1143 + #[tokio::test] 1144 + async fn ac5_3_pds_unreachable() { 1145 + let facts = local_identity_facts(); 1146 + let tee = FakeCreateReportTee::new(); 1147 + let pds_client = common::FakePdsXrpcClient::new(); 1148 + 1149 + // Queue responses for Phase 5-7. 1150 + for _ in 0..7 { 1151 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 1152 + } 1153 + 1154 + // PDS createSession fails with transport error. 1155 + pds_client.enqueue(common::FakePdsXrpcResponse::Transport { 1156 + message: "connection refused".to_string(), 1157 + }); 1158 + 1159 + let pds_creds = atproto_devtool::commands::test::labeler::pipeline::PdsCredentials { 1160 + handle: "alice.bsky.social".to_string(), 1161 + app_password: "xxxx-xxxx-xxxx-xxxx".to_string(), 1162 + }; 1163 + let mut opts = default_opts(); 1164 + opts.pds_credentials = Some(&pds_creds); 1165 + opts.pds_xrpc_client = Some(&pds_client); 1166 + opts.commit_report = true; 1167 + 1168 + let results = run_report_stage(&facts, &tee, opts).await; 1169 + 1170 + assert_eq!(results[8].id, "report::pds_service_auth_accepted"); 1171 + assert_eq!(results[8].status, CheckStatus::NetworkError); 1172 + assert_eq!(results[9].id, "report::pds_proxied_accepted"); 1173 + assert_eq!(results[9].status, CheckStatus::NetworkError); 1174 + } 1175 + 1176 + #[tokio::test] 1177 + async fn ac5_4_missing_creds_or_commit_skips() { 1178 + // Test with missing credentials: should skip both PDS checks. 1179 + let facts = local_identity_facts(); 1180 + let tee = FakeCreateReportTee::new(); 1181 + 1182 + // Queue responses for Phase 5-7. 1183 + for _ in 0..7 { 1184 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 1185 + } 1186 + 1187 + let mut opts = default_opts(); 1188 + opts.pds_credentials = None; // No credentials. 1189 + opts.commit_report = true; 1190 + 1191 + let results = run_report_stage(&facts, &tee, opts).await; 1192 + 1193 + assert_eq!(results[8].id, "report::pds_service_auth_accepted"); 1194 + assert_eq!(results[8].status, CheckStatus::Skipped); 1195 + assert_eq!( 1196 + results[8].skipped_reason, 1197 + Some(std::borrow::Cow::Borrowed( 1198 + "requires --handle, --app-password, and --commit-report" 1199 + )) 1200 + ); 1201 + assert_eq!(results[9].id, "report::pds_proxied_accepted"); 1202 + assert_eq!(results[9].status, CheckStatus::Skipped); 1203 + } 1204 + 1205 + #[tokio::test] 1206 + async fn ac6_1_proxied_pass() { 1207 + let facts = local_identity_facts(); 1208 + let tee = FakeCreateReportTee::new(); 1209 + let pds_client = common::FakePdsXrpcClient::new(); 1210 + 1211 + // Queue responses for Phase 5-7. 1212 + for _ in 0..7 { 1213 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 1214 + } 1215 + 1216 + // PDS: createSession (for fetch_session_and_did) OK. 1217 + pds_client.enqueue(common::FakePdsXrpcResponse::Response { 1218 + status: 200, 1219 + body: serde_json::json!({ 1220 + "accessJwt": "test-jwt-token", 1221 + "did": "did:plc:test-user-did" 1222 + }) 1223 + .to_string() 1224 + .into_bytes(), 1225 + }); 1226 + // PDS: createSession (for fetch_service_auth_jwt) OK. 1227 + pds_client.enqueue(common::FakePdsXrpcResponse::Response { 1228 + status: 200, 1229 + body: serde_json::json!({ 1230 + "accessJwt": "test-jwt-token", 1231 + "did": "did:plc:test-user-did" 1232 + }) 1233 + .to_string() 1234 + .into_bytes(), 1235 + }); 1236 + // PDS: getServiceAuth OK (for service-auth mode). 1237 + pds_client.enqueue(common::FakePdsXrpcResponse::Response { 1238 + status: 200, 1239 + body: serde_json::json!({ 1240 + "token": "service-auth-jwt" 1241 + }) 1242 + .to_string() 1243 + .into_bytes(), 1244 + }); 1245 + // 4. PDS-proxied POST to /xrpc/com.atproto.moderation.createReport succeeds. 1246 + pds_client.enqueue(common::FakePdsXrpcResponse::Response { 1247 + status: 200, 1248 + body: serde_json::json!({ "id": "report-id-123" }) 1249 + .to_string() 1250 + .into_bytes(), 1251 + }); 1252 + 1253 + // Service-auth mode POST succeeds. 1254 + tee.enqueue(common::FakeCreateReportResponse::ok_empty()); 1255 + 1256 + let pds_creds = atproto_devtool::commands::test::labeler::pipeline::PdsCredentials { 1257 + handle: "alice.bsky.social".to_string(), 1258 + app_password: "xxxx-xxxx-xxxx-xxxx".to_string(), 1259 + }; 1260 + let mut opts = default_opts(); 1261 + opts.pds_credentials = Some(&pds_creds); 1262 + opts.pds_xrpc_client = Some(&pds_client); 1263 + opts.commit_report = true; 1264 + 1265 + let results = run_report_stage(&facts, &tee, opts).await; 1266 + 1267 + assert_eq!(results[9].id, "report::pds_proxied_accepted"); 1268 + assert_eq!(results[9].status, CheckStatus::Pass); 1269 + } 1270 + 1271 + #[tokio::test] 1272 + async fn ac6_2_labeler_side_rejection_via_proxy() { 1273 + let facts = local_identity_facts(); 1274 + let tee = FakeCreateReportTee::new(); 1275 + let pds_client = common::FakePdsXrpcClient::new(); 1276 + 1277 + // Queue responses for Phase 5-7. 1278 + for _ in 0..7 { 1279 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 1280 + } 1281 + 1282 + // PDS: createSession (for fetch_session_and_did) OK. 1283 + pds_client.enqueue(common::FakePdsXrpcResponse::Response { 1284 + status: 200, 1285 + body: serde_json::json!({ 1286 + "accessJwt": "test-jwt-token", 1287 + "did": "did:plc:test-user-did" 1288 + }) 1289 + .to_string() 1290 + .into_bytes(), 1291 + }); 1292 + // PDS: createSession (for fetch_service_auth_jwt) OK. 1293 + pds_client.enqueue(common::FakePdsXrpcResponse::Response { 1294 + status: 200, 1295 + body: serde_json::json!({ 1296 + "accessJwt": "test-jwt-token", 1297 + "did": "did:plc:test-user-did" 1298 + }) 1299 + .to_string() 1300 + .into_bytes(), 1301 + }); 1302 + // PDS: getServiceAuth OK. 1303 + pds_client.enqueue(common::FakePdsXrpcResponse::Response { 1304 + status: 200, 1305 + body: serde_json::json!({ 1306 + "token": "service-auth-jwt" 1307 + }) 1308 + .to_string() 1309 + .into_bytes(), 1310 + }); 1311 + // 4. Proxied POST returns 502 with UpstreamError (labeler-side rejection via PDS). 1312 + pds_client.enqueue(common::FakePdsXrpcResponse::Response { 1313 + status: 502, 1314 + body: serde_json::json!({ "error": "UpstreamError", "message": "labeler rejected" }) 1315 + .to_string() 1316 + .into_bytes(), 1317 + }); 1318 + 1319 + // Service-auth mode POST succeeds. 1320 + tee.enqueue(common::FakeCreateReportResponse::ok_empty()); 1321 + 1322 + let pds_creds = atproto_devtool::commands::test::labeler::pipeline::PdsCredentials { 1323 + handle: "alice.bsky.social".to_string(), 1324 + app_password: "xxxx-xxxx-xxxx-xxxx".to_string(), 1325 + }; 1326 + let mut opts = default_opts(); 1327 + opts.pds_credentials = Some(&pds_creds); 1328 + opts.pds_xrpc_client = Some(&pds_client); 1329 + opts.commit_report = true; 1330 + 1331 + let results = run_report_stage(&facts, &tee, opts).await; 1332 + 1333 + assert_eq!(results[9].id, "report::pds_proxied_accepted"); 1334 + assert_eq!(results[9].status, CheckStatus::SpecViolation); 1335 + // Check that a diagnostic is present. 1336 + assert!( 1337 + results[9].diagnostic.is_some(), 1338 + "expected diagnostic for pds_proxied_rejected" 1339 + ); 1340 + } 1341 + 1342 + #[tokio::test] 1343 + async fn ac6_3_pds_rejects_proxy() { 1344 + let facts = local_identity_facts(); 1345 + let tee = FakeCreateReportTee::new(); 1346 + let pds_client = common::FakePdsXrpcClient::new(); 1347 + 1348 + // Queue responses for Phase 5-7. 1349 + for _ in 0..7 { 1350 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 1351 + } 1352 + 1353 + // PDS: createSession (for fetch_session_and_did) OK. 1354 + pds_client.enqueue(common::FakePdsXrpcResponse::Response { 1355 + status: 200, 1356 + body: serde_json::json!({ 1357 + "accessJwt": "test-jwt-token", 1358 + "did": "did:plc:test-user-did" 1359 + }) 1360 + .to_string() 1361 + .into_bytes(), 1362 + }); 1363 + // PDS: createSession (for fetch_service_auth_jwt) OK. 1364 + pds_client.enqueue(common::FakePdsXrpcResponse::Response { 1365 + status: 200, 1366 + body: serde_json::json!({ 1367 + "accessJwt": "test-jwt-token", 1368 + "did": "did:plc:test-user-did" 1369 + }) 1370 + .to_string() 1371 + .into_bytes(), 1372 + }); 1373 + // PDS: getServiceAuth OK. 1374 + pds_client.enqueue(common::FakePdsXrpcResponse::Response { 1375 + status: 200, 1376 + body: serde_json::json!({ 1377 + "token": "service-auth-jwt" 1378 + }) 1379 + .to_string() 1380 + .into_bytes(), 1381 + }); 1382 + // 4. Proxied POST: PDS rejects with 400 InvalidRequest (not an upstream error). 1383 + pds_client.enqueue(common::FakePdsXrpcResponse::Response { 1384 + status: 400, 1385 + body: serde_json::json!({ "error": "InvalidRequest", "message": "bad request" }) 1386 + .to_string() 1387 + .into_bytes(), 1388 + }); 1389 + 1390 + // Service-auth mode POST succeeds. 1391 + tee.enqueue(common::FakeCreateReportResponse::ok_empty()); 1392 + 1393 + let pds_creds = atproto_devtool::commands::test::labeler::pipeline::PdsCredentials { 1394 + handle: "alice.bsky.social".to_string(), 1395 + app_password: "xxxx-xxxx-xxxx-xxxx".to_string(), 1396 + }; 1397 + let mut opts = default_opts(); 1398 + opts.pds_credentials = Some(&pds_creds); 1399 + opts.pds_xrpc_client = Some(&pds_client); 1400 + opts.commit_report = true; 1401 + 1402 + let results = run_report_stage(&facts, &tee, opts).await; 1403 + 1404 + assert_eq!(results[9].id, "report::pds_proxied_accepted"); 1405 + assert_eq!(results[9].status, CheckStatus::NetworkError); 1406 + } 1407 + 1408 + #[tokio::test] 1409 + async fn ac6_4_missing_creds_or_commit_skips() { 1410 + // Test that proxied mode also skips when credentials missing. 1411 + let facts = local_identity_facts(); 1412 + let tee = FakeCreateReportTee::new(); 1413 + 1414 + // Queue responses for Phase 5-7. 1415 + for _ in 0..7 { 1416 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 1417 + } 1418 + 1419 + let mut opts = default_opts(); 1420 + opts.pds_credentials = None; 1421 + opts.commit_report = false; // Missing commit-report. 1422 + 1423 + let results = run_report_stage(&facts, &tee, opts).await; 1424 + 1425 + assert_eq!(results[9].id, "report::pds_proxied_accepted"); 1426 + assert_eq!(results[9].status, CheckStatus::Skipped); 1427 + assert_eq!( 1428 + results[9].skipped_reason, 1429 + Some(std::borrow::Cow::Borrowed( 1430 + "requires --handle, --app-password, and --commit-report" 1431 + )) 1432 + ); 1433 + } 1434 + 994 1435 // Task 5 tests: AC8.3 subject override 995 1436 996 1437 #[tokio::test]