Skip to content
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
3 changes: 2 additions & 1 deletion .github/workflows/web.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ permissions:
jobs:
checks:
runs-on: ubuntu-24.04
timeout-minutes: 20
# The unit tests alone took 11 to 19 minutes on 2026-11-05; 20 cut one run off mid-suite
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Reads the pnpm version from packageManager in the root package.json
Expand Down
4 changes: 3 additions & 1 deletion backend/tests/api/workspaces/test_invitations.py
Original file line number Diff line number Diff line change
Expand Up @@ -1102,7 +1102,9 @@ async def test_a_huge_address_is_refused_without_being_normalised(
"message": "String should have at most 254 characters",
}
]
assert elapsed < 1.0
# Wall time through the API, on CPUs shared with parallel test workers: generous next to
# the minute that normalising would take.
assert elapsed < 5.0


@pytest.mark.parametrize(
Expand Down
14 changes: 8 additions & 6 deletions backend/tests/extraction/test_aliases.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,10 +238,12 @@ def test_a_stored_pattern_the_matcher_cannot_run_is_left_out_not_fatal() -> None


def test_a_pathological_pattern_runs_in_linear_time() -> None:
# CPU time of this process, not wall time: the suite runs on parallel workers that share the
# CPUs, and waiting for a core says nothing about the matcher. Applies to the timings below too.
matcher = AliasMatcher([_entity(ACME, "Acme")], [_alias(ACME, "(a+)+$", "regex")])
started = time.monotonic()
started = time.process_time()
matcher.match("a" * 50_000 + "!")
assert time.monotonic() - started < 1.0
assert time.process_time() - started < 1.0


# Overlaps, negatives, products, domains.
Expand Down Expand Up @@ -343,9 +345,9 @@ def test_a_negative_that_drops_nothing_is_not_reported() -> None:
def test_many_negatives_on_a_long_text_are_quick() -> None:
entities = [_entity(APPLE, "Apple")]
aliases = [_alias(APPLE, "apple"), _alias(APPLE, "apple pie", negative=True)]
started = time.monotonic()
started = time.process_time()
result = _match("apple pie " * 10_000, entities, aliases)
assert time.monotonic() - started < 2.0
assert time.process_time() - started < 2.0
assert result.mentions == ()
assert len(result.negatives_applied) == 10_000

Expand All @@ -354,9 +356,9 @@ def test_overlapping_matches_on_a_long_text_resolve_quickly() -> None:
"""A substring alias of one letter on the alias test's 100 KB limit."""
entities = [_entity(ACME, "A"), _entity(HUBSPOT, "Aa")]
aliases = [_alias(ACME, "a", "substring"), _alias(HUBSPOT, "aa", "substring")]
started = time.monotonic()
started = time.process_time()
result = _match("a" * 100_000, entities, aliases)
assert time.monotonic() - started < 5.0
assert time.process_time() - started < 5.0
assert [len(m.spans) for m in result.mentions] == [50_000]


Expand Down
58 changes: 57 additions & 1 deletion backend/tests/security/test_cross_tenant.py
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,59 @@ def _problem(response: httpx.Response) -> tuple[int, str]:
return response.status_code, response.json()["code"]


def _json_parts(node: object) -> list[object]:
"""Every key and scalar value of a JSON document."""
if isinstance(node, dict):
return [part for key, item in node.items() for part in (key, *_json_parts(item))]
if isinstance(node, list):
return [part for item in node for part in _json_parts(item)]
return [node]


def _shows(body: object, text: str, secret: str) -> bool:
"""Whether a response shows `secret`. Ids, slugs and addresses are found anywhere in the text. A
numeric id only as a whole number: a JSON number equal to it, or in a string or key a run of
digits not part of a longer number, word or decimal (`kw-232`, `id:232` and `/k/232` count;
`9232`, `0.232` and `22:32.232Z` do not). As a substring, `232` is inside the other tenant's own
UUIDs, times and decimals, and the ids stay that short on a fresh test database."""
if not secret.isdigit():
return secret in text
whole = re.compile(rf"(?<![0-9A-Za-z.]){secret}(?![0-9A-Za-z]|\.\d)")
for part in _json_parts(body):
if isinstance(part, bool):
continue
if isinstance(part, int) and str(part) == secret:
return True
if isinstance(part, str) and whole.search(part):
return True
return False


def test_a_numeric_id_counts_as_shown_only_as_a_whole_value() -> None:
hidden_inside = {
"id": "5f0c2322-7a1b-4c22-9232-0d1e2f3a4b5c",
"at": "2026-11-05T10:22:32.232Z",
"cost": "0.232000",
"count": 2320,
}
assert not _shows(hidden_inside, str(hidden_inside), "232")
for shown in (
{"keyword_id": 232},
{"ref": "232"},
{"232": True},
{"url": "/k/232/x"},
{"url": "/k?id=232&x=1"},
{"detail": "keyword 232 deleted"},
{"ref": "kw-232"},
{"ref": "id:232"},
{"ref": "snap_232"},
{"ref": "(232)"},
):
assert _shows(shown, str(shown), "232"), shown
assert not _shows({"ok": True}, '{"ok": true}', "1") # a boolean is not the number 1
assert _shows({}, "acme-crm@client.example", "acme-crm")


def _without_request_details(response: httpx.Response) -> dict[str, Any]:
body: dict[str, Any] = response.json()
return {key: value for key, value in body.items() if key not in ("instance", "request_id")}
Expand Down Expand Up @@ -803,8 +856,11 @@ async def test_a_list_holds_nothing_of_another_workspace(
)

assert response.status_code == 200, response.text
body = response.json()
for secret in b.secrets():
assert secret not in response.text, f"{path} shows {secret} of another workspace"
assert not _shows(body, response.text, secret), (
f"{path} shows {secret} of another workspace"
)
# The list is not empty for a trivial reason: it shows A's own data.
if path not in PLATFORM_LISTS | COUNT_ROUTES:
assert any(own in response.text for own in [*a.ids().values(), str(a.owner.id)])
Expand Down
3 changes: 2 additions & 1 deletion backend/tests/worker/test_import_acceptance.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,8 @@ async def with_metrics() -> bool:
)
return bool(row["n"] == KEYWORDS)

await eventually(with_metrics, within=170, every=1)
# docs/29 section 3: metrics within 5 minutes; the suite's workers share the CPUs.
await eventually(with_metrics, within=290, every=1)
# A few more passes of everything: nothing is asked again.
await asyncio.sleep(3)
finally:
Expand Down
Loading