fix(node): add load-or-create identity helper and TTL-aware deduplication
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
3eeaf90d38
commit
56bd30d3d2
4 changed files with 108 additions and 4 deletions
|
|
@ -109,6 +109,48 @@ func NewNodeManagerWithPaths(keyPath, configPath string) (*NodeManager, error) {
|
|||
return nm, nil
|
||||
}
|
||||
|
||||
// LoadOrCreateIdentity loads the node identity from the default XDG paths or
|
||||
// generates a new dual-role identity when none exists yet.
|
||||
func LoadOrCreateIdentity() (*NodeManager, error) {
|
||||
keyPath, err := xdg.DataFile("lethean-desktop/node/private.key")
|
||||
if err != nil {
|
||||
return nil, coreerr.E("LoadOrCreateIdentity", "failed to get key path", err)
|
||||
}
|
||||
|
||||
configPath, err := xdg.ConfigFile("lethean-desktop/node.json")
|
||||
if err != nil {
|
||||
return nil, coreerr.E("LoadOrCreateIdentity", "failed to get config path", err)
|
||||
}
|
||||
|
||||
return LoadOrCreateIdentityWithPaths(keyPath, configPath)
|
||||
}
|
||||
|
||||
// LoadOrCreateIdentityWithPaths loads an existing identity from the supplied
|
||||
// paths or creates a new dual-role identity if no persisted identity exists.
|
||||
// The generated identity name falls back to the host name, then a stable
|
||||
// project-specific default if the host name cannot be determined.
|
||||
func LoadOrCreateIdentityWithPaths(keyPath, configPath string) (*NodeManager, error) {
|
||||
nm, err := NewNodeManagerWithPaths(keyPath, configPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if nm.HasIdentity() {
|
||||
return nm, nil
|
||||
}
|
||||
|
||||
name, err := os.Hostname()
|
||||
if err != nil || name == "" {
|
||||
name = "lethean-node"
|
||||
}
|
||||
|
||||
if err := nm.GenerateIdentity(name, RoleDual); err != nil {
|
||||
return nil, coreerr.E("LoadOrCreateIdentityWithPaths", "failed to generate identity", err)
|
||||
}
|
||||
|
||||
return nm, nil
|
||||
}
|
||||
|
||||
// HasIdentity returns true if a node identity has been initialized.
|
||||
func (n *NodeManager) HasIdentity() bool {
|
||||
n.mu.RLock()
|
||||
|
|
|
|||
|
|
@ -215,6 +215,47 @@ func TestNodeIdentity(t *testing.T) {
|
|||
t.Error("should not have identity after delete")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("LoadOrCreateIdentityWithPaths", func(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "node-load-or-create-test")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
keyPath := filepath.Join(tmpDir, "private.key")
|
||||
configPath := filepath.Join(tmpDir, "node.json")
|
||||
|
||||
nm, err := LoadOrCreateIdentityWithPaths(keyPath, configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load or create identity: %v", err)
|
||||
}
|
||||
|
||||
if !nm.HasIdentity() {
|
||||
t.Fatal("expected identity to be initialised")
|
||||
}
|
||||
|
||||
identity := nm.GetIdentity()
|
||||
if identity == nil {
|
||||
t.Fatal("identity should not be nil")
|
||||
}
|
||||
|
||||
if identity.Name == "" {
|
||||
t.Error("identity name should be populated")
|
||||
}
|
||||
|
||||
if identity.Role != RoleDual {
|
||||
t.Errorf("expected default role dual, got %s", identity.Role)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(keyPath); err != nil {
|
||||
t.Fatalf("expected private key to be persisted: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(configPath); err != nil {
|
||||
t.Fatalf("expected identity config to be persisted: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestNodeRoles(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -76,10 +76,20 @@ func NewMessageDeduplicator(ttl time.Duration) *MessageDeduplicator {
|
|||
|
||||
// IsDuplicate checks if a message ID has been seen recently
|
||||
func (d *MessageDeduplicator) IsDuplicate(msgID string) bool {
|
||||
d.mu.RLock()
|
||||
_, exists := d.seen[msgID]
|
||||
d.mu.RUnlock()
|
||||
return exists
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
seenAt, exists := d.seen[msgID]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
if d.ttl > 0 && time.Since(seenAt) > d.ttl {
|
||||
delete(d.seen, msgID)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Mark records a message ID as seen
|
||||
|
|
|
|||
|
|
@ -159,6 +159,17 @@ func TestMessageDeduplicator(t *testing.T) {
|
|||
}
|
||||
})
|
||||
|
||||
t.Run("ExpiredEntriesAreNotDuplicates", func(t *testing.T) {
|
||||
d := NewMessageDeduplicator(25 * time.Millisecond)
|
||||
d.Mark("msg-expired")
|
||||
|
||||
time.Sleep(40 * time.Millisecond)
|
||||
|
||||
if d.IsDuplicate("msg-expired") {
|
||||
t.Error("expired message should not remain a duplicate")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ConcurrentAccess", func(t *testing.T) {
|
||||
d := NewMessageDeduplicator(5 * time.Minute)
|
||||
var wg sync.WaitGroup
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue