| 1 | package tinyclient |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "net/http" |
| 7 | "net/http/httptest" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | |
| 11 | "github.com/FelineStateMachine/tinyrelay/internal/policy" |
| 12 | gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit" |
| 13 | ) |
| 14 | |
| 15 | type browseBackend struct { |
| 16 | fakeBackend |
| 17 | method string |
| 18 | params []json.RawMessage |
| 19 | } |
| 20 | |
| 21 | func (b *browseBackend) Query(_ context.Context, method string, params []json.RawMessage, _ string) (any, error) { |
| 22 | b.method, b.params = method, params |
| 23 | switch method { |
| 24 | case "browserepos" : |
| 25 | return map[string]any{"items" : []any{map[string]any{"owner" : "alice" , "identifier" : "notes" , "head" : "abc" }}}, nil |
| 26 | case "browserepo" : |
| 27 | return map[string]any{"view" : "file" , "content" : "one\ntwo" , "entries" : []any{}}, nil |
| 28 | case "browsefiles" : |
| 29 | return map[string]any{"items" : []any{map[string]any{"sha256" : "deadbeef" , "type" : "text/plain" }}}, nil |
| 30 | case "browsefile" : |
| 31 | return map[string]any{"content" : "blob" , "binary" : false}, nil |
| 32 | case "browsestatus" : |
| 33 | return map[string]any{"jobs" : []any{}, "storage" : map[string]any{"ok" : true}}, nil |
| 34 | default: |
| 35 | return nil, nil |
| 36 | } |
| 37 | } |
| 38 | |
| 39 | func TestBrowseRoutesUseObjectContractsAndRenderData(t *testing.T) { |
| 40 | b := &browseBackend{fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a" , 64))}} |
| 41 | app, err := New(b, Options{}) |
| 42 | if err != nil { |
| 43 | t.Fatal(err) |
| 44 | } |
| 45 | tests := []struct { |
| 46 | path, method, marker string |
| 47 | }{ |
| 48 | {"/repos" , "browserepos" , "notes" }, |
| 49 | {"/repo?owner=alice&repo=notes&view=file" , "browserepo" , "one" }, |
| 50 | {"/files" , "browsefiles" , "deadbeef" }, |
| 51 | {"/file?hash=deadbeef" , "browsefile" , "blob" }, |
| 52 | {"/manage/health" , "browsestatus" , "manage this relay" }, |
| 53 | } |
| 54 | for _, test := range tests { |
| 55 | recorder := httptest.NewRecorder() |
| 56 | app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, test.path, nil)) |
| 57 | if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), test.marker) { |
| 58 | t.Fatalf("%s: status=%d body=%s" , test.path, recorder.Code, recorder.Body.String()) |
| 59 | } |
| 60 | if b.method != test.method || len(b.params) != 1 { |
| 61 | t.Fatalf("%s: method=%q params=%d" , test.path, b.method, len(b.params)) |
| 62 | } |
| 63 | var object map[string]any |
| 64 | if err := json.Unmarshal(b.params[0], &object); err != nil { |
| 65 | t.Fatalf("%s: params are not an object: %v" , test.path, err) |
| 66 | } |
| 67 | if test.method == "browserepo" && object["view" ] != "file" { |
| 68 | t.Fatalf("repo view=%v" , object["view" ]) |
| 69 | } |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | func TestConnectPublishesGRASPAndDeliveryKinds(t *testing.T) { |
| 74 | b := &fakeBackend{policy: policy.Defaults(strings.Repeat("a" , 64))} |
| 75 | app, err := New(b, Options{Actor: func(*http.Request) (string, error) { return b.policy.Owner, nil }}) |
| 76 | if err != nil { |
| 77 | t.Fatal(err) |
| 78 | } |
| 79 | recorder := httptest.NewRecorder() |
| 80 | app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/connect" , nil)) |
| 81 | body := recorder.Body.String() |
| 82 | for _, marker := range []string{`<publish-list kind="10317" tag="g" scheme="ws">` , `<publish-list kind="10063" tag="server">` , `name="lines"` , `src="/scripts/components.js?v=` } { |
| 83 | if !strings.Contains(body, marker) { |
| 84 | t.Fatalf("connect page missing %q" , marker) |
| 85 | } |
| 86 | } |
| 87 | components := servedScript(t, app, "/scripts/components.js" ) |
| 88 | for _, marker := range []string{`customElements.define("publish-list"` , `tiny.signedFetch("/events", "POST"` } { |
| 89 | if !strings.Contains(components, marker) { |
| 90 | t.Fatalf("components script missing %q" , marker) |
| 91 | } |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | func TestBrowseRendersTypedGitRelayPage(t *testing.T) { |
| 96 | b := &browseBackend{fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a" , 64))}} |
| 97 | // Keep the fixture typed exactly as daemon.executeBrowse returns it. This |
| 98 | // catches template regressions where fields are assumed to be map values. |
| 99 | page := gitrelay.BrowsePage{Owner: "alice" , Identifier: "notes" , View: "file" , Content: "<script>\nline two" , Clone: []string{"https://example.test/notes.git" }} |
| 100 | browse := &typedBrowseBackend{browseBackend: b, page: page} |
| 101 | app, err := New(browse, Options{}) |
| 102 | if err != nil { |
| 103 | t.Fatal(err) |
| 104 | } |
| 105 | recorder := httptest.NewRecorder() |
| 106 | app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/repo?owner=alice&repo=notes&view=file" , nil)) |
| 107 | body := recorder.Body.String() |
| 108 | if recorder.Code != http.StatusOK || !strings.Contains(body, "<script>" ) || !strings.Contains(body, "line two" ) || !strings.Contains(body, ">raw</a>" ) { |
| 109 | t.Fatalf("typed browse page status=%d body=%s" , recorder.Code, body) |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | func TestFileViewerEscapesHashAndExplainsEmptyFiles(t *testing.T) { |
| 114 | b := &emptyFileBackend{browseBackend: &browseBackend{fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a" , 64))}}} |
| 115 | app, err := New(b, Options{}) |
| 116 | if err != nil { |
| 117 | t.Fatal(err) |
| 118 | } |
| 119 | recorder := httptest.NewRecorder() |
| 120 | app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/file?hash=a%26b" , nil)) |
| 121 | body := recorder.Body.String() |
| 122 | if recorder.Code != http.StatusOK || !strings.Contains(body, `fx-ignore href="/files/raw?hash=a%26b"` ) { |
| 123 | t.Fatalf("file hash was not URL encoded: status=%d body=%s" , recorder.Code, body) |
| 124 | } |
| 125 | if !strings.Contains(body, "This file is empty." ) { |
| 126 | t.Fatalf("empty file did not get an explanatory preview: %s" , body) |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | type emptyFileBackend struct{ *browseBackend } |
| 131 | |
| 132 | func (b *emptyFileBackend) Query(ctx context.Context, method string, params []json.RawMessage, actor string) (any, error) { |
| 133 | if method == "browsefile" { |
| 134 | return map[string]any{"sha256" : "a&b" , "type" : "text/plain" , "content" : "" }, nil |
| 135 | } |
| 136 | return b.browseBackend.Query(ctx, method, params, actor) |
| 137 | } |
| 138 | |
| 139 | type typedBrowseBackend struct { |
| 140 | *browseBackend |
| 141 | page gitrelay.BrowsePage |
| 142 | } |
| 143 | |
| 144 | func (b *typedBrowseBackend) Query(_ context.Context, method string, _ []json.RawMessage, _ string) (any, error) { |
| 145 | if method == "browserepo" { |
| 146 | return b.page, nil |
| 147 | } |
| 148 | return b.browseBackend.Query(context.Background(), method, nil, "" ) |
| 149 | } |
| 150 | |
| 151 | // servedScript fetches one of the cached browser scripts through the app. |
| 152 | func servedScript(t *testing.T, app *App, path string) string { |
| 153 | t.Helper() |
| 154 | recorder := httptest.NewRecorder() |
| 155 | app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil)) |
| 156 | if recorder.Code != http.StatusOK { |
| 157 | t.Fatalf("%s: status=%d" , path, recorder.Code) |
| 158 | } |
| 159 | return recorder.Body.String() |
| 160 | } |
| 161 | |
| 162 | func TestNostrNameBundleIsServedAndLoadedOnEveryPage(t *testing.T) { |
| 163 | b := &browseBackend{fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a" , 64))}} |
| 164 | app, err := New(b, Options{}) |
| 165 | if err != nil { |
| 166 | t.Fatal(err) |
| 167 | } |
| 168 | bundle := servedScript(t, app, "/scripts/nostr-name.js" ) |
| 169 | for _, marker := range []string{`customElements.define("nostr-name"` , "nostrSharedMetadataLoader" , "tiny:names:v1" } { |
| 170 | if !strings.Contains(bundle, marker) { |
| 171 | t.Fatalf("nostr-name bundle missing %q" , marker) |
| 172 | } |
| 173 | } |
| 174 | if strings.Contains(bundle, "cdn.jsdelivr.net" ) { |
| 175 | t.Fatal("nostr-name bundle reaches for a CDN" ) |
| 176 | } |
| 177 | recorder := httptest.NewRecorder() |
| 178 | app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/repos" , nil)) |
| 179 | if body := recorder.Body.String(); !strings.Contains(body, `src="/scripts/nostr-name.js?v=` ) { |
| 180 | t.Fatal("page does not load the nostr-name bundle" ) |
| 181 | } |
| 182 | } |