1package tinyclient23import (4 "context"5 "encoding/json"6 "net/http"7 "net/http/httptest"8 "strings"9 "testing"1011 "github.com/FelineStateMachine/tinyrelay/internal/policy"12 gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"13)1415type browseBackend struct {16 fakeBackend17 method string18 params []json.RawMessage19}2021func (b *browseBackend) Query(_ context.Context, method string, params []json.RawMessage, _ string) (any, error) {22 b.method, b.params = method, params23 switch method {24 case "browserepos":25 return map[string]any{"items": []any{map[string]any{"owner": "alice", "identifier": "notes", "head": "abc"}}}, nil26 case "browserepo":27 return map[string]any{"view": "file", "content": "one\ntwo", "entries": []any{}}, nil28 case "browsefiles":29 return map[string]any{"items": []any{map[string]any{"sha256": "deadbeef", "type": "text/plain"}}}, nil30 case "browsefile":31 return map[string]any{"content": "blob", "binary": false}, nil32 case "browsestatus":33 return map[string]any{"jobs": []any{}, "storage": map[string]any{"ok": true}}, nil34 default:35 return nil, nil36 }37}3839func 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 string47 }{48 {"/repos", "browserepos", "notes"},49 {"/repos/alice/notes/file/README", "browserepo", "one"},50 {"/files", "browsefiles", "deadbeef"},51 {"/file/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]any64 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}7273func 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}9495func 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. This98 // 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, "/repos/alice/notes/file/README", 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}112113func 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/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}129130type emptyFileBackend struct{ *browseBackend }131132func (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": ""}, nil135 }136 return b.browseBackend.Query(ctx, method, params, actor)137}138139type typedBrowseBackend struct {140 *browseBackend141 page gitrelay.BrowsePage142}143144func (b *typedBrowseBackend) Query(_ context.Context, method string, _ []json.RawMessage, _ string) (any, error) {145 if method == "browserepo" {146 return b.page, nil147 }148 return b.browseBackend.Query(context.Background(), method, nil, "")149}150151// servedScript fetches one of the cached browser scripts through the app.152func 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}161162func 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}