tinyrelayfile
tinyrelay/tinyclient/webui_test.go/

tinyclient/webui_test.go raw
1package tinyclient
2
3import (
4 "context"
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
8 "strings"
9 "testing"
10
11 "github.com/FelineStateMachine/tinyrelay/internal/policy"
12)
13
14type fakeBackend struct {
15 policy policy.Policy
16 call string
17 params []json.RawMessage
18}
19
20type privateFakeBackend struct {
21 *fakeBackend
22 allowed map[string]bool
23}
24
25func (f *privateFakeBackend) ReadAllowed(_ context.Context, actor string) error {
26 if f.allowed[actor] {
27 return nil
28 }
29 return context.Canceled
30}
31
32func (f *fakeBackend) Query(_ context.Context, method string, params []json.RawMessage, actor string) (any, error) {
33 f.call = method + ":" + actor
34 f.params = params
35 return map[string]any{"method": method}, nil
36}
37func (f *fakeBackend) Policy() policy.Policy { return f.policy }
38func (f *fakeBackend) URL() string { return "http://relay.example" }
39func (f *fakeBackend) Slug() string { return "demo" }
40func (f *fakeBackend) Identity() string { return strings.Repeat("a", 64) }
41
42func TestPagesUsePlainHTMLAndExposeAllManagementTabs(t *testing.T) {
43 backend := &fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}
44 app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return backend.policy.Owner, nil }})
45 if err != nil {
46 t.Fatal(err)
47 }
48 for _, path := range []string{"/", "/inbox", "/outbox", "/manage/people", "/manage/agents", "/manage/moderation", "/manage/rules", "/manage/identity", "/manage/connect", "/manage/data", "/manage/sync", "/manage/views", "/manage/health", "/manage/owner"} {
49 request := httptest.NewRequest(http.MethodGet, path, nil)
50 recorder := httptest.NewRecorder()
51 app.ServeHTTP(recorder, request)
52 if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "<html") || !strings.Contains(recorder.Body.String(), `id="layout"`) {
53 t.Fatalf("%s: status=%d body=%s", path, recorder.Code, recorder.Body.String())
54 }
55 if strings.Contains(recorder.Body.String(), "fonts.googleapis") || strings.Contains(recorder.Body.String(), "bind.ws") {
56 t.Fatalf("%s carries old branding or external style", path)
57 }
58 if strings.Contains(recorder.Body.String(), "display:flex") || strings.Contains(recorder.Body.String(), "display:grid") {
59 t.Fatalf("%s uses a non-table layout", path)
60 }
61 }
62}
63
64func TestPrivatePagesUseGenericShellUntilMembershipIsAuthorized(t *testing.T) {
65 p := policy.Defaults(strings.Repeat("a", 64))
66 p.Features.Grasp = true
67 p.Features.Grasp08 = true
68 p.Reads = "members"
69 p.Name = "secret tenant"
70 p.Description = "secret description"
71 backend := &privateFakeBackend{fakeBackend: &fakeBackend{policy: p}, allowed: map[string]bool{p.Owner: true, "member": true}}
72 actor := func(r *http.Request) (string, error) {
73 if r.Header.Get("X-Actor") == "invalid" {
74 return "", context.Canceled
75 }
76 return r.Header.Get("X-Actor"), nil
77 }
78 app, err := New(backend, Options{Actor: actor})
79 if err != nil {
80 t.Fatal(err)
81 }
82 for _, tc := range []struct {
83 name string
84 actor string
85 code int
86 want string
87 miss string
88 }{
89 {name: "anonymous", code: http.StatusOK, want: "Private relay", miss: "secret description"},
90 {name: "invalid", actor: "invalid", code: http.StatusOK, want: "Private relay", miss: "secret tenant"},
91 {name: "outsider", actor: "outsider", code: http.StatusOK, want: "Private relay", miss: "secret description"},
92 {name: "member", actor: "member", code: http.StatusOK, want: "secret tenant", miss: "Private relay"},
93 } {
94 req := httptest.NewRequest(http.MethodGet, "/manage/identity", nil)
95 if tc.actor != "" {
96 req.Header.Set("X-Actor", tc.actor)
97 }
98 res := httptest.NewRecorder()
99 app.ServeHTTP(res, req)
100 body := res.Body.String()
101 if res.Code != tc.code || !strings.Contains(body, tc.want) || strings.Contains(body, tc.miss) {
102 t.Fatalf("%s: status=%d want=%q miss=%q body=%s", tc.name, res.Code, tc.want, tc.miss, body)
103 }
104 if res.Header().Get("Cache-Control") != "private, no-store" {
105 t.Fatalf("%s: missing private cache policy", tc.name)
106 }
107 }
108}
109
110func TestPrivateConnectShellKeepsSignerAssetsAvailable(t *testing.T) {
111 p := policy.Defaults(strings.Repeat("a", 64))
112 p.Features.Grasp = true
113 p.Features.Grasp08 = true
114 p.Reads = "members"
115 p.Name = "secret tenant"
116 backend := &privateFakeBackend{fakeBackend: &fakeBackend{policy: p}, allowed: map[string]bool{}}
117 app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return "", nil }})
118 if err != nil {
119 t.Fatal(err)
120 }
121 req := httptest.NewRequest(http.MethodGet, "/manage/connect", nil)
122 res := httptest.NewRecorder()
123 app.ServeHTTP(res, req)
124 body := res.Body.String()
125 if res.Code != http.StatusOK || !strings.Contains(body, "id=\"session-login\"") || !strings.Contains(body, "/signer.js") || strings.Contains(body, "secret tenant") {
126 t.Fatalf("private connect shell invalid: %d %s", res.Code, body)
127 }
128}
129
130func TestPrivateProtectedEndpointRechecksRevokedMember(t *testing.T) {
131 p := policy.Defaults(strings.Repeat("a", 64))
132 p.Features.Grasp = true
133 p.Features.Grasp08 = true
134 p.Reads = "members"
135 p.Name = "secret endpoint tenant"
136 backend := &privateFakeBackend{fakeBackend: &fakeBackend{policy: p}, allowed: map[string]bool{"member": true}}
137 app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return "member", nil }})
138 if err != nil {
139 t.Fatal(err)
140 }
141 backend.allowed["member"] = false
142 req := httptest.NewRequest(http.MethodGet, "/card.json", nil)
143 res := httptest.NewRecorder()
144 app.ServeHTTP(res, req)
145 if res.Code != http.StatusForbidden || strings.Contains(res.Body.String(), p.Name) {
146 t.Fatalf("revoked member reached protected endpoint: %d %s", res.Code, res.Body.String())
147 }
148}
149
150func TestDataPageGuidesGitStorageArguments(t *testing.T) {
151 backend := &fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}
152 app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return backend.policy.Owner, nil }})
153 if err != nil {
154 t.Fatal(err)
155 }
156 recorder := httptest.NewRecorder()
157 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/data", nil))
158 body := recorder.Body.String()
159 if !strings.Contains(body, `method="gitstorage"`) || !strings.Contains(body, `placeholder="64 hex pubkey"`) || !strings.Contains(body, `placeholder="repository name"`) {
160 t.Fatalf("gitstorage form is missing guided owner and identifier fields: %s", body)
161 }
162}
163
164func TestGuidedFormsExposeSourceConsoleControls(t *testing.T) {
165 p := policy.Defaults(strings.Repeat("a", 64))
166 p.Icon = "https://relay.example/icon.png"
167 p.Banner = "https://relay.example/banner.png"
168 p.Contact = "owner@example.com"
169 p.Tags = []string{"bitcoin", "cats"}
170 backend := &fakeBackend{policy: p}
171 app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return backend.policy.Owner, nil }})
172 if err != nil {
173 t.Fatal(err)
174 }
175 cases := map[string][]string{
176 "/manage/people": {`compose="member"`, `name="member-role"`},
177 "/manage/rules": {`compose="memberInvites"`, `method="setretention"`},
178 "/manage/identity": {`method="changerelayicon"`, `compose="identity"`, `Banner URL`},
179 "/manage/owner": {`compose="succession"`, `method="clearsuccession"`},
180 "/manage/data": {`placeholder="64 hex pubkey"`, `placeholder="repository name"`},
181 }
182 for path, wants := range cases {
183 recorder := httptest.NewRecorder()
184 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))
185 body := recorder.Body.String()
186 for _, want := range wants {
187 if !strings.Contains(body, want) {
188 t.Errorf("%s missing guided control %q", path, want)
189 }
190 }
191 }
192 recorder := httptest.NewRecorder()
193 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/identity", nil))
194 body := recorder.Body.String()
195 for _, want := range []string{`value="https://relay.example/icon.png"`, `value="owner@example.com"`, `value="bitcoin, cats"`} {
196 if !strings.Contains(body, want) {
197 t.Errorf("identity form did not preload %q", want)
198 }
199 }
200 recorder = httptest.NewRecorder()
201 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/owner", nil))
202 if strings.Contains(recorder.Body.String(), `method="deleterelay"><label>Type relay name to confirm <input name="param" value=`) {
203 t.Fatal("delete confirmation was prefilled")
204 }
205}
206
207func TestRPCUsesVerifiedActorAndBackendContract(t *testing.T) {
208 backend := &fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}
209 app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return "actor", nil }})
210 if err != nil {
211 t.Fatal(err)
212 }
213 request := httptest.NewRequest(http.MethodPost, "/manage/rpc", strings.NewReader(`{"method":"stats","params":[]}`))
214 request.Header.Set("content-type", "application/json")
215 recorder := httptest.NewRecorder()
216 app.ServeHTTP(recorder, request)
217 if recorder.Code != http.StatusOK || backend.call != "stats:actor" {
218 t.Fatalf("rpc did not reach backend: code=%d call=%q body=%s", recorder.Code, backend.call, recorder.Body.String())
219 }
220}
221
222func TestSignerBundleAndDedicatedJourneysArePresent(t *testing.T) {
223 backend := &fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}
224 app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return "", nil }})
225 if err != nil {
226 t.Fatal(err)
227 }
228 request := httptest.NewRequest(http.MethodGet, "/manage/connect", nil)
229 recorder := httptest.NewRecorder()
230 app.ServeHTTP(recorder, request)
231 body := recorder.Body.String()
232 for _, want := range []string{"bunker://", "nostrconnect", "signer-qr", "/signer.js", `src="/scripts/bridge.js?v=`} {
233 if !strings.Contains(body, want) {
234 t.Errorf("connect page missing %q", want)
235 }
236 }
237 if !strings.Contains(servedScript(t, app, "/scripts/bridge.js"), "27235") {
238 t.Error("bridge script missing the NIP-98 kind")
239 }
240 signerRequest := httptest.NewRequest(http.MethodGet, "/signer.js", nil)
241 signerResponse := httptest.NewRecorder()
242 app.ServeHTTP(signerResponse, signerRequest)
243 if signerResponse.Code != http.StatusOK || !strings.Contains(signerResponse.Body.String(), "BunkerSigner") {
244 t.Fatalf("signer bundle unavailable: status=%d", signerResponse.Code)
245 }
246 fixiRequest := httptest.NewRequest(http.MethodGet, "/fixi.js", nil)
247 fixiResponse := httptest.NewRecorder()
248 app.ServeHTTP(fixiResponse, fixiRequest)
249 if fixiResponse.Code != http.StatusOK || !strings.Contains(fixiResponse.Body.String(), "fx-action") {
250 t.Fatalf("fixi bundle unavailable: status=%d", fixiResponse.Code)
251 }
252}
253
254func TestQRAndCardEndpointsProduceNativeArtifacts(t *testing.T) {
255 backend := &fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}
256 app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return "owner", nil }})
257 if err != nil {
258 t.Fatal(err)
259 }
260 request := httptest.NewRequest(http.MethodGet, "/qr.svg?text=nostrconnect%3A%2F%2Fdemo", nil)
261 recorder := httptest.NewRecorder()
262 app.ServeHTTP(recorder, request)
263 if recorder.Code != http.StatusOK || !strings.Contains(recorder.Header().Get("content-type"), "image/svg+xml") || !strings.Contains(recorder.Body.String(), "shape-rendering") {
264 t.Fatalf("invalid QR response: status=%d type=%q", recorder.Code, recorder.Header().Get("content-type"))
265 }
266 for _, path := range []string{"/card.svg", "/card.nostr"} {
267 recorder = httptest.NewRecorder()
268 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))
269 if recorder.Code != http.StatusOK || (path == "/card.nostr" && !strings.Contains(recorder.Body.String(), "nostr:npub1")) || (path == "/card.svg" && !strings.Contains(recorder.Body.String(), backend.URL())) {
270 t.Fatalf("invalid %s response: %d %s", path, recorder.Code, recorder.Body.String())
271 }
272 }
273}
274
275func TestPublicParityRoutesAndInviteClaim(t *testing.T) {
276 backend := &fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}
277 backend.policy.JoinTerms = "Be kind."
278 app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return "member", nil }})
279 if err != nil {
280 t.Fatal(err)
281 }
282 for _, path := range []string{"/e/" + strings.Repeat("b", 64), "/a/example", "/view/latest", "/invite/code", "/feed.xml"} {
283 recorder := httptest.NewRecorder()
284 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))
285 if recorder.Code != http.StatusOK || recorder.Body.Len() == 0 {
286 t.Fatalf("%s: status=%d body=%s", path, recorder.Code, recorder.Body.String())
287 }
288 }
289 recorder := httptest.NewRecorder()
290 policyRecorder := httptest.NewRecorder()
291 app.ServeHTTP(policyRecorder, httptest.NewRequest(http.MethodGet, "/api/join-policy", nil))
292 if policyRecorder.Code != http.StatusOK || !strings.Contains(policyRecorder.Body.String(), "Be kind.") {
293 t.Fatalf("join policy missing: %d %s", policyRecorder.Code, policyRecorder.Body.String())
294 }
295 request := httptest.NewRequest(http.MethodPost, "/api/invites/claim", strings.NewReader(`{"method":"claiminvite","params":["code","terms-hash"]}`))
296 request.Header.Set("content-type", "application/json")
297 app.ServeHTTP(recorder, request)
298 if recorder.Code != http.StatusOK || backend.call != "claiminvite:member" || len(backend.params) != 2 {
299 t.Fatalf("invite claim did not reach backend: status=%d call=%q body=%s", recorder.Code, backend.call, recorder.Body.String())
300 }
301}
302
303func TestJobStatusRequiresActorAndEmitsHTMLSSE(t *testing.T) {
304 backend := &fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}
305 app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return "owner", nil }})
306 if err != nil {
307 t.Fatal(err)
308 }
309 ctx, cancel := context.WithCancel(context.Background())
310 cancel()
311 request := httptest.NewRequest(http.MethodGet, "/manage/jobs/status", nil).WithContext(ctx)
312 recorder := httptest.NewRecorder()
313 app.ServeHTTP(recorder, request)
314 if recorder.Code != http.StatusOK || !strings.Contains(recorder.Header().Get("content-type"), "text/event-stream") || !strings.Contains(recorder.Body.String(), "<thead>") || backend.call != "listjobs:owner" {
315 t.Fatalf("invalid job stream: status=%d type=%q call=%q body=%s", recorder.Code, recorder.Header().Get("content-type"), backend.call, recorder.Body.String())
316 }
317}
318
319func TestConnectPageEditsConnectionsAsList(t *testing.T) {
320 backend := &fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}
321 app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return backend.policy.Owner, nil }})
322 if err != nil {
323 t.Fatal(err)
324 }
325 recorder := httptest.NewRecorder()
326 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/connect", nil))
327 body := recorder.Body.String()
328 for _, want := range []string{"<connect-list>", `<select name="template">`, `<button type="button" name="add">`, `method="setconnections"`, "<private-services", "kind 10318", "NIP-44"} {
329 if !strings.Contains(body, want) {
330 t.Errorf("connect page missing %q", want)
331 }
332 }
333 for _, gone := range []string{"connect/fragment", "Preview connections", `method="listconnections"`} {
334 if strings.Contains(body, gone) {
335 t.Errorf("connect page still carries %q", gone)
336 }
337 }
338}
339
340func TestTenantPrefixRewritesUIEndpoints(t *testing.T) {
341 backend := &fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}
342 app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return backend.policy.Owner, nil }})
343 if err != nil {
344 t.Fatal(err)
345 }
346 recorder := httptest.NewRecorder()
347 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/r/alice/manage/sync", nil))
348 body := recorder.Body.String()
349 for _, want := range []string{`src="/r/alice/fixi.js"`, `src="/r/alice/signer.js"`, `fx-action="/r/alice/manage/jobs/status"`, `src="/r/alice/scripts/bridge.js?v=`, `src="/r/alice/scripts/components.js?v=`} {
350 if !strings.Contains(body, want) {
351 t.Errorf("tenant prefix missing %q", want)
352 }
353 }
354 bridge := servedScript(t, app, "/scripts/bridge.js")
355 for _, want := range []string{`signedSession("/session")`, `replace(/^http/,"ws")+root`} {
356 if !strings.Contains(bridge, want) {
357 t.Errorf("bridge script missing %q", want)
358 }
359 }
360 if !strings.Contains(servedScript(t, app, "/scripts/components.js"), `localPath(action || "/manage/rpc")`) {
361 t.Error("components script missing the prefixed management endpoint")
362 }
363}
364
365func TestTenantPrefixHeaderSurvivesDaemonPathStripping(t *testing.T) {
366 backend := &fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}
367 app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return backend.policy.Owner, nil }})
368 if err != nil {
369 t.Fatal(err)
370 }
371 request := httptest.NewRequest(http.MethodGet, "/manage/sync", nil)
372 request.RequestURI = "/r/alice/manage/sync"
373 recorder := httptest.NewRecorder()
374 app.ServeHTTP(recorder, request)
375 if !strings.Contains(recorder.Body.String(), `fx-action="/r/alice/manage/jobs/status"`) {
376 t.Fatal("daemon prefix header was not applied")
377 }
378}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs