clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/standalone-relay
tinyrelay / internal / daemon / custom_views_access_test.go
custom_views_access_test.go 8.6K
1package daemon23import (4 "context"5 "fmt"6 "net/http"7 "net/http/httptest"8 "os"9 "path/filepath"10 "strings"11 "testing"12 "time"1314 "github.com/FelineStateMachine/tinyrelay/internal/event"15 "github.com/FelineStateMachine/tinyrelay/internal/relay"16 "github.com/FelineStateMachine/tinyrelay/internal/views"17)1819func 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}5354func TestCustomViewArtifactSourceLifecycleBlocksMediaAndEventReads(t *testing.T) {55 tests := []struct {56 name string57 mutate func(context.Context, *Tenant, event.Event) error58 }{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 err62 }},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 err66 }},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 err70 }},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}102103func 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}123124func 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 = true129 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}