|
1
|
+ |
//! `oa trace upload` against the ingest route.
|
|
2
|
+ |
//!
|
|
3
|
+ |
//! The command used to be absent from this CLI and to refuse with exit 16 in the
|
|
4
|
+ |
//! TypeScript one, on the grounds that `POST /api/v1/traces` did not exist. It does
|
|
5
|
+ |
//! exist. So the thing worth asserting is no longer "does it refuse" but what it
|
|
6
|
+ |
//! actually puts on the wire, and — more importantly — what it refuses to say when
|
|
7
|
+ |
//! the server's answer does not support saying it.
|
|
8
|
+ |
//!
|
|
9
|
+ |
//! These run against a stub server on localhost. Uploading a trace is a write, and a
|
|
10
|
+ |
//! test that proves upload works by writing to the real store is not one anyone can
|
|
11
|
+ |
//! run twice.
|
|
12
|
+ |
|
|
13
|
+ |
use openagents_cli::trace_client::{read_visibility, TraceClient, TRACE_VISIBILITIES};
|
|
14
|
+ |
use std::io::{BufRead, BufReader, Read, Write};
|
|
15
|
+ |
use std::net::TcpListener;
|
|
16
|
+ |
use std::sync::mpsc::{channel, Receiver};
|
|
17
|
+ |
|
|
18
|
+ |
#[derive(Debug, Clone)]
|
|
19
|
+ |
struct SeenRequest {
|
|
20
|
+ |
method: String,
|
|
21
|
+ |
path: String,
|
|
22
|
+ |
authorization: Option<String>,
|
|
23
|
+ |
body: serde_json::Value,
|
|
24
|
+ |
}
|
|
25
|
+ |
|
|
26
|
+ |
struct StubApi {
|
|
27
|
+ |
base: String,
|
|
28
|
+ |
seen: Receiver<SeenRequest>,
|
|
29
|
+ |
}
|
|
30
|
+ |
|
|
31
|
+ |
/// Serve one request with `status` and `body`, and report what was asked.
|
|
32
|
+ |
fn start_stub_api(status: u16, body: serde_json::Value) -> StubApi {
|
|
33
|
+ |
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
34
|
+ |
let port = listener.local_addr().unwrap().port();
|
|
35
|
+ |
let (sender, seen) = channel();
|
|
36
|
+ |
|
|
37
|
+ |
std::thread::spawn(move || {
|
|
38
|
+ |
let Ok((stream, _)) = listener.accept() else {
|
|
39
|
+ |
return;
|
|
40
|
+ |
};
|
|
41
|
+ |
let mut reader = BufReader::new(stream);
|
|
42
|
+ |
|
|
43
|
+ |
let mut request_line = String::new();
|
|
44
|
+ |
if reader.read_line(&mut request_line).is_err() {
|
|
45
|
+ |
return;
|
|
46
|
+ |
}
|
|
47
|
+ |
let mut parts = request_line.split_whitespace();
|
|
48
|
+ |
let method = parts.next().unwrap_or_default().to_string();
|
|
49
|
+ |
let path = parts.next().unwrap_or_default().to_string();
|
|
50
|
+ |
|
|
51
|
+ |
let mut content_length = 0usize;
|
|
52
|
+ |
let mut authorization = None;
|
|
53
|
+ |
loop {
|
|
54
|
+ |
let mut header = String::new();
|
|
55
|
+ |
match reader.read_line(&mut header) {
|
|
56
|
+ |
Ok(0) => break,
|
|
57
|
+ |
Ok(_) => {}
|
|
58
|
+ |
Err(_) => return,
|
|
59
|
+ |
}
|
|
60
|
+ |
let trimmed = header.trim_end();
|
|
61
|
+ |
if trimmed.is_empty() {
|
|
62
|
+ |
break;
|
|
63
|
+ |
}
|
|
64
|
+ |
if let Some((name, value)) = trimmed.split_once(':') {
|
|
65
|
+ |
if name.eq_ignore_ascii_case("content-length") {
|
|
66
|
+ |
content_length = value.trim().parse().unwrap_or(0);
|
|
67
|
+ |
}
|
|
68
|
+ |
if name.eq_ignore_ascii_case("authorization") {
|
|
69
|
+ |
authorization = Some(value.trim().to_string());
|
|
70
|
+ |
}
|
|
71
|
+ |
}
|
|
72
|
+ |
}
|
|
73
|
+ |
|
|
74
|
+ |
let mut raw = vec![0u8; content_length];
|
|
75
|
+ |
if content_length > 0 && reader.read_exact(&mut raw).is_err() {
|
|
76
|
+ |
return;
|
|
77
|
+ |
}
|
|
78
|
+ |
let parsed = if raw.is_empty() {
|
|
79
|
+ |
serde_json::Value::Null
|
|
80
|
+ |
} else {
|
|
81
|
+ |
serde_json::from_slice(&raw).unwrap_or(serde_json::Value::Null)
|
|
82
|
+ |
};
|
|
83
|
+ |
let _ = sender.send(SeenRequest {
|
|
84
|
+ |
method,
|
|
85
|
+ |
path,
|
|
86
|
+ |
authorization,
|
|
87
|
+ |
body: parsed,
|
|
88
|
+ |
});
|
|
89
|
+ |
|
|
90
|
+ |
let payload = if body.is_null() {
|
|
91
|
+ |
String::new()
|
|
92
|
+ |
} else {
|
|
93
|
+ |
body.to_string()
|
|
94
|
+ |
};
|
|
95
|
+ |
let response = format!(
|
|
96
|
+ |
"HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{payload}",
|
|
97
|
+ |
payload.len()
|
|
98
|
+ |
);
|
|
99
|
+ |
let stream = reader.get_mut();
|
|
100
|
+ |
let _ = stream.write_all(response.as_bytes());
|
|
101
|
+ |
let _ = stream.flush();
|
|
102
|
+ |
});
|
|
103
|
+ |
|
|
104
|
+ |
StubApi {
|
|
105
|
+ |
base: format!("http://127.0.0.1:{port}/api/v1"),
|
|
106
|
+ |
seen,
|
|
107
|
+ |
}
|
|
108
|
+ |
}
|
|
109
|
+ |
|
|
110
|
+ |
fn seen(stub: &StubApi) -> SeenRequest {
|
|
111
|
+ |
stub.seen
|
|
112
|
+ |
.recv_timeout(std::time::Duration::from_secs(10))
|
|
113
|
+ |
.expect("the client never sent a request")
|
|
114
|
+ |
}
|
|
115
|
+ |
|
|
116
|
+ |
fn atif_document() -> serde_json::Value {
|
|
117
|
+ |
serde_json::json!({
|
|
118
|
+ |
"schema_version": "ATIF-v1.7",
|
|
119
|
+ |
"session_id": "probe",
|
|
120
|
+ |
"steps": [{ "step_id": 1, "source": "user", "message": "hello" }]
|
|
121
|
+ |
})
|
|
122
|
+ |
}
|
|
123
|
+ |
|
|
124
|
+ |
fn stored_body(visibility: &str) -> serde_json::Value {
|
|
125
|
+ |
serde_json::json!({
|
|
126
|
+ |
"id": "trace-1",
|
|
127
|
+ |
// The route the server points at here does not exist. The client must
|
|
128
|
+ |
// not carry it through to the caller as a place to look.
|
|
129
|
+ |
"url": "https://openagents.com/api/v1/traces/trace-1",
|
|
130
|
+ |
"digest": format!("sha256:{}", "a".repeat(64)),
|
|
131
|
+ |
"byte_size": 412,
|
|
132
|
+ |
"visibility": visibility,
|
|
133
|
+ |
"inserted_at": "2026-08-26T03:00:00Z"
|
|
134
|
+ |
})
|
|
135
|
+ |
}
|
|
136
|
+ |
|
|
137
|
+ |
#[test]
|
|
138
|
+ |
fn a_visibility_outside_the_servers_set_is_refused_with_the_set() {
|
|
139
|
+ |
for name in TRACE_VISIBILITIES {
|
|
140
|
+ |
assert_eq!(read_visibility(name).unwrap(), name);
|
|
141
|
+ |
}
|
|
142
|
+ |
// The names the old flags spoke. They were never the server's vocabulary.
|
|
143
|
+ |
for wrong in ["public", "unlisted", "owner_only", ""] {
|
|
144
|
+ |
let refusal = read_visibility(wrong)
|
|
145
|
+ |
.expect_err(&format!("{wrong} is not a rung the server stores at"));
|
|
146
|
+ |
let text = refusal.to_string();
|
|
147
|
+ |
assert!(
|
|
148
|
+ |
text.contains("dark, pulse, ledger, glass"),
|
|
149
|
+ |
"the refusal must name the choices; got {text}"
|
|
150
|
+ |
);
|
|
151
|
+ |
}
|
|
152
|
+ |
}
|
|
153
|
+ |
|
|
154
|
+ |
#[tokio::test]
|
|
155
|
+ |
async fn upload_posts_the_document_itself_with_the_visibility_named() {
|
|
156
|
+ |
let stub = start_stub_api(201, stored_body("dark"));
|
|
157
|
+ |
let client = TraceClient::new(&stub.base, Some("oa_pat_test".to_string()));
|
|
158
|
+ |
|
|
159
|
+ |
let stored = client
|
|
160
|
+ |
.upload(&atif_document(), "dark", None)
|
|
161
|
+ |
.await
|
|
162
|
+ |
.expect("the stub answered 201");
|
|
163
|
+ |
|
|
164
|
+ |
let request = seen(&stub);
|
|
165
|
+ |
assert_eq!(request.method, "POST");
|
|
166
|
+ |
assert_eq!(request.path, "/api/v1/traces?visibility=dark");
|
|
167
|
+ |
assert_eq!(
|
|
168
|
+ |
request.authorization.as_deref(),
|
|
169
|
+ |
Some("Bearer oa_pat_test"),
|
|
170
|
+ |
"the ingest route is account-scoped"
|
|
171
|
+ |
);
|
|
172
|
+ |
// The body is the document, with nothing wrapped around it.
|
|
173
|
+ |
assert_eq!(request.body, atif_document());
|
|
174
|
+ |
|
|
175
|
+ |
assert_eq!(stored.id, "trace-1");
|
|
176
|
+ |
assert!(
|
|
177
|
+ |
stored.created,
|
|
178
|
+ |
"201 means the server did not hold it before"
|
|
179
|
+ |
);
|
|
180
|
+ |
assert_eq!(stored.byte_size, 412);
|
|
181
|
+ |
assert_eq!(stored.visibility, "dark");
|
|
182
|
+ |
}
|
|
183
|
+ |
|
|
184
|
+ |
#[tokio::test]
|
|
185
|
+ |
async fn an_existing_digest_is_reported_as_existing_not_as_an_upload() {
|
|
186
|
+ |
let stub = start_stub_api(200, stored_body("dark"));
|
|
187
|
+ |
let client = TraceClient::new(&stub.base, None);
|
|
188
|
+ |
|
|
189
|
+ |
let stored = client.upload(&atif_document(), "dark", None).await.unwrap();
|
|
190
|
+ |
|
|
191
|
+ |
let _ = seen(&stub);
|
|
192
|
+ |
// The status is the only thing that tells the two apart. Discarding it is
|
|
193
|
+ |
// how a caller comes to believe a write happened that did not.
|
|
194
|
+ |
assert!(
|
|
195
|
+ |
!stored.created,
|
|
196
|
+ |
"a 200 means the server already held this digest"
|
|
197
|
+ |
);
|
|
198
|
+ |
}
|
|
199
|
+ |
|
|
200
|
+ |
#[tokio::test]
|
|
201
|
+ |
async fn the_attempt_binding_and_a_higher_rung_reach_the_route() {
|
|
202
|
+ |
let stub = start_stub_api(201, stored_body("ledger"));
|
|
203
|
+ |
let client = TraceClient::new(&stub.base, None);
|
|
204
|
+ |
|
|
205
|
+ |
client
|
|
206
|
+ |
.upload(&atif_document(), "ledger", Some("asg-9"))
|
|
207
|
+ |
.await
|
|
208
|
+ |
.unwrap();
|
|
209
|
+ |
|
|
210
|
+ |
let request = seen(&stub);
|
|
211
|
+ |
assert!(
|
|
212
|
+ |
request.path.contains("visibility=ledger"),
|
|
213
|
+ |
"path was {}",
|
|
214
|
+ |
request.path
|
|
215
|
+ |
);
|
|
216
|
+ |
assert!(
|
|
217
|
+ |
request.path.contains("assignment_id=asg-9"),
|
|
218
|
+ |
"path was {}",
|
|
219
|
+ |
request.path
|
|
220
|
+ |
);
|
|
221
|
+ |
}
|
|
222
|
+ |
|
|
223
|
+ |
#[tokio::test]
|
|
224
|
+ |
async fn nothing_the_client_returns_carries_the_dead_url_the_server_sends() {
|
|
225
|
+ |
let stub = start_stub_api(201, stored_body("dark"));
|
|
226
|
+ |
let client = TraceClient::new(&stub.base, None);
|
|
227
|
+ |
|
|
228
|
+ |
let stored = client.upload(&atif_document(), "dark", None).await.unwrap();
|
|
229
|
+ |
let _ = seen(&stub);
|
|
230
|
+ |
|
|
231
|
+ |
// `GET /api/v1/traces/:id` is not a route. Reporting the url the server
|
|
232
|
+ |
// builds would hand the reader a 404 dressed as a receipt.
|
|
233
|
+ |
let rendered = serde_json::to_string(&stored).unwrap();
|
|
234
|
+ |
assert!(
|
|
235
|
+ |
!rendered.contains("openagents.com/api/v1/traces/"),
|
|
236
|
+ |
"the client carried the server's unreachable url through: {rendered}"
|
|
237
|
+ |
);
|
|
238
|
+ |
}
|
|
239
|
+ |
|
|
240
|
+ |
#[tokio::test]
|
|
241
|
+ |
async fn an_accepted_status_that_names_nothing_stored_is_an_error() {
|
|
242
|
+ |
// 201 with an empty body: the server said yes and said nothing. Reporting a
|
|
243
|
+ |
// stored trace here is how a caller comes to believe in one that has no id.
|
|
244
|
+ |
let stub = start_stub_api(201, serde_json::json!({}));
|
|
245
|
+ |
let client = TraceClient::new(&stub.base, None);
|
|
246
|
+ |
|
|
247
|
+ |
let refused = client.upload(&atif_document(), "dark", None).await;
|
|
248
|
+ |
let _ = seen(&stub);
|
|
249
|
+ |
|
|
250
|
+ |
let error = refused.expect_err("an id-less 201 is not a stored trace");
|
|
251
|
+ |
let text = error.to_string();
|
|
252
|
+ |
assert!(
|
|
253
|
+ |
text.contains("no id or digest"),
|
|
254
|
+ |
"the error must say what was missing; got {text}"
|
|
255
|
+ |
);
|
|
256
|
+ |
}
|
|
257
|
+ |
|
|
258
|
+ |
#[tokio::test]
|
|
259
|
+ |
async fn a_refusal_is_an_error_carrying_what_the_server_said() {
|
|
260
|
+ |
let stub = start_stub_api(
|
|
261
|
+ |
422,
|
|
262
|
+ |
serde_json::json!({
|
|
263
|
+ |
"message": "Validation Failed",
|
|
264
|
+ |
"errors": { "document": ["The document is not a valid ATIF v1 object."] }
|
|
265
|
+ |
}),
|
|
266
|
+ |
);
|
|
267
|
+ |
let client = TraceClient::new(&stub.base, None);
|
|
268
|
+ |
|
|
269
|
+ |
let refused = client.upload(&atif_document(), "dark", None).await;
|
|
270
|
+ |
let _ = seen(&stub);
|
|
271
|
+ |
|
|
272
|
+ |
let text = refused
|
|
273
|
+ |
.expect_err("a 422 is not a stored trace")
|
|
274
|
+ |
.to_string();
|
|
275
|
+ |
assert!(
|
|
276
|
+ |
text.contains("not a valid ATIF v1 object"),
|
|
277
|
+ |
"the refusal must carry the server's own words; got {text}"
|
|
278
|
+ |
);
|
|
279
|
+ |
}
|