| 1 | package tinyclient |
| 2 | |
| 3 | import ( |
| 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 | |
| 14 | type fakeBackend struct { |
| 15 | policy policy.Policy |
| 16 | call string |
| 17 | params []json.RawMessage |
| 18 | } |
| 19 | |
| 20 | type privateFakeBackend struct { |
| 21 | *fakeBackend |
| 22 | allowed map[string]bool |
| 23 | } |
| 24 | |
| 25 | func (f *privateFakeBackend) ReadAllowed(_ context.Context, actor string) error { |
| 26 | if f.allowed[actor] { |
| 27 | return nil |
| 28 | } |
| 29 | return context.Canceled |
| 30 | } |
| 31 | |
| 32 | func (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 | } |
| 37 | func (f *fakeBackend) Policy() policy.Policy { return f.policy } |
| 38 | func (f *fakeBackend) URL() string { return "http://relay.example" } |
| 39 | func (f *fakeBackend) Slug() string { return "demo" } |
| 40 | func (f *fakeBackend) Identity() string { return strings.Repeat("a" , 64) } |
| 41 | |
| 42 | func 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 | |
| 64 | func 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 | |
| 110 | func 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 | |
| 130 | func 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 | |
| 150 | func 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 | |
| 164 | func 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 | |
| 207 | func 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 | |
| 222 | func 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 | |
| 254 | func 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 | |
| 275 | func 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 | |
| 303 | func 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 | |
| 319 | func 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 | |
| 340 | func 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 | |
| 365 | func 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 | } |