package buzz import ( "context" "encoding/json" "os" "path/filepath" "strings" "testing" "time" "github.com/nbd-wtf/go-nostr" "github.com/nbd-wtf/go-nostr/nip19" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" ) func TestNormalizeSecretKeyAcceptsHex(t *testing.T) { sk := nostr.GeneratePrivateKey() got, err := normalizeSecretKey(sk) if err != nil { t.Fatalf("unexpected error: %v", err) } if got != sk { t.Fatalf("expected %s, got %s", sk, got) } } func TestNormalizeSecretKeyAcceptsNsec(t *testing.T) { sk := nostr.GeneratePrivateKey() nsec, err := nip19.EncodePrivateKey(sk) if err != nil { t.Fatalf("failed to encode nsec: %v", err) } got, err := normalizeSecretKey(nsec) if err != nil { t.Fatalf("unexpected error: %v", err) } if got != sk { t.Fatalf("expected nsec to decode to %s, got %s", sk, got) } } func TestNormalizeSecretKeyTrimsWhitespace(t *testing.T) { sk := nostr.GeneratePrivateKey() got, err := normalizeSecretKey(" " + sk + "\n") if err != nil { t.Fatalf("unexpected error: %v", err) } if got != sk { t.Fatalf("expected %s, got %s", sk, got) } } func TestNormalizeSecretKeyRejectsBadInput(t *testing.T) { cases := map[string]string{ "empty": "", "whitespace": " ", "short hex": "abcdef", "bad nsec": "nsec1notarealkey", "npub prefix": "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq", } for name, input := range cases { t.Run(name, func(t *testing.T) { if _, err := normalizeSecretKey(input); err == nil { t.Fatalf("expected an error for %q", input) } }) } } func TestFirstTagValue(t *testing.T) { tags := nostr.Tags{ nostr.Tag{"p", "pubkey-one"}, nostr.Tag{"h", "channel-abc"}, nostr.Tag{"h", "channel-second"}, nostr.Tag{"malformed"}, } if got := firstTagValue(tags, "h"); got != "channel-abc" { t.Fatalf("expected first h tag, got %q", got) } if got := firstTagValue(tags, "e"); got != "" { t.Fatalf("expected empty string for missing tag, got %q", got) } // A tag with no value must not panic or be treated as present. if got := firstTagValue(tags, "malformed"); got != "" { t.Fatalf("expected empty string for valueless tag, got %q", got) } } func TestHasPTag(t *testing.T) { const self = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" tags := nostr.Tags{ nostr.Tag{"h", "channel-abc"}, nostr.Tag{"p", strings.ToUpper(self)}, } if !hasPTag(tags, self) { t.Fatal("expected p tag match to be case-insensitive") } other := nostr.Tags{ nostr.Tag{"h", "channel-abc"}, nostr.Tag{"p", "ffff"}, } if hasPTag(other, self) { t.Fatal("expected no match for a different pubkey") } // An "h" tag carrying our pubkey must not count as a mention. spoof := nostr.Tags{nostr.Tag{"h", self}} if hasPTag(spoof, self) { t.Fatal("expected only p tags to count as mentions") } } func TestShortPubkey(t *testing.T) { const full = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" got := shortPubkey(full) if !strings.HasPrefix(got, "01234567") || !strings.HasSuffix(got, "cdef") { t.Fatalf("unexpected short form: %s", got) } // Short inputs are returned unchanged rather than sliced out of range. if got := shortPubkey("abc"); got != "abc" { t.Fatalf("expected short input unchanged, got %s", got) } } // newTestBuzzChannel builds a real BuzzChannel wired to a temp-disk seen store // and a live message bus, without touching the network. The caller's pubkey // (c.publicKey) is the bot identity; events are signed with a different key so // they are not treated as self-messages. func newTestBuzzChannel(t *testing.T, seenPath string) (*BuzzChannel, *bus.MessageBus, context.Context) { t.Helper() botSK := nostr.GeneratePrivateKey() if _, err := nostr.GetPublicKey(botSK); err != nil { t.Fatalf("bot pubkey: %v", err) } bc := &config.Channel{} bc.SetName("buzz-test") cfg := &config.BuzzSettings{ RelayURL: "wss://relay.invalid", PrivateKey: *config.NewSecureString(botSK), Channels: config.FlexibleStringSlice{"test-channel"}, SeenStatePath: seenPath, } msgBus := bus.NewMessageBus() t.Cleanup(msgBus.Close) ch, err := NewBuzzChannel(bc, cfg, msgBus) if err != nil { t.Fatalf("NewBuzzChannel: %v", err) } // onEvent reads c.ctx; Start would set it but we don't want a relay. ch.ctx, ch.cancel = context.WithCancel(context.Background()) t.Cleanup(func() { if ch.cancel != nil { ch.cancel() } }) return ch, msgBus, ch.ctx } // makeEvent builds and signs a kind:9 event from senderSK scoped to chatID, // returning a real nostr.Event with a valid ID. func makeEvent(t *testing.T, senderSK, chatID, content string, createdAt nostr.Timestamp) *nostr.Event { t.Helper() senderPK, err := nostr.GetPublicKey(senderSK) if err != nil { t.Fatalf("sender pubkey: %v", err) } evt := &nostr.Event{ PubKey: senderPK, CreatedAt: createdAt, Kind: kindStreamMessage, Tags: nostr.Tags{nostr.Tag{"h", chatID}}, Content: content, } if err := evt.Sign(senderSK); err != nil { t.Fatalf("sign: %v", err) } return evt } // expectInbound drains one inbound message from the bus, failing if none // arrives within the timeout or if the bus is closed empty. func expectInbound(t *testing.T, msgBus *bus.MessageBus, wantContent string) { t.Helper() select { case msg, ok := <-msgBus.InboundChan(): if !ok { t.Fatal("expected an inbound message but bus channel was closed") } if msg.Content != wantContent { t.Fatalf("inbound content = %q, want %q", msg.Content, wantContent) } case <-time.After(time.Second): t.Fatal("expected an inbound message but none arrived within 1s") } } // expectNoInbound asserts that no inbound message arrives within the timeout, // i.e. the event was suppressed by the seen store. func expectNoInbound(t *testing.T, msgBus *bus.MessageBus) { t.Helper() select { case msg, ok := <-msgBus.InboundChan(): if ok { t.Fatalf("expected no inbound message, but got one: %q (id=%q)", msg.Content, msg.Context.MessageID) } case <-time.After(200 * time.Millisecond): // No message within the window — replay suppression held. This is the // success path. } } // TestReplaySuppression_PersistedEventNotRedelivered covers the core restart // scenario: an event handled in run 1 is recorded to the persistent store; a // fresh channel loaded from the same store path must not redeliver it. func TestReplaySuppression_PersistedEventNotRedelivered(t *testing.T) { seenPath := filepath.Join(t.TempDir(), "seen.json") // Run 1: a fresh channel handles one event. ch1, msgBus1, _ := newTestBuzzChannel(t, seenPath) senderSK := nostr.GeneratePrivateKey() evt := makeEvent(t, senderSK, "test-channel", "hello from the past", nostr.Now()) ch1.onEvent(evt) expectInbound(t, msgBus1, "hello from the past") // The store file must exist on disk after handling. if _, err := os.Stat(seenPath); err != nil { t.Fatalf("stat seen file: %v", err) } // The persisted JSON must concretely record the handled event's ID and // high-water timestamp, so a restart can dedup it without re-running the // handler. raw, err := os.ReadFile(seenPath) if err != nil { t.Fatalf("read seen file: %v", err) } var persisted map[string]*channelHighWater if err := json.Unmarshal(raw, &persisted); err != nil { t.Fatalf("unmarshal seen file: %v", err) } entry, ok := persisted["test-channel"] if !ok { t.Fatalf("persisted state missing test-channel entry; got %v", persisted) } if entry.MaxCreatedAt != int64(evt.CreatedAt) { t.Fatalf("persisted max_created_at = %d, want %d", entry.MaxCreatedAt, int64(evt.CreatedAt)) } foundID := false for _, id := range entry.RecentIDs { if id == evt.ID { foundID = true break } } if !foundID { t.Fatalf("persisted recent_ids %v do not contain event ID %q", entry.RecentIDs, evt.ID) } // Run 2: a brand-new channel instance reading the same store must not // redeliver the same event ID, simulating a service restart. ch2, msgBus2, _ := newTestBuzzChannel(t, seenPath) ch2.onEvent(evt) expectNoInbound(t, msgBus2) // High-water mark must reflect the handled event's CreatedAt so the // subscription Since filter would skip older history on restart. if hw := ch2.seen.highWaterMark(); hw != evt.CreatedAt { t.Fatalf("high-water mark = %d, want %d", hw, evt.CreatedAt) } } // TestReplaySuppression_FreshEventStillHandled ensures the seen store does not // suppress events that were never handled before — the live delivery path is // preserved. func TestReplaySuppression_FreshEventStillHandled(t *testing.T) { seenPath := filepath.Join(t.TempDir(), "seen.json") ch, msgBus, _ := newTestBuzzChannel(t, seenPath) senderSK := nostr.GeneratePrivateKey() first := makeEvent(t, senderSK, "test-channel", "first message", nostr.Now()) ch.onEvent(first) expectInbound(t, msgBus, "first message") // A different event ID must still be delivered. second := makeEvent(t, senderSK, "test-channel", "second message", nostr.Now()+1) ch.onEvent(second) expectInbound(t, msgBus, "second message") // Replaying the first event again is still suppressed within the same run // (idempotency, not just restart protection). ch.onEvent(first) expectNoInbound(t, msgBus) } // TestReplaySuppression_SelfMessageNotRecorded confirms self-message filtering // still works and that such messages do not pollute the seen store (so a future // legitimate event with the same hypothetical ID — impossible in practice but // the invariant matters — would not be wrongly suppressed). func TestReplaySuppression_SelfMessageNotRecorded(t *testing.T) { seenPath := filepath.Join(t.TempDir(), "seen.json") ch, msgBus, _ := newTestBuzzChannel(t, seenPath) // Build an event signed with the bot's own key so PubKey == c.publicKey. selfEvt := makeEvent(t, ch.secretKey, "test-channel", "self echo", nostr.Now()) ch.onEvent(selfEvt) expectNoInbound(t, msgBus) // The store should have no entry for this channel because self-messages // return before markHandled. if ch.seen.seen("test-channel", selfEvt.ID, selfEvt.CreatedAt) { t.Fatal("self-message was recorded in the seen store; it should be skipped before recording") } } // TestSeenStoreHighWaterMarkAcrossChannels verifies the high-water mark is the // max across all channels, so the subscription Since filter uses a single // global cutoff that skips history for every channel. func TestSeenStoreHighWaterMarkAcrossChannels(t *testing.T) { s, err := newSeenStore(filepath.Join(t.TempDir(), "seen.json"), "test") if err != nil { t.Fatalf("newSeenStore: %v", err) } if hw := s.highWaterMark(); hw != 0 { t.Fatalf("empty store high-water = %d, want 0", hw) } if err := s.markHandled("chan-a", "id-a1", 100); err != nil { t.Fatalf("markHandled a: %v", err) } if err := s.markHandled("chan-b", "id-b1", 150); err != nil { t.Fatalf("markHandled b: %v", err) } if err := s.markHandled("chan-a", "id-a2", 120); err != nil { t.Fatalf("markHandled a2: %v", err) } if hw := s.highWaterMark(); hw != 150 { t.Fatalf("high-water = %d, want 150", hw) } // Reload from disk to confirm persistence. s2, err := newSeenStore(s.path, "test") if err != nil { t.Fatalf("newSeenStore reload: %v", err) } if hw := s2.highWaterMark(); hw != 150 { t.Fatalf("after reload, high-water = %d, want 150", hw) } if !s2.seen("chan-a", "id-a1", 100) || !s2.seen("chan-b", "id-b1", 150) { t.Fatal("reloaded store lost a recorded event ID") } } func TestReplaySuppression_EventsOlderThanSeededHighWaterAreDropped(t *testing.T) { seenPath := filepath.Join(t.TempDir(), "seen.json") ch, msgBus, _ := newTestBuzzChannel(t, seenPath) if err := ch.seen.markHandled("test-channel", "seed", nostr.Now()); err != nil { t.Fatalf("seed high-water: %v", err) } oldEvent := makeEvent( t, nostr.GeneratePrivateKey(), "test-channel", "replayed history", nostr.Now()-60, ) ch.onEvent(oldEvent) expectNoInbound(t, msgBus) } // TestSeenStoreRingTrims verifies the recent-ID ring does not grow unbounded, // keeping only the most recent recentIDWindow entries per channel. func TestSeenStoreRingTrims(t *testing.T) { s, err := newSeenStore(filepath.Join(t.TempDir(), "seen.json"), "test") if err != nil { t.Fatalf("newSeenStore: %v", err) } // Insert more IDs than the window; old IDs should be evicted. for i := 0; i < recentIDWindow+50; i++ { id := "id-" + strings.Repeat("x", i%4) // Use unique IDs so they are not deduped by the ring's duplicate check. id = "id-" + nip19Letter(i) if err := s.markHandled("chan", id, nostr.Timestamp(i)); err != nil { t.Fatalf("markHandled %d: %v", i, err) } } hw, ok := s.highWater["chan"] if !ok { t.Fatal("channel entry missing") } if len(hw.RecentIDs) != recentIDWindow { t.Fatalf("ring length = %d, want %d", len(hw.RecentIDs), recentIDWindow) } // The first 50 IDs must have been evicted; the oldest remaining must be id // number 50 (0-indexed). if hw.RecentIDs[0] != "id-"+nip19Letter(50) { t.Fatalf("oldest remaining ID = %q, want id for index 50", hw.RecentIDs[0]) } } // nip19Letter converts n to a short distinct string for unique IDs in the ring // test. Using a deterministic suffix keeps IDs unique and short. func nip19Letter(n int) string { const digits = "0123456789abcdefghijklmnopqrstuvwxyz" var out []byte if n == 0 { return "0" } for n > 0 { out = append([]byte{digits[n%36]}, out...) n /= 36 } return string(out) } // TestNewBuzzChannel_RejectsCorruptSeenState asserts that NewBuzzChannel fails // when the persisted seen-state file is unparseable. Starting a channel whose // replay state cannot be loaded would silently lose dedup memory and risk // redelivering already-handled events, so the constructor must refuse. func TestNewBuzzChannel_RejectsCorruptSeenState(t *testing.T) { seenPath := filepath.Join(t.TempDir(), "seen.json") if err := os.WriteFile(seenPath, []byte("{not valid json"), 0o600); err != nil { t.Fatalf("write corrupt seen file: %v", err) } botSK := nostr.GeneratePrivateKey() bc := &config.Channel{} bc.SetName("buzz-corrupt") cfg := &config.BuzzSettings{ RelayURL: "wss://relay.invalid", PrivateKey: *config.NewSecureString(botSK), Channels: config.FlexibleStringSlice{"test-channel"}, SeenStatePath: seenPath, } msgBus := bus.NewMessageBus() t.Cleanup(msgBus.Close) if _, err := NewBuzzChannel(bc, cfg, msgBus); err == nil { t.Fatal("NewBuzzChannel unexpectedly succeeded with a corrupt seen-state file") } } // TestNewSeenStore_RejectsCorruptState exercises the store constructor // directly: a corrupt file must surface a load error, while a missing file // must not. func TestNewSeenStore_RejectsCorruptState(t *testing.T) { seenPath := filepath.Join(t.TempDir(), "seen.json") // Missing file is fine — fresh store starts empty. s, err := newSeenStore(seenPath, "test") if err != nil { t.Fatalf("missing file should not error, got: %v", err) } if hw := s.highWaterMark(); hw != 0 { t.Fatalf("fresh store high-water = %d, want 0", hw) } // Corrupt file must error. if err := os.WriteFile(seenPath, []byte("::{unjson"), 0o600); err != nil { t.Fatalf("write corrupt file: %v", err) } if _, err := newSeenStore(seenPath, "test"); err == nil { t.Fatal("newSeenStore unexpectedly succeeded with a corrupt file") } } // TestOnEvent_PersistenceFailurePreventsDispatch verifies that if marking an // event as handled fails to persist, onEvent does NOT dispatch the event to // the inbound bus. This preserves at-most-once delivery: a successful but // un-persisted mark would let the same event be redelivered and re-handled on // the next restart. // // The persistence failure is induced by replacing the store's parent directory // with a regular file after load, so saveLocked's MkdirAll fails. This is // deterministic even when tests run as root and exercises real I/O. func TestOnEvent_PersistenceFailurePreventsDispatch(t *testing.T) { parent := filepath.Join(t.TempDir(), "store-parent") storeDir := filepath.Join(parent, "store") if err := os.Mkdir(parent, 0o700); err != nil { t.Fatalf("mkdir store parent: %v", err) } if err := os.Mkdir(storeDir, 0o700); err != nil { t.Fatalf("mkdir store: %v", err) } seenPath := filepath.Join(storeDir, "seen.json") // Valid empty state so load() succeeds; save() will fail once the dir // is made read-only. if err := os.WriteFile(seenPath, []byte("{}"), 0o600); err != nil { t.Fatalf("seed seen file: %v", err) } ch, msgBus, _ := newTestBuzzChannel(t, seenPath) if err := os.RemoveAll(storeDir); err != nil { t.Fatalf("remove store directory: %v", err) } if err := os.WriteFile(storeDir, []byte("not a directory"), 0o600); err != nil { t.Fatalf("replace store directory with file: %v", err) } senderSK := nostr.GeneratePrivateKey() evt := makeEvent(t, senderSK, "test-channel", "should not be dispatched", nostr.Now()) ch.onEvent(evt) expectNoInbound(t, msgBus) // A second, fresh event must also be suppressed while persistence is // broken — the channel must not "give up" suppressing after one failure. second := makeEvent(t, senderSK, "test-channel", "also not dispatched", nostr.Now()+1) ch.onEvent(second) expectNoInbound(t, msgBus) } // TestOnEvent_PersistenceFailureThenRecoveryDispatches confirms that once // persistence is restored, a fresh event is dispatched normally — the // persistence-failure path does not wedge the channel permanently. func TestOnEvent_PersistenceFailureThenRecoveryDispatches(t *testing.T) { // Start with a working seen store on a fresh channel. seenPath := filepath.Join(t.TempDir(), "seen.json") ch, msgBus, _ := newTestBuzzChannel(t, seenPath) senderSK := nostr.GeneratePrivateKey() evt := makeEvent(t, senderSK, "test-channel", "recovered path delivers", nostr.Now()) ch.onEvent(evt) expectInbound(t, msgBus, "recovered path delivers") }