| 1 | package records |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "path/filepath" |
| 6 | "testing" |
| 7 | |
| 8 | "github.com/FelineStateMachine/tinyrelay/internal/communityread" |
| 9 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 10 | "github.com/FelineStateMachine/tinyrelay/internal/policy" |
| 11 | "github.com/FelineStateMachine/tinyrelay/internal/storage" |
| 12 | ) |
| 13 | |
| 14 | type emptyCommunityReader struct{} |
| 15 | |
| 16 | func (emptyCommunityReader) Members(context.Context) ([]communityread.Member, error) { |
| 17 | return nil, nil |
| 18 | } |
| 19 | |
| 20 | func (emptyCommunityReader) ModerationCounts(context.Context, int64) (communityread.ModerationCounts, error) { |
| 21 | return communityread.ModerationCounts{}, nil |
| 22 | } |
| 23 | |
| 24 | func (emptyCommunityReader) MemberStatus(context.Context, string) (bool, int, error) { |
| 25 | return false, 0, nil |
| 26 | } |
| 27 | |
| 28 | func testStore(t *testing.T) (*storage.Store, context.Context) { |
| 29 | t.Helper() |
| 30 | ctx := context.Background() |
| 31 | s, err := storage.Open(ctx, filepath.Join(t.TempDir(), "tenant.db" )) |
| 32 | if err != nil { |
| 33 | t.Fatal(err) |
| 34 | } |
| 35 | t.Cleanup(func() { _ = s.Close() }) |
| 36 | return s, ctx |
| 37 | } |
| 38 | |
| 39 | func TestIdentitySurvivesRestartAndClockIsMonotonic(t *testing.T) { |
| 40 | ctx := context.Background() |
| 41 | path := filepath.Join(t.TempDir(), "tenant.db" ) |
| 42 | s, err := storage.Open(ctx, path) |
| 43 | if err != nil { |
| 44 | t.Fatal(err) |
| 45 | } |
| 46 | p := policy.Defaults("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" ) |
| 47 | r, err := New(ctx, Config{Community: emptyCommunityReader{}, Store: s, Policy: func() policy.Policy { return p }, RelayURL: "wss://relay.example" }) |
| 48 | if err != nil { |
| 49 | t.Fatal(err) |
| 50 | } |
| 51 | first, err := r.Generate(ctx, event.KIND_APP_DATA, [][]string{{"d" , "x" }}, "one" , 100) |
| 52 | if err != nil { |
| 53 | t.Fatal(err) |
| 54 | } |
| 55 | if err := event.Validate(first); err != nil { |
| 56 | t.Fatal(err) |
| 57 | } |
| 58 | key := r.PublicKey() |
| 59 | if err := s.Close(); err != nil { |
| 60 | t.Fatal(err) |
| 61 | } |
| 62 | s2, err := storage.Open(ctx, path) |
| 63 | if err != nil { |
| 64 | t.Fatal(err) |
| 65 | } |
| 66 | r2, err := New(ctx, Config{Community: emptyCommunityReader{}, Store: s2, Policy: func() policy.Policy { return p }, RelayURL: "wss://relay.example" }) |
| 67 | if err != nil { |
| 68 | t.Fatal(err) |
| 69 | } |
| 70 | if r2.PublicKey() != key { |
| 71 | t.Fatalf("identity changed across restart: %s != %s" , r2.PublicKey(), key) |
| 72 | } |
| 73 | second, err := r2.Generate(ctx, event.KIND_APP_DATA, [][]string{{"d" , "x" }}, "two" , 1) |
| 74 | if err != nil { |
| 75 | t.Fatal(err) |
| 76 | } |
| 77 | if second.CreatedAt <= first.CreatedAt { |
| 78 | t.Fatalf("clock regressed: %d <= %d" , second.CreatedAt, first.CreatedAt) |
| 79 | } |
| 80 | if err := event.Validate(second); err != nil { |
| 81 | t.Fatal(err) |
| 82 | } |
| 83 | _ = s2.Close() |
| 84 | } |
| 85 | |
| 86 | func TestIdentityContinuityAndViewPrivacy(t *testing.T) { |
| 87 | s, ctx := testStore(t) |
| 88 | p := policy.Defaults("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" ) |
| 89 | p.Views = map[string]string{"profiles" : "daily" } |
| 90 | owner := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" |
| 91 | p.Owner = owner |
| 92 | r, err := New(ctx, Config{Community: emptyCommunityReader{}, Store: s, Policy: func() policy.Policy { return p }, RelayURL: "wss://relay.example" }) |
| 93 | if err != nil { |
| 94 | t.Fatal(err) |
| 95 | } |
| 96 | key := r.PublicKey() |
| 97 | p.Reads = "members" |
| 98 | if _, err := r.Views(ctx, policy.Access{}, 100); err == nil { |
| 99 | t.Fatal("private view leaked to guest" ) |
| 100 | } |
| 101 | if _, err := r.Views(ctx, policy.Access{Owner: true}, 100); err != nil { |
| 102 | t.Fatal(err) |
| 103 | } |
| 104 | if r.PublicKey() != key { |
| 105 | t.Fatal("public identity changed" ) |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | func TestDirectoryPrivateViewsRequireMembersAndDoNotTick(t *testing.T) { |
| 110 | s, ctx := testStore(t) |
| 111 | owner := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" |
| 112 | p := policy.Defaults(owner) |
| 113 | p.DirectoryPublic = false |
| 114 | p.Views = map[string]string{"profiles" : "daily" } |
| 115 | r, err := New(ctx, Config{Community: emptyCommunityReader{}, Store: s, Policy: func() policy.Policy { return p }, RelayURL: "wss://relay.example" }) |
| 116 | if err != nil { |
| 117 | t.Fatal(err) |
| 118 | } |
| 119 | if _, err := r.Views(ctx, policy.Access{}, 100); err == nil { |
| 120 | t.Fatal("private directory view leaked to guest" ) |
| 121 | } |
| 122 | if err := r.Tick(ctx, 100); err != nil { |
| 123 | t.Fatal(err) |
| 124 | } |
| 125 | var count int |
| 126 | if err := s.DB().QueryRow(`SELECT count(*) FROM events WHERE kind=?` , event.KIND_VIEW).Scan(&count); err != nil { |
| 127 | t.Fatal(err) |
| 128 | } |
| 129 | if count != 0 { |
| 130 | t.Fatalf("private tick stored %d views" , count) |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | func TestViewAudienceAndMemberFoldAreNotStored(t *testing.T) { |
| 135 | s, ctx := testStore(t) |
| 136 | owner := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" |
| 137 | p := policy.Defaults(owner) |
| 138 | p.DirectoryPublic = false |
| 139 | p.Reads = "open" |
| 140 | p.Views = map[string]string{"profiles" : "daily" , "relays" : "off" , "calendar" : "daily" , "moderation" : "off" , "articles" : "off" , "zaps" : "off" , "presence" : "off" } |
| 141 | generated := []event.Event{} |
| 142 | r, err := New(ctx, Config{Community: emptyCommunityReader{}, Store: s, Policy: func() policy.Policy { return p }, RelayURL: "wss://relay.example" , OnGenerated: func(context.Context, event.Event) error { |
| 143 | generated = append(generated, event.Event{}) |
| 144 | return nil |
| 145 | }}) |
| 146 | if err != nil { |
| 147 | t.Fatal(err) |
| 148 | } |
| 149 | // Calendar is read-public even when the directory is private, while the |
| 150 | // profile fold is member-only and must be signed without persistence. |
| 151 | views, err := r.Views(ctx, policy.Access{}, 100) |
| 152 | if err == nil { |
| 153 | t.Fatal("guest received a member-only profile view" ) |
| 154 | } |
| 155 | if err := r.Tick(ctx, 100); err != nil { |
| 156 | t.Fatal(err) |
| 157 | } |
| 158 | if len(generated) != 1 { |
| 159 | t.Fatalf("generated callbacks=%d, want public calendar only" , len(generated)) |
| 160 | } |
| 161 | _ = views |
| 162 | } |
| 163 | |
| 164 | func TestPresenceIsSignedWithoutGeneratedCallback(t *testing.T) { |
| 165 | s, ctx := testStore(t) |
| 166 | owner := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" |
| 167 | p := policy.Defaults(owner) |
| 168 | called := 0 |
| 169 | r, err := New(ctx, Config{Community: emptyCommunityReader{}, Store: s, Policy: func() policy.Policy { return p }, OnGenerated: func(context.Context, event.Event) error { |
| 170 | called++ |
| 171 | return nil |
| 172 | }}) |
| 173 | if err != nil { |
| 174 | t.Fatal(err) |
| 175 | } |
| 176 | if _, err := r.Presence(ctx, policy.Access{Owner: true}, 100); err != nil { |
| 177 | t.Fatal(err) |
| 178 | } |
| 179 | if called != 0 { |
| 180 | t.Fatalf("presence invoked generated callback %d times" , called) |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | func TestProfileFoldUsesMembersAndNIP05(t *testing.T) { |
| 185 | owner := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" |
| 186 | member := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" |
| 187 | e := event.Event{PubKey: owner, Kind: event.KIND_PROFILE, CreatedAt: 1, Content: `{"name":"Owner"}` } |
| 188 | tags, _ := profileFold([]event.Event{e}, "wss://relay.example" , map[string]string{owner: "" , member: "alice" }, []string{owner, member}) |
| 189 | if len(tags) != 2 || tags[1][1] != member || tags[1][4] != "alice@relay.example" { |
| 190 | t.Fatalf("profile fold=%v" , tags) |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | func TestHourlyViewFingerprintSkipsUnchangedRun(t *testing.T) { |
| 195 | s, ctx := testStore(t) |
| 196 | owner := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" |
| 197 | p := policy.Defaults(owner) |
| 198 | p.Views = map[string]string{"profiles" : "off" , "relays" : "off" , "calendar" : "hourly" , "moderation" : "off" , "articles" : "off" , "zaps" : "off" , "presence" : "off" } |
| 199 | generated := 0 |
| 200 | r, err := New(ctx, Config{Community: emptyCommunityReader{}, Store: s, Policy: func() policy.Policy { return p }, OnGenerated: func(context.Context, event.Event) error { |
| 201 | generated++ |
| 202 | return nil |
| 203 | }}) |
| 204 | if err != nil { |
| 205 | t.Fatal(err) |
| 206 | } |
| 207 | if err := r.Tick(ctx, 100); err != nil { |
| 208 | t.Fatal(err) |
| 209 | } |
| 210 | if err := r.Tick(ctx, 3700); err != nil { |
| 211 | t.Fatal(err) |
| 212 | } |
| 213 | if generated != 1 { |
| 214 | t.Fatalf("unchanged hourly view generated %d records" , generated) |
| 215 | } |
| 216 | runs, err := r.ViewRuns(ctx, "calendar" ) |
| 217 | if err != nil { |
| 218 | t.Fatal(err) |
| 219 | } |
| 220 | if len(runs) != 1 { |
| 221 | t.Fatalf("runs=%d, want only published run" , len(runs)) |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | func TestDefaultArticleWriteTriggerCoalescesDurably(t *testing.T) { |
| 226 | s, ctx := testStore(t) |
| 227 | owner := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" |
| 228 | p := policy.Defaults(owner) |
| 229 | for _, name := range []string{"profiles" , "relays" , "calendar" , "moderation" , "zaps" , "presence" } { |
| 230 | p.Views[name] = "off" |
| 231 | } |
| 232 | generated := 0 |
| 233 | r, err := New(ctx, Config{Community: emptyCommunityReader{}, Store: s, Policy: func() policy.Policy { return p }, OnGenerated: func(context.Context, event.Event) error { generated++; return nil }}) |
| 234 | if err != nil { |
| 235 | t.Fatal(err) |
| 236 | } |
| 237 | if err := r.MarkView(ctx, "articles" , 100); err != nil { |
| 238 | t.Fatal(err) |
| 239 | } |
| 240 | if err := r.MarkView(ctx, "articles" , 101); err != nil { |
| 241 | t.Fatal(err) |
| 242 | } |
| 243 | if got, err := r.NextDue(ctx, 105); err != nil || got != 110 { |
| 244 | t.Fatalf("next due=%d err=%v" , got, err) |
| 245 | } |
| 246 | if err := r.Tick(ctx, 105); err != nil { |
| 247 | t.Fatal(err) |
| 248 | } |
| 249 | if generated != 0 { |
| 250 | t.Fatal("published before coalescing window" ) |
| 251 | } |
| 252 | if err := r.Tick(ctx, 110); err != nil { |
| 253 | t.Fatal(err) |
| 254 | } |
| 255 | if generated != 1 { |
| 256 | t.Fatalf("generated=%d, want one coalesced article view" , generated) |
| 257 | } |
| 258 | var dirty int64 |
| 259 | if err := s.GetSetting(ctx, "records.view.articles.dirty" , &dirty); err != nil || dirty != 0 { |
| 260 | t.Fatalf("dirty=%d err=%v" , dirty, err) |
| 261 | } |
| 262 | } |
| 263 | |
| 264 | func TestSuccessionWarningAndOwnerActivityAbort(t *testing.T) { |
| 265 | s, ctx := testStore(t) |
| 266 | owner := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" |
| 267 | heir := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" |
| 268 | p := policy.Defaults(owner) |
| 269 | p.Notify.Succession = true |
| 270 | p.Succession = &policy.Succession{Heir: heir, AfterDays: 90} |
| 271 | transfers := 0 |
| 272 | r, err := New(ctx, Config{Community: emptyCommunityReader{}, Store: s, Policy: func() policy.Policy { return p }, SetPolicy: func(next policy.Policy) error { p = next; return nil }, RelayURL: "wss://relay.example" , OnTransfer: func(context.Context, string, string) error { transfers++; return nil }}) |
| 273 | if err != nil { |
| 274 | t.Fatal(err) |
| 275 | } |
| 276 | base := int64(1000) |
| 277 | if err := r.Heartbeat(ctx, owner, base); err != nil { |
| 278 | t.Fatal(err) |
| 279 | } |
| 280 | if err := r.Tick(ctx, base+90*86400+29*86400); err != nil { |
| 281 | t.Fatal(err) |
| 282 | } |
| 283 | if transfers != 0 { |
| 284 | t.Fatal("transferred during warning period" ) |
| 285 | } |
| 286 | if err := r.Heartbeat(ctx, owner, base+90*86400+29*86400+1); err != nil { |
| 287 | t.Fatal(err) |
| 288 | } |
| 289 | if err := r.Tick(ctx, base+90*86400+31*86400); err != nil { |
| 290 | t.Fatal(err) |
| 291 | } |
| 292 | if transfers != 0 { |
| 293 | t.Fatal("owner heartbeat did not abort succession" ) |
| 294 | } |
| 295 | // Let the dead-man clock expire on a second relay instance: ownership must |
| 296 | // be changed through the policy callback, not merely reported as a hook. |
| 297 | p.Succession = &policy.Succession{Heir: heir, AfterDays: 90} |
| 298 | if err := r.Heartbeat(ctx, owner, base); err != nil { |
| 299 | t.Fatal(err) |
| 300 | } |
| 301 | if err := r.Tick(ctx, base+90*86400); err != nil { |
| 302 | t.Fatal(err) |
| 303 | } |
| 304 | if err := r.Tick(ctx, base+90*86400+30*86400+1); err != nil { |
| 305 | t.Fatal(err) |
| 306 | } |
| 307 | if p.Owner != heir || p.Succession != nil { |
| 308 | t.Fatalf("succession did not transfer ownership: %+v" , p) |
| 309 | } |
| 310 | } |