fix: the game was unplayable, and the note redirect was refused
Some checks failed
ci / check (push) Failing after 3s
Some checks failed
ci / check (push) Failing after 3s
Tier S (fixes inside a boundary; chaos d8=7, no override). Four observations from play, three of them caused by CB-WP-0028 -- and make all was green for all of them. THE NOTE BUG, WHICH I GOT WRONG TWICE. The first fix put the token in the form's action, and that worked. But the 303 afterwards pointed at bare `/` with no token, so the note WAS SAVED and then the browser followed a redirect control 1 refuses. The player sees "no session token" for a note that already landed. A redirect is a request the browser makes on your behalf and is subject to every control the others are. I had tested the POST and stopped there -- the same mistake as the first fix, one step further along. Guard::page_path() now carries the token, and the test asserts the redirect target is ADMITTED rather than merely non-empty. WHY DRAGGING BROKE, WHICH WAS NOT THE DRAG. The gesture logic was fine: the JS harness posts correctly against the served page, and all 14 drop targets are present. The table was 620px tall, which pushed the action cards a full screen below the Problems -- and you cannot drag between two things that are never on screen together. Now 440, with a test asserting the declared height stays under 460 and saying why. That is a proxy for a browser layout, not the property itself, and the test says so. Seats sat ON the ellipse: they were placed at 0.83 of the table radius, which is inside it. Now outside, asserted numerically at 2 through 6 seats against the ellipse equation rather than eyeballed. And the `table` drop target was a separate CARD among the move buttons, which is exactly why a player looking at a picture of a table could not find anywhere to drop. The drawn ellipse is the drop zone now, and a test asserts there is EXACTLY ONE table target and that it is the drawn one -- two elements claiming to be the table is worse than none. The gap this exposes is the one CB-EV-0026 named a day earlier: every test asserted the DOM was correct, and it was. Nothing asserted the page was usable, and the drag test passes on a page you cannot physically drag on. What is added here are proxies a browser-less test can check. make all: exit 0. 61 render tests, 26 cb-play. Verified over real HTTP rather than by inspection: note POST 303, the redirect carries the token, following it returns 200, the table declares 440, one drop zone, seats at (380,421)/(113,112)/(647,112) against a table of rx=200 ry=118, and both notes reached the trial log. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
ab364a0319
commit
8eadb6c963
4 changed files with 175 additions and 46 deletions
|
|
@ -467,18 +467,24 @@ fn piles_body(out: &mut String, view: &GroundView) {
|
||||||
/// and drawing the same thing twice is how the two drift.
|
/// and drawing the same thing twice is how the two drift.
|
||||||
fn table_svg(view: &GroundView) -> String {
|
fn table_svg(view: &GroundView) -> String {
|
||||||
let n = view.players.len().max(1);
|
let n = view.players.len().max(1);
|
||||||
let (cx, cy) = (380.0f64, 300.0f64);
|
// Compact on purpose. The first version was 760x620, which pushed the
|
||||||
let seat_r = 240.0f64;
|
// action cards a full screen below the Problems -- and you cannot drag
|
||||||
|
// between two things that are never on screen together, which made the
|
||||||
|
// game unplayable. Height is the constraint, not width.
|
||||||
|
let (cx, cy) = (380.0f64, 215.0f64);
|
||||||
|
let (rx, ry) = (200.0f64, 118.0f64);
|
||||||
|
// Seats sit OUTSIDE the table, as people do. The first version put
|
||||||
|
// them at 0.83 of the ellipse and they sat on it.
|
||||||
|
let (sx, sy) = (rx + 108.0, ry + 88.0);
|
||||||
|
|
||||||
let pos: Vec<(PlayerId, f64, f64)> = view
|
let pos: Vec<(PlayerId, f64, f64)> = view
|
||||||
.players
|
.players
|
||||||
.keys()
|
.keys()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, p)| {
|
.map(|(i, p)| {
|
||||||
// Start at the bottom, not the top: the viewer sits nearest
|
// Start at the bottom: the viewer sits nearest the reader.
|
||||||
// the reader, which is where you sit at a real table.
|
|
||||||
let a = std::f64::consts::TAU * (i as f64) / (n as f64) + std::f64::consts::FRAC_PI_2;
|
let a = std::f64::consts::TAU * (i as f64) / (n as f64) + std::f64::consts::FRAC_PI_2;
|
||||||
(*p, cx + seat_r * a.cos(), cy + seat_r * a.sin() * 0.72)
|
(*p, cx + sx * a.cos(), cy + sy * a.sin())
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let find = |p: PlayerId| {
|
let find = |p: PlayerId| {
|
||||||
|
|
@ -488,12 +494,22 @@ fn table_svg(view: &GroundView) -> String {
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut s = String::from(
|
let mut s = String::from(
|
||||||
"<svg viewBox=\"0 0 760 620\" width=\"100%\" style=\"max-width:760px\" \
|
"<svg viewBox=\"0 0 760 440\" width=\"100%\" style=\"max-width:760px\" \
|
||||||
role=\"img\" aria-label=\"the table, seen from above\">\
|
role=\"img\" aria-label=\"the table, seen from above\">",
|
||||||
<ellipse cx=\"380\" cy=\"300\" rx=\"290\" ry=\"215\" fill=\"#171b23\" stroke=\"#2a3140\"/>",
|
);
|
||||||
|
|
||||||
|
// THE TABLE IS THE DROP ZONE. `table` was an abstract id with no
|
||||||
|
// picture; the player looked for somewhere to drop GROUND and there
|
||||||
|
// was nowhere. Now the surface you can see is the surface you drop on.
|
||||||
|
let _ = write!(
|
||||||
|
s,
|
||||||
|
"<g data-drop=\"table\"><ellipse cx=\"{cx}\" cy=\"{cy}\" rx=\"{rx}\" ry=\"{ry}\" \
|
||||||
|
fill=\"#171b23\" stroke=\"#2a3140\" stroke-width=\"2\"/>\
|
||||||
|
<text x=\"{cx}\" y=\"{ty}\" fill=\"#556\" font-size=\"10\" \
|
||||||
|
text-anchor=\"middle\">the table</text></g>",
|
||||||
|
ty = cy + ry - 8.0,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Relations first, so seats draw over them.
|
|
||||||
for (pair, rel) in &view.relations {
|
for (pair, rel) in &view.relations {
|
||||||
if let (Some((x1, y1)), Some((x2, y2))) = (find(pair.0), find(pair.1)) {
|
if let (Some((x1, y1)), Some((x2, y2))) = (find(pair.0), find(pair.1)) {
|
||||||
let colour = match rel {
|
let colour = match rel {
|
||||||
|
|
@ -503,7 +519,7 @@ fn table_svg(view: &GroundView) -> String {
|
||||||
let _ = write!(
|
let _ = write!(
|
||||||
s,
|
s,
|
||||||
"<line x1=\"{x1:.0}\" y1=\"{y1:.0}\" x2=\"{x2:.0}\" y2=\"{y2:.0}\" \
|
"<line x1=\"{x1:.0}\" y1=\"{y1:.0}\" x2=\"{x2:.0}\" y2=\"{y2:.0}\" \
|
||||||
stroke=\"{colour}\" stroke-width=\"2\"/>\
|
stroke=\"{colour}\" stroke-width=\"2\" stroke-opacity=\"0.7\"/>\
|
||||||
<text x=\"{mx:.0}\" y=\"{my:.0}\" fill=\"{colour}\" font-size=\"10\" \
|
<text x=\"{mx:.0}\" y=\"{my:.0}\" fill=\"{colour}\" font-size=\"10\" \
|
||||||
text-anchor=\"middle\">{rel:?}</text>",
|
text-anchor=\"middle\">{rel:?}</text>",
|
||||||
mx = (x1 + x2) / 2.0,
|
mx = (x1 + x2) / 2.0,
|
||||||
|
|
@ -512,22 +528,28 @@ fn table_svg(view: &GroundView) -> String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The middle of the table: Problems, then the two stacks under them.
|
// On the table: Problems across the middle, the two stacks under them.
|
||||||
let span = (view.problems.len().max(1) as f64) * 130.0;
|
let count = view.problems.len().max(1) as f64;
|
||||||
|
let scale = (2.0 * rx * 0.92 / (count * 130.0)).min(0.82);
|
||||||
let _ = write!(
|
let _ = write!(
|
||||||
s,
|
s,
|
||||||
"<g transform=\"translate({x:.0},170)\">",
|
"<g transform=\"translate({x:.0},{y:.0}) scale({scale:.3})\">",
|
||||||
x = cx - span / 2.0,
|
x = cx - count * 130.0 * scale / 2.0,
|
||||||
|
y = cy - ry + 24.0,
|
||||||
);
|
);
|
||||||
for (i, (priority, p)) in view.problems.iter().enumerate() {
|
for (i, (priority, p)) in view.problems.iter().enumerate() {
|
||||||
problem_svg(&mut s, *priority, p, (i as i32) * 130);
|
problem_svg(&mut s, *priority, p, (i as i32) * 130);
|
||||||
}
|
}
|
||||||
s.push_str("</g>");
|
s.push_str("</g>");
|
||||||
let _ = write!(s, "<g transform=\"translate({x:.0},290)\">", x = cx - 150.0);
|
let _ = write!(
|
||||||
|
s,
|
||||||
|
"<g transform=\"translate({x:.0},{y:.0}) scale(0.62)\">",
|
||||||
|
x = cx - 300.0 * 0.62 / 2.0,
|
||||||
|
y = cy + 8.0,
|
||||||
|
);
|
||||||
piles_body(&mut s, view);
|
piles_body(&mut s, view);
|
||||||
s.push_str("</g>");
|
s.push_str("</g>");
|
||||||
|
|
||||||
// Seats, each with what it played in front of it.
|
|
||||||
for (p, x, y) in &pos {
|
for (p, x, y) in &pos {
|
||||||
let is_viewer = view.viewer == Some(*p);
|
let is_viewer = view.viewer == Some(*p);
|
||||||
let focus = view
|
let focus = view
|
||||||
|
|
@ -536,34 +558,31 @@ fn table_svg(view: &GroundView) -> String {
|
||||||
.map(|f| format!(" \u{2192}{}", seat_name(*f)))
|
.map(|f| format!(" \u{2192}{}", seat_name(*f)))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let pv = &view.players[p];
|
let pv = &view.players[p];
|
||||||
// The played card sits between the seat and the centre.
|
// The played card sits between the seat and the table.
|
||||||
let (dx, dy) = ((cx - x) * 0.30, (cy - y) * 0.30);
|
|
||||||
if let Some(sel) = view.selections.get(p) {
|
if let Some(sel) = view.selections.get(p) {
|
||||||
let _ = write!(
|
let _ = write!(
|
||||||
s,
|
s,
|
||||||
"<g transform=\"translate({px:.0},{py:.0}) scale(0.62)\">",
|
"<g transform=\"translate({px:.0},{py:.0}) scale(0.55)\">",
|
||||||
px = x + dx - 26.0,
|
px = x + (cx - x) * 0.34 - 23.0,
|
||||||
py = y + dy - 17.0,
|
py = y + (cy - y) * 0.34 - 15.0,
|
||||||
);
|
);
|
||||||
played_inner(&mut s, sel);
|
played_svg(&mut s, sel);
|
||||||
s.push_str("</g>");
|
s.push_str("</g>");
|
||||||
}
|
}
|
||||||
let _ = write!(
|
let _ = write!(
|
||||||
s,
|
s,
|
||||||
"<g data-drop=\"seat-{raw}\">\
|
"<g data-drop=\"seat-{raw}\">\
|
||||||
<circle cx=\"{x:.0}\" cy=\"{y:.0}\" r=\"34\" fill=\"#1b1e26\" \
|
<circle cx=\"{x:.0}\" cy=\"{y:.0}\" r=\"32\" fill=\"#1b1e26\" \
|
||||||
stroke=\"{stroke}\" stroke-width=\"{sw}\"/>\
|
stroke=\"{stroke}\" stroke-width=\"{sw}\"/>\
|
||||||
<text x=\"{x:.0}\" y=\"{t1:.0}\" fill=\"#dde\" font-size=\"12\" \
|
<text x=\"{x:.0}\" y=\"{t1:.0}\" fill=\"#dde\" font-size=\"12\" \
|
||||||
text-anchor=\"middle\">{name}{you}</text>\
|
text-anchor=\"middle\">{name}{you}</text>\
|
||||||
<text x=\"{x:.0}\" y=\"{t2:.0}\" fill=\"#89a\" font-size=\"10\" \
|
<text x=\"{x:.0}\" y=\"{t2:.0}\" fill=\"#89a\" font-size=\"10\" \
|
||||||
text-anchor=\"middle\">stress {stress}{focus}</text></g>",
|
text-anchor=\"middle\">stress {stress}{focus}</text></g>",
|
||||||
raw = p.0,
|
raw = p.0,
|
||||||
// The viewer's own seat is thicker and blue: a table where you
|
|
||||||
// cannot find yourself is worse than a list.
|
|
||||||
stroke = if is_viewer { "#9cf" } else { "#5a6b7a" },
|
stroke = if is_viewer { "#9cf" } else { "#5a6b7a" },
|
||||||
sw = if is_viewer { 3 } else { 2 },
|
sw = if is_viewer { 3 } else { 2 },
|
||||||
t1 = y - 2.0,
|
t1 = y - 2.0,
|
||||||
t2 = y + 14.0,
|
t2 = y + 13.0,
|
||||||
name = seat_name(*p),
|
name = seat_name(*p),
|
||||||
you = if is_viewer { " (you)" } else { "" },
|
you = if is_viewer { " (you)" } else { "" },
|
||||||
stress = pv.stress,
|
stress = pv.stress,
|
||||||
|
|
@ -610,11 +629,6 @@ const CARD_BACK: &str = "<svg viewBox=\"0 0 84 56\" width=\"84\" height=\"56\" c
|
||||||
<rect x=\"2\" y=\"2\" width=\"80\" height=\"52\" rx=\"7\" fill=\"#2a2f3a\" stroke=\"#5a6b7a\"/>\
|
<rect x=\"2\" y=\"2\" width=\"80\" height=\"52\" rx=\"7\" fill=\"#2a2f3a\" stroke=\"#5a6b7a\"/>\
|
||||||
<path d=\"M14 14 h56 M14 28 h56 M14 42 h56\" stroke=\"#3d4756\" stroke-width=\"3\"/></svg>";
|
<path d=\"M14 14 h56 M14 28 h56 M14 42 h56\" stroke=\"#3d4756\" stroke-width=\"3\"/></svg>";
|
||||||
|
|
||||||
/// The played card without its own `<svg>`, for placing on the table.
|
|
||||||
fn played_inner(out: &mut String, sel: &SelectionView) {
|
|
||||||
played_svg(out, sel);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn played_svg(out: &mut String, sel: &SelectionView) {
|
fn played_svg(out: &mut String, sel: &SelectionView) {
|
||||||
let s = match sel {
|
let s = match sel {
|
||||||
SelectionView::Hidden => {
|
SelectionView::Hidden => {
|
||||||
|
|
@ -1053,10 +1067,11 @@ fn move_section(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.push_str(
|
// CB-WP-0028: the table's drop zone is THE TABLE, drawn above.
|
||||||
"</div><div class=\"card\" data-drop=\"table\">the table \u{2014} drop here for an \
|
// This was a separate card labelled "the table" sitting among the
|
||||||
untargeted action</div>",
|
// move buttons, which is why a player looking at a picture of a
|
||||||
);
|
// table could not find anywhere to drop.
|
||||||
|
s.push_str("</div>");
|
||||||
}
|
}
|
||||||
if may_pass {
|
if may_pass {
|
||||||
s.push_str(
|
s.push_str(
|
||||||
|
|
|
||||||
|
|
@ -721,6 +721,18 @@ mod overhead_table {
|
||||||
use cb_kernel::PlayerId;
|
use cb_kernel::PlayerId;
|
||||||
use games_ground::GroundState;
|
use games_ground::GroundState;
|
||||||
|
|
||||||
|
/// Seat circle centres, read out of the emitted SVG.
|
||||||
|
fn seat_centres(html: &str) -> Vec<(f64, f64)> {
|
||||||
|
html.match_indices("<circle cx=\"")
|
||||||
|
.filter_map(|(i, _)| {
|
||||||
|
let rest = &html[i + 12..];
|
||||||
|
let (x, rest) = rest.split_once("\" cy=\"")?;
|
||||||
|
let (y, _) = rest.split_once('"')?;
|
||||||
|
Some((x.parse().ok()?, y.parse().ok()?))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn view_of(players: u8) -> games_ground::view::GroundView {
|
fn view_of(players: u8) -> games_ground::view::GroundView {
|
||||||
GroundState::setup(
|
GroundState::setup(
|
||||||
&Setup {
|
&Setup {
|
||||||
|
|
@ -743,15 +755,7 @@ mod overhead_table {
|
||||||
let v = view_of(players);
|
let v = view_of(players);
|
||||||
let html = crate::doc::document(&v, &[], "/command?t=x", Some(PlayerId(0)), false);
|
let html = crate::doc::document(&v, &[], "/command?t=x", Some(PlayerId(0)), false);
|
||||||
|
|
||||||
let seats: Vec<(f64, f64)> = html
|
let seats = seat_centres(&html);
|
||||||
.match_indices("<circle cx=\"")
|
|
||||||
.filter_map(|(i, _)| {
|
|
||||||
let rest = &html[i + 12..];
|
|
||||||
let (x, rest) = rest.split_once("\" cy=\"")?;
|
|
||||||
let (y, _) = rest.split_once('"')?;
|
|
||||||
Some((x.parse().ok()?, y.parse().ok()?))
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
seats.len(),
|
seats.len(),
|
||||||
players as usize,
|
players as usize,
|
||||||
|
|
@ -761,7 +765,7 @@ mod overhead_table {
|
||||||
for b in &seats[i + 1..] {
|
for b in &seats[i + 1..] {
|
||||||
let d = ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt();
|
let d = ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt();
|
||||||
assert!(
|
assert!(
|
||||||
d > 70.0,
|
d > 64.0,
|
||||||
"{players}p: two seats are {d:.0}px apart and the circles are r=34 — \
|
"{players}p: two seats are {d:.0}px apart and the circles are r=34 — \
|
||||||
they overlap"
|
they overlap"
|
||||||
);
|
);
|
||||||
|
|
@ -800,6 +804,77 @@ mod overhead_table {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **Observation 1: the seats were ON the table, not around it.**
|
||||||
|
/// Every seat circle must lie outside the ellipse.
|
||||||
|
#[test]
|
||||||
|
fn the_seats_sit_outside_the_table_not_on_it() {
|
||||||
|
for players in 2..=6u8 {
|
||||||
|
let v = view_of(players);
|
||||||
|
let html = crate::doc::document(&v, &[], "/command?t=x", Some(PlayerId(0)), false);
|
||||||
|
let (cx, cy, rx, ry) = (380.0f64, 215.0f64, 200.0f64, 118.0f64);
|
||||||
|
for (x, y) in seat_centres(&html) {
|
||||||
|
// Outside an ellipse: (dx/rx)^2 + (dy/ry)^2 > 1, with the
|
||||||
|
// seat's own radius kept clear of the rim.
|
||||||
|
let d = ((x - cx) / (rx + 30.0)).powi(2) + ((y - cy) / (ry + 30.0)).powi(2);
|
||||||
|
assert!(
|
||||||
|
d > 1.0,
|
||||||
|
"{players}p: a seat at ({x:.0},{y:.0}) is on or inside the table"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Observations 3 and 4: the table you can see is the table you drop
|
||||||
|
/// on, and the game must be playable.** A `table` drop target that is
|
||||||
|
/// not the drawn table is why a player could not find where to drop.
|
||||||
|
#[test]
|
||||||
|
fn the_drawn_table_is_the_drop_target_and_there_is_only_one() {
|
||||||
|
let v = view_of(3);
|
||||||
|
let html = crate::doc::document(&v, &[], "/command?t=x", Some(PlayerId(0)), false);
|
||||||
|
assert_eq!(
|
||||||
|
html.matches("data-drop=\"table\"").count(),
|
||||||
|
1,
|
||||||
|
"two elements claim to be the table; a player cannot tell which to use"
|
||||||
|
);
|
||||||
|
let table = html
|
||||||
|
.split("aria-label=\"the table, seen from above\"")
|
||||||
|
.nth(1)
|
||||||
|
.and_then(|s| s.split("</svg>").next())
|
||||||
|
.expect("one table svg");
|
||||||
|
assert!(
|
||||||
|
table.contains("data-drop=\"table\""),
|
||||||
|
"the drop target is not on the drawn table"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
table.contains("<ellipse"),
|
||||||
|
"the drop target should be the surface itself"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The reason the game became unplayable.** The table was 620px
|
||||||
|
/// tall, so the action cards sat a screen below the Problems — and you
|
||||||
|
/// cannot drag between two things never on screen together.
|
||||||
|
///
|
||||||
|
/// Asserted on the declared height, which is the only thing a test
|
||||||
|
/// without a browser can see, and that limit is said out loud rather
|
||||||
|
/// than implied.
|
||||||
|
#[test]
|
||||||
|
fn the_table_is_short_enough_to_drag_from() {
|
||||||
|
let v = view_of(6);
|
||||||
|
let html = crate::doc::document(&v, &[], "/command?t=x", Some(PlayerId(0)), false);
|
||||||
|
let vb = html
|
||||||
|
.split("viewBox=\"0 0 760 ")
|
||||||
|
.nth(1)
|
||||||
|
.and_then(|s| s.split('"').next())
|
||||||
|
.expect("the table declares a viewBox");
|
||||||
|
let h: f64 = vb.parse().expect("a number");
|
||||||
|
assert!(
|
||||||
|
h <= 460.0,
|
||||||
|
"the table is {h}px tall; the action cards end up off-screen and \
|
||||||
|
dragging to a Problem becomes impossible"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// The three diagrams became one: the relationship circle and the
|
/// The three diagrams became one: the relationship circle and the
|
||||||
/// piles picture are gone as separate views, and their content is on
|
/// piles picture are gone as separate views, and their content is on
|
||||||
/// the table.
|
/// the table.
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,17 @@ impl Guard {
|
||||||
format!("/note?t={}", self.token)
|
format!("/note?t={}", self.token)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The page's own path, token and all — for a `Location:` header.
|
||||||
|
///
|
||||||
|
/// **A redirect back to bare `/` is refused**, because control 1
|
||||||
|
/// requires the token on every request including the one the browser
|
||||||
|
/// makes for you. CB-WP-0027's note form saved the note and then sent
|
||||||
|
/// the browser somewhere it was not allowed to go, which reads to a
|
||||||
|
/// player as "the note failed".
|
||||||
|
pub fn page_path(&self) -> String {
|
||||||
|
format!("/?t={}", self.token)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn page_url(&self) -> String {
|
pub fn page_url(&self) -> String {
|
||||||
format!("{}/?t={}", self.origin, self.token)
|
format!("{}/?t={}", self.origin, self.token)
|
||||||
}
|
}
|
||||||
|
|
@ -245,6 +256,28 @@ mod tests {
|
||||||
/// M-D1-MUT: delete the `None => Err(Refusal::MissingToken)` arm in
|
/// M-D1-MUT: delete the `None => Err(Refusal::MissingToken)` arm in
|
||||||
/// `admit` (return `Ok(())` instead) and this goes red with
|
/// `admit` (return `Ok(())` instead) and this goes red with
|
||||||
/// `a token-less request was admitted`. Run 2026-08-02.
|
/// `a token-less request was admitted`. Run 2026-08-02.
|
||||||
|
/// **The bug a player reported as "I can't save notes".** The note
|
||||||
|
/// POST succeeded and the 303 sent the browser to bare `/`, which
|
||||||
|
/// control 1 refuses — so the note was stored and the player was shown
|
||||||
|
/// "no session token", which reads as the save having failed.
|
||||||
|
///
|
||||||
|
/// A redirect is a request the browser makes on your behalf, and it
|
||||||
|
/// is subject to every control the others are.
|
||||||
|
#[test]
|
||||||
|
fn the_page_path_a_redirect_uses_carries_the_token() {
|
||||||
|
let g = guard();
|
||||||
|
let path = g.page_path();
|
||||||
|
assert!(
|
||||||
|
path.contains(g.token()),
|
||||||
|
"a redirect to {path:?} would be refused for want of a token"
|
||||||
|
);
|
||||||
|
// And it is admitted, which is the property that matters.
|
||||||
|
let req = req(&format!(
|
||||||
|
"GET {path} HTTP/1.1\r\nSec-Fetch-Site: same-origin\r\n\r\n"
|
||||||
|
));
|
||||||
|
assert!(g.admit(&req).is_ok(), "the redirect target is refused");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_token_less_request_is_refused() {
|
fn a_token_less_request_is_refused() {
|
||||||
let g = guard();
|
let g = guard();
|
||||||
|
|
|
||||||
|
|
@ -290,7 +290,13 @@ impl Server {
|
||||||
// 303 so the browser re-GETs the table rather
|
// 303 so the browser re-GETs the table rather
|
||||||
// than leaving a form POST in history — a
|
// than leaving a form POST in history — a
|
||||||
// reload would otherwise re-submit the note.
|
// reload would otherwise re-submit the note.
|
||||||
respond_seeother(&mut stream, "/");
|
//
|
||||||
|
// WITH THE TOKEN. Redirecting to bare `/` sent
|
||||||
|
// the browser to a request control 1 refuses,
|
||||||
|
// so the note was saved and the player was
|
||||||
|
// shown "no session token" — which reads as
|
||||||
|
// the note having failed.
|
||||||
|
respond_seeother(&mut stream, &self.guard.page_path());
|
||||||
}
|
}
|
||||||
Err(e) => respond(&mut stream, 500, "text/plain", &e),
|
Err(e) => respond(&mut stream, 500, "text/plain", &e),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue