tinyrelayfile
tinyrelay/tinyclient/approvals_test.go/

tinyclient/approvals_test.go raw
1package tinyclient
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "net/http"
8 "net/http/httptest"
9 "strings"
10 "testing"
11
12 "github.com/FelineStateMachine/tinyrelay/internal/policy"
13)
14
15type approvalsBackend struct {
16 *fakeBackend
17 calls []string
18}
19
20func (b *approvalsBackend) Query(_ context.Context, method string, params []json.RawMessage, actor string) (any, error) {
21 b.calls = append(b.calls, method)
22 b.params = params
23 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/repo?owner=" + b.policy.Owner + "&repo=tinyrelay&view=issue&id=" + 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"}}}}, nil
35 case "browseapproval":
36 var q map[string]any
37 _ = 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{}}, nil
42 }
43 return map[string]any{}, nil
44}
45
46func 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]any
59 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="+">Approve</button> <button name="reaction" value="-">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="/repo?owner=` + backend.policy.Owner + `&amp;repo=tinyrelay&amp;view=issue&amp;id=` + 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">`, "approved | <time", `data-state="expired"><td>`, "<td>expired</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>", "Answers from the phone need the remembered signer.",
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 }
93
94 // A notification opens one request; when it has left the first page it
95 // is read on its own and shown first.
96 recorder = httptest.NewRecorder()
97 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/approvals?id="+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}
107
108func 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}
121
122func 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, "<th>open</th><td>0</td>") {
138 t.Fatalf("guest panel:\n%s", body)
139 }
140}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs