Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions ONBOARDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,10 +310,13 @@ branch, open a PR, get it green, then merge.
- **The map opens on localhost too.** There is one console per environment —
`app`, `staging-app`, `test-app` — and the surface is chosen by path, not by
hostname: `/map` is the real network, `/sim` the simulated fleet, and
`/sim/physics` the page that tunes it. `/sim` exists everywhere but is only
worth opening where the server sets `SYNTHETIC_FLEET_ENABLED` (test,
staging, the laptop — not production), which is what `/api/health` and
`/api/auth/me` both report so the nav can say so. `map`, `testmap`,
`/sim/physics` the page that tunes it. Both are open without signing in,
and so is the save behind the physics page — there is no identity provider
to sign an operator in with yet, and the fleet is synthetic. `/sim` exists
everywhere but is only worth opening where the server sets
`SYNTHETIC_FLEET_ENABLED` (test, staging, the laptop — not production),
which is what `/api/health` and `/api/auth/me` both report so the nav and
the physics route can follow it. `map`, `testmap`,
`test-testmap`, `staging-map` and the other retired names are Cloudflare
redirects into those consoles. `/map` still takes its feed from the
hostname: `app` and `test-app` are real-only, and a local hostname retains
Expand Down
6 changes: 5 additions & 1 deletion backend/routes/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -440,9 +440,13 @@ async def get_simulation_config():


