| 1 | package sites |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "database/sql" |
| 6 | "io" |
| 7 | "net" |
| 8 | "net/http" |
| 9 | "net/http/httptest" |
| 10 | "path/filepath" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | |
| 14 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 15 | "github.com/FelineStateMachine/tinyrelay/internal/policy" |
| 16 | "github.com/FelineStateMachine/tinyrelay/internal/storage" |
| 17 | ) |
| 18 | |
| 19 | func TestSiteLabelsAndManifestValidation(t *testing.T) { |
| 20 | key := "0000000000000000000000000000000000000000000000000000000000000001" |
| 21 | hash := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" |
| 22 | paths := [][]string{{"path" , "/index.html" , hash}, {"path" , "/404.html" , hash}} |
| 23 | root := event.Event{Kind: KindSite, PubKey: key, Tags: paths} |
| 24 | if got := SiteLabel(root); got == "" { |
| 25 | t.Fatal("root site label is empty" ) |
| 26 | } |
| 27 | if err := ValidateManifest(root); err != nil { |
| 28 | t.Fatalf("root manifest rejected: %v" , err) |
| 29 | } |
| 30 | named := root |
| 31 | named.Kind = KindNamedSite |
| 32 | named.Tags = append(append([][]string(nil), paths...), []string{"d" , "demo-site" }) |
| 33 | if err := ValidateManifest(named); err != nil { |
| 34 | t.Fatalf("named manifest rejected: %v" , err) |
| 35 | } |
| 36 | bad := root |
| 37 | bad.Tags = append(append([][]string(nil), paths...), []string{"path" , "/../x.js" , hash}) |
| 38 | if err := ValidateManifest(bad); err == nil { |
| 39 | t.Fatal("path traversal manifest accepted" ) |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | func TestHandlerServesVerifiedBlobAndFallback(t *testing.T) { |
| 44 | const hash = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" |
| 45 | store, err := storage.Open(context.Background(), filepath.Join(t.TempDir(), "relay.db" )) |
| 46 | if err != nil { |
| 47 | t.Fatal(err) |
| 48 | } |
| 49 | t.Cleanup(func() { _ = store.Close() }) |
| 50 | service, err := New(Config{Store: store, BaseDomain: "example.test" , Policy: func() policy.Policy { return policy.Defaults("owner" ) }, GetBlob: func(context.Context, string) (Blob, error) { |
| 51 | return Blob{Body: io.NopCloser(strings.NewReader("hello" )), Type: "text/plain" }, nil |
| 52 | }}) |
| 53 | if err != nil { |
| 54 | t.Fatal(err) |
| 55 | } |
| 56 | key := "0000000000000000000000000000000000000000000000000000000000000001" |
| 57 | e := event.Event{ID: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" , Kind: KindSite, PubKey: key, Tags: [][]string{{"path" , "/index.html" , hash}, {"path" , "/blog/index.html" , hash}, {"path" , "/404.html" , hash}}} |
| 58 | if _, err := store.Save(context.Background(), e, service.SaveOptions(e, 1)); err != nil { |
| 59 | t.Fatal(err) |
| 60 | } |
| 61 | host := SiteLabel(e) + ".example.test" |
| 62 | req := httptest.NewRequest(http.MethodGet, "https://" +host+"/" , nil) |
| 63 | response := httptest.NewRecorder() |
| 64 | service.Handler().ServeHTTP(response, req) |
| 65 | if response.Code != http.StatusOK || response.Body.String() != "hello" { |
| 66 | t.Fatalf("root = %d %q" , response.Code, response.Body.String()) |
| 67 | } |
| 68 | req = httptest.NewRequest(http.MethodGet, "https://" +host+"/index.html" , nil) |
| 69 | req.Header.Set("Range" , "bytes=1-3" ) |
| 70 | response = httptest.NewRecorder() |
| 71 | service.Handler().ServeHTTP(response, req) |
| 72 | if response.Code != http.StatusPartialContent || response.Body.String() != "ell" { |
| 73 | t.Fatalf("range = %d %q" , response.Code, response.Body.String()) |
| 74 | } |
| 75 | req = httptest.NewRequest(http.MethodGet, "https://" +host+"/blog?x=1" , nil) |
| 76 | req.Header.Set("Accept" , "text/html" ) |
| 77 | response = httptest.NewRecorder() |
| 78 | service.Handler().ServeHTTP(response, req) |
| 79 | if response.Code != http.StatusPermanentRedirect { |
| 80 | t.Fatalf("directory redirect = %d" , response.Code) |
| 81 | } |
| 82 | location, err := response.Result().Location() |
| 83 | if err != nil { |
| 84 | t.Fatal(err) |
| 85 | } |
| 86 | if location.Path != "/blog/" || location.RawQuery != "x=1" { |
| 87 | t.Fatalf("directory location = %s" , location.String()) |
| 88 | } |
| 89 | req = httptest.NewRequest(http.MethodGet, "https://" +host+"/.well-known/nostr.json?path=%2F" , nil) |
| 90 | response = httptest.NewRecorder() |
| 91 | service.Handler().ServeHTTP(response, req) |
| 92 | if response.Code != http.StatusOK || response.Header().Get("Access-Control-Allow-Origin" ) != "*" || !strings.Contains(response.Body.String(), `"authors"` ) { |
| 93 | t.Fatalf("discovery = %d %q" , response.Code, response.Body.String()) |
| 94 | } |
| 95 | req.Method = http.MethodHead |
| 96 | response = httptest.NewRecorder() |
| 97 | service.Handler().ServeHTTP(response, req) |
| 98 | if response.Code != http.StatusOK || response.Body.Len() != 0 { |
| 99 | t.Fatalf("discovery HEAD = %d %q" , response.Code, response.Body.String()) |
| 100 | } |
| 101 | req = httptest.NewRequest(http.MethodGet, "https://" +host+"/missing.js" , nil) |
| 102 | response = httptest.NewRecorder() |
| 103 | service.Handler().ServeHTTP(response, req) |
| 104 | if response.Code != http.StatusNotFound || response.Body.String() != "hello" { |
| 105 | t.Fatalf("fallback = %d %q" , response.Code, response.Body.String()) |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | func TestMirrorUsesVerifiedRemoteAndBlobWriter(t *testing.T) { |
| 110 | const hash = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" |
| 111 | store, err := storage.Open(context.Background(), filepath.Join(t.TempDir(), "relay.db" )) |
| 112 | if err != nil { |
| 113 | t.Fatal(err) |
| 114 | } |
| 115 | t.Cleanup(func() { _ = store.Close() }) |
| 116 | var saved []byte |
| 117 | service, err := New(Config{Store: store, Policy: func() policy.Policy { return policy.Defaults("owner" ) }, Fetch: func(context.Context, *http.Request) (*http.Response, error) { |
| 118 | return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("hello" )), Header: make(http.Header)}, nil |
| 119 | }, ResolveIP: func(context.Context, string) ([]net.IP, error) { return []net.IP{{8, 8, 8, 8}}, nil }, PutBlob: func(_ context.Context, _ string, _ string, body io.Reader) error { |
| 120 | var err error |
| 121 | saved, err = io.ReadAll(body) |
| 122 | return err |
| 123 | }}) |
| 124 | if err != nil { |
| 125 | t.Fatal(err) |
| 126 | } |
| 127 | manifest := event.Event{ID: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" , Kind: KindSite, PubKey: "0000000000000000000000000000000000000000000000000000000000000001" , Tags: [][]string{{"path" , "/index.html" , hash}, {"server" , "https://origin.example" }}} |
| 128 | if err := service.Mirror(context.Background(), manifest); err != nil { |
| 129 | t.Fatal(err) |
| 130 | } |
| 131 | if string(saved) != "hello" { |
| 132 | t.Fatalf("saved = %q" , saved) |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | func TestManifestIndexFollowsEventDeletion(t *testing.T) { |
| 137 | store, err := storage.Open(context.Background(), filepath.Join(t.TempDir(), "relay.db" )) |
| 138 | if err != nil { |
| 139 | t.Fatal(err) |
| 140 | } |
| 141 | t.Cleanup(func() { _ = store.Close() }) |
| 142 | service, err := New(Config{Store: store, Policy: func() policy.Policy { return policy.Defaults("owner" ) }}) |
| 143 | if err != nil { |
| 144 | t.Fatal(err) |
| 145 | } |
| 146 | e := event.Event{ID: strings.Repeat("b" , 64), Kind: KindSite, PubKey: strings.Repeat("0" , 63) + "1" , Tags: [][]string{{"path" , "/index.html" , strings.Repeat("a" , 64)}}} |
| 147 | if _, err := store.Save(context.Background(), e, service.SaveOptions(e, 1)); err != nil { |
| 148 | t.Fatal(err) |
| 149 | } |
| 150 | if err := store.WithTx(context.Background(), func(tx *sql.Tx) error { _, err := tx.Exec("DELETE FROM events WHERE id=?" , e.ID); return err }); err != nil { |
| 151 | t.Fatal(err) |
| 152 | } |
| 153 | var count int |
| 154 | if err := store.DB().QueryRow("SELECT count(*) FROM site_manifests WHERE event_id=?" , e.ID).Scan(&count); err != nil { |
| 155 | t.Fatal(err) |
| 156 | } |
| 157 | if count != 0 { |
| 158 | t.Fatalf("manifest index count = %d" , count) |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | func TestSnapshotAggregateAndParseLabel(t *testing.T) { |
| 163 | key := "0000000000000000000000000000000000000000000000000000000000000001" |
| 164 | paths := [][]string{{"path" , "/index.html" , "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }} |
| 165 | aggregate := Aggregate(paths) |
| 166 | if len(aggregate) != 64 { |
| 167 | t.Fatalf("aggregate length = %d" , len(aggregate)) |
| 168 | } |
| 169 | snapshot := event.Event{Kind: KindSiteSnapshot, PubKey: key, ID: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" , Tags: append(paths, []string{"x" , aggregate, "aggregate" }, []string{"a" , "15128:" + key + ":" })} |
| 170 | if err := ValidateManifest(snapshot); err != nil { |
| 171 | t.Fatalf("snapshot rejected: %v" , err) |
| 172 | } |
| 173 | parsed, ok := ParseSite(SiteLabel(snapshot)) |
| 174 | if !ok || parsed.Kind != KindSiteSnapshot || parsed.ID != snapshot.ID { |
| 175 | t.Fatalf("snapshot label did not round trip: %#v" , parsed) |
| 176 | } |
| 177 | } |