339 lines
12 KiB
Go
339 lines
12 KiB
Go
package trust
|
|
|
|
// Tests for the trust store (US-018, #134). The store is a JSON map of
|
|
// directory path to a nullable boolean (true/false/null); these tests pin the
|
|
// tri-state semantics, the nearest-ancestor walk, session-vs-persisted trust,
|
|
// and the on-disk round-trip including the null value.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
// newTestManager builds a Manager backed by a temp file, failing the test if
|
|
// construction fails. Every test starts from an empty store.
|
|
func newTestManager(t *testing.T) *Manager {
|
|
t.Helper()
|
|
m, err := NewManager(filepath.Join(t.TempDir(), "trust.json"))
|
|
if err != nil {
|
|
t.Fatalf("NewManager: %v", err)
|
|
}
|
|
return m
|
|
}
|
|
|
|
// reload reopens the manager at the same path, asserting no error, so a test
|
|
// can verify a decision survived a write.
|
|
func reload(t *testing.T, m *Manager) *Manager {
|
|
t.Helper()
|
|
m2, err := NewManager(m.path)
|
|
if err != nil {
|
|
t.Fatalf("reload NewManager: %v", err)
|
|
}
|
|
return m2
|
|
}
|
|
|
|
// TestNewManagerMissingFile verifies a missing trust file is not an error: the
|
|
// manager starts empty and the nearest lookup reports nothing found.
|
|
func TestNewManagerMissingFile(t *testing.T) {
|
|
m := newTestManager(t)
|
|
if res := m.NearestTrustDecision("/some/dir"); res.Found {
|
|
t.Errorf("NearestTrustDecision on empty store: Found=true, want false")
|
|
}
|
|
}
|
|
|
|
// TestNewManagerEmptyPath verifies an empty path disables persistence: Set and
|
|
// Forget are no-ops on disk and never error, and lookups still work in-memory.
|
|
func TestNewManagerEmptyPath(t *testing.T) {
|
|
m, err := NewManager("")
|
|
if err != nil {
|
|
t.Fatalf("NewManager(\"\"): %v", err)
|
|
}
|
|
if err := m.SetDecision("/a", Trusted); err != nil {
|
|
t.Fatalf("SetDecision on empty-path manager: %v", err)
|
|
}
|
|
if !m.IsTrusted("/a") {
|
|
t.Error("IsTrusted(/a) = false after in-memory SetDecision, want true")
|
|
}
|
|
}
|
|
|
|
// TestSetDecisionPersists verifies Trusted/Untrusted round-trip through disk:
|
|
// after SetDecision + reload, the nearest decision matches what was written.
|
|
func TestSetDecisionPersists(t *testing.T) {
|
|
m := newTestManager(t)
|
|
if err := m.SetDecision("/a", Trusted); err != nil {
|
|
t.Fatalf("SetDecision Trusted: %v", err)
|
|
}
|
|
if err := m.SetDecision("/b", Untrusted); err != nil {
|
|
t.Fatalf("SetDecision Untrusted: %v", err)
|
|
}
|
|
m2 := reload(t, m)
|
|
if got := m2.NearestTrustDecision("/a"); !got.Found || got.Decision != Trusted || got.Path != "/a" {
|
|
t.Errorf("reload NearestTrustDecision(/a) = %+v, want Found/Trusted//a", got)
|
|
}
|
|
if got := m2.NearestTrustDecision("/b"); !got.Found || got.Decision != Untrusted || got.Path != "/b" {
|
|
t.Errorf("reload NearestTrustDecision(/b) = %+v, want Found/Untrusted//b", got)
|
|
}
|
|
}
|
|
|
|
// TestNearestAncestorWalk verifies the lookup walks up from cwd to root and
|
|
// returns the nearest ancestor (inclusive) with an entry: a decision saved for
|
|
// /a applies to /a/b/c, and the returned Path is the directory it was saved
|
|
// for, not the query directory.
|
|
func TestNearestAncestorWalk(t *testing.T) {
|
|
m := newTestManager(t)
|
|
if err := m.SetDecision("/a", Trusted); err != nil {
|
|
t.Fatalf("SetDecision: %v", err)
|
|
}
|
|
got := m.NearestTrustDecision("/a/b/c")
|
|
if !got.Found || got.Decision != Trusted || got.Path != "/a" {
|
|
t.Errorf("NearestTrustDecision(/a/b/c) = %+v, want Found/Trusted/Path=/a", got)
|
|
}
|
|
// A more specific entry shadows a broader one: /a/b/untrusted wins over
|
|
// /a/trusted for anything under /a/b.
|
|
if err := m.SetDecision("/a/b", Untrusted); err != nil {
|
|
t.Fatalf("SetDecision /a/b: %v", err)
|
|
}
|
|
got = m.NearestTrustDecision("/a/b/c")
|
|
if !got.Found || got.Decision != Untrusted || got.Path != "/a/b" {
|
|
t.Errorf("NearestTrustDecision(/a/b/c) after shadow = %+v, want Found/Untrusted/Path=/a/b", got)
|
|
}
|
|
// /a/d is under /a but not /a/b, so it still sees /a/trusted.
|
|
got = m.NearestTrustDecision("/a/d")
|
|
if !got.Found || got.Decision != Trusted || got.Path != "/a" {
|
|
t.Errorf("NearestTrustDecision(/a/d) = %+v, want Found/Trusted/Path=/a", got)
|
|
}
|
|
}
|
|
|
|
// TestNearestNotFound verifies a query with no entry on the path returns
|
|
// Found=false and Undecided.
|
|
func TestNearestNotFound(t *testing.T) {
|
|
m := newTestManager(t)
|
|
if err := m.SetDecision("/a", Trusted); err != nil {
|
|
t.Fatalf("SetDecision: %v", err)
|
|
}
|
|
if got := m.NearestTrustDecision("/completely/unrelated"); got.Found {
|
|
t.Errorf("NearestTrustDecision(unrelated) = %+v, want Found=false", got)
|
|
}
|
|
}
|
|
|
|
// TestNullEntryRoundTrip verifies the "null" half of the "path -> bool|null"
|
|
// schema: SetDecision(Undecided) writes an explicit JSON null, which reloads as
|
|
// Found=true with Decision Undecided (recorded but not trusted).
|
|
func TestNullEntryRoundTrip(t *testing.T) {
|
|
m := newTestManager(t)
|
|
if err := m.SetDecision("/a", Undecided); err != nil {
|
|
t.Fatalf("SetDecision Undecided: %v", err)
|
|
}
|
|
// The on-disk value really is null, not omitted.
|
|
raw, err := os.ReadFile(m.path)
|
|
if err != nil {
|
|
t.Fatalf("read trust file: %v", err)
|
|
}
|
|
var got map[string]*bool
|
|
if err := json.Unmarshal(raw, &got); err != nil {
|
|
t.Fatalf("parse trust file: %v", err)
|
|
}
|
|
v, ok := got["/a"]
|
|
if !ok {
|
|
t.Fatal("entry /a missing from trust file")
|
|
}
|
|
if v != nil {
|
|
t.Errorf("stored value = %v, want nil (JSON null)", *v)
|
|
}
|
|
// Reload: Found=true (an entry exists), Decision=Undecided (it is null).
|
|
m2 := reload(t, m)
|
|
res := m2.NearestTrustDecision("/a")
|
|
if !res.Found || res.Decision != Undecided {
|
|
t.Errorf("reload NearestTrustDecision(/a) = %+v, want Found=true/Undecided", res)
|
|
}
|
|
}
|
|
|
|
// TestIsTrusted verifies the gating predicate: true only for Trusted (persisted
|
|
// or session), false for Untrusted/Undecided/absent, and that an ancestor
|
|
// Trusted decision covers a descendant.
|
|
func TestIsTrusted(t *testing.T) {
|
|
m := newTestManager(t)
|
|
if err := m.SetDecision("/trusted", Trusted); err != nil {
|
|
t.Fatalf("SetDecision Trusted: %v", err)
|
|
}
|
|
if err := m.SetDecision("/untrusted", Untrusted); err != nil {
|
|
t.Fatalf("SetDecision Untrusted: %v", err)
|
|
}
|
|
cases := []struct {
|
|
cwd string
|
|
want bool
|
|
}{
|
|
{"/trusted", true}, // exact trusted
|
|
{"/trusted/sub/deep", true}, // ancestor trusted
|
|
{"/untrusted", false}, // exact untrusted
|
|
{"/untrusted/sub", false}, // ancestor untrusted
|
|
{"/undecided", false}, // no entry
|
|
{"/", false}, // root, no entry
|
|
}
|
|
for _, c := range cases {
|
|
if got := m.IsTrusted(c.cwd); got != c.want {
|
|
t.Errorf("IsTrusted(%q) = %v, want %v", c.cwd, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSessionTrustNotPersisted verifies SetSessionTrust grants trust for the
|
|
// current process but does not survive a reload (matching the "just once" REPL
|
|
// choice): a fresh manager over the same file does not see the grant.
|
|
func TestSessionTrustNotPersisted(t *testing.T) {
|
|
m := newTestManager(t)
|
|
m.SetSessionTrust("/proj")
|
|
if !m.IsTrusted("/proj") {
|
|
t.Error("IsTrusted(/proj) = false after SetSessionTrust, want true")
|
|
}
|
|
if !m.IsTrusted("/proj/sub") {
|
|
t.Error("IsTrusted(/proj/sub) = false, want true (session trust covers descendants)")
|
|
}
|
|
m2 := reload(t, m)
|
|
if m2.IsTrusted("/proj") {
|
|
t.Error("reload IsTrusted(/proj) = true, want false (session trust must not persist)")
|
|
}
|
|
}
|
|
|
|
// TestClearSessionTrust verifies ClearSessionTrust revokes a session grant so
|
|
// IsTrusted reflects only the persisted decision, including grants on an
|
|
// ancestor (walkUp). This is the contract "/trust off" relies on to take effect
|
|
// immediately rather than only after a restart.
|
|
func TestClearSessionTrust(t *testing.T) {
|
|
m := newTestManager(t)
|
|
m.SetSessionTrust("/proj")
|
|
if !m.IsTrusted("/proj") {
|
|
t.Fatal("IsTrusted = false after SetSessionTrust, want true")
|
|
}
|
|
m.ClearSessionTrust("/proj")
|
|
if m.IsTrusted("/proj") {
|
|
t.Error("IsTrusted = true after ClearSessionTrust, want false")
|
|
}
|
|
// Clearing a descendant also revokes an ancestor's session grant, since
|
|
// ClearSessionTrust walks up (matching IsTrusted's walkUp check).
|
|
m.SetSessionTrust("/a")
|
|
m.ClearSessionTrust("/a/b")
|
|
if m.IsTrusted("/a/b") {
|
|
t.Error("IsTrusted(/a/b) = true after ClearSessionTrust(/a/b), want false (ancestor /a grant revoked)")
|
|
}
|
|
}
|
|
|
|
// TestForget verifies Forget removes an entry so the directory becomes
|
|
// undecided again, both in-memory and after reload.
|
|
func TestForget(t *testing.T) {
|
|
m := newTestManager(t)
|
|
if err := m.SetDecision("/a", Trusted); err != nil {
|
|
t.Fatalf("SetDecision: %v", err)
|
|
}
|
|
if err := m.Forget("/a"); err != nil {
|
|
t.Fatalf("Forget: %v", err)
|
|
}
|
|
if got := m.NearestTrustDecision("/a"); got.Found {
|
|
t.Errorf("NearestTrustDecision after Forget = %+v, want Found=false", got)
|
|
}
|
|
// Forget is idempotent: forgetting a path with no entry is not an error.
|
|
if err := m.Forget("/a"); err != nil {
|
|
t.Errorf("Forget missing entry: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestDecisionForExactPath verifies the exact-path lookup (no walk) returns the
|
|
// stored decision and a Found flag, distinct from the walk-based nearest.
|
|
func TestDecisionForExactPath(t *testing.T) {
|
|
m := newTestManager(t)
|
|
if err := m.SetDecision("/a", Trusted); err != nil {
|
|
t.Fatalf("SetDecision: %v", err)
|
|
}
|
|
if dec, found := m.DecisionFor("/a"); !found || dec != Trusted {
|
|
t.Errorf("DecisionFor(/a) = %v,%v, want Trusted,true", dec, found)
|
|
}
|
|
if dec, found := m.DecisionFor("/a/b"); found {
|
|
t.Errorf("DecisionFor(/a/b) = %v,%v, want _,false (no exact entry)", dec, found)
|
|
}
|
|
}
|
|
|
|
// TestDefaultPath verifies DefaultPath honors PIGO_HOME and falls back to
|
|
// ~/.pigo/trust.json.
|
|
func TestDefaultPath(t *testing.T) {
|
|
t.Setenv("PIGO_HOME", "/custom/pigo")
|
|
if got := DefaultPath(); got != "/custom/pigo/trust.json" {
|
|
t.Errorf("DefaultPath with PIGO_HOME = %q, want /custom/pigo/trust.json", got)
|
|
}
|
|
t.Setenv("PIGO_HOME", "")
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
t.Skip("home dir unavailable")
|
|
}
|
|
want := filepath.Join(home, ".pigo", "trust.json")
|
|
if got := DefaultPath(); got != want {
|
|
t.Errorf("DefaultPath default = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
// TestSaveIsSorted verifies the written file has sorted keys (stable, diffable
|
|
// output) and is a valid JSON object.
|
|
func TestSaveIsSorted(t *testing.T) {
|
|
m := newTestManager(t)
|
|
for _, p := range []string{"/zeta", "/alpha", "/mid"} {
|
|
if err := m.SetDecision(p, Trusted); err != nil {
|
|
t.Fatalf("SetDecision %s: %v", p, err)
|
|
}
|
|
}
|
|
raw, err := os.ReadFile(m.path)
|
|
if err != nil {
|
|
t.Fatalf("read: %v", err)
|
|
}
|
|
s := string(raw)
|
|
i := strings.Index(s, "/alpha")
|
|
j := strings.Index(s, "/mid")
|
|
k := strings.Index(s, "/zeta")
|
|
if i < 0 || j < 0 || k < 0 {
|
|
t.Fatalf("expected /alpha, /mid, /zeta in output; got indices %d/%d/%d", i, j, k)
|
|
}
|
|
if !(i < j && j < k) {
|
|
t.Errorf("keys not sorted in output: alpha@%d mid@%d zeta@%d", i, j, k)
|
|
}
|
|
var got map[string]*bool
|
|
if err := json.Unmarshal(raw, &got); err != nil {
|
|
t.Errorf("output is not valid JSON: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestMalformedFileIsError verifies a corrupted trust file is a hard error
|
|
// rather than being silently overwritten, so the user's data is surfaced.
|
|
func TestMalformedFileIsError(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "trust.json")
|
|
if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil {
|
|
t.Fatalf("write malformed file: %v", err)
|
|
}
|
|
if _, err := NewManager(path); err == nil {
|
|
t.Error("NewManager on malformed file returned nil error, want a parse error")
|
|
}
|
|
}
|
|
|
|
// TestConcurrentAccess exercises the mutex under -race: many goroutines reading
|
|
// and writing concurrently must not trip the race detector.
|
|
func TestConcurrentAccess(t *testing.T) {
|
|
m := newTestManager(t)
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 50; i++ {
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
dir := filepath.Join("/p", "d"+strconv.Itoa(i))
|
|
_ = m.SetDecision(dir, Trusted)
|
|
_ = m.IsTrusted(dir)
|
|
_ = m.NearestTrustDecision(dir)
|
|
m.SetSessionTrust(dir)
|
|
_, _ = m.DecisionFor(dir)
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
}
|