Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion internal/server/http/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,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)
Expand Down
44 changes: 44 additions & 0 deletions internal/server/http/http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
10 changes: 6 additions & 4 deletions internal/server/store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,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, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id, name) DO NOTHING
SELECT $1, $2, $3, $4, $5
WHERE NOT EXISTS (
SELECT 1 FROM vaults WHERE user_id = $2 AND LOWER(name) = LOWER($3)
)
RETURNING id`,
vault.ID, userID, name, vault.CreatedAt, vault.UpdatedAt,
).Scan(&vault.ID)
Expand All @@ -96,7 +98,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, created_at, updated_at FROM vaults WHERE user_id = $1 AND name = $2", userID, name).Scan(&vault.ID, &vault.Name, &vault.CreatedAt, &vault.UpdatedAt)
err := s.db.QueryRowContext(ctx, "SELECT id, name, created_at, updated_at FROM vaults WHERE user_id = $1 AND LOWER(name) = LOWER($2)", userID, name).Scan(&vault.ID, &vault.Name, &vault.CreatedAt, &vault.UpdatedAt)
if err != nil {
if err == sql.ErrNoRows {
return Vault{}, ErrVaultNotFound
Expand Down Expand Up @@ -129,7 +131,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)
}
Expand Down
77 changes: 77 additions & 0 deletions internal/server/store/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,24 @@ 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")

if _, err := s.CreateVault(context.Background(), user.ID, "Notes"); 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)
}
}

Expand All @@ -80,6 +98,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")
Expand All @@ -101,6 +132,30 @@ 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")
}
}
}

func TestVaultByNameNotFound(t *testing.T) {
s := newTestStore(t)
user := seedUser(t, s, "a@example.com")
Expand Down Expand Up @@ -192,6 +247,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")
Expand Down