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, `