@router.put("/api/simulation/config")
async def put_simulation_config(body: dict = Body(...), _admin=Depends(require_admin)):
async def put_simulation_config(body: dict = Body(...)):
"""Update simulation physics fractions.

Open to anyone, like the GET beside it and the page that calls both: the
simulator is a public demo, the console has no identity provider yet, and
the fleet it reconfigures is synthetic. Nothing here touches a real node.

Accepted keys: frac_anomalous, frac_drone, frac_dark (0.0–1.0 each).
Sum of the three must not exceed 1.0 — the remainder is commercial aircraft.
frac_adsb_outage (0.0–1.0) is deliberately OUTSIDE that sum: it is the
Expand Down
16 changes: 16 additions & 0 deletions backend/tests/test_sim_ingest.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,22 @@ def test_live_knobs_default_on_with_dark_share(self, client):
assert cfg["live_adsb_enabled"] is True
assert cfg["frac_live_dark"] == 0.15

def test_config_put_needs_no_session(self, client, monkeypatch):
# The suite reaches admin routes through the anonymous-admin bypass,
# which would mask an auth gate on this one. Off, an unsigned PUT is
# still accepted here while the admin PUT beside it goes on refusing.
import core.users as users

monkeypatch.setattr(users, "AUTH_BYPASS", False)
try:
r = client.put("/api/simulation/config", json={"frac_live_dark": 0.2})
assert r.status_code == 200
assert r.json()["config"]["frac_live_dark"] == 0.2
assert client.put("/api/test/known-hold", json={}).status_code == 401
finally:
monkeypatch.undo()
client.put("/api/simulation/config", json={"frac_live_dark": 0.15})

def test_live_knobs_accepted_and_echoed(self, client):
r = client.put("/api/simulation/config", json={"frac_live_dark": 0.6, "live_adsb_enabled": False})
assert r.status_code == 200
Expand Down
14 changes: 7 additions & 7 deletions dashboard/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import MapFrontDoor from "./components/MapFrontDoor";
import PhysicsRedirect from "./components/PhysicsRedirect";
import { resolveSurface, warnIfModeIgnored } from "./utils/surface";
import { useAuth } from "./context/AuthContext";
import { showsPhysics } from "./utils/physics";

// User pages — lazy-loaded so each chunk is only downloaded when first visited
const OverviewPage = lazy(() => import("./pages/user/OverviewPage"));
Expand Down Expand Up @@ -62,7 +61,7 @@ const { isAdmin: isAdminSite, modeParamIgnored } = resolveSurface(
warnIfModeIgnored(modeParamIgnored);

export default function App() {
const { user } = useAuth();
const { syntheticFleet } = useAuth();
return (
<Routes>
<Route path="/login" element={<LoginPage isAdmin={isAdminSite} />} />
Expand Down Expand Up @@ -107,14 +106,15 @@ export default function App() {
fleet by name, so one console serves both and neither
depends on what the browser's address bar says. */}
<Route path="sim" element={<MapPage feed="synthetic" />} />
{/* Gated as /physics was: the page draws the fleet's
solver internals and saves its configuration, so it
is a signed-in page on a server that has a fleet. The
old address below forwards whether or not this route
{/* Open like /sim, session or not: the page draws the
fleet's solver internals and saves its configuration,
and both ends of that are public on a server that
has a fleet. Absent where there is none. The old
address below forwards whether or not this route
exists, so a stale link lands on the new one and then
renders nothing where there is no fleet — exactly
what /physics did on such a deployment already. */}
{showsPhysics(user) && (
{syntheticFleet && (
<Route path="sim/physics" element={<PhysicsPage />} />
)}
<Route path="physics" element={<PhysicsRedirect />} />
Expand Down
11 changes: 5 additions & 6 deletions dashboard/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { NavLink } from "react-router-dom";
import { towerFinderUrl } from "../utils/siblings";
import { useAuth } from "../context/AuthContext";
import { advertisedPublicRoutes } from "../utils/publicRoutes";
import { showsPhysics } from "../utils/physics";
import { externalLinkIcon } from "./RetnodeLink";

type NavItem = {
Expand All @@ -22,7 +21,7 @@ type NavSection = { title: string; items: NavItem[] };
// Built per render rather than at module scope. The links below read
// window.location, and an import-time read that threw would take down every
// module that transitively imports this one, not just the sidebar.
const userNav = (physics: boolean): NavSection[] => [
const userNav = (syntheticFleet: boolean): NavSection[] => [
{
title: "Dashboard",
items: [
Expand All @@ -37,7 +36,7 @@ const userNav = (physics: boolean): NavSection[] => [
// because the second is a setting of the first. Both exist only where
// the server runs a fleet; on a fleetless deployment /sim would be an
// empty map and the physics form would have nothing to configure.
...(physics
...(syntheticFleet
? [
{ to: "/sim", label: "Simulation", icon: "target", end: true },
{ to: "/sim/physics", label: "Physics Layer", icon: "layers" },
Expand Down Expand Up @@ -80,11 +79,11 @@ const userNav = (physics: boolean): NavSection[] => [
const publicNav = (syntheticFleet: boolean): NavSection[] => [
{
title: "Explore",
items: advertisedPublicRoutes(syntheticFleet).map(({ path, label, icon, exact }) => ({
items: advertisedPublicRoutes(syntheticFleet).map(({ path, label, icon, end }) => ({
to: path,
label,
icon,
end: exact,
end,
})),
},
];
Expand Down Expand Up @@ -296,7 +295,7 @@ export default function Sidebar({ isAdmin, collapsed, onToggle }) {
const nav = isAdmin
? adminNav
: user
? userNav(showsPhysics(user))
? userNav(Boolean(syntheticFleet))
: publicNav(Boolean(syntheticFleet));

return (
Expand Down
19 changes: 12 additions & 7 deletions dashboard/src/context/AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@ const AuthContext = createContext(null);
/** Whether this server runs a synthetic fleet, for a caller who has no session
* to read it off.
*
* A signed-in user gets the same fact as `synthetic_fleet` on /api/auth/me,
* and the console's own gates (utils/physics.ts) go on using that. A visitor
* does not, and the signed-out nav has to decide whether to point at /sim:
* test and staging run a fleet, production does not, and one bundle serves
* all three. /api/health is the only thing the server tells everyone, so the
* flag is answered there.
* A signed-in user gets the same fact as `synthetic_fleet` on /api/auth/me.
* A visitor does not, and the console has to decide whether to point at
* /sim and mount the physics page under it: test and staging run a fleet,
* production does not, and one bundle serves all three. /api/health is the
* only thing the server tells everyone, so the flag is answered there, and
* AuthProvider folds the two sources into the one `syntheticFleet` it hands
* out.
*
* Asked once at boot, beside the /me fetch, and never again — it is a
* property of the deployment, which does not change under a running tab. A
Expand Down Expand Up @@ -41,7 +42,11 @@ function useSyntheticFleet(): boolean {

export function AuthProvider({ children }) {
const { user, loading, setUser } = useCurrentUser();
const syntheticFleet = useSyntheticFleet();
const healthFleet = useSyntheticFleet();
// /me's word first: it arrives under the loading gate, so a signed-in
// caller's pages mount on the first settled render rather than a health
// round trip later.
const syntheticFleet = Boolean(user?.synthetic_fleet ?? healthFleet);

// Resolves { redirected } so a caller knows not to route over a navigation
// that is still in flight.
Expand Down
76 changes: 43 additions & 33 deletions dashboard/src/test/physicsRoute.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,16 @@ import { MemoryRouter, useLocation } from "react-router-dom";
import App from "../App";
import Sidebar from "../components/Sidebar";
import { ThemeProvider } from "../context/ThemeContext";
import { showsPhysics } from "../utils/physics";

type User = { name: string; email: string; synthetic_fleet?: boolean };

// The Physics item is in the signed-in nav, so the mocked caller has a session.
// AuthProvider answers `syntheticFleet` from /api/auth/me for a session and
// from /api/health for a visitor; the mock hands the cases the folded answer.
const state = vi.hoisted(() => ({
auth: {
user: { name: "Ada", email: "ada@example.com" } as User | null,
loading: false,
syntheticFleet: false,
logout: async () => ({ redirected: false }),
},
}));
Expand Down Expand Up @@ -42,8 +43,17 @@ function Where() {
return <output aria-label="location">{`${pathname}${search}${hash}`}</output>;
}

function renderSidebar({ fleet, realOnly }: { fleet: boolean; realOnly: boolean }) {
state.auth.user = { name: "Ada", email: "ada@example.com", synthetic_fleet: fleet };
function renderSidebar({
fleet,
realOnly,
signedIn = true,
}: {
fleet: boolean;
realOnly: boolean;
signedIn?: boolean;
}) {
state.auth.user = signedIn ? { name: "Ada", email: "ada@example.com", synthetic_fleet: fleet } : null;
state.auth.syntheticFleet = fleet;
flags.realOnly = realOnly;
return render(
<MemoryRouter>
Expand All @@ -68,6 +78,12 @@ describe("the Physics Layer route", () => {
expect(container.querySelector('a[href="/sim/physics"]')).toBeNull();
});

// The save behind it is open, so there is no session to wait for.
it("is offered to a visitor with no session", () => {
const { container } = renderSidebar({ fleet: true, realOnly: false, signedIn: false });
expect(container.querySelector('a[href="/sim/physics"]')).toHaveTextContent("Physics Layer");
});

// The old address is gone from the nav entirely: it survives only as a
// forward for links already in circulation, and an entry pointing at a
// redirect is one hop the caller need never take.
Expand All @@ -91,21 +107,26 @@ describe("the Simulation route", () => {
// Otherwise NavLink lights both entries up at once on /sim/physics, since a
// link without `end` is current for its whole subtree.
it("is not current while the physics page under it is", () => {
state.auth.user = { name: "Ada", email: "ada@example.com", synthetic_fleet: true };
flags.realOnly = false;
const { container } = render(
<MemoryRouter initialEntries={["/sim/physics"]}>
<Sidebar isAdmin={false} collapsed={false} onToggle={() => {}} />
</MemoryRouter>,
);
expect(container.querySelector('a[href="/sim"]')).not.toHaveClass("active");
expect(container.querySelector('a[href="/sim/physics"]')).toHaveClass("active");
for (const signedIn of [true, false]) {
state.auth.user = signedIn ? { name: "Ada", email: "ada@example.com", synthetic_fleet: true } : null;
state.auth.syntheticFleet = true;
flags.realOnly = false;
const { container, unmount } = render(
<MemoryRouter initialEntries={["/sim/physics"]}>
<Sidebar isAdmin={false} collapsed={false} onToggle={() => {}} />
</MemoryRouter>,
);
expect(container.querySelector('a[href="/sim"]')).not.toHaveClass("active");
expect(container.querySelector('a[href="/sim/physics"]')).toHaveClass("active");
unmount();
}
});
});

describe("the /sim/physics address", () => {
function visit(fleet: boolean, at = "/sim/physics") {
state.auth.user = { name: "Ada", email: "ada@example.com", synthetic_fleet: fleet };
function visit(fleet: boolean, at = "/sim/physics", { signedIn = true } = {}) {
state.auth.user = signedIn ? { name: "Ada", email: "ada@example.com", synthetic_fleet: fleet } : null;
state.auth.syntheticFleet = fleet;
window.matchMedia = vi.fn().mockReturnValue({
matches: false,
addEventListener: () => {},
Expand All @@ -126,6 +147,13 @@ describe("the /sim/physics address", () => {
expect(await screen.findByText("physics page")).toBeInTheDocument();
});

// No login card on the way: the route is open like /sim above it.
it("opens the page to a visitor with no session", async () => {
visit(true, "/sim/physics", { signedIn: false });
expect(await screen.findByText("physics page")).toBeInTheDocument();
expect(screen.getByLabelText("location")).toHaveTextContent("/sim/physics");
});

it("is no page at all where it runs none", async () => {
visit(false);
// Give the lazy route a chance to resolve before asserting it never did.
Expand All @@ -149,21 +177,3 @@ describe("the /sim/physics address", () => {
);
});
});

describe("showsPhysics", () => {
it("shows it to a signed-in user on a server with a fleet", () => {
expect(showsPhysics({ synthetic_fleet: true })).toBe(true);
});

it("hides it where the server runs no fleet", () => {
expect(showsPhysics({ synthetic_fleet: false })).toBe(false);
});

it("hides it from a server that does not say", () => {
expect(showsPhysics({})).toBe(false);
});

it("hides it when signed out", () => {
expect(showsPhysics(null)).toBe(false);
});
});
27 changes: 19 additions & 8 deletions dashboard/src/test/publicRoutes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,16 +51,15 @@ describe("the simulation surface", () => {
expect(isPublicRoute("/sim", false)).toBe(true);
});

// The page that writes the fleet's configuration. The PUT behind it is
// admin-only server-side, so this is defence in depth rather than the only
// lock — but a console that renders the form to a visitor and then refuses
// every save is worse than one that never offers it.
it("refuses the physics page nested under it", () => {
expect(isPublicRoute("/sim/physics", false)).toBe(false);
// The page that writes the fleet's configuration. It used to be refused
// here because the PUT behind it was admin-only; the save is open now, so
// the page travels with its parent like every other nested route.
it("admits the physics page nested under it", () => {
expect(isPublicRoute("/sim/physics", false)).toBe(true);
});

it("refuses anything else nested under it", () => {
expect(isPublicRoute("/sim/anything", false)).toBe(false);
it("admits the rest of the subtree, as the other open routes do", () => {
expect(isPublicRoute("/sim/anything", false)).toBe(true);
});

it("still admits it spelled with a trailing slash", () => {
Expand All @@ -83,6 +82,18 @@ describe("what a visitor is pointed at", () => {
expect(labels(true)).toContain("Simulation");
});

// Right after the simulator, as its setting.
it("offers the physics page beside it, and only there", () => {
expect(labels(true).indexOf("Physics Layer")).toBe(labels(true).indexOf("Simulation") + 1);
expect(labels(false)).not.toContain("Physics Layer");
});

// NavLink would otherwise mark /sim current on /sim/physics as well.
it("ends the simulation entry at its own path", () => {
const sim = advertisedPublicRoutes(true).find((r) => r.path === "/sim");
expect(sim?.end).toBe(true);
});

// Advertisement is the only thing the flag moves; the route stays reachable
// by link everywhere, because one bundle serves every environment.
it("does not close the route it declines to advertise", () => {
Expand Down
10 changes: 8 additions & 2 deletions dashboard/src/test/signedOutChrome.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,15 @@ describe("the sidebar shown to a caller with no session", () => {
expect(shownLabels()).toEqual(advertisedPublicRoutes(true).map((r) => r.label));
});

// The page that configures the fleet, whose save is admin-only server-side.
it("never offers the physics page, fleet or no fleet", () => {
// The page that configures the fleet. Its save is open like the rest of
// the simulator, so a visitor is pointed at it wherever there is a fleet.
it("offers the physics page beside the simulation where one is running", () => {
state.auth = { ...state.auth, syntheticFleet: true };
renderSidebar();
expect(screen.getByRole("link", { name: "Physics Layer" })).toHaveAttribute("href", "/sim/physics");
});

it("withholds the physics page with the simulation where there is no fleet", () => {
renderSidebar();
expect(screen.queryByText("Physics Layer")).not.toBeInTheDocument();
});
Expand Down
8 changes: 0 additions & 8 deletions dashboard/src/utils/physics.ts

This file was deleted.

Loading
Loading