package secrets import ( "context" "errors" "path/filepath" "testing" "github.com/jmoiron/sqlx" "github.com/kandev/kandev/internal/auth/authn" "github.com/kandev/kandev/internal/db" ) func newTestSQLiteStore(t *testing.T) *sqliteStore { t.Helper() dir := t.TempDir() conn, err := db.OpenSQLite(filepath.Join(dir, "secrets.db")) if err != nil { t.Fatalf("open sqlite: %v", err) } sqlxDB := sqlx.NewDb(conn, "sqlite3") t.Cleanup(func() { _ = sqlxDB.Close() }) crypto, err := NewMasterKeyProvider(dir) if err != nil { t.Fatalf("master key: %v", err) } store, cleanup, err := Provide(sqlxDB, sqlxDB, crypto) if err != nil { t.Fatalf("provide store: %v", err) } t.Cleanup(func() { _ = cleanup() }) return store } // TestSQLiteStore_MissingID_IsErrNotFound proves the store signals an absent // entry via the exported secrets.ErrNotFound sentinel (matchable with // errors.Is), so consumers don't have to string-match the message. func TestSQLiteStore_MissingID_IsErrNotFound(t *testing.T) { store := newTestSQLiteStore(t) ctx := context.Background() if _, err := store.Get(ctx, "does-not-exist"); !errors.Is(err, ErrNotFound) { t.Errorf("Get(missing) error = %v, want errors.Is ErrNotFound", err) } if _, err := store.Reveal(ctx, "does-not-exist"); !errors.Is(err, ErrNotFound) { t.Errorf("Reveal(missing) error = %v, want errors.Is ErrNotFound", err) } if err := store.Delete(ctx, "does-not-exist"); !errors.Is(err, ErrNotFound) { t.Errorf("Delete(missing) error = %v, want errors.Is ErrNotFound", err) } } // TestSQLiteStore_MissingID_MessageUnchanged confirms the human-readable text // is still "secret not found: " so existing logs are unchanged; only the // detection mechanism (errors.Is) is new. func TestSQLiteStore_MissingID_MessageUnchanged(t *testing.T) { store := newTestSQLiteStore(t) _, err := store.Get(context.Background(), "abc") if err == nil { t.Fatal("Get(missing) returned nil error") } if got, want := err.Error(), "secret not found: abc"; got != want { t.Errorf("error message = %q, want %q", got, want) } } // Per-user secret scoping (opt-in auth): owned secrets are invisible to other // users; unowned (pre-auth) rows stay shared; internal/synthetic callers are // unscoped; ClaimUnowned hands legacy rows to the setup-wizard admin. func TestSQLiteStore_PerUserScoping(t *testing.T) { store := newTestSQLiteStore(t) internalCtx := context.Background() userA := authn.WithIdentity(context.Background(), authn.Identity{UserID: "user-a", Role: authn.RoleMember}) userB := authn.WithIdentity(context.Background(), authn.Identity{UserID: "user-b", Role: authn.RoleMember}) shared := &SecretWithValue{Secret: Secret{Name: "SHARED"}, Value: "legacy"} if err := store.Create(internalCtx, shared); err != nil { t.Fatalf("create shared: %v", err) } mine := &SecretWithValue{Secret: Secret{Name: "MINE"}, Value: "a-only"} if err := store.Create(userA, mine); err != nil { t.Fatalf("create owned: %v", err) } // Owner + internal see the owned secret; another user does not. if _, err := store.Reveal(userA, mine.ID); err != nil { t.Fatalf("owner reveal: %v", err) } if _, err := store.Reveal(internalCtx, mine.ID); err != nil { t.Fatalf("internal reveal: %v", err) } if _, err := store.Reveal(userB, mine.ID); !errors.Is(err, ErrNotFound) { t.Fatalf("foreign reveal: %v, want ErrNotFound", err) } if err := store.Delete(userB, mine.ID); !errors.Is(err, ErrNotFound) { t.Fatalf("foreign delete: %v, want ErrNotFound", err) } // Unowned rows are visible to everyone. if _, err := store.Reveal(userB, shared.ID); err != nil { t.Fatalf("shared reveal: %v", err) } // List is scoped: A sees both, B sees only the shared one. itemsA, err := store.List(userA) if err != nil || len(itemsA) != 2 { t.Fatalf("list A: %v (%d items, want 2)", err, len(itemsA)) } itemsB, err := store.List(userB) if err != nil || len(itemsB) != 1 { t.Fatalf("list B: %v (%d items, want 1)", err, len(itemsB)) } // Setup-wizard claim: shared secret becomes admin-owned, invisible to B. if err := store.ClaimUnowned(internalCtx, "user-a"); err != nil { t.Fatalf("claim: %v", err) } if _, err := store.Reveal(userB, shared.ID); !errors.Is(err, ErrNotFound) { t.Fatalf("post-claim foreign reveal: %v, want ErrNotFound", err) } } func TestSQLiteStore_ScopedSecretVisibility(t *testing.T) { store := newTestSQLiteStore(t) ctx := context.Background() userA := authn.WithIdentity(ctx, authn.Identity{UserID: "user-a", Role: authn.RoleMember}) userB := authn.WithIdentity(ctx, authn.Identity{UserID: "user-b", Role: authn.RoleMember}) global := &SecretWithValue{Secret: Secret{Name: "global"}, Value: "global-value"} workspace := &SecretWithValue{Secret: Secret{ Name: "workspace", Scope: ScopeWorkspace, WorkspaceID: "workspace-a", }, Value: "workspace-value"} if err := store.Create(userA, global); err != nil { t.Fatalf("create global: %v", err) } if err := store.Create(userA, workspace); err != nil { t.Fatalf("create workspace: %v", err) } globalItems, err := store.ListScoped(userA, SecretListOptions{Scope: ScopeGlobal}) if err != nil { t.Fatalf("list global: %v", err) } if len(globalItems) != 1 || globalItems[0].ID != global.ID || globalItems[0].Scope != ScopeGlobal { t.Fatalf("global items = %+v, want only %s", globalItems, global.ID) } workspaceItems, err := store.ListScoped(userA, SecretListOptions{ Scope: ScopeWorkspace, WorkspaceID: "workspace-a", }) if err != nil { t.Fatalf("list workspace: %v", err) } if len(workspaceItems) != 1 || workspaceItems[0].ID != workspace.ID { t.Fatalf("workspace items = %+v, want only %s", workspaceItems, workspace.ID) } if _, err := store.GetForWorkspace(userA, workspace.ID, "workspace-a"); err != nil { t.Fatalf("workspace owner get: %v", err) } if _, err := store.GetForWorkspace(userB, workspace.ID, "workspace-a"); !errors.Is(err, ErrNotFound) { t.Fatalf("foreign workspace get = %v, want ErrNotFound", err) } if _, err := store.GetForWorkspace(userA, workspace.ID, "workspace-b"); !errors.Is(err, ErrNotFound) { t.Fatalf("wrong workspace get = %v, want ErrNotFound", err) } if _, err := store.RevealGlobal(userA, workspace.ID); !errors.Is(err, ErrNotFound) { t.Fatalf("workspace reveal through global API = %v, want ErrNotFound", err) } if err := store.DeleteWorkspaceSecrets(ctx, "workspace-a"); err != nil { t.Fatalf("delete workspace secrets: %v", err) } if _, err := store.Get(ctx, workspace.ID); !errors.Is(err, ErrNotFound) { t.Fatalf("deleted workspace secret = %v, want ErrNotFound", err) } } func TestSQLiteStore_ListScopedRequiresExplicitScope(t *testing.T) { store := newTestSQLiteStore(t) ctx := context.Background() if err := store.Create(ctx, &SecretWithValue{Secret: Secret{Name: "global"}, Value: "global"}); err != nil { t.Fatalf("create global: %v", err) } if err := store.Create(ctx, &SecretWithValue{Secret: Secret{ Name: "workspace", Scope: ScopeWorkspace, WorkspaceID: "workspace-a", }, Value: "workspace"}); err != nil { t.Fatalf("create workspace: %v", err) } if _, err := store.ListScoped(ctx, SecretListOptions{}); err == nil { t.Fatal("ListScoped with no scope succeeded, want explicit-scope error") } items, err := store.List(ctx) if err != nil { t.Fatalf("List: %v", err) } if len(items) != 1 || items[0].Scope != ScopeGlobal { t.Fatalf("List returned %#v, want only global secret", items) } }