fix(node): add load-or-create identity helper and TTL-aware deduplication
Some checks failed
Security Scan / security (push) Has been cancelled
Test / test (push) Has been cancelled

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-03 05:10:11 +00:00
parent 3eeaf90d38
commit 56bd30d3d2
4 changed files with 108 additions and 4 deletions

View file

@ -109,6 +109,48 @@ func NewNodeManagerWithPaths(keyPath, configPath string) (*NodeManager, error) {
return nm, nil 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. // HasIdentity returns true if a node identity has been initialized.
func (n *NodeManager) HasIdentity() bool { func (n *NodeManager) HasIdentity() bool {
n.mu.RLock() n.mu.RLock()

View file

@ -215,6 +215,47 @@ func TestNodeIdentity(t *testing.T) {
t.Error("should not have identity after delete") 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) { func TestNodeRoles(t *testing.T) {

View file

@ -76,10 +76,20 @@ func NewMessageDeduplicator(ttl time.Duration) *MessageDeduplicator {
// IsDuplicate checks if a message ID has been seen recently // IsDuplicate checks if a message ID has been seen recently
func (d *MessageDeduplicator) IsDuplicate(msgID string) bool { func (d *MessageDeduplicator) IsDuplicate(msgID string) bool {
d.mu.RLock() d.mu.Lock()
_, exists := d.seen[msgID] defer d.mu.Unlock()
d.mu.RUnlock()
return exists 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 // Mark records a message ID as seen

View file

@ -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) { t.Run("ConcurrentAccess", func(t *testing.T) {
d := NewMessageDeduplicator(5 * time.Minute) d := NewMessageDeduplicator(5 * time.Minute)
var wg sync.WaitGroup var wg sync.WaitGroup