clone

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

feat/standalone-relay
tinyrelay / internal / daemon / member_file_access_test.go
member_file_access_test.go 5.5K
1package daemon23import (4 "context"5 "encoding/json"6 "net/http"7 "net/http/httptest"8 "strings"9 "testing"10)1112func TestMemberFileAccessAcrossHTTPAndBrowseSurfaces(t *testing.T) {13 h := newRoomHarness(t)14 ctx := context.Background()15 const body = "members-only file bytes"16 request := httptest.NewRequest(http.MethodPut, "http://relay.test/upload?access=members&filename=private.txt", strings.NewReader(body))17 request.Header.Set("Content-Type", "text/plain")18 signRequestWithSecret(t, request, body, h.secrets["alice"])19 uploaded := httptest.NewRecorder()20 h.tenant.ServeHTTP(uploaded, request)21 if uploaded.Code != http.StatusOK {22 t.Fatalf("member upload = %d: %s", uploaded.Code, uploaded.Body.String())23 }24 var descriptor struct {25 Hash string `json:"sha256"`26 }27 if err := json.Unmarshal(uploaded.Body.Bytes(), &descriptor); err != nil || len(descriptor.Hash) != 64 {28 t.Fatalf("upload descriptor = %s: %v", uploaded.Body.String(), err)29 }3031 if rows, err := h.browse("bob", "browsefiles", map[string]any{"view": "library", "limit": 50}); err != nil {32 t.Fatalf("member library: %v", err)33 } else if !libraryContainsHash(rows, descriptor.Hash, "private.txt") {34 t.Fatalf("member library omitted private file: %v", rows)35 }36 if rows, err := h.browse("stranger", "browsefiles", map[string]any{"view": "library", "limit": 50}); err != nil {37 t.Fatalf("outsider library: %v", err)38 } else if libraryContainsHash(rows, descriptor.Hash, "private.txt") {39 t.Fatalf("outsider library exposed private file: %v", rows)40 }4142 for _, route := range []string{"/" + descriptor.Hash, "/nip96/" + descriptor.Hash, "/files/raw?hash=" + descriptor.Hash, "/media/" + descriptor.Hash} {43 for _, method := range []string{http.MethodGet, http.MethodHead} {44 for _, actor := range []string{"owner", "bob"} {45 t.Run(method+" "+actor+" "+route, func(t *testing.T) {46 response := memberFileRequest(t, h, method, route, actor)47 if response.Code != http.StatusOK {48 t.Fatalf("%s response = %d: %s", actor, response.Code, response.Body.String())49 }50 if method == http.MethodGet && response.Body.String() != body {51 t.Fatalf("%s body = %q", actor, response.Body.String())52 }53 })54 }55 t.Run(method+" guest "+route, func(t *testing.T) {56 response := memberFileRequest(t, h, method, route, "")57 if response.Code != http.StatusUnauthorized && response.Code != http.StatusForbidden {58 t.Fatalf("guest response = %d: %s", response.Code, response.Body.String())59 }60 })61 t.Run(method+" outsider "+route, func(t *testing.T) {62 response := memberFileRequest(t, h, method, route, "stranger")63 if response.Code != http.StatusForbidden {64 t.Fatalf("outsider response = %d: %s", response.Code, response.Body.String())65 }66 })67 }68 }6970 for _, actor := range []string{"bob", "stranger"} {71 request := httptest.NewRequest(http.MethodGet, "http://relay.test/file?hash="+descriptor.Hash, nil)72 if actor != "" {73 signRequestWithSecret(t, request, "", h.secrets[actor])74 }75 response := httptest.NewRecorder()76 h.tenant.ServeHTTP(response, request)77 page := response.Body.String()78 if actor == "bob" {79 if response.Code != http.StatusOK || !strings.Contains(page, "private.txt") {80 t.Fatalf("member file page = %d: %s", response.Code, page)81 }82 } else if strings.Contains(page, "private.txt") || strings.Contains(page, body) {83 t.Fatalf("outsider file page leaked private data: %d: %s", response.Code, page)84 }85 }8687 result, err := h.tenant.Execute(ctx, h.keys["bob"], "browsefile", []json.RawMessage{rawJSON(map[string]any{"hash": descriptor.Hash})})88 if err != nil || !strings.Contains(string(rawJSON(result)), "private.txt") {89 t.Fatalf("member browsefile = %v, %v", result, err)90 }91 if _, err := h.tenant.Execute(ctx, h.keys["stranger"], "browsefile", []json.RawMessage{rawJSON(map[string]any{"hash": descriptor.Hash})}); err == nil {92 t.Fatal("outsider browsefile exposed private file")93 }9495 if _, err := h.tenant.Execute(ctx, h.keys["owner"], "removemember", []json.RawMessage{rawJSON(h.keys["bob"])}); err != nil {96 t.Fatalf("remove member: %v", err)97 }98 if response := memberFileRequest(t, h, http.MethodGet, "/files/raw?hash="+descriptor.Hash+"&check=revoked", "bob"); response.Code != http.StatusForbidden {99 t.Fatalf("revoked member response = %d: %s", response.Code, response.Body.String())100 }101 if _, err := h.tenant.Execute(ctx, h.keys["owner"], "setmember", []json.RawMessage{rawJSON(h.keys["bob"]), rawJSON(map[string]any{"role": "member"})}); err != nil {102 t.Fatalf("restore member: %v", err)103 }104 if _, err := h.tenant.Execute(ctx, h.keys["owner"], "banpubkey", []json.RawMessage{rawJSON(h.keys["bob"]), rawJSON("test")}); err != nil {105 t.Fatalf("ban member: %v", err)106 }107 if response := memberFileRequest(t, h, http.MethodGet, "/files/raw?hash="+descriptor.Hash+"&check=banned", "bob"); response.Code != http.StatusForbidden {108 t.Fatalf("banned member response = %d: %s", response.Code, response.Body.String())109 }110}111112func memberFileRequest(t *testing.T, h *roomHarness, method, route, actor string) *httptest.ResponseRecorder {113 t.Helper()114 request := httptest.NewRequest(method, "http://relay.test"+route, nil)115 if actor != "" {116 signRequestWithSecret(t, request, "", h.secrets[actor])117 }118 response := httptest.NewRecorder()119 h.tenant.ServeHTTP(response, request)120 return response121}122123func libraryContainsHash(rows map[string]any, hash, name string) bool {124 items, ok := rows["items"].([]any)125 if !ok {126 return false127 }128 for _, raw := range items {129 item, ok := raw.(map[string]any)130 if ok && item["sha256"] == hash && item["name"] == name {131 return true132 }133 }134 return false135}