clone

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

feat/ui-updates
tinyrelay / tinyclient / approvals_test.go
approvals_test.go 8.1K
1package tinyclient23import (4 "context"5 "encoding/json"6 "errors"7 "net/http"8 "net/http/httptest"9 "strings"10 "testing"1112 "github.com/FelineStateMachine/tinyrelay/internal/policy"13)1415type approvalsBackend struct {16 *fakeBackend17 calls []string18}1920func (b *approvalsBackend) Query(_ context.Context, method string, params []json.RawMessage, actor string) (any, error) {21 b.calls = append(b.calls, method)22 b.params = params23 if actor == "" {24 return nil, errors.New("auth-required: sign in to see requests addressed to you")25 }26 asker := strings.Repeat("c", 64)27 open := map[string]any{"id": strings.Repeat("1", 64), "kind": 1111, "type": "approve", "asker": asker, "subject": "Publish release notes 1.4", "content": "Publish release notes 1.4 to the articles feed as drafted?", "created_at": 1788876000, "expires": 1788897600, "state": "open",28 "about": map[string]any{"coordinate": "30617:" + b.policy.Owner + ":tinyrelay", "event": strings.Repeat("d", 64), "kind": "1621", "url": "http://relay.example/repos/" + b.policy.Owner + "/tinyrelay/issues/" + strings.Repeat("d", 64)}}29 question := map[string]any{"id": strings.Repeat("2", 64), "kind": 9, "type": "question", "asker": asker, "content": "Mention the edge?", "created_at": 1788876100, "state": "open", "room": "build"}30 answered := map[string]any{"id": strings.Repeat("3", 64), "kind": 1111, "type": "decide", "asker": asker, "subject": "Close issue 41 as duplicate of 12", "content": "", "created_at": 1788870000, "state": "answered", "answer": map[string]any{"id": strings.Repeat("e", 64), "kind": 7, "author": b.policy.Owner, "decision": "approved", "content": "+", "created_at": 1788871000}}31 expired := map[string]any{"id": strings.Repeat("4", 64), "kind": 1111, "type": "approve", "asker": asker, "content": "Create room #release", "created_at": 1788860000, "expires": 1788863600, "state": "expired"}32 switch method {33 case "browseapprovals":34 return map[string]any{"items": []any{open, question, answered, expired}, "next_cursor": "", "counts": map[string]int{"open": 2, "answered": 1, "expired": 1}, "oldest_open": 1788876000, "devices": []any{map[string]any{"created_at": 1788800000, "categories": []string{"approvals", "mentions"}}}}, nil35 case "browseapproval":36 var q map[string]any37 _ = json.Unmarshal(params[0], &q)38 if q["id"] != strings.Repeat("5", 64) {39 return nil, errors.New("not found: request")40 }41 return map[string]any{"item": map[string]any{"id": strings.Repeat("5", 64), "kind": 11, "type": "decide", "asker": asker, "subject": "Older request", "content": "Body", "created_at": 1788000000, "state": "open"}, "answers": []any{}}, nil42 }43 return map[string]any{}, nil44}4546func TestApprovalsPageRendersRequestsForTheOwner(t *testing.T) {47 backend := &approvalsBackend{fakeBackend: &fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}48 app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return backend.policy.Owner, nil }})49 if err != nil {50 t.Fatal(err)51 }52 recorder := httptest.NewRecorder()53 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/approvals", nil))54 body := recorder.Body.String()55 if recorder.Code != http.StatusOK {56 t.Fatalf("status %d: %s", recorder.Code, body)57 }58 var params map[string]any59 if err := json.Unmarshal(backend.params[0], &params); err != nil || params["state"] != "all" || params["limit"] != 50.0 {60 t.Fatalf("browse params %s %v", backend.params[0], err)61 }62 asker := strings.Repeat("c", 64)63 for _, marker := range []string{64 `<approval-item id="approval-` + strings.Repeat("1", 64) + `" data-type="approve" data-state="open">`,65 `<nostr-name pubkey="` + asker + `"`, "in repository tinyrelay", "expires <time",66 "<p>Publish release notes 1.4</p>", "<p>Publish release notes 1.4 to the articles feed as drafted?</p>",67 "<pre>about 30617:" + backend.policy.Owner + ":tinyrelay | kind 1621 dddddddddddd</pre>",68 `<nostr-react event="` + strings.Repeat("1", 64) + `" pubkey="` + asker + `" kind="1111"><button name="reaction" value="+" data-primary>Approve</button> <button name="reaction" value="-" data-danger>Deny</button></nostr-react>`,69 `<nostr-compose kind="1111" root="` + strings.Repeat("1", 64) + `" root-pubkey="` + asker + `" root-kind="1111" coordinate="30617:` + backend.policy.Owner + `:tinyrelay">`,70 `<a href="/repos/` + backend.policy.Owner + `/tinyrelay/issues/` + strings.Repeat("d", 64) + `">open</a>`,71 `<approval-item id="approval-` + strings.Repeat("2", 64) + `" data-type="question"`, "in #build", `<a href="/e/` + strings.Repeat("2", 64) + `">open</a>`,72 "<h3>Answered</h3>", `<tr id="approval-` + strings.Repeat("3", 64) + `" data-state="answered">`, `<status-badge kind="approved">approved</status-badge> <time`, `data-state="expired"><td>`, `<td><status-badge kind="expired">expired</status-badge></td>`,73 "<h4>Waiting</h4>", "<th>open</th><td>2</td>", "<th>oldest</th><td><time", "<h4>Devices</h4>", "<p><i></i>device 1 | approvals, mentions</p>", "<p><i></i>device 1 | ",74 `<a href="/approvals" aria-current="page">/approvals</a>`,75 } {76 if !strings.Contains(body, marker) {77 t.Fatalf("missing %q in\n%s", marker, body)78 }79 }80 // The question type offers no decision buttons, only a reply.81 question := body[strings.Index(body, `id="approval-`+strings.Repeat("2", 64)):]82 question = question[:strings.Index(question, "</approval-item>")]83 if strings.Contains(question, "<nostr-react") || !strings.Contains(question, `<nostr-compose kind="1111" root="`+strings.Repeat("2", 64)+`" root-pubkey="`+asker+`" root-kind="9">`) {84 t.Fatalf("question controls:\n%s", question)85 }86 if strings.Contains(body, "class=") {87 t.Fatal("page carries class attributes")88 }89 rail := body[strings.Index(body, `id="railbox"`):strings.Index(body, `id="rail-tools"`)]90 if strings.Contains(rail, "/inbox") || strings.Contains(rail, "/outbox") || strings.Index(rail, "/approvals") < strings.Index(rail, "/repos") {91 t.Fatalf("rail order:\n%s", rail)92 }9394 // A notification opens one request; when it has left the first page it95 // is read on its own and shown first.96 recorder = httptest.NewRecorder()97 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/approvals/"+strings.Repeat("5", 64)+"?answer=approve", nil))98 body = recorder.Body.String()99 if !strings.Contains(strings.Join(backend.calls, ","), "browseapprovals,browseapproval") {100 t.Fatalf("calls %v", backend.calls)101 }102 first := strings.Index(body, `id="approval-`+strings.Repeat("5", 64)+`"`)103 if first < 0 || first > strings.Index(body, `id="approval-`+strings.Repeat("1", 64)+`"`) || !strings.Contains(body, "<p>Older request</p><p>Body</p>") {104 t.Fatalf("focused request missing or not first:\n%s", body)105 }106}107108func TestGrantScopeRowsRenderNumericKinds(t *testing.T) {109 review := map[string]any{110 "before": map[string]any{"agent": strings.Repeat("a", 64), "scope": map[string]any{"kinds": []any{9.0, 30617.0}}},111 "after": map[string]any{"agent": strings.Repeat("a", 64), "scope": map[string]any{"kinds": []any{9.0, 30617.0, 30618.0}}},112 }113 rows := grantScopeRows(review)114 if got := rows[7].Before; got != "9, 30617" {115 t.Fatalf("before kinds = %q", got)116 }117 if got := rows[7].After; got != "9, 30617, 30618" {118 t.Fatalf("after kinds = %q", got)119 }120}121122func TestApprovalsPageAsksGuestsToSignIn(t *testing.T) {123 backend := &approvalsBackend{fakeBackend: &fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}124 app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return "", nil }})125 if err != nil {126 t.Fatal(err)127 }128 recorder := httptest.NewRecorder()129 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/approvals", nil))130 body := recorder.Body.String()131 if recorder.Code != http.StatusOK || !strings.Contains(body, `<a href="/signin?next=%2Fapprovals">Sign in</a> to see the requests addressed to your key.`) {132 t.Fatalf("guest page %d:\n%s", recorder.Code, body)133 }134 if strings.Contains(body, "<approval-item") || strings.Contains(body, "auth-required") || strings.Contains(body, "role=\"alert\"") {135 t.Fatalf("guest page leaks requests or the error:\n%s", body)136 }137 if !strings.Contains(body, "Sign in to see which devices are notified.") || strings.Contains(body, "<h4>Waiting</h4>") {138 t.Fatalf("guest panel:\n%s", body)139 }140}