Files
meshcore-analyzer/cmd/server/short_url_test.go
T
Kpa-clawbot df69a17718 feat(#772): short pubkey-prefix URLs for mesh sharing (#1016)
## Summary

Fixes #772 — adds a short-URL form for node detail pages so operators
can paste node links into a mesh chat without bringing along a
64-hex-char public key.

## Approach

**Pubkey-prefix resolution** (no allocator, no lookup table).

- The SPA hash route `#/nodes/<key>` already accepts whatever
pubkey-shaped string the user pastes; the front end forwards it to `GET
/api/nodes/<key>`.
- When that lookup misses **and** the path is 8..63 hex chars, the
backend now calls `DB.GetNodeByPrefix` and:
  - returns the matching node when exactly one node has that prefix,
- returns **409 Conflict** when multiple nodes share the prefix (with a
"use a longer prefix" hint),
  - falls through to the existing 404 otherwise.
- 8 hex chars = 32 bits of entropy, which is enough for fleets in the
low thousands. Operators can extend to 10–12 chars if collisions become
common.
- The full-screen node detail card gets a new **📡 Copy short URL**
button that copies `…/#/nodes/<first 8 hex chars>`.

### Why not an opaque ID table (`/s/<id>`)?

Considered and rejected:

- Needs persistence + an allocator + cleanup story.
- IDs aren't self-describing — operators can't sanity-check them.
- IDs don't survive a DB rebuild.
- 32 bits of pubkey already buys us collision resistance with zero
moving parts.

If the directory grows past the point where 8-char prefixes routinely
collide, we can extend the minimum length without changing the URL
shape.

## Changes

- `cmd/server/db.go` — new `GetNodeByPrefix(prefix)` returning `(node,
ambiguous, error)`. Validates hex; rejects <8 chars; `LIMIT 2` to detect
collisions cheaply.
- `cmd/server/routes.go` — `handleNodeDetail` falls back to prefix
resolution; canonicalizes pubkey downstream; emits 409 on ambiguity;
honors blacklist on the resolved pubkey.
- `public/nodes.js` — adds **📡 Copy short URL** button + handler on the
full-screen node detail card.
- `cmd/server/short_url_test.go` — Go tests (red-then-green).
- `test-e2e-playwright.js` — E2E: navigates via prefix-only URL and
asserts the new button surfaces.

## TDD evidence

- Red commit: `2dea97a` — tests added with a stub `GetNodeByPrefix`
returning `(nil, false, nil)`. All four assertions failed (assertion
failures, not build errors): expected node got nil; expected
ambiguous=true got false; route 404 vs expected 200/409.
- Green commit: `9b8f146` — implementation lands; `go test ./...` passes
locally in `cmd/server`.

## Compatibility

- Existing 64-char pubkey URLs are untouched (exact lookup runs first).
- Blacklist is enforced both on the raw input and on the resolved
pubkey.
- No new config knobs.

## What I did **not** touch

- `cmd/server/db_test.go`, other route tests — unchanged.
- Packet-detail short URLs (issue scopes nodes; revisit in a follow-up
if asked).

Fixes #772

---------

Co-authored-by: clawbot <bot@corescope.local>
2026-05-03 17:40:54 -07:00

110 lines
3.3 KiB
Go

package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// Issue #772 — shortened URL for easier sending over the mesh.
//
// Public keys are 64 hex chars. Operators want to share node URLs over a
// mesh radio link where every byte counts. We allow truncating the pubkey
// in the URL down to a minimum 8-hex-char prefix; the server resolves the
// prefix back to the full pubkey when (and only when) it is unambiguous.
func TestResolveNodePrefix_Unique(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
// "aabbccdd" uniquely identifies the seeded TestRepeater (pubkey aabbccdd11223344).
node, ambiguous, err := db.GetNodeByPrefix("aabbccdd")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if ambiguous {
t.Fatalf("expected unambiguous match, got ambiguous=true")
}
if node == nil {
t.Fatalf("expected node, got nil")
}
if got, _ := node["public_key"].(string); got != "aabbccdd11223344" {
t.Errorf("expected public_key aabbccdd11223344, got %q", got)
}
}
func TestResolveNodePrefix_Ambiguous(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
// Insert a second node sharing the 8-char prefix "aabbccdd".
if _, err := db.conn.Exec(`INSERT INTO nodes (public_key, name, role, advert_count)
VALUES ('aabbccdd99887766', 'OtherNode', 'companion', 1)`); err != nil {
t.Fatal(err)
}
node, ambiguous, err := db.GetNodeByPrefix("aabbccdd")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if !ambiguous {
t.Fatalf("expected ambiguous=true for shared prefix, got false (node=%v)", node)
}
if node != nil {
t.Errorf("expected nil node when ambiguous, got %v", node["public_key"])
}
}
func TestResolveNodePrefix_TooShort(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
// <8 hex chars must NOT resolve, even if it would be unique.
node, _, err := db.GetNodeByPrefix("aabbccd")
if err == nil && node != nil {
t.Errorf("expected nil/error for 7-char prefix, got node %v", node["public_key"])
}
}
// Route-level: GET /api/nodes/<8-char-prefix> resolves to the full node.
func TestNodeDetailRoute_PrefixResolves(t *testing.T) {
_, router := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/nodes/aabbccdd", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for unique 8-char prefix, got %d body=%s", w.Code, w.Body.String())
}
var body NodeDetailResponse
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal: %v", err)
}
pk, _ := body.Node["public_key"].(string)
if pk != "aabbccdd11223344" {
t.Errorf("expected resolved pubkey aabbccdd11223344, got %q", pk)
}
}
// Route-level: GET /api/nodes/<ambiguous-prefix> returns 409 with a hint.
func TestNodeDetailRoute_PrefixAmbiguous(t *testing.T) {
srv, router := setupTestServer(t)
if _, err := srv.db.conn.Exec(`INSERT INTO nodes (public_key, name, role, advert_count)
VALUES ('aabbccdd99887766', 'OtherNode', 'companion', 1)`); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest("GET", "/api/nodes/aabbccdd", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusConflict {
t.Fatalf("expected 409 for ambiguous prefix, got %d body=%s", w.Code, w.Body.String())
}
}