clone

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

feat/ui-updates
tinyrelay / internal / daemon / client_files_test.go
client_files_test.go 8.9K
1package daemon23import (4 "context"5 "encoding/json"6 "fmt"7 "strings"8 "testing"9 "time"1011 "github.com/FelineStateMachine/tinyrelay/internal/blob"12 "github.com/FelineStateMachine/tinyrelay/internal/event"13 gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"14 "github.com/FelineStateMachine/tinyrelay/internal/relay"15 "github.com/FelineStateMachine/tinyrelay/internal/sites"16)1718func fileItems(t *testing.T, value any) []map[string]any {19 t.Helper()20 row, ok := value.(map[string]any)21 if !ok {22 t.Fatalf("files result = %T", value)23 }24 items, ok := row["items"].([]map[string]any)25 if !ok {26 t.Fatalf("files items = %T", row["items"])27 }28 return items29}3031func TestFolderItemsKeepsFoldersAndFilesPageable(t *testing.T) {32 rows := []map[string]any{33 {"sha256": "aaaaaaaa", "type": "text/plain", "uploader": "alice", "uploaded": int64(2), "name": "readme.txt", "path": "docs/readme.txt"},34 {"sha256": "bbbbbbbb", "type": "image/png", "uploader": "alice", "uploaded": int64(1), "name": "cover.png", "path": "cover.png"},35 }36 first := (&Tenant{}).folderItems(rows, map[string]bool{}, clientBrowseRequest{BrowseRequest: browseRequestForTest(1)}, "library", true)37 items := first["items"].([]map[string]any)38 if len(items) != 1 || items[0]["kind"] != "folder" || first["next_cursor"] == "" {39 t.Fatalf("first page = %#v", first)40 }41 second := (&Tenant{}).folderItems(rows, map[string]bool{}, clientBrowseRequest{BrowseRequest: browseRequestForTest(1), Cursor: first["next_cursor"].(string)}, "library", true)42 items = second["items"].([]map[string]any)43 if len(items) != 1 || items[0]["kind"] != "file" || items[0]["name"] != "cover.png" {44 t.Fatalf("second page = %#v", second)45 }46}4748func browseRequestForTest(limit int) gitrelay.BrowseRequest {49 return gitrelay.BrowseRequest{Limit: limit}50}5152func TestContextualRowsRetainsDistinctPathsForSharedHash(t *testing.T) {53 rows := []map[string]any{{"sha256": "same", "name": "old", "path": "old"}}54 out := contextualRows(rows, map[string][]string{"same": {"site-a/index.html", "site-b/index.html"}}, "site")55 if len(out) != 2 || out[0]["path"] == out[1]["path"] {56 t.Fatalf("contextual rows = %#v", out)57 }58}5960func TestContextualFilesRespectClaimNamesAndPaginateLibrary(t *testing.T) {61 _, tenant := testTenant(t)62 ctx := context.Background()63 owner := tenant.Policy().Owner64 other := strings.Repeat("b", 64)65 shared, err := tenant.blobs.Put(ctx, blob.PutOptions{Reader: strings.NewReader("shared"), Type: "text/plain", Uploader: owner, Name: "owner.txt", Path: "docs/owner.txt"})66 if err != nil {67 t.Fatal(err)68 }69 if _, err := tenant.blobs.Put(ctx, blob.PutOptions{Reader: strings.NewReader("shared"), Type: "text/plain", Uploader: other, Name: "private.txt", Path: "private/private.txt"}); err != nil {70 t.Fatal(err)71 }72 for i := 0; i < 60; i++ {73 if _, err := tenant.blobs.Put(ctx, blob.PutOptions{Reader: strings.NewReader(fmt.Sprintf("library-%d", i)), Type: "text/plain", Uploader: owner, Name: fmt.Sprintf("file-%02d.txt", i), Path: fmt.Sprintf("folder-%02d/file-%02d.txt", i, i)}); err != nil {74 t.Fatal(err)75 }76 }77 firstValue, err := tenant.Execute(ctx, owner, "browsefiles", []json.RawMessage{rawJSON(map[string]any{"view": "library", "limit": 50})})78 if err != nil {79 t.Fatal(err)80 }81 first := fileItems(t, firstValue)82 firstRow := firstValue.(map[string]any)83 if len(first) != 50 || firstRow["next_cursor"] == "" {84 t.Fatalf("library first page = %d, next=%v", len(first), firstRow["next_cursor"])85 }86 secondValue, err := tenant.Execute(ctx, owner, "browsefiles", []json.RawMessage{rawJSON(map[string]any{"view": "library", "limit": 50, "cursor": firstRow["next_cursor"]})})87 if err != nil {88 t.Fatal(err)89 }90 second := fileItems(t, secondValue)91 if len(second) != 11 {92 t.Fatalf("library second page = %d, want 11", len(second))93 }94 seen := map[string]bool{}95 for _, item := range append(first, second...) {96 key := item["path"].(string)97 if seen[key] {98 t.Fatalf("duplicate library path %q", key)99 }100 seen[key] = true101 }102 ownerFile, err := tenant.Execute(ctx, owner, "browsefile", []json.RawMessage{rawJSON(map[string]any{"hash": shared.SHA256})})103 if err != nil {104 t.Fatal(err)105 }106 if ownerFile.(map[string]any)["name"] != "owner.txt" || ownerFile.(map[string]any)["path"] != "docs/owner.txt" {107 t.Fatalf("owner file metadata = %#v", ownerFile)108 }109 otherFile, err := tenant.Execute(ctx, other, "browsefile", []json.RawMessage{rawJSON(map[string]any{"hash": shared.SHA256})})110 if err != nil {111 t.Fatal(err)112 }113 if otherFile.(map[string]any)["name"] == "owner.txt" || otherFile.(map[string]any)["path"] == "docs/owner.txt" {114 t.Fatalf("other uploader saw owner's name: %#v", otherFile)115 }116}117118func TestStorageViewDeduplicatesSharedBlobClaims(t *testing.T) {119 _, tenant := testTenant(t)120 ctx := context.Background()121 owner := tenant.Policy().Owner122 other := strings.Repeat("c", 64)123 entry, err := tenant.blobs.Put(ctx, blob.PutOptions{Reader: strings.NewReader("same bytes"), Type: "text/plain", Uploader: owner, Name: "owner.txt"})124 if err != nil {125 t.Fatal(err)126 }127 if _, err := tenant.blobs.Put(ctx, blob.PutOptions{Reader: strings.NewReader("same bytes"), Type: "text/plain", Uploader: other, Name: "other.txt"}); err != nil {128 t.Fatal(err)129 }130 value, err := tenant.Execute(ctx, owner, "browsefiles", []json.RawMessage{rawJSON(map[string]any{"view": "storage", "limit": 50})})131 if err != nil {132 t.Fatal(err)133 }134 items := fileItems(t, value)135 count := 0136 for _, item := range items {137 if item["sha256"] == entry.SHA256 {138 count++139 }140 }141 if count != 1 {142 t.Fatalf("shared blob appears %d times in storage view: %#v", count, items)143 }144}145146func TestSiteFilesRetainSharedHashPaths(t *testing.T) {147 app, tenant := testTenant(t)148 defer app.Close(context.Background())149 ctx := context.Background()150 ownerSecret := strings.Repeat("0", 63) + "1"151 owner := tenant.Policy().Owner152 entry, err := tenant.blobs.Put(ctx, blob.PutOptions{Reader: strings.NewReader("site bytes"), Type: "text/html", Uploader: owner})153 if err != nil {154 t.Fatal(err)155 }156 manifest := event.Event{Kind: sites.KindSite, CreatedAt: time.Now().Unix(), Tags: [][]string{{"path", "/index.html", entry.SHA256}, {"path", "/mirror.html", entry.SHA256}}}157 if err := event.Sign(&manifest, ownerSecret); err != nil {158 t.Fatal(err)159 }160 if _, err := tenant.Publish(ctx, manifest, relay.Session{PubKeys: []string{owner}}); err != nil {161 t.Fatal(err)162 }163 value, err := tenant.Execute(ctx, owner, "browsefiles", []json.RawMessage{rawJSON(map[string]any{"view": "sites", "limit": 50})})164 if err != nil {165 t.Fatal(err)166 }167 paths := map[string]bool{}168 for _, folder := range fileItems(t, value) {169 if folder["kind"] != "folder" && folder["kind"] != "site" {170 continue171 }172 child, err := tenant.Execute(ctx, owner, "browsefiles", []json.RawMessage{rawJSON(map[string]any{"view": "sites", "path": folder["path"], "limit": 50})})173 if err != nil {174 t.Fatal(err)175 }176 for _, item := range fileItems(t, child) {177 if item["sha256"] == entry.SHA256 {178 paths[item["path"].(string)] = true179 }180 }181 }182 if len(paths) != 2 {183 t.Fatalf("site paths for shared hash = %#v", paths)184 }185}186187func TestRoomFilesRespectPrivateAccessAndSameNameRooms(t *testing.T) {188 h := newRoomHarness(t)189 ctx := h.ctx190 owner := h.keys["owner"]191 h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "private-one"}, {"name", "Twin"}, {"closed"}}, "")192 h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "private-two"}, {"name", "Twin"}, {"closed"}}, "")193 for i, roomID := range []string{"private-one", "private-two"} {194 room, err := h.tenant.community.Room(ctx, roomID)195 if err != nil {196 t.Fatal(err)197 }198 entry, err := h.tenant.blobs.Put(ctx, blob.PutOptions{Reader: strings.NewReader(fmt.Sprintf("private room %d", i)), Type: "text/plain", Uploader: h.keys["alice"]})199 if err != nil {200 t.Fatal(err)201 }202 if _, err := h.tenant.store.DB().ExecContext(ctx, "INSERT INTO room_attachments(room_id,room_event_id,sha256,filename,created_at) VALUES(?,?,?,?,unixepoch())", roomID, room.EventID, entry.SHA256, fmt.Sprintf("note-%d.txt", i)); err != nil {203 t.Fatal(err)204 }205 }206 ownerValue, err := h.tenant.Execute(ctx, owner, "browsefiles", []json.RawMessage{rawJSON(map[string]any{"view": "rooms", "limit": 50})})207 if err != nil {208 t.Fatal(err)209 }210 paths := map[string]bool{}211 for _, folder := range fileItems(t, ownerValue) {212 if folder["kind"] != "folder" && folder["kind"] != "room" {213 continue214 }215 child, err := h.tenant.Execute(ctx, owner, "browsefiles", []json.RawMessage{rawJSON(map[string]any{"view": "rooms", "path": folder["path"], "limit": 50})})216 if err != nil {217 t.Fatal(err)218 }219 for _, item := range fileItems(t, child) {220 if item["kind"] == "file" {221 paths[item["path"].(string)] = true222 }223 }224 }225 if len(paths) != 2 {226 t.Fatalf("same-name room paths = %#v", paths)227 }228 bobValue, err := h.tenant.Execute(ctx, h.keys["bob"], "browsefiles", []json.RawMessage{rawJSON(map[string]any{"view": "rooms", "limit": 50})})229 if err != nil {230 t.Fatal(err)231 }232 if got := len(fileItems(t, bobValue)); got != 0 {233 t.Fatalf("private room files visible to outsider: %d", got)234 }235}