|
1
|
+ |
//! What `oa box` says when it cannot resolve the account's conversation.
|
|
2
|
+ |
//!
|
|
3
|
+ |
//! Every box subcommand starts by turning "no `--conversation`" into a
|
|
4
|
+ |
//! conversation id, so this is the first thing a reader sees when anything is
|
|
5
|
+ |
//! wrong, and the sentence it prints is the one they act on. There are two
|
|
6
|
+ |
//! different situations behind it and they need two different sentences:
|
|
7
|
+ |
//!
|
|
8
|
+ |
//! * the server read the credential and answered — a `401` because the token
|
|
9
|
+ |
//! carries `forge:write` and not `box:control`, say. Nothing is broken;
|
|
10
|
+ |
//! the reader needs `--conversation`, or a token with the scope.
|
|
11
|
+ |
//! * the request never got an answer — a `502` from the edge, a gateway
|
|
12
|
+ |
//! error page, a dead socket. Nothing about the account is known, least of
|
|
13
|
+ |
//! all that it has no conversation.
|
|
14
|
+ |
//!
|
|
15
|
+ |
//! Reporting the second as the first was observed against production: a
|
|
16
|
+ |
//! transient `502` on `GET /api/v1/conversation` printed "This deployment does
|
|
17
|
+ |
//! not report a conversation for the account", for an account whose
|
|
18
|
+ |
//! conversation resolved a minute earlier and a minute later.
|
|
19
|
+ |
|
|
20
|
+ |
use openagents_cli::box_client::BoxClient;
|
|
21
|
+ |
use openagents_cli::tracker::ApiError;
|
|
22
|
+ |
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
23
|
+ |
|
|
24
|
+ |
/// A server that answers every request with one canned status and body.
|
|
25
|
+ |
async fn start_stub(status_line: &'static str, body: &'static str) -> String {
|
|
26
|
+ |
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
27
|
+ |
let port = listener.local_addr().unwrap().port();
|
|
28
|
+ |
|
|
29
|
+ |
tokio::spawn(async move {
|
|
30
|
+ |
loop {
|
|
31
|
+ |
let Ok((mut socket, _)) = listener.accept().await else {
|
|
32
|
+ |
return;
|
|
33
|
+ |
};
|
|
34
|
+ |
let mut buffer = vec![0u8; 8192];
|
|
35
|
+ |
if socket.read(&mut buffer).await.unwrap_or(0) == 0 {
|
|
36
|
+ |
continue;
|
|
37
|
+ |
}
|
|
38
|
+ |
let response = format!(
|
|
39
|
+ |
"HTTP/1.1 {status_line}\r\ncontent-type: text/html\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
|
|
40
|
+ |
body.len()
|
|
41
|
+ |
);
|
|
42
|
+ |
let _ = socket.write_all(response.as_bytes()).await;
|
|
43
|
+ |
let _ = socket.flush().await;
|
|
44
|
+ |
}
|
|
45
|
+ |
});
|
|
46
|
+ |
|
|
47
|
+ |
format!("http://127.0.0.1:{port}/api/v1")
|
|
48
|
+ |
}
|
|
49
|
+ |
|
|
50
|
+ |
/// A gateway failure is reported as a gateway failure.
|
|
51
|
+ |
#[tokio::test]
|
|
52
|
+ |
async fn a_five_hundred_from_the_conversation_route_is_not_reported_as_a_missing_conversation() {
|
|
53
|
+ |
let base = start_stub("502 Bad Gateway", "<html><title>502</title></html>").await;
|
|
54
|
+ |
let client = BoxClient::new(&base, Some("token".to_string()));
|
|
55
|
+ |
|
|
56
|
+ |
let error = client
|
|
57
|
+ |
.resolve_conversation_id()
|
|
58
|
+ |
.await
|
|
59
|
+ |
.expect_err("a 502 must not resolve to a conversation id");
|
|
60
|
+ |
|
|
61
|
+ |
match error {
|
|
62
|
+ |
ApiError::Refused {
|
|
63
|
+ |
status, message, ..
|
|
64
|
+ |
} => {
|
|
65
|
+ |
assert_eq!(
|
|
66
|
+ |
status, 502,
|
|
67
|
+ |
"the status the server actually sent must survive"
|
|
68
|
+ |
);
|
|
69
|
+ |
assert!(
|
|
70
|
+ |
!message.contains("does not report a conversation"),
|
|
71
|
+ |
"a 502 is not an account without a conversation; the CLI said: {message}"
|
|
72
|
+ |
);
|
|
73
|
+ |
}
|
|
74
|
+ |
other => panic!("expected the server's own refusal, got {other:?}"),
|
|
75
|
+ |
}
|
|
76
|
+ |
}
|
|
77
|
+ |
|
|
78
|
+ |
/// A transport failure is reported as a transport failure. Port 1 refuses
|
|
79
|
+ |
/// connections, so nothing about the account is ever known here.
|
|
80
|
+ |
#[tokio::test]
|
|
81
|
+ |
async fn an_unreachable_api_is_not_reported_as_a_missing_conversation() {
|
|
82
|
+ |
let client = BoxClient::new("http://127.0.0.1:1/api/v1", Some("token".to_string()));
|
|
83
|
+ |
|
|
84
|
+ |
let error = client
|
|
85
|
+ |
.resolve_conversation_id()
|
|
86
|
+ |
.await
|
|
87
|
+ |
.expect_err("an unreachable API must not resolve to a conversation id");
|
|
88
|
+ |
|
|
89
|
+ |
assert!(
|
|
90
|
+ |
matches!(error, ApiError::Transport { .. }),
|
|
91
|
+ |
"an unreachable API must surface as a transport failure, got {error:?}"
|
|
92
|
+ |
);
|
|
93
|
+ |
}
|
|
94
|
+ |
|
|
95
|
+ |
/// The refusal the caller *can* act on keeps its sentence. A `401` on both
|
|
96
|
+ |
/// routes is a real answer from the server: the credential was read and turned
|
|
97
|
+ |
/// down, and naming `--conversation` is the way past it.
|
|
98
|
+ |
#[tokio::test]
|
|
99
|
+ |
async fn a_refused_credential_still_names_the_flag_that_unblocks_the_caller() {
|
|
100
|
+ |
let base = start_stub(
|
|
101
|
+ |
"401 Unauthorized",
|
|
102
|
+ |
r#"{"error":{"code":"invalid_api_token"}}"#,
|
|
103
|
+ |
)
|
|
104
|
+ |
.await;
|
|
105
|
+ |
let client = BoxClient::new(&base, Some("token".to_string()));
|
|
106
|
+ |
|
|
107
|
+ |
let error = client
|
|
108
|
+ |
.resolve_conversation_id()
|
|
109
|
+ |
.await
|
|
110
|
+ |
.expect_err("a 401 must not resolve to a conversation id");
|
|
111
|
+ |
|
|
112
|
+ |
match error {
|
|
113
|
+ |
ApiError::Refused {
|
|
114
|
+ |
status, message, ..
|
|
115
|
+ |
} => {
|
|
116
|
+ |
assert_eq!(status, 401);
|
|
117
|
+ |
assert!(
|
|
118
|
+ |
message.contains("--conversation"),
|
|
119
|
+ |
"the refusal must name the flag that unblocks the caller; it said: {message}"
|
|
120
|
+ |
);
|
|
121
|
+ |
}
|
|
122
|
+ |
other => panic!("expected a refusal, got {other:?}"),
|
|
123
|
+ |
}
|
|
124
|
+ |
}
|