| 1 | package tinyclient |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "net/http" |
| 7 | "net/http/httptest" |
| 8 | "net/url" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | |
| 12 | "github.com/FelineStateMachine/tinyrelay/internal/policy" |
| 13 | "github.com/FelineStateMachine/tinyrelay/internal/views" |
| 14 | "github.com/FelineStateMachine/tinyrelay/internal/wiki" |
| 15 | ) |
| 16 | |
| 17 | // viewsBackend reports two custom views and lists them for the owner. |
| 18 | type viewsBackend struct { |
| 19 | fakeBackend |
| 20 | views []views.View |
| 21 | rows []map[string]any |
| 22 | } |
| 23 | |
| 24 | func (b *viewsBackend) CustomViews() []views.View { return b.views } |
| 25 | |
| 26 | func (b *viewsBackend) Query(ctx context.Context, method string, params []json.RawMessage, actor string) (any, error) { |
| 27 | if method == "listcustomviews" { |
| 28 | if actor != b.policy.Owner { |
| 29 | return nil, context.Canceled |
| 30 | } |
| 31 | return b.rows, nil |
| 32 | } |
| 33 | if method == "browserepo" { |
| 34 | var request map[string]any |
| 35 | if len(params) == 1 { |
| 36 | _ = json.Unmarshal(params[0], &request) |
| 37 | } |
| 38 | if request["view" ] == "file" { |
| 39 | return map[string]any{"content" : "# Notes\n\n```mermaid\ngraph TD; a-->b;\n```\n\nSee [guide](docs/guide.md).\n" }, nil |
| 40 | } |
| 41 | return map[string]any{"identifier" : "notes" , "entries" : []any{}}, nil |
| 42 | } |
| 43 | return b.fakeBackend.Query(ctx, method, params, actor) |
| 44 | } |
| 45 | |
| 46 | const mermaidSource = "graph TD;\n A-->B & <C>;" |
| 47 | |
| 48 | func mermaidFigure(view string) string { |
| 49 | return `<figure data-view="` + view + `"><view-artifact><img src="/views/` + view + `/` + views.Hash("mermaid" , mermaidSource) + `" alt="Rendered mermaid block"><details><summary>Source</summary><pre><code data-lang="mermaid">graph TD;` + "\n" + ` A-->B & <C>;</code></pre></details></view-artifact></figure>` |
| 50 | } |
| 51 | |
| 52 | func TestRenderersShowCustomViewArtifactsWithTheCodeAsFallback(t *testing.T) { |
| 53 | renderer := views.NewRenderer([]views.View{{Name: "diagrams" , Languages: []string{"mermaid" }}}) |
| 54 | source := "Intro\n\n```mermaid\n" + mermaidSource + "\n```\n\n```go\nfunc main() {}\n```\n\nhttps://example.com/x\n" |
| 55 | got := string(renderMarkdownWith(source, renderer.Block)) |
| 56 | wantAll(t, "markdown" , got, mermaidFigure("diagrams" ), `<pre><code data-lang="go">func main() {}</code></pre>` ) |
| 57 | if strings.Contains(got, "class=" ) { |
| 58 | t.Fatal("markdown carries a class attribute" ) |
| 59 | } |
| 60 | // Without a renderer, every fenced block stays code. |
| 61 | plain := string(renderMarkdown(source)) |
| 62 | wantAll(t, "plain markdown" , plain, `<pre><code data-lang="mermaid">graph TD;` ) |
| 63 | wantNone(t, "plain markdown" , plain, "<figure" , "<object" ) |
| 64 | // Chat keeps the figure and links the bare URL outside it. |
| 65 | chat := string(renderChatMarkdownWith(source, renderer.Block)) |
| 66 | wantAll(t, "chat" , chat, mermaidFigure("diagrams" ), `<a href="https://example.com/x" rel="noopener">https://example.com/x</a>` ) |
| 67 | // README rendering maps relative links and keeps the figure. |
| 68 | query := url.Values{"owner" : {"alice" }, "repo" : {"notes" }, "ref" : {"main" }} |
| 69 | repo := string(renderRepositoryMarkdownWith(source+"[guide](docs/guide.md)\n" , query, renderer.Block)) |
| 70 | wantAll(t, "repository" , repo, mermaidFigure("diagrams" ), `href="/repo?owner=alice&path=docs%2Fguide.md&ref=main&repo=notes&view=file"` ) |
| 71 | // The wiki renderer takes the same renderer through its options. |
| 72 | article := string(wiki.RenderHTMLWith("# Title\n\n``` Mermaid\n" +mermaidSource+"\n```\n\n~~~\nplain\n~~~\n" , wiki.Options{Block: renderer.Block})) |
| 73 | wantAll(t, "wiki" , article, "<h1>Title</h1>" , mermaidFigure("diagrams" ), "<pre><code>plain\n</code></pre>" ) |
| 74 | if plainWiki := string(wiki.RenderHTML("```mermaid\n" + mermaidSource + "\n```\n" )); strings.Contains(plainWiki, "<figure" ) || !strings.Contains(plainWiki, `<code data-lang="mermaid">` ) { |
| 75 | t.Fatalf("wiki without options: %s" , plainWiki) |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | func TestPagesRenderCustomViewFiguresFromTheBackendSummary(t *testing.T) { |
| 80 | owner := strings.Repeat("a" , 64) |
| 81 | backend := &viewsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(owner)}, views: []views.View{{Name: "diagrams" , Languages: []string{"mermaid" }}}} |
| 82 | app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return owner, nil }}) |
| 83 | if err != nil { |
| 84 | t.Fatal(err) |
| 85 | } |
| 86 | source := "```mermaid\n" + mermaidSource + "\n```" |
| 87 | for name, got := range map[string]string{ |
| 88 | "markdown" : string(app.markdown(source)), |
| 89 | "chat" : string(app.chatMarkdown(source)), |
| 90 | "wiki" : string(app.wikiHTML(source)), |
| 91 | } { |
| 92 | wantAll(t, name, got, mermaidFigure("diagrams" )) |
| 93 | } |
| 94 | // The repository home renders the README through the same renderer, |
| 95 | // and the tenant prefix reaches the object address. |
| 96 | recorder := httptest.NewRecorder() |
| 97 | app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/repo?owner=" +owner+"&repo=notes&view=home" , nil)) |
| 98 | body := recorder.Body.String() |
| 99 | if recorder.Code != http.StatusOK { |
| 100 | t.Fatalf("repo home: %d %s" , recorder.Code, body) |
| 101 | } |
| 102 | hash := views.Hash("mermaid" , "graph TD; a-->b;" ) |
| 103 | figure := `<figure data-view="diagrams"><view-artifact><img src="/views/diagrams/` + hash + `" alt="Rendered mermaid block"><details><summary>Source</summary><pre><code data-lang="mermaid">graph TD; a-->b;</code></pre></details></view-artifact></figure>` |
| 104 | wantAll(t, "repo home" , body, figure, `href="/repo?owner=` ) |
| 105 | if prefixed := injectBase(figure, "/r/work" ); !strings.Contains(prefixed, `<img src="/r/work/views/diagrams/` +hash+`"` ) { |
| 106 | t.Fatalf("tenant prefix missed the object address: %s" , prefixed) |
| 107 | } |
| 108 | // A backend without the summary keeps code blocks as code. |
| 109 | plainApp, err := New(&fakeBackend{policy: policy.Defaults(owner)}, Options{Actor: func(*http.Request) (string, error) { return owner, nil }}) |
| 110 | if err != nil { |
| 111 | t.Fatal(err) |
| 112 | } |
| 113 | if got := string(plainApp.markdown(source)); strings.Contains(got, "<figure" ) { |
| 114 | t.Fatalf("figure without a summary: %s" , got) |
| 115 | } |
| 116 | backend.views = nil |
| 117 | if got := string(app.markdown(source)); strings.Contains(got, "<figure" ) { |
| 118 | t.Fatalf("figure without views: %s" , got) |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | func TestTemplateClonesKeepBackendSpecificMarkdownHelpers(t *testing.T) { |
| 123 | owner := strings.Repeat("a" , 64) |
| 124 | withView := &viewsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(owner)}, views: []views.View{{Name: "diagrams" , Languages: []string{"mermaid" }}}} |
| 125 | withView.policy.Description = "```mermaid\ngraph TD; A-->B\n```" |
| 126 | withoutView := &viewsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(owner)}} |
| 127 | withoutView.policy.Description = withView.policy.Description |
| 128 | withApp, err := New(withView, Options{}) |
| 129 | if err != nil { |
| 130 | t.Fatal(err) |
| 131 | } |
| 132 | withoutApp, err := New(withoutView, Options{}) |
| 133 | if err != nil { |
| 134 | t.Fatal(err) |
| 135 | } |
| 136 | withResponse := httptest.NewRecorder() |
| 137 | withoutResponse := httptest.NewRecorder() |
| 138 | withApp.ServeHTTP(withResponse, httptest.NewRequest(http.MethodGet, "/" , nil)) |
| 139 | withoutApp.ServeHTTP(withoutResponse, httptest.NewRequest(http.MethodGet, "/" , nil)) |
| 140 | if !strings.Contains(withResponse.Body.String(), `<figure data-view="diagrams">` ) { |
| 141 | t.Fatal("backend with a custom view did not render its figure" ) |
| 142 | } |
| 143 | if strings.Contains(withoutResponse.Body.String(), `<figure data-view="diagrams">` ) { |
| 144 | t.Fatal("backend without a custom view inherited another App's markdown helper" ) |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | func TestViewsPageListsCustomViewsWithControlsAndTheAddForm(t *testing.T) { |
| 149 | owner := strings.Repeat("a" , 64) |
| 150 | backend := &viewsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(owner)}, rows: []map[string]any{ |
| 151 | {"name" : "diagrams" , "kinds" : []any{1.0, 30023.0}, "languages" : []any{"mermaid" , "dot" }, "trigger" : "write" , "audience" : "public" , "enabled" : true, "state" : "active" , "lastRun" : 1700000000.0, "lastStatus" : "ok" , "transform" : "https://render.example/tiny?token=secret-path" }, |
| 152 | {"name" : "charts" , "kinds" : []any{30618.0}, "languages" : []any{"vega" }, "trigger" : "hourly" , "audience" : "members" , "enabled" : false, "state" : "paused" , "lastRun" : 0.0, "lastStatus" : "paused after 20 failures: HTTP 500" }, |
| 153 | }} |
| 154 | app, err := New(backend, Options{Actor: func(r *http.Request) (string, error) { return r.Header.Get("X-Actor" ), nil }}) |
| 155 | if err != nil { |
| 156 | t.Fatal(err) |
| 157 | } |
| 158 | get := func(actor string) string { |
| 159 | t.Helper() |
| 160 | request := httptest.NewRequest(http.MethodGet, "/manage/views" , nil) |
| 161 | request.Header.Set("X-Actor" , actor) |
| 162 | recorder := httptest.NewRecorder() |
| 163 | app.ServeHTTP(recorder, request) |
| 164 | if recorder.Code != http.StatusOK { |
| 165 | t.Fatalf("views page: %d %s" , recorder.Code, recorder.Body.String()) |
| 166 | } |
| 167 | body := recorder.Body.String() |
| 168 | if strings.Contains(body, "class=" ) { |
| 169 | t.Fatal("views page carries a class attribute" ) |
| 170 | } |
| 171 | if strings.Contains(body, "ยท" ) { |
| 172 | t.Fatal("views page contains a middle dot" ) |
| 173 | } |
| 174 | return body |
| 175 | } |
| 176 | body := get(owner) |
| 177 | wantAll(t, "/manage/views" , body, |
| 178 | `<rpc-form method="listviews">` , `<rpc-form method="publishview">` , |
| 179 | `<table id="custom-views">` , `<tr id="view-diagrams">` , `<code>diagrams</code>` , `<td>1, 30023</td>` , `<td>mermaid, dot</td>` , `<td>write</td>` , `<td>public</td>` , |
| 180 | `<td data-state="active">active | ok</td>` , `<td>2023-11-14 22:13 UTC</td>` , |
| 181 | `<rpc-form method="pausecustomview" refresh><input type="hidden" name="param" value="diagrams"><button>Pause</button></rpc-form>` , |
| 182 | `<rpc-form method="runcustomview" refresh><input type="hidden" name="param" value="diagrams"><button>Run now</button></rpc-form>` , |
| 183 | `<rpc-form method="removecustomview" refresh><input type="hidden" name="param" value="diagrams"><button>Remove</button></rpc-form>` , |
| 184 | `<tr id="view-charts">` , `<td data-state="paused">paused | paused after 20 failures: HTTP 500</td>` , `<td>never</td>` , |
| 185 | `<rpc-form method="resumecustomview" refresh><input type="hidden" name="param" value="charts"><button>Resume</button></rpc-form>` , |
| 186 | `<view-form method="addcustomview">` , `<input name="view-name" pattern="[a-z0-9-]{1,32}"` , `<input name="view-kinds"` , `<input name="view-languages"` , `<input name="view-transform" type="url"` , |
| 187 | `<select name="view-trigger"><option value="write">write</option><option value="hourly">hourly</option></select>` , `<select name="view-audience">` , `<input name="view-max-bytes" type="number" min="1" max="4194304"` , `<input name="view-secret" minlength="16" maxlength="128"` , `<button>Add view</button>` ) |
| 188 | wantNone(t, "/manage/views" , body, "secret-path" , `<rpc-form method="pausecustomview" refresh><input type="hidden" name="param" value="charts">` ) |
| 189 | // A member keeps the built-in controls and sees no custom view section. |
| 190 | member := get(strings.Repeat("b" , 64)) |
| 191 | wantAll(t, "/manage/views as member" , member, `<rpc-form method="listviews">` ) |
| 192 | wantNone(t, "/manage/views as member" , member, `id="custom-views"` , "<view-form" ) |
| 193 | // The owner without views sees the empty note and the form. |
| 194 | backend.rows = nil |
| 195 | empty := get(owner) |
| 196 | wantAll(t, "/manage/views empty" , empty, `<p id="custom-views-empty">No custom views yet.</p>` , `<view-form method="addcustomview">` ) |
| 197 | wantNone(t, "/manage/views empty" , empty, `<table id="custom-views">` ) |
| 198 | } |