package main // The local UI's front door, driven end to end. // // Not tagged !windows, for the same reason uiauth_test.go is not: Windows is the // platform this UI exists for, so the flow has to be proven there. // // The handler is driven directly rather than through httptest.NewServer, because // the thing under test includes the Host header, and a real client would make // that agree with the listener by construction — which is precisely the check // that must NOT pass by construction. import ( "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" ) const uiTestPort = "7877" func uiTestServer(t *testing.T) *uiServer { t.Helper() srv, err := newUIServer(uiTestPort, "test-secret-aaaaaaaaaaaaaaaaaaaaaaaaaaaa") if err != nil { t.Fatalf("newUIServer: %v — a template that does not parse takes the whole UI down", err) } return srv } // uiTestPairing writes one pairing into an isolated config dir, so the page has // something real to render. func uiTestPairing(t *testing.T, host string) { t.Helper() uiTestConfigDir(t) d, err := pairingsDir() if err != nil { t.Fatal(err) } if err := os.MkdirAll(d, 0o700); err != nil { t.Fatal(err) } b, _ := json.Marshal(&Config{ HostName: host, ListenAddr: "127.0.0.1:59999", // nothing listens: "not answering" BastionTunnelIP: "fd00:9::a:1", State: pairingActive, FirstSeen: "2026-09-01T00:00:00Z", }) if err := os.WriteFile(filepath.Join(d, host+".json"), b, 0o600); err != nil { t.Fatal(err) } } func uiReq(method, target string) *http.Request { r := httptest.NewRequest(method, target, nil) r.Host = "127.0.0.1:" + uiTestPort return r } // ── the whole flow, in order ──────────────────────────────────────────────── func TestTheFrontDoorFlow(t *testing.T) { uiTestPairing(t, "member.host.privatae.ai") u := uiTestServer(t) h := u.handler() // 1. Nothing is visible without a session. w := httptest.NewRecorder() h.ServeHTTP(w, uiReq(http.MethodGet, "/")) if w.Code != http.StatusUnauthorized { t.Fatalf("/ without a session = %d, want 401 — the page lists every host "+ "this machine serves", w.Code) } // 2. The CLI trades the secret for a ticket. r := uiReq(http.MethodPost, "/auth/ticket") r.Header.Set("Authorization", "Bearer "+u.secret) w = httptest.NewRecorder() h.ServeHTTP(w, r) if w.Code != http.StatusOK { t.Fatalf("/auth/ticket = %d, want 200: %s", w.Code, w.Body.String()) } var out struct { Ticket string `json:"ticket"` } if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil || out.Ticket == "" { t.Fatalf("no ticket in %s", w.Body.String()) } // 3. The browser opens ?k= and is given a session. w = httptest.NewRecorder() h.ServeHTTP(w, uiReq(http.MethodGet, "/?k="+out.Ticket)) if w.Code != http.StatusSeeOther { t.Fatalf("?k= = %d, want 303: %s", w.Code, w.Body.String()) } if loc := w.Header().Get("Location"); loc != "/" { t.Fatalf("redirect to %q — the ticket must be stripped out of the address bar", loc) } cookie := w.Header().Get("Set-Cookie") if !strings.Contains(cookie, uiCookieName+"=") { t.Fatalf("no session cookie was set: %q", cookie) } // 4. The page renders, and renders THIS machine. r = uiReq(http.MethodGet, "/") r.Header.Set("Cookie", cookie) w = httptest.NewRecorder() h.ServeHTTP(w, r) if w.Code != http.StatusOK { t.Fatalf("/ with a session = %d: %s", w.Code, w.Body.String()) } body := w.Body.String() for _, want := range []string{ "member.host.privatae.ai", // the pairing "not answering", // its liveness, probed not assumed "NOT RUNNING", // the headline `hx-get="/fragments/status"`, "/assets/htmx.min.js", } { if !strings.Contains(body, want) { t.Errorf("the page does not contain %q", want) } } // 5. The htmx poll target renders the same fragment, and only the fragment. r = uiReq(http.MethodGet, "/fragments/status") r.Header.Set("Cookie", cookie) w = httptest.NewRecorder() h.ServeHTTP(w, r) if w.Code != http.StatusOK { t.Fatalf("/fragments/status = %d: %s", w.Code, w.Body.String()) } frag := w.Body.String() if !strings.Contains(frag, "member.host.privatae.ai") { t.Error("the fragment does not carry the pairing") } if strings.Contains(frag, ". Effect was small and nothing links to it; // the name for it is well known enough to be worth one line. func TestSignOutIsPostOnly(t *testing.T) { uiTestConfigDir(t) u := uiTestServer(t) h := u.handler() tok, _ := u.sessions.mint() cookie := uiCookieName + "=" + tok r := uiReq(http.MethodGet, "/signout") r.Header.Set("Cookie", cookie) w := httptest.NewRecorder() h.ServeHTTP(w, r) if w.Code != http.StatusMethodNotAllowed { t.Errorf("GET /signout = %d, want 405", w.Code) } if !u.sessions.valid(tok) { t.Fatal("a GET signed the session out — that is the CSRF logout") } // Cross-site POST: the Origin check is what it now takes. r = httptest.NewRequest(http.MethodPost, "/signout", nil) r.Host = "127.0.0.1:" + uiTestPort r.Header.Set("Origin", "https://evil.example") r.Header.Set("Cookie", cookie) w = httptest.NewRecorder() h.ServeHTTP(w, r) if w.Code != http.StatusForbidden { t.Errorf("cross-site POST /signout = %d, want 403", w.Code) } if !u.sessions.valid(tok) { t.Fatal("a cross-site POST signed the session out") } // Our own page still works. r = httptest.NewRequest(http.MethodPost, "/signout", nil) r.Host = "127.0.0.1:" + uiTestPort r.Header.Set("Origin", "http://127.0.0.1:"+uiTestPort) r.Header.Set("Cookie", cookie) w = httptest.NewRecorder() h.ServeHTTP(w, r) if w.Code != http.StatusSeeOther { t.Fatalf("same-origin POST /signout = %d, want 303", w.Code) } if u.sessions.valid(tok) { t.Fatal("sign out did not drop the session") } } // ── rebinding, at the server rather than the predicate ────────────────────── func TestEveryRouteRefusesARebindingHost(t *testing.T) { uiTestConfigDir(t) u := uiTestServer(t) h := u.handler() // A valid session, so the ONLY thing standing between the page and the // caller is the Host check. tok, _ := u.sessions.mint() for _, path := range []string{"/", "/fragments/status", "/assets/app.css", "/assets/htmx.min.js", "/auth/ticket", "/signout", // What this machine has given away (uigiven.go): the pairing list, the // caps each host holds and the paths of the folders shared from here. "/given", "/fragments/given", "/assets/given.css", // The approvals popup (uidevice.go): what the hosts are waiting on, and // the three routes that sign or refuse with the device key. "/approvals", "/fragments/approvals", "/fragments/approvals-news", "/device/approve", "/device/deny", "/device/approve-shown", // Phase 3 (uisetup.go). Every one of them goes through u.guarded, which // runs uiRequestAllowed before it looks at a cookie or a method — the // ordering signOut got wrong. "/setup", "/setup/pair", "/setup/stepup/start", "/setup/stepup/qr", "/setup/stepup/key", "/setup/stepup/confirm", "/setup/folder/check", "/setup/folder/propose", "/setup/anchor/choose", "/setup/owner/name", "/assets/setup.css", // The disk map (uidiskmap.go). Its page carries the name of every // large file on this machine, which is the most detailed thing about a // person that anything in this binary renders. "/diskmap", "/fragments/diskmap", "/diskmap/scan", "/diskmap/stop", "/assets/diskmap.css", // The automated programs seen here and the marks on them (uisanction.go). // The two mutating routes are in this list for the same reason the rest // are: guarded runs uiRequestAllowed before it looks at a cookie or a // method, so a rebound host is refused before the route admits it exists. "/agents", "/fragments/agents", "/agents/mark", "/agents/unmark", "/assets/agents.css", // The dry-run policy (uipolicy.go). Both routes are reads, and a read // is exactly what a rebinding page would want here: this one names // every automated program on the machine and adds the machine's own // opinion of which it would stop. "/policy", "/fragments/policy", "/assets/policy.css"} { r := httptest.NewRequest(http.MethodGet, path, nil) r.Host = "evil.example:" + uiTestPort // resolves to 127.0.0.1, is not 127.0.0.1 r.Header.Set("Cookie", uiCookieName+"="+tok) r.Header.Set("Authorization", "Bearer "+u.secret) w := httptest.NewRecorder() h.ServeHTTP(w, r) if w.Code != http.StatusForbidden { t.Errorf("%s from a rebound host = %d, want 403 (body %q)", path, w.Code, strings.TrimSpace(w.Body.String())) } } } // ── assets ────────────────────────────────────────────────────────────────── func TestAssetsAreServedFromTheBinaryAndNothingElseIs(t *testing.T) { uiTestConfigDir(t) h := uiTestServer(t).handler() for path, sniff := range map[string]string{ "/assets/htmx.min.js": "htmx", "/assets/app.css": "--bg", } { w := httptest.NewRecorder() h.ServeHTTP(w, uiReq(http.MethodGet, path)) if w.Code != http.StatusOK { t.Fatalf("%s = %d", path, w.Code) } if !strings.Contains(w.Body.String(), sniff) { t.Errorf("%s does not look like itself (no %q) — the embed may be empty", path, sniff) } if w.Header().Get("X-Content-Type-Options") != "nosniff" { t.Errorf("%s is served without nosniff", path) } } // The asset route reads from an embedded FS by name, so it must serve only // the two names it knows. for _, path := range []string{ "/assets/page.html", // a template, not an asset "/assets/../ui.go", // traversal "/assets/ui.secret", // the credential, if the root were ever the config dir "/assets/", // the directory } { w := httptest.NewRecorder() h.ServeHTTP(w, uiReq(http.MethodGet, path)) if w.Code == http.StatusOK { t.Errorf("%s was served (%d) — the asset route must be an allow-list", path, w.Code) } } } // ── the unauthenticated surface ───────────────────────────────────────────── func TestNothingAboutTheMachineLeaksWithoutASession(t *testing.T) { uiTestPairing(t, "secret-host.privatae.ai") h := uiTestServer(t).handler() // The given and setup pages both list every pairing, and the setup routes // answer questions about this machine's folders and its anchor. None of them // may say a word before a session exists. for _, path := range []string{"/", "/fragments/status", "/given", "/fragments/given", // The approvals popup names the machines your hosts want to act on and // what they want to do there. "/approvals", "/fragments/approvals", "/fragments/approvals-news", "/device/approve", "/device/deny", "/device/approve-shown", "/setup", "/setup/pair", "/setup/stepup/start", "/setup/stepup/qr", "/setup/stepup/key", "/setup/stepup/confirm", "/setup/folder/check", "/setup/folder/propose", "/setup/anchor/choose", "/setup/owner/name", // The disk map is the sharpest case on this list. A pairing name says // which host; a file listing says what a person works on, banks with // and is ill with, by name, and the map's own page prints the paths it // could not read as well as the ones it could. "/diskmap", "/fragments/diskmap", "/diskmap/scan", "/diskmap/stop", // The agents page names every automated program running here, its user // and its command line — as much about this machine as the pairing list. "/agents", "/fragments/agents", "/agents/mark", "/agents/unmark", // The policy page names the same programs and adds this machine's own // opinion of which of them it would stop. "/policy", "/fragments/policy"} { w := httptest.NewRecorder() h.ServeHTTP(w, uiReq(http.MethodGet, path)) if w.Code != http.StatusUnauthorized { t.Errorf("%s = %d, want 401", path, w.Code) } if strings.Contains(w.Body.String(), "secret-host.privatae.ai") { t.Errorf("%s leaked a host name in its REFUSAL: %q", path, w.Body.String()) } } }