- 1
//! The browser surface, end to end (docs/design/48-web-client.md). - 2
//! - 3
//! These are the boundaries that decide whether serving a workspace over a - 4
//! network is safe, so they are tested against the real secured router - 5
//! rather than a handler in isolation — the middleware IS the security - 6
//! property here, and a handler test would prove nothing about it. - 7
- 8
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 9
- 10
use std::net::SocketAddr; - 11
- 12
use vak_core::Core; - 13
- 14
/// A real bound server with the full secured stack. - 15
async fn spawn() -> (SocketAddr, String) { - 16
let dir = tempfile::tempdir().unwrap(); - 17
vak_config::paths::isolate_home_for_tests(); - 18
let core = Core::new(dir.path().to_path_buf()).unwrap(); - 19
core.set_sessions_home(dir.path().join("home")); - 20
// Outlives the test body; a removed directory would fail reads midway. - 21
std::mem::forget(dir); - 22
- 23
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - 24
let addr = listener.local_addr().unwrap(); - 25
let (app, token) = vak_server::secured_router_with_port(core, false, addr.port()); - 26
tokio::spawn(async move { - 27
axum::serve(listener, app).await.unwrap(); - 28
}); - 29
(addr, token) - 30
} - 31
- 32
/// Log in and return the raw `Set-Cookie` value. - 33
/// - 34
/// The cookie is carried by hand rather than by a cookie jar, which makes - 35
/// the round trip the test is actually about — what the server sets, and - 36
/// what it accepts back — visible in the test instead of hidden in a - 37
/// client feature. - 38
async fn login(client: &reqwest::Client, addr: SocketAddr, token: &str) -> String { - 39
let response = client - 40
.post(format!("http://{addr}/auth/login")) - 41
.json(&serde_json::json!({ "token": token })) - 42
.send() - 43
.await - 44
.unwrap(); - 45
assert_eq!(response.status(), reqwest::StatusCode::OK); - 46
response - 47
.headers() - 48
.get(reqwest::header::SET_COOKIE) - 49
.expect("login must set a session cookie") - 50
.to_str() - 51
.unwrap() - 52
.to_string() - 53
} - 54
- 55
/// The client bundle has to load before anyone can be asked for a token — - 56
/// the login form is part of it. So the shell is auth-exempt, and every - 57
/// route it then calls is not. - 58
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] - 59
async fn the_client_shell_loads_without_a_session_but_data_does_not() { - 60
let (addr, _token) = spawn().await; - 61
let client = reqwest::Client::new(); - 62
- 63
let shell = client - 64
.get(format!("http://{addr}/app")) - 65
.send() - 66
.await - 67
.unwrap(); - 68
assert_eq!(shell.status(), reqwest::StatusCode::OK); - 69
assert!( - 70
shell.text().await.unwrap().contains("/app/assets/"), - 71
"the shell must reference its own hashed assets under /app" - 72
); - 73
- 74
// Data, on the other hand, is gated. - 75
let sessions = client - 76
.get(format!("http://{addr}/sessions")) - 77
.send() - 78
.await - 79
.unwrap(); - 80
assert_eq!(sessions.status(), reqwest::StatusCode::UNAUTHORIZED); - 81
} - 82
- 83
/// Character portraits are part of the shell, like hashed CSS and JS. They - 84
/// must be available before authentication or Agent pickers render empty - 85
/// silhouettes while the loopback/session exchange is still settling. - 86
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] - 87
async fn packaged_character_assets_load_with_the_unauthenticated_shell() { - 88
let (addr, _token) = spawn().await; - 89
let response = reqwest::get(format!("http://{addr}/app/characters/mira-atlas-128.webp")) - 90
.await - 91
.unwrap(); - 92
assert_eq!(response.status(), reqwest::StatusCode::OK); - 93
assert_eq!( - 94
response - 95
.headers() - 96
.get(reqwest::header::CONTENT_TYPE) - 97
.unwrap(), - 98
"image/webp" - 99
); - 100
assert!(!response.bytes().await.unwrap().is_empty()); - 101
// The 1024px source atlases are brand source art, not shipped runtime: - 102
// the path falls through to the client shell, never to an image. - 103
let source = reqwest::get(format!("http://{addr}/app/characters/mira-atlas.png")) - 104
.await - 105
.unwrap(); - 106
let kind = source - 107
.headers() - 108
.get(reqwest::header::CONTENT_TYPE) - 109
.and_then(|value| value.to_str().ok()) - 110
.unwrap_or_default() - 111
.to_string(); - 112
assert!(!kind.starts_with("image/"), "source atlas served as {kind}"); - 113
} - 114
- 115
/// `/auth/session` must distinguish "no session yet" from "unreachable", - 116
/// and on loopback it must hand over the session rather than ask for it. - 117
/// - 118
/// A 401 conflates the first two, and the client cannot then tell whether - 119
/// to show the login form or an error. The second half is the whole point - 120
/// of `[server] loopback_auto_login`: anything that can reach loopback can - 121
/// already read the token off disk, so prompting for it to reach the - 122
/// machine you are sitting at buys nothing and cost every local user a - 123
/// token hunt. `host_policy.rs` covers the other side — a request arriving - 124
/// with a real hostname is refused before this handler ever runs. - 125
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] - 126
async fn session_status_answers_rather_than_rejecting() { - 127
let (addr, token) = spawn().await; - 128
let client = reqwest::Client::new(); - 129
- 130
let response = client - 131
.get(format!("http://{addr}/auth/session")) - 132
.send() - 133
.await - 134
.unwrap(); - 135
// Answered, not rejected: the distinction the client needs. - 136
assert_eq!(response.status(), reqwest::StatusCode::OK); - 137
assert!( - 138
response - 139
.headers() - 140
.get(reqwest::header::SET_COOKIE) - 141
.is_some(), - 142
"the loopback probe must hand over the session, not merely report it" - 143
); - 144
let before: serde_json::Value = response.json().await.unwrap(); - 145
assert_eq!(before["authenticated"], true); - 146
assert_eq!(before["granted"], "loopback"); - 147
- 148
let cookie = login(&client, addr, &token).await; - 149
- 150
let after: serde_json::Value = client - 151
.get(format!("http://{addr}/auth/session")) - 152
.header(reqwest::header::COOKIE, &cookie) - 153
.send() - 154
.await - 155
.unwrap() - 156
.json() - 157
.await - 158
.unwrap(); - 159
assert_eq!(after["authenticated"], true); - 160
} - 161
- 162
/// The cookie must actually authenticate, because `EventSource` cannot - 163
/// send a header and the cookie is the only channel a browser stream has. - 164
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] - 165
async fn the_login_cookie_authenticates_subsequent_requests() { - 166
let (addr, token) = spawn().await; - 167
let client = reqwest::Client::new(); - 168
- 169
let bad = client - 170
.post(format!("http://{addr}/auth/login")) - 171
.json(&serde_json::json!({ "token": "wrong" })) - 172
.send() - 173
.await - 174
.unwrap(); - 175
assert_eq!(bad.status(), reqwest::StatusCode::UNAUTHORIZED); - 176
- 177
let login = client - 178
.post(format!("http://{addr}/auth/login")) - 179
.json(&serde_json::json!({ "token": token })) - 180
.send() - 181
.await - 182
.unwrap(); - 183
assert_eq!(login.status(), reqwest::StatusCode::OK); - 184
let cookie = login - 185
.headers() - 186
.get(reqwest::header::SET_COOKIE) - 187
.unwrap() - 188
.to_str() - 189
.unwrap() - 190
.to_string(); - 191
assert!(cookie.starts_with("vak_session=")); - 192
assert!( - 193
cookie.contains("HttpOnly"), - 194
"script must not be able to read it" - 195
); - 196
assert!(cookie.contains("SameSite=Strict")); - 197
// Plain http: a `Secure` cookie here would be silently discarded by the - 198
// browser and the session would never persist. - 199
assert!( - 200
!cookie.contains("Secure"), - 201
"Secure over plain http makes the browser drop the cookie: {cookie}" - 202
); - 203
- 204
// No bearer header anywhere — the cookie alone carries this. - 205
let pair = cookie.split(';').next().unwrap(); - 206
let host = client - 207
.get(format!("http://{addr}/host")) - 208
.header(reqwest::header::COOKIE, pair) - 209
.send() - 210
.await - 211
.unwrap(); - 212
assert_eq!(host.status(), reqwest::StatusCode::OK); - 213
} - 214
- 215
/// The old `/admin/login` was REMOVED, not kept alongside `/auth/login`. - 216
/// Two endpoints against one cookie is two contracts that must agree - 217
/// forever (AGENTS.md invariant 30). - 218
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] - 219
async fn the_superseded_admin_login_is_gone() { - 220
let (addr, token) = spawn().await; - 221
let client = reqwest::Client::new(); - 222
// Authenticated, so this reaches the router rather than stopping at the - 223
// auth layer — an unauthenticated probe would 401 whether the route - 224
// existed or not, and would prove nothing about its absence. - 225
let response = client - 226
.post(format!("http://{addr}/admin/login")) - 227
.bearer_auth(&token) - 228
.header(reqwest::header::ORIGIN, format!("http://{addr}")) - 229
.json(&serde_json::json!({ "token": token })) - 230
.send() - 231
.await - 232
.unwrap(); - 233
assert_eq!( - 234
response.status(), - 235
reqwest::StatusCode::NOT_FOUND, - 236
"a second login endpoint must not survive the one that replaced it" - 237
); - 238
} - 239
- 240
/// A cookie is attached by the browser to whoever asks, so a mutation - 241
/// carrying one must ALSO prove it came from a page we serve. - 242
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] - 243
async fn a_cross_origin_mutation_is_refused() { - 244
let (addr, token) = spawn().await; - 245
let client = reqwest::Client::new(); - 246
let cookie = login(&client, addr, &token).await; - 247
let pair = cookie.split(';').next().unwrap().to_string(); - 248
- 249
let evil = client - 250
.post(format!("http://{addr}/sessions")) - 251
.header(reqwest::header::COOKIE, &pair) - 252
.header(reqwest::header::ORIGIN, "https://attacker.example") - 253
.send() - 254
.await - 255
.unwrap(); - 256
assert_eq!(evil.status(), reqwest::StatusCode::FORBIDDEN); - 257
- 258
// Our own origin is fine. - 259
let ours = client - 260
.post(format!("http://{addr}/sessions")) - 261
.header(reqwest::header::COOKIE, &pair) - 262
.header(reqwest::header::ORIGIN, format!("http://{addr}")) - 263
.send() - 264
.await - 265
.unwrap(); - 266
assert_eq!(ours.status(), reqwest::StatusCode::OK); - 267
} - 268
- 269
/// A request with no `Origin` at all is not a browser mutation (curl, the - 270
/// CLI, a bridge), so it is allowed past the origin check — and must then - 271
/// still satisfy the token check, which is what keeps it from being a way - 272
/// around anything. - 273
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] - 274
async fn an_originless_mutation_still_needs_a_token() { - 275
let (addr, token) = spawn().await; - 276
let client = reqwest::Client::new(); - 277
- 278
let anonymous = client - 279
.post(format!("http://{addr}/sessions")) - 280
.send() - 281
.await - 282
.unwrap(); - 283
assert_eq!(anonymous.status(), reqwest::StatusCode::UNAUTHORIZED); - 284
- 285
let authorized = client - 286
.post(format!("http://{addr}/sessions")) - 287
.bearer_auth(&token) - 288
.send() - 289
.await - 290
.unwrap(); - 291
assert_eq!(authorized.status(), reqwest::StatusCode::OK); - 292
} - 293
- 294
/// Binding loopback does not stop a page from resolving its own domain to - 295
/// 127.0.0.1 and becoming same-origin with this server. Pinning the `Host` - 296
/// name is the check that does, and `trusted_hosts` is empty by default. - 297
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] - 298
async fn an_untrusted_host_header_is_refused() { - 299
let (addr, token) = spawn().await; - 300
let client = reqwest::Client::new(); - 301
let rebound = client - 302
.get(format!("http://{addr}/health")) - 303
.header(reqwest::header::HOST, "rebind.attacker.example") - 304
.bearer_auth(&token) - 305
.send() - 306
.await - 307
.unwrap(); - 308
assert_eq!(rebound.status(), reqwest::StatusCode::MISDIRECTED_REQUEST); - 309
} - 310
- 311
/// A token in a query string can reach access logs, `Referer` headers, and - 312
/// history. That is acceptable for an in-process loopback client with no - 313
/// proxy in between, and not acceptable anywhere else — so the channel - 314
/// exists only for loopback. (Here the request IS loopback, which is the - 315
/// case that must keep working: the desktop's SSE depends on it.) - 316
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] - 317
async fn the_query_token_channel_works_on_loopback() { - 318
let (addr, token) = spawn().await; - 319
let client = reqwest::Client::new(); - 320
let response = client - 321
.get(format!("http://{addr}/host?token={token}")) - 322
.send() - 323
.await - 324
.unwrap(); - 325
assert_eq!(response.status(), reqwest::StatusCode::OK); - 326
} - 327
- 328
/// The picker answers "which folders are there", and nothing more. It must - 329
/// not become a way to read the filesystem, nor to walk out of the roots. - 330
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] - 331
async fn the_directory_browser_stays_inside_its_roots() { - 332
let (addr, token) = spawn().await; - 333
let client = reqwest::Client::new(); - 334
- 335
let escaped = client - 336
.get(format!("http://{addr}/fs/dirs?path=/etc")) - 337
.bearer_auth(&token) - 338
.send() - 339
.await - 340
.unwrap(); - 341
assert_eq!( - 342
escaped.status(), - 343
reqwest::StatusCode::FORBIDDEN, - 344
"the picker must not be walkable outside [server] workspace_roots" - 345
); - 346
} - 347
- 348
/// The terminal is a real shell. It is off unless an operator turned it on, - 349
/// and the refusal says which setting turns it on rather than 404ing. - 350
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] - 351
async fn the_terminal_is_refused_until_it_is_enabled() { - 352
let (addr, token) = spawn().await; - 353
let client = reqwest::Client::new(); - 354
// Sent as a real upgrade, because that is how the only client that - 355
// reaches this route asks. A plain GET is rejected earlier, by the - 356
// upgrade extractor, and would never exercise the config gate. - 357
let response = client - 358
.get(format!("http://{addr}/pty")) - 359
.bearer_auth(&token) - 360
.header(reqwest::header::CONNECTION, "Upgrade") - 361
.header(reqwest::header::UPGRADE, "websocket") - 362
.header(reqwest::header::SEC_WEBSOCKET_VERSION, "13") - 363
.header( - 364
reqwest::header::SEC_WEBSOCKET_KEY, - 365
"dGhlIHNhbXBsZSBub25jZQ==", - 366
) - 367
.send() - 368
.await - 369
.unwrap(); - 370
assert_eq!(response.status(), reqwest::StatusCode::FORBIDDEN); - 371
let body: serde_json::Value = response.json().await.unwrap(); - 372
assert!( - 373
body["error"] - 374
.as_str() - 375
.unwrap_or_default() - 376
.contains("terminal"), - 377
"the refusal must name what is disabled: {body}" - 378
); - 379
} - 380
- 381
/// `/host` stands in for the desktop shell's `backend_info`. It must NOT - 382
/// carry a base URL or token: their absence is precisely how the client - 383
/// knows it is same-origin and cookie-authenticated. - 384
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] - 385
async fn the_host_descriptor_hands_out_no_credentials() { - 386
let (addr, token) = spawn().await; - 387
let client = reqwest::Client::new(); - 388
let body: serde_json::Value = client - 389
.get(format!("http://{addr}/host")) - 390
.bearer_auth(&token) - 391
.send() - 392
.await - 393
.unwrap() - 394
.json() - 395
.await - 396
.unwrap(); - 397
assert_eq!(body["ready"], true); - 398
assert!(body["cwd"].is_string()); - 399
assert!(body.get("token").is_none(), "a token must never be served"); - 400
assert!(body.get("base_url").is_none()); - 401
assert_eq!(body["terminal"], false); - 402
} - 403
- 404
/// The static bundles are compressed; the API is not. - 405
/// - 406
/// `/sessions/:id/events` is server-sent events. A compressor sits between - 407
/// the writer and the socket, so a live transcript would arrive in - 408
/// buffer-sized batches rather than per frame — a laggy agent in exchange - 409
/// for a smaller JSON body nobody was waiting on. The layer is therefore - 410
/// scoped to the three static routers rather than applied at the root, and - 411
/// this is the test that keeps it there. - 412
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] - 413
async fn compression_covers_the_static_bundles_and_not_the_api() { - 414
let (addr, _token) = spawn().await; - 415
let client = reqwest::Client::builder() - 416
// Ask for gzip without letting reqwest transparently strip the - 417
// header we are asserting on. - 418
.no_gzip() - 419
.build() - 420
.unwrap(); - 421
- 422
let page = client - 423
.get(format!("http://{addr}/")) - 424
.header(reqwest::header::ACCEPT_ENCODING, "gzip") - 425
.send() - 426
.await - 427
.unwrap(); - 428
assert_eq!( - 429
page.headers() - 430
.get(reqwest::header::CONTENT_ENCODING) - 431
.map(|v| v.to_str().unwrap()), - 432
Some("gzip"), - 433
"the front door is ~78 KB of inlined CSS and markup and must compress" - 434
); - 435
- 436
for path in ["/version", "/health"] { - 437
let api = client - 438
.get(format!("http://{addr}{path}")) - 439
.header(reqwest::header::ACCEPT_ENCODING, "gzip") - 440
.send() - 441
.await - 442
.unwrap(); - 443
assert!( - 444
api.headers() - 445
.get(reqwest::header::CONTENT_ENCODING) - 446
.is_none(), - 447
"{path} must not be compressed — the layer belongs to the static bundles only, \ - 448
because the same layer at the root would buffer server-sent events" - 449
); - 450
} - 451
} - 452
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.