| 1 | package tinyclient |
| 2 | |
| 3 | import ( |
| 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 | |
| 15 | type approvalsBackend struct { |
| 16 | *fakeBackend |
| 17 | calls []string |
| 18 | } |
| 19 | |
| 20 | func (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 | |
| 46 | func 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], ¶ms); 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 + `&repo=tinyrelay&view=issue&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 | |
| 108 | func 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 | |
| 122 | func 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 | } |