Filtering of synchronized secrets on collection, organization, and/or folder (#12)

* Filtering feature, minor logic changes
+: Add IP validation (to properly log IP even when trusted proxy is missing)
+: Added synchronization of decrypted organization, collection, and folder names (for filtering purposes)
+: Changed sync behavior to prevent collapsing multiple logins with the same name
+: Added possibility to filter logins based on organization, collection, and folder name/id in GetSecret
+: Added support for space character in secret name validation (it is also properly URL-decoded)

* Update README

* Fixes related to issues reported by CodeRabbit:
+: Changed british use of organisations in README
+: Reduced line count in parseSecretFilters by including helper function for filter dimensions resolution
+: Added comment clarifying absence of existence checking on dimension IDs as opposed to names
+: Removed efensive comma split logic on IPs (redundant due to IPValidation setting on Fiber)
+: Changed Query validator to align with name validator (mainly removing hard-coded values)
+: Added comments to decryptVaultLabel for clarity
+: Change empty SyncNameMaps instance with emptySyncNameMaps helper for consistency
+: Add lexical sort for lookupIDByName for deterministic result in case of multiple matches

* Bumped logging to warn and added entity metadata in log

* Unit tests:
+: Added unit tests for every newly introduced or changed function
+: Rewrote NewClient constructor to accept optional preloaded data (so it can be used in test scenarios)

* Changes requested by CodeRabbit
This commit is contained in:
Jethro Young authored and GitHub committed 2026-05-20 23:26:29 +02:00
1 parent cac042a5c4
commit 76b82d4dba
11 files changed
+1174 -88

No files matched your search

+194 -27
View File
@@ -6,12 +6,13 @@ import (
"io"
"net/http"
"net/url"
"sort"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/Turbootzz/vaultwarden-api/pkg/logger"
"github.com/google/uuid"
)
// PreloginResponse contains the KDF parameters from the server.
@@ -34,16 +35,18 @@ type TokenResponse struct {
// SyncResponse contains the full vault sync data.
type SyncResponse struct {
Profile SyncProfile `json:"profile"`
Ciphers []SyncCipher `json:"ciphers"`
Profile SyncProfile `json:"profile"`
Ciphers []SyncCipher `json:"ciphers"`
Collections []SyncCollection `json:"collections"`
Folders []SyncFolder `json:"folders"`
}
// SyncProfile contains user profile info.
type SyncProfile struct {
ID string `json:"id"`
Email string `json:"email"`
Key string `json:"key"`
PrivateKey string `json:"privateKey"`
ID string `json:"id"`
Email string `json:"email"`
Key string `json:"key"`
PrivateKey string `json:"privateKey"`
Organizations []SyncOrganization `json:"organizations"`
}
@@ -54,11 +57,26 @@ type SyncOrganization struct {
Key string `json:"key"`
}
// SyncCollection represents a collection that logins can be assigned to.
type SyncCollection struct {
ID string `json:"id"`
OrganizationID string `json:"organizationId"`
Name string `json:"name"`
}
// SyncFolder represents a folder that logins can be assigned to.
type SyncFolder struct {
ID string `json:"id"`
Name string `json:"name"`
}
// SyncCipher represents an encrypted vault item from the sync response.
type SyncCipher struct {
ID string `json:"id"`
Type int `json:"type"`
OrganizationID *string `json:"organizationId"`
CollectionIDs []string `json:"collectionIds"`
FolderID *string `json:"folderId"`
Name string `json:"name"`
Notes *string `json:"notes"`
Login *SyncLogin `json:"login"`
@@ -264,10 +282,128 @@ func (ac *APIClient) EnsureValidToken() error {
return nil
}
// Sync fetches and decrypts all vault items.
func (ac *APIClient) Sync() ([]DecryptedItem, error) {
// SyncNameMaps holds decrypted display names keyed by Vaultwarden UUID (from sync).
type SyncNameMaps struct {
Organizations map[string]string // organization id -> name (decrypted with user symmetric key)
Folders map[string]string // folder id -> name (decrypted with user symmetric key)
Collections map[string]string // collection id -> name (decrypted with org symmetric key)
}
func emptySyncNameMaps() SyncNameMaps {
return SyncNameMaps{
Organizations: make(map[string]string),
Folders: make(map[string]string),
Collections: make(map[string]string),
}
}
// decryptVaultLabel decrypts a cipher string using the given keys in order.
// decryptVaultLabel decrypts a cipher string using the given keys in order.
// The entityKind and entityID are used for tracing during logging.
func decryptVaultLabel(raw, entityKind, entityID string, keys ...SymmetricKey) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
for _, k := range keys {
if len(k.EncKey) == 0 {
continue
}
out, err := DecryptStr(raw, k)
if err == nil && out != "" {
return out
}
}
// If not a ciphertext, return as-is
// NOTE: This does not mean that the decryption will silently fail and return the raw value if
// the decryption fails during MAC verification (that part still throws an error as it should)
if _, err := ParseCipherString(raw); err != nil {
return raw
}
logger.Warn.Printf("decryptVaultLabel: could not decrypt ciphertext-form %s name (id=%s)", entityKind, entityID)
return ""
}
// buildSyncNameMaps decrypts organization, folder, and collection names from a sync response.
// Organization names are tried with the user symmetric key first, then that org's symmetric key
// Folder names use the user key; collection names use the org key.
func buildSyncNameMaps(syncResp SyncResponse, userKey SymmetricKey, orgKeys map[string]SymmetricKey) SyncNameMaps {
out := emptySyncNameMaps()
for _, org := range syncResp.Profile.Organizations {
if org.ID == "" {
continue
}
var keys []SymmetricKey
keys = append(keys, userKey)
if k, ok := orgKeys[org.ID]; ok {
keys = append(keys, k)
}
name := decryptVaultLabel(org.Name, "organization", org.ID, keys...)
if name == "" {
logger.Debug.Printf("Empty organization display name for org %s (raw len=%d)", org.ID, len(org.Name))
continue
}
out.Organizations[org.ID] = name
}
for _, f := range syncResp.Folders {
if f.ID == "" {
continue
}
name := decryptVaultLabel(f.Name, "folder", f.ID, userKey)
if name == "" {
logger.Debug.Printf("Empty folder name for folder %s", f.ID)
continue
}
out.Folders[f.ID] = name
}
for _, col := range syncResp.Collections {
if col.ID == "" || col.OrganizationID == "" {
continue
}
orgKey, ok := orgKeys[col.OrganizationID]
if !ok {
logger.Debug.Printf("No org key for collection %s (org %s), skipping name decrypt", col.ID, col.OrganizationID)
continue
}
name := decryptVaultLabel(col.Name, "collection", col.ID, orgKey)
if name == "" {
logger.Debug.Printf("Empty collection name for collection %s", col.ID)
continue
}
out.Collections[col.ID] = name
}
return out
}
// LookupIDByName returns an id whose display name equals target (case-insensitive, trimmed).
// If several ids share that name, it returns the lexicographically smallest id.
func LookupIDByName(idToName map[string]string, target string) (id string, ok bool) {
target = strings.TrimSpace(target)
if target == "" || len(idToName) == 0 {
return "", false
}
var matches []string
for id, n := range idToName {
if strings.EqualFold(strings.TrimSpace(n), target) {
matches = append(matches, id)
}
}
if len(matches) == 0 {
return "", false
}
sort.Strings(matches)
return matches[0], true
}
// Sync fetches and decrypts all vault items and returns them along with maps of decrypted
// organization, folder, and collection names.
func (ac *APIClient) Sync() ([]DecryptedItem, SyncNameMaps, error) {
if err := ac.EnsureValidToken(); err != nil {
return nil, fmt.Errorf("ensure valid token: %w", err)
return nil, emptySyncNameMaps(), fmt.Errorf("ensure valid token: %w", err)
}
ac.mu.RLock()
@@ -277,20 +413,24 @@ func (ac *APIClient) Sync() ([]DecryptedItem, error) {
req, err := http.NewRequest("GET", ac.baseURL+"/api/sync", nil)
if err != nil {
return nil, fmt.Errorf("create sync request: %w", err)
return nil, emptySyncNameMaps(), fmt.Errorf("create sync request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := ac.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("sync request: %w", err)
return nil, emptySyncNameMaps(), fmt.Errorf("sync request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
_, _ = io.Copy(io.Discard, resp.Body)
if closeErr := resp.Body.Close(); closeErr != nil {
logger.Warn.Printf("close sync 401 response body: %v", closeErr)
}
// Token might be invalid, try to refresh and retry once.
if err := ac.RefreshAccessToken(); err != nil {
return nil, fmt.Errorf("sync auth failed, refresh failed: %w", err)
return nil, emptySyncNameMaps(), fmt.Errorf("sync auth failed, refresh failed: %w", err)
}
ac.mu.RLock()
token = ac.accessToken
@@ -299,19 +439,23 @@ func (ac *APIClient) Sync() ([]DecryptedItem, error) {
req.Header.Set("Authorization", "Bearer "+token)
resp, err = ac.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("sync retry: %w", err)
return nil, emptySyncNameMaps(), fmt.Errorf("sync retry: %w", err)
}
defer resp.Body.Close()
}
defer func() {
if err := resp.Body.Close(); err != nil {
logger.Warn.Printf("close sync response body: %v", err)
}
}()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("sync failed (HTTP %d): %s", resp.StatusCode, string(body))
return nil, emptySyncNameMaps(), fmt.Errorf("sync failed (HTTP %d): %s", resp.StatusCode, string(body))
}
var syncResp SyncResponse
if err := json.NewDecoder(resp.Body).Decode(&syncResp); err != nil {
return nil, fmt.Errorf("decode sync response: %w", err)
return nil, emptySyncNameMaps(), fmt.Errorf("decode sync response: %w", err)
}
// Decrypt org keys if organizations are present.
@@ -357,19 +501,32 @@ func (ac *APIClient) Sync() ([]DecryptedItem, error) {
}
logger.Info.Printf("Synced and decrypted %d vault items", len(items))
return items, nil
nameMaps := buildSyncNameMaps(syncResp, key, orgKeys)
logger.Info.Printf(
"Synced %d organizations, %d folders, and %d collections",
len(syncResp.Profile.Organizations),
len(syncResp.Folders),
len(syncResp.Collections),
)
return items, nameMaps, nil
}
// DecryptedItem is a decrypted vault item ready for cache lookup.
type DecryptedItem struct {
ID string
Type int
Name string
Username string
Password string
Notes string
URI string
Fields map[string]string
ID string
Type int
Name string
Username string
Password string
Notes string
URI string
Fields map[string]string
OrganizationID string
CollectionIDs []string
FolderID string
}
// decryptCipher decrypts a single vault cipher into a DecryptedItem.
@@ -418,6 +575,16 @@ func decryptCipher(c SyncCipher, key SymmetricKey) (DecryptedItem, error) {
}
}
if c.OrganizationID != nil {
item.OrganizationID = strings.TrimSpace(*c.OrganizationID)
}
if len(c.CollectionIDs) > 0 {
item.CollectionIDs = append([]string(nil), c.CollectionIDs...)
}
if c.FolderID != nil {
item.FolderID = strings.TrimSpace(*c.FolderID)
}
return item, nil
}
+237
View File
@@ -0,0 +1,237 @@
package vaultwarden
import (
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"fmt"
"testing"
)
const (
testOrgID = "11111111-1111-4111-8111-111111111111"
testOrgID2 = "22222222-2222-4222-8222-222222222222"
testFolderID = "33333333-3333-4333-8333-333333333333"
testCollectionID = "44444444-4444-4444-8444-444444444444"
)
// testUserKey generates a test symmetric key for the user.
func testUserKey() SymmetricKey {
encKey := make([]byte, 32)
macKey := make([]byte, 32)
for i := range encKey {
encKey[i] = byte(i)
}
for i := range macKey {
macKey[i] = byte(i + 32)
}
return SymmetricKey{EncKey: encKey, MacKey: macKey}
}
// testOrgKey generates a test symmetric key for the organization.
func testOrgKey() SymmetricKey {
encKey := make([]byte, 32)
macKey := make([]byte, 32)
for i := range encKey {
encKey[i] = byte(i + 64)
}
for i := range macKey {
macKey[i] = byte(i + 96)
}
return SymmetricKey{EncKey: encKey, MacKey: macKey}
}
// encryptType2Cipher builds a Bitwarden type-2 cipher string for unit tests.
func encryptType2Cipher(plaintext string, key SymmetricKey) (string, error) {
data := []byte(plaintext)
padLen := aes.BlockSize - (len(data) % aes.BlockSize)
padded := make([]byte, len(data)+padLen)
copy(padded, data)
for i := len(data); i < len(padded); i++ {
padded[i] = byte(padLen)
}
iv := make([]byte, 16)
for i := range iv {
iv[i] = byte(i + 7)
}
block, err := aes.NewCipher(key.EncKey)
if err != nil {
return "", err
}
ct := make([]byte, len(padded))
cipher.NewCBCEncrypter(block, iv).CryptBlocks(ct, padded)
mac := hmac.New(sha256.New, key.MacKey)
mac.Write(iv)
mac.Write(ct)
macBytes := mac.Sum(nil)
return fmt.Sprintf("2.%s|%s|%s",
base64.StdEncoding.EncodeToString(iv),
base64.StdEncoding.EncodeToString(ct),
base64.StdEncoding.EncodeToString(macBytes),
), nil
}
// mustEncryptType2Cipher encrypts a plaintext string using the given symmetric key and returns the encrypted string.
func mustEncryptType2Cipher(t *testing.T, plaintext string, key SymmetricKey) string {
t.Helper()
s, err := encryptType2Cipher(plaintext, key)
if err != nil {
t.Fatalf("encryptType2Cipher: %v", err)
}
return s
}
func TestEmptySyncNameMaps(t *testing.T) {
t.Parallel()
// Test that the function generates empty maps
m := emptySyncNameMaps()
if m.Organizations == nil || m.Folders == nil || m.Collections == nil {
t.Fatal("expected non-nil maps")
}
if len(m.Organizations)+len(m.Folders)+len(m.Collections) != 0 {
t.Fatal("expected empty maps")
}
// Test that the function generates a new object each time it is called
m.Organizations["x"] = "y"
m2 := emptySyncNameMaps()
if _, ok := m2.Organizations["x"]; ok {
t.Fatal("maps should not be shared between calls")
}
}
func TestDecryptVaultLabel(t *testing.T) {
userKey := testUserKey()
wrongKey := testOrgKey()
encrypted := mustEncryptType2Cipher(t, "Secret Org", userKey)
encryptedNope := mustEncryptType2Cipher(t, "Nope", userKey)
tests := []struct {
name string
raw string
keys []SymmetricKey
want string
}{
// Check that empty input returns empty string
{"empty", "", nil, ""},
// Check that plaintext is passed through as-is
{"plaintext passthrough", "Plain Org", []SymmetricKey{userKey}, "Plain Org"},
// Check that encrypted text is decrypted with the given keys
{"encrypted with user key", encrypted, []SymmetricKey{userKey}, "Secret Org"},
{"ecnrypted try multiple", encrypted, []SymmetricKey{wrongKey, userKey}, "Secret Org"},
// Check that wrong key on ciphertext returns empty string
{"wrong key on ciphertext", encryptedNope, []SymmetricKey{wrongKey}, ""},
// Check that empty keys only returns empty string
{"empty keys only", encrypted, []SymmetricKey{{}, {}}, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := decryptVaultLabel(tt.raw, "organization", testOrgID, tt.keys...)
if got != tt.want {
t.Errorf("decryptVaultLabel() = %q, want %q", got, tt.want)
}
})
}
}
func TestBuildSyncNameMaps(t *testing.T) {
userKey := testUserKey()
orgKey := testOrgKey()
orgKeys := map[string]SymmetricKey{testOrgID: orgKey}
// Create fake sync response containing test orgs, folders, and collections
syncResp := SyncResponse{
Profile: SyncProfile{
Organizations: []SyncOrganization{
{ID: testOrgID, Name: "Plain Org"},
{ID: testOrgID2, Name: mustEncryptType2Cipher(t, "Encrypted Org", userKey)},
},
},
Folders: []SyncFolder{
{ID: testFolderID, Name: mustEncryptType2Cipher(t, "Work", userKey)},
{ID: "00000000-0000-4000-8000-000000000099", Name: mustEncryptType2Cipher(t, "Bad", testOrgKey())},
},
Collections: []SyncCollection{
{ID: testCollectionID, OrganizationID: testOrgID, Name: mustEncryptType2Cipher(t, "Shared", orgKey)},
{ID: "55555555-5555-4555-8555-555555555555", OrganizationID: "99999999-9999-4999-8999-999999999999", Name: "orphan"},
{ID: "", OrganizationID: testOrgID, Name: "skip-empty-id"},
},
}
maps := buildSyncNameMaps(syncResp, userKey, orgKeys)
// Test that the function decrypts the organization names correctly
if got := maps.Organizations[testOrgID]; got != "Plain Org" {
t.Errorf("plain org name = %q, want Plain Org", got)
}
if got := maps.Organizations[testOrgID2]; got != "Encrypted Org" {
t.Errorf("encrypted org name = %q, want Encrypted Org", got)
}
// Test that the function decrypts the folder names correctly
if got := maps.Folders[testFolderID]; got != "Work" {
t.Errorf("folder name = %q, want Work", got)
}
// Test that the function does not include folders with wrong decrypt key
if _, ok := maps.Folders["00000000-0000-4000-8000-000000000099"]; ok {
t.Error("folder with wrong decrypt key should be omitted")
}
// Test that the function decrypts the collection names correctly
if got := maps.Collections[testCollectionID]; got != "Shared" {
t.Errorf("collection name = %q, want Shared", got)
}
// Test that the function does not include collections with empty organization ID
if len(maps.Collections) != 1 {
t.Errorf("collections map len = %d, want 1", len(maps.Collections))
}
}
func TestLookupIDByName(t *testing.T) {
t.Parallel()
// The map to run tests on
idToName := map[string]string{
"bbb-id": "Acme",
"aaa-id": "Acme",
"ccc-id": "Other",
}
tests := []struct {
name string
target string
wantID string
wantOK bool
}{
{"exact match", "Acme", "aaa-id", true},
{"case insensitive and trimmed", " acme ", "aaa-id", true},
{"unknown", "Missing", "", false},
{"empty target", "", "", false},
{"other name", "other", "ccc-id", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
id, ok := LookupIDByName(idToName, tt.target)
if ok != tt.wantOK || id != tt.wantID {
t.Errorf("LookupIDByName(%q) = (%q, %v), want (%q, %v)", tt.target, id, ok, tt.wantID, tt.wantOK)
}
})
}
// Test that empty map returns false
t.Run("empty map", func(t *testing.T) {
t.Parallel()
_, ok := LookupIDByName(nil, "Acme")
if ok {
t.Error("expected false for empty map")
}
})
}
+88 -19
View File
@@ -4,6 +4,7 @@ package vaultwarden
import (
"fmt"
"maps"
"strings"
"sync"
"time"
@@ -18,22 +19,41 @@ type Client struct {
syncEvery time.Duration
mu sync.RWMutex
items map[string]DecryptedItem // keyed by lowercase name
byID map[string]DecryptedItem // keyed by ID
items map[string]DecryptedItem // keyed by cipher id
// nameMaps from the last successful sync (for resolving filter names to UUIDs).
nameMaps SyncNameMaps
stopSync chan struct{}
}
// NewClient creates a new vault client backed by the native API client.
func NewClient(api *APIClient, cacheTTL, syncInterval time.Duration) *Client {
return &Client{
// ClientOption configures NewClient.
type ClientOption func(*Client)
// WithState preloads decrypted items and name maps (e.g. unit tests with api set to nil).
func WithState(items map[string]DecryptedItem, nameMaps SyncNameMaps) ClientOption {
return func(c *Client) {
if items != nil {
c.items = items
}
c.nameMaps = nameMaps
}
}
// NewClient creates a vault client. Pass WithState to preload cache data without calling Initialize.
func NewClient(api *APIClient, cacheTTL, syncInterval time.Duration, opts ...ClientOption) *Client {
c := &Client{
api: api,
cacheTTL: cacheTTL,
syncEvery: syncInterval,
items: make(map[string]DecryptedItem),
byID: make(map[string]DecryptedItem),
nameMaps: emptySyncNameMaps(),
stopSync: make(chan struct{}),
}
for _, opt := range opts {
opt(c)
}
return c
}
// Initialize authenticates and performs the initial vault sync.
@@ -52,9 +72,39 @@ func (c *Client) Initialize() error {
return nil
}
// SecretFilter limits lookup by vault placement. Empty fields are ignored (no constraint).
// Use at most one of organization id vs name, etc., enforced at the HTTP layer.
type SecretFilter struct {
OrganizationID string
CollectionID string
FolderID string
}
func matchesSecretFilter(item DecryptedItem, f SecretFilter) bool {
if f.OrganizationID != "" && !strings.EqualFold(item.OrganizationID, f.OrganizationID) {
return false
}
if f.CollectionID != "" {
found := false
for _, id := range item.CollectionIDs {
if strings.EqualFold(id, f.CollectionID) {
found = true
break
}
}
if !found {
return false
}
}
if f.FolderID != "" && !strings.EqualFold(item.FolderID, f.FolderID) {
return false
}
return true
}
// GetSecret retrieves a decrypted secret by name.
// It searches by exact name (case-insensitive), then falls back to partial match.
func (c *Client) GetSecret(name string) (string, error) {
func (c *Client) GetSecret(name string, filter SecretFilter) (string, error) {
if name == "" {
return "", fmt.Errorf("secret name cannot be empty")
}
@@ -64,13 +114,21 @@ func (c *Client) GetSecret(name string) (string, error) {
key := strings.ToLower(name)
// Exact match.
if item, ok := c.items[key]; ok {
return extractSecret(item), nil
candidates := make([]DecryptedItem, 0, len(c.items))
for _, item := range c.items {
if matchesSecretFilter(item, filter) {
candidates = append(candidates, item)
}
}
// Partial match (search).
for _, item := range c.items {
// Case 1: Exact match.
for _, item := range candidates {
if strings.EqualFold(item.Name, name) {
return extractSecret(item), nil
}
}
// Case 2: Partial match
for _, item := range candidates {
if strings.Contains(strings.ToLower(item.Name), key) {
logger.Debug.Printf("Partial match found for secret lookup")
return extractSecret(item), nil
@@ -92,25 +150,36 @@ func (c *Client) Stop() {
close(c.stopSync)
}
// NameMaps returns a copy of decrypted organization, folder, and collection names
// from the last successful vault sync.
func (c *Client) NameMaps() SyncNameMaps {
c.mu.RLock()
defer c.mu.RUnlock()
return SyncNameMaps{
Organizations: maps.Clone(c.nameMaps.Organizations),
Folders: maps.Clone(c.nameMaps.Folders),
Collections: maps.Clone(c.nameMaps.Collections),
}
}
// syncVault fetches and decrypts all items from the vault.
func (c *Client) syncVault() error {
items, err := c.api.Sync()
items, nameMaps, err := c.api.Sync()
if err != nil {
return err
}
newItems := make(map[string]DecryptedItem, len(items))
newByID := make(map[string]DecryptedItem, len(items))
for _, item := range items {
key := strings.ToLower(item.Name)
newItems[key] = item
newByID[item.ID] = item
if item.ID == "" {
continue
}
newItems[item.ID] = item
}
c.mu.Lock()
c.items = newItems
c.byID = newByID
c.nameMaps = nameMaps
c.mu.Unlock()
return nil
+77
View File
@@ -0,0 +1,77 @@
package vaultwarden
import (
"strings"
"testing"
)
func TestNewClient_withState(t *testing.T) {
items := map[string]DecryptedItem{
"c1": {ID: "c1", Name: "db-password", Password: "pw"},
}
nameMaps := SyncNameMaps{
Organizations: map[string]string{testOrgID: "Acme"},
Folders: map[string]string{},
Collections: map[string]string{},
}
c := NewClient(nil, 0, 0, WithState(items, nameMaps))
val, err := c.GetSecret("db-password", SecretFilter{})
if err != nil || val != "pw" {
t.Fatalf("GetSecret() = (%q, %v), want (pw, nil)", val, err)
}
if got := c.NameMaps().Organizations[testOrgID]; got != "Acme" {
t.Errorf("NameMaps org = %q, want Acme", got)
}
}
func TestMatchesSecretFilter(t *testing.T) {
t.Parallel()
base := DecryptedItem{
ID: "item-1",
OrganizationID: testOrgID,
CollectionIDs: []string{testCollectionID, "66666666-6666-4666-8666-666666666666"},
FolderID: testFolderID,
}
tests := []struct {
name string
filter SecretFilter
want bool
}{
{"empty filter", SecretFilter{}, true},
{"org match", SecretFilter{OrganizationID: testOrgID}, true},
{"org case insensitive", SecretFilter{OrganizationID: strings.ToUpper(testOrgID)}, true},
{"org mismatch", SecretFilter{OrganizationID: testOrgID2}, false},
{"collection match", SecretFilter{CollectionID: testCollectionID}, true},
{"collection case insensitive", SecretFilter{CollectionID: strings.ToUpper(testCollectionID)}, true},
{"collection missing", SecretFilter{CollectionID: "77777777-7777-4777-8777-777777777777"}, false},
{"folder match", SecretFilter{FolderID: testFolderID}, true},
{"folder mismatch", SecretFilter{FolderID: "88888888-8888-4888-8888-888888888888"}, false},
{
"all dimensions match",
SecretFilter{
OrganizationID: testOrgID,
CollectionID: testCollectionID,
FolderID: testFolderID,
},
true,
},
{
"org ok collection fail",
SecretFilter{OrganizationID: testOrgID, CollectionID: "77777777-7777-4777-8777-777777777777"},
false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := matchesSecretFilter(base, tt.filter); got != tt.want {
t.Errorf("matchesSecretFilter() = %v, want %v", got, tt.want)
}
})
}
}