tinyrelayfile
tinyrelay/internal/daemon/custom_views_test.go/

internal/daemon/custom_views_test.go raw
1package daemon
2
3import (
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
27const (
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)
31
32// transformServer stands in for a view transform: it records every request
33// and answers with the artifacts it is told to.
34type transformServer struct {
35 mu sync.Mutex
36 status int
37 body string
38 headers []http.Header
39 requests [][]byte
40}
41
42func (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.body
48 s.mu.Unlock()
49 w.Header().Set("Content-Type", "application/json")
50 w.WriteHeader(status)
51 _, _ = w.Write([]byte(answer))
52}
53
54func (s *transformServer) count() int {
55 s.mu.Lock()
56 defer s.mu.Unlock()
57 return len(s.requests)
58}
59
60func (s *transformServer) answer(status int, artifacts ...map[string]any) {
61 s.mu.Lock()
62 defer s.mu.Unlock()
63 s.status = status
64 raw, _ := json.Marshal(map[string]any{"artifacts": artifacts, "errors": []map[string]any{{"block": 9, "error": "unknown"}}})
65 s.body = string(raw)
66}
67
68func svgArtifact(block int) map[string]any {
69 return map[string]any{"block": block, "type": "image/svg+xml", "body": testSVG, "engine": "mermaid"}
70}
71
72func viewParams(values map[string]any) []json.RawMessage {
73 raw, _ := json.Marshal(values)
74 return []json.RawMessage{raw}
75}
76
77func 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 that
87// 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] = value
99 }
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.
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.Intent
113 for rows.Next() {
114 var item work.Intent
115 var next int64
116 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 out
123}
124
125// runViewIntent drives one intent the way the worker would and marks it
126// 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}
136
137func 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
146func storedArtifact(t *testing.T, tenant *Tenant, name, hash string) (event.Event, bool) {
147 t.Helper()
148 var raw string
149 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{}, false
152 }
153 e, err := event.Parse([]byte(raw))
154 if err != nil {
155 t.Fatal(err)
156 }
157 return e, true
158}
159
160func artifactTags(e event.Event, name string) []string {
161 var out []string
162 for _, tag := range e.Tags {
163 if len(tag) >= 2 && tag[0] == name {
164 out = append(out, tag[1])
165 }
166 }
167 return out
168}
169
170func 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
181func 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 res
190}
191
192func TestCustomViewManagementIsOwnerOnlyAndValidated(t *testing.T) {
193 _, tenant := testTenant(t)
194 ctx := context.Background()
195 owner := tenant.Policy().Owner
196 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] = value
227 }
228 for key, value := range changes {
229 out[key] = value
230 }
231 return out
232 }
233 many := make([]int, viewKindMax+1)
234 for _, tc := range []struct {
235 name string
236 values map[string]any
237 want string
238 }{
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 string
281 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] = true
331 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
342func 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]any
376 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 int
411 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 string
415 var failures int
416 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 }
419
420 // 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 }
441
442 // A second note with the same block reuses the artifact: no POST, a
443 // 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 }
462
463 // Deleting the first note detaches it; deleting the second removes the
464 // 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 }
489
490 // 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 }
510
511 // 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}
524
525func 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 and
543 // 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}
576
577func 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 viewPayload
585 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 intent
593 }
594 }
595 t.Fatal("no rebuild was queued")
596 return work.Intent{}
597}
598
599func 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}
630
631func 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 int
659 var status string
660 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}
725
726func TestCustomViewArtifactValidation(t *testing.T) {
727 png := base64.StdEncoding.EncodeToString([]byte(testPNGRaw))
728 for _, tc := range []struct {
729 name string
730 artifact viewArtifact
731 max int
732 want string
733 }{
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}
771
772func 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 string
789 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}
829
830func 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}
873
874func 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 = true
879 if err := tenant.applyPolicy(ctx, p); err != nil {
880 t.Fatal(err)
881 }
882 owner := tenant.Policy().Owner
883 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]any
924 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}
939
940func 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)] = true
967 }
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.0
985 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}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs