| 1 | package daemon |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "net/http" |
| 7 | "net/http/httptest" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 15 | "github.com/FelineStateMachine/tinyrelay/internal/relay" |
| 16 | "github.com/FelineStateMachine/tinyrelay/internal/views" |
| 17 | ) |
| 18 | |
| 19 | func TestCustomViewArtifactSourceVisibilityCoversMediaAndEventReads(t *testing.T) { |
| 20 | tenant, server, _ := viewTenant(t, []int{1}, nil) |
| 21 | server.answer(http.StatusOK, svgArtifact(0)) |
| 22 | first := signedEvent(t, testOwnerSecret, 1, time.Now().Unix(), nil, "```mermaid\ngraph TD; a-->b;\n```" ) |
| 23 | if err := publishAs(t, tenant, first); err != nil { |
| 24 | t.Fatal(err) |
| 25 | } |
| 26 | runPendingView(t, tenant, "diagrams" , first.ID) |
| 27 | second := signedEvent(t, testOwnerSecret, 1, time.Now().Unix()+1, nil, first.Content) |
| 28 | if err := publishAs(t, tenant, second); err != nil { |
| 29 | t.Fatal(err) |
| 30 | } |
| 31 | runPendingView(t, tenant, "diagrams" , second.ID) |
| 32 | ctx := context.Background() |
| 33 | if _, err := tenant.store.DB().ExecContext(ctx, `INSERT INTO hidden_events(id,reason) VALUES(?,'test')` , first.ID); err != nil { |
| 34 | t.Fatal(err) |
| 35 | } |
| 36 | _, image, ok := strings.Cut(views.Figure("diagrams" , "mermaid" , "graph TD; a-->b;" ), `<img src="` ) |
| 37 | if !ok { |
| 38 | t.Fatal("custom view has no image" ) |
| 39 | } |
| 40 | path, _, _ := strings.Cut(image, `"` ) |
| 41 | if response := getArtifact(t, tenant, path, "" ); response.Code != http.StatusOK { |
| 42 | t.Fatalf("visible source should serve shared media: %d" , response.Code) |
| 43 | } |
| 44 | hash := views.Hash("mermaid" , "graph TD; a-->b;" ) |
| 45 | rows, err := tenant.Query(ctx, []event.Filter{{Kinds: []int{event.KIND_VIEW}, Tags: map[string][]string{"d" : {"bind.ws/view/diagrams/" + hash}}}}, relay.Session{}) |
| 46 | if err != nil { |
| 47 | t.Fatal(err) |
| 48 | } |
| 49 | if len(rows) != 0 { |
| 50 | t.Fatalf("artifact event leaked a hidden source: %d" , len(rows)) |
| 51 | } |
| 52 | } |
| 53 | |
| 54 | func TestCustomViewArtifactSourceLifecycleBlocksMediaAndEventReads(t *testing.T) { |
| 55 | tests := []struct { |
| 56 | name string |
| 57 | mutate func(context.Context, *Tenant, event.Event) error |
| 58 | }{ |
| 59 | {name: "hidden" , mutate: func(ctx context.Context, tenant *Tenant, source event.Event) error { |
| 60 | _, err := tenant.store.DB().ExecContext(ctx, `INSERT INTO hidden_events(id,reason) VALUES(?,'test')` , source.ID) |
| 61 | return err |
| 62 | }}, |
| 63 | {name: "deleted" , mutate: func(ctx context.Context, tenant *Tenant, source event.Event) error { |
| 64 | _, err := tenant.store.DB().ExecContext(ctx, `DELETE FROM events WHERE id=?` , source.ID) |
| 65 | return err |
| 66 | }}, |
| 67 | {name: "expired" , mutate: func(ctx context.Context, tenant *Tenant, source event.Event) error { |
| 68 | _, err := tenant.store.DB().ExecContext(ctx, `UPDATE events SET expires=? WHERE id=?` , time.Now().Unix()-1, source.ID) |
| 69 | return err |
| 70 | }}, |
| 71 | } |
| 72 | for _, tc := range tests { |
| 73 | t.Run(tc.name, func(t *testing.T) { |
| 74 | tenant, server, _ := viewTenant(t, []int{1}, nil) |
| 75 | server.answer(http.StatusOK, svgArtifact(0)) |
| 76 | source := signedEvent(t, testOwnerSecret, 1, time.Now().Unix(), nil, "```mermaid\nA\n```" ) |
| 77 | if err := publishAs(t, tenant, source); err != nil { |
| 78 | t.Fatal(err) |
| 79 | } |
| 80 | runPendingView(t, tenant, "diagrams" , source.ID) |
| 81 | hash := views.Hash("mermaid" , "A" ) |
| 82 | path := "/views/diagrams/" + hash + ".svg" |
| 83 | if response := getArtifact(t, tenant, path, "" ); response.Code != http.StatusOK { |
| 84 | t.Fatalf("baseline artifact: %d" , response.Code) |
| 85 | } |
| 86 | if err := tc.mutate(context.Background(), tenant, source); err != nil { |
| 87 | t.Fatal(err) |
| 88 | } |
| 89 | if response := getArtifact(t, tenant, path, "" ); response.Code != http.StatusNotFound { |
| 90 | t.Fatalf("inaccessible source served media: %d" , response.Code) |
| 91 | } |
| 92 | rows, err := tenant.Query(context.Background(), []event.Filter{{Kinds: []int{event.KIND_VIEW}, Tags: map[string][]string{"d" : {"bind.ws/view/diagrams/" + hash}}}}, relay.Session{}) |
| 93 | if err != nil { |
| 94 | t.Fatal(err) |
| 95 | } |
| 96 | if len(rows) != 0 { |
| 97 | t.Fatalf("inaccessible source leaked artifact event: %d" , len(rows)) |
| 98 | } |
| 99 | }) |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | func TestCustomViewDoesNotTransformExpiredQueuedSource(t *testing.T) { |
| 104 | tenant, server, _ := viewTenant(t, []int{1}, nil) |
| 105 | server.answer(http.StatusOK, svgArtifact(0)) |
| 106 | now := time.Now().Unix() |
| 107 | source := signedEvent(t, testOwnerSecret, 1, now, [][]string{{"expiration" , fmt.Sprint(now + 3600)}}, "```mermaid\nA\n```" ) |
| 108 | if err := publishAs(t, tenant, source); err != nil { |
| 109 | t.Fatal(err) |
| 110 | } |
| 111 | queued := pendingViewIntents(t, tenant, "diagrams" , source.ID) |
| 112 | if len(queued) != 1 { |
| 113 | t.Fatalf("queued intents = %d" , len(queued)) |
| 114 | } |
| 115 | if _, err := tenant.store.DB().ExecContext(context.Background(), `UPDATE events SET expires=? WHERE id=?` , now-1, source.ID); err != nil { |
| 116 | t.Fatal(err) |
| 117 | } |
| 118 | runViewIntent(t, tenant, queued[0]) |
| 119 | if server.count() != 0 { |
| 120 | t.Fatalf("expired source was sent to transform: %d requests" , server.count()) |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | func TestCustomViewArtifactCoordinateRequiresCurrentMatchingSource(t *testing.T) { |
| 125 | tenant, server, _ := viewTenant(t, []int{event.KIND_REPO_STATE}, nil) |
| 126 | ctx := context.Background() |
| 127 | p := tenant.Policy() |
| 128 | p.Features.Grasp = true |
| 129 | if err := tenant.applyPolicy(ctx, p); err != nil { |
| 130 | t.Fatal(err) |
| 131 | } |
| 132 | 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" }}, "" ) |
| 133 | if err := publishAs(t, tenant, announcement); err != nil { |
| 134 | t.Fatal(err) |
| 135 | } |
| 136 | source := filepath.Join(t.TempDir(), "source.git" ) |
| 137 | gitTest(t, "init" , "--bare" , source) |
| 138 | work := filepath.Join(t.TempDir(), "work" ) |
| 139 | gitTest(t, "clone" , source, work) |
| 140 | gitTest(t, "-C" , work, "config" , "user.email" , "test@example.com" ) |
| 141 | gitTest(t, "-C" , work, "config" , "user.name" , "Test" ) |
| 142 | if err := os.WriteFile(filepath.Join(work, "README.md" ), []byte("# Docs\n\n```mermaid\ngraph LR; a-->b;\n```\n" ), 0o600); err != nil { |
| 143 | t.Fatal(err) |
| 144 | } |
| 145 | gitTest(t, "-C" , work, "add" , "README.md" ) |
| 146 | gitTest(t, "-C" , work, "commit" , "-m" , "readme" ) |
| 147 | gitTest(t, "-C" , work, "branch" , "-M" , "main" ) |
| 148 | sha := strings.TrimSpace(gitOutput(t, "-C" , work, "rev-parse" , "HEAD" )) |
| 149 | state := signedEvent(t, testOwnerSecret, event.KIND_REPO_STATE, time.Now().Unix(), [][]string{{"d" , "docs" }, {"HEAD" , "ref: refs/heads/main" }, {"refs/heads/main" , sha}}, "" ) |
| 150 | if err := publishAs(t, tenant, state); err != nil { |
| 151 | t.Fatal(err) |
| 152 | } |
| 153 | relayServer := httptest.NewServer(tenant) |
| 154 | defer relayServer.Close() |
| 155 | gitTest(t, "-C" , work, "push" , relayServer.URL+"/npub10xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqpkge6d/docs.git" , "refs/heads/main:refs/heads/main" ) |
| 156 | queued := pendingViewIntents(t, tenant, "diagrams" , state.ID) |
| 157 | if len(queued) != 1 { |
| 158 | t.Fatalf("state intents = %d" , len(queued)) |
| 159 | } |
| 160 | server.answer(http.StatusOK, svgArtifact(0)) |
| 161 | runViewIntent(t, tenant, queued[0]) |
| 162 | hash := views.Hash("mermaid" , "graph LR; a-->b;" ) |
| 163 | path := "/views/diagrams/" + hash + ".svg" |
| 164 | if blocks, blockErr := tenant.customViews.sourceBlocks(ctx, state); blockErr != nil { |
| 165 | t.Fatalf("initial source blocks: %v" , blockErr) |
| 166 | } else if len(blocks) != 1 { |
| 167 | t.Fatalf("initial source blocks = %+v" , blocks) |
| 168 | } |
| 169 | if response := getArtifact(t, tenant, path, "" ); response.Code != http.StatusOK { |
| 170 | t.Fatalf("artifact through current state: %d" , response.Code) |
| 171 | } |
| 172 | rows, err := tenant.Query(ctx, []event.Filter{{Kinds: []int{event.KIND_VIEW}, Tags: map[string][]string{"d" : {"bind.ws/view/diagrams/" + hash}}}}, relay.Session{}) |
| 173 | if err != nil { |
| 174 | t.Fatal(err) |
| 175 | } |
| 176 | if len(rows) != 1 { |
| 177 | t.Fatalf("artifact query through current state = %d, want 1" , len(rows)) |
| 178 | } |
| 179 | if err := os.WriteFile(filepath.Join(work, "README.md" ), []byte("# Docs\n\nNo diagrams here.\n" ), 0o600); err != nil { |
| 180 | t.Fatal(err) |
| 181 | } |
| 182 | gitTest(t, "-C" , work, "add" , "README.md" ) |
| 183 | gitTest(t, "-C" , work, "commit" , "-m" , "remove diagram" ) |
| 184 | newSHA := strings.TrimSpace(gitOutput(t, "-C" , work, "rev-parse" , "HEAD" )) |
| 185 | replacement := signedEvent(t, testOwnerSecret, event.KIND_REPO_STATE, time.Now().Unix()+1, [][]string{{"d" , "docs" }, {"HEAD" , "ref: refs/heads/main" }, {"refs/heads/main" , newSHA}}, "" ) |
| 186 | if err := publishAs(t, tenant, replacement); err != nil { |
| 187 | t.Fatal(err) |
| 188 | } |
| 189 | gitTest(t, "-C" , work, "push" , relayServer.URL+"/npub10xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqpkge6d/docs.git" , "refs/heads/main:refs/heads/main" ) |
| 190 | if _, ok := storedArtifact(t, tenant, "diagrams" , hash); !ok { |
| 191 | t.Fatal("replacement removed the artifact before the access check" ) |
| 192 | } |
| 193 | if response := getArtifact(t, tenant, path, "" ); response.Code != http.StatusNotFound { |
| 194 | t.Fatalf("stale coordinate served media: %d" , response.Code) |
| 195 | } |
| 196 | rows, err = tenant.Query(ctx, []event.Filter{{Kinds: []int{event.KIND_VIEW}, Tags: map[string][]string{"d" : {"bind.ws/view/diagrams/" + hash}}}}, relay.Session{}) |
| 197 | if err != nil { |
| 198 | t.Fatal(err) |
| 199 | } |
| 200 | if len(rows) != 0 { |
| 201 | t.Fatalf("stale coordinate leaked artifact event: %d" , len(rows)) |
| 202 | } |
| 203 | } |