clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/standalone-relay
tinyrelay / tinyclient / webui_test.go
webui_test.go 16.4K
1package tinyclient23import (4 "context"5 "encoding/json"6 "net/http"7 "net/http/httptest"8 "strings"9 "testing"1011 "github.com/FelineStateMachine/tinyrelay/internal/policy"12)1314type fakeBackend struct {15 policy policy.Policy16 call string17 params []json.RawMessage18}1920type privateFakeBackend struct {21 *fakeBackend22 allowed map[string]bool23}2425func (f *privateFakeBackend) ReadAllowed(_ context.Context, actor string) error {26 if f.allowed[actor] {27 return nil28 }29 return context.Canceled30}3132func (f *fakeBackend) Query(_ context.Context, method string, params []json.RawMessage, actor string) (any, error) {33 f.call = method + ":" + actor34 f.params = params35 return map[string]any{"method": method}, nil36}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) }4142func 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}6364func TestPrivatePagesUseGenericShellUntilMembershipIsAuthorized(t *testing.T) {65 p := policy.Defaults(strings.Repeat("a", 64))66 p.Features.Grasp = true67 p.Features.Grasp08 = true68 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.Canceled75 }76 return r.Header.Get("X-Actor"), nil77 }78 app, err := New(backend, Options{Actor: actor})79 if err != nil {80 t.Fatal(err)81 }82 for _, tc := range []struct {83 name string84 actor string85 code int86 want string87 miss string88 }{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}109110func TestPrivateConnectShellKeepsSignerAssetsAvailable(t *testing.T) {111 p := policy.Defaults(strings.Repeat("a", 64))112 p.Features.Grasp = true113 p.Features.Grasp08 = true114 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}129130func TestPrivateProtectedEndpointRechecksRevokedMember(t *testing.T) {131 p := policy.Defaults(strings.Repeat("a", 64))132 p.Features.Grasp = true133 p.Features.Grasp08 = true134 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"] = false142 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}149150func 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}163164func 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}206207func 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}221222func 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}253254func 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}274275func 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}302303func 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}318319func 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}339340func 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}364365func 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}