Pulse/internal/servicediscovery/store_test.go
2026-03-29 13:53:46 +01:00

1213 lines
34 KiB
Go

package servicediscovery
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/crypto"
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
)
type fakeCrypto struct{}
func (fakeCrypto) Encrypt(plaintext []byte) ([]byte, error) {
out := make([]byte, len(plaintext))
for i := range plaintext {
out[i] = plaintext[len(plaintext)-1-i]
}
return out, nil
}
func (fakeCrypto) Decrypt(ciphertext []byte) ([]byte, error) {
return fakeCrypto{}.Encrypt(ciphertext)
}
type taggedCrypto struct{}
func (taggedCrypto) Encrypt(plaintext []byte) ([]byte, error) {
out := make([]byte, 4+len(plaintext))
copy(out, []byte("enc:"))
for i := range plaintext {
out[4+i] = plaintext[len(plaintext)-1-i]
}
return out, nil
}
func (taggedCrypto) Decrypt(ciphertext []byte) ([]byte, error) {
if len(ciphertext) < 4 || string(ciphertext[:4]) != "enc:" {
return nil, os.ErrInvalid
}
payload := ciphertext[4:]
out := make([]byte, len(payload))
for i := range payload {
out[i] = payload[len(payload)-1-i]
}
return out, nil
}
type errorCrypto struct{}
func (errorCrypto) Encrypt(plaintext []byte) ([]byte, error) {
return nil, os.ErrInvalid
}
func (errorCrypto) Decrypt(ciphertext []byte) ([]byte, error) {
return nil, os.ErrInvalid
}
func TestNormalizeResourceType_DoesNotAliasLegacyTypes(t *testing.T) {
cases := []ResourceType{"host", "lxc", "docker_lxc"}
for _, tc := range cases {
if got := NormalizeResourceType(tc); got != tc {
t.Fatalf("NormalizeResourceType(%q) = %q, want %q", tc, got, tc)
}
}
}
func TestCanonicalStoredResourceType_DoesNotAliasLegacyTypes(t *testing.T) {
cases := []ResourceType{"host", "lxc", "docker_lxc"}
for _, tc := range cases {
if got := canonicalStoredResourceType(tc); got != tc {
t.Fatalf("canonicalStoredResourceType(%q) = %q, want %q", tc, got, tc)
}
}
}
func TestNormalizeResourceID_DoesNotAliasHostPrefix(t *testing.T) {
const legacyID = "host:host1:host1"
if got := normalizeResourceID(legacyID); got != legacyID {
t.Fatalf("normalizeResourceID(%q) = %q, want %q", legacyID, got, legacyID)
}
}
func TestCanonicalStoredResourceID_DoesNotAliasHostPrefix(t *testing.T) {
const legacyID = "host:host1:host1"
if got := canonicalStoredResourceID(legacyID); got != legacyID {
t.Fatalf("canonicalStoredResourceID(%q) = %q, want %q", legacyID, got, legacyID)
}
}
func TestStore_SaveGetListAndNotes(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
store.crypto = nil
d1 := &ResourceDiscovery{
ID: MakeResourceID(ResourceTypeDocker, "host1", "nginx"),
ResourceType: ResourceTypeDocker,
ResourceID: "nginx",
TargetID: "host1",
ServiceName: "Nginx",
}
if err := store.Save(d1); err != nil {
t.Fatalf("Save error: %v", err)
}
got, err := store.Get(d1.ID)
if err != nil {
t.Fatalf("Get error: %v", err)
}
if got == nil || got.ServiceName != "Nginx" {
t.Fatalf("unexpected discovery: %#v", got)
}
if got.TargetID != "host1" {
t.Fatalf("expected TargetID host1, got %q", got.TargetID)
}
if !store.Exists(d1.ID) {
t.Fatalf("expected discovery to exist")
}
if err := store.UpdateNotes(d1.ID, "notes", map[string]string{"token": "abc"}); err != nil {
t.Fatalf("UpdateNotes error: %v", err)
}
updated, err := store.Get(d1.ID)
if err != nil {
t.Fatalf("Get updated error: %v", err)
}
if updated.UserNotes != "notes" || updated.UserSecrets["token"] != "abc" {
t.Fatalf("notes not updated: %#v", updated)
}
d2 := &ResourceDiscovery{
ID: MakeResourceID(ResourceTypeVM, "node1", "101"),
ResourceType: ResourceTypeVM,
ResourceID: "101",
TargetID: "node1",
ServiceName: "VM",
}
if err := store.Save(d2); err != nil {
t.Fatalf("Save d2 error: %v", err)
}
list, err := store.List()
if err != nil {
t.Fatalf("List error: %v", err)
}
if len(list) != 2 {
t.Fatalf("expected 2 discoveries, got %d", len(list))
}
byType, err := store.ListByType(ResourceTypeVM)
if err != nil {
t.Fatalf("ListByType error: %v", err)
}
if len(byType) != 1 || byType[0].ID != d2.ID {
t.Fatalf("unexpected ListByType: %#v", byType)
}
byTarget, err := store.ListByTarget("host1")
if err != nil {
t.Fatalf("ListByTarget error: %v", err)
}
if len(byTarget) != 1 || byTarget[0].ID != d1.ID {
t.Fatalf("unexpected ListByTarget: %#v", byTarget)
}
summary := updated.ToSummary()
if summary.ID != d1.ID || !summary.HasUserNotes {
t.Fatalf("unexpected summary: %#v", summary)
}
if err := store.Delete(d1.ID); err != nil {
t.Fatalf("Delete error: %v", err)
}
if store.Exists(d1.ID) {
t.Fatalf("expected discovery to be deleted")
}
}
func TestStore_SaveCanonicalizesAgentAndTargetIDs(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
store.crypto = nil
d := &ResourceDiscovery{
ID: MakeResourceID(ResourceTypeAgent, "agent-1", "agent-1"),
ResourceType: ResourceTypeAgent,
ResourceID: "agent-1",
TargetID: "agent-1",
ServiceName: "Agent",
}
if err := store.Save(d); err != nil {
t.Fatalf("Save error: %v", err)
}
got, err := store.Get(d.ID)
if err != nil {
t.Fatalf("Get error: %v", err)
}
if got == nil {
t.Fatal("expected discovery, got nil")
}
if got.TargetID != "agent-1" {
t.Fatalf("expected TargetID agent-1, got %q", got.TargetID)
}
if got.AgentID != "agent-1" {
t.Fatalf("expected AgentID agent-1, got %q", got.AgentID)
}
}
func TestStore_Get_DerivesTargetIDFromResourceIDWhenMissing(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
store.crypto = nil
id := MakeResourceID(ResourceTypeDocker, "legacy-host", "web")
payloadWithoutTarget := map[string]any{
"id": id,
"resource_type": ResourceTypeDocker,
"resource_id": "web",
"service_name": "Web",
}
data, err := json.Marshal(payloadWithoutTarget)
if err != nil {
t.Fatalf("Marshal error: %v", err)
}
if err := os.WriteFile(store.getFilePath(id), data, 0600); err != nil {
t.Fatalf("WriteFile error: %v", err)
}
got, err := store.Get(id)
if err != nil {
t.Fatalf("Get error: %v", err)
}
if got == nil {
t.Fatal("expected discovery, got nil")
}
if got.TargetID != "legacy-host" {
t.Fatalf("expected TargetID legacy-host, got %q", got.TargetID)
}
}
func TestStore_CryptoRoundTripAndPaths(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
store.crypto = taggedCrypto{}
id := "docker:host1:app/name"
d := &ResourceDiscovery{
ID: id,
ResourceType: ResourceTypeDocker,
ResourceID: "app/name",
TargetID: "host1",
ServiceName: "App",
}
if err := store.Save(d); err != nil {
t.Fatalf("Save error: %v", err)
}
path := store.getFilePath(id)
base := filepath.Base(path)
if strings.Contains(base, ":") || strings.Contains(base, "/") {
t.Fatalf("expected sanitized base filename, got %s", base)
}
loaded, err := store.Get(id)
if err != nil {
t.Fatalf("Get error: %v", err)
}
if loaded == nil || loaded.ServiceName != "App" {
t.Fatalf("unexpected discovery: %#v", loaded)
}
store.ClearCache()
if _, err := store.Get(id); err != nil {
t.Fatalf("Get with decrypt error: %v", err)
}
list, err := store.List()
if err != nil || len(list) != 1 {
t.Fatalf("List with decrypt error: %v len=%d", err, len(list))
}
}
func TestStoreDiscoveryPathForLeafRejectsTraversal(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
if _, err := store.discoveryPathForLeaf("../evil.enc"); err == nil {
t.Fatal("expected discoveryPathForLeaf to reject traversal leaf")
}
if _, err := store.readDiscoveryIDFromFile("../evil.enc"); err == nil {
t.Fatal("expected readDiscoveryIDFromFile to reject traversal filename")
}
}
func TestStoreFingerprintPathRejectsTraversal(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
if _, err := securityutil.JoinStorageLeaf(store.fingerprintDir, "../evil.json"); err == nil {
t.Fatal("expected fingerprint storage leaf join to reject traversal leaf")
}
}
func TestStore_PlaintextDiscoveryRewritesEncryptedStorageOnRead(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
store.crypto = taggedCrypto{}
discovery := &ResourceDiscovery{
ID: MakeResourceID(ResourceTypeDocker, "host1", "app"),
ResourceType: ResourceTypeDocker,
ResourceID: "app",
TargetID: "host1",
ServiceName: "Plaintext App",
UserSecrets: map[string]string{"token": "plain-secret"},
}
raw, err := json.Marshal(discovery)
if err != nil {
t.Fatalf("Marshal error: %v", err)
}
filePath := store.getFilePath(discovery.ID)
if err := os.WriteFile(filePath, raw, 0600); err != nil {
t.Fatalf("WriteFile error: %v", err)
}
got, err := store.Get(discovery.ID)
if err != nil {
t.Fatalf("Get error: %v", err)
}
if got == nil || got.ServiceName != discovery.ServiceName {
t.Fatalf("unexpected discovery after migration: %#v", got)
}
rewritten, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("ReadFile rewritten discovery error: %v", err)
}
if bytes.Equal(rewritten, raw) {
t.Fatal("expected plaintext discovery file to be rewritten encrypted")
}
if bytes.Contains(rewritten, []byte("plain-secret")) {
t.Fatal("plaintext discovery secret remained on disk after migration rewrite")
}
store.ClearCache()
list, err := store.List()
if err != nil {
t.Fatalf("List error: %v", err)
}
if len(list) != 1 || list[0].ID != discovery.ID {
t.Fatalf("unexpected discovery list after migration: %#v", list)
}
ids := store.ListDiscoveryIDs()
if len(ids) != 1 || ids[0] != discovery.ID {
t.Fatalf("unexpected discovery IDs after migration: %v", ids)
}
}
func TestStore_NeedsRefreshAndGetMultiple(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
store.crypto = nil
if !store.NeedsRefresh("missing", time.Minute) {
t.Fatalf("expected missing discovery to need refresh")
}
d := &ResourceDiscovery{
ID: MakeResourceID(ResourceTypeAgent, "host1", "host1"),
ResourceType: ResourceTypeAgent,
ResourceID: "host1",
TargetID: "host1",
ServiceName: "Host",
}
if err := store.Save(d); err != nil {
t.Fatalf("Save error: %v", err)
}
path := store.getFilePath(d.ID)
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile error: %v", err)
}
var saved ResourceDiscovery
if err := json.Unmarshal(data, &saved); err != nil {
t.Fatalf("Unmarshal error: %v", err)
}
saved.UpdatedAt = time.Now().Add(-2 * time.Hour)
data, err = json.Marshal(&saved)
if err != nil {
t.Fatalf("Marshal error: %v", err)
}
if err := os.WriteFile(path, data, 0600); err != nil {
t.Fatalf("WriteFile error: %v", err)
}
store.ClearCache()
if !store.NeedsRefresh(d.ID, time.Minute) {
t.Fatalf("expected old discovery to need refresh")
}
ids := []string{d.ID, "missing"}
multi, err := store.GetMultiple(ids)
if err != nil {
t.Fatalf("GetMultiple error: %v", err)
}
if len(multi) != 1 || multi[0].ID != d.ID {
t.Fatalf("unexpected GetMultiple: %#v", multi)
}
}
func TestStore_ErrorsAndListSkips(t *testing.T) {
dir := t.TempDir()
store, err := NewStore(dir)
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
store.crypto = nil
if err := store.Save(&ResourceDiscovery{}); err == nil {
t.Fatalf("expected error for empty ID")
}
store.crypto = errorCrypto{}
if err := store.Save(&ResourceDiscovery{ID: "bad"}); err == nil {
t.Fatalf("expected encrypt error")
}
store.crypto = nil
if _, err := store.Get("missing"); err != nil {
t.Fatalf("unexpected missing error: %v", err)
}
d := &ResourceDiscovery{
ID: MakeResourceID(ResourceTypeDocker, "host1", "web"),
ResourceType: ResourceTypeDocker,
ResourceID: "web",
TargetID: "host1",
ServiceName: "Web",
UserSecrets: map[string]string{"token": "abc"},
}
if err := store.Save(d); err != nil {
t.Fatalf("Save error: %v", err)
}
// Corrupt file to force unmarshal error during List.
badPath := filepath.Join(store.dataDir, "bad.enc")
if err := os.WriteFile(badPath, []byte("{bad"), 0600); err != nil {
t.Fatalf("WriteFile error: %v", err)
}
if err := os.WriteFile(filepath.Join(store.dataDir, "note.txt"), []byte("skip"), 0600); err != nil {
t.Fatalf("WriteFile error: %v", err)
}
if err := os.WriteFile(filepath.Join(store.dataDir, "skip.enc.tmp"), []byte("skip"), 0600); err != nil {
t.Fatalf("WriteFile error: %v", err)
}
if err := os.MkdirAll(filepath.Join(store.dataDir, "dir"), 0700); err != nil {
t.Fatalf("MkdirAll error: %v", err)
}
unreadable := filepath.Join(store.dataDir, "unreadable.enc")
if err := os.WriteFile(unreadable, []byte("nope"), 0000); err != nil {
t.Fatalf("WriteFile error: %v", err)
}
list, err := store.List()
if err != nil {
t.Fatalf("List error: %v", err)
}
if len(list) != 1 {
t.Fatalf("expected 1 discovery, got %d", len(list))
}
store.crypto = errorCrypto{}
list, err = store.List()
if err != nil {
t.Fatalf("List with crypto error: %v", err)
}
if len(list) != 0 {
t.Fatalf("expected crypto errors to skip entries")
}
store.crypto = errorCrypto{}
store.ClearCache()
if _, err := store.Get(d.ID); err == nil {
t.Fatalf("expected decrypt error")
}
store.crypto = nil
if got, err := store.GetByResource(ResourceTypeDocker, "host1", "web"); err != nil || got == nil {
t.Fatalf("GetByResource error: %v", err)
}
if err := store.UpdateNotes(d.ID, "notes-only", nil); err != nil {
t.Fatalf("UpdateNotes error: %v", err)
}
updated, err := store.Get(d.ID)
if err != nil || updated.UserSecrets == nil {
t.Fatalf("expected secrets to be preserved: %#v err=%v", updated, err)
}
store.crypto = errorCrypto{}
store.ClearCache()
if err := store.UpdateNotes(d.ID, "notes", nil); err == nil {
t.Fatalf("expected update notes error with crypto failure")
}
if got, err := store.GetMultiple([]string{d.ID}); err != nil || len(got) != 0 {
t.Fatalf("expected GetMultiple to skip errors")
}
if err := store.UpdateNotes("missing", "notes", nil); err == nil {
t.Fatalf("expected error for missing discovery")
}
if err := store.Delete("missing"); err != nil {
t.Fatalf("unexpected delete error: %v", err)
}
}
func TestStore_NewStoreError(t *testing.T) {
dir := t.TempDir()
file := filepath.Join(dir, "file")
if err := os.WriteFile(file, []byte("x"), 0600); err != nil {
t.Fatalf("WriteFile error: %v", err)
}
if _, err := NewStore(file); err == nil {
t.Fatalf("expected error for file data dir")
}
}
func TestStore_NewStoreRejectsBlankDataDir(t *testing.T) {
if _, err := NewStore(" \t\n "); err == nil {
t.Fatalf("expected error for blank data dir")
}
}
func TestStore_NewStoreTrimsDataDir(t *testing.T) {
dir := t.TempDir()
store, err := NewStore(" " + dir + " ")
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
want := filepath.Join(dir, "discovery")
if store.dataDir != want {
t.Fatalf("store.dataDir = %q, want %q", store.dataDir, want)
}
}
func TestStore_NewStoreCryptoFailure(t *testing.T) {
orig := newCryptoManagerAt
newCryptoManagerAt = func(dataDir string) (*crypto.CryptoManager, error) {
manager, err := crypto.NewCryptoManagerAt(dataDir)
if err != nil {
return nil, err
}
return manager, os.ErrInvalid
}
t.Cleanup(func() {
newCryptoManagerAt = orig
})
store, err := NewStore(t.TempDir())
if err == nil {
t.Fatal("expected crypto init failure")
}
if store != nil {
t.Fatal("expected nil store on crypto init failure")
}
}
func TestStore_SaveMarshalError(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
store.crypto = nil
orig := marshalDiscovery
marshalDiscovery = func(any) ([]byte, error) {
return nil, os.ErrInvalid
}
t.Cleanup(func() {
marshalDiscovery = orig
})
if err := store.Save(&ResourceDiscovery{ID: "marshal"}); err == nil {
t.Fatalf("expected marshal error")
}
}
func TestStore_SaveAndGetErrors(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
store.crypto = nil
id := MakeResourceID(ResourceTypeDocker, "host1", "web")
filePath := store.getFilePath(id)
if err := os.MkdirAll(filePath, 0700); err != nil {
t.Fatalf("MkdirAll error: %v", err)
}
if err := store.Save(&ResourceDiscovery{ID: id}); err == nil {
t.Fatalf("expected rename error")
}
tmpFile := filepath.Join(t.TempDir(), "file")
if err := os.WriteFile(tmpFile, []byte("x"), 0600); err != nil {
t.Fatalf("WriteFile error: %v", err)
}
store.dataDir = tmpFile
if err := store.Save(&ResourceDiscovery{ID: "bad"}); err == nil {
t.Fatalf("expected write error")
}
store.dataDir = t.TempDir()
store.crypto = nil
badPath := store.getFilePath("bad")
if err := os.WriteFile(badPath, []byte("{bad"), 0600); err != nil {
t.Fatalf("WriteFile error: %v", err)
}
if _, err := store.Get("bad"); err == nil {
t.Fatalf("expected unmarshal error")
}
}
func TestStore_SaveAndGet_ReturnsDefensiveCopies(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
store.crypto = nil
discovery := &ResourceDiscovery{
ID: MakeResourceID(ResourceTypeDocker, "host1", "web"),
ResourceType: ResourceTypeDocker,
ResourceID: "web",
TargetID: "host1",
ServiceName: "Web",
Facts: []DiscoveryFact{
{Key: "service", Value: "nginx"},
},
ConfigPaths: []string{"/etc/nginx"},
UserSecrets: map[string]string{"token": "abc"},
RawCommandOutput: map[string]string{
"ps": "nginx",
},
}
if err := store.Save(discovery); err != nil {
t.Fatalf("Save error: %v", err)
}
// Mutate caller-owned value after Save; cache/state must remain unchanged.
discovery.ServiceName = "Mutated"
discovery.Facts[0].Value = "changed"
discovery.ConfigPaths[0] = "/tmp"
discovery.UserSecrets["token"] = "mutated"
discovery.RawCommandOutput["ps"] = "changed"
got1, err := store.Get(discovery.ID)
if err != nil {
t.Fatalf("Get error: %v", err)
}
if got1 == nil {
t.Fatalf("expected discovery, got nil")
}
if got1.ServiceName != "Web" {
t.Fatalf("expected cached service name to remain Web, got %q", got1.ServiceName)
}
if got1.Facts[0].Value != "nginx" {
t.Fatalf("expected cached fact value nginx, got %q", got1.Facts[0].Value)
}
if got1.ConfigPaths[0] != "/etc/nginx" {
t.Fatalf("expected cached config path /etc/nginx, got %q", got1.ConfigPaths[0])
}
if got1.UserSecrets["token"] != "abc" {
t.Fatalf("expected cached secret token abc, got %q", got1.UserSecrets["token"])
}
if got1.RawCommandOutput["ps"] != "nginx" {
t.Fatalf("expected cached raw output nginx, got %q", got1.RawCommandOutput["ps"])
}
// Mutate returned value from Get; internal cache must remain unchanged.
got1.ServiceName = "ChangedByCaller"
got1.Facts[0].Value = "bad"
got1.ConfigPaths[0] = "/bad"
got1.UserSecrets["token"] = "bad"
got1.RawCommandOutput["ps"] = "bad"
got2, err := store.Get(discovery.ID)
if err != nil {
t.Fatalf("second Get error: %v", err)
}
if got2.ServiceName != "Web" || got2.Facts[0].Value != "nginx" || got2.ConfigPaths[0] != "/etc/nginx" || got2.UserSecrets["token"] != "abc" || got2.RawCommandOutput["ps"] != "nginx" {
t.Fatalf("expected second Get to be isolated from caller mutations, got %#v", got2)
}
}
func TestStore_Fingerprints_ReturnDefensiveCopies(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
store.crypto = nil
fp := &ContainerFingerprint{
ResourceID: "docker:host1:web",
TargetID: "host1",
Hash: "abc123",
Ports: []string{"80/tcp"},
MountPaths: []string{"/config"},
EnvKeys: []string{"FOO"},
}
if err := store.SaveFingerprint(fp); err != nil {
t.Fatalf("SaveFingerprint error: %v", err)
}
// Mutate caller-owned fingerprint after SaveFingerprint.
fp.Hash = "mutated"
fp.Ports[0] = "443/tcp"
fp.MountPaths[0] = "/tmp"
fp.EnvKeys[0] = "BAR"
got1, err := store.GetFingerprint("docker:host1:web")
if err != nil {
t.Fatalf("GetFingerprint error: %v", err)
}
if got1 == nil {
t.Fatalf("expected fingerprint, got nil")
}
if got1.Hash != "abc123" || got1.Ports[0] != "80/tcp" || got1.MountPaths[0] != "/config" || got1.EnvKeys[0] != "FOO" {
t.Fatalf("expected stored fingerprint to be isolated from caller mutations, got %#v", got1)
}
// Mutate returned fingerprint; store should still return original data.
got1.Hash = "changed-by-caller"
got1.Ports[0] = "9999/tcp"
got1.MountPaths[0] = "/bad"
got1.EnvKeys[0] = "BAD"
got2, err := store.GetFingerprint("docker:host1:web")
if err != nil {
t.Fatalf("second GetFingerprint error: %v", err)
}
if got2.Hash != "abc123" || got2.Ports[0] != "80/tcp" || got2.MountPaths[0] != "/config" || got2.EnvKeys[0] != "FOO" {
t.Fatalf("expected second GetFingerprint to be isolated from caller mutations, got %#v", got2)
}
all := store.GetAllFingerprints()
all["docker:host1:web"].Hash = "changed-from-map"
got3, err := store.GetFingerprint("docker:host1:web")
if err != nil {
t.Fatalf("third GetFingerprint error: %v", err)
}
if got3.Hash != "abc123" {
t.Fatalf("expected GetAllFingerprints map to be isolated copy, got hash %q", got3.Hash)
}
}
func TestStore_ListErrors(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
store.crypto = nil
store.dataDir = filepath.Join(t.TempDir(), "missing")
list, err := store.List()
if err != nil || len(list) != 0 {
t.Fatalf("expected empty list for missing dir")
}
file := filepath.Join(t.TempDir(), "file")
if err := os.WriteFile(file, []byte("x"), 0600); err != nil {
t.Fatalf("WriteFile error: %v", err)
}
store.dataDir = file
if _, err := store.List(); err == nil {
t.Fatalf("expected list error for file path")
}
if _, err := store.ListByType(ResourceTypeDocker); err == nil {
t.Fatalf("expected list by type error")
}
if _, err := store.ListByTarget("host1"); err == nil {
t.Fatalf("expected list by target error")
}
}
func TestStore_GetChangedResources(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
store.crypto = nil
// Save fingerprints for different resource types, using the same key
// format that collectFingerprints uses: "type:host:id"
dockerFP := &ContainerFingerprint{
ResourceID: "docker:host1:nginx",
TargetID: "host1",
Hash: "aaa111",
}
systemContainerFP := &ContainerFingerprint{
ResourceID: "system-container:node1:101",
TargetID: "node1",
Hash: "bbb222",
}
vmFP := &ContainerFingerprint{
ResourceID: "vm:node1:200",
TargetID: "node1",
Hash: "ccc333",
}
for _, fp := range []*ContainerFingerprint{dockerFP, systemContainerFP, vmFP} {
if err := store.SaveFingerprint(fp); err != nil {
t.Fatalf("SaveFingerprint error: %v", err)
}
}
// No discoveries exist yet — all three should be reported as changed.
changed, err := store.GetChangedResources()
if err != nil {
t.Fatalf("GetChangedResources error: %v", err)
}
if len(changed) != 3 {
t.Fatalf("expected 3 changed (no discoveries yet), got %d: %v", len(changed), changed)
}
// Save discoveries with matching fingerprint hashes.
for _, d := range []*ResourceDiscovery{
{ID: "docker:host1:nginx", ResourceType: ResourceTypeDocker, TargetID: "host1", ResourceID: "nginx", Fingerprint: "aaa111"},
{ID: "system-container:node1:101", ResourceType: ResourceTypeSystemContainer, TargetID: "node1", ResourceID: "101", Fingerprint: "bbb222"},
{ID: "vm:node1:200", ResourceType: ResourceTypeVM, TargetID: "node1", ResourceID: "200", Fingerprint: "ccc333"},
} {
if err := store.Save(d); err != nil {
t.Fatalf("Save error: %v", err)
}
}
// All fingerprints match their discoveries — nothing should be changed.
changed, err = store.GetChangedResources()
if err != nil {
t.Fatalf("GetChangedResources error: %v", err)
}
if len(changed) != 0 {
t.Fatalf("expected 0 changed (all match), got %d: %v", len(changed), changed)
}
// Update the system-container fingerprint to simulate a change.
systemContainerFP.Hash = "bbb222_changed"
if err := store.SaveFingerprint(systemContainerFP); err != nil {
t.Fatalf("SaveFingerprint error: %v", err)
}
changed, err = store.GetChangedResources()
if err != nil {
t.Fatalf("GetChangedResources error: %v", err)
}
if len(changed) != 1 {
t.Fatalf("expected 1 changed (LXC only), got %d: %v", len(changed), changed)
}
if changed[0] != "system-container:node1:101" {
t.Fatalf("expected changed resource to be system-container:node1:101, got %s", changed[0])
}
}
func TestStore_GetStaleResourcesUsesLastUpdatedTimestamp(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
store.crypto = nil
id := MakeResourceID(ResourceTypeDocker, "host1", "web")
if err := store.Save(&ResourceDiscovery{
ID: id,
ResourceType: ResourceTypeDocker,
ResourceID: "web",
TargetID: "host1",
}); err != nil {
t.Fatalf("Save error: %v", err)
}
path := store.getFilePath(id)
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile error: %v", err)
}
var saved ResourceDiscovery
if err := json.Unmarshal(data, &saved); err != nil {
t.Fatalf("Unmarshal error: %v", err)
}
// First discovery happened long ago, but the record was updated recently.
saved.DiscoveredAt = time.Now().Add(-48 * time.Hour)
saved.UpdatedAt = time.Now().Add(-10 * time.Minute)
data, err = json.Marshal(&saved)
if err != nil {
t.Fatalf("Marshal error: %v", err)
}
if err := os.WriteFile(path, data, 0600); err != nil {
t.Fatalf("WriteFile error: %v", err)
}
stale, err := store.GetStaleResources(time.Hour)
if err != nil {
t.Fatalf("GetStaleResources error: %v", err)
}
if len(stale) != 0 {
t.Fatalf("expected no stale discoveries when UpdatedAt is recent, got %v", stale)
}
// Once last update is old, it should be considered stale.
saved.UpdatedAt = time.Now().Add(-48 * time.Hour)
data, err = json.Marshal(&saved)
if err != nil {
t.Fatalf("Marshal error: %v", err)
}
if err := os.WriteFile(path, data, 0600); err != nil {
t.Fatalf("WriteFile error: %v", err)
}
stale, err = store.GetStaleResources(time.Hour)
if err != nil {
t.Fatalf("GetStaleResources error: %v", err)
}
if len(stale) != 1 || stale[0] != id {
t.Fatalf("expected stale discovery %q, got %v", id, stale)
}
}
func TestStore_DeleteError(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
store.crypto = nil
id := MakeResourceID(ResourceTypeDocker, "host1", "dir")
filePath := store.getFilePath(id)
if err := os.MkdirAll(filePath, 0700); err != nil {
t.Fatalf("MkdirAll error: %v", err)
}
nested := filepath.Join(filePath, "nested")
if err := os.WriteFile(nested, []byte("x"), 0600); err != nil {
t.Fatalf("WriteFile error: %v", err)
}
if err := store.Delete(id); err == nil {
t.Fatalf("expected delete error for non-empty dir")
}
}
func TestStore_FingerprintAccessorsAndCleanup(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
store.crypto = nil
origLimit := maxDiscoveryFileReadBytes
maxDiscoveryFileReadBytes = 64
t.Cleanup(func() {
maxDiscoveryFileReadBytes = origLimit
})
id := MakeResourceID(ResourceTypeDocker, "host1", "oversized")
if err := os.WriteFile(store.getFilePath(id), []byte(strings.Repeat("x", 128)), 0600); err != nil {
t.Fatalf("WriteFile error: %v", err)
}
if _, err := store.Get(id); err == nil || !strings.Contains(err.Error(), "exceeds max size") {
t.Fatalf("expected max size error, got: %v", err)
}
}
func TestStore_GetRejectsNonRegularDiscoveryFile(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
store.crypto = nil
id := MakeResourceID(ResourceTypeDocker, "host1", "dir")
if err := os.MkdirAll(store.getFilePath(id), 0700); err != nil {
t.Fatalf("MkdirAll error: %v", err)
}
if _, err := store.Get(id); err == nil || !strings.Contains(err.Error(), "not a regular file") {
t.Fatalf("expected non-regular file error, got: %v", err)
}
}
func TestStore_ListSkipsOversizedAndSymlinkDiscoveries(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
store.crypto = nil
origLimit := maxDiscoveryFileReadBytes
maxDiscoveryFileReadBytes = 4096
t.Cleanup(func() {
maxDiscoveryFileReadBytes = origLimit
})
valid := &ResourceDiscovery{
ID: MakeResourceID(ResourceTypeDocker, "host1", "valid"),
ResourceType: ResourceTypeDocker,
ResourceID: "valid",
TargetID: "host1",
ServiceName: "Valid",
}
if err := store.Save(valid); err != nil {
t.Fatalf("Save error: %v", err)
}
oversizedPath := filepath.Join(store.dataDir, "oversized.enc")
if err := os.WriteFile(oversizedPath, []byte(strings.Repeat("x", 8192)), 0600); err != nil {
t.Fatalf("WriteFile error: %v", err)
}
symlinkPath := filepath.Join(store.dataDir, "symlink.enc")
if err := os.Symlink(store.getFilePath(valid.ID), symlinkPath); err != nil {
t.Skipf("symlink not supported in this environment: %v", err)
}
list, err := store.List()
if err != nil {
t.Fatalf("List error: %v", err)
}
if len(list) != 1 || list[0].ID != valid.ID {
t.Fatalf("expected only valid discovery, got: %#v", list)
}
}
func TestStore_LoadFingerprintsSkipsOversizedFiles(t *testing.T) {
dir := t.TempDir()
fingerprintDir := filepath.Join(dir, "discovery", "fingerprints")
if err := os.MkdirAll(fingerprintDir, 0700); err != nil {
t.Fatalf("MkdirAll error: %v", err)
}
origLimit := maxFingerprintFileReadBytes
maxFingerprintFileReadBytes = 256
t.Cleanup(func() {
maxFingerprintFileReadBytes = origLimit
})
valid := &ContainerFingerprint{
ResourceID: "docker:host1:nginx",
TargetID: "host1",
Hash: "abc123",
}
validData, err := json.Marshal(valid)
if err != nil {
t.Fatalf("Marshal error: %v", err)
}
if err := os.WriteFile(filepath.Join(fingerprintDir, "valid.json"), validData, 0600); err != nil {
t.Fatalf("WriteFile valid fingerprint error: %v", err)
}
if err := os.WriteFile(filepath.Join(fingerprintDir, "oversized.json"), []byte(strings.Repeat("x", 512)), 0600); err != nil {
t.Fatalf("WriteFile oversized fingerprint error: %v", err)
}
store, err := NewStore(dir)
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
file := filepath.Join(t.TempDir(), "not-a-dir")
if err := os.WriteFile(file, []byte("x"), 0600); err != nil {
t.Fatalf("WriteFile error: %v", err)
}
store.dataDir = file
if ids := store.ListDiscoveryIDs(); ids != nil {
t.Fatalf("expected nil IDs when dataDir is unreadable, got %v", ids)
}
}
func TestStore_LoadFingerprints_DerivesTargetIDFromResourceIDWhenMissing(t *testing.T) {
dir := t.TempDir()
fingerprintDir := filepath.Join(dir, "discovery", "fingerprints")
if err := os.MkdirAll(fingerprintDir, 0700); err != nil {
t.Fatalf("MkdirAll error: %v", err)
}
payloadWithoutTarget := map[string]any{
"resource_id": "docker:legacy-host:web",
"hash": "legacy123",
}
legacyData, err := json.Marshal(payloadWithoutTarget)
if err != nil {
t.Fatalf("Marshal error: %v", err)
}
if err := os.WriteFile(filepath.Join(fingerprintDir, "legacy.json"), legacyData, 0600); err != nil {
t.Fatalf("WriteFile legacy fingerprint error: %v", err)
}
store, err := NewStore(dir)
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
fp, err := store.GetFingerprint("docker:legacy-host:web")
if err != nil {
t.Fatalf("GetFingerprint error: %v", err)
}
if fp == nil {
t.Fatal("expected fingerprint, got nil")
}
if fp.TargetID != "legacy-host" {
t.Fatalf("expected TargetID legacy-host, got %q", fp.TargetID)
}
}
func TestStore_GetStaleResources(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
store.crypto = nil
old := &ResourceDiscovery{
ID: MakeResourceID(ResourceTypeDocker, "host1", "old"),
ResourceType: ResourceTypeDocker,
ResourceID: "old",
TargetID: "host1",
DiscoveredAt: time.Now().Add(-2 * time.Hour),
}
fresh := &ResourceDiscovery{
ID: MakeResourceID(ResourceTypeDocker, "host1", "fresh"),
ResourceType: ResourceTypeDocker,
ResourceID: "fresh",
TargetID: "host1",
DiscoveredAt: time.Now().Add(-5 * time.Minute),
}
for _, d := range []*ResourceDiscovery{old, fresh} {
if err := store.Save(d); err != nil {
t.Fatalf("Save error: %v", err)
}
}
// Save() sets UpdatedAt to time.Now(), so overwrite the on-disk files
// (which List() reads) to simulate genuinely stale/fresh entries.
for _, d := range []*ResourceDiscovery{old, fresh} {
switch d.ResourceID {
case "old":
d.UpdatedAt = time.Now().Add(-2 * time.Hour)
case "fresh":
d.UpdatedAt = time.Now().Add(-5 * time.Minute)
}
data, err := json.Marshal(d)
if err != nil {
t.Fatalf("marshal error: %v", err)
}
if err := os.WriteFile(store.getFilePath(d.ID), data, 0600); err != nil {
t.Fatalf("write error: %v", err)
}
}
stale, err := store.GetStaleResources(time.Hour)
if err != nil {
t.Fatalf("GetStaleResources error: %v", err)
}
if len(stale) != 1 || stale[0] != old.ID {
t.Fatalf("expected stale IDs [%q], got %v", old.ID, stale)
}
file := filepath.Join(t.TempDir(), "not-a-dir")
if err := os.WriteFile(file, []byte("x"), 0600); err != nil {
t.Fatalf("WriteFile error: %v", err)
}
store.dataDir = file
if _, err := store.GetStaleResources(time.Hour); err == nil {
t.Fatalf("expected GetStaleResources to return list error")
}
}
func containsString(items []string, target string) bool {
for _, item := range items {
if item == target {
return true
}
}
return false
}