213 lines
7.3 KiB
Go
213 lines
7.3 KiB
Go
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: <id>" 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)
|
|
}
|
|
}
|