| 1 | package daemon |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/hmac" |
| 6 | "crypto/sha256" |
| 7 | "encoding/base64" |
| 8 | "encoding/hex" |
| 9 | "encoding/json" |
| 10 | "io" |
| 11 | "net/http" |
| 12 | "net/http/httptest" |
| 13 | "os" |
| 14 | "path/filepath" |
| 15 | "strconv" |
| 16 | "strings" |
| 17 | "sync" |
| 18 | "testing" |
| 19 | "time" |
| 20 | |
| 21 | "github.com/FelineStateMachine/tinyrelay/internal/community" |
| 22 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 23 | "github.com/FelineStateMachine/tinyrelay/internal/views" |
| 24 | "github.com/FelineStateMachine/tinyrelay/internal/work" |
| 25 | ) |
| 26 | |
| 27 | const ( |
| 28 | testSVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10"/></svg>` |
| 29 | testPNGRaw = "\x89PNG\r\n\x1a\n\x00\x00\x00\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. |
| 34 | type transformServer struct { |
| 35 | mu sync.Mutex |
| 36 | status int |
| 37 | body string |
| 38 | headers []http.Header |
| 39 | requests [][]byte |
| 40 | } |
| 41 | |
| 42 | func (s *transformServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| 43 | body, _ := io.ReadAll(r.Body) |
| 44 | s.mu.Lock() |
| 45 | s.headers = append(s.headers, r.Header.Clone()) |
| 46 | s.requests = append(s.requests, body) |
| 47 | status, answer := s.status, s.body |
| 48 | s.mu.Unlock() |
| 49 | w.Header().Set("Content-Type" , "application/json" ) |
| 50 | w.WriteHeader(status) |
| 51 | _, _ = w.Write([]byte(answer)) |
| 52 | } |
| 53 | |
| 54 | func (s *transformServer) count() int { |
| 55 | s.mu.Lock() |
| 56 | defer s.mu.Unlock() |
| 57 | return len(s.requests) |
| 58 | } |
| 59 | |
| 60 | func (s *transformServer) answer(status int, artifacts ...map[string]any) { |
| 61 | s.mu.Lock() |
| 62 | defer s.mu.Unlock() |
| 63 | s.status = 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 | |
| 68 | func svgArtifact(block int) map[string]any { |
| 69 | return map[string]any{"block" : block, "type" : "image/svg+xml" , "body" : testSVG, "engine" : "mermaid" } |
| 70 | } |
| 71 | |
| 72 | func viewParams(values map[string]any) []json.RawMessage { |
| 73 | raw, _ := json.Marshal(values) |
| 74 | return []json.RawMessage{raw} |
| 75 | } |
| 76 | |
| 77 | func addView(t *testing.T, tenant *Tenant, values map[string]any) map[string]any { |
| 78 | t.Helper() |
| 79 | result, err := tenant.Execute(context.Background(), tenant.Policy().Owner, "addcustomview" , viewParams(values)) |
| 80 | if err != nil { |
| 81 | t.Fatalf("addcustomview: %v" , err) |
| 82 | } |
| 83 | return result.(map[string]any) |
| 84 | } |
| 85 | |
| 86 | // viewTenant is a loopback tenant with a transform server and one view that |
| 87 | // watches the given kinds. |
| 88 | func viewTenant(t *testing.T, kinds []int, extra map[string]any) (*Tenant, *transformServer, map[string]any) { |
| 89 | t.Helper() |
| 90 | _, tenant := loopbackTenant(t) |
| 91 | server := &transformServer{} |
| 92 | server.answer(http.StatusOK) |
| 93 | service := httptest.NewTLSServer(server) |
| 94 | t.Cleanup(service.Close) |
| 95 | tenant.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. |
| 105 | func pendingViewIntents(t *testing.T, tenant *Tenant, name, eventID string) []work.Intent { |
| 106 | t.Helper() |
| 107 | rows, err := tenant.store.DB().QueryContext(context.Background(), "SELECT id,event_id,payload,next_at FROM work_intents WHERE kind=? AND target=? AND state='pending' AND (event_id=? OR event_id LIKE ? OR event_id LIKE ?) ORDER BY next_at,created_at,id" , viewTransform, name, eventID, eventID+"#%" , eventID+"@%" ) |
| 108 | if err != nil { |
| 109 | t.Fatal(err) |
| 110 | } |
| 111 | defer rows.Close() |
| 112 | var out []work.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. |
| 127 | func 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 | |
| 137 | func runPendingView(t *testing.T, tenant *Tenant, name, eventID string) { |
| 138 | t.Helper() |
| 139 | queued := pendingViewIntents(t, tenant, name, eventID) |
| 140 | if len(queued) != 1 { |
| 141 | t.Fatalf("queued intents for %s = %d" , eventID, len(queued)) |
| 142 | } |
| 143 | runViewIntent(t, tenant, queued[0]) |
| 144 | } |
| 145 | |
| 146 | func storedArtifact(t *testing.T, tenant *Tenant, name, hash string) (event.Event, bool) { |
| 147 | t.Helper() |
| 148 | var raw 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 | |
| 160 | func 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 | |
| 170 | func signRequestAs(t *testing.T, r *http.Request, secret string) { |
| 171 | t.Helper() |
| 172 | hash := sha256.Sum256(nil) |
| 173 | e := event.Event{Kind: 27235, CreatedAt: time.Now().Unix(), Tags: [][]string{{"u" , r.URL.String()}, {"method" , r.Method}, {"payload" , hex.EncodeToString(hash[:])}}} |
| 174 | if err := event.Sign(&e, secret); err != nil { |
| 175 | t.Fatal(err) |
| 176 | } |
| 177 | raw, _ := json.Marshal(e) |
| 178 | r.Header.Set("Authorization" , "Nostr " +base64.StdEncoding.EncodeToString(raw)) |
| 179 | } |
| 180 | |
| 181 | func getArtifact(t *testing.T, tenant *Tenant, path, secret string) *httptest.ResponseRecorder { |
| 182 | t.Helper() |
| 183 | req := httptest.NewRequest(http.MethodGet, "http://127.0.0.1:8080" +path, nil) |
| 184 | if secret != "" { |
| 185 | signRequestAs(t, req, secret) |
| 186 | } |
| 187 | res := httptest.NewRecorder() |
| 188 | tenant.ServeHTTP(res, req) |
| 189 | return res |
| 190 | } |
| 191 | |
| 192 | func 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 | |
| 342 | func TestCustomViewTransformSendsBlocksAndKeepsSignedArtifacts(t *testing.T) { |
| 343 | tenant, server, added := viewTenant(t, []int{1}, nil) |
| 344 | ctx := context.Background() |
| 345 | secret := added["secret" ].(string) |
| 346 | server.answer(http.StatusOK, svgArtifact(0), map[string]any{"block" : 2, "type" : "image/png" , "body" : base64.StdEncoding.EncodeToString([]byte(testPNGRaw))}, map[string]any{"block" : 1, "type" : "image/svg+xml" , "body" : testSVG}) |
| 347 | now := time.Now().Unix() |
| 348 | content := "Look:\n\n```mermaid\ngraph TD;\n A-->B;\n```\n\n```go\nfunc main() {}\n```\n\n~~~ dot\ndigraph { a -> b }\n~~~\n" |
| 349 | note := signedEvent(t, testOwnerSecret, 1, now, [][]string{{"expiration" , strconv.FormatInt(now+86400, 10)}}, content) |
| 350 | if err := publishAs(t, tenant, note); err != nil { |
| 351 | t.Fatal(err) |
| 352 | } |
| 353 | plain := signedEvent(t, testOwnerSecret, 1, now+1, nil, "no blocks here\n```go\nx\n```" ) |
| 354 | if err := publishAs(t, tenant, plain); err != nil { |
| 355 | t.Fatal(err) |
| 356 | } |
| 357 | if len(pendingViewIntents(t, tenant, "diagrams" , plain.ID)) != 0 { |
| 358 | t.Fatal("an event without a matching block was queued" ) |
| 359 | } |
| 360 | runPendingView(t, tenant, "diagrams" , note.ID) |
| 361 | if server.count() != 1 { |
| 362 | t.Fatalf("requests = %d" , server.count()) |
| 363 | } |
| 364 | headers, body := server.headers[0], server.requests[0] |
| 365 | mac := hmac.New(sha256.New, []byte(secret)) |
| 366 | mac.Write(body) |
| 367 | if headers.Get("Content-Type" ) != "application/json" || headers.Get("X-Tiny-View" ) != "diagrams" || headers.Get("X-Tiny-Relay" ) != "http://127.0.0.1:8080" || headers.Get("X-Tiny-Signature" ) != "sha256=" +hex.EncodeToString(mac.Sum(nil)) { |
| 368 | t.Fatalf("headers = %v" , headers) |
| 369 | } |
| 370 | 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 | |
| 525 | func 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 | |
| 577 | func 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 | |
| 599 | func 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 | |
| 631 | func 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 | |
| 726 | func 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 | |
| 772 | func 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 | |
| 830 | func 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 | |
| 874 | func 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 | |
| 940 | func 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 | } |