1package daemon23import (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"2021 "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)2627const (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\x0dIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x04\x00\x00\x00\xb5\x1c\x0c\x02\x00\x00\x00\x0bIDATx\xda\x63\x64\xf8\x0f\x00\x01\x05\x01\x01\x27\x18\xe3\x66\x00\x00\x00\x00IEND\xaeB\x60\x82"30)3132// transformServer stands in for a view transform: it records every request33// and answers with the artifacts it is told to.34type transformServer struct {35 mu sync.Mutex36 status int37 body string38 headers []http.Header39 requests [][]byte40}4142func (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}5354func (s *transformServer) count() int {55 s.mu.Lock()56 defer s.mu.Unlock()57 return len(s.requests)58}5960func (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}6768func svgArtifact(block int) map[string]any {69 return map[string]any{"block": block, "type": "image/svg+xml", "body": testSVG, "engine": "mermaid"}70}7172func viewParams(values map[string]any) []json.RawMessage {73 raw, _ := json.Marshal(values)74 return []json.RawMessage{raw}75}7677func 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}8586// viewTenant is a loopback tenant with a transform server and one view that87// watches the given kinds.88func 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.customViews.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}102103// pendingViewIntents lists queued transforms of one source for one view,104// first attempt first.105func 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}124125// runViewIntent drives one intent the way the worker would and marks it126// complete.127func runViewIntent(t *testing.T, tenant *Tenant, intent work.Intent) {128 t.Helper()129 if err := tenant.customViews.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}136137func 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}145146func 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}159160func 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}169170func 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}180181func 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}191192func 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.customViews.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}341342func 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 for _, field := range []string{"View", "Relay", "Signature"} {371 if headers.Get("X-Transform-"+field) != headers.Get("X-Tiny-"+field) {372 t.Fatalf("canonical transform header %s differs from its legacy value", field)373 }374 }375 var request map[string]any376 if err := json.Unmarshal(body, &request); err != nil {377 t.Fatal(err)378 }379 if _, leaked := request["event"]; leaked || request["relay"] != "http://127.0.0.1:8080" || request["view"] != "diagrams" {380 t.Fatalf("request = %s", body)381 }382 if source := request["source"].(map[string]any); source["id"] != note.ID || source["kind"] != float64(1) || len(source) != 2 {383 t.Fatalf("source = %v", source)384 }385 if strings.Contains(string(body), "Look:") || strings.Contains(string(body), tenant.Policy().Owner) {386 t.Fatalf("request carries event content or author: %s", body)387 }388 blocks := request["blocks"].([]any)389 if len(blocks) != 2 {390 t.Fatalf("blocks = %v", blocks)391 }392 first, third := blocks[0].(map[string]any), blocks[1].(map[string]any)393 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 }" {394 t.Fatalf("blocks = %v", blocks)395 }396 // The SVG artifact is a stored relay-signed record attached to the note.397 svgHash := views.Hash("mermaid", "graph TD;\n A-->B;")398 artifact, ok := storedArtifact(t, tenant, "diagrams", svgHash)399 if !ok {400 t.Fatal("svg artifact was not stored")401 }402 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] != "-" {403 t.Fatalf("artifact = %+v", artifact)404 }405 pngHash := views.Hash("dot", "digraph { a -> b }")406 png, ok := storedArtifact(t, tenant, "diagrams", pngHash)407 if !ok || png.Content != base64.StdEncoding.EncodeToString([]byte(testPNGRaw)) || event.Tag(png, "type") != "image/png" || event.Tag(png, "block") != "2" || event.Tag(png, "engine") != "" {408 t.Fatalf("png artifact = %+v %v", png, ok)409 }410 var count int411 if err := tenant.store.DB().QueryRowContext(ctx, "SELECT count(*) FROM custom_view_artifacts").Scan(&count); err != nil || count != 2 {412 t.Fatalf("artifacts = %d %v (an artifact for an unsent block was kept)", count, err)413 }414 var lastStatus string415 var failures int416 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 {417 t.Fatalf("view state %q %d %v", lastStatus, failures, err)418 }419420 // The route serves each artifact under a sandbox, by its own type.421 res := getArtifact(t, tenant, "/views/diagrams/"+svgHash+".svg", "")422 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") != "private, no-cache" || res.Header().Get("ETag") == "" {423 t.Fatalf("svg route: %d %v %s", res.Code, res.Header(), res.Body.String())424 }425 conditional := httptest.NewRequest(http.MethodGet, "http://127.0.0.1:8080/views/diagrams/"+svgHash+".svg", nil)426 conditional.Header.Set("If-None-Match", res.Header().Get("ETag"))427 conditionalResponse := httptest.NewRecorder()428 tenant.ServeHTTP(conditionalResponse, conditional)429 if conditionalResponse.Code != http.StatusNotModified {430 t.Fatalf("conditional artifact: %d %v", conditionalResponse.Code, conditionalResponse.Header())431 }432 res = getArtifact(t, tenant, "/views/diagrams/"+pngHash+".png", "")433 if res.Code != http.StatusOK || res.Body.String() != testPNGRaw || res.Header().Get("Content-Type") != "image/png" {434 t.Fatalf("png route: %d %v", res.Code, res.Header())435 }436 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"} {437 if res := getArtifact(t, tenant, path, ""); res.Code != http.StatusNotFound {438 t.Fatalf("%s: %d", path, res.Code)439 }440 }441442 // A second note with the same block reuses the artifact: no POST, a443 // fresh record with both sources.444 second := signedEvent(t, testOwnerSecret, 1, now+2, nil, "Again:\n\n```mermaid\ngraph TD;\n A-->B;\n```\n")445 if err := publishAs(t, tenant, second); err != nil {446 t.Fatal(err)447 }448 runPendingView(t, tenant, "diagrams", second.ID)449 if server.count() != 1 {450 t.Fatalf("requests after reuse = %d", server.count())451 }452 artifact, _ = storedArtifact(t, tenant, "diagrams", svgHash)453 if sources := artifactTags(artifact, "e"); len(sources) != 2 || sources[0] != note.ID || sources[1] != second.ID {454 t.Fatalf("sources after reuse = %v", sources)455 }456 if event.Tag(artifact, "expiration") != "" {457 t.Fatal("artifact kept an expiration while a source without one is attached")458 }459 if blocks := artifactTags(artifact, "block"); len(blocks) != 2 || blocks[0] != "0" || blocks[1] != "0" {460 t.Fatalf("block tags = %v", blocks)461 }462463 // Deleting the first note detaches it; deleting the second removes the464 // artifact and its record.465 deletion := signedEvent(t, testOwnerSecret, event.KIND_DELETION, now+3, [][]string{{"e", note.ID}}, "")466 if err := publishAs(t, tenant, deletion); err != nil {467 t.Fatal(err)468 }469 artifact, ok = storedArtifact(t, tenant, "diagrams", svgHash)470 if !ok || len(artifactTags(artifact, "e")) != 1 || event.Tag(artifact, "e") != second.ID {471 t.Fatalf("artifact after first deletion = %+v %v", artifact, ok)472 }473 if _, ok := storedArtifact(t, tenant, "diagrams", pngHash); ok {474 t.Fatal("png artifact survived the deletion of its only source")475 }476 if res := getArtifact(t, tenant, "/views/diagrams/"+pngHash+".png", ""); res.Code != http.StatusNotFound {477 t.Fatalf("png route after deletion: %d", res.Code)478 }479 deletion = signedEvent(t, testOwnerSecret, event.KIND_DELETION, now+4, [][]string{{"e", second.ID}}, "")480 if err := publishAs(t, tenant, deletion); err != nil {481 t.Fatal(err)482 }483 if _, ok := storedArtifact(t, tenant, "diagrams", svgHash); ok {484 t.Fatal("svg artifact survived the deletion of its last source")485 }486 if err := tenant.store.DB().QueryRowContext(ctx, "SELECT count(*) FROM custom_view_artifacts").Scan(&count); err != nil || count != 0 {487 t.Fatalf("artifacts after deletions = %d %v", count, err)488 }489490 // Expiry: an expired source loses its artifacts at the sweep.491 expiring := signedEvent(t, testOwnerSecret, 1, now+5, [][]string{{"expiration", strconv.FormatInt(now+30, 10)}}, "```dot\nx\n```")492 if err := publishAs(t, tenant, expiring); err != nil {493 t.Fatal(err)494 }495 server.answer(http.StatusOK, svgArtifact(0))496 runPendingView(t, tenant, "diagrams", expiring.ID)497 expiringHash := views.Hash("dot", "x")498 if _, ok := storedArtifact(t, tenant, "diagrams", expiringHash); !ok {499 t.Fatal("expiring artifact was not stored")500 }501 if err := tenant.sweep(ctx, now+60); err != nil {502 t.Fatal(err)503 }504 if _, ok := storedArtifact(t, tenant, "diagrams", expiringHash); ok {505 t.Fatal("artifact survived its source's expiry")506 }507 if res := getArtifact(t, tenant, "/views/diagrams/"+expiringHash+".svg", ""); res.Code != http.StatusNotFound {508 t.Fatalf("expired route: %d", res.Code)509 }510511 // Removing the view removes what is left.512 last := signedEvent(t, testOwnerSecret, 1, now+6, nil, "```dot\ny\n```")513 if err := publishAs(t, tenant, last); err != nil {514 t.Fatal(err)515 }516 runPendingView(t, tenant, "diagrams", last.ID)517 if _, err := tenant.Execute(ctx, tenant.Policy().Owner, "removecustomview", []json.RawMessage{json.RawMessage(`"diagrams"`)}); err != nil {518 t.Fatal(err)519 }520 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 {521 t.Fatalf("records after removal = %d %v", count, err)522 }523}524525func TestCustomViewReplacementReusesAndDropsBlocks(t *testing.T) {526 tenant, server, _ := viewTenant(t, []int{30023}, nil)527 ctx := context.Background()528 now := time.Now().Unix()529 server.answer(http.StatusOK, svgArtifact(0), svgArtifact(1))530 first := signedEvent(t, testOwnerSecret, 30023, now, [][]string{{"d", "post"}}, "```mermaid\nA\n```\n\n```mermaid\nB\n```\n")531 if err := publishAs(t, tenant, first); err != nil {532 t.Fatal(err)533 }534 runPendingView(t, tenant, "diagrams", first.ID)535 hashA, hashB := views.Hash("mermaid", "A"), views.Hash("mermaid", "B")536 if _, ok := storedArtifact(t, tenant, "diagrams", hashA); !ok {537 t.Fatal("A missing")538 }539 if _, ok := storedArtifact(t, tenant, "diagrams", hashB); !ok {540 t.Fatal("B missing")541 }542 // The replacement keeps A and drops B; A is reused without a POST and543 // carries only the new version.544 second := signedEvent(t, testOwnerSecret, 30023, now+1, [][]string{{"d", "post"}}, "```mermaid\nA\n```\n")545 if err := publishAs(t, tenant, second); err != nil {546 t.Fatal(err)547 }548 runPendingView(t, tenant, "diagrams", second.ID)549 if server.count() != 1 {550 t.Fatalf("requests = %d", server.count())551 }552 artifact, ok := storedArtifact(t, tenant, "diagrams", hashA)553 if !ok || len(artifactTags(artifact, "e")) != 1 || event.Tag(artifact, "e") != second.ID {554 t.Fatalf("A after replacement = %+v %v", artifact, ok)555 }556 if _, ok := storedArtifact(t, tenant, "diagrams", hashB); ok {557 t.Fatal("B survived the replacement")558 }559 // A stale intent for the replaced version renders nothing.560 stale := tenant.customViews.viewIntent(customView{Name: "diagrams"}, first, "old")561 if err := tenant.customViews.handleViewTransform(ctx, work.Intent{ID: "stale", Kind: stale.Kind, EventID: stale.EventID, Target: stale.Target, Payload: stale.Payload}); err != nil {562 t.Fatal(err)563 }564 if server.count() != 1 {565 t.Fatal("a replaced version was sent to the transform")566 }567 // Deleting the address removes the artifact.568 deletion := signedEvent(t, testOwnerSecret, event.KIND_DELETION, now+2, [][]string{{"a", "30023:" + tenant.Policy().Owner + ":post"}}, "")569 if err := publishAs(t, tenant, deletion); err != nil {570 t.Fatal(err)571 }572 if _, ok := storedArtifact(t, tenant, "diagrams", hashA); ok {573 t.Fatal("A survived the address deletion")574 }575}576577func queueViewRebuild(t *testing.T, tenant *Tenant, source string) work.Intent {578 t.Helper()579 params := viewParams(map[string]any{"name": "diagrams", "refresh": true})580 if _, err := tenant.Execute(context.Background(), tenant.Policy().Owner, "runcustomview", params); err != nil {581 t.Fatal(err)582 }583 for _, intent := range pendingViewIntents(t, tenant, "diagrams", source) {584 var payload viewPayload585 if err := json.Unmarshal([]byte(intent.Payload), &payload); err != nil {586 t.Fatal(err)587 }588 if payload.Attempt == 1 {589 if !payload.Refresh {590 t.Fatal("queued rebuild lost refresh flag")591 }592 return intent593 }594 }595 t.Fatal("no rebuild was queued")596 return work.Intent{}597}598599func TestCustomViewRebuildReplacesOnlyAfterSuccess(t *testing.T) {600 tenant, server, _ := viewTenant(t, []int{1}, nil)601 note := signedEvent(t, testOwnerSecret, 1, time.Now().Unix(), nil, "```mermaid\nA\n```")602 if err := publishAs(t, tenant, note); err != nil {603 t.Fatal(err)604 }605 server.answer(http.StatusOK, svgArtifact(0))606 runPendingView(t, tenant, "diagrams", note.ID)607 path := views.Path("diagrams", views.Hash("mermaid", "A"), "")608 before := getArtifact(t, tenant, path, "")609 if before.Code != http.StatusOK {610 t.Fatalf("initial artifact: %d", before.Code)611 }612 server.answer(http.StatusInternalServerError)613 runViewIntent(t, tenant, queueViewRebuild(t, tenant, note.ID))614 afterFailure := getArtifact(t, tenant, path, "")615 if afterFailure.Body.String() != before.Body.String() || afterFailure.Header().Get("ETag") != before.Header().Get("ETag") {616 t.Fatal("failed rebuild changed the stored artifact")617 }618 replacement := svgArtifact(0)619 replacement["body"] = strings.Replace(testSVG, "<rect", `<rect fill="#334155"`, 1)620 server.answer(http.StatusOK, replacement)621 runViewIntent(t, tenant, queueViewRebuild(t, tenant, note.ID))622 after := getArtifact(t, tenant, path, "")623 if after.Body.String() != replacement["body"] || after.Header().Get("ETag") == before.Header().Get("ETag") {624 t.Fatal("successful rebuild did not replace and revalidate the artifact")625 }626 if server.count() != 3 {627 t.Fatalf("transform requests = %d", server.count())628 }629}630631func TestCustomViewTransformRetriesThenPauses(t *testing.T) {632 tenant, server, _ := viewTenant(t, []int{1}, nil)633 ctx := context.Background()634 now := time.Now().Unix()635 server.answer(http.StatusInternalServerError)636 note := signedEvent(t, testOwnerSecret, 1, now, nil, "```mermaid\nA\n```")637 if err := publishAs(t, tenant, note); err != nil {638 t.Fatal(err)639 }640 queued := pendingViewIntents(t, tenant, "diagrams", note.ID)641 if len(queued) != 1 {642 t.Fatalf("queued = %d", len(queued))643 }644 runViewIntent(t, tenant, queued[0])645 queued = pendingViewIntents(t, tenant, "diagrams", note.ID)646 if len(queued) != 1 || queued[0].EventID != note.ID+"#2" || queued[0].NextAt.Unix() < now+55 || queued[0].NextAt.Unix() > now+70 {647 t.Fatalf("second attempt = %+v", queued)648 }649 runViewIntent(t, tenant, queued[0])650 queued = pendingViewIntents(t, tenant, "diagrams", note.ID)651 if len(queued) != 1 || queued[0].EventID != note.ID+"#3" || queued[0].NextAt.Unix() < now+295 || queued[0].NextAt.Unix() > now+310 {652 t.Fatalf("third attempt = %+v", queued)653 }654 runViewIntent(t, tenant, queued[0])655 if queued = pendingViewIntents(t, tenant, "diagrams", note.ID); len(queued) != 0 {656 t.Fatalf("fourth attempt queued: %+v", queued)657 }658 var failures, enabled int659 var status string660 state := func() {661 t.Helper()662 if err := tenant.store.DB().QueryRowContext(ctx, "SELECT failures,enabled,last_status FROM custom_views WHERE name='diagrams'").Scan(&failures, &enabled, &status); err != nil {663 t.Fatal(err)664 }665 }666 state()667 if failures != 3 || enabled != 1 || status != "HTTP 500" {668 t.Fatalf("after three failures: %d %d %q", failures, enabled, status)669 }670 if server.count() != 3 {671 t.Fatalf("requests = %d", server.count())672 }673 // A 200 that is not JSON is a failure too.674 server.mu.Lock()675 server.status, server.body = http.StatusOK, "<html>"676 server.mu.Unlock()677 other := signedEvent(t, testOwnerSecret, 1, now+1, nil, "```mermaid\nB\n```")678 if err := publishAs(t, tenant, other); err != nil {679 t.Fatal(err)680 }681 runPendingView(t, tenant, "diagrams", other.ID)682 state()683 if failures != 4 || status != "invalid response" {684 t.Fatalf("after bad json: %d %q", failures, status)685 }686 // The twentieth failure in a row pauses the view; nothing new is queued.687 if _, err := tenant.store.DB().ExecContext(ctx, "UPDATE custom_views SET failures=19 WHERE name='diagrams'"); err != nil {688 t.Fatal(err)689 }690 server.answer(http.StatusBadGateway)691 third := signedEvent(t, testOwnerSecret, 1, now+2, nil, "```mermaid\nC\n```")692 if err := publishAs(t, tenant, third); err != nil {693 t.Fatal(err)694 }695 runPendingView(t, tenant, "diagrams", third.ID)696 state()697 if failures != 20 || enabled != 0 || status != "paused after 20 failures: HTTP 502" {698 t.Fatalf("after twenty failures: %d %d %q", failures, enabled, status)699 }700 if queued := pendingViewIntents(t, tenant, "diagrams", third.ID); len(queued) != 0 {701 t.Fatalf("paused view queued a retry: %+v", queued)702 }703 fourth := signedEvent(t, testOwnerSecret, 1, now+3, nil, "```mermaid\nD\n```")704 if err := publishAs(t, tenant, fourth); err != nil {705 t.Fatal(err)706 }707 if queued := pendingViewIntents(t, tenant, "diagrams", fourth.ID); len(queued) != 0 {708 t.Fatal("paused view queued a transform")709 }710 // Resuming clears the count; a success resets it after a failure.711 if _, err := tenant.Execute(ctx, tenant.Policy().Owner, "resumecustomview", []json.RawMessage{json.RawMessage(`"diagrams"`)}); err != nil {712 t.Fatal(err)713 }714 server.answer(http.StatusOK, svgArtifact(0))715 fifth := signedEvent(t, testOwnerSecret, 1, now+4, nil, "```mermaid\nE\n```")716 if err := publishAs(t, tenant, fifth); err != nil {717 t.Fatal(err)718 }719 runPendingView(t, tenant, "diagrams", fifth.ID)720 state()721 if failures != 0 || enabled != 1 || status != "ok" {722 t.Fatalf("after success: %d %d %q", failures, enabled, status)723 }724}725726func TestCustomViewArtifactValidation(t *testing.T) {727 png := base64.StdEncoding.EncodeToString([]byte(testPNGRaw))728 for _, tc := range []struct {729 name string730 artifact viewArtifact731 max int732 want string733 }{734 {"svg", viewArtifact{Type: "image/svg+xml", Body: testSVG}, 1024, ""},735 {"svg fragment href", viewArtifact{Type: "image/svg+xml", Body: `<svg><use href="#a"/><a xlink:href='#b'/></svg>`}, 1024, ""},736 {"png", viewArtifact{Type: "image/png", Body: png}, 1024, ""},737 {"png raw base64", viewArtifact{Type: "image/png", Body: strings.TrimRight(png, "=")}, 1024, ""},738 {"type", viewArtifact{Type: "text/html", Body: "<p>"}, 1024, "invalid: artifact type"},739 {"svg size", viewArtifact{Type: "image/svg+xml", Body: testSVG}, 10, "invalid: artifact exceeds"},740 {"png size", viewArtifact{Type: "image/png", Body: png}, 4, "invalid: artifact exceeds"},741 {"png base64", viewArtifact{Type: "image/png", Body: "not base64!"}, 1024, "invalid: png body must be base64"},742 {"png signature", viewArtifact{Type: "image/png", Body: base64.StdEncoding.EncodeToString([]byte("GIF89a"))}, 1024, "invalid: png body is not a PNG"},743 {"png truncated", viewArtifact{Type: "image/png", Body: png[:len(png)-8]}, 1024, "invalid: png body is not a valid PNG"},744 {"svg element", viewArtifact{Type: "image/svg+xml", Body: "<div/>"}, 1024, "invalid: svg body has no svg"},745 {"svg malformed", viewArtifact{Type: "image/svg+xml", Body: `<svg><rect></svg>`}, 1024, "invalid: svg is not well-formed XML"},746 {"script", viewArtifact{Type: "image/svg+xml", Body: `<svg><SCRIPT>alert(1)</SCRIPT></svg>`}, 1024, "invalid: svg contains script"},747 {"handler", viewArtifact{Type: "image/svg+xml", Body: `<svg onload = "x()"/>`}, 1024, "invalid: svg contains an event handler"},748 {"foreign object", viewArtifact{Type: "image/svg+xml", Body: `<svg><foreignobject/></svg>`}, 1024, "invalid: svg contains a foreign object"},749 {"javascript href", viewArtifact{Type: "image/svg+xml", Body: `<svg><a href="javascript:alert(1)"/></svg>`}, 1024, "invalid: svg contains a javascript"},750 {"external href", viewArtifact{Type: "image/svg+xml", Body: `<svg><image href="https://evil.example/x.png"/></svg>`}, 1024, "invalid: svg references an external"},751 {"external css", viewArtifact{Type: "image/svg+xml", Body: `<svg><style>.x { fill: url(https://evil.example/x) }</style></svg>`}, 1024, "invalid: svg references an external"},752 {"external xlink", viewArtifact{Type: "image/svg+xml", Body: `<svg><use xlink:href='/x.svg#a'/></svg>`}, 1024, "invalid: svg references an external"},753 {"unquoted href", viewArtifact{Type: "image/svg+xml", Body: `<svg><a href=data:text/html,x>`}, 1024, "invalid: svg is not well-formed XML"},754 {"css escape", viewArtifact{Type: "image/svg+xml", Body: `<svg><style>.x{fill:u\72l(https://evil.example/x)}</style></svg>`}, 1024, "invalid: svg references an external"},755 {"css comment split", viewArtifact{Type: "image/svg+xml", Body: `<svg><style>.x{fill:u/**/rl(https://evil.example/x)}</style></svg>`}, 1024, "invalid: svg references an external"},756 {"css import", viewArtifact{Type: "image/svg+xml", Body: `<svg><style>@import "https://evil.example/x.css"</style></svg>`}, 1024, "invalid: svg references an external"},757 {"css local palette", viewArtifact{Type: "image/svg+xml", Body: `<svg><style>@media (prefers-color-scheme:dark){.x{fill:url('#dark')}}</style><path class="x" fill="url(#light)"/></svg>`}, 1024, ""},758 } {759 _, err := checkArtifact(tc.artifact, tc.max)760 if tc.want == "" && err != nil {761 t.Errorf("%s: unexpected %v", tc.name, err)762 }763 if tc.want != "" && (err == nil || !strings.HasPrefix(err.Error(), tc.want)) {764 t.Errorf("%s: err = %v, want prefix %q", tc.name, err, tc.want)765 }766 }767 if body, err := checkArtifact(viewArtifact{Type: "image/png", Body: " " + png + "\n"}, 1024); err != nil || body != png {768 t.Fatalf("png normalization: %q %v", body, err)769 }770}771772func TestCustomViewMembersAudienceStaysOffTheStore(t *testing.T) {773 tenant, server, added := viewTenant(t, []int{1}, map[string]any{"audience": "members"})774 ctx := context.Background()775 member, _ := event.PublicKey(testMemberSecret)776 setRole(t, tenant, member, "member")777 now := time.Now().Unix()778 server.answer(http.StatusOK, svgArtifact(0))779 note := signedEvent(t, testOwnerSecret, 1, now, nil, "```mermaid\nA\n```")780 if err := publishAs(t, tenant, note); err != nil {781 t.Fatal(err)782 }783 runPendingView(t, tenant, "diagrams", note.ID)784 hash := views.Hash("mermaid", "A")785 if _, ok := storedArtifact(t, tenant, "diagrams", hash); ok {786 t.Fatal("a members-only artifact became a stored record")787 }788 var raw string789 if err := tenant.store.DB().QueryRowContext(ctx, "SELECT raw FROM custom_view_artifacts WHERE view='diagrams' AND hash=?", hash).Scan(&raw); err != nil {790 t.Fatal(err)791 }792 signed, err := event.Parse([]byte(raw))793 if err != nil || signed.PubKey != tenant.records.PublicKey() || event.Tag(signed, "d") != "bind.ws/view/diagrams/"+hash {794 t.Fatalf("members artifact record = %+v %v", signed, err)795 }796 path := "/views/diagrams/" + hash + ".svg"797 if res := getArtifact(t, tenant, path, ""); res.Code != http.StatusUnauthorized {798 t.Fatalf("guest: %d", res.Code)799 }800 if res := getArtifact(t, tenant, path, strings.Repeat("7", 64)); res.Code != http.StatusForbidden {801 t.Fatalf("outsider: %d", res.Code)802 }803 for _, secret := range []string{testMemberSecret, testOwnerSecret} {804 res := getArtifact(t, tenant, path, secret)805 if res.Code != http.StatusOK || res.Body.String() != testSVG || res.Header().Get("Cache-Control") != "private, no-cache" || res.Header().Get("ETag") == "" {806 t.Fatalf("member: %d %v", res.Code, res.Header())807 }808 }809 // A public view on a members-only relay follows the read rule.810 p := tenant.Policy()811 p.Reads = "members"812 if err := tenant.applyPolicy(ctx, p); err != nil {813 t.Fatal(err)814 }815 addView(t, tenant, map[string]any{"name": "open", "kinds": []int{1}, "transform": added["transform"], "languages": []string{"dot"}})816 other := signedEvent(t, testOwnerSecret, 1, now+1, nil, "```dot\nB\n```")817 if err := publishAs(t, tenant, other); err != nil {818 t.Fatal(err)819 }820 runPendingView(t, tenant, "open", other.ID)821 openPath := "/views/open/" + views.Hash("dot", "B") + ".svg"822 if res := getArtifact(t, tenant, openPath, ""); res.Code != http.StatusUnauthorized && res.Code != http.StatusForbidden {823 t.Fatalf("guest on a members relay: %d", res.Code)824 }825 if res := getArtifact(t, tenant, openPath, testMemberSecret); res.Code != http.StatusOK {826 t.Fatalf("member on a members relay: %d %s", res.Code, res.Body.String())827 }828}829830func TestCustomViewHourlyTickQueuesRecentSources(t *testing.T) {831 tenant, server, _ := viewTenant(t, []int{1}, map[string]any{"trigger": "hourly"})832 ctx := context.Background()833 now := time.Now().Unix()834 server.answer(http.StatusOK, svgArtifact(0))835 note := signedEvent(t, testOwnerSecret, 1, now, nil, "```mermaid\nA\n```")836 if err := publishAs(t, tenant, note); err != nil {837 t.Fatal(err)838 }839 if queued := pendingViewIntents(t, tenant, "diagrams", note.ID); len(queued) != 0 {840 t.Fatal("an hourly view queued at write time")841 }842 if err := tenant.customViews.tickCustomViews(ctx, now); err != nil {843 t.Fatal(err)844 }845 queued := pendingViewIntents(t, tenant, "diagrams", note.ID)846 if len(queued) != 1 || !strings.HasPrefix(queued[0].EventID, note.ID+"@") {847 t.Fatalf("hourly queue = %+v", queued)848 }849 runViewIntent(t, tenant, queued[0])850 if _, ok := storedArtifact(t, tenant, "diagrams", views.Hash("mermaid", "A")); !ok {851 t.Fatal("hourly artifact missing")852 }853 // Not due again for an hour; a backfill through runcustomview queues now.854 if err := tenant.customViews.tickCustomViews(ctx, now+60); err != nil {855 t.Fatal(err)856 }857 if queued := pendingViewIntents(t, tenant, "diagrams", note.ID); len(queued) != 0 {858 t.Fatal("hourly view queued again within the hour")859 }860 ran, err := tenant.Execute(ctx, tenant.Policy().Owner, "runcustomview", []json.RawMessage{json.RawMessage(`"diagrams"`)})861 if err != nil || ran.(map[string]any)["queued"] != 1 {862 t.Fatalf("runcustomview: %v %v", ran, err)863 }864 queued = pendingViewIntents(t, tenant, "diagrams", note.ID)865 if len(queued) != 1 {866 t.Fatalf("backfill queue = %+v", queued)867 }868 runViewIntent(t, tenant, queued[0])869 if server.count() != 1 {870 t.Fatalf("backfill re-rendered a block with an artifact: %d requests", server.count())871 }872}873874func TestCustomViewRendersTheReadmeAtTheStateHead(t *testing.T) {875 tenant, server, _ := viewTenant(t, []int{event.KIND_REPO_STATE}, nil)876 ctx := context.Background()877 p := tenant.Policy()878 p.Features.Grasp = true879 if err := tenant.applyPolicy(ctx, p); err != nil {880 t.Fatal(err)881 }882 owner := tenant.Policy().Owner883 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"}}, "")884 if err := publishAs(t, tenant, announcement); err != nil {885 t.Fatal(err)886 }887 source := filepath.Join(t.TempDir(), "source.git")888 gitTest(t, "init", "--bare", source)889 work := filepath.Join(t.TempDir(), "work")890 gitTest(t, "clone", source, work)891 gitTest(t, "-C", work, "config", "user.email", "test@example.com")892 gitTest(t, "-C", work, "config", "user.name", "Test")893 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 {894 t.Fatal(err)895 }896 gitTest(t, "-C", work, "add", "README.md")897 gitTest(t, "-C", work, "commit", "-m", "readme")898 gitTest(t, "-C", work, "branch", "-M", "main")899 sha := strings.TrimSpace(gitOutput(t, "-C", work, "rev-parse", "HEAD"))900 state := signedEvent(t, testOwnerSecret, event.KIND_REPO_STATE, time.Now().Unix(), [][]string{{"d", "docs"}, {"HEAD", "ref: refs/heads/main"}, {"refs/heads/main", sha}}, "")901 if err := publishAs(t, tenant, state); err != nil {902 t.Fatal(err)903 }904 if queued := pendingViewIntents(t, tenant, "diagrams", state.ID); len(queued) != 0 {905 t.Fatal("a state was queued before its objects arrived")906 }907 relay := httptest.NewServer(tenant)908 defer relay.Close()909 gitTest(t, "-C", work, "push", relay.URL+"/npub10xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqpkge6d/docs.git", "refs/heads/main:refs/heads/main")910 blocks, err := tenant.customViews.sourceBlocks(ctx, state)911 if err != nil {912 t.Fatal(err)913 }914 if len(blocks) != 2 || blocks[1].Index != 1 || blocks[1].Lang != "mermaid" || blocks[1].Source != "graph LR; a-->b;" {915 t.Fatalf("readme blocks = %+v", blocks)916 }917 queued := pendingViewIntents(t, tenant, "diagrams", state.ID)918 if len(queued) != 1 {919 t.Fatalf("state intents after push = %d", len(queued))920 }921 server.answer(http.StatusOK, svgArtifact(1))922 runViewIntent(t, tenant, queued[0])923 var request map[string]any924 if err := json.Unmarshal(server.requests[0], &request); err != nil {925 t.Fatal(err)926 }927 coordinate := "30618:" + owner + ":docs"928 if source := request["source"].(map[string]any); source["id"] != coordinate || source["kind"] != float64(30618) {929 t.Fatalf("state source = %v", source)930 }931 if blocks := request["blocks"].([]any); len(blocks) != 1 || blocks[0].(map[string]any)["index"] != float64(1) {932 t.Fatalf("state blocks = %v", blocks)933 }934 artifact, ok := storedArtifact(t, tenant, "diagrams", views.Hash("mermaid", "graph LR; a-->b;"))935 if !ok || event.Tag(artifact, "a") != coordinate || event.Tag(artifact, "e") != "" || event.Tag(artifact, "block") != "1" {936 t.Fatalf("state artifact = %+v %v", artifact, ok)937 }938}939940func TestMCPCustomViewTools(t *testing.T) {941 app, tenant := testTenant(t)942 member, _ := event.PublicKey(testMemberSecret)943 setRole(t, tenant, member, "member")944 call := func(name string, arguments map[string]any, secret string) (map[string]any, bool) {945 t.Helper()946 w, response := mcpCall{method: "tools/call", name: name, arguments: arguments, sign: true, secret: secret}.do(t, app, "/mcp")947 if w.Code != http.StatusOK {948 t.Fatalf("%s: %d %s", name, w.Code, w.Body.String())949 }950 return mcpToolResult(t, response)951 }952 structured := func(result map[string]any) any {953 value, _ := result["structuredContent"].(map[string]any)954 return value["result"]955 }956 text := func(result map[string]any) string {957 content, _ := result["content"].([]any)958 if len(content) == 0 {959 return ""960 }961 return content[0].(map[string]any)["text"].(string)962 }963 _, response := mcpCall{method: "tools/list", sign: true}.do(t, app, "/mcp")964 names := map[string]bool{}965 for _, tool := range response.Result.(map[string]any)["tools"].([]any) {966 names[tool.(map[string]any)["name"].(string)] = true967 }968 for _, want := range []string{"list_custom_views", "add_custom_view", "remove_custom_view", "pause_custom_view", "resume_custom_view", "run_custom_view"} {969 if !names[want] {970 t.Fatalf("missing tool %s", want)971 }972 }973 definition := map[string]any{"name": "diagrams", "kinds": []any{1.0, 30023.0}, "transform": "https://render.example/tiny", "languages": []any{"mermaid"}}974 result, isError := call("add_custom_view", definition, testMemberSecret)975 if !isError || !strings.Contains(text(result), "restricted") {976 t.Fatalf("add_custom_view as member: %v %s", isError, text(result))977 }978 result, isError = call("add_custom_view", definition, "")979 added, _ := structured(result).(map[string]any)980 secret, _ := added["secret"].(string)981 if isError || len(secret) != 64 || added["name"] != "diagrams" || added["trigger"] != "write" {982 t.Fatalf("add_custom_view: %v %s", isError, text(result))983 }984 definition["name"], definition["secret"], definition["trigger"], definition["audience"], definition["max_bytes"] = "charts", "shared-secret-value-1234", "hourly", "members", 4096.0985 result, isError = call("add_custom_view", definition, "")986 if pasted, _ := structured(result).(map[string]any); isError || pasted["secret"] != "shared-secret-value-1234" || pasted["trigger"] != "hourly" || pasted["audience"] != "members" || pasted["maxBytes"] != float64(4096) {987 t.Fatalf("add_custom_view with a secret: %v %s", isError, text(result))988 }989 result, isError = call("list_custom_views", nil, "")990 rows, _ := structured(result).([]any)991 if isError || len(rows) != 2 || strings.Contains(text(result), secret) || strings.Contains(text(result), "shared-secret-value-1234") {992 t.Fatalf("list_custom_views: %v %s", isError, text(result))993 }994 if result, isError = call("list_custom_views", nil, testMemberSecret); !isError {995 t.Fatalf("list_custom_views as member: %s", text(result))996 }997 result, isError = call("pause_custom_view", map[string]any{"name": "diagrams"}, "")998 if paused, _ := structured(result).(map[string]any); isError || paused["enabled"] != false {999 t.Fatalf("pause_custom_view: %v %s", isError, text(result))1000 }1001 result, isError = call("resume_custom_view", map[string]any{"name": "diagrams"}, "")1002 if resumed, _ := structured(result).(map[string]any); isError || resumed["enabled"] != true {1003 t.Fatalf("resume_custom_view: %v %s", isError, text(result))1004 }1005 result, isError = call("run_custom_view", map[string]any{"name": "diagrams"}, "")1006 if ran, _ := structured(result).(map[string]any); isError || ran["queued"] != float64(0) {1007 t.Fatalf("run_custom_view: %v %s", isError, text(result))1008 }1009 result, isError = call("remove_custom_view", map[string]any{"name": "charts"}, "")1010 if removed, _ := structured(result).(map[string]any); isError || removed["removed"] != true {1011 t.Fatalf("remove_custom_view: %v %s", isError, text(result))1012 }1013 result, isError = call("remove_custom_view", map[string]any{"name": "charts"}, "")1014 if !isError || !strings.Contains(text(result), "not found") {1015 t.Fatalf("remove_custom_view twice: %v %s", isError, text(result))1016 }1017}