Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for comma-separated lists to filters, ensure comparison is valid #1634

Merged
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: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ All notable changes to this project will be documented in this file. The format

- Filtering, pagination, and tests for listings endpoint (Parts of Detroit Team [#18](https://github.com/CityOfDetroit/bloom/pull/18), [#133](https://github.com/CityOfDetroit/bloom/pull/133), [#180](https://github.com/CityOfDetroit/bloom/pull/180), [#257](https://github.com/CityOfDetroit/bloom/pull/257), [#264](https://github.com/CityOfDetroit/bloom/pull/264), [#271](https://github.com/CityOfDetroit/bloom/pull/271)) [#1578](https://github.com/CityOfDetroit/bloom/pull/1578)
- Units summary table ([#1607](https://github.com/bloom-housing/bloom/pull/1607))
- Add support for comma-separated lists to filters, ensure comparison is valid ([Detroit Team #356](https://github.com/CityOfDetroit/bloom/pull/356), [#1634](https://github.com/bloom-housing/bloom/pull/1634))

- Changed:

Expand Down
47 changes: 46 additions & 1 deletion backend/core/src/listings/listings.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,32 @@ describe("ListingsService", () => {
)
})

it("should support filters with comma-separated arrays", async () => {
mockListingsRepo.createQueryBuilder
.mockReturnValueOnce(mockInnerQueryBuilder)
.mockReturnValueOnce(mockQueryBuilder)
const expectedNeighborhoodString = "Fox Creek, , Coliseum," // intentional extra and trailing commas for test
// lowercased, trimmed spaces, filtered empty
const expectedNeighborhoodArray = ["fox creek", "coliseum"]

const queryParams: ListingsQueryParams = {
filter: {
$comparison: Compare["IN"],
neighborhood: expectedNeighborhoodString,
},
}

const listings = await service.list(origin, queryParams)

expect(listings.items).toEqual(mockListings)
expect(mockInnerQueryBuilder.andWhere).toHaveBeenCalledWith(
"LOWER(CAST(property.neighborhood as text)) IN (:...neighborhood_0)",
{
neighborhood_0: expectedNeighborhoodArray,
}
)
})

it("should throw an exception if an unsupported filter is used", async () => {
mockListingsRepo.createQueryBuilder.mockReturnValueOnce(mockInnerQueryBuilder)

Expand All @@ -116,7 +142,7 @@ describe("ListingsService", () => {
$comparison: Compare["="],
otherField: "otherField",
// The querystring can contain unknown fields that aren't on the
// ListingFilterParams type, so we force it to the type for testing
// ListingFilterParams type, so we force it to the type for testing.
} as ListingFilterParams,
}

Expand All @@ -125,6 +151,25 @@ describe("ListingsService", () => {
)
})

//TODO(avaleske): A lot of these tests should be moved to a spec file specific to the filters code.
it("should throw an exception if an unsupported comparison is used", async () => {
mockListingsRepo.createQueryBuilder.mockReturnValueOnce(mockInnerQueryBuilder)

const queryParams: ListingsQueryParams = {
filter: {
// The value of the filter[$comparison] query param is not validated,
// and the type system trusts that whatever is provided is correct,
// so we force it to an invalid type for testing.
$comparison: "); DROP TABLE Students;" as Compare,
name: "test name",
} as ListingFilterParams,
}

await expect(service.list(origin, queryParams)).rejects.toThrow(
new HttpException("Comparison Not Implemented", HttpStatus.NOT_IMPLEMENTED)
)
})

it("should not call limit() and offset() if pagination params are not specified", async () => {
mockListingsRepo.createQueryBuilder
.mockReturnValueOnce(mockInnerQueryBuilder)
Expand Down
1 change: 1 addition & 0 deletions backend/core/src/shared/dto/filter.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Expose } from "class-transformer"
export enum Compare {
"=" = "=",
"<>" = "<>",
"IN" = "IN",
}

export class BaseFilter {
Expand Down
40 changes: 32 additions & 8 deletions backend/core/src/shared/filter/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { HttpException, HttpStatus } from "@nestjs/common"
import { WhereExpression } from "typeorm"
import { Compare } from "../dto/filter.dto"

/**
*
Expand Down Expand Up @@ -57,14 +58,37 @@ export function addFilters<FilterParams, FilterFieldMap>(
values.forEach((val: string, i: number) => {
// Each WHERE param must be unique across the entire QueryBuilder
const whereParameterName = `${filterType}_${i}`
qb.andWhere(
`LOWER(CAST(${filterTypeToFieldMap[filterType.toLowerCase()]} as text)) ${
comparisonsForCurrentFilter[i]
} LOWER(:${whereParameterName})`,
{
[whereParameterName]: val,
}
)

const comparison = comparisonsForCurrentFilter[i]
// Explicitly check for allowed comparisons, to prevent SQL injections
switch (comparison) {
case Compare.IN:
qb.andWhere(
`LOWER(CAST(${
filterTypeToFieldMap[filterType.toLowerCase()]
} as text)) IN (:...${whereParameterName})`,
{
[whereParameterName]: val
.split(",")
.map((s) => s.trim().toLowerCase())
.filter((s) => s.length !== 0),
}
)
break
case Compare["<>"]:
case Compare["="]:
qb.andWhere(
`LOWER(CAST(${
filterTypeToFieldMap[filterType.toLowerCase()]
} as text)) ${comparison} LOWER(:${whereParameterName})`,
{
[whereParameterName]: val,
}
)
break
default:
throw new HttpException("Comparison Not Implemented", HttpStatus.NOT_IMPLEMENTED)
}
})
}
}
Expand Down