Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_steel/tests/tiles.rs

16.7 KiB, 11 runs

created by r1870400018:59925, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! The tile route: what it answers, what it will not answer, and what it cannot read.
2//!
3//! The archive here is held in memory behind the same `TileArchive` interface the PMTiles reader
4//! will implement, so everything the route decides is exercised without an archive file. Requests
5//! go through the real wire parser, because the privacy claims are about bytes that arrived from
6//! outside, cookies included.
7//!
8//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
9//! Anthropic Claude
10
11use oxedyne_fe2o3_core::prelude::*;
12use oxedyne_fe2o3_jdat::prelude::*;
13use oxedyne_fe2o3_net::http::{
14 encoding::{
15 self,
16 ContentCoding,
17 },
18 msg::HttpMessage,
19};
20use oxedyne_fe2o3_steel::srv::{
21 cfg::{
22 TileConfig,
23 VhostConfig,
24 },
25 tiles::{
26 TileArchive,
27 TileInfo,
28 TileKind,
29 TileRequest,
30 TileService,
31 TileSource,
32 },
33};
34
35use std::{
36 collections::BTreeMap,
37 path::PathBuf,
38 pin::Pin,
39};
40
41use tokio::io::AsyncWriteExt;
42
43
44const APP: &str = "https://oxegen.io";
45const PLAIN: &[u8] = b"a vector tile, pretending";
46
47struct MemArchive {
48 tiles: BTreeMap<(u8, u32, u32), Vec<u8>>,
49 broken: bool,
50}
51
52impl TileArchive for MemArchive {
53 fn info(&self) -> TileInfo {
54 TileInfo {
55 kind: TileKind::Mvt,
56 coding: ContentCoding::Gzip,
57 min_zoom: 0,
58 max_zoom: 15,
59 bounds_e7: [1_129_000_000, -437_000_000, 1_537_000_000, -106_000_000],
60 }
61 }
62 fn tile(&self, z: u8, x: u32, y: u32) -> Outcome<Option<Vec<u8>>> {
63 if self.broken {
64 return Err(err!("The test archive is broken on purpose."; Test, IO));
65 }
66 Ok(self.tiles.get(&(z, x, y)).cloned())
67 }
68}
69
70fn config() -> TileConfig {
71 let mut builds = BTreeMap::new();
72 builds.insert("20260922".to_string(), PathBuf::from("/srv/tiles/20260922.pmtiles"));
73 TileConfig {
74 prefix: "/t".to_string(),
75 current: "20260922".to_string(),
76 builds,
77 allow_origins: vec![APP.to_string()],
78 attribution: "© OpenStreetMap".to_string(),
79 }
80}
81
82fn service(broken: bool) -> Outcome<TileService<MemArchive>> {
83 let stored = res!(encoding::gzip(PLAIN));
84 TileService::new(&config(), "tiles.oxegen.io", 63_072_000, |_, _| {
85 let mut tiles = BTreeMap::new();
86 tiles.insert((14, 13_433, 9_798), stored.clone());
87 tiles.insert((3, 7, 4), Vec::new());
88 Ok(MemArchive { tiles, broken })
89 })
90}
91
92async fn parse_request(raw: &str) -> Outcome<HttpMessage> {
93 let (mut near, mut far) = tokio::io::duplex(8192);
94 let bytes = raw.as_bytes().to_vec();
95 tokio::spawn(async move {
96 let _ = far.write_all(&bytes).await;
97 let _ = far.flush().await;
98 });
99 match res!(HttpMessage::read::<1024, 1024, _>(
100 Pin::new(&mut near), &Vec::new(), Some(true), None).await)
101 {
102 (Some(msg), _) => Ok(msg),
103 (None, _) => Err(err!("The test request did not parse."; Test, Invalid, Input)),
104 }
105}
106
107// The answer, as the lower-cased head and the body, the way a client would receive it.
108struct Answer {
109 head: String,
110 body: Vec<u8>,
111}
112
113impl Answer {
114 fn status(&self) -> &str {
115 self.head.get(9..12).unwrap_or("")
116 }
117 fn field(&self, name: &str) -> Option<String> {
118 let want = fmt!("{}: ", name);
119 self.head.split("\r\n")
120 .find(|l| l.starts_with(&want))
121 .map(|l| l[want.len()..].to_string())
122 }
123}
124
125async fn ask(svc: &TileService<MemArchive>, raw: &str) -> Outcome<Option<Answer>> {
126 let msg = res!(parse_request(raw).await);
127 let req = res!(TileRequest::of(&msg).ok_or_else(|| err!(
128 "The test message is not a request."; Test, Invalid)));
129 let resp = match svc.respond(req).await {
130 Some(r) => r,
131 None => return Ok(None),
132 };
133 let mut wire: Vec<u8> = Vec::new();
134 res!(resp.write_all(&mut wire).await);
135 let split = res!(wire.windows(4).position(|w| w == b"\r\n\r\n").ok_or_else(|| err!(
136 "The answer has no end of head."; Test, Invalid)));
137 Ok(Some(Answer {
138 head: String::from_utf8_lossy(&wire[..split]).to_lowercase(),
139 body: wire[split + 4..].to_vec(),
140 }))
141}
142
143async fn must(svc: &TileService<MemArchive>, raw: &str) -> Outcome<Answer> {
144 res!(ask(svc, raw).await).ok_or_else(|| err!(
145 "The route did not claim a path under its prefix."; Test, Missing))
146}
147
148fn get(path: &str, extra: &str) -> String {
149 fmt!("GET {} HTTP/1.1\r\nHost: tiles.oxegen.io\r\n{}\r\n", path, extra)
150}
151
152const TILE: &str = "/t/20260922/14/13433/9798.mvt";
153
154#[tokio::test]
155async fn stored_gzip_is_passed_through_and_cached_for_good() -> Outcome<()> {
156 let svc = res!(service(false));
157 let a = res!(must(&svc, &get(TILE,
158 "Origin: https://oxegen.io\r\nAccept-Encoding: gzip, br\r\n")).await);
159 assert_eq!(a.status(), "200", "{}", a.head);
160 assert_eq!(a.field("content-encoding").as_deref(), Some("gzip"));
161 assert_eq!(a.field("content-type").as_deref(), Some("application/vnd.mapbox-vector-tile"));
162 assert_eq!(res!(encoding::gunzip(&a.body)), PLAIN.to_vec(),
163 "the stored bytes must reach the client as they are");
164 assert_eq!(a.field("cache-control").as_deref(),
165 Some("public, max-age=31536000, immutable"));
166 assert_eq!(a.field("access-control-allow-origin").as_deref(), Some(APP));
167 assert_eq!(a.field("vary").as_deref(), Some("origin, accept-encoding"));
168 assert_eq!(a.field("cross-origin-resource-policy").as_deref(), Some("same-site"));
169 assert_eq!(a.field("strict-transport-security").as_deref(),
170 Some("max-age=63072000; includesubdomains"));
171 assert!(a.field("access-control-allow-credentials").is_none());
172 Ok(())
173}
174
175#[tokio::test]
176async fn a_client_refusing_gzip_gets_the_plain_tile() -> Outcome<()> {
177 let svc = res!(service(false));
178 let a = res!(must(&svc, &get(TILE, "Accept-Encoding: identity\r\n")).await);
179 assert_eq!(a.status(), "200", "{}", a.head);
180 assert!(a.field("content-encoding").is_none(), "{}", a.head);
181 assert_eq!(a.body, PLAIN.to_vec());
182 // No Origin: served, but with nothing granting a cross-origin read.
183 assert!(a.field("access-control-allow-origin").is_none(), "{}", a.head);
184 Ok(())
185}
186
187#[tokio::test]
188async fn an_empty_tile_is_204_and_cached_like_a_full_one() -> Outcome<()> {
189 let svc = res!(service(false));
190 for path in ["/t/20260922/3/7/4.mvt", "/t/20260922/5/1/1.mvt"] {
191 let a = res!(must(&svc, &get(path, "Origin: https://oxegen.io\r\n")).await);
192 assert_eq!(a.status(), "204", "{}: {}", path, a.head);
193 assert!(a.body.is_empty());
194 assert!(a.field("content-length").is_none(), "{}", a.head);
195 assert_eq!(a.field("cache-control").as_deref(),
196 Some("public, max-age=31536000, immutable"));
197 assert_eq!(a.field("access-control-allow-origin").as_deref(), Some(APP));
198 }
199 Ok(())
200}
201
202#[tokio::test]
203async fn paths_naming_no_tile_are_404_and_not_stored() -> Outcome<()> {
204 let svc = res!(service(false));
205 for path in [
206 "/t",
207 "/t/",
208 "/t/20260922",
209 "/t/20260101/14/13433/9798.mvt", // unknown build
210 "/t/20260922/16/0/0.mvt", // beyond the archive's zooms
211 "/t/20260922/3/8/0.mvt", // off the edge at z3
212 "/t/20260922/3/0/8.mvt",
213 "/t/20260922/14/13433/9798.png", // not the archive's kind
214 "/t/20260922/14/013433/9798.mvt", // a second spelling of one tile
215 "/t/20260922/14/+13433/9798.mvt",
216 "/t/20260922/14/13433/9798",
217 "/t/20260922/14/13433/9798.mvt/x",
218 "/t/../t/20260922/14/13433/9798.mvt",
219 "/t/20260922/99999/0/0.mvt",
220 ] {
221 let a = res!(must(&svc, &get(path, "")).await);
222 assert_eq!(a.status(), "404", "{}: {}", path, a.head);
223 assert_eq!(a.field("cache-control").as_deref(), Some("no-store"), "{}", path);
224 }
225 Ok(())
226}
227
228#[tokio::test]
229async fn paths_outside_the_prefix_are_not_claimed() -> Outcome<()> {
230 let svc = res!(service(false));
231 for path in ["/", "/tx/20260922/14/13433/9798.mvt", "/tiles.json", "/index.html"] {
232 assert!(res!(ask(&svc, &get(path, "")).await).is_none(), "{} was claimed", path);
233 }
234 Ok(())
235}
236
237#[tokio::test]
238async fn an_unlisted_origin_is_refused() -> Outcome<()> {
239 let svc = res!(service(false));
240 for origin in ["https://evil.example", "http://oxegen.io", "https://oxegen.io.evil.example",
241 "null"]
242 {
243 let a = res!(must(&svc, &get(TILE, &fmt!("Origin: {}\r\n", origin))).await);
244 assert_eq!(a.status(), "403", "{}: {}", origin, a.head);
245 assert!(a.field("access-control-allow-origin").is_none(), "{}", a.head);
246 assert!(a.body.len() < 32, "a refused origin must not be handed the tile");
247 }
248 Ok(())
249}
250
251#[tokio::test]
252async fn cookies_and_credentials_change_nothing_and_none_are_set() -> Outcome<()> {
253 let svc = res!(service(false));
254 let bare = res!(must(&svc, &get(TILE,
255 "Origin: https://oxegen.io\r\nAccept-Encoding: gzip\r\n")).await);
256 let loaded = res!(must(&svc, &get(TILE,
257 "Origin: https://oxegen.io\r\nAccept-Encoding: gzip\r\n\
258 Cookie: sid=0123456789abcdef; member=alice\r\n\
259 Authorization: Bearer abc.def\r\nX-Member-Id: 42\r\n\
260 Referer: https://oxegen.io/map?at=-31.95,115.86\r\n")).await);
261 assert_eq!(bare.head, loaded.head, "the request's identity reached the answer");
262 assert_eq!(bare.body, loaded.body);
263 for a in [&bare, &loaded] {
264 assert!(!a.head.contains("set-cookie"), "{}", a.head);
265 }
266 // And the index, the one answer that is not a tile.
267 let idx = res!(must(&svc, &get("/t/tiles.json", "Cookie: sid=1\r\n")).await);
268 assert!(!idx.head.contains("set-cookie"), "{}", idx.head);
269 Ok(())
270}
271
272#[tokio::test]
273async fn preflight_head_and_other_methods() -> Outcome<()> {
274 let svc = res!(service(false));
275 let a = res!(must(&svc, &fmt!(
276 "OPTIONS {} HTTP/1.1\r\nHost: tiles.oxegen.io\r\nOrigin: https://oxegen.io\r\n\
277 Access-Control-Request-Method: GET\r\n\r\n", TILE)).await);
278 assert_eq!(a.status(), "204", "{}", a.head);
279 assert_eq!(a.field("access-control-allow-origin").as_deref(), Some(APP));
280 assert_eq!(a.field("access-control-allow-methods").as_deref(), Some("get, head, options"));
281
282 let full = res!(must(&svc, &get(TILE, "Accept-Encoding: gzip\r\n")).await);
283 let head = res!(must(&svc, &fmt!(
284 "HEAD {} HTTP/1.1\r\nHost: tiles.oxegen.io\r\nAccept-Encoding: gzip\r\n\r\n",
285 TILE)).await);
286 assert_eq!(head.status(), "200");
287 assert!(head.body.is_empty(), "a HEAD answer carried a body");
288 assert_eq!(head.head, full.head, "a HEAD must say what the GET would");
289
290 let a = res!(must(&svc, &fmt!(
291 "POST {} HTTP/1.1\r\nHost: tiles.oxegen.io\r\nContent-Length: 0\r\n\r\n",
292 TILE)).await);
293 assert_eq!(a.status(), "405", "{}", a.head);
294 assert_eq!(a.field("allow").as_deref(), Some("get, head, options"));
295 Ok(())
296}
297
298#[tokio::test]
299async fn the_index_names_the_current_build() -> Outcome<()> {
300 let svc = res!(service(false));
301 let a = res!(must(&svc, &get("/t/tiles.json", "Origin: https://oxegen.io\r\n")).await);
302 assert_eq!(a.status(), "200", "{}", a.head);
303 assert_eq!(a.field("cache-control").as_deref(), Some("public, max-age=300"));
304 let dat = res!(Dat::decode_string(String::from_utf8_lossy(&a.body).to_string()));
305 let m = match dat {
306 Dat::Map(m) => m,
307 other => return Err(err!("The index is not a map: {:?}", other; Test, Invalid)),
308 };
309 assert_eq!(m.get(&dat!("build")), Some(&dat!("20260922")));
310 assert_eq!(m.get(&dat!("attribution")), Some(&dat!("© OpenStreetMap")));
311 let body = String::from_utf8_lossy(&a.body).to_string();
312 assert!(body.contains(
313 "\"https://tiles.oxegen.io/t/20260922/{z}/{x}/{y}.mvt\""), "{}", body);
314 assert!(body.contains("\"maxzoom\":15"), "{}", body);
315 Ok(())
316}
317
318#[tokio::test]
319async fn a_failed_read_is_500_and_not_stored() -> Outcome<()> {
320 let svc = res!(service(true));
321 let a = res!(must(&svc, &get(TILE, "")).await);
322 assert_eq!(a.status(), "500", "{}", a.head);
323 assert_eq!(a.field("cache-control").as_deref(), Some("no-store"));
324 Ok(())
325}
326
327// ── Config ──────────────────────────────────────────────────────────────────
328
329fn vhost(tiles: &str, access_log: &str) -> Outcome<VhostConfig> {
330 let text = fmt!("{{\"hostnames\": [\"tiles.oxegen.io\"], {} {}}}", tiles, access_log);
331 match res!(Dat::decode_string(text)) {
332 Dat::Map(m) => VhostConfig::from_datmap(&m),
333 _ => Err(err!("The test config is not a map."; Test, Invalid)),
334 }
335}
336
337const TILES: &str = "\"tiles\": {\"current\": \"20260922\", \
338 \"builds\": {\"20260922\": \"/srv/tiles/20260922.pmtiles\"}, \
339 \"allow_origins\": [\"https://oxegen.io\"]},";
340
341#[test]
342fn a_tile_vhost_must_turn_its_access_log_off() -> Outcome<()> {
343 let e = match vhost(TILES, "") {
344 Ok(_) => return Err(err!("A tile vhost with its access log on was accepted."; Test)),
345 Err(e) => e,
346 };
347 assert!(fmt!("{}", e).contains("access_log"), "{}", e);
348 assert!(vhost(TILES, "\"access_log\": true").is_err());
349 let v = res!(vhost(TILES, "\"access_log\": false"));
350 assert!(!v.access_log);
351 let t = res!(v.tiles.ok_or_else(|| err!("The tiles block was dropped."; Test, Missing)));
352 assert_eq!(t.prefix, "/t");
353 assert_eq!(t.attribution, "© OpenStreetMap");
354 // A vhost without tiles keeps its log unless told otherwise.
355 assert!(res!(vhost("", "")).access_log);
356 Ok(())
357}
358
359#[test]
360fn allow_origins_reads_the_same_in_either_list_shape() -> Outcome<()> {
361 let vek = "\"tiles\": {\"current\": \"20260922\", \
362 \"builds\": {\"20260922\": \"/srv/tiles/20260922.pmtiles\"}, \
363 \"allow_origins\": (vek|[\"https://oxegen.io\", \"https://test.oxegen.io\"])},";
364 let v = res!(vhost(vek, "\"access_log\": false"));
365 let t = res!(v.tiles.ok_or_else(|| err!("The tiles block was dropped."; Test, Missing)));
366 assert_eq!(t.allow_origins, vec!["https://oxegen.io".to_string(), "https://test.oxegen.io".to_string()]);
367 let v = res!(vhost(TILES, "\"access_log\": false"));
368 let t = res!(v.tiles.ok_or_else(|| err!("The tiles block was dropped."; Test, Missing)));
369 assert_eq!(t.allow_origins, vec!["https://oxegen.io".to_string()]);
370 Ok(())
371}
372
373#[test]
374fn tile_config_refuses_what_it_cannot_honour() -> Outcome<()> {
375 for (why, tiles) in [
376 ("relative path", "\"tiles\": {\"current\": \"a\", \"builds\": {\"a\": \"tiles/a.pmtiles\"}},"),
377 ("current absent", "\"tiles\": {\"current\": \"b\", \"builds\": {\"a\": \"/srv/a.pmtiles\"}},"),
378 ("no builds", "\"tiles\": {\"current\": \"a\"},"),
379 ("wildcard origin", "\"tiles\": {\"current\": \"a\", \"builds\": {\"a\": \"/srv/a.pmtiles\"}, \"allow_origins\": [\"*\"]},"),
380 ("origin with path", "\"tiles\": {\"current\": \"a\", \"builds\": {\"a\": \"/srv/a.pmtiles\"}, \"allow_origins\": [\"https://oxegen.io/\"]},"),
381 ("slash in build", "\"tiles\": {\"current\": \"a/b\", \"builds\": {\"a/b\": \"/srv/a.pmtiles\"}},"),
382 ("dot build", "\"tiles\": {\"current\": \"..\", \"builds\": {\"..\": \"/srv/a.pmtiles\"}},"),
383 ("trailing slash prefix", "\"tiles\": {\"prefix\": \"/t/\", \"current\": \"a\", \"builds\": {\"a\": \"/srv/a.pmtiles\"}},"),
384 ] {
385 assert!(vhost(tiles, "\"access_log\": false").is_err(), "accepted: {}", why);
386 }
387 Ok(())
388}
389
390fn sample() -> PathBuf {
391 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../fe2o3_geom/tests/data/protomaps/sample.pmtiles")
392}
393
394#[test]
395fn a_missing_archive_stops_start_up_and_a_real_one_opens() -> Outcome<()> {
396 // The configured path does not exist, so start-up is refused, naming the build.
397 let r = TileService::<TileSource>::new(&config(), "tiles.oxegen.io", 0, TileSource::open);
398 let e = match r {
399 Ok(_) => return Err(err!("An archive that is not there opened."; Test)),
400 Err(e) => e,
401 };
402 assert!(fmt!("{:?}", e).contains("20260922"), "{:?}", e);
403 // A real archive, written by the reference Python writer, opens and says what it holds.
404 let src = res!(TileSource::open("sample", &sample()));
405 let info = src.info();
406 assert_eq!(info.kind, TileKind::Mvt);
407 assert_eq!(info.coding, ContentCoding::Gzip);
408 assert_eq!((info.min_zoom, info.max_zoom), (12, 15));
409 assert_eq!(info.bounds_e7, [1_156_000_000, -326_000_000, 1_163_000_000, -316_000_000]);
410 let stored = res!(src.tile(13, 6729, 4865));
411 let plain = res!(encoding::gunzip(&res!(stored.ok_or_else(|| err!("No tile."; Test)))));
412 let want = res!(std::fs::read(PathBuf::from(env!("CARGO_MANIFEST_DIR"))
413 .join("../fe2o3_geom/tests/data/protomaps/13_6729_4865.mvt")));
414 assert_eq!(plain, want);
415 assert_eq!(res!(src.tile(13, 0, 0)), None);
416 Ok(())
417}