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 // Tiny agent interactions: a multiple-choice question with a free-text33 // answer, an open-ended text question, a single-choice approval, and an34 // answered question whose combined answer is shown by option label.35 native := []any{map[string]any{"id": "ship", "label": "Ship it"}, map[string]any{"id": "hold", "label": "Hold"}}36 nativeEvent := map[string]any{"tags": []any{[]any{"tinyagent", "1"}, []any{"h", "build"}}}37 nativeQuestion := map[string]any{"id": strings.Repeat("6", 64), "kind": 9, "type": "question", "asker": asker, "asked": []string{b.policy.Owner}, "subject": "Ship the build?", "content": "The build is green.", "created_at": 1788876200, "expires": 1788897600, "state": "open", "room": "build", "interaction": "question", "selection": "multiple", "freeform": true, "options": native, "event": nativeEvent}38 nativeText := map[string]any{"id": strings.Repeat("7", 64), "kind": 9, "type": "question", "asker": asker, "asked": []string{b.policy.Owner}, "content": "Which branch?", "created_at": 1788876300, "state": "open", "room": "build", "interaction": "question", "selection": "text", "event": nativeEvent}39 nativeApproval := map[string]any{"id": strings.Repeat("8", 64), "kind": 9, "type": "approve", "asker": asker, "asked": []string{b.policy.Owner}, "content": "Delete the stale branch?", "created_at": 1788876400, "state": "open", "room": "build", "interaction": "approval", "selection": "single", "options": []any{map[string]any{"id": "yes", "label": "Yes"}, map[string]any{"id": "no", "label": "No"}}, "event": nativeEvent}40 nativeAnswered := map[string]any{"id": strings.Repeat("9", 64), "kind": 9, "type": "question", "asker": asker, "asked": []string{b.policy.Owner}, "subject": "Ship 1.3?", "created_at": 1788869000, "state": "answered", "room": "build", "interaction": "question", "selection": "multiple", "freeform": true, "options": native, "event": nativeEvent, "answer": map[string]any{"id": strings.Repeat("f", 64), "kind": 1111, "author": b.policy.Owner, "decision": "replied", "content": `{"choices":["ship"],"text":"Go."}`, "created_at": 1788869500}}41 switch method {42 case "browseapprovals":43 return map[string]any{"items": []any{open, question, answered, expired, nativeQuestion, nativeText, nativeApproval, nativeAnswered}, "next_cursor": "", "counts": map[string]int{"open": 5, "answered": 2, "expired": 1}, "oldest_open": 1788876000, "devices": []any{map[string]any{"created_at": 1788800000, "categories": []string{"approvals", "mentions"}}}}, nil44 case "browseapproval":45 var q map[string]any46 _ = json.Unmarshal(params[0], &q)47 if q["id"] != strings.Repeat("5", 64) {48 return nil, errors.New("not found: request")49 }50 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{}}, nil51 }52 return map[string]any{}, nil53}5455func TestApprovalsPageRendersRequestsForTheOwner(t *testing.T) {56 backend := &approvalsBackend{fakeBackend: &fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}57 app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return backend.policy.Owner, nil }})58 if err != nil {59 t.Fatal(err)60 }61 recorder := httptest.NewRecorder()62 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/approvals", nil))63 body := recorder.Body.String()64 if recorder.Code != http.StatusOK {65 t.Fatalf("status %d: %s", recorder.Code, body)66 }67 var params map[string]any68 if err := json.Unmarshal(backend.params[0], ¶ms); err != nil || params["state"] != "all" || params["limit"] != 50.0 {69 t.Fatalf("browse params %s %v", backend.params[0], err)70 }71 asker := strings.Repeat("c", 64)72 for _, marker := range []string{73 `<approval-item id="approval-` + strings.Repeat("1", 64) + `" data-type="approve" data-state="open">`,74 `<nostr-name pubkey="` + asker + `"`, "in repository tinyrelay", "expires <time",75 "<p>Publish release notes 1.4</p>", "<p>Publish release notes 1.4 to the articles feed as drafted?</p>",76 "<pre>about 30617:" + backend.policy.Owner + ":tinyrelay | kind 1621 dddddddddddd</pre>",77 `<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>`,78 `<nostr-compose kind="1111" root="` + strings.Repeat("1", 64) + `" root-pubkey="` + asker + `" root-kind="1111" coordinate="30617:` + backend.policy.Owner + `:tinyrelay">`,79 `<a href="/repos/` + backend.policy.Owner + `/tinyrelay/issues/` + strings.Repeat("d", 64) + `">open</a>`,80 `<approval-item id="approval-` + strings.Repeat("2", 64) + `" data-type="question"`, "in #build", `<a href="/e/` + strings.Repeat("2", 64) + `">open</a>`,81 "<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>`,82 "<h4>Waiting</h4>", "<th>open</th><td>5</td>", "<th>oldest</th><td><time", "<h4>Devices</h4>", "<p><i></i>device 1 | approvals, mentions</p>", "<p><i></i>device 1 | ",83 `<a href="/approvals" aria-current="page">/approvals</a>`,84 // A native interaction renders the room card's choices in a85 // chat-decision whose host re-reads this page, and shows an answer86 // by option label.87 `<chat-activity endpoint="/approvals" room="build" actor="` + backend.policy.Owner + `"><chat-decision event="` + strings.Repeat("6", 64) + `" pubkey="` + asker + `" kind="9" room="build" actor="` + backend.policy.Owner + `" expires="1788897600" interaction="question" selection="multiple" native freeform question multiple>`,88 `<legend>Choose one or more options</legend>`, `<label><input type="checkbox" name="option" value="ship"> Ship it</label><label><input type="checkbox" name="option" value="hold"> Hold</label></fieldset><label>Add details or write your own answer <textarea name="custom-answer" maxlength="8000" rows="2"></textarea></label><button type="submit" data-primary>Submit</button></chat-decision><noscript><p>Answering needs JavaScript and a connected signer.</p></noscript></chat-activity>`,89 `<chat-decision event="` + strings.Repeat("7", 64) + `" pubkey="` + asker + `" kind="9" room="build" actor="` + backend.policy.Owner + `" expires="0" interaction="question" selection="text" native freeform question><label>Your answer <textarea name="custom-answer"`,90 `<chat-decision event="` + strings.Repeat("8", 64) + `" pubkey="` + asker + `" kind="9" room="build" actor="` + backend.policy.Owner + `" expires="0" interaction="approval" selection="single" native><fieldset><legend>Choose an option</legend><label><input type="radio" name="option" value="yes"> Yes</label><label><input type="radio" name="option" value="no"> No</label></fieldset><button type="submit" data-primary>Submit</button></chat-decision>`,91 `<tr id="approval-` + strings.Repeat("9", 64) + `" data-state="answered">`, "<small>Ship it\n\nGo.</small>",92 } {93 if !strings.Contains(body, marker) {94 t.Fatalf("missing %q in\n%s", marker, body)95 }96 }97 // The question type offers no decision buttons, only a reply.98 question := body[strings.Index(body, `id="approval-`+strings.Repeat("2", 64)):]99 question = question[:strings.Index(question, "</approval-item>")]100 if strings.Contains(question, "<nostr-react") || !strings.Contains(question, `<nostr-compose kind="1111" root="`+strings.Repeat("2", 64)+`" root-pubkey="`+asker+`" root-kind="9">`) {101 t.Fatalf("question controls:\n%s", question)102 }103 // A native item offers neither the reaction buttons nor the plain reply,104 // which could not carry a valid answer; its host must not poll or swap.105 for _, id := range []string{strings.Repeat("6", 64), strings.Repeat("7", 64), strings.Repeat("8", 64)} {106 item := body[strings.Index(body, `id="approval-`+id):]107 item = item[:strings.Index(item, "</approval-item>")]108 if strings.Contains(item, "<nostr-react") || strings.Contains(item, "<nostr-compose") || strings.Contains(item, "fx-action") || strings.Contains(item, "fx-trigger") || !strings.Contains(item, `<footer><a href="/e/`+id+`">open</a></footer>`) {109 t.Fatalf("native controls for %s:\n%s", id[:4], item)110 }111 }112 approval := body[strings.Index(body, `id="approval-`+strings.Repeat("8", 64)):]113 approval = approval[:strings.Index(approval, "</approval-item>")]114 if strings.Contains(approval, "<textarea") {115 t.Fatalf("native approval offers free text:\n%s", approval)116 }117 if strings.Contains(body, "class=") {118 t.Fatal("page carries class attributes")119 }120 rail := body[strings.Index(body, `id="railbox"`):strings.Index(body, `id="rail-tools"`)]121 if strings.Contains(rail, "/inbox") || strings.Contains(rail, "/outbox") || strings.Index(rail, "/approvals") < strings.Index(rail, "/repos") {122 t.Fatalf("rail order:\n%s", rail)123 }124125 // A notification opens one request; when it has left the first page it126 // is read on its own and shown first.127 recorder = httptest.NewRecorder()128 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/approvals/"+strings.Repeat("5", 64)+"?answer=approve", nil))129 body = recorder.Body.String()130 if !strings.Contains(strings.Join(backend.calls, ","), "browseapprovals,browseapproval") {131 t.Fatalf("calls %v", backend.calls)132 }133 first := strings.Index(body, `id="approval-`+strings.Repeat("5", 64)+`"`)134 if first < 0 || first > strings.Index(body, `id="approval-`+strings.Repeat("1", 64)+`"`) || !strings.Contains(body, "<p>Older request</p><p>Body</p>") {135 t.Fatalf("focused request missing or not first:\n%s", body)136 }137}138139func TestGrantScopeRowsRenderNumericKinds(t *testing.T) {140 review := map[string]any{141 "before": map[string]any{"agent": strings.Repeat("a", 64), "scope": map[string]any{"kinds": []any{9.0, 30617.0}}},142 "after": map[string]any{"agent": strings.Repeat("a", 64), "scope": map[string]any{"kinds": []any{9.0, 30617.0, 30618.0}}},143 }144 rows := grantScopeRows(review)145 if got := rows[7].Before; got != "9, 30617" {146 t.Fatalf("before kinds = %q", got)147 }148 if got := rows[7].After; got != "9, 30617, 30618" {149 t.Fatalf("after kinds = %q", got)150 }151}152153func TestApprovalsPageAsksGuestsToSignIn(t *testing.T) {154 backend := &approvalsBackend{fakeBackend: &fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}155 app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return "", nil }})156 if err != nil {157 t.Fatal(err)158 }159 recorder := httptest.NewRecorder()160 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/approvals", nil))161 body := recorder.Body.String()162 if recorder.Code != http.StatusOK || !strings.Contains(body, `<a href="/signin?next=%2Fapprovals">Sign in</a> to see the requests addressed to your key.`) {163 t.Fatalf("guest page %d:\n%s", recorder.Code, body)164 }165 if strings.Contains(body, "<approval-item") || strings.Contains(body, "auth-required") || strings.Contains(body, "role=\"alert\"") {166 t.Fatalf("guest page leaks requests or the error:\n%s", body)167 }168 if !strings.Contains(body, "Sign in to see which devices are notified.") || strings.Contains(body, "<h4>Waiting</h4>") {169 t.Fatalf("guest panel:\n%s", body)170 }171}172173func TestApprovalViewReadsNativeInteractionFields(t *testing.T) {174 asker := strings.Repeat("c", 64)175 tagged := map[string]any{"tags": []any{[]any{"tinyagent", "1"}}}176 options := []any{map[string]any{"id": "ship", "label": "Ship it"}, map[string]any{"id": "hold", "label": "Hold"}}177 multiple := approvalViewFrom(map[string]any{"id": strings.Repeat("6", 64), "kind": 9, "type": "question", "asker": asker, "room": "build", "state": "answered", "interaction": "question", "selection": "multiple", "freeform": true, "options": options, "event": tagged, "answer": map[string]any{"decision": "replied", "content": `["ship","hold"]`}}, "")178 if !multiple.Native || multiple.Interaction != "question" || multiple.Selection != "multiple" || !multiple.Multiple || !multiple.Freeform || !multiple.Question || multiple.Room != "build" || len(multiple.Options) != 2 || multiple.Options[1] != (chatActivityOption{ID: "hold", Label: "Hold"}) || multiple.AnswerContent != "Ship it\nHold" {179 t.Fatalf("multiple-choice view %+v", multiple)180 }181 text := approvalViewFrom(map[string]any{"id": strings.Repeat("7", 64), "kind": 9, "type": "question", "asker": asker, "room": "build", "state": "open", "interaction": "question", "selection": "text", "event": tagged}, "")182 if !text.Native || text.Selection != "text" || text.Multiple || !text.Freeform || !text.Question || len(text.Options) != 0 {183 t.Fatalf("text view %+v", text)184 }185 approval := approvalViewFrom(map[string]any{"id": strings.Repeat("8", 64), "kind": 9, "type": "approve", "asker": asker, "room": "build", "state": "open", "interaction": "approval", "selection": "single", "options": options, "event": tagged}, "")186 if !approval.Native || approval.Selection != "single" || approval.Multiple || approval.Freeform || approval.Question || approval.Interaction != "approval" {187 t.Fatalf("approval view %+v", approval)188 }189 // Without options and without the tinyagent tag a selection is not a190 // native interaction; an ordinary question keeps Question for its type.191 plain := approvalViewFrom(map[string]any{"id": strings.Repeat("2", 64), "kind": 9, "type": "question", "asker": asker, "room": "build", "state": "open", "selection": "single", "event": map[string]any{"tags": []any{}}}, "")192 if plain.Native || !plain.Question || plain.Selection != "" || plain.Interaction != "" || plain.Options != nil {193 t.Fatalf("plain view %+v", plain)194 }195 ordinary := approvalViewFrom(map[string]any{"id": strings.Repeat("1", 64), "kind": 1111, "type": "approve", "asker": asker, "state": "answered", "answer": map[string]any{"decision": "replied", "content": `["ship"]`}}, "")196 if ordinary.Native || ordinary.Question || ordinary.Room != "" || ordinary.AnswerContent != `["ship"]` {197 t.Fatalf("ordinary view %+v", ordinary)198 }199}