- commit
- c2bdcb3f3d21194488a643ccb262b20a083076ce
- parent
- cd6f6d0eca9accf4d7e5a60bf842c97f1d3064ef
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 14 | +- |
| A | internal/ | 944 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 37 | +- |
| M | internal/ | 4 | +- |
| M | internal/ | 18 | + |
| A | internal/ | 172 | +++++ |
@@ -422,7 +422,7 @@ func (t *Tenant) customViewRows(ctx context.Context, names ...string) ([]customV
422422 query += ` WHERE name=?`423423 args = append(args, names[0])424424 }425- query += ` ORDER BY created_at,name`425+ query += ` ORDER BY created_at,rowid`426426 rows, err := t.store.DB().QueryContext(ctx, query, args...)427427 if err != nil {428428 return nil, fmt.Errorf("list custom views: %w", err)@@ -505,7 +505,7 @@ func (t *Tenant) queueCustomViews(ctx context.Context, e event.Event) {
505505 if len(candidates) == 0 {506506 return507507 }508- if e.Kind == event.KIND_REPO_STATE && (t.git == nil || t.git.IsPending(e.ID)) {508+ if e.Kind == event.KIND_REPO_STATE && (t.git == nil || t.statePending(ctx, e.ID)) {509509 // The README is read from the objects, which have not arrived yet;510510 // releaseGit queues the state once they have.511511 return@@ -545,6 +545,14 @@ func (t *Tenant) viewIntent(view customView, e event.Event, run string) storage.
545545 return storage.Intent{Kind: viewTransform, EventID: id, Target: view.Name, Payload: string(payload)}546546 }547547 548+// statePending reports whether a repository state still waits for its549+// objects.550+func (t *Tenant) statePending(ctx context.Context, id string) bool {551+ var one int552+ err := t.store.DB().QueryRowContext(ctx, `SELECT 1 FROM pending_events WHERE id=?`, id).Scan(&one)553+ return err == nil || t.git.IsPending(id)554+}555+548556 // queueCustomViewBackfill queues the newest sources of a view's kinds, at549557 // most 500, written since the given time. Repository states are queued550558 // whether or not their README has blocks; the handler reads it.@@ -559,7 +567,7 @@ func (t *Tenant) queueCustomViewBackfill(ctx context.Context, view customView, s
559567 continue560568 }561569 if e.Kind == event.KIND_REPO_STATE {562- if t.git == nil || t.git.IsPending(e.ID) {570+ if t.git == nil || t.statePending(ctx, e.ID) {563571 continue564572 }565573 } else if len(views.Matching(views.Blocks(e.Content), view.Languages)) == 0 {@@ -0,0 +1,944 @@
1+package daemon2+3+import (4+ "context"5+ "crypto/hmac"6+ "crypto/sha256"7+ "encoding/base64"8+ "encoding/hex"9+ "encoding/json"10+ "io"11+ "net/http"12+ "net/http/httptest"13+ "os"14+ "path/filepath"15+ "strconv"16+ "strings"17+ "sync"18+ "testing"19+ "time"20+21+ "github.com/FelineStateMachine/tinyrelay/internal/community"22+ "github.com/FelineStateMachine/tinyrelay/internal/event"23+ "github.com/FelineStateMachine/tinyrelay/internal/views"24+ "github.com/FelineStateMachine/tinyrelay/internal/work"25+)26+27+const (28+ testSVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10"/></svg>`29+ testPNGRaw = "\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"30+)31+32+// transformServer stands in for a view transform: it records every request33+// and answers with the artifacts it is told to.34+type transformServer struct {35+ mu sync.Mutex36+ status int37+ body string38+ headers []http.Header39+ requests [][]byte40+}41+42+func (s *transformServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {43+ body, _ := io.ReadAll(r.Body)44+ s.mu.Lock()45+ s.headers = append(s.headers, r.Header.Clone())46+ s.requests = append(s.requests, body)47+ status, answer := s.status, s.body48+ s.mu.Unlock()49+ w.Header().Set("Content-Type", "application/json")50+ w.WriteHeader(status)51+ _, _ = w.Write([]byte(answer))52+}53+54+func (s *transformServer) count() int {55+ s.mu.Lock()56+ defer s.mu.Unlock()57+ return len(s.requests)58+}59+60+func (s *transformServer) answer(status int, artifacts ...map[string]any) {61+ s.mu.Lock()62+ defer s.mu.Unlock()63+ s.status = status64+ raw, _ := json.Marshal(map[string]any{"artifacts": artifacts, "errors": []map[string]any{{"block": 9, "error": "unknown"}}})65+ s.body = string(raw)66+}67+68+func svgArtifact(block int) map[string]any {69+ return map[string]any{"block": block, "type": "image/svg+xml", "body": testSVG, "engine": "mermaid"}70+}71+72+func viewParams(values map[string]any) []json.RawMessage {73+ raw, _ := json.Marshal(values)74+ return []json.RawMessage{raw}75+}76+77+func addView(t *testing.T, tenant *Tenant, values map[string]any) map[string]any {78+ t.Helper()79+ result, err := tenant.Execute(context.Background(), tenant.Policy().Owner, "addcustomview", viewParams(values))80+ if err != nil {81+ t.Fatalf("addcustomview: %v", err)82+ }83+ return result.(map[string]any)84+}85+86+// viewTenant is a loopback tenant with a transform server and one view that87+// watches the given kinds.88+func viewTenant(t *testing.T, kinds []int, extra map[string]any) (*Tenant, *transformServer, map[string]any) {89+ t.Helper()90+ _, tenant := loopbackTenant(t)91+ server := &transformServer{}92+ server.answer(http.StatusOK)93+ service := httptest.NewTLSServer(server)94+ t.Cleanup(service.Close)95+ tenant.viewClient = service.Client()96+ values := map[string]any{"name": "diagrams", "kinds": kinds, "transform": service.URL + "/render?token=t0k3n", "languages": []string{"mermaid", "dot"}}97+ for key, value := range extra {98+ values[key] = value99+ }100+ return tenant, server, addView(t, tenant, values)101+}102+103+// pendingViewIntents lists queued transforms of one source for one view,104+// first attempt first.105+func pendingViewIntents(t *testing.T, tenant *Tenant, name, eventID string) []work.Intent {106+ t.Helper()107+ rows, err := tenant.store.DB().QueryContext(context.Background(), "SELECT id,event_id,payload,next_at FROM work_intents WHERE kind=? AND target=? AND state='pending' AND (event_id=? OR event_id LIKE ? OR event_id LIKE ?) ORDER BY next_at,created_at,id", viewTransform, name, eventID, eventID+"#%", eventID+"@%")108+ if err != nil {109+ t.Fatal(err)110+ }111+ defer rows.Close()112+ var out []work.Intent113+ for rows.Next() {114+ var item work.Intent115+ var next int64116+ if err := rows.Scan(&item.ID, &item.EventID, &item.Payload, &next); err != nil {117+ t.Fatal(err)118+ }119+ item.Kind, item.Target, item.NextAt = viewTransform, name, time.Unix(next, 0)120+ out = append(out, item)121+ }122+ return out123+}124+125+// runViewIntent drives one intent the way the worker would and marks it126+// complete.127+func runViewIntent(t *testing.T, tenant *Tenant, intent work.Intent) {128+ t.Helper()129+ if err := tenant.handleViewTransform(context.Background(), intent); err != nil {130+ t.Fatalf("handleViewTransform: %v", err)131+ }132+ if _, err := tenant.store.DB().ExecContext(context.Background(), "UPDATE work_intents SET state='completed' WHERE id=?", intent.ID); err != nil {133+ t.Fatal(err)134+ }135+}136+137+func runPendingView(t *testing.T, tenant *Tenant, name, eventID string) {138+ t.Helper()139+ queued := pendingViewIntents(t, tenant, name, eventID)140+ if len(queued) != 1 {141+ t.Fatalf("queued intents for %s = %d", eventID, len(queued))142+ }143+ runViewIntent(t, tenant, queued[0])144+}145+146+func storedArtifact(t *testing.T, tenant *Tenant, name, hash string) (event.Event, bool) {147+ t.Helper()148+ var raw string149+ err := tenant.store.DB().QueryRowContext(context.Background(), "SELECT raw FROM events WHERE kind=? AND pubkey=? AND d=?", event.KIND_VIEW, tenant.records.PublicKey(), "bind.ws/view/"+name+"/"+hash).Scan(&raw)150+ if err != nil {151+ return event.Event{}, false152+ }153+ e, err := event.Parse([]byte(raw))154+ if err != nil {155+ t.Fatal(err)156+ }157+ return e, true158+}159+160+func artifactTags(e event.Event, name string) []string {161+ var out []string162+ for _, tag := range e.Tags {163+ if len(tag) >= 2 && tag[0] == name {164+ out = append(out, tag[1])165+ }166+ }167+ return out168+}169+170+func signRequestAs(t *testing.T, r *http.Request, secret string) {171+ t.Helper()172+ hash := sha256.Sum256(nil)173+ e := event.Event{Kind: 27235, CreatedAt: time.Now().Unix(), Tags: [][]string{{"u", r.URL.String()}, {"method", r.Method}, {"payload", hex.EncodeToString(hash[:])}}}174+ if err := event.Sign(&e, secret); err != nil {175+ t.Fatal(err)176+ }177+ raw, _ := json.Marshal(e)178+ r.Header.Set("Authorization", "Nostr "+base64.StdEncoding.EncodeToString(raw))179+}180+181+func getArtifact(t *testing.T, tenant *Tenant, path, secret string) *httptest.ResponseRecorder {182+ t.Helper()183+ req := httptest.NewRequest(http.MethodGet, "http://127.0.0.1:8080"+path, nil)184+ if secret != "" {185+ signRequestAs(t, req, secret)186+ }187+ res := httptest.NewRecorder()188+ tenant.ServeHTTP(res, req)189+ return res190+}191+192+func TestCustomViewManagementIsOwnerOnlyAndValidated(t *testing.T) {193+ _, tenant := testTenant(t)194+ ctx := context.Background()195+ owner := tenant.Policy().Owner196+ member, _ := event.PublicKey(testMemberSecret)197+ moderator, _ := event.PublicKey(testModSecret)198+ guest := strings.Repeat("e", 64)199+ setRole(t, tenant, member, "member")200+ setRole(t, tenant, moderator, "moderator")201+ methods, err := tenant.Execute(ctx, owner, "supportedmethods", nil)202+ if err != nil {203+ t.Fatal(err)204+ }205+ all := []string{"listcustomviews", "addcustomview", "removecustomview", "runcustomview", "pausecustomview", "resumecustomview"}206+ for _, name := range all {207+ if !containsString(methods.([]string), name) || !containsString(ManagementMethods(), name) {208+ t.Fatalf("registry lacks %s", name)209+ }210+ }211+ base := map[string]any{"name": "diagrams", "kinds": []int{1, 30023}, "transform": "https://render.example/tiny", "languages": []string{"mermaid"}}212+ for _, actor := range []string{guest, member, moderator} {213+ for _, method := range all {214+ params := []json.RawMessage{json.RawMessage(`"diagrams"`)}215+ if method == "addcustomview" {216+ params = viewParams(base)217+ }218+ if _, err := tenant.Execute(ctx, actor, method, params); err == nil || !strings.HasPrefix(err.Error(), "restricted:") {219+ t.Fatalf("%s by non-owner: %v", method, err)220+ }221+ }222+ }223+ with := func(changes map[string]any) map[string]any {224+ out := map[string]any{}225+ for key, value := range base {226+ out[key] = value227+ }228+ for key, value := range changes {229+ out[key] = value230+ }231+ return out232+ }233+ many := make([]int, viewKindMax+1)234+ for _, tc := range []struct {235+ name string236+ values map[string]any237+ want string238+ }{239+ {"bad name", with(map[string]any{"name": "Diagrams!"}), "invalid: name"},240+ {"long name", with(map[string]any{"name": strings.Repeat("a", 33)}), "invalid: name"},241+ {"no kinds", with(map[string]any{"kinds": []int{}}), "invalid: kinds"},242+ {"too many kinds", with(map[string]any{"kinds": many}), "invalid: kinds"},243+ {"bad kind", with(map[string]any{"kinds": []int{70000}}), "invalid: kinds must be between"},244+ {"http transform", with(map[string]any{"transform": "http://render.example/tiny"}), "invalid: transform"},245+ {"private transform", with(map[string]any{"transform": "https://10.0.0.1/tiny"}), "invalid: transform"},246+ {"bad trigger", with(map[string]any{"trigger": "daily"}), "invalid: trigger"},247+ {"bad audience", with(map[string]any{"audience": "owner"}), "invalid: audience"},248+ {"no languages", with(map[string]any{"languages": []string{}}), "invalid: languages"},249+ {"bad language", with(map[string]any{"languages": []string{"mer maid"}}), "invalid: languages"},250+ {"max bytes cap", with(map[string]any{"max_bytes": viewMaxBytesCap + 1}), "invalid: max_bytes"},251+ {"zero max bytes", with(map[string]any{"max_bytes": 0}), "invalid: max_bytes"},252+ {"short secret", with(map[string]any{"secret": "short"}), "invalid: secret"},253+ {"control in secret", with(map[string]any{"secret": strings.Repeat("a", 15) + "\n"}), "invalid: secret"},254+ } {255+ if _, err := tenant.Execute(ctx, owner, "addcustomview", viewParams(tc.values)); err == nil || !strings.HasPrefix(err.Error(), tc.want) {256+ t.Errorf("%s: err = %v, want prefix %q", tc.name, err, tc.want)257+ }258+ }259+ // A plain form sends kinds and languages as text; defaults fill trigger,260+ // audience and the size limit; the secret is generated and shown once.261+ added := addView(t, tenant, map[string]any{"name": "diagrams", "kinds": "30023, 1, 1", "transform": "https://render.example/tiny", "languages": "Mermaid, dot"})262+ secret, _ := added["secret"].(string)263+ if len(secret) != 64 || added["trigger"] != "write" || added["audience"] != "public" || added["maxBytes"] != viewDefaultMaxBytes || added["host"] != "render.example" {264+ t.Fatalf("added = %v", added)265+ }266+ if kinds, _ := json.Marshal(added["kinds"]); string(kinds) != "[1,30023]" {267+ t.Fatalf("kinds = %s", kinds)268+ }269+ if languages, _ := json.Marshal(added["languages"]); string(languages) != `["mermaid","dot"]` {270+ t.Fatalf("languages = %s", languages)271+ }272+ if _, err := tenant.Execute(ctx, owner, "addcustomview", viewParams(base)); err == nil || !strings.Contains(err.Error(), "exists") {273+ t.Fatalf("duplicate name: %v", err)274+ }275+ // An owner may paste the secret the transform already holds.276+ pasted := addView(t, tenant, with(map[string]any{"name": "charts", "trigger": "hourly", "audience": "members", "max_bytes": 2048, "secret": "shared-secret-value-1234"}))277+ if pasted["secret"] != "shared-secret-value-1234" || pasted["trigger"] != "hourly" || pasted["audience"] != "members" || pasted["maxBytes"] != 2048 {278+ t.Fatalf("pasted = %v", pasted)279+ }280+ var stored string281+ if err := tenant.store.DB().QueryRowContext(ctx, "SELECT secret FROM custom_views WHERE name='charts'").Scan(&stored); err != nil || stored != "shared-secret-value-1234" {282+ t.Fatalf("stored secret %q %v", stored, err)283+ }284+ listed, err := tenant.Execute(ctx, owner, "listcustomviews", nil)285+ if err != nil {286+ t.Fatal(err)287+ }288+ rows := listed.([]map[string]any)289+ if len(rows) != 2 || rows[0]["name"] != "diagrams" || rows[1]["name"] != "charts" {290+ t.Fatalf("listing = %v", rows)291+ }292+ for _, row := range rows {293+ if _, leaked := row["secret"]; leaked || row["state"] != "active" {294+ t.Fatalf("listing row = %v", row)295+ }296+ }297+ paused, err := tenant.Execute(ctx, owner, "pausecustomview", []json.RawMessage{json.RawMessage(`{"name":"diagrams"}`)})298+ if err != nil || paused.(map[string]any)["enabled"] != false || paused.(map[string]any)["state"] != "paused" {299+ t.Fatalf("pause: %v %v", paused, err)300+ }301+ if _, err := tenant.Execute(ctx, owner, "runcustomview", []json.RawMessage{json.RawMessage(`"diagrams"`)}); err == nil || !strings.Contains(err.Error(), "resume") {302+ t.Fatalf("run while paused: %v", err)303+ }304+ for _, view := range tenant.customViewIndex(ctx)[1] {305+ if view.Name == "diagrams" {306+ t.Fatal("paused view stayed in the kind index")307+ }308+ }309+ resumed, err := tenant.Execute(ctx, owner, "resumecustomview", []json.RawMessage{json.RawMessage(`"diagrams"`)})310+ if err != nil || resumed.(map[string]any)["enabled"] != true {311+ t.Fatalf("resume: %v %v", resumed, err)312+ }313+ ran, err := tenant.Execute(ctx, owner, "runcustomview", []json.RawMessage{json.RawMessage(`"diagrams"`)})314+ if err != nil || ran.(map[string]any)["queued"] != 0 {315+ t.Fatalf("run: %v %v", ran, err)316+ }317+ if _, err := tenant.Execute(ctx, owner, "removecustomview", []json.RawMessage{json.RawMessage(`"missing"`)}); err == nil || !strings.HasPrefix(err.Error(), "not found:") {318+ t.Fatalf("remove missing: %v", err)319+ }320+ removed, err := tenant.Execute(ctx, owner, "removecustomview", []json.RawMessage{json.RawMessage(`"charts"`)})321+ if err != nil || removed.(map[string]any)["removed"] != true {322+ t.Fatalf("remove: %v %v", removed, err)323+ }324+ audit, err := tenant.Execute(ctx, owner, "listaudit", nil)325+ if err != nil {326+ t.Fatal(err)327+ }328+ seen := map[string]bool{}329+ for _, row := range audit.([]community.AuditRow) {330+ seen[row.Action] = true331+ if strings.Contains(row.Detail, "render.example") || strings.Contains(row.Target, "render.example") || strings.Contains(row.Detail, secret) {332+ t.Fatalf("audit carries a transform URL or secret: %+v", row)333+ }334+ }335+ for _, action := range all {336+ if action != "listcustomviews" && !seen[action] {337+ t.Fatalf("audit lacks %s: %v", action, seen)338+ }339+ }340+}341+342+func TestCustomViewTransformSendsBlocksAndKeepsSignedArtifacts(t *testing.T) {343+ tenant, server, added := viewTenant(t, []int{1}, nil)344+ ctx := context.Background()345+ secret := added["secret"].(string)346+ server.answer(http.StatusOK, svgArtifact(0), map[string]any{"block": 2, "type": "image/png", "body": base64.StdEncoding.EncodeToString([]byte(testPNGRaw))}, map[string]any{"block": 1, "type": "image/svg+xml", "body": testSVG})347+ now := time.Now().Unix()348+ content := "Look:\n\n```mermaid\ngraph TD;\n A-->B;\n```\n\n```go\nfunc main() {}\n```\n\n~~~ dot\ndigraph { a -> b }\n~~~\n"349+ note := signedEvent(t, testOwnerSecret, 1, now, [][]string{{"expiration", strconv.FormatInt(now+86400, 10)}}, content)350+ if err := publishAs(t, tenant, note); err != nil {351+ t.Fatal(err)352+ }353+ plain := signedEvent(t, testOwnerSecret, 1, now+1, nil, "no blocks here\n```go\nx\n```")354+ if err := publishAs(t, tenant, plain); err != nil {355+ t.Fatal(err)356+ }357+ if len(pendingViewIntents(t, tenant, "diagrams", plain.ID)) != 0 {358+ t.Fatal("an event without a matching block was queued")359+ }360+ runPendingView(t, tenant, "diagrams", note.ID)361+ if server.count() != 1 {362+ t.Fatalf("requests = %d", server.count())363+ }364+ headers, body := server.headers[0], server.requests[0]365+ mac := hmac.New(sha256.New, []byte(secret))366+ mac.Write(body)367+ if headers.Get("Content-Type") != "application/json" || headers.Get("X-Tiny-View") != "diagrams" || headers.Get("X-Tiny-Relay") != "http://127.0.0.1:8080" || headers.Get("X-Tiny-Signature") != "sha256="+hex.EncodeToString(mac.Sum(nil)) {368+ t.Fatalf("headers = %v", headers)369+ }370+ var request map[string]any371+ if err := json.Unmarshal(body, &request); err != nil {372+ t.Fatal(err)373+ }374+ if _, leaked := request["event"]; leaked || request["relay"] != "http://127.0.0.1:8080" || request["view"] != "diagrams" {375+ t.Fatalf("request = %s", body)376+ }377+ if source := request["source"].(map[string]any); source["id"] != note.ID || source["kind"] != float64(1) || len(source) != 2 {378+ t.Fatalf("source = %v", source)379+ }380+ if strings.Contains(string(body), "Look:") || strings.Contains(string(body), tenant.Policy().Owner) {381+ t.Fatalf("request carries event content or author: %s", body)382+ }383+ blocks := request["blocks"].([]any)384+ if len(blocks) != 2 {385+ t.Fatalf("blocks = %v", blocks)386+ }387+ first, third := blocks[0].(map[string]any), blocks[1].(map[string]any)388+ if first["index"] != float64(0) || first["lang"] != "mermaid" || first["source"] != "graph TD;\n A-->B;" || third["index"] != float64(2) || third["lang"] != "dot" || third["source"] != "digraph { a -> b }" {389+ t.Fatalf("blocks = %v", blocks)390+ }391+ // The SVG artifact is a stored relay-signed record attached to the note.392+ svgHash := views.Hash("mermaid", "graph TD;\n A-->B;")393+ artifact, ok := storedArtifact(t, tenant, "diagrams", svgHash)394+ if !ok {395+ t.Fatal("svg artifact was not stored")396+ }397+ if artifact.Content != testSVG || artifact.PubKey != tenant.records.PublicKey() || event.Tag(artifact, "view") != "diagrams" || event.Tag(artifact, "e") != note.ID || event.Tag(artifact, "block") != "0" || event.Tag(artifact, "type") != "image/svg+xml" || event.Tag(artifact, "engine") != "mermaid" || event.Tag(artifact, "expiration") != strconv.FormatInt(now+86400, 10) || len(artifact.Tags[0]) != 1 || artifact.Tags[0][0] != "-" {398+ t.Fatalf("artifact = %+v", artifact)399+ }400+ pngHash := views.Hash("dot", "digraph { a -> b }")401+ png, ok := storedArtifact(t, tenant, "diagrams", pngHash)402+ if !ok || png.Content != base64.StdEncoding.EncodeToString([]byte(testPNGRaw)) || event.Tag(png, "type") != "image/png" || event.Tag(png, "block") != "2" || event.Tag(png, "engine") != "" {403+ t.Fatalf("png artifact = %+v %v", png, ok)404+ }405+ var count int406+ if err := tenant.store.DB().QueryRowContext(ctx, "SELECT count(*) FROM custom_view_artifacts").Scan(&count); err != nil || count != 2 {407+ t.Fatalf("artifacts = %d %v (an artifact for an unsent block was kept)", count, err)408+ }409+ var lastStatus string410+ var failures int411+ if err := tenant.store.DB().QueryRowContext(ctx, "SELECT last_status,failures FROM custom_views WHERE name='diagrams'").Scan(&lastStatus, &failures); err != nil || lastStatus != "ok" || failures != 0 {412+ t.Fatalf("view state %q %d %v", lastStatus, failures, err)413+ }414+415+ // The route serves each artifact under a sandbox, by its own type.416+ res := getArtifact(t, tenant, "/views/diagrams/"+svgHash+".svg", "")417+ if res.Code != http.StatusOK || res.Body.String() != testSVG || res.Header().Get("Content-Type") != "image/svg+xml" || res.Header().Get("Content-Security-Policy") != "sandbox; default-src 'none'; style-src 'unsafe-inline'" || res.Header().Get("X-Content-Type-Options") != "nosniff" || res.Header().Get("Cache-Control") != "public, max-age=31536000, immutable" {418+ t.Fatalf("svg route: %d %v %s", res.Code, res.Header(), res.Body.String())419+ }420+ res = getArtifact(t, tenant, "/views/diagrams/"+pngHash+".png", "")421+ if res.Code != http.StatusOK || res.Body.String() != testPNGRaw || res.Header().Get("Content-Type") != "image/png" {422+ t.Fatalf("png route: %d %v", res.Code, res.Header())423+ }424+ for _, path := range []string{"/views/diagrams/" + pngHash + ".svg", "/views/diagrams/" + strings.Repeat("0", 64) + ".svg", "/views/other/" + svgHash + ".svg", "/views/diagrams/" + svgHash + ".gif", "/views/diagrams/" + svgHash, "/views/diagrams"} {425+ if res := getArtifact(t, tenant, path, ""); res.Code != http.StatusNotFound {426+ t.Fatalf("%s: %d", path, res.Code)427+ }428+ }429+430+ // A second note with the same block reuses the artifact: no POST, a431+ // fresh record with both sources.432+ second := signedEvent(t, testOwnerSecret, 1, now+2, nil, "Again:\n\n```mermaid\ngraph TD;\n A-->B;\n```\n")433+ if err := publishAs(t, tenant, second); err != nil {434+ t.Fatal(err)435+ }436+ runPendingView(t, tenant, "diagrams", second.ID)437+ if server.count() != 1 {438+ t.Fatalf("requests after reuse = %d", server.count())439+ }440+ artifact, _ = storedArtifact(t, tenant, "diagrams", svgHash)441+ if sources := artifactTags(artifact, "e"); len(sources) != 2 || sources[0] != note.ID || sources[1] != second.ID {442+ t.Fatalf("sources after reuse = %v", sources)443+ }444+ if event.Tag(artifact, "expiration") != "" {445+ t.Fatal("artifact kept an expiration while a source without one is attached")446+ }447+ if blocks := artifactTags(artifact, "block"); len(blocks) != 2 || blocks[0] != "0" || blocks[1] != "0" {448+ t.Fatalf("block tags = %v", blocks)449+ }450+451+ // Deleting the first note detaches it; deleting the second removes the452+ // artifact and its record.453+ deletion := signedEvent(t, testOwnerSecret, event.KIND_DELETION, now+3, [][]string{{"e", note.ID}}, "")454+ if err := publishAs(t, tenant, deletion); err != nil {455+ t.Fatal(err)456+ }457+ artifact, ok = storedArtifact(t, tenant, "diagrams", svgHash)458+ if !ok || len(artifactTags(artifact, "e")) != 1 || event.Tag(artifact, "e") != second.ID {459+ t.Fatalf("artifact after first deletion = %+v %v", artifact, ok)460+ }461+ if _, ok := storedArtifact(t, tenant, "diagrams", pngHash); ok {462+ t.Fatal("png artifact survived the deletion of its only source")463+ }464+ if res := getArtifact(t, tenant, "/views/diagrams/"+pngHash+".png", ""); res.Code != http.StatusNotFound {465+ t.Fatalf("png route after deletion: %d", res.Code)466+ }467+ deletion = signedEvent(t, testOwnerSecret, event.KIND_DELETION, now+4, [][]string{{"e", second.ID}}, "")468+ if err := publishAs(t, tenant, deletion); err != nil {469+ t.Fatal(err)470+ }471+ if _, ok := storedArtifact(t, tenant, "diagrams", svgHash); ok {472+ t.Fatal("svg artifact survived the deletion of its last source")473+ }474+ if err := tenant.store.DB().QueryRowContext(ctx, "SELECT count(*) FROM custom_view_artifacts").Scan(&count); err != nil || count != 0 {475+ t.Fatalf("artifacts after deletions = %d %v", count, err)476+ }477+478+ // Expiry: an expired source loses its artifacts at the sweep.479+ expiring := signedEvent(t, testOwnerSecret, 1, now+5, [][]string{{"expiration", strconv.FormatInt(now+30, 10)}}, "```dot\nx\n```")480+ if err := publishAs(t, tenant, expiring); err != nil {481+ t.Fatal(err)482+ }483+ server.answer(http.StatusOK, svgArtifact(0))484+ runPendingView(t, tenant, "diagrams", expiring.ID)485+ expiringHash := views.Hash("dot", "x")486+ if _, ok := storedArtifact(t, tenant, "diagrams", expiringHash); !ok {487+ t.Fatal("expiring artifact was not stored")488+ }489+ if err := tenant.sweep(ctx, now+60); err != nil {490+ t.Fatal(err)491+ }492+ if _, ok := storedArtifact(t, tenant, "diagrams", expiringHash); ok {493+ t.Fatal("artifact survived its source's expiry")494+ }495+ if res := getArtifact(t, tenant, "/views/diagrams/"+expiringHash+".svg", ""); res.Code != http.StatusNotFound {496+ t.Fatalf("expired route: %d", res.Code)497+ }498+499+ // Removing the view removes what is left.500+ last := signedEvent(t, testOwnerSecret, 1, now+6, nil, "```dot\ny\n```")501+ if err := publishAs(t, tenant, last); err != nil {502+ t.Fatal(err)503+ }504+ runPendingView(t, tenant, "diagrams", last.ID)505+ if _, err := tenant.Execute(ctx, tenant.Policy().Owner, "removecustomview", []json.RawMessage{json.RawMessage(`"diagrams"`)}); err != nil {506+ t.Fatal(err)507+ }508+ if err := tenant.store.DB().QueryRowContext(ctx, "SELECT count(*) FROM events WHERE kind=? AND pubkey=?", event.KIND_VIEW, tenant.records.PublicKey()).Scan(&count); err != nil || count != 0 {509+ t.Fatalf("records after removal = %d %v", count, err)510+ }511+}512+513+func TestCustomViewReplacementReusesAndDropsBlocks(t *testing.T) {514+ tenant, server, _ := viewTenant(t, []int{30023}, nil)515+ ctx := context.Background()516+ now := time.Now().Unix()517+ server.answer(http.StatusOK, svgArtifact(0), svgArtifact(1))518+ first := signedEvent(t, testOwnerSecret, 30023, now, [][]string{{"d", "post"}}, "```mermaid\nA\n```\n\n```mermaid\nB\n```\n")519+ if err := publishAs(t, tenant, first); err != nil {520+ t.Fatal(err)521+ }522+ runPendingView(t, tenant, "diagrams", first.ID)523+ hashA, hashB := views.Hash("mermaid", "A"), views.Hash("mermaid", "B")524+ if _, ok := storedArtifact(t, tenant, "diagrams", hashA); !ok {525+ t.Fatal("A missing")526+ }527+ if _, ok := storedArtifact(t, tenant, "diagrams", hashB); !ok {528+ t.Fatal("B missing")529+ }530+ // The replacement keeps A and drops B; A is reused without a POST and531+ // carries only the new version.532+ second := signedEvent(t, testOwnerSecret, 30023, now+1, [][]string{{"d", "post"}}, "```mermaid\nA\n```\n")533+ if err := publishAs(t, tenant, second); err != nil {534+ t.Fatal(err)535+ }536+ runPendingView(t, tenant, "diagrams", second.ID)537+ if server.count() != 1 {538+ t.Fatalf("requests = %d", server.count())539+ }540+ artifact, ok := storedArtifact(t, tenant, "diagrams", hashA)541+ if !ok || len(artifactTags(artifact, "e")) != 1 || event.Tag(artifact, "e") != second.ID {542+ t.Fatalf("A after replacement = %+v %v", artifact, ok)543+ }544+ if _, ok := storedArtifact(t, tenant, "diagrams", hashB); ok {545+ t.Fatal("B survived the replacement")546+ }547+ // A stale intent for the replaced version renders nothing.548+ stale := tenant.viewIntent(customView{Name: "diagrams"}, first, "old")549+ if err := tenant.handleViewTransform(ctx, work.Intent{ID: "stale", Kind: stale.Kind, EventID: stale.EventID, Target: stale.Target, Payload: stale.Payload}); err != nil {550+ t.Fatal(err)551+ }552+ if server.count() != 1 {553+ t.Fatal("a replaced version was sent to the transform")554+ }555+ // Deleting the address removes the artifact.556+ deletion := signedEvent(t, testOwnerSecret, event.KIND_DELETION, now+2, [][]string{{"a", "30023:" + tenant.Policy().Owner + ":post"}}, "")557+ if err := publishAs(t, tenant, deletion); err != nil {558+ t.Fatal(err)559+ }560+ if _, ok := storedArtifact(t, tenant, "diagrams", hashA); ok {561+ t.Fatal("A survived the address deletion")562+ }563+}564+565+func TestCustomViewTransformRetriesThenPauses(t *testing.T) {566+ tenant, server, _ := viewTenant(t, []int{1}, nil)567+ ctx := context.Background()568+ now := time.Now().Unix()569+ server.answer(http.StatusInternalServerError)570+ note := signedEvent(t, testOwnerSecret, 1, now, nil, "```mermaid\nA\n```")571+ if err := publishAs(t, tenant, note); err != nil {572+ t.Fatal(err)573+ }574+ queued := pendingViewIntents(t, tenant, "diagrams", note.ID)575+ if len(queued) != 1 {576+ t.Fatalf("queued = %d", len(queued))577+ }578+ runViewIntent(t, tenant, queued[0])579+ queued = pendingViewIntents(t, tenant, "diagrams", note.ID)580+ if len(queued) != 1 || queued[0].EventID != note.ID+"#2" || queued[0].NextAt.Unix() < now+55 || queued[0].NextAt.Unix() > now+70 {581+ t.Fatalf("second attempt = %+v", queued)582+ }583+ runViewIntent(t, tenant, queued[0])584+ queued = pendingViewIntents(t, tenant, "diagrams", note.ID)585+ if len(queued) != 1 || queued[0].EventID != note.ID+"#3" || queued[0].NextAt.Unix() < now+295 || queued[0].NextAt.Unix() > now+310 {586+ t.Fatalf("third attempt = %+v", queued)587+ }588+ runViewIntent(t, tenant, queued[0])589+ if queued = pendingViewIntents(t, tenant, "diagrams", note.ID); len(queued) != 0 {590+ t.Fatalf("fourth attempt queued: %+v", queued)591+ }592+ var failures, enabled int593+ var status string594+ state := func() {595+ t.Helper()596+ if err := tenant.store.DB().QueryRowContext(ctx, "SELECT failures,enabled,last_status FROM custom_views WHERE name='diagrams'").Scan(&failures, &enabled, &status); err != nil {597+ t.Fatal(err)598+ }599+ }600+ state()601+ if failures != 3 || enabled != 1 || status != "HTTP 500" {602+ t.Fatalf("after three failures: %d %d %q", failures, enabled, status)603+ }604+ if server.count() != 3 {605+ t.Fatalf("requests = %d", server.count())606+ }607+ // A 200 that is not JSON is a failure too.608+ server.mu.Lock()609+ server.status, server.body = http.StatusOK, "<html>"610+ server.mu.Unlock()611+ other := signedEvent(t, testOwnerSecret, 1, now+1, nil, "```mermaid\nB\n```")612+ if err := publishAs(t, tenant, other); err != nil {613+ t.Fatal(err)614+ }615+ runPendingView(t, tenant, "diagrams", other.ID)616+ state()617+ if failures != 4 || status != "invalid response" {618+ t.Fatalf("after bad json: %d %q", failures, status)619+ }620+ // The twentieth failure in a row pauses the view; nothing new is queued.621+ if _, err := tenant.store.DB().ExecContext(ctx, "UPDATE custom_views SET failures=19 WHERE name='diagrams'"); err != nil {622+ t.Fatal(err)623+ }624+ server.answer(http.StatusBadGateway)625+ third := signedEvent(t, testOwnerSecret, 1, now+2, nil, "```mermaid\nC\n```")626+ if err := publishAs(t, tenant, third); err != nil {627+ t.Fatal(err)628+ }629+ runPendingView(t, tenant, "diagrams", third.ID)630+ state()631+ if failures != 20 || enabled != 0 || status != "paused after 20 failures: HTTP 502" {632+ t.Fatalf("after twenty failures: %d %d %q", failures, enabled, status)633+ }634+ if queued := pendingViewIntents(t, tenant, "diagrams", third.ID); len(queued) != 0 {635+ t.Fatalf("paused view queued a retry: %+v", queued)636+ }637+ fourth := signedEvent(t, testOwnerSecret, 1, now+3, nil, "```mermaid\nD\n```")638+ if err := publishAs(t, tenant, fourth); err != nil {639+ t.Fatal(err)640+ }641+ if queued := pendingViewIntents(t, tenant, "diagrams", fourth.ID); len(queued) != 0 {642+ t.Fatal("paused view queued a transform")643+ }644+ // Resuming clears the count; a success resets it after a failure.645+ if _, err := tenant.Execute(ctx, tenant.Policy().Owner, "resumecustomview", []json.RawMessage{json.RawMessage(`"diagrams"`)}); err != nil {646+ t.Fatal(err)647+ }648+ server.answer(http.StatusOK, svgArtifact(0))649+ fifth := signedEvent(t, testOwnerSecret, 1, now+4, nil, "```mermaid\nE\n```")650+ if err := publishAs(t, tenant, fifth); err != nil {651+ t.Fatal(err)652+ }653+ runPendingView(t, tenant, "diagrams", fifth.ID)654+ state()655+ if failures != 0 || enabled != 1 || status != "ok" {656+ t.Fatalf("after success: %d %d %q", failures, enabled, status)657+ }658+}659+660+func TestCustomViewArtifactValidation(t *testing.T) {661+ png := base64.StdEncoding.EncodeToString([]byte(testPNGRaw))662+ for _, tc := range []struct {663+ name string664+ artifact viewArtifact665+ max int666+ want string667+ }{668+ {"svg", viewArtifact{Type: "image/svg+xml", Body: testSVG}, 1024, ""},669+ {"svg fragment href", viewArtifact{Type: "image/svg+xml", Body: `<svg><use href="#a"/><a xlink:href='#b'/></svg>`}, 1024, ""},670+ {"png", viewArtifact{Type: "image/png", Body: png}, 1024, ""},671+ {"png raw base64", viewArtifact{Type: "image/png", Body: strings.TrimRight(png, "=")}, 1024, ""},672+ {"type", viewArtifact{Type: "text/html", Body: "<p>"}, 1024, "invalid: artifact type"},673+ {"svg size", viewArtifact{Type: "image/svg+xml", Body: testSVG}, 10, "invalid: artifact exceeds"},674+ {"png size", viewArtifact{Type: "image/png", Body: png}, 4, "invalid: artifact exceeds"},675+ {"png base64", viewArtifact{Type: "image/png", Body: "not base64!"}, 1024, "invalid: png body must be base64"},676+ {"png signature", viewArtifact{Type: "image/png", Body: base64.StdEncoding.EncodeToString([]byte("GIF89a"))}, 1024, "invalid: png body is not a PNG"},677+ {"svg element", viewArtifact{Type: "image/svg+xml", Body: "<div/>"}, 1024, "invalid: svg body has no svg"},678+ {"script", viewArtifact{Type: "image/svg+xml", Body: `<svg><SCRIPT>alert(1)</SCRIPT></svg>`}, 1024, "invalid: svg contains script"},679+ {"handler", viewArtifact{Type: "image/svg+xml", Body: `<svg onload = "x()"/>`}, 1024, "invalid: svg contains an event handler"},680+ {"foreign object", viewArtifact{Type: "image/svg+xml", Body: `<svg><foreignobject/></svg>`}, 1024, "invalid: svg contains a foreign object"},681+ {"javascript href", viewArtifact{Type: "image/svg+xml", Body: `<svg><a href="javascript:alert(1)"/></svg>`}, 1024, "invalid: svg contains a javascript"},682+ {"external href", viewArtifact{Type: "image/svg+xml", Body: `<svg><image href="https://evil.example/x.png"/></svg>`}, 1024, "invalid: svg references an external"},683+ {"external xlink", viewArtifact{Type: "image/svg+xml", Body: `<svg><use xlink:href='/x.svg#a'/></svg>`}, 1024, "invalid: svg references an external"},684+ {"unquoted href", viewArtifact{Type: "image/svg+xml", Body: `<svg><a href=data:text/html,x>`}, 1024, "invalid: svg references an external"},685+ } {686+ _, err := checkArtifact(tc.artifact, tc.max)687+ if tc.want == "" && err != nil {688+ t.Errorf("%s: unexpected %v", tc.name, err)689+ }690+ if tc.want != "" && (err == nil || !strings.HasPrefix(err.Error(), tc.want)) {691+ t.Errorf("%s: err = %v, want prefix %q", tc.name, err, tc.want)692+ }693+ }694+ if body, err := checkArtifact(viewArtifact{Type: "image/png", Body: " " + png + "\n"}, 1024); err != nil || body != png {695+ t.Fatalf("png normalization: %q %v", body, err)696+ }697+}698+699+func TestCustomViewMembersAudienceStaysOffTheStore(t *testing.T) {700+ tenant, server, added := viewTenant(t, []int{1}, map[string]any{"audience": "members"})701+ ctx := context.Background()702+ member, _ := event.PublicKey(testMemberSecret)703+ setRole(t, tenant, member, "member")704+ now := time.Now().Unix()705+ server.answer(http.StatusOK, svgArtifact(0))706+ note := signedEvent(t, testOwnerSecret, 1, now, nil, "```mermaid\nA\n```")707+ if err := publishAs(t, tenant, note); err != nil {708+ t.Fatal(err)709+ }710+ runPendingView(t, tenant, "diagrams", note.ID)711+ hash := views.Hash("mermaid", "A")712+ if _, ok := storedArtifact(t, tenant, "diagrams", hash); ok {713+ t.Fatal("a members-only artifact became a stored record")714+ }715+ var raw string716+ if err := tenant.store.DB().QueryRowContext(ctx, "SELECT raw FROM custom_view_artifacts WHERE view='diagrams' AND hash=?", hash).Scan(&raw); err != nil {717+ t.Fatal(err)718+ }719+ signed, err := event.Parse([]byte(raw))720+ if err != nil || signed.PubKey != tenant.records.PublicKey() || event.Tag(signed, "d") != "bind.ws/view/diagrams/"+hash {721+ t.Fatalf("members artifact record = %+v %v", signed, err)722+ }723+ path := "/views/diagrams/" + hash + ".svg"724+ if res := getArtifact(t, tenant, path, ""); res.Code != http.StatusUnauthorized {725+ t.Fatalf("guest: %d", res.Code)726+ }727+ if res := getArtifact(t, tenant, path, strings.Repeat("7", 64)); res.Code != http.StatusForbidden {728+ t.Fatalf("outsider: %d", res.Code)729+ }730+ for _, secret := range []string{testMemberSecret, testOwnerSecret} {731+ res := getArtifact(t, tenant, path, secret)732+ if res.Code != http.StatusOK || res.Body.String() != testSVG || res.Header().Get("Cache-Control") != "private, max-age=31536000, immutable" {733+ t.Fatalf("member: %d %v", res.Code, res.Header())734+ }735+ }736+ // A public view on a members-only relay follows the read rule.737+ p := tenant.Policy()738+ p.Reads = "members"739+ if err := tenant.applyPolicy(ctx, p); err != nil {740+ t.Fatal(err)741+ }742+ addView(t, tenant, map[string]any{"name": "open", "kinds": []int{1}, "transform": added["transform"], "languages": []string{"dot"}})743+ other := signedEvent(t, testOwnerSecret, 1, now+1, nil, "```dot\nB\n```")744+ if err := publishAs(t, tenant, other); err != nil {745+ t.Fatal(err)746+ }747+ runPendingView(t, tenant, "open", other.ID)748+ openPath := "/views/open/" + views.Hash("dot", "B") + ".svg"749+ if res := getArtifact(t, tenant, openPath, ""); res.Code != http.StatusUnauthorized && res.Code != http.StatusForbidden {750+ t.Fatalf("guest on a members relay: %d", res.Code)751+ }752+ if res := getArtifact(t, tenant, openPath, testMemberSecret); res.Code != http.StatusOK {753+ t.Fatalf("member on a members relay: %d %s", res.Code, res.Body.String())754+ }755+}756+757+func TestCustomViewHourlyTickQueuesRecentSources(t *testing.T) {758+ tenant, server, _ := viewTenant(t, []int{1}, map[string]any{"trigger": "hourly"})759+ ctx := context.Background()760+ now := time.Now().Unix()761+ server.answer(http.StatusOK, svgArtifact(0))762+ note := signedEvent(t, testOwnerSecret, 1, now, nil, "```mermaid\nA\n```")763+ if err := publishAs(t, tenant, note); err != nil {764+ t.Fatal(err)765+ }766+ if queued := pendingViewIntents(t, tenant, "diagrams", note.ID); len(queued) != 0 {767+ t.Fatal("an hourly view queued at write time")768+ }769+ if err := tenant.tickCustomViews(ctx, now); err != nil {770+ t.Fatal(err)771+ }772+ queued := pendingViewIntents(t, tenant, "diagrams", note.ID)773+ if len(queued) != 1 || !strings.HasPrefix(queued[0].EventID, note.ID+"@") {774+ t.Fatalf("hourly queue = %+v", queued)775+ }776+ runViewIntent(t, tenant, queued[0])777+ if _, ok := storedArtifact(t, tenant, "diagrams", views.Hash("mermaid", "A")); !ok {778+ t.Fatal("hourly artifact missing")779+ }780+ // Not due again for an hour; a backfill through runcustomview queues now.781+ if err := tenant.tickCustomViews(ctx, now+60); err != nil {782+ t.Fatal(err)783+ }784+ if queued := pendingViewIntents(t, tenant, "diagrams", note.ID); len(queued) != 0 {785+ t.Fatal("hourly view queued again within the hour")786+ }787+ ran, err := tenant.Execute(ctx, tenant.Policy().Owner, "runcustomview", []json.RawMessage{json.RawMessage(`"diagrams"`)})788+ if err != nil || ran.(map[string]any)["queued"] != 1 {789+ t.Fatalf("runcustomview: %v %v", ran, err)790+ }791+ queued = pendingViewIntents(t, tenant, "diagrams", note.ID)792+ if len(queued) != 1 {793+ t.Fatalf("backfill queue = %+v", queued)794+ }795+ runViewIntent(t, tenant, queued[0])796+ if server.count() != 1 {797+ t.Fatalf("backfill re-rendered a block with an artifact: %d requests", server.count())798+ }799+}800+801+func TestCustomViewRendersTheReadmeAtTheStateHead(t *testing.T) {802+ tenant, server, _ := viewTenant(t, []int{event.KIND_REPO_STATE}, nil)803+ ctx := context.Background()804+ p := tenant.Policy()805+ p.Features.Grasp = true806+ if err := tenant.applyPolicy(ctx, p); err != nil {807+ t.Fatal(err)808+ }809+ owner := tenant.Policy().Owner810+ announcement := signedEvent(t, testOwnerSecret, event.KIND_REPO, time.Now().Unix(), [][]string{{"d", "docs"}, {"clone", "http://127.0.0.1:8080/npub10xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqpkge6d/docs.git"}, {"relays", "ws://127.0.0.1:8080"}}, "")811+ if err := publishAs(t, tenant, announcement); err != nil {812+ t.Fatal(err)813+ }814+ source := filepath.Join(t.TempDir(), "source.git")815+ gitTest(t, "init", "--bare", source)816+ work := filepath.Join(t.TempDir(), "work")817+ gitTest(t, "clone", source, work)818+ gitTest(t, "-C", work, "config", "user.email", "test@example.com")819+ gitTest(t, "-C", work, "config", "user.name", "Test")820+ if err := os.WriteFile(filepath.Join(work, "README.md"), []byte("# Docs\n\n```go\nx\n```\n\n```mermaid\ngraph LR; a-->b;\n```\n"), 0o600); err != nil {821+ t.Fatal(err)822+ }823+ gitTest(t, "-C", work, "add", "README.md")824+ gitTest(t, "-C", work, "commit", "-m", "readme")825+ gitTest(t, "-C", work, "branch", "-M", "main")826+ sha := strings.TrimSpace(gitOutput(t, "-C", work, "rev-parse", "HEAD"))827+ state := signedEvent(t, testOwnerSecret, event.KIND_REPO_STATE, time.Now().Unix(), [][]string{{"d", "docs"}, {"HEAD", "ref: refs/heads/main"}, {"refs/heads/main", sha}}, "")828+ if err := publishAs(t, tenant, state); err != nil {829+ t.Fatal(err)830+ }831+ if queued := pendingViewIntents(t, tenant, "diagrams", state.ID); len(queued) != 0 {832+ t.Fatal("a state was queued before its objects arrived")833+ }834+ relay := httptest.NewServer(tenant)835+ defer relay.Close()836+ gitTest(t, "-C", work, "push", relay.URL+"/npub10xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqpkge6d/docs.git", "refs/heads/main:refs/heads/main")837+ blocks, err := tenant.sourceBlocks(ctx, state)838+ if err != nil {839+ t.Fatal(err)840+ }841+ if len(blocks) != 2 || blocks[1].Index != 1 || blocks[1].Lang != "mermaid" || blocks[1].Source != "graph LR; a-->b;" {842+ t.Fatalf("readme blocks = %+v", blocks)843+ }844+ queued := pendingViewIntents(t, tenant, "diagrams", state.ID)845+ if len(queued) != 1 {846+ t.Fatalf("state intents after push = %d", len(queued))847+ }848+ server.answer(http.StatusOK, svgArtifact(1))849+ runViewIntent(t, tenant, queued[0])850+ var request map[string]any851+ if err := json.Unmarshal(server.requests[0], &request); err != nil {852+ t.Fatal(err)853+ }854+ coordinate := "30618:" + owner + ":docs"855+ if source := request["source"].(map[string]any); source["id"] != coordinate || source["kind"] != float64(30618) {856+ t.Fatalf("state source = %v", source)857+ }858+ if blocks := request["blocks"].([]any); len(blocks) != 1 || blocks[0].(map[string]any)["index"] != float64(1) {859+ t.Fatalf("state blocks = %v", blocks)860+ }861+ artifact, ok := storedArtifact(t, tenant, "diagrams", views.Hash("mermaid", "graph LR; a-->b;"))862+ if !ok || event.Tag(artifact, "a") != coordinate || event.Tag(artifact, "e") != "" || event.Tag(artifact, "block") != "1" {863+ t.Fatalf("state artifact = %+v %v", artifact, ok)864+ }865+}866+867+func TestMCPCustomViewTools(t *testing.T) {868+ app, tenant := testTenant(t)869+ member, _ := event.PublicKey(testMemberSecret)870+ setRole(t, tenant, member, "member")871+ call := func(name string, arguments map[string]any, secret string) (map[string]any, bool) {872+ t.Helper()873+ w, response := mcpCall{method: "tools/call", name: name, arguments: arguments, sign: true, secret: secret}.do(t, app, "/mcp")874+ if w.Code != http.StatusOK {875+ t.Fatalf("%s: %d %s", name, w.Code, w.Body.String())876+ }877+ return mcpToolResult(t, response)878+ }879+ structured := func(result map[string]any) any {880+ value, _ := result["structuredContent"].(map[string]any)881+ return value["result"]882+ }883+ text := func(result map[string]any) string {884+ content, _ := result["content"].([]any)885+ if len(content) == 0 {886+ return ""887+ }888+ return content[0].(map[string]any)["text"].(string)889+ }890+ _, response := mcpCall{method: "tools/list", sign: true}.do(t, app, "/mcp")891+ names := map[string]bool{}892+ for _, tool := range response.Result.(map[string]any)["tools"].([]any) {893+ names[tool.(map[string]any)["name"].(string)] = true894+ }895+ for _, want := range []string{"list_custom_views", "add_custom_view", "remove_custom_view", "pause_custom_view", "resume_custom_view", "run_custom_view"} {896+ if !names[want] {897+ t.Fatalf("missing tool %s", want)898+ }899+ }900+ definition := map[string]any{"name": "diagrams", "kinds": []any{1.0, 30023.0}, "transform": "https://render.example/tiny", "languages": []any{"mermaid"}}901+ result, isError := call("add_custom_view", definition, testMemberSecret)902+ if !isError || !strings.Contains(text(result), "restricted") {903+ t.Fatalf("add_custom_view as member: %v %s", isError, text(result))904+ }905+ result, isError = call("add_custom_view", definition, "")906+ added, _ := structured(result).(map[string]any)907+ secret, _ := added["secret"].(string)908+ if isError || len(secret) != 64 || added["name"] != "diagrams" || added["trigger"] != "write" {909+ t.Fatalf("add_custom_view: %v %s", isError, text(result))910+ }911+ definition["name"], definition["secret"], definition["trigger"], definition["audience"], definition["max_bytes"] = "charts", "shared-secret-value-1234", "hourly", "members", 4096.0912+ result, isError = call("add_custom_view", definition, "")913+ if pasted, _ := structured(result).(map[string]any); isError || pasted["secret"] != "shared-secret-value-1234" || pasted["trigger"] != "hourly" || pasted["audience"] != "members" || pasted["maxBytes"] != float64(4096) {914+ t.Fatalf("add_custom_view with a secret: %v %s", isError, text(result))915+ }916+ result, isError = call("list_custom_views", nil, "")917+ rows, _ := structured(result).([]any)918+ if isError || len(rows) != 2 || strings.Contains(text(result), secret) || strings.Contains(text(result), "shared-secret-value-1234") {919+ t.Fatalf("list_custom_views: %v %s", isError, text(result))920+ }921+ if result, isError = call("list_custom_views", nil, testMemberSecret); !isError {922+ t.Fatalf("list_custom_views as member: %s", text(result))923+ }924+ result, isError = call("pause_custom_view", map[string]any{"name": "diagrams"}, "")925+ if paused, _ := structured(result).(map[string]any); isError || paused["enabled"] != false {926+ t.Fatalf("pause_custom_view: %v %s", isError, text(result))927+ }928+ result, isError = call("resume_custom_view", map[string]any{"name": "diagrams"}, "")929+ if resumed, _ := structured(result).(map[string]any); isError || resumed["enabled"] != true {930+ t.Fatalf("resume_custom_view: %v %s", isError, text(result))931+ }932+ result, isError = call("run_custom_view", map[string]any{"name": "diagrams"}, "")933+ if ran, _ := structured(result).(map[string]any); isError || ran["queued"] != float64(0) {934+ t.Fatalf("run_custom_view: %v %s", isError, text(result))935+ }936+ result, isError = call("remove_custom_view", map[string]any{"name": "charts"}, "")937+ if removed, _ := structured(result).(map[string]any); isError || removed["removed"] != true {938+ t.Fatalf("remove_custom_view: %v %s", isError, text(result))939+ }940+ result, isError = call("remove_custom_view", map[string]any{"name": "charts"}, "")941+ if !isError || !strings.Contains(text(result), "not found") {942+ t.Fatalf("remove_custom_view twice: %v %s", isError, text(result))943+ }944+}@@ -323,9 +323,11 @@ func (t *Tenant) retryViewTransform(ctx context.Context, intent work.Intent, pay
323323 return err324324 }325325 now := time.Now().Unix()326- key := sha256.Sum256([]byte(viewTransform + "\x00" + intent.EventID + "\x00" + intent.Target + "\x00" + fmt.Sprint(payload.Attempt)))326+ // The intent id names the source and run; earlier attempts add "#n".327+ source, _, _ := strings.Cut(intent.EventID, "#")328+ key := sha256.Sum256([]byte(viewTransform + "\x00" + source + "\x00" + intent.Target + "\x00" + fmt.Sprint(payload.Attempt)))327329 return t.store.WithTx(ctx, func(tx *sql.Tx) error {328- _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO work_intents(id,kind,event_id,target,payload,next_at,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)`, hex.EncodeToString(key[:]), viewTransform, intent.EventID+"#"+fmt.Sprint(payload.Attempt), intent.Target, string(encoded), now+int64(delay/time.Second), now, now)330+ _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO work_intents(id,kind,event_id,target,payload,next_at,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)`, hex.EncodeToString(key[:]), viewTransform, source+"#"+fmt.Sprint(payload.Attempt), intent.Target, string(encoded), now+int64(delay/time.Second), now, now)329331 return err330332 })331333 }@@ -686,12 +688,13 @@ func (t *Tenant) dropOrphanArtifacts(ctx context.Context, name string) error {
686688 if err != nil {687689 return nil688690 }689- rows, err := t.store.DB().QueryContext(ctx, `SELECT a.hash, a.raw FROM custom_view_artifacts a WHERE a.view=?`, name)691+ rows, err := t.store.DB().QueryContext(ctx, `SELECT hash, raw FROM custom_view_artifacts WHERE view=?`, name)690692 if err != nil {691693 return err692694 }693- type stale struct{ hash string }694- var refresh []stale695+ // Read the rows out before asking about sources: the store lends one696+ // connection at a time.697+ tagged := map[string][]string{}695698 for rows.Next() {696699 var hash, raw string697700 if err := rows.Scan(&hash, &raw); err != nil {@@ -702,28 +705,28 @@ func (t *Tenant) dropOrphanArtifacts(ctx context.Context, name string) error {
702705 if json.Unmarshal([]byte(raw), &signed) != nil {703706 continue704707 }705- var tagged []string708+ sources := []string{}706709 for _, tag := range signed.Tags {707710 if len(tag) == 2 && (tag[0] == "e" || tag[0] == "a") {708- tagged = append(tagged, tag[1])711+ sources = append(sources, tag[1])709712 }710713 }714+ tagged[hash] = sources715+ }716+ if err := errors.Join(rows.Err(), rows.Close()); err != nil {717+ return err718+ }719+ for hash, sources := range tagged {711720 current, err := t.artifactSources(ctx, name, hash)712721 if err != nil {713- rows.Close()714722 return err715723 }716- sort.Strings(tagged)724+ sort.Strings(sources)717725 sort.Strings(current)718- if strings.Join(tagged, "\n") != strings.Join(current, "\n") {719- refresh = append(refresh, stale{hash})726+ if strings.Join(sources, "\n") == strings.Join(current, "\n") {727+ continue720728 }721- }722- if err := errors.Join(rows.Err(), rows.Close()); err != nil {723- return err724- }725- for _, item := range refresh {726- if err := t.signArtifact(ctx, view, item.hash, time.Now().Unix()); err != nil {729+ if err := t.signArtifact(ctx, view, hash, time.Now().Unix()); err != nil {727730 return err728731 }729732 }@@ -91,8 +91,8 @@ func (t *Tenant) llmsHTTP(w http.ResponseWriter, r *http.Request) {
9191 "- Endpoint: "+base+"/mcp accepts POST only. Protocol version "+mcp.Version+", stateless Streamable HTTP, JSON responses.",9292 "- Headers: `MCP-Protocol-Version: "+mcp.Version+"`, `Mcp-Method` equal to the JSON-RPC method and, for tools/call, `Mcp-Name` equal to params.name. The body's params._meta carries `"+mcp.MetaProtocolVersion+"` and `"+mcp.MetaClientCapabilities+"`.",9393 "- Methods: server/discover, tools/list and tools/call.",94- "- Read tools: list_repositories, read_repository, list_issues, read_issue, list_pull_requests, read_pull_request, list_files, read_file, read_status, read_management, list_rooms, read_room, read_thread, list_wiki, read_wiki_page, read_merge_request, list_agents, list_callbacks, list_join_requests, list_jobs and read_job.",95- "- Management tools: run_job, add_job, remove_job, backup_now, dump_now, set_policy, set_connections, send_test_notification, pause_agent, resume_agent, revoke_agent, pause_all_agents, resume_all_agents, approve_join, deny_join, add_callback, remove_callback, pause_callback and resume_callback. approve_join and deny_join answer access requests from people who asked to join without an invite. A callback POSTs each new matching event to an https URL, signed with X-Tiny-Signature.",94+ "- Read tools: list_repositories, read_repository, list_issues, read_issue, list_pull_requests, read_pull_request, list_files, read_file, read_status, read_management, list_rooms, read_room, read_thread, list_wiki, read_wiki_page, read_merge_request, list_agents, list_callbacks, list_custom_views, list_join_requests, list_jobs and read_job.",95+ "- Management tools: run_job, add_job, remove_job, backup_now, dump_now, set_policy, set_connections, send_test_notification, pause_agent, resume_agent, revoke_agent, pause_all_agents, resume_all_agents, approve_join, deny_join, add_callback, remove_callback, pause_callback, resume_callback, add_custom_view, remove_custom_view, pause_custom_view, resume_custom_view and run_custom_view. approve_join and deny_join answer access requests from people who asked to join without an invite. A callback POSTs each new matching event to an https URL, signed with X-Tiny-Signature. A custom view POSTs the fenced code blocks of matching events to an https transform and keeps the SVG or PNG it returns as signed artifacts served at /views/<name>/<hash>.svg.",9696 "- Write tools take a signed event, or return the unsigned event to sign when called with plain fields: publish_event, create_issue, create_pull_request, comment, set_status, post_message, start_thread, reply_in_thread, react, publish_wiki_page, propose_wiki_merge, publish_site, create_room, request_decision, request_job, job_feedback and job_result. A decision request is answered by a kind 7 reaction from the person asked; a job request is answered by kind 7000 feedback and a result of the request kind plus 1000. publish_site takes [path, sha256] pairs for files already in the blob store and publishes a NIP-5A manifest; an agent needs a sites grant, and a grant with a ttl requires an expiration tag.",9797 "",9898 "## Files",@@ -133,6 +133,9 @@ func (t *Tenant) mcpTools() (*mcp.Registry, error) {
133133 add("list_callbacks", "List event callbacks: id, owner, host, filter, paused state, failures and last delivery. Members and agents see their own; the owner and moderators see every callback. The secret is never listed.", mcp.Object(nil), mcpReads, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {134134 return t.mcpManage(ctx, call, "listcallbacks")135135 })136+ add("list_custom_views", "List the owner's custom views: name, kinds, languages, transform host, trigger, audience, state, failures and last run. The secret is never listed. Owner only.", mcp.Object(nil), mcpReads, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {137+ return t.mcpManage(ctx, call, "listcustomviews")138+ })136139 add("list_join_requests", "List access requests from people who asked to join without an invite: pubkey, reason, requested_at, status (pending, approved or denied), decided_by and decided_at. Pending requests come first. Requires an owner or moderator key.", mcp.Object(nil), mcpReads, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {137140 return t.mcpManage(ctx, call, "listjoinrequests")138141 })@@ -164,6 +167,21 @@ func (t *Tenant) mcpTools() (*mcp.Registry, error) {
164167 add("add_callback", "Register an https URL that receives a POST with each new event matching filter that your key may see. The filter takes kinds (required), authors, #a, #e, #p and #h with at most 8 values each. The answer carries the callback id and, once, the secret used for the X-Tiny-Signature header; keep it. Members and agents may hold up to the relay's callback allowance, 4 by default.", mcp.Object(map[string]any{"url": map[string]any{"type": "string", "description": "An https URL on a public host."}, "filter": map[string]any{"type": "object", "description": "NIP-01 filter subset: kinds, authors, #a, #e, #p, #h.", "minProperties": 1}, "secret": map[string]any{"type": "string", "minLength": callbackSecretMin, "maxLength": callbackSecretMax, "description": "Optional shared secret, 16 to 128 printable ASCII characters. Generated when omitted."}}, "url", "filter"), mcpChanges, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {165168 return t.mcpManage(ctx, call, "addcallback", call.Arguments)166169 })170+ add("add_custom_view", "Define a custom view: the relay POSTs the fenced code blocks of matching events, in the listed languages, to an https transform and keeps the SVG or PNG artifacts it returns as relay-signed events shown in place of the blocks. The answer carries the secret once, used for the X-Tiny-Signature header; keep it. Owner only.", mcp.Object(map[string]any{"name": map[string]any{"type": "string", "pattern": "^[a-z0-9-]{1,32}$", "description": "View name: 1 to 32 lowercase letters, digits or hyphens."}, "kinds": map[string]any{"type": "array", "items": map[string]any{"type": "integer", "minimum": 0, "maximum": 65535}, "minItems": 1, "maxItems": viewKindMax, "description": "Event kinds the view watches; 30618 sends the repository README at its head."}, "transform": map[string]any{"type": "string", "description": "An https URL on a public host that renders the blocks."}, "trigger": map[string]any{"type": "string", "enum": []string{"write", "hourly"}, "description": "write sends each matching event as it arrives; hourly sends the newest ones every hour."}, "audience": map[string]any{"type": "string", "enum": []string{"public", "members"}, "description": "Who may see the artifacts."}, "languages": map[string]any{"type": "array", "items": map[string]any{"type": "string", "minLength": 1, "maxLength": viewLanguageLength}, "minItems": 1, "maxItems": viewLanguageMax, "description": "Fenced block languages the view renders, lowercase."}, "max_bytes": map[string]any{"type": "integer", "minimum": 1, "maximum": viewMaxBytesCap, "description": "Largest artifact accepted, in bytes. 1 MiB when omitted, 4 MiB at most."}, "secret": map[string]any{"type": "string", "minLength": callbackSecretMin, "maxLength": callbackSecretMax, "description": "Optional shared secret, 16 to 128 printable ASCII characters. Generated when omitted."}}, "name", "kinds", "transform", "languages"), mcpChanges, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {171+ return t.mcpManage(ctx, call, "addcustomview", call.Arguments)172+ })173+ add("remove_custom_view", "Delete a custom view and every artifact it produced. Owner only.", mcp.Object(map[string]any{"name": mcpID}, "name"), mcpSettings, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {174+ return t.mcpManage(ctx, call, "removecustomview", call.String("name"))175+ })176+ add("pause_custom_view", "Stop a custom view: no transform requests until it is resumed. Its artifacts stay. Owner only.", mcp.Object(map[string]any{"name": mcpID}, "name"), mcpControls, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {177+ return t.mcpManage(ctx, call, "pausecustomview", call.String("name"))178+ })179+ add("resume_custom_view", "Resume a paused custom view and clear its failure count. Owner only.", mcp.Object(map[string]any{"name": mcpID}, "name"), mcpControls, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {180+ return t.mcpManage(ctx, call, "resumecustomview", call.String("name"))181+ })182+ add("run_custom_view", "Queue a backfill of a custom view over the newest 500 events of its kinds. Blocks that already have an artifact are reused. Owner only.", mcp.Object(map[string]any{"name": mcpID}, "name"), mcpChanges, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {183+ return t.mcpManage(ctx, call, "runcustomview", call.String("name"))184+ })167185 add("remove_callback", "Delete a callback by id. The callback's owner, the relay owner and moderators may do this.", mcp.Object(map[string]any{"id": mcpID}, "id"), mcpSettings, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {168186 return t.mcpManage(ctx, call, "removecallback", call.String("id"))169187 })@@ -0,0 +1,172 @@
1+package webui2+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+ fakeBackend20+ views []views.View21+ rows []map[string]any22+}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.Canceled30+ }31+ return b.rows, nil32+ }33+ if method == "browserepo" {34+ var request map[string]any35+ 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"}, nil40+ }41+ return map[string]any{"identifier": "notes", "entries": []any{}}, nil42+ }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 + `"><object data="/views/` + view + `/` + views.Hash("mermaid", mermaidSource) + `.svg" type="image/svg+xml"><pre><code data-lang="mermaid">graph TD;` + "\n" + ` A-->B & <C>;</code></pre></object></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"><object data="/views/diagrams/` + hash + `.svg" type="image/svg+xml"><pre><code data-lang="mermaid">graph TD; a-->b;</code></pre></object></figure>`104+ wantAll(t, "repo home", body, figure, `href="/repo?owner=`)105+ if prefixed := injectBase(figure, "/r/work"); !strings.Contains(prefixed, `<object data="/r/work/views/diagrams/`+hash+`.svg"`) {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 = nil117+ if got := string(app.markdown(source)); strings.Contains(got, "<figure") {118+ t.Fatalf("figure without views: %s", got)119+ }120+}121+122+func TestViewsPageListsCustomViewsWithControlsAndTheAddForm(t *testing.T) {123+ owner := strings.Repeat("a", 64)124+ backend := &viewsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(owner)}, rows: []map[string]any{125+ {"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"},126+ {"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"},127+ }}128+ app, err := New(backend, Options{Actor: func(r *http.Request) (string, error) { return r.Header.Get("X-Actor"), nil }})129+ if err != nil {130+ t.Fatal(err)131+ }132+ get := func(actor string) string {133+ t.Helper()134+ request := httptest.NewRequest(http.MethodGet, "/manage/views", nil)135+ request.Header.Set("X-Actor", actor)136+ recorder := httptest.NewRecorder()137+ app.ServeHTTP(recorder, request)138+ if recorder.Code != http.StatusOK {139+ t.Fatalf("views page: %d %s", recorder.Code, recorder.Body.String())140+ }141+ body := recorder.Body.String()142+ if strings.Contains(body, "class=") {143+ t.Fatal("views page carries a class attribute")144+ }145+ if strings.Contains(body, "ยท") {146+ t.Fatal("views page contains a middle dot")147+ }148+ return body149+ }150+ body := get(owner)151+ wantAll(t, "/manage/views", body,152+ `<rpc-form method="listviews">`, `<rpc-form method="publishview">`,153+ `<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>`,154+ `<td data-state="active">active | ok</td>`, `<td>2023-11-14 22:13 UTC</td>`,155+ `<rpc-form method="pausecustomview" refresh><input type="hidden" name="param" value="diagrams"><button>Pause</button></rpc-form>`,156+ `<rpc-form method="runcustomview" refresh><input type="hidden" name="param" value="diagrams"><button>Run now</button></rpc-form>`,157+ `<rpc-form method="removecustomview" refresh><input type="hidden" name="param" value="diagrams"><button>Remove</button></rpc-form>`,158+ `<tr id="view-charts">`, `<td data-state="paused">paused | paused after 20 failures: HTTP 500</td>`, `<td>never</td>`,159+ `<rpc-form method="resumecustomview" refresh><input type="hidden" name="param" value="charts"><button>Resume</button></rpc-form>`,160+ `<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"`,161+ `<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>`)162+ wantNone(t, "/manage/views", body, "secret-path", `<rpc-form method="pausecustomview" refresh><input type="hidden" name="param" value="charts">`)163+ // A member keeps the built-in controls and sees no custom view section.164+ member := get(strings.Repeat("b", 64))165+ wantAll(t, "/manage/views as member", member, `<rpc-form method="listviews">`)166+ wantNone(t, "/manage/views as member", member, `id="custom-views"`, "<view-form")167+ // The owner without views sees the empty note and the form.168+ backend.rows = nil169+ empty := get(owner)170+ wantAll(t, "/manage/views empty", empty, `<p id="custom-views-empty">No custom views yet.</p>`, `<view-form method="addcustomview">`)171+ wantNone(t, "/manage/views empty", empty, `<table id="custom-views">`)172+}