mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-19 09:30:21 +00:00
Compare commits
67
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
652c950a71 | ||
|
|
da166c04e6 | ||
|
|
ba64cf35b8 | ||
|
|
e78c128de4 | ||
|
|
0c296b3227 | ||
|
|
d9f957a892 | ||
|
|
4d32fa59b7 | ||
|
|
2403dbca82 | ||
|
|
ac3dbd1959 | ||
|
|
cbc6d23444 | ||
|
|
c02c28fc76 | ||
|
|
00d2aacf26 | ||
|
|
ff3e4a4d24 | ||
|
|
7da6d06f51 | ||
|
|
0c9aa84676 | ||
|
|
b388bb68f6 | ||
|
|
df0775d135 | ||
|
|
5af26a5f53 | ||
|
|
5690d1ad94 | ||
|
|
e178722f0b | ||
|
|
d28272b17b | ||
|
|
4ffe215a67 | ||
|
|
d60cb02f72 | ||
|
|
349d6d8dd4 | ||
|
|
6d1852b38a | ||
|
|
531b7fd311 | ||
|
|
d19c81bdbb | ||
|
|
b323ab139e | ||
|
|
57434ea16e | ||
|
|
c9c769c1df | ||
|
|
71be560bc2 | ||
|
|
608ce240d0 | ||
|
|
c3420151b5 | ||
|
|
6adbb75ca9 | ||
|
|
23d6c77a78 | ||
|
|
9d43aa0c33 | ||
|
|
434e5aaf03 | ||
|
|
68f1092f56 | ||
|
|
5ed235088c | ||
|
|
ed1fce420e | ||
|
|
8481a43fe3 | ||
|
|
37b3ae7d25 | ||
|
|
553c0429e6 | ||
|
|
2f59b89b49 | ||
|
|
4cf3d7af73 | ||
|
|
d60bb6ee62 | ||
|
|
327d0ed0c9 | ||
|
|
ffa593dff9 | ||
|
|
dfc66e43a8 | ||
|
|
bdb2e52261 | ||
|
|
e15a68c6d1 | ||
|
|
866cb6077f | ||
|
|
fce28e364a | ||
|
|
7cf3d4d486 | ||
|
|
8089a7576e | ||
|
|
4fa4354796 | ||
|
|
0aa6457ef1 | ||
|
|
baa0d03c06 | ||
|
|
19dadeca05 | ||
|
|
513b76d68d | ||
|
|
34f752cf41 | ||
|
|
f3224d0988 | ||
|
|
450e6ba553 | ||
|
|
2cd93fe119 | ||
|
|
f807b22ea6 | ||
|
|
b6e9967da6 | ||
|
|
16e62f7514 |
@@ -199,7 +199,7 @@ You MUST structure your response using this EXACT format. Do NOT include anythin
|
||||
|
||||
### For Check Logic Bug
|
||||
|
||||
```
|
||||
```markdown
|
||||
### AI Assessment [Experimental]: Check Logic Bug
|
||||
|
||||
**Component**: {component from issue template}
|
||||
@@ -297,7 +297,7 @@ Write tests FIRST (TDD). The skills contain all testing conventions and patterns
|
||||
|
||||
### For Bug (non-check)
|
||||
|
||||
```
|
||||
```markdown
|
||||
### AI Assessment [Experimental]: Bug
|
||||
|
||||
**Component**: {CLI/SDK | API | UI | Dashboard | MCP Server | Other}
|
||||
@@ -378,7 +378,7 @@ Write tests FIRST (TDD). The skills contain all testing conventions and patterns
|
||||
|
||||
### For Already Fixed
|
||||
|
||||
```
|
||||
```markdown
|
||||
### AI Assessment [Experimental]: Already Fixed
|
||||
|
||||
**Component**: {component}
|
||||
@@ -401,7 +401,7 @@ Upgrade to the latest version. Close the issue as resolved.
|
||||
|
||||
### For Feature Request
|
||||
|
||||
```
|
||||
```markdown
|
||||
### AI Assessment [Experimental]: Feature Request
|
||||
|
||||
**Component**: {component}
|
||||
@@ -419,7 +419,7 @@ Upgrade to the latest version. Close the issue as resolved.
|
||||
|
||||
### For Not a Bug
|
||||
|
||||
```
|
||||
```markdown
|
||||
### AI Assessment [Experimental]: Not a Bug
|
||||
|
||||
**Component**: {component}
|
||||
@@ -440,7 +440,7 @@ Upgrade to the latest version. Close the issue as resolved.
|
||||
|
||||
### For Needs More Information
|
||||
|
||||
```
|
||||
```markdown
|
||||
### AI Assessment [Experimental]: Needs More Information
|
||||
|
||||
**Component**: {component or "Unknown"}
|
||||
|
||||
@@ -52,6 +52,16 @@ provider/alibabacloud:
|
||||
- any-glob-to-any-file: "prowler/providers/alibabacloud/**"
|
||||
- any-glob-to-any-file: "tests/providers/alibabacloud/**"
|
||||
|
||||
provider/huaweicloud:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: "prowler/providers/huaweicloud/**"
|
||||
- any-glob-to-any-file: "tests/providers/huaweicloud/**"
|
||||
|
||||
provider/image:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: "prowler/providers/image/**"
|
||||
- any-glob-to-any-file: "tests/providers/image/**"
|
||||
|
||||
provider/cloudflare:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: "prowler/providers/cloudflare/**"
|
||||
@@ -82,6 +92,11 @@ provider/linode:
|
||||
- any-glob-to-any-file: "prowler/providers/linode/**"
|
||||
- any-glob-to-any-file: "tests/providers/linode/**"
|
||||
|
||||
provider/stackit:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: "prowler/providers/stackit/**"
|
||||
- any-glob-to-any-file: "tests/providers/stackit/**"
|
||||
|
||||
github_actions:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ".github/workflows/*"
|
||||
|
||||
@@ -8,11 +8,11 @@ These JSON templates are used with the `slackapi/slack-github-action` using the
|
||||
|
||||
### Available Templates
|
||||
|
||||
**Container Releases**
|
||||
#### Container Releases
|
||||
- `container-release-started.json`: Simple one-line notification when container push starts
|
||||
- `container-release-completed.json`: Simple one-line notification when container release completes
|
||||
|
||||
**Deployments**
|
||||
#### Deployments
|
||||
- `deployment-started.json`: Deployment start notification with Block Kit formatting
|
||||
- `deployment-completed.json`: Deployment completion notification (updates the start message)
|
||||
|
||||
@@ -416,17 +416,17 @@ For deployments that start with one message and update it with the final status:
|
||||
### Container Release (Simple One-Line)
|
||||
|
||||
**Start message:**
|
||||
```
|
||||
```text
|
||||
API container release 4.5.0 push started... View run
|
||||
```
|
||||
|
||||
**Completion message (success):**
|
||||
```
|
||||
```text
|
||||
[✓] API container release 4.5.0 push completed successfully! View run
|
||||
```
|
||||
|
||||
**Completion message (failure):**
|
||||
```
|
||||
```text
|
||||
[✗] API container release 4.5.0 push failed View run
|
||||
```
|
||||
|
||||
|
||||
@@ -55,6 +55,10 @@ jobs:
|
||||
# Pin must match .pre-commit-config.yaml so prek and CI behave identically.
|
||||
# pnpm dlx doesn't accept --ignore-scripts as a flag; the env var
|
||||
# disables postinstall scripts on transitives the same way.
|
||||
#
|
||||
# Files come from `git ls-files` because markdownlint doesn't traverse
|
||||
# dot-directories, so `.github/**/*.md` went unlinted.
|
||||
# `.markdownlintignore` still applies to the listed paths.
|
||||
env:
|
||||
pnpm_config_ignore_scripts: 'true'
|
||||
run: pnpm dlx markdownlint-cli@0.45.0 '**/*.md'
|
||||
run: git ls-files -z '*.md' | xargs -0 -r pnpm dlx markdownlint-cli@0.45.0 --
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
name: 'SDK: Package Checks'
|
||||
|
||||
# Rehearses the PyPI release on every packaging change and once a week, from the
|
||||
# consumer's side. Two incidents this guards against:
|
||||
#
|
||||
# - 5.38.0 shipped an unsatisfiable Requires-Dist (cryptography==50.0.0 while
|
||||
# alibabacloud-tea-openapi and pyopenssl cap it below 49). A [tool.uv] override hid
|
||||
# the conflict inside the repo; pip could not install the wheel and silently
|
||||
# resolved `pip install prowler` to 5.37.1 for a week.
|
||||
# - 5.39.0 never published: an unpinned build backend started emitting core metadata
|
||||
# 2.5 and the twine bundled in the publish action rejected it.
|
||||
#
|
||||
# Both were only detectable at release time because nothing built and installed the
|
||||
# artifact earlier. The weekly run also catches releases yanked from PyPI after we
|
||||
# pinned them (zstd 1.5.7.3, "buggy - not thread safe", sat in uv.lock for months).
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'v5.*'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'v5.*'
|
||||
schedule:
|
||||
# Monday 06:00 UTC. Yanks and upstream releases happen without a commit here.
|
||||
- cron: '0 6 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: {}
|
||||
|
||||
env:
|
||||
# Must equal the twine bundled in the pypa/gh-action-pypi-publish pin used by
|
||||
# sdk-pypi-release.yml (requirements/runtime.txt in that repo at the pinned tag).
|
||||
# A metadata check that passes here must pass there.
|
||||
TWINE_VERSION: '7.0.0'
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
if: github.repository == 'prowler-cloud/prowler'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
# Scheduled and manual runs always execute; pushes and PRs only when a packaging
|
||||
# input changed. Jobs skipped this way still report success to branch protection.
|
||||
run: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || steps.filter.outputs.any_changed == 'true' }}
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
api.github.com:443
|
||||
|
||||
- name: Checkout repository
|
||||
if: github.event_name == 'push' || github.event_name == 'pull_request'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# zizmor: ignore[artipacked]
|
||||
persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch
|
||||
|
||||
- name: Detect packaging changes
|
||||
if: github.event_name == 'push' || github.event_name == 'pull_request'
|
||||
id: filter
|
||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
||||
with:
|
||||
files: |
|
||||
pyproject.toml
|
||||
uv.lock
|
||||
README.md
|
||||
util/replicate_pypi_package.py
|
||||
util/check_yanked_pins.py
|
||||
api/pyproject.toml
|
||||
api/uv.lock
|
||||
mcp_server/pyproject.toml
|
||||
mcp_server/uv.lock
|
||||
.github/workflows/sdk-package-checks.yml
|
||||
.github/workflows/sdk-pypi-release.yml
|
||||
.github/actions/setup-python-uv/**
|
||||
|
||||
install-from-wheel:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.run == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version:
|
||||
- '3.10'
|
||||
- '3.11'
|
||||
- '3.12'
|
||||
- '3.13'
|
||||
package:
|
||||
- 'prowler'
|
||||
include:
|
||||
# prowler-cloud is the same tree renamed by util/replicate_pypi_package.py;
|
||||
# one Python is enough to prove the rename and its build still work.
|
||||
- python-version: '3.12'
|
||||
package: 'prowler-cloud'
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
api.github.com:443
|
||||
release-assets.githubusercontent.com:443
|
||||
pypi.org:443
|
||||
files.pythonhosted.org:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Python with uv
|
||||
uses: ./.github/actions/setup-python-uv
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
install-dependencies: 'false'
|
||||
|
||||
- name: Rename package to prowler-cloud
|
||||
if: matrix.package == 'prowler-cloud'
|
||||
run: |
|
||||
pip install --no-cache-dir toml
|
||||
python util/replicate_pypi_package.py
|
||||
|
||||
- name: Build sdist and wheel
|
||||
run: uv build
|
||||
|
||||
- name: Check metadata with the release workflow's twine
|
||||
run: uvx --from "twine==${TWINE_VERSION}" twine check --strict dist/*
|
||||
|
||||
- name: Install the wheel with pip into a clean virtualenv
|
||||
# Plain pip, --isolated, from outside the repo: consumers never see [tool.uv]
|
||||
# override-dependencies or constraint-dependencies, so neither does this step.
|
||||
run: |
|
||||
python -m venv "${RUNNER_TEMP}/consumer"
|
||||
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --quiet --upgrade pip
|
||||
cd "${RUNNER_TEMP}"
|
||||
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --isolated --no-cache-dir "${GITHUB_WORKSPACE}"/dist/*.whl
|
||||
|
||||
- name: Smoke test the installed CLI
|
||||
run: |
|
||||
cd "${RUNNER_TEMP}"
|
||||
"${RUNNER_TEMP}/consumer/bin/prowler" --version
|
||||
# Loads every AWS check module from the installed wheel: catches files missing
|
||||
# from the package. grep fails the step if the summary line never appears.
|
||||
"${RUNNER_TEMP}/consumer/bin/prowler" aws --list-checks | grep 'available checks'
|
||||
|
||||
pinned-releases-not-yanked:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.run == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
api.github.com:443
|
||||
release-assets.githubusercontent.com:443
|
||||
pypi.org:443
|
||||
files.pythonhosted.org:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Check every pinned and locked release against PyPI
|
||||
run: python util/check_yanked_pins.py . api mcp_server
|
||||
@@ -84,6 +84,16 @@ jobs:
|
||||
- name: Build Prowler package
|
||||
run: uv build
|
||||
|
||||
- name: Verify the wheel installs with pip
|
||||
# Same check as "SDK: Package Checks", repeated on the exact artifact about to be
|
||||
# published. Plain pip, --isolated, from outside the repo: an unsatisfiable
|
||||
# Requires-Dist fails here instead of on users' machines (5.38.0 shipped one).
|
||||
run: |
|
||||
python -m venv "${RUNNER_TEMP}/consumer"
|
||||
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --quiet --upgrade pip
|
||||
cd "${RUNNER_TEMP}"
|
||||
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --isolated --no-cache-dir --dry-run "${GITHUB_WORKSPACE}"/dist/*.whl
|
||||
|
||||
- name: Publish Prowler package to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
||||
with:
|
||||
@@ -128,6 +138,16 @@ jobs:
|
||||
- name: Build prowler-cloud package
|
||||
run: uv build
|
||||
|
||||
- name: Verify the wheel installs with pip
|
||||
# Same check as "SDK: Package Checks", repeated on the exact artifact about to be
|
||||
# published. Plain pip, --isolated, from outside the repo: an unsatisfiable
|
||||
# Requires-Dist fails here instead of on users' machines (5.38.0 shipped one).
|
||||
run: |
|
||||
python -m venv "${RUNNER_TEMP}/consumer"
|
||||
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --quiet --upgrade pip
|
||||
cd "${RUNNER_TEMP}"
|
||||
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --isolated --no-cache-dir --dry-run "${GITHUB_WORKSPACE}"/dist/*.whl
|
||||
|
||||
- name: Publish prowler-cloud package to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
||||
with:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Bump alibabacloud-tea-openapi to 0.4.6, oci to 2.184.1 and pyopenssl to 26.4.0 to match the SDK; the cryptography override now names its actual blockers (azure-cli-core pins msal below 1.37, workos 8.3.0 requires cryptography 48)
|
||||
@@ -0,0 +1 @@
|
||||
Pin zstd to 1.5.7.2; 1.5.7.3 was yanked from PyPI as not thread safe
|
||||
+11
-7
@@ -92,8 +92,7 @@ extend-select = [
|
||||
|
||||
[tool.uv]
|
||||
# Transitive pins matching master to avoid silent drift; bump deliberately.
|
||||
# workos and pyopenssl run ahead of master: the versions master pins cap cryptography
|
||||
# below 48, so both were bumped to versions that allow it (PROWLER-2310).
|
||||
# workos is api-only; pyopenssl matches master (PROWLER-2310).
|
||||
constraint-dependencies = [
|
||||
"about-time==4.2.1",
|
||||
"adal==1.2.7",
|
||||
@@ -130,7 +129,7 @@ constraint-dependencies = [
|
||||
"alibabacloud-sls20201230==5.9.0",
|
||||
"alibabacloud-sts20150401==1.1.6",
|
||||
"alibabacloud-tea==0.4.3",
|
||||
"alibabacloud-tea-openapi==0.4.5",
|
||||
"alibabacloud-tea-openapi==0.4.6",
|
||||
"alibabacloud-tea-util==0.3.14",
|
||||
"alibabacloud-tea-xml==0.0.3",
|
||||
"alibabacloud-vpc20160428==6.13.0",
|
||||
@@ -339,7 +338,7 @@ constraint-dependencies = [
|
||||
"nltk==3.9.4",
|
||||
"numpy==2.2.6",
|
||||
"oauthlib==3.3.1",
|
||||
"oci==2.183.0",
|
||||
"oci==2.184.1",
|
||||
"openai==1.109.1",
|
||||
"openstacksdk==4.2.0",
|
||||
"opentelemetry-api==1.39.1",
|
||||
@@ -380,7 +379,7 @@ constraint-dependencies = [
|
||||
"pylint==3.2.5",
|
||||
"pymsalruntime==0.18.1",
|
||||
"pynacl==1.6.2",
|
||||
"pyopenssl==26.2.0",
|
||||
"pyopenssl==26.4.0",
|
||||
"pyparsing==3.3.2",
|
||||
"pyreadline3==3.5.4",
|
||||
"pysocks==1.7.1",
|
||||
@@ -458,7 +457,7 @@ constraint-dependencies = [
|
||||
"zipp==3.23.0",
|
||||
"zope-event==6.1",
|
||||
"zope-interface==8.2",
|
||||
"zstd==1.5.7.3"
|
||||
"zstd==1.5.7.2"
|
||||
]
|
||||
# prowler@master needs okta==3.4.2, but cartography 0.138.1 requires okta<1.0.0.
|
||||
# Attack Paths does not ingest Okta today, so override the Cartography
|
||||
@@ -485,7 +484,12 @@ constraint-dependencies = [
|
||||
# that request pyjwt[crypto] and leave cryptography (needed for RS256) only transitive.
|
||||
override-dependencies = [
|
||||
"okta==3.4.2",
|
||||
# alibabacloud-tea-openapi 0.4.5 caps cryptography below 49 and is the latest release.
|
||||
# prowler requires cryptography==50.0.0. Two api-only dependencies still cap it below
|
||||
# 49 and cannot move yet: msal, pinned exactly by azure-cli-core (2.83.0 -> 1.35.0b1,
|
||||
# 2.89.1 -> 1.36.0, both <49; cartography needs azure-cli-core), and workos 8.3.0
|
||||
# (~=48.0; workos 10.1.1+ needs ~=50.0 and is a separate SDK upgrade). This api is
|
||||
# deployed from this lock with `uv sync --locked`, so the override applies to what runs.
|
||||
# Remove when azure-cli-core pins msal>=1.37.0 and workos is on 10.x.
|
||||
"cryptography==50.0.0",
|
||||
"azure-mgmt-containerservice==34.1.0",
|
||||
"microsoft-kiota-abstractions==1.9.10",
|
||||
|
||||
Generated
+36
-48
@@ -45,7 +45,7 @@ constraints = [
|
||||
{ name = "alibabacloud-sls20201230", specifier = "==5.9.0" },
|
||||
{ name = "alibabacloud-sts20150401", specifier = "==1.1.6" },
|
||||
{ name = "alibabacloud-tea", specifier = "==0.4.3" },
|
||||
{ name = "alibabacloud-tea-openapi", specifier = "==0.4.5" },
|
||||
{ name = "alibabacloud-tea-openapi", specifier = "==0.4.6" },
|
||||
{ name = "alibabacloud-tea-util", specifier = "==0.3.14" },
|
||||
{ name = "alibabacloud-tea-xml", specifier = "==0.0.3" },
|
||||
{ name = "alibabacloud-vpc20160428", specifier = "==6.13.0" },
|
||||
@@ -254,7 +254,7 @@ constraints = [
|
||||
{ name = "nltk", specifier = "==3.9.4" },
|
||||
{ name = "numpy", specifier = "==2.2.6" },
|
||||
{ name = "oauthlib", specifier = "==3.3.1" },
|
||||
{ name = "oci", specifier = "==2.183.0" },
|
||||
{ name = "oci", specifier = "==2.184.1" },
|
||||
{ name = "openai", specifier = "==1.109.1" },
|
||||
{ name = "openstacksdk", specifier = "==4.2.0" },
|
||||
{ name = "opentelemetry-api", specifier = "==1.39.1" },
|
||||
@@ -295,7 +295,7 @@ constraints = [
|
||||
{ name = "pylint", specifier = "==3.2.5" },
|
||||
{ name = "pymsalruntime", specifier = "==0.18.1" },
|
||||
{ name = "pynacl", specifier = "==1.6.2" },
|
||||
{ name = "pyopenssl", specifier = "==26.2.0" },
|
||||
{ name = "pyopenssl", specifier = "==26.4.0" },
|
||||
{ name = "pyparsing", specifier = "==3.3.2" },
|
||||
{ name = "pyreadline3", specifier = "==3.5.4" },
|
||||
{ name = "pysocks", specifier = "==1.7.1" },
|
||||
@@ -373,7 +373,7 @@ constraints = [
|
||||
{ name = "zipp", specifier = "==3.23.0" },
|
||||
{ name = "zope-event", specifier = "==6.1" },
|
||||
{ name = "zope-interface", specifier = "==8.2" },
|
||||
{ name = "zstd", specifier = "==1.5.7.3" },
|
||||
{ name = "zstd", specifier = "==1.5.7.2" },
|
||||
]
|
||||
overrides = [
|
||||
{ name = "azure-mgmt-containerservice", specifier = "==34.1.0" },
|
||||
@@ -860,7 +860,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/9a/7d/b22cb9a0d4f396ee0
|
||||
|
||||
[[package]]
|
||||
name = "alibabacloud-tea-openapi"
|
||||
version = "0.4.5"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "alibabacloud-credentials" },
|
||||
@@ -869,9 +869,9 @@ dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "darabonba-core" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3b/73/fb0c4d44759791ecdf269fc715c1e810fa1aba3981bfaaf8a01f61899296/alibabacloud_tea_openapi-0.4.5.tar.gz", hash = "sha256:75fa1f4360a46e41f5bf5f8d4917e52efb6f64885839bc1328c35590670c97b9", size = 26616, upload-time = "2026-07-14T13:15:39.364Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ab/34/1918a2d780676494365c7f945bfab397ecddb988054d78025bd26f438977/alibabacloud_tea_openapi-0.4.6.tar.gz", hash = "sha256:dafc32401712f5b21c12dc3d05ba887a91ad156d9b49a7662279f9fd90526fb2", size = 26742, upload-time = "2026-08-17T08:34:11.55Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/ec/6b368a10e9c2e8b1b394c69b96ac213ae66e8c4895e0baa1ffaf7178fd32/alibabacloud_tea_openapi-0.4.5-py3-none-any.whl", hash = "sha256:338979095c7beda80a5b413c31262892cafdc12069dde4ce4fc2e4f7ce0fc609", size = 33333, upload-time = "2026-07-14T13:15:38.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/00/2f534f5884e5f299d9cb3a1e8be2def8071bc6a6e2a192ba4ff2a8cd5e02/alibabacloud_tea_openapi-0.4.6-py3-none-any.whl", hash = "sha256:c9e1727b9fb2936f487d050fc3590c99f9f2065256dc3a927e5b61f414674ed6", size = 33448, upload-time = "2026-08-17T08:34:10.472Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4426,7 +4426,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "oci"
|
||||
version = "2.183.0"
|
||||
version = "2.184.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
@@ -4439,9 +4439,9 @@ dependencies = [
|
||||
{ name = "pytz" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1e/2a/77bd6cbf1c69b2f368fe3d6462d84369b0cba15e37ce713cdc08d459b95a/oci-2.183.0.tar.gz", hash = "sha256:ff572ef5f2030a788796bb509d257e6a41c6510ef9b4b6a75a079efd06e533ce", size = 17759723, upload-time = "2026-07-28T06:02:29.76Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/74/2d/fa5368cfabb868f4111c6978e8b5f66aa3a55076c40c1a59ac3081b0227b/oci-2.184.1.tar.gz", hash = "sha256:617dad69caf8dd6e521d224dbc3e8a8bc289906943a0214fd2c3419094e26435", size = 17990631, upload-time = "2026-08-11T11:01:26.194Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/de/8574b3e527996a099d196e87794a4652d91a0c3185fcc7fdbb5649b75a8a/oci-2.183.0-py3-none-any.whl", hash = "sha256:bd789c98a94d7c5ea08c20d11dcf68c9cd1ad479b134727d80a930b84387070b", size = 36133501, upload-time = "2026-07-28T06:02:18.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/63/5ae22e42aaf96a5da74dc2b9de449c78b4d7418cce621d5da723b3e49f32/oci-2.184.1-py3-none-any.whl", hash = "sha256:bd814e38a70da2190e721937455a08689ab13c0750bd2ef8dd0c98b2dc5a38ea", size = 36628063, upload-time = "2026-08-11T11:01:18.178Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4835,8 +4835,8 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler"
|
||||
version = "5.38.0"
|
||||
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#b3d174d0c1eb202ed7cb9a9daf0500683f4443be" }
|
||||
version = "5.40.0"
|
||||
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#b6e9967da6bebd6c7b8b237317a2a95e2e0c65bc" }
|
||||
dependencies = [
|
||||
{ name = "alibabacloud-actiontrail20200706" },
|
||||
{ name = "alibabacloud-credentials" },
|
||||
@@ -5426,15 +5426,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pyopenssl"
|
||||
version = "26.2.0"
|
||||
version = "26.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1a/51/27a5ad5f939d08f690a326ef9582cda7140555180db71695f6fb747d6a36/pyopenssl-26.2.0.tar.gz", hash = "sha256:8c6fcecd1183a7fc897548dfe388b0cdb7f37e018200d8409cf33959dbe35387", size = 182195, upload-time = "2026-05-04T23:06:09.72Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3f/e8/7325d258199b159eb2c03fe32107533e2832e70e63f4fb88a6aa00023201/pyopenssl-26.4.0.tar.gz", hash = "sha256:28dfcce0162b9211413e26dfbfdf1d24317fbeba18fc93c12400a1856b2a0bc7", size = 182046, upload-time = "2026-08-01T19:50:50.512Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/73/b8/a0e2790ae249d6f38c9f66de7a211621a7ab2650217bcd04e1262f578a56/pyopenssl-26.2.0-py3-none-any.whl", hash = "sha256:4f9d971bc5298b8bc1fab282803da04bf000c755d4ad9d99b52de2569ca19a70", size = 55823, upload-time = "2026-05-04T23:06:08.395Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/ad/2cf6d3fa2fae5c79e1ed9960c0d42badd0f94d81dd12b50604cdc839e648/pyopenssl-26.4.0-py3-none-any.whl", hash = "sha256:f0eb0cb2d581d3ad2b9c489468485e7f2ab6727d08401bcf9d824c3caddf3c1c", size = 56026, upload-time = "2026-08-01T19:50:48.94Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6623,39 +6623,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "1.5.7.3"
|
||||
version = "1.5.7.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/62/b9c075ad664e7c4cbb3d8d2be7c246506abe1bc7f778eb58d260ef9538c8/zstd-1.5.7.3.tar.gz", hash = "sha256:403e5205f4ac04b92e6b0cda654be2f51de268228a0db0067bc087faacf2f495", size = 672559, upload-time = "2026-01-08T16:24:43.361Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0f/78/9a476e09c825304df47b98be80d1ffe223733b03550af71325415028f615/zstd-1.5.7.2.tar.gz", hash = "sha256:6d8684c69009be49e1b18ec251a5eb0d7e24f93624990a8a124a1da66a92fc8a", size = 670481, upload-time = "2025-06-23T12:36:08.131Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/75/0d/8c89c0d010b58c21a7865a239790bb1c6822029c053b1ded858d6b573e3a/zstd-1.5.7.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a3c1781a24e2ced2c0ddee11d45b1f04018b03615eeb622a62eca4d56d3358a", size = 267641, upload-time = "2026-01-08T16:30:50.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/6d/155d8c344d96eca2a5a003a5ddd63373a5f13591fd5cf2b9490250d6805a/zstd-1.5.7.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a6c7c81056362b60a04baa34632e713d596662a860ec34efd8e9b109c10e6ec7", size = 230962, upload-time = "2026-01-08T16:30:49.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/c7/ab93916a26eb58cd501ad701974c31b4bc67a7f6abd6c24bef8fe4d7649b/zstd-1.5.7.3-cp311-cp311-manylinux_2_14_x86_64.whl", hash = "sha256:e564f34a55effc7d654eb293468edc80b64d476b0f899f82760ecd8323223ff5", size = 304166, upload-time = "2026-01-10T11:17:45.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/54/27a7040a360019a4602343e3c98c0c0a140f382186002c01e1992fd21837/zstd-1.5.7.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:fbc49a57188184931d5e3c9f1133cad7eea5a370a9e9418fb8122d58c14340a5", size = 1540288, upload-time = "2026-01-08T17:50:26.913Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/93/4a4d4edd1b2e809e0ebbb16000404bdcc9a09743c04ee1661442c9581b75/zstd-1.5.7.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:d121d3e63722819e1fe5effbcd9628d8a7cfea0cddabcc5bb37ea861a6a83424", size = 1619134, upload-time = "2026-01-08T17:50:32.324Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/6b/cd6f0a7f4f0d98e4110aa77763cf3e85f594d983ea9ca3d64cc0cee10684/zstd-1.5.7.3-cp311-cp311-manylinux_2_4_i686.whl", hash = "sha256:621f2e7ca8e9eb52a83eb9c91ec3cd283d87591bf75cc658de486b65f44742c7", size = 300166, upload-time = "2026-01-10T11:12:27.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/3f/c717e0d15127d04b7fa58ba9b4c56e8b88b803048b9766cd9d158dbb22ea/zstd-1.5.7.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:c1950fcae690ba32d0f31702b335c548fb42547821565925e48576afdad774a5", size = 1525776, upload-time = "2026-01-08T17:50:35.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/a2/1813cd787d1a2f9ab8e8a90d28dcbc8e8098997dd04de38897ea8e75dd08/zstd-1.5.7.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bac4f0d03da69115878bedbfa03c4a3f64364e8396b432028c4ce0f05141a0fb", size = 2096057, upload-time = "2026-01-08T17:50:33.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/ce/f5a3c7c12de458dd9ce15c484d627fe5412b60c155da23dacb5fcf08d9d5/zstd-1.5.7.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:da0ab134b7fd28023dedf013751ca850de300a090eb11f689d2a1c178c87d9dc", size = 2132659, upload-time = "2026-01-08T17:50:29.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/66/151f9546498bfd8971a0b6ad67d87c26d7a0df17d57f724da674f3778666/zstd-1.5.7.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b9923175842ee8f7602ec9cc578f5fc396896f0e8460d3ac9a5adc3cea77244e", size = 2124811, upload-time = "2026-01-08T17:50:37.612Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/34/4d2dbb36cb2373d3f115c047cb901b64f89de0703d10779da39de9453812/zstd-1.5.7.3-cp311-cp311-win32.whl", hash = "sha256:0612b604948d7b58aecc6788c7ceb53c5f21d94a155bb6ea9bd0f54ffa43725d", size = 150363, upload-time = "2026-01-08T17:11:02.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/de/f53687e0dd8c0d0ebfaed9ae88f6a96a1a0388ae7424b469e74bb17ac57d/zstd-1.5.7.3-cp311-cp311-win_amd64.whl", hash = "sha256:5b7f8c81b2bd3b62c0345242247d484cafa4b518d59d18619813d9225af5c5c3", size = 167577, upload-time = "2026-01-08T17:11:03.356Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/58/d4a6a902e229e953ed273fe9b78587ed31f57567aa68d3e34af6056e42af/zstd-1.5.7.3-cp311-cp311-win_arm64.whl", hash = "sha256:ea112e3acd9e1765adca35df7b54ac75b36194290f64ea03a3a59664209c8527", size = 157238, upload-time = "2026-01-08T16:36:06.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/ed/5a3bf2e29dc56d4cc7619929bb51f0c758de6d02967cc73c5d8755a862c0/zstd-1.5.7.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:01a39efb0eeab7cc45cb308618233b624b0840d5e16dcf85456b6cca0592f203", size = 268124, upload-time = "2026-01-08T16:29:57.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/1d/efc2074ac90af938e78f2ed4004639fe24f294d9086c5280f8d9a02b9897/zstd-1.5.7.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7a8e8838cf35fa3987bfe1958584cc22e1797efce8e155a63544b4144fc671f8", size = 230988, upload-time = "2026-01-08T16:29:55.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/52/178393b8d70e23fba67f42dfce4663e4e8a30867110168beb490a36d4639/zstd-1.5.7.3-cp312-cp312-manylinux_2_14_i686.whl", hash = "sha256:f3920ac1d1cc7e9f252f3e29f217fe3cd36f2191bb3dbcae826c29e189b7ad54", size = 300207, upload-time = "2026-01-10T11:26:58.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/7a/8dcd86a2efb2ed3f9dae39545a05d3c7ed26c7678330786ce4a44cd8b099/zstd-1.5.7.3-cp312-cp312-manylinux_2_14_x86_64.whl", hash = "sha256:143f9062953fb5590cbd47c1040d357336742c79696bf90b6d5b835279a68304", size = 304154, upload-time = "2026-01-10T11:17:40.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/ce/0c96905ab01ffe0e53a3cec8132123b82db26bd583a71608029bcc789ebc/zstd-1.5.7.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36d1fd8647e47e1f21b345e192f1a279e925678c23dad8236b547d04456cd699", size = 2162222, upload-time = "2026-01-08T18:02:22.762Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/c4/db4807d6a68b4628c74fd379de7e3c67ec34f19a2a80ac246b3837cde6cb/zstd-1.5.7.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1538db419afa62773cf534fc7f3009ff59ecf55ecee4e889587ac2ef0010ed8", size = 2201732, upload-time = "2026-01-08T18:02:20.835Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/99/c19a3c0f5580ff9c33a74f06d98d6060ed1fa6bd09b55aed9be852ec191f/zstd-1.5.7.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5efd16adb092e2a547a7d51cfdaf6fd5680528227684c5bafc7669ab4a55f41", size = 2096459, upload-time = "2026-01-08T18:02:25.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/fd/02eac30419475dbe50212c119043a2d0698a0cbc756da85fd3fd9abddf42/zstd-1.5.7.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:39b3438e64637d80a5b1860526903b92020acb9bae9ceb5adffd9838c1441328", size = 2125442, upload-time = "2026-01-08T18:02:17.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/43/3a16ff0a8c913bb9825379db1bd533c75c57c2d2f31dd9111aa9b53711f4/zstd-1.5.7.3-cp312-cp312-win32.whl", hash = "sha256:cbf48c53461e224ffc2490cfe5120a1ff40d14c84d2b512c6d6d99fc91685cf3", size = 150367, upload-time = "2026-01-08T17:03:40.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/83/b85875d7428e63dfa9247e41d17fac611443c774f7892f8643bd4164a6b2/zstd-1.5.7.3-cp312-cp312-win_amd64.whl", hash = "sha256:943a189910f2fea997462e3e4d7fbf727a06d231ef801ebee557b1c87568981c", size = 167604, upload-time = "2026-01-08T17:03:41.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/42/cf291e26804de2f55500cdac93f5e9fa6267cf315def8aa402529bae3a87/zstd-1.5.7.3-cp312-cp312-win_arm64.whl", hash = "sha256:85c4d508f8109afa7c51c4960626c3325af2cf1e442c6c36ebfea15d04757e3f", size = 157241, upload-time = "2026-01-08T16:47:34.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/b8/d13d584867d5eb1bc607877a870858e02a256d4706a4274e475413a000aa/zstd-1.5.7.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:76c49ea969bc08389ea59155cea7c5dea224522ffc62f443f3c0a915f5fd184d", size = 260025, upload-time = "2026-01-08T16:57:45.739Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/a1/1e5faf75bedfd2bfccfb83e18736b115bed6e348504bd21800cd8f30dcea/zstd-1.5.7.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:6b1a638ff3dfce8f4cb1203c662fb5606dd99b4a62c5ddc4c406d2d1326bcfdd", size = 221038, upload-time = "2026-01-08T17:16:32.005Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/2c/0fe74d8b2029eef8000bc71aac5b3e5b55d00581238711cf627814183ea3/zstd-1.5.7.3-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5e96a5cb100a0edc162935227f2d9784b1031ce4a8a83e96e66eae2673c10143", size = 326792, upload-time = "2026-01-08T16:57:35.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/e0/2c7f081f3524f872128ff31bea2acb6b21cb1dacccef920eb6a1a77a87c6/zstd-1.5.7.3-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bda0bbf3a9553720cd33f1f85940a259656c7ffba4be717ff82b7f062052188", size = 322283, upload-time = "2026-01-08T16:57:36.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/a7/3bebfcc18d66b90bc7b506a61b2ff4af5ee1b0b16e784ea644afa06241c5/zstd-1.5.7.3-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac36e4022422f6e49b3f07bdbb8a964fd348223d3dc9c82ad5398a4f0432a719", size = 311553, upload-time = "2026-01-08T16:57:38.465Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/75/8a791cae2c98e5e44a158e15db50d21b7ec0b37aeaffa68d151bc8ffb6d6/zstd-1.5.7.3-pp311-pypy311_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:fa4d760a220541b18ce732a3a2cf7547ea05afc76d05b3b39edebfeb721f6079", size = 317071, upload-time = "2026-01-08T16:36:07.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/25/b6624e6b08d515242154436c9d06fb20b790d300ac82e84f3c4c133e25e1/zstd-1.5.7.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a69e60146bf8aaa6a0e6c9a94a7c5f3133d68091e2e5c5a3c5ababf71fd5ec7a", size = 167654, upload-time = "2026-01-08T17:00:56.667Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/2a/0885f6f1921ec1ef4a8f8ab29ab0a335cc867abe4c7aaa4e5031435a32a5/zstd-1.5.7.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f799c1e9900ad77e7a3d994b9b5146d7cfd1cbd1b61c3db53a697bf21ffcc57b", size = 269702, upload-time = "2025-06-23T12:50:11.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/e6/629cf6b77e47fc7149f5724fb4853c48edcdeb10d8c64e391d7026cb10e1/zstd-1.5.7.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1ff4c667f29101566a7b71f06bbd677a63192818396003354131f586383db042", size = 228145, upload-time = "2025-06-23T12:50:10.411Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/b8/9ddefd4670bfe9328ca6657ad335eb8d9c657466247e234a579818b6b0b9/zstd-1.5.7.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:8526a32fa9f67b07fd09e62474e345f8ca1daf3e37a41137643d45bd1bc90773", size = 1536530, upload-time = "2025-06-23T13:51:38.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/6a/1bb836c18760dc1e28ca7a9706016e482ebdea633b980d8505dbb65e18f8/zstd-1.5.7.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:2cec2472760d48a7a3445beaba509d3f7850e200fed65db15a1a66e315baec6a", size = 1616141, upload-time = "2025-06-23T13:51:34.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/7a/bb6c6e2cb2a066e347dc27d45d5205058b69d6c8b8d4ae2ee7d6b91c64a5/zstd-1.5.7.2-cp311-cp311-manylinux_2_4_i686.whl", hash = "sha256:a200c479ee1bb661bc45518e016a1fdc215a1d8f7e4bf6c7de0af254976cfdf6", size = 322188, upload-time = "2025-06-23T13:01:48.704Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/4f/cf0669c8a89fdcc91814bf92bd05cc363d5d12a79b656418c0add6f2d266/zstd-1.5.7.2-cp311-cp311-manylinux_2_4_x86_64.whl", hash = "sha256:f5d159e57a13147aa8293c0f14803a75e9039fd8afdf6cf1c8c2289fb4d2333a", size = 302736, upload-time = "2025-06-23T13:05:33.649Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/bc/e5f8b7f61826323e39e099db1eb5c0e09b18315df1b1ff778f7ae9aadcac/zstd-1.5.7.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:7206934a2bd390080e972a1fed5a897e184dfd71dbb54e978dc11c6b295e1806", size = 1522687, upload-time = "2025-06-23T13:51:35.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/8c/7660a949a020ac9d02b3166a25dd1c12144572d77b11ae92a31d341016da/zstd-1.5.7.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7e0027b20f296d1c9a8e85b8436834cf46560240a29d623aa8eaa8911832eb58", size = 2098794, upload-time = "2025-06-23T13:51:37.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/b2/730c811a78d670104d40c7f08cc8092577cdff870cba42b3158f20fceb57/zstd-1.5.7.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d6b17e5581dd1a13437079bd62838d2635db8eb8aca9c0e9251faa5d4d40a6d7", size = 2112266, upload-time = "2025-06-23T13:51:31.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/74/2c16e1632094db36c8920d4c13b8e2e843024d548ae26888c2d22af6a676/zstd-1.5.7.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b13285c99cc710f60dd270785ec75233018870a1831f5655d862745470a0ca29", size = 2109465, upload-time = "2025-06-23T13:51:32.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/6e/b9c9a834769d96cab2122da1be8c8c700d3f76be796d2b7516e85d2eca0e/zstd-1.5.7.2-cp311-cp311-win32.whl", hash = "sha256:cdb5ec80da299f63f8aeccec0bff3247e96252d4c8442876363ff1b438d8049b", size = 149448, upload-time = "2025-06-23T13:06:21.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/b7/fc22ad6292a32d7676ab815de3a23573beac3679e8abd9914288d1496ceb/zstd-1.5.7.2-cp311-cp311-win_amd64.whl", hash = "sha256:4f6861c8edceb25fda37cdaf422fc5f15dcc88ced37c6a5b3c9011eda51aa218", size = 166592, upload-time = "2025-06-23T13:06:22.126Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/14/096bb77f3e5ef525b452cd6294da33de7f8a8c9647ba78293378fbb0a7ce/zstd-1.5.7.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d2ebe3e60dbace52525fa7aa604479e231dc3e4fcc76d0b4c54d8abce5e58734", size = 269408, upload-time = "2025-06-23T13:11:46.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/b8/2bc2590a34c733ea0570f366e6ad7d889d05c7825bd3ccab01f36ece71c6/zstd-1.5.7.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ef201b6f7d3a6751d85cc52f9e6198d4d870e83d490172016b64a6dd654a9583", size = 228188, upload-time = "2025-06-23T13:11:47.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/80/6252de3a70cfd7767718ad476893f1c7dc129f942cc7ed0322e3137c03d9/zstd-1.5.7.2-cp312-cp312-manylinux_2_14_x86_64.whl", hash = "sha256:ac7bdfedda51b1fcdcf0ab69267d01256fc97ddf666ce894fde0fae9f3630eac", size = 302720, upload-time = "2025-06-23T12:40:11.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/b6/af908387814b99172d3aea6aeb24b19583aadfa45f6021e5e2a0d6d8e99a/zstd-1.5.7.2-cp312-cp312-manylinux_2_4_i686.whl", hash = "sha256:b835405cc4080b378e45029f2fe500e408d1eaedfba7dd7402aba27af16955f9", size = 322237, upload-time = "2025-06-23T13:17:35.482Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/d7/ab9142e002a7eaa451cb4bb37a74c390c489ba8ae75ade543840496eda04/zstd-1.5.7.2-cp312-cp312-win32.whl", hash = "sha256:e4cf97bb97ed6dbb62d139d68fd42fa1af51fd26fd178c501f7b62040e897c50", size = 149453, upload-time = "2025-06-23T13:13:02.786Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/c7/c182ea7bc283f591e3f3c5f0f239e7a92c9bc1f626642ae2c4dfbe51d6f2/zstd-1.5.7.2-cp312-cp312-win_amd64.whl", hash = "sha256:55e2edc4560a5cf8ee9908595e90a15b1f47536ea9aad4b2889f0e6165890a38", size = 166628, upload-time = "2025-06-23T13:13:03.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/c9/a6495a7bf168a78f0a0c01d61d830ebfb401315a64fd1ae8d725c458114c/zstd-1.5.7.2-pp311-pypy311_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:5fb2ff5718fe89181223c23ce7308bd0b4a427239379e2566294da805d8df68a", size = 315542, upload-time = "2025-06-23T12:39:27.598Z" },
|
||||
]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ECS task-definition checks no longer report PASS when `DescribeTaskDefinition` fails before container evidence is gathered
|
||||
@@ -0,0 +1 @@
|
||||
Bump alibabacloud-tea-openapi to 0.4.6, oci to 2.184.1 and pyopenssl to 26.4.0 so the published wheel installs with cryptography 50.0.0; 5.38.0 declared cryptography 50.0.0 while those packages capped it below 50, so pip could not install it and `pip install prowler` silently fell back to 5.37.1
|
||||
@@ -0,0 +1 @@
|
||||
`ses_identity_not_publicly_accessible` now evaluates every SES identity authorization policy and marks mixed public Allow and Deny statements for manual review
|
||||
@@ -0,0 +1 @@
|
||||
Pin zstd to 1.5.7.2; 1.5.7.3 was yanked from PyPI as not thread safe
|
||||
@@ -149,8 +149,8 @@ class ECS(AWSService):
|
||||
"TAGS",
|
||||
],
|
||||
)
|
||||
container_definitions = response["taskDefinition"]["containerDefinitions"]
|
||||
for container in container_definitions:
|
||||
container_definitions = []
|
||||
for container in response["taskDefinition"]["containerDefinitions"]:
|
||||
environment = []
|
||||
if "environment" in container:
|
||||
for env_var in container["environment"]:
|
||||
@@ -159,7 +159,7 @@ class ECS(AWSService):
|
||||
name=env_var["name"], value=env_var["value"]
|
||||
)
|
||||
)
|
||||
task_definition.container_definitions.append(
|
||||
container_definitions.append(
|
||||
ContainerDefinition(
|
||||
name=container["name"],
|
||||
privileged=container.get("privileged", False),
|
||||
@@ -176,14 +176,16 @@ class ECS(AWSService):
|
||||
.get("mode", ""),
|
||||
)
|
||||
)
|
||||
task_definition.pid_mode = response["taskDefinition"].get("pidMode", "")
|
||||
task_definition.registered_at = response["taskDefinition"].get(
|
||||
"registeredAt"
|
||||
)
|
||||
task_definition.tags = response.get("tags")
|
||||
task_definition.network_mode = response["taskDefinition"].get(
|
||||
"networkMode", "bridge"
|
||||
)
|
||||
pid_mode = response["taskDefinition"].get("pidMode", "")
|
||||
registered_at = response["taskDefinition"].get("registeredAt")
|
||||
tags = response.get("tags")
|
||||
network_mode = response["taskDefinition"].get("networkMode", "bridge")
|
||||
|
||||
task_definition.container_definitions = container_definitions
|
||||
task_definition.pid_mode = pid_mode
|
||||
task_definition.registered_at = registered_at
|
||||
task_definition.tags = tags
|
||||
task_definition.network_mode = network_mode
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
@@ -302,7 +304,7 @@ class TaskDefinition(BaseModel):
|
||||
arn: str
|
||||
revision: str
|
||||
region: str
|
||||
container_definitions: list[ContainerDefinition] = []
|
||||
container_definitions: Optional[list[ContainerDefinition]] = None
|
||||
pid_mode: Optional[str]
|
||||
registered_at: Optional[datetime] = None
|
||||
tags: Optional[list] = []
|
||||
|
||||
+2
@@ -6,6 +6,8 @@ class ecs_task_definitions_containers_readonly_access(Check):
|
||||
def execute(self):
|
||||
findings = []
|
||||
for task_definition in ecs_client.task_definitions.values():
|
||||
if task_definition.container_definitions is None:
|
||||
continue
|
||||
report = Check_Report_AWS(
|
||||
metadata=self.metadata(), resource=task_definition
|
||||
)
|
||||
|
||||
+2
@@ -6,6 +6,8 @@ class ecs_task_definitions_host_namespace_not_shared(Check):
|
||||
def execute(self):
|
||||
findings = []
|
||||
for task_definition in ecs_client.task_definitions.values():
|
||||
if task_definition.container_definitions is None:
|
||||
continue
|
||||
report = Check_Report_AWS(
|
||||
metadata=self.metadata(), resource=task_definition
|
||||
)
|
||||
|
||||
+2
@@ -6,6 +6,8 @@ class ecs_task_definitions_host_networking_mode_users(Check):
|
||||
def execute(self):
|
||||
findings = []
|
||||
for task_definition in ecs_client.task_definitions.values():
|
||||
if task_definition.container_definitions is None:
|
||||
continue
|
||||
report = Check_Report_AWS(
|
||||
metadata=self.metadata(), resource=task_definition
|
||||
)
|
||||
|
||||
+2
@@ -6,6 +6,8 @@ class ecs_task_definitions_logging_block_mode(Check):
|
||||
def execute(self):
|
||||
findings = []
|
||||
for task_definition in ecs_client.task_definitions.values():
|
||||
if task_definition.container_definitions is None:
|
||||
continue
|
||||
report = Check_Report_AWS(
|
||||
metadata=self.metadata(), resource=task_definition
|
||||
)
|
||||
|
||||
+2
@@ -6,6 +6,8 @@ class ecs_task_definitions_logging_enabled(Check):
|
||||
def execute(self):
|
||||
findings = []
|
||||
for task_definition in ecs_client.task_definitions.values():
|
||||
if task_definition.container_definitions is None:
|
||||
continue
|
||||
report = Check_Report_AWS(
|
||||
metadata=self.metadata(), resource=task_definition
|
||||
)
|
||||
|
||||
+5
-1
@@ -16,7 +16,11 @@ class ecs_task_definitions_no_environment_secrets(Check):
|
||||
"secrets_ignore_patterns", []
|
||||
)
|
||||
validate = ecs_client.audit_config.get("secrets_validate", False)
|
||||
task_definitions = list(ecs_client.task_definitions.values())
|
||||
task_definitions = [
|
||||
task_definition
|
||||
for task_definition in ecs_client.task_definitions.values()
|
||||
if task_definition.container_definitions is not None
|
||||
]
|
||||
|
||||
# Scan every (task definition, container) environment in batched
|
||||
# Kingfisher invocations instead of one subprocess per container.
|
||||
|
||||
+2
@@ -6,6 +6,8 @@ class ecs_task_definitions_no_privileged_containers(Check):
|
||||
def execute(self):
|
||||
findings = []
|
||||
for task_definition in ecs_client.task_definitions.values():
|
||||
if task_definition.container_definitions is None:
|
||||
continue
|
||||
report = Check_Report_AWS(
|
||||
metadata=self.metadata(), resource=task_definition
|
||||
)
|
||||
|
||||
+41
-8
@@ -1,25 +1,58 @@
|
||||
from copy import deepcopy
|
||||
|
||||
from prowler.lib.check.models import Check, Check_Report_AWS
|
||||
from prowler.providers.aws.services.iam.lib.policy import is_policy_public
|
||||
from prowler.providers.aws.services.ses.ses_client import ses_client
|
||||
|
||||
|
||||
def _normalize_policy_statements(policy: dict) -> dict:
|
||||
statements = policy.get("Statement", [])
|
||||
if isinstance(statements, dict):
|
||||
return {**policy, "Statement": [statements]}
|
||||
return policy
|
||||
|
||||
|
||||
def _has_explicit_deny(policy: dict) -> bool:
|
||||
return any(
|
||||
isinstance(statement, dict) and statement.get("Effect") == "Deny"
|
||||
for statement in _normalize_policy_statements(policy).get("Statement", [])
|
||||
)
|
||||
|
||||
|
||||
class ses_identity_not_publicly_accessible(Check):
|
||||
def execute(self):
|
||||
"""Ensure SES identities are not publicly accessible through authorization policies."""
|
||||
|
||||
def execute(self) -> list[Check_Report_AWS]:
|
||||
"""Evaluate every authorization policy attached to each SES identity.
|
||||
|
||||
Returns:
|
||||
A list of reports containing the public-access result for each identity.
|
||||
"""
|
||||
findings = []
|
||||
for identity in ses_client.email_identities.values():
|
||||
if identity.policy is None:
|
||||
if not identity.policies:
|
||||
continue
|
||||
report = Check_Report_AWS(metadata=self.metadata(), resource=identity)
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"SES identity {identity.name} is not publicly accessible."
|
||||
)
|
||||
if is_policy_public(
|
||||
identity.policy,
|
||||
ses_client.audited_account,
|
||||
):
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"SES identity {identity.name} is publicly accessible due to its resource policy."
|
||||
has_public_allow = any(
|
||||
is_policy_public(
|
||||
_normalize_policy_statements(deepcopy(policy)),
|
||||
ses_client.audited_account,
|
||||
)
|
||||
for policy in identity.policies.values()
|
||||
)
|
||||
if has_public_allow:
|
||||
if any(
|
||||
_has_explicit_deny(policy) for policy in identity.policies.values()
|
||||
):
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = f"SES identity {identity.name} has public Allow and explicit Deny statements in its resource policies. Effective public access requires manual review."
|
||||
else:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"SES identity {identity.name} is publicly accessible due to its resource policies."
|
||||
|
||||
findings.append(report)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from json import loads
|
||||
from typing import Optional
|
||||
|
||||
from pydantic.v1 import BaseModel
|
||||
from pydantic.v1 import BaseModel, Field
|
||||
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.lib.scan_filters.scan_filters import is_resource_filtered
|
||||
@@ -46,8 +46,11 @@ class SES(AWSService):
|
||||
identity_attributes = regional_client.get_email_identity(
|
||||
EmailIdentity=identity.name
|
||||
)
|
||||
for _, content in identity_attributes.get("Policies", {}).items():
|
||||
identity.policy = loads(content)
|
||||
identity.policies = {
|
||||
name: loads(content)
|
||||
for name, content in identity_attributes.get("Policies", {}).items()
|
||||
}
|
||||
identity.policy = next(reversed(identity.policies.values()), None)
|
||||
identity.tags = identity_attributes.get("Tags", [])
|
||||
dkim_attrs = identity_attributes.get("DkimAttributes", {}) or {}
|
||||
identity.dkim_status = dkim_attrs.get("Status")
|
||||
@@ -72,6 +75,7 @@ class Identity(BaseModel):
|
||||
region: str
|
||||
type: Optional[str]
|
||||
policy: Optional[dict] = None
|
||||
policies: dict[str, dict] = Field(default_factory=dict)
|
||||
tags: Optional[list] = []
|
||||
dkim_status: Optional[str] = None
|
||||
dkim_signing_attributes_origin: Optional[str] = None
|
||||
|
||||
+11
-11
@@ -68,6 +68,10 @@ dependencies = [
|
||||
"boto3==1.40.61",
|
||||
"botocore==1.40.61",
|
||||
"colorama==0.4.6",
|
||||
# cryptography 50 needs alibabacloud-tea-openapi>=0.4.6, oci>=2.184.1 and, in the
|
||||
# [tool.uv] pins, msal>=1.37.0 and pyopenssl>=26.4.0: earlier releases cap it below 49
|
||||
# or 50. Keep the five in step. Never widen a cap with [tool.uv] override-dependencies:
|
||||
# overrides do not ship in the wheel, and 5.38.0 was uninstallable with pip because of one.
|
||||
"cryptography==50.0.0",
|
||||
"dash==3.1.1",
|
||||
"dash-bootstrap-components==2.0.3",
|
||||
@@ -103,10 +107,10 @@ dependencies = [
|
||||
"uuid6==2024.7.10",
|
||||
"py-iam-expand==0.3.0",
|
||||
"h2==4.3.0",
|
||||
"oci==2.183.0",
|
||||
"oci==2.184.1",
|
||||
"alibabacloud_credentials==1.0.3",
|
||||
"alibabacloud_ram20150501==1.2.0",
|
||||
"alibabacloud_tea_openapi==0.4.5",
|
||||
"alibabacloud_tea_openapi==0.4.6",
|
||||
"alibabacloud_sts20150401==1.1.6",
|
||||
"alibabacloud_vpc20160428==6.13.0",
|
||||
"alibabacloud_ecs20140526==7.2.5",
|
||||
@@ -199,7 +203,7 @@ constraint-dependencies = [
|
||||
"alibabacloud-sas20181203==6.1.0",
|
||||
"alibabacloud-sts20150401==1.1.6",
|
||||
"alibabacloud-tea==0.4.3",
|
||||
"alibabacloud-tea-openapi==0.4.5",
|
||||
"alibabacloud-tea-openapi==0.4.6",
|
||||
"alibabacloud-tea-util==0.3.14",
|
||||
"alibabacloud-tea-xml==0.0.3",
|
||||
"alibabacloud-vpc20160428==6.13.0",
|
||||
@@ -300,7 +304,7 @@ constraint-dependencies = [
|
||||
"mock==5.2.0",
|
||||
"moto==5.1.11",
|
||||
"mpmath==1.3.0",
|
||||
"msal==1.36.0",
|
||||
"msal==1.37.0",
|
||||
"msal-extensions==1.3.1",
|
||||
"msgraph-core==1.3.8",
|
||||
"msrest==0.7.1",
|
||||
@@ -343,7 +347,7 @@ constraint-dependencies = [
|
||||
"pyjwt==2.13.0",
|
||||
"pylint==3.3.4",
|
||||
"pynacl==1.6.2",
|
||||
"pyopenssl==26.2.0",
|
||||
"pyopenssl==26.4.0",
|
||||
"pyparsing==3.3.2",
|
||||
"pytest==9.0.3",
|
||||
"pytest-cov==6.0.0",
|
||||
@@ -387,13 +391,9 @@ constraint-dependencies = [
|
||||
"xmltodict==1.0.4",
|
||||
"yarl==1.23.0",
|
||||
"zipp==3.23.1",
|
||||
"zstd==1.5.7.3"
|
||||
]
|
||||
override-dependencies = [
|
||||
"okta==3.4.2",
|
||||
# alibabacloud-tea-openapi 0.4.5 caps cryptography below 49 and is the latest release.
|
||||
"cryptography==50.0.0"
|
||||
"zstd==1.5.7.2"
|
||||
]
|
||||
override-dependencies = ["okta==3.4.2"]
|
||||
|
||||
[tool.vulture]
|
||||
# Suppress known false positives. The CI command only passes --exclude and
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
from datetime import datetime, timezone
|
||||
from importlib import import_module
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import botocore
|
||||
import pytest
|
||||
|
||||
from prowler.providers.aws.services.ecs.ecs_service import ECS, TaskDefinition
|
||||
from tests.providers.aws.utils import (
|
||||
AWS_ACCOUNT_NUMBER,
|
||||
AWS_REGION_US_EAST_1,
|
||||
set_mocked_aws_provider,
|
||||
)
|
||||
|
||||
TASK_NAME = "test-task"
|
||||
TASK_REVISION = "1"
|
||||
TASK_ARN = (
|
||||
f"arn:aws:ecs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:"
|
||||
f"task-definition/{TASK_NAME}:{TASK_REVISION}"
|
||||
)
|
||||
make_api_call = botocore.client.BaseClient._make_api_call
|
||||
|
||||
|
||||
def _mock_ecs_api(describe_result):
|
||||
def mock_make_api_call(self, operation_name, kwargs):
|
||||
if operation_name == "ListTaskDefinitions":
|
||||
return {"taskDefinitionArns": [TASK_ARN]}
|
||||
if operation_name == "DescribeTaskDefinition":
|
||||
if isinstance(describe_result, Exception):
|
||||
raise describe_result
|
||||
return describe_result
|
||||
if operation_name == "ListClusters":
|
||||
return {"clusterArns": []}
|
||||
return make_api_call(self, operation_name, kwargs)
|
||||
|
||||
return mock_make_api_call
|
||||
|
||||
|
||||
def _collect_task_definition(describe_result):
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
with patch(
|
||||
"botocore.client.BaseClient._make_api_call",
|
||||
new=_mock_ecs_api(describe_result),
|
||||
):
|
||||
return ECS(aws_provider).task_definitions[TASK_ARN]
|
||||
|
||||
|
||||
def _undescribed_ecs_client():
|
||||
task_definition = TaskDefinition(
|
||||
name=TASK_NAME,
|
||||
arn=TASK_ARN,
|
||||
revision=TASK_REVISION,
|
||||
region=AWS_REGION_US_EAST_1,
|
||||
environment_variables=[],
|
||||
)
|
||||
task_definition.container_definitions = None
|
||||
return SimpleNamespace(
|
||||
audit_config={},
|
||||
task_definitions={TASK_ARN: task_definition},
|
||||
)
|
||||
|
||||
|
||||
def test_failed_describe_leaves_task_definition_undescribed():
|
||||
error = botocore.exceptions.ClientError(
|
||||
{"Error": {"Code": "ThrottlingException", "Message": "rate exceeded"}},
|
||||
"DescribeTaskDefinition",
|
||||
)
|
||||
|
||||
task_definition = _collect_task_definition(error)
|
||||
|
||||
assert task_definition.container_definitions is None
|
||||
assert task_definition.pid_mode is None
|
||||
assert task_definition.network_mode is None
|
||||
|
||||
|
||||
def test_successful_describe_preserves_empty_container_definitions():
|
||||
task_definition = _collect_task_definition(
|
||||
{
|
||||
"taskDefinition": {
|
||||
"containerDefinitions": [],
|
||||
"pidMode": "task",
|
||||
"networkMode": "awsvpc",
|
||||
},
|
||||
"tags": [],
|
||||
}
|
||||
)
|
||||
|
||||
assert task_definition.container_definitions == []
|
||||
assert task_definition.pid_mode == "task"
|
||||
assert task_definition.network_mode == "awsvpc"
|
||||
|
||||
|
||||
def test_partial_parse_leaves_task_definition_undescribed():
|
||||
task_definition = _collect_task_definition(
|
||||
{
|
||||
"taskDefinition": {
|
||||
"containerDefinitions": [
|
||||
{"name": "valid-container"},
|
||||
{"privileged": False},
|
||||
],
|
||||
"pidMode": "host",
|
||||
"networkMode": "host",
|
||||
"registeredAt": datetime(2026, 8, 13, tzinfo=timezone.utc),
|
||||
},
|
||||
"tags": [{"key": "Environment", "value": "production"}],
|
||||
}
|
||||
)
|
||||
|
||||
assert task_definition.container_definitions is None
|
||||
assert task_definition.pid_mode is None
|
||||
assert task_definition.network_mode is None
|
||||
assert task_definition.registered_at is None
|
||||
assert task_definition.tags == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("check_package", "check_name"),
|
||||
[
|
||||
(
|
||||
"ecs_task_definitions_containers_readonly_access",
|
||||
"ecs_task_definitions_containers_readonly_access",
|
||||
),
|
||||
(
|
||||
"ecs_task_definitions_host_namespace_not_shared",
|
||||
"ecs_task_definitions_host_namespace_not_shared",
|
||||
),
|
||||
(
|
||||
"ecs_task_definitions_host_networking_mode_users",
|
||||
"ecs_task_definitions_host_networking_mode_users",
|
||||
),
|
||||
(
|
||||
"ecs_task_definitions_logging_block_mode",
|
||||
"ecs_task_definitions_logging_block_mode",
|
||||
),
|
||||
(
|
||||
"ecs_task_definitions_logging_enabled",
|
||||
"ecs_task_definitions_logging_enabled",
|
||||
),
|
||||
(
|
||||
"ecs_task_definitions_no_environment_secrets",
|
||||
"ecs_task_definitions_no_environment_secrets",
|
||||
),
|
||||
(
|
||||
"ecs_task_definitions_no_privileged_containers",
|
||||
"ecs_task_definitions_no_privileged_containers",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_undescribed_task_definitions_are_not_reported(
|
||||
check_package, check_name, monkeypatch
|
||||
):
|
||||
with patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider([AWS_REGION_US_EAST_1]),
|
||||
):
|
||||
module = import_module(
|
||||
f"prowler.providers.aws.services.ecs.{check_package}.{check_name}"
|
||||
)
|
||||
monkeypatch.setattr(module, "ecs_client", _undescribed_ecs_client())
|
||||
|
||||
check = getattr(module, check_name)()
|
||||
|
||||
assert check.execute() == []
|
||||
+218
-1
@@ -1,6 +1,8 @@
|
||||
from copy import deepcopy
|
||||
from unittest import mock
|
||||
|
||||
import botocore
|
||||
import pytest
|
||||
from boto3 import client
|
||||
from moto import mock_aws
|
||||
|
||||
@@ -54,6 +56,113 @@ def mock_make_api_call_v2(self, operation_name, kwarg):
|
||||
return make_api_call(self, operation_name, kwarg)
|
||||
|
||||
|
||||
PUBLIC_ALLOW_POLICY = '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"ses:SendEmail","Resource":"*"}]}'
|
||||
PRIVATE_ALLOW_POLICY = '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"arn:aws:iam::123456789012:root"},"Action":"ses:SendEmail","Resource":"*"}]}'
|
||||
MATCHING_DENY_POLICY = '{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Principal":"*","Action":"ses:SendEmail","Resource":"*"}]}'
|
||||
UNRELATED_DENY_POLICY = '{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Principal":"*","Action":"ses:SendRawEmail","Resource":"*"}]}'
|
||||
PUBLIC_ALLOW_AND_DENY_POLICY = '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"ses:SendEmail","Resource":"*"},{"Effect":"Deny","Principal":"*","Action":"ses:SendEmail","Resource":"*"}]}'
|
||||
PUBLIC_ALLOW_SINGLE_STATEMENT_POLICY = '{"Version":"2012-10-17","Statement":{"Effect":"Allow","Principal":"*","Action":"ses:SendEmail","Resource":"*"}}'
|
||||
PRIVATE_ALLOW_SINGLE_STATEMENT_POLICY = '{"Version":"2012-10-17","Statement":{"Effect":"Allow","Principal":{"AWS":"arn:aws:iam::123456789012:root"},"Action":"ses:SendEmail","Resource":"*"}}'
|
||||
MATCHING_DENY_SINGLE_STATEMENT_POLICY = '{"Version":"2012-10-17","Statement":{"Effect":"Deny","Principal":"*","Action":"ses:SendEmail","Resource":"*"}}'
|
||||
CONDITIONAL_ALLOW_SINGLE_STATEMENT_POLICY = '{"Version":"2012-10-17","Statement":{"Effect":"Allow","Principal":"*","Action":"ses:SendEmail","Resource":"*","Condition":{"StringEquals":{"AWS:SourceAccount":"123456789012"}}}}'
|
||||
|
||||
|
||||
def make_multiple_policies_api_mock(policies):
|
||||
def mock_api_call(self, operation_name, kwarg):
|
||||
if operation_name == "ListEmailIdentities":
|
||||
return {
|
||||
"EmailIdentities": [
|
||||
{
|
||||
"IdentityType": "DOMAIN",
|
||||
"IdentityName": "test-email-identity-multiple-policies",
|
||||
}
|
||||
],
|
||||
}
|
||||
elif operation_name == "GetEmailIdentity":
|
||||
return {"Policies": policies, "Tags": {}}
|
||||
return make_api_call(self, operation_name, kwarg)
|
||||
|
||||
return mock_api_call
|
||||
|
||||
|
||||
mock_make_api_call_multiple_policies = make_multiple_policies_api_mock(
|
||||
{
|
||||
"public-policy": PUBLIC_ALLOW_POLICY,
|
||||
"private-policy": PRIVATE_ALLOW_POLICY,
|
||||
}
|
||||
)
|
||||
mock_make_api_call_multiple_policies_reversed = make_multiple_policies_api_mock(
|
||||
{
|
||||
"private-policy": PRIVATE_ALLOW_POLICY,
|
||||
"public-policy": PUBLIC_ALLOW_POLICY,
|
||||
}
|
||||
)
|
||||
mock_make_api_call_public_allow_and_matching_deny = make_multiple_policies_api_mock(
|
||||
{
|
||||
"public-policy": PUBLIC_ALLOW_POLICY,
|
||||
"deny-policy": MATCHING_DENY_POLICY,
|
||||
}
|
||||
)
|
||||
mock_make_api_call_matching_deny_and_public_allow = make_multiple_policies_api_mock(
|
||||
{
|
||||
"deny-policy": MATCHING_DENY_POLICY,
|
||||
"public-policy": PUBLIC_ALLOW_POLICY,
|
||||
}
|
||||
)
|
||||
mock_make_api_call_public_allow_and_unrelated_deny = make_multiple_policies_api_mock(
|
||||
{
|
||||
"public-policy": PUBLIC_ALLOW_POLICY,
|
||||
"deny-policy": UNRELATED_DENY_POLICY,
|
||||
}
|
||||
)
|
||||
mock_make_api_call_same_policy_allow_and_deny = make_multiple_policies_api_mock(
|
||||
{"combined-policy": PUBLIC_ALLOW_AND_DENY_POLICY}
|
||||
)
|
||||
mock_make_api_call_multiple_private_policies = make_multiple_policies_api_mock(
|
||||
{
|
||||
"private-policy-1": PRIVATE_ALLOW_POLICY,
|
||||
"private-policy-2": PRIVATE_ALLOW_POLICY,
|
||||
}
|
||||
)
|
||||
mock_make_api_call_public_single_statement = make_multiple_policies_api_mock(
|
||||
{"public-policy": PUBLIC_ALLOW_SINGLE_STATEMENT_POLICY}
|
||||
)
|
||||
mock_make_api_call_private_single_statement = make_multiple_policies_api_mock(
|
||||
{"private-policy": PRIVATE_ALLOW_SINGLE_STATEMENT_POLICY}
|
||||
)
|
||||
mock_make_api_call_public_and_deny_single_statements = make_multiple_policies_api_mock(
|
||||
{
|
||||
"public-policy": PUBLIC_ALLOW_SINGLE_STATEMENT_POLICY,
|
||||
"deny-policy": MATCHING_DENY_SINGLE_STATEMENT_POLICY,
|
||||
}
|
||||
)
|
||||
mock_make_api_call_conditional_single_statement = make_multiple_policies_api_mock(
|
||||
{"conditional-policy": CONDITIONAL_ALLOW_SINGLE_STATEMENT_POLICY}
|
||||
)
|
||||
|
||||
|
||||
def execute_check_with_api_mock(api_call_mock):
|
||||
with mock.patch("botocore.client.BaseClient._make_api_call", new=api_call_mock):
|
||||
client("sesv2", region_name=AWS_REGION_EU_WEST_1)
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ses.ses_identity_not_publicly_accessible.ses_identity_not_publicly_accessible.ses_client",
|
||||
new=SES(aws_provider),
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ses.ses_identity_not_publicly_accessible.ses_identity_not_publicly_accessible import (
|
||||
ses_identity_not_publicly_accessible,
|
||||
)
|
||||
|
||||
return ses_identity_not_publicly_accessible().execute()
|
||||
|
||||
|
||||
class Test_ses_identities_not_publicly_accessible:
|
||||
@mock_aws
|
||||
def test_no_identities(self):
|
||||
@@ -114,6 +223,114 @@ class Test_ses_identities_not_publicly_accessible:
|
||||
assert result[0].resource_tags == {"tag1": "value1", "tag2": "value2"}
|
||||
assert result[0].region == AWS_REGION_EU_WEST_1
|
||||
|
||||
@mock_aws
|
||||
@pytest.mark.parametrize(
|
||||
"api_call_mock",
|
||||
[
|
||||
mock_make_api_call_multiple_policies,
|
||||
mock_make_api_call_multiple_policies_reversed,
|
||||
],
|
||||
ids=["public-policy-first", "public-policy-last"],
|
||||
)
|
||||
def test_email_identity_public_when_any_policy_is_public(self, api_call_mock):
|
||||
result = execute_check_with_api_mock(api_call_mock)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert (
|
||||
result[0].status_extended
|
||||
== "SES identity test-email-identity-multiple-policies is publicly accessible due to its resource policies."
|
||||
)
|
||||
|
||||
@mock_aws
|
||||
@pytest.mark.parametrize(
|
||||
"api_call_mock",
|
||||
[
|
||||
mock_make_api_call_public_allow_and_matching_deny,
|
||||
mock_make_api_call_matching_deny_and_public_allow,
|
||||
mock_make_api_call_public_allow_and_unrelated_deny,
|
||||
mock_make_api_call_same_policy_allow_and_deny,
|
||||
],
|
||||
ids=[
|
||||
"matching-deny-last",
|
||||
"matching-deny-first",
|
||||
"unrelated-deny",
|
||||
"same-policy-deny",
|
||||
],
|
||||
)
|
||||
def test_email_identity_public_allow_with_explicit_deny_is_manual(
|
||||
self, api_call_mock
|
||||
):
|
||||
result = execute_check_with_api_mock(api_call_mock)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "MANUAL"
|
||||
assert (
|
||||
result[0].status_extended
|
||||
== "SES identity test-email-identity-multiple-policies has public Allow and explicit Deny statements in its resource policies. Effective public access requires manual review."
|
||||
)
|
||||
|
||||
@mock_aws
|
||||
def test_email_identity_multiple_private_policies(self):
|
||||
result = execute_check_with_api_mock(
|
||||
mock_make_api_call_multiple_private_policies
|
||||
)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert (
|
||||
result[0].status_extended
|
||||
== "SES identity test-email-identity-multiple-policies is not publicly accessible."
|
||||
)
|
||||
|
||||
@mock_aws
|
||||
@pytest.mark.parametrize(
|
||||
("api_call_mock", "expected_status"),
|
||||
[
|
||||
(mock_make_api_call_public_single_statement, "FAIL"),
|
||||
(mock_make_api_call_private_single_statement, "PASS"),
|
||||
(mock_make_api_call_public_and_deny_single_statements, "MANUAL"),
|
||||
],
|
||||
ids=["public", "private", "public-with-deny"],
|
||||
)
|
||||
def test_email_identity_single_statement_policy(
|
||||
self, api_call_mock, expected_status
|
||||
):
|
||||
result = execute_check_with_api_mock(api_call_mock)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == expected_status
|
||||
|
||||
@mock_aws
|
||||
def test_check_preserves_nested_policy_condition_keys(self):
|
||||
with mock.patch(
|
||||
"botocore.client.BaseClient._make_api_call",
|
||||
new=mock_make_api_call_conditional_single_statement,
|
||||
):
|
||||
client("sesv2", region_name=AWS_REGION_EU_WEST_1)
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
|
||||
ses_client = SES(aws_provider)
|
||||
identity = next(iter(ses_client.email_identities.values()))
|
||||
policies_before_check = deepcopy(identity.policies)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ses.ses_identity_not_publicly_accessible.ses_identity_not_publicly_accessible.ses_client",
|
||||
new=ses_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ses.ses_identity_not_publicly_accessible.ses_identity_not_publicly_accessible import (
|
||||
ses_identity_not_publicly_accessible,
|
||||
)
|
||||
|
||||
ses_identity_not_publicly_accessible().execute()
|
||||
|
||||
assert identity.policies == policies_before_check
|
||||
|
||||
@mock_aws
|
||||
@mock.patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call_v2)
|
||||
def test_email_identity_public(self):
|
||||
@@ -140,7 +357,7 @@ class Test_ses_identities_not_publicly_accessible:
|
||||
assert result[0].status == "FAIL"
|
||||
assert (
|
||||
result[0].status_extended
|
||||
== "SES identity test-email-identity-public is publicly accessible due to its resource policy."
|
||||
== "SES identity test-email-identity-public is publicly accessible due to its resource policies."
|
||||
)
|
||||
assert result[0].resource_id == "test-email-identity-public"
|
||||
assert (
|
||||
|
||||
@@ -27,6 +27,7 @@ def mock_make_api_call(self, operation_name, kwarg):
|
||||
return {
|
||||
"Policies": {
|
||||
"policy1": '{"policy1": "value1"}',
|
||||
"policy2": '{"policy2": "value2"}',
|
||||
},
|
||||
"Tags": {"tag1": "value1", "tag2": "value2"},
|
||||
"DkimAttributes": {
|
||||
@@ -81,7 +82,11 @@ class Test_SES_Service:
|
||||
assert ses.email_identities[arn].type == "EMAIL_ADDRESS"
|
||||
assert ses.email_identities[arn].arn == arn
|
||||
assert ses.email_identities[arn].region == AWS_REGION_EU_WEST_1
|
||||
assert ses.email_identities[arn].policy == {"policy1": "value1"}
|
||||
assert ses.email_identities[arn].policy == {"policy2": "value2"}
|
||||
assert ses.email_identities[arn].policies == {
|
||||
"policy1": {"policy1": "value1"},
|
||||
"policy2": {"policy2": "value2"},
|
||||
}
|
||||
assert ses.email_identities[arn].tags == {"tag1": "value1", "tag2": "value2"}
|
||||
assert ses.email_identities[arn].dkim_status == "SUCCESS"
|
||||
assert ses.email_identities[arn].dkim_signing_attributes_origin == "AWS_SES"
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from util.check_yanked_pins import (
|
||||
Pin,
|
||||
collect_pins,
|
||||
evaluate,
|
||||
main,
|
||||
normalize,
|
||||
pins_from_pyproject,
|
||||
pins_from_uv_lock,
|
||||
)
|
||||
|
||||
PYPROJECT = """
|
||||
[project]
|
||||
name = "demo"
|
||||
dependencies = [
|
||||
"cryptography==48.0.1",
|
||||
"alibabacloud_tea_openapi==0.4.5",
|
||||
"Requests[security]==2.34.2 ; python_version >= '3.10'",
|
||||
"boto3>=1.40",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
extra = ["okta==3.4.2"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["pytest==9.0.3", {include-group = "lint"}]
|
||||
lint = ["flake8==7.1.2"]
|
||||
|
||||
[tool.uv]
|
||||
constraint-dependencies = ["zstd==1.5.7.3"]
|
||||
override-dependencies = ["okta==3.4.2"]
|
||||
"""
|
||||
|
||||
UV_LOCK = """
|
||||
version = 1
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "1.5.7.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
|
||||
[[package]]
|
||||
name = "Cryptography"
|
||||
version = "48.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
|
||||
[[package]]
|
||||
name = "prowler"
|
||||
version = "5.40.0"
|
||||
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#abc" }
|
||||
|
||||
[[package]]
|
||||
name = "demo"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
"""
|
||||
|
||||
|
||||
class TestNormalize:
|
||||
"""normalize() applies PEP 503 so spellings of one project compare equal."""
|
||||
|
||||
def test_pep503_equivalence(self):
|
||||
"""Underscores, dots and case collapse to the canonical dashed lowercase form."""
|
||||
assert normalize("alibabacloud_tea_openapi") == "alibabacloud-tea-openapi"
|
||||
assert normalize("Requests") == "requests"
|
||||
assert normalize("zope.interface") == "zope-interface"
|
||||
|
||||
|
||||
class TestPinsFromPyproject:
|
||||
"""pins_from_pyproject() reads exact pins from every dependency-bearing table."""
|
||||
|
||||
def test_collects_exact_pins_from_every_table(self):
|
||||
"""Dependencies, extras, dependency groups and both [tool.uv] lists are covered."""
|
||||
pins = pins_from_pyproject(PYPROJECT, "")
|
||||
assert {(p.name, p.version) for p in pins} == {
|
||||
("cryptography", "48.0.1"),
|
||||
("alibabacloud-tea-openapi", "0.4.5"),
|
||||
("requests", "2.34.2"),
|
||||
("okta", "3.4.2"),
|
||||
("pytest", "9.0.3"),
|
||||
("flake8", "7.1.2"),
|
||||
("zstd", "1.5.7.3"),
|
||||
}
|
||||
|
||||
def test_ignores_ranges_and_records_source_table(self):
|
||||
"""Non-exact specifiers are skipped and each pin remembers its table."""
|
||||
pins = pins_from_pyproject(PYPROJECT, "api/")
|
||||
names = {p.name for p in pins}
|
||||
assert "boto3" not in names
|
||||
zstd = next(p for p in pins if p.name == "zstd")
|
||||
assert zstd.source == "api/pyproject.toml [tool.uv.constraint-dependencies]"
|
||||
|
||||
def test_same_pin_in_two_tables_keeps_both_sources(self):
|
||||
"""The same version in two tables yields two pins, one per source."""
|
||||
okta = {
|
||||
p.source for p in pins_from_pyproject(PYPROJECT, "") if p.name == "okta"
|
||||
}
|
||||
assert okta == {
|
||||
"pyproject.toml [project.optional-dependencies.extra]",
|
||||
"pyproject.toml [tool.uv.override-dependencies]",
|
||||
}
|
||||
|
||||
|
||||
class TestPinsFromUvLock:
|
||||
"""pins_from_uv_lock() reads locked versions that live on a registry."""
|
||||
|
||||
def test_only_registry_packages(self):
|
||||
"""git, path and editable sources are not on PyPI and are skipped."""
|
||||
pins = pins_from_uv_lock(UV_LOCK, "")
|
||||
assert {(p.name, p.version) for p in pins} == {
|
||||
("zstd", "1.5.7.3"),
|
||||
("cryptography", "48.0.1"),
|
||||
}
|
||||
assert all(p.source == "uv.lock" for p in pins)
|
||||
|
||||
|
||||
class TestCollectPins:
|
||||
"""collect_pins() merges a project's pyproject.toml and uv.lock."""
|
||||
|
||||
def test_missing_files_raise(self, tmp_path: Path):
|
||||
"""A directory with neither file is a caller error, not an empty result."""
|
||||
with pytest.raises(FileNotFoundError):
|
||||
collect_pins(tmp_path)
|
||||
|
||||
def test_merges_pyproject_and_lock(self, tmp_path: Path):
|
||||
"""Pins from both files are returned with the directory as source prefix."""
|
||||
(tmp_path / "pyproject.toml").write_text(PYPROJECT)
|
||||
(tmp_path / "uv.lock").write_text(UV_LOCK)
|
||||
sources = {p.source for p in collect_pins(tmp_path)}
|
||||
prefix = f"{tmp_path.as_posix()}/"
|
||||
assert f"{prefix}uv.lock" in sources
|
||||
assert f"{prefix}pyproject.toml [project.dependencies]" in sources
|
||||
|
||||
|
||||
class TestEvaluate:
|
||||
"""evaluate() queries PyPI once per release and reports per pin."""
|
||||
|
||||
def test_queries_each_release_once_and_fans_out_to_every_source(self):
|
||||
"""One fetch per (name, version); its verdict reaches every source of that pin."""
|
||||
calls = []
|
||||
|
||||
def fake_fetch(name, version):
|
||||
"""Stand-in for fetch_release() that records calls and returns fixed verdicts."""
|
||||
calls.append((name, version))
|
||||
if (name, version) == ("zstd", "1.5.7.3"):
|
||||
return "yanked", "buggy - not thread safe"
|
||||
if (name, version) == ("gone", "0.0.1"):
|
||||
return "missing", "not found on PyPI"
|
||||
return "ok", ""
|
||||
|
||||
pins = {
|
||||
Pin("zstd", "1.5.7.3", "pyproject.toml [tool.uv.constraint-dependencies]"),
|
||||
Pin("zstd", "1.5.7.3", "uv.lock"),
|
||||
Pin("cryptography", "48.0.1", "uv.lock"),
|
||||
Pin("gone", "0.0.1", "uv.lock"),
|
||||
}
|
||||
verdicts = evaluate(pins, fetch=fake_fetch, workers=2)
|
||||
|
||||
assert sorted(calls) == [
|
||||
("cryptography", "48.0.1"),
|
||||
("gone", "0.0.1"),
|
||||
("zstd", "1.5.7.3"),
|
||||
]
|
||||
by_status = {}
|
||||
for verdict in verdicts:
|
||||
by_status.setdefault(verdict.status, []).append(verdict.pin)
|
||||
assert len(by_status["yanked"]) == 2
|
||||
assert {p.source for p in by_status["yanked"]} == {
|
||||
"pyproject.toml [tool.uv.constraint-dependencies]",
|
||||
"uv.lock",
|
||||
}
|
||||
assert by_status["missing"] == [Pin("gone", "0.0.1", "uv.lock")]
|
||||
assert by_status["ok"] == [Pin("cryptography", "48.0.1", "uv.lock")]
|
||||
|
||||
|
||||
class TestMain:
|
||||
"""main() turns verdicts into a process exit code and annotations."""
|
||||
|
||||
def test_exit_code_reflects_verdicts(self, tmp_path: Path, monkeypatch, capsys):
|
||||
"""0 when every pin is ok, 1 plus a ::error:: line when one is yanked."""
|
||||
(tmp_path / "pyproject.toml").write_text(
|
||||
'[project]\ndependencies = ["zstd==1.5.7.3"]\n'
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"util.check_yanked_pins.fetch_release",
|
||||
lambda name, version, retries=3: ("ok", ""),
|
||||
)
|
||||
assert main([str(tmp_path)]) == 0
|
||||
|
||||
monkeypatch.setattr(
|
||||
"util.check_yanked_pins.fetch_release",
|
||||
lambda name, version, retries=3: ("yanked", "buggy - not thread safe"),
|
||||
)
|
||||
assert main([str(tmp_path)]) == 1
|
||||
assert (
|
||||
"::error::zstd==1.5.7.3 is yanked (buggy - not thread safe)"
|
||||
in capsys.readouterr().out
|
||||
)
|
||||
@@ -0,0 +1,491 @@
|
||||
/**
|
||||
* Fixture data for the Slack handlers. Shapes follow the API contract in
|
||||
* `openspec/changes/add-slack-integration/design.md`.
|
||||
*/
|
||||
|
||||
export interface SlackWorkspaceFixture {
|
||||
teamId: string;
|
||||
teamName: string;
|
||||
botUserId: string;
|
||||
/**
|
||||
* Absent from the serialized configuration until a channel is chosen: the API
|
||||
* omits the keys rather than sending nulls.
|
||||
*/
|
||||
channelId?: string;
|
||||
channelName?: string;
|
||||
}
|
||||
|
||||
export interface SlackInstallFixture {
|
||||
id: string;
|
||||
/** `null` until the first connection check runs. */
|
||||
connected: boolean | null;
|
||||
connectionLastCheckedAt: string | null;
|
||||
workspace: SlackWorkspaceFixture;
|
||||
}
|
||||
|
||||
export const SLACK_EXCHANGE_OUTCOME = {
|
||||
CREATED: "created",
|
||||
/** Same workspace re-installed: the existing row keeps its id. */
|
||||
REINSTALLED: "reinstalled",
|
||||
REFUSED_STATE: "refused-state",
|
||||
SLACK_REFUSED: "slack-refused",
|
||||
/** A `409` named by its `code`: one workspace per tenant. */
|
||||
DIFFERENT_WORKSPACE: "different-workspace",
|
||||
/**
|
||||
* The three below are `2xx`: the install happened, but the answer is
|
||||
* unreadable, so nothing on the failure path sees them.
|
||||
*/
|
||||
UNREADABLE_NO_CONTENT: "unreadable-no-content",
|
||||
UNREADABLE_HTML: "unreadable-html",
|
||||
UNREADABLE_NO_DATA: "unreadable-no-data",
|
||||
} as const;
|
||||
|
||||
export type SlackExchangeOutcome =
|
||||
(typeof SLACK_EXCHANGE_OUTCOME)[keyof typeof SLACK_EXCHANGE_OUTCOME];
|
||||
|
||||
export interface SlackConnectionFixture {
|
||||
connected: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** A channel the listing endpoint offers for the picker. */
|
||||
export interface SlackChannelFixture {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Private channels are listed only where `@Prowler` has been invited. */
|
||||
isPrivate: boolean;
|
||||
}
|
||||
|
||||
export interface SlackTestMessageFixture {
|
||||
accepted: boolean;
|
||||
/**
|
||||
* Why it did not: the reason `code` would carry, or prose — the contract
|
||||
* leaves the task result's shape open.
|
||||
*/
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A refusal as the API sends one: the machine-readable reason in `code`, human
|
||||
* copy in `detail`, and — for a `429` — the wait in `Retry-After`.
|
||||
*/
|
||||
export interface SlackRefusalFixture {
|
||||
status: number;
|
||||
/** Slack's stable reason. `null` for the failures classified by status. */
|
||||
code: string | null;
|
||||
detail: string;
|
||||
/** Seconds `Retry-After` asked for; only a `429` carries one. */
|
||||
retryAfterSeconds: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What `DELETE /integrations/{id}` reports about revoking the token at Slack.
|
||||
* Revocation is best-effort: the row goes either way, and the outcome travels in
|
||||
* JSON:API `meta`.
|
||||
*
|
||||
* One boolean is the whole of it: the API sends no reason for a revocation that
|
||||
* did not happen, so modelling one would let a test prove copy the real
|
||||
* deployment can never produce.
|
||||
*/
|
||||
export interface SlackRevocationFixture {
|
||||
/**
|
||||
* Slack confirmed the token no longer grants Prowler anything. `null` when the
|
||||
* answer reports nothing at all — the plain `204` a deployment without a
|
||||
* `destroy` override sends, which is what the UI meets today.
|
||||
*/
|
||||
revoked: boolean | null;
|
||||
}
|
||||
|
||||
export interface SlackFixture {
|
||||
/**
|
||||
* The deployment has `SLACK_CLIENT_ID` / `SLACK_CLIENT_SECRET` /
|
||||
* `SLACK_REDIRECT_URI`. Without them every Slack OAuth call answers `503`.
|
||||
*/
|
||||
appConfigured: boolean;
|
||||
install: SlackInstallFixture | null;
|
||||
exchangeWorkspace: SlackWorkspaceFixture;
|
||||
exchangeOutcome: SlackExchangeOutcome;
|
||||
connection: SlackConnectionFixture;
|
||||
/** The Slack OAuth calls answer `429` with a `Retry-After`. */
|
||||
rateLimited: boolean;
|
||||
/**
|
||||
* The shared `GET /integrations` read answers `500`, which the UI's own
|
||||
* helper turns into a thrown error rather than a result.
|
||||
*/
|
||||
listServerError: boolean;
|
||||
/** The consent-URL call answers `200` with a proxy's HTML page, not JSON. */
|
||||
authorizeUrlUnreadable: boolean;
|
||||
/**
|
||||
* Both Slack OAuth calls answer `502`, the contract's status for upstream and
|
||||
* transport failures. Distinct from `appConfigured: false`, which is a `503`.
|
||||
*/
|
||||
oauthUpstreamError: boolean;
|
||||
channels: SlackChannelFixture[];
|
||||
/**
|
||||
* Small on purpose: the default workspace spans two pages, so a UI that
|
||||
* stopped at `data` instead of following `links.next` would lose channels.
|
||||
*/
|
||||
channelsPageSize: number;
|
||||
/** Slack refused the listing outright, with the reason named in `code`. */
|
||||
channelsRefusal: SlackRefusalFixture | null;
|
||||
/**
|
||||
* The cursor the refusal starts at. Absent, the whole read fails; a page
|
||||
* size serves the first page and refuses the second — the partial read.
|
||||
*/
|
||||
channelsRefusalFromCursor?: number;
|
||||
/**
|
||||
* Slack refused the chosen channel when the `PATCH` validated it — the
|
||||
* listing itself answered fine.
|
||||
*/
|
||||
channelSaveRefusal: SlackRefusalFixture | null;
|
||||
testMessage: SlackTestMessageFixture;
|
||||
revocation: SlackRevocationFixture;
|
||||
}
|
||||
|
||||
/**
|
||||
* A UUID, as the API's ids are: it travels in the URL of every Slack call and
|
||||
* the actions accept no other shape.
|
||||
*/
|
||||
export const SLACK_INTEGRATION_ID = "7c9e6a1b-2d3f-4e5a-8b6c-9d0e1f2a3b4c";
|
||||
|
||||
/** The scopes the channel picker and the posting need (design D2). */
|
||||
export const SLACK_BOT_SCOPES = [
|
||||
"chat:write",
|
||||
"chat:write.public",
|
||||
"channels:read",
|
||||
"groups:read",
|
||||
] as const;
|
||||
|
||||
export const SLACK_REDIRECT_URI =
|
||||
"https://cloud.prowler.com/integrations/slack/callback";
|
||||
|
||||
/** Server-minted, single-use, bound to the tenant and user (design D5). */
|
||||
export const SLACK_OAUTH_STATE = "st-2f1c9d7a";
|
||||
export const SLACK_OAUTH_CODE = "slack-code-1f4a";
|
||||
|
||||
export const SLACK_AUTHORIZE_URL =
|
||||
"https://slack.com/oauth/v2/authorize" +
|
||||
"?client_id=1234567890.0987654321" +
|
||||
`&scope=${encodeURIComponent(SLACK_BOT_SCOPES.join(","))}` +
|
||||
`&state=${SLACK_OAUTH_STATE}` +
|
||||
`&redirect_uri=${encodeURIComponent(SLACK_REDIRECT_URI)}`;
|
||||
|
||||
/**
|
||||
* The `detail` strings the implementation sends. Human copy; the
|
||||
* machine-readable reason travels in `code`, which is what the UI maps.
|
||||
*/
|
||||
export const SLACK_UNCONFIGURED_DETAIL =
|
||||
"Slack integration is not configured or temporarily unavailable.";
|
||||
export const SLACK_REFUSED_STATE_DETAIL =
|
||||
"OAuth state is invalid, expired, or already consumed.";
|
||||
export const SLACK_INVALID_CODE_DETAIL = "The Slack OAuth code is invalid.";
|
||||
export const SLACK_DIFFERENT_WORKSPACE_DETAIL =
|
||||
"This tenant is already connected to a different Slack workspace.";
|
||||
export const SLACK_UPSTREAM_DETAIL = "Slack is temporarily unavailable.";
|
||||
/**
|
||||
* The `code` on the contract's `502`. The UI maps no copy of its own to it, so
|
||||
* the `detail` is what reaches the user.
|
||||
*/
|
||||
export const SLACK_UPSTREAM_ERROR_CODE = "service_unavailable";
|
||||
/**
|
||||
* Raised as a `ValidationError({"channel_id": ...})` that still points at
|
||||
* `/data` rather than at the attribute.
|
||||
*/
|
||||
export const SLACK_NO_CHANNEL_DETAIL =
|
||||
"This Slack integration has no channel configured.";
|
||||
export const SLACK_RATE_LIMITED_DETAIL =
|
||||
"Slack is rate limiting requests from Prowler.";
|
||||
/**
|
||||
* What a `500` from the shared `GET /integrations` read carries. Nothing here
|
||||
* is for the user to act on, so the UI answers a server error in its own words.
|
||||
*/
|
||||
export const INTEGRATIONS_SERVER_ERROR_DETAIL = "A server error occurred.";
|
||||
export const SLACK_MISSING_SCOPE_DETAIL =
|
||||
"Slack refused the request: missing_scope.";
|
||||
/**
|
||||
* Names the raw reason, as the missing-scope wording does: what lets a test tell
|
||||
* copy the UI mapped from `code` apart from an echoed `detail`.
|
||||
*/
|
||||
export const SLACK_TOKEN_EXPIRED_DETAIL =
|
||||
"Slack refused the request: token_expired.";
|
||||
/**
|
||||
* The same sentence for "it is gone" and "the app was removed from it": only
|
||||
* `code` separates them, which is why a client must read `code`.
|
||||
*/
|
||||
export const SLACK_UNKNOWN_CHANNEL_DETAIL =
|
||||
"That channel is not one Prowler can post to.";
|
||||
export const SLACK_NO_DEFAULT_CHANNEL_DETAIL =
|
||||
"No default channel is recorded on this integration.";
|
||||
/** A task result that reports the refusal as prose instead of as a reason. */
|
||||
export const SLACK_TEST_MESSAGE_REFUSED_DETAIL =
|
||||
"Slack rejected the message: the channel is archived.";
|
||||
|
||||
/**
|
||||
* A `200` challenge page from a proxy or WAF that took the call instead of the
|
||||
* API. V8 truncates the parser message for this body before the word `html`, so
|
||||
* the UI's own detection (`HTML_ERROR_PATTERN`) cannot recognise it either.
|
||||
*/
|
||||
export const PROXY_CHALLENGE_PAGE = [
|
||||
"<!DOCTYPE html>",
|
||||
"<html><head><title>Attention Required</title></head>",
|
||||
"<body><h1>Checking your browser before you proceed.</h1></body></html>",
|
||||
].join("\n");
|
||||
|
||||
/**
|
||||
* The `code` values the refusals below are named by. Wire values, spelled out
|
||||
* rather than imported from the UI's own mapping: a rename on our side must
|
||||
* fail these tests, not quietly agree with itself.
|
||||
*/
|
||||
export const SLACK_WORKSPACE_CONFLICT_CODE = "slack_workspace_conflict";
|
||||
export const SLACK_MISSING_SCOPE_CODE = "missing_scope";
|
||||
export const SLACK_CHANNEL_NOT_FOUND_CODE = "channel_not_found";
|
||||
export const SLACK_NOT_IN_CHANNEL_CODE = "not_in_channel";
|
||||
/**
|
||||
* A reason Slack really sends that the UI's mapping does not cover — the set is
|
||||
* open-ended, so having no copy for one is the ordinary case.
|
||||
*/
|
||||
export const SLACK_UNMAPPED_REASON_CODE = "is_archived";
|
||||
/**
|
||||
* Two of the four dead-grant codes the contract lists. Whichever call surfaces
|
||||
* one, the integration is disconnected and the only way out is connecting the
|
||||
* workspace again (contract, Cross-cutting).
|
||||
*/
|
||||
export const SLACK_TOKEN_REVOKED_CODE = "token_revoked";
|
||||
export const SLACK_TOKEN_EXPIRED_CODE = "token_expired";
|
||||
|
||||
export const SLACK_RETRY_AFTER_SECONDS = 30;
|
||||
|
||||
/** The install never granted a scope the call needs: actionable, so a `400`. */
|
||||
export const SLACK_MISSING_SCOPE_REFUSAL: SlackRefusalFixture = {
|
||||
status: 400,
|
||||
code: SLACK_MISSING_SCOPE_CODE,
|
||||
detail: SLACK_MISSING_SCOPE_DETAIL,
|
||||
retryAfterSeconds: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Where this really happens is the channel listing: `conversations.list` is
|
||||
* tier 2 and paginated.
|
||||
*/
|
||||
export const SLACK_RATE_LIMITED_REFUSAL: SlackRefusalFixture = {
|
||||
status: 429,
|
||||
code: null,
|
||||
detail: SLACK_RATE_LIMITED_DETAIL,
|
||||
retryAfterSeconds: SLACK_RETRY_AFTER_SECONDS,
|
||||
};
|
||||
|
||||
/**
|
||||
* The stored grant is no longer usable: a `400` like any other actionable
|
||||
* refusal, deliberately not the `401` that would read as an expired Prowler
|
||||
* session (contract, Errors).
|
||||
*/
|
||||
export const SLACK_TOKEN_EXPIRED_REFUSAL: SlackRefusalFixture = {
|
||||
status: 400,
|
||||
code: SLACK_TOKEN_EXPIRED_CODE,
|
||||
detail: SLACK_TOKEN_EXPIRED_DETAIL,
|
||||
retryAfterSeconds: null,
|
||||
};
|
||||
|
||||
/** Slack-side or transport failure — a `502` naming no reason at all. */
|
||||
export const SLACK_UPSTREAM_REFUSAL: SlackRefusalFixture = {
|
||||
status: 502,
|
||||
code: null,
|
||||
detail: SLACK_UPSTREAM_DETAIL,
|
||||
retryAfterSeconds: null,
|
||||
};
|
||||
|
||||
/** The chosen channel is archived, deleted, or was never in the workspace. */
|
||||
export const SLACK_CHANNEL_NOT_FOUND_REFUSAL: SlackRefusalFixture = {
|
||||
status: 400,
|
||||
code: SLACK_CHANNEL_NOT_FOUND_CODE,
|
||||
detail: SLACK_UNKNOWN_CHANNEL_DETAIL,
|
||||
retryAfterSeconds: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* The channel is fine, the Prowler app is simply not in it — fixed with
|
||||
* `/invite @Prowler`. Identical `detail` to the refusal above, deliberately.
|
||||
*/
|
||||
export const SLACK_NOT_IN_CHANNEL_REFUSAL: SlackRefusalFixture = {
|
||||
status: 400,
|
||||
code: SLACK_NOT_IN_CHANNEL_CODE,
|
||||
detail: SLACK_UNKNOWN_CHANNEL_DETAIL,
|
||||
retryAfterSeconds: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Two public channels and one private the Prowler app was invited to, ordered
|
||||
* so the private one lands on the second cursor page.
|
||||
*/
|
||||
export const SLACK_PUBLIC_CHANNEL: SlackChannelFixture = {
|
||||
id: "C0123AB",
|
||||
name: "security",
|
||||
isPrivate: false,
|
||||
};
|
||||
|
||||
export const SLACK_SECOND_PUBLIC_CHANNEL: SlackChannelFixture = {
|
||||
id: "C0789EF",
|
||||
name: "platform",
|
||||
isPrivate: false,
|
||||
};
|
||||
|
||||
export const SLACK_PRIVATE_CHANNEL: SlackChannelFixture = {
|
||||
id: "C0456CD",
|
||||
name: "security-alerts",
|
||||
isPrivate: true,
|
||||
};
|
||||
|
||||
export const SLACK_CHANNELS: SlackChannelFixture[] = [
|
||||
SLACK_PUBLIC_CHANNEL,
|
||||
SLACK_SECOND_PUBLIC_CHANNEL,
|
||||
SLACK_PRIVATE_CHANNEL,
|
||||
];
|
||||
|
||||
/** Two channels per page, so `SLACK_CHANNELS` spans exactly two pages. */
|
||||
export const SLACK_CHANNELS_PAGE_SIZE = 2;
|
||||
|
||||
/**
|
||||
* The first channel the picker offers, so an install seeded with it always
|
||||
* points at a channel the listing really has.
|
||||
*/
|
||||
export const SLACK_DEFAULT_CHANNEL = SLACK_PUBLIC_CHANNEL;
|
||||
|
||||
const PROWLER_HQ: SlackWorkspaceFixture = {
|
||||
teamId: "T01PROWLER",
|
||||
teamName: "Prowler HQ",
|
||||
botUserId: "U01PROWLERBOT",
|
||||
};
|
||||
|
||||
export const slackFixture = (
|
||||
overrides: Partial<SlackFixture> = {},
|
||||
): SlackFixture => ({
|
||||
appConfigured: true,
|
||||
install: null,
|
||||
exchangeWorkspace: { ...PROWLER_HQ },
|
||||
exchangeOutcome: SLACK_EXCHANGE_OUTCOME.CREATED,
|
||||
connection: { connected: true, error: null },
|
||||
rateLimited: false,
|
||||
listServerError: false,
|
||||
authorizeUrlUnreadable: false,
|
||||
oauthUpstreamError: false,
|
||||
channels: SLACK_CHANNELS.map((channel) => ({ ...channel })),
|
||||
channelsPageSize: SLACK_CHANNELS_PAGE_SIZE,
|
||||
channelsRefusal: null,
|
||||
channelSaveRefusal: null,
|
||||
testMessage: { accepted: true, error: null },
|
||||
revocation: { revoked: true },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/**
|
||||
* A workspace approved with no destination channel yet. `connected` is `null`,
|
||||
* not `true`: the check runs against the channel, so it has never run
|
||||
* (design.md, "Connection state, in order").
|
||||
*/
|
||||
export const connectedSlackFixture = (
|
||||
overrides: Partial<SlackFixture> = {},
|
||||
): SlackFixture =>
|
||||
slackFixture({
|
||||
install: {
|
||||
id: SLACK_INTEGRATION_ID,
|
||||
connected: null,
|
||||
connectionLastCheckedAt: null,
|
||||
workspace: { ...PROWLER_HQ },
|
||||
},
|
||||
exchangeOutcome: SLACK_EXCHANGE_OUTCOME.REINSTALLED,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const configuredInstall = (
|
||||
channel: SlackChannelFixture = SLACK_DEFAULT_CHANNEL,
|
||||
): SlackInstallFixture => ({
|
||||
id: SLACK_INTEGRATION_ID,
|
||||
connected: true,
|
||||
connectionLastCheckedAt: "2026-08-10T09:30:00Z",
|
||||
workspace: {
|
||||
...PROWLER_HQ,
|
||||
channelId: channel.id,
|
||||
channelName: channel.name,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* The same tenant with a destination channel already on record: the state a
|
||||
* second visit starts from.
|
||||
*/
|
||||
export const slackFixtureWithDefaultChannel = (
|
||||
channel: SlackChannelFixture = SLACK_PUBLIC_CHANNEL,
|
||||
overrides: Partial<SlackFixture> = {},
|
||||
): SlackFixture =>
|
||||
connectedSlackFixture({ install: configuredInstall(channel), ...overrides });
|
||||
|
||||
/**
|
||||
* The same finished setup, with a check time no parser can read: a zero date
|
||||
* from a bad write or a serializer change. The contract types the attribute as
|
||||
* a string and rules nothing else out.
|
||||
*/
|
||||
export const unreadableCheckTimeSlackFixture = (): SlackFixture =>
|
||||
connectedSlackFixture({
|
||||
install: {
|
||||
...configuredInstall(),
|
||||
connectionLastCheckedAt: "0000-00-00T00:00:00Z",
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* The first cursor page is served and Slack rate limits the second: what is
|
||||
* already read stays usable, the refusal only says why the list is short.
|
||||
*/
|
||||
export const partiallyReadSlackFixture = (
|
||||
overrides: Partial<SlackFixture> = {},
|
||||
): SlackFixture =>
|
||||
slackFixtureWithDefaultChannel(SLACK_PUBLIC_CHANNEL, {
|
||||
channelsRefusal: SLACK_RATE_LIMITED_REFUSAL,
|
||||
channelsRefusalFromCursor: SLACK_CHANNELS_PAGE_SIZE,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/**
|
||||
* A workspace connected *and* a channel on record. Anything the API refuses
|
||||
* until a channel exists (the connection check) needs this fixture.
|
||||
*/
|
||||
export const configuredSlackFixture = (
|
||||
overrides: Partial<SlackFixture> = {},
|
||||
): SlackFixture =>
|
||||
slackFixtureWithDefaultChannel(SLACK_DEFAULT_CHANNEL, overrides);
|
||||
|
||||
/**
|
||||
* A connected tenant whose disconnect removes the row but cannot revoke at
|
||||
* Slack — the outcome the user has to finish by hand in the workspace.
|
||||
*/
|
||||
export const revokeFailureSlackFixture = (
|
||||
overrides: Partial<SlackFixture> = {},
|
||||
): SlackFixture =>
|
||||
connectedSlackFixture({
|
||||
revocation: { revoked: false },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/**
|
||||
* A connected tenant whose disconnect answers a plain `204` with no body: the
|
||||
* row is gone and the revocation is unreported.
|
||||
*/
|
||||
export const unreportedRevocationSlackFixture = (
|
||||
overrides: Partial<SlackFixture> = {},
|
||||
): SlackFixture =>
|
||||
connectedSlackFixture({
|
||||
revocation: { revoked: null },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/**
|
||||
* A connected tenant whose token has been revoked at Slack: the row still says
|
||||
* connected until a check runs, and the check is what surfaces it.
|
||||
*/
|
||||
export const revokedTokenSlackFixture = (
|
||||
overrides: Partial<SlackFixture> = {},
|
||||
): SlackFixture =>
|
||||
configuredSlackFixture({
|
||||
connection: { connected: false, error: SLACK_TOKEN_REVOKED_CODE },
|
||||
...overrides,
|
||||
});
|
||||
@@ -0,0 +1,381 @@
|
||||
/**
|
||||
* MSW handlers for the Slack integration, derived from the API contract in
|
||||
* `openspec/changes/add-slack-integration/design.md` (the API itself lives in
|
||||
* the cloud repository). State is per-call: an exchange creates the install the
|
||||
* subsequent `GET /integrations` returns.
|
||||
*
|
||||
* Wire them per test via `worker.use(...handlersForSlack(fx))`.
|
||||
*/
|
||||
|
||||
import { http, HttpResponse } from "msw";
|
||||
|
||||
import {
|
||||
INTEGRATIONS_SERVER_ERROR_DETAIL,
|
||||
PROXY_CHALLENGE_PAGE,
|
||||
SLACK_AUTHORIZE_URL,
|
||||
SLACK_DIFFERENT_WORKSPACE_DETAIL,
|
||||
SLACK_EXCHANGE_OUTCOME,
|
||||
SLACK_INTEGRATION_ID,
|
||||
SLACK_INVALID_CODE_DETAIL,
|
||||
SLACK_NO_CHANNEL_DETAIL,
|
||||
SLACK_NO_DEFAULT_CHANNEL_DETAIL,
|
||||
SLACK_RATE_LIMITED_REFUSAL,
|
||||
SLACK_REFUSED_STATE_DETAIL,
|
||||
SLACK_UNCONFIGURED_DETAIL,
|
||||
SLACK_UNKNOWN_CHANNEL_DETAIL,
|
||||
SLACK_UPSTREAM_DETAIL,
|
||||
SLACK_UPSTREAM_ERROR_CODE,
|
||||
SLACK_WORKSPACE_CONFLICT_CODE,
|
||||
} from "./slack.fixtures";
|
||||
import type {
|
||||
SlackExchangeOutcome,
|
||||
SlackFixture,
|
||||
SlackInstallFixture,
|
||||
SlackRefusalFixture,
|
||||
} from "./slack.fixtures";
|
||||
|
||||
const API = process.env.UI_API_BASE_URL;
|
||||
const TS = "2026-08-10T09:00:00Z";
|
||||
|
||||
const CONNECTION_TASK_PREFIX = "slack-conn-task-";
|
||||
const TEST_MESSAGE_TASK_PREFIX = "slack-test-message-task-";
|
||||
|
||||
/** Opaque to the UI, which only ever follows `links.next` (design D6). */
|
||||
const CHANNEL_CURSOR_PARAM = "page[cursor]";
|
||||
|
||||
/**
|
||||
* `status` is a string, per the JSON:API spec. `source.pointer` is `/data` even
|
||||
* for a field-shaped `ValidationError`: the errors are about the request.
|
||||
*/
|
||||
const errorBody = (detail: string, status: number, code?: string) => ({
|
||||
errors: [
|
||||
{
|
||||
status: String(status),
|
||||
...(code ? { code } : {}),
|
||||
detail,
|
||||
source: { pointer: "/data" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* Answer a fixture's refusal as the API would: its own status, its `code`
|
||||
* when it names one, and `Retry-After` only where the status carries a wait.
|
||||
*/
|
||||
const refuse = (refusal: SlackRefusalFixture) =>
|
||||
HttpResponse.json(
|
||||
errorBody(refusal.detail, refusal.status, refusal.code ?? undefined),
|
||||
{
|
||||
status: refusal.status,
|
||||
...(refusal.retryAfterSeconds === null
|
||||
? {}
|
||||
: { headers: { "Retry-After": String(refusal.retryAfterSeconds) } }),
|
||||
},
|
||||
);
|
||||
|
||||
const configuration = (workspace: SlackInstallFixture["workspace"]) => ({
|
||||
team_id: workspace.teamId,
|
||||
team_name: workspace.teamName,
|
||||
bot_user_id: workspace.botUserId,
|
||||
// The API omits these keys until a channel is chosen, never sending nulls.
|
||||
...(workspace.channelId ? { channel_id: workspace.channelId } : {}),
|
||||
...(workspace.channelName ? { channel_name: workspace.channelName } : {}),
|
||||
});
|
||||
|
||||
const integrationResource = (install: SlackInstallFixture) => ({
|
||||
id: install.id,
|
||||
type: "integrations",
|
||||
attributes: {
|
||||
inserted_at: TS,
|
||||
updated_at: TS,
|
||||
enabled: true,
|
||||
connected: install.connected,
|
||||
connection_last_checked_at: install.connectionLastCheckedAt,
|
||||
integration_type: "slack",
|
||||
// No credentials: the bot token is encrypted at rest and never serialized.
|
||||
configuration: configuration(install.workspace),
|
||||
},
|
||||
links: { self: `${API}/integrations/${install.id}` },
|
||||
});
|
||||
|
||||
const collection = (install: SlackInstallFixture | null) => ({
|
||||
data: install ? [integrationResource(install)] : [],
|
||||
meta: {
|
||||
version: "v1",
|
||||
pagination: {
|
||||
page: 1,
|
||||
pages: 1,
|
||||
count: install ? 1 : 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const taskResource = (id: string, state: string, result: unknown) => ({
|
||||
data: { id, type: "tasks", attributes: { state, result } },
|
||||
});
|
||||
|
||||
/**
|
||||
* All three are `2xx`: the first two make `response.json()` throw, the third
|
||||
* parses into a body that names no resource.
|
||||
*/
|
||||
const unreadableExchange = (outcome: SlackExchangeOutcome): Response => {
|
||||
switch (outcome) {
|
||||
case SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_CONTENT:
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
case SLACK_EXCHANGE_OUTCOME.UNREADABLE_HTML:
|
||||
return HttpResponse.html(PROXY_CHALLENGE_PAGE);
|
||||
default:
|
||||
return HttpResponse.json({ meta: { version: "v1" } });
|
||||
}
|
||||
};
|
||||
|
||||
export const handlersForSlack = (fx: SlackFixture) => {
|
||||
// Mutable copy: the exchange must not write through to the caller's fixture.
|
||||
let install: SlackInstallFixture | null = fx.install
|
||||
? { ...fx.install, workspace: { ...fx.install.workspace } }
|
||||
: null;
|
||||
|
||||
const unconfigured = () =>
|
||||
HttpResponse.json(errorBody(SLACK_UNCONFIGURED_DETAIL, 503), {
|
||||
status: 503,
|
||||
});
|
||||
|
||||
const rateLimited = () => refuse(SLACK_RATE_LIMITED_REFUSAL);
|
||||
|
||||
/** A `502` per the contract's taxonomy: a server fault, not a Slack state. */
|
||||
const upstreamError = () =>
|
||||
HttpResponse.json(
|
||||
errorBody(SLACK_UPSTREAM_DETAIL, 502, SLACK_UPSTREAM_ERROR_CODE),
|
||||
{ status: 502, statusText: "Bad Gateway" },
|
||||
);
|
||||
|
||||
return [
|
||||
// --- OAuth ------------------------------------------------------------
|
||||
http.post(`${API}/integrations/slack/oauth/authorize-url`, () => {
|
||||
if (!fx.appConfigured) return unconfigured();
|
||||
if (fx.rateLimited) return rateLimited();
|
||||
if (fx.oauthUpstreamError) return upstreamError();
|
||||
if (fx.authorizeUrlUnreadable) {
|
||||
return HttpResponse.html(PROXY_CHALLENGE_PAGE);
|
||||
}
|
||||
// The URL travels in `meta`; the call creates nothing.
|
||||
return HttpResponse.json({
|
||||
meta: { authorize_url: SLACK_AUTHORIZE_URL },
|
||||
});
|
||||
}),
|
||||
|
||||
http.post(`${API}/integrations/slack/oauth/exchange`, () => {
|
||||
if (!fx.appConfigured) return unconfigured();
|
||||
if (fx.rateLimited) return rateLimited();
|
||||
if (fx.oauthUpstreamError) return upstreamError();
|
||||
|
||||
switch (fx.exchangeOutcome) {
|
||||
case SLACK_EXCHANGE_OUTCOME.REFUSED_STATE:
|
||||
return HttpResponse.json(errorBody(SLACK_REFUSED_STATE_DETAIL, 400), {
|
||||
status: 400,
|
||||
});
|
||||
case SLACK_EXCHANGE_OUTCOME.SLACK_REFUSED:
|
||||
return HttpResponse.json(errorBody(SLACK_INVALID_CODE_DETAIL, 400), {
|
||||
status: 400,
|
||||
});
|
||||
case SLACK_EXCHANGE_OUTCOME.DIFFERENT_WORKSPACE:
|
||||
return HttpResponse.json(
|
||||
errorBody(
|
||||
SLACK_DIFFERENT_WORKSPACE_DETAIL,
|
||||
409,
|
||||
SLACK_WORKSPACE_CONFLICT_CODE,
|
||||
),
|
||||
{ status: 409 },
|
||||
);
|
||||
case SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_CONTENT:
|
||||
case SLACK_EXCHANGE_OUTCOME.UNREADABLE_HTML:
|
||||
case SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_DATA:
|
||||
// The install still happened: the API upserts before it answers.
|
||||
install = {
|
||||
id: SLACK_INTEGRATION_ID,
|
||||
connected: null,
|
||||
connectionLastCheckedAt: null,
|
||||
workspace: { ...fx.exchangeWorkspace },
|
||||
};
|
||||
return unreadableExchange(fx.exchangeOutcome);
|
||||
case SLACK_EXCHANGE_OUTCOME.REINSTALLED:
|
||||
install = {
|
||||
id: install?.id ?? SLACK_INTEGRATION_ID,
|
||||
connected: null,
|
||||
connectionLastCheckedAt: null,
|
||||
workspace: { ...fx.exchangeWorkspace },
|
||||
};
|
||||
return HttpResponse.json({ data: integrationResource(install) });
|
||||
default:
|
||||
install = {
|
||||
id: SLACK_INTEGRATION_ID,
|
||||
connected: null,
|
||||
connectionLastCheckedAt: null,
|
||||
workspace: { ...fx.exchangeWorkspace },
|
||||
};
|
||||
return HttpResponse.json(
|
||||
{ data: integrationResource(install) },
|
||||
{ status: 201 },
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
// --- Generic integration endpoints the Slack UI reuses -----------------
|
||||
http.get(`${API}/integrations`, ({ request }) => {
|
||||
if (fx.listServerError) {
|
||||
return HttpResponse.json(
|
||||
errorBody(INTEGRATIONS_SERVER_ERROR_DETAIL, 500),
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const type = new URL(request.url).searchParams.get(
|
||||
"filter[integration_type]",
|
||||
);
|
||||
// An unfiltered read would pull every type into the Slack page.
|
||||
return HttpResponse.json(collection(type === "slack" ? install : null));
|
||||
}),
|
||||
|
||||
http.post<{ id: string }>(
|
||||
`${API}/integrations/:id/connection`,
|
||||
({ params }) => {
|
||||
// The check posts to the channel, so the API refuses until one exists.
|
||||
if (!install?.workspace.channelId) {
|
||||
return HttpResponse.json(errorBody(SLACK_NO_CHANNEL_DETAIL, 400), {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return HttpResponse.json(
|
||||
taskResource(
|
||||
`${CONNECTION_TASK_PREFIX}${params.id}`,
|
||||
"executing",
|
||||
null,
|
||||
),
|
||||
{ status: 202 },
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
http.get<{ taskId: string }>(`${API}/tasks/:taskId`, ({ params }) => {
|
||||
// The test message settles as its own task (design D9).
|
||||
if (params.taskId.startsWith(TEST_MESSAGE_TASK_PREFIX)) {
|
||||
const { accepted, error } = fx.testMessage;
|
||||
return HttpResponse.json(
|
||||
taskResource(params.taskId, accepted ? "completed" : "failed", {
|
||||
error,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const { connected, error } = fx.connection;
|
||||
if (install && params.taskId.startsWith(CONNECTION_TASK_PREFIX)) {
|
||||
install.connected = connected;
|
||||
install.connectionLastCheckedAt = TS;
|
||||
}
|
||||
return HttpResponse.json(
|
||||
taskResource(params.taskId, "completed", { connected, error }),
|
||||
);
|
||||
}),
|
||||
|
||||
// --- Channels ----------------------------------------------------------
|
||||
http.get<{ id: string }>(
|
||||
`${API}/integrations/:id/slack/channels`,
|
||||
({ params, request }) => {
|
||||
// The UI follows `links.next` opaquely, so the cursor's shape is this
|
||||
// fixture's business alone. Read first: the page decides the refusal.
|
||||
const cursor = Number(
|
||||
new URL(request.url).searchParams.get(CHANNEL_CURSOR_PARAM) ?? "0",
|
||||
);
|
||||
|
||||
// An endpoint-specific refusal wins over the blanket rate limiting,
|
||||
// and applies from the named cursor, so a partial read is expressible.
|
||||
if (
|
||||
fx.channelsRefusal &&
|
||||
cursor >= (fx.channelsRefusalFromCursor ?? 0)
|
||||
) {
|
||||
return refuse(fx.channelsRefusal);
|
||||
}
|
||||
if (fx.rateLimited) return rateLimited();
|
||||
|
||||
const nextCursor = cursor + fx.channelsPageSize;
|
||||
const page = fx.channels.slice(cursor, nextCursor);
|
||||
const hasMore = nextCursor < fx.channels.length;
|
||||
|
||||
return HttpResponse.json({
|
||||
data: page.map((channel) => ({
|
||||
type: "slack-channels",
|
||||
id: channel.id,
|
||||
attributes: { name: channel.name, is_private: channel.isPrivate },
|
||||
})),
|
||||
links: {
|
||||
next: hasMore
|
||||
? `${API}/integrations/${params.id}/slack/channels` +
|
||||
`?${CHANNEL_CURSOR_PARAM}=${nextCursor}`
|
||||
: null,
|
||||
},
|
||||
});
|
||||
},
|
||||
),
|
||||
|
||||
/**
|
||||
* The generic PATCH. The UI submits only `channel_id`; the name is derived
|
||||
* from it here, as the API derives it from Slack (design D6).
|
||||
*/
|
||||
http.patch(`${API}/integrations/:id`, async ({ request }) => {
|
||||
const body = (await request.json().catch(() => null)) as {
|
||||
data?: { attributes?: { configuration?: { channel_id?: string } } };
|
||||
} | null;
|
||||
const channelId = body?.data?.attributes?.configuration?.channel_id;
|
||||
const channel = fx.channels.find((c) => c.id === channelId);
|
||||
|
||||
if (!install) {
|
||||
return HttpResponse.json(errorBody("Not found.", 404), { status: 404 });
|
||||
}
|
||||
// Checked before the id lookup: the picker did offer this channel, and
|
||||
// Slack refused it anyway when the API validated it.
|
||||
if (fx.channelSaveRefusal) return refuse(fx.channelSaveRefusal);
|
||||
if (!channel) {
|
||||
return HttpResponse.json(errorBody(SLACK_UNKNOWN_CHANNEL_DETAIL, 400), {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
install.workspace.channelId = channel.id;
|
||||
install.workspace.channelName = channel.name;
|
||||
return HttpResponse.json({ data: integrationResource(install) });
|
||||
}),
|
||||
|
||||
// --- Test message ------------------------------------------------------
|
||||
http.post<{ id: string }>(
|
||||
`${API}/integrations/:id/slack/test-message`,
|
||||
({ params }) => {
|
||||
if (!install?.workspace.channelId) {
|
||||
return HttpResponse.json(
|
||||
errorBody(SLACK_NO_DEFAULT_CHANNEL_DETAIL, 400),
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
return HttpResponse.json(
|
||||
taskResource(
|
||||
`${TEST_MESSAGE_TASK_PREFIX}${params.id}`,
|
||||
"available",
|
||||
null,
|
||||
),
|
||||
{ status: 202 },
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// Disconnect. Revocation at Slack is best-effort: the row is removed either
|
||||
// way and the outcome travels in `meta` — or nowhere at all, in the plain
|
||||
// `204` a deployment with no `destroy` override sends.
|
||||
http.delete(`${API}/integrations/:id`, () => {
|
||||
install = null;
|
||||
if (fx.revocation.revoked === null) {
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
}
|
||||
return HttpResponse.json({ meta: { revoked: fx.revocation.revoked } });
|
||||
}),
|
||||
];
|
||||
};
|
||||
@@ -341,6 +341,7 @@ export const testIntegrationConnection = async (
|
||||
revalidatePath("/integrations/amazon-s3");
|
||||
revalidatePath("/integrations/aws-security-hub");
|
||||
revalidatePath("/integrations/jira");
|
||||
revalidatePath("/integrations/slack");
|
||||
|
||||
if ("error" in pollResult) {
|
||||
return { success: false, error: pollResult.error };
|
||||
@@ -387,6 +388,7 @@ export const pollConnectionTestStatus = async (
|
||||
revalidatePath("/integrations/amazon-s3");
|
||||
revalidatePath("/integrations/aws-security-hub");
|
||||
revalidatePath("/integrations/jira");
|
||||
revalidatePath("/integrations/slack");
|
||||
|
||||
if ("error" in pollResult) {
|
||||
return { success: false, error: pollResult.error };
|
||||
|
||||
@@ -0,0 +1,782 @@
|
||||
/**
|
||||
* What the Slack actions do off the DOM, which
|
||||
* `slack-page.integration.test.tsx` cannot cover: which failures reach Sentry,
|
||||
* and the URLs the channel listing's cursor pagination follows.
|
||||
*/
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
SLACK_ERROR_CODE,
|
||||
SLACK_ERROR_MESSAGES,
|
||||
SLACK_GENERIC_ERROR_MESSAGE,
|
||||
SLACK_PARTIAL_CHANNEL_LIST_MESSAGE,
|
||||
SLACK_UNREADABLE_RESULT_MESSAGE,
|
||||
} from "@/lib/integrations/slack-errors";
|
||||
import { SentryErrorSource, SentryErrorType } from "@/sentry";
|
||||
|
||||
const { captureExceptionMock, captureMessageMock, fetchMock } = vi.hoisted(
|
||||
() => ({
|
||||
/**
|
||||
* The real SDK marks the exception `__sentry_captured__`, and
|
||||
* `handleApiError` reads that mark to avoid reporting the same throw twice.
|
||||
*/
|
||||
captureExceptionMock: vi.fn((exception: unknown, _options?: unknown) => {
|
||||
if (exception !== null && typeof exception === "object") {
|
||||
Object.defineProperty(exception, "__sentry_captured__", {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
}
|
||||
}),
|
||||
captureMessageMock: vi.fn(),
|
||||
fetchMock: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@sentry/nextjs", () => ({
|
||||
captureException: captureExceptionMock,
|
||||
captureMessage: captureMessageMock,
|
||||
// The task poll leaves breadcrumbs on every read it makes.
|
||||
addBreadcrumb: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/cache", () => ({
|
||||
revalidatePath: vi.fn(),
|
||||
}));
|
||||
|
||||
// The real `handleApiResponse` reads its copy from `lib/helper`, which reaches
|
||||
// next-auth through `@/auth.config`; stubbing the session lets that copy load.
|
||||
vi.mock("@/auth.config", () => ({
|
||||
auth: vi.fn(() => Promise.resolve({ accessToken: "test-access-token" })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.test/api/v1",
|
||||
getAuthHeaders: vi.fn(() =>
|
||||
Promise.resolve({ Authorization: "Bearer test-token" }),
|
||||
),
|
||||
parseStringify: (value: unknown) => value,
|
||||
}));
|
||||
|
||||
import {
|
||||
disconnectSlackIntegration,
|
||||
exchangeSlackOAuthCode,
|
||||
getSlackAuthorizeUrl,
|
||||
getSlackChannels,
|
||||
sendSlackTestMessage,
|
||||
setSlackDefaultChannel,
|
||||
} from "./slack";
|
||||
|
||||
/** The status the contract reserves for an upstream Slack failure. */
|
||||
const UPSTREAM_STATUS = 502;
|
||||
const UPSTREAM_DETAIL = "Slack is temporarily unavailable.";
|
||||
const GENERIC_SERVER_ERROR_MESSAGE =
|
||||
"Server is temporarily unavailable. Please try again in a few minutes.";
|
||||
|
||||
const errorResponse = (status: number, detail: string, code?: string) =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
errors: [
|
||||
{
|
||||
status: String(status),
|
||||
...(code ? { code } : {}),
|
||||
detail,
|
||||
source: { pointer: "/data" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status, headers: { "content-type": "application/vnd.api+json" } },
|
||||
);
|
||||
|
||||
const exchange = () =>
|
||||
exchangeSlackOAuthCode({ code: "slack-code-1f4a", state: "st-2f1c9d7a" });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{ action: getSlackAuthorizeUrl, name: "getSlackAuthorizeUrl" },
|
||||
{ action: exchange, name: "exchangeSlackOAuthCode" },
|
||||
])("$name", ({ action }) => {
|
||||
it("reports an upstream Slack failure instead of only turning it into copy", async () => {
|
||||
// 502 covers `internal_error`, `fatal_error`, `service_unavailable` and
|
||||
// transport failures.
|
||||
fetchMock.mockResolvedValue(
|
||||
errorResponse(UPSTREAM_STATUS, UPSTREAM_DETAIL, "service_unavailable"),
|
||||
);
|
||||
|
||||
const result = await action();
|
||||
|
||||
// Once, not twice: `handleApiResponse` reports and throws, and the action's
|
||||
// catch sees the mark.
|
||||
expect(captureExceptionMock).toHaveBeenCalledTimes(1);
|
||||
expect(captureExceptionMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
tags: {
|
||||
api_error: true,
|
||||
error_source: SentryErrorSource.HANDLE_API_RESPONSE,
|
||||
error_type: SentryErrorType.SERVER_ERROR,
|
||||
status_code: String(UPSTREAM_STATUS),
|
||||
},
|
||||
});
|
||||
expect(captureMessageMock).not.toHaveBeenCalled();
|
||||
|
||||
// The throw lands in the action's catch, so the page gets a result to
|
||||
// render rather than a rejection that strands the callback on its spinner.
|
||||
expect(result).toEqual({ error: UPSTREAM_DETAIL });
|
||||
});
|
||||
|
||||
it("answers a 5xx the API described in HTML in Prowler's own words", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response("<html><body><h1>502 Bad Gateway</h1></body></html>", {
|
||||
status: UPSTREAM_STATUS,
|
||||
statusText: "Bad Gateway",
|
||||
headers: { "content-type": "text/html" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await action();
|
||||
|
||||
expect(result).toEqual({ error: GENERIC_SERVER_ERROR_MESSAGE });
|
||||
expect(captureExceptionMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([503, 404])(
|
||||
"reports nothing for a %s: that is the feature being dark, not a fault",
|
||||
async (status) => {
|
||||
// 503 means `SLACK_CLIENT_*` is unset; 404 means no Slack API is served
|
||||
// in this deployment at all.
|
||||
fetchMock.mockResolvedValue(
|
||||
errorResponse(status, "Slack integration is not configured."),
|
||||
);
|
||||
|
||||
const result = await action();
|
||||
|
||||
// Capturing this would report the deliberate ship-dark state from every
|
||||
// tenant on every page load.
|
||||
expect(result).toEqual({ unavailable: true });
|
||||
expect(captureExceptionMock).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("reports nothing when Slack is rate limiting: it is a wait, not a fault", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
errors: [{ status: "429", detail: "Slack is rate limiting." }],
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
"content-type": "application/vnd.api+json",
|
||||
"Retry-After": "30",
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const result = await action();
|
||||
|
||||
expect(result).toMatchObject({ rateLimited: true, retryAfterSeconds: 30 });
|
||||
expect(captureExceptionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The URL is rendered as the `Add to Slack` link's `href`, so a value the API
|
||||
* got wrong must not become a redirect to somewhere that is not Slack.
|
||||
*/
|
||||
describe("getSlackAuthorizeUrl authorize URL", () => {
|
||||
const NO_AUTHORIZE_URL_MESSAGE = "Slack did not return an authorization URL.";
|
||||
const CONSENT_SCREEN_URL =
|
||||
"https://slack.com/oauth/v2/authorize" +
|
||||
"?client_id=1234567890.0987654321&state=st-2f1c9d7a";
|
||||
|
||||
const authorizeUrlResponse = (authorizeUrl: unknown) =>
|
||||
new Response(JSON.stringify({ meta: { authorize_url: authorizeUrl } }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/vnd.api+json" },
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a hostile scheme", "javascript:alert(document.domain)"],
|
||||
["plain HTTP", "http://slack.com/oauth/v2/authorize?client_id=1"],
|
||||
["another origin", "https://evil.test/oauth/v2/authorize?client_id=1"],
|
||||
["a lookalike hostname", "https://slack.com.evil.test/oauth/v2/authorize"],
|
||||
[
|
||||
"another Slack path",
|
||||
"https://slack.com/redirect?to=https%3A%2F%2Fevil.test",
|
||||
],
|
||||
["a value that is not a URL", "oauth/v2/authorize"],
|
||||
])(
|
||||
"refuses %s instead of offering it as the install link",
|
||||
async (_label, authorizeUrl) => {
|
||||
// Given — a 2xx whose `meta.authorize_url` is not Slack's consent screen.
|
||||
fetchMock.mockResolvedValue(authorizeUrlResponse(authorizeUrl));
|
||||
|
||||
// When
|
||||
const result = await getSlackAuthorizeUrl();
|
||||
|
||||
// Then — the answer for no URL at all: nothing here is safe to link to.
|
||||
expect(result).toEqual({ error: NO_AUTHORIZE_URL_MESSAGE });
|
||||
},
|
||||
);
|
||||
|
||||
it("hands over Slack's consent screen with its query untouched", async () => {
|
||||
// Given
|
||||
fetchMock.mockResolvedValue(authorizeUrlResponse(CONSENT_SCREEN_URL));
|
||||
|
||||
// When / Then
|
||||
expect(await getSlackAuthorizeUrl()).toEqual({
|
||||
authorizeUrl: CONSENT_SCREEN_URL,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The callback names the workspace and redirects on `integration` alone, so a
|
||||
* `2xx` body it cannot read back as an integration must not reach it.
|
||||
*/
|
||||
describe("exchangeSlackOAuthCode result shape", () => {
|
||||
const INTEGRATION = {
|
||||
id: "9b1f4c22-5e7a-4c2e-8f0d-6a3b1c9d7e42",
|
||||
type: "integrations",
|
||||
attributes: {
|
||||
integration_type: "slack",
|
||||
configuration: { team_name: "Prowler HQ" },
|
||||
},
|
||||
};
|
||||
|
||||
const exchangeResponse = (data: unknown) =>
|
||||
new Response(JSON.stringify({ data }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/vnd.api+json" },
|
||||
});
|
||||
|
||||
it.each<[string, unknown]>([
|
||||
["an empty object", {}],
|
||||
["an array", []],
|
||||
["a bare string", "invalid"],
|
||||
["a resource with no id", { type: "integrations", attributes: {} }],
|
||||
["a resource with an empty id", { ...INTEGRATION, id: "" }],
|
||||
["a resource of another type", { ...INTEGRATION, type: "tasks" }],
|
||||
[
|
||||
"a resource with no attributes",
|
||||
{ id: INTEGRATION.id, type: "integrations" },
|
||||
],
|
||||
[
|
||||
"another kind of integration",
|
||||
{
|
||||
...INTEGRATION,
|
||||
attributes: { ...INTEGRATION.attributes, integration_type: "jira" },
|
||||
},
|
||||
],
|
||||
])("cannot confirm the install from %s", async (_label, data) => {
|
||||
// Given — a 2xx whose `data` is truthy but is not an integration resource.
|
||||
fetchMock.mockResolvedValue(exchangeResponse(data));
|
||||
|
||||
// When
|
||||
const result = await exchange();
|
||||
|
||||
// Then — the answer for a body with no `data`: the install happened, only
|
||||
// its result is unknown.
|
||||
expect(result).toEqual({
|
||||
unconfirmed: true,
|
||||
message: SLACK_UNREADABLE_RESULT_MESSAGE,
|
||||
});
|
||||
});
|
||||
|
||||
it("hands over the workspace the API upserted", async () => {
|
||||
// Given
|
||||
fetchMock.mockResolvedValue(exchangeResponse(INTEGRATION));
|
||||
|
||||
// When / Then
|
||||
expect(await exchange()).toEqual({ integration: INTEGRATION });
|
||||
});
|
||||
});
|
||||
|
||||
/** The shape the API's integration ids have, which is the only shape accepted. */
|
||||
const SLACK_INTEGRATION_ID = "b2c7fd0a-3e51-4d8f-9a6c-1f0e2d3c4b5a";
|
||||
|
||||
const CHANNELS_URL =
|
||||
`https://api.test/api/v1/integrations/${SLACK_INTEGRATION_ID}` +
|
||||
"/slack/channels";
|
||||
|
||||
const FIRST_CHANNEL = { id: "C0123AB", name: "security" };
|
||||
const SECOND_CHANNEL = { id: "C0789EF", name: "platform" };
|
||||
|
||||
const channelPage = (
|
||||
channel: { id: string; name: string },
|
||||
next: string | null,
|
||||
) =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: [
|
||||
{
|
||||
type: "slack-channels",
|
||||
id: channel.id,
|
||||
attributes: { name: channel.name, is_private: false },
|
||||
},
|
||||
],
|
||||
links: { next },
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/vnd.api+json" } },
|
||||
);
|
||||
|
||||
const channelOption = (channel: { id: string; name: string }) => ({
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
is_private: false,
|
||||
});
|
||||
|
||||
const requestedUrls = (): string[] =>
|
||||
fetchMock.mock.calls.map(([url]) => String(url));
|
||||
|
||||
/**
|
||||
* `MAX_CHANNEL_PAGES` in the action, which a `"use server"` module cannot
|
||||
* export: only async functions may leave one.
|
||||
*/
|
||||
const MAX_CHANNEL_PAGES = 20;
|
||||
|
||||
const channelOptions = (count: number) =>
|
||||
Array.from({ length: count }, () => channelOption(FIRST_CHANNEL));
|
||||
|
||||
/** What a `429` carrying `Retry-After: 30` is turned into. */
|
||||
const RATE_LIMITED_MESSAGE =
|
||||
"Slack is rate limiting Prowler right now. Try again in about 30 seconds.";
|
||||
|
||||
/** A dead grant as the API reports it: reason in `code`, prose in `detail`. */
|
||||
const TOKEN_EXPIRED_CODE = "token_expired";
|
||||
const TOKEN_EXPIRED_DETAIL = "Slack refused the request: token_expired.";
|
||||
const TOKEN_EXPIRED_MESSAGE =
|
||||
"Prowler's Slack credential has expired. Connect the workspace again to restore access.";
|
||||
|
||||
describe("getSlackChannels", () => {
|
||||
it("follows a cursor-only `next` on the listing's own URL, not on the API root", async () => {
|
||||
// The link is opaque (design D6), so the API may answer with the cursor
|
||||
// alone; resolved against the API root it loses the listing's own path.
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(channelPage(FIRST_CHANNEL, "?page[cursor]=2"))
|
||||
.mockResolvedValueOnce(channelPage(SECOND_CHANNEL, null));
|
||||
|
||||
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
|
||||
|
||||
expect(requestedUrls()).toEqual([
|
||||
CHANNELS_URL,
|
||||
`${CHANNELS_URL}?page[cursor]=2`,
|
||||
]);
|
||||
expect(result).toEqual({
|
||||
channels: [channelOption(FIRST_CHANNEL), channelOption(SECOND_CHANNEL)],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
shape: "an absolute",
|
||||
next: "https://evil.test/api/v1/integrations/x/slack/channels?cursor=2",
|
||||
},
|
||||
{ shape: "a protocol-relative", next: "//evil.test/api/v1/channels?c=2" },
|
||||
])(
|
||||
"stops at $shape off-origin `next` rather than sending the tenant's token to it",
|
||||
async ({ next }) => {
|
||||
// `fetch` strips the tenant's `Authorization` on a redirect that leaves
|
||||
// the origin, but not on a hop the UI makes itself.
|
||||
fetchMock.mockResolvedValueOnce(channelPage(FIRST_CHANNEL, next));
|
||||
|
||||
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
|
||||
|
||||
expect(requestedUrls()).toEqual([CHANNELS_URL]);
|
||||
expect(result).toEqual({
|
||||
channels: [channelOption(FIRST_CHANNEL)],
|
||||
incomplete: SLACK_PARTIAL_CHANNEL_LIST_MESSAGE,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("answers an unreadable page as no channels rather than parser prose", async () => {
|
||||
fetchMock.mockResolvedValueOnce(unreadableOk(HTML_INTERSTITIAL));
|
||||
|
||||
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
|
||||
|
||||
expect(result).toEqual({ channels: [] });
|
||||
expectNoParserProse(result);
|
||||
});
|
||||
|
||||
it("says the list is short of the workspace when the page budget runs out", async () => {
|
||||
// The budget exists because `conversations.list` is tier 2 and a workspace
|
||||
// can outgrow it (design.md, Risks). A fresh `Response` per call: one
|
||||
// instance is already consumed on its second read.
|
||||
fetchMock.mockImplementation(() =>
|
||||
Promise.resolve(channelPage(FIRST_CHANNEL, "?page[cursor]=next")),
|
||||
);
|
||||
|
||||
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(MAX_CHANNEL_PAGES);
|
||||
expect(result).toEqual({
|
||||
channels: channelOptions(MAX_CHANNEL_PAGES),
|
||||
incomplete: SLACK_PARTIAL_CHANNEL_LIST_MESSAGE,
|
||||
});
|
||||
});
|
||||
|
||||
it("says nothing about a short list for a workspace that just fits the budget", async () => {
|
||||
let page = 0;
|
||||
fetchMock.mockImplementation(() => {
|
||||
page += 1;
|
||||
return Promise.resolve(
|
||||
channelPage(
|
||||
FIRST_CHANNEL,
|
||||
page < MAX_CHANNEL_PAGES ? `?page[cursor]=${page}` : null,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(MAX_CHANNEL_PAGES);
|
||||
expect(result).toEqual({ channels: channelOptions(MAX_CHANNEL_PAGES) });
|
||||
expect(result).not.toHaveProperty("incomplete");
|
||||
});
|
||||
|
||||
it("keeps the pages it read when a later one is refused, saying why the list stops", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(channelPage(FIRST_CHANNEL, "?page[cursor]=2"))
|
||||
.mockResolvedValueOnce(rateLimitedResponse());
|
||||
|
||||
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
|
||||
|
||||
// A rate limit says nothing about the grant, so the truncation names none.
|
||||
expect(result).toEqual({
|
||||
channels: [channelOption(FIRST_CHANNEL)],
|
||||
incomplete: RATE_LIMITED_MESSAGE,
|
||||
code: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("names the reason a later page was refused, not only the wording", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(channelPage(FIRST_CHANNEL, "?page[cursor]=2"))
|
||||
.mockResolvedValueOnce(
|
||||
errorResponse(400, TOKEN_EXPIRED_DETAIL, TOKEN_EXPIRED_CODE),
|
||||
);
|
||||
|
||||
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
|
||||
|
||||
expect(result).toEqual({
|
||||
channels: [channelOption(FIRST_CHANNEL)],
|
||||
incomplete: TOKEN_EXPIRED_MESSAGE,
|
||||
code: TOKEN_EXPIRED_CODE,
|
||||
});
|
||||
});
|
||||
|
||||
it("answers a refusal on the first page as a failure, having nothing to show", async () => {
|
||||
fetchMock.mockResolvedValueOnce(rateLimitedResponse());
|
||||
|
||||
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
|
||||
|
||||
expect(result).toEqual({ error: RATE_LIMITED_MESSAGE, code: null });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A `2xx` whose body is not JSON:API: an empty answer, or the HTML a proxy or
|
||||
* WAF puts in front of one. The raw `SyntaxError` survives
|
||||
* `sanitizeErrorMessage` (V8 truncates the snippet to ten characters, so its
|
||||
* `<!doctype html>` branch never matches) and would be shown verbatim.
|
||||
*/
|
||||
const HTML_INTERSTITIAL =
|
||||
"<!DOCTYPE html><html><body><h1>Checking your browser</h1></body></html>";
|
||||
|
||||
const unreadableOk = (body: string) =>
|
||||
new Response(body, {
|
||||
status: 200,
|
||||
headers: { "content-type": body ? "text/html" : "application/json" },
|
||||
});
|
||||
|
||||
/** V8's parser wording, which no user should ever be shown. */
|
||||
const PARSER_PROSE = /unexpected (token|end of json)|not valid json/i;
|
||||
|
||||
const expectNoParserProse = (result: unknown) => {
|
||||
const message = (result as { error?: string }).error ?? "";
|
||||
expect(message).not.toMatch(PARSER_PROSE);
|
||||
};
|
||||
|
||||
const INTEGRATION_URL = `https://api.test/api/v1/integrations/${SLACK_INTEGRATION_ID}`;
|
||||
|
||||
const saveChannel = () =>
|
||||
setSlackDefaultChannel(SLACK_INTEGRATION_ID, FIRST_CHANNEL.id);
|
||||
|
||||
const expectIntegrationsRevalidated = () => {
|
||||
expect(vi.mocked(revalidatePath).mock.calls).toEqual([
|
||||
["/integrations"],
|
||||
["/integrations/slack"],
|
||||
]);
|
||||
};
|
||||
|
||||
describe("setSlackDefaultChannel", () => {
|
||||
it("returns the saved integration and revalidates the pages listing it", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
type: "integrations",
|
||||
id: SLACK_INTEGRATION_ID,
|
||||
attributes: {
|
||||
integration_type: "slack",
|
||||
configuration: {
|
||||
channel_id: FIRST_CHANNEL.id,
|
||||
channel_name: FIRST_CHANNEL.name,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/vnd.api+json" },
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const result = await saveChannel();
|
||||
|
||||
expect(requestedUrls()).toEqual([INTEGRATION_URL]);
|
||||
expect(result).toMatchObject({
|
||||
integration: {
|
||||
attributes: { configuration: { channel_name: FIRST_CHANNEL.name } },
|
||||
},
|
||||
});
|
||||
expectIntegrationsRevalidated();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ shape: "empty", body: "" },
|
||||
{ shape: "an HTML interstitial", body: HTML_INTERSTITIAL },
|
||||
])(
|
||||
"answers a $shape `200` as an unread result, not as a failed save",
|
||||
async ({ body }) => {
|
||||
fetchMock.mockResolvedValueOnce(unreadableOk(body));
|
||||
|
||||
const result = await saveChannel();
|
||||
|
||||
expect(result).toEqual({ error: SLACK_UNREADABLE_RESULT_MESSAGE });
|
||||
expectNoParserProse(result);
|
||||
// The API recorded the channel before answering, so both pages refresh.
|
||||
expectIntegrationsRevalidated();
|
||||
},
|
||||
);
|
||||
|
||||
// The caller reads `integration.attributes.configuration`, so a shallower
|
||||
// guard lets the miss surface later as the manager's generic catch.
|
||||
it.each([
|
||||
{ shape: "no `data`", body: {} },
|
||||
{ shape: "a null `data`", body: { data: null } },
|
||||
{ shape: "a `data` with no configuration", body: { data: {} } },
|
||||
])(
|
||||
"answers a `200` carrying $shape as an unread result",
|
||||
async ({ body }) => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/vnd.api+json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await saveChannel();
|
||||
|
||||
expect(result).toEqual({ error: SLACK_UNREADABLE_RESULT_MESSAGE });
|
||||
expectNoParserProse(result);
|
||||
expectIntegrationsRevalidated();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/** The `202` that hands back the task the post is reported on (design D9). */
|
||||
const TEST_MESSAGE_TASK_ID = "5f8b1c2d-7e64-4a90-8c31-2b7d6e5f4a90";
|
||||
|
||||
const testMessageAccepted = () =>
|
||||
new Response(JSON.stringify({ data: { id: TEST_MESSAGE_TASK_ID } }), {
|
||||
status: 202,
|
||||
headers: { "content-type": "application/vnd.api+json" },
|
||||
});
|
||||
|
||||
/** The task read the poll makes, already settled on its first look. */
|
||||
const settledTask = (state: string, result: unknown) =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
type: "tasks",
|
||||
id: TEST_MESSAGE_TASK_ID,
|
||||
attributes: { state, result },
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/vnd.api+json" } },
|
||||
);
|
||||
|
||||
describe("sendSlackTestMessage", () => {
|
||||
it("answers an unreadable `202` as no task started, not as parser prose", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(HTML_INTERSTITIAL, {
|
||||
status: 202,
|
||||
headers: { "content-type": "text/html" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await sendSlackTestMessage(SLACK_INTEGRATION_ID);
|
||||
|
||||
expect(result).toEqual({ error: "Slack did not start the test message." });
|
||||
expectNoParserProse(result);
|
||||
});
|
||||
|
||||
it("wraps a reason it has no copy for instead of answering with the bare token", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(testMessageAccepted())
|
||||
.mockResolvedValueOnce(settledTask("failed", { error: "is_archived" }));
|
||||
|
||||
const result = await sendSlackTestMessage(SLACK_INTEGRATION_ID);
|
||||
|
||||
const error = (result as { error?: string }).error ?? "";
|
||||
expect(error).toMatch(/Slack refused the message/);
|
||||
expect(error).toContain("is_archived");
|
||||
expect(error).not.toBe("is_archived");
|
||||
});
|
||||
|
||||
it("keeps Prowler's own wording for a reason the mapping covers", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(testMessageAccepted())
|
||||
.mockResolvedValueOnce(
|
||||
settledTask("failed", { error: "not_in_channel" }),
|
||||
);
|
||||
|
||||
const result = await sendSlackTestMessage(SLACK_INTEGRATION_ID);
|
||||
|
||||
expect(result).toEqual({
|
||||
error: SLACK_ERROR_MESSAGES[SLACK_ERROR_CODE.NOT_IN_CHANNEL],
|
||||
code: SLACK_ERROR_CODE.NOT_IN_CHANNEL,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a reason the task worded itself as the prose it is", async () => {
|
||||
// Not token-shaped, so nothing is wrapped around it.
|
||||
const prose = "Slack rejected the message: the channel is archived.";
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(testMessageAccepted())
|
||||
.mockResolvedValueOnce(settledTask("failed", { error: prose }));
|
||||
|
||||
const result = await sendSlackTestMessage(SLACK_INTEGRATION_ID);
|
||||
|
||||
expect(result).toEqual({ error: prose });
|
||||
});
|
||||
});
|
||||
|
||||
/** The calls whose only failure path is one line of copy. */
|
||||
const COPY_ONLY_ACTIONS = [
|
||||
{
|
||||
name: "getSlackChannels",
|
||||
call: (id: string) => getSlackChannels(id),
|
||||
},
|
||||
{
|
||||
name: "setSlackDefaultChannel",
|
||||
call: (id: string) => setSlackDefaultChannel(id, FIRST_CHANNEL.id),
|
||||
},
|
||||
{
|
||||
name: "sendSlackTestMessage",
|
||||
call: (id: string) => sendSlackTestMessage(id),
|
||||
},
|
||||
{
|
||||
name: "disconnectSlackIntegration",
|
||||
call: (id: string) => disconnectSlackIntegration(id),
|
||||
},
|
||||
];
|
||||
|
||||
const rateLimitedResponse = () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
errors: [{ status: "429", detail: "Slack is rate limiting." }],
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
"content-type": "application/vnd.api+json",
|
||||
"Retry-After": "30",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
describe.each(COPY_ONLY_ACTIONS)("$name", ({ call }) => {
|
||||
it("reports an upstream Slack failure and still answers in the same words", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
errorResponse(UPSTREAM_STATUS, UPSTREAM_DETAIL),
|
||||
);
|
||||
|
||||
const result = await call(SLACK_INTEGRATION_ID);
|
||||
|
||||
// Once, not twice: `handleApiResponse` reports and throws, and the action's
|
||||
// catch sees the mark.
|
||||
expect(captureExceptionMock).toHaveBeenCalledTimes(1);
|
||||
expect(captureExceptionMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
tags: {
|
||||
api_error: true,
|
||||
error_source: SentryErrorSource.HANDLE_API_RESPONSE,
|
||||
error_type: SentryErrorType.SERVER_ERROR,
|
||||
status_code: String(UPSTREAM_STATUS),
|
||||
},
|
||||
});
|
||||
expect(captureMessageMock).not.toHaveBeenCalled();
|
||||
|
||||
expect(result).toEqual({ error: UPSTREAM_DETAIL });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
status: 503,
|
||||
why: "Slack being unavailable, not a fault",
|
||||
response: () => errorResponse(503, "Slack is unavailable."),
|
||||
expected: "Slack is unavailable.",
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
why: "a wait, not a fault",
|
||||
response: rateLimitedResponse,
|
||||
expected:
|
||||
"Slack is rate limiting Prowler right now. Try again in about 30 seconds.",
|
||||
},
|
||||
{
|
||||
status: 400,
|
||||
why: "a refusal the API meant to give",
|
||||
response: () => errorResponse(400, "No default channel is set."),
|
||||
expected: "No default channel is set.",
|
||||
},
|
||||
])("reports nothing for a $status: that is $why", async (refusal) => {
|
||||
fetchMock.mockResolvedValue(refusal.response());
|
||||
|
||||
const result = await call(SLACK_INTEGRATION_ID);
|
||||
|
||||
expect(captureExceptionMock).not.toHaveBeenCalled();
|
||||
expect(captureMessageMock).not.toHaveBeenCalled();
|
||||
// None of these refusals names a `code`.
|
||||
expect(result).toEqual({ error: refusal.expected, code: null });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The integration id is interpolated into every one of these URLs, so a
|
||||
* malformed one is refused before the request is built.
|
||||
*/
|
||||
describe.each(COPY_ONLY_ACTIONS)("$name", ({ call }) => {
|
||||
it.each(["../../users", "not-a-uuid", ""])(
|
||||
"asks the API nothing when the integration id is %o",
|
||||
async (id) => {
|
||||
const result = await call(id);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ error: SLACK_GENERIC_ERROR_MESSAGE });
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,640 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
|
||||
import { pollTaskUntilSettled } from "@/actions/task/poll";
|
||||
import { apiBaseUrl, getAuthHeaders, parseStringify } from "@/lib";
|
||||
import {
|
||||
readSlackFailure,
|
||||
SLACK_GENERIC_ERROR_MESSAGE,
|
||||
SLACK_PARTIAL_CHANNEL_LIST_MESSAGE,
|
||||
SLACK_REASON_TOKEN,
|
||||
SLACK_UNREADABLE_RESULT_MESSAGE,
|
||||
slackErrorMessage,
|
||||
slackRateLimitMessage,
|
||||
slackUnknownReasonMessage,
|
||||
} from "@/lib/integrations/slack-errors";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import {
|
||||
INTEGRATION_TYPE,
|
||||
type IntegrationProps,
|
||||
type SlackChannelOption,
|
||||
} from "@/types/integrations";
|
||||
|
||||
interface SlackUnavailable {
|
||||
unavailable: true;
|
||||
}
|
||||
|
||||
interface SlackRateLimited {
|
||||
rateLimited: true;
|
||||
retryAfterSeconds: number | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The API accepted the exchange (`2xx`) and the UI could not read the workspace
|
||||
* back: the install happened, only its result is unknown.
|
||||
*/
|
||||
interface SlackUnconfirmed {
|
||||
unconfirmed: true;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface SlackActionError {
|
||||
error: string;
|
||||
/**
|
||||
* The refusal's `code`, when it named one, alongside the copy. A caller reads
|
||||
* it to recognise a class of failure the wording cannot be pattern-matched
|
||||
* for — a Slack grant that has stopped working, which the contract allows
|
||||
* from any of these calls (Cross-cutting) and is recovered from by
|
||||
* reconnecting rather than by retrying.
|
||||
*/
|
||||
code?: string | null;
|
||||
}
|
||||
|
||||
interface SlackAuthorizeUrl {
|
||||
authorizeUrl: string;
|
||||
}
|
||||
|
||||
export type SlackAuthorizeUrlResult =
|
||||
| SlackAuthorizeUrl
|
||||
| SlackUnavailable
|
||||
| SlackRateLimited
|
||||
| SlackActionError;
|
||||
|
||||
interface SlackExchangeInput {
|
||||
code: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
const slackExchangeInputSchema = z.object({
|
||||
code: z.string().min(1),
|
||||
state: z.string().min(1),
|
||||
});
|
||||
|
||||
/**
|
||||
* SSRF guard: the integration id is interpolated into the request URL, so only
|
||||
* the shape the API's ids have reaches it.
|
||||
*/
|
||||
const integrationIdSchema = z.uuid();
|
||||
|
||||
const parseIntegrationId = (integrationId: string): string | null => {
|
||||
const parsed = integrationIdSchema.safeParse(integrationId);
|
||||
return parsed.success ? parsed.data : null;
|
||||
};
|
||||
|
||||
interface SlackExchangeSuccess {
|
||||
integration: IntegrationProps;
|
||||
}
|
||||
|
||||
export type SlackExchangeResult =
|
||||
| SlackExchangeSuccess
|
||||
| SlackUnavailable
|
||||
| SlackRateLimited
|
||||
| SlackUnconfirmed
|
||||
| SlackActionError;
|
||||
|
||||
/**
|
||||
* `503`: no Slack app configured in this deployment. `404`: no Slack API at
|
||||
* all. Both mean "not available here", unlike `429`/`502` which mean "not now".
|
||||
*/
|
||||
const isUnavailableStatus = (status: number): boolean =>
|
||||
status === 503 || status === 404;
|
||||
|
||||
const RATE_LIMITED_STATUS = 429;
|
||||
|
||||
const SLACK_AUTHORIZE_HOSTNAME = "slack.com";
|
||||
const SLACK_AUTHORIZE_PATHNAME = "/oauth/v2/authorize";
|
||||
const NO_AUTHORIZE_URL_MESSAGE = "Slack did not return an authorization URL.";
|
||||
|
||||
/**
|
||||
* The URL is rendered as the `Add to Slack` link's `href`, so anything that is
|
||||
* not Slack's consent screen is a redirect to an origin the user did not choose.
|
||||
*/
|
||||
const isSlackAuthorizeUrl = (value: string): boolean => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return (
|
||||
url.protocol === "https:" &&
|
||||
url.hostname === SLACK_AUTHORIZE_HOSTNAME &&
|
||||
url.pathname === SLACK_AUTHORIZE_PATHNAME
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const INTEGRATIONS_RESOURCE_TYPE = "integrations";
|
||||
|
||||
/**
|
||||
* The callback names the workspace and redirects on this value alone, so a `2xx`
|
||||
* payload that is not a JSON:API resource (`{}`, `[]`, `"invalid"`) must read as
|
||||
* unreadable rather than as a connected workspace. Identity too: a resource
|
||||
* that is not a linkable Slack integration would be shown as the workspace
|
||||
* just installed.
|
||||
*/
|
||||
const isIntegrationResource = (value: unknown): boolean => {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { id, type, attributes } = value as Record<string, unknown>;
|
||||
|
||||
if (
|
||||
typeof id !== "string" ||
|
||||
id === "" ||
|
||||
type !== INTEGRATIONS_RESOURCE_TYPE ||
|
||||
typeof attributes !== "object" ||
|
||||
attributes === null ||
|
||||
Array.isArray(attributes)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
(attributes as Record<string, unknown>).integration_type ===
|
||||
INTEGRATION_TYPE.SLACK
|
||||
);
|
||||
};
|
||||
|
||||
const failureFrom = async (
|
||||
response: Response,
|
||||
fallback: string,
|
||||
): Promise<SlackUnavailable | SlackRateLimited | SlackActionError> => {
|
||||
if (isUnavailableStatus(response.status)) return { unavailable: true };
|
||||
|
||||
// A 5xx (including the `502` the contract reserves for "Slack upstream
|
||||
// broke") goes through the repo's 5xx handling, which reports to Sentry and
|
||||
// throws, so the caller's catch answers the user. Must run before
|
||||
// `readSlackFailure`: a body can only be read once.
|
||||
if (response.status >= 500) await handleApiResponse(response);
|
||||
|
||||
const failure = await readSlackFailure(response);
|
||||
|
||||
if (failure.status === RATE_LIMITED_STATUS) {
|
||||
return {
|
||||
rateLimited: true,
|
||||
retryAfterSeconds: failure.retryAfterSeconds,
|
||||
message: slackRateLimitMessage(failure.retryAfterSeconds),
|
||||
};
|
||||
}
|
||||
|
||||
return { error: slackErrorMessage(failure, fallback), code: failure.code };
|
||||
};
|
||||
|
||||
/**
|
||||
* `failureFrom` flattened to one refusal, for the calls whose only outcome is
|
||||
* "it did not work". Rate limiting keeps its own wording: `conversations.list`
|
||||
* is Slack tier 2, so a `429` shows up here (contract, Errors) and the wait it
|
||||
* names is the useful part.
|
||||
*/
|
||||
const refusalFrom = async (
|
||||
response: Response,
|
||||
fallback: string,
|
||||
): Promise<SlackActionError> => {
|
||||
// Same 5xx handling as `failureFrom`, `503` excepted: here too it means Slack
|
||||
// is unavailable. Must run before `readSlackFailure`: a body can only be read
|
||||
// once.
|
||||
if (response.status >= 500 && response.status !== 503) {
|
||||
await handleApiResponse(response);
|
||||
}
|
||||
|
||||
const failure = await readSlackFailure(response);
|
||||
|
||||
return {
|
||||
error:
|
||||
failure.status === RATE_LIMITED_STATUS
|
||||
? slackRateLimitMessage(failure.retryAfterSeconds)
|
||||
: slackErrorMessage(failure, fallback),
|
||||
code: failure.code,
|
||||
};
|
||||
};
|
||||
|
||||
/** Mint an OAuth state and get the consent URL. Creates no integration. */
|
||||
export const getSlackAuthorizeUrl =
|
||||
async (): Promise<SlackAuthorizeUrlResult> => {
|
||||
const headers = await getAuthHeaders({ contentType: true });
|
||||
const url = new URL(`${apiBaseUrl}/integrations/slack/oauth/authorize-url`);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), { method: "POST", headers });
|
||||
|
||||
if (!response.ok) {
|
||||
// Awaited inside the `try`: a returned promise's rejection would skip
|
||||
// this `catch`, and a 5xx rejects.
|
||||
return await failureFrom(
|
||||
response,
|
||||
`Unable to start the Slack install: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
// The URL travels in JSON:API `meta`: the call creates no resource. A
|
||||
// non-JSON `2xx` reads as "no URL" instead of throwing a parser message
|
||||
// the user would be shown verbatim.
|
||||
const body = await response.json().catch(() => null);
|
||||
const authorizeUrl = body?.meta?.authorize_url;
|
||||
|
||||
// A URL that is not Slack's own is no more usable than a missing one.
|
||||
if (
|
||||
typeof authorizeUrl !== "string" ||
|
||||
!isSlackAuthorizeUrl(authorizeUrl)
|
||||
) {
|
||||
return { error: NO_AUTHORIZE_URL_MESSAGE };
|
||||
}
|
||||
|
||||
return { authorizeUrl };
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Complete the install with what Slack put in the callback URL. The API
|
||||
* consumes the `state`, exchanges the single-use `code`, and upserts the
|
||||
* tenant's Slack integration.
|
||||
*/
|
||||
export const exchangeSlackOAuthCode = async (
|
||||
input: SlackExchangeInput,
|
||||
): Promise<SlackExchangeResult> => {
|
||||
const parsed = slackExchangeInputSchema.safeParse(input);
|
||||
if (!parsed.success) return { error: SLACK_GENERIC_ERROR_MESSAGE };
|
||||
|
||||
const { code, state } = parsed.data;
|
||||
const headers = await getAuthHeaders({ contentType: true });
|
||||
const url = new URL(`${apiBaseUrl}/integrations/slack/oauth/exchange`);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
type: "slack-oauth-exchanges",
|
||||
attributes: { code, state },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Awaited inside the `try`: unawaited, a 5xx's rejection would skip this
|
||||
// `catch` and leave the callback on its spinner.
|
||||
return await failureFrom(
|
||||
response,
|
||||
`Unable to connect the Slack workspace: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const body = await response.json().catch(() => null);
|
||||
|
||||
// Before the guard and on both paths: the API upserted the integration
|
||||
// before answering, so a cache filled when there was none would list the
|
||||
// connected workspace as missing.
|
||||
revalidatePath("/integrations");
|
||||
revalidatePath("/integrations/slack");
|
||||
|
||||
if (!isIntegrationResource(body?.data)) {
|
||||
return { unconfirmed: true, message: SLACK_UNREADABLE_RESULT_MESSAGE };
|
||||
}
|
||||
|
||||
return { integration: parseStringify(body.data) as IntegrationProps };
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
};
|
||||
|
||||
interface SlackChannelsSuccess {
|
||||
channels: SlackChannelOption[];
|
||||
/**
|
||||
* Present when these channels are only part of the workspace's, carrying the
|
||||
* sentence that says why: a partial read is a success, so the caller renders
|
||||
* the picker *and* the reason.
|
||||
*/
|
||||
incomplete?: string;
|
||||
/**
|
||||
* The `code` of the refusal that cut the read short, when it named one. A
|
||||
* grant that has stopped working refuses the second cursor page exactly as it
|
||||
* refuses the first, and a caller reading only the failure path would never
|
||||
* hear about it.
|
||||
*/
|
||||
code?: string | null;
|
||||
}
|
||||
|
||||
export type SlackChannelsResult = SlackChannelsSuccess | SlackActionError;
|
||||
|
||||
/**
|
||||
* Cursor pages followed before giving up: `conversations.list` is a tier-2,
|
||||
* rate-limited Slack call (design.md, Risks), so the aggregation is bounded
|
||||
* rather than open-ended.
|
||||
*/
|
||||
const MAX_CHANNEL_PAGES = 20;
|
||||
|
||||
/**
|
||||
* Every channel Prowler can post to in the connected workspace — the picker's
|
||||
* options.
|
||||
*
|
||||
* The durable primitive, not the channel stored on the integration (design D6):
|
||||
* a consumer needing a per-rule channel reads the same endpoint. `links.next`
|
||||
* is followed opaquely — the contract does not pin the cursor parameter naming,
|
||||
* so the UI never builds one of its own. An early stop that still read
|
||||
* something reports through `incomplete`, not as a failure.
|
||||
*/
|
||||
export const getSlackChannels = async (
|
||||
integrationId: string,
|
||||
): Promise<SlackChannelsResult> => {
|
||||
const id = parseIntegrationId(integrationId);
|
||||
if (!id) return { error: SLACK_GENERIC_ERROR_MESSAGE };
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const channels: SlackChannelOption[] = [];
|
||||
|
||||
const listing = new URL(`${apiBaseUrl}/integrations/${id}/slack/channels`);
|
||||
let next: string | null = listing.toString();
|
||||
let incomplete: string | null = null;
|
||||
|
||||
try {
|
||||
for (let page = 0; next && page < MAX_CHANNEL_PAGES; page += 1) {
|
||||
const current: string = next;
|
||||
const response: Response = await fetch(current, {
|
||||
method: "GET",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const refusal = await refusalFrom(
|
||||
response,
|
||||
`Unable to read the workspace's channels: ${response.statusText}`,
|
||||
);
|
||||
|
||||
return channels.length > 0
|
||||
? { channels, incomplete: refusal.error, code: refusal.code }
|
||||
: refusal;
|
||||
}
|
||||
|
||||
// A page that is not JSON reads as no channels, rather than throwing a
|
||||
// parser message the user would be shown verbatim.
|
||||
const body = await response.json().catch(() => null);
|
||||
|
||||
for (const resource of body?.data ?? []) {
|
||||
channels.push({
|
||||
id: resource?.id,
|
||||
name: resource?.attributes?.name ?? "",
|
||||
is_private: Boolean(resource?.attributes?.is_private),
|
||||
});
|
||||
}
|
||||
|
||||
const rawNext = body?.links?.next;
|
||||
const candidate =
|
||||
typeof rawNext === "string" && rawNext.length > 0
|
||||
? new URL(rawNext, current)
|
||||
: null;
|
||||
// Resolved against the page it arrived on, so a cursor-only `next` keeps
|
||||
// this listing's path. Followed only while it stays on the listing's
|
||||
// origin: every page is fetched with the tenant's token, and an
|
||||
// off-origin hop made here would carry it along.
|
||||
if (candidate === null) {
|
||||
next = null;
|
||||
} else if (candidate.origin === listing.origin) {
|
||||
next = candidate.toString();
|
||||
} else {
|
||||
next = null;
|
||||
incomplete = SLACK_PARTIAL_CHANNEL_LIST_MESSAGE;
|
||||
}
|
||||
}
|
||||
|
||||
// A link still waiting when the budget ran out. Checked rather than assumed
|
||||
// from the page count: a workspace of exactly `MAX_CHANNEL_PAGES` pages was
|
||||
// read to the end.
|
||||
if (next) incomplete = SLACK_PARTIAL_CHANNEL_LIST_MESSAGE;
|
||||
|
||||
return incomplete === null ? { channels } : { channels, incomplete };
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
};
|
||||
|
||||
interface SlackDefaultChannelSuccess {
|
||||
integration: IntegrationProps;
|
||||
}
|
||||
|
||||
export type SlackDefaultChannelResult =
|
||||
| SlackDefaultChannelSuccess
|
||||
| SlackActionError;
|
||||
|
||||
/**
|
||||
* Record the channel Prowler posts to, on the generic integration endpoint.
|
||||
*
|
||||
* A Slack action despite the generic `PATCH`: `channel_not_found` and
|
||||
* `not_in_channel` carry the same `detail`, so only `code` tells them apart,
|
||||
* and the generic action reads `detail` alone. Only `channel_id` travels — the
|
||||
* API derives `channel_name` server-side (design D6).
|
||||
*/
|
||||
export const setSlackDefaultChannel = async (
|
||||
integrationId: string,
|
||||
channelId: string,
|
||||
): Promise<SlackDefaultChannelResult> => {
|
||||
const id = parseIntegrationId(integrationId);
|
||||
if (!id) return { error: SLACK_GENERIC_ERROR_MESSAGE };
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: true });
|
||||
const url = new URL(`${apiBaseUrl}/integrations/${id}`);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "PATCH",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
type: "integrations",
|
||||
id,
|
||||
attributes: {
|
||||
integration_type: "slack",
|
||||
configuration: { channel_id: channelId },
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Awaited inside the `try`: unawaited, a 5xx's rejection would skip
|
||||
// this `catch`.
|
||||
return await refusalFrom(
|
||||
response,
|
||||
`Unable to save the destination channel: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const body = await response.json().catch(() => null);
|
||||
|
||||
// Before the guard and on both paths: the save happened, so a cache still
|
||||
// holding the previous channel would keep showing it.
|
||||
revalidatePath("/integrations");
|
||||
revalidatePath("/integrations/slack");
|
||||
|
||||
// Guarded as deep as the caller reads: it names the saved channel from
|
||||
// `attributes.configuration`.
|
||||
if (!body?.data?.attributes?.configuration) {
|
||||
return { error: SLACK_UNREADABLE_RESULT_MESSAGE };
|
||||
}
|
||||
|
||||
return { integration: parseStringify(body.data) as IntegrationProps };
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
};
|
||||
|
||||
interface SlackTestMessageSuccess {
|
||||
sent: true;
|
||||
}
|
||||
|
||||
export type SlackTestMessageResult = SlackTestMessageSuccess | SlackActionError;
|
||||
|
||||
interface SlackTestMessageTaskResult {
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
const TEST_MESSAGE_POLL = { maxAttempts: 20, delayMs: 3000 } as const;
|
||||
|
||||
/**
|
||||
* Post the test message to the integration's default channel.
|
||||
*
|
||||
* Async on the API's side — `202` plus a Task (design D9) — so this polls the
|
||||
* same task machinery the connection test uses. A `400` means no default
|
||||
* channel is recorded.
|
||||
*/
|
||||
export const sendSlackTestMessage = async (
|
||||
integrationId: string,
|
||||
): Promise<SlackTestMessageResult> => {
|
||||
const id = parseIntegrationId(integrationId);
|
||||
if (!id) return { error: SLACK_GENERIC_ERROR_MESSAGE };
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: true });
|
||||
const url = new URL(`${apiBaseUrl}/integrations/${id}/slack/test-message`);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), { method: "POST", headers });
|
||||
|
||||
if (!response.ok) {
|
||||
return await refusalFrom(
|
||||
response,
|
||||
`Unable to send the test message: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
// As above: an unreadable `202` is "no task to follow", not a parser
|
||||
// message.
|
||||
const body = await response.json().catch(() => null);
|
||||
const taskId = body?.data?.id;
|
||||
|
||||
if (!taskId) {
|
||||
return { error: "Slack did not start the test message." };
|
||||
}
|
||||
|
||||
const settled = await pollTaskUntilSettled<SlackTestMessageTaskResult>(
|
||||
taskId,
|
||||
TEST_MESSAGE_POLL,
|
||||
);
|
||||
|
||||
if (!settled.ok) {
|
||||
return { error: settled.error };
|
||||
}
|
||||
|
||||
// Slack's refusal travels in the task result, not in an HTTP error: the
|
||||
// post happens after the `202`. A known code gets Prowler's own wording, a
|
||||
// code-shaped reason is wrapped in one (contract, test-message), and prose
|
||||
// is shown as it arrived.
|
||||
const reason = settled.result?.error?.trim();
|
||||
if (reason) {
|
||||
return SLACK_REASON_TOKEN.test(reason)
|
||||
? {
|
||||
error: slackErrorMessage(
|
||||
{ code: reason },
|
||||
slackUnknownReasonMessage(reason),
|
||||
),
|
||||
// A dead grant can surface here too, so the reason travels on as
|
||||
// a `code`, not only as its sentence.
|
||||
code: reason,
|
||||
}
|
||||
: { error: reason };
|
||||
}
|
||||
if (settled.state !== "completed") {
|
||||
return { error: "Slack did not accept the test message." };
|
||||
}
|
||||
|
||||
return { sent: true };
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* What the API reports about revoking Prowler's token at Slack: one boolean in
|
||||
* `meta`, and nothing else — it sends no reason for a revocation that did not
|
||||
* happen, so there is no field here to hold one.
|
||||
*/
|
||||
export interface SlackRevocation {
|
||||
/**
|
||||
* Whether Slack confirmed the token no longer grants Prowler anything, or
|
||||
* `null` when the response carried no outcome. The contract says the outcome
|
||||
* is always reported, so `null` means the response is wrong, not the
|
||||
* revocation.
|
||||
*/
|
||||
revoked: boolean | null;
|
||||
}
|
||||
|
||||
interface SlackDisconnectSuccess {
|
||||
/** The integration is gone from Prowler, whatever Slack answered. */
|
||||
disconnected: true;
|
||||
revocation: SlackRevocation;
|
||||
}
|
||||
|
||||
export type SlackDisconnectResult = SlackDisconnectSuccess | SlackActionError;
|
||||
|
||||
/**
|
||||
* Disconnect the workspace: `DELETE /integrations/{id}`.
|
||||
*
|
||||
* The generic `deleteIntegration` cannot serve this: it discards the response
|
||||
* body, and the body is the whole point. Revocation at Slack is best-effort —
|
||||
* the row is removed either way and the outcome travels in JSON:API `meta` — so
|
||||
* a caller has to tell "gone and revoked" from "gone, but still installed in
|
||||
* Slack".
|
||||
*
|
||||
* A body without the field (an empty `204`, say) yields `null`, not `false`: an
|
||||
* unreported outcome must not send the user off to clean up Slack, nor be shown
|
||||
* as access revoked.
|
||||
*/
|
||||
export const disconnectSlackIntegration = async (
|
||||
integrationId: string,
|
||||
): Promise<SlackDisconnectResult> => {
|
||||
const id = parseIntegrationId(integrationId);
|
||||
if (!id) return { error: SLACK_GENERIC_ERROR_MESSAGE };
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: true });
|
||||
const url = new URL(`${apiBaseUrl}/integrations/${id}`);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), { method: "DELETE", headers });
|
||||
|
||||
if (!response.ok) {
|
||||
return await refusalFrom(
|
||||
response,
|
||||
`Unable to disconnect the Slack workspace: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const body = await response.json().catch(() => ({}));
|
||||
const meta = body?.meta ?? {};
|
||||
|
||||
revalidatePath("/integrations");
|
||||
revalidatePath("/integrations/slack");
|
||||
|
||||
return {
|
||||
disconnected: true,
|
||||
revocation: {
|
||||
revoked: typeof meta.revoked === "boolean" ? meta.revoked : null,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { ApiKeyLinkCard } from "@/components/integrations/api-key/api-key-link-card";
|
||||
import { JiraIntegrationCard } from "@/components/integrations/jira/jira-integration-card";
|
||||
import { S3IntegrationCard } from "@/components/integrations/s3/s3-integration-card";
|
||||
import { SecurityHubIntegrationCard } from "@/components/integrations/security-hub/security-hub-integration-card";
|
||||
import { SlackIntegrationCard } from "@/components/integrations/slack/slack-integration-card";
|
||||
import { SsoLinkCard } from "@/components/integrations/sso/sso-link-card";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
|
||||
/**
|
||||
* Split out of `page.tsx` for the browser-mode tests: `ContentLayout`'s navbar
|
||||
* streams async server children a client renderer can't resolve.
|
||||
*/
|
||||
export function IntegrationsContent() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Connect external services to enhance your security workflow and
|
||||
automatically export your scan results.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6">
|
||||
{/* Amazon S3 Integration */}
|
||||
<S3IntegrationCard />
|
||||
|
||||
{/* AWS Security Hub Integration */}
|
||||
<SecurityHubIntegrationCard />
|
||||
|
||||
{/* Jira Integration */}
|
||||
<JiraIntegrationCard />
|
||||
|
||||
{/* Slack Integration - cloud-only API, nothing to manage self-hosted */}
|
||||
{isCloud() && <SlackIntegrationCard />}
|
||||
|
||||
{/* SSO Configuration - redirects to Profile */}
|
||||
<SsoLinkCard />
|
||||
|
||||
{/* API Keys - redirects to Profile */}
|
||||
<ApiKeyLinkCard />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Browser-mode tests for the Slack entry in the integrations catalogue
|
||||
* (`/integrations`), which is offered in Prowler Cloud only. Driven through
|
||||
* `SlackIntegrationHarness` against the MSW handlers.
|
||||
*/
|
||||
|
||||
import { describe, expect } from "vitest";
|
||||
|
||||
import { it } from "@/__tests__/fixtures";
|
||||
import { slackFixture } from "@/__tests__/msw/handlers/slack.fixtures";
|
||||
|
||||
import { SlackIntegrationHarness } from "./slack/slack-integration.harness";
|
||||
|
||||
describe("the integrations catalogue", () => {
|
||||
it("offers Slack in Prowler Cloud, with a way to manage it", async () => {
|
||||
// Given — a Prowler Cloud deployment (the fixtures' default island).
|
||||
const harness = new SlackIntegrationHarness(slackFixture());
|
||||
|
||||
harness.mountCatalogue();
|
||||
|
||||
expect(await harness.listedIntegrations()).toContain("Slack");
|
||||
expect(harness.offersSlackManagement()).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it("omits Slack in a deployment that is not Prowler Cloud", async ({
|
||||
seedRuntimeConfig,
|
||||
}) => {
|
||||
seedRuntimeConfig({ cloudEnabled: false });
|
||||
const harness = new SlackIntegrationHarness(slackFixture());
|
||||
|
||||
harness.mountCatalogue();
|
||||
|
||||
const listed = await harness.listedIntegrations();
|
||||
expect(listed).not.toContain("Slack");
|
||||
expect(harness.offersSlackManagement()).toBe(false);
|
||||
// Tripwire: the catalogue rendered, so the assertions above are Slack's
|
||||
// absence rather than the page failing to load.
|
||||
expect(listed).toContain("Jira");
|
||||
}, 30000);
|
||||
});
|
||||
@@ -1,40 +1,11 @@
|
||||
import {
|
||||
ApiKeyLinkCard,
|
||||
JiraIntegrationCard,
|
||||
S3IntegrationCard,
|
||||
SecurityHubIntegrationCard,
|
||||
SsoLinkCard,
|
||||
} from "@/components/integrations";
|
||||
import { ContentLayout } from "@/components/shadcn/content-layout";
|
||||
|
||||
import { IntegrationsContent } from "./integrations-content";
|
||||
|
||||
export default async function Integrations() {
|
||||
return (
|
||||
<ContentLayout title="Integrations" icon="lucide:puzzle">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Connect external services to enhance your security workflow and
|
||||
automatically export your scan results.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6">
|
||||
{/* Amazon S3 Integration */}
|
||||
<S3IntegrationCard />
|
||||
|
||||
{/* AWS Security Hub Integration */}
|
||||
<SecurityHubIntegrationCard />
|
||||
|
||||
{/* Jira Integration */}
|
||||
<JiraIntegrationCard />
|
||||
|
||||
{/* SSO Configuration - redirects to Profile */}
|
||||
<SsoLinkCard />
|
||||
|
||||
{/* API Keys - redirects to Profile */}
|
||||
<ApiKeyLinkCard />
|
||||
</div>
|
||||
</div>
|
||||
<IntegrationsContent />
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { SlackCallback } from "@/components/integrations/slack/slack-callback";
|
||||
import { ContentLayout } from "@/components/shadcn/content-layout";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
|
||||
export default async function SlackCallbackPage() {
|
||||
if (!isCloud()) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
return (
|
||||
<ContentLayout title="Slack">
|
||||
{/* `SlackCallback` reads the query string, so it needs a boundary. */}
|
||||
<Suspense fallback={null}>
|
||||
<SlackCallback />
|
||||
</Suspense>
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { ContentLayout } from "@/components/shadcn/content-layout";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
|
||||
import { SlackIntegrationContent } from "./slack-integration-content";
|
||||
|
||||
export default async function SlackIntegrationPage() {
|
||||
// The Slack API is cloud-only, so self-hosted has nothing behind this page.
|
||||
// Mirrors `/alerts`.
|
||||
if (!isCloud()) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
return (
|
||||
<ContentLayout title="Slack">
|
||||
<div className="flex flex-col gap-6">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Connect a Slack workspace so Prowler can post to one of its channels.
|
||||
</p>
|
||||
|
||||
<SlackIntegrationContent />
|
||||
</div>
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Browser-mode tests for the Slack OAuth callback
|
||||
* (`/integrations/slack/callback`), driven through `SlackIntegrationHarness`.
|
||||
* MSW answers from handlers derived from the API contract in `design.md`.
|
||||
*/
|
||||
|
||||
import { describe, expect } from "vitest";
|
||||
|
||||
import { it } from "@/__tests__/fixtures";
|
||||
import {
|
||||
SLACK_EXCHANGE_OUTCOME,
|
||||
SLACK_OAUTH_CODE,
|
||||
SLACK_OAUTH_STATE,
|
||||
slackFixture,
|
||||
} from "@/__tests__/msw/handlers/slack.fixtures";
|
||||
|
||||
import { SlackIntegrationHarness } from "./slack-integration.harness";
|
||||
|
||||
/** The workspace the fixtures connect. */
|
||||
const WORKSPACE_NAME = "Prowler HQ";
|
||||
|
||||
/**
|
||||
* Callback headlines, spelled out rather than imported so a rename fails here.
|
||||
* `FAILURE_TITLE` is for installs that connected nothing; `UNCONFIRMED_TITLE`
|
||||
* for answers that arrive after the API already upserted the integration.
|
||||
*/
|
||||
const FAILURE_TITLE = "Slack workspace not connected";
|
||||
const UNCONFIRMED_TITLE = "Slack install not confirmed";
|
||||
|
||||
describe("returning from Slack", () => {
|
||||
it("completes the install and shows the connected workspace", async () => {
|
||||
const harness = new SlackIntegrationHarness(slackFixture());
|
||||
|
||||
await harness.mountCallback({
|
||||
code: SLACK_OAUTH_CODE,
|
||||
state: SLACK_OAUTH_STATE,
|
||||
});
|
||||
|
||||
expect(await harness.completedInstall()).toBe(true);
|
||||
expect(await harness.connectedWorkspaceName()).toBe(WORKSPACE_NAME);
|
||||
// The code is single-use and the exchange runs from a render (design D4):
|
||||
// without the once-guard, a second call burns it and reports a failure.
|
||||
expect(harness.exchangeCallCount).toBe(1);
|
||||
// A completed install invalidates the cached "none connected".
|
||||
expect(harness.revalidatedPaths).toEqual(
|
||||
expect.arrayContaining(["/integrations", "/integrations/slack"]),
|
||||
);
|
||||
}, 30000);
|
||||
|
||||
it("does not report an install the API completed as failed when it answers no content", async () => {
|
||||
// Given — a `204`: the API consumed the code and upserted the integration,
|
||||
// then answered with no body. `response.ok` is true, so this is no refusal.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
slackFixture({
|
||||
exchangeOutcome: SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_CONTENT,
|
||||
}),
|
||||
);
|
||||
|
||||
await harness.mountCallback({
|
||||
code: SLACK_OAUTH_CODE,
|
||||
state: SLACK_OAUTH_STATE,
|
||||
});
|
||||
|
||||
const reason = await harness.installFailureReason();
|
||||
expect(reason).toMatch(/could not read the result of the install/);
|
||||
expect(reason).toMatch(/Slack integration page/);
|
||||
expect(reason).not.toMatch(/JSON/i);
|
||||
expect(harness.offersRetry()).toBe(true);
|
||||
// The `204` says the workspace is connected; the headline cannot deny it.
|
||||
expect(await harness.installFailureTitle()).toBe(UNCONFIRMED_TITLE);
|
||||
// The install exists, so the cached "none connected" has to go with it.
|
||||
expect(harness.revalidatedPaths).toEqual(
|
||||
expect.arrayContaining(["/integrations", "/integrations/slack"]),
|
||||
);
|
||||
}, 30000);
|
||||
|
||||
it("shows Prowler's own wording when a proxy answers the completion with an HTML page", async () => {
|
||||
// Given — a proxy answering `200` with a challenge page instead of JSON.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
slackFixture({ exchangeOutcome: SLACK_EXCHANGE_OUTCOME.UNREADABLE_HTML }),
|
||||
);
|
||||
|
||||
await harness.mountCallback({
|
||||
code: SLACK_OAUTH_CODE,
|
||||
state: SLACK_OAUTH_STATE,
|
||||
});
|
||||
|
||||
// V8's parse message truncates before the word `html`, so the shared
|
||||
// HTML-shaped-error filter cannot catch this one.
|
||||
const reason = await harness.installFailureReason();
|
||||
expect(reason).toMatch(/could not read the result of the install/);
|
||||
expect(reason).not.toMatch(/DOCTYPE/i);
|
||||
expect(reason).not.toMatch(/not valid JSON/i);
|
||||
}, 30000);
|
||||
|
||||
it("says the result is unreadable, not that the workspace is unknown, when the answer names no resource", async () => {
|
||||
// Given — a `200` carrying well-formed JSON:API with no `data` member.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
slackFixture({
|
||||
exchangeOutcome: SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_DATA,
|
||||
}),
|
||||
);
|
||||
|
||||
await harness.mountCallback({
|
||||
code: SLACK_OAUTH_CODE,
|
||||
state: SLACK_OAUTH_STATE,
|
||||
});
|
||||
|
||||
const reason = await harness.installFailureReason();
|
||||
expect(reason).toMatch(/could not read the result of the install/);
|
||||
expect(reason).not.toMatch(/undefined/i);
|
||||
expect(await harness.completedInstall()).toBe(false);
|
||||
}, 30000);
|
||||
|
||||
it("connects nothing when the user declines in Slack, and offers to retry", async () => {
|
||||
const harness = new SlackIntegrationHarness(slackFixture());
|
||||
|
||||
await harness.mountCallback({ error: "access_denied" });
|
||||
|
||||
expect(await harness.installFailureReason()).toMatch(
|
||||
/not approved in Slack/,
|
||||
);
|
||||
expect(harness.offersRetry()).toBe(true);
|
||||
// A declined consent carries no code, so there was nothing to exchange.
|
||||
expect(harness.exchangeCallCount).toBe(0);
|
||||
}, 30000);
|
||||
|
||||
it("surfaces the reason when Slack refuses to complete the install", async () => {
|
||||
// Given — Slack rejects the code, and the API's own wording explains it.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
slackFixture({ exchangeOutcome: SLACK_EXCHANGE_OUTCOME.SLACK_REFUSED }),
|
||||
);
|
||||
|
||||
await harness.mountCallback({
|
||||
code: SLACK_OAUTH_CODE,
|
||||
state: SLACK_OAUTH_STATE,
|
||||
});
|
||||
|
||||
// A refusal Prowler has no wording of its own for falls back to the API's
|
||||
// `detail`, not to a generic failure.
|
||||
expect(await harness.installFailureReason()).toMatch(
|
||||
/OAuth code is invalid/,
|
||||
);
|
||||
expect(harness.offersRetry()).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it("surfaces a completion the API refuses, and connects nothing", async () => {
|
||||
// Given — the state was minted for another session, or already consumed.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
slackFixture({ exchangeOutcome: SLACK_EXCHANGE_OUTCOME.REFUSED_STATE }),
|
||||
);
|
||||
|
||||
await harness.mountCallback({
|
||||
code: SLACK_OAUTH_CODE,
|
||||
state: "state-from-another-session",
|
||||
});
|
||||
|
||||
expect(await harness.installFailureReason()).toMatch(
|
||||
/state is invalid, expired, or already consumed/,
|
||||
);
|
||||
// The API refused before consuming anything, so nothing was created and the
|
||||
// headline states that plainly.
|
||||
expect(await harness.installFailureTitle()).toBe(FAILURE_TITLE);
|
||||
expect(await harness.completedInstall()).toBe(false);
|
||||
expect(harness.offersRetry()).toBe(true);
|
||||
// Refused once, not retried into a second burnt code.
|
||||
expect(harness.exchangeCallCount).toBe(1);
|
||||
}, 30000);
|
||||
|
||||
it("says how to resolve a workspace conflict, in Prowler's own words", async () => {
|
||||
// Given — this tenant already has a different workspace connected, which
|
||||
// the API refuses as a 409 naming the conflict in `code`.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
slackFixture({
|
||||
exchangeOutcome: SLACK_EXCHANGE_OUTCOME.DIFFERENT_WORKSPACE,
|
||||
}),
|
||||
);
|
||||
|
||||
await harness.mountCallback({
|
||||
code: SLACK_OAUTH_CODE,
|
||||
state: SLACK_OAUTH_STATE,
|
||||
});
|
||||
|
||||
// The copy comes from the error `code`: the API's `detail` states the
|
||||
// conflict but not the way out of it.
|
||||
const reason = await harness.installFailureReason();
|
||||
expect(reason).toMatch(/already connected to a different Slack workspace/);
|
||||
expect(reason).toMatch(/Disconnect it before connecting another/);
|
||||
expect(reason).not.toMatch(/tenant/);
|
||||
expect(await harness.completedInstall()).toBe(false);
|
||||
expect(harness.offersRetry()).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it("tells the user when to come back if Slack is rate limiting the install", async () => {
|
||||
// Given — Slack answers 429 with a Retry-After.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
slackFixture({ rateLimited: true }),
|
||||
);
|
||||
|
||||
await harness.mountCallback({
|
||||
code: SLACK_OAUTH_CODE,
|
||||
state: SLACK_OAUTH_STATE,
|
||||
});
|
||||
|
||||
const reason = await harness.installFailureReason();
|
||||
expect(reason).toMatch(/rate limiting/);
|
||||
expect(reason).toMatch(/about 30 seconds/);
|
||||
expect(reason).not.toMatch(/not available in this environment/);
|
||||
// A 429 refuses the exchange outright, so nothing was connected: the plain
|
||||
// headline, unlike the unreadable `2xx` that arrives after the upsert.
|
||||
expect(await harness.installFailureTitle()).toBe(FAILURE_TITLE);
|
||||
expect(harness.offersRetry()).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it("reports Slack being broken upstream, rather than leaving the callback spinning", async () => {
|
||||
// Given — the completion answers `502`, the contract's status for a Slack
|
||||
// upstream failure. The shared 5xx handling throws, so the callback only
|
||||
// renders this if the action answers that rejection itself.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
slackFixture({ oauthUpstreamError: true }),
|
||||
);
|
||||
|
||||
await harness.mountCallback({
|
||||
code: SLACK_OAUTH_CODE,
|
||||
state: SLACK_OAUTH_STATE,
|
||||
});
|
||||
|
||||
// The API refused, so nothing was created: not the "could not confirm" the
|
||||
// page falls back to when the action never answers at all.
|
||||
const reason = await harness.installFailureReason();
|
||||
expect(reason).toMatch(/temporarily unavailable/);
|
||||
expect(reason).not.toMatch(/could not confirm/);
|
||||
expect(await harness.completedInstall()).toBe(false);
|
||||
expect(harness.offersRetry()).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it("does not attempt an exchange when the completion carries no state", async () => {
|
||||
const harness = new SlackIntegrationHarness(slackFixture());
|
||||
|
||||
await harness.mountCallback({ code: SLACK_OAUTH_CODE });
|
||||
|
||||
// Refused before the API is ever asked, so no code is spent.
|
||||
expect(await harness.installFailureReason()).toMatch(/incomplete response/);
|
||||
expect(harness.exchangeCallCount).toBe(0);
|
||||
}, 30000);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { getIntegrations } from "@/actions/integrations/integrations";
|
||||
import { getSlackAuthorizeUrl } from "@/actions/integrations/slack";
|
||||
import { SlackIntegrationManager } from "@/components/integrations/slack/slack-integration-manager";
|
||||
import { GENERIC_SERVER_ERROR_MESSAGE } from "@/lib/helper";
|
||||
import { INTEGRATION_TYPE, type IntegrationProps } from "@/types/integrations";
|
||||
|
||||
/**
|
||||
* `getIntegrations` throws a `>= 500` answer past its own catch, which covers
|
||||
* only transport. Uncaught it trips the route's error boundary and replaces a
|
||||
* page that could still offer the install, so report it as `{ error }` and take
|
||||
* the page's one error path.
|
||||
*/
|
||||
const readSlackIntegrations = async (searchParams: URLSearchParams) => {
|
||||
try {
|
||||
return await getIntegrations(searchParams);
|
||||
} catch {
|
||||
// The thrown message can carry the server's own wording; `handleApiResponse`
|
||||
// already reported it to Sentry.
|
||||
return { error: GENERIC_SERVER_ERROR_MESSAGE };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Split out of `page.tsx` so the browser-mode tests can render it without the
|
||||
* surrounding `ContentLayout`.
|
||||
*/
|
||||
export async function SlackIntegrationContent() {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("filter[integration_type]", INTEGRATION_TYPE.SLACK);
|
||||
// One workspace per tenant, so one row is the whole result set.
|
||||
searchParams.set("page[size]", "1");
|
||||
|
||||
const integrations = await readSlackIntegrations(searchParams);
|
||||
const loadError =
|
||||
integrations && "error" in integrations
|
||||
? (integrations.error as string)
|
||||
: null;
|
||||
const integration: IntegrationProps | null =
|
||||
(integrations?.data?.[0] as IntegrationProps | undefined) ?? null;
|
||||
|
||||
const authorize = integration ? null : await getSlackAuthorizeUrl();
|
||||
|
||||
return (
|
||||
<SlackIntegrationManager
|
||||
integration={integration}
|
||||
authorizeUrl={
|
||||
authorize && "authorizeUrl" in authorize ? authorize.authorizeUrl : null
|
||||
}
|
||||
unavailable={Boolean(authorize && "unavailable" in authorize)}
|
||||
// Rate limited is not unavailable: the install is still on offer, it just
|
||||
// cannot be started yet.
|
||||
rateLimitMessage={
|
||||
authorize && "rateLimited" in authorize ? authorize.message : null
|
||||
}
|
||||
loadError={
|
||||
loadError ??
|
||||
(authorize && "error" in authorize ? authorize.error : null)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,809 @@
|
||||
/**
|
||||
* Page-level test harness for the Slack integration (Vitest Browser Mode).
|
||||
*
|
||||
* A client renderer cannot render an async server component, so the component is
|
||||
* called and the element it returns is what gets rendered.
|
||||
*/
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { createElement } from "react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { BrowserHarness } from "@/__tests__/browser-harness";
|
||||
import { handlersForSlack } from "@/__tests__/msw/handlers/slack";
|
||||
import type { SlackFixture } from "@/__tests__/msw/handlers/slack.fixtures";
|
||||
import { worker } from "@/__tests__/msw/worker";
|
||||
import { render } from "@/__tests__/render-browser";
|
||||
import { setSlackDefaultChannel } from "@/actions/integrations/slack";
|
||||
import { SlackCallback } from "@/components/integrations/slack/slack-callback";
|
||||
|
||||
import { IntegrationsContent } from "../integrations-content";
|
||||
|
||||
import { SlackIntegrationContent } from "./slack-integration-content";
|
||||
|
||||
export const CONNECTION_OUTCOME = {
|
||||
SUCCESS: "success",
|
||||
FAILURE: "failure",
|
||||
} as const;
|
||||
|
||||
export type ConnectionOutcome =
|
||||
(typeof CONNECTION_OUTCOME)[keyof typeof CONNECTION_OUTCOME];
|
||||
|
||||
export const TEST_MESSAGE_OUTCOME = {
|
||||
SENT: "sent",
|
||||
FAILED: "failed",
|
||||
} as const;
|
||||
|
||||
export type TestMessageOutcome =
|
||||
(typeof TEST_MESSAGE_OUTCOME)[keyof typeof TEST_MESSAGE_OUTCOME];
|
||||
|
||||
/** Sentinel: the page settled on "no channel recorded", rather than not yet. */
|
||||
const NO_DEFAULT_CHANNEL = "<no channel recorded>";
|
||||
|
||||
export const REVOCATION_OUTCOME = {
|
||||
REVOKED: "revoked",
|
||||
NOT_REVOKED: "not-revoked",
|
||||
/** The answer said nothing either way, so the page claims neither. */
|
||||
UNREPORTED: "unreported",
|
||||
} as const;
|
||||
|
||||
export type RevocationOutcome =
|
||||
(typeof REVOCATION_OUTCOME)[keyof typeof REVOCATION_OUTCOME];
|
||||
|
||||
interface CallbackParams {
|
||||
code?: string;
|
||||
state?: string;
|
||||
/** Slack's own refusal code, e.g. `access_denied`. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class SlackIntegrationHarness extends BrowserHarness<SlackFixture> {
|
||||
get exchangeCallCount(): number {
|
||||
return this.countRequests("POST", "/slack/oauth/exchange");
|
||||
}
|
||||
|
||||
get authorizeUrlCallCount(): number {
|
||||
return this.countRequests("POST", "/slack/oauth/authorize-url");
|
||||
}
|
||||
|
||||
/** Paths the actions asked Next to refresh (`next/cache` is stubbed in this lane). */
|
||||
get revalidatedPaths(): string[] {
|
||||
return vi.mocked(revalidatePath).mock.calls.map(([path]) => path);
|
||||
}
|
||||
|
||||
// --- Mounting -----------------------------------------------------------
|
||||
|
||||
private wireHandlers(): void {
|
||||
// The stub is module-level and shared, so clearing it here is what makes
|
||||
// `revalidatedPaths` mean "since this mount".
|
||||
vi.mocked(revalidatePath).mockClear();
|
||||
worker.use(...handlersForSlack(this.fixture));
|
||||
this.trackRequests(worker);
|
||||
}
|
||||
|
||||
async mount(): Promise<void> {
|
||||
window.history.replaceState(null, "", "/integrations/slack");
|
||||
this.wireHandlers();
|
||||
|
||||
const readsBefore = this.channelListCallCount;
|
||||
this.mounted = render(await SlackIntegrationContent());
|
||||
if (this.fixture.install) await this.waitForChannelsRead(readsBefore);
|
||||
}
|
||||
|
||||
private mounted: ReturnType<typeof render> | null = null;
|
||||
|
||||
/**
|
||||
* Open the management page again, the way a later visit does — the handlers in
|
||||
* place keep serving what the previous visit left behind. Unmounts the previous
|
||||
* render first: two live copies would make every assertion ambiguous.
|
||||
*/
|
||||
async revisit(): Promise<void> {
|
||||
(await this.mounted)?.unmount();
|
||||
const readsBefore = this.channelListCallCount;
|
||||
this.mounted = render(await SlackIntegrationContent());
|
||||
await this.mounted;
|
||||
if (this.fixture.install) await this.waitForChannelsRead(readsBefore);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the page's server data under the open card, as `revalidatePath` does
|
||||
* after an action: new props, no unmount, so React state survives — unlike
|
||||
* `revisit()`, which re-seeds everything from scratch.
|
||||
*/
|
||||
async refreshPageData(): Promise<void> {
|
||||
const rendered = await this.mounted;
|
||||
if (!rendered) {
|
||||
throw new Error("refreshPageData: the page is not mounted");
|
||||
}
|
||||
await rendered.rerender(await SlackIntegrationContent());
|
||||
}
|
||||
|
||||
async mountCallback({ code, state, error }: CallbackParams): Promise<void> {
|
||||
const params = new URLSearchParams();
|
||||
if (code) params.set("code", code);
|
||||
if (state) params.set("state", state);
|
||||
if (error) params.set("error", error);
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`/integrations/slack/callback?${params.toString()}`,
|
||||
);
|
||||
this.wireHandlers();
|
||||
|
||||
render(createElement(SlackCallback));
|
||||
}
|
||||
|
||||
/** Mount the integrations catalogue. No handlers: every card there is static. */
|
||||
mountCatalogue(): void {
|
||||
window.history.replaceState(null, "", "/integrations");
|
||||
|
||||
render(createElement(IntegrationsContent));
|
||||
}
|
||||
|
||||
// --- The integrations catalogue ------------------------------------------
|
||||
|
||||
async listedIntegrations(): Promise<string[]> {
|
||||
const headings = await this.waitFor(
|
||||
() => {
|
||||
const found = Array.from(
|
||||
this.container.querySelectorAll<HTMLElement>("h4"),
|
||||
);
|
||||
return found.length > 0 ? found : null;
|
||||
},
|
||||
5000,
|
||||
"the integrations catalogue",
|
||||
);
|
||||
return headings.map((heading) => (heading.textContent ?? "").trim());
|
||||
}
|
||||
|
||||
offersSlackManagement(): boolean {
|
||||
return this.q('a[href="/integrations/slack"]') !== null;
|
||||
}
|
||||
|
||||
// --- Starting the install -----------------------------------------------
|
||||
|
||||
private connectLink(): HTMLAnchorElement | null {
|
||||
return (
|
||||
Array.from(this.container.querySelectorAll("a")).find((anchor) =>
|
||||
/Add to Slack/.test(anchor.textContent ?? ""),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
async authorizeUrl(): Promise<string> {
|
||||
const link = await this.waitFor(
|
||||
() => this.connectLink(),
|
||||
5000,
|
||||
"the Add to Slack link",
|
||||
);
|
||||
return link.href;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks the install affordance and reports where it points. The default
|
||||
* action is cancelled: following the link navigates the test frame off the app.
|
||||
*/
|
||||
async connect(): Promise<string> {
|
||||
const link = await this.waitFor(
|
||||
() => this.connectLink(),
|
||||
5000,
|
||||
"the Add to Slack link",
|
||||
);
|
||||
|
||||
let destination = "";
|
||||
const intercept = (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
destination = link.href;
|
||||
};
|
||||
link.addEventListener("click", intercept);
|
||||
try {
|
||||
await this.clickElement(link, { fallbackToDomClick: true });
|
||||
} finally {
|
||||
link.removeEventListener("click", intercept);
|
||||
}
|
||||
|
||||
return destination;
|
||||
}
|
||||
|
||||
offersInstall(): boolean {
|
||||
return this.connectLink() !== null;
|
||||
}
|
||||
|
||||
async waitForUnavailable(): Promise<void> {
|
||||
await this.waitForText(/Slack is not available in this environment yet/);
|
||||
}
|
||||
|
||||
saysUnavailable(): boolean {
|
||||
return this.containsText(/Slack is not available in this environment yet/);
|
||||
}
|
||||
|
||||
saysLoadFailed(): boolean {
|
||||
return this.containsText(/Could not load your Slack integration/);
|
||||
}
|
||||
|
||||
async rateLimitNotice(): Promise<string> {
|
||||
await this.waitForText(/Slack is busy right now/, 10000);
|
||||
const description = await this.waitFor(
|
||||
() => this.q('[data-slot="alert-description"]'),
|
||||
5000,
|
||||
"the rate limit notice",
|
||||
);
|
||||
return (description.textContent ?? "").trim();
|
||||
}
|
||||
|
||||
async loadErrorNotice(): Promise<string> {
|
||||
await this.waitForText(/Could not load your Slack integration/, 10000);
|
||||
const description = await this.waitFor(
|
||||
() => this.q('[data-slot="alert-description"]'),
|
||||
5000,
|
||||
"the load error notice",
|
||||
);
|
||||
return (description.textContent ?? "").trim();
|
||||
}
|
||||
|
||||
// --- Connected state ----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Read from the heading element, not the page text: in `textContent`
|
||||
* "Connected to <workspace>" runs straight into the copy that follows it.
|
||||
*/
|
||||
async connectedWorkspaceName(): Promise<string> {
|
||||
const heading = await this.waitFor(
|
||||
() => this.deepestElementMatching(/^Connected to \S/),
|
||||
5000,
|
||||
"the connected workspace name",
|
||||
);
|
||||
return (heading.textContent ?? "").trim().replace(/^Connected to /, "");
|
||||
}
|
||||
|
||||
/** Last match in document order: every ancestor of a match matches too. */
|
||||
private deepestElementMatching(pattern: RegExp): HTMLElement | null {
|
||||
return (
|
||||
Array.from(this.container.querySelectorAll<HTMLElement>("*"))
|
||||
.reverse()
|
||||
.find((element) => pattern.test((element.textContent ?? "").trim())) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyed on the badge's state attribute, not its copy: the heading beside it
|
||||
* also starts "Connected to …".
|
||||
*/
|
||||
async connectionBadge(): Promise<string> {
|
||||
const badge = await this.waitFor(
|
||||
() => this.q("[data-connection-status]"),
|
||||
5000,
|
||||
"the connection badge",
|
||||
);
|
||||
return (badge.textContent ?? "").trim();
|
||||
}
|
||||
|
||||
async offersConnectionTest(): Promise<boolean> {
|
||||
const button = await this.waitFor(
|
||||
() => this.buttonByText(/Test connection/),
|
||||
5000,
|
||||
"the Test connection button",
|
||||
);
|
||||
return !button.disabled;
|
||||
}
|
||||
|
||||
saysChannelIsNextStep(): boolean {
|
||||
return this.containsText(/Choosing a destination channel is the next step/);
|
||||
}
|
||||
|
||||
/**
|
||||
* The "last checked" line as rendered, or null when the page shows none —
|
||||
* which is what a workspace whose connection was never checked shows.
|
||||
*/
|
||||
lastCheckedLine(): string | null {
|
||||
const line = Array.from(
|
||||
this.container.querySelectorAll<HTMLElement>("p"),
|
||||
).find((p) => /^Last checked:/.test((p.textContent ?? "").trim()));
|
||||
return line ? (line.textContent ?? "").trim() : null;
|
||||
}
|
||||
|
||||
async testConnection(): Promise<ConnectionOutcome> {
|
||||
await this.clickButton(/Test connection/);
|
||||
|
||||
return this.waitFor(
|
||||
() => {
|
||||
if (this.containsText(/Connection test successful/)) {
|
||||
return CONNECTION_OUTCOME.SUCCESS;
|
||||
}
|
||||
if (this.containsText(/Connection test failed/)) {
|
||||
return CONNECTION_OUTCOME.FAILURE;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
15000,
|
||||
"the connection test outcome",
|
||||
);
|
||||
}
|
||||
|
||||
// --- Returning from Slack -----------------------------------------------
|
||||
|
||||
/**
|
||||
* The one element every non-success outcome renders. Keyed on it rather than
|
||||
* the alert title, which is not the same claim on every outcome.
|
||||
*/
|
||||
private backLink(): HTMLAnchorElement | null {
|
||||
return (
|
||||
Array.from(this.container.querySelectorAll("a")).find(
|
||||
(anchor) =>
|
||||
anchor.getAttribute("href") === "/integrations/slack" &&
|
||||
/Back to Slack integration/.test(anchor.textContent ?? ""),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
async completedInstall(): Promise<boolean> {
|
||||
const outcome = await this.waitFor(
|
||||
() => this.containsText(/Connected to /) || this.backLink() !== null,
|
||||
10000,
|
||||
"the callback outcome",
|
||||
);
|
||||
return outcome && this.containsText(/Connected to /);
|
||||
}
|
||||
|
||||
async installFailureReason(): Promise<string> {
|
||||
await this.waitFor(() => this.backLink(), 10000, "the failed callback");
|
||||
const description = await this.waitFor(
|
||||
() => this.q('[data-slot="alert-description"]'),
|
||||
5000,
|
||||
"the failure reason",
|
||||
);
|
||||
return (description.textContent ?? "").trim();
|
||||
}
|
||||
|
||||
async installFailureTitle(): Promise<string> {
|
||||
await this.waitFor(() => this.backLink(), 10000, "the failed callback");
|
||||
const title = await this.waitFor(
|
||||
() => this.q('[data-slot="alert-title"]'),
|
||||
5000,
|
||||
"the failure title",
|
||||
);
|
||||
return (title.textContent ?? "").trim();
|
||||
}
|
||||
|
||||
offersRetry(): boolean {
|
||||
return this.backLink() !== null || this.offersInstall();
|
||||
}
|
||||
|
||||
// --- Choosing a destination channel --------------------------------------
|
||||
|
||||
/** Channel reads issued — one per cursor page the UI followed. */
|
||||
get channelListCallCount(): number {
|
||||
return this.countRequests("GET", "/slack/channels");
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the channel read every connected mount starts, counting from the
|
||||
* reads already issued: one still in flight when the test ends lands in the
|
||||
* middle of the next, against a harness that never asked for it.
|
||||
*/
|
||||
private async waitForChannelsRead(readsBefore: number): Promise<void> {
|
||||
await this.waitFor(
|
||||
() => {
|
||||
const refresh = this.buttonByText(/Refresh channels/);
|
||||
return this.channelListCallCount > readsBefore &&
|
||||
refresh !== null &&
|
||||
!refresh.disabled
|
||||
? true
|
||||
: null;
|
||||
},
|
||||
15000,
|
||||
"the workspace's channels to be read",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the picker and hand back its options. A re-render landing mid-gesture
|
||||
* makes Radix drop the open state, so re-open from the keyboard when nothing
|
||||
* mounted at all.
|
||||
*/
|
||||
private async openChannelPicker(): Promise<HTMLElement[]> {
|
||||
const mounted = (): HTMLElement[] | null => {
|
||||
const options = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="option"]'),
|
||||
);
|
||||
return options.length > 0 ? options : null;
|
||||
};
|
||||
|
||||
const alreadyOpen = mounted();
|
||||
if (alreadyOpen) return alreadyOpen;
|
||||
|
||||
const trigger = await this.waitFor<HTMLElement>(
|
||||
() => this.q("#slack-channel"),
|
||||
10000,
|
||||
"the channel picker",
|
||||
);
|
||||
|
||||
await this.clickElement(trigger, { fallbackToDomClick: true });
|
||||
|
||||
let options = await this.waitForOrNull(
|
||||
mounted,
|
||||
2000,
|
||||
"the channel options",
|
||||
);
|
||||
if (!options) {
|
||||
await this.user.keyboard("{Enter}");
|
||||
options = await this.waitForOrNull(mounted, 8000, "the channel options");
|
||||
}
|
||||
|
||||
if (!options) {
|
||||
throw new Error("openChannelPicker: the channel picker offered nothing");
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
private async closeChannelPicker(): Promise<void> {
|
||||
await this.user.keyboard("{Escape}");
|
||||
await this.waitForTransition();
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read the workspace's channels, the way a user does after inviting
|
||||
* `@Prowler` to one in Slack. Waits for the read to have settled, not for the
|
||||
* click alone.
|
||||
*/
|
||||
async refreshChannels(): Promise<void> {
|
||||
const readsBefore = this.channelListCallCount;
|
||||
await this.clickButton(/Refresh channels/);
|
||||
|
||||
await this.waitFor(
|
||||
() => {
|
||||
const button = this.buttonByText(/Refresh channels/);
|
||||
return (
|
||||
this.channelListCallCount > readsBefore &&
|
||||
button !== null &&
|
||||
!button.disabled
|
||||
);
|
||||
},
|
||||
15000,
|
||||
"the workspace's channels to be read again",
|
||||
);
|
||||
}
|
||||
|
||||
/** The channels the workspace offers, in the order the picker lists them. */
|
||||
async channelOptions(): Promise<string[]> {
|
||||
const options = await this.openChannelPicker();
|
||||
const names = options.map(
|
||||
(option) => option.getAttribute("data-channel") ?? "",
|
||||
);
|
||||
|
||||
await this.closeChannelPicker();
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the channel offered under `name` is presented as private — read
|
||||
* from the marker the user sees, not from how the option is wired up.
|
||||
*/
|
||||
async isChannelShownAsPrivate(name: string): Promise<boolean> {
|
||||
const options = await this.openChannelPicker();
|
||||
const option = options.find(
|
||||
(element) => element.getAttribute("data-channel") === name,
|
||||
);
|
||||
|
||||
await this.closeChannelPicker();
|
||||
|
||||
return /Private/.test(option?.textContent ?? "");
|
||||
}
|
||||
|
||||
private async pickAndSave(name: string): Promise<void> {
|
||||
const options = await this.openChannelPicker();
|
||||
const option = options.find(
|
||||
(element) => element.getAttribute("data-channel") === name,
|
||||
);
|
||||
|
||||
if (!option) {
|
||||
throw new Error(`pickAndSave: no channel named "${name}" is offered`);
|
||||
}
|
||||
|
||||
await this.user.click(option);
|
||||
await this.waitForTransition();
|
||||
await this.clickButton(/Save channel/);
|
||||
}
|
||||
|
||||
/** Pick a channel, save it, and wait for it to be recorded as the destination. */
|
||||
async chooseChannel(name: string): Promise<void> {
|
||||
await this.pickAndSave(name);
|
||||
await this.waitFor(
|
||||
() => this.defaultChannelName() === name,
|
||||
15000,
|
||||
`#${name} to be recorded as the destination`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a different destination away from this page — a second tab, or someone
|
||||
* else in the tenant. Goes through the same call the page makes, leaving this
|
||||
* page's own copy of it untouched.
|
||||
*/
|
||||
async channelRecordedElsewhere(name: string): Promise<void> {
|
||||
const channel = this.fixture.channels.find((c) => c.name === name);
|
||||
if (!channel) {
|
||||
throw new Error(
|
||||
`channelRecordedElsewhere: no channel named "${name}" is offered`,
|
||||
);
|
||||
}
|
||||
|
||||
const integrationId = this.fixture.install?.id;
|
||||
if (!integrationId) {
|
||||
throw new Error("channelRecordedElsewhere: no workspace is connected");
|
||||
}
|
||||
|
||||
const result = await setSlackDefaultChannel(integrationId, channel.id);
|
||||
if ("error" in result) {
|
||||
throw new Error(`channelRecordedElsewhere: ${result.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the picked channel can be saved — false when there is nothing new to save. */
|
||||
offersChannelSave(): boolean {
|
||||
const button = this.buttonByText(/Save channel/);
|
||||
return button !== null && !button.disabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to save a channel the API refuses and hand back what the user is told. A
|
||||
* save that succeeds fails the test rather than timing out.
|
||||
*/
|
||||
async refusedChannelSave(name: string): Promise<string> {
|
||||
await this.pickAndSave(name);
|
||||
|
||||
return this.waitFor(
|
||||
() => {
|
||||
if (this.defaultChannelName() === name) {
|
||||
throw new Error(
|
||||
`refusedChannelSave: #${name} was recorded, not refused`,
|
||||
);
|
||||
}
|
||||
return this.toastText(/Could not save the destination channel/);
|
||||
},
|
||||
15000,
|
||||
"the refused channel save",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The text of the toast matching `pattern` — title and message together. Radix
|
||||
* portals each toast into its viewport as an `<li>`, outside the page's markup.
|
||||
*/
|
||||
private toastText(pattern: RegExp): string | null {
|
||||
const toast = Array.from(
|
||||
document.querySelectorAll<HTMLElement>("ol li"),
|
||||
).find((element) => pattern.test(element.textContent ?? ""));
|
||||
return toast ? (toast.textContent ?? "").replace(/\s+/g, " ").trim() : null;
|
||||
}
|
||||
|
||||
private defaultChannelName(): string | null {
|
||||
return (
|
||||
/Prowler posts to #(\S+?)\./.exec(
|
||||
this.container.textContent ?? "",
|
||||
)?.[1] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/** The channel recorded as the integration's destination, if any. */
|
||||
async defaultChannel(): Promise<string | null> {
|
||||
const settled = await this.waitFor(
|
||||
() =>
|
||||
this.defaultChannelName() ??
|
||||
(this.containsText(/No destination channel recorded yet/)
|
||||
? NO_DEFAULT_CHANNEL
|
||||
: null),
|
||||
10000,
|
||||
"the recorded destination channel",
|
||||
);
|
||||
return settled === NO_DEFAULT_CHANNEL ? null : settled;
|
||||
}
|
||||
|
||||
/** What the user is told when the workspace exposes no channel at all. */
|
||||
async channelPickerMessage(): Promise<string> {
|
||||
const alert = await this.waitFor(
|
||||
() =>
|
||||
Array.from(
|
||||
this.container.querySelectorAll<HTMLElement>('[data-slot="alert"]'),
|
||||
).find((element) =>
|
||||
/No channels available yet|Could not read the workspace/.test(
|
||||
element.textContent ?? "",
|
||||
),
|
||||
),
|
||||
10000,
|
||||
"the channel picker's message",
|
||||
);
|
||||
return (alert.textContent ?? "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* What the user is told about a list short of the workspace, shown beside a
|
||||
* picker that still works — unlike `channelPickerMessage()`, which replaces it.
|
||||
*/
|
||||
partialListNotice(): string | null {
|
||||
const notice = this.q("[data-channels-notice]");
|
||||
return notice
|
||||
? (notice.textContent ?? "").replace(/\s+/g, " ").trim()
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Whether the picker was replaced by the "could not read them" alert. */
|
||||
saysChannelsUnreadable(): boolean {
|
||||
return this.containsText(/Could not read the workspace/);
|
||||
}
|
||||
|
||||
/** The invite copy that says how to make a private channel appear. */
|
||||
channelInviteHint(): string | null {
|
||||
const hint = Array.from(
|
||||
this.container.querySelectorAll<HTMLElement>("p"),
|
||||
).find((element) => /invites? @Prowler/.test(element.textContent ?? ""));
|
||||
return hint ? (hint.textContent ?? "").trim() : null;
|
||||
}
|
||||
|
||||
// --- The test message ----------------------------------------------------
|
||||
|
||||
offersTestMessage(): boolean {
|
||||
return this.buttonByText(/Send test message/) !== null;
|
||||
}
|
||||
|
||||
private testMessageAlert(): HTMLElement | null {
|
||||
return (
|
||||
Array.from(
|
||||
this.container.querySelectorAll<HTMLElement>('[data-slot="alert"]'),
|
||||
).find((element) =>
|
||||
/Test message (sent|failed)/.test(element.textContent ?? ""),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
async sendTestMessage(): Promise<TestMessageOutcome> {
|
||||
await this.clickButton(/Send test message/);
|
||||
|
||||
return this.waitFor(
|
||||
() => {
|
||||
const alert = this.testMessageAlert();
|
||||
if (!alert) return null;
|
||||
return /Test message sent/.test(alert.textContent ?? "")
|
||||
? TEST_MESSAGE_OUTCOME.SENT
|
||||
: TEST_MESSAGE_OUTCOME.FAILED;
|
||||
},
|
||||
15000,
|
||||
"the test message outcome",
|
||||
);
|
||||
}
|
||||
|
||||
async lastTestMessageOutcome(): Promise<string> {
|
||||
const alert = await this.waitFor(
|
||||
() => this.testMessageAlert(),
|
||||
10000,
|
||||
"the test message outcome",
|
||||
);
|
||||
const description = alert.querySelector<HTMLElement>(
|
||||
'[data-slot="alert-description"]',
|
||||
);
|
||||
return (description?.textContent ?? "").trim();
|
||||
}
|
||||
|
||||
// --- Disconnecting ------------------------------------------------------
|
||||
|
||||
get disconnectCallCount(): number {
|
||||
return this.countRequests("DELETE", "/integrations/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnects the workspace, confirming the way a user has to, and reports
|
||||
* what the page says about the revocation. The outcomes are mutually
|
||||
* exclusive, so asking for one also checks the others are absent.
|
||||
*
|
||||
* The revoked and unreported outcomes share a toast title, so each is read
|
||||
* from its own description: a title match would agree with either.
|
||||
*/
|
||||
async disconnect(): Promise<RevocationOutcome> {
|
||||
// The dialog's own button carries the noun too, hence the exact match on
|
||||
// the card's action.
|
||||
await this.clickButton(/^\s*Disconnect\s*$/);
|
||||
await this.clickButton(/Disconnect workspace/);
|
||||
|
||||
return this.waitFor(
|
||||
() => {
|
||||
if (this.alertMatching(/revocation/i)) {
|
||||
return REVOCATION_OUTCOME.NOT_REVOKED;
|
||||
}
|
||||
if (this.containsText(/has been revoked/)) {
|
||||
return REVOCATION_OUTCOME.REVOKED;
|
||||
}
|
||||
if (this.containsText(/is no longer connected to Prowler/)) {
|
||||
return REVOCATION_OUTCOME.UNREPORTED;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
15000,
|
||||
"the disconnect outcome",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the page is back to offering an install with no workspace
|
||||
* connected. The consent URL is minted after the disconnect, so the install
|
||||
* affordance appears a beat after the copy does.
|
||||
*/
|
||||
async returnedToUnconnectedState(): Promise<boolean> {
|
||||
await this.waitForText(/No workspace connected/, 10000);
|
||||
return (
|
||||
(await this.waitForOrNull(
|
||||
() => this.offersInstall(),
|
||||
5000,
|
||||
"the install to be offered again",
|
||||
)) ?? false
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether the page is asking the user to remove the access in Slack. */
|
||||
showsRevocationNotice(): boolean {
|
||||
return this.alertMatching(/revocation/i) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the user is told when the row was removed but Slack never confirmed
|
||||
* the revocation.
|
||||
*/
|
||||
async revocationNotice(): Promise<string> {
|
||||
const notice = await this.waitFor(
|
||||
() => this.alertMatching(/revocation/i),
|
||||
10000,
|
||||
"the revocation notice",
|
||||
);
|
||||
return (notice.textContent ?? "").trim();
|
||||
}
|
||||
|
||||
// --- A credential Slack no longer accepts --------------------------------
|
||||
|
||||
/** What the user is told when Slack has stopped accepting the credential. */
|
||||
async revokedCredentialNotice(): Promise<string> {
|
||||
const notice = await this.waitFor(
|
||||
() => this.alertMatching(/no longer accepts Prowler's access/),
|
||||
10000,
|
||||
"the revoked-credential notice",
|
||||
);
|
||||
return (notice.textContent ?? "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
/** Whether the page is saying Slack has stopped accepting the credential. */
|
||||
showsRevokedCredentialNotice(): boolean {
|
||||
return this.alertMatching(/no longer accepts Prowler's access/) !== null;
|
||||
}
|
||||
|
||||
private reconnectLink(): HTMLAnchorElement | null {
|
||||
return (
|
||||
Array.from(this.container.querySelectorAll("a")).find((anchor) =>
|
||||
/Reconnect to Slack/.test(anchor.textContent ?? ""),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether the page offers to approve Prowler in the workspace again. */
|
||||
offersReconnect(): boolean {
|
||||
return this.reconnectLink() !== null;
|
||||
}
|
||||
|
||||
/** The consent URL the reconnect affordance points at, once it is offered. */
|
||||
async reconnectUrl(): Promise<string> {
|
||||
const link = await this.waitFor(
|
||||
() => this.reconnectLink(),
|
||||
10000,
|
||||
"the reconnect link",
|
||||
);
|
||||
return link.href;
|
||||
}
|
||||
|
||||
/** The alert whose text matches, of however many the page is showing. */
|
||||
private alertMatching(pattern: RegExp): HTMLElement | null {
|
||||
return (
|
||||
Array.from(
|
||||
this.container.querySelectorAll<HTMLElement>('[data-slot="alert"]'),
|
||||
).find((alert) => pattern.test(alert.textContent ?? "")) ?? null
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,834 @@
|
||||
/**
|
||||
* Browser-mode tests for the Slack integration page (`/integrations/slack`),
|
||||
* driven through `SlackIntegrationHarness`. MSW answers from handlers derived
|
||||
* from the API contract in `design.md`. The OAuth callback is its own route,
|
||||
* covered in `slack-callback-page.integration.test.tsx`.
|
||||
*/
|
||||
|
||||
import { describe, expect } from "vitest";
|
||||
|
||||
import { it } from "@/__tests__/fixtures";
|
||||
import {
|
||||
configuredSlackFixture,
|
||||
connectedSlackFixture,
|
||||
INTEGRATIONS_SERVER_ERROR_DETAIL,
|
||||
partiallyReadSlackFixture,
|
||||
revokedTokenSlackFixture,
|
||||
revokeFailureSlackFixture,
|
||||
SLACK_CHANNEL_NOT_FOUND_REFUSAL,
|
||||
SLACK_MISSING_SCOPE_CODE,
|
||||
SLACK_MISSING_SCOPE_REFUSAL,
|
||||
SLACK_NOT_IN_CHANNEL_CODE,
|
||||
SLACK_NOT_IN_CHANNEL_REFUSAL,
|
||||
SLACK_PRIVATE_CHANNEL,
|
||||
SLACK_PUBLIC_CHANNEL,
|
||||
SLACK_RATE_LIMITED_REFUSAL,
|
||||
SLACK_SECOND_PUBLIC_CHANNEL,
|
||||
SLACK_TEST_MESSAGE_REFUSED_DETAIL,
|
||||
SLACK_TOKEN_EXPIRED_CODE,
|
||||
SLACK_TOKEN_EXPIRED_REFUSAL,
|
||||
SLACK_TOKEN_REVOKED_CODE,
|
||||
SLACK_UNKNOWN_CHANNEL_DETAIL,
|
||||
SLACK_UNMAPPED_REASON_CODE,
|
||||
SLACK_UPSTREAM_REFUSAL,
|
||||
slackFixture,
|
||||
slackFixtureWithDefaultChannel,
|
||||
unreadableCheckTimeSlackFixture,
|
||||
unreportedRevocationSlackFixture,
|
||||
} from "@/__tests__/msw/handlers/slack.fixtures";
|
||||
|
||||
import {
|
||||
CONNECTION_OUTCOME,
|
||||
REVOCATION_OUTCOME,
|
||||
SlackIntegrationHarness,
|
||||
TEST_MESSAGE_OUTCOME,
|
||||
} from "./slack-integration.harness";
|
||||
|
||||
/** The shape the channel save is asserted against — only the id travels. */
|
||||
interface PatchIntegrationBody {
|
||||
data: { attributes: { configuration: { channel_id: string } } };
|
||||
}
|
||||
|
||||
/** The workspace the fixtures connect. */
|
||||
const WORKSPACE_NAME = "Prowler HQ";
|
||||
|
||||
/** The only scopes Prowler asks a workspace for (design D2). */
|
||||
const REQUIRED_SCOPES = [
|
||||
"chat:write",
|
||||
"chat:write.public",
|
||||
"channels:read",
|
||||
"groups:read",
|
||||
];
|
||||
|
||||
describe("starting the install", () => {
|
||||
it("sends the user to Slack's consent screen for the access Prowler needs", async () => {
|
||||
// Given — a tenant with no workspace connected yet.
|
||||
const harness = new SlackIntegrationHarness(slackFixture());
|
||||
await harness.mount();
|
||||
|
||||
const consentScreen = new URL(await harness.connect());
|
||||
|
||||
expect(`${consentScreen.origin}${consentScreen.pathname}`).toBe(
|
||||
"https://slack.com/oauth/v2/authorize",
|
||||
);
|
||||
const scopes = (consentScreen.searchParams.get("scope") ?? "").split(",");
|
||||
expect(scopes).toHaveLength(REQUIRED_SCOPES.length);
|
||||
expect(scopes).toEqual(expect.arrayContaining(REQUIRED_SCOPES));
|
||||
// The state is server-minted, binding this install to the session
|
||||
// (design D5).
|
||||
expect(consentScreen.searchParams.get("state")).toBeTruthy();
|
||||
}, 30000);
|
||||
|
||||
it("says so when the deployment has no Slack app, instead of offering an install", async () => {
|
||||
// Given — no SLACK_CLIENT_ID/SECRET/REDIRECT_URI, which the API answers
|
||||
// with a 503.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
slackFixture({ appConfigured: false }),
|
||||
);
|
||||
|
||||
await harness.mount();
|
||||
|
||||
// The read itself succeeded: an empty collection is what a deployment with
|
||||
// no Slack app has, so nothing claims it failed.
|
||||
await harness.waitForUnavailable();
|
||||
expect(harness.offersInstall()).toBe(false);
|
||||
expect(harness.saysLoadFailed()).toBe(false);
|
||||
}, 30000);
|
||||
|
||||
it("still says the read failed when the deployment also has no Slack app", async () => {
|
||||
// Given — both states, which coincide during rollout and rollback
|
||||
// (design.md, Migration Plan §2-3 and §5).
|
||||
const harness = new SlackIntegrationHarness(
|
||||
slackFixture({ appConfigured: false, listServerError: true }),
|
||||
);
|
||||
|
||||
await harness.mount();
|
||||
|
||||
// Both notices: the read's is the actionable half (a retry may still show a
|
||||
// workspace this tenant has connected).
|
||||
const notice = await harness.loadErrorNotice();
|
||||
expect(notice).toMatch(/temporarily unavailable/);
|
||||
expect(notice).not.toMatch(INTEGRATIONS_SERVER_ERROR_DETAIL);
|
||||
expect(harness.saysUnavailable()).toBe(true);
|
||||
// The install is still not on offer: there is no Slack app to install into.
|
||||
expect(harness.offersInstall()).toBe(false);
|
||||
}, 30000);
|
||||
|
||||
it("says Slack is busy, not that the deployment has no Slack app, when it is rate limiting", async () => {
|
||||
// Given — the app is configured; Slack rate limits (429) the call that
|
||||
// mints the consent URL.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
slackFixture({ rateLimited: true }),
|
||||
);
|
||||
|
||||
await harness.mount();
|
||||
|
||||
expect(await harness.rateLimitNotice()).toMatch(/about 30 seconds/);
|
||||
expect(harness.saysUnavailable()).toBe(false);
|
||||
}, 30000);
|
||||
|
||||
it("keeps the page usable when reading the install fails on the server", async () => {
|
||||
// Given — the shared `GET /integrations` read answers 500. The action
|
||||
// throws instead of returning a result, so the page has to catch it:
|
||||
// uncaught, the route's error boundary replaces the Slack page.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
slackFixture({ listServerError: true }),
|
||||
);
|
||||
|
||||
await harness.mount();
|
||||
|
||||
const notice = await harness.loadErrorNotice();
|
||||
expect(notice).toMatch(/temporarily unavailable/);
|
||||
expect(notice).not.toMatch(INTEGRATIONS_SERVER_ERROR_DETAIL);
|
||||
// The install stays on offer: one read failed, the Slack app is fine.
|
||||
expect(harness.offersInstall()).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it("keeps the page usable when Slack's own side is broken upstream", async () => {
|
||||
// Given — the `502` the contract reserves for a Slack upstream failure.
|
||||
// The UI's shared 5xx handling throws, so this is the page's other
|
||||
// rejection path.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
slackFixture({ oauthUpstreamError: true }),
|
||||
);
|
||||
|
||||
await harness.mount();
|
||||
|
||||
const notice = await harness.loadErrorNotice();
|
||||
expect(notice).toMatch(/temporarily unavailable/);
|
||||
// 502 is not 503: the app is configured, Slack is down.
|
||||
expect(harness.saysUnavailable()).toBe(false);
|
||||
}, 30000);
|
||||
|
||||
it("names the missing consent URL when a proxy answers that call with an HTML page", async () => {
|
||||
// Given — a 200 carrying a challenge page instead of JSON. Nothing refused
|
||||
// the call, so the action reaches its success path with no URL.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
slackFixture({ authorizeUrlUnreadable: true }),
|
||||
);
|
||||
|
||||
await harness.mount();
|
||||
|
||||
// V8 truncates the parse message to `"<!DOCTYPE "`, before the word `html`,
|
||||
// so the UI's HTML-shaped-error filter can never match it.
|
||||
const notice = await harness.loadErrorNotice();
|
||||
expect(notice).toMatch(/did not return an authorization URL/);
|
||||
expect(notice).not.toMatch(/DOCTYPE/i);
|
||||
expect(notice).not.toMatch(/not valid JSON/i);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe("a connected workspace", () => {
|
||||
it("identifies the workspace and reports the connection as healthy", async () => {
|
||||
// Given — a finished setup: workspace approved and a destination channel
|
||||
// recorded, which the API requires before it will check a connection.
|
||||
const harness = new SlackIntegrationHarness(configuredSlackFixture());
|
||||
await harness.mount();
|
||||
|
||||
expect(await harness.connectedWorkspaceName()).toBe(WORKSPACE_NAME);
|
||||
expect(await harness.connectionBadge()).toBe("Connected");
|
||||
expect(await harness.offersConnectionTest()).toBe(true);
|
||||
expect(await harness.testConnection()).toBe(CONNECTION_OUTCOME.SUCCESS);
|
||||
// One workspace per tenant (design D10): no second install on offer, and no
|
||||
// consent URL minted for a page that would never use it.
|
||||
expect(harness.offersInstall()).toBe(false);
|
||||
expect(harness.authorizeUrlCallCount).toBe(0);
|
||||
}, 30000);
|
||||
|
||||
it("still identifies the workspace before a destination channel is chosen", async () => {
|
||||
// Given — the state the OAuth exchange leaves behind.
|
||||
const harness = new SlackIntegrationHarness(connectedSlackFixture());
|
||||
|
||||
await harness.mount();
|
||||
|
||||
// The configuration carries no channel keys at all, which is "nothing
|
||||
// chosen yet", not a broken install.
|
||||
expect(await harness.connectedWorkspaceName()).toBe(WORKSPACE_NAME);
|
||||
expect(harness.offersInstall()).toBe(false);
|
||||
}, 30000);
|
||||
|
||||
it("reports the connection as never checked, not as broken, before the first check", async () => {
|
||||
// Given — the state the OAuth exchange leaves behind: `connected` is null,
|
||||
// neither true nor false (design.md, "Connection state, in order").
|
||||
const harness = new SlackIntegrationHarness(connectedSlackFixture());
|
||||
|
||||
await harness.mount();
|
||||
|
||||
const badge = await harness.connectionBadge();
|
||||
expect(badge).toBe("Not checked yet");
|
||||
expect(badge).not.toMatch(/Disconnected/);
|
||||
}, 30000);
|
||||
|
||||
it("keeps the page usable when the recorded check time is one no parser can read", async () => {
|
||||
// Given — a finished setup whose `connection_last_checked_at` is a zero
|
||||
// date. `date-fns` throws a RangeError on it, which would replace the whole
|
||||
// page with the route's error boundary.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
unreadableCheckTimeSlackFixture(),
|
||||
);
|
||||
|
||||
await harness.mount();
|
||||
|
||||
expect(await harness.connectedWorkspaceName()).toBe(WORKSPACE_NAME);
|
||||
expect(await harness.connectionBadge()).toBe("Connected");
|
||||
// Nothing to show, so nothing is shown: the same line a workspace that was
|
||||
// never checked renders.
|
||||
expect(harness.lastCheckedLine()).toBeNull();
|
||||
}, 30000);
|
||||
|
||||
it("does not offer a connection check the API is bound to refuse", async () => {
|
||||
// Given — a workspace connected and no destination channel recorded.
|
||||
const harness = new SlackIntegrationHarness(connectedSlackFixture());
|
||||
|
||||
await harness.mount();
|
||||
|
||||
// The check posts to the destination channel, so with none recorded the API
|
||||
// answers 400 rather than `connected: false`.
|
||||
expect(await harness.offersConnectionTest()).toBe(false);
|
||||
expect(harness.saysChannelIsNextStep()).toBe(true);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe("choosing a destination channel", () => {
|
||||
it("offers the workspace's channels and remembers the one chosen", async () => {
|
||||
// Given — a connected tenant whose channels span two cursor pages.
|
||||
const harness = new SlackIntegrationHarness(connectedSlackFixture());
|
||||
await harness.mount();
|
||||
|
||||
// Then — every channel is offered, so the picker followed `links.next`
|
||||
// rather than stopping at the first page (design D6).
|
||||
expect(await harness.channelOptions()).toEqual([
|
||||
SLACK_PUBLIC_CHANNEL.name,
|
||||
SLACK_SECOND_PUBLIC_CHANNEL.name,
|
||||
SLACK_PRIVATE_CHANNEL.name,
|
||||
]);
|
||||
expect(harness.channelListCallCount).toBe(2);
|
||||
|
||||
// When
|
||||
await harness.chooseChannel(SLACK_PUBLIC_CHANNEL.name);
|
||||
|
||||
// Then — only the id is submitted: the API derives the name from it.
|
||||
const saved = await harness.lastRequestBody<PatchIntegrationBody>(
|
||||
"PATCH",
|
||||
"/integrations/",
|
||||
);
|
||||
expect(saved?.data.attributes.configuration).toEqual({
|
||||
channel_id: SLACK_PUBLIC_CHANNEL.id,
|
||||
});
|
||||
|
||||
// And — a later visit shows it, under the name the API derived from the id.
|
||||
await harness.revisit();
|
||||
expect(await harness.defaultChannel()).toBe(SLACK_PUBLIC_CHANNEL.name);
|
||||
}, 60000);
|
||||
|
||||
it("offers a private channel the app was invited to, marked as private, and saves it", async () => {
|
||||
// Given — `@Prowler` was invited to one private channel; `groups:read` is
|
||||
// membership-gated (D2).
|
||||
const harness = new SlackIntegrationHarness(connectedSlackFixture());
|
||||
await harness.mount();
|
||||
|
||||
// Then
|
||||
expect(await harness.channelOptions()).toContain(
|
||||
SLACK_PRIVATE_CHANNEL.name,
|
||||
);
|
||||
expect(
|
||||
await harness.isChannelShownAsPrivate(SLACK_PRIVATE_CHANNEL.name),
|
||||
).toBe(true);
|
||||
expect(
|
||||
await harness.isChannelShownAsPrivate(SLACK_PUBLIC_CHANNEL.name),
|
||||
).toBe(false);
|
||||
|
||||
// When
|
||||
await harness.chooseChannel(SLACK_PRIVATE_CHANNEL.name);
|
||||
|
||||
// Then
|
||||
expect(await harness.defaultChannel()).toBe(SLACK_PRIVATE_CHANNEL.name);
|
||||
}, 60000);
|
||||
|
||||
it("offers a private channel once @Prowler is invited to it and the list is refreshed", async () => {
|
||||
// Given — a workspace whose only channels are public: `groups:read` is
|
||||
// membership-gated (design D2).
|
||||
const harness = new SlackIntegrationHarness(
|
||||
connectedSlackFixture({
|
||||
channels: [
|
||||
{ ...SLACK_PUBLIC_CHANNEL },
|
||||
{ ...SLACK_SECOND_PUBLIC_CHANNEL },
|
||||
],
|
||||
}),
|
||||
);
|
||||
await harness.mount();
|
||||
expect(await harness.channelOptions()).not.toContain(
|
||||
SLACK_PRIVATE_CHANNEL.name,
|
||||
);
|
||||
|
||||
// When — `@Prowler` is invited to a private channel, and the user refreshes
|
||||
// instead of reconnecting the workspace.
|
||||
harness.fixture.channels.push({ ...SLACK_PRIVATE_CHANNEL });
|
||||
await harness.refreshChannels();
|
||||
|
||||
// Then
|
||||
expect(await harness.channelOptions()).toContain(
|
||||
SLACK_PRIVATE_CHANNEL.name,
|
||||
);
|
||||
expect(
|
||||
await harness.isChannelShownAsPrivate(SLACK_PRIVATE_CHANNEL.name),
|
||||
).toBe(true);
|
||||
}, 60000);
|
||||
|
||||
it("says what to do when the workspace exposes no channel Prowler can post to", async () => {
|
||||
// Given — a connected workspace exposing no channels at all.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
connectedSlackFixture({ channels: [] }),
|
||||
);
|
||||
|
||||
// When
|
||||
await harness.mount();
|
||||
|
||||
// Then — the user is told what to do, not merely that the list is empty.
|
||||
const message = await harness.channelPickerMessage();
|
||||
expect(message).toMatch(/No channels available yet/);
|
||||
expect(message).toMatch(/invite @Prowler/);
|
||||
expect(await harness.defaultChannel()).toBeNull();
|
||||
expect(harness.offersTestMessage()).toBe(false);
|
||||
}, 30000);
|
||||
|
||||
it("offers the connection check as soon as the destination is saved, without a revisit", async () => {
|
||||
// Given — connected with nothing recorded: the check posts to the
|
||||
// destination, so it is not offered yet.
|
||||
const harness = new SlackIntegrationHarness(connectedSlackFixture());
|
||||
await harness.mount();
|
||||
expect(await harness.offersConnectionTest()).toBe(false);
|
||||
expect(harness.saysChannelIsNextStep()).toBe(true);
|
||||
|
||||
// When
|
||||
await harness.chooseChannel(SLACK_PUBLIC_CHANNEL.name);
|
||||
|
||||
// Then — everything waiting on a destination moves with the save, in the
|
||||
// same paint: no reload to find the check on offer.
|
||||
expect(await harness.offersConnectionTest()).toBe(true);
|
||||
expect(harness.saysChannelIsNextStep()).toBe(false);
|
||||
// And — the check really runs.
|
||||
expect(await harness.testConnection()).toBe(CONNECTION_OUTCOME.SUCCESS);
|
||||
}, 60000);
|
||||
|
||||
it("follows the destination recorded elsewhere when the page's data refreshes under it", async () => {
|
||||
// Given — a finished setup, open on screen.
|
||||
const harness = new SlackIntegrationHarness(configuredSlackFixture());
|
||||
await harness.mount();
|
||||
expect(await harness.defaultChannel()).toBe(SLACK_PUBLIC_CHANNEL.name);
|
||||
|
||||
// When — the destination changes elsewhere (a second tab, another user) and
|
||||
// this page's server data refreshes under the open card, as
|
||||
// `revalidatePath` does after an action.
|
||||
await harness.channelRecordedElsewhere(SLACK_SECOND_PUBLIC_CHANNEL.name);
|
||||
await harness.refreshPageData();
|
||||
|
||||
// Then — the card reports what is on record, not the copy it took at mount.
|
||||
expect(await harness.defaultChannel()).toBe(
|
||||
SLACK_SECOND_PUBLIC_CHANNEL.name,
|
||||
);
|
||||
expect(harness.offersTestMessage()).toBe(true);
|
||||
// And — the picker followed too: the superseded destination is not left one
|
||||
// click from being saved back.
|
||||
expect(harness.offersChannelSave()).toBe(false);
|
||||
}, 60000);
|
||||
|
||||
it("says which permission is missing when Slack refuses the channel listing, leaving the recorded channel alone", async () => {
|
||||
// Given — a recorded destination, and an install missing a scope the listing
|
||||
// needs. The API names it in `code` (contract, Errors), not in `detail`.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
slackFixtureWithDefaultChannel(SLACK_PUBLIC_CHANNEL, {
|
||||
channelsRefusal: SLACK_MISSING_SCOPE_REFUSAL,
|
||||
}),
|
||||
);
|
||||
|
||||
// When
|
||||
await harness.mount();
|
||||
|
||||
// Then — the reason, worded as a fix, with the invite copy still beside the
|
||||
// picker.
|
||||
const message = await harness.channelPickerMessage();
|
||||
expect(message).toMatch(/missing a permission it needs in Slack/);
|
||||
expect(message).toMatch(/Connect the workspace again and approve/);
|
||||
// Slack's reason is a protocol token: it travels in `code` and is never
|
||||
// shown.
|
||||
expect(message).not.toMatch(SLACK_MISSING_SCOPE_CODE);
|
||||
expect(harness.channelInviteHint()).toMatch(/invites @Prowler/);
|
||||
|
||||
// And — a listing Prowler could not read says nothing about the channel
|
||||
// already recorded.
|
||||
expect(await harness.defaultChannel()).toBe(SLACK_PUBLIC_CHANNEL.name);
|
||||
expect(harness.offersTestMessage()).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it("names the wait Slack asked for when it rate limits the channel listing", async () => {
|
||||
// Given — `conversations.list` is Slack tier 2 and paginated (contract,
|
||||
// Errors); the `429` carries the wait in `Retry-After`.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
slackFixtureWithDefaultChannel(SLACK_PUBLIC_CHANNEL, {
|
||||
channelsRefusal: SLACK_RATE_LIMITED_REFUSAL,
|
||||
}),
|
||||
);
|
||||
|
||||
// When
|
||||
await harness.mount();
|
||||
|
||||
// Then — when to come back, not just that it was refused: the wait is
|
||||
// asserted, not only the wording.
|
||||
const message = await harness.channelPickerMessage();
|
||||
expect(message).toMatch(/rate limiting/);
|
||||
expect(message).toMatch(/about 30 seconds/);
|
||||
|
||||
// And — waiting is the fix, so nothing is said about permissions.
|
||||
expect(message).not.toMatch(/permission/);
|
||||
expect(await harness.defaultChannel()).toBe(SLACK_PUBLIC_CHANNEL.name);
|
||||
}, 30000);
|
||||
|
||||
it("keeps the channels it did read on offer when Slack refuses a later page", async () => {
|
||||
// Given — a two-page workspace whose second page is rate limited
|
||||
// (`conversations.list` is tier 2, contract, Errors).
|
||||
const harness = new SlackIntegrationHarness(partiallyReadSlackFixture());
|
||||
|
||||
// When
|
||||
await harness.mount();
|
||||
|
||||
// Then — the picker offers what was read rather than being replaced by the
|
||||
// refusal: every reload re-runs the same reads into the same limit.
|
||||
expect(await harness.channelOptions()).toEqual([
|
||||
SLACK_PUBLIC_CHANNEL.name,
|
||||
SLACK_SECOND_PUBLIC_CHANNEL.name,
|
||||
]);
|
||||
expect(harness.saysChannelsUnreadable()).toBe(false);
|
||||
|
||||
// And — the wait is still said, as the explanation for the short list.
|
||||
const notice = harness.partialListNotice();
|
||||
expect(notice).toMatch(/rate limiting/);
|
||||
expect(notice).toMatch(/about 30 seconds/);
|
||||
|
||||
// And — a partial read says nothing about the destination already recorded.
|
||||
expect(await harness.defaultChannel()).toBe(SLACK_PUBLIC_CHANNEL.name);
|
||||
expect(harness.offersTestMessage()).toBe(true);
|
||||
}, 60000);
|
||||
|
||||
it("says nothing about a short list when the whole workspace was read", async () => {
|
||||
// Given — the default workspace: two cursor pages, read to the end.
|
||||
const harness = new SlackIntegrationHarness(connectedSlackFixture());
|
||||
|
||||
// When
|
||||
await harness.mount();
|
||||
|
||||
// Then
|
||||
expect(harness.partialListNotice()).toBeNull();
|
||||
}, 30000);
|
||||
|
||||
it("falls back to the API's wording when the listing fails upstream", async () => {
|
||||
// Given — a `502`, which names no `code` because there is nothing to act on
|
||||
// (contract, Errors).
|
||||
const harness = new SlackIntegrationHarness(
|
||||
slackFixtureWithDefaultChannel(SLACK_PUBLIC_CHANNEL, {
|
||||
channelsRefusal: SLACK_UPSTREAM_REFUSAL,
|
||||
}),
|
||||
);
|
||||
|
||||
// When
|
||||
await harness.mount();
|
||||
|
||||
// Then — the API's own `detail`, and not a wait that was never promised.
|
||||
const message = await harness.channelPickerMessage();
|
||||
expect(message).toMatch(/Slack is temporarily unavailable/);
|
||||
expect(message).not.toMatch(/rate limiting/);
|
||||
expect(await harness.defaultChannel()).toBe(SLACK_PUBLIC_CHANNEL.name);
|
||||
}, 30000);
|
||||
|
||||
it("says to invite @Prowler when Slack refuses the channel because the app is not in it", async () => {
|
||||
// Given — a private channel the app was removed from. The API validates the
|
||||
// channel against Slack on the way in and refuses with `not_in_channel`.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
connectedSlackFixture({
|
||||
channelSaveRefusal: SLACK_NOT_IN_CHANNEL_REFUSAL,
|
||||
}),
|
||||
);
|
||||
await harness.mount();
|
||||
|
||||
// When
|
||||
const refusal = await harness.refusedChannelSave(
|
||||
SLACK_PRIVATE_CHANNEL.name,
|
||||
);
|
||||
|
||||
// Then — the one fix the user can carry out themselves, in Slack.
|
||||
expect(refusal).toMatch(/Prowler is not in that channel/);
|
||||
expect(refusal).toMatch(/Invite @Prowler to it in Slack/);
|
||||
expect(refusal).not.toMatch(SLACK_NOT_IN_CHANNEL_CODE);
|
||||
|
||||
// And — nothing was recorded, so nothing is offered to post with.
|
||||
expect(await harness.defaultChannel()).toBeNull();
|
||||
expect(harness.offersTestMessage()).toBe(false);
|
||||
}, 60000);
|
||||
|
||||
it("says the channel is gone, not that @Prowler needs inviting, when Slack no longer has it", async () => {
|
||||
// Given — a channel archived since the listing was read. The API's `detail`
|
||||
// is word-for-word the one for `not_in_channel`, so only `code` tells them
|
||||
// apart.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
connectedSlackFixture({
|
||||
channelSaveRefusal: SLACK_CHANNEL_NOT_FOUND_REFUSAL,
|
||||
}),
|
||||
);
|
||||
await harness.mount();
|
||||
|
||||
// When
|
||||
const refusal = await harness.refusedChannelSave(SLACK_PUBLIC_CHANNEL.name);
|
||||
|
||||
// Then — a different problem, so different copy: nothing to invite to a
|
||||
// channel that no longer exists.
|
||||
expect(refusal).toMatch(/no longer exists in the workspace/);
|
||||
expect(refusal).toMatch(/Choose another one/);
|
||||
expect(refusal).not.toMatch(/Invite @Prowler/);
|
||||
expect(refusal).not.toMatch(SLACK_UNKNOWN_CHANNEL_DETAIL);
|
||||
expect(await harness.defaultChannel()).toBeNull();
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
describe("sending a test message", () => {
|
||||
it("is not offered until a destination channel is recorded", async () => {
|
||||
// Given — connected, but no channel chosen yet.
|
||||
const harness = new SlackIntegrationHarness(connectedSlackFixture());
|
||||
|
||||
// When
|
||||
await harness.mount();
|
||||
|
||||
// Then
|
||||
expect(await harness.defaultChannel()).toBeNull();
|
||||
expect(harness.offersTestMessage()).toBe(false);
|
||||
}, 30000);
|
||||
|
||||
it("sends a test message to the recorded channel and reports it delivered", async () => {
|
||||
// Given — a tenant that has recorded where Prowler should post.
|
||||
const harness = new SlackIntegrationHarness(connectedSlackFixture());
|
||||
await harness.mount();
|
||||
await harness.chooseChannel(SLACK_PUBLIC_CHANNEL.name);
|
||||
|
||||
// When
|
||||
const outcome = await harness.sendTestMessage();
|
||||
|
||||
// Then — sent, and the user reads which channel it went to.
|
||||
expect(outcome).toBe(TEST_MESSAGE_OUTCOME.SENT);
|
||||
expect(await harness.lastTestMessageOutcome()).toMatch(
|
||||
`#${SLACK_PUBLIC_CHANNEL.name}`,
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it("surfaces the reason when Slack refuses the test message", async () => {
|
||||
// Given — the post fails, which the API reports on the task it handed back
|
||||
// (design D9), not on the request that started it, using the same stable
|
||||
// reason the synchronous endpoints put in `code`.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
connectedSlackFixture({
|
||||
testMessage: { accepted: false, error: SLACK_NOT_IN_CHANNEL_CODE },
|
||||
}),
|
||||
);
|
||||
await harness.mount();
|
||||
await harness.chooseChannel(SLACK_PUBLIC_CHANNEL.name);
|
||||
|
||||
// When
|
||||
const outcome = await harness.sendTestMessage();
|
||||
|
||||
// Then — the same copy the synchronous refusals get, not the raw token.
|
||||
expect(outcome).toBe(TEST_MESSAGE_OUTCOME.FAILED);
|
||||
const reported = await harness.lastTestMessageOutcome();
|
||||
expect(reported).toMatch(/Prowler is not in that channel/);
|
||||
expect(reported).toMatch(/Invite @Prowler to it in Slack/);
|
||||
expect(reported).not.toMatch(SLACK_NOT_IN_CHANNEL_CODE);
|
||||
}, 60000);
|
||||
|
||||
it("reports a refusal the task words itself, rather than swallowing it", async () => {
|
||||
// Given — a task result carrying prose instead of a stable reason; its exact
|
||||
// shape is the cloud lane's to pin down (contract, test-message).
|
||||
const harness = new SlackIntegrationHarness(
|
||||
connectedSlackFixture({
|
||||
testMessage: {
|
||||
accepted: false,
|
||||
error: SLACK_TEST_MESSAGE_REFUSED_DETAIL,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await harness.mount();
|
||||
await harness.chooseChannel(SLACK_PUBLIC_CHANNEL.name);
|
||||
|
||||
// When
|
||||
const outcome = await harness.sendTestMessage();
|
||||
|
||||
// Then
|
||||
expect(outcome).toBe(TEST_MESSAGE_OUTCOME.FAILED);
|
||||
expect(await harness.lastTestMessageOutcome()).toMatch(
|
||||
SLACK_TEST_MESSAGE_REFUSED_DETAIL,
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it("keeps a reason it has no copy for inside its own sentence, not as the whole message", async () => {
|
||||
// Given — a real Slack reason this UI has no copy for; Slack's set is
|
||||
// open-ended, so this is the ordinary case.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
connectedSlackFixture({
|
||||
testMessage: { accepted: false, error: SLACK_UNMAPPED_REASON_CODE },
|
||||
}),
|
||||
);
|
||||
await harness.mount();
|
||||
await harness.chooseChannel(SLACK_PUBLIC_CHANNEL.name);
|
||||
|
||||
// When
|
||||
const outcome = await harness.sendTestMessage();
|
||||
|
||||
// Then — Prowler's wording, with Slack's word for it kept for diagnosis.
|
||||
expect(outcome).toBe(TEST_MESSAGE_OUTCOME.FAILED);
|
||||
const reported = await harness.lastTestMessageOutcome();
|
||||
expect(reported).toMatch(/Slack refused the message/);
|
||||
expect(reported).toMatch(SLACK_UNMAPPED_REASON_CODE);
|
||||
expect(reported).not.toBe(SLACK_UNMAPPED_REASON_CODE);
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
describe("disconnecting a workspace", () => {
|
||||
it("removes the integration and returns the card to its unconnected state", async () => {
|
||||
// Given — a tenant with a workspace connected.
|
||||
const harness = new SlackIntegrationHarness(connectedSlackFixture());
|
||||
await harness.mount();
|
||||
|
||||
// When — the user disconnects and confirms; Slack confirms the revocation.
|
||||
expect(await harness.disconnect()).toBe(REVOCATION_OUTCOME.REVOKED);
|
||||
|
||||
expect(harness.disconnectCallCount).toBe(1);
|
||||
expect(await harness.returnedToUnconnectedState()).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it("still removes the integration when the revocation fails, and says access may need removing by hand", async () => {
|
||||
// Given — Slack will not accept the revocation; the row goes either way.
|
||||
const harness = new SlackIntegrationHarness(revokeFailureSlackFixture());
|
||||
await harness.mount();
|
||||
|
||||
// When
|
||||
expect(await harness.disconnect()).toBe(REVOCATION_OUTCOME.NOT_REVOKED);
|
||||
|
||||
// And — the disconnect revalidates, so the copy below is read from props
|
||||
// that no longer carry an integration at all.
|
||||
await harness.refreshPageData();
|
||||
|
||||
// Then — what is true of both sides: nothing is left in Prowler to retry,
|
||||
// and the app may still be installed at Slack.
|
||||
const notice = await harness.revocationNotice();
|
||||
expect(notice).toMatch(/gone from Prowler/);
|
||||
expect(notice).toMatch(/nothing to retry here/);
|
||||
expect(notice).toMatch(/may still be installed in Prowler HQ/);
|
||||
expect(notice).toMatch(
|
||||
/remove it from that workspace's Slack app settings/,
|
||||
);
|
||||
expect(await harness.returnedToUnconnectedState()).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it("says only that the workspace is no longer connected when nothing reports the revocation", async () => {
|
||||
// Given — the plain `204` a deployment that overrides nothing answers: no
|
||||
// body, so no `meta` to read the outcome from. The case users really meet.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
unreportedRevocationSlackFixture(),
|
||||
);
|
||||
await harness.mount();
|
||||
|
||||
// When
|
||||
expect(await harness.disconnect()).toBe(REVOCATION_OUTCOME.UNREPORTED);
|
||||
|
||||
// Then — nothing sends the user to Slack to finish a job no answer said
|
||||
// was unfinished.
|
||||
expect(harness.showsRevocationNotice()).toBe(false);
|
||||
expect(await harness.returnedToUnconnectedState()).toBe(true);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe("a credential Slack no longer accepts", () => {
|
||||
it("says the connection check found a dead credential, and offers to connect the workspace again", async () => {
|
||||
// Given — the token was revoked at Slack, so the row still reads connected
|
||||
// until a check runs (contract, Cross-cutting).
|
||||
const harness = new SlackIntegrationHarness(revokedTokenSlackFixture());
|
||||
await harness.mount();
|
||||
|
||||
// When
|
||||
expect(await harness.testConnection()).toBe(CONNECTION_OUTCOME.FAILURE);
|
||||
|
||||
// Then — a way forward rather than only an error: a revoked token is fixed
|
||||
// by approving Prowler again, not by checking a second time.
|
||||
const notice = await harness.revokedCredentialNotice();
|
||||
expect(notice).toMatch(/no longer accepts Prowler's access to Prowler HQ/);
|
||||
expect(notice).toMatch(/Prowler's access to Slack was revoked/);
|
||||
expect(notice).toMatch(/Connect the workspace again to restore access/);
|
||||
// Slack's reason is a protocol token: it is what the UI switched on, never
|
||||
// what it showed.
|
||||
expect(notice).not.toMatch(new RegExp(SLACK_TOKEN_REVOKED_CODE));
|
||||
|
||||
const consentScreen = new URL(await harness.reconnectUrl());
|
||||
expect(`${consentScreen.origin}${consentScreen.pathname}`).toBe(
|
||||
"https://slack.com/oauth/v2/authorize",
|
||||
);
|
||||
expect(harness.offersReconnect()).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it("offers the same recovery when the channel listing is what finds the credential dead", async () => {
|
||||
// Given — a finished setup whose credential expired. The listing runs on
|
||||
// arrival, so it meets Slack before any check does, and the contract says
|
||||
// any call can be the one that surfaces this.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
configuredSlackFixture({ channelsRefusal: SLACK_TOKEN_EXPIRED_REFUSAL }),
|
||||
);
|
||||
|
||||
// When — nothing but opening the page.
|
||||
await harness.mount();
|
||||
|
||||
// Then — the same answer the connection check gives, worded for how this
|
||||
// credential died rather than left as a channel problem.
|
||||
const notice = await harness.revokedCredentialNotice();
|
||||
expect(notice).toMatch(/Prowler's Slack credential has expired/);
|
||||
expect(notice).toMatch(/Connect the workspace again to restore access/);
|
||||
expect(harness.offersReconnect()).toBe(true);
|
||||
|
||||
// And — the picker says the same, in the same words: `detail` names the raw
|
||||
// reason, and it is `code` the UI answered from.
|
||||
const message = await harness.channelPickerMessage();
|
||||
expect(message).toMatch(/Prowler's Slack credential has expired/);
|
||||
expect(message).not.toMatch(new RegExp(SLACK_TOKEN_EXPIRED_CODE));
|
||||
}, 30000);
|
||||
|
||||
it("offers it too when only a later cursor page is what Slack refuses", async () => {
|
||||
// Given — a two-page workspace whose second page is refused by a credential
|
||||
// Slack no longer accepts: the read stops short rather than failing.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
partiallyReadSlackFixture({
|
||||
channelsRefusal: SLACK_TOKEN_EXPIRED_REFUSAL,
|
||||
}),
|
||||
);
|
||||
|
||||
// When — nothing but opening the page.
|
||||
await harness.mount();
|
||||
|
||||
// Then — what was read stays on offer, as it does for any short list.
|
||||
expect(await harness.channelOptions()).toEqual([
|
||||
SLACK_PUBLIC_CHANNEL.name,
|
||||
SLACK_SECOND_PUBLIC_CHANNEL.name,
|
||||
]);
|
||||
|
||||
// And — the dead credential is reported all the same: a picker that still
|
||||
// works is no reason to leave the user without the one fix there is.
|
||||
const notice = await harness.revokedCredentialNotice();
|
||||
expect(notice).toMatch(/Prowler's Slack credential has expired/);
|
||||
expect(harness.offersReconnect()).toBe(true);
|
||||
expect(await harness.connectionBadge()).toBe("Disconnected");
|
||||
}, 60000);
|
||||
|
||||
it("keeps saying so when a later check fails without Slack naming a reason", async () => {
|
||||
// Given — the listing found the credential dead on arrival, and a later
|
||||
// check that fails naming no reason at all.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
configuredSlackFixture({
|
||||
channelsRefusal: SLACK_TOKEN_EXPIRED_REFUSAL,
|
||||
connection: { connected: false, error: null },
|
||||
}),
|
||||
);
|
||||
await harness.mount();
|
||||
expect(await harness.revokedCredentialNotice()).toMatch(
|
||||
/Prowler's Slack credential has expired/,
|
||||
);
|
||||
|
||||
// When
|
||||
expect(await harness.testConnection()).toBe(CONNECTION_OUTCOME.FAILURE);
|
||||
|
||||
// Then — a failure Slack never answered is no evidence the grant works
|
||||
// again, so the dead credential is still what the page reports.
|
||||
expect(await harness.revokedCredentialNotice()).toMatch(
|
||||
/Prowler's Slack credential has expired/,
|
||||
);
|
||||
expect(harness.offersReconnect()).toBe(true);
|
||||
expect(await harness.connectionBadge()).toBe("Disconnected");
|
||||
}, 60000);
|
||||
|
||||
it("stops saying so once a save Slack validated goes through", async () => {
|
||||
// Given — a finished setup whose test message found the grant revoked.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
configuredSlackFixture({
|
||||
testMessage: { accepted: false, error: SLACK_TOKEN_REVOKED_CODE },
|
||||
}),
|
||||
);
|
||||
await harness.mount();
|
||||
expect(await harness.connectionBadge()).toBe("Connected");
|
||||
expect(await harness.sendTestMessage()).toBe(TEST_MESSAGE_OUTCOME.FAILED);
|
||||
expect(harness.showsRevokedCredentialNotice()).toBe(true);
|
||||
expect(await harness.connectionBadge()).toBe("Disconnected");
|
||||
|
||||
// When — the access is approved again in Slack, away from this page, and
|
||||
// the user saves a destination here. The API validates the channel against
|
||||
// Slack, so the save is an answer about the credential.
|
||||
await harness.chooseChannel(SLACK_SECOND_PUBLIC_CHANNEL.name);
|
||||
|
||||
// Then — Slack answered, so the notice about a credential it no longer
|
||||
// accepts goes, and the card is back to what it reported on arrival.
|
||||
expect(harness.showsRevokedCredentialNotice()).toBe(false);
|
||||
expect(harness.offersReconnect()).toBe(false);
|
||||
expect(await harness.connectionBadge()).toBe("Connected");
|
||||
}, 60000);
|
||||
});
|
||||
@@ -32,13 +32,6 @@ export const ProvidersTabContent = async ({
|
||||
}: {
|
||||
searchParams: SearchParamsProps;
|
||||
}) => {
|
||||
// The React Compiler (`reactCompiler: true`) otherwise instruments this as a
|
||||
// client component and injects `useMemoCache`, which needs a React dispatcher.
|
||||
// An async server component renders once per request, so there is nothing to
|
||||
// memoize — and the injected hook makes it uncallable outside a render, which
|
||||
// is exactly how the browser-mode tests mount it.
|
||||
"use no memo";
|
||||
|
||||
const isCloudEnvironment = isCloud();
|
||||
const [providersView, scanConfigsState] = await Promise.all([
|
||||
loadProvidersAccountsViewData({
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Slack integration: connect a Slack workspace from the Integrations page (Prowler Cloud only)
|
||||
@@ -752,6 +752,51 @@ export const JiraIcon: React.FC<IconSvgProps> = ({
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const SlackIcon: React.FC<IconSvgProps> = ({
|
||||
size = 32,
|
||||
width,
|
||||
height,
|
||||
className = "rounded-md",
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height={height ?? size}
|
||||
viewBox="0 0 48 48"
|
||||
width={width ?? size}
|
||||
className={className}
|
||||
{...props}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M0 12C0 5.37258 5.37258 0 12 0H36C42.6274 0 48 5.37258 48 12V36C48 42.6274 42.6274 48 36 48H12C5.37258 48 0 42.6274 0 36V12Z"
|
||||
fill="#FFFFFF"
|
||||
/>
|
||||
{/* Slack mark on its native 122.8 grid, scaled into the 48px tile's 30px
|
||||
safe area (30 / 122.8 = 0.2443). */}
|
||||
<g transform="translate(9 9) scale(0.2443)">
|
||||
<path
|
||||
d="M25.8 77.6c0 7.1-5.8 12.9-12.9 12.9S0 84.7 0 77.6s5.8-12.9 12.9-12.9h12.9v12.9zm6.5 0c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9v32.3c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V77.6z"
|
||||
fill="#E01E5A"
|
||||
/>
|
||||
<path
|
||||
d="M45.2 25.8c-7.1 0-12.9-5.8-12.9-12.9S38.1 0 45.2 0s12.9 5.8 12.9 12.9v12.9H45.2zm0 6.5c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H12.9C5.8 58.1 0 52.3 0 45.2s5.8-12.9 12.9-12.9h32.3z"
|
||||
fill="#36C5F0"
|
||||
/>
|
||||
<path
|
||||
d="M97 45.2c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9-5.8 12.9-12.9 12.9H97V45.2zm-6.5 0c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V12.9C64.7 5.8 70.5 0 77.6 0s12.9 5.8 12.9 12.9v32.3z"
|
||||
fill="#2EB67D"
|
||||
/>
|
||||
<path
|
||||
d="M77.6 97c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9-12.9-5.8-12.9-12.9V97h12.9zm0-6.5c-7.1 0-12.9-5.8-12.9-12.9s5.8-12.9 12.9-12.9h32.3c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H77.6z"
|
||||
fill="#ECB22E"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const AWSSecurityHubIcon: React.FC<IconSvgProps> = ({
|
||||
size = 32,
|
||||
width,
|
||||
|
||||
@@ -6,18 +6,47 @@ import { ReactNode } from "react";
|
||||
import { Badge } from "@/components/shadcn";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// `null` means never checked, not disconnected: it must not get the fail tokens.
|
||||
const CONNECTION_BADGE = {
|
||||
connected: {
|
||||
label: "Connected",
|
||||
className:
|
||||
"bg-bg-pass-secondary text-text-success-primary border-transparent",
|
||||
},
|
||||
disconnected: {
|
||||
label: "Disconnected",
|
||||
className:
|
||||
"bg-bg-fail-secondary text-text-error-primary border-transparent",
|
||||
},
|
||||
unchecked: {
|
||||
label: "Not checked yet",
|
||||
className: "border-border-tag bg-bg-tag text-text-neutral-secondary",
|
||||
},
|
||||
} as const;
|
||||
|
||||
type ConnectionBadgeState = keyof typeof CONNECTION_BADGE;
|
||||
|
||||
const connectionBadgeState = (
|
||||
connected: boolean | null,
|
||||
): ConnectionBadgeState =>
|
||||
connected === null ? "unchecked" : connected ? "connected" : "disconnected";
|
||||
|
||||
interface IntegrationCardChip {
|
||||
label: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface IntegrationConnectionStatus {
|
||||
connected: boolean | null;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface IntegrationCardHeaderProps {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
chips?: Array<{
|
||||
label: string;
|
||||
className?: string;
|
||||
}>;
|
||||
connectionStatus?: {
|
||||
connected: boolean;
|
||||
label?: string;
|
||||
};
|
||||
chips?: IntegrationCardChip[];
|
||||
connectionStatus?: IntegrationConnectionStatus;
|
||||
navigationUrl?: string;
|
||||
}
|
||||
|
||||
@@ -29,6 +58,11 @@ export const IntegrationCardHeader = ({
|
||||
connectionStatus,
|
||||
navigationUrl,
|
||||
}: IntegrationCardHeaderProps) => {
|
||||
const badgeState = connectionStatus
|
||||
? connectionBadgeState(connectionStatus.connected)
|
||||
: null;
|
||||
const badge = badgeState ? CONNECTION_BADGE[badgeState] : null;
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -55,7 +89,7 @@ export const IntegrationCardHeader = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(chips.length > 0 || connectionStatus) && (
|
||||
{(chips.length > 0 || badge) && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{chips.map((chip, index) => (
|
||||
<Badge
|
||||
@@ -69,18 +103,13 @@ export const IntegrationCardHeader = ({
|
||||
{chip.label}
|
||||
</Badge>
|
||||
))}
|
||||
{connectionStatus && (
|
||||
{badge && badgeState && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-xs font-normal",
|
||||
connectionStatus.connected
|
||||
? "bg-bg-pass-secondary text-text-success-primary border-transparent"
|
||||
: "bg-bg-fail-secondary text-text-error-primary border-transparent",
|
||||
)}
|
||||
data-connection-status={badgeState}
|
||||
className={cn("text-xs font-normal", badge.className)}
|
||||
>
|
||||
{connectionStatus.label ||
|
||||
(connectionStatus.connected ? "Connected" : "Disconnected")}
|
||||
{connectionStatus?.label || badge.label}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* The cases `slack-page.integration.test.tsx` cannot express: it runs the Server
|
||||
* Action as a plain function, so there is no client→server transport to reject,
|
||||
* and its handler only answers the contract's shapes. React error boundaries
|
||||
* cannot see a rejection awaited in an effect, so an uncaught one leaves the
|
||||
* user on the spinner with no error and no way out.
|
||||
*/
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { IntegrationProps } from "@/types/integrations";
|
||||
|
||||
import { SlackCallback } from "./slack-callback";
|
||||
|
||||
const COMPLETED_QUERY = "code=slack-code-1f4a&state=st-2f1c9d7a";
|
||||
|
||||
const { exchangeSlackOAuthCode, callbackQuery, routerReplace } = vi.hoisted(
|
||||
() => ({
|
||||
exchangeSlackOAuthCode: vi.fn(),
|
||||
callbackQuery: { value: "" },
|
||||
routerReplace: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@/actions/integrations/slack", () => ({ exchangeSlackOAuthCode }));
|
||||
|
||||
// One router across renders, so the redirect off the spent code is assertable.
|
||||
const router = { replace: routerReplace };
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => router,
|
||||
useSearchParams: () => new URLSearchParams(callbackQuery.value),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
callbackQuery.value = COMPLETED_QUERY;
|
||||
routerReplace.mockClear();
|
||||
});
|
||||
|
||||
const SPINNER_COPY = /Connecting your Slack workspace/;
|
||||
|
||||
/**
|
||||
* Literals, not imports: a rename on the component's side has to fail here.
|
||||
* `FAILURE_TITLE` claims nothing was connected, which only holds for outcomes
|
||||
* that happen before the API consumed the code.
|
||||
*/
|
||||
const FAILURE_TITLE = "Slack workspace not connected";
|
||||
const UNCONFIRMED_TITLE = "Slack install not confirmed";
|
||||
|
||||
describe("returning from Slack when the completion answers unexpectedly", () => {
|
||||
it("reports an unconfirmed result instead of spinning forever when the exchange call never comes back", async () => {
|
||||
// The client→server POST itself fails (dropped connection, action id
|
||||
// invalidated by a deploy), so the action's own error handling never runs.
|
||||
exchangeSlackOAuthCode.mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
|
||||
render(<SlackCallback />);
|
||||
|
||||
// The API consumes the single-use code before answering, so the workspace
|
||||
// may well be connected: unknown, not failed.
|
||||
expect(
|
||||
await screen.findByText(/could not confirm whether the workspace/i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("link", { name: /Back to Slack integration/ }),
|
||||
).toHaveAttribute("href", "/integrations/slack");
|
||||
expect(screen.queryByText(SPINNER_COPY)).not.toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(UNCONFIRMED_TITLE)).toBeInTheDocument();
|
||||
expect(screen.queryByText(FAILURE_TITLE)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("still reports the workspace as connected when the created integration carries no configuration", async () => {
|
||||
// The install already succeeded; `configuration` only goes missing on the
|
||||
// client, where the callback reads the workspace name off it.
|
||||
exchangeSlackOAuthCode.mockResolvedValue({
|
||||
integration: {
|
||||
type: "integrations",
|
||||
id: "slack-integration-1",
|
||||
attributes: {
|
||||
inserted_at: "2026-08-10T09:00:00Z",
|
||||
updated_at: "2026-08-10T09:00:00Z",
|
||||
enabled: true,
|
||||
connected: null,
|
||||
connection_last_checked_at: null,
|
||||
integration_type: "slack",
|
||||
},
|
||||
links: { self: "/api/v1/integrations/slack-integration-1" },
|
||||
// Cast: the shape is the one the contract rules out.
|
||||
} as unknown as IntegrationProps,
|
||||
});
|
||||
|
||||
render(<SlackCallback />);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/Connected to your Slack workspace/),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(SPINNER_COPY)).not.toBeInTheDocument();
|
||||
// Keyed on the escape link, the only element unique to the failure branch,
|
||||
// so this holds whichever headline that branch would have carried.
|
||||
expect(
|
||||
screen.queryByRole("link", { name: /Back to Slack integration/ }),
|
||||
).not.toBeInTheDocument();
|
||||
// `replace`, not `push`: a back navigation must not remount onto the code.
|
||||
expect(routerReplace).toHaveBeenCalledWith("/integrations/slack");
|
||||
});
|
||||
});
|
||||
|
||||
describe("returning from Slack with an error on the callback URL", () => {
|
||||
it("says the install was declined when Slack reports the approval was refused", async () => {
|
||||
// The one code Slack reliably sends to this redirect.
|
||||
callbackQuery.value = "error=access_denied&state=st-2f1c9d7a";
|
||||
|
||||
render(<SlackCallback />);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/was not approved in Slack/),
|
||||
).toBeInTheDocument();
|
||||
expect(exchangeSlackOAuthCode).not.toHaveBeenCalled();
|
||||
|
||||
// Slack refused before issuing a code, so the flat "not connected" is a
|
||||
// fact here, unlike in the outcomes that follow an exchange.
|
||||
expect(screen.getByText(FAILURE_TITLE)).toBeInTheDocument();
|
||||
expect(screen.queryByText(UNCONFIRMED_TITLE)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("names a Slack code it does not recognise, so a new failure reason is still diagnosable", async () => {
|
||||
// Slack publishes no closed set of codes for this redirect, so the guard is
|
||||
// on the shape of the value rather than on an allowlist.
|
||||
callbackQuery.value = "error=invalid_scope&state=st-2f1c9d7a";
|
||||
|
||||
render(<SlackCallback />);
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"Slack could not complete the install (invalid_scope).",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("drops a sentence smuggled into the error parameter instead of rendering it as Prowler's own copy", async () => {
|
||||
// The balancing punctuation is the point: it closes Prowler's parenthetical
|
||||
// and reopens it, so the payload would read as Prowler's own sentence.
|
||||
const payload =
|
||||
"). Slack has flagged this workspace. Contact Prowler support at +1-555-0100 to restore alerting (";
|
||||
callbackQuery.value = `error=${encodeURIComponent(payload)}&state=st-2f1c9d7a`;
|
||||
|
||||
render(<SlackCallback />);
|
||||
|
||||
expect(
|
||||
await screen.findByText("Slack could not complete the install."),
|
||||
).toBeInTheDocument();
|
||||
expect(document.body.textContent).not.toContain("+1-555-0100");
|
||||
expect(document.body.textContent).not.toContain("flagged this workspace");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import { AlertCircle, CircleCheck, Loader2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { exchangeSlackOAuthCode } from "@/actions/integrations/slack";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
Button,
|
||||
} from "@/components/shadcn";
|
||||
import { SLACK_REASON_TOKEN } from "@/lib/integrations/slack-errors";
|
||||
|
||||
const SLACK_INTEGRATION_PATH = "/integrations/slack";
|
||||
|
||||
const STATUS = {
|
||||
CONNECTING: "connecting",
|
||||
CONNECTED: "connected",
|
||||
FAILED: "failed",
|
||||
} as const;
|
||||
|
||||
type Status = (typeof STATUS)[keyof typeof STATUS];
|
||||
|
||||
const UNCONFIRMED_COMPLETION_MESSAGE =
|
||||
"Prowler could not confirm whether the workspace was connected. Open the Slack integration page to check — if none is listed there, start the install again.";
|
||||
|
||||
const FAILURE_TITLE = "Slack workspace not connected";
|
||||
|
||||
/**
|
||||
* The API consumes the code and upserts the integration before it answers, so an
|
||||
* unreadable or missing answer can still mean a connected workspace. Kept short:
|
||||
* `AlertTitle` clamps to one line.
|
||||
*/
|
||||
const UNCONFIRMED_TITLE = "Slack install not confirmed";
|
||||
|
||||
const describeSlackError = (reason: string): string => {
|
||||
if (reason === "access_denied") {
|
||||
return "The install was not approved in Slack, so no workspace was connected.";
|
||||
}
|
||||
// `error` comes straight off the URL and is interpolated into Prowler's own
|
||||
// copy, so gate on the shape of a code: Slack publishes no closed set.
|
||||
return SLACK_REASON_TOKEN.test(reason)
|
||||
? `Slack could not complete the install (${reason}).`
|
||||
: "Slack could not complete the install.";
|
||||
};
|
||||
|
||||
/**
|
||||
* Slack's `code` is single-use: `hasStarted` holds the exchange to one run per
|
||||
* mount, and `router.replace` (not `push`) keeps a back navigation from
|
||||
* remounting onto a spent code.
|
||||
*/
|
||||
export const SlackCallback = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [status, setStatus] = useState<Status>(STATUS.CONNECTING);
|
||||
const [workspaceName, setWorkspaceName] = useState<string | null>(null);
|
||||
const [failure, setFailure] = useState<string>("");
|
||||
const [failureTitle, setFailureTitle] = useState<string>(FAILURE_TITLE);
|
||||
const hasStarted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasStarted.current) return;
|
||||
hasStarted.current = true;
|
||||
|
||||
const slackError = searchParams.get("error");
|
||||
const code = searchParams.get("code");
|
||||
const state = searchParams.get("state");
|
||||
|
||||
// Slack answers a declined install with `error` and no code, so there is
|
||||
// nothing to exchange.
|
||||
if (slackError) {
|
||||
setFailure(describeSlackError(slackError));
|
||||
setStatus(STATUS.FAILED);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
setFailure(
|
||||
"Slack sent an incomplete response back, so the install could not be completed.",
|
||||
);
|
||||
setStatus(STATUS.FAILED);
|
||||
return;
|
||||
}
|
||||
|
||||
const complete = async () => {
|
||||
const result = await exchangeSlackOAuthCode({ code, state });
|
||||
|
||||
if ("integration" in result) {
|
||||
setWorkspaceName(
|
||||
result.integration.attributes?.configuration?.team_name ?? null,
|
||||
);
|
||||
setStatus(STATUS.CONNECTED);
|
||||
router.replace(SLACK_INTEGRATION_PATH);
|
||||
return;
|
||||
}
|
||||
|
||||
if ("unavailable" in result) {
|
||||
setFailure("Slack is not available in this environment yet.");
|
||||
} else if ("rateLimited" in result) {
|
||||
setFailure(result.message);
|
||||
} else if ("unconfirmed" in result) {
|
||||
setFailure(result.message);
|
||||
setFailureTitle(UNCONFIRMED_TITLE);
|
||||
} else {
|
||||
setFailure(result.error);
|
||||
}
|
||||
setStatus(STATUS.FAILED);
|
||||
};
|
||||
|
||||
// A rejection here means the call never came back (stale action id after a
|
||||
// deploy, HTML 502): error boundaries cannot see a rejection awaited inside
|
||||
// an effect, and the once-guard blocks a retry, so the page would spin.
|
||||
void complete().catch(() => {
|
||||
setFailure(UNCONFIRMED_COMPLETION_MESSAGE);
|
||||
setFailureTitle(UNCONFIRMED_TITLE);
|
||||
setStatus(STATUS.FAILED);
|
||||
});
|
||||
}, [router, searchParams]);
|
||||
|
||||
if (status === STATUS.CONNECTING) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-300">
|
||||
<Loader2 className="animate-spin" size={16} />
|
||||
Connecting your Slack workspace...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === STATUS.CONNECTED) {
|
||||
return (
|
||||
<Alert variant="success">
|
||||
<CircleCheck />
|
||||
<AlertTitle>
|
||||
Connected to {workspaceName ?? "your Slack workspace"}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
Taking you back to the Slack integration, where you can choose the
|
||||
channel Prowler posts to.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-4">
|
||||
<Alert variant="error">
|
||||
<AlertCircle />
|
||||
<AlertTitle>{failureTitle}</AlertTitle>
|
||||
<AlertDescription>{failure}</AlertDescription>
|
||||
</Alert>
|
||||
<Button asChild variant="outline">
|
||||
<Link href={SLACK_INTEGRATION_PATH}>Back to Slack integration</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { Lock, RefreshCw } from "lucide-react";
|
||||
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
Badge,
|
||||
Button,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/shadcn";
|
||||
import type { SlackChannelOption } from "@/types/integrations";
|
||||
|
||||
const INVITE_HINT =
|
||||
"A private channel only appears here after someone invites @Prowler to it in Slack. Invite it, then refresh.";
|
||||
|
||||
interface SlackChannelSelectorProps {
|
||||
options: SlackChannelOption[];
|
||||
value: string | null;
|
||||
onChange: (channelId: string) => void;
|
||||
isLoading?: boolean;
|
||||
/** Why the channels could not be read — Slack's own reason, when it gave one. */
|
||||
error?: string | null;
|
||||
/** Why the list is partial. Shown with the picker, not instead of it. */
|
||||
incompleteNotice?: string | null;
|
||||
onRefresh?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/** Driven entirely by props (design D13) so the alert-rule form can reuse it. */
|
||||
export const SlackChannelSelector = ({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
incompleteNotice = null,
|
||||
onRefresh,
|
||||
disabled = false,
|
||||
}: SlackChannelSelectorProps) => {
|
||||
const isEmpty = !isLoading && !error && options.length === 0;
|
||||
// `htmlFor` may only name an element that exists, and the trigger is only
|
||||
// rendered in the picker branch.
|
||||
const hasPicker = !error && !isEmpty;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label htmlFor={hasPicker ? "slack-channel" : undefined}>
|
||||
Destination channel
|
||||
</Label>
|
||||
{onRefresh && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
onClick={onRefresh}
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
{isLoading ? "Refreshing..." : "Refresh channels"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<Alert variant="error">
|
||||
<AlertTitle>Could not read the workspace's channels</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : isEmpty ? (
|
||||
<Alert variant="info">
|
||||
<AlertTitle>No channels available yet</AlertTitle>
|
||||
<AlertDescription>
|
||||
Prowler cannot see a single channel in this workspace. Create a
|
||||
public channel, or invite @Prowler to a private one in Slack with
|
||||
<span className="font-medium"> /invite @Prowler</span>, then
|
||||
refresh.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
{incompleteNotice && (
|
||||
<Alert variant="warning" data-channels-notice>
|
||||
<AlertTitle>Not every channel is listed</AlertTitle>
|
||||
<AlertDescription>{incompleteNotice}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<Select
|
||||
value={value ?? undefined}
|
||||
onValueChange={onChange}
|
||||
disabled={disabled || isLoading}
|
||||
>
|
||||
<SelectTrigger id="slack-channel" size="sm">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
isLoading ? "Reading channels..." : "Choose a channel"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem
|
||||
key={option.id}
|
||||
value={option.id}
|
||||
// Name hook: the rendered label mixes it with a lock icon
|
||||
// and a "Private" badge.
|
||||
data-channel={option.name}
|
||||
>
|
||||
{option.is_private && <Lock size={14} aria-hidden="true" />}
|
||||
<span className="min-w-0 truncate">#{option.name}</span>
|
||||
{option.is_private && (
|
||||
<Badge variant="tag" size="sm">
|
||||
Private
|
||||
</Badge>
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="text-text-neutral-secondary text-xs">{INVITE_HINT}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import { SettingsIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { SlackIcon } from "@/components/icons/services/IconServices";
|
||||
import { Button, Card, CardContent, CardHeader } from "@/components/shadcn";
|
||||
import { CustomLink } from "@/components/shadcn/custom/custom-link";
|
||||
|
||||
// Placeholder slug: the docs slice writes the page and confirms it.
|
||||
const SLACK_DOCS_URL =
|
||||
"https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-slack-integration/";
|
||||
|
||||
export const SlackIntegrationCard = () => {
|
||||
return (
|
||||
<Card variant="base" padding="lg">
|
||||
<CardHeader>
|
||||
<div className="flex w-full flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<SlackIcon size={40} />
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||
Slack
|
||||
</h4>
|
||||
<div className="flex flex-col items-start gap-2 sm:flex-row sm:items-center">
|
||||
<p className="text-xs text-nowrap text-gray-500 dark:text-gray-300">
|
||||
Send Prowler messages to your Slack workspace.
|
||||
</p>
|
||||
<CustomLink
|
||||
href={SLACK_DOCS_URL}
|
||||
aria-label="Learn more about Slack integration"
|
||||
size="xs"
|
||||
>
|
||||
Learn more
|
||||
</CustomLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 self-end sm:self-center">
|
||||
<Button asChild size="sm">
|
||||
<Link href="/integrations/slack">
|
||||
<SettingsIcon size={14} />
|
||||
Manage
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Connect a Slack workspace and pick the channel Prowler posts to, so
|
||||
your team gets security updates where it already works.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* The case `slack-page.integration.test.tsx` cannot express: it asserts against
|
||||
* a hydrated, settled page, so it never sees the first frame the user is
|
||||
* served. The effect that reads the channels only runs in the browser, so the
|
||||
* channel state at render time is what the served HTML says until hydration.
|
||||
*/
|
||||
|
||||
import { renderToString } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { INTEGRATION_TYPE, type IntegrationProps } from "@/types/integrations";
|
||||
|
||||
import { SlackIntegrationManager } from "./slack-integration-manager";
|
||||
|
||||
vi.mock("@/actions/integrations/slack", () => ({
|
||||
getSlackChannels: vi.fn(),
|
||||
sendSlackTestMessage: vi.fn(),
|
||||
setSlackDefaultChannel: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/integrations/integrations", () => ({
|
||||
testIntegrationConnection: vi.fn(),
|
||||
}));
|
||||
|
||||
/**
|
||||
* A connected workspace with no channel recorded, as the contract has it before
|
||||
* a save: with one, the picker would show that channel instead of the
|
||||
* placeholder this test reads.
|
||||
*/
|
||||
const CONNECTED_WORKSPACE: IntegrationProps = {
|
||||
type: "integrations",
|
||||
id: "slack-integration-1",
|
||||
attributes: {
|
||||
inserted_at: "2026-08-10T09:00:00Z",
|
||||
updated_at: "2026-08-10T09:00:00Z",
|
||||
enabled: true,
|
||||
connected: true,
|
||||
connection_last_checked_at: "2026-08-10T09:05:00Z",
|
||||
integration_type: INTEGRATION_TYPE.SLACK,
|
||||
configuration: {
|
||||
team_id: "T024BE7LD",
|
||||
team_name: "Prowler HQ",
|
||||
bot_user_id: "U0KRQLJ9H",
|
||||
},
|
||||
},
|
||||
links: { self: "/api/v1/integrations/slack-integration-1" },
|
||||
};
|
||||
|
||||
describe("the first paint of a connected workspace", () => {
|
||||
it("reads as still reading the channels rather than as a workspace with none", () => {
|
||||
// When
|
||||
const serverHtml = renderToString(
|
||||
<SlackIntegrationManager
|
||||
integration={CONNECTED_WORKSPACE}
|
||||
authorizeUrl={null}
|
||||
unavailable={false}
|
||||
rateLimitMessage={null}
|
||||
loadError={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(serverHtml).toContain("Reading channels...");
|
||||
expect(serverHtml).not.toContain("No channels available yet");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,745 @@
|
||||
"use client";
|
||||
|
||||
import { format, isValid, parseISO } from "date-fns";
|
||||
import { Send, TestTube, Unplug } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { testIntegrationConnection } from "@/actions/integrations/integrations";
|
||||
import {
|
||||
disconnectSlackIntegration,
|
||||
getSlackAuthorizeUrl,
|
||||
getSlackChannels,
|
||||
sendSlackTestMessage,
|
||||
setSlackDefaultChannel,
|
||||
} from "@/actions/integrations/slack";
|
||||
import { SlackIcon } from "@/components/icons/services/IconServices";
|
||||
import { IntegrationCardHeader } from "@/components/integrations/shared";
|
||||
import { SlackChannelSelector } from "@/components/integrations/slack/slack-channel-selector";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
useToast,
|
||||
} from "@/components/shadcn";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import {
|
||||
isSlackTokenErrorCode,
|
||||
SLACK_REASON_TOKEN,
|
||||
slackErrorMessage,
|
||||
} from "@/lib/integrations/slack-errors";
|
||||
import type { SlackTokenErrorCode } from "@/lib/integrations/slack-errors";
|
||||
import type {
|
||||
IntegrationProps,
|
||||
SlackChannelOption,
|
||||
} from "@/types/integrations";
|
||||
|
||||
const CHANNELS_STATUS = {
|
||||
LOADING: "loading",
|
||||
ERROR: "error",
|
||||
LOADED: "loaded",
|
||||
} as const;
|
||||
|
||||
interface ChannelsLoading {
|
||||
status: typeof CHANNELS_STATUS.LOADING;
|
||||
}
|
||||
|
||||
interface ChannelsFailed {
|
||||
status: typeof CHANNELS_STATUS.ERROR;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface ChannelsLoaded {
|
||||
status: typeof CHANNELS_STATUS.LOADED;
|
||||
channels: SlackChannelOption[];
|
||||
// Rides with the list it qualifies, so it can never outlive it.
|
||||
notice: string | null;
|
||||
}
|
||||
|
||||
type ChannelsState = ChannelsLoading | ChannelsFailed | ChannelsLoaded;
|
||||
|
||||
const TEST_MESSAGE_STATUS = {
|
||||
IDLE: "idle",
|
||||
SENDING: "sending",
|
||||
SENT: "sent",
|
||||
FAILED: "failed",
|
||||
} as const;
|
||||
|
||||
interface TestMessageIdle {
|
||||
status: typeof TEST_MESSAGE_STATUS.IDLE;
|
||||
}
|
||||
|
||||
interface TestMessageSending {
|
||||
status: typeof TEST_MESSAGE_STATUS.SENDING;
|
||||
}
|
||||
|
||||
interface TestMessageSent {
|
||||
status: typeof TEST_MESSAGE_STATUS.SENT;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
interface TestMessageFailed {
|
||||
status: typeof TEST_MESSAGE_STATUS.FAILED;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
type TestMessageState =
|
||||
| TestMessageIdle
|
||||
| TestMessageSending
|
||||
| TestMessageSent
|
||||
| TestMessageFailed;
|
||||
|
||||
/**
|
||||
* A disconnect that removed the row without Slack confirming the revocation.
|
||||
* The workspace name travels with it: the notice exists to name the workspace
|
||||
* to clean up, and the record is gone by the time revalidation lands.
|
||||
*/
|
||||
interface UnconfirmedRevocation {
|
||||
workspace: string | null;
|
||||
}
|
||||
|
||||
// The name may be missing: the id decides what the UI can do with it.
|
||||
interface SlackChannelRef {
|
||||
id: string;
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
const channelRefEquals = (
|
||||
a: SlackChannelRef | null,
|
||||
b: SlackChannelRef | null,
|
||||
) => a?.id === b?.id && a?.name === b?.name;
|
||||
|
||||
/**
|
||||
* Slack's own reason, when the string is one: the connection check reports a
|
||||
* reason and its own prose in the same field, and only a reason is an answer
|
||||
* from Slack about the credential.
|
||||
*/
|
||||
const asReasonCode = (reason: string | null): string | null =>
|
||||
reason && SLACK_REASON_TOKEN.test(reason) ? reason : null;
|
||||
|
||||
interface SlackIntegrationManagerProps {
|
||||
/** At most one exists per tenant (one workspace). */
|
||||
integration: IntegrationProps | null;
|
||||
authorizeUrl: string | null;
|
||||
/** This deployment has no Prowler Slack app, so no install can be started. */
|
||||
unavailable: boolean;
|
||||
rateLimitMessage: string | null;
|
||||
loadError: string | null;
|
||||
}
|
||||
|
||||
export const SlackIntegrationManager = ({
|
||||
integration,
|
||||
authorizeUrl,
|
||||
unavailable,
|
||||
rateLimitMessage,
|
||||
loadError,
|
||||
}: SlackIntegrationManagerProps) => {
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [isDisconnectOpen, setIsDisconnectOpen] = useState(false);
|
||||
const [isDisconnecting, setIsDisconnecting] = useState(false);
|
||||
// The row is gone the moment the API says so; the server component's
|
||||
// revalidation only catches up on the next navigation.
|
||||
const [disconnected, setDisconnected] = useState(false);
|
||||
const [unconfirmedRevocation, setUnconfirmedRevocation] =
|
||||
useState<UnconfirmedRevocation | null>(null);
|
||||
/**
|
||||
* The `code` of the last refusal any Slack-backed call ran into, or `null`
|
||||
* when the last answer was not a refusal. A dead grant can surface from any
|
||||
* of them (contract, Cross-cutting), so every call reports here instead of
|
||||
* deciding on its own.
|
||||
*/
|
||||
const [lastRefusalCode, setLastRefusalCode] = useState<string | null>(null);
|
||||
// A connected workspace arrives with no consent URL, since no install is left
|
||||
// to start (design D10), so one is minted only if a reconnect turns out to be
|
||||
// the way out.
|
||||
const [mintedInstallUrl, setMintedInstallUrl] = useState<string | null>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
const integrationId = integration?.id ?? null;
|
||||
const recordedChannelId =
|
||||
integration?.attributes.configuration.channel_id ?? null;
|
||||
const recordedChannelName =
|
||||
integration?.attributes.configuration.channel_name ?? null;
|
||||
|
||||
const recordedChannel: SlackChannelRef | null = recordedChannelId
|
||||
? { id: recordedChannelId, name: recordedChannelName }
|
||||
: null;
|
||||
|
||||
// Seeded `loading`, not by the effect: the effect never runs on the server,
|
||||
// so anything else would server-render a "no channels" picker until
|
||||
// hydration.
|
||||
const [channelsState, setChannelsState] = useState<ChannelsState>(
|
||||
integrationId
|
||||
? { status: CHANNELS_STATUS.LOADING }
|
||||
: { status: CHANNELS_STATUS.LOADED, channels: [], notice: null },
|
||||
);
|
||||
// Bumped by refresh: a channel invited after load only shows on a re-read.
|
||||
const [channelReloads, setChannelReloads] = useState(0);
|
||||
// Local state needed: the pick is buffered until the user saves it.
|
||||
const [selectedChannelId, setSelectedChannelId] = useState<string | null>(
|
||||
recordedChannelId,
|
||||
);
|
||||
// Mirrored in state, not read from the prop, so channel-gated affordances
|
||||
// move on save instead of waiting for the revalidation.
|
||||
const [defaultChannel, setDefaultChannel] = useState(recordedChannel);
|
||||
// The prop the mirror was last taken from: the card never unmounts, so a
|
||||
// mirror seeded only at mount would go stale when the record changes.
|
||||
const [syncedChannel, setSyncedChannel] = useState(recordedChannel);
|
||||
const [isSavingChannel, setIsSavingChannel] = useState(false);
|
||||
const [testMessageState, setTestMessageState] = useState<TestMessageState>({
|
||||
status: TEST_MESSAGE_STATUS.IDLE,
|
||||
});
|
||||
|
||||
if (!channelRefEquals(recordedChannel, syncedChannel)) {
|
||||
const previousSyncedId = syncedChannel?.id ?? null;
|
||||
setSyncedChannel(recordedChannel);
|
||||
setDefaultChannel(recordedChannel);
|
||||
// Follow the record only while the buffered pick still matches it: an
|
||||
// unsaved pick is the user's, not ours to overwrite mid-edit.
|
||||
if (selectedChannelId === previousSyncedId) {
|
||||
setSelectedChannelId(recordedChannel?.id ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
// Only an answer from Slack moves the bus: a call that never got one proves
|
||||
// nothing and leaves the last answer standing.
|
||||
const provedCredentialAlive = () => setLastRefusalCode(null);
|
||||
|
||||
const recordRefusal = (code: string | null | undefined) => {
|
||||
if (code) setLastRefusalCode(code);
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the last refusal proves the grant itself is dead, rather than a
|
||||
* channel unreachable or Slack busy. Derived, not stored, so it self-clears:
|
||||
* a later call Slack answered at all (even to refuse a channel) is proof the
|
||||
* credential works again, and the notice goes with it.
|
||||
*/
|
||||
const credentialFailure: SlackTokenErrorCode | null = isSlackTokenErrorCode(
|
||||
lastRefusalCode,
|
||||
)
|
||||
? lastRefusalCode
|
||||
: null;
|
||||
|
||||
const needsInstallUrl = disconnected || credentialFailure !== null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!needsInstallUrl) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
getSlackAuthorizeUrl()
|
||||
.then((result) => {
|
||||
if (cancelled || !("authorizeUrl" in result)) return;
|
||||
setMintedInstallUrl(result.authorizeUrl);
|
||||
})
|
||||
.catch(() => {
|
||||
// Nothing to say: the page loses a shortcut, not a way to reconnect.
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [needsInstallUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!integrationId) return;
|
||||
|
||||
let cancelled = false;
|
||||
setChannelsState({ status: CHANNELS_STATUS.LOADING });
|
||||
|
||||
getSlackChannels(integrationId)
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
setChannelsState(
|
||||
"error" in result
|
||||
? { status: CHANNELS_STATUS.ERROR, message: result.error }
|
||||
: {
|
||||
status: CHANNELS_STATUS.LOADED,
|
||||
channels: result.channels,
|
||||
notice: result.incomplete ?? null,
|
||||
},
|
||||
);
|
||||
// The listing runs on arrival, so it is where a dead credential shows
|
||||
// up first. A read cut short still names its refusal's code, so a grant
|
||||
// that died on a later cursor page is heard too; a truncation naming
|
||||
// none was Slack busy, not refusing.
|
||||
if ("error" in result || result.code) recordRefusal(result.code);
|
||||
else provedCredentialAlive();
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setChannelsState({
|
||||
status: CHANNELS_STATUS.ERROR,
|
||||
message: "Could not reach Slack to read the channel list.",
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [integrationId, channelReloads]);
|
||||
|
||||
const channels =
|
||||
channelsState.status === CHANNELS_STATUS.LOADED
|
||||
? channelsState.channels
|
||||
: [];
|
||||
|
||||
const handleSaveChannel = async () => {
|
||||
if (!integrationId || !selectedChannelId) return;
|
||||
|
||||
setIsSavingChannel(true);
|
||||
try {
|
||||
// Only the id travels — the API validates it and derives the name
|
||||
// (design D6).
|
||||
const result = await setSlackDefaultChannel(
|
||||
integrationId,
|
||||
selectedChannelId,
|
||||
);
|
||||
|
||||
if ("error" in result) {
|
||||
// The API validates the channel against Slack, so the save can
|
||||
// discover the credential is gone.
|
||||
recordRefusal(result.code);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Could not save the destination channel",
|
||||
description: result.error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Prefer the API's derived name: a channel renamed in Slack since the
|
||||
// list was read would otherwise show its old name.
|
||||
const savedName =
|
||||
result.integration.attributes.configuration.channel_name ??
|
||||
channels.find((channel) => channel.id === selectedChannelId)?.name ??
|
||||
null;
|
||||
|
||||
provedCredentialAlive();
|
||||
setDefaultChannel({ id: selectedChannelId, name: savedName });
|
||||
// An outcome about the previous destination would mislead here.
|
||||
setTestMessageState({ status: TEST_MESSAGE_STATUS.IDLE });
|
||||
toast({
|
||||
title: "Destination channel saved",
|
||||
description: savedName
|
||||
? `Prowler will post to #${savedName}.`
|
||||
: "Prowler will post to the channel you chose.",
|
||||
});
|
||||
} catch (_error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Could not save the destination channel",
|
||||
description: "Something went wrong. Please try again.",
|
||||
});
|
||||
} finally {
|
||||
setIsSavingChannel(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendTestMessage = async () => {
|
||||
if (!integrationId) return;
|
||||
|
||||
setTestMessageState({ status: TEST_MESSAGE_STATUS.SENDING });
|
||||
try {
|
||||
const result = await sendSlackTestMessage(integrationId);
|
||||
|
||||
// The post happens in a task, so a credential that died since the last
|
||||
// check surfaces here.
|
||||
if ("error" in result) recordRefusal(result.code);
|
||||
else provedCredentialAlive();
|
||||
|
||||
setTestMessageState(
|
||||
"sent" in result
|
||||
? {
|
||||
status: TEST_MESSAGE_STATUS.SENT,
|
||||
detail: defaultChannel?.name
|
||||
? `Prowler posted a test message to #${defaultChannel.name}.`
|
||||
: "Prowler posted a test message to your default channel.",
|
||||
}
|
||||
: { status: TEST_MESSAGE_STATUS.FAILED, detail: result.error },
|
||||
);
|
||||
} catch (_error) {
|
||||
setTestMessageState({
|
||||
status: TEST_MESSAGE_STATUS.FAILED,
|
||||
detail: "Something went wrong. Please try again.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestConnection = async (id: string) => {
|
||||
setIsTesting(true);
|
||||
try {
|
||||
const result = await testIntegrationConnection(id);
|
||||
|
||||
if (result.success) {
|
||||
provedCredentialAlive();
|
||||
toast({
|
||||
title: "Connection test successful!",
|
||||
description:
|
||||
result.message || "Prowler can reach your Slack workspace.",
|
||||
});
|
||||
} else {
|
||||
// A dead credential named here is not a failure checking again can
|
||||
// fix, so the reason is recorded and not only reported.
|
||||
const reason = result.error?.trim() || null;
|
||||
|
||||
recordRefusal(asReasonCode(reason));
|
||||
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Connection test failed",
|
||||
description: reason
|
||||
? slackErrorMessage({ code: reason, detail: reason })
|
||||
: "Failed to reach your Slack workspace.",
|
||||
});
|
||||
}
|
||||
} catch (_error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: "Failed to test connection. Please try again.",
|
||||
});
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = async (id: string) => {
|
||||
const recordedWorkspace =
|
||||
integration?.attributes.configuration.team_name ?? null;
|
||||
const workspace = recordedWorkspace ?? "your Slack workspace";
|
||||
|
||||
setIsDisconnecting(true);
|
||||
try {
|
||||
const result = await disconnectSlackIntegration(id);
|
||||
|
||||
if ("error" in result) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Disconnect failed",
|
||||
description: result.error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { revoked } = result.revocation;
|
||||
|
||||
// The row is gone whatever Slack answered, so the page goes back to its
|
||||
// unconnected state either way, and a dead credential is moot once the
|
||||
// row it belonged to is gone.
|
||||
setDisconnected(true);
|
||||
setLastRefusalCode(null);
|
||||
// Only an explicit `false` sends the user to finish the job in Slack: an
|
||||
// unreported outcome is neither a failed revocation nor a confirmed one,
|
||||
// so it claims neither.
|
||||
setUnconfirmedRevocation(
|
||||
revoked === false ? { workspace: recordedWorkspace } : null,
|
||||
);
|
||||
|
||||
if (revoked !== false) {
|
||||
toast({
|
||||
title: "Slack workspace disconnected",
|
||||
description:
|
||||
revoked === true
|
||||
? `Prowler's access to ${workspace} has been revoked.`
|
||||
: `${workspace} is no longer connected to Prowler.`,
|
||||
});
|
||||
}
|
||||
} catch (_error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: "Failed to disconnect Slack. Please try again.",
|
||||
});
|
||||
} finally {
|
||||
setIsDisconnecting(false);
|
||||
setIsDisconnectOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const workspaceName = integration?.attributes.configuration.team_name;
|
||||
const installUrl = mintedInstallUrl ?? authorizeUrl;
|
||||
|
||||
const checkedAt = integration?.attributes.connection_last_checked_at;
|
||||
const checkedOn = checkedAt ? parseISO(checkedAt) : null;
|
||||
// `format` throws a RangeError on an unreadable value, which would replace
|
||||
// the page with the route's error boundary: show nothing instead, as for a
|
||||
// connection that was never checked.
|
||||
const lastCheckedOn =
|
||||
checkedOn && isValid(checkedOn) ? format(checkedOn, "yyyy/MM/dd") : null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{rateLimitMessage && (
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>Slack is busy right now</AlertTitle>
|
||||
<AlertDescription>{rateLimitMessage}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={isDisconnectOpen}
|
||||
onOpenChange={setIsDisconnectOpen}
|
||||
title="Disconnect Slack workspace"
|
||||
description={`Prowler will revoke its access at Slack and stop posting to ${workspaceName ?? "this workspace"}. Connecting again means approving Prowler in Slack.`}
|
||||
>
|
||||
<div className="flex w-full justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
disabled={isDisconnecting}
|
||||
onClick={() => setIsDisconnectOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="lg"
|
||||
disabled={isDisconnecting}
|
||||
onClick={() => integration && handleDisconnect(integration.id)}
|
||||
>
|
||||
{!isDisconnecting && <Unplug size={20} />}
|
||||
{isDisconnecting ? "Disconnecting..." : "Disconnect workspace"}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{loadError && (
|
||||
<Alert variant="error">
|
||||
<AlertTitle>Could not load your Slack integration</AlertTitle>
|
||||
<AlertDescription>{loadError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{unconfirmedRevocation && (
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>
|
||||
Slack disconnected — remove Prowler's access in Slack
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
The integration and the token Prowler had stored are gone from
|
||||
Prowler, so there is nothing to retry here. Slack did not confirm
|
||||
the revocation, so the Prowler app may still be installed in{" "}
|
||||
{unconfirmedRevocation.workspace ?? "the workspace"} — remove it
|
||||
from that workspace's Slack app settings.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{credentialFailure && (
|
||||
<Alert variant="error">
|
||||
<AlertTitle>
|
||||
Slack no longer accepts Prowler's access to{" "}
|
||||
{workspaceName ?? "this workspace"}
|
||||
</AlertTitle>
|
||||
{/* Each mapped sentence already ends in the thing that fixes it. */}
|
||||
<AlertDescription>
|
||||
{slackErrorMessage({ code: credentialFailure })} Until then, nothing
|
||||
Prowler sends will reach the workspace.
|
||||
</AlertDescription>
|
||||
{installUrl && (
|
||||
<div className="col-start-2 mt-3">
|
||||
<Button asChild size="sm">
|
||||
<a href={installUrl} rel="noopener noreferrer">
|
||||
<SlackIcon size={16} />
|
||||
Reconnect to Slack
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Replaces the cards, not the whole page: an early return here would
|
||||
swallow the rate-limit and load-error notices above. */}
|
||||
{unavailable ? (
|
||||
<Alert variant="info">
|
||||
<AlertTitle>
|
||||
Slack is not available in this environment yet
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
The Prowler Slack app is not configured here, so no workspace can be
|
||||
connected. Nothing to do on your side — this page starts working as
|
||||
soon as it is.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : integration && !disconnected ? (
|
||||
<Card variant="base">
|
||||
<CardHeader>
|
||||
<IntegrationCardHeader
|
||||
icon={<SlackIcon size={32} />}
|
||||
title={`Connected to ${workspaceName ?? "your Slack workspace"}`}
|
||||
subtitle="Prowler posts to this workspace only."
|
||||
connectionStatus={{
|
||||
// A dead token outranks the state the page was loaded with.
|
||||
connected:
|
||||
credentialFailure === null
|
||||
? integration.attributes.connected
|
||||
: false,
|
||||
}}
|
||||
/>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pt-0">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-300">
|
||||
{lastCheckedOn && (
|
||||
<p>
|
||||
<span className="font-medium">Last checked:</span>{" "}
|
||||
{lastCheckedOn}
|
||||
</p>
|
||||
)}
|
||||
{!defaultChannel && (
|
||||
<p>
|
||||
Choosing a destination channel is the next step — the
|
||||
connection is checked against it.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* The check posts to the destination channel: the API answers
|
||||
400 when none is recorded yet. */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isTesting || !defaultChannel}
|
||||
onClick={() => handleTestConnection(integration.id)}
|
||||
>
|
||||
<TestTube size={14} />
|
||||
{isTesting ? "Testing..." : "Test connection"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={isDisconnecting}
|
||||
onClick={() => setIsDisconnectOpen(true)}
|
||||
>
|
||||
<Unplug size={14} />
|
||||
Disconnect
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-border-neutral-secondary mt-6 flex flex-col gap-4 border-t pt-6">
|
||||
<SlackChannelSelector
|
||||
options={channels}
|
||||
value={selectedChannelId}
|
||||
onChange={setSelectedChannelId}
|
||||
isLoading={channelsState.status === CHANNELS_STATUS.LOADING}
|
||||
error={
|
||||
channelsState.status === CHANNELS_STATUS.ERROR
|
||||
? channelsState.message
|
||||
: null
|
||||
}
|
||||
incompleteNotice={
|
||||
channelsState.status === CHANNELS_STATUS.LOADED
|
||||
? channelsState.notice
|
||||
: null
|
||||
}
|
||||
onRefresh={() => setChannelReloads((reloads) => reloads + 1)}
|
||||
disabled={isSavingChannel}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-text-neutral-secondary text-xs">
|
||||
{/* The id decides, not the name: a missing name would deny
|
||||
a destination the test button posts to. */}
|
||||
{defaultChannel
|
||||
? defaultChannel.name
|
||||
? `Prowler posts to #${defaultChannel.name}.`
|
||||
: "Prowler posts to the channel you saved."
|
||||
: "No destination channel recorded yet."}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={
|
||||
!selectedChannelId ||
|
||||
selectedChannelId === (defaultChannel?.id ?? null) ||
|
||||
isSavingChannel
|
||||
}
|
||||
onClick={handleSaveChannel}
|
||||
>
|
||||
{isSavingChannel ? "Saving..." : "Save channel"}
|
||||
</Button>
|
||||
{defaultChannel && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={
|
||||
testMessageState.status === TEST_MESSAGE_STATUS.SENDING
|
||||
}
|
||||
onClick={handleSendTestMessage}
|
||||
>
|
||||
<Send size={14} />
|
||||
{testMessageState.status === TEST_MESSAGE_STATUS.SENDING
|
||||
? "Sending..."
|
||||
: "Send test message"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(testMessageState.status === TEST_MESSAGE_STATUS.SENT ||
|
||||
testMessageState.status === TEST_MESSAGE_STATUS.FAILED) && (
|
||||
<Alert
|
||||
variant={
|
||||
testMessageState.status === TEST_MESSAGE_STATUS.SENT
|
||||
? "success"
|
||||
: "error"
|
||||
}
|
||||
>
|
||||
<AlertTitle>
|
||||
{testMessageState.status === TEST_MESSAGE_STATUS.SENT
|
||||
? "Test message sent"
|
||||
: "Test message failed"}
|
||||
</AlertTitle>
|
||||
<AlertDescription>{testMessageState.detail}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card variant="base">
|
||||
<CardHeader>
|
||||
<IntegrationCardHeader
|
||||
icon={<SlackIcon size={32} />}
|
||||
title="No workspace connected"
|
||||
subtitle="Approve Prowler in Slack to connect a workspace. No tokens to copy."
|
||||
/>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pt-0">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Prowler asks for permission to post messages and to read the
|
||||
workspace's channel list.
|
||||
</p>
|
||||
{installUrl ? (
|
||||
<Button asChild>
|
||||
<a href={installUrl} rel="noopener noreferrer">
|
||||
<SlackIcon size={16} />
|
||||
Add to Slack
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Button disabled>
|
||||
<SlackIcon size={16} />
|
||||
Add to Slack
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Unit-tested because most of the codes in the mapping belong to flows this
|
||||
* layer does not have yet: the channel picker, the test message, the disconnect.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
isSlackTokenErrorCode,
|
||||
SLACK_ERROR_CODE,
|
||||
SLACK_ERROR_MESSAGES,
|
||||
SLACK_GENERIC_ERROR_MESSAGE,
|
||||
SLACK_RATE_LIMITED_MESSAGE,
|
||||
SLACK_REASON_TOKEN,
|
||||
SLACK_TOKEN_ERROR_CODES,
|
||||
readSlackFailure,
|
||||
slackErrorMessage,
|
||||
slackRateLimitMessage,
|
||||
slackUnknownReasonMessage,
|
||||
} from "./slack-errors";
|
||||
|
||||
describe("slackErrorMessage", () => {
|
||||
it("prefers the code's own copy over the API's wording", () => {
|
||||
// `detail` states the condition; the code's copy states the fix.
|
||||
const failure = {
|
||||
code: SLACK_ERROR_CODE.WORKSPACE_CONFLICT,
|
||||
detail:
|
||||
"This tenant is already connected to a different Slack workspace.",
|
||||
};
|
||||
|
||||
expect(slackErrorMessage(failure)).toBe(
|
||||
SLACK_ERROR_MESSAGES[SLACK_ERROR_CODE.WORKSPACE_CONFLICT],
|
||||
);
|
||||
expect(slackErrorMessage(failure)).toMatch(/Disconnect it/);
|
||||
});
|
||||
|
||||
it("tells the user how to grant a scope Prowler is missing", () => {
|
||||
// A missing scope is fixable by the reader, so the copy names the fix.
|
||||
const message = slackErrorMessage({
|
||||
code: SLACK_ERROR_CODE.MISSING_SCOPE,
|
||||
detail: "missing_scope",
|
||||
});
|
||||
|
||||
expect(message).toMatch(/Connect the workspace again/);
|
||||
expect(message).not.toMatch(/missing_scope/);
|
||||
});
|
||||
|
||||
it("says what to do about each channel refusal", () => {
|
||||
expect(
|
||||
slackErrorMessage({ code: SLACK_ERROR_CODE.CHANNEL_NOT_FOUND }),
|
||||
).toMatch(/Choose another one/);
|
||||
expect(
|
||||
slackErrorMessage({ code: SLACK_ERROR_CODE.NOT_IN_CHANNEL }),
|
||||
).toMatch(/Invite @Prowler/);
|
||||
expect(slackErrorMessage({ code: SLACK_ERROR_CODE.NO_PERMISSION })).toMatch(
|
||||
/Choose another channel/,
|
||||
);
|
||||
});
|
||||
|
||||
it("points every dead-credential code at reconnecting, not at retrying", () => {
|
||||
for (const code of SLACK_TOKEN_ERROR_CODES) {
|
||||
// Revoked, invalid, inactive or expired: no retry helps for any of them.
|
||||
expect(slackErrorMessage({ code })).toMatch(
|
||||
/Connect the workspace again to restore access/,
|
||||
);
|
||||
expect(isSlackTokenErrorCode(code)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not treat an actionable refusal as a dead credential", () => {
|
||||
expect(isSlackTokenErrorCode(SLACK_ERROR_CODE.MISSING_SCOPE)).toBe(false);
|
||||
expect(isSlackTokenErrorCode(null)).toBe(false);
|
||||
expect(isSlackTokenErrorCode(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to the API's detail for a code it does not know", () => {
|
||||
expect(
|
||||
slackErrorMessage({
|
||||
code: "some_future_slack_reason",
|
||||
detail: "Slack said no.",
|
||||
}),
|
||||
).toBe("Slack said no.");
|
||||
});
|
||||
|
||||
it("falls back to the generic line when there is neither", () => {
|
||||
expect(slackErrorMessage({ code: null, detail: null })).toBe(
|
||||
SLACK_GENERIC_ERROR_MESSAGE,
|
||||
);
|
||||
expect(slackErrorMessage(null)).toBe(SLACK_GENERIC_ERROR_MESSAGE);
|
||||
expect(
|
||||
slackErrorMessage({ detail: " " }, "Could not read channels."),
|
||||
).toBe("Could not read channels.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("slackUnknownReasonMessage", () => {
|
||||
/** A real Slack reason this UI has no copy of its own for. */
|
||||
const UNMAPPED_REASON = "is_archived";
|
||||
|
||||
it("keeps an unmapped reason diagnosable without letting it be the message", () => {
|
||||
const message = slackUnknownReasonMessage(UNMAPPED_REASON);
|
||||
|
||||
expect(message).toMatch(/Slack refused the message/);
|
||||
expect(message).toContain(UNMAPPED_REASON);
|
||||
expect(message).not.toBe(UNMAPPED_REASON);
|
||||
expect(message).toMatch(/Choose another channel/);
|
||||
});
|
||||
|
||||
it("is only reached for a code the mapping does not cover", () => {
|
||||
expect(
|
||||
slackErrorMessage(
|
||||
{ code: SLACK_ERROR_CODE.NOT_IN_CHANNEL },
|
||||
slackUnknownReasonMessage(SLACK_ERROR_CODE.NOT_IN_CHANNEL),
|
||||
),
|
||||
).toBe(SLACK_ERROR_MESSAGES[SLACK_ERROR_CODE.NOT_IN_CHANNEL]);
|
||||
|
||||
// No `detail`: one holding the same token would make the raw token the
|
||||
// whole message again.
|
||||
expect(
|
||||
slackErrorMessage(
|
||||
{ code: UNMAPPED_REASON },
|
||||
slackUnknownReasonMessage(UNMAPPED_REASON),
|
||||
),
|
||||
).toBe(slackUnknownReasonMessage(UNMAPPED_REASON));
|
||||
});
|
||||
});
|
||||
|
||||
describe("SLACK_REASON_TOKEN", () => {
|
||||
it("recognises a reason code and refuses anything that reads as a sentence", () => {
|
||||
// Slack publishes no closed set of reasons, so the guard is on shape rather
|
||||
// than an allowlist.
|
||||
for (const reason of [
|
||||
"is_archived",
|
||||
"restricted_action",
|
||||
"team_access_not_granted",
|
||||
"ekm_access_denied",
|
||||
"messages_tab_disabled",
|
||||
]) {
|
||||
expect(SLACK_REASON_TOKEN.test(reason)).toBe(true);
|
||||
}
|
||||
|
||||
for (const prose of [
|
||||
"Slack rejected the message: the channel is archived.",
|
||||
"). Contact support at +1-555-0100 (",
|
||||
"",
|
||||
"a".repeat(49),
|
||||
]) {
|
||||
expect(SLACK_REASON_TOKEN.test(prose)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("slackRateLimitMessage", () => {
|
||||
it("names the wait Slack asked for", () => {
|
||||
expect(slackRateLimitMessage(30)).toMatch(/about 30 seconds/);
|
||||
expect(slackRateLimitMessage(1)).toMatch(/about 1 second\b/);
|
||||
expect(slackRateLimitMessage(90)).toMatch(/about 2 minutes/);
|
||||
});
|
||||
|
||||
it("still says to come back when Slack named no wait", () => {
|
||||
expect(slackRateLimitMessage(null)).toBe(SLACK_RATE_LIMITED_MESSAGE);
|
||||
expect(slackRateLimitMessage(0)).toBe(SLACK_RATE_LIMITED_MESSAGE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readSlackFailure", () => {
|
||||
it("reads the code, the detail and the wait off a JSON:API refusal", async () => {
|
||||
const response = new Response(
|
||||
JSON.stringify({
|
||||
errors: [
|
||||
{
|
||||
status: "429",
|
||||
detail: "Slack is rate limiting requests from Prowler.",
|
||||
source: { pointer: "/data" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 429, headers: { "Retry-After": "30" } },
|
||||
);
|
||||
|
||||
const failure = await readSlackFailure(response);
|
||||
|
||||
expect(failure).toEqual({
|
||||
status: 429,
|
||||
code: null,
|
||||
detail: "Slack is rate limiting requests from Prowler.",
|
||||
retryAfterSeconds: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it("survives a body that is not JSON:API at all", async () => {
|
||||
const failure = await readSlackFailure(
|
||||
new Response("<html>Bad gateway</html>", { status: 502 }),
|
||||
);
|
||||
|
||||
expect(failure).toEqual({
|
||||
status: 502,
|
||||
code: null,
|
||||
detail: null,
|
||||
retryAfterSeconds: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores a Retry-After it cannot use", async () => {
|
||||
const failure = await readSlackFailure(
|
||||
new Response("{}", { status: 429, headers: { "Retry-After": "soon" } }),
|
||||
);
|
||||
|
||||
expect(failure.retryAfterSeconds).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
export const SLACK_ERROR_CODE = {
|
||||
MISSING_SCOPE: "missing_scope",
|
||||
CHANNEL_NOT_FOUND: "channel_not_found",
|
||||
NOT_IN_CHANNEL: "not_in_channel",
|
||||
NO_PERMISSION: "no_permission",
|
||||
TOKEN_REVOKED: "token_revoked",
|
||||
INVALID_AUTH: "invalid_auth",
|
||||
ACCOUNT_INACTIVE: "account_inactive",
|
||||
TOKEN_EXPIRED: "token_expired",
|
||||
/** One workspace per tenant. */
|
||||
WORKSPACE_CONFLICT: "slack_workspace_conflict",
|
||||
} as const;
|
||||
|
||||
export type SlackErrorCode =
|
||||
(typeof SLACK_ERROR_CODE)[keyof typeof SLACK_ERROR_CODE];
|
||||
|
||||
/**
|
||||
* The grant itself is dead: reconnecting is the only way out, not retrying. The
|
||||
* API answers these with `400`, not `401`, so they are not mistaken for an
|
||||
* expired Prowler session.
|
||||
*/
|
||||
export const SLACK_TOKEN_ERROR_CODES = [
|
||||
SLACK_ERROR_CODE.TOKEN_REVOKED,
|
||||
SLACK_ERROR_CODE.INVALID_AUTH,
|
||||
SLACK_ERROR_CODE.ACCOUNT_INACTIVE,
|
||||
SLACK_ERROR_CODE.TOKEN_EXPIRED,
|
||||
] as const;
|
||||
|
||||
export type SlackTokenErrorCode = (typeof SLACK_TOKEN_ERROR_CODES)[number];
|
||||
|
||||
export const isSlackTokenErrorCode = (
|
||||
code: string | null | undefined,
|
||||
): code is SlackTokenErrorCode =>
|
||||
SLACK_TOKEN_ERROR_CODES.includes(code as SlackTokenErrorCode);
|
||||
|
||||
export const SLACK_GENERIC_ERROR_MESSAGE =
|
||||
"Slack could not complete that request. Try again in a moment.";
|
||||
|
||||
export const SLACK_RATE_LIMITED_MESSAGE =
|
||||
"Slack is rate limiting Prowler right now. Try again in a few moments.";
|
||||
|
||||
/**
|
||||
* For a channel list that stopped short of the workspace: the page budget ran
|
||||
* out, or `links.next` left the API's origin.
|
||||
*/
|
||||
export const SLACK_PARTIAL_CHANNEL_LIST_MESSAGE =
|
||||
"This workspace has more channels than Prowler reads in one go, so this list is not all of them. A channel missing from it is not necessarily one @Prowler has to be invited to.";
|
||||
|
||||
/**
|
||||
* For a `2xx` the UI could not read. Not phrased as a failure: the install
|
||||
* happened, only the workspace cannot be named.
|
||||
*/
|
||||
export const SLACK_UNREADABLE_RESULT_MESSAGE =
|
||||
"Prowler could not read the result of the install. Open the Slack integration page to see the workspace — if none is listed there, start the install again.";
|
||||
|
||||
/**
|
||||
* The shape of a Slack reason code, as opposed to a sentence: the set is
|
||||
* open-ended, so a reason is gated on its shape before being interpolated.
|
||||
*/
|
||||
export const SLACK_REASON_TOKEN = /^[a-z0-9_]{1,48}$/;
|
||||
|
||||
/**
|
||||
* Copy for a reason code Prowler has no wording of its own for — the ordinary
|
||||
* case, since the set is open-ended.
|
||||
*/
|
||||
export const slackUnknownReasonMessage = (reason: string): string =>
|
||||
`Slack refused the message (${reason}). Choose another channel, or check the channel in Slack.`;
|
||||
|
||||
const RECONNECT = "Connect the workspace again to restore access.";
|
||||
|
||||
export const SLACK_ERROR_MESSAGES = {
|
||||
[SLACK_ERROR_CODE.MISSING_SCOPE]:
|
||||
"Prowler is missing a permission it needs in Slack. Connect the workspace again and approve the access Prowler asks for.",
|
||||
[SLACK_ERROR_CODE.CHANNEL_NOT_FOUND]:
|
||||
"That channel no longer exists in the workspace. Choose another one.",
|
||||
[SLACK_ERROR_CODE.NOT_IN_CHANNEL]:
|
||||
"Prowler is not in that channel. Invite @Prowler to it in Slack, or choose a channel it can already post to.",
|
||||
[SLACK_ERROR_CODE.NO_PERMISSION]:
|
||||
"Slack did not allow Prowler to post there. Choose another channel, or ask a workspace admin to allow it.",
|
||||
[SLACK_ERROR_CODE.TOKEN_REVOKED]: `Prowler's access to Slack was revoked. ${RECONNECT}`,
|
||||
[SLACK_ERROR_CODE.INVALID_AUTH]: `Slack no longer accepts Prowler's credential. ${RECONNECT}`,
|
||||
[SLACK_ERROR_CODE.ACCOUNT_INACTIVE]: `The Slack account Prowler was installed with is no longer active. ${RECONNECT}`,
|
||||
[SLACK_ERROR_CODE.TOKEN_EXPIRED]: `Prowler's Slack credential has expired. ${RECONNECT}`,
|
||||
[SLACK_ERROR_CODE.WORKSPACE_CONFLICT]:
|
||||
"Prowler is already connected to a different Slack workspace. Disconnect it before connecting another one.",
|
||||
} as const satisfies Record<SlackErrorCode, string>;
|
||||
|
||||
/** The parts of a JSON:API error this mapping reads. */
|
||||
export interface SlackErrorSource {
|
||||
code?: string | null;
|
||||
detail?: string | null;
|
||||
}
|
||||
|
||||
export interface SlackApiFailure extends SlackErrorSource {
|
||||
status: number;
|
||||
retryAfterSeconds: number | null;
|
||||
}
|
||||
|
||||
const isKnownCode = (code: string | null | undefined): code is SlackErrorCode =>
|
||||
typeof code === "string" &&
|
||||
Object.prototype.hasOwnProperty.call(SLACK_ERROR_MESSAGES, code);
|
||||
|
||||
/**
|
||||
* Copy for a refusal: Prowler's wording for a known `code`, else the API's
|
||||
* `detail`, else `fallback`.
|
||||
*/
|
||||
export const slackErrorMessage = (
|
||||
error: SlackErrorSource | null | undefined,
|
||||
fallback: string = SLACK_GENERIC_ERROR_MESSAGE,
|
||||
): string => {
|
||||
if (isKnownCode(error?.code)) return SLACK_ERROR_MESSAGES[error.code];
|
||||
return error?.detail?.trim() || fallback;
|
||||
};
|
||||
|
||||
const describeWait = (seconds: number): string => {
|
||||
if (seconds < 60) return `${seconds} second${seconds === 1 ? "" : "s"}`;
|
||||
const minutes = Math.ceil(seconds / 60);
|
||||
return `${minutes} minute${minutes === 1 ? "" : "s"}`;
|
||||
};
|
||||
|
||||
export const slackRateLimitMessage = (
|
||||
retryAfterSeconds: number | null,
|
||||
): string => {
|
||||
if (retryAfterSeconds === null || retryAfterSeconds <= 0) {
|
||||
return SLACK_RATE_LIMITED_MESSAGE;
|
||||
}
|
||||
return `Slack is rate limiting Prowler right now. Try again in about ${describeWait(
|
||||
Math.ceil(retryAfterSeconds),
|
||||
)}.`;
|
||||
};
|
||||
|
||||
const retryAfterFrom = (response: Response): number | null => {
|
||||
const header = response.headers.get("retry-after");
|
||||
if (!header) return null;
|
||||
const seconds = Number(header.trim());
|
||||
return Number.isFinite(seconds) && seconds > 0 ? seconds : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Read a non-OK Slack response into the failure it describes. Never throws: a
|
||||
* body that is not JSON:API still yields a failure carrying the status.
|
||||
*/
|
||||
export const readSlackFailure = async (
|
||||
response: Response,
|
||||
): Promise<SlackApiFailure> => {
|
||||
const body = await response.json().catch(() => null);
|
||||
const error = Array.isArray(body?.errors) ? body.errors[0] : null;
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
code: typeof error?.code === "string" ? error.code : null,
|
||||
detail: typeof error?.detail === "string" ? error.detail : null,
|
||||
retryAfterSeconds: retryAfterFrom(response),
|
||||
};
|
||||
};
|
||||
@@ -2,7 +2,15 @@ import { z } from "zod";
|
||||
|
||||
import type { TaskState } from "@/types/tasks";
|
||||
|
||||
export type IntegrationType = "amazon_s3" | "aws_security_hub" | "jira";
|
||||
export const INTEGRATION_TYPE = {
|
||||
AMAZON_S3: "amazon_s3",
|
||||
AWS_SECURITY_HUB: "aws_security_hub",
|
||||
JIRA: "jira",
|
||||
SLACK: "slack",
|
||||
} as const;
|
||||
|
||||
export type IntegrationType =
|
||||
(typeof INTEGRATION_TYPE)[keyof typeof INTEGRATION_TYPE];
|
||||
|
||||
export const JIRA_DISPATCH_MODE = {
|
||||
INDIVIDUAL: "individual",
|
||||
@@ -68,7 +76,10 @@ export interface IntegrationProps {
|
||||
inserted_at: string;
|
||||
updated_at: string;
|
||||
enabled: boolean;
|
||||
connected: boolean;
|
||||
// `null` until a connection check has run: never verified, neither working
|
||||
// nor broken. A Slack install starts here, and returns here on a channel
|
||||
// change.
|
||||
connected: boolean | null;
|
||||
connection_last_checked_at: string | null;
|
||||
integration_type: IntegrationType;
|
||||
configuration: {
|
||||
@@ -87,6 +98,13 @@ export interface IntegrationProps {
|
||||
domain?: string;
|
||||
projects?: { [key: string]: string };
|
||||
issue_types?: { [key: string]: string[] };
|
||||
// Slack specific configuration, server-owned. The channel keys are absent
|
||||
// until one is chosen, not present and null: read them with `?? null`.
|
||||
team_id?: string;
|
||||
team_name?: string;
|
||||
bot_user_id?: string;
|
||||
channel_id?: string;
|
||||
channel_name?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
url?: string;
|
||||
@@ -95,6 +113,16 @@ export interface IntegrationProps {
|
||||
links: { self: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* A channel Prowler can post to: every active public channel, plus the private
|
||||
* ones `@Prowler` was invited to. `is_private` keeps the API's own naming.
|
||||
*/
|
||||
export interface SlackChannelOption {
|
||||
id: string;
|
||||
name: string;
|
||||
is_private: boolean;
|
||||
}
|
||||
|
||||
// Jira dispatch types
|
||||
export interface JiraDispatchRequest {
|
||||
data: {
|
||||
|
||||
+30
-8
@@ -1,9 +1,34 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import react, { type BabelOptions } from "@vitejs/plugin-react";
|
||||
import { playwright } from "@vitest/browser-playwright";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import type { TestProjectConfiguration } from "vitest/config";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
/**
|
||||
* Next runs the React Compiler on the client compilation only — its
|
||||
* `getReactCompilerPlugins` returns nothing when `isServer` — so a Server
|
||||
* Component ships uncompiled. Mirror that: compiled, it calls `useMemoCache`
|
||||
* on the active dispatcher, which a harness invoking the component as a
|
||||
* function has none of, and `react/compiler-runtime` reads the client
|
||||
* internals the `react-server` build does not export anyway.
|
||||
*/
|
||||
const isServerModule = (id: string): boolean => {
|
||||
const file = id.split("?")[0];
|
||||
if (!file.includes("/app/")) return false;
|
||||
try {
|
||||
return !/^\s*["']use client["']/.test(fs.readFileSync(file, "utf8"));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const reactCompilerBabel = (id: string): BabelOptions => ({
|
||||
plugins: isServerModule(id)
|
||||
? []
|
||||
: [["babel-plugin-react-compiler", { target: "19" }]],
|
||||
});
|
||||
|
||||
export default defineConfig(() => {
|
||||
const apiBaseUrl = process.env.UI_API_BASE_URL ?? "http://localhost/api/v1";
|
||||
|
||||
@@ -57,13 +82,7 @@ export default defineConfig(() => {
|
||||
},
|
||||
{
|
||||
extends: true,
|
||||
plugins: [
|
||||
react({
|
||||
babel: {
|
||||
plugins: [["babel-plugin-react-compiler", { target: "19" }]],
|
||||
},
|
||||
}),
|
||||
],
|
||||
plugins: [react({ babel: reactCompilerBabel })],
|
||||
test: {
|
||||
name: "integration",
|
||||
setupFiles: ["./vitest.integration.setup.ts"],
|
||||
@@ -109,6 +128,9 @@ export default defineConfig(() => {
|
||||
// React runtime (pre-bundle so a cold run doesn't re-optimize and
|
||||
// reload mid-test — see the on-demand-reload note above).
|
||||
"react-dom/client",
|
||||
// What the compiler's output imports. `@vitejs/plugin-react` adds it
|
||||
// itself only when `babel` is a plain object, and ours is a function.
|
||||
"react/compiler-runtime",
|
||||
|
||||
// Next runtime
|
||||
"next/headers",
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Fail when a pinned or locked package version has been yanked from PyPI.
|
||||
|
||||
Exact pins (`==`) still install a yanked release: pip and uv both accept a yanked
|
||||
version when it is the only candidate an exact specifier allows, printing at most a
|
||||
warning. That is how zstd 1.5.7.3 (yanked as "buggy - not thread safe") stayed in
|
||||
uv.lock for months. Yanks happen on PyPI's side after the pin lands, so this check
|
||||
must run on a schedule, not only on pull requests.
|
||||
|
||||
For each project directory given (default: current directory) the script collects:
|
||||
|
||||
- exact `==` pins from pyproject.toml: [project] dependencies and optional
|
||||
dependencies, [dependency-groups], and [tool.uv] constraint-dependencies and
|
||||
override-dependencies
|
||||
- every registry-sourced package in uv.lock
|
||||
|
||||
and asks the PyPI JSON API whether each (name, version) is yanked or gone.
|
||||
|
||||
Usage:
|
||||
python util/check_yanked_pins.py # checks ./pyproject.toml and ./uv.lock
|
||||
python util/check_yanked_pins.py . api mcp_server
|
||||
|
||||
Exit status is 1 when any pin is yanked or no longer exists on PyPI, 0 otherwise.
|
||||
Network errors are retried; a persistent error also exits 1, because "unknown"
|
||||
must not read as "clean".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from time import sleep
|
||||
from typing import Callable, Iterable
|
||||
|
||||
try:
|
||||
import tomllib
|
||||
except ModuleNotFoundError: # Python 3.10: tomllib arrived in 3.11
|
||||
import tomli as tomllib
|
||||
|
||||
PYPI_JSON = "https://pypi.org/pypi/{name}/{version}/json"
|
||||
USER_AGENT = "prowler-check-yanked-pins (+https://github.com/prowler-cloud/prowler)"
|
||||
|
||||
# PEP 508 requirement with an exact pin: "name[extras]==version ; markers"
|
||||
_EXACT_PIN = re.compile(
|
||||
r"^\s*(?P<name>[A-Za-z0-9][A-Za-z0-9._-]*)\s*(\[[^\]]*\])?\s*==\s*(?P<version>[^\s;,]+)"
|
||||
)
|
||||
|
||||
|
||||
def normalize(name: str) -> str:
|
||||
"""PEP 503 name normalization: alibabacloud_tea_openapi == alibabacloud-tea-openapi."""
|
||||
return re.sub(r"[-_.]+", "-", name).lower()
|
||||
|
||||
|
||||
@dataclass(frozen=True, order=True)
|
||||
class Pin:
|
||||
"""One exact version requirement and the file/table it was read from."""
|
||||
|
||||
name: str
|
||||
version: str
|
||||
source: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Verdict:
|
||||
"""PyPI's answer for one pin: ok, yanked, missing (404) or error (unreachable)."""
|
||||
|
||||
pin: Pin
|
||||
status: str # "ok" | "yanked" | "missing" | "error"
|
||||
detail: str = ""
|
||||
|
||||
|
||||
def pins_from_pyproject(text: str, source_prefix: str) -> set[Pin]:
|
||||
"""Collect exact == pins from every dependency-bearing table in a pyproject.toml."""
|
||||
data = tomllib.loads(text)
|
||||
tables: list[tuple[str, Iterable[str]]] = []
|
||||
|
||||
project = data.get("project", {})
|
||||
tables.append(("project.dependencies", project.get("dependencies", [])))
|
||||
for extra, reqs in project.get("optional-dependencies", {}).items():
|
||||
tables.append((f"project.optional-dependencies.{extra}", reqs))
|
||||
for group, reqs in data.get("dependency-groups", {}).items():
|
||||
# dependency-groups entries may be tables ({include-group = ...}); keep strings only
|
||||
tables.append(
|
||||
(f"dependency-groups.{group}", [r for r in reqs if isinstance(r, str)])
|
||||
)
|
||||
uv = data.get("tool", {}).get("uv", {})
|
||||
tables.append(
|
||||
("tool.uv.constraint-dependencies", uv.get("constraint-dependencies", []))
|
||||
)
|
||||
tables.append(
|
||||
("tool.uv.override-dependencies", uv.get("override-dependencies", []))
|
||||
)
|
||||
|
||||
pins: set[Pin] = set()
|
||||
for table, requirements in tables:
|
||||
for requirement in requirements:
|
||||
match = _EXACT_PIN.match(requirement)
|
||||
if match:
|
||||
pins.add(
|
||||
Pin(
|
||||
normalize(match.group("name")),
|
||||
match.group("version"),
|
||||
f"{source_prefix}pyproject.toml [{table}]",
|
||||
)
|
||||
)
|
||||
return pins
|
||||
|
||||
|
||||
def pins_from_uv_lock(text: str, source_prefix: str) -> set[Pin]:
|
||||
"""Collect every registry-sourced (name, version) from a uv.lock."""
|
||||
data = tomllib.loads(text)
|
||||
pins: set[Pin] = set()
|
||||
for package in data.get("package", []):
|
||||
source = package.get("source", {})
|
||||
# git, path, editable and virtual sources are not on PyPI; skip them
|
||||
if "registry" not in source:
|
||||
continue
|
||||
pins.add(
|
||||
Pin(
|
||||
normalize(package["name"]),
|
||||
package["version"],
|
||||
f"{source_prefix}uv.lock",
|
||||
)
|
||||
)
|
||||
return pins
|
||||
|
||||
|
||||
def collect_pins(project_dir: Path) -> set[Pin]:
|
||||
"""Gather pins from a project's pyproject.toml and uv.lock, whichever exist."""
|
||||
prefix = "" if project_dir == Path(".") else f"{project_dir.as_posix()}/"
|
||||
pins: set[Pin] = set()
|
||||
pyproject = project_dir / "pyproject.toml"
|
||||
lock = project_dir / "uv.lock"
|
||||
if not pyproject.is_file() and not lock.is_file():
|
||||
raise FileNotFoundError(
|
||||
f"{project_dir}: neither pyproject.toml nor uv.lock found"
|
||||
)
|
||||
if pyproject.is_file():
|
||||
pins |= pins_from_pyproject(pyproject.read_text(encoding="utf-8"), prefix)
|
||||
if lock.is_file():
|
||||
pins |= pins_from_uv_lock(lock.read_text(encoding="utf-8"), prefix)
|
||||
return pins
|
||||
|
||||
|
||||
def fetch_release(name: str, version: str, retries: int = 3) -> tuple[str, str]:
|
||||
"""Return (status, detail) for one release, where status is ok|yanked|missing|error."""
|
||||
request = urllib.request.Request(
|
||||
PYPI_JSON.format(name=name, version=version), headers={"User-Agent": USER_AGENT}
|
||||
)
|
||||
last_error = ""
|
||||
for attempt in range(retries):
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=20) as response:
|
||||
info = json.load(response)["info"]
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 404:
|
||||
return "missing", "not found on PyPI"
|
||||
last_error = f"HTTP {exc.code}"
|
||||
except (
|
||||
urllib.error.URLError,
|
||||
TimeoutError,
|
||||
OSError,
|
||||
ValueError,
|
||||
KeyError,
|
||||
) as exc:
|
||||
last_error = repr(exc)
|
||||
else:
|
||||
if info.get("yanked"):
|
||||
return "yanked", info.get("yanked_reason") or "no reason given"
|
||||
return "ok", ""
|
||||
sleep(2**attempt)
|
||||
return "error", last_error
|
||||
|
||||
|
||||
def evaluate(
|
||||
pins: Iterable[Pin],
|
||||
fetch: Callable[[str, str], tuple[str, str]] | None = None,
|
||||
workers: int = 16,
|
||||
) -> list[Verdict]:
|
||||
"""Query each distinct (name, version) once and fan the answer out to every source."""
|
||||
if fetch is None:
|
||||
fetch = fetch_release
|
||||
pins = sorted(set(pins))
|
||||
releases = sorted({(pin.name, pin.version) for pin in pins})
|
||||
with ThreadPoolExecutor(max_workers=workers) as pool:
|
||||
results = dict(
|
||||
zip(
|
||||
releases,
|
||||
pool.map(lambda release: fetch(*release), releases),
|
||||
strict=True,
|
||||
)
|
||||
)
|
||||
return [Verdict(pin, *results[(pin.name, pin.version)]) for pin in pins]
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Check every project on the command line; return 1 if any pin is not ok."""
|
||||
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
||||
parser.add_argument(
|
||||
"projects",
|
||||
nargs="*",
|
||||
default=["."],
|
||||
help="project directories containing pyproject.toml and/or uv.lock (default: .)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--workers", type=int, default=16, help="concurrent PyPI requests"
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
pins: set[Pin] = set()
|
||||
for project in args.projects:
|
||||
pins |= collect_pins(Path(project))
|
||||
print(
|
||||
f"Checking {len({(p.name, p.version) for p in pins})} pinned releases from {len(pins)} pins"
|
||||
)
|
||||
|
||||
verdicts = evaluate(pins, workers=args.workers)
|
||||
problems = [v for v in verdicts if v.status != "ok"]
|
||||
for verdict in problems:
|
||||
pin = verdict.pin
|
||||
print(
|
||||
f"::error::{pin.name}=={pin.version} is {verdict.status} ({verdict.detail}) in {pin.source}"
|
||||
)
|
||||
if problems:
|
||||
print(f"{len(problems)} problem(s) found")
|
||||
return 1
|
||||
print("No yanked or missing releases")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -39,7 +39,7 @@ constraints = [
|
||||
{ name = "alibabacloud-sas20181203", specifier = "==6.1.0" },
|
||||
{ name = "alibabacloud-sts20150401", specifier = "==1.1.6" },
|
||||
{ name = "alibabacloud-tea", specifier = "==0.4.3" },
|
||||
{ name = "alibabacloud-tea-openapi", specifier = "==0.4.5" },
|
||||
{ name = "alibabacloud-tea-openapi", specifier = "==0.4.6" },
|
||||
{ name = "alibabacloud-tea-util", specifier = "==0.3.14" },
|
||||
{ name = "alibabacloud-tea-xml", specifier = "==0.0.3" },
|
||||
{ name = "alibabacloud-vpc20160428", specifier = "==6.13.0" },
|
||||
@@ -140,7 +140,7 @@ constraints = [
|
||||
{ name = "mock", specifier = "==5.2.0" },
|
||||
{ name = "moto", specifier = "==5.1.11" },
|
||||
{ name = "mpmath", specifier = "==1.3.0" },
|
||||
{ name = "msal", specifier = "==1.36.0" },
|
||||
{ name = "msal", specifier = "==1.37.0" },
|
||||
{ name = "msal-extensions", specifier = "==1.3.1" },
|
||||
{ name = "msgraph-core", specifier = "==1.3.8" },
|
||||
{ name = "msrest", specifier = "==0.7.1" },
|
||||
@@ -183,7 +183,7 @@ constraints = [
|
||||
{ name = "pyjwt", specifier = "==2.13.0" },
|
||||
{ name = "pylint", specifier = "==3.3.4" },
|
||||
{ name = "pynacl", specifier = "==1.6.2" },
|
||||
{ name = "pyopenssl", specifier = "==26.2.0" },
|
||||
{ name = "pyopenssl", specifier = "==26.4.0" },
|
||||
{ name = "pyparsing", specifier = "==3.3.2" },
|
||||
{ name = "pytest", specifier = "==9.0.3" },
|
||||
{ name = "pytest-cov", specifier = "==6.0.0" },
|
||||
@@ -227,12 +227,9 @@ constraints = [
|
||||
{ name = "xmltodict", specifier = "==1.0.4" },
|
||||
{ name = "yarl", specifier = "==1.23.0" },
|
||||
{ name = "zipp", specifier = "==3.23.1" },
|
||||
{ name = "zstd", specifier = "==1.5.7.3" },
|
||||
]
|
||||
overrides = [
|
||||
{ name = "cryptography", specifier = "==50.0.0" },
|
||||
{ name = "okta", specifier = "==3.4.2" },
|
||||
{ name = "zstd", specifier = "==1.5.7.2" },
|
||||
]
|
||||
overrides = [{ name = "okta", specifier = "==3.4.2" }]
|
||||
|
||||
[[package]]
|
||||
name = "about-time"
|
||||
@@ -686,7 +683,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/9a/7d/b22cb9a0d4f396ee0
|
||||
|
||||
[[package]]
|
||||
name = "alibabacloud-tea-openapi"
|
||||
version = "0.4.5"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "alibabacloud-credentials" },
|
||||
@@ -695,9 +692,9 @@ dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "darabonba-core" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3b/73/fb0c4d44759791ecdf269fc715c1e810fa1aba3981bfaaf8a01f61899296/alibabacloud_tea_openapi-0.4.5.tar.gz", hash = "sha256:75fa1f4360a46e41f5bf5f8d4917e52efb6f64885839bc1328c35590670c97b9", size = 26616, upload-time = "2026-07-14T13:15:39.364Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ab/34/1918a2d780676494365c7f945bfab397ecddb988054d78025bd26f438977/alibabacloud_tea_openapi-0.4.6.tar.gz", hash = "sha256:dafc32401712f5b21c12dc3d05ba887a91ad156d9b49a7662279f9fd90526fb2", size = 26742, upload-time = "2026-08-17T08:34:11.55Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/ec/6b368a10e9c2e8b1b394c69b96ac213ae66e8c4895e0baa1ffaf7178fd32/alibabacloud_tea_openapi-0.4.5-py3-none-any.whl", hash = "sha256:338979095c7beda80a5b413c31262892cafdc12069dde4ce4fc2e4f7ce0fc609", size = 33333, upload-time = "2026-07-14T13:15:38.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/00/2f534f5884e5f299d9cb3a1e8be2def8071bc6a6e2a192ba4ff2a8cd5e02/alibabacloud_tea_openapi-0.4.6-py3-none-any.whl", hash = "sha256:c9e1727b9fb2936f487d050fc3590c99f9f2065256dc3a927e5b61f414674ed6", size = 33448, upload-time = "2026-08-17T08:34:10.472Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3045,16 +3042,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "msal"
|
||||
version = "1.36.0"
|
||||
version = "1.37.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "pyjwt", extra = ["crypto"] },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/de/cb/b02b0f748ac668922364ccb3c3bff5b71628a05f5adfec2ba2a5c3031483/msal-1.36.0.tar.gz", hash = "sha256:3f6a4af2b036b476a4215111c4297b4e6e236ed186cd804faefba23e4990978b", size = 174217, upload-time = "2026-04-09T10:20:33.525Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9a/99/d840198ecf6e8057bbc937f129ae940404485d736cda73253bbff9537f01/msal-1.37.0.tar.gz", hash = "sha256:1b1672a33ee467c1d70b341bb16cafd51bb3c817147a95b93263794b03971bec", size = 182444, upload-time = "2026-05-29T19:49:05.561Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/d3/414d1f0a5f6f4fe5313c2b002c54e78a3332970feb3f5fed14237aa17064/msal-1.36.0-py3-none-any.whl", hash = "sha256:36ecac30e2ff4322d956029aabce3c82301c29f0acb1ad89b94edcabb0e58ec4", size = 121547, upload-time = "2026-04-09T10:20:32.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/b0/d807279f4b55d16d1f120d5ac4344c6e39b56732e2a224d40bded7fd67ad/msal-1.37.0-py3-none-any.whl", hash = "sha256:dd17e95a7c71bce75e8108113438ba7c4a086b3bcad4f57a8c09b7af3d753c2d", size = 123725, upload-time = "2026-05-29T19:49:04.335Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3337,7 +3334,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "oci"
|
||||
version = "2.183.0"
|
||||
version = "2.184.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
@@ -3350,9 +3347,9 @@ dependencies = [
|
||||
{ name = "pytz" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1e/2a/77bd6cbf1c69b2f368fe3d6462d84369b0cba15e37ce713cdc08d459b95a/oci-2.183.0.tar.gz", hash = "sha256:ff572ef5f2030a788796bb509d257e6a41c6510ef9b4b6a75a079efd06e533ce", size = 17759723, upload-time = "2026-07-28T06:02:29.76Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/74/2d/fa5368cfabb868f4111c6978e8b5f66aa3a55076c40c1a59ac3081b0227b/oci-2.184.1.tar.gz", hash = "sha256:617dad69caf8dd6e521d224dbc3e8a8bc289906943a0214fd2c3419094e26435", size = 17990631, upload-time = "2026-08-11T11:01:26.194Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/de/8574b3e527996a099d196e87794a4652d91a0c3185fcc7fdbb5649b75a8a/oci-2.183.0-py3-none-any.whl", hash = "sha256:bd789c98a94d7c5ea08c20d11dcf68c9cd1ad479b134727d80a930b84387070b", size = 36133501, upload-time = "2026-07-28T06:02:18.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/63/5ae22e42aaf96a5da74dc2b9de449c78b4d7418cce621d5da723b3e49f32/oci-2.184.1-py3-none-any.whl", hash = "sha256:bd814e38a70da2190e721937455a08689ab13c0750bd2ef8dd0c98b2dc5a38ea", size = 36628063, upload-time = "2026-08-11T11:01:18.178Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3889,7 +3886,7 @@ requires-dist = [
|
||||
{ name = "alibabacloud-sas20181203", specifier = "==6.1.0" },
|
||||
{ name = "alibabacloud-sls20201230", specifier = "==5.9.0" },
|
||||
{ name = "alibabacloud-sts20150401", specifier = "==1.1.6" },
|
||||
{ name = "alibabacloud-tea-openapi", specifier = "==0.4.5" },
|
||||
{ name = "alibabacloud-tea-openapi", specifier = "==0.4.6" },
|
||||
{ name = "alibabacloud-vpc20160428", specifier = "==6.13.0" },
|
||||
{ name = "alive-progress", specifier = "==3.3.0" },
|
||||
{ name = "azure-identity", specifier = "==1.21.0" },
|
||||
@@ -3950,7 +3947,7 @@ requires-dist = [
|
||||
{ name = "microsoft-kiota-abstractions", specifier = "==1.9.10" },
|
||||
{ name = "msgraph-sdk", specifier = "==1.55.0" },
|
||||
{ name = "numpy", specifier = "==2.2.6" },
|
||||
{ name = "oci", specifier = "==2.183.0" },
|
||||
{ name = "oci", specifier = "==2.184.1" },
|
||||
{ name = "okta", specifier = "==3.4.2" },
|
||||
{ name = "openstacksdk", specifier = "==4.2.0" },
|
||||
{ name = "pandas", specifier = "==2.2.3" },
|
||||
@@ -4399,15 +4396,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pyopenssl"
|
||||
version = "26.2.0"
|
||||
version = "26.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1a/51/27a5ad5f939d08f690a326ef9582cda7140555180db71695f6fb747d6a36/pyopenssl-26.2.0.tar.gz", hash = "sha256:8c6fcecd1183a7fc897548dfe388b0cdb7f37e018200d8409cf33959dbe35387", size = 182195, upload-time = "2026-05-04T23:06:09.72Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3f/e8/7325d258199b159eb2c03fe32107533e2832e70e63f4fb88a6aa00023201/pyopenssl-26.4.0.tar.gz", hash = "sha256:28dfcce0162b9211413e26dfbfdf1d24317fbeba18fc93c12400a1856b2a0bc7", size = 182046, upload-time = "2026-08-01T19:50:50.512Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/73/b8/a0e2790ae249d6f38c9f66de7a211621a7ab2650217bcd04e1262f578a56/pyopenssl-26.2.0-py3-none-any.whl", hash = "sha256:4f9d971bc5298b8bc1fab282803da04bf000c755d4ad9d99b52de2569ca19a70", size = 55823, upload-time = "2026-05-04T23:06:08.395Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/ad/2cf6d3fa2fae5c79e1ed9960c0d42badd0f94d81dd12b50604cdc839e648/pyopenssl-26.4.0-py3-none-any.whl", hash = "sha256:f0eb0cb2d581d3ad2b9c489468485e7f2ab6727d08401bcf9d824c3caddf3c1c", size = 56026, upload-time = "2026-08-01T19:50:48.94Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5520,71 +5517,49 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "1.5.7.3"
|
||||
version = "1.5.7.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/62/b9c075ad664e7c4cbb3d8d2be7c246506abe1bc7f778eb58d260ef9538c8/zstd-1.5.7.3.tar.gz", hash = "sha256:403e5205f4ac04b92e6b0cda654be2f51de268228a0db0067bc087faacf2f495", size = 672559, upload-time = "2026-01-08T16:24:43.361Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0f/78/9a476e09c825304df47b98be80d1ffe223733b03550af71325415028f615/zstd-1.5.7.2.tar.gz", hash = "sha256:6d8684c69009be49e1b18ec251a5eb0d7e24f93624990a8a124a1da66a92fc8a", size = 670481, upload-time = "2025-06-23T12:36:08.131Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/54/95fe3f714a4a0c2befc1f5734deb2706c635481feff4e5497ace0f307fef/zstd-1.5.7.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:76f3535616887a1a38e8c6d0de693a23c5bb1f190651eb20d96bfc8e4ab706a0", size = 267642, upload-time = "2026-01-08T16:46:57.829Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/af/e88d733bf7dac8bcb0f90e90f9ea2163909e873e37aaf90617e7e5ed34d8/zstd-1.5.7.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:67507937e8e4c2a8dfed8e7fa77f4043ec9e6e831a5faebf0f99138b1a25ccbd", size = 230964, upload-time = "2026-01-08T16:46:59.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/7a/9e8b541b5799bb699e70d6f0c4fa5a0607c9229634209e9662f4a6a8a6ce/zstd-1.5.7.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:bd0a2309c524608ce7b940abcc9f8eb5447c6ea2c834a630e0081211ab9d40ec", size = 1540287, upload-time = "2026-01-08T17:39:27.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/1e/d0fe5f8e860c39f50831889c499fbf91a5bfdb8adcd148d35f1f7a3e7ea7/zstd-1.5.7.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:2b497306580d544406b5414c8485c4037a9283ad2ca6ae4ccdf3732c9563141d", size = 1619041, upload-time = "2026-01-08T17:39:23.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/bd/b9b381edad8cfdca944cd15025932c6b0edacc5164ed67b603b4d8a38f84/zstd-1.5.7.3-cp310-cp310-manylinux_2_4_i686.whl", hash = "sha256:e9939a98ea946d1f9e8f9fecc940ae939b8e9e5ef9d71b104f7843567d764f30", size = 300166, upload-time = "2026-01-10T11:12:23.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/a8/9b6f65a3bd7fb54148bceadf9a5a9a869b64454c9e15b6f1362160594785/zstd-1.5.7.3-cp310-cp310-manylinux_2_4_x86_64.whl", hash = "sha256:d32c0fe8f6b805b7cbeaade462b094a843e84d893d8c6f66ab705e8777cc1850", size = 304165, upload-time = "2026-01-10T11:22:59.825Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/22/2fb52f1d288bb5e8176108a4bdbc25484fc05cc902cdf5c99cf604aba979/zstd-1.5.7.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:8aa33b1ef24602b2ef1e8aa67ea3c8f821854a4dbf70c3c8c46b96b54b6ceb5d", size = 1525903, upload-time = "2026-01-08T17:39:26.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/be/26451e696c2cc5f604eb872408a4e0ddc64478e45928897c755b2ab0330c/zstd-1.5.7.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1bd69fa9c4c97fd04206c919dedbf9f75f544ebb77880db51a13c1e3802cd655", size = 2095723, upload-time = "2026-01-08T17:39:30.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/1a/43ee13d01e367eb5bb2dead554e2fb3931e4f2d4a45a7642601e44b138b1/zstd-1.5.7.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:aee96742a64ede2e35dc0316ef0cd1e50089e889ce77e82ca8edf40174a1439c", size = 2132397, upload-time = "2026-01-08T17:39:24.739Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/d7/5497d54dadb172ee148820aff1551cb189344522c207bc83f073f8a85a59/zstd-1.5.7.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5ac207573d2815a51f4f4fd4e255408396491729a01f690b9f5fb672d39e5610", size = 2124660, upload-time = "2026-01-08T17:39:29.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/89/665c5fac2da24c129ef403f65c2d8e9b08142b97b02ab7daa3a44cddeca7/zstd-1.5.7.3-cp310-cp310-win32.whl", hash = "sha256:04e62e4f9eba79699d072d3c96731ed4aff99f1d334eb967489b091186a6078f", size = 150362, upload-time = "2026-01-08T17:09:29.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/2e/cae4878efd693ddfb712577eee4ac37dd4c0fe757054c4ee3479530b416f/zstd-1.5.7.3-cp310-cp310-win_amd64.whl", hash = "sha256:0794b23b9950af240888087d2bd5943aa4be67273ba32cdafabdc5704778b90e", size = 167580, upload-time = "2026-01-08T17:09:30.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/f9/9908234f86aafb48edd5fe630b41488c3acd8a4edbfbc921a7a6db1ab8b4/zstd-1.5.7.3-cp310-cp310-win_arm64.whl", hash = "sha256:7827fd4901f3e71a7a755d26719549658f08e04fdf0870a952ed08e71b484435", size = 157239, upload-time = "2026-01-08T16:43:01.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/0d/8c89c0d010b58c21a7865a239790bb1c6822029c053b1ded858d6b573e3a/zstd-1.5.7.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a3c1781a24e2ced2c0ddee11d45b1f04018b03615eeb622a62eca4d56d3358a", size = 267641, upload-time = "2026-01-08T16:30:50.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/6d/155d8c344d96eca2a5a003a5ddd63373a5f13591fd5cf2b9490250d6805a/zstd-1.5.7.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a6c7c81056362b60a04baa34632e713d596662a860ec34efd8e9b109c10e6ec7", size = 230962, upload-time = "2026-01-08T16:30:49.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/c7/ab93916a26eb58cd501ad701974c31b4bc67a7f6abd6c24bef8fe4d7649b/zstd-1.5.7.3-cp311-cp311-manylinux_2_14_x86_64.whl", hash = "sha256:e564f34a55effc7d654eb293468edc80b64d476b0f899f82760ecd8323223ff5", size = 304166, upload-time = "2026-01-10T11:17:45.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/54/27a7040a360019a4602343e3c98c0c0a140f382186002c01e1992fd21837/zstd-1.5.7.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:fbc49a57188184931d5e3c9f1133cad7eea5a370a9e9418fb8122d58c14340a5", size = 1540288, upload-time = "2026-01-08T17:50:26.913Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/93/4a4d4edd1b2e809e0ebbb16000404bdcc9a09743c04ee1661442c9581b75/zstd-1.5.7.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:d121d3e63722819e1fe5effbcd9628d8a7cfea0cddabcc5bb37ea861a6a83424", size = 1619134, upload-time = "2026-01-08T17:50:32.324Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/6b/cd6f0a7f4f0d98e4110aa77763cf3e85f594d983ea9ca3d64cc0cee10684/zstd-1.5.7.3-cp311-cp311-manylinux_2_4_i686.whl", hash = "sha256:621f2e7ca8e9eb52a83eb9c91ec3cd283d87591bf75cc658de486b65f44742c7", size = 300166, upload-time = "2026-01-10T11:12:27.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/3f/c717e0d15127d04b7fa58ba9b4c56e8b88b803048b9766cd9d158dbb22ea/zstd-1.5.7.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:c1950fcae690ba32d0f31702b335c548fb42547821565925e48576afdad774a5", size = 1525776, upload-time = "2026-01-08T17:50:35.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/a2/1813cd787d1a2f9ab8e8a90d28dcbc8e8098997dd04de38897ea8e75dd08/zstd-1.5.7.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bac4f0d03da69115878bedbfa03c4a3f64364e8396b432028c4ce0f05141a0fb", size = 2096057, upload-time = "2026-01-08T17:50:33.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/ce/f5a3c7c12de458dd9ce15c484d627fe5412b60c155da23dacb5fcf08d9d5/zstd-1.5.7.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:da0ab134b7fd28023dedf013751ca850de300a090eb11f689d2a1c178c87d9dc", size = 2132659, upload-time = "2026-01-08T17:50:29.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/66/151f9546498bfd8971a0b6ad67d87c26d7a0df17d57f724da674f3778666/zstd-1.5.7.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b9923175842ee8f7602ec9cc578f5fc396896f0e8460d3ac9a5adc3cea77244e", size = 2124811, upload-time = "2026-01-08T17:50:37.612Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/34/4d2dbb36cb2373d3f115c047cb901b64f89de0703d10779da39de9453812/zstd-1.5.7.3-cp311-cp311-win32.whl", hash = "sha256:0612b604948d7b58aecc6788c7ceb53c5f21d94a155bb6ea9bd0f54ffa43725d", size = 150363, upload-time = "2026-01-08T17:11:02.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/de/f53687e0dd8c0d0ebfaed9ae88f6a96a1a0388ae7424b469e74bb17ac57d/zstd-1.5.7.3-cp311-cp311-win_amd64.whl", hash = "sha256:5b7f8c81b2bd3b62c0345242247d484cafa4b518d59d18619813d9225af5c5c3", size = 167577, upload-time = "2026-01-08T17:11:03.356Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/58/d4a6a902e229e953ed273fe9b78587ed31f57567aa68d3e34af6056e42af/zstd-1.5.7.3-cp311-cp311-win_arm64.whl", hash = "sha256:ea112e3acd9e1765adca35df7b54ac75b36194290f64ea03a3a59664209c8527", size = 157238, upload-time = "2026-01-08T16:36:06.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/ed/5a3bf2e29dc56d4cc7619929bb51f0c758de6d02967cc73c5d8755a862c0/zstd-1.5.7.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:01a39efb0eeab7cc45cb308618233b624b0840d5e16dcf85456b6cca0592f203", size = 268124, upload-time = "2026-01-08T16:29:57.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/1d/efc2074ac90af938e78f2ed4004639fe24f294d9086c5280f8d9a02b9897/zstd-1.5.7.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7a8e8838cf35fa3987bfe1958584cc22e1797efce8e155a63544b4144fc671f8", size = 230988, upload-time = "2026-01-08T16:29:55.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/52/178393b8d70e23fba67f42dfce4663e4e8a30867110168beb490a36d4639/zstd-1.5.7.3-cp312-cp312-manylinux_2_14_i686.whl", hash = "sha256:f3920ac1d1cc7e9f252f3e29f217fe3cd36f2191bb3dbcae826c29e189b7ad54", size = 300207, upload-time = "2026-01-10T11:26:58.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/7a/8dcd86a2efb2ed3f9dae39545a05d3c7ed26c7678330786ce4a44cd8b099/zstd-1.5.7.3-cp312-cp312-manylinux_2_14_x86_64.whl", hash = "sha256:143f9062953fb5590cbd47c1040d357336742c79696bf90b6d5b835279a68304", size = 304154, upload-time = "2026-01-10T11:17:40.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/ce/0c96905ab01ffe0e53a3cec8132123b82db26bd583a71608029bcc789ebc/zstd-1.5.7.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36d1fd8647e47e1f21b345e192f1a279e925678c23dad8236b547d04456cd699", size = 2162222, upload-time = "2026-01-08T18:02:22.762Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/c4/db4807d6a68b4628c74fd379de7e3c67ec34f19a2a80ac246b3837cde6cb/zstd-1.5.7.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1538db419afa62773cf534fc7f3009ff59ecf55ecee4e889587ac2ef0010ed8", size = 2201732, upload-time = "2026-01-08T18:02:20.835Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/99/c19a3c0f5580ff9c33a74f06d98d6060ed1fa6bd09b55aed9be852ec191f/zstd-1.5.7.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5efd16adb092e2a547a7d51cfdaf6fd5680528227684c5bafc7669ab4a55f41", size = 2096459, upload-time = "2026-01-08T18:02:25.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/fd/02eac30419475dbe50212c119043a2d0698a0cbc756da85fd3fd9abddf42/zstd-1.5.7.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:39b3438e64637d80a5b1860526903b92020acb9bae9ceb5adffd9838c1441328", size = 2125442, upload-time = "2026-01-08T18:02:17.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/43/3a16ff0a8c913bb9825379db1bd533c75c57c2d2f31dd9111aa9b53711f4/zstd-1.5.7.3-cp312-cp312-win32.whl", hash = "sha256:cbf48c53461e224ffc2490cfe5120a1ff40d14c84d2b512c6d6d99fc91685cf3", size = 150367, upload-time = "2026-01-08T17:03:40.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/83/b85875d7428e63dfa9247e41d17fac611443c774f7892f8643bd4164a6b2/zstd-1.5.7.3-cp312-cp312-win_amd64.whl", hash = "sha256:943a189910f2fea997462e3e4d7fbf727a06d231ef801ebee557b1c87568981c", size = 167604, upload-time = "2026-01-08T17:03:41.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/42/cf291e26804de2f55500cdac93f5e9fa6267cf315def8aa402529bae3a87/zstd-1.5.7.3-cp312-cp312-win_arm64.whl", hash = "sha256:85c4d508f8109afa7c51c4960626c3325af2cf1e442c6c36ebfea15d04757e3f", size = 157241, upload-time = "2026-01-08T16:47:34.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/7c/f2fe6e09b9d064873ebd384f2692b9fcad3d8e9412298dfb09a935aec77d/zstd-1.5.7.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b2455e56f1d265dacbd450510b8c2f632a5d8d92c23282e7723fb04af37001a2", size = 268133, upload-time = "2026-01-08T17:31:54.616Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/8d/0a1e49844ed82c7ab0f66dce5e0dd822742fc7e9d04f147032db33740840/zstd-1.5.7.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3486dc4f1b4e52bb059f8eec1f31daa3e540062c0f522f221782cf132a8bc9a8", size = 231005, upload-time = "2026-01-08T17:31:55.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/24/0ab682096da2411f83236a10c89423f26859a65431660e460e2d637b5628/zstd-1.5.7.3-cp313-cp313-manylinux_2_14_i686.whl", hash = "sha256:1cb47bf10ffcb6a782edacfe758da2c94879f7e89c6628feb3f1254daf8cc596", size = 300230, upload-time = "2026-01-08T16:30:52.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/2e/3ff0d28ea8d6b9bd931af7477fad082b99633cb7901cdd657dcb7ecfee11/zstd-1.5.7.3-cp313-cp313-manylinux_2_14_x86_64.whl", hash = "sha256:07b1378d1230ddeea8773f99d7518a3060e6468c76edd502057cb795fe278d7e", size = 297097, upload-time = "2026-01-08T16:49:41.211Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/ef/25c15570fb6b06a4a03bd054afa2d084df687ac10e336b703139acc77182/zstd-1.5.7.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ee34317f013e3405108f5baea53502159809cfc4510598d614257525500c70d", size = 2162274, upload-time = "2026-01-08T17:35:24.464Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/24/fd16ba9e9be877a2194f05462ae77dcb62c8f90c4ecc186d9ee71e9bdc9f/zstd-1.5.7.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c19127ca2c79855376a34a2d7a6969408094b25c1f44485b0373eba4be851b98", size = 2201877, upload-time = "2026-01-08T17:35:28.596Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/ef/73c37a81ac36429bb1bbb69c8ac43f3a154cfab739dce6424d28f95301c3/zstd-1.5.7.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e79cae70dd08cb247391312463085c624c0302e8c860d13f87f4c76502d8202", size = 2096535, upload-time = "2026-01-08T17:35:30.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/1f/859a8049634e444feb6855347d5e558c1280d87b0bc6385f13cd3d95dbe6/zstd-1.5.7.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0e83e91e5daf89037c737f5529da0f80da80a78a6ad0b1d70a09860eb267dea4", size = 2125473, upload-time = "2026-01-08T17:35:26.596Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/5c/e7b8aa8eea46f032891ecef187f1d469e20f9ddf0d10607e9523b3d306a1/zstd-1.5.7.3-cp313-cp313-win32.whl", hash = "sha256:2283f3bb910c028e1b9fe76b834016012ab021025a0ea197e27a1333f85e3031", size = 150370, upload-time = "2026-01-08T17:18:12.899Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/2b/ba558ff87ba7f6c29e3a9b1c3b3e95338146aee7250b10807229d412a9c4/zstd-1.5.7.3-cp313-cp313-win_amd64.whl", hash = "sha256:3ad5fe4c36bab5dfa5a4b8d050bd07c50c1e69f94d381bc65337ab14cd69e5b1", size = 167602, upload-time = "2026-01-08T17:18:13.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/7d/4a5c9813fafad2949d42deee3857d7ecc8caf369bbf82a88b60519200083/zstd-1.5.7.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e878172b0eb69ac2edc6576eb862e00747c7c25e638fb354630a1ea7cfddf49", size = 157239, upload-time = "2026-01-08T16:42:28.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/67/5fcec6bbf8aab4aeb26e3cbe8cc9fa2f323dd143d066e313b134b8c28dc8/zstd-1.5.7.3-cp313-cp313t-manylinux_2_14_x86_64.whl", hash = "sha256:7e0a7e94d5b63b4cacf2396079ca9584d11f49f87cb4e5aa21f126a8f6b83446", size = 297302, upload-time = "2026-01-08T16:36:49.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/ad/f6588943c9fde34a28f1b0448a8ac824b2ebe341f7af56ff035d0489338d/zstd-1.5.7.3-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:2b9ec4d5ba8c170d3fdf21ae5da3c15eaea2beef9c419a5f3274a6f9e03c412a", size = 260091, upload-time = "2026-01-08T17:14:18.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/6a/6d2d3b9b7bad0124c684b7b77621ee6bdc3fc220a580f002014cf0a8f558/zstd-1.5.7.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:a7ab69fc4d90eeb64b98a567751f8e48373f4bcf301597fca344b8e8342e1d5e", size = 221149, upload-time = "2026-01-08T17:14:17.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/8d/ad4d3c24293c70d8ae9c80e06b2da2922048933f9a00f35d18df5166346a/zstd-1.5.7.3-pp310-pypy310_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da70f0918bf739bc75d7770410c9b94ea0dcb6f02d7ef70598b464bd5fcb193a", size = 326792, upload-time = "2026-01-08T17:12:35.38Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/37/98c3dd075935b5a7a1806837db343c1a37e6726c53db93c27aa4d7e5e86c/zstd-1.5.7.3-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3dd5c069d0409284f1963b0b6b119f21b1da9e22a503e88933eb0696249d87d3", size = 322283, upload-time = "2026-01-08T17:12:32.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/96/20fd30bd330529b4ad8420f4ba9030b80b971499be75d37c39306cdeb038/zstd-1.5.7.3-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46ca4a075f36f118e2ce07ba07d9ece7aeda193cea6f50b82aaee635df7b5fc2", size = 311551, upload-time = "2026-01-08T17:12:33.683Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/b5/001cc10b6a221e4e5fb4ec19ff49d6dcc028f97a5ce53a8ef5cee67ac409/zstd-1.5.7.3-pp310-pypy310_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:4a521cb7615fc61bfe9514bea182e224894b5987fc7843b6d6da20a61206ef24", size = 317071, upload-time = "2026-01-08T16:30:35.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/81/75bdf0e4515c74094adff7e5119f4d50bc9af20359b78c04f8f6cac3f59c/zstd-1.5.7.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:71ea22c953a164f34eb4b8c2c3b97eaa22da6a75296ea80b3ba4473187f15046", size = 167655, upload-time = "2026-01-08T16:55:06.572Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/b8/d13d584867d5eb1bc607877a870858e02a256d4706a4274e475413a000aa/zstd-1.5.7.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:76c49ea969bc08389ea59155cea7c5dea224522ffc62f443f3c0a915f5fd184d", size = 260025, upload-time = "2026-01-08T16:57:45.739Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/a1/1e5faf75bedfd2bfccfb83e18736b115bed6e348504bd21800cd8f30dcea/zstd-1.5.7.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:6b1a638ff3dfce8f4cb1203c662fb5606dd99b4a62c5ddc4c406d2d1326bcfdd", size = 221038, upload-time = "2026-01-08T17:16:32.005Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/2c/0fe74d8b2029eef8000bc71aac5b3e5b55d00581238711cf627814183ea3/zstd-1.5.7.3-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5e96a5cb100a0edc162935227f2d9784b1031ce4a8a83e96e66eae2673c10143", size = 326792, upload-time = "2026-01-08T16:57:35.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/e0/2c7f081f3524f872128ff31bea2acb6b21cb1dacccef920eb6a1a77a87c6/zstd-1.5.7.3-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bda0bbf3a9553720cd33f1f85940a259656c7ffba4be717ff82b7f062052188", size = 322283, upload-time = "2026-01-08T16:57:36.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/a7/3bebfcc18d66b90bc7b506a61b2ff4af5ee1b0b16e784ea644afa06241c5/zstd-1.5.7.3-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac36e4022422f6e49b3f07bdbb8a964fd348223d3dc9c82ad5398a4f0432a719", size = 311553, upload-time = "2026-01-08T16:57:38.465Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/75/8a791cae2c98e5e44a158e15db50d21b7ec0b37aeaffa68d151bc8ffb6d6/zstd-1.5.7.3-pp311-pypy311_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:fa4d760a220541b18ce732a3a2cf7547ea05afc76d05b3b39edebfeb721f6079", size = 317071, upload-time = "2026-01-08T16:36:07.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/25/b6624e6b08d515242154436c9d06fb20b790d300ac82e84f3c4c133e25e1/zstd-1.5.7.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a69e60146bf8aaa6a0e6c9a94a7c5f3133d68091e2e5c5a3c5ababf71fd5ec7a", size = 167654, upload-time = "2026-01-08T17:00:56.667Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/76/825a002361bcfb4444d8ff0bd5c75d60e449158c5a9cd3b884971b3ecd1e/zstd-1.5.7.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d3f14c5c405ea353b68fe105236780494eb67c756ecd346fd295498f5eab6d24", size = 269695, upload-time = "2025-06-23T12:54:29.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/0a/a8c936edc431217186085276a37eba8e52c9bd4cd3025b38403baa2466a4/zstd-1.5.7.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07d2061df22a3efc06453089e6e8b96e58f5bb7a0c4074dcfd0b0ce243ddde72", size = 228243, upload-time = "2025-06-23T12:54:30.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/24/e81d1561ab3e32be2370de82e13d3c50b68a9fed6977b4d7d596d3ddd1b9/zstd-1.5.7.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:27e55aa2043ba7d8a08aba0978c652d4d5857338a8188aa84522569f3586c7bb", size = 1536535, upload-time = "2025-06-23T13:53:22.123Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/9d/60d956dc3f457620997906bc4c220fad12b2ad1a3a5e2224d3b5dbf0a28e/zstd-1.5.7.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:8e97933addfd71ea9608306f18dc18e7d2a5e64212ba2bb9a4ccb6d714f9f280", size = 1616160, upload-time = "2025-06-23T13:53:16.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/6a/49fc94a39f44994c5db20259d44a849e558af5232072580a7614cdb2058d/zstd-1.5.7.2-cp310-cp310-manylinux_2_4_i686.whl", hash = "sha256:27e2ed58b64001c9ef0a8e028625477f1a6ed4ca949412ff6548544945cc59c2", size = 322186, upload-time = "2025-06-23T12:41:36.574Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/20/e1e06a7f39c7eb27a1fe1c0281970c840fcde539a2f8ad99bb3155dbf3ad/zstd-1.5.7.2-cp310-cp310-manylinux_2_4_x86_64.whl", hash = "sha256:92f072819fc0c7e8445f51a232c9ad76642027c069d2f36470cdb5e663839cdb", size = 302736, upload-time = "2025-06-23T13:05:04.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/c0/86bb2d8e556062edf663f8d08c315418fefb80cae7c786cf39957e10455f/zstd-1.5.7.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:2a653cdd2c52d60c28e519d44bde8d759f2c1837f0ff8e8e1b0045ca62fcf70e", size = 1522689, upload-time = "2025-06-23T13:53:17.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/24/60a125d82d64b4d2a823f490904d8b5861117771237e34bb02e2cc311572/zstd-1.5.7.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:047803d87d910f4905f48d99aeff1e0539ec2e4f4bf17d077701b5d0b2392a95", size = 2098532, upload-time = "2025-06-23T13:53:11.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/eb/3274ea05a788bbdb90e3de90bfa27dc9113ee0114011d28bfad6d9fd34d7/zstd-1.5.7.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0d8c1dc947e5ccea3bd81043080213685faf1d43886c27c51851fabf325f05c0", size = 2112079, upload-time = "2025-06-23T13:53:19.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/c6/fa6898d55f8313e9649e2853ea3fede8b7301a5a1c40d8aa920252c31a52/zstd-1.5.7.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8291d393321fac30604c6bbf40067103fee315aa476647a5eaecf877ee53496f", size = 2109450, upload-time = "2025-06-23T13:53:13.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/56/4180cd24fdc468f4f0beae3d4f5e8690a16995a561b1926dfdde223ecc3d/zstd-1.5.7.2-cp310-cp310-win32.whl", hash = "sha256:6922ceac5f2d60bb57a7875168c8aa442477b83e8951f2206cf1e9be788b0a6e", size = 149448, upload-time = "2025-06-23T13:09:43.678Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/55/3b315dc894b9726c16e5d58f48a618e6e2670e93c0eacc03fd30330444ee/zstd-1.5.7.2-cp310-cp310-win_amd64.whl", hash = "sha256:346d1e4774d89a77d67fc70d53964bfca57c0abecfd885a4e00f87fd7c71e074", size = 166591, upload-time = "2025-06-23T13:09:44.85Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/2a/0885f6f1921ec1ef4a8f8ab29ab0a335cc867abe4c7aaa4e5031435a32a5/zstd-1.5.7.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f799c1e9900ad77e7a3d994b9b5146d7cfd1cbd1b61c3db53a697bf21ffcc57b", size = 269702, upload-time = "2025-06-23T12:50:11.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/e6/629cf6b77e47fc7149f5724fb4853c48edcdeb10d8c64e391d7026cb10e1/zstd-1.5.7.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1ff4c667f29101566a7b71f06bbd677a63192818396003354131f586383db042", size = 228145, upload-time = "2025-06-23T12:50:10.411Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/b8/9ddefd4670bfe9328ca6657ad335eb8d9c657466247e234a579818b6b0b9/zstd-1.5.7.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:8526a32fa9f67b07fd09e62474e345f8ca1daf3e37a41137643d45bd1bc90773", size = 1536530, upload-time = "2025-06-23T13:51:38.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/6a/1bb836c18760dc1e28ca7a9706016e482ebdea633b980d8505dbb65e18f8/zstd-1.5.7.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:2cec2472760d48a7a3445beaba509d3f7850e200fed65db15a1a66e315baec6a", size = 1616141, upload-time = "2025-06-23T13:51:34.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/7a/bb6c6e2cb2a066e347dc27d45d5205058b69d6c8b8d4ae2ee7d6b91c64a5/zstd-1.5.7.2-cp311-cp311-manylinux_2_4_i686.whl", hash = "sha256:a200c479ee1bb661bc45518e016a1fdc215a1d8f7e4bf6c7de0af254976cfdf6", size = 322188, upload-time = "2025-06-23T13:01:48.704Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/4f/cf0669c8a89fdcc91814bf92bd05cc363d5d12a79b656418c0add6f2d266/zstd-1.5.7.2-cp311-cp311-manylinux_2_4_x86_64.whl", hash = "sha256:f5d159e57a13147aa8293c0f14803a75e9039fd8afdf6cf1c8c2289fb4d2333a", size = 302736, upload-time = "2025-06-23T13:05:33.649Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/bc/e5f8b7f61826323e39e099db1eb5c0e09b18315df1b1ff778f7ae9aadcac/zstd-1.5.7.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:7206934a2bd390080e972a1fed5a897e184dfd71dbb54e978dc11c6b295e1806", size = 1522687, upload-time = "2025-06-23T13:51:35.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/8c/7660a949a020ac9d02b3166a25dd1c12144572d77b11ae92a31d341016da/zstd-1.5.7.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7e0027b20f296d1c9a8e85b8436834cf46560240a29d623aa8eaa8911832eb58", size = 2098794, upload-time = "2025-06-23T13:51:37.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/b2/730c811a78d670104d40c7f08cc8092577cdff870cba42b3158f20fceb57/zstd-1.5.7.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d6b17e5581dd1a13437079bd62838d2635db8eb8aca9c0e9251faa5d4d40a6d7", size = 2112266, upload-time = "2025-06-23T13:51:31.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/74/2c16e1632094db36c8920d4c13b8e2e843024d548ae26888c2d22af6a676/zstd-1.5.7.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b13285c99cc710f60dd270785ec75233018870a1831f5655d862745470a0ca29", size = 2109465, upload-time = "2025-06-23T13:51:32.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/6e/b9c9a834769d96cab2122da1be8c8c700d3f76be796d2b7516e85d2eca0e/zstd-1.5.7.2-cp311-cp311-win32.whl", hash = "sha256:cdb5ec80da299f63f8aeccec0bff3247e96252d4c8442876363ff1b438d8049b", size = 149448, upload-time = "2025-06-23T13:06:21.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/b7/fc22ad6292a32d7676ab815de3a23573beac3679e8abd9914288d1496ceb/zstd-1.5.7.2-cp311-cp311-win_amd64.whl", hash = "sha256:4f6861c8edceb25fda37cdaf422fc5f15dcc88ced37c6a5b3c9011eda51aa218", size = 166592, upload-time = "2025-06-23T13:06:22.126Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/14/096bb77f3e5ef525b452cd6294da33de7f8a8c9647ba78293378fbb0a7ce/zstd-1.5.7.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d2ebe3e60dbace52525fa7aa604479e231dc3e4fcc76d0b4c54d8abce5e58734", size = 269408, upload-time = "2025-06-23T13:11:46.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/b8/2bc2590a34c733ea0570f366e6ad7d889d05c7825bd3ccab01f36ece71c6/zstd-1.5.7.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ef201b6f7d3a6751d85cc52f9e6198d4d870e83d490172016b64a6dd654a9583", size = 228188, upload-time = "2025-06-23T13:11:47.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/80/6252de3a70cfd7767718ad476893f1c7dc129f942cc7ed0322e3137c03d9/zstd-1.5.7.2-cp312-cp312-manylinux_2_14_x86_64.whl", hash = "sha256:ac7bdfedda51b1fcdcf0ab69267d01256fc97ddf666ce894fde0fae9f3630eac", size = 302720, upload-time = "2025-06-23T12:40:11.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/b6/af908387814b99172d3aea6aeb24b19583aadfa45f6021e5e2a0d6d8e99a/zstd-1.5.7.2-cp312-cp312-manylinux_2_4_i686.whl", hash = "sha256:b835405cc4080b378e45029f2fe500e408d1eaedfba7dd7402aba27af16955f9", size = 322237, upload-time = "2025-06-23T13:17:35.482Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/d7/ab9142e002a7eaa451cb4bb37a74c390c489ba8ae75ade543840496eda04/zstd-1.5.7.2-cp312-cp312-win32.whl", hash = "sha256:e4cf97bb97ed6dbb62d139d68fd42fa1af51fd26fd178c501f7b62040e897c50", size = 149453, upload-time = "2025-06-23T13:13:02.786Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/c7/c182ea7bc283f591e3f3c5f0f239e7a92c9bc1f626642ae2c4dfbe51d6f2/zstd-1.5.7.2-cp312-cp312-win_amd64.whl", hash = "sha256:55e2edc4560a5cf8ee9908595e90a15b1f47536ea9aad4b2889f0e6165890a38", size = 166628, upload-time = "2025-06-23T13:13:03.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/63/0d392a8ec2231dee9fc2290faea7a6642584686720d6b77899ad8b12e35a/zstd-1.5.7.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6e684e27064b6550aa2e7dc85d171ea1b62cb5930a2c99b3df9b30bf620b5c06", size = 269438, upload-time = "2025-06-23T12:57:52.507Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/1f/85aae095f92811bed3d2944bbed971fe07ec1dd2d82c9eb1395d69d2123c/zstd-1.5.7.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fd6262788a98807d6b2befd065d127db177c1cd76bb8e536e0dded419eb7c7fb", size = 228179, upload-time = "2025-06-23T12:57:51.031Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/4e/547949993ea347ac44f5908262ebe6e85edfa7b11a5df136319789be731d/zstd-1.5.7.2-cp313-cp313-manylinux_2_14_x86_64.whl", hash = "sha256:53948be45f286a1b25c07a6aa2aca5c902208eb3df9fe36cf891efa0394c8b71", size = 302763, upload-time = "2025-06-23T12:51:51.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/ca/4a6882846e3049be249031f825251a9229ecad471e18e7fd27974540549c/zstd-1.5.7.2-cp313-cp313-win32.whl", hash = "sha256:edf816c218e5978033b7bb47dcb453dfb71038cb8a9bf4877f3f823e74d58174", size = 149452, upload-time = "2025-06-23T12:57:32.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/aa/89339605864c9803e4738f176932a6c9f1ad99d03c03ef2cb0634ddca680/zstd-1.5.7.2-cp313-cp313-win_amd64.whl", hash = "sha256:eea9bddf06f3f5e1e450fd647665c86df048a45e8b956d53522387c1dff41b7a", size = 166625, upload-time = "2025-06-23T12:57:33.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/e9/501291a2f9b300b2c73dcc6d086df778e895e71573df9575def54d9dbab2/zstd-1.5.7.2-cp313-cp313t-manylinux_2_14_x86_64.whl", hash = "sha256:1d71f9f92b3abe18b06b5f0aefa5b9c42112beef3bff27e36028d147cb4426a6", size = 302906, upload-time = "2025-06-23T13:21:13.331Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/b9/179ad7330e6ea33ce655b671ee6f961fbbf4714996aa7c5180ef08d1616a/zstd-1.5.7.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:426e5c6b7b3e2401b734bfd08050b071e17c15df5e3b31e63651d1fd9ba4c751", size = 262933, upload-time = "2025-06-23T13:03:44.34Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/d9/f9b73abd3ccce44468ccbdc1ad48b8adb6eaffeacc556472a6e42331b2c3/zstd-1.5.7.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:53375b23f2f39359ade944169bbd88f8895eed91290ee608ccbc28810ac360ba", size = 218516, upload-time = "2025-06-23T13:19:05.55Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/3b/f6f6c4d009b5945bbe043e576a61a8adc71eba5e9adc7b1872c080508b26/zstd-1.5.7.2-pp310-pypy310_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:1b301b2f9dbb0e848093127fb10cbe6334a697dc3aea6740f0bb726450ee9a34", size = 315543, upload-time = "2025-06-23T13:20:47.275Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/39/7edf76a442621d76dc18ee82dcce82f8a0df2fbc7b962ade42a833e30a32/zstd-1.5.7.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5414c9ae27069ab3ec8420fe8d005cb1b227806cbc874a7b4c73a96b4697a633", size = 166648, upload-time = "2025-06-23T13:11:55.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/c9/a6495a7bf168a78f0a0c01d61d830ebfb401315a64fd1ae8d725c458114c/zstd-1.5.7.2-pp311-pypy311_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:5fb2ff5718fe89181223c23ce7308bd0b4a427239379e2566294da805d8df68a", size = 315542, upload-time = "2025-06-23T12:39:27.598Z" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user