Skip to content

Commit c431d0f

Browse files
777arcMarc Lichtman
andauthored
Block search calls that dont include a collection in the body or query param (#240)
* Block search calls that dont include a collection in the body or query param * also allow collectionid to be in the filter * switch to orjson --------- Co-authored-by: Marc Lichtman <[email protected]>
1 parent 8602de8 commit c431d0f

File tree

3 files changed

+111
-19
lines changed

3 files changed

+111
-19
lines changed

README.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,16 +103,21 @@ To run the servers, use
103103
./scripts/server
104104
```
105105

106-
This will bring up the development database, STAC API, Tiler, Azure Functions, and other services.
106+
This will bring up the development database, STAC API, Tiler, Azure Functions, and other services. If at this point something errors out (e.g. nginx complaining about a config file), try deleting the containers/images and rerunning `./scripts/setup`.
107107

108-
To test the tiler, try going to <http://localhost:8080/data/mosaic/info?collection=naip>.
108+
The STAC API can be found at <http://localhost:8080/stac/> (goes through nginx) or <http://localhost:8081> directly.
109+
110+
To hit the tiler, try going to <http://localhost:8080/data/mosaic/info?collection=naip>, although it will fail due to lack of an authorization header.
109111

110112
#### Testing and and formatting
111113

112-
To run tests, use
114+
To run tests, use one of the following (note, you don't need `./scripts/server` running). If you get an immediate error related to library stubs, just run it again. The tiler tests may fail locally, TBD why.
113115

114116
```console
115117
./scripts/test
118+
./scripts/test --stac
119+
./scripts/test --tiler
120+
./scripts/test --common
116121
```
117122

118123
To format code, use

pcstac/pcstac/client.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
from urllib.parse import urljoin
55

66
import attr
7-
from fastapi import Request
7+
import orjson
8+
from fastapi import HTTPException, Request
89
from stac_fastapi.pgstac.core import CoreCrudClient
910
from stac_fastapi.types.errors import NotFoundError
1011
from stac_fastapi.types.stac import (
@@ -215,7 +216,17 @@ async def _fetch() -> ItemCollection:
215216
)
216217
return item_collection
217218

219+
# Block searches that don't specify a collection
220+
if (
221+
search_request.collections is None
222+
and "collection=" not in str(request.url)
223+
and '{"property":"collection"}'
224+
not in orjson.dumps(search_request.filter).decode("utf-8")
225+
):
226+
raise HTTPException(status_code=422, detail="collection is required")
227+
218228
search_json = search_request.model_dump_json()
229+
219230
add_stac_attributes_from_search(search_json, request)
220231

221232
logger.info(

pcstac/tests/resources/test_item.py

Lines changed: 91 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,7 @@ async def test_item_search_bbox_get(app_client):
223223
assert resp_json["features"][0]["id"] == first_item["id"]
224224

225225

226+
# @pytest.mark.skip(reason="TODO")
226227
@pytest.mark.asyncio
227228
async def test_item_search_get_without_collections(app_client):
228229
"""Test GET search without specifying collections"""
@@ -234,9 +235,7 @@ async def test_item_search_get_without_collections(app_client):
234235
"bbox": ",".join([str(coord) for coord in first_item["bbox"]]),
235236
}
236237
resp = await app_client.get("/search", params=params)
237-
assert resp.status_code == 200
238-
resp_json = resp.json()
239-
assert resp_json["features"][0]["id"] == first_item["id"]
238+
assert resp.status_code == 422 # Unprocessable Content
240239

241240

242241
@pytest.mark.asyncio
@@ -299,9 +298,7 @@ async def test_item_search_post_without_collection(app_client):
299298
"bbox": first_item["bbox"],
300299
}
301300
resp = await app_client.post("/search", json=params)
302-
assert resp.status_code == 200
303-
resp_json = resp.json()
304-
assert resp_json["features"][0]["id"] == first_item["id"]
301+
assert resp.status_code == 422 # Unprocessable Content
305302

306303

307304
@pytest.mark.asyncio
@@ -313,7 +310,10 @@ async def test_item_search_properties_jsonb(app_client):
313310
first_item = items_resp.json()["features"][0]
314311

315312
# EPSG is a JSONB key
316-
params = {"query": {"proj:epsg": {"eq": first_item["properties"]["proj:epsg"]}}}
313+
params = {
314+
"collections": [first_item["collection"]],
315+
"query": {"proj:epsg": {"eq": first_item["properties"]["proj:epsg"]}},
316+
}
317317
print(params)
318318
resp = await app_client.post("/search", json=params)
319319
assert resp.status_code == 200
@@ -395,6 +395,69 @@ async def test_item_search_get_filter_extension_cql(app_client):
395395
)
396396

397397

398+
@pytest.mark.asyncio
399+
async def test_search_using_filter_with_collectionid(app_client):
400+
"""Test POST search with JSONB query (cql json filter extension)
401+
that includes a collectionid in the filter and no where else"""
402+
items_resp = await app_client.get("/collections/naip/items")
403+
assert items_resp.status_code == 200
404+
405+
first_item = items_resp.json()["features"][0]
406+
407+
# EPSG is a JSONB key
408+
body = {
409+
"filter": {
410+
"op": "and",
411+
"args": [
412+
{"op": "=", "args": [{"property": "collection"}, "naip"]},
413+
{
414+
"op": "=",
415+
"args": [
416+
{"property": "proj:epsg"},
417+
first_item["properties"]["proj:epsg"],
418+
],
419+
},
420+
],
421+
}
422+
}
423+
resp = await app_client.post("/search", json=body)
424+
resp_json = resp.json()
425+
426+
assert resp.status_code == 200
427+
assert len(resp_json["features"]) == 12
428+
assert (
429+
resp_json["features"][0]["properties"]["proj:epsg"]
430+
== first_item["properties"]["proj:epsg"]
431+
)
432+
433+
434+
@pytest.mark.asyncio
435+
async def test_search_using_filter_without_collectionid(app_client):
436+
"""Test POST search with JSONB query (cql json filter extension)
437+
that includes a collectionid in the filter and no where else"""
438+
items_resp = await app_client.get("/collections/naip/items")
439+
assert items_resp.status_code == 200
440+
441+
first_item = items_resp.json()["features"][0]
442+
443+
# EPSG is a JSONB key
444+
body = {
445+
"filter": {
446+
"args": [
447+
{
448+
"op": "=",
449+
"args": [
450+
{"property": "proj:epsg"},
451+
first_item["properties"]["proj:epsg"],
452+
],
453+
},
454+
],
455+
}
456+
}
457+
resp = await app_client.post("/search", json=body)
458+
assert resp.status_code == 422
459+
460+
398461
@pytest.mark.asyncio
399462
async def test_get_missing_item_collection(app_client):
400463
"""Test reading a collection which does not exist"""
@@ -459,7 +522,7 @@ async def test_pagination_post(app_client):
459522
ids = [item["id"] for item in items_resp.json()["features"]]
460523

461524
# Paginate through all 5 items with a limit of 1 (expecting 5 requests)
462-
request_body = {"ids": ids, "limit": 1}
525+
request_body = {"ids": ids, "limit": 1, "collections": ["naip"]}
463526
page = await app_client.post("/search", json=request_body)
464527
idx = 0
465528
item_ids = []
@@ -489,7 +552,11 @@ async def test_pagination_token_idempotent(app_client):
489552
# so that a "next" link is returned
490553
page = await app_client.get(
491554
"/search",
492-
params={"datetime": "1900-01-01T00:00:00Z/2030-01-01T00:00:00Z", "limit": 3},
555+
params={
556+
"datetime": "1900-01-01T00:00:00Z/2030-01-01T00:00:00Z",
557+
"limit": 3,
558+
"collections": ["naip"],
559+
},
493560
)
494561
assert page.status_code == 200
495562

@@ -516,7 +583,10 @@ async def test_pagination_token_idempotent(app_client):
516583
@pytest.mark.asyncio
517584
async def test_field_extension_get(app_client):
518585
"""Test GET search with included fields (fields extension)"""
519-
params = {"fields": "+properties.proj:epsg,+properties.gsd,+collection"}
586+
params = {
587+
"fields": "+properties.proj:epsg,+properties.gsd,+collection",
588+
"collections": ["naip"],
589+
}
520590
resp = await app_client.get("/search", params=params)
521591
print(resp.json())
522592
feat_properties = resp.json()["features"][0]["properties"]
@@ -526,7 +596,7 @@ async def test_field_extension_get(app_client):
526596
@pytest.mark.asyncio
527597
async def test_field_extension_exclude_default_includes(app_client):
528598
"""Test POST search excluding a forbidden field (fields extension)"""
529-
body = {"fields": {"exclude": ["geometry"]}}
599+
body = {"fields": {"exclude": ["geometry"]}, "collections": ["naip"]}
530600

531601
resp = await app_client.post("/search", json=body)
532602
resp_json = resp.json()
@@ -538,7 +608,7 @@ async def test_search_intersects_and_bbox(app_client):
538608
"""Test POST search intersects and bbox are mutually exclusive (core)"""
539609
bbox = [-118, 34, -117, 35]
540610
geoj = Polygon.from_bounds(*bbox).model_dump(exclude_none=True)
541-
params = {"bbox": bbox, "intersects": geoj}
611+
params = {"bbox": bbox, "intersects": geoj, "collections": ["naip"]}
542612
resp = await app_client.post("/search", json=params)
543613
assert resp.status_code == 400
544614

@@ -599,15 +669,18 @@ async def test_tiler_link_construction(app_client):
599669

600670
@pytest.mark.asyncio
601671
async def test_search_bbox_errors(app_client):
602-
body = {"query": {"bbox": [0]}}
672+
body = {"query": {"bbox": [0]}, "collections": ["naip"]}
603673
resp = await app_client.post("/search", json=body)
604674
assert resp.status_code == 400
605675

606-
body = {"query": {"bbox": [100.0, 0.0, 0.0, 105.0, 1.0, 1.0]}}
676+
body = {
677+
"query": {"bbox": [100.0, 0.0, 0.0, 105.0, 1.0, 1.0]},
678+
"collections": ["naip"],
679+
}
607680
resp = await app_client.post("/search", json=body)
608681
assert resp.status_code == 400
609682

610-
params = {"bbox": "100.0,0.0,0.0,105.0"}
683+
params = {"bbox": "100.0,0.0,0.0,105.0", "collections": ["naip"]}
611684
resp = await app_client.get("/search", params=params)
612685
assert resp.status_code == 400
613686

@@ -628,6 +701,9 @@ async def test_search_get_page_limits(app_client):
628701
assert len(resp_json["features"]) == 12
629702

630703

704+
@pytest.mark.skip(
705+
reason="Are these params even valid? they are not within filter field"
706+
)
631707
@pytest.mark.asyncio
632708
async def test_search_post_page_limits(app_client):
633709
params = {"op": "=", "args": [{"property": "collection"}, "naip"]}

0 commit comments

Comments
 (0)