diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index a9a1416d..a8b19bb1 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -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 diff --git a/backend/tests/api/workspaces/test_invitations.py b/backend/tests/api/workspaces/test_invitations.py index 5ee3d412..f8fc2ee0 100644 --- a/backend/tests/api/workspaces/test_invitations.py +++ b/backend/tests/api/workspaces/test_invitations.py @@ -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( diff --git a/backend/tests/extraction/test_aliases.py b/backend/tests/extraction/test_aliases.py index 3e20e6ea..0bcf6ce6 100644 --- a/backend/tests/extraction/test_aliases.py +++ b/backend/tests/extraction/test_aliases.py @@ -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. @@ -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 @@ -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] diff --git a/backend/tests/security/test_cross_tenant.py b/backend/tests/security/test_cross_tenant.py index 2216f029..c92dabd2 100644 --- a/backend/tests/security/test_cross_tenant.py +++ b/backend/tests/security/test_cross_tenant.py @@ -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"(? 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")} @@ -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)]) diff --git a/backend/tests/worker/test_import_acceptance.py b/backend/tests/worker/test_import_acceptance.py index ef66a503..e82f1a20 100644 --- a/backend/tests/worker/test_import_acceptance.py +++ b/backend/tests/worker/test_import_acceptance.py @@ -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: