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..2aea4ae 100644 --- a/internal/server/http/http_test.go +++ b/internal/server/http/http_test.go @@ -309,6 +309,21 @@ func TestVaultByNameEndpoint(t *testing.T) { } }) + t.Run("case insensitive", func(t *testing.T) { + rec := get("NOTES") + if rec.Code != http.StatusOK { + t.Fatalf("expected status code %d, got %d (body: %s)", http.StatusOK, rec.Code, rec.Body.String()) + } + + var got store.Vault + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode response: %v (body: %s)", err, rec.Body.String()) + } + if got.Name != "notes" { + t.Errorf("Name = %q, want stored spelling %q", got.Name, "notes") + } + }) + t.Run("not found", func(t *testing.T) { rec := get("does-not-exist") if rec.Code != http.StatusNotFound { diff --git a/internal/server/store/store.go b/internal/server/store/store.go index 00d87ee..db5d990 100644 --- a/internal/server/store/store.go +++ b/internal/server/store/store.go @@ -94,7 +94,19 @@ func (s *Store) CreateVault(ctx context.Context, userID, name string) (Vault, er UpdatedAt: time.Now(), } + var existing string err := s.db.QueryRowContext(ctx, ` + SELECT id FROM vaults WHERE user_id = $1 AND name = $2 COLLATE NOCASE`, + userID, name, + ).Scan(&existing) + if err == nil { + return Vault{}, ErrVaultExists + } + if err != sql.ErrNoRows { + return Vault{}, fmt.Errorf("store: create vault: %w", err) + } + + 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 @@ -115,7 +127,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 name = $2 COLLATE NOCASE", 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 +160,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 name = $2 COLLATE NOCASE", 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..6567df3 100644 --- a/internal/server/store/store_test.go +++ b/internal/server/store/store_test.go @@ -106,6 +106,10 @@ 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") } + + if _, err := s.CreateVault(context.Background(), user.ID, "NOTES"); !errors.Is(err, ErrVaultExists) { + t.Errorf("CreateVault(NOTES) error = %v, want ErrVaultExists", err) + } } func TestCreateVaultSameNameDifferentUsers(t *testing.T) { @@ -167,6 +171,35 @@ func TestVaultByNameScopedToUser(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) + } + + got, err := s.VaultByName(context.Background(), user.ID, "notes") + if err != nil { + t.Fatalf("VaultByName(notes) error: %v", err) + } + if got.ID != created.ID { + t.Errorf("ID = %q, want %q", got.ID, created.ID) + } + if got.Name != "Notes" { + t.Errorf("Name = %q, want stored spelling %q", got.Name, "Notes") + } + + got, err = s.VaultByName(context.Background(), user.ID, "NOTES") + if err != nil { + t.Fatalf("VaultByName(NOTES) error: %v", err) + } + if got.ID != created.ID { + t.Errorf("ID = %q, want %q", got.ID, created.ID) + } +} + func TestListVaults(t *testing.T) { s := newTestStore(t) userA := seedUser(t, s, "a@example.com") @@ -233,6 +266,27 @@ 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(NOTES) error: %v", err) + } + if !deleted { + t.Errorf("DeleteVault(NOTES) = false, want true") + } + + if _, err := s.VaultByName(context.Background(), user.ID, "Notes"); !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")