package main
// The SET IT UP page, driven through the real handler.
//
// Not tagged !windows, for the reason ui_test.go gives: Windows is the platform
// this UI exists for. The two platform-shaped things here — the claim-anchor
// line and the claim-owner line — take the platform as an argument so a Linux
// builder can check the Windows string, which is the hole restartHintFor was
// rewritten to close (privilege.go:88).
import (
"encoding/json"
"html"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"rsc.io/qr"
)
// ── fixtures ────────────────────────────────────────────────────────────────
// uiSetupPairingInState starts a fresh isolated config dir holding ONE pairing
// in a named state, and returns the DCN dir it points at.
//
// Split from uiSetupAddPairing because uiTestConfigDir points XDG_CONFIG_HOME at
// a NEW t.TempDir() on every call (uiauth_test.go:18) — so calling it twice does
// not give a machine with two pairings, it gives two machines with one each, and
// a test written that way silently checks the single-host path.
func uiSetupPairingInState(t *testing.T, host, state string) string {
t.Helper()
uiTestConfigDir(t)
return uiSetupAddPairing(t, host, state)
}
// uiSetupAddPairing adds a pairing to the config dir already in force.
func uiSetupAddPairing(t *testing.T, host, state string) string {
t.Helper()
pd, err := pairingsDir()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(pd, 0o700); err != nil {
t.Fatal(err)
}
dcn := filepath.Join(t.TempDir(), "dcn")
if err := os.MkdirAll(dcn, 0o700); err != nil {
t.Fatal(err)
}
b, _ := json.Marshal(&Config{
HostName: host,
ListenAddr: "127.0.0.1:59999", // nothing answers there
State: state,
DCNDir: dcn,
FirstSeen: "2026-09-01T00:00:00Z",
})
if err := os.WriteFile(filepath.Join(pd, host+".json"), b, 0o600); err != nil {
t.Fatal(err)
}
return dcn
}
func uiSetupPairingWithDCN(t *testing.T, host string) string {
t.Helper()
return uiSetupPairingInState(t, host, pairingActive)
}
// uiSetupNetworked writes mode.json DIRECTLY, the way repinBox does
// (anchor_test.go:1006) — setBoxMode needs machine authority, and this is
// setting up state, not driving the gate.
func uiSetupNetworked(t *testing.T) error {
t.Helper()
p, err := modePath()
if err != nil {
return err
}
if err := os.WriteFile(p, []byte(`{"mode":"networked"}`), 0o600); err != nil {
return err
}
if boxMode() != modeNetworked {
t.Fatalf("box must read networked, got %s", boxMode())
}
return nil
}
func uiSetupAnchorPath(t *testing.T) string {
t.Helper()
p, err := anchorPath()
if err != nil {
t.Fatal(err)
}
return p
}
func runtimeGOOSForTest() string { return runtime.GOOS }
// uiSetupNoMachineMarker points the machine marker at a path that does not
// exist, so the singleton guard reads "absent ⇒ today's behaviour"
// (singleton.go:362) instead of the real marker this builder carries.
//
// Not singleton_test.go's withMarkerAt, which is identical and behind
// `//go:build !windows` — and this file has to compile for Windows, because
// Windows is the platform the whole UI exists for.
func uiSetupNoMachineMarker(t *testing.T) {
t.Helper()
old := markerPathOverride
markerPathOverride = filepath.Join(t.TempDir(), "no-such-marker.json")
t.Cleanup(func() { markerPathOverride = old })
}
// qrTerminalPNGForTest is what the QR route must produce for a given payload.
func qrTerminalPNGForTest(uri string) ([]byte, error) {
c, err := qr.Encode(uri, qr.M)
if err != nil {
return nil, err
}
return c.PNG(), nil
}
// uiSetupTestBox gives the setup page a machine to render: an isolated config
// dir and its own mux, so the test can reach the enrolments held in memory.
func uiSetupTestBox(t *testing.T) (*uiServer, *uiSetup, http.Handler, string) {
t.Helper()
u := uiTestServer(t)
mux := http.NewServeMux()
s := u.setupRoutes(mux)
return u, s, mux, uiSignedIn(t, u)
}
func uiSetupGet(t *testing.T, h http.Handler, path, cookie string) *httptest.ResponseRecorder {
t.Helper()
r := uiReq(http.MethodGet, path)
r.Header.Set("Cookie", cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
// ── the page ────────────────────────────────────────────────────────────────
func TestTheSetupPageRendersEveryPanelItPromises(t *testing.T) {
uiTestPairing(t, "member.host.privatae.ai")
_, _, h, cookie := uiSetupTestBox(t)
w := uiSetupGet(t, h, "/setup", cookie)
if w.Code != http.StatusOK {
t.Fatalf("/setup = %d: %s", w.Code, w.Body.String())
}
body := w.Body.String()
for _, want := range []string{
"member.host.privatae.ai", // the machine it is describing
`hx-post="/setup/pair"`, // 1. pair
`hx-post="/setup/stepup/start"`, // 2. a second factor
`hx-post="/setup/folder/check"`, // 3. the live path check
`hx-post="/setup/anchor/choose"`, // 4. the anchor picker
`hx-post="/setup/owner/name"`, // 5. the owner name
`name="host" value="member.host.privatae.ai"`, // the anchor choice is a real pairing
"/assets/setup.css",
"This page will not do it", // the root-gated promise, said out loud
} {
if !strings.Contains(body, want) {
t.Errorf("the setup page is missing %q", want)
}
}
// No inline styles and no template.HTML anywhere: the page's own CSP has no
// 'unsafe-inline' in it, so a style attribute would be silently dropped —
// the failure app.css:149 records.
if strings.Contains(body, "style=") {
t.Error("the page carries an inline style, which its own CSP blocks")
}
}
// The setup page and the status page must be held to the same headers. They are
// rendered by two functions (uisetup.go's render, ui.go's renderWith) because
// their view models differ, and two functions drift.
func TestTheSetupPageIsHeldToTheSameHeadersAsTheStatusPage(t *testing.T) {
uiTestPairing(t, "member.host.privatae.ai")
u := uiTestServer(t)
h := u.handler()
cookie := uiSignedIn(t, u)
status := uiSetupGet(t, h, "/", cookie)
setup := uiSetupGet(t, h, "/setup", cookie)
if status.Code != http.StatusOK || setup.Code != http.StatusOK {
t.Fatalf("/ = %d, /setup = %d", status.Code, setup.Code)
}
for _, hdr := range []string{"Content-Security-Policy", "Cache-Control",
"X-Content-Type-Options", "Content-Type"} {
if got, want := setup.Header().Get(hdr), status.Header().Get(hdr); got != want {
t.Errorf("/setup %s = %q, / = %q — the two render paths have drifted", hdr, got, want)
}
}
}
// A pending host is offerable as the anchor — claim-anchor flips it active
// because the anchor never waits on itself (anchor.go:931) — so it appears in
// the picker, and its state appears WITH it. A choice whose consequence is
// invisible at the moment of choosing is the thing this page exists to fix.
func TestAPendingHostIsOfferedTheAnchorWithItsStateVisible(t *testing.T) {
const host = "waiting.host.privatae.ai"
const live = "serving.host.privatae.ai"
uiSetupPairingInState(t, host, pairingPending)
// A live pairing beside it, so the panels that DO render a host list render
// one — otherwise they collapse to their empty state and the assertions
// below about what is missing from them would hold for the wrong reason.
uiSetupAddPairing(t, live, pairingActive)
_, _, h, cookie := uiSetupTestBox(t)
body := uiSetupGet(t, h, "/setup", cookie).Body.String()
radio := ``
i := strings.Index(body, radio)
if i < 0 {
t.Fatalf("a pending host is not offered the anchor at all: %q", body)
}
// Inside the same label, not merely somewhere on the page.
label := body[i:]
if end := strings.Index(label, ""); end > 0 {
label = label[:end]
}
if !strings.Contains(label, "waiting for approval") {
t.Errorf("the pending state is not shown beside the choice: %q", label)
}
// But nothing that would CREATE state for it is offered: a pending pairing
// gets no directory on this machine until the anchor approves it
// (run.go:311), and both the folder target and the factor host are dropdowns
// that would.
if strings.Contains(body, `