mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-03 04:38:48 +00:00
Backfill could save a stale List snapshot after manual discovery repaired a service, restoring unknown identity and dropping its URL and engine version. Derive and persist missing suggestions from the current record under the store lock instead, without holding it across monitor reads. Add a deterministic SetReadState/manual-refresh interleaving and encrypted restart assertions, plus coverage for current identity, dismissed proposals, deletion and persistence failure. The discovery package passes twenty race-enabled repetitions. Change-source: pulse-maintainer
1328 lines
38 KiB
Go
1328 lines
38 KiB
Go
package servicediscovery
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"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{}
|
|
|
|
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 TestStoreAvailabilityProposalDispositionIsEvidenceBound(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", "grafana"),
|
|
ResourceType: ResourceTypeDocker,
|
|
ResourceID: "grafana",
|
|
TargetID: "host1",
|
|
SuggestedAvailabilityProbe: SuggestAvailabilityProbe(
|
|
&ResourceDiscovery{ServiceType: "grafana", ServiceName: "Grafana"},
|
|
"10.0.0.8",
|
|
),
|
|
}
|
|
if err := store.Save(discovery); err != nil {
|
|
t.Fatalf("Save error: %v", err)
|
|
}
|
|
fingerprint := discovery.SuggestedAvailabilityProbe.EvidenceFingerprint
|
|
if err := store.UpdateAvailabilityProposalDisposition(discovery.ID, fingerprint, true); err != nil {
|
|
t.Fatalf("dismiss error: %v", err)
|
|
}
|
|
dismissed, _ := store.Get(discovery.ID)
|
|
if dismissed.DismissedAvailabilityProbeFingerprint != fingerprint {
|
|
t.Fatalf("dismissed fingerprint = %q, want %q", dismissed.DismissedAvailabilityProbeFingerprint, fingerprint)
|
|
}
|
|
|
|
if err := store.UpdateAvailabilityProposalDisposition(discovery.ID, "sha256:stale", true); !errors.Is(err, ErrAvailabilityProposalEvidenceChanged) {
|
|
t.Fatalf("stale dismissal error = %v, want ErrAvailabilityProposalEvidenceChanged", err)
|
|
}
|
|
|
|
dismissed.SuggestedAvailabilityProbe.Path = "/health"
|
|
if err := store.Save(dismissed); err != nil {
|
|
t.Fatalf("Save changed proposal error: %v", err)
|
|
}
|
|
reopened, _ := store.Get(discovery.ID)
|
|
if reopened.DismissedAvailabilityProbeFingerprint != "" {
|
|
t.Fatalf("materially changed evidence retained dismissal: %q", reopened.DismissedAvailabilityProbeFingerprint)
|
|
}
|
|
}
|
|
|
|
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 TestStore_BackfillAvailabilitySuggestionUsesCurrentRecord(t *testing.T) {
|
|
for _, scenario := range []string{"new identity", "existing dismissed proposal", "deleted", "unsupported", "write failure"} {
|
|
t.Run(scenario, func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
store, err := NewStore(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
id := MakeResourceID(ResourceTypeSystemContainer, "pve1", "102")
|
|
old := &ResourceDiscovery{ID: id, Hostname: "esphome", ServiceType: "unknown"}
|
|
if err := store.Save(old); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
work, err := store.List()
|
|
if err != nil || len(work) != 1 {
|
|
t.Fatalf("List: %v, %v", work, err)
|
|
}
|
|
current := cloneResourceDiscovery(old)
|
|
current.ServiceType = "redis"
|
|
current.ServiceName = "New Redis"
|
|
current.UserNotes = "operator note after list"
|
|
if scenario == "existing dismissed proposal" {
|
|
current.SuggestedAvailabilityProbe = SuggestAvailabilityProbe(current, "192.0.2.20")
|
|
current.DismissedAvailabilityProbeFingerprint = current.SuggestedAvailabilityProbe.EvidenceFingerprint
|
|
}
|
|
if scenario == "unsupported" {
|
|
current.ServiceType = "unknown"
|
|
current.Hostname = "unidentified"
|
|
}
|
|
if err := store.Save(current); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
before, err := store.Get(id)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if scenario == "deleted" {
|
|
if err := store.Delete(id); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if scenario == "write failure" {
|
|
// A directory at the temporary file path causes a real persistence error.
|
|
if err := os.Mkdir(store.getFilePath(id)+".tmp", 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
changed, err := store.backfillAvailabilitySuggestion(work[0].ID, "192.0.2.10")
|
|
if scenario == "write failure" {
|
|
if err == nil || changed {
|
|
t.Fatalf("expected failed write, got changed=%v err=%v", changed, err)
|
|
}
|
|
} else if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if changed != (scenario == "new identity") {
|
|
t.Fatalf("unexpected changed=%v", changed)
|
|
}
|
|
restarted, err := NewStore(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for name, reader := range map[string]*Store{"cache": store, "disk": restarted} {
|
|
got, err := reader.Get(id)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if scenario == "deleted" {
|
|
if got != nil {
|
|
t.Errorf("%s: deleted discovery resurrected", name)
|
|
}
|
|
continue
|
|
}
|
|
want := cloneResourceDiscovery(before)
|
|
if scenario == "new identity" {
|
|
want.SuggestedAvailabilityProbe = SuggestAvailabilityProbe(before, "192.0.2.10")
|
|
want.UpdatedAt = got.UpdatedAt
|
|
if got.SuggestedAvailabilityProbe == nil || got.SuggestedAvailabilityProbe.Port != 6379 {
|
|
t.Fatalf("%s: suggestion not derived from current Redis identity", name)
|
|
}
|
|
}
|
|
// JSON comparison ignores time.Time's process-local monotonic clock.
|
|
gotJSON, _ := json.Marshal(got)
|
|
wantJSON, _ := json.Marshal(want)
|
|
if string(gotJSON) != string(wantJSON) {
|
|
t.Errorf("%s: backfill changed unrelated/current fields", name)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|