diff --git a/internal/client/cli/commands/pull.go b/internal/client/cli/commands/pull.go index 378a258..9c35c95 100644 --- a/internal/client/cli/commands/pull.go +++ b/internal/client/cli/commands/pull.go @@ -36,35 +36,36 @@ func NewPullCmd() *cobra.Command { if !ok { return errors.New(color.RedString("Vault %q is not registered locally, run `potok vault-add %s` first", name, name)) } + storedName := v.Name apiKey, err := secrets.Get(secrets.APIKey) if err != nil { return errors.New(color.RedString("Error getting API key: %v", err)) } - passphrase, err := secrets.Get(secrets.VaultKeyName(name)) + passphrase, err := secrets.Get(secrets.VaultKeyName(storedName)) if err != nil { - return errors.New(color.RedString("No stored passphrase for vault %q: %v", name, err)) + return errors.New(color.RedString("No stored passphrase for vault %q: %v", storedName, err)) } client := transport.New(cfg.ServerURL, apiKey) - remote, found, err := client.FetchVault(name) + remote, found, err := client.FetchVault(storedName) if err != nil { return err } if !found { - return errors.New(color.RedString("Vault %q has not been pushed to the server yet", name)) + return errors.New(color.RedString("Vault %q has not been pushed to the server yet", storedName)) } key := crypto.DeriveKey([]byte(passphrase), remote.KDFSalt) - manifestCiphertext, found, err := client.FetchManifest(name) + manifestCiphertext, found, err := client.FetchManifest(storedName) if err != nil { return err } if !found { - fmt.Println(color.YellowString("Vault %q has no manifest yet - nothing to pull.", name)) + fmt.Println(color.YellowString("Vault %q has no manifest yet - nothing to pull.", storedName)) return nil } @@ -79,7 +80,7 @@ func NewPullCmd() *cobra.Command { } for _, f := range manifest.Files { - ciphertext, err := client.DownloadBlob(name, f.BlobID) + ciphertext, err := client.DownloadBlob(storedName, f.BlobID) if err != nil { return fmt.Errorf("failed to download %s: %w", f.Path, err) } @@ -98,7 +99,7 @@ func NewPullCmd() *cobra.Command { } } - fmt.Println(color.GreenString("Pulled %d file(s) into vault %q.", len(manifest.Files), name)) + fmt.Println(color.GreenString("Pulled %d file(s) into vault %q.", len(manifest.Files), storedName)) return nil }, } diff --git a/internal/client/cli/commands/push.go b/internal/client/cli/commands/push.go index feb567f..e53516e 100644 --- a/internal/client/cli/commands/push.go +++ b/internal/client/cli/commands/push.go @@ -37,20 +37,21 @@ func NewPushCmd() *cobra.Command { if !ok { return errors.New(color.RedString("Vault %q is not registered locally, run `potok vault-add %s` first", name, name)) } + storedName := v.Name apiKey, err := secrets.Get(secrets.APIKey) if err != nil { return errors.New(color.RedString("Error getting API key: %v", err)) } - passphrase, err := secrets.Get(secrets.VaultKeyName(name)) + passphrase, err := secrets.Get(secrets.VaultKeyName(storedName)) if err != nil { - return errors.New(color.RedString("No stored passphrase for vault %q: %v", name, err)) + return errors.New(color.RedString("No stored passphrase for vault %q: %v", storedName, err)) } client := transport.New(cfg.ServerURL, apiKey) - remote, err := client.GetOrCreateVault(name) + remote, err := client.GetOrCreateVault(storedName) if err != nil { return err } @@ -75,7 +76,7 @@ func NewPushCmd() *cobra.Command { return fmt.Errorf("failed to encrypt %s: %w", f.Path, err) } - blobID, err := client.UploadBlob(name, ciphertext) + blobID, err := client.UploadBlob(storedName, ciphertext) if err != nil { return fmt.Errorf("failed to upload %s: %w", f.Path, err) } @@ -98,19 +99,19 @@ func NewPushCmd() *cobra.Command { return fmt.Errorf("failed to encrypt manifest: %w", err) } - generation, err := client.CurrentManifestGeneration(name) + generation, err := client.CurrentManifestGeneration(storedName) if err != nil { return err } - if err := client.PutManifest(name, generation, manifestCiphertext); err != nil { + if err := client.PutManifest(storedName, generation, manifestCiphertext); err != nil { if errors.Is(err, transport.ErrConflict) { return errors.New(color.RedString(transport.ErrConflict.Error())) } return err } - fmt.Println(color.GreenString("Pushed %d file(s) from vault %q.", len(files), name)) + fmt.Println(color.GreenString("Pushed %d file(s) from vault %q.", len(files), storedName)) return nil }, } diff --git a/internal/client/cli/commands/push_pull_test.go b/internal/client/cli/commands/push_pull_test.go index fab7137..386ba50 100644 --- a/internal/client/cli/commands/push_pull_test.go +++ b/internal/client/cli/commands/push_pull_test.go @@ -216,6 +216,49 @@ func TestPushThenPullWrongPassphraseFails(t *testing.T) { } } +func TestPushThenPullCaseInsensitiveVaultName(t *testing.T) { + client := newTestServer(t) + + source := t.TempDir() + writeVaultFile(t, source, "note.md", []byte("hello, potok")) + + pushVault(t, client, "Notes", source, "hunter2") + + created, found, err := client.FetchVault("NOTES") + if err != nil { + t.Fatalf("FetchVault(NOTES): %v", err) + } + if !found { + t.Fatal("FetchVault(NOTES) not found") + } + if created.Name != "Notes" { + t.Errorf("FetchVault(NOTES).Name = %q, want stored casing %q", created.Name, "Notes") + } + + again, err := client.GetOrCreateVault("notes") + if err != nil { + t.Fatalf("GetOrCreateVault(notes) after Notes exists: %v", err) + } + if again.ID != created.ID { + t.Errorf("GetOrCreateVault(notes) created a new vault; ID = %q, want %q", again.ID, created.ID) + } + if again.Name != "Notes" { + t.Errorf("GetOrCreateVault(notes).Name = %q, want original %q", again.Name, "Notes") + } + + dest := t.TempDir() + if err := pullVault(client, "NOTES", dest, "hunter2"); err != nil { + t.Fatalf("pullVault(NOTES): %v", err) + } + content, err := os.ReadFile(filepath.Join(dest, "note.md")) + if err != nil { + t.Fatalf("read pulled note.md: %v", err) + } + if string(content) != "hello, potok" { + t.Errorf("pulled content = %q, want %q", content, "hello, potok") + } +} + func TestPushCreatesRemoteVaultOnFirstPush(t *testing.T) { client := newTestServer(t) diff --git a/internal/client/cli/commands/vault_remove.go b/internal/client/cli/commands/vault_remove.go index 726e7d1..853f2f7 100644 --- a/internal/client/cli/commands/vault_remove.go +++ b/internal/client/cli/commands/vault_remove.go @@ -26,19 +26,25 @@ func NewVaultRemoveCmd() *cobra.Command { return err } - if !cfg.RemoveVault(name) { + v, ok := cfg.Vault(name) + if !ok { return errors.New(color.RedString("Vault %q is not registered locally", name)) } + storedName := v.Name - if err := secrets.Delete(secrets.VaultKeyName(name)); err != nil && !errors.Is(err, secrets.ErrNotFound) { - return fmt.Errorf("failed to delete stored passphrase for vault %q: %w", name, err) + if !cfg.RemoveVault(storedName) { + return errors.New(color.RedString("Vault %q is not registered locally", name)) + } + + if err := secrets.Delete(secrets.VaultKeyName(storedName)); err != nil && !errors.Is(err, secrets.ErrNotFound) { + return fmt.Errorf("failed to delete stored passphrase for vault %q: %w", storedName, err) } if err := config.Save(cfg); err != nil { return err } - fmt.Println(color.GreenString("Removed vault %q and its stored passphrase.", name)) + fmt.Println(color.GreenString("Removed vault %q and its stored passphrase.", storedName)) return nil }, } diff --git a/internal/client/cli/commands/vault_remove_test.go b/internal/client/cli/commands/vault_remove_test.go index 65cb5f3..d36f46e 100644 --- a/internal/client/cli/commands/vault_remove_test.go +++ b/internal/client/cli/commands/vault_remove_test.go @@ -69,6 +69,30 @@ func TestVaultRemoveDeletesStoredPassphrase(t *testing.T) { } } +func TestVaultRemoveMatchesNameCaseInsensitively(t *testing.T) { + setupVaultRemove(t) + saveConfigWithVault(t) + + if err := secrets.Set(secrets.VaultKeyName("notes"), "supersecret"); err != nil { + t.Fatalf("secrets.Set() = %v", err) + } + + if err := executeCmd(t, NewVaultRemoveCmd(), "NOTES"); err != nil { + t.Fatalf("vault-remove NOTES = %v", err) + } + + cfg, err := config.Load() + if err != nil { + t.Fatalf("config.Load() = %v", err) + } + if len(cfg.Vaults) != 0 { + t.Errorf("Vaults = %+v, want none after case-insensitive removal", cfg.Vaults) + } + if _, err := secrets.Get(secrets.VaultKeyName("notes")); !errors.Is(err, secrets.ErrNotFound) { + t.Errorf("secrets.Get() after case-insensitive remove = %v, want ErrNotFound", err) + } +} + func TestVaultRemoveFailsForUnknownVault(t *testing.T) { setupVaultRemove(t) saveConfigWithVault(t) diff --git a/internal/client/config/config.go b/internal/client/config/config.go index 1e5eb91..e4b21e5 100644 --- a/internal/client/config/config.go +++ b/internal/client/config/config.go @@ -141,10 +141,11 @@ func (c *Config) Validate() error { if err := ValidateVaultName(v.Name); err != nil { return err } - if _, dup := seen[v.Name]; dup { + key := strings.ToLower(v.Name) + if _, dup := seen[key]; dup { return fmt.Errorf("config: vault %q is registered twice", v.Name) } - seen[v.Name] = struct{}{} + seen[key] = struct{}{} if !filepath.IsAbs(v.Path) { return fmt.Errorf("config: vault %q needs an absolute path, got %q", v.Name, v.Path) @@ -171,7 +172,7 @@ func ValidateVaultName(name string) error { func (c *Config) Vault(name string) (*Vault, bool) { for i := range c.Vaults { - if c.Vaults[i].Name == name { + if vaultNameEqual(c.Vaults[i].Name, name) { return &c.Vaults[i], true } } @@ -194,10 +195,14 @@ func (c *Config) AddVault(v Vault) error { func (c *Config) RemoveVault(name string) bool { for i := range c.Vaults { - if c.Vaults[i].Name == name { + if vaultNameEqual(c.Vaults[i].Name, name) { c.Vaults = append(c.Vaults[:i], c.Vaults[i+1:]...) return true } } return false } + +func vaultNameEqual(a, b string) bool { + return strings.EqualFold(a, b) +} diff --git a/internal/client/config/config_test.go b/internal/client/config/config_test.go index 21db7ce..76e82aa 100644 --- a/internal/client/config/config_test.go +++ b/internal/client/config/config_test.go @@ -125,16 +125,17 @@ func TestValidate(t *testing.T) { cfg Config wantErr bool }{ - "zero value": {cfg: Config{}}, - "valid": {cfg: Config{ServerURL: "http://localhost:8080", Vaults: []Vault{{Name: "notes", Path: "/home/user/notes"}}}}, - "bad scheme": {cfg: Config{ServerURL: "ftp://example.com"}, wantErr: true}, - "no host": {cfg: Config{ServerURL: "https://"}, wantErr: true}, - "relative path": {cfg: Config{Vaults: []Vault{{Name: "notes", Path: "notes"}}}, wantErr: true}, - "duplicate names": {cfg: Config{Vaults: []Vault{{Name: "a", Path: "/a"}, {Name: "a", Path: "/b"}}}, wantErr: true}, - "empty name": {cfg: Config{Vaults: []Vault{{Name: "", Path: "/a"}}}, wantErr: true}, - "name with space": {cfg: Config{Vaults: []Vault{{Name: "my notes", Path: "/a"}}}, wantErr: true}, - "name with separator": {cfg: Config{Vaults: []Vault{{Name: "work/notes", Path: "/a"}}}, wantErr: true}, - "name starts with -": {cfg: Config{Vaults: []Vault{{Name: "-force", Path: "/a"}}}, wantErr: true}, + "zero value": {cfg: Config{}}, + "valid": {cfg: Config{ServerURL: "http://localhost:8080", Vaults: []Vault{{Name: "notes", Path: "/home/user/notes"}}}}, + "bad scheme": {cfg: Config{ServerURL: "ftp://example.com"}, wantErr: true}, + "no host": {cfg: Config{ServerURL: "https://"}, wantErr: true}, + "relative path": {cfg: Config{Vaults: []Vault{{Name: "notes", Path: "notes"}}}, wantErr: true}, + "duplicate names": {cfg: Config{Vaults: []Vault{{Name: "a", Path: "/a"}, {Name: "a", Path: "/b"}}}, wantErr: true}, + "duplicate names case": {cfg: Config{Vaults: []Vault{{Name: "Notes", Path: "/a"}, {Name: "notes", Path: "/b"}}}, wantErr: true}, + "empty name": {cfg: Config{Vaults: []Vault{{Name: "", Path: "/a"}}}, wantErr: true}, + "name with space": {cfg: Config{Vaults: []Vault{{Name: "my notes", Path: "/a"}}}, wantErr: true}, + "name with separator": {cfg: Config{Vaults: []Vault{{Name: "work/notes", Path: "/a"}}}, wantErr: true}, + "name starts with -": {cfg: Config{Vaults: []Vault{{Name: "-force", Path: "/a"}}}, wantErr: true}, } for name, tc := range tests { t.Run(name, func(t *testing.T) { @@ -154,6 +155,9 @@ func TestAddVault(t *testing.T) { if err := cfg.AddVault(Vault{Name: "notes", Path: "/elsewhere"}); err == nil { t.Error("AddVault() = nil, want an error for a duplicate name") } + if err := cfg.AddVault(Vault{Name: "NOTES", Path: "/elsewhere"}); err == nil { + t.Error("AddVault() = nil, want an error for a case-insensitive duplicate name") + } if err := cfg.AddVault(Vault{Name: "work", Path: "relative"}); err == nil { t.Error("AddVault() = nil, want an error for a relative path") } @@ -176,6 +180,17 @@ func TestRemoveVault(t *testing.T) { } } +func TestRemoveVaultCaseInsensitive(t *testing.T) { + cfg := &Config{Vaults: []Vault{{Name: "Notes", Path: "/a"}, {Name: "work", Path: "/b"}}} + + if !cfg.RemoveVault("NOTES") { + t.Error("RemoveVault(NOTES) = false, want true") + } + if len(cfg.Vaults) != 1 || cfg.Vaults[0].Name != "work" { + t.Errorf("Vaults = %+v, want work only", cfg.Vaults) + } +} + func TestVaultReturnsPointer(t *testing.T) { cfg := &Config{Vaults: []Vault{{Name: "notes", Path: "/a"}}} @@ -193,6 +208,29 @@ func TestVaultReturnsPointer(t *testing.T) { } } +func TestVaultCaseInsensitive(t *testing.T) { + cfg := &Config{Vaults: []Vault{{Name: "Notes", Path: "/a"}}} + + for _, name := range []string{"Notes", "notes", "NOTES", "nOtEs"} { + v, ok := cfg.Vault(name) + if !ok { + t.Fatalf("Vault(%q) not found", name) + } + if v.Name != "Notes" { + t.Errorf("Vault(%q).Name = %q, want stored casing %q", name, v.Name, "Notes") + } + } + + v, ok := cfg.Vault("NOTES") + if !ok { + t.Fatal("Vault(NOTES) not found") + } + v.RemoteID = "vault_123" + if cfg.Vaults[0].RemoteID != "vault_123" { + t.Error("Vault() returned a copy; updates would be lost") + } +} + func TestDirPrecedence(t *testing.T) { t.Setenv("POTOK_CONFIG_DIR", "/custom/potok") t.Setenv("XDG_CONFIG_HOME", "/xdg") diff --git a/internal/server/http/http.go b/internal/server/http/http.go index 2a155a4..b6bbb14 100644 --- a/internal/server/http/http.go +++ b/internal/server/http/http.go @@ -152,7 +152,6 @@ func (h *Handler) VaultByName(w http.ResponseWriter, r *http.Request) { return } - // TODO: Implement case insensitive name matching (will need modifiying in store) name := r.PathValue("name") if name == "" { http.Error(w, "name is required", http.StatusBadRequest) diff --git a/internal/server/http/http_test.go b/internal/server/http/http_test.go index 497ee9e..3344ff1 100644 --- a/internal/server/http/http_test.go +++ b/internal/server/http/http_test.go @@ -157,6 +157,12 @@ func TestCreateVaultEndpoint(t *testing.T) { input: `{"name":"notes"}`, wantStatus: http.StatusConflict, }, + { + name: "duplicate name different case", + seed: `{"name":"Notes"}`, + input: `{"name":"notes"}`, + wantStatus: http.StatusConflict, + }, } for _, test := range tests { @@ -309,6 +315,26 @@ func TestVaultByNameEndpoint(t *testing.T) { } }) + t.Run("found case insensitive", func(t *testing.T) { + for _, name := range []string{"notes", "NOTES", "Notes"} { + rec := get(name) + if rec.Code != http.StatusOK { + t.Errorf("GET %q: expected status code %d, got %d (body: %s)", + name, http.StatusOK, rec.Code, rec.Body.String()) + continue + } + + var got store.Vault + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Errorf("GET %q: decode response: %v (body: %s)", name, err, rec.Body.String()) + continue + } + if got.Name != "notes" { + t.Errorf("GET %q: Name = %q, want %q (stored casing)", name, got.Name, "notes") + } + } + }) + t.Run("not found", func(t *testing.T) { rec := get("does-not-exist") if rec.Code != http.StatusNotFound { @@ -379,6 +405,24 @@ func TestDeleteVaultEndpoint(t *testing.T) { } }) + t.Run("deletes matching name case insensitively", func(t *testing.T) { + if _, err := s.CreateVault(context.Background(), user.ID, "Journal"); err != nil { + t.Fatalf("seed CreateVault: %v", err) + } + + rec := del("JOURNAL") + if rec.Code != http.StatusOK { + t.Fatalf("expected status code %d, got %d (body: %s)", http.StatusOK, rec.Code, rec.Body.String()) + } + if got, want := rec.Body.String(), "Vault deleted"; got != want { + t.Errorf("body = %q, want %q", got, want) + } + + if _, err := s.VaultByName(context.Background(), user.ID, "journal"); !errors.Is(err, store.ErrVaultNotFound) { + t.Errorf("VaultByName() after case-insensitive delete = %v, want ErrVaultNotFound", err) + } + }) + t.Run("deleting a nonexistent vault is idempotent but reports nothing was deleted", func(t *testing.T) { rec := del("does-not-exist") if rec.Code != http.StatusOK { diff --git a/internal/server/store/store.go b/internal/server/store/store.go index 00d87ee..c11340d 100644 --- a/internal/server/store/store.go +++ b/internal/server/store/store.go @@ -96,8 +96,10 @@ func (s *Store) CreateVault(ctx context.Context, userID, name string) (Vault, er err := s.db.QueryRowContext(ctx, ` INSERT INTO vaults (id, user_id, name, kdf_salt, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6) - ON CONFLICT (user_id, name) DO NOTHING + SELECT $1, $2, $3, $4, $5, $6 + WHERE NOT EXISTS ( + SELECT 1 FROM vaults WHERE user_id = $2 AND LOWER(name) = LOWER($3) + ) RETURNING id`, vault.ID, userID, name, vault.KDFSalt, vault.CreatedAt, vault.UpdatedAt, ).Scan(&vault.ID) @@ -115,7 +117,7 @@ func (s *Store) CreateVault(ctx context.Context, userID, name string) (Vault, er func (s *Store) VaultByName(ctx context.Context, userID, name string) (Vault, error) { var vault Vault - err := s.db.QueryRowContext(ctx, "SELECT id, name, kdf_salt, created_at, updated_at FROM vaults WHERE user_id = $1 AND name = $2", userID, name).Scan(&vault.ID, &vault.Name, &vault.KDFSalt, &vault.CreatedAt, &vault.UpdatedAt) + err := s.db.QueryRowContext(ctx, "SELECT id, name, kdf_salt, created_at, updated_at FROM vaults WHERE user_id = $1 AND LOWER(name) = LOWER($2)", userID, name).Scan(&vault.ID, &vault.Name, &vault.KDFSalt, &vault.CreatedAt, &vault.UpdatedAt) if err != nil { if err == sql.ErrNoRows { return Vault{}, ErrVaultNotFound @@ -148,7 +150,7 @@ func (s *Store) ListVaults(ctx context.Context, userID string) ([]Vault, error) } func (s *Store) DeleteVault(ctx context.Context, userID, name string) (bool, error) { - res, err := s.db.ExecContext(ctx, "DELETE FROM vaults WHERE user_id = $1 AND name = $2", userID, name) + res, err := s.db.ExecContext(ctx, "DELETE FROM vaults WHERE user_id = $1 AND LOWER(name) = LOWER($2)", userID, name) if err != nil { return false, fmt.Errorf("store: delete vault: %w", err) } diff --git a/internal/server/store/store_test.go b/internal/server/store/store_test.go index 473eca0..a35c8e1 100644 --- a/internal/server/store/store_test.go +++ b/internal/server/store/store_test.go @@ -105,6 +105,36 @@ func TestCreateVaultDuplicateName(t *testing.T) { if _, err := s.CreateVault(context.Background(), user.ID, "notes"); err == nil { t.Error("CreateVault() with a name the user already has expected an error, got nil") + } else if !errors.Is(err, ErrVaultExists) { + t.Errorf("CreateVault() error = %v, want ErrVaultExists", err) + } +} + +func TestCreateVaultDuplicateNameDifferentCase(t *testing.T) { + s := newTestStore(t) + user := seedUser(t, s, "a@example.com") + + created, err := s.CreateVault(context.Background(), user.ID, "Notes") + if err != nil { + t.Fatalf("seed CreateVault: %v", err) + } + + if _, err := s.CreateVault(context.Background(), user.ID, "notes"); !errors.Is(err, ErrVaultExists) { + t.Errorf("CreateVault() with a case-insensitive duplicate = %v, want ErrVaultExists", err) + } + if _, err := s.CreateVault(context.Background(), user.ID, "NOTES"); !errors.Is(err, ErrVaultExists) { + t.Errorf("CreateVault() with a case-insensitive duplicate = %v, want ErrVaultExists", err) + } + + got, err := s.VaultByName(context.Background(), user.ID, "notes") + if err != nil { + t.Fatalf("VaultByName() after rejected duplicates: %v", err) + } + if got.Name != "Notes" { + t.Errorf("stored Name = %q, want original casing %q", got.Name, "Notes") + } + if got.ID != created.ID { + t.Errorf("ID = %q, want the original vault %q", got.ID, created.ID) } } @@ -121,6 +151,19 @@ func TestCreateVaultSameNameDifferentUsers(t *testing.T) { } } +func TestCreateVaultSameNameDifferentCaseDifferentUsers(t *testing.T) { + s := newTestStore(t) + userA := seedUser(t, s, "a@example.com") + userB := seedUser(t, s, "b@example.com") + + if _, err := s.CreateVault(context.Background(), userA.ID, "notes"); err != nil { + t.Fatalf("CreateVault(userA): %v", err) + } + if _, err := s.CreateVault(context.Background(), userB.ID, "NOTES"); err != nil { + t.Errorf("CreateVault(userB) with a different-case name as userA's vault: %v", err) + } +} + func TestVaultByName(t *testing.T) { s := newTestStore(t) user := seedUser(t, s, "a@example.com") @@ -142,6 +185,33 @@ func TestVaultByName(t *testing.T) { } } +func TestVaultByNameCaseInsensitive(t *testing.T) { + s := newTestStore(t) + user := seedUser(t, s, "a@example.com") + + created, err := s.CreateVault(context.Background(), user.ID, "Notes") + if err != nil { + t.Fatalf("seed CreateVault: %v", err) + } + + for _, name := range []string{"Notes", "notes", "NOTES", "nOtEs"} { + got, err := s.VaultByName(context.Background(), user.ID, name) + if err != nil { + t.Errorf("VaultByName(%q) error: %v", name, err) + continue + } + if got.ID != created.ID { + t.Errorf("VaultByName(%q) ID = %q, want %q", name, got.ID, created.ID) + } + if got.Name != "Notes" { + t.Errorf("VaultByName(%q) Name = %q, want %q (stored casing)", name, got.Name, "Notes") + } + if !bytes.Equal(got.KDFSalt, created.KDFSalt) { + t.Errorf("VaultByName(%q) KDFSalt = %x, want %x", name, got.KDFSalt, created.KDFSalt) + } + } +} + func TestVaultByNameNotFound(t *testing.T) { s := newTestStore(t) user := seedUser(t, s, "a@example.com") @@ -233,6 +303,28 @@ func TestDeleteVault(t *testing.T) { } } +func TestDeleteVaultCaseInsensitive(t *testing.T) { + s := newTestStore(t) + user := seedUser(t, s, "a@example.com") + + if _, err := s.CreateVault(context.Background(), user.ID, "Notes"); err != nil { + t.Fatalf("seed CreateVault: %v", err) + } + + deleted, err := s.DeleteVault(context.Background(), user.ID, "NOTES") + if err != nil { + t.Fatalf("DeleteVault() error: %v", err) + } + if !deleted { + t.Errorf("DeleteVault() = false, want true") + } + + _, err = s.VaultByName(context.Background(), user.ID, "notes") + if !errors.Is(err, ErrVaultNotFound) { + t.Errorf("VaultByName() after case-insensitive delete = %v, want ErrVaultNotFound", err) + } +} + func TestDeleteVaultScopedToUser(t *testing.T) { s := newTestStore(t) userA := seedUser(t, s, "a@example.com")