first commit
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user