diff --git a/backend/routes/admin.py b/backend/routes/admin.py index 70b55915..8fb13166 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -46,7 +46,7 @@ user_to_dict, ) from services import publication -from services.node_refs import id_for_ref, public_identity, public_name +from services.node_refs import id_for_ref, public_identity, public_name, ref_to_id_map logger = logging.getLogger(__name__) @@ -352,6 +352,27 @@ async def admin_clear_node_location_privacy(node_id: str, admin=Depends(require_ } +@router.get("/node-refs") +async def admin_list_node_refs(_admin=Depends(require_admin)): + """Return {node_ref: node_id} for the whole fleet. + + The one route that serves the mapping publication exists to withhold (D16), + which is why it is gated on require_admin rather than on a logged-in caller + the way the leaderboard is. The admin pages are built on the public, + ref-keyed feeds, so this is what lets them name a node to an operator, join + the node_id-keyed admin routes beside it, and link to the node's own site — + which is named after the node_id, not the ref. + + While a deployment sets AUTH_ALLOW_ANONYMOUS_ADMIN with no OAuth configured, + require_admin admits every caller and this mapping is public there. Closing + that door is ClickUp 86cb1emcx; until it closes, treat any environment with + the bypass on as publishing the whole boundary, not just this route. + """ + with state.connected_nodes_lock: + connected = list(state.connected_nodes) + return ref_to_id_map(connected) + + @router.get("/node-contacts") async def admin_list_node_contacts( session: AsyncSession = Depends(get_async_session), diff --git a/backend/services/node_refs.py b/backend/services/node_refs.py index a9e1cdb4..c263a028 100644 --- a/backend/services/node_refs.py +++ b/backend/services/node_refs.py @@ -143,6 +143,27 @@ def id_for_identity(identity: str | None) -> str | None: return id_for_ref(identity) +def ref_to_id_map(node_ids: Iterable[str] = ()) -> dict[str, str]: + """Every published handle and the node behind it: {node_ref: node_id}. + + The inverse of the boundary, so only a caller already entitled to both + identifiers may be handed it — today that is routes/admin.py's `node-refs`, + gated on require_admin. Everything else resolves one node at a time. + + `node_ids` widens the registry with ids the caller holds, resolved through + `owner_identity` so a mirrored node and a synthetic one appear under the + same handle they publish as. A registry row wins over a mirrored ref, as it + does on the way out; a node with no handle at all is absent rather than + named under its own id. + """ + _refresh() + mapping = dict(_reverse) + for node_id in node_ids: + if ref := owner_identity(node_id): + mapping.setdefault(ref, node_id) + return mapping + + def _names_a_node(value: str, known_ids: Iterable[str]) -> bool: """Whether a string is the private id of a node. diff --git a/backend/tests/test_admin_routes.py b/backend/tests/test_admin_routes.py index 45f8bb54..a8261187 100644 --- a/backend/tests/test_admin_routes.py +++ b/backend/tests/test_admin_routes.py @@ -658,3 +658,56 @@ def test_both_routes_are_gated_on_require_admin(self): assert "/api/admin/node-contacts" in gated assert "/api/admin/nodes/{node_id}/contact" in gated + + +class TestNodeRefs: + """The one route that hands back the mapping publication withholds.""" + + @staticmethod + def _register(node_id, node_ref): + import asyncio + + from core.nodes import Node + from core.users import async_session_maker + + async def _go(): + async with async_session_maker() as session: + session.add(Node(node_id=node_id, node_ref=node_ref)) + await session.commit() + + asyncio.run(_go()) + asyncio.set_event_loop(asyncio.new_event_loop()) + + def teardown_method(self): + with state.connected_nodes_lock: + state.connected_nodes.clear() + + def test_it_names_the_node_behind_a_ref(self, client): + self._register("ret1a2b3c4d", "nde1a2b3c4d00") + + body = client.get("/api/admin/node-refs").json() + + assert body["nde1a2b3c4d00"] == "ret1a2b3c4d" + + def test_it_covers_a_connected_node_the_registry_cannot_answer_for(self, client): + """A mirrored node carries its ref in the fleet snapshot, not a row.""" + with state.connected_nodes_lock: + state.connected_nodes["ret0badcafe"] = {"status": "active", "node_ref": "ndemirrored001"} + + body = client.get("/api/admin/node-refs").json() + + assert body["ndemirrored001"] == "ret0badcafe" + + def test_it_is_gated_on_require_admin(self): + """The suite runs with AUTH_ALLOW_ANONYMOUS_ADMIN=1, so the gate is + asserted where it is declared rather than by a refused request.""" + from core.users import require_admin + + gated = { + route.path + for route in app.routes + if getattr(route, "dependant", None) + and any(dep.call is require_admin for dep in route.dependant.dependencies) + } + + assert "/api/admin/node-refs" in gated diff --git a/backend/tests/test_node_refs.py b/backend/tests/test_node_refs.py index 5f27f327..1d645eb6 100644 --- a/backend/tests/test_node_refs.py +++ b/backend/tests/test_node_refs.py @@ -360,3 +360,50 @@ def test_a_mirrored_node_reaches_the_published_feed(self, seed): self._connected(node_ref="ndemirrored001") out = node_refs.substitute_identities({"aircraft": [{"hex": "abc123", "node_id": _MIRRORED}]}) assert out["aircraft"] == [{"hex": "abc123", "node_ref": "ndemirrored001"}] + + +class TestRefToIdMap: + """The inverse of the boundary, for the admin routes allowed to cross it.""" + + def _connected(self, node_id, **entry): + from core import state + + with state.connected_nodes_lock: + state.connected_nodes[node_id] = {"is_synthetic": False, "status": "active", **entry} + + def teardown_method(self): + from core import state + + with state.connected_nodes_lock: + state.connected_nodes.clear() + + def test_it_names_the_node_behind_every_registered_ref(self, seed): + seed(ret1a2b3c4d="nde1a2b3c4d00", ret9f8e7d6c="nde9f8e7d6c00") + assert node_refs.ref_to_id_map() == { + "nde1a2b3c4d00": "ret1a2b3c4d", + "nde9f8e7d6c00": "ret9f8e7d6c", + } + + def test_a_mirrored_node_resolves_through_the_ids_it_is_given(self, seed): + """It has no row here, so only a caller holding the id can place it.""" + seed(ret1a2b3c4d="nde1a2b3c4d00") + self._connected(_MIRRORED, node_ref="ndemirrored001") + assert node_refs.ref_to_id_map([_MIRRORED])["ndemirrored001"] == _MIRRORED + + def test_the_registry_wins_over_a_mirrored_ref(self, seed): + seed(**{_MIRRORED: "ndelocalrow001"}) + self._connected(_MIRRORED, node_ref="ndemirrored001") + mapping = node_refs.ref_to_id_map([_MIRRORED]) + assert mapping["ndelocalrow001"] == _MIRRORED + assert "ndemirrored001" not in mapping + + def test_a_synthetic_node_maps_to_itself(self, seed): + """It publishes under its own id, so that id is both halves.""" + seed(ret1a2b3c4d="nde1a2b3c4d00") + self._connected("synth-node-1", is_synthetic=True) + assert node_refs.ref_to_id_map(["synth-node-1"])["synth-node-1"] == "synth-node-1" + + def test_a_node_with_no_handle_at_all_is_left_out(self, seed): + seed(ret1a2b3c4d="nde1a2b3c4d00") + self._connected(_MIRRORED) + assert node_refs.ref_to_id_map([_MIRRORED]) == {"nde1a2b3c4d00": "ret1a2b3c4d"} diff --git a/dashboard/src/api/client.ts b/dashboard/src/api/client.ts index 96b5719d..dcb333c9 100644 --- a/dashboard/src/api/client.ts +++ b/dashboard/src/api/client.ts @@ -112,6 +112,10 @@ export const api = { adminRevokeInvite: (token) => request(`/api/admin/invites/${encodeURIComponent(token)}`, { method: "DELETE" }), + // Admin: node identity. {node_ref: node_id} for the fleet — the one route + // that crosses the publication boundary, which is why it is admin-only. + adminNodeRefs: () => request("/api/admin/node-refs"), + // Admin: node ownership adminNodeOwners: () => request("/api/admin/node-owners"), adminNodeContacts: () => request("/api/admin/node-contacts"), diff --git a/dashboard/src/components/RetnodeLink.tsx b/dashboard/src/components/RetnodeLink.tsx index f47ca4dd..95295a1f 100644 --- a/dashboard/src/components/RetnodeLink.tsx +++ b/dashboard/src/components/RetnodeLink.tsx @@ -7,7 +7,7 @@ const RETNODE_DOMAIN = "retnode.com"; // link rather than a URL that cannot resolve. const DNS_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i; -export function retnodeUrl(nodeId: string): string | null { +export function retnodeUrl(nodeId: string | null | undefined): string | null { if (!nodeId || !DNS_LABEL.test(nodeId)) return null; return `https://${nodeId.toLowerCase()}.${RETNODE_DOMAIN}`; } @@ -21,7 +21,10 @@ const externalLinkIcon = ( ); type Props = { - nodeId: string; + /** The node's private id, which its site is named after — never the node_ref + * standing in for it, which names nothing. Null where the caller reads a + * ref-keyed feed and could not resolve one; the label then stands alone. */ + nodeId: string | null; /** From the node payload's `is_synthetic`. Simulated and test nodes have no * box behind them, so they get no link; an absent value links, since only a * positive verdict is evidence there is nothing to open. */ @@ -34,7 +37,7 @@ type Props = { * there is nothing to open, so a caller can use it wherever an id appears. * Swallows the click: the Nodes page wraps it in a card that navigates. */ export function RetnodeLink({ nodeId, synthetic, children }: Props) { - const label = children ?? nodeId; + const label = children ?? nodeId ?? ""; const url = synthetic ? null : retnodeUrl(nodeId); if (!url) return <>{label}; return ( diff --git a/dashboard/src/components/useNodeIds.ts b/dashboard/src/components/useNodeIds.ts new file mode 100644 index 00000000..06de2731 --- /dev/null +++ b/dashboard/src/components/useNodeIds.ts @@ -0,0 +1,39 @@ +import { useEffect, useState } from "react"; + +import { api } from "../api/client"; + +/** `{node_ref: node_id}` for the fleet, or `null` until the answer is in. + * + * The admin pages are built on the public feeds, which are keyed on node_ref + * and carry no node_id at all, so this is what puts the private identifier in + * front of an admin: the node's own site is named after it, and the contact + * and location-privacy routes are keyed on it. + * + * Fetched once per mount and soft-failing to an empty map, so a page that + * lists the fleet is not lost with the ids it would have carried. A node that + * registers while the page is open therefore shows no id until it is + * reloaded; polling for that would cost a request per tick on a page that + * already polls, to catch an event that happens a few times a month. + * + * The null is what keeps "not asked yet" apart from "asked, and this ref has + * no id": the same absence, wanting different words. + */ +export function useNodeIds(): Record | null { + const [idsByRef, setIdsByRef] = useState | null>(null); + + useEffect(() => { + let cancelled = false; + api + .adminNodeRefs() + .then((m) => { if (!cancelled) setIdsByRef(m || {}); }) + .catch((e) => { + // Logged before the fallback: an empty map is also what a fleet with no + // registered node looks like, and the two should not read the same. + console.error("node ids unavailable", e); + if (!cancelled) setIdsByRef({}); + }); + return () => { cancelled = true; }; + }, []); + + return idsByRef; +} diff --git a/dashboard/src/pages/admin/CustodyPage.tsx b/dashboard/src/pages/admin/CustodyPage.tsx index 5d3c603c..a4e51cdf 100644 --- a/dashboard/src/pages/admin/CustodyPage.tsx +++ b/dashboard/src/pages/admin/CustodyPage.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from "react"; import { api } from "../../api/client"; +import { useNodeIds } from "../../components/useNodeIds"; const PAGE_SIZE = 25; @@ -8,6 +9,7 @@ export default function CustodyPage() { const [loading, setLoading] = useState(true); const [page, setPage] = useState(0); const [search, setSearch] = useState(""); + const idsByRef = useNodeIds(); useEffect(() => { api.custody() @@ -18,10 +20,13 @@ export default function CustodyPage() { if (loading) return
Loading…
; - const nodeIds = Object.keys(custody?.node_keys || {}); + // The custody payload is published, so it is keyed on node_ref throughout. + const refs = Object.keys(custody?.node_keys || {}); const filtered = search - ? nodeIds.filter((id) => id.toLowerCase().includes(search.toLowerCase())) - : nodeIds; + ? refs.filter((ref) => + [ref, idsByRef?.[ref]].some((s) => (s || "").toLowerCase().includes(search.toLowerCase())), + ) + : refs; const totalPages = Math.ceil(filtered.length / PAGE_SIZE); const paged = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE); @@ -35,12 +40,12 @@ export default function CustodyPage() {
Registered Nodes
-
{custody?.registered_nodes ?? nodeIds.length}
+
{custody?.registered_nodes ?? refs.length}
With Chain Entries
- {nodeIds.filter((id) => (custody?.chain_entries?.[id]?.count || 0) > 0).length} + {refs.filter((ref) => (custody?.chain_entries?.[ref]?.count || 0) > 0).length}
@@ -62,6 +67,7 @@ export default function CustodyPage() { + @@ -72,14 +78,17 @@ export default function CustodyPage() { - {paged.map((nodeId) => { - const chain = custody?.chain_entries?.[nodeId] || {}; + {paged.map((ref) => { + const chain = custody?.chain_entries?.[ref] || {}; const count = chain.count || 0; const verified = chain.latest_verified === true; - const keyInfo = custody?.node_keys?.[nodeId] || {}; + const keyInfo = custody?.node_keys?.[ref] || {}; return ( - - + + + - + diff --git a/dashboard/src/pages/admin/NetworkHealthPage.tsx b/dashboard/src/pages/admin/NetworkHealthPage.tsx index 743264c5..dceedf5b 100644 --- a/dashboard/src/pages/admin/NetworkHealthPage.tsx +++ b/dashboard/src/pages/admin/NetworkHealthPage.tsx @@ -5,6 +5,7 @@ import { import { MapContainer, TileLayer, CircleMarker, Popup } from "react-leaflet"; import { api } from "../../api/client"; import { RetnodeLink } from "../../components/RetnodeLink"; +import { useNodeIds } from "../../components/useNodeIds"; const PAGE_SIZE = 25; @@ -15,6 +16,7 @@ export default function NetworkHealthPage() { const [history, setHistory] = useState([]); const [page, setPage] = useState(0); const [search, setSearch] = useState(""); + const idsByRef = useNodeIds(); const timerRef = useRef>(undefined); const fetchAll = () => { @@ -26,18 +28,17 @@ export default function NetworkHealthPage() { setDashboard(d); const acList = a.aircraft || []; setAircraft(acList); - // api.nodes() returns {nodes: {id: {...}, ...}, total, connected} + // api.nodes() returns {nodes: {node_ref: {...}, ...}, total, connected}, + // and analytics.nodes is {node_ref: {trust, metrics, detection_area, + // reputation, ...}} — both public feeds, both keyed on the published + // identity and carrying no node_id. useNodeIds supplies that. const nodeMap = n.nodes || {}; - // analytics.nodes is {node_id: {trust, metrics, detection_area, reputation, ...}} const analyticsMap = an?.nodes || {}; - const nodeList = Object.entries(nodeMap).map(([id, info]: [string, any]) => { - const stats = analyticsMap[id] || {}; - return { - node_id: id, - ...info, - _analytics: stats, - }; - }); + const nodeList = Object.entries(nodeMap).map(([ref, info]: [string, any]) => ({ + ...info, + node_ref: ref, + _analytics: analyticsMap[ref] || {}, + })); setHistory((prev) => { const next = [ ...prev, @@ -156,11 +157,11 @@ export default function NetworkHealthPage() { url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" /> {geoNodes.map((node) => { - const id = node.node_id; + const ref = node.node_ref; const online = node.status !== "disconnected" && node.status != null; return ( - {node.name || id}
+ {node.name || ref}
+ Ref: {ref}
+ Node ID: {idsByRef?.[ref] ?? "—"}
Status: {online ? "Online" : "Offline"}
{node.frequency ? `Freq: ${(node.frequency / 1e6).toFixed(1)} MHz` : ""}
@@ -204,8 +207,13 @@ export default function NetworkHealthPage() {
{(() => { + // Either identifier finds a node: an operator arrives holding + // whichever one their last conversation used. const filtered = search - ? nodes.filter((n) => (n.node_id || n.name || "").toLowerCase().includes(search.toLowerCase())) + ? nodes.filter((n) => + [n.node_ref, idsByRef?.[n.node_ref], n.name] + .some((s) => (s || "").toLowerCase().includes(search.toLowerCase())), + ) : nodes; const totalPages = Math.ceil(filtered.length / PAGE_SIZE); const paged = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE); @@ -214,6 +222,7 @@ export default function NetworkHealthPage() {
Node ref Node ID Status Chain Length
{nodeId}
{ref} + {idsByRef?.[ref] ?? "—"} + 0 ? "warning" : "offline"}`}> {verified ? "Verified" : count > 0 ? "Unverified" : "None"} @@ -87,7 +96,7 @@ export default function CustodyPage() { {count} {chain.latest_hour || "—"}{custody?.iq_commitments?.[nodeId] || 0}{custody?.iq_commitments?.[ref] || 0} {keyInfo.signing_mode || "—"} {keyInfo.fingerprint || "—"}
+ @@ -226,12 +235,20 @@ export default function NetworkHealthPage() { {paged.map((node) => { - const id = node.node_id || node.id || ""; + const ref = node.node_ref; + // The node's own site is named after the private id, so + // the ref is the label and the id is the destination. + const nodeId = idsByRef?.[ref] ?? null; const online = node.status !== "disconnected" && node.status != null; return ( - + + + )}
Node ref Node ID Status Last Heartbeat
- + + {ref} + + + {nodeId ?? "—"} @@ -250,7 +267,7 @@ export default function NetworkHealthPage() { ); })} {paged.length === 0 && ( -
No nodes found
No nodes found
diff --git a/dashboard/src/pages/admin/NodeManagementPage.tsx b/dashboard/src/pages/admin/NodeManagementPage.tsx index 70554228..882e9806 100644 --- a/dashboard/src/pages/admin/NodeManagementPage.tsx +++ b/dashboard/src/pages/admin/NodeManagementPage.tsx @@ -7,10 +7,15 @@ import { LocationPrivacyControl, } from "../../components/LocationPrivacyControl"; import { RetnodeLink } from "../../components/RetnodeLink"; +import { useNodeIds } from "../../components/useNodeIds"; import type { LocationPrivacyState } from "../../types"; const PAGE_SIZE = 25; +// Both identifiers are opaque strings read character by character when they are +// compared against something else on screen. +const MONO = { fontFamily: "monospace", fontSize: 12 } as const; + // Every field is independently optional server side, so a contact can be a // phone number and nothing else; falling through to it is what keeps such a // node from reading as "nobody reported anything". @@ -27,6 +32,7 @@ export default function NodeManagementPage() { const [loading, setLoading] = useState(true); const [page, setPage] = useState(0); const [search, setSearch] = useState(""); + const idsByRef = useNodeIds(); const navigate = useNavigate(); useEffect(() => { @@ -41,7 +47,12 @@ export default function NodeManagementPage() { Promise.all([api.nodes(), api.analytics(), contactsOrNone]) .then(([n, a, c]) => { const nodeMap = n.nodes || {}; - const nodeList = Object.entries(nodeMap).map(([id, info]: [string, any]) => ({ node_id: id, ...info })); + // Keyed on node_ref: the listing is a public feed and carries no + // node_id. What needs one joins through useNodeIds below. + const nodeList = Object.entries(nodeMap).map(([ref, info]: [string, any]) => ({ + ...info, + node_ref: ref, + })); setNodes(nodeList); setAnalytics(a); setContacts(c); @@ -62,9 +73,12 @@ export default function NodeManagementPage() { Object.entries(rawSummaries).forEach(([ref, s]) => { summaryMap[ref] = s; }); } - const filtered = search - ? nodes.filter((n) => ((n.node_id || n.id || n.name || "")).toLowerCase().includes(search.toLowerCase())) - : nodes; + // Either identifier finds a node: an operator arrives holding whichever one + // their last conversation used. + const matches = (n) => + [n.node_ref, idsByRef?.[n.node_ref], n.name] + .some((s) => (s || "").toLowerCase().includes(search.toLowerCase())); + const filtered = search ? nodes.filter(matches) : nodes; const totalPages = Math.ceil(filtered.length / PAGE_SIZE); const paged = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE); @@ -109,27 +123,38 @@ export default function NodeManagementPage() {
{paged.map((node) => { - const id = node.node_id || node.id; + const ref = node.node_ref; + // The private id, or null while the map is in flight and for a ref + // that resolves to nothing. The node's own site, its contact row and + // its privacy override are all named after it; nothing here may fall + // back to the ref, which names none of them. + const nodeId = idsByRef?.[ref] ?? null; const online = node.status !== "disconnected" && node.status != null; - const summary = summaryMap[id] || {}; - const contact = contacts[id]; + const summary = summaryMap[ref] || {}; + const contact = nodeId ? contacts[nodeId] : undefined; const contactText = contactLabel(contact); // The label is the name when there is one, so the address is worth a // tooltip only then; otherwise it is already what the cell shows. const named = Boolean(contact?.first_name || contact?.last_name); const contactTitle = named ? contact.email || undefined : undefined; return ( -
navigate(`/nodes/${id}`)}> + // The node page is addressed by the public identity, since the + // per-node analytics route behind it is. +
navigate(`/nodes/${ref}`)}>
{online ? "Online" : "Offline"} - - {node.name || id} + + {node.name || ref}
+ Node ref + {ref} + Node ID + {nodeId ?? "—"} Frequency {node.frequency ? `${(node.frequency / 1e6).toFixed(2)} MHz` : "—"} Detections @@ -147,7 +172,7 @@ export default function NodeManagementPage() { Contact {contactText}
- +
); })} @@ -167,12 +192,18 @@ export default function NodeManagementPage() { /** Per-node location privacy for the admin list. The admin API answers one * node at a time, so each card asks for its own — which keeps the requests to * the page of cards actually on screen instead of the whole fleet. Clicks are - * stopped here: the card around it navigates to the node page. */ -function NodeLocationPrivacy({ nodeId }: { nodeId: string }) { + * stopped here: the card around it navigates to the node page. + * + * Addressed by node_id, which is the key the override is stored under. The + * route accepts any string, so a ref passed here would be written happily and + * then never consulted — hence `unresolved`, which says the id is missing + * rather than late and leaves the control unrendered. */ +function NodeLocationPrivacy({ nodeId, unresolved }: { nodeId: string | null; unresolved: boolean }) { const [state, setState] = useState(null); const [failed, setFailed] = useState(false); useEffect(() => { + if (!nodeId) return; let cancelled = false; api .adminNodeLocationPrivacy(nodeId) @@ -192,9 +223,10 @@ function NodeLocationPrivacy({ nodeId }: { nodeId: string }) {
- {failed &&
Could not load location privacy.
} - {!failed && !state &&
Loading…
} - {state && ( + {unresolved &&
No node id for this ref; the override is keyed on one.
} + {!unresolved && failed &&
Could not load location privacy.
} + {!unresolved && !failed && !state &&
Loading…
} + {state && nodeId && ( ({ nodes: vi.fn(), analytics: vi.fn(), adminNodeContacts: vi.fn(), + adminNodeRefs: vi.fn(), adminNodeLocationPrivacy: vi.fn().mockResolvedValue({ location_private: false, location_privacy_source: "default" }), setAdminNodeLocationPrivacy: vi.fn(), clearAdminNodeLocationPrivacy: vi.fn(), @@ -47,12 +48,17 @@ describe("contactLabel", () => { }); }); +// The node listing is a public feed: keyed on node_ref, carrying no node_id. +// The contacts it is joined against are keyed on node_id, so the two only meet +// through the admin ref map. const NODES = { nodes: { - ret1a2b3c4d: { name: "Ada's Node", status: "online" }, - ret5e6f7g8h: { name: "No Contact Node", status: "online" }, + nde1a2b3c4d00: { name: "Ada's Node", status: "online" }, + nde9f8e7d6c00: { name: "No Contact Node", status: "online" }, }, }; +const NODE_IDS = { nde1a2b3c4d00: "ret1a2b3c4d", nde9f8e7d6c00: "ret9f8e7d6c" }; +const ADA_CONTACT = { ret1a2b3c4d: { first_name: "Ada", last_name: "Lovelace", email: "ada@example.com" } }; /** The Contact cell of one card, found through its own label. * @@ -73,12 +79,14 @@ function renderPage() { } describe("NodeManagementPage contact rendering", () => { - it("shows the contact label on a node with a contact on file", async () => { + beforeEach(() => { (api.nodes as any).mockResolvedValue(NODES); (api.analytics as any).mockResolvedValue({ nodes: {} }); - (api.adminNodeContacts as any).mockResolvedValue({ - ret1a2b3c4d: { first_name: "Ada", last_name: "Lovelace", email: "ada@example.com" }, - }); + (api.adminNodeRefs as any).mockResolvedValue(NODE_IDS); + }); + + it("shows the contact label on a node with a contact on file", async () => { + (api.adminNodeContacts as any).mockResolvedValue(ADA_CONTACT); renderPage(); @@ -86,11 +94,7 @@ describe("NodeManagementPage contact rendering", () => { }); it("shows a dash for a node with no contact on file", async () => { - (api.nodes as any).mockResolvedValue(NODES); - (api.analytics as any).mockResolvedValue({ nodes: {} }); - (api.adminNodeContacts as any).mockResolvedValue({ - ret1a2b3c4d: { first_name: "Ada", last_name: "Lovelace", email: "ada@example.com" }, - }); + (api.adminNodeContacts as any).mockResolvedValue(ADA_CONTACT); renderPage(); @@ -100,8 +104,6 @@ describe("NodeManagementPage contact rendering", () => { }); it("still renders the node cards when the contacts fetch fails", async () => { - (api.nodes as any).mockResolvedValue(NODES); - (api.analytics as any).mockResolvedValue({ nodes: {} }); (api.adminNodeContacts as any).mockRejectedValue(new Error("501: no such route yet")); renderPage(); diff --git a/dashboard/src/test/NodeIdentity.test.tsx b/dashboard/src/test/NodeIdentity.test.tsx new file mode 100644 index 00000000..b33161fa --- /dev/null +++ b/dashboard/src/test/NodeIdentity.test.tsx @@ -0,0 +1,139 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; + +import { api } from "../api/client"; +import NodeManagementPage from "../pages/admin/NodeManagementPage"; +import NetworkHealthPage from "../pages/admin/NetworkHealthPage"; +import CustodyPage from "../pages/admin/CustodyPage"; + +// Every api method either page reaches for: an unmocked one throws out of the +// effect and takes the render with it. +vi.mock("../api/client", () => ({ + api: { + nodes: vi.fn(), + analytics: vi.fn(), + aircraft: vi.fn().mockResolvedValue({ aircraft: [] }), + fleetDashboard: vi.fn().mockResolvedValue({}), + adminNodeContacts: vi.fn().mockResolvedValue({}), + adminNodeRefs: vi.fn(), + custody: vi.fn(), + adminNodeLocationPrivacy: vi.fn().mockResolvedValue({ + location_private: false, + location_privacy_source: "default", + }), + setAdminNodeLocationPrivacy: vi.fn(), + clearAdminNodeLocationPrivacy: vi.fn(), + }, +})); + +// The public feeds key on node_ref and carry no node_id, which is the whole +// point of the boundary — so that is how the fixtures are shaped. +const REF = "nde1a2b3c4d00"; +const NODE_ID = "ret1a2b3c4d"; +const NODES = { nodes: { [REF]: { name: "Ada's Node", status: "online", node_ref: REF } } }; + +function renderPage(Page) { + return render( + + + , + ); +} + +function setup({ refs, contacts }: { refs?: Record; contacts?: Record } = {}) { + refs = refs ?? { [REF]: NODE_ID }; + contacts = contacts ?? {}; + (api.nodes as any).mockResolvedValue(NODES); + (api.analytics as any).mockResolvedValue({ nodes: {} }); + (api.adminNodeRefs as any).mockResolvedValue(refs); + (api.adminNodeContacts as any).mockResolvedValue(contacts); +} + +describe("Node Management: both identifiers", () => { + it("shows the node_ref and the node_id on the card", async () => { + setup(); + + renderPage(NodeManagementPage); + + const card = (await screen.findByText("Ada's Node")).closest(".node-card") as HTMLElement; + await waitFor(() => expect(within(card).getByText("Node ID").nextElementSibling).toHaveTextContent(NODE_ID)); + expect(within(card).getByText("Node ref").nextElementSibling).toHaveTextContent(REF); + }); + + it("links the node to the site its node_id names, not its ref", async () => { + setup(); + + renderPage(NodeManagementPage); + + const link = await screen.findByRole("link", { name: /Ada's Node/ }); + expect(link).toHaveAttribute("href", `https://${NODE_ID}.retnode.com`); + }); + + it("offers no link for a ref it cannot resolve to a node id", async () => { + setup({ refs: {} }); + + renderPage(NodeManagementPage); + + expect(await screen.findByText("Ada's Node")).toBeInTheDocument(); + await waitFor(() => expect(screen.queryByRole("link", { name: /Ada's Node/ })).toBeNull()); + }); + + it("joins the contact on the node_id the contacts route is keyed on", async () => { + setup({ contacts: { [NODE_ID]: { first_name: "Ada", last_name: "Lovelace" } } }); + + renderPage(NodeManagementPage); + + expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument(); + }); + + it("asks for location privacy by node_id, which the override is keyed on", async () => { + setup(); + + renderPage(NodeManagementPage); + + await waitFor(() => expect(api.adminNodeLocationPrivacy).toHaveBeenCalledWith(NODE_ID)); + }); + + it("does not ask for location privacy under a ref", async () => { + setup({ refs: {} }); + + renderPage(NodeManagementPage); + + expect(await screen.findByText("Ada's Node")).toBeInTheDocument(); + await waitFor(() => expect(screen.getByText(/no node id/i)).toBeInTheDocument()); + expect(api.adminNodeLocationPrivacy).not.toHaveBeenCalledWith(REF); + }); +}); + +describe("Network Health: both identifiers", () => { + it("shows the ref and the node id in their own columns", async () => { + setup(); + + renderPage(NetworkHealthPage); + + const row = (await screen.findByText(REF)).closest("tr") as HTMLElement; + await waitFor(() => expect(within(row).getByText(NODE_ID)).toBeInTheDocument()); + }); + + it("links the ref to the site the node_id names", async () => { + setup(); + + renderPage(NetworkHealthPage); + + const link = await screen.findByRole("link", { name: new RegExp(REF) }); + expect(link).toHaveAttribute("href", `https://${NODE_ID}.retnode.com`); + }); +}); + +describe("Chain of custody: both identifiers", () => { + it("names the node behind the ref the chain is published under", async () => { + setup(); + (api.custody as any).mockResolvedValue({ node_keys: { [REF]: { signing_mode: "hardware" } } }); + + renderPage(CustodyPage); + + const row = (await screen.findByText(REF)).closest("tr") as HTMLElement; + await waitFor(() => expect(within(row).getByText(NODE_ID)).toBeInTheDocument()); + }); +}); diff --git a/docs/architecture.md b/docs/architecture.md index 4a97d6a3..a97d6741 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -64,11 +64,14 @@ are in [`arc-display.md`](arc-display.md). geometry (`beam_azimuth_deg`, `beam_width_deg`, `max_range_km`, `max_bistatic_range_km`) flows from node registration into the per-node pipelines, the arc builder, and inter-node association — one contract. -- **`services/node_ref.py`** — the public handle for a node. Every payload a +- **`services/node_refs.py`** — the public handle for a node. Every payload a stranger can fetch names a node by `node_ref`, never by `node_id`: the registry's ref when the node registered through `/v1/nodes`, an HMAC-derived ref of the same shape (fuzz salt, `node_ref|` domain) when it - did not. See [`pipeline.md`](pipeline.md) §7. + did not. See [`pipeline.md`](pipeline.md) §7. The inverse, `ref_to_id_map`, + is served from one admin-only route (`GET /api/admin/node-refs`) and exists + so the dashboard can name a node to an operator and link to its own site, + which is `.retnode.com`. - **`services/tasks/`** — background async tasks: `aircraft_flush` (broadcast), `feed_gc` (stale-store GC on its own 5 s timer, deliberately not tied to the feed build), `solver` workers, `analytics_refresh`, archive lifecycle,