mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
78 Commits
fix/infini
...
uv-migrati
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64b1e68d2a | ||
|
|
f2a3a0da56 | ||
|
|
50487f2a9c | ||
|
|
3e6c1f0d27 | ||
|
|
72b200d5a5 | ||
|
|
8968e1f691 | ||
|
|
86374d139d | ||
|
|
6ccd42bb29 | ||
|
|
1146ea2274 | ||
|
|
ff28e13698 | ||
|
|
9171986dde | ||
|
|
9d405243b8 | ||
|
|
d7218925c4 | ||
|
|
27c16d6691 | ||
|
|
eabba5c160 | ||
|
|
ece7e2dd39 | ||
|
|
13762eba7c | ||
|
|
9cf7d64bfe | ||
|
|
92baebc4bd | ||
|
|
3d0aa50450 | ||
|
|
0e3332d974 | ||
|
|
3c6d2ff1d6 | ||
|
|
b7b76c7a30 | ||
|
|
17b1c04aa0 | ||
|
|
624a241bbf | ||
|
|
7862e10f03 | ||
|
|
7380039bf6 | ||
|
|
d773dd6514 | ||
|
|
175117e8b5 | ||
|
|
778a1cf609 | ||
|
|
c08adc87b4 | ||
|
|
434647e4e4 | ||
|
|
849ae13118 | ||
|
|
180df8ea20 | ||
|
|
17791e5e62 | ||
|
|
f3aaebdc33 | ||
|
|
0e4f0c25d7 | ||
|
|
d4cf1d4590 | ||
|
|
9b50d0cb7d | ||
|
|
5c411e7fc1 | ||
|
|
6442f772a0 | ||
|
|
5fb431bcc5 | ||
|
|
adfabe7659 | ||
|
|
0ddac3879e | ||
|
|
7398737b06 | ||
|
|
50d9cbac04 | ||
|
|
a40f7bda21 | ||
|
|
39f0e6ed94 | ||
|
|
6475aa3487 | ||
|
|
5dea0d22b4 | ||
|
|
a6e8b819ad | ||
|
|
c97e7082f7 | ||
|
|
cb9e6fde24 | ||
|
|
828837a969 | ||
|
|
bbdedf8641 | ||
|
|
11d1e79506 | ||
|
|
e485c93119 | ||
|
|
cddf01b4e9 | ||
|
|
6086c0b09d | ||
|
|
15836c4d4b | ||
|
|
20eb9bd3c5 | ||
|
|
ba1770ad89 | ||
|
|
4b7ce82f71 | ||
|
|
5c20724845 | ||
|
|
8ddb815a89 | ||
|
|
08df955ba7 | ||
|
|
b816d0448b | ||
|
|
fa974f8106 | ||
|
|
af5c22700c | ||
|
|
1907ebeaa8 | ||
|
|
9686ee02f3 | ||
|
|
d053a3d363 | ||
|
|
acc0e893e3 | ||
|
|
a8098505c2 | ||
|
|
9b834bf660 | ||
|
|
5744f6602b | ||
|
|
4a82768e6d | ||
|
|
6f86e589c8 |
10
.github/pull_request_template.md
vendored
10
.github/pull_request_template.md
vendored
@@ -1,10 +1,16 @@
|
||||
<!-- Ideally you should open a PR when it is ready for review. Draft PRs will not be reviewed -->
|
||||
|
||||
## Summary of PR
|
||||
|
||||
<!-- Summarize what the PR does, explaining any non-trivial design decisions. -->
|
||||
<!-- Summarize what the PR does -->
|
||||
|
||||
## Demo Screenshots/Videos
|
||||
|
||||
<!-- AI/LLM AGENTS: This section is intended for a human author to add screenshots or videos demonstrating the PR in action (optional). While many pull requests may be generated by AI/LLM agents, we are fine with this as long as a human author has reviewed and tested the changes to ensure accuracy and functionality. -->
|
||||
|
||||
## Change Type
|
||||
|
||||
<!-- Choose the types that apply to your PR and remove the rest. -->
|
||||
<!-- Choose the types that apply to your PR -->
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
|
||||
3
.github/workflows/e2e-tests.yml
vendored
3
.github/workflows/e2e-tests.yml
vendored
@@ -26,6 +26,9 @@ jobs:
|
||||
with:
|
||||
poetry-version: 2.1.3
|
||||
|
||||
- name: Install UV
|
||||
run: curl -LsSf https://astral.sh/uv/install.sh | sh && echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
||||
140
.github/workflows/ghcr-build.yml
vendored
140
.github/workflows/ghcr-build.yml
vendored
@@ -116,6 +116,8 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Install poetry via pipx
|
||||
run: pipx install poetry
|
||||
- name: Install UV
|
||||
run: curl -LsSf https://astral.sh/uv/install.sh | sh && echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
- name: Set up Python
|
||||
uses: useblacksmith/setup-python@v6
|
||||
with:
|
||||
@@ -252,151 +254,15 @@ jobs:
|
||||
-d "{\"ref\": \"main\", \"inputs\": {\"openhandsPrNumber\": \"${{ github.event.pull_request.number }}\", \"deployEnvironment\": \"feature\", \"enterpriseImageTag\": \"pr-${{ github.event.pull_request.number }}\" }}" \
|
||||
https://api.github.com/repos/OpenHands/deploy/actions/workflows/deploy.yaml/dispatches
|
||||
|
||||
# Run unit tests with the Docker runtime Docker images as root
|
||||
test_runtime_root:
|
||||
name: RT Unit Tests (Root)
|
||||
needs: [ghcr_build_runtime, define-matrix]
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
base_image: ${{ fromJson(needs.define-matrix.outputs.base_image) }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Download runtime source for fork
|
||||
if: github.event.pull_request.head.repo.fork
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
name: runtime-src-${{ matrix.base_image.tag }}
|
||||
path: containers/runtime
|
||||
- name: Lowercase Repository Owner
|
||||
run: |
|
||||
echo REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||
# Forked repos can't push to GHCR, so we need to rebuild using cache
|
||||
- name: Build runtime image ${{ matrix.base_image.image }} for fork
|
||||
if: github.event.pull_request.head.repo.fork
|
||||
uses: useblacksmith/build-push-action@v1
|
||||
with:
|
||||
load: true
|
||||
tags: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
|
||||
context: containers/runtime
|
||||
- name: Install poetry via pipx
|
||||
run: pipx install poetry
|
||||
- name: Set up Python
|
||||
uses: useblacksmith/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: poetry
|
||||
- name: Install Python dependencies using Poetry
|
||||
run: make install-python-dependencies INSTALL_PLAYWRIGHT=0
|
||||
- name: Run docker runtime tests
|
||||
shell: bash
|
||||
run: |
|
||||
# We install pytest-xdist in order to run tests across CPUs
|
||||
poetry run pip install pytest-xdist
|
||||
|
||||
# Install to be able to retry on failures for flakey tests
|
||||
poetry run pip install pytest-rerunfailures
|
||||
|
||||
image_name=ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
|
||||
|
||||
# Setting RUN_AS_OPENHANDS to false means use root.
|
||||
# That should mean SANDBOX_USER_ID is ignored but some tests do not check for RUN_AS_OPENHANDS.
|
||||
|
||||
TEST_RUNTIME=docker \
|
||||
SANDBOX_USER_ID=$(id -u) \
|
||||
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
|
||||
TEST_IN_CI=true \
|
||||
RUN_AS_OPENHANDS=false \
|
||||
poetry run pytest -n 5 -raRs --reruns 2 --reruns-delay 3 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
|
||||
env:
|
||||
DEBUG: "1"
|
||||
|
||||
# Run unit tests with the Docker runtime Docker images as openhands user
|
||||
test_runtime_oh:
|
||||
name: RT Unit Tests (openhands)
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
needs: [ghcr_build_runtime, define-matrix]
|
||||
strategy:
|
||||
matrix:
|
||||
base_image: ${{ fromJson(needs.define-matrix.outputs.base_image) }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Download runtime source for fork
|
||||
if: github.event.pull_request.head.repo.fork
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
name: runtime-src-${{ matrix.base_image.tag }}
|
||||
path: containers/runtime
|
||||
- name: Lowercase Repository Owner
|
||||
run: |
|
||||
echo REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||
# Forked repos can't push to GHCR, so we need to rebuild using cache
|
||||
- name: Build runtime image ${{ matrix.base_image.image }} for fork
|
||||
if: github.event.pull_request.head.repo.fork
|
||||
uses: useblacksmith/build-push-action@v1
|
||||
with:
|
||||
load: true
|
||||
tags: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
|
||||
context: containers/runtime
|
||||
- name: Install poetry via pipx
|
||||
run: pipx install poetry
|
||||
- name: Set up Python
|
||||
uses: useblacksmith/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: poetry
|
||||
- name: Install Python dependencies using Poetry
|
||||
run: make install-python-dependencies POETRY_GROUP=main,test,runtime INSTALL_PLAYWRIGHT=0
|
||||
- name: Run runtime tests
|
||||
shell: bash
|
||||
run: |
|
||||
# We install pytest-xdist in order to run tests across CPUs
|
||||
poetry run pip install pytest-xdist
|
||||
|
||||
# Install to be able to retry on failures for flaky tests
|
||||
poetry run pip install pytest-rerunfailures
|
||||
|
||||
image_name=ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
|
||||
|
||||
TEST_RUNTIME=docker \
|
||||
SANDBOX_USER_ID=$(id -u) \
|
||||
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
|
||||
TEST_IN_CI=true \
|
||||
RUN_AS_OPENHANDS=true \
|
||||
poetry run pytest -n 5 -raRs --reruns 2 --reruns-delay 3 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
|
||||
env:
|
||||
DEBUG: "1"
|
||||
|
||||
# The two following jobs (named identically) are to check whether all the runtime tests have passed as the
|
||||
# "All Runtime Tests Passed" is a required job for PRs to merge
|
||||
# Due to this bug: https://github.com/actions/runner/issues/2566, we want to create a job that runs when the
|
||||
# prerequisites have been cancelled or failed so merging is disallowed, otherwise Github considers "skipped" as "success"
|
||||
# We can remove this once the config changes
|
||||
runtime_tests_check_success:
|
||||
name: All Runtime Tests Passed
|
||||
if: ${{ !cancelled() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }}
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
needs: [test_runtime_root, test_runtime_oh]
|
||||
steps:
|
||||
- name: All tests passed
|
||||
run: echo "All runtime tests have passed successfully!"
|
||||
|
||||
runtime_tests_check_fail:
|
||||
name: All Runtime Tests Passed
|
||||
if: ${{ cancelled() || contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
needs: [test_runtime_root, test_runtime_oh]
|
||||
steps:
|
||||
- name: Some tests failed
|
||||
run: |
|
||||
echo "Some runtime tests failed or were cancelled"
|
||||
exit 1
|
||||
update_pr_description:
|
||||
name: Update PR Description
|
||||
if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]'
|
||||
|
||||
4
.github/workflows/py-tests.yml
vendored
4
.github/workflows/py-tests.yml
vendored
@@ -42,6 +42,8 @@ jobs:
|
||||
node-version: "22.x"
|
||||
- name: Install poetry via pipx
|
||||
run: pipx install poetry
|
||||
- name: Install UV
|
||||
run: curl -LsSf https://astral.sh/uv/install.sh | sh && echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
- name: Set up Python
|
||||
uses: useblacksmith/setup-python@v6
|
||||
with:
|
||||
@@ -81,6 +83,8 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install poetry via pipx
|
||||
run: pipx install poetry
|
||||
- name: Install UV
|
||||
run: curl -LsSf https://astral.sh/uv/install.sh | sh && echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
- name: Set up Python
|
||||
uses: useblacksmith/setup-python@v6
|
||||
with:
|
||||
|
||||
2
.github/workflows/pypi-release.yml
vendored
2
.github/workflows/pypi-release.yml
vendored
@@ -32,6 +32,8 @@ jobs:
|
||||
with:
|
||||
virtualenvs-in-project: true
|
||||
virtualenvs-path: ~/.virtualenvs
|
||||
- name: Install UV
|
||||
run: curl -LsSf https://astral.sh/uv/install.sh | sh && echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
- name: Install Poetry Dependencies
|
||||
run: poetry install --no-interaction --no-root
|
||||
- name: Build poetry project
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
"This issue has been labeled as **good first issue**, which means it's a great place to get started with the OpenHands project.\n\n" +
|
||||
"If you're interested in working on it, feel free to! No need to ask for permission.\n\n" +
|
||||
"Be sure to check out our [development setup guide](" + repoUrl + "/blob/main/Development.md) to get your environment set up, and follow our [contribution guidelines](" + repoUrl + "/blob/main/CONTRIBUTING.md) when you're ready to submit a fix.\n\n" +
|
||||
"Feel free to join our developer community on [Slack](https://all-hands.dev/joinslack). You can ask for [help](https://openhands-ai.slack.com/archives/C078L0FUGUX), [feedback](https://openhands-ai.slack.com/archives/C086ARSNMGA), and even ask for a [PR review](https://openhands-ai.slack.com/archives/C08D8FJ5771).\n\n" +
|
||||
"Feel free to join our developer community on [Slack](https://openhands.dev/joinslack). You can ask for [help](https://openhands-ai.slack.com/archives/C078L0FUGUX), [feedback](https://openhands-ai.slack.com/archives/C086ARSNMGA), and even ask for a [PR review](https://openhands-ai.slack.com/archives/C08D8FJ5771).\n\n" +
|
||||
"🙌 Happy hacking! 🙌\n\n" +
|
||||
"<!-- auto-comment:good-first-issue -->"
|
||||
});
|
||||
|
||||
@@ -150,9 +150,9 @@ Each integration follows a consistent pattern with service classes, storage mode
|
||||
|
||||
**Important Notes:**
|
||||
- Enterprise code is licensed under Polyform Free Trial License (30-day limit)
|
||||
- The enterprise server extends the OSS server through dynamic imports
|
||||
- The enterprise server extends the OpenHands server through dynamic imports
|
||||
- Database changes require careful migration planning in `enterprise/migrations/`
|
||||
- Always test changes in both OSS and enterprise contexts
|
||||
- Always test changes in both OpenHands and enterprise contexts
|
||||
- Use the enterprise-specific Makefile commands for development
|
||||
|
||||
**Enterprise Testing Best Practices:**
|
||||
@@ -166,7 +166,7 @@ Each integration follows a consistent pattern with service classes, storage mode
|
||||
**Import Patterns:**
|
||||
- Use relative imports without `enterprise.` prefix in enterprise code
|
||||
- Example: `from storage.database import session_maker` not `from enterprise.storage.database import session_maker`
|
||||
- This ensures code works in both OSS and enterprise contexts
|
||||
- This ensures code works in both OpenHands and enterprise contexts
|
||||
|
||||
**Test Structure:**
|
||||
- Place tests in `enterprise/tests/unit/` following the same structure as the source code
|
||||
@@ -61,7 +61,7 @@ representative at an online or offline event.
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
contact@all-hands.dev.
|
||||
contact@openhands.dev.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
@@ -115,7 +115,9 @@ community.
|
||||
|
||||
### Slack Etiquettes
|
||||
|
||||
These Slack etiquette guidelines are designed to foster an inclusive, respectful, and productive environment for all community members. By following these best practices, we ensure effective communication and collaboration while minimizing disruptions. Let’s work together to build a supportive and welcoming community!
|
||||
These Slack etiquette guidelines are designed to foster an inclusive, respectful, and productive environment for all
|
||||
community members. By following these best practices, we ensure effective communication and collaboration while
|
||||
minimizing disruptions. Let’s work together to build a supportive and welcoming community!
|
||||
|
||||
- Communicate respectfully and professionally, avoiding sarcasm or harsh language, and remember that tone can be difficult to interpret in text.
|
||||
- Use threads for specific discussions to keep channels organized and easier to follow.
|
||||
@@ -125,7 +127,10 @@ These Slack etiquette guidelines are designed to foster an inclusive, respectful
|
||||
- When asking for help or raising issues, include necessary details like links, screenshots, or clear explanations to provide context.
|
||||
- Keep discussions in public channels whenever possible to allow others to benefit from the conversation, unless the matter is sensitive or private.
|
||||
- Always adhere to [our standards](https://github.com/OpenHands/OpenHands/blob/main/CODE_OF_CONDUCT.md#our-standards) to ensure a welcoming and collaborative environment.
|
||||
- If you choose to mute a channel, consider setting up alerts for topics that still interest you to stay engaged. For Slack, Go to Settings → Notifications → My Keywords to add specific keywords that will notify you when mentioned. For example, if you're here for discussions about LLMs, mute the channel if it’s too busy, but set notifications to alert you only when “LLMs” appears in messages.
|
||||
- If you choose to mute a channel, consider setting up alerts for topics that still interest you to stay engaged.
|
||||
For Slack, Go to Settings → Notifications → My Keywords to add specific keywords that will notify you when mentioned.
|
||||
For example, if you're here for discussions about LLMs, mute the channel if it’s too busy, but set notifications to
|
||||
alert you only when “LLMs” appears in messages.
|
||||
|
||||
## Attribution
|
||||
|
||||
|
||||
37
COMMUNITY.md
37
COMMUNITY.md
@@ -1,36 +1,44 @@
|
||||
# The OpenHands Community
|
||||
|
||||
OpenHands is a community of engineers, academics, and enthusiasts reimagining software development for an AI-powered world.
|
||||
OpenHands is a community of engineers, academics, and enthusiasts reimagining software development for an AI-powered
|
||||
world.
|
||||
|
||||
## Mission
|
||||
|
||||
It’s very clear that AI is changing software development. We want the developer community to drive that change organically, through open source.
|
||||
It’s very clear that AI is changing software development. We want the developer community to drive that change
|
||||
organically, through open source.
|
||||
|
||||
So we’re not just building friendly interfaces for AI-driven development. We’re publishing _building blocks_ that empower developers to create new experiences, tailored to your own habits, needs, and imagination.
|
||||
So we’re not just building friendly interfaces for AI-driven development. We’re publishing _building blocks_ that
|
||||
empower developers to create new experiences, tailored to your own habits, needs, and imagination.
|
||||
|
||||
## Ethos
|
||||
|
||||
We have two core values: **high openness** and **high agency**. While we don’t expect everyone in the community to embody these values, we want to establish them as norms.
|
||||
We have two core values: **high openness** and **high agency**. While we don’t expect everyone in the community to
|
||||
embody these values, we want to establish them as norms.
|
||||
|
||||
### High Openness
|
||||
|
||||
We welcome anyone and everyone into our community by default. You don’t have to be a software developer to help us build. You don’t have to be pro-AI to help us learn.
|
||||
We welcome anyone and everyone into our community by default. You don’t have to be a software developer to help us
|
||||
build. You don’t have to be pro-AI to help us learn.
|
||||
|
||||
Our plans, our work, our successes, and our failures are all public record. We want the world to see not just the fruits of our work, but the whole process of growing it.
|
||||
Our plans, our work, our successes, and our failures are all public record. We want the world to see not just the
|
||||
fruits of our work, but the whole process of growing it.
|
||||
|
||||
We welcome thoughtful criticism, whether it’s a comment on a PR or feedback on the community as a whole.
|
||||
|
||||
### High Agency
|
||||
|
||||
Everyone should feel empowered to contribute to OpenHands. Whether it’s by making a PR, hosting an event, sharing feedback, or just asking a question, don’t hold back!
|
||||
Everyone should feel empowered to contribute to OpenHands. Whether it’s by making a PR, hosting an event, sharing
|
||||
feedback, or just asking a question, don’t hold back!
|
||||
|
||||
OpenHands gives everyone the building blocks to create state-of-the-art developer experiences. We experiment constantly and love building new things.
|
||||
OpenHands gives everyone the building blocks to create state-of-the-art developer experiences. We experiment constantly
|
||||
and love building new things.
|
||||
|
||||
Coding, development practices, and communities are changing rapidly. We won’t hesitate to change direction and make big bets.
|
||||
|
||||
## Relationship to All Hands
|
||||
|
||||
OpenHands is supported by the for-profit organization [All Hands AI, Inc](https://www.all-hands.dev/).
|
||||
OpenHands is supported by the for-profit organization [All Hands AI, Inc](https://www.openhands.dev/).
|
||||
|
||||
All Hands was founded by three of the first major contributors to OpenHands:
|
||||
|
||||
@@ -38,8 +46,13 @@ All Hands was founded by three of the first major contributors to OpenHands:
|
||||
- Graham Neubig, a CMU Professor who rallied the academic community around OpenHands
|
||||
- Robert Brennan, a software engineer who architected the user-facing features of OpenHands
|
||||
|
||||
All Hands is an important part of the OpenHands ecosystem. We’ve raised over $20M--mainly to hire developers and researchers who can work on OpenHands full-time, and to provide them with expensive infrastructure. ([Join us!](https://allhandsai.applytojob.com/apply/))
|
||||
All Hands is an important part of the OpenHands ecosystem. We’ve raised over $20M--mainly to hire developers and
|
||||
researchers who can work on OpenHands full-time, and to provide them with expensive infrastructure. ([Join us!](https://allhandsai.applytojob.com/apply/))
|
||||
|
||||
But we see OpenHands as much larger, and ultimately more important, than All Hands. When our financial responsibility to investors is at odds with our social responsibility to the community—as it inevitably will be, from time to time—we promise to navigate that conflict thoughtfully and transparently.
|
||||
But we see OpenHands as much larger, and ultimately more important, than All Hands. When our financial responsibility
|
||||
to investors is at odds with our social responsibility to the community—as it inevitably will be, from time to time—we
|
||||
promise to navigate that conflict thoughtfully and transparently.
|
||||
|
||||
At some point, we may transfer custody of OpenHands to an open source foundation. But for now, the [Benevolent Dictator approach](http://www.catb.org/~esr/writings/cathedral-bazaar/homesteading/ar01s16.html) helps us move forward with speed and intention. If we ever forget the “benevolent” part, please: fork us.
|
||||
At some point, we may transfer custody of OpenHands to an open source foundation. But for now,
|
||||
the [Benevolent Dictator approach](http://www.catb.org/~esr/writings/cathedral-bazaar/homesteading/ar01s16.html) helps us move forward with speed and intention. If we ever forget the
|
||||
“benevolent” part, please: fork us.
|
||||
|
||||
@@ -13,20 +13,23 @@ To understand the codebase, please refer to the README in each module:
|
||||
|
||||
## Setting up Your Development Environment
|
||||
|
||||
We have a separate doc [Development.md](https://github.com/OpenHands/OpenHands/blob/main/Development.md) that tells you how to set up a development workflow.
|
||||
We have a separate doc [Development.md](https://github.com/OpenHands/OpenHands/blob/main/Development.md) that tells
|
||||
you how to set up a development workflow.
|
||||
|
||||
## How Can I Contribute?
|
||||
|
||||
There are many ways that you can contribute:
|
||||
|
||||
1. **Download and use** OpenHands, and send [issues](https://github.com/OpenHands/OpenHands/issues) when you encounter something that isn't working or a feature that you'd like to see.
|
||||
2. **Send feedback** after each session by [clicking the thumbs-up thumbs-down buttons](https://docs.all-hands.dev/usage/feedback), so we can see where things are working and failing, and also build an open dataset for training code agents.
|
||||
2. **Send feedback** after each session by [clicking the thumbs-up thumbs-down buttons](https://docs.openhands.dev/usage/feedback), so we can see where things are working and failing, and also build an open dataset for training code agents.
|
||||
3. **Improve the Codebase** by sending [PRs](#sending-pull-requests-to-openhands) (see details below). In particular, we have some [good first issues](https://github.com/OpenHands/OpenHands/labels/good%20first%20issue) that may be ones to start on.
|
||||
|
||||
## What Can I Build?
|
||||
|
||||
Here are a few ways you can help improve the codebase.
|
||||
|
||||
#### UI/UX
|
||||
|
||||
We're always looking to improve the look and feel of the application. If you've got a small fix
|
||||
for something that's bugging you, feel free to open up a PR that changes the [`./frontend`](./frontend) directory.
|
||||
|
||||
@@ -35,6 +38,7 @@ of the application, please open an issue first, or better, join the #dev-ui-ux c
|
||||
to gather consensus from our design team first.
|
||||
|
||||
#### Improving the agent
|
||||
|
||||
Our main agent is the CodeAct agent. You can [see its prompts here](https://github.com/OpenHands/OpenHands/tree/main/openhands/agenthub/codeact_agent).
|
||||
|
||||
Changes to these prompts, and to the underlying behavior in Python, can have a huge impact on user experience.
|
||||
@@ -46,10 +50,12 @@ We use the [SWE-bench](https://www.swebench.com/) benchmark to test our agent. Y
|
||||
channel in Slack to learn more.
|
||||
|
||||
#### Adding a new agent
|
||||
|
||||
You may want to experiment with building new types of agents. You can add an agent to [`openhands/agenthub`](./openhands/agenthub)
|
||||
to help expand the capabilities of OpenHands.
|
||||
|
||||
#### Adding a new runtime
|
||||
|
||||
The agent needs a place to run code and commands. When you run OpenHands on your laptop, it uses a Docker container
|
||||
to do this by default. But there are other ways of creating a sandbox for the agent.
|
||||
|
||||
@@ -57,8 +63,11 @@ If you work for a company that provides a cloud-based runtime, you could help us
|
||||
by implementing the [interface specified here](https://github.com/OpenHands/OpenHands/blob/main/openhands/runtime/base.py).
|
||||
|
||||
#### Testing
|
||||
When you write code, it is also good to write tests. Please navigate to the [`./tests`](./tests) folder to see existing test suites.
|
||||
At the moment, we have these kinds of tests: [`unit`](./tests/unit), [`runtime`](./tests/runtime), and [`end-to-end (e2e)`](./tests/e2e). Please refer to the README for each test suite. These tests also run on GitHub's continuous integration to ensure quality of the project.
|
||||
|
||||
When you write code, it is also good to write tests. Please navigate to the [`./tests`](./tests) folder to see existing
|
||||
test suites. At the moment, we have these kinds of tests: [`unit`](./tests/unit), [`runtime`](./tests/runtime), and [`end-to-end (e2e)`](./tests/e2e).
|
||||
Please refer to the README for each test suite. These tests also run on GitHub's continuous integration to ensure
|
||||
quality of the project.
|
||||
|
||||
## Sending Pull Requests to OpenHands
|
||||
|
||||
@@ -66,7 +75,8 @@ You'll need to fork our repository to send us a Pull Request. You can learn more
|
||||
about how to fork a GitHub repo and open a PR with your changes in [this article](https://medium.com/swlh/forks-and-pull-requests-how-to-contribute-to-github-repos-8843fac34ce8).
|
||||
|
||||
### Pull Request title
|
||||
As described [here](https://github.com/commitizen/conventional-commit-types/blob/master/index.json), a valid PR title should begin with one of the following prefixes:
|
||||
|
||||
As described [here](https://github.com/commitizen/conventional-commit-types/blob/master/index.json), ideally a valid PR title should begin with one of the following prefixes:
|
||||
|
||||
- `feat`: A new feature
|
||||
- `fix`: A bug fix
|
||||
@@ -87,6 +97,7 @@ For example, a PR title could be:
|
||||
You may also check out previous PRs in the [PR list](https://github.com/OpenHands/OpenHands/pulls).
|
||||
|
||||
### Pull Request description
|
||||
|
||||
- If your PR is small (such as a typo fix), you can go brief.
|
||||
- If it contains a lot of changes, it's better to write more details.
|
||||
|
||||
@@ -97,7 +108,9 @@ please include a short message that we can add to our changelog.
|
||||
|
||||
### Opening Issues
|
||||
|
||||
If you notice any bugs or have any feature requests please open them via the [issues page](https://github.com/OpenHands/OpenHands/issues). We will triage based on how critical the bug is or how potentially useful the improvement is, discuss, and implement the ones that the community has interest/effort for.
|
||||
If you notice any bugs or have any feature requests please open them via the [issues page](https://github.com/OpenHands/OpenHands/issues). We will triage
|
||||
based on how critical the bug is or how potentially useful the improvement is, discuss, and implement the ones that
|
||||
the community has interest/effort for.
|
||||
|
||||
Further, if you see an issue you like, please leave a "thumbs-up" or a comment, which will help us prioritize.
|
||||
|
||||
@@ -108,11 +121,13 @@ We're generally happy to consider all pull requests with the evaluation process
|
||||
#### For Small Improvements
|
||||
|
||||
Small improvements with few downsides are typically reviewed and approved quickly.
|
||||
One thing to check when making changes is to ensure that all continuous integration tests pass, which you can check before getting a review.
|
||||
One thing to check when making changes is to ensure that all continuous integration tests pass, which you can check
|
||||
before getting a review.
|
||||
|
||||
#### For Core Agent Changes
|
||||
|
||||
We need to be more careful with changes to the core agent, as it is imperative to maintain high quality. These PRs are evaluated based on three key metrics:
|
||||
We need to be more careful with changes to the core agent, as it is imperative to maintain high quality. These PRs are
|
||||
evaluated based on three key metrics:
|
||||
|
||||
1. **Accuracy**
|
||||
2. **Efficiency**
|
||||
|
||||
44
CREDITS.md
44
CREDITS.md
@@ -2,11 +2,13 @@
|
||||
|
||||
## Contributors
|
||||
|
||||
We would like to thank all the [contributors](https://github.com/OpenHands/OpenHands/graphs/contributors) who have helped make OpenHands possible. We greatly appreciate your dedication and hard work.
|
||||
We would like to thank all the [contributors](https://github.com/OpenHands/OpenHands/graphs/contributors) who have
|
||||
helped make OpenHands possible. We greatly appreciate your dedication and hard work.
|
||||
|
||||
## Open Source Projects
|
||||
|
||||
OpenHands includes and adapts the following open source projects. We are grateful for their contributions to the open source community:
|
||||
OpenHands includes and adapts the following open source projects. We are grateful for their contributions to the
|
||||
open source community:
|
||||
|
||||
#### [SWE Agent](https://github.com/princeton-nlp/swe-agent)
|
||||
- License: MIT License
|
||||
@@ -20,8 +22,8 @@ OpenHands includes and adapts the following open source projects. We are gratefu
|
||||
- License: Apache License 2.0
|
||||
- Description: Adapted in implementing the browsing agent
|
||||
|
||||
|
||||
### Reference Implementations for Evaluation Benchmarks
|
||||
|
||||
OpenHands integrates code of the reference implementations for the following agent evaluation benchmarks:
|
||||
|
||||
#### [HumanEval](https://github.com/openai/human-eval)
|
||||
@@ -52,28 +54,44 @@ OpenHands integrates code of the reference implementations for the following age
|
||||
#### [ProntoQA](https://github.com/asaparov/prontoqa)
|
||||
- License: Apache License 2.0
|
||||
|
||||
|
||||
## Open Source licenses
|
||||
|
||||
### MIT License
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
|
||||
documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
|
||||
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
|
||||
persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
|
||||
Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO
|
||||
THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
|
||||
OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
### BSD 3-Clause License
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the
|
||||
following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following
|
||||
disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
||||
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following
|
||||
disclaimer in the documentation and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
|
||||
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote
|
||||
products derived from this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
||||
INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
|
||||
WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
### Apache License 2.0
|
||||
|
||||
@@ -268,8 +286,6 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
|
||||
|
||||
### Non-Open Source Reference Implementations:
|
||||
|
||||
#### [MultiPL-E](https://github.com/nuprl/MultiPL-E)
|
||||
|
||||
@@ -76,7 +76,7 @@ variables in your terminal. The final configurations are set from highest to low
|
||||
Environment variables > config.toml variables > default variables
|
||||
|
||||
**Note on Alternative Models:**
|
||||
See [our documentation](https://docs.all-hands.dev/usage/llms) for recommended models.
|
||||
See [our documentation](https://docs.openhands.dev/usage/llms) for recommended models.
|
||||
|
||||
### 4. Running the application
|
||||
|
||||
@@ -195,7 +195,7 @@ Here's a guide to the important documentation files in the repository:
|
||||
- [/README.md](./README.md): Main project overview, features, and basic setup instructions
|
||||
- [/Development.md](./Development.md) (this file): Comprehensive guide for developers working on OpenHands
|
||||
- [/CONTRIBUTING.md](./CONTRIBUTING.md): Guidelines for contributing to the project, including code style and PR process
|
||||
- [DOC_STYLE_GUIDE.md](https://github.com/All-Hands-AI/docs/blob/main/openhands/DOC_STYLE_GUIDE.md): Standards for writing and maintaining project documentation
|
||||
- [DOC_STYLE_GUIDE.md](https://github.com/OpenHands/docs/blob/main/openhands/DOC_STYLE_GUIDE.md): Standards for writing and maintaining project documentation
|
||||
- [/openhands/README.md](./openhands/README.md): Details about the backend Python implementation
|
||||
- [/frontend/README.md](./frontend/README.md): Frontend React application setup and development guide
|
||||
- [/containers/README.md](./containers/README.md): Information about Docker containers and deployment
|
||||
|
||||
@@ -3,14 +3,14 @@ These are the procedures and guidelines on how issues are triaged in this repo b
|
||||
|
||||
## General
|
||||
* All issues must be tagged with **enhancement**, **bug** or **troubleshooting/help**.
|
||||
* Issues may be tagged with what it relates to (**agent quality**, **resolver**, **CLI**, etc.).
|
||||
* Issues may be tagged with what it relates to (**llm**, **app tab**, **UI/UX**, etc.).
|
||||
|
||||
## Severity
|
||||
* **High**: High visibility issues or affecting many users.
|
||||
* **Critical**: Affecting all users or potential security issues.
|
||||
|
||||
## Difficulty
|
||||
* Issues with low implementation difficulty may be tagged with **good first issue**.
|
||||
* Issues good for newcomers may be tagged with **good first issue**.
|
||||
|
||||
## Not Enough Information
|
||||
* User is asked to provide more information (logs, how to reproduce, etc.) when the issue is not clear.
|
||||
@@ -22,6 +22,6 @@ the issue may be closed as **not planned** (Usually after a week).
|
||||
* Issues may be broken down into multiple issues if required.
|
||||
|
||||
## Stale and Auto Closures
|
||||
* In order to keep a maintainable backlog, issues that have no activity within 30 days are automatically marked as **Stale**.
|
||||
* If issues marked as **Stale** continue to have no activity for 7 more days, they will automatically be closed as not planned.
|
||||
* In order to keep a maintainable backlog, issues that have no activity within 40 days are automatically marked as **Stale**.
|
||||
* If issues marked as **Stale** continue to have no activity for 10 more days, they will automatically be closed as not planned.
|
||||
* Issues may be reopened by maintainers if deemed important.
|
||||
|
||||
74
Makefile
74
Makefile
@@ -14,6 +14,23 @@ PRE_COMMIT_CONFIG_PATH = "./dev_config/python/.pre-commit-config.yaml"
|
||||
PYTHON_VERSION = 3.12
|
||||
KIND_CLUSTER_NAME = "local-hands"
|
||||
|
||||
# Package manager selection: "uv" or "poetry" (default: poetry for backward compatibility)
|
||||
# Set USE_UV=1 to use UV instead of Poetry
|
||||
USE_UV ?= 0
|
||||
ifeq ($(USE_UV),1)
|
||||
PKG_MANAGER = uv
|
||||
PKG_RUN = uv run
|
||||
PKG_INSTALL = uv sync
|
||||
PKG_INSTALL_GROUPS = --group dev --group test --group runtime
|
||||
PKG_INSTALL_ONLY_PREFIX = --only-group
|
||||
else
|
||||
PKG_MANAGER = poetry
|
||||
PKG_RUN = poetry run
|
||||
PKG_INSTALL = poetry install
|
||||
PKG_INSTALL_GROUPS = --with dev,test,runtime
|
||||
PKG_INSTALL_ONLY_PREFIX = --only
|
||||
endif
|
||||
|
||||
# ANSI color codes
|
||||
GREEN=$(shell tput -Txterm setaf 2)
|
||||
YELLOW=$(shell tput -Txterm setaf 3)
|
||||
@@ -40,7 +57,7 @@ check-dependencies:
|
||||
ifeq ($(INSTALL_DOCKER),)
|
||||
@$(MAKE) -s check-docker
|
||||
endif
|
||||
@$(MAKE) -s check-poetry
|
||||
@$(MAKE) -s check-pkg-manager
|
||||
@$(MAKE) -s check-tmux
|
||||
@echo "$(GREEN)Dependencies checked successfully.$(RESET)"
|
||||
|
||||
@@ -116,13 +133,24 @@ check-tmux:
|
||||
echo "$(YELLOW)╚════════════════════════════════════════════════════════════════════════════╝$(RESET)"; \
|
||||
fi
|
||||
|
||||
check-poetry:
|
||||
check-pkg-manager:
|
||||
ifeq ($(USE_UV),1)
|
||||
@echo "$(YELLOW)Checking UV installation...$(RESET)"
|
||||
@if command -v uv > /dev/null; then \
|
||||
echo "$(BLUE)$$(uv --version) is already installed.$(RESET)"; \
|
||||
else \
|
||||
echo "$(RED)UV is not installed. You can install UV by running:"; \
|
||||
echo "$(RED) curl -LsSf https://astral.sh/uv/install.sh | sh$(RESET)"; \
|
||||
echo "$(RED)More detail here: https://docs.astral.sh/uv/getting-started/installation/$(RESET)"; \
|
||||
exit 1; \
|
||||
fi
|
||||
else
|
||||
@echo "$(YELLOW)Checking Poetry installation...$(RESET)"
|
||||
@if command -v poetry > /dev/null; then \
|
||||
POETRY_VERSION=$(shell poetry --version 2>&1 | sed -E 's/Poetry \(version ([0-9]+\.[0-9]+\.[0-9]+)\)/\1/'); \
|
||||
POETRY_VERSION=$$(poetry --version 2>&1 | sed -E 's/Poetry \(version ([0-9]+\.[0-9]+\.[0-9]+)\)/\1/'); \
|
||||
IFS='.' read -r -a POETRY_VERSION_ARRAY <<< "$$POETRY_VERSION"; \
|
||||
if [ $${POETRY_VERSION_ARRAY[0]} -gt 1 ] || ([ $${POETRY_VERSION_ARRAY[0]} -eq 1 ] && [ $${POETRY_VERSION_ARRAY[1]} -ge 8 ]); then \
|
||||
echo "$(BLUE)$(shell poetry --version) is already installed.$(RESET)"; \
|
||||
echo "$(BLUE)$$(poetry --version) is already installed.$(RESET)"; \
|
||||
else \
|
||||
echo "$(RED)Poetry 1.8 or later is required. You can install poetry by running the following command, then adding Poetry to your PATH:"; \
|
||||
echo "$(RED) curl -sSL https://install.python-poetry.org | python$(PYTHON_VERSION) -$(RESET)"; \
|
||||
@@ -135,6 +163,10 @@ check-poetry:
|
||||
echo "$(RED)More detail here: https://python-poetry.org/docs/#installing-with-the-official-installer$(RESET)"; \
|
||||
exit 1; \
|
||||
fi
|
||||
endif
|
||||
|
||||
# Legacy alias for backward compatibility
|
||||
check-poetry: check-pkg-manager
|
||||
|
||||
install-python-dependencies:
|
||||
@echo "$(GREEN)Installing Python dependencies...$(RESET)"
|
||||
@@ -142,6 +174,21 @@ install-python-dependencies:
|
||||
echo "Defaulting TZ (timezone) to UTC"; \
|
||||
export TZ="UTC"; \
|
||||
fi
|
||||
ifeq ($(USE_UV),1)
|
||||
@echo "$(BLUE)Using UV for dependency management$(RESET)"
|
||||
@if [ "$(shell uname)" = "Darwin" ]; then \
|
||||
echo "$(BLUE)Installing chroma-hnswlib...$(RESET)"; \
|
||||
export HNSWLIB_NO_NATIVE=1; \
|
||||
uv pip install chroma-hnswlib; \
|
||||
fi
|
||||
@if [ -n "${DEP_GROUP}" ]; then \
|
||||
echo "Installing only DEP_GROUP=${DEP_GROUP}"; \
|
||||
uv sync --only-group $${DEP_GROUP}; \
|
||||
else \
|
||||
uv sync --group dev --group test --group runtime; \
|
||||
fi
|
||||
else
|
||||
@echo "$(BLUE)Using Poetry for dependency management$(RESET)"
|
||||
poetry env use python$(PYTHON_VERSION)
|
||||
@if [ "$(shell uname)" = "Darwin" ]; then \
|
||||
echo "$(BLUE)Installing chroma-hnswlib...$(RESET)"; \
|
||||
@@ -154,15 +201,16 @@ install-python-dependencies:
|
||||
else \
|
||||
poetry install --with dev,test,runtime; \
|
||||
fi
|
||||
endif
|
||||
@if [ "${INSTALL_PLAYWRIGHT}" != "false" ] && [ "${INSTALL_PLAYWRIGHT}" != "0" ]; then \
|
||||
if [ -f "/etc/manjaro-release" ]; then \
|
||||
echo "$(BLUE)Detected Manjaro Linux. Installing Playwright dependencies...$(RESET)"; \
|
||||
poetry run pip install playwright; \
|
||||
poetry run playwright install chromium; \
|
||||
$(PKG_RUN) pip install playwright; \
|
||||
$(PKG_RUN) playwright install chromium; \
|
||||
else \
|
||||
if [ ! -f cache/playwright_chromium_is_installed.txt ]; then \
|
||||
echo "Running playwright install --with-deps chromium..."; \
|
||||
poetry run playwright install --with-deps chromium; \
|
||||
$(PKG_RUN) playwright install --with-deps chromium; \
|
||||
mkdir -p cache; \
|
||||
touch cache/playwright_chromium_is_installed.txt; \
|
||||
else \
|
||||
@@ -182,15 +230,15 @@ install-frontend-dependencies: check-npm check-nodejs
|
||||
@cd frontend && npm install
|
||||
@echo "$(GREEN)Frontend dependencies installed successfully.$(RESET)"
|
||||
|
||||
install-pre-commit-hooks: check-python check-poetry install-python-dependencies
|
||||
install-pre-commit-hooks: check-python check-pkg-manager install-python-dependencies
|
||||
@echo "$(YELLOW)Installing pre-commit hooks...$(RESET)"
|
||||
@git config --unset-all core.hooksPath || true
|
||||
@poetry run pre-commit install --config $(PRE_COMMIT_CONFIG_PATH)
|
||||
@$(PKG_RUN) pre-commit install --config $(PRE_COMMIT_CONFIG_PATH)
|
||||
@echo "$(GREEN)Pre-commit hooks installed successfully.$(RESET)"
|
||||
|
||||
lint-backend: install-pre-commit-hooks
|
||||
@echo "$(YELLOW)Running linters...$(RESET)"
|
||||
@poetry run pre-commit run --all-files --show-diff-on-failure --config $(PRE_COMMIT_CONFIG_PATH)
|
||||
@$(PKG_RUN) pre-commit run --all-files --show-diff-on-failure --config $(PRE_COMMIT_CONFIG_PATH)
|
||||
|
||||
lint-frontend: install-frontend-dependencies
|
||||
@echo "$(YELLOW)Running linters for frontend...$(RESET)"
|
||||
@@ -248,7 +296,7 @@ build-frontend:
|
||||
# Start backend
|
||||
start-backend:
|
||||
@echo "$(YELLOW)Starting backend...$(RESET)"
|
||||
@poetry run uvicorn openhands.server.listen:app --host $(BACKEND_HOST) --port $(BACKEND_PORT) --reload --reload-exclude "./workspace"
|
||||
@$(PKG_RUN) uvicorn openhands.server.listen:app --host $(BACKEND_HOST) --port $(BACKEND_PORT) --reload --reload-exclude "./workspace"
|
||||
|
||||
# Start frontend
|
||||
start-frontend:
|
||||
@@ -270,7 +318,7 @@ _run_setup:
|
||||
fi
|
||||
@mkdir -p logs
|
||||
@echo "$(YELLOW)Starting backend server...$(RESET)"
|
||||
@poetry run uvicorn openhands.server.listen:app --host $(BACKEND_HOST) --port $(BACKEND_PORT) &
|
||||
@$(PKG_RUN) uvicorn openhands.server.listen:app --host $(BACKEND_HOST) --port $(BACKEND_PORT) &
|
||||
@echo "$(YELLOW)Waiting for the backend to start...$(RESET)"
|
||||
@until nc -z localhost $(BACKEND_PORT); do sleep 0.1; done
|
||||
@echo "$(GREEN)Backend started successfully.$(RESET)"
|
||||
@@ -367,5 +415,5 @@ help:
|
||||
@echo " $(GREEN)help$(RESET) - Display this help message, providing information on available targets."
|
||||
|
||||
# Phony targets
|
||||
.PHONY: build check-dependencies check-system check-python check-npm check-nodejs check-docker check-poetry install-python-dependencies install-frontend-dependencies install-pre-commit-hooks lint-backend lint-frontend lint test-frontend test build-frontend start-backend start-frontend _run_setup run run-wsl setup-config setup-config-prompts setup-config-basic openhands-cloud-run docker-dev docker-run clean help
|
||||
.PHONY: build check-dependencies check-system check-python check-npm check-nodejs check-docker check-pkg-manager check-poetry install-python-dependencies install-frontend-dependencies install-pre-commit-hooks lint-backend lint-frontend lint test-frontend test build-frontend start-backend start-frontend _run_setup run run-wsl setup-config setup-config-prompts setup-config-basic openhands-cloud-run docker-dev docker-run clean help
|
||||
.PHONY: kind
|
||||
|
||||
@@ -54,7 +54,7 @@ The experience will be familiar to anyone who has used Devin or Jules.
|
||||
### OpenHands Cloud
|
||||
This is a deployment of OpenHands GUI, running on hosted infrastructure.
|
||||
|
||||
You can try it with a free $10 credit by [signing in with your GitHub account](https://app.all-hands.dev).
|
||||
You can try it with a free $10 credit by [signing in with your GitHub or GitLab account](https://app.all-hands.dev).
|
||||
|
||||
OpenHands Cloud comes with source-available features and integrations:
|
||||
- Integrations with Slack, Jira, and Linear
|
||||
|
||||
@@ -15,6 +15,9 @@ FROM base AS backend-builder
|
||||
WORKDIR /app
|
||||
ENV PYTHONPATH='/app'
|
||||
|
||||
# Package manager selection: set USE_UV=1 to use UV instead of Poetry
|
||||
ARG USE_UV=0
|
||||
|
||||
ENV POETRY_NO_INTERACTION=1 \
|
||||
POETRY_VIRTUALENVS_IN_PROJECT=1 \
|
||||
POETRY_VIRTUALENVS_CREATE=1 \
|
||||
@@ -22,11 +25,21 @@ ENV POETRY_NO_INTERACTION=1 \
|
||||
|
||||
RUN apt-get update -y \
|
||||
&& apt-get install -y curl make git build-essential jq gettext \
|
||||
&& python3 -m pip install poetry --break-system-packages
|
||||
&& python3 -m pip install poetry --break-system-packages \
|
||||
&& curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
COPY pyproject.toml poetry.lock ./
|
||||
# Copy both lock files for flexibility
|
||||
COPY pyproject.toml poetry.lock uv.lock ./
|
||||
RUN touch README.md
|
||||
RUN export POETRY_CACHE_DIR && poetry install --no-root && rm -rf $POETRY_CACHE_DIR
|
||||
|
||||
# Install dependencies using selected package manager
|
||||
RUN if [ "$USE_UV" = "1" ]; then \
|
||||
echo "Installing dependencies with UV..." && \
|
||||
/root/.local/bin/uv sync --no-dev; \
|
||||
else \
|
||||
echo "Installing dependencies with Poetry..." && \
|
||||
export POETRY_CACHE_DIR && poetry install --no-root && rm -rf $POETRY_CACHE_DIR; \
|
||||
fi
|
||||
|
||||
FROM base AS openhands-app
|
||||
|
||||
@@ -76,7 +89,7 @@ COPY --chown=openhands:openhands --chmod=770 --from=backend-builder ${VIRTUAL_EN
|
||||
COPY --chown=openhands:openhands --chmod=770 ./skills ./skills
|
||||
COPY --chown=openhands:openhands --chmod=770 ./openhands ./openhands
|
||||
COPY --chown=openhands:openhands --chmod=777 ./openhands/runtime/plugins ./openhands/runtime/plugins
|
||||
COPY --chown=openhands:openhands pyproject.toml poetry.lock README.md MANIFEST.in LICENSE ./
|
||||
COPY --chown=openhands:openhands pyproject.toml poetry.lock uv.lock README.md MANIFEST.in LICENSE ./
|
||||
|
||||
# This is run as "openhands" user, and will create __pycache__ with openhands:openhands ownership
|
||||
RUN python openhands/core/download.py # No-op to download assets
|
||||
|
||||
@@ -69,6 +69,10 @@ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||
RUN curl -fsSL https://install.python-poetry.org | python3.12 - \
|
||||
&& ln -s ~/.local/bin/poetry /usr/local/bin/poetry
|
||||
|
||||
# UV (alternative package manager)
|
||||
RUN curl -LsSf https://astral.sh/uv/install.sh | sh \
|
||||
&& ln -s ~/.local/bin/uv /usr/local/bin/uv
|
||||
|
||||
#
|
||||
RUN <<EOF
|
||||
#!/bin/bash
|
||||
@@ -80,9 +84,10 @@ gh --version | head -n 1
|
||||
git --version
|
||||
#
|
||||
python --version
|
||||
echo node `node --version`
|
||||
echo npm `npm --version`
|
||||
echo node \`node --version\`
|
||||
echo npm \`npm --version\`
|
||||
poetry --version
|
||||
uv --version
|
||||
netcat -h 2>&1 | head -n 1
|
||||
" > /version.sh
|
||||
chmod a+x /version.sh
|
||||
|
||||
@@ -10,6 +10,15 @@ repos:
|
||||
args: ["--allow-multiple-documents"]
|
||||
- id: debug-statements
|
||||
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: warn-appmode-oss
|
||||
name: "Warn on AppMode.OSS in backend (use AppMode.OPENHANDS)"
|
||||
language: system
|
||||
entry: bash -lc 'if rg -n "\\bAppMode\\.OSS\\b" openhands tests/unit; then echo "Found AppMode.OSS usage. Prefer AppMode.OPENHANDS."; exit 1; fi'
|
||||
pass_filenames: false
|
||||
|
||||
- repo: https://github.com/tox-dev/pyproject-fmt
|
||||
rev: v2.5.1
|
||||
hooks:
|
||||
|
||||
@@ -24,9 +24,9 @@ RUN apt-get update && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python packages with security fixes
|
||||
RUN pip install alembic psycopg2-binary cloud-sql-python-connector pg8000 gspread stripe python-keycloak asyncpg sqlalchemy[asyncio] resend tenacity slack-sdk ddtrace "posthog>=6.0.0" "limits==5.2.0" coredis prometheus-client shap scikit-learn pandas numpy && \
|
||||
RUN /app/.venv/bin/pip install alembic psycopg2-binary cloud-sql-python-connector pg8000 gspread stripe python-keycloak asyncpg sqlalchemy[asyncio] resend tenacity slack-sdk ddtrace "posthog>=6.0.0" "limits==5.2.0" coredis prometheus-client shap scikit-learn pandas numpy google-cloud-recaptcha-enterprise && \
|
||||
# Update packages with known CVE fixes
|
||||
pip install --upgrade \
|
||||
/app/.venv/bin/pip install --upgrade \
|
||||
"mcp>=1.10.0" \
|
||||
"pillow>=11.3.0"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# OpenHands Enterprise Server
|
||||
> [!WARNING]
|
||||
> This software is licensed under the [Polyform Free Trial License](./LICENSE). This is **NOT** an open source license. Usage is limited to 30 days per calendar year without a commercial license. If you would like to use it beyond 30 days, please [contact us](https://www.all-hands.dev/contact).
|
||||
> This software is licensed under the [Polyform Free Trial License](./LICENSE). This is **NOT** an open source license. Usage is limited to 30 days per calendar year without a commercial license. If you would like to use it beyond 30 days, please [contact us](https://www.openhands.dev/contact).
|
||||
|
||||
> [!WARNING]
|
||||
> This is a work in progress and may contain bugs, incomplete features, or breaking changes.
|
||||
@@ -10,13 +10,13 @@ This directory contains the enterprise server used by [OpenHands Cloud](https://
|
||||
|
||||
You may also want to check out the MIT-licensed [OpenHands](https://github.com/OpenHands/OpenHands)
|
||||
|
||||
## Extension of OpenHands (OSS)
|
||||
## Extension of OpenHands
|
||||
|
||||
The code in `/enterprise` directory builds on top of open source (OSS) code, extending its functionality. The enterprise code is entangled with the OSS code in two ways
|
||||
The code in `/enterprise` builds on top of OpenHands (MIT-licensed), extending its functionality. The enterprise code is entangled with OpenHands in two ways:
|
||||
|
||||
- Enterprise stacks on top of OSS. For example, the middleware in enterprise is stacked right on top of the middlewares in OSS. In `SAAS`, the middleware from BOTH repos will be present and running (which can sometimes cause conflicts)
|
||||
- Enterprise stacks on top of OpenHands. For example, the middleware in enterprise is stacked right on top of the middlewares in OpenHands. In `SAAS`, the middleware from BOTH repos will be present and running (which can sometimes cause conflicts)
|
||||
|
||||
- Enterprise overrides the implementation in OSS (only one is present at a time). For example, the server config SaasServerConfig which overrides [`ServerConfig`](https://github.com/OpenHands/OpenHands/blob/main/openhands/server/config/server_config.py#L8) on OSS. This is done through dynamic imports ([see here](https://github.com/OpenHands/OpenHands/blob/main/openhands/server/config/server_config.py#L37-#L45))
|
||||
- Enterprise overrides the implementation in OpenHands (only one is present at a time). For example, the server config SaasServerConfig overrides [`ServerConfig`](https://github.com/OpenHands/OpenHands/blob/main/openhands/server/config/server_config.py#L8) in OpenHands. This is done through dynamic imports ([see here](https://github.com/OpenHands/OpenHands/blob/main/openhands/server/config/server_config.py#L37-#L45))
|
||||
|
||||
Key areas that change on `SAAS` are
|
||||
|
||||
@@ -26,11 +26,11 @@ Key areas that change on `SAAS` are
|
||||
|
||||
### Authentication
|
||||
|
||||
| Aspect | OSS | Enterprise |
|
||||
| Aspect | OpenHands | Enterprise |
|
||||
| ------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Authentication Method** | User adds a personal access token (PAT) through the UI | User performs OAuth through the UI. The Github app provides a short-lived access token and refresh token |
|
||||
| **Authentication Method** | User adds a personal access token (PAT) through the UI | User performs OAuth through the UI. The GitHub app provides a short-lived access token and refresh token |
|
||||
| **Token Storage** | PAT is stored in **Settings** | Token is stored in **GithubTokenManager** (a file store in our backend) |
|
||||
| **Authenticated status** | We simply check if token exists in `Settings` | We issue a signed cookie with `github_user_id` during oauth, so subsequent requests with the cookie can be considered authenticated |
|
||||
| **Authenticated status** | We simply check if token exists in `Settings` | We issue a signed cookie with `github_user_id` during OAuth, so subsequent requests with the cookie can be considered authenticated |
|
||||
|
||||
Note that in the future, authentication will happen via keycloak. All modifications for authentication will happen in enterprise.
|
||||
|
||||
@@ -38,7 +38,7 @@ Note that in the future, authentication will happen via keycloak. All modificati
|
||||
|
||||
The github service is responsible for interacting with Github APIs. As a consequence, it uses the user's token and refreshes it if need be
|
||||
|
||||
| Aspect | OSS | Enterprise |
|
||||
| Aspect | OpenHands | Enterprise |
|
||||
| ------------------------- | -------------------------------------- | ---------------------------------------------- |
|
||||
| **Class used** | `GitHubService` | `SaaSGitHubService` |
|
||||
| **Token used** | User's PAT fetched from `Settings` | User's token fetched from `GitHubTokenManager` |
|
||||
@@ -50,7 +50,7 @@ NOTE: in the future we will simply replace the `GithubTokenManager` with keycloa
|
||||
|
||||
## User ID vs User Token
|
||||
|
||||
- On OSS, the entire APP revolves around the Github token the user sets. `openhands/server` uses `request.state.github_token` for the entire app
|
||||
- In OpenHands, the entire app revolves around the GitHub token the user sets. `openhands/server` uses `request.state.github_token` for the entire app
|
||||
- On Enterprise, the entire APP resolves around the Github User ID. This is because the cookie sets it, so `openhands/server` AND `enterprise/server` depend on it and completly ignore `request.state.github_token` (token is fetched from `GithubTokenManager` instead)
|
||||
|
||||
Note that introducing Github User ID on OSS, for instance, will cause large breakages.
|
||||
Note that introducing GitHub User ID in OpenHands, for instance, will cause large breakages.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
You have a few options here, which are expanded on below:
|
||||
|
||||
- A simple local development setup, with live reloading for both OSS and this repo
|
||||
- A simple local development setup, with live reloading for both OpenHands and this repo
|
||||
- A more complex setup that includes Redis
|
||||
- An even more complex setup that includes GitHub events
|
||||
|
||||
@@ -26,7 +26,7 @@ Before starting, make sure you have the following tools installed:
|
||||
|
||||
## Option 1: Simple local development
|
||||
|
||||
This option will allow you to modify the both the OSS code and the code in this repo,
|
||||
This option will allow you to modify both the OpenHands code and the code in this repo,
|
||||
and see the changes in real-time.
|
||||
|
||||
This option works best for most scenarios. The only thing it's missing is
|
||||
@@ -105,9 +105,9 @@ export REDIS_PORT=6379
|
||||
|
||||
(see above)
|
||||
|
||||
### 2. Build OSS Openhands
|
||||
### 2. Build OpenHands
|
||||
|
||||
Develop on [Openhands](https://github.com/All-Hands-AI/OpenHands) locally. When ready, run the following inside Openhands repo (not the Deploy repo)
|
||||
Develop on [Openhands](https://github.com/OpenHands/OpenHands) locally. When ready, run the following inside Openhands repo (not the Deploy repo)
|
||||
|
||||
```
|
||||
docker build -f containers/app/Dockerfile -t openhands .
|
||||
@@ -155,7 +155,7 @@ Visit the tunnel domain found in Step 4 to run the app (`https://bc71-2603-7000-
|
||||
|
||||
### Local Debugging with VSCode
|
||||
|
||||
Local Development necessitates running a version of OpenHands that is as similar as possible to the version running in the SAAS Environment. Before running these steps, it is assumed you have a local development version of the OSS OpenHands project running.
|
||||
Local Development necessitates running a version of OpenHands that is as similar as possible to the version running in the SAAS Environment. Before running these steps, it is assumed you have a local development version of OpenHands running.
|
||||
|
||||
#### Redis
|
||||
|
||||
@@ -201,8 +201,8 @@ And then invoking `printenv`. NOTE: _DO NOT DO THIS WITH PROD!!!_ (Hopefully by
|
||||
"DEBUG": "1",
|
||||
"FILE_STORE": "local",
|
||||
"REDIS_HOST": "localhost:6379",
|
||||
"OPENHANDS": "<YOUR LOCAL OSS OPENHANDS DIR>",
|
||||
"FRONTEND_DIRECTORY": "<YOUR LOCAL OSS OPENHANDS DIR>/frontend/build",
|
||||
"OPENHANDS": "<YOUR LOCAL OPENHANDS DIR>",
|
||||
"FRONTEND_DIRECTORY": "<YOUR LOCAL OPENHANDS DIR>/frontend/build",
|
||||
"SANDBOX_RUNTIME_CONTAINER_IMAGE": "ghcr.io/openhands/runtime:main-nikolaik",
|
||||
"FILE_STORE_PATH": "<YOUR HOME DIRECTORY>>/.openhands-state",
|
||||
"OPENHANDS_CONFIG_CLS": "server.config.SaaSServerConfig",
|
||||
@@ -235,8 +235,8 @@ And then invoking `printenv`. NOTE: _DO NOT DO THIS WITH PROD!!!_ (Hopefully by
|
||||
"DEBUG": "1",
|
||||
"FILE_STORE": "local",
|
||||
"REDIS_HOST": "localhost:6379",
|
||||
"OPENHANDS": "<YOUR LOCAL OSS OPENHANDS DIR>",
|
||||
"FRONTEND_DIRECTORY": "<YOUR LOCAL OSS OPENHANDS DIR>/frontend/build",
|
||||
"OPENHANDS": "<YOUR LOCAL OPENHANDS DIR>",
|
||||
"FRONTEND_DIRECTORY": "<YOUR LOCAL OPENHANDS DIR>/frontend/build",
|
||||
"SANDBOX_RUNTIME_CONTAINER_IMAGE": "ghcr.io/openhands/runtime:main-nikolaik",
|
||||
"FILE_STORE_PATH": "<YOUR HOME DIRECTORY>>/.openhands-state",
|
||||
"OPENHANDS_CONFIG_CLS": "server.config.SaaSServerConfig",
|
||||
|
||||
@@ -21,6 +21,7 @@ from integrations.utils import (
|
||||
CONVERSATION_URL,
|
||||
HOST_URL,
|
||||
OPENHANDS_RESOLVER_TEMPLATES_DIR,
|
||||
get_session_expired_message,
|
||||
)
|
||||
from integrations.v1_utils import get_saas_user_auth
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
@@ -31,7 +32,11 @@ from server.utils.conversation_callback_utils import register_callback_processor
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import ProviderToken, ProviderType
|
||||
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
|
||||
from openhands.server.types import (
|
||||
LLMAuthenticationError,
|
||||
MissingSettingsError,
|
||||
SessionExpiredError,
|
||||
)
|
||||
from openhands.storage.data_models.secrets import Secrets
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
|
||||
@@ -305,7 +310,7 @@ class GithubManager(Manager):
|
||||
f'[GitHub] Created conversation {conversation_id} for user {user_info.username}'
|
||||
)
|
||||
|
||||
if not github_view.v1:
|
||||
if not github_view.v1_enabled:
|
||||
# Create a GithubCallbackProcessor
|
||||
processor = GithubCallbackProcessor(
|
||||
github_view=github_view,
|
||||
@@ -342,6 +347,13 @@ class GithubManager(Manager):
|
||||
|
||||
msg_info = f'@{user_info.username} please set a valid LLM API key in [OpenHands Cloud]({HOST_URL}) before starting a job.'
|
||||
|
||||
except SessionExpiredError as e:
|
||||
logger.warning(
|
||||
f'[GitHub] Session expired for user {user_info.username}: {str(e)}'
|
||||
)
|
||||
|
||||
msg_info = get_session_expired_message(user_info.username)
|
||||
|
||||
msg = self.create_outgoing_message(msg_info)
|
||||
await self.send_message(msg, github_view)
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
from github import Auth, Github, GithubIntegration
|
||||
from integrations.utils import CONVERSATION_URL, get_summary_instruction
|
||||
from pydantic import Field
|
||||
from server.auth.constants import GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
|
||||
|
||||
from openhands.agent_server.models import AskAgentRequest, AskAgentResponse
|
||||
from openhands.app_server.event_callback.event_callback_models import (
|
||||
@@ -20,8 +21,6 @@ from openhands.app_server.event_callback.util import (
|
||||
ensure_conversation_found,
|
||||
ensure_running_sandbox,
|
||||
get_agent_server_url_from_sandbox,
|
||||
get_conversation_url,
|
||||
get_prompt_template,
|
||||
)
|
||||
from openhands.sdk import Event
|
||||
from openhands.sdk.event import ConversationStateUpdateEvent
|
||||
@@ -34,7 +33,6 @@ class GithubV1CallbackProcessor(EventCallbackProcessor):
|
||||
|
||||
github_view_data: dict[str, Any] = Field(default_factory=dict)
|
||||
should_request_summary: bool = Field(default=True)
|
||||
should_extract: bool = Field(default=True)
|
||||
inline_pr_comment: bool = Field(default=False)
|
||||
|
||||
async def __call__(
|
||||
@@ -64,7 +62,12 @@ class GithubV1CallbackProcessor(EventCallbackProcessor):
|
||||
self.should_request_summary = False
|
||||
|
||||
try:
|
||||
_logger.info(f'[GitHub V1] Requesting summary {conversation_id}')
|
||||
summary = await self._request_summary(conversation_id)
|
||||
_logger.info(
|
||||
f'[GitHub V1] Posting summary {conversation_id}',
|
||||
extra={'summary': summary},
|
||||
)
|
||||
await self._post_summary_to_github(summary)
|
||||
|
||||
return EventCallbackResult(
|
||||
@@ -82,12 +85,12 @@ class GithubV1CallbackProcessor(EventCallbackProcessor):
|
||||
# Check if we have installation ID and credentials before posting
|
||||
if (
|
||||
self.github_view_data.get('installation_id')
|
||||
and os.getenv('GITHUB_APP_CLIENT_ID')
|
||||
and os.getenv('GITHUB_APP_PRIVATE_KEY')
|
||||
and GITHUB_APP_CLIENT_ID
|
||||
and GITHUB_APP_PRIVATE_KEY
|
||||
):
|
||||
await self._post_summary_to_github(
|
||||
f'OpenHands encountered an error: **{str(e)}**.\n\n'
|
||||
f'[See the conversation]({get_conversation_url().format(conversation_id)})'
|
||||
f'[See the conversation]({CONVERSATION_URL.format(conversation_id)})'
|
||||
'for more information.'
|
||||
)
|
||||
except Exception as post_error:
|
||||
@@ -115,16 +118,11 @@ class GithubV1CallbackProcessor(EventCallbackProcessor):
|
||||
f'Missing installation ID for GitHub payload: {self.github_view_data}'
|
||||
)
|
||||
|
||||
github_app_client_id = os.getenv('GITHUB_APP_CLIENT_ID', '').strip()
|
||||
github_app_private_key = os.getenv('GITHUB_APP_PRIVATE_KEY', '').replace(
|
||||
'\\n', '\n'
|
||||
)
|
||||
|
||||
if not github_app_client_id or not github_app_private_key:
|
||||
if not GITHUB_APP_CLIENT_ID or not GITHUB_APP_PRIVATE_KEY:
|
||||
raise ValueError('GitHub App credentials are not configured')
|
||||
|
||||
github_integration = GithubIntegration(
|
||||
auth=Auth.AppAuth(github_app_client_id, github_app_private_key),
|
||||
auth=Auth.AppAuth(GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY),
|
||||
)
|
||||
token_data = github_integration.get_access_token(installation_id)
|
||||
return token_data.token
|
||||
@@ -140,7 +138,7 @@ class GithubV1CallbackProcessor(EventCallbackProcessor):
|
||||
issue_number = self.github_view_data['issue_number']
|
||||
|
||||
if self.inline_pr_comment:
|
||||
with Github(installation_token) as github_client:
|
||||
with Github(auth=Auth.Token(installation_token)) as github_client:
|
||||
repo = github_client.get_repo(full_repo_name)
|
||||
pr = repo.get_pull(issue_number)
|
||||
pr.create_review_comment_reply(
|
||||
@@ -148,7 +146,7 @@ class GithubV1CallbackProcessor(EventCallbackProcessor):
|
||||
)
|
||||
return
|
||||
|
||||
with Github(installation_token) as github_client:
|
||||
with Github(auth=Auth.Token(installation_token)) as github_client:
|
||||
repo = github_client.get_repo(full_repo_name)
|
||||
issue = repo.get_issue(number=issue_number)
|
||||
issue.create_comment(summary)
|
||||
@@ -274,16 +272,16 @@ class GithubV1CallbackProcessor(EventCallbackProcessor):
|
||||
app_conversation_info.sandbox_id,
|
||||
)
|
||||
|
||||
assert sandbox.session_api_key is not None, (
|
||||
f'No session API key for sandbox: {sandbox.id}'
|
||||
)
|
||||
assert (
|
||||
sandbox.session_api_key is not None
|
||||
), f'No session API key for sandbox: {sandbox.id}'
|
||||
|
||||
# 3. URL + instruction
|
||||
agent_server_url = get_agent_server_url_from_sandbox(sandbox)
|
||||
agent_server_url = get_agent_server_url_from_sandbox(sandbox)
|
||||
|
||||
# Prepare message based on agent state
|
||||
message_content = get_prompt_template('summary_prompt.j2')
|
||||
message_content = get_summary_instruction()
|
||||
|
||||
# Ask the agent and return the response text
|
||||
return await self._ask_question(
|
||||
@@ -17,6 +17,7 @@ from integrations.utils import (
|
||||
HOST,
|
||||
HOST_URL,
|
||||
get_oh_labels,
|
||||
get_user_v1_enabled_setting,
|
||||
has_exact_mention,
|
||||
)
|
||||
from jinja2 import Environment
|
||||
@@ -55,6 +56,10 @@ from openhands.utils.async_utils import call_sync_from_async
|
||||
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)
|
||||
|
||||
|
||||
async def is_v1_enabled_for_github_resolver(user_id: str) -> bool:
|
||||
return await get_user_v1_enabled_setting(user_id) and ENABLE_V1_GITHUB_RESOLVER
|
||||
|
||||
|
||||
async def get_user_proactive_conversation_setting(user_id: str | None) -> bool:
|
||||
"""Get the user's proactive conversation setting.
|
||||
|
||||
@@ -88,38 +93,6 @@ async def get_user_proactive_conversation_setting(user_id: str | None) -> bool:
|
||||
return settings.enable_proactive_conversation_starters
|
||||
|
||||
|
||||
async def get_user_v1_enabled_setting(user_id: str) -> bool:
|
||||
"""Get the user's V1 conversation API setting.
|
||||
|
||||
Args:
|
||||
user_id: The keycloak user ID
|
||||
|
||||
Returns:
|
||||
True if V1 conversations are enabled for this user, False otherwise
|
||||
|
||||
Note:
|
||||
This function checks both the global environment variable kill switch AND
|
||||
the user's individual setting. Both must be true for the function to return true.
|
||||
"""
|
||||
# Check the global environment variable first
|
||||
if not ENABLE_V1_GITHUB_RESOLVER:
|
||||
return False
|
||||
|
||||
config = get_config()
|
||||
settings_store = SaasSettingsStore(
|
||||
user_id=user_id, session_maker=session_maker, config=config
|
||||
)
|
||||
|
||||
settings = await call_sync_from_async(
|
||||
settings_store.get_user_settings_by_keycloak_id, user_id
|
||||
)
|
||||
|
||||
if not settings or settings.v1_enabled is None:
|
||||
return False
|
||||
|
||||
return settings.v1_enabled
|
||||
|
||||
|
||||
# =================================================
|
||||
# SECTION: Github view types
|
||||
# =================================================
|
||||
@@ -140,7 +113,10 @@ class GithubIssue(ResolverViewInterface):
|
||||
title: str
|
||||
description: str
|
||||
previous_comments: list[Comment]
|
||||
v1: bool
|
||||
v1_enabled: bool
|
||||
|
||||
def _get_branch_name(self) -> str | None:
|
||||
return getattr(self, 'branch_name', None)
|
||||
|
||||
async def _load_resolver_context(self):
|
||||
github_service = GithubServiceImpl(
|
||||
@@ -188,23 +164,28 @@ class GithubIssue(ResolverViewInterface):
|
||||
async def initialize_new_conversation(self) -> ConversationMetadata:
|
||||
# FIXME: Handle if initialize_conversation returns None
|
||||
|
||||
v1_enabled = await get_user_v1_enabled_setting(self.user_info.keycloak_user_id)
|
||||
logger.info(
|
||||
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {v1_enabled}'
|
||||
self.v1_enabled = await is_v1_enabled_for_github_resolver(
|
||||
self.user_info.keycloak_user_id
|
||||
)
|
||||
if v1_enabled:
|
||||
|
||||
logger.info(
|
||||
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {self.v1_enabled}'
|
||||
)
|
||||
if self.v1_enabled:
|
||||
# Create dummy conversationm metadata
|
||||
# Don't save to conversation store
|
||||
# V1 conversations are stored in a separate table
|
||||
self.conversation_id = uuid4().hex
|
||||
return ConversationMetadata(
|
||||
conversation_id=uuid4().hex, selected_repository=self.full_repo_name
|
||||
conversation_id=self.conversation_id,
|
||||
selected_repository=self.full_repo_name,
|
||||
)
|
||||
|
||||
conversation_metadata: ConversationMetadata = await initialize_conversation( # type: ignore[assignment]
|
||||
user_id=self.user_info.keycloak_user_id,
|
||||
conversation_id=None,
|
||||
selected_repository=self.full_repo_name,
|
||||
selected_branch=None,
|
||||
selected_branch=self._get_branch_name(),
|
||||
conversation_trigger=ConversationTrigger.RESOLVER,
|
||||
git_provider=ProviderType.GITHUB,
|
||||
)
|
||||
@@ -218,25 +199,18 @@ class GithubIssue(ResolverViewInterface):
|
||||
conversation_metadata: ConversationMetadata,
|
||||
saas_user_auth: UserAuth,
|
||||
):
|
||||
v1_enabled = await get_user_v1_enabled_setting(self.user_info.keycloak_user_id)
|
||||
logger.info(
|
||||
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {v1_enabled}'
|
||||
)
|
||||
if v1_enabled:
|
||||
try:
|
||||
# Use V1 app conversation service
|
||||
await self._create_v1_conversation(
|
||||
jinja_env, saas_user_auth, conversation_metadata
|
||||
)
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f'Error checking V1 settings, falling back to V0: {e}')
|
||||
|
||||
# Use existing V0 conversation service
|
||||
await self._create_v0_conversation(
|
||||
jinja_env, git_provider_tokens, conversation_metadata
|
||||
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {self.v1_enabled}'
|
||||
)
|
||||
if self.v1_enabled:
|
||||
# Use V1 app conversation service
|
||||
await self._create_v1_conversation(
|
||||
jinja_env, saas_user_auth, conversation_metadata
|
||||
)
|
||||
else:
|
||||
await self._create_v0_conversation(
|
||||
jinja_env, git_provider_tokens, conversation_metadata
|
||||
)
|
||||
|
||||
async def _create_v0_conversation(
|
||||
self,
|
||||
@@ -294,6 +268,7 @@ class GithubIssue(ResolverViewInterface):
|
||||
system_message_suffix=conversation_instructions,
|
||||
initial_message=initial_message,
|
||||
selected_repository=self.full_repo_name,
|
||||
selected_branch=self._get_branch_name(),
|
||||
git_provider=ProviderType.GITHUB,
|
||||
title=f'GitHub Issue #{self.issue_number}: {self.title}',
|
||||
trigger=ConversationTrigger.RESOLVER,
|
||||
@@ -318,11 +293,9 @@ class GithubIssue(ResolverViewInterface):
|
||||
f'Failed to start V1 conversation: {task.detail}'
|
||||
)
|
||||
|
||||
self.v1 = True
|
||||
|
||||
def _create_github_v1_callback_processor(self):
|
||||
"""Create a V1 callback processor for GitHub integration."""
|
||||
from openhands.app_server.event_callback.github_v1_callback_processor import (
|
||||
from integrations.github.github_v1_callback_processor import (
|
||||
GithubV1CallbackProcessor,
|
||||
)
|
||||
|
||||
@@ -390,31 +363,6 @@ class GithubPRComment(GithubIssueComment):
|
||||
|
||||
return user_instructions, conversation_instructions
|
||||
|
||||
async def initialize_new_conversation(self) -> ConversationMetadata:
|
||||
v1_enabled = await get_user_v1_enabled_setting(self.user_info.keycloak_user_id)
|
||||
logger.info(
|
||||
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {v1_enabled}'
|
||||
)
|
||||
if v1_enabled:
|
||||
# Create dummy conversationm metadata
|
||||
# Don't save to conversation store
|
||||
# V1 conversations are stored in a separate table
|
||||
return ConversationMetadata(
|
||||
conversation_id=uuid4().hex, selected_repository=self.full_repo_name
|
||||
)
|
||||
|
||||
conversation_metadata: ConversationMetadata = await initialize_conversation( # type: ignore[assignment]
|
||||
user_id=self.user_info.keycloak_user_id,
|
||||
conversation_id=None,
|
||||
selected_repository=self.full_repo_name,
|
||||
selected_branch=self.branch_name,
|
||||
conversation_trigger=ConversationTrigger.RESOLVER,
|
||||
git_provider=ProviderType.GITHUB,
|
||||
)
|
||||
|
||||
self.conversation_id = conversation_metadata.conversation_id
|
||||
return conversation_metadata
|
||||
|
||||
|
||||
@dataclass
|
||||
class GithubInlinePRComment(GithubPRComment):
|
||||
@@ -464,7 +412,7 @@ class GithubInlinePRComment(GithubPRComment):
|
||||
|
||||
def _create_github_v1_callback_processor(self):
|
||||
"""Create a V1 callback processor for GitHub integration."""
|
||||
from openhands.app_server.event_callback.github_v1_callback_processor import (
|
||||
from integrations.github.github_v1_callback_processor import (
|
||||
GithubV1CallbackProcessor,
|
||||
)
|
||||
|
||||
@@ -830,7 +778,7 @@ class GithubFactory:
|
||||
title='',
|
||||
description='',
|
||||
previous_comments=[],
|
||||
v1=False,
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
elif GithubFactory.is_issue_comment(message):
|
||||
@@ -856,7 +804,7 @@ class GithubFactory:
|
||||
title='',
|
||||
description='',
|
||||
previous_comments=[],
|
||||
v1=False,
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
elif GithubFactory.is_pr_comment(message):
|
||||
@@ -898,7 +846,7 @@ class GithubFactory:
|
||||
title='',
|
||||
description='',
|
||||
previous_comments=[],
|
||||
v1=False,
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
elif GithubFactory.is_inline_pr_comment(message):
|
||||
@@ -932,7 +880,7 @@ class GithubFactory:
|
||||
title='',
|
||||
description='',
|
||||
previous_comments=[],
|
||||
v1=False,
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
else:
|
||||
|
||||
@@ -15,6 +15,7 @@ from integrations.utils import (
|
||||
CONVERSATION_URL,
|
||||
HOST_URL,
|
||||
OPENHANDS_RESOLVER_TEMPLATES_DIR,
|
||||
get_session_expired_message,
|
||||
)
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from pydantic import SecretStr
|
||||
@@ -24,7 +25,11 @@ from server.utils.conversation_callback_utils import register_callback_processor
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
|
||||
from openhands.integrations.provider import ProviderToken, ProviderType
|
||||
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
|
||||
from openhands.server.types import (
|
||||
LLMAuthenticationError,
|
||||
MissingSettingsError,
|
||||
SessionExpiredError,
|
||||
)
|
||||
from openhands.storage.data_models.secrets import Secrets
|
||||
|
||||
|
||||
@@ -249,6 +254,13 @@ class GitlabManager(Manager):
|
||||
|
||||
msg_info = f'@{user_info.username} please set a valid LLM API key in [OpenHands Cloud]({HOST_URL}) before starting a job.'
|
||||
|
||||
except SessionExpiredError as e:
|
||||
logger.warning(
|
||||
f'[GitLab] Session expired for user {user_info.username}: {str(e)}'
|
||||
)
|
||||
|
||||
msg_info = get_session_expired_message(user_info.username)
|
||||
|
||||
# Send the acknowledgment message
|
||||
msg = self.create_outgoing_message(msg_info)
|
||||
await self.send_message(msg, gitlab_view)
|
||||
|
||||
@@ -80,22 +80,52 @@ class SaaSGitLabService(GitLabService):
|
||||
logger.warning('external_auth_token and user_id not set!')
|
||||
return gitlab_token
|
||||
|
||||
async def get_owned_groups(self) -> list[dict]:
|
||||
async def get_owned_groups(self, min_access_level: int = 40) -> list[dict]:
|
||||
"""
|
||||
Get all groups for which the current user is the owner.
|
||||
Get all top-level groups where the current user has admin access.
|
||||
|
||||
This method supports pagination and fetches all groups where the user has
|
||||
at least the specified access level.
|
||||
|
||||
Args:
|
||||
min_access_level: Minimum access level required (default: 40 for Maintainer or Owner)
|
||||
- 40: Maintainer or Owner
|
||||
- 50: Owner only
|
||||
|
||||
Returns:
|
||||
list[dict]: A list of groups owned by the current user.
|
||||
list[dict]: A list of groups where user has the specified access level or higher.
|
||||
"""
|
||||
url = f'{self.BASE_URL}/groups'
|
||||
params = {'owned': 'true', 'per_page': 100, 'top_level_only': 'true'}
|
||||
groups_with_admin_access = []
|
||||
page = 1
|
||||
per_page = 100
|
||||
|
||||
try:
|
||||
response, headers = await self._make_request(url, params)
|
||||
return response
|
||||
except Exception:
|
||||
logger.warning('Error fetching owned groups', exc_info=True)
|
||||
return []
|
||||
while True:
|
||||
try:
|
||||
url = f'{self.BASE_URL}/groups'
|
||||
params = {
|
||||
'page': str(page),
|
||||
'per_page': str(per_page),
|
||||
'min_access_level': min_access_level,
|
||||
'top_level_only': 'true',
|
||||
}
|
||||
response, headers = await self._make_request(url, params)
|
||||
|
||||
if not response:
|
||||
break
|
||||
|
||||
groups_with_admin_access.extend(response)
|
||||
page += 1
|
||||
|
||||
# Check if we've reached the last page
|
||||
link_header = headers.get('Link', '')
|
||||
if 'rel="next"' not in link_header:
|
||||
break
|
||||
|
||||
except Exception:
|
||||
logger.warning(f'Error fetching groups on page {page}', exc_info=True)
|
||||
break
|
||||
|
||||
return groups_with_admin_access
|
||||
|
||||
async def add_owned_projects_and_groups_to_db(self, owned_personal_projects):
|
||||
"""
|
||||
@@ -527,3 +557,55 @@ class SaaSGitLabService(GitLabService):
|
||||
await self._make_request(url=url, params=params, method=RequestMethod.POST)
|
||||
except Exception as e:
|
||||
logger.exception(f'[GitLab]: Reply to MR failed {e}')
|
||||
|
||||
async def get_user_resources_with_admin_access(
|
||||
self,
|
||||
) -> tuple[list[dict], list[dict]]:
|
||||
"""
|
||||
Get all projects and groups where the current user has admin access (maintainer or owner).
|
||||
|
||||
Returns:
|
||||
tuple[list[dict], list[dict]]: A tuple containing:
|
||||
- list of projects where user has admin access
|
||||
- list of groups where user has admin access
|
||||
"""
|
||||
projects_with_admin_access = []
|
||||
groups_with_admin_access = []
|
||||
|
||||
# Fetch all projects the user is a member of
|
||||
page = 1
|
||||
per_page = 100
|
||||
while True:
|
||||
try:
|
||||
url = f'{self.BASE_URL}/projects'
|
||||
params = {
|
||||
'page': str(page),
|
||||
'per_page': str(per_page),
|
||||
'membership': 1,
|
||||
'min_access_level': 40, # Maintainer or Owner
|
||||
}
|
||||
response, headers = await self._make_request(url, params)
|
||||
|
||||
if not response:
|
||||
break
|
||||
|
||||
projects_with_admin_access.extend(response)
|
||||
page += 1
|
||||
|
||||
# Check if we've reached the last page
|
||||
link_header = headers.get('Link', '')
|
||||
if 'rel="next"' not in link_header:
|
||||
break
|
||||
|
||||
except Exception:
|
||||
logger.warning(f'Error fetching projects on page {page}', exc_info=True)
|
||||
break
|
||||
|
||||
# Fetch all groups where user is owner or maintainer
|
||||
groups_with_admin_access = await self.get_owned_groups(min_access_level=40)
|
||||
|
||||
logger.info(
|
||||
f'Found {len(projects_with_admin_access)} projects and {len(groups_with_admin_access)} groups with admin access'
|
||||
)
|
||||
|
||||
return projects_with_admin_access, groups_with_admin_access
|
||||
|
||||
199
enterprise/integrations/gitlab/webhook_installation.py
Normal file
199
enterprise/integrations/gitlab/webhook_installation.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""Shared utilities for GitLab webhook installation.
|
||||
|
||||
This module contains reusable functions and classes for installing GitLab webhooks
|
||||
that can be used by both the cron job and API routes.
|
||||
"""
|
||||
|
||||
from typing import cast
|
||||
from uuid import uuid4
|
||||
|
||||
from integrations.types import GitLabResourceType
|
||||
from integrations.utils import GITLAB_WEBHOOK_URL
|
||||
from storage.gitlab_webhook import GitlabWebhook, WebhookStatus
|
||||
from storage.gitlab_webhook_store import GitlabWebhookStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.service_types import GitService
|
||||
|
||||
# Webhook configuration constants
|
||||
WEBHOOK_NAME = 'OpenHands Resolver'
|
||||
SCOPES: list[str] = [
|
||||
'note_events',
|
||||
'merge_requests_events',
|
||||
'confidential_issues_events',
|
||||
'issues_events',
|
||||
'confidential_note_events',
|
||||
'job_events',
|
||||
'pipeline_events',
|
||||
]
|
||||
|
||||
|
||||
class BreakLoopException(Exception):
|
||||
"""Exception raised when webhook installation conditions are not met or rate limited."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
async def verify_webhook_conditions(
|
||||
gitlab_service: type[GitService],
|
||||
resource_type: GitLabResourceType,
|
||||
resource_id: str,
|
||||
webhook_store: GitlabWebhookStore,
|
||||
webhook: GitlabWebhook,
|
||||
) -> None:
|
||||
"""
|
||||
Verify all conditions are met for webhook installation.
|
||||
Raises BreakLoopException if any condition fails or rate limited.
|
||||
|
||||
Args:
|
||||
gitlab_service: GitLab service instance
|
||||
resource_type: Type of resource (PROJECT or GROUP)
|
||||
resource_id: ID of the resource
|
||||
webhook_store: Webhook store instance
|
||||
webhook: Webhook object to verify
|
||||
"""
|
||||
from integrations.gitlab.gitlab_service import SaaSGitLabService
|
||||
|
||||
gitlab_service = cast(type[SaaSGitLabService], gitlab_service)
|
||||
|
||||
# Check if resource exists
|
||||
does_resource_exist, status = await gitlab_service.check_resource_exists(
|
||||
resource_type, resource_id
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Does resource exists',
|
||||
extra={
|
||||
'does_resource_exist': does_resource_exist,
|
||||
'status': status,
|
||||
'resource_id': resource_id,
|
||||
'resource_type': resource_type,
|
||||
},
|
||||
)
|
||||
|
||||
if status == WebhookStatus.RATE_LIMITED:
|
||||
raise BreakLoopException()
|
||||
if not does_resource_exist and status != WebhookStatus.RATE_LIMITED:
|
||||
await webhook_store.delete_webhook(webhook)
|
||||
raise BreakLoopException()
|
||||
|
||||
# Check if user has admin access
|
||||
(
|
||||
is_user_admin_of_resource,
|
||||
status,
|
||||
) = await gitlab_service.check_user_has_admin_access_to_resource(
|
||||
resource_type, resource_id
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Is user admin',
|
||||
extra={
|
||||
'is_user_admin': is_user_admin_of_resource,
|
||||
'status': status,
|
||||
'resource_id': resource_id,
|
||||
'resource_type': resource_type,
|
||||
},
|
||||
)
|
||||
|
||||
if status == WebhookStatus.RATE_LIMITED:
|
||||
raise BreakLoopException()
|
||||
if not is_user_admin_of_resource:
|
||||
await webhook_store.delete_webhook(webhook)
|
||||
raise BreakLoopException()
|
||||
|
||||
# Check if webhook already exists
|
||||
(
|
||||
does_webhook_exist_on_resource,
|
||||
status,
|
||||
) = await gitlab_service.check_webhook_exists_on_resource(
|
||||
resource_type, resource_id, GITLAB_WEBHOOK_URL
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Does webhook already exist',
|
||||
extra={
|
||||
'does_webhook_exist_on_resource': does_webhook_exist_on_resource,
|
||||
'status': status,
|
||||
'resource_id': resource_id,
|
||||
'resource_type': resource_type,
|
||||
},
|
||||
)
|
||||
|
||||
if status == WebhookStatus.RATE_LIMITED:
|
||||
raise BreakLoopException()
|
||||
if does_webhook_exist_on_resource != webhook.webhook_exists:
|
||||
await webhook_store.update_webhook(
|
||||
webhook, {'webhook_exists': does_webhook_exist_on_resource}
|
||||
)
|
||||
|
||||
if does_webhook_exist_on_resource:
|
||||
raise BreakLoopException()
|
||||
|
||||
|
||||
async def install_webhook_on_resource(
|
||||
gitlab_service: type[GitService],
|
||||
resource_type: GitLabResourceType,
|
||||
resource_id: str,
|
||||
webhook_store: GitlabWebhookStore,
|
||||
webhook: GitlabWebhook,
|
||||
) -> tuple[str | None, WebhookStatus | None]:
|
||||
"""
|
||||
Install webhook on a GitLab resource.
|
||||
|
||||
Args:
|
||||
gitlab_service: GitLab service instance
|
||||
resource_type: Type of resource (PROJECT or GROUP)
|
||||
resource_id: ID of the resource
|
||||
webhook_store: Webhook store instance
|
||||
webhook: Webhook object to install
|
||||
|
||||
Returns:
|
||||
Tuple of (webhook_id, status)
|
||||
"""
|
||||
from integrations.gitlab.gitlab_service import SaaSGitLabService
|
||||
|
||||
gitlab_service = cast(type[SaaSGitLabService], gitlab_service)
|
||||
|
||||
webhook_secret = f'{webhook.user_id}-{str(uuid4())}'
|
||||
webhook_uuid = f'{str(uuid4())}'
|
||||
|
||||
webhook_id, status = await gitlab_service.install_webhook(
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_name=WEBHOOK_NAME,
|
||||
webhook_url=GITLAB_WEBHOOK_URL,
|
||||
webhook_secret=webhook_secret,
|
||||
webhook_uuid=webhook_uuid,
|
||||
scopes=SCOPES,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Creating new webhook',
|
||||
extra={
|
||||
'webhook_id': webhook_id,
|
||||
'status': status,
|
||||
'resource_id': resource_id,
|
||||
'resource_type': resource_type,
|
||||
},
|
||||
)
|
||||
|
||||
if status == WebhookStatus.RATE_LIMITED:
|
||||
raise BreakLoopException()
|
||||
|
||||
if webhook_id:
|
||||
await webhook_store.update_webhook(
|
||||
webhook=webhook,
|
||||
update_fields={
|
||||
'webhook_secret': webhook_secret,
|
||||
'webhook_exists': True, # webhook was created
|
||||
'webhook_url': GITLAB_WEBHOOK_URL,
|
||||
'scopes': SCOPES,
|
||||
'webhook_uuid': webhook_uuid, # required to identify which webhook installation is sending payload
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'Installed webhook for {webhook.user_id} on {resource_type}:{resource_id}'
|
||||
)
|
||||
|
||||
return webhook_id, status
|
||||
@@ -17,6 +17,7 @@ from integrations.utils import (
|
||||
HOST_URL,
|
||||
OPENHANDS_RESOLVER_TEMPLATES_DIR,
|
||||
filter_potential_repos_by_user_msg,
|
||||
get_session_expired_message,
|
||||
)
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from server.auth.saas_user_auth import get_user_auth_from_keycloak_id
|
||||
@@ -30,7 +31,11 @@ from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
from openhands.integrations.service_types import Repository
|
||||
from openhands.server.shared import server_config
|
||||
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
|
||||
from openhands.server.types import (
|
||||
LLMAuthenticationError,
|
||||
MissingSettingsError,
|
||||
SessionExpiredError,
|
||||
)
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
|
||||
@@ -380,6 +385,10 @@ class JiraManager(Manager):
|
||||
logger.warning(f'[Jira] LLM authentication error: {str(e)}')
|
||||
msg_info = f'Please set a valid LLM API key in [OpenHands Cloud]({HOST_URL}) before starting a job.'
|
||||
|
||||
except SessionExpiredError as e:
|
||||
logger.warning(f'[Jira] Session expired: {str(e)}')
|
||||
msg_info = get_session_expired_message()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'[Jira] Unexpected error starting job: {str(e)}', exc_info=True
|
||||
|
||||
@@ -19,6 +19,7 @@ from integrations.utils import (
|
||||
HOST_URL,
|
||||
OPENHANDS_RESOLVER_TEMPLATES_DIR,
|
||||
filter_potential_repos_by_user_msg,
|
||||
get_session_expired_message,
|
||||
)
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from server.auth.saas_user_auth import get_user_auth_from_keycloak_id
|
||||
@@ -32,7 +33,11 @@ from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
from openhands.integrations.service_types import Repository
|
||||
from openhands.server.shared import server_config
|
||||
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
|
||||
from openhands.server.types import (
|
||||
LLMAuthenticationError,
|
||||
MissingSettingsError,
|
||||
SessionExpiredError,
|
||||
)
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
|
||||
@@ -397,6 +402,10 @@ class JiraDcManager(Manager):
|
||||
logger.warning(f'[Jira DC] LLM authentication error: {str(e)}')
|
||||
msg_info = f'Please set a valid LLM API key in [OpenHands Cloud]({HOST_URL}) before starting a job.'
|
||||
|
||||
except SessionExpiredError as e:
|
||||
logger.warning(f'[Jira DC] Session expired: {str(e)}')
|
||||
msg_info = get_session_expired_message()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'[Jira DC] Unexpected error starting job: {str(e)}', exc_info=True
|
||||
|
||||
@@ -16,6 +16,7 @@ from integrations.utils import (
|
||||
HOST_URL,
|
||||
OPENHANDS_RESOLVER_TEMPLATES_DIR,
|
||||
filter_potential_repos_by_user_msg,
|
||||
get_session_expired_message,
|
||||
)
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from server.auth.saas_user_auth import get_user_auth_from_keycloak_id
|
||||
@@ -29,7 +30,11 @@ from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
from openhands.integrations.service_types import Repository
|
||||
from openhands.server.shared import server_config
|
||||
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
|
||||
from openhands.server.types import (
|
||||
LLMAuthenticationError,
|
||||
MissingSettingsError,
|
||||
SessionExpiredError,
|
||||
)
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
|
||||
@@ -387,6 +392,10 @@ class LinearManager(Manager):
|
||||
logger.warning(f'[Linear] LLM authentication error: {str(e)}')
|
||||
msg_info = f'Please set a valid LLM API key in [OpenHands Cloud]({HOST_URL}) before starting a job.'
|
||||
|
||||
except SessionExpiredError as e:
|
||||
logger.warning(f'[Linear] Session expired: {str(e)}')
|
||||
msg_info = get_session_expired_message()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'[Linear] Unexpected error starting job: {str(e)}', exc_info=True
|
||||
|
||||
@@ -14,10 +14,10 @@ from integrations.slack.slack_view import (
|
||||
from integrations.utils import (
|
||||
HOST_URL,
|
||||
OPENHANDS_RESOLVER_TEMPLATES_DIR,
|
||||
get_session_expired_message,
|
||||
)
|
||||
from integrations.v1_utils import get_saas_user_auth
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from pydantic import SecretStr
|
||||
from server.auth.saas_user_auth import SaasUserAuth
|
||||
from server.constants import SLACK_CLIENT_ID
|
||||
from server.utils.conversation_callback_utils import register_callback_processor
|
||||
from slack_sdk.oauth import AuthorizeUrlGenerator
|
||||
@@ -29,7 +29,11 @@ from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
from openhands.integrations.service_types import Repository
|
||||
from openhands.server.shared import config, server_config
|
||||
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
|
||||
from openhands.server.types import (
|
||||
LLMAuthenticationError,
|
||||
MissingSettingsError,
|
||||
SessionExpiredError,
|
||||
)
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
authorize_url_generator = AuthorizeUrlGenerator(
|
||||
@@ -54,17 +58,6 @@ class SlackManager(Manager):
|
||||
if message.source != SourceType.SLACK:
|
||||
raise ValueError(f'Unexpected message source {message.source}')
|
||||
|
||||
async def _get_user_auth(self, keycloak_user_id: str) -> UserAuth:
|
||||
offline_token = await self.token_manager.load_offline_token(keycloak_user_id)
|
||||
if offline_token is None:
|
||||
logger.info('no_offline_token_found')
|
||||
|
||||
user_auth = SaasUserAuth(
|
||||
user_id=keycloak_user_id,
|
||||
refresh_token=SecretStr(offline_token),
|
||||
)
|
||||
return user_auth
|
||||
|
||||
async def authenticate_user(
|
||||
self, slack_user_id: str
|
||||
) -> tuple[SlackUser | None, UserAuth | None]:
|
||||
@@ -81,7 +74,9 @@ class SlackManager(Manager):
|
||||
|
||||
saas_user_auth = None
|
||||
if slack_user:
|
||||
saas_user_auth = await self._get_user_auth(slack_user.keycloak_user_id)
|
||||
saas_user_auth = await get_saas_user_auth(
|
||||
slack_user.keycloak_user_id, self.token_manager
|
||||
)
|
||||
# slack_view.saas_user_auth = await self._get_user_auth(slack_view.slack_to_openhands_user.keycloak_user_id)
|
||||
|
||||
return slack_user, saas_user_auth
|
||||
@@ -244,13 +239,11 @@ class SlackManager(Manager):
|
||||
async def is_job_requested(
|
||||
self, message: Message, slack_view: SlackViewInterface
|
||||
) -> bool:
|
||||
"""
|
||||
A job is always request we only receive webhooks for events associated with the slack bot
|
||||
"""A job is always request we only receive webhooks for events associated with the slack bot
|
||||
This method really just checks
|
||||
1. Is the user is authenticated
|
||||
2. Do we have the necessary information to start a job (either by inferring the selected repo, otherwise asking the user)
|
||||
"""
|
||||
|
||||
# Infer repo from user message is not needed; user selected repo from the form or is updating existing convo
|
||||
if isinstance(slack_view, SlackUpdateExistingConversationView):
|
||||
return True
|
||||
@@ -317,10 +310,15 @@ class SlackManager(Manager):
|
||||
f'[Slack] Created conversation {conversation_id} for user {user_info.slack_display_name}'
|
||||
)
|
||||
|
||||
if not isinstance(slack_view, SlackUpdateExistingConversationView):
|
||||
# Only add SlackCallbackProcessor for new conversations (not updates) and non-v1 conversations
|
||||
if (
|
||||
not isinstance(slack_view, SlackUpdateExistingConversationView)
|
||||
and not slack_view.v1_enabled
|
||||
):
|
||||
# We don't re-subscribe for follow up messages from slack.
|
||||
# Summaries are generated for every messages anyways, we only need to do
|
||||
# this subscription once for the event which kicked off the job.
|
||||
|
||||
processor = SlackCallbackProcessor(
|
||||
slack_user_id=slack_view.slack_user_id,
|
||||
channel_id=slack_view.channel_id,
|
||||
@@ -335,6 +333,14 @@ class SlackManager(Manager):
|
||||
logger.info(
|
||||
f'[Slack] Created callback processor for conversation {conversation_id}'
|
||||
)
|
||||
elif isinstance(slack_view, SlackUpdateExistingConversationView):
|
||||
logger.info(
|
||||
f'[Slack] Skipping callback processor for existing conversation update {conversation_id}'
|
||||
)
|
||||
elif slack_view.v1_enabled:
|
||||
logger.info(
|
||||
f'[Slack] Skipping callback processor for v1 conversation {conversation_id}'
|
||||
)
|
||||
|
||||
msg_info = slack_view.get_response_msg()
|
||||
|
||||
@@ -352,6 +358,13 @@ class SlackManager(Manager):
|
||||
|
||||
msg_info = f'@{user_info.slack_display_name} please set a valid LLM API key in [OpenHands Cloud]({HOST_URL}) before starting a job.'
|
||||
|
||||
except SessionExpiredError as e:
|
||||
logger.warning(
|
||||
f'[Slack] Session expired for user {user_info.slack_display_name}: {str(e)}'
|
||||
)
|
||||
|
||||
msg_info = get_session_expired_message(user_info.slack_display_name)
|
||||
|
||||
except StartingConvoException as e:
|
||||
msg_info = str(e)
|
||||
|
||||
|
||||
@@ -21,20 +21,16 @@ class SlackViewInterface(SummaryExtractionTracker, ABC):
|
||||
send_summary_instruction: bool
|
||||
conversation_id: str
|
||||
team_id: str
|
||||
v1_enabled: bool
|
||||
|
||||
@abstractmethod
|
||||
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
"Instructions passed when conversation is first initialized"
|
||||
"""Instructions passed when conversation is first initialized"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def create_or_update_conversation(self, jinja_env: Environment):
|
||||
"Create a new conversation"
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_callback_id(self) -> str:
|
||||
"Unique callback id for subscribription made to EventStream for fetching agent summary"
|
||||
"""Create a new conversation"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
@@ -43,6 +39,4 @@ class SlackViewInterface(SummaryExtractionTracker, ABC):
|
||||
|
||||
|
||||
class StartingConvoException(Exception):
|
||||
"""
|
||||
Raised when trying to send message to a conversation that's is still starting up
|
||||
"""
|
||||
"""Raised when trying to send message to a conversation that's is still starting up"""
|
||||
|
||||
273
enterprise/integrations/slack/slack_v1_callback_processor.py
Normal file
273
enterprise/integrations/slack/slack_v1_callback_processor.py
Normal file
@@ -0,0 +1,273 @@
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
from integrations.utils import CONVERSATION_URL, get_summary_instruction
|
||||
from pydantic import Field
|
||||
from slack_sdk import WebClient
|
||||
from storage.slack_team_store import SlackTeamStore
|
||||
|
||||
from openhands.agent_server.models import AskAgentRequest, AskAgentResponse
|
||||
from openhands.app_server.event_callback.event_callback_models import (
|
||||
EventCallback,
|
||||
EventCallbackProcessor,
|
||||
)
|
||||
from openhands.app_server.event_callback.event_callback_result_models import (
|
||||
EventCallbackResult,
|
||||
EventCallbackResultStatus,
|
||||
)
|
||||
from openhands.app_server.event_callback.util import (
|
||||
ensure_conversation_found,
|
||||
ensure_running_sandbox,
|
||||
get_agent_server_url_from_sandbox,
|
||||
)
|
||||
from openhands.sdk import Event
|
||||
from openhands.sdk.event import ConversationStateUpdateEvent
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SlackV1CallbackProcessor(EventCallbackProcessor):
|
||||
"""Callback processor for Slack V1 integrations."""
|
||||
|
||||
slack_view_data: dict[str, str | None] = Field(default_factory=dict)
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
conversation_id: UUID,
|
||||
callback: EventCallback,
|
||||
event: Event,
|
||||
) -> EventCallbackResult | None:
|
||||
"""Process events for Slack V1 integration."""
|
||||
|
||||
# Only handle ConversationStateUpdateEvent
|
||||
if not isinstance(event, ConversationStateUpdateEvent):
|
||||
return None
|
||||
|
||||
# Only act when execution has finished
|
||||
if not (event.key == 'execution_status' and event.value == 'finished'):
|
||||
return None
|
||||
|
||||
_logger.info('[Slack V1] Callback agent state was %s', event)
|
||||
|
||||
try:
|
||||
summary = await self._request_summary(conversation_id)
|
||||
await self._post_summary_to_slack(summary)
|
||||
|
||||
return EventCallbackResult(
|
||||
status=EventCallbackResultStatus.SUCCESS,
|
||||
event_callback_id=callback.id,
|
||||
event_id=event.id,
|
||||
conversation_id=conversation_id,
|
||||
detail=summary,
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.exception('[Slack V1] Error processing callback: %s', e)
|
||||
|
||||
# Only try to post error to Slack if we have basic requirements
|
||||
try:
|
||||
await self._post_summary_to_slack(
|
||||
f'OpenHands encountered an error: **{str(e)}**.\n\n'
|
||||
f'[See the conversation]({CONVERSATION_URL.format(conversation_id)})'
|
||||
'for more information.'
|
||||
)
|
||||
except Exception as post_error:
|
||||
_logger.warning(
|
||||
'[Slack V1] Failed to post error message to Slack: %s', post_error
|
||||
)
|
||||
|
||||
return EventCallbackResult(
|
||||
status=EventCallbackResultStatus.ERROR,
|
||||
event_callback_id=callback.id,
|
||||
event_id=event.id,
|
||||
conversation_id=conversation_id,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Slack helpers
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _get_bot_access_token(self):
|
||||
slack_team_store = SlackTeamStore.get_instance()
|
||||
bot_access_token = slack_team_store.get_team_bot_token(
|
||||
self.slack_view_data['team_id']
|
||||
)
|
||||
|
||||
return bot_access_token
|
||||
|
||||
async def _post_summary_to_slack(self, summary: str) -> None:
|
||||
"""Post a summary message to the configured Slack channel."""
|
||||
bot_access_token = self._get_bot_access_token()
|
||||
if not bot_access_token:
|
||||
raise RuntimeError('Missing Slack bot access token')
|
||||
|
||||
channel_id = self.slack_view_data['channel_id']
|
||||
thread_ts = self.slack_view_data.get('thread_ts') or self.slack_view_data.get(
|
||||
'message_ts'
|
||||
)
|
||||
|
||||
client = WebClient(token=bot_access_token)
|
||||
|
||||
try:
|
||||
# Post the summary as a threaded reply
|
||||
response = client.chat_postMessage(
|
||||
channel=channel_id,
|
||||
text=summary,
|
||||
thread_ts=thread_ts,
|
||||
unfurl_links=False,
|
||||
unfurl_media=False,
|
||||
)
|
||||
|
||||
if not response['ok']:
|
||||
raise RuntimeError(
|
||||
f"Slack API error: {response.get('error', 'Unknown error')}"
|
||||
)
|
||||
|
||||
_logger.info(
|
||||
'[Slack V1] Successfully posted summary to channel %s', channel_id
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
_logger.error('[Slack V1] Failed to post message to Slack: %s', e)
|
||||
raise
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Agent / sandbox helpers
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def _ask_question(
|
||||
self,
|
||||
httpx_client: httpx.AsyncClient,
|
||||
agent_server_url: str,
|
||||
conversation_id: UUID,
|
||||
session_api_key: str,
|
||||
message_content: str,
|
||||
) -> str:
|
||||
"""Send a message to the agent server via the V1 API and return response text."""
|
||||
send_message_request = AskAgentRequest(question=message_content)
|
||||
|
||||
url = (
|
||||
f'{agent_server_url.rstrip("/")}'
|
||||
f'/api/conversations/{conversation_id}/ask_agent'
|
||||
)
|
||||
headers = {'X-Session-API-Key': session_api_key}
|
||||
payload = send_message_request.model_dump()
|
||||
|
||||
try:
|
||||
response = await httpx_client.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=30.0,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
agent_response = AskAgentResponse.model_validate(response.json())
|
||||
return agent_response.response
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
error_detail = f'HTTP {e.response.status_code} error'
|
||||
try:
|
||||
error_body = e.response.text
|
||||
if error_body:
|
||||
error_detail += f': {error_body}'
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
_logger.error(
|
||||
'[Slack V1] HTTP error sending message to %s: %s. '
|
||||
'Request payload: %s. Response headers: %s',
|
||||
url,
|
||||
error_detail,
|
||||
payload,
|
||||
dict(e.response.headers),
|
||||
exc_info=True,
|
||||
)
|
||||
raise Exception(f'Failed to send message to agent server: {error_detail}')
|
||||
|
||||
except httpx.TimeoutException:
|
||||
error_detail = f'Request timeout after 30 seconds to {url}'
|
||||
_logger.error(
|
||||
'[Slack V1] %s. Request payload: %s',
|
||||
error_detail,
|
||||
payload,
|
||||
exc_info=True,
|
||||
)
|
||||
raise Exception(error_detail)
|
||||
|
||||
except httpx.RequestError as e:
|
||||
error_detail = f'Request error to {url}: {str(e)}'
|
||||
_logger.error(
|
||||
'[Slack V1] %s. Request payload: %s',
|
||||
error_detail,
|
||||
payload,
|
||||
exc_info=True,
|
||||
)
|
||||
raise Exception(error_detail)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Summary orchestration
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def _request_summary(self, conversation_id: UUID) -> str:
|
||||
"""
|
||||
Ask the agent to produce a summary of its work and return the agent response.
|
||||
|
||||
NOTE: This method now returns a string (the agent server's response text)
|
||||
and raises exceptions on errors. The wrapping into EventCallbackResult
|
||||
is handled by __call__.
|
||||
"""
|
||||
# Import services within the method to avoid circular imports
|
||||
from openhands.app_server.config import (
|
||||
get_app_conversation_info_service,
|
||||
get_httpx_client,
|
||||
get_sandbox_service,
|
||||
)
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.app_server.user.specifiy_user_context import (
|
||||
ADMIN,
|
||||
USER_CONTEXT_ATTR,
|
||||
)
|
||||
|
||||
# Create injector state for dependency injection
|
||||
state = InjectorState()
|
||||
setattr(state, USER_CONTEXT_ATTR, ADMIN)
|
||||
|
||||
async with (
|
||||
get_app_conversation_info_service(state) as app_conversation_info_service,
|
||||
get_sandbox_service(state) as sandbox_service,
|
||||
get_httpx_client(state) as httpx_client,
|
||||
):
|
||||
# 1. Conversation lookup
|
||||
app_conversation_info = ensure_conversation_found(
|
||||
await app_conversation_info_service.get_app_conversation_info(
|
||||
conversation_id
|
||||
),
|
||||
conversation_id,
|
||||
)
|
||||
|
||||
# 2. Sandbox lookup + validation
|
||||
sandbox = ensure_running_sandbox(
|
||||
await sandbox_service.get_sandbox(app_conversation_info.sandbox_id),
|
||||
app_conversation_info.sandbox_id,
|
||||
)
|
||||
|
||||
assert (
|
||||
sandbox.session_api_key is not None
|
||||
), f'No session API key for sandbox: {sandbox.id}'
|
||||
|
||||
# 3. URL + instruction
|
||||
agent_server_url = get_agent_server_url_from_sandbox(sandbox)
|
||||
|
||||
# Prepare message based on agent state
|
||||
message_content = get_summary_instruction()
|
||||
|
||||
# Ask the agent and return the response text
|
||||
return await self._ask_question(
|
||||
httpx_client=httpx_client,
|
||||
agent_server_url=agent_server_url,
|
||||
conversation_id=conversation_id,
|
||||
session_api_key=sandbox.session_api_key,
|
||||
message_content=message_content,
|
||||
)
|
||||
@@ -1,8 +1,16 @@
|
||||
from dataclasses import dataclass
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from integrations.models import Message
|
||||
from integrations.resolver_context import ResolverUserContext
|
||||
from integrations.slack.slack_types import SlackViewInterface, StartingConvoException
|
||||
from integrations.utils import CONVERSATION_URL, get_final_agent_observation
|
||||
from integrations.slack.slack_v1_callback_processor import SlackV1CallbackProcessor
|
||||
from integrations.utils import (
|
||||
CONVERSATION_URL,
|
||||
ENABLE_V1_SLACK_RESOLVER,
|
||||
get_final_agent_observation,
|
||||
get_user_v1_enabled_setting,
|
||||
)
|
||||
from jinja2 import Environment
|
||||
from slack_sdk import WebClient
|
||||
from storage.slack_conversation import SlackConversation
|
||||
@@ -10,22 +18,34 @@ from storage.slack_conversation_store import SlackConversationStore
|
||||
from storage.slack_team_store import SlackTeamStore
|
||||
from storage.slack_user import SlackUser
|
||||
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
AppConversationStartRequest,
|
||||
AppConversationStartTaskStatus,
|
||||
SendMessageRequest,
|
||||
)
|
||||
from openhands.app_server.config import get_app_conversation_service
|
||||
from openhands.app_server.sandbox.sandbox_models import SandboxStatus
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.schema.agent import AgentState
|
||||
from openhands.events.action import MessageAction
|
||||
from openhands.events.serialization.event import event_to_dict
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
from openhands.integrations.provider import ProviderHandler, ProviderType
|
||||
from openhands.sdk import TextContent
|
||||
from openhands.server.services.conversation_service import (
|
||||
create_new_conversation,
|
||||
setup_init_conversation_settings,
|
||||
)
|
||||
from openhands.server.shared import ConversationStoreImpl, config, conversation_manager
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
|
||||
from openhands.storage.data_models.conversation_metadata import (
|
||||
ConversationTrigger,
|
||||
)
|
||||
from openhands.utils.async_utils import GENERAL_TIMEOUT, call_async_from_sync
|
||||
|
||||
# =================================================
|
||||
# SECTION: Github view types
|
||||
# SECTION: Slack view types
|
||||
# =================================================
|
||||
|
||||
|
||||
@@ -34,6 +54,10 @@ slack_conversation_store = SlackConversationStore.get_instance()
|
||||
slack_team_store = SlackTeamStore.get_instance()
|
||||
|
||||
|
||||
async def is_v1_enabled_for_slack_resolver(user_id: str) -> bool:
|
||||
return await get_user_v1_enabled_setting(user_id) and ENABLE_V1_SLACK_RESOLVER
|
||||
|
||||
|
||||
@dataclass
|
||||
class SlackUnkownUserView(SlackViewInterface):
|
||||
bot_access_token: str
|
||||
@@ -49,6 +73,7 @@ class SlackUnkownUserView(SlackViewInterface):
|
||||
send_summary_instruction: bool
|
||||
conversation_id: str
|
||||
team_id: str
|
||||
v1_enabled: bool
|
||||
|
||||
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
raise NotImplementedError
|
||||
@@ -56,9 +81,6 @@ class SlackUnkownUserView(SlackViewInterface):
|
||||
async def create_or_update_conversation(self, jinja_env: Environment):
|
||||
raise NotImplementedError
|
||||
|
||||
def get_callback_id(self) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
def get_response_msg(self) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -78,6 +100,7 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
send_summary_instruction: bool
|
||||
conversation_id: str
|
||||
team_id: str
|
||||
v1_enabled: bool
|
||||
|
||||
def _get_initial_prompt(self, text: str, blocks: list[dict]):
|
||||
bot_id = self._get_bot_id(blocks)
|
||||
@@ -96,8 +119,7 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
return ''
|
||||
|
||||
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
"Instructions passed when conversation is first initialized"
|
||||
|
||||
"""Instructions passed when conversation is first initialized"""
|
||||
user_info: SlackUser = self.slack_to_openhands_user
|
||||
|
||||
messages = []
|
||||
@@ -157,7 +179,7 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
'Attempting to start conversation without confirming selected repo from user'
|
||||
)
|
||||
|
||||
async def save_slack_convo(self):
|
||||
async def save_slack_convo(self, v1_enabled: bool = False):
|
||||
if self.slack_to_openhands_user:
|
||||
user_info: SlackUser = self.slack_to_openhands_user
|
||||
|
||||
@@ -168,6 +190,7 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
'conversation_id': self.conversation_id,
|
||||
'keycloak_user_id': user_info.keycloak_user_id,
|
||||
'parent_id': self.thread_ts or self.message_ts,
|
||||
'v1_enabled': v1_enabled,
|
||||
},
|
||||
)
|
||||
slack_conversation = SlackConversation(
|
||||
@@ -176,17 +199,47 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
keycloak_user_id=user_info.keycloak_user_id,
|
||||
parent_id=self.thread_ts
|
||||
or self.message_ts, # conversations can start in a thread reply as well; we should always references the parent's (root level msg's) message ID
|
||||
v1_enabled=v1_enabled,
|
||||
)
|
||||
await slack_conversation_store.create_slack_conversation(slack_conversation)
|
||||
|
||||
def _create_slack_v1_callback_processor(self) -> SlackV1CallbackProcessor:
|
||||
"""Create a SlackV1CallbackProcessor for V1 conversation handling."""
|
||||
return SlackV1CallbackProcessor(
|
||||
slack_view_data={
|
||||
'channel_id': self.channel_id,
|
||||
'message_ts': self.message_ts,
|
||||
'thread_ts': self.thread_ts,
|
||||
'team_id': self.team_id,
|
||||
'slack_user_id': self.slack_user_id,
|
||||
}
|
||||
)
|
||||
|
||||
async def create_or_update_conversation(self, jinja: Environment) -> str:
|
||||
"""
|
||||
Only creates a new conversation
|
||||
"""
|
||||
"""Only creates a new conversation"""
|
||||
self._verify_necessary_values_are_set()
|
||||
|
||||
provider_tokens = await self.saas_user_auth.get_provider_tokens()
|
||||
user_secrets = await self.saas_user_auth.get_secrets()
|
||||
|
||||
# Check if V1 conversations are enabled for this user
|
||||
self.v1_enabled = await is_v1_enabled_for_slack_resolver(
|
||||
self.slack_to_openhands_user.keycloak_user_id
|
||||
)
|
||||
|
||||
if self.v1_enabled:
|
||||
# Use V1 app conversation service
|
||||
await self._create_v1_conversation(jinja)
|
||||
return self.conversation_id
|
||||
else:
|
||||
# Use existing V0 conversation service
|
||||
await self._create_v0_conversation(jinja, provider_tokens, user_secrets)
|
||||
return self.conversation_id
|
||||
|
||||
async def _create_v0_conversation(
|
||||
self, jinja: Environment, provider_tokens, user_secrets
|
||||
) -> None:
|
||||
"""Create conversation using the legacy V0 system."""
|
||||
user_instructions, conversation_instructions = self._get_instructions(jinja)
|
||||
|
||||
# Determine git provider from repository
|
||||
@@ -213,11 +266,65 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
)
|
||||
|
||||
self.conversation_id = agent_loop_info.conversation_id
|
||||
await self.save_slack_convo()
|
||||
return self.conversation_id
|
||||
logger.info(f'[Slack]: Created V0 conversation: {self.conversation_id}')
|
||||
await self.save_slack_convo(v1_enabled=False)
|
||||
|
||||
def get_callback_id(self) -> str:
|
||||
return f'slack_{self.channel_id}_{self.message_ts}'
|
||||
async def _create_v1_conversation(self, jinja: Environment) -> None:
|
||||
"""Create conversation using the new V1 app conversation system."""
|
||||
user_instructions, conversation_instructions = self._get_instructions(jinja)
|
||||
|
||||
# Create the initial message request
|
||||
initial_message = SendMessageRequest(
|
||||
role='user', content=[TextContent(text=user_instructions)]
|
||||
)
|
||||
|
||||
# Create the Slack V1 callback processor
|
||||
slack_callback_processor = self._create_slack_v1_callback_processor()
|
||||
|
||||
# Determine git provider from repository
|
||||
git_provider = None
|
||||
provider_tokens = await self.saas_user_auth.get_provider_tokens()
|
||||
if self.selected_repo and provider_tokens:
|
||||
provider_handler = ProviderHandler(provider_tokens)
|
||||
repository = await provider_handler.verify_repo_provider(self.selected_repo)
|
||||
git_provider = ProviderType(repository.git_provider.value)
|
||||
|
||||
# Get the app conversation service and start the conversation
|
||||
injector_state = InjectorState()
|
||||
|
||||
# Create the V1 conversation start request with the callback processor
|
||||
self.conversation_id = uuid4().hex
|
||||
start_request = AppConversationStartRequest(
|
||||
conversation_id=UUID(self.conversation_id),
|
||||
system_message_suffix=conversation_instructions,
|
||||
initial_message=initial_message,
|
||||
selected_repository=self.selected_repo,
|
||||
git_provider=git_provider,
|
||||
title=f'Slack conversation from {self.slack_to_openhands_user.slack_display_name}',
|
||||
trigger=ConversationTrigger.SLACK,
|
||||
processors=[
|
||||
slack_callback_processor
|
||||
], # Pass the callback processor directly
|
||||
)
|
||||
|
||||
# Set up the Slack user context for the V1 system
|
||||
slack_user_context = ResolverUserContext(saas_user_auth=self.saas_user_auth)
|
||||
setattr(injector_state, USER_CONTEXT_ATTR, slack_user_context)
|
||||
|
||||
async with get_app_conversation_service(
|
||||
injector_state
|
||||
) as app_conversation_service:
|
||||
async for task in app_conversation_service.start_app_conversation(
|
||||
start_request
|
||||
):
|
||||
if task.status == AppConversationStartTaskStatus.ERROR:
|
||||
logger.error(f'Failed to start V1 conversation: {task.detail}')
|
||||
raise RuntimeError(
|
||||
f'Failed to start V1 conversation: {task.detail}'
|
||||
)
|
||||
|
||||
logger.info(f'[Slack V1]: Created new conversation: {self.conversation_id}')
|
||||
await self.save_slack_convo(v1_enabled=True)
|
||||
|
||||
def get_response_msg(self) -> str:
|
||||
user_info: SlackUser = self.slack_to_openhands_user
|
||||
@@ -254,32 +361,20 @@ class SlackUpdateExistingConversationView(SlackNewConversationView):
|
||||
|
||||
return user_message, ''
|
||||
|
||||
async def create_or_update_conversation(self, jinja: Environment) -> str:
|
||||
"""
|
||||
Send new user message to converation
|
||||
"""
|
||||
async def send_message_to_v0_conversation(self, jinja: Environment):
|
||||
user_info: SlackUser = self.slack_to_openhands_user
|
||||
saas_user_auth: UserAuth = self.saas_user_auth
|
||||
user_id = user_info.keycloak_user_id
|
||||
|
||||
# Org management in the future will get rid of this
|
||||
# For now, only user that created the conversation can send follow up messages to it
|
||||
if user_id != self.slack_conversation.keycloak_user_id:
|
||||
raise StartingConvoException(
|
||||
f'{user_info.slack_display_name} is not authorized to send messages to this conversation.'
|
||||
)
|
||||
|
||||
# Check if conversation has been deleted
|
||||
# Update logic when soft delete is implemented
|
||||
conversation_store = await ConversationStoreImpl.get_instance(config, user_id)
|
||||
saas_user_auth: UserAuth = self.saas_user_auth
|
||||
provider_tokens = await saas_user_auth.get_provider_tokens()
|
||||
|
||||
try:
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, user_id
|
||||
)
|
||||
await conversation_store.get_metadata(self.conversation_id)
|
||||
except FileNotFoundError:
|
||||
raise StartingConvoException('Conversation no longer exists.')
|
||||
|
||||
provider_tokens = await saas_user_auth.get_provider_tokens()
|
||||
|
||||
# Should we raise here if there are no provider tokens?
|
||||
providers_set = list(provider_tokens.keys()) if provider_tokens else []
|
||||
|
||||
@@ -310,6 +405,117 @@ class SlackUpdateExistingConversationView(SlackNewConversationView):
|
||||
self.conversation_id, event_to_dict(user_msg_action)
|
||||
)
|
||||
|
||||
async def send_message_to_v1_conversation(self, jinja: Environment):
|
||||
"""Send a message to a v1 conversation using the agent server API."""
|
||||
# Import services within the method to avoid circular imports
|
||||
from openhands.agent_server.models import SendMessageRequest
|
||||
from openhands.app_server.config import (
|
||||
get_app_conversation_info_service,
|
||||
get_httpx_client,
|
||||
get_sandbox_service,
|
||||
)
|
||||
from openhands.app_server.event_callback.util import (
|
||||
ensure_conversation_found,
|
||||
get_agent_server_url_from_sandbox,
|
||||
)
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.app_server.user.specifiy_user_context import (
|
||||
ADMIN,
|
||||
USER_CONTEXT_ATTR,
|
||||
)
|
||||
|
||||
# Create injector state for dependency injection
|
||||
state = InjectorState()
|
||||
setattr(state, USER_CONTEXT_ATTR, ADMIN)
|
||||
|
||||
async with (
|
||||
get_app_conversation_info_service(state) as app_conversation_info_service,
|
||||
get_sandbox_service(state) as sandbox_service,
|
||||
get_httpx_client(state) as httpx_client,
|
||||
):
|
||||
# 1. Conversation lookup
|
||||
app_conversation_info = ensure_conversation_found(
|
||||
await app_conversation_info_service.get_app_conversation_info(
|
||||
UUID(self.conversation_id)
|
||||
),
|
||||
UUID(self.conversation_id),
|
||||
)
|
||||
|
||||
# 2. Sandbox lookup + validation
|
||||
sandbox = await sandbox_service.get_sandbox(
|
||||
app_conversation_info.sandbox_id
|
||||
)
|
||||
|
||||
if sandbox and sandbox.status == SandboxStatus.PAUSED:
|
||||
# Resume paused sandbox and wait for it to be running
|
||||
logger.info('[Slack V1]: Attempting to resume paused sandbox')
|
||||
await sandbox_service.resume_sandbox(app_conversation_info.sandbox_id)
|
||||
|
||||
# Wait for sandbox to be running (handles both fresh start and resume)
|
||||
running_sandbox = await sandbox_service.wait_for_sandbox_running(
|
||||
app_conversation_info.sandbox_id,
|
||||
timeout=120,
|
||||
poll_interval=2,
|
||||
httpx_client=httpx_client,
|
||||
)
|
||||
|
||||
assert (
|
||||
running_sandbox.session_api_key is not None
|
||||
), f'No session API key for sandbox: {running_sandbox.id}'
|
||||
|
||||
# 3. Get the agent server URL
|
||||
agent_server_url = get_agent_server_url_from_sandbox(running_sandbox)
|
||||
|
||||
# 4. Prepare the message content
|
||||
user_msg, _ = self._get_instructions(jinja)
|
||||
|
||||
# 5. Create the message request
|
||||
send_message_request = SendMessageRequest(
|
||||
role='user', content=[TextContent(text=user_msg)], run=True
|
||||
)
|
||||
|
||||
# 6. Send the message to the agent server
|
||||
url = f'{agent_server_url.rstrip("/")}/api/conversations/{UUID(self.conversation_id)}/events'
|
||||
|
||||
headers = {'X-Session-API-Key': running_sandbox.session_api_key}
|
||||
payload = send_message_request.model_dump()
|
||||
|
||||
try:
|
||||
response = await httpx_client.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=30.0,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'[Slack V1] Failed to send message to conversation %s: %s',
|
||||
self.conversation_id,
|
||||
str(e),
|
||||
exc_info=True,
|
||||
)
|
||||
raise Exception(f'Failed to send message to v1 conversation: {str(e)}')
|
||||
|
||||
async def create_or_update_conversation(self, jinja: Environment) -> str:
|
||||
"""Send new user message to converation"""
|
||||
user_info: SlackUser = self.slack_to_openhands_user
|
||||
|
||||
user_id = user_info.keycloak_user_id
|
||||
|
||||
# Org management in the future will get rid of this
|
||||
# For now, only user that created the conversation can send follow up messages to it
|
||||
if user_id != self.slack_conversation.keycloak_user_id:
|
||||
raise StartingConvoException(
|
||||
f'{user_info.slack_display_name} is not authorized to send messages to this conversation.'
|
||||
)
|
||||
|
||||
if self.slack_conversation.v1_enabled:
|
||||
await self.send_message_to_v1_conversation(jinja)
|
||||
else:
|
||||
await self.send_message_to_v0_conversation(jinja)
|
||||
|
||||
return self.conversation_id
|
||||
|
||||
def get_response_msg(self):
|
||||
@@ -361,7 +567,7 @@ class SlackFactory:
|
||||
'channel_id': channel_id,
|
||||
},
|
||||
)
|
||||
raise Exception('Did not slack team')
|
||||
raise Exception('Did not find slack team')
|
||||
|
||||
# Determine if this is a known slack user by openhands
|
||||
if not slack_user or not saas_user_auth or not channel_id:
|
||||
@@ -379,6 +585,7 @@ class SlackFactory:
|
||||
send_summary_instruction=False,
|
||||
conversation_id='',
|
||||
team_id=team_id,
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
conversation: SlackConversation | None = call_async_from_sync(
|
||||
@@ -409,6 +616,7 @@ class SlackFactory:
|
||||
conversation_id=conversation.conversation_id,
|
||||
slack_conversation=conversation,
|
||||
team_id=team_id,
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
elif SlackFactory.did_user_select_repo_from_form(message):
|
||||
@@ -426,6 +634,7 @@ class SlackFactory:
|
||||
send_summary_instruction=True,
|
||||
conversation_id='',
|
||||
team_id=team_id,
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
else:
|
||||
@@ -443,4 +652,5 @@ class SlackFactory:
|
||||
send_summary_instruction=True,
|
||||
conversation_id='',
|
||||
team_id=team_id,
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
@@ -45,7 +45,3 @@ class ResolverViewInterface(SummaryExtractionTracker):
|
||||
async def create_new_conversation(self, jinja_env: Environment, token: str):
|
||||
"Create a new conversation"
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_callback_id(self) -> str:
|
||||
"Unique callback id for subscribription made to EventStream for fetching agent summary"
|
||||
raise NotImplementedError()
|
||||
|
||||
@@ -6,7 +6,9 @@ import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from server.config import get_config
|
||||
from server.constants import WEB_HOST
|
||||
from storage.database import session_maker
|
||||
from storage.repository_store import RepositoryStore
|
||||
from storage.stored_repository import StoredRepository
|
||||
from storage.user_repo_map import UserRepositoryMap
|
||||
@@ -25,6 +27,7 @@ from openhands.events.event_store_abc import EventStoreABC
|
||||
from openhands.events.observation.agent import AgentStateChangedObservation
|
||||
from openhands.integrations.service_types import Repository
|
||||
from openhands.storage.data_models.conversation_status import ConversationStatus
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openhands.server.conversation_manager.conversation_manager import (
|
||||
@@ -36,7 +39,7 @@ if TYPE_CHECKING:
|
||||
HOST = WEB_HOST
|
||||
# ---- DO NOT REMOVE ----
|
||||
|
||||
HOST_URL = f'https://{HOST}'
|
||||
HOST_URL = f'https://{HOST}' if 'localhost' not in HOST else f'http://{HOST}'
|
||||
GITHUB_WEBHOOK_URL = f'{HOST_URL}/integration/github/events'
|
||||
GITLAB_WEBHOOK_URL = f'{HOST_URL}/integration/gitlab/events'
|
||||
conversation_prefix = 'conversations/{}'
|
||||
@@ -47,6 +50,27 @@ ENABLE_PROACTIVE_CONVERSATION_STARTERS = (
|
||||
os.getenv('ENABLE_PROACTIVE_CONVERSATION_STARTERS', 'false').lower() == 'true'
|
||||
)
|
||||
|
||||
|
||||
def get_session_expired_message(username: str | None = None) -> str:
|
||||
"""Get a user-friendly session expired message.
|
||||
|
||||
Used by integrations to notify users when their Keycloak offline session
|
||||
has expired.
|
||||
|
||||
Args:
|
||||
username: Optional username to mention in the message. If provided,
|
||||
the message will include @username prefix (used by Git providers
|
||||
like GitHub, GitLab, Slack). If None, returns a generic message
|
||||
(used by Jira, Jira DC, Linear).
|
||||
|
||||
Returns:
|
||||
A formatted session expired message
|
||||
"""
|
||||
if username:
|
||||
return f'@{username} your session has expired. Please login again at [OpenHands Cloud]({HOST_URL}) and try again.'
|
||||
return f'Your session has expired. Please login again at [OpenHands Cloud]({HOST_URL}) and try again.'
|
||||
|
||||
|
||||
# Toggle for solvability report feature
|
||||
ENABLE_SOLVABILITY_ANALYSIS = (
|
||||
os.getenv('ENABLE_SOLVABILITY_ANALYSIS', 'false').lower() == 'true'
|
||||
@@ -57,8 +81,14 @@ ENABLE_V1_GITHUB_RESOLVER = (
|
||||
os.getenv('ENABLE_V1_GITHUB_RESOLVER', 'false').lower() == 'true'
|
||||
)
|
||||
|
||||
ENABLE_V1_SLACK_RESOLVER = (
|
||||
os.getenv('ENABLE_V1_SLACK_RESOLVER', 'false').lower() == 'true'
|
||||
)
|
||||
|
||||
OPENHANDS_RESOLVER_TEMPLATES_DIR = 'openhands/integrations/templates/resolver/'
|
||||
OPENHANDS_RESOLVER_TEMPLATES_DIR = (
|
||||
os.getenv('OPENHANDS_RESOLVER_TEMPLATES_DIR')
|
||||
or 'openhands/integrations/templates/resolver/'
|
||||
)
|
||||
jinja_env = Environment(loader=FileSystemLoader(OPENHANDS_RESOLVER_TEMPLATES_DIR))
|
||||
|
||||
|
||||
@@ -86,6 +116,37 @@ def get_summary_instruction():
|
||||
return summary_instruction
|
||||
|
||||
|
||||
async def get_user_v1_enabled_setting(user_id: str | None) -> bool:
|
||||
"""Get the user's V1 conversation API setting.
|
||||
|
||||
Args:
|
||||
user_id: The keycloak user ID
|
||||
|
||||
Returns:
|
||||
True if V1 conversations are enabled for this user, False otherwise
|
||||
"""
|
||||
|
||||
# If no user ID is provided, we can't check user settings
|
||||
if not user_id:
|
||||
return False
|
||||
|
||||
from storage.saas_settings_store import SaasSettingsStore
|
||||
|
||||
config = get_config()
|
||||
settings_store = SaasSettingsStore(
|
||||
user_id=user_id, session_maker=session_maker, config=config
|
||||
)
|
||||
|
||||
settings = await call_sync_from_async(
|
||||
settings_store.get_user_settings_by_keycloak_id, user_id
|
||||
)
|
||||
|
||||
if not settings or settings.v1_enabled is None:
|
||||
return False
|
||||
|
||||
return settings.v1_enabled
|
||||
|
||||
|
||||
def has_exact_mention(text: str, mention: str) -> bool:
|
||||
"""Check if the text contains an exact mention (not part of a larger word).
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""add v1 column to slack conversation table
|
||||
|
||||
Revision ID: 086
|
||||
Revises: 085
|
||||
Create Date: 2025-12-02 15:30:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '086'
|
||||
down_revision: Union[str, None] = '085'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add v1 column
|
||||
op.add_column(
|
||||
'slack_conversation', sa.Column('v1_enabled', sa.Boolean(), nullable=True)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop v1 column
|
||||
op.drop_column('slack_conversation', 'v1_enabled')
|
||||
@@ -0,0 +1,61 @@
|
||||
"""bump condenser defaults: max_size 120->240
|
||||
|
||||
Revision ID: 087
|
||||
Revises: 086
|
||||
Create Date: 2026-01-05
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.sql import column, table
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '087'
|
||||
down_revision: Union[str, None] = '086'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema.
|
||||
|
||||
Update existing users with condenser_max_size=120 or NULL to 240.
|
||||
This covers both users who had the old default (120) explicitly set
|
||||
and users who had NULL (which defaulted to 120 in the application code).
|
||||
The SDK default for keep_first will be used automatically.
|
||||
"""
|
||||
user_settings_table = table(
|
||||
'user_settings',
|
||||
column('condenser_max_size', sa.Integer),
|
||||
)
|
||||
# Update users with explicit 120 value
|
||||
op.execute(
|
||||
user_settings_table.update()
|
||||
.where(user_settings_table.c.condenser_max_size == 120)
|
||||
.values(condenser_max_size=240)
|
||||
)
|
||||
# Update users with NULL value (which defaulted to 120 in application code)
|
||||
op.execute(
|
||||
user_settings_table.update()
|
||||
.where(user_settings_table.c.condenser_max_size.is_(None))
|
||||
.values(condenser_max_size=240)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema.
|
||||
|
||||
Note: This sets all 240 values back to NULL (not 120) since we can't
|
||||
distinguish between users who had 120 vs NULL before the upgrade.
|
||||
"""
|
||||
user_settings_table = table(
|
||||
'user_settings', column('condenser_max_size', sa.Integer)
|
||||
)
|
||||
op.execute(
|
||||
user_settings_table.update()
|
||||
.where(user_settings_table.c.condenser_max_size == 240)
|
||||
.values(condenser_max_size=None)
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""create blocked_email_domains table
|
||||
|
||||
Revision ID: 088
|
||||
Revises: 087
|
||||
Create Date: 2025-01-27 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '088'
|
||||
down_revision: Union[str, None] = '087'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create blocked_email_domains table for storing blocked email domain patterns."""
|
||||
op.create_table(
|
||||
'blocked_email_domains',
|
||||
sa.Column('id', sa.Integer(), sa.Identity(), nullable=False, primary_key=True),
|
||||
sa.Column('domain', sa.String(), nullable=False),
|
||||
sa.Column(
|
||||
'created_at',
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text('CURRENT_TIMESTAMP'),
|
||||
),
|
||||
sa.Column(
|
||||
'updated_at',
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text('CURRENT_TIMESTAMP'),
|
||||
),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
|
||||
# Create unique index on domain column
|
||||
op.create_index(
|
||||
'ix_blocked_email_domains_domain',
|
||||
'blocked_email_domains',
|
||||
['domain'],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop blocked_email_domains table."""
|
||||
op.drop_index('ix_blocked_email_domains_domain', table_name='blocked_email_domains')
|
||||
op.drop_table('blocked_email_domains')
|
||||
339
enterprise/poetry.lock
generated
339
enterprise/poetry.lock
generated
@@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "aiofiles"
|
||||
@@ -2884,6 +2884,28 @@ google-auth = ">=1.25.0,<3.0dev"
|
||||
[package.extras]
|
||||
grpc = ["grpcio (>=1.38.0,<2.0dev)", "grpcio-status (>=1.38.0,<2.0.dev0)"]
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-recaptcha-enterprise"
|
||||
version = "1.29.0"
|
||||
description = "Google Cloud Recaptcha Enterprise API client library"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "google_cloud_recaptcha_enterprise-1.29.0-py3-none-any.whl", hash = "sha256:d3332f3ab9c586404c187d111326670bc745b4cbb64cad0a1c16259356c43c6d"},
|
||||
{file = "google_cloud_recaptcha_enterprise-1.29.0.tar.gz", hash = "sha256:60cb3e8fb5860733c3c2c0b69d3a0aca6cf1ece2738ad7b5952183f1fb77e3e7"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0", extras = ["grpc"]}
|
||||
google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0"
|
||||
grpcio = ">=1.33.2,<2.0.0"
|
||||
proto-plus = [
|
||||
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
|
||||
{version = ">=1.22.3,<2.0.0"},
|
||||
]
|
||||
protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-resource-manager"
|
||||
version = "1.14.2"
|
||||
@@ -4517,14 +4539,14 @@ dev = ["Sphinx (>=5.1.1)", "black (==24.8.0)", "build (>=0.10.0)", "coverage[tom
|
||||
|
||||
[[package]]
|
||||
name = "libtmux"
|
||||
version = "0.46.2"
|
||||
version = "0.53.0"
|
||||
description = "Typed library that provides an ORM wrapper for tmux, a terminal multiplexer."
|
||||
optional = false
|
||||
python-versions = "<4.0,>=3.9"
|
||||
python-versions = "<4.0,>=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "libtmux-0.46.2-py3-none-any.whl", hash = "sha256:6c32dbf22bde8e5e33b2714a4295f6e838dc640f337cd4c085a044f6828c7793"},
|
||||
{file = "libtmux-0.46.2.tar.gz", hash = "sha256:9a398fec5d714129c8344555d466e1a903dfc0f741ba07aabe75a8ceb25c5dda"},
|
||||
{file = "libtmux-0.53.0-py3-none-any.whl", hash = "sha256:024b7ae6a12aae55358e8feb914c8632b3ab9bd61c0987c53559643c6a58ee4f"},
|
||||
{file = "libtmux-0.53.0.tar.gz", hash = "sha256:1d19af4cea0c19543954d7e7317c7025c0739b029cccbe3b843212fae238f1bd"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5836,14 +5858,14 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
|
||||
|
||||
[[package]]
|
||||
name = "openhands-agent-server"
|
||||
version = "1.7.3"
|
||||
version = "1.8.1"
|
||||
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_agent_server-1.7.3-py3-none-any.whl", hash = "sha256:456e7162cefec8ed7fda61433180b3f867265e15c7151b3a2e3e02546c9d9b6d"},
|
||||
{file = "openhands_agent_server-1.7.3.tar.gz", hash = "sha256:2c06dc497c38050d445559da2825d4d69fe84af90289c82a95317e45359cc547"},
|
||||
{file = "openhands_agent_server-1.8.1-py3-none-any.whl", hash = "sha256:c0dfe620184633a173094ffaa77b0d13124ea7bf84e7b534b1641e5fc5fd0256"},
|
||||
{file = "openhands_agent_server-1.8.1.tar.gz", hash = "sha256:08adfe26d867ff0cb0c1e87bb0ad6e058c9a97374964ba6a9860ea35d32764a0"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -5860,7 +5882,7 @@ wsproto = ">=1.2.0"
|
||||
|
||||
[[package]]
|
||||
name = "openhands-ai"
|
||||
version = "0.0.0-post.5769+b5758b160"
|
||||
version = "1.1.0"
|
||||
description = "OpenHands: Code Less, Make More"
|
||||
optional = false
|
||||
python-versions = "^3.12,<3.14"
|
||||
@@ -5890,7 +5912,7 @@ google-genai = "*"
|
||||
html2text = "*"
|
||||
httpx-aiohttp = "^0.1.8"
|
||||
ipywidgets = "^8.1.5"
|
||||
jinja2 = "^3.1.3"
|
||||
jinja2 = "^3.1.6"
|
||||
joblib = "*"
|
||||
json-repair = "*"
|
||||
jupyter_kernel_gateway = "*"
|
||||
@@ -5902,9 +5924,9 @@ memory-profiler = "^0.61.0"
|
||||
numpy = "*"
|
||||
openai = "2.8.0"
|
||||
openhands-aci = "0.3.2"
|
||||
openhands-agent-server = "1.7.3"
|
||||
openhands-sdk = "1.7.3"
|
||||
openhands-tools = "1.7.3"
|
||||
openhands-agent-server = "1.8.1"
|
||||
openhands-sdk = "1.8.1"
|
||||
openhands-tools = "1.8.1"
|
||||
opentelemetry-api = "^1.33.1"
|
||||
opentelemetry-exporter-otlp-proto-grpc = "^1.33.1"
|
||||
pathspec = "^0.12.1"
|
||||
@@ -5921,7 +5943,6 @@ pygithub = "^2.5.0"
|
||||
pyjwt = "^2.9.0"
|
||||
pylatexenc = "*"
|
||||
pypdf = "^6.0.0"
|
||||
PyPDF2 = "*"
|
||||
python-docx = "*"
|
||||
python-dotenv = "*"
|
||||
python-frontmatter = "^1.1.0"
|
||||
@@ -5944,9 +5965,9 @@ starlette = "^0.48.0"
|
||||
tenacity = ">=8.5,<10.0"
|
||||
termcolor = "*"
|
||||
toml = "*"
|
||||
tornado = "*"
|
||||
tornado = ">=6.5"
|
||||
types-toml = "*"
|
||||
urllib3 = "^2.5.0"
|
||||
urllib3 = "^2.6.3"
|
||||
uvicorn = "*"
|
||||
whatthepatch = "^1.0.6"
|
||||
zope-interface = "7.2"
|
||||
@@ -5960,14 +5981,14 @@ url = ".."
|
||||
|
||||
[[package]]
|
||||
name = "openhands-sdk"
|
||||
version = "1.7.3"
|
||||
version = "1.8.1"
|
||||
description = "OpenHands SDK - Core functionality for building AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_sdk-1.7.3-py3-none-any.whl", hash = "sha256:afbce9c9e7d1167d9b9610673657fbbcd454b04f0151d943418d897de790aeed"},
|
||||
{file = "openhands_sdk-1.7.3.tar.gz", hash = "sha256:7fa0cde9148ab905e24346b50f2d7267fb6dde32ec8dcbc1c7d35ced6e0233aa"},
|
||||
{file = "openhands_sdk-1.8.1-py3-none-any.whl", hash = "sha256:133275f56321585c016b4718d56c8fc7bb834f4ef7cab1ef66b0c71c49d47d1d"},
|
||||
{file = "openhands_sdk-1.8.1.tar.gz", hash = "sha256:9e2baa6c512ac4c2bc1c2c0bf8b1dbdb0267d794a8b86b7306a4656fc0cb8b0b"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -5976,7 +5997,7 @@ fastmcp = ">=2.11.3"
|
||||
httpx = ">=0.27.0"
|
||||
litellm = ">=1.80.10"
|
||||
lmnr = ">=0.7.24"
|
||||
pydantic = ">=2.11.7"
|
||||
pydantic = ">=2.12.5"
|
||||
python-frontmatter = ">=1.1.0"
|
||||
python-json-logger = ">=3.3.0"
|
||||
tenacity = ">=9.1.2"
|
||||
@@ -5987,14 +6008,14 @@ boto3 = ["boto3 (>=1.35.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "openhands-tools"
|
||||
version = "1.7.3"
|
||||
version = "1.8.1"
|
||||
description = "OpenHands Tools - Runtime tools for AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_tools-1.7.3-py3-none-any.whl", hash = "sha256:e823f5a47936dd23221cb4eb846d62b59dce5be69210330095fc242772e71d27"},
|
||||
{file = "openhands_tools-1.7.3.tar.gz", hash = "sha256:f2779cc5ca3b78b9afebb7617006da8069c12b41e6d67cbf0cc8de5d819005f8"},
|
||||
{file = "openhands_tools-1.8.1-py3-none-any.whl", hash = "sha256:9404b17edb8960d4af3a4439e6f68e37c92c59d0705f13096e4a8ff9b6ffc472"},
|
||||
{file = "openhands_tools-1.8.1.tar.gz", hash = "sha256:e59fcd9ca3baa6266e92020646c4c5f5266f57761f434770cf0cd458b1a33cb0"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6003,7 +6024,7 @@ binaryornot = ">=0.4.4"
|
||||
browser-use = ">=0.8.0"
|
||||
cachetools = "*"
|
||||
func-timeout = ">=4.3.5"
|
||||
libtmux = ">=0.46.2"
|
||||
libtmux = ">=0.53.0"
|
||||
openhands-sdk = "*"
|
||||
pydantic = ">=2.11.7"
|
||||
tom-swe = ">=1.0.3"
|
||||
@@ -6859,22 +6880,6 @@ files = [
|
||||
[package.extras]
|
||||
twisted = ["twisted"]
|
||||
|
||||
[[package]]
|
||||
name = "prometheus-fastapi-instrumentator"
|
||||
version = "7.1.0"
|
||||
description = "Instrument your FastAPI app with Prometheus metrics"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "prometheus_fastapi_instrumentator-7.1.0-py3-none-any.whl", hash = "sha256:978130f3c0bb7b8ebcc90d35516a6fe13e02d2eb358c8f83887cdef7020c31e9"},
|
||||
{file = "prometheus_fastapi_instrumentator-7.1.0.tar.gz", hash = "sha256:be7cd61eeea4e5912aeccb4261c6631b3f227d8924542d79eaf5af3f439cbe5e"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
prometheus-client = ">=0.8.0,<1.0.0"
|
||||
starlette = ">=0.30.0,<1.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "prompt-toolkit"
|
||||
version = "3.0.52"
|
||||
@@ -7255,22 +7260,22 @@ markers = {test = "platform_python_implementation == \"CPython\" and sys_platfor
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.11.7"
|
||||
version = "2.12.5"
|
||||
description = "Data validation using Python type hints"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main", "test"]
|
||||
files = [
|
||||
{file = "pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b"},
|
||||
{file = "pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db"},
|
||||
{file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"},
|
||||
{file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
annotated-types = ">=0.6.0"
|
||||
email-validator = {version = ">=2.0.0", optional = true, markers = "extra == \"email\""}
|
||||
pydantic-core = "2.33.2"
|
||||
typing-extensions = ">=4.12.2"
|
||||
typing-inspection = ">=0.4.0"
|
||||
pydantic-core = "2.41.5"
|
||||
typing-extensions = ">=4.14.1"
|
||||
typing-inspection = ">=0.4.2"
|
||||
|
||||
[package.extras]
|
||||
email = ["email-validator (>=2.0.0)"]
|
||||
@@ -7278,115 +7283,137 @@ timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.33.2"
|
||||
version = "2.41.5"
|
||||
description = "Core functionality for Pydantic validation and serialization"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main", "test"]
|
||||
files = [
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27"},
|
||||
{file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b"},
|
||||
{file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034"},
|
||||
{file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c"},
|
||||
{file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2"},
|
||||
{file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad"},
|
||||
{file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd"},
|
||||
{file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc"},
|
||||
{file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56"},
|
||||
{file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b"},
|
||||
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8"},
|
||||
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a"},
|
||||
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b"},
|
||||
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2"},
|
||||
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093"},
|
||||
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a"},
|
||||
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963"},
|
||||
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a"},
|
||||
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26"},
|
||||
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808"},
|
||||
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc"},
|
||||
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1"},
|
||||
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84"},
|
||||
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770"},
|
||||
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f"},
|
||||
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51"},
|
||||
{file = "pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
|
||||
typing-extensions = ">=4.14.1"
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-settings"
|
||||
@@ -13625,14 +13652,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.1"
|
||||
version = "0.4.2"
|
||||
description = "Runtime typing introspection tools"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main", "test"]
|
||||
files = [
|
||||
{file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"},
|
||||
{file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"},
|
||||
{file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"},
|
||||
{file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -13679,21 +13706,21 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.5.0"
|
||||
version = "2.6.3"
|
||||
description = "HTTP library with thread-safe connection pooling, file post, and more."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"},
|
||||
{file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"},
|
||||
{file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"},
|
||||
{file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""]
|
||||
brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""]
|
||||
h2 = ["h2 (>=4,<5)"]
|
||||
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
|
||||
zstd = ["zstandard (>=0.18.0)"]
|
||||
zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""]
|
||||
|
||||
[[package]]
|
||||
name = "uuid7"
|
||||
@@ -14487,4 +14514,4 @@ cffi = ["cffi (>=1.17) ; python_version >= \"3.13\" and platform_python_implemen
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12,<3.14"
|
||||
content-hash = "fac67a8991a3e2c840a23702dc90f99e98d381f3537ad50b4c4739cdbde941ca"
|
||||
content-hash = "b5cbb1e25176845ac9f95650a802667e2f8be1a536e3e55a9269b5af5a42e3fc"
|
||||
|
||||
@@ -29,7 +29,6 @@ cloud-sql-python-connector = "^1.16.0"
|
||||
psycopg2-binary = "^2.9.10"
|
||||
pg8000 = "^1.31.2"
|
||||
stripe = "^11.5.0"
|
||||
prometheus-fastapi-instrumentator = "^7.0.2"
|
||||
python-json-logger = "^3.2.1"
|
||||
python-keycloak = "^5.3.1"
|
||||
asyncpg = "^0.30.0"
|
||||
@@ -44,6 +43,7 @@ coredis = "^4.22.0"
|
||||
httpx = "*"
|
||||
scikit-learn = "^1.7.0"
|
||||
shap = "^0.48.0"
|
||||
google-cloud-recaptcha-enterprise = "^1.24.0"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
ruff = "0.8.3"
|
||||
|
||||
@@ -18,7 +18,6 @@ from server.auth.constants import ( # noqa: E402
|
||||
)
|
||||
from server.constants import PERMITTED_CORS_ORIGINS # noqa: E402
|
||||
from server.logger import logger # noqa: E402
|
||||
from server.metrics import metrics_app # noqa: E402
|
||||
from server.middleware import SetAuthCookieMiddleware # noqa: E402
|
||||
from server.rate_limit import setup_rate_limit_handler # noqa: E402
|
||||
from server.routes.api_keys import api_router as api_keys_router # noqa: E402
|
||||
@@ -61,9 +60,6 @@ def is_saas():
|
||||
return {'saas': True}
|
||||
|
||||
|
||||
# This requires a trailing slash to access, like /api/metrics/
|
||||
base_app.mount('/internal/metrics', metrics_app())
|
||||
|
||||
base_app.include_router(readiness_router) # Add routes for readiness checks
|
||||
base_app.include_router(api_router) # Add additional route for github auth
|
||||
base_app.include_router(oauth_router) # Add additional route for oauth callback
|
||||
|
||||
@@ -38,8 +38,16 @@ ROLE_CHECK_ENABLED = os.getenv('ROLE_CHECK_ENABLED', 'false').lower() in (
|
||||
'y',
|
||||
'on',
|
||||
)
|
||||
BLOCKED_EMAIL_DOMAINS = [
|
||||
domain.strip().lower()
|
||||
for domain in os.getenv('BLOCKED_EMAIL_DOMAINS', '').split(',')
|
||||
if domain.strip()
|
||||
]
|
||||
|
||||
# reCAPTCHA Enterprise
|
||||
RECAPTCHA_PROJECT_ID = os.getenv('RECAPTCHA_PROJECT_ID', '').strip()
|
||||
RECAPTCHA_SITE_KEY = os.getenv('RECAPTCHA_SITE_KEY', '').strip()
|
||||
RECAPTCHA_HMAC_SECRET = os.getenv('RECAPTCHA_HMAC_SECRET', '').strip()
|
||||
RECAPTCHA_BLOCK_THRESHOLD = float(os.getenv('RECAPTCHA_BLOCK_THRESHOLD', '0.3'))
|
||||
|
||||
# Account Defender labels that indicate suspicious activity
|
||||
SUSPICIOUS_LABELS = {
|
||||
'SUSPICIOUS_LOGIN_ACTIVITY',
|
||||
'SUSPICIOUS_ACCOUNT_CREATION',
|
||||
'RELATED_ACCOUNTS_NUMBER_HIGH',
|
||||
}
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
from server.auth.constants import BLOCKED_EMAIL_DOMAINS
|
||||
from storage.blocked_email_domain_store import BlockedEmailDomainStore
|
||||
from storage.database import session_maker
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
class DomainBlocker:
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, store: BlockedEmailDomainStore) -> None:
|
||||
logger.debug('Initializing DomainBlocker')
|
||||
self.blocked_domains: list[str] = BLOCKED_EMAIL_DOMAINS
|
||||
if self.blocked_domains:
|
||||
logger.info(
|
||||
f'Successfully loaded {len(self.blocked_domains)} blocked email domains: {self.blocked_domains}'
|
||||
)
|
||||
|
||||
def is_active(self) -> bool:
|
||||
"""Check if domain blocking is enabled"""
|
||||
return bool(self.blocked_domains)
|
||||
self.store = store
|
||||
|
||||
def _extract_domain(self, email: str) -> str | None:
|
||||
"""Extract and normalize email domain from email address"""
|
||||
@@ -31,16 +24,16 @@ class DomainBlocker:
|
||||
return None
|
||||
|
||||
def is_domain_blocked(self, email: str) -> bool:
|
||||
"""Check if email domain is blocked
|
||||
"""Check if email domain is blocked by querying the database directly via SQL.
|
||||
|
||||
Supports blocking:
|
||||
- Exact domains: 'example.com' blocks 'user@example.com'
|
||||
- Subdomains: 'example.com' blocks 'user@subdomain.example.com'
|
||||
- TLDs: '.us' blocks 'user@company.us' and 'user@subdomain.company.us'
|
||||
"""
|
||||
if not self.is_active():
|
||||
return False
|
||||
|
||||
The blocking logic is handled efficiently in SQL, avoiding the need to load
|
||||
all blocked domains into memory.
|
||||
"""
|
||||
if not email:
|
||||
logger.debug('No email provided for domain check')
|
||||
return False
|
||||
@@ -50,26 +43,25 @@ class DomainBlocker:
|
||||
logger.debug(f'Could not extract domain from email: {email}')
|
||||
return False
|
||||
|
||||
# Check if domain matches any blocked pattern
|
||||
for blocked_pattern in self.blocked_domains:
|
||||
if blocked_pattern.startswith('.'):
|
||||
# TLD pattern (e.g., '.us') - check if domain ends with it
|
||||
if domain.endswith(blocked_pattern):
|
||||
logger.warning(
|
||||
f'Email domain {domain} is blocked by TLD pattern {blocked_pattern} for email: {email}'
|
||||
)
|
||||
return True
|
||||
try:
|
||||
# Query database directly via SQL to check if domain is blocked
|
||||
is_blocked = self.store.is_domain_blocked(domain)
|
||||
|
||||
if is_blocked:
|
||||
logger.warning(f'Email domain {domain} is blocked for email: {email}')
|
||||
else:
|
||||
# Full domain pattern (e.g., 'example.com')
|
||||
# Block exact match or subdomains
|
||||
if domain == blocked_pattern or domain.endswith(f'.{blocked_pattern}'):
|
||||
logger.warning(
|
||||
f'Email domain {domain} is blocked by domain pattern {blocked_pattern} for email: {email}'
|
||||
)
|
||||
return True
|
||||
logger.debug(f'Email domain {domain} is not blocked')
|
||||
|
||||
logger.debug(f'Email domain {domain} is not blocked')
|
||||
return False
|
||||
return is_blocked
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'Error checking if domain is blocked for email {email}: {e}',
|
||||
exc_info=True,
|
||||
)
|
||||
# Fail-safe: if database query fails, don't block (allow auth to proceed)
|
||||
return False
|
||||
|
||||
|
||||
domain_blocker = DomainBlocker()
|
||||
# Initialize store and domain blocker
|
||||
_store = BlockedEmailDomainStore(session_maker=session_maker)
|
||||
domain_blocker = DomainBlocker(store=_store)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import asyncio
|
||||
|
||||
from integrations.gitlab.gitlab_service import SaaSGitLabService
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
@@ -19,6 +18,12 @@ def schedule_gitlab_repo_sync(
|
||||
|
||||
async def _run():
|
||||
try:
|
||||
# Lazy import to avoid circular dependency:
|
||||
# middleware -> gitlab_sync -> integrations.gitlab.gitlab_service
|
||||
# -> openhands.integrations.gitlab.gitlab_service -> get_impl
|
||||
# -> integrations.gitlab.gitlab_service (circular)
|
||||
from integrations.gitlab.gitlab_service import SaaSGitLabService
|
||||
|
||||
service = SaaSGitLabService(
|
||||
external_auth_id=user_id, external_auth_token=keycloak_access_token
|
||||
)
|
||||
|
||||
153
enterprise/server/auth/recaptcha_service.py
Normal file
153
enterprise/server/auth/recaptcha_service.py
Normal file
@@ -0,0 +1,153 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
from dataclasses import dataclass
|
||||
|
||||
from google.cloud import recaptchaenterprise_v1
|
||||
from server.auth.constants import (
|
||||
RECAPTCHA_BLOCK_THRESHOLD,
|
||||
RECAPTCHA_HMAC_SECRET,
|
||||
RECAPTCHA_PROJECT_ID,
|
||||
RECAPTCHA_SITE_KEY,
|
||||
SUSPICIOUS_LABELS,
|
||||
)
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
@dataclass
|
||||
class AssessmentResult:
|
||||
"""Result of a reCAPTCHA Enterprise assessment."""
|
||||
|
||||
score: float
|
||||
valid: bool
|
||||
action_valid: bool
|
||||
reason_codes: list[str]
|
||||
account_defender_labels: list[str]
|
||||
allowed: bool
|
||||
|
||||
|
||||
class RecaptchaService:
|
||||
"""Service for creating reCAPTCHA Enterprise assessments."""
|
||||
|
||||
def __init__(self):
|
||||
self._client = None
|
||||
self.project_id = RECAPTCHA_PROJECT_ID
|
||||
self.site_key = RECAPTCHA_SITE_KEY
|
||||
|
||||
@property
|
||||
def client(self):
|
||||
"""Lazily initialize the reCAPTCHA client to avoid credential errors at import time."""
|
||||
if self._client is None:
|
||||
self._client = recaptchaenterprise_v1.RecaptchaEnterpriseServiceClient()
|
||||
return self._client
|
||||
|
||||
def hash_account_id(self, email: str) -> str:
|
||||
"""Hash email using SHA256-HMAC for Account Defender.
|
||||
|
||||
Args:
|
||||
email: The user's email address.
|
||||
|
||||
Returns:
|
||||
Hex-encoded HMAC-SHA256 hash of the lowercase email.
|
||||
"""
|
||||
return hmac.new(
|
||||
RECAPTCHA_HMAC_SECRET.encode(),
|
||||
email.lower().encode(),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
def create_assessment(
|
||||
self,
|
||||
token: str,
|
||||
action: str,
|
||||
user_ip: str,
|
||||
user_agent: str,
|
||||
email: str | None = None,
|
||||
) -> AssessmentResult:
|
||||
"""Create a reCAPTCHA Enterprise assessment.
|
||||
|
||||
Args:
|
||||
token: The reCAPTCHA token from the frontend.
|
||||
action: The expected action name (e.g., 'LOGIN').
|
||||
user_ip: The user's IP address.
|
||||
user_agent: The user's browser user agent.
|
||||
email: Optional email for Account Defender hashing.
|
||||
|
||||
Returns:
|
||||
AssessmentResult with score, validity, and allowed status.
|
||||
"""
|
||||
event = recaptchaenterprise_v1.Event()
|
||||
event.site_key = self.site_key
|
||||
event.token = token
|
||||
event.user_ip_address = user_ip
|
||||
event.user_agent = user_agent
|
||||
event.expected_action = action
|
||||
|
||||
# Account Defender: use user_info.account_id (hashed_account_id is deprecated)
|
||||
if email:
|
||||
user_info = recaptchaenterprise_v1.UserInfo()
|
||||
user_info.account_id = self.hash_account_id(email)
|
||||
# Also include email as a user identifier for better fraud detection
|
||||
user_info.user_ids.append(recaptchaenterprise_v1.UserId(email=email))
|
||||
event.user_info = user_info
|
||||
|
||||
assessment = recaptchaenterprise_v1.Assessment()
|
||||
assessment.event = event
|
||||
|
||||
request = recaptchaenterprise_v1.CreateAssessmentRequest()
|
||||
request.assessment = assessment
|
||||
request.parent = f'projects/{self.project_id}'
|
||||
|
||||
response = self.client.create_assessment(request)
|
||||
|
||||
token_properties = response.token_properties
|
||||
risk_analysis = response.risk_analysis
|
||||
|
||||
score = risk_analysis.score
|
||||
valid = token_properties.valid
|
||||
action_valid = token_properties.action == action
|
||||
reason_codes = [str(r) for r in risk_analysis.reasons]
|
||||
|
||||
# Extract Account Defender labels
|
||||
account_defender_labels = []
|
||||
if response.account_defender_assessment:
|
||||
account_defender_labels = [
|
||||
str(label) for label in response.account_defender_assessment.labels
|
||||
]
|
||||
|
||||
# Check if any suspicious labels are present
|
||||
has_suspicious_labels = bool(set(account_defender_labels) & SUSPICIOUS_LABELS)
|
||||
|
||||
# Block if: invalid token, wrong action, low score, OR suspicious Account Defender labels
|
||||
allowed = (
|
||||
valid
|
||||
and action_valid
|
||||
and score >= RECAPTCHA_BLOCK_THRESHOLD
|
||||
and not has_suspicious_labels
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'recaptcha_assessment',
|
||||
extra={
|
||||
'score': score,
|
||||
'valid': valid,
|
||||
'action_valid': action_valid,
|
||||
'reasons': reason_codes,
|
||||
'account_defender_labels': account_defender_labels,
|
||||
'has_suspicious_labels': has_suspicious_labels,
|
||||
'allowed': allowed,
|
||||
'user_ip': user_ip,
|
||||
},
|
||||
)
|
||||
|
||||
return AssessmentResult(
|
||||
score=score,
|
||||
valid=valid,
|
||||
action_valid=action_valid,
|
||||
reason_codes=reason_codes,
|
||||
account_defender_labels=account_defender_labels,
|
||||
allowed=allowed,
|
||||
)
|
||||
|
||||
|
||||
recaptcha_service = RecaptchaService()
|
||||
@@ -317,7 +317,7 @@ async def saas_user_auth_from_signed_token(signed_token: str) -> SaasUserAuth:
|
||||
email_verified = access_token_payload['email_verified']
|
||||
|
||||
# Check if email domain is blocked
|
||||
if email and domain_blocker.is_active() and domain_blocker.is_domain_blocked(email):
|
||||
if email and domain_blocker.is_domain_blocked(email):
|
||||
logger.warning(
|
||||
f'Blocked authentication attempt for existing user with email: {email}'
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ from keycloak.exceptions import (
|
||||
KeycloakAuthenticationError,
|
||||
KeycloakConnectionError,
|
||||
KeycloakError,
|
||||
KeycloakPostError,
|
||||
)
|
||||
from server.auth.constants import (
|
||||
BITBUCKET_APP_CLIENT_ID,
|
||||
@@ -43,6 +44,7 @@ from storage.offline_token_store import OfflineTokenStore
|
||||
from tenacity import RetryCallState, retry, retry_if_exception_type, stop_after_attempt
|
||||
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
from openhands.server.types import SessionExpiredError
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
|
||||
|
||||
@@ -465,6 +467,14 @@ class TokenManager:
|
||||
except KeycloakConnectionError:
|
||||
logger.exception('KeycloakConnectionError when refreshing token')
|
||||
raise
|
||||
except KeycloakPostError as e:
|
||||
error_message = str(e)
|
||||
if 'invalid_grant' in error_message or 'session not found' in error_message:
|
||||
logger.warning(f'User session expired or invalid: {error_message}')
|
||||
raise SessionExpiredError(
|
||||
'Your session has expired. Please login again.'
|
||||
) from e
|
||||
raise
|
||||
|
||||
@retry(
|
||||
stop=stop_after_attempt(2),
|
||||
|
||||
@@ -8,7 +8,6 @@ import socketio
|
||||
from server.logger import logger
|
||||
from server.utils.conversation_callback_utils import invoke_conversation_callbacks
|
||||
from storage.database import session_maker
|
||||
from storage.saas_settings_store import SaasSettingsStore
|
||||
from storage.stored_conversation_metadata import StoredConversationMetadata
|
||||
|
||||
from openhands.core.config import LLMConfig
|
||||
@@ -743,6 +742,8 @@ class ClusteredConversationManager(StandaloneConversationManager):
|
||||
return
|
||||
|
||||
# Restart the agent loop
|
||||
from storage.saas_settings_store import SaasSettingsStore
|
||||
|
||||
config = load_openhands_config()
|
||||
settings_store = await SaasSettingsStore.get_instance(config, user_id)
|
||||
settings = await settings_store.load()
|
||||
|
||||
@@ -17,6 +17,7 @@ from server.auth.constants import (
|
||||
GITHUB_APP_PRIVATE_KEY,
|
||||
GITHUB_APP_WEBHOOK_SECRET,
|
||||
GITLAB_APP_CLIENT_ID,
|
||||
RECAPTCHA_SITE_KEY,
|
||||
)
|
||||
|
||||
from openhands.core.config.utils import load_openhands_config
|
||||
@@ -187,4 +188,7 @@ class SaaSServerConfig(ServerConfig):
|
||||
if self.auth_url:
|
||||
config['AUTH_URL'] = self.auth_url
|
||||
|
||||
if RECAPTCHA_SITE_KEY:
|
||||
config['RECAPTCHA_SITE_KEY'] = RECAPTCHA_SITE_KEY
|
||||
|
||||
return config
|
||||
|
||||
@@ -73,36 +73,22 @@ PERMITTED_CORS_ORIGINS = [
|
||||
|
||||
|
||||
def build_litellm_proxy_model_path(model_name: str) -> str:
|
||||
"""
|
||||
Build the LiteLLM proxy model path based on environment and model name.
|
||||
|
||||
This utility constructs the full model path for LiteLLM proxy based on:
|
||||
- Environment type (staging vs prod)
|
||||
- The provided model name
|
||||
"""Build the LiteLLM proxy model path based on model name.
|
||||
|
||||
Args:
|
||||
model_name: The base model name (e.g., 'claude-3-7-sonnet-20250219')
|
||||
|
||||
Returns:
|
||||
The full LiteLLM proxy model path (e.g., 'litellm_proxy/prod/claude-3-7-sonnet-20250219')
|
||||
The full LiteLLM proxy model path (e.g., 'litellm_proxy/claude-3-7-sonnet-20250219')
|
||||
"""
|
||||
|
||||
if 'prod' in model_name or 'litellm' in model_name or 'proxy' in model_name:
|
||||
if 'litellm' in model_name:
|
||||
raise ValueError("Only include model name, don't include prefix")
|
||||
|
||||
prefix = 'litellm_proxy/'
|
||||
|
||||
if not IS_STAGING_ENV and not IS_LOCAL_ENV:
|
||||
prefix += 'prod/'
|
||||
|
||||
return prefix + model_name
|
||||
return 'litellm_proxy/' + model_name
|
||||
|
||||
|
||||
def get_default_litellm_model():
|
||||
"""
|
||||
Construct proxy for litellm model based on user settings and environment type (staging vs prod)
|
||||
if not set explicitly
|
||||
"""
|
||||
"""Construct proxy for litellm model based on user settings if not set explicitly."""
|
||||
if LITELLM_DEFAULT_MODEL:
|
||||
return LITELLM_DEFAULT_MODEL
|
||||
model = USER_SETTINGS_VERSION_TO_MODEL[CURRENT_USER_SETTINGS_VERSION]
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
from typing import Callable
|
||||
|
||||
from prometheus_client import Gauge, make_asgi_app
|
||||
from server.clustered_conversation_manager import ClusteredConversationManager
|
||||
|
||||
from openhands.server.shared import (
|
||||
conversation_manager,
|
||||
)
|
||||
|
||||
RUNNING_AGENT_LOOPS_GAUGE = Gauge(
|
||||
'saas_running_agent_loops',
|
||||
'Count of running agent loops, aggregate by session_id to dedupe',
|
||||
['session_id'],
|
||||
)
|
||||
|
||||
|
||||
async def _update_metrics():
|
||||
"""Update any prometheus metrics that are not updated during normal operation."""
|
||||
if isinstance(conversation_manager, ClusteredConversationManager):
|
||||
running_agent_loops = (
|
||||
await conversation_manager.get_running_agent_loops_locally()
|
||||
)
|
||||
# Clear so we don't keep counting old sessions.
|
||||
# This is theoretically a race condition but this is scraped on a regular interval.
|
||||
RUNNING_AGENT_LOOPS_GAUGE.clear()
|
||||
# running_agent_loops shouldn't be None, but can be.
|
||||
if running_agent_loops is not None:
|
||||
for sid in running_agent_loops:
|
||||
RUNNING_AGENT_LOOPS_GAUGE.labels(session_id=sid).set(1)
|
||||
|
||||
|
||||
def metrics_app() -> Callable:
|
||||
metrics_callable = make_asgi_app()
|
||||
|
||||
async def wrapped_handler(scope, receive, send):
|
||||
"""
|
||||
Call _update_metrics before serving Prometheus metrics endpoint.
|
||||
Not wrapped in a `try`, failing would make metrics endpoint unavailable.
|
||||
"""
|
||||
await _update_metrics()
|
||||
await metrics_callable(scope, receive, send)
|
||||
|
||||
return wrapped_handler
|
||||
@@ -166,6 +166,12 @@ class SetAuthCookieMiddleware:
|
||||
if path in ignore_paths:
|
||||
return False
|
||||
|
||||
# Allow public access to shared conversations and events
|
||||
if path.startswith('/api/shared-conversations') or path.startswith(
|
||||
'/api/shared-events'
|
||||
):
|
||||
return False
|
||||
|
||||
is_mcp = path.startswith('/mcp')
|
||||
is_api_route = path.startswith('/api')
|
||||
return is_api_route or is_mcp
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import base64
|
||||
import json
|
||||
import warnings
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated, Literal, Optional
|
||||
@@ -12,10 +14,12 @@ from server.auth.constants import (
|
||||
KEYCLOAK_CLIENT_ID,
|
||||
KEYCLOAK_REALM_NAME,
|
||||
KEYCLOAK_SERVER_URL_EXT,
|
||||
RECAPTCHA_SITE_KEY,
|
||||
ROLE_CHECK_ENABLED,
|
||||
)
|
||||
from server.auth.domain_blocker import domain_blocker
|
||||
from server.auth.gitlab_sync import schedule_gitlab_repo_sync
|
||||
from server.auth.recaptcha_service import recaptcha_service
|
||||
from server.auth.saas_user_auth import SaasUserAuth
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.config import get_config, sign_token
|
||||
@@ -98,6 +102,24 @@ def get_cookie_samesite(request: Request) -> Literal['lax', 'strict']:
|
||||
)
|
||||
|
||||
|
||||
def _extract_recaptcha_state(state: str | None) -> tuple[str, str | None]:
|
||||
"""Extract redirect URL and reCAPTCHA token from OAuth state.
|
||||
|
||||
Returns:
|
||||
Tuple of (redirect_url, recaptcha_token). Token may be None.
|
||||
"""
|
||||
if not state:
|
||||
return '', None
|
||||
|
||||
try:
|
||||
# Try to decode as JSON (new format with reCAPTCHA)
|
||||
state_data = json.loads(base64.urlsafe_b64decode(state.encode()).decode())
|
||||
return state_data.get('redirect_url', ''), state_data.get('recaptcha_token')
|
||||
except Exception:
|
||||
# Old format - state is just the redirect URL
|
||||
return state, None
|
||||
|
||||
|
||||
@oauth_router.get('/keycloak/callback')
|
||||
async def keycloak_callback(
|
||||
request: Request,
|
||||
@@ -106,7 +128,11 @@ async def keycloak_callback(
|
||||
error: Optional[str] = None,
|
||||
error_description: Optional[str] = None,
|
||||
):
|
||||
redirect_url: str = state if state else str(request.base_url)
|
||||
# Extract redirect URL and reCAPTCHA token from state
|
||||
redirect_url, recaptcha_token = _extract_recaptcha_state(state)
|
||||
if not redirect_url:
|
||||
redirect_url = str(request.base_url)
|
||||
|
||||
if not code:
|
||||
# check if this is a forward from the account linking page
|
||||
if (
|
||||
@@ -149,9 +175,44 @@ async def keycloak_callback(
|
||||
email = user_info.get('email')
|
||||
user_id = user_info['sub']
|
||||
|
||||
# reCAPTCHA verification with Account Defender
|
||||
if RECAPTCHA_SITE_KEY and recaptcha_token:
|
||||
user_ip = request.client.host if request.client else 'unknown'
|
||||
user_agent = request.headers.get('User-Agent', '')
|
||||
|
||||
# Handle X-Forwarded-For for proxied requests
|
||||
forwarded_for = request.headers.get('X-Forwarded-For')
|
||||
if forwarded_for:
|
||||
user_ip = forwarded_for.split(',')[0].strip()
|
||||
|
||||
try:
|
||||
result = recaptcha_service.create_assessment(
|
||||
token=recaptcha_token,
|
||||
action='LOGIN',
|
||||
user_ip=user_ip,
|
||||
user_agent=user_agent,
|
||||
email=email,
|
||||
)
|
||||
|
||||
if not result.allowed:
|
||||
logger.warning(
|
||||
'recaptcha_blocked_at_callback',
|
||||
extra={
|
||||
'user_ip': user_ip,
|
||||
'score': result.score,
|
||||
'user_id': user_id,
|
||||
},
|
||||
)
|
||||
# Redirect to home with error parameter
|
||||
error_url = f'{request.base_url}login?recaptcha_blocked=true'
|
||||
return RedirectResponse(error_url, status_code=302)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f'reCAPTCHA verification error at callback: {e}')
|
||||
# Fail open - continue with login if reCAPTCHA service unavailable
|
||||
|
||||
# Check if email domain is blocked
|
||||
email = user_info.get('email')
|
||||
if email and domain_blocker.is_active() and domain_blocker.is_domain_blocked(email):
|
||||
if email and domain_blocker.is_domain_blocked(email):
|
||||
logger.warning(
|
||||
f'Blocked authentication attempt for email: {email}, user_id: {user_id}'
|
||||
)
|
||||
@@ -193,7 +254,7 @@ async def keycloak_callback(
|
||||
)
|
||||
|
||||
# Redirect to home page with query parameter indicating the issue
|
||||
home_url = f'{request.base_url}?duplicated_email=true'
|
||||
home_url = f'{request.base_url}/login?duplicated_email=true'
|
||||
return RedirectResponse(home_url, status_code=302)
|
||||
except Exception as e:
|
||||
# Log error but allow signup to proceed (fail open)
|
||||
@@ -210,9 +271,7 @@ async def keycloak_callback(
|
||||
from server.routes.email import verify_email
|
||||
|
||||
await verify_email(request=request, user_id=user_id, is_auth_flow=True)
|
||||
redirect_url = (
|
||||
f'{request.base_url}?email_verification_required=true&user_id={user_id}'
|
||||
)
|
||||
redirect_url = f'{request.base_url}login?email_verification_required=true&user_id={user_id}'
|
||||
response = RedirectResponse(redirect_url, status_code=302)
|
||||
return response
|
||||
|
||||
|
||||
@@ -112,7 +112,9 @@ async def get_credits(user_id: str = Depends(get_user_id)) -> GetCreditsResponse
|
||||
if not stripe_service.STRIPE_API_KEY:
|
||||
return GetCreditsResponse()
|
||||
try:
|
||||
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
|
||||
async with httpx.AsyncClient(
|
||||
verify=httpx_verify_option(), timeout=15.0
|
||||
) as client:
|
||||
user_json = await _get_litellm_user(client, user_id)
|
||||
credits = calculate_credits(user_json['user_info'])
|
||||
return GetCreditsResponse(credits=Decimal('{:.2f}'.format(credits)))
|
||||
|
||||
@@ -166,7 +166,7 @@ async def verify_email(request: Request, user_id: str, is_auth_flow: bool = Fals
|
||||
keycloak_admin = get_keycloak_admin()
|
||||
scheme = 'http' if request.url.hostname == 'localhost' else 'https'
|
||||
if is_auth_flow:
|
||||
redirect_uri = f'{scheme}://{request.url.netloc}?email_verified=true'
|
||||
redirect_uri = f'{scheme}://{request.url.netloc}/login?email_verified=true'
|
||||
else:
|
||||
redirect_uri = f'{scheme}://{request.url.netloc}/api/email/verified'
|
||||
logger.info(f'Redirect URI: {redirect_uri}')
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, Request
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from integrations.gitlab.gitlab_manager import GitlabManager
|
||||
from integrations.gitlab.gitlab_service import SaaSGitLabService
|
||||
from integrations.gitlab.webhook_installation import (
|
||||
BreakLoopException,
|
||||
install_webhook_on_resource,
|
||||
verify_webhook_conditions,
|
||||
)
|
||||
from integrations.models import Message, SourceType
|
||||
from integrations.types import GitLabResourceType
|
||||
from integrations.utils import GITLAB_WEBHOOK_URL
|
||||
from pydantic import BaseModel
|
||||
from server.auth.token_manager import TokenManager
|
||||
from storage.gitlab_webhook import GitlabWebhook
|
||||
from storage.gitlab_webhook_store import GitlabWebhookStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
|
||||
from openhands.server.shared import sio
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
gitlab_integration_router = APIRouter(prefix='/integration')
|
||||
webhook_store = GitlabWebhookStore()
|
||||
@@ -18,6 +31,37 @@ token_manager = TokenManager()
|
||||
gitlab_manager = GitlabManager(token_manager)
|
||||
|
||||
|
||||
# Request/Response models
|
||||
class ResourceIdentifier(BaseModel):
|
||||
type: GitLabResourceType
|
||||
id: str
|
||||
|
||||
|
||||
class ReinstallWebhookRequest(BaseModel):
|
||||
resource: ResourceIdentifier
|
||||
|
||||
|
||||
class ResourceWithWebhookStatus(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
full_path: str
|
||||
type: str
|
||||
webhook_installed: bool
|
||||
webhook_uuid: str | None
|
||||
last_synced: str | None
|
||||
|
||||
|
||||
class GitLabResourcesResponse(BaseModel):
|
||||
resources: list[ResourceWithWebhookStatus]
|
||||
|
||||
|
||||
class ResourceInstallationResult(BaseModel):
|
||||
resource_id: str
|
||||
resource_type: str
|
||||
success: bool
|
||||
error: str | None
|
||||
|
||||
|
||||
async def verify_gitlab_signature(
|
||||
header_webhook_secret: str, webhook_uuid: str, user_id: str
|
||||
):
|
||||
@@ -83,3 +127,260 @@ async def gitlab_events(
|
||||
except Exception as e:
|
||||
logger.exception(f'Error processing GitLab event: {e}')
|
||||
return JSONResponse(status_code=400, content={'error': 'Invalid payload.'})
|
||||
|
||||
|
||||
@gitlab_integration_router.get('/gitlab/resources')
|
||||
async def get_gitlab_resources(
|
||||
user_id: str = Depends(get_user_id),
|
||||
) -> GitLabResourcesResponse:
|
||||
"""Get all GitLab projects and groups where the user has admin access.
|
||||
|
||||
Returns a list of resources with their webhook installation status.
|
||||
"""
|
||||
try:
|
||||
# Get GitLab service for the user
|
||||
gitlab_service = GitLabServiceImpl(external_auth_id=user_id)
|
||||
|
||||
if not isinstance(gitlab_service, SaaSGitLabService):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Only SaaS GitLab service is supported',
|
||||
)
|
||||
|
||||
# Fetch projects and groups with admin access
|
||||
projects, groups = await gitlab_service.get_user_resources_with_admin_access()
|
||||
|
||||
# Filter out projects that belong to a group (nested projects)
|
||||
# We only want top-level personal projects since group webhooks cover nested projects
|
||||
filtered_projects = [
|
||||
project
|
||||
for project in projects
|
||||
if project.get('namespace', {}).get('kind') != 'group'
|
||||
]
|
||||
|
||||
# Extract IDs for bulk fetching
|
||||
project_ids = [str(project['id']) for project in filtered_projects]
|
||||
group_ids = [str(group['id']) for group in groups]
|
||||
|
||||
# Bulk fetch webhook records from database (organization-wide)
|
||||
(
|
||||
project_webhook_map,
|
||||
group_webhook_map,
|
||||
) = await webhook_store.get_webhooks_by_resources(project_ids, group_ids)
|
||||
|
||||
# Parallelize GitLab API calls to check webhook status for all resources
|
||||
async def check_project_webhook(project):
|
||||
project_id = str(project['id'])
|
||||
webhook_exists, _ = await gitlab_service.check_webhook_exists_on_resource(
|
||||
GitLabResourceType.PROJECT, project_id, GITLAB_WEBHOOK_URL
|
||||
)
|
||||
return project_id, webhook_exists
|
||||
|
||||
async def check_group_webhook(group):
|
||||
group_id = str(group['id'])
|
||||
webhook_exists, _ = await gitlab_service.check_webhook_exists_on_resource(
|
||||
GitLabResourceType.GROUP, group_id, GITLAB_WEBHOOK_URL
|
||||
)
|
||||
return group_id, webhook_exists
|
||||
|
||||
# Gather all API calls in parallel
|
||||
project_checks = [
|
||||
check_project_webhook(project) for project in filtered_projects
|
||||
]
|
||||
group_checks = [check_group_webhook(group) for group in groups]
|
||||
|
||||
# Execute all checks concurrently
|
||||
all_results = await asyncio.gather(*(project_checks + group_checks))
|
||||
|
||||
# Split results back into projects and groups
|
||||
num_projects = len(filtered_projects)
|
||||
project_results = all_results[:num_projects]
|
||||
group_results = all_results[num_projects:]
|
||||
|
||||
# Build response
|
||||
resources = []
|
||||
|
||||
# Add projects with their webhook status
|
||||
for project, (project_id, webhook_exists) in zip(
|
||||
filtered_projects, project_results
|
||||
):
|
||||
webhook = project_webhook_map.get(project_id)
|
||||
|
||||
resources.append(
|
||||
ResourceWithWebhookStatus(
|
||||
id=project_id,
|
||||
name=project.get('name', ''),
|
||||
full_path=project.get('path_with_namespace', ''),
|
||||
type='project',
|
||||
webhook_installed=webhook_exists,
|
||||
webhook_uuid=webhook.webhook_uuid if webhook else None,
|
||||
last_synced=(
|
||||
webhook.last_synced.isoformat()
|
||||
if webhook and webhook.last_synced
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# Add groups with their webhook status
|
||||
for group, (group_id, webhook_exists) in zip(groups, group_results):
|
||||
webhook = group_webhook_map.get(group_id)
|
||||
|
||||
resources.append(
|
||||
ResourceWithWebhookStatus(
|
||||
id=group_id,
|
||||
name=group.get('name', ''),
|
||||
full_path=group.get('full_path', ''),
|
||||
type='group',
|
||||
webhook_installed=webhook_exists,
|
||||
webhook_uuid=webhook.webhook_uuid if webhook else None,
|
||||
last_synced=(
|
||||
webhook.last_synced.isoformat()
|
||||
if webhook and webhook.last_synced
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Retrieved GitLab resources',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'project_count': len(projects),
|
||||
'group_count': len(groups),
|
||||
},
|
||||
)
|
||||
|
||||
return GitLabResourcesResponse(resources=resources)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f'Error retrieving GitLab resources: {e}')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to retrieve GitLab resources',
|
||||
)
|
||||
|
||||
|
||||
@gitlab_integration_router.post('/gitlab/reinstall-webhook')
|
||||
async def reinstall_gitlab_webhook(
|
||||
body: ReinstallWebhookRequest,
|
||||
user_id: str = Depends(get_user_id),
|
||||
) -> ResourceInstallationResult:
|
||||
"""Reinstall GitLab webhook for a specific resource immediately.
|
||||
|
||||
This endpoint validates permissions, resets webhook status in the database,
|
||||
and immediately installs the webhook on the specified resource.
|
||||
"""
|
||||
try:
|
||||
# Get GitLab service for the user
|
||||
gitlab_service = GitLabServiceImpl(external_auth_id=user_id)
|
||||
|
||||
if not isinstance(gitlab_service, SaaSGitLabService):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Only SaaS GitLab service is supported',
|
||||
)
|
||||
|
||||
resource_id = body.resource.id
|
||||
resource_type = body.resource.type
|
||||
|
||||
# Check if user has admin access to this resource
|
||||
(
|
||||
has_admin_access,
|
||||
check_status,
|
||||
) = await gitlab_service.check_user_has_admin_access_to_resource(
|
||||
resource_type, resource_id
|
||||
)
|
||||
|
||||
if not has_admin_access:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='User does not have admin access to this resource',
|
||||
)
|
||||
|
||||
# Reset webhook in database (organization-wide, not user-specific)
|
||||
# This allows any admin user to reinstall webhooks
|
||||
await webhook_store.reset_webhook_for_reinstallation_by_resource(
|
||||
resource_type, resource_id, user_id
|
||||
)
|
||||
|
||||
# Get or create webhook record (without user_id filter)
|
||||
webhook = await webhook_store.get_webhook_by_resource_only(
|
||||
resource_type, resource_id
|
||||
)
|
||||
|
||||
if not webhook:
|
||||
# Create new webhook record
|
||||
webhook = GitlabWebhook(
|
||||
user_id=user_id, # Track who created it
|
||||
project_id=resource_id
|
||||
if resource_type == GitLabResourceType.PROJECT
|
||||
else None,
|
||||
group_id=resource_id
|
||||
if resource_type == GitLabResourceType.GROUP
|
||||
else None,
|
||||
webhook_exists=False,
|
||||
)
|
||||
await webhook_store.store_webhooks([webhook])
|
||||
# Fetch it again to get the ID (without user_id filter)
|
||||
webhook = await webhook_store.get_webhook_by_resource_only(
|
||||
resource_type, resource_id
|
||||
)
|
||||
|
||||
# Verify conditions and install webhook
|
||||
try:
|
||||
await verify_webhook_conditions(
|
||||
gitlab_service=gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=webhook_store,
|
||||
webhook=webhook,
|
||||
)
|
||||
|
||||
# Install the webhook
|
||||
webhook_id, install_status = await install_webhook_on_resource(
|
||||
gitlab_service=gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=webhook_store,
|
||||
webhook=webhook,
|
||||
)
|
||||
|
||||
if webhook_id:
|
||||
logger.info(
|
||||
'GitLab webhook reinstalled successfully',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'resource_type': resource_type.value,
|
||||
'resource_id': resource_id,
|
||||
},
|
||||
)
|
||||
return ResourceInstallationResult(
|
||||
resource_id=resource_id,
|
||||
resource_type=resource_type.value,
|
||||
success=True,
|
||||
error=None,
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to install webhook',
|
||||
)
|
||||
|
||||
except BreakLoopException:
|
||||
# Conditions not met or webhook already exists
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Webhook installation conditions not met or webhook already exists',
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f'Error reinstalling GitLab webhook: {e}')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to reinstall webhook',
|
||||
)
|
||||
|
||||
@@ -107,7 +107,7 @@ async def install_callback(
|
||||
|
||||
# Redirect into keycloak
|
||||
scope = quote('openid email profile offline_access')
|
||||
redirect_uri = quote(f'{HOST_URL}/slack/keycloak-callback')
|
||||
redirect_uri = f'{HOST_URL}/slack/keycloak-callback'
|
||||
auth_url = (
|
||||
f'{KEYCLOAK_SERVER_URL_EXT}/realms/{KEYCLOAK_REALM_NAME}/protocol/openid-connect/auth'
|
||||
f'?client_id={KEYCLOAK_CLIENT_ID}&response_type=code'
|
||||
@@ -158,7 +158,7 @@ async def keycloak_callback(
|
||||
team_id = payload['team_id']
|
||||
|
||||
# Retrieve the keycloak_user_id
|
||||
redirect_uri = f'https://{request.url.netloc}{request.url.path}'
|
||||
redirect_uri = f'{HOST_URL}{request.url.path}'
|
||||
(
|
||||
keycloak_access_token,
|
||||
keycloak_refresh_token,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from prometheus_client import Counter, Histogram
|
||||
from server.logger import logger
|
||||
|
||||
from openhands.core.config.openhands_config import OpenHandsConfig
|
||||
@@ -9,45 +8,27 @@ from openhands.events.observation import (
|
||||
)
|
||||
from openhands.server.monitoring import MonitoringListener
|
||||
|
||||
AGENT_STATUS_ERROR_COUNT = Counter(
|
||||
'saas_agent_status_errors', 'Agent Status change events to status error'
|
||||
)
|
||||
CREATE_CONVERSATION_COUNT = Counter(
|
||||
'saas_create_conversation', 'Create conversation attempts'
|
||||
)
|
||||
AGENT_SESSION_START_HISTOGRAM = Histogram(
|
||||
'saas_agent_session_start',
|
||||
'AgentSession starts with success and duration',
|
||||
labelnames=['success'],
|
||||
)
|
||||
|
||||
|
||||
class SaaSMonitoringListener(MonitoringListener):
|
||||
"""
|
||||
Forward app signals to Prometheus.
|
||||
"""
|
||||
"""Forward app signals to structured logging for GCP native monitoring."""
|
||||
|
||||
def on_session_event(self, event: Event) -> None:
|
||||
"""
|
||||
Track metrics about events being added to a Session's EventStream.
|
||||
"""
|
||||
"""Track metrics about events being added to a Session's EventStream."""
|
||||
if (
|
||||
isinstance(event, AgentStateChangedObservation)
|
||||
and event.agent_state == AgentState.ERROR
|
||||
):
|
||||
AGENT_STATUS_ERROR_COUNT.inc()
|
||||
logger.info(
|
||||
'Tracking agent status error',
|
||||
extra={'signal': 'saas_agent_status_errors'},
|
||||
)
|
||||
|
||||
def on_agent_session_start(self, success: bool, duration: float) -> None:
|
||||
"""
|
||||
Track an agent session start.
|
||||
"""Track an agent session start.
|
||||
|
||||
Success is true if startup completed without error.
|
||||
Duration is start time in seconds observed by AgentSession.
|
||||
"""
|
||||
AGENT_SESSION_START_HISTOGRAM.labels(success=success).observe(duration)
|
||||
logger.info(
|
||||
'Tracking agent session start',
|
||||
extra={
|
||||
@@ -58,11 +39,10 @@ class SaaSMonitoringListener(MonitoringListener):
|
||||
)
|
||||
|
||||
def on_create_conversation(self) -> None:
|
||||
"""
|
||||
Track the beginning of conversation creation.
|
||||
"""Track the beginning of conversation creation.
|
||||
|
||||
Does not currently capture whether it succeed.
|
||||
"""
|
||||
CREATE_CONVERSATION_COUNT.inc()
|
||||
logger.info(
|
||||
'Tracking create conversation', extra={'signal': 'saas_create_conversation'}
|
||||
)
|
||||
|
||||
@@ -9,12 +9,18 @@ This implementation provides read-only access to events from shared conversation
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Request
|
||||
from google.cloud import storage
|
||||
from google.cloud.storage.bucket import Bucket
|
||||
from google.cloud.storage.client import Client
|
||||
from pydantic import Field
|
||||
from server.sharing.shared_conversation_info_service import (
|
||||
SharedConversationInfoService,
|
||||
)
|
||||
@@ -28,6 +34,9 @@ from server.sharing.sql_shared_conversation_info_service import (
|
||||
|
||||
from openhands.agent_server.models import EventPage, EventSortOrder
|
||||
from openhands.app_server.event.event_service import EventService
|
||||
from openhands.app_server.event.google_cloud_event_service import (
|
||||
GoogleCloudEventService,
|
||||
)
|
||||
from openhands.app_server.event_callback.event_callback_models import EventKind
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.sdk import Event
|
||||
@@ -36,17 +45,13 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SharedEventServiceImpl(SharedEventService):
|
||||
class GoogleCloudSharedEventService(SharedEventService):
|
||||
"""Implementation of SharedEventService that validates shared access."""
|
||||
|
||||
shared_conversation_info_service: SharedConversationInfoService
|
||||
event_service: EventService
|
||||
bucket: Bucket
|
||||
|
||||
async def get_shared_event(
|
||||
self, conversation_id: UUID, event_id: str
|
||||
) -> Event | None:
|
||||
"""Given a conversation_id and event_id, retrieve an event if the conversation is shared."""
|
||||
# First check if the conversation is shared
|
||||
async def get_event_service(self, conversation_id: UUID) -> EventService | None:
|
||||
shared_conversation_info = (
|
||||
await self.shared_conversation_info_service.get_shared_conversation_info(
|
||||
conversation_id
|
||||
@@ -55,8 +60,25 @@ class SharedEventServiceImpl(SharedEventService):
|
||||
if shared_conversation_info is None:
|
||||
return None
|
||||
|
||||
return GoogleCloudEventService(
|
||||
bucket=self.bucket,
|
||||
prefix=Path('users'),
|
||||
user_id=shared_conversation_info.created_by_user_id,
|
||||
app_conversation_info_service=None,
|
||||
app_conversation_info_load_tasks={},
|
||||
)
|
||||
|
||||
async def get_shared_event(
|
||||
self, conversation_id: UUID, event_id: UUID
|
||||
) -> Event | None:
|
||||
"""Given a conversation_id and event_id, retrieve an event if the conversation is shared."""
|
||||
# First check if the conversation is shared
|
||||
event_service = await self.get_event_service(conversation_id)
|
||||
if event_service is None:
|
||||
return None
|
||||
|
||||
# If conversation is shared, get the event
|
||||
return await self.event_service.get_event(event_id)
|
||||
return await event_service.get_event(conversation_id, event_id)
|
||||
|
||||
async def search_shared_events(
|
||||
self,
|
||||
@@ -70,18 +92,14 @@ class SharedEventServiceImpl(SharedEventService):
|
||||
) -> EventPage:
|
||||
"""Search events for a specific shared conversation."""
|
||||
# First check if the conversation is shared
|
||||
shared_conversation_info = (
|
||||
await self.shared_conversation_info_service.get_shared_conversation_info(
|
||||
conversation_id
|
||||
)
|
||||
)
|
||||
if shared_conversation_info is None:
|
||||
event_service = await self.get_event_service(conversation_id)
|
||||
if event_service is None:
|
||||
# Return empty page if conversation is not shared
|
||||
return EventPage(items=[], next_page_id=None)
|
||||
|
||||
# If conversation is shared, search events for this conversation
|
||||
return await self.event_service.search_events(
|
||||
conversation_id__eq=conversation_id,
|
||||
return await event_service.search_events(
|
||||
conversation_id=conversation_id,
|
||||
kind__eq=kind__eq,
|
||||
timestamp__gte=timestamp__gte,
|
||||
timestamp__lt=timestamp__lt,
|
||||
@@ -96,47 +114,45 @@ class SharedEventServiceImpl(SharedEventService):
|
||||
kind__eq: EventKind | None = None,
|
||||
timestamp__gte: datetime | None = None,
|
||||
timestamp__lt: datetime | None = None,
|
||||
sort_order: EventSortOrder = EventSortOrder.TIMESTAMP,
|
||||
) -> int:
|
||||
"""Count events for a specific shared conversation."""
|
||||
# First check if the conversation is shared
|
||||
shared_conversation_info = (
|
||||
await self.shared_conversation_info_service.get_shared_conversation_info(
|
||||
conversation_id
|
||||
)
|
||||
)
|
||||
if shared_conversation_info is None:
|
||||
event_service = await self.get_event_service(conversation_id)
|
||||
if event_service is None:
|
||||
# Return empty page if conversation is not shared
|
||||
return 0
|
||||
|
||||
# If conversation is shared, count events for this conversation
|
||||
return await self.event_service.count_events(
|
||||
conversation_id__eq=conversation_id,
|
||||
return await event_service.count_events(
|
||||
conversation_id=conversation_id,
|
||||
kind__eq=kind__eq,
|
||||
timestamp__gte=timestamp__gte,
|
||||
timestamp__lt=timestamp__lt,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
|
||||
|
||||
class SharedEventServiceImplInjector(SharedEventServiceInjector):
|
||||
class GoogleCloudSharedEventServiceInjector(SharedEventServiceInjector):
|
||||
bucket_name: str | None = Field(
|
||||
default_factory=lambda: os.environ.get('FILE_STORE_PATH')
|
||||
)
|
||||
|
||||
async def inject(
|
||||
self, state: InjectorState, request: Request | None = None
|
||||
) -> AsyncGenerator[SharedEventService, None]:
|
||||
# Define inline to prevent circular lookup
|
||||
from openhands.app_server.config import (
|
||||
get_db_session,
|
||||
get_event_service,
|
||||
)
|
||||
from openhands.app_server.config import get_db_session
|
||||
|
||||
async with (
|
||||
get_db_session(state, request) as db_session,
|
||||
get_event_service(state, request) as event_service,
|
||||
):
|
||||
async with get_db_session(state, request) as db_session:
|
||||
shared_conversation_info_service = SQLSharedConversationInfoService(
|
||||
db_session=db_session
|
||||
)
|
||||
service = SharedEventServiceImpl(
|
||||
|
||||
bucket_name = self.bucket_name
|
||||
storage_client: Client = storage.Client()
|
||||
bucket: Bucket = storage_client.bucket(bucket_name)
|
||||
|
||||
service = GoogleCloudSharedEventService(
|
||||
shared_conversation_info_service=shared_conversation_info_service,
|
||||
event_service=event_service,
|
||||
bucket=bucket,
|
||||
)
|
||||
yield service
|
||||
@@ -5,8 +5,8 @@ from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from server.sharing.filesystem_shared_event_service import (
|
||||
SharedEventServiceImplInjector,
|
||||
from server.sharing.google_cloud_shared_event_service import (
|
||||
GoogleCloudSharedEventServiceInjector,
|
||||
)
|
||||
from server.sharing.shared_event_service import SharedEventService
|
||||
|
||||
@@ -15,7 +15,9 @@ from openhands.app_server.event_callback.event_callback_models import EventKind
|
||||
from openhands.sdk import Event
|
||||
|
||||
router = APIRouter(prefix='/api/shared-events', tags=['Sharing'])
|
||||
shared_event_service_dependency = Depends(SharedEventServiceImplInjector().depends)
|
||||
shared_event_service_dependency = Depends(
|
||||
GoogleCloudSharedEventServiceInjector().depends
|
||||
)
|
||||
|
||||
|
||||
# Read methods
|
||||
@@ -85,10 +87,6 @@ async def count_shared_events(
|
||||
datetime | None,
|
||||
Query(title='Optional filter by timestamp less than'),
|
||||
] = None,
|
||||
sort_order: Annotated[
|
||||
EventSortOrder,
|
||||
Query(title='Sort order for results'),
|
||||
] = EventSortOrder.TIMESTAMP,
|
||||
shared_event_service: SharedEventService = shared_event_service_dependency,
|
||||
) -> int:
|
||||
"""Count events for a shared conversation matching the given filters."""
|
||||
@@ -97,14 +95,13 @@ async def count_shared_events(
|
||||
kind__eq=kind__eq,
|
||||
timestamp__gte=timestamp__gte,
|
||||
timestamp__lt=timestamp__lt,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
|
||||
|
||||
@router.get('')
|
||||
async def batch_get_shared_events(
|
||||
conversation_id: Annotated[
|
||||
UUID,
|
||||
str,
|
||||
Query(title='Conversation ID to get events for'),
|
||||
],
|
||||
id: Annotated[list[str], Query()],
|
||||
@@ -112,15 +109,20 @@ async def batch_get_shared_events(
|
||||
) -> list[Event | None]:
|
||||
"""Get a batch of events for a shared conversation given their ids, returning null for any missing event."""
|
||||
assert len(id) <= 100
|
||||
events = await shared_event_service.batch_get_shared_events(conversation_id, id)
|
||||
event_ids = [UUID(id_) for id_ in id]
|
||||
events = await shared_event_service.batch_get_shared_events(
|
||||
UUID(conversation_id), event_ids
|
||||
)
|
||||
return events
|
||||
|
||||
|
||||
@router.get('/{conversation_id}/{event_id}')
|
||||
async def get_shared_event(
|
||||
conversation_id: UUID,
|
||||
conversation_id: str,
|
||||
event_id: str,
|
||||
shared_event_service: SharedEventService = shared_event_service_dependency,
|
||||
) -> Event | None:
|
||||
"""Get a single event from a shared conversation by conversation_id and event_id."""
|
||||
return await shared_event_service.get_shared_event(conversation_id, event_id)
|
||||
return await shared_event_service.get_shared_event(
|
||||
UUID(conversation_id), UUID(event_id)
|
||||
)
|
||||
|
||||
@@ -18,7 +18,7 @@ class SharedEventService(ABC):
|
||||
|
||||
@abstractmethod
|
||||
async def get_shared_event(
|
||||
self, conversation_id: UUID, event_id: str
|
||||
self, conversation_id: UUID, event_id: UUID
|
||||
) -> Event | None:
|
||||
"""Given a conversation_id and event_id, retrieve an event if the conversation is shared."""
|
||||
|
||||
@@ -42,12 +42,11 @@ class SharedEventService(ABC):
|
||||
kind__eq: EventKind | None = None,
|
||||
timestamp__gte: datetime | None = None,
|
||||
timestamp__lt: datetime | None = None,
|
||||
sort_order: EventSortOrder = EventSortOrder.TIMESTAMP,
|
||||
) -> int:
|
||||
"""Count events for a specific shared conversation."""
|
||||
|
||||
async def batch_get_shared_events(
|
||||
self, conversation_id: UUID, event_ids: list[str]
|
||||
self, conversation_id: UUID, event_ids: list[UUID]
|
||||
) -> list[Event | None]:
|
||||
"""Given a conversation_id and list of event_ids, get events if the conversation is shared."""
|
||||
return await asyncio.gather(
|
||||
|
||||
30
enterprise/storage/blocked_email_domain.py
Normal file
30
enterprise/storage/blocked_email_domain.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import Column, DateTime, Identity, Integer, String
|
||||
from storage.base import Base
|
||||
|
||||
|
||||
class BlockedEmailDomain(Base): # type: ignore
|
||||
"""Stores blocked email domain patterns.
|
||||
|
||||
Supports blocking:
|
||||
- Exact domains: 'example.com' blocks 'user@example.com'
|
||||
- Subdomains: 'example.com' blocks 'user@subdomain.example.com'
|
||||
- TLDs: '.us' blocks 'user@company.us' and 'user@subdomain.company.us'
|
||||
"""
|
||||
|
||||
__tablename__ = 'blocked_email_domains'
|
||||
|
||||
id = Column(Integer, Identity(), primary_key=True)
|
||||
domain = Column(String, nullable=False, unique=True)
|
||||
created_at = Column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(UTC),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at = Column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
nullable=False,
|
||||
)
|
||||
45
enterprise/storage/blocked_email_domain_store.py
Normal file
45
enterprise/storage/blocked_email_domain_store.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
|
||||
@dataclass
|
||||
class BlockedEmailDomainStore:
|
||||
session_maker: sessionmaker
|
||||
|
||||
def is_domain_blocked(self, domain: str) -> bool:
|
||||
"""Check if a domain is blocked by querying the database directly.
|
||||
|
||||
This method uses SQL to efficiently check if the domain matches any blocked pattern:
|
||||
- TLD patterns (e.g., '.us'): checks if domain ends with the pattern
|
||||
- Full domain patterns (e.g., 'example.com'): checks for exact match or subdomain match
|
||||
|
||||
Args:
|
||||
domain: The extracted domain from the email (e.g., 'example.com' or 'subdomain.example.com')
|
||||
|
||||
Returns:
|
||||
True if the domain is blocked, False otherwise
|
||||
"""
|
||||
with self.session_maker() as session:
|
||||
# SQL query that handles both TLD patterns and full domain patterns
|
||||
# TLD patterns (starting with '.'): check if domain ends with the pattern
|
||||
# Full domain patterns: check for exact match or subdomain match
|
||||
# All comparisons are case-insensitive using LOWER() to ensure consistent matching
|
||||
query = text("""
|
||||
SELECT EXISTS(
|
||||
SELECT 1
|
||||
FROM blocked_email_domains
|
||||
WHERE
|
||||
-- TLD pattern (e.g., '.us') - check if domain ends with it (case-insensitive)
|
||||
(LOWER(domain) LIKE '.%' AND LOWER(:domain) LIKE '%' || LOWER(domain)) OR
|
||||
-- Full domain pattern (e.g., 'example.com')
|
||||
-- Block exact match or subdomains (case-insensitive)
|
||||
(LOWER(domain) NOT LIKE '.%' AND (
|
||||
LOWER(:domain) = LOWER(domain) OR
|
||||
LOWER(:domain) LIKE '%.' || LOWER(domain)
|
||||
))
|
||||
)
|
||||
""")
|
||||
result = session.execute(query, {'domain': domain}).scalar()
|
||||
return bool(result)
|
||||
@@ -220,6 +220,127 @@ class GitlabWebhookStore:
|
||||
return webhooks[0].webhook_secret
|
||||
return None
|
||||
|
||||
async def get_webhook_by_resource_only(
|
||||
self, resource_type: GitLabResourceType, resource_id: str
|
||||
) -> GitlabWebhook | None:
|
||||
"""Get a webhook by resource without filtering by user_id.
|
||||
|
||||
This allows any admin user in the organization to manage webhooks,
|
||||
not just the original installer.
|
||||
|
||||
Args:
|
||||
resource_type: The type of resource (PROJECT or GROUP)
|
||||
resource_id: The ID of the resource
|
||||
|
||||
Returns:
|
||||
GitlabWebhook object if found, None otherwise
|
||||
"""
|
||||
async with self.a_session_maker() as session:
|
||||
if resource_type == GitLabResourceType.PROJECT:
|
||||
query = select(GitlabWebhook).where(
|
||||
GitlabWebhook.project_id == resource_id
|
||||
)
|
||||
else: # GROUP
|
||||
query = select(GitlabWebhook).where(
|
||||
GitlabWebhook.group_id == resource_id
|
||||
)
|
||||
|
||||
result = await session.execute(query)
|
||||
webhook = result.scalars().first()
|
||||
return webhook
|
||||
|
||||
async def get_webhooks_by_resources(
|
||||
self, project_ids: list[str], group_ids: list[str]
|
||||
) -> tuple[dict[str, GitlabWebhook], dict[str, GitlabWebhook]]:
|
||||
"""Bulk fetch webhooks for multiple resources.
|
||||
|
||||
This is more efficient than fetching one at a time in a loop.
|
||||
|
||||
Args:
|
||||
project_ids: List of project IDs to fetch
|
||||
group_ids: List of group IDs to fetch
|
||||
|
||||
Returns:
|
||||
Tuple of (project_webhook_map, group_webhook_map)
|
||||
"""
|
||||
async with self.a_session_maker() as session:
|
||||
project_webhook_map = {}
|
||||
group_webhook_map = {}
|
||||
|
||||
# Fetch all project webhooks in one query
|
||||
if project_ids:
|
||||
project_query = select(GitlabWebhook).where(
|
||||
GitlabWebhook.project_id.in_(project_ids)
|
||||
)
|
||||
result = await session.execute(project_query)
|
||||
project_webhooks = result.scalars().all()
|
||||
project_webhook_map = {wh.project_id: wh for wh in project_webhooks}
|
||||
|
||||
# Fetch all group webhooks in one query
|
||||
if group_ids:
|
||||
group_query = select(GitlabWebhook).where(
|
||||
GitlabWebhook.group_id.in_(group_ids)
|
||||
)
|
||||
result = await session.execute(group_query)
|
||||
group_webhooks = result.scalars().all()
|
||||
group_webhook_map = {wh.group_id: wh for wh in group_webhooks}
|
||||
|
||||
return project_webhook_map, group_webhook_map
|
||||
|
||||
async def reset_webhook_for_reinstallation_by_resource(
|
||||
self, resource_type: GitLabResourceType, resource_id: str, updating_user_id: str
|
||||
) -> bool:
|
||||
"""Reset webhook for reinstallation without filtering by user_id.
|
||||
|
||||
This allows any admin user to reset webhooks, and updates the user_id
|
||||
to track who last modified it.
|
||||
|
||||
Args:
|
||||
resource_type: The type of resource (PROJECT or GROUP)
|
||||
resource_id: The ID of the resource
|
||||
updating_user_id: The user ID performing the update (for audit purposes)
|
||||
|
||||
Returns:
|
||||
True if webhook was reset, False if not found
|
||||
"""
|
||||
async with self.a_session_maker() as session:
|
||||
async with session.begin():
|
||||
if resource_type == GitLabResourceType.PROJECT:
|
||||
update_statement = (
|
||||
update(GitlabWebhook)
|
||||
.where(GitlabWebhook.project_id == resource_id)
|
||||
.values(
|
||||
webhook_exists=False,
|
||||
webhook_uuid=None,
|
||||
user_id=updating_user_id, # Update to track who modified it
|
||||
)
|
||||
)
|
||||
else: # GROUP
|
||||
update_statement = (
|
||||
update(GitlabWebhook)
|
||||
.where(GitlabWebhook.group_id == resource_id)
|
||||
.values(
|
||||
webhook_exists=False,
|
||||
webhook_uuid=None,
|
||||
user_id=updating_user_id, # Update to track who modified it
|
||||
)
|
||||
)
|
||||
|
||||
result = await session.execute(update_statement)
|
||||
rows_updated = result.rowcount
|
||||
|
||||
logger.info(
|
||||
'Reset webhook for reinstallation (organization-wide)',
|
||||
extra={
|
||||
'updating_user_id': updating_user_id,
|
||||
'resource_type': resource_type.value,
|
||||
'resource_id': resource_id,
|
||||
'rows_updated': rows_updated,
|
||||
},
|
||||
)
|
||||
|
||||
return rows_updated > 0
|
||||
|
||||
@classmethod
|
||||
async def get_instance(cls) -> GitlabWebhookStore:
|
||||
"""Get an instance of the GitlabWebhookStore.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import binascii
|
||||
import hashlib
|
||||
import json
|
||||
@@ -34,6 +35,13 @@ from openhands.storage.settings.settings_store import SettingsStore
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
|
||||
# The max possible time to wait for another process to finish creating a user before retrying
|
||||
_REDIS_CREATE_TIMEOUT_SECONDS = 30
|
||||
# The delay to wait for another process to finish creating a user before trying to load again
|
||||
_RETRY_LOAD_DELAY_SECONDS = 2
|
||||
# Redis key prefix for user creation locks
|
||||
_REDIS_USER_CREATION_KEY_PREFIX = 'create_user:'
|
||||
|
||||
|
||||
@dataclass
|
||||
class SaasSettingsStore(SettingsStore):
|
||||
@@ -131,6 +139,32 @@ class SaasSettingsStore(SettingsStore):
|
||||
session.add(settings)
|
||||
session.commit()
|
||||
|
||||
def _get_redis_client(self):
|
||||
"""Get the Redis client from the Socket.IO manager."""
|
||||
from openhands.server.shared import sio
|
||||
|
||||
return getattr(sio.manager, 'redis', None)
|
||||
|
||||
async def _acquire_user_creation_lock(self) -> bool:
|
||||
"""Attempt to acquire a distributed lock for user creation.
|
||||
|
||||
Returns True if the lock was acquired or if Redis is unavailable (fallback to no locking).
|
||||
Returns False if another process holds the lock.
|
||||
"""
|
||||
redis_client = self._get_redis_client()
|
||||
if redis_client is None:
|
||||
logger.warning(
|
||||
'saas_settings_store:_acquire_user_creation_lock:no_redis_client',
|
||||
extra={'user_id': self.user_id},
|
||||
)
|
||||
return True # Proceed without locking if Redis is unavailable
|
||||
|
||||
user_key = f'{_REDIS_USER_CREATION_KEY_PREFIX}{self.user_id}'
|
||||
lock_acquired = await redis_client.set(
|
||||
user_key, 1, nx=True, ex=_REDIS_CREATE_TIMEOUT_SECONDS
|
||||
)
|
||||
return bool(lock_acquired)
|
||||
|
||||
async def create_default_settings(self, user_settings: UserSettings | None):
|
||||
logger.info(
|
||||
'saas_settings_store:create_default_settings:start',
|
||||
@@ -140,6 +174,16 @@ class SaasSettingsStore(SettingsStore):
|
||||
if not self.user_id:
|
||||
return None
|
||||
|
||||
# Prevent duplicate settings creation using distributed lock
|
||||
if not await self._acquire_user_creation_lock():
|
||||
# The user is already being created in another thread / process
|
||||
logger.info(
|
||||
'saas_settings_store:create_default_settings:waiting_for_lock',
|
||||
extra={'user_id': self.user_id},
|
||||
)
|
||||
await asyncio.sleep(_RETRY_LOAD_DELAY_SECONDS)
|
||||
return await self.load()
|
||||
|
||||
# Only users that have specified a payment method get default settings
|
||||
if REQUIRE_PAYMENT and not await stripe_service.has_payment_method(
|
||||
self.user_id
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy import Column, Identity, Integer, String
|
||||
from sqlalchemy import Boolean, Column, Identity, Integer, String
|
||||
from storage.base import Base
|
||||
|
||||
|
||||
@@ -9,3 +9,4 @@ class SlackConversation(Base): # type: ignore
|
||||
channel_id = Column(String, nullable=False)
|
||||
keycloak_user_id = Column(String, nullable=False)
|
||||
parent_id = Column(String, nullable=True, index=True)
|
||||
v1_enabled = Column(Boolean, nullable=True)
|
||||
|
||||
@@ -14,8 +14,7 @@ class SlackConversationStore:
|
||||
async def get_slack_conversation(
|
||||
self, channel_id: str, parent_id: str
|
||||
) -> SlackConversation | None:
|
||||
"""
|
||||
Get a slack conversation by channel_id and message_ts.
|
||||
"""Get a slack conversation by channel_id and message_ts.
|
||||
Both parameters are required to match for a conversation to be returned.
|
||||
"""
|
||||
with session_maker() as session:
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import asyncio
|
||||
from typing import cast
|
||||
from uuid import uuid4
|
||||
|
||||
from integrations.gitlab.webhook_installation import (
|
||||
BreakLoopException,
|
||||
install_webhook_on_resource,
|
||||
verify_webhook_conditions,
|
||||
)
|
||||
from integrations.types import GitLabResourceType
|
||||
from integrations.utils import GITLAB_WEBHOOK_URL
|
||||
from sqlalchemy import text
|
||||
@@ -14,20 +18,6 @@ from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
|
||||
from openhands.integrations.service_types import GitService
|
||||
|
||||
CHUNK_SIZE = 100
|
||||
WEBHOOK_NAME = 'OpenHands Resolver'
|
||||
SCOPES: list[str] = [
|
||||
'note_events',
|
||||
'merge_requests_events',
|
||||
'confidential_issues_events',
|
||||
'issues_events',
|
||||
'confidential_note_events',
|
||||
'job_events',
|
||||
'pipeline_events',
|
||||
]
|
||||
|
||||
|
||||
class BreakLoopException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class VerifyWebhookStatus:
|
||||
@@ -43,77 +33,6 @@ class VerifyWebhookStatus:
|
||||
if status == WebhookStatus.RATE_LIMITED:
|
||||
raise BreakLoopException()
|
||||
|
||||
async def check_if_resource_exists(
|
||||
self,
|
||||
gitlab_service: type[GitService],
|
||||
resource_type: GitLabResourceType,
|
||||
resource_id: str,
|
||||
webhook_store: GitlabWebhookStore,
|
||||
webhook: GitlabWebhook,
|
||||
):
|
||||
"""
|
||||
Check if the GitLab resource still exists
|
||||
"""
|
||||
from integrations.gitlab.gitlab_service import SaaSGitLabService
|
||||
|
||||
gitlab_service = cast(type[SaaSGitLabService], gitlab_service)
|
||||
|
||||
does_resource_exist, status = await gitlab_service.check_resource_exists(
|
||||
resource_type, resource_id
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Does resource exists',
|
||||
extra={
|
||||
'does_resource_exist': does_resource_exist,
|
||||
'status': status,
|
||||
'resource_id': resource_id,
|
||||
'resource_type': resource_type,
|
||||
},
|
||||
)
|
||||
|
||||
self.determine_if_rate_limited(status)
|
||||
if not does_resource_exist and status != WebhookStatus.RATE_LIMITED:
|
||||
await webhook_store.delete_webhook(webhook)
|
||||
raise BreakLoopException()
|
||||
|
||||
async def check_if_user_has_admin_acccess_to_resource(
|
||||
self,
|
||||
gitlab_service: type[GitService],
|
||||
resource_type: GitLabResourceType,
|
||||
resource_id: str,
|
||||
webhook_store: GitlabWebhookStore,
|
||||
webhook: GitlabWebhook,
|
||||
):
|
||||
"""
|
||||
Check is user still has permission to resource
|
||||
"""
|
||||
from integrations.gitlab.gitlab_service import SaaSGitLabService
|
||||
|
||||
gitlab_service = cast(type[SaaSGitLabService], gitlab_service)
|
||||
|
||||
(
|
||||
is_user_admin_of_resource,
|
||||
status,
|
||||
) = await gitlab_service.check_user_has_admin_access_to_resource(
|
||||
resource_type, resource_id
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Is user admin',
|
||||
extra={
|
||||
'is_user_admin': is_user_admin_of_resource,
|
||||
'status': status,
|
||||
'resource_id': resource_id,
|
||||
'resource_type': resource_type,
|
||||
},
|
||||
)
|
||||
|
||||
self.determine_if_rate_limited(status)
|
||||
if not is_user_admin_of_resource:
|
||||
await webhook_store.delete_webhook(webhook)
|
||||
raise BreakLoopException()
|
||||
|
||||
async def check_if_webhook_already_exists_on_resource(
|
||||
self,
|
||||
gitlab_service: type[GitService],
|
||||
@@ -162,23 +81,8 @@ class VerifyWebhookStatus:
|
||||
webhook_store: GitlabWebhookStore,
|
||||
webhook: GitlabWebhook,
|
||||
):
|
||||
await self.check_if_resource_exists(
|
||||
gitlab_service=gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=webhook_store,
|
||||
webhook=webhook,
|
||||
)
|
||||
|
||||
await self.check_if_user_has_admin_acccess_to_resource(
|
||||
gitlab_service=gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=webhook_store,
|
||||
webhook=webhook,
|
||||
)
|
||||
|
||||
await self.check_if_webhook_already_exists_on_resource(
|
||||
# Use the standalone function
|
||||
await verify_webhook_conditions(
|
||||
gitlab_service=gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
@@ -197,51 +101,15 @@ class VerifyWebhookStatus:
|
||||
"""
|
||||
Install webhook on resource
|
||||
"""
|
||||
from integrations.gitlab.gitlab_service import SaaSGitLabService
|
||||
|
||||
gitlab_service = cast(type[SaaSGitLabService], gitlab_service)
|
||||
|
||||
webhook_secret = f'{webhook.user_id}-{str(uuid4())}'
|
||||
webhook_uuid = f'{str(uuid4())}'
|
||||
|
||||
webhook_id, status = await gitlab_service.install_webhook(
|
||||
# Use the standalone function
|
||||
await install_webhook_on_resource(
|
||||
gitlab_service=gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_name=WEBHOOK_NAME,
|
||||
webhook_url=GITLAB_WEBHOOK_URL,
|
||||
webhook_secret=webhook_secret,
|
||||
webhook_uuid=webhook_uuid,
|
||||
scopes=SCOPES,
|
||||
webhook_store=webhook_store,
|
||||
webhook=webhook,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Creating new webhook',
|
||||
extra={
|
||||
'webhook_id': webhook_id,
|
||||
'status': status,
|
||||
'resource_id': resource_id,
|
||||
'resource_type': resource_type,
|
||||
},
|
||||
)
|
||||
|
||||
self.determine_if_rate_limited(status)
|
||||
|
||||
if webhook_id:
|
||||
await webhook_store.update_webhook(
|
||||
webhook=webhook,
|
||||
update_fields={
|
||||
'webhook_secret': webhook_secret,
|
||||
'webhook_exists': True, # webhook was created
|
||||
'webhook_url': GITLAB_WEBHOOK_URL,
|
||||
'scopes': SCOPES,
|
||||
'webhook_uuid': webhook_uuid, # required to identify which webhook installation is sending payload
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'Installed webhook for {webhook.user_id} on {resource_type}:{resource_id}'
|
||||
)
|
||||
|
||||
async def install_webhooks(self):
|
||||
"""
|
||||
Periodically check the conditions for installing a webhook on resource as valid
|
||||
|
||||
@@ -10,12 +10,14 @@ Covers:
|
||||
- Low-level helper methods
|
||||
"""
|
||||
|
||||
import os
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from integrations.github.github_v1_callback_processor import (
|
||||
GithubV1CallbackProcessor,
|
||||
)
|
||||
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
AppConversationInfo,
|
||||
@@ -24,9 +26,6 @@ from openhands.app_server.event_callback.event_callback_models import EventCallb
|
||||
from openhands.app_server.event_callback.event_callback_result_models import (
|
||||
EventCallbackResultStatus,
|
||||
)
|
||||
from openhands.app_server.event_callback.github_v1_callback_processor import (
|
||||
GithubV1CallbackProcessor,
|
||||
)
|
||||
from openhands.app_server.sandbox.sandbox_models import (
|
||||
ExposedUrl,
|
||||
SandboxInfo,
|
||||
@@ -198,30 +197,27 @@ class TestGithubV1CallbackProcessor:
|
||||
# Successful paths
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'GITHUB_APP_CLIENT_ID': 'test_client_id',
|
||||
'GITHUB_APP_PRIVATE_KEY': 'test_private_key',
|
||||
},
|
||||
@patch(
|
||||
'integrations.github.github_v1_callback_processor.GITHUB_APP_CLIENT_ID',
|
||||
'test_client_id',
|
||||
)
|
||||
@patch(
|
||||
'integrations.github.github_v1_callback_processor.GITHUB_APP_PRIVATE_KEY',
|
||||
'test_private_key',
|
||||
)
|
||||
@patch('openhands.app_server.config.get_app_conversation_info_service')
|
||||
@patch('openhands.app_server.config.get_sandbox_service')
|
||||
@patch('openhands.app_server.config.get_httpx_client')
|
||||
@patch(
|
||||
'openhands.app_server.event_callback.github_v1_callback_processor.get_prompt_template'
|
||||
)
|
||||
@patch('openhands.app_server.event_callback.github_v1_callback_processor.Auth')
|
||||
@patch(
|
||||
'openhands.app_server.event_callback.github_v1_callback_processor.GithubIntegration'
|
||||
)
|
||||
@patch('openhands.app_server.event_callback.github_v1_callback_processor.Github')
|
||||
@patch('integrations.github.github_v1_callback_processor.get_summary_instruction')
|
||||
@patch('integrations.github.github_v1_callback_processor.Auth')
|
||||
@patch('integrations.github.github_v1_callback_processor.GithubIntegration')
|
||||
@patch('integrations.github.github_v1_callback_processor.Github')
|
||||
async def test_successful_callback_execution(
|
||||
self,
|
||||
mock_github,
|
||||
mock_github_integration,
|
||||
mock_auth,
|
||||
mock_get_prompt_template,
|
||||
mock_get_summary_instruction,
|
||||
mock_get_httpx_client,
|
||||
mock_get_sandbox_service,
|
||||
mock_get_app_conversation_info_service,
|
||||
@@ -242,11 +238,13 @@ class TestGithubV1CallbackProcessor:
|
||||
mock_sandbox_info,
|
||||
)
|
||||
|
||||
mock_get_prompt_template.return_value = 'Please provide a summary'
|
||||
mock_get_summary_instruction.return_value = 'Please provide a summary'
|
||||
|
||||
# Auth.AppAuth mock
|
||||
# Auth.AppAuth and Auth.Token mock
|
||||
mock_app_auth_instance = MagicMock()
|
||||
mock_auth.AppAuth.return_value = mock_app_auth_instance
|
||||
mock_token_auth_instance = MagicMock()
|
||||
mock_auth.Token.return_value = mock_token_auth_instance
|
||||
|
||||
# GitHub integration
|
||||
mock_token_data = MagicMock()
|
||||
@@ -281,7 +279,8 @@ class TestGithubV1CallbackProcessor:
|
||||
mock_github_integration.assert_called_once_with(auth=mock_app_auth_instance)
|
||||
mock_integration_instance.get_access_token.assert_called_once_with(12345)
|
||||
|
||||
mock_github.assert_called_once_with('test_access_token')
|
||||
mock_auth.Token.assert_called_once_with('test_access_token')
|
||||
mock_github.assert_called_once_with(auth=mock_token_auth_instance)
|
||||
mock_github_client.get_repo.assert_called_once_with('test-owner/test-repo')
|
||||
mock_repo.get_issue.assert_called_once_with(number=42)
|
||||
mock_issue.create_comment.assert_called_once_with('Test summary from agent')
|
||||
@@ -293,28 +292,25 @@ class TestGithubV1CallbackProcessor:
|
||||
assert kwargs['headers']['X-Session-API-Key'] == 'test_api_key'
|
||||
assert kwargs['json']['question'] == 'Please provide a summary'
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'GITHUB_APP_CLIENT_ID': 'test_client_id',
|
||||
'GITHUB_APP_PRIVATE_KEY': 'test_private_key',
|
||||
},
|
||||
@patch(
|
||||
'integrations.github.github_v1_callback_processor.GITHUB_APP_CLIENT_ID',
|
||||
'test_client_id',
|
||||
)
|
||||
@patch(
|
||||
'integrations.github.github_v1_callback_processor.GITHUB_APP_PRIVATE_KEY',
|
||||
'test_private_key',
|
||||
)
|
||||
@patch('openhands.app_server.config.get_app_conversation_info_service')
|
||||
@patch('openhands.app_server.config.get_sandbox_service')
|
||||
@patch('openhands.app_server.config.get_httpx_client')
|
||||
@patch(
|
||||
'openhands.app_server.event_callback.github_v1_callback_processor.get_prompt_template'
|
||||
)
|
||||
@patch(
|
||||
'openhands.app_server.event_callback.github_v1_callback_processor.GithubIntegration'
|
||||
)
|
||||
@patch('openhands.app_server.event_callback.github_v1_callback_processor.Github')
|
||||
@patch('integrations.github.github_v1_callback_processor.get_summary_instruction')
|
||||
@patch('integrations.github.github_v1_callback_processor.GithubIntegration')
|
||||
@patch('integrations.github.github_v1_callback_processor.Github')
|
||||
async def test_successful_inline_pr_comment(
|
||||
self,
|
||||
mock_github,
|
||||
mock_github_integration,
|
||||
mock_get_prompt_template,
|
||||
mock_get_summary_instruction,
|
||||
mock_get_httpx_client,
|
||||
mock_get_sandbox_service,
|
||||
mock_get_app_conversation_info_service,
|
||||
@@ -334,7 +330,7 @@ class TestGithubV1CallbackProcessor:
|
||||
mock_sandbox_info,
|
||||
)
|
||||
|
||||
mock_get_prompt_template.return_value = 'Please provide a summary'
|
||||
mock_get_summary_instruction.return_value = 'Please provide a summary'
|
||||
|
||||
mock_token_data = MagicMock()
|
||||
mock_token_data.token = 'test_access_token'
|
||||
@@ -367,6 +363,7 @@ class TestGithubV1CallbackProcessor:
|
||||
# Error paths
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@patch('integrations.github.github_v1_callback_processor.get_summary_instruction')
|
||||
@patch('openhands.app_server.config.get_httpx_client')
|
||||
@patch('openhands.app_server.config.get_sandbox_service')
|
||||
@patch('openhands.app_server.config.get_app_conversation_info_service')
|
||||
@@ -375,6 +372,7 @@ class TestGithubV1CallbackProcessor:
|
||||
mock_get_app_conversation_info_service,
|
||||
mock_get_sandbox_service,
|
||||
mock_get_httpx_client,
|
||||
mock_get_summary_instruction,
|
||||
conversation_state_update_event,
|
||||
event_callback,
|
||||
mock_app_conversation_info,
|
||||
@@ -393,6 +391,8 @@ class TestGithubV1CallbackProcessor:
|
||||
mock_sandbox_info,
|
||||
)
|
||||
|
||||
mock_get_summary_instruction.return_value = 'Please provide a summary'
|
||||
|
||||
result = await processor(
|
||||
conversation_id=conversation_id,
|
||||
callback=event_callback,
|
||||
@@ -403,7 +403,15 @@ class TestGithubV1CallbackProcessor:
|
||||
assert result.status == EventCallbackResultStatus.ERROR
|
||||
assert 'Missing installation ID' in result.detail
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
@patch(
|
||||
'integrations.github.github_v1_callback_processor.GITHUB_APP_CLIENT_ID',
|
||||
'',
|
||||
)
|
||||
@patch(
|
||||
'integrations.github.github_v1_callback_processor.GITHUB_APP_PRIVATE_KEY',
|
||||
'',
|
||||
)
|
||||
@patch('integrations.github.github_v1_callback_processor.get_summary_instruction')
|
||||
@patch('openhands.app_server.config.get_httpx_client')
|
||||
@patch('openhands.app_server.config.get_sandbox_service')
|
||||
@patch('openhands.app_server.config.get_app_conversation_info_service')
|
||||
@@ -412,6 +420,7 @@ class TestGithubV1CallbackProcessor:
|
||||
mock_get_app_conversation_info_service,
|
||||
mock_get_sandbox_service,
|
||||
mock_get_httpx_client,
|
||||
mock_get_summary_instruction,
|
||||
github_callback_processor,
|
||||
conversation_state_update_event,
|
||||
event_callback,
|
||||
@@ -428,6 +437,8 @@ class TestGithubV1CallbackProcessor:
|
||||
mock_sandbox_info,
|
||||
)
|
||||
|
||||
mock_get_summary_instruction.return_value = 'Please provide a summary'
|
||||
|
||||
result = await github_callback_processor(
|
||||
conversation_id=conversation_id,
|
||||
callback=event_callback,
|
||||
@@ -438,12 +449,13 @@ class TestGithubV1CallbackProcessor:
|
||||
assert result.status == EventCallbackResultStatus.ERROR
|
||||
assert 'GitHub App credentials are not configured' in result.detail
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'GITHUB_APP_CLIENT_ID': 'test_client_id',
|
||||
'GITHUB_APP_PRIVATE_KEY': 'test_private_key',
|
||||
},
|
||||
@patch(
|
||||
'integrations.github.github_v1_callback_processor.GITHUB_APP_CLIENT_ID',
|
||||
'test_client_id',
|
||||
)
|
||||
@patch(
|
||||
'integrations.github.github_v1_callback_processor.GITHUB_APP_PRIVATE_KEY',
|
||||
'test_private_key',
|
||||
)
|
||||
@patch('openhands.app_server.config.get_app_conversation_info_service')
|
||||
@patch('openhands.app_server.config.get_sandbox_service')
|
||||
@@ -489,22 +501,21 @@ class TestGithubV1CallbackProcessor:
|
||||
assert result.status == EventCallbackResultStatus.ERROR
|
||||
assert 'Sandbox not running' in result.detail
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'GITHUB_APP_CLIENT_ID': 'test_client_id',
|
||||
'GITHUB_APP_PRIVATE_KEY': 'test_private_key',
|
||||
},
|
||||
@patch(
|
||||
'integrations.github.github_v1_callback_processor.GITHUB_APP_CLIENT_ID',
|
||||
'test_client_id',
|
||||
)
|
||||
@patch(
|
||||
'integrations.github.github_v1_callback_processor.GITHUB_APP_PRIVATE_KEY',
|
||||
'test_private_key',
|
||||
)
|
||||
@patch('openhands.app_server.config.get_app_conversation_info_service')
|
||||
@patch('openhands.app_server.config.get_sandbox_service')
|
||||
@patch('openhands.app_server.config.get_httpx_client')
|
||||
@patch(
|
||||
'openhands.app_server.event_callback.github_v1_callback_processor.get_prompt_template'
|
||||
)
|
||||
@patch('integrations.github.github_v1_callback_processor.get_summary_instruction')
|
||||
async def test_agent_server_http_error(
|
||||
self,
|
||||
mock_get_prompt_template,
|
||||
mock_get_summary_instruction,
|
||||
mock_get_httpx_client,
|
||||
mock_get_sandbox_service,
|
||||
mock_get_app_conversation_info_service,
|
||||
@@ -525,7 +536,7 @@ class TestGithubV1CallbackProcessor:
|
||||
mock_sandbox_info,
|
||||
)
|
||||
|
||||
mock_get_prompt_template.return_value = 'Please provide a summary'
|
||||
mock_get_summary_instruction.return_value = 'Please provide a summary'
|
||||
|
||||
mock_httpx_client = mock_get_httpx_client.return_value.__aenter__.return_value
|
||||
mock_response = MagicMock()
|
||||
@@ -547,22 +558,21 @@ class TestGithubV1CallbackProcessor:
|
||||
assert result.status == EventCallbackResultStatus.ERROR
|
||||
assert 'Failed to send message to agent server' in result.detail
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'GITHUB_APP_CLIENT_ID': 'test_client_id',
|
||||
'GITHUB_APP_PRIVATE_KEY': 'test_private_key',
|
||||
},
|
||||
@patch(
|
||||
'integrations.github.github_v1_callback_processor.GITHUB_APP_CLIENT_ID',
|
||||
'test_client_id',
|
||||
)
|
||||
@patch(
|
||||
'integrations.github.github_v1_callback_processor.GITHUB_APP_PRIVATE_KEY',
|
||||
'test_private_key',
|
||||
)
|
||||
@patch('openhands.app_server.config.get_app_conversation_info_service')
|
||||
@patch('openhands.app_server.config.get_sandbox_service')
|
||||
@patch('openhands.app_server.config.get_httpx_client')
|
||||
@patch(
|
||||
'openhands.app_server.event_callback.github_v1_callback_processor.get_prompt_template'
|
||||
)
|
||||
@patch('integrations.github.github_v1_callback_processor.get_summary_instruction')
|
||||
async def test_agent_server_timeout(
|
||||
self,
|
||||
mock_get_prompt_template,
|
||||
mock_get_summary_instruction,
|
||||
mock_get_httpx_client,
|
||||
mock_get_sandbox_service,
|
||||
mock_get_app_conversation_info_service,
|
||||
@@ -582,7 +592,7 @@ class TestGithubV1CallbackProcessor:
|
||||
mock_sandbox_info,
|
||||
)
|
||||
|
||||
mock_get_prompt_template.return_value = 'Please provide a summary'
|
||||
mock_get_summary_instruction.return_value = 'Please provide a summary'
|
||||
|
||||
mock_httpx_client = mock_get_httpx_client.return_value.__aenter__.return_value
|
||||
mock_httpx_client.post.side_effect = httpx.TimeoutException('Request timeout')
|
||||
@@ -607,7 +617,14 @@ class TestGithubV1CallbackProcessor:
|
||||
with pytest.raises(ValueError, match='Missing installation ID'):
|
||||
processor._get_installation_access_token()
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
@patch(
|
||||
'integrations.github.github_v1_callback_processor.GITHUB_APP_CLIENT_ID',
|
||||
'',
|
||||
)
|
||||
@patch(
|
||||
'integrations.github.github_v1_callback_processor.GITHUB_APP_PRIVATE_KEY',
|
||||
'',
|
||||
)
|
||||
def test_get_installation_access_token_missing_credentials(
|
||||
self, github_callback_processor
|
||||
):
|
||||
@@ -616,17 +633,16 @@ class TestGithubV1CallbackProcessor:
|
||||
):
|
||||
github_callback_processor._get_installation_access_token()
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'GITHUB_APP_CLIENT_ID': 'test_client_id',
|
||||
'GITHUB_APP_PRIVATE_KEY': 'test_private_key\\nwith_newlines',
|
||||
},
|
||||
)
|
||||
@patch('openhands.app_server.event_callback.github_v1_callback_processor.Auth')
|
||||
@patch(
|
||||
'openhands.app_server.event_callback.github_v1_callback_processor.GithubIntegration'
|
||||
'integrations.github.github_v1_callback_processor.GITHUB_APP_CLIENT_ID',
|
||||
'test_client_id',
|
||||
)
|
||||
@patch(
|
||||
'integrations.github.github_v1_callback_processor.GITHUB_APP_PRIVATE_KEY',
|
||||
'test_private_key\nwith_newlines',
|
||||
)
|
||||
@patch('integrations.github.github_v1_callback_processor.Auth')
|
||||
@patch('integrations.github.github_v1_callback_processor.GithubIntegration')
|
||||
def test_get_installation_access_token_success(
|
||||
self, mock_github_integration, mock_auth, github_callback_processor
|
||||
):
|
||||
@@ -649,9 +665,10 @@ class TestGithubV1CallbackProcessor:
|
||||
mock_github_integration.assert_called_once_with(auth=mock_app_auth_instance)
|
||||
mock_integration_instance.get_access_token.assert_called_once_with(12345)
|
||||
|
||||
@patch('openhands.app_server.event_callback.github_v1_callback_processor.Github')
|
||||
@patch('integrations.github.github_v1_callback_processor.Auth')
|
||||
@patch('integrations.github.github_v1_callback_processor.Github')
|
||||
async def test_post_summary_to_github_issue_comment(
|
||||
self, mock_github, github_callback_processor
|
||||
self, mock_github, mock_auth, github_callback_processor
|
||||
):
|
||||
mock_github_client = MagicMock()
|
||||
mock_repo = MagicMock()
|
||||
@@ -660,6 +677,9 @@ class TestGithubV1CallbackProcessor:
|
||||
mock_github_client.get_repo.return_value = mock_repo
|
||||
mock_github.return_value.__enter__.return_value = mock_github_client
|
||||
|
||||
mock_token_auth = MagicMock()
|
||||
mock_auth.Token.return_value = mock_token_auth
|
||||
|
||||
with patch.object(
|
||||
github_callback_processor,
|
||||
'_get_installation_access_token',
|
||||
@@ -667,14 +687,16 @@ class TestGithubV1CallbackProcessor:
|
||||
):
|
||||
await github_callback_processor._post_summary_to_github('Test summary')
|
||||
|
||||
mock_github.assert_called_once_with('test_token')
|
||||
mock_auth.Token.assert_called_once_with('test_token')
|
||||
mock_github.assert_called_once_with(auth=mock_token_auth)
|
||||
mock_github_client.get_repo.assert_called_once_with('test-owner/test-repo')
|
||||
mock_repo.get_issue.assert_called_once_with(number=42)
|
||||
mock_issue.create_comment.assert_called_once_with('Test summary')
|
||||
|
||||
@patch('openhands.app_server.event_callback.github_v1_callback_processor.Github')
|
||||
@patch('integrations.github.github_v1_callback_processor.Auth')
|
||||
@patch('integrations.github.github_v1_callback_processor.Github')
|
||||
async def test_post_summary_to_github_pr_comment(
|
||||
self, mock_github, github_callback_processor_inline
|
||||
self, mock_github, mock_auth, github_callback_processor_inline
|
||||
):
|
||||
mock_github_client = MagicMock()
|
||||
mock_repo = MagicMock()
|
||||
@@ -683,6 +705,9 @@ class TestGithubV1CallbackProcessor:
|
||||
mock_github_client.get_repo.return_value = mock_repo
|
||||
mock_github.return_value.__enter__.return_value = mock_github_client
|
||||
|
||||
mock_token_auth = MagicMock()
|
||||
mock_auth.Token.return_value = mock_token_auth
|
||||
|
||||
with patch.object(
|
||||
github_callback_processor_inline,
|
||||
'_get_installation_access_token',
|
||||
@@ -692,7 +717,8 @@ class TestGithubV1CallbackProcessor:
|
||||
'Test summary'
|
||||
)
|
||||
|
||||
mock_github.assert_called_once_with('test_token')
|
||||
mock_auth.Token.assert_called_once_with('test_token')
|
||||
mock_github.assert_called_once_with(auth=mock_token_auth)
|
||||
mock_github_client.get_repo.assert_called_once_with('test-owner/test-repo')
|
||||
mock_repo.get_pull.assert_called_once_with(42)
|
||||
mock_pr.create_review_comment_reply.assert_called_once_with(
|
||||
@@ -708,14 +734,15 @@ class TestGithubV1CallbackProcessor:
|
||||
with pytest.raises(RuntimeError, match='Missing GitHub credentials'):
|
||||
await github_callback_processor._post_summary_to_github('Test summary')
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'GITHUB_APP_CLIENT_ID': 'test_client_id',
|
||||
'GITHUB_APP_PRIVATE_KEY': 'test_private_key',
|
||||
'WEB_HOST': 'test.example.com',
|
||||
},
|
||||
@patch(
|
||||
'integrations.github.github_v1_callback_processor.GITHUB_APP_CLIENT_ID',
|
||||
'test_client_id',
|
||||
)
|
||||
@patch(
|
||||
'integrations.github.github_v1_callback_processor.GITHUB_APP_PRIVATE_KEY',
|
||||
'test_private_key',
|
||||
)
|
||||
@patch('integrations.github.github_v1_callback_processor.get_summary_instruction')
|
||||
@patch('openhands.app_server.config.get_httpx_client')
|
||||
@patch('openhands.app_server.config.get_sandbox_service')
|
||||
@patch('openhands.app_server.config.get_app_conversation_info_service')
|
||||
@@ -724,6 +751,7 @@ class TestGithubV1CallbackProcessor:
|
||||
mock_get_app_conversation_info_service,
|
||||
mock_get_sandbox_service,
|
||||
mock_get_httpx_client,
|
||||
mock_get_summary_instruction,
|
||||
github_callback_processor,
|
||||
conversation_state_update_event,
|
||||
event_callback,
|
||||
@@ -741,13 +769,14 @@ class TestGithubV1CallbackProcessor:
|
||||
mock_sandbox_info,
|
||||
)
|
||||
mock_httpx_client.post.side_effect = Exception('Simulated agent server error')
|
||||
mock_get_summary_instruction.return_value = 'Please provide a summary'
|
||||
|
||||
with (
|
||||
patch(
|
||||
'openhands.app_server.event_callback.github_v1_callback_processor.GithubIntegration'
|
||||
'integrations.github.github_v1_callback_processor.GithubIntegration'
|
||||
) as mock_github_integration,
|
||||
patch(
|
||||
'openhands.app_server.event_callback.github_v1_callback_processor.Github'
|
||||
'integrations.github.github_v1_callback_processor.Github'
|
||||
) as mock_github,
|
||||
):
|
||||
mock_integration = MagicMock()
|
||||
204
enterprise/tests/unit/integrations/gitlab/test_gitlab_service.py
Normal file
204
enterprise/tests/unit/integrations/gitlab/test_gitlab_service.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""Unit tests for SaaSGitLabService."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from integrations.gitlab.gitlab_service import SaaSGitLabService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gitlab_service():
|
||||
"""Create a SaaSGitLabService instance for testing."""
|
||||
return SaaSGitLabService(external_auth_id='test_user_id')
|
||||
|
||||
|
||||
class TestGetUserResourcesWithAdminAccess:
|
||||
"""Test cases for get_user_resources_with_admin_access method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resources_single_page_projects_and_groups(self, gitlab_service):
|
||||
"""Test fetching resources when all data fits in a single page."""
|
||||
# Arrange
|
||||
mock_projects = [
|
||||
{'id': 1, 'name': 'Project 1'},
|
||||
{'id': 2, 'name': 'Project 2'},
|
||||
]
|
||||
mock_groups = [
|
||||
{'id': 10, 'name': 'Group 1'},
|
||||
]
|
||||
|
||||
with patch.object(gitlab_service, '_make_request') as mock_request:
|
||||
# First call for projects, second for groups
|
||||
mock_request.side_effect = [
|
||||
(mock_projects, {'Link': ''}), # No next page
|
||||
(mock_groups, {'Link': ''}), # No next page
|
||||
]
|
||||
|
||||
# Act
|
||||
(
|
||||
projects,
|
||||
groups,
|
||||
) = await gitlab_service.get_user_resources_with_admin_access()
|
||||
|
||||
# Assert
|
||||
assert len(projects) == 2
|
||||
assert len(groups) == 1
|
||||
assert projects[0]['id'] == 1
|
||||
assert projects[1]['id'] == 2
|
||||
assert groups[0]['id'] == 10
|
||||
assert mock_request.call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resources_multiple_pages_projects(self, gitlab_service):
|
||||
"""Test fetching projects across multiple pages."""
|
||||
# Arrange
|
||||
page1_projects = [{'id': i, 'name': f'Project {i}'} for i in range(1, 101)]
|
||||
page2_projects = [{'id': i, 'name': f'Project {i}'} for i in range(101, 151)]
|
||||
|
||||
with patch.object(gitlab_service, '_make_request') as mock_request:
|
||||
mock_request.side_effect = [
|
||||
(page1_projects, {'Link': '<url>; rel="next"'}), # Has next page
|
||||
(page2_projects, {'Link': ''}), # Last page
|
||||
([], {'Link': ''}), # Groups (empty)
|
||||
]
|
||||
|
||||
# Act
|
||||
(
|
||||
projects,
|
||||
groups,
|
||||
) = await gitlab_service.get_user_resources_with_admin_access()
|
||||
|
||||
# Assert
|
||||
assert len(projects) == 150
|
||||
assert len(groups) == 0
|
||||
assert mock_request.call_count == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resources_multiple_pages_groups(self, gitlab_service):
|
||||
"""Test fetching groups across multiple pages."""
|
||||
# Arrange
|
||||
page1_groups = [{'id': i, 'name': f'Group {i}'} for i in range(1, 101)]
|
||||
page2_groups = [{'id': i, 'name': f'Group {i}'} for i in range(101, 151)]
|
||||
|
||||
with patch.object(gitlab_service, '_make_request') as mock_request:
|
||||
mock_request.side_effect = [
|
||||
([], {'Link': ''}), # Projects (empty)
|
||||
(page1_groups, {'Link': '<url>; rel="next"'}), # Has next page
|
||||
(page2_groups, {'Link': ''}), # Last page
|
||||
]
|
||||
|
||||
# Act
|
||||
(
|
||||
projects,
|
||||
groups,
|
||||
) = await gitlab_service.get_user_resources_with_admin_access()
|
||||
|
||||
# Assert
|
||||
assert len(projects) == 0
|
||||
assert len(groups) == 150
|
||||
assert mock_request.call_count == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resources_empty_response(self, gitlab_service):
|
||||
"""Test when user has no projects or groups with admin access."""
|
||||
# Arrange
|
||||
with patch.object(gitlab_service, '_make_request') as mock_request:
|
||||
mock_request.side_effect = [
|
||||
([], {'Link': ''}), # No projects
|
||||
([], {'Link': ''}), # No groups
|
||||
]
|
||||
|
||||
# Act
|
||||
(
|
||||
projects,
|
||||
groups,
|
||||
) = await gitlab_service.get_user_resources_with_admin_access()
|
||||
|
||||
# Assert
|
||||
assert len(projects) == 0
|
||||
assert len(groups) == 0
|
||||
assert mock_request.call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resources_uses_correct_params_for_projects(self, gitlab_service):
|
||||
"""Test that projects API is called with correct parameters."""
|
||||
# Arrange
|
||||
with patch.object(gitlab_service, '_make_request') as mock_request:
|
||||
mock_request.side_effect = [
|
||||
([], {'Link': ''}), # Projects
|
||||
([], {'Link': ''}), # Groups
|
||||
]
|
||||
|
||||
# Act
|
||||
await gitlab_service.get_user_resources_with_admin_access()
|
||||
|
||||
# Assert
|
||||
# Check first call (projects)
|
||||
first_call = mock_request.call_args_list[0]
|
||||
assert 'projects' in first_call[0][0]
|
||||
assert first_call[0][1]['membership'] == 1
|
||||
assert first_call[0][1]['min_access_level'] == 40
|
||||
assert first_call[0][1]['per_page'] == '100'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resources_uses_correct_params_for_groups(self, gitlab_service):
|
||||
"""Test that groups API is called with correct parameters."""
|
||||
# Arrange
|
||||
with patch.object(gitlab_service, '_make_request') as mock_request:
|
||||
mock_request.side_effect = [
|
||||
([], {'Link': ''}), # Projects
|
||||
([], {'Link': ''}), # Groups
|
||||
]
|
||||
|
||||
# Act
|
||||
await gitlab_service.get_user_resources_with_admin_access()
|
||||
|
||||
# Assert
|
||||
# Check second call (groups)
|
||||
second_call = mock_request.call_args_list[1]
|
||||
assert 'groups' in second_call[0][0]
|
||||
assert second_call[0][1]['min_access_level'] == 40
|
||||
assert second_call[0][1]['top_level_only'] == 'true'
|
||||
assert second_call[0][1]['per_page'] == '100'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resources_handles_api_error_gracefully(self, gitlab_service):
|
||||
"""Test that API errors are handled gracefully and don't crash."""
|
||||
# Arrange
|
||||
with patch.object(gitlab_service, '_make_request') as mock_request:
|
||||
# First call succeeds, second call fails
|
||||
mock_request.side_effect = [
|
||||
([{'id': 1, 'name': 'Project 1'}], {'Link': ''}),
|
||||
Exception('API Error'),
|
||||
]
|
||||
|
||||
# Act
|
||||
(
|
||||
projects,
|
||||
groups,
|
||||
) = await gitlab_service.get_user_resources_with_admin_access()
|
||||
|
||||
# Assert
|
||||
# Should return what was fetched before the error
|
||||
assert len(projects) == 1
|
||||
assert len(groups) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resources_stops_on_empty_response(self, gitlab_service):
|
||||
"""Test that pagination stops when API returns empty response."""
|
||||
# Arrange
|
||||
with patch.object(gitlab_service, '_make_request') as mock_request:
|
||||
mock_request.side_effect = [
|
||||
(None, {'Link': ''}), # Empty response stops pagination
|
||||
([], {'Link': ''}), # Groups
|
||||
]
|
||||
|
||||
# Act
|
||||
(
|
||||
projects,
|
||||
groups,
|
||||
) = await gitlab_service.get_user_resources_with_admin_access()
|
||||
|
||||
# Assert
|
||||
assert len(projects) == 0
|
||||
assert mock_request.call_count == 2 # Should not continue pagination
|
||||
@@ -18,7 +18,11 @@ from integrations.jira.jira_view import (
|
||||
from integrations.models import Message, SourceType
|
||||
|
||||
from openhands.integrations.service_types import ProviderType, Repository
|
||||
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
|
||||
from openhands.server.types import (
|
||||
LLMAuthenticationError,
|
||||
MissingSettingsError,
|
||||
SessionExpiredError,
|
||||
)
|
||||
|
||||
|
||||
class TestJiraManagerInit:
|
||||
@@ -732,6 +736,32 @@ class TestStartJob:
|
||||
call_args = jira_manager.send_message.call_args[0]
|
||||
assert 'valid LLM API key' in call_args[0].message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_job_session_expired_error(
|
||||
self, jira_manager, sample_jira_workspace
|
||||
):
|
||||
"""Test job start with session expired error."""
|
||||
mock_view = MagicMock(spec=JiraNewConversationView)
|
||||
mock_view.jira_user = MagicMock()
|
||||
mock_view.jira_user.keycloak_user_id = 'test_user'
|
||||
mock_view.job_context = MagicMock()
|
||||
mock_view.job_context.issue_key = 'PROJ-123'
|
||||
mock_view.jira_workspace = sample_jira_workspace
|
||||
mock_view.create_or_update_conversation = AsyncMock(
|
||||
side_effect=SessionExpiredError('Session expired')
|
||||
)
|
||||
|
||||
jira_manager.send_message = AsyncMock()
|
||||
jira_manager.token_manager.decrypt_text.return_value = 'decrypted_key'
|
||||
|
||||
await jira_manager.start_job(mock_view)
|
||||
|
||||
# Should send error message about session expired
|
||||
jira_manager.send_message.assert_called_once()
|
||||
call_args = jira_manager.send_message.call_args[0]
|
||||
assert 'session has expired' in call_args[0].message
|
||||
assert 'login again' in call_args[0].message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_job_unexpected_error(
|
||||
self, jira_manager, sample_jira_workspace
|
||||
|
||||
@@ -18,7 +18,11 @@ from integrations.jira_dc.jira_dc_view import (
|
||||
from integrations.models import Message, SourceType
|
||||
|
||||
from openhands.integrations.service_types import ProviderType, Repository
|
||||
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
|
||||
from openhands.server.types import (
|
||||
LLMAuthenticationError,
|
||||
MissingSettingsError,
|
||||
SessionExpiredError,
|
||||
)
|
||||
|
||||
|
||||
class TestJiraDcManagerInit:
|
||||
@@ -761,6 +765,32 @@ class TestStartJob:
|
||||
call_args = jira_dc_manager.send_message.call_args[0]
|
||||
assert 'valid LLM API key' in call_args[0].message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_job_session_expired_error(
|
||||
self, jira_dc_manager, sample_jira_dc_workspace
|
||||
):
|
||||
"""Test job start with session expired error."""
|
||||
mock_view = MagicMock(spec=JiraDcNewConversationView)
|
||||
mock_view.jira_dc_user = MagicMock()
|
||||
mock_view.jira_dc_user.keycloak_user_id = 'test_user'
|
||||
mock_view.job_context = MagicMock()
|
||||
mock_view.job_context.issue_key = 'PROJ-123'
|
||||
mock_view.jira_dc_workspace = sample_jira_dc_workspace
|
||||
mock_view.create_or_update_conversation = AsyncMock(
|
||||
side_effect=SessionExpiredError('Session expired')
|
||||
)
|
||||
|
||||
jira_dc_manager.send_message = AsyncMock()
|
||||
jira_dc_manager.token_manager.decrypt_text.return_value = 'decrypted_key'
|
||||
|
||||
await jira_dc_manager.start_job(mock_view)
|
||||
|
||||
# Should send error message about session expired
|
||||
jira_dc_manager.send_message.assert_called_once()
|
||||
call_args = jira_dc_manager.send_message.call_args[0]
|
||||
assert 'session has expired' in call_args[0].message
|
||||
assert 'login again' in call_args[0].message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_job_unexpected_error(
|
||||
self, jira_dc_manager, sample_jira_dc_workspace
|
||||
|
||||
@@ -18,7 +18,11 @@ from integrations.linear.linear_view import (
|
||||
from integrations.models import Message, SourceType
|
||||
|
||||
from openhands.integrations.service_types import ProviderType, Repository
|
||||
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
|
||||
from openhands.server.types import (
|
||||
LLMAuthenticationError,
|
||||
MissingSettingsError,
|
||||
SessionExpiredError,
|
||||
)
|
||||
|
||||
|
||||
class TestLinearManagerInit:
|
||||
@@ -826,6 +830,33 @@ class TestStartJob:
|
||||
call_args = linear_manager.send_message.call_args[0]
|
||||
assert 'valid LLM API key' in call_args[0].message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_job_session_expired_error(
|
||||
self, linear_manager, sample_linear_workspace
|
||||
):
|
||||
"""Test job start with session expired error."""
|
||||
mock_view = MagicMock(spec=LinearNewConversationView)
|
||||
mock_view.linear_user = MagicMock()
|
||||
mock_view.linear_user.keycloak_user_id = 'test_user'
|
||||
mock_view.job_context = MagicMock()
|
||||
mock_view.job_context.issue_key = 'TEST-123'
|
||||
mock_view.job_context.issue_id = 'issue_id'
|
||||
mock_view.linear_workspace = sample_linear_workspace
|
||||
mock_view.create_or_update_conversation = AsyncMock(
|
||||
side_effect=SessionExpiredError('Session expired')
|
||||
)
|
||||
|
||||
linear_manager.send_message = AsyncMock()
|
||||
linear_manager.token_manager.decrypt_text.return_value = 'decrypted_key'
|
||||
|
||||
await linear_manager.start_job(mock_view)
|
||||
|
||||
# Should send error message about session expired
|
||||
linear_manager.send_message.assert_called_once()
|
||||
call_args = linear_manager.send_message.call_args[0]
|
||||
assert 'session has expired' in call_args[0].message
|
||||
assert 'login again' in call_args[0].message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_job_unexpected_error(
|
||||
self, linear_manager, sample_linear_workspace
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
"""Tests for the SlackV1CallbackProcessor.
|
||||
|
||||
Focuses on high-impact scenarios:
|
||||
- Double callback processing (main requirement)
|
||||
- Event filtering
|
||||
- Error handling for critical failures
|
||||
- Successful end-to-end flow
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from integrations.slack.slack_v1_callback_processor import (
|
||||
SlackV1CallbackProcessor,
|
||||
)
|
||||
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
AppConversationInfo,
|
||||
)
|
||||
from openhands.app_server.event_callback.event_callback_models import EventCallback
|
||||
from openhands.app_server.event_callback.event_callback_result_models import (
|
||||
EventCallbackResultStatus,
|
||||
)
|
||||
from openhands.app_server.sandbox.sandbox_models import (
|
||||
ExposedUrl,
|
||||
SandboxInfo,
|
||||
SandboxStatus,
|
||||
)
|
||||
from openhands.events.action.message import MessageAction
|
||||
from openhands.sdk.event import ConversationStateUpdateEvent
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def slack_callback_processor():
|
||||
return SlackV1CallbackProcessor(
|
||||
slack_view_data={
|
||||
'channel_id': 'C1234567890',
|
||||
'message_ts': '1234567890.123456',
|
||||
'team_id': 'T1234567890',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def finish_event():
|
||||
return ConversationStateUpdateEvent(key='execution_status', value='finished')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def event_callback():
|
||||
return EventCallback(
|
||||
id=uuid4(),
|
||||
conversation_id=uuid4(),
|
||||
processor=SlackV1CallbackProcessor(),
|
||||
event_kind='ConversationStateUpdateEvent',
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_app_conversation_info():
|
||||
return AppConversationInfo(
|
||||
id=uuid4(),
|
||||
created_by_user_id='test-user-123',
|
||||
sandbox_id=str(uuid4()),
|
||||
title='Test Conversation',
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_sandbox_info():
|
||||
return SandboxInfo(
|
||||
id=str(uuid4()),
|
||||
created_by_user_id='test-user-123',
|
||||
sandbox_spec_id='test-spec-123',
|
||||
status=SandboxStatus.RUNNING,
|
||||
session_api_key='test-session-key',
|
||||
exposed_urls=[
|
||||
ExposedUrl(
|
||||
url='http://localhost:8000',
|
||||
name='AGENT_SERVER',
|
||||
port=8000,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSlackV1CallbackProcessor:
|
||||
"""Test the SlackV1CallbackProcessor class with focus on high-impact scenarios."""
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Event filtering tests (parameterized)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'event,expected_result',
|
||||
[
|
||||
# Wrong event types should be ignored
|
||||
(MessageAction(content='Hello world'), None),
|
||||
# Wrong state values should be ignored
|
||||
(
|
||||
ConversationStateUpdateEvent(key='execution_status', value='running'),
|
||||
None,
|
||||
),
|
||||
(
|
||||
ConversationStateUpdateEvent(key='execution_status', value='started'),
|
||||
None,
|
||||
),
|
||||
(ConversationStateUpdateEvent(key='other_key', value='finished'), None),
|
||||
],
|
||||
)
|
||||
async def test_event_filtering(
|
||||
self, slack_callback_processor, event_callback, event, expected_result
|
||||
):
|
||||
"""Test that processor correctly filters events."""
|
||||
result = await slack_callback_processor(uuid4(), event_callback, event)
|
||||
assert result == expected_result
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Double callback processing (main requirement)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@patch('storage.slack_team_store.SlackTeamStore.get_instance')
|
||||
@patch('integrations.slack.slack_v1_callback_processor.WebClient')
|
||||
@patch.object(SlackV1CallbackProcessor, '_request_summary')
|
||||
async def test_double_callback_processing(
|
||||
self,
|
||||
mock_request_summary,
|
||||
mock_web_client,
|
||||
mock_slack_team_store,
|
||||
slack_callback_processor,
|
||||
finish_event,
|
||||
event_callback,
|
||||
):
|
||||
"""Test that processor handles double callback correctly and processes both times."""
|
||||
conversation_id = uuid4()
|
||||
|
||||
# Mock SlackTeamStore
|
||||
mock_store = MagicMock()
|
||||
mock_store.get_team_bot_token.return_value = 'xoxb-test-token'
|
||||
mock_slack_team_store.return_value = mock_store
|
||||
|
||||
# Mock successful summary generation
|
||||
mock_request_summary.return_value = 'Test summary from agent'
|
||||
|
||||
# Mock Slack WebClient
|
||||
mock_slack_client = MagicMock()
|
||||
mock_slack_client.chat_postMessage.return_value = {'ok': True}
|
||||
mock_web_client.return_value = mock_slack_client
|
||||
|
||||
# First callback
|
||||
result1 = await slack_callback_processor(
|
||||
conversation_id, event_callback, finish_event
|
||||
)
|
||||
|
||||
# Second callback (should not exit, should process again)
|
||||
result2 = await slack_callback_processor(
|
||||
conversation_id, event_callback, finish_event
|
||||
)
|
||||
|
||||
# Verify both callbacks succeeded
|
||||
assert result1 is not None
|
||||
assert result1.status == EventCallbackResultStatus.SUCCESS
|
||||
assert result1.detail == 'Test summary from agent'
|
||||
|
||||
assert result2 is not None
|
||||
assert result2.status == EventCallbackResultStatus.SUCCESS
|
||||
assert result2.detail == 'Test summary from agent'
|
||||
|
||||
# Verify both callbacks triggered summary requests and Slack posts
|
||||
assert mock_request_summary.call_count == 2
|
||||
assert mock_slack_client.chat_postMessage.call_count == 2
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Successful end-to-end flow
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@patch('storage.slack_team_store.SlackTeamStore.get_instance')
|
||||
@patch('openhands.app_server.config.get_httpx_client')
|
||||
@patch('openhands.app_server.config.get_sandbox_service')
|
||||
@patch('openhands.app_server.config.get_app_conversation_info_service')
|
||||
@patch('integrations.slack.slack_v1_callback_processor.get_summary_instruction')
|
||||
@patch('integrations.slack.slack_v1_callback_processor.WebClient')
|
||||
async def test_successful_end_to_end_flow(
|
||||
self,
|
||||
mock_web_client,
|
||||
mock_get_summary_instruction,
|
||||
mock_get_app_conversation_info_service,
|
||||
mock_get_sandbox_service,
|
||||
mock_get_httpx_client,
|
||||
mock_slack_team_store,
|
||||
slack_callback_processor,
|
||||
finish_event,
|
||||
event_callback,
|
||||
mock_app_conversation_info,
|
||||
mock_sandbox_info,
|
||||
):
|
||||
"""Test successful end-to-end callback execution."""
|
||||
conversation_id = uuid4()
|
||||
|
||||
# Mock SlackTeamStore
|
||||
mock_store = MagicMock()
|
||||
mock_store.get_team_bot_token.return_value = 'xoxb-test-token'
|
||||
mock_slack_team_store.return_value = mock_store
|
||||
|
||||
# Mock summary instruction
|
||||
mock_get_summary_instruction.return_value = 'Please provide a summary'
|
||||
|
||||
# Mock services
|
||||
mock_app_conversation_info_service = AsyncMock()
|
||||
mock_app_conversation_info_service.get_app_conversation_info.return_value = (
|
||||
mock_app_conversation_info
|
||||
)
|
||||
mock_get_app_conversation_info_service.return_value.__aenter__.return_value = (
|
||||
mock_app_conversation_info_service
|
||||
)
|
||||
|
||||
mock_sandbox_service = AsyncMock()
|
||||
mock_sandbox_service.get_sandbox.return_value = mock_sandbox_info
|
||||
mock_get_sandbox_service.return_value.__aenter__.return_value = (
|
||||
mock_sandbox_service
|
||||
)
|
||||
|
||||
mock_httpx_client = AsyncMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {'response': 'Test summary from agent'}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_httpx_client.post.return_value = mock_response
|
||||
mock_get_httpx_client.return_value.__aenter__.return_value = mock_httpx_client
|
||||
|
||||
# Mock Slack WebClient
|
||||
mock_slack_client = MagicMock()
|
||||
mock_slack_client.chat_postMessage.return_value = {'ok': True}
|
||||
mock_web_client.return_value = mock_slack_client
|
||||
|
||||
# Execute
|
||||
result = await slack_callback_processor(
|
||||
conversation_id, event_callback, finish_event
|
||||
)
|
||||
|
||||
# Verify result
|
||||
assert result is not None
|
||||
assert result.status == EventCallbackResultStatus.SUCCESS
|
||||
assert result.conversation_id == conversation_id
|
||||
assert result.detail == 'Test summary from agent'
|
||||
|
||||
# Verify Slack posting
|
||||
mock_slack_client.chat_postMessage.assert_called_once_with(
|
||||
channel='C1234567890',
|
||||
text='Test summary from agent',
|
||||
thread_ts='1234567890.123456',
|
||||
unfurl_links=False,
|
||||
unfurl_media=False,
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Error handling tests (parameterized)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'bot_token,expected_error',
|
||||
[
|
||||
(None, 'Missing Slack bot access token'),
|
||||
('', 'Missing Slack bot access token'),
|
||||
],
|
||||
)
|
||||
@patch('storage.slack_team_store.SlackTeamStore.get_instance')
|
||||
@patch.object(SlackV1CallbackProcessor, '_request_summary')
|
||||
async def test_missing_bot_token_scenarios(
|
||||
self,
|
||||
mock_request_summary,
|
||||
mock_slack_team_store,
|
||||
slack_callback_processor,
|
||||
finish_event,
|
||||
event_callback,
|
||||
bot_token,
|
||||
expected_error,
|
||||
):
|
||||
"""Test error handling when bot access token is missing or empty."""
|
||||
# Mock SlackTeamStore to return the test token
|
||||
mock_store = MagicMock()
|
||||
mock_store.get_team_bot_token.return_value = bot_token
|
||||
mock_slack_team_store.return_value = mock_store
|
||||
|
||||
# Mock successful summary generation
|
||||
mock_request_summary.return_value = 'Test summary'
|
||||
|
||||
result = await slack_callback_processor(uuid4(), event_callback, finish_event)
|
||||
|
||||
assert result is not None
|
||||
assert result.status == EventCallbackResultStatus.ERROR
|
||||
assert expected_error in result.detail
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'slack_response,expected_error',
|
||||
[
|
||||
(
|
||||
{'ok': False, 'error': 'channel_not_found'},
|
||||
'Slack API error: channel_not_found',
|
||||
),
|
||||
({'ok': False, 'error': 'invalid_auth'}, 'Slack API error: invalid_auth'),
|
||||
({'ok': False}, 'Slack API error: Unknown error'),
|
||||
],
|
||||
)
|
||||
@patch('storage.slack_team_store.SlackTeamStore.get_instance')
|
||||
@patch('integrations.slack.slack_v1_callback_processor.WebClient')
|
||||
@patch.object(SlackV1CallbackProcessor, '_request_summary')
|
||||
async def test_slack_api_error_scenarios(
|
||||
self,
|
||||
mock_request_summary,
|
||||
mock_web_client,
|
||||
mock_slack_team_store,
|
||||
slack_callback_processor,
|
||||
finish_event,
|
||||
event_callback,
|
||||
slack_response,
|
||||
expected_error,
|
||||
):
|
||||
"""Test error handling for various Slack API errors."""
|
||||
# Mock SlackTeamStore
|
||||
mock_store = MagicMock()
|
||||
mock_store.get_team_bot_token.return_value = 'xoxb-test-token'
|
||||
mock_slack_team_store.return_value = mock_store
|
||||
|
||||
# Mock successful summary generation
|
||||
mock_request_summary.return_value = 'Test summary'
|
||||
|
||||
# Mock Slack WebClient with error response
|
||||
mock_slack_client = MagicMock()
|
||||
mock_slack_client.chat_postMessage.return_value = slack_response
|
||||
mock_web_client.return_value = mock_slack_client
|
||||
|
||||
result = await slack_callback_processor(uuid4(), event_callback, finish_event)
|
||||
|
||||
assert result is not None
|
||||
assert result.status == EventCallbackResultStatus.ERROR
|
||||
assert expected_error in result.detail
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'exception,expected_error_fragment',
|
||||
[
|
||||
(
|
||||
httpx.TimeoutException('Request timeout'),
|
||||
'Request timeout after 30 seconds',
|
||||
),
|
||||
(
|
||||
httpx.HTTPStatusError(
|
||||
'Server error',
|
||||
request=MagicMock(),
|
||||
response=MagicMock(
|
||||
status_code=500, text='Internal Server Error', headers={}
|
||||
),
|
||||
),
|
||||
'Failed to send message to agent server',
|
||||
),
|
||||
(
|
||||
httpx.RequestError('Connection error'),
|
||||
'Request error',
|
||||
),
|
||||
],
|
||||
)
|
||||
@patch('storage.slack_team_store.SlackTeamStore.get_instance')
|
||||
@patch('openhands.app_server.config.get_httpx_client')
|
||||
@patch('openhands.app_server.config.get_sandbox_service')
|
||||
@patch('openhands.app_server.config.get_app_conversation_info_service')
|
||||
@patch('integrations.slack.slack_v1_callback_processor.get_summary_instruction')
|
||||
async def test_agent_server_error_scenarios(
|
||||
self,
|
||||
mock_get_summary_instruction,
|
||||
mock_get_app_conversation_info_service,
|
||||
mock_get_sandbox_service,
|
||||
mock_get_httpx_client,
|
||||
mock_slack_team_store,
|
||||
slack_callback_processor,
|
||||
finish_event,
|
||||
event_callback,
|
||||
mock_app_conversation_info,
|
||||
mock_sandbox_info,
|
||||
exception,
|
||||
expected_error_fragment,
|
||||
):
|
||||
"""Test error handling for various agent server errors."""
|
||||
conversation_id = uuid4()
|
||||
|
||||
# Mock SlackTeamStore
|
||||
mock_store = MagicMock()
|
||||
mock_store.get_team_bot_token.return_value = 'xoxb-test-token'
|
||||
mock_slack_team_store.return_value = mock_store
|
||||
|
||||
# Mock summary instruction
|
||||
mock_get_summary_instruction.return_value = 'Please provide a summary'
|
||||
|
||||
# Mock services
|
||||
mock_app_conversation_info_service = AsyncMock()
|
||||
mock_app_conversation_info_service.get_app_conversation_info.return_value = (
|
||||
mock_app_conversation_info
|
||||
)
|
||||
mock_get_app_conversation_info_service.return_value.__aenter__.return_value = (
|
||||
mock_app_conversation_info_service
|
||||
)
|
||||
|
||||
mock_sandbox_service = AsyncMock()
|
||||
mock_sandbox_service.get_sandbox.return_value = mock_sandbox_info
|
||||
mock_get_sandbox_service.return_value.__aenter__.return_value = (
|
||||
mock_sandbox_service
|
||||
)
|
||||
|
||||
# Mock HTTP client with the specified exception
|
||||
mock_httpx_client = AsyncMock()
|
||||
mock_httpx_client.post.side_effect = exception
|
||||
mock_get_httpx_client.return_value.__aenter__.return_value = mock_httpx_client
|
||||
|
||||
# Execute
|
||||
result = await slack_callback_processor(
|
||||
conversation_id, event_callback, finish_event
|
||||
)
|
||||
|
||||
# Verify error result
|
||||
assert result is not None
|
||||
assert result.status == EventCallbackResultStatus.ERROR
|
||||
assert expected_error_fragment in result.detail
|
||||
341
enterprise/tests/unit/integrations/slack/test_slack_view.py
Normal file
341
enterprise/tests/unit/integrations/slack/test_slack_view.py
Normal file
@@ -0,0 +1,341 @@
|
||||
"""Tests for the Slack view classes and their v1 vs v0 conversation handling.
|
||||
|
||||
Focuses on the 3 essential scenarios:
|
||||
1. V1 vs V0 decision logic based on user setting
|
||||
2. Message routing to correct method based on conversation v1 flag
|
||||
3. Paused sandbox resumption for V1 conversations
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from integrations.slack.slack_view import (
|
||||
SlackNewConversationView,
|
||||
SlackUpdateExistingConversationView,
|
||||
)
|
||||
from jinja2 import DictLoader, Environment
|
||||
from storage.slack_conversation import SlackConversation
|
||||
from storage.slack_user import SlackUser
|
||||
|
||||
from openhands.app_server.sandbox.sandbox_models import SandboxStatus
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_jinja_env():
|
||||
"""Create a mock Jinja environment with test templates."""
|
||||
templates = {
|
||||
'user_message_conversation_instructions.j2': 'Previous messages: {{ messages|join(", ") }}\nUser: {{ username }}\nURL: {{ conversation_url }}'
|
||||
}
|
||||
return Environment(loader=DictLoader(templates))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_slack_user():
|
||||
"""Create a mock SlackUser."""
|
||||
user = SlackUser()
|
||||
user.slack_user_id = 'U1234567890'
|
||||
user.keycloak_user_id = 'test-user-123'
|
||||
user.slack_display_name = 'Test User'
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_user_auth():
|
||||
"""Create a mock UserAuth."""
|
||||
auth = MagicMock(spec=UserAuth)
|
||||
auth.get_provider_tokens = AsyncMock(return_value={})
|
||||
auth.get_secrets = AsyncMock(return_value=MagicMock(custom_secrets={}))
|
||||
return auth
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def slack_new_conversation_view(mock_slack_user, mock_user_auth):
|
||||
"""Create a SlackNewConversationView instance."""
|
||||
return SlackNewConversationView(
|
||||
bot_access_token='xoxb-test-token',
|
||||
user_msg='Hello OpenHands!',
|
||||
slack_user_id='U1234567890',
|
||||
slack_to_openhands_user=mock_slack_user,
|
||||
saas_user_auth=mock_user_auth,
|
||||
channel_id='C1234567890',
|
||||
message_ts='1234567890.123456',
|
||||
thread_ts=None,
|
||||
selected_repo='owner/repo',
|
||||
should_extract=True,
|
||||
send_summary_instruction=True,
|
||||
conversation_id='',
|
||||
team_id='T1234567890',
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def slack_update_conversation_view_v0(mock_slack_user, mock_user_auth):
|
||||
"""Create a SlackUpdateExistingConversationView instance for V0."""
|
||||
conversation_id = '87654321-4321-8765-4321-876543218765'
|
||||
mock_conversation = SlackConversation(
|
||||
conversation_id=conversation_id,
|
||||
channel_id='C1234567890',
|
||||
keycloak_user_id='test-user-123',
|
||||
parent_id='1234567890.123456',
|
||||
v1_enabled=False,
|
||||
)
|
||||
return SlackUpdateExistingConversationView(
|
||||
bot_access_token='xoxb-test-token',
|
||||
user_msg='Follow up message',
|
||||
slack_user_id='U1234567890',
|
||||
slack_to_openhands_user=mock_slack_user,
|
||||
saas_user_auth=mock_user_auth,
|
||||
channel_id='C1234567890',
|
||||
message_ts='1234567890.123457',
|
||||
thread_ts='1234567890.123456',
|
||||
selected_repo=None,
|
||||
should_extract=True,
|
||||
send_summary_instruction=True,
|
||||
conversation_id=conversation_id,
|
||||
slack_conversation=mock_conversation,
|
||||
team_id='T1234567890',
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def slack_update_conversation_view_v1(mock_slack_user, mock_user_auth):
|
||||
"""Create a SlackUpdateExistingConversationView instance for V1."""
|
||||
conversation_id = '12345678-1234-5678-1234-567812345678'
|
||||
mock_conversation = SlackConversation(
|
||||
conversation_id=conversation_id,
|
||||
channel_id='C1234567890',
|
||||
keycloak_user_id='test-user-123',
|
||||
parent_id='1234567890.123456',
|
||||
v1_enabled=True,
|
||||
)
|
||||
return SlackUpdateExistingConversationView(
|
||||
bot_access_token='xoxb-test-token',
|
||||
user_msg='Follow up message',
|
||||
slack_user_id='U1234567890',
|
||||
slack_to_openhands_user=mock_slack_user,
|
||||
saas_user_auth=mock_user_auth,
|
||||
channel_id='C1234567890',
|
||||
message_ts='1234567890.123457',
|
||||
thread_ts='1234567890.123456',
|
||||
selected_repo=None,
|
||||
should_extract=True,
|
||||
send_summary_instruction=True,
|
||||
conversation_id=conversation_id,
|
||||
slack_conversation=mock_conversation,
|
||||
team_id='T1234567890',
|
||||
v1_enabled=True,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 1: V1 vs V0 Decision Logic Based on User Setting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestV1V0DecisionLogic:
|
||||
"""Test the decision logic for choosing between V1 and V0 conversations based on user setting."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'v1_enabled,expected_v1_flag',
|
||||
[
|
||||
(True, True), # V1 enabled, use V1
|
||||
(False, False), # V1 disabled, use V0
|
||||
],
|
||||
)
|
||||
@patch('integrations.slack.slack_view.is_v1_enabled_for_slack_resolver')
|
||||
@patch.object(SlackNewConversationView, '_create_v1_conversation')
|
||||
@patch.object(SlackNewConversationView, '_create_v0_conversation')
|
||||
async def test_v1_v0_decision_logic(
|
||||
self,
|
||||
mock_create_v0,
|
||||
mock_create_v1,
|
||||
mock_is_v1_enabled,
|
||||
slack_new_conversation_view,
|
||||
mock_jinja_env,
|
||||
v1_enabled,
|
||||
expected_v1_flag,
|
||||
):
|
||||
"""Test the decision logic for V1 vs V0 conversation creation based on user setting."""
|
||||
# Setup mocks
|
||||
mock_is_v1_enabled.return_value = v1_enabled
|
||||
mock_create_v1.return_value = None
|
||||
mock_create_v0.return_value = None
|
||||
|
||||
# Execute
|
||||
result = await slack_new_conversation_view.create_or_update_conversation(
|
||||
mock_jinja_env
|
||||
)
|
||||
|
||||
# Verify
|
||||
assert result == slack_new_conversation_view.conversation_id
|
||||
assert slack_new_conversation_view.v1_enabled == expected_v1_flag
|
||||
|
||||
if v1_enabled:
|
||||
mock_create_v1.assert_called_once()
|
||||
mock_create_v0.assert_not_called()
|
||||
else:
|
||||
mock_create_v1.assert_not_called()
|
||||
mock_create_v0.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 2: Message Routing Based on Conversation V1 Flag
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMessageRouting:
|
||||
"""Test that message sending routes to correct method based on conversation v1 flag."""
|
||||
|
||||
@patch.object(
|
||||
SlackUpdateExistingConversationView, 'send_message_to_v1_conversation'
|
||||
)
|
||||
@patch.object(
|
||||
SlackUpdateExistingConversationView, 'send_message_to_v0_conversation'
|
||||
)
|
||||
async def test_message_routing_to_v1(
|
||||
self,
|
||||
mock_send_v0,
|
||||
mock_send_v1,
|
||||
slack_update_conversation_view_v1,
|
||||
mock_jinja_env,
|
||||
):
|
||||
"""Test that V1 conversations route to V1 message sending method."""
|
||||
# Setup
|
||||
mock_send_v0.return_value = None
|
||||
mock_send_v1.return_value = None
|
||||
|
||||
# Execute
|
||||
result = await slack_update_conversation_view_v1.create_or_update_conversation(
|
||||
mock_jinja_env
|
||||
)
|
||||
|
||||
# Verify
|
||||
assert result == slack_update_conversation_view_v1.conversation_id
|
||||
mock_send_v1.assert_called_once_with(mock_jinja_env)
|
||||
mock_send_v0.assert_not_called()
|
||||
|
||||
@patch.object(
|
||||
SlackUpdateExistingConversationView, 'send_message_to_v1_conversation'
|
||||
)
|
||||
@patch.object(
|
||||
SlackUpdateExistingConversationView, 'send_message_to_v0_conversation'
|
||||
)
|
||||
async def test_message_routing_to_v0(
|
||||
self,
|
||||
mock_send_v0,
|
||||
mock_send_v1,
|
||||
slack_update_conversation_view_v0,
|
||||
mock_jinja_env,
|
||||
):
|
||||
"""Test that V0 conversations route to V0 message sending method."""
|
||||
# Setup
|
||||
mock_send_v0.return_value = None
|
||||
mock_send_v1.return_value = None
|
||||
|
||||
# Execute
|
||||
result = await slack_update_conversation_view_v0.create_or_update_conversation(
|
||||
mock_jinja_env
|
||||
)
|
||||
|
||||
# Verify
|
||||
assert result == slack_update_conversation_view_v0.conversation_id
|
||||
mock_send_v0.assert_called_once_with(mock_jinja_env)
|
||||
mock_send_v1.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 3: Paused Sandbox Resumption for V1 Conversations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPausedSandboxResumption:
|
||||
"""Test that paused sandboxes are resumed when sending messages to V1 conversations."""
|
||||
|
||||
@patch('openhands.app_server.config.get_sandbox_service')
|
||||
@patch('openhands.app_server.config.get_app_conversation_info_service')
|
||||
@patch('openhands.app_server.config.get_httpx_client')
|
||||
@patch('openhands.app_server.event_callback.util.ensure_running_sandbox')
|
||||
@patch('openhands.app_server.event_callback.util.get_agent_server_url_from_sandbox')
|
||||
@patch.object(SlackUpdateExistingConversationView, '_get_instructions')
|
||||
async def test_paused_sandbox_resumption(
|
||||
self,
|
||||
mock_get_instructions,
|
||||
mock_get_agent_server_url,
|
||||
mock_ensure_running_sandbox,
|
||||
mock_get_httpx_client,
|
||||
mock_get_app_info_service,
|
||||
mock_get_sandbox_service,
|
||||
slack_update_conversation_view_v1,
|
||||
mock_jinja_env,
|
||||
):
|
||||
"""Test that paused sandboxes are resumed when sending messages to V1 conversations."""
|
||||
# Setup mocks
|
||||
mock_get_instructions.return_value = ('User message', '')
|
||||
|
||||
# Mock app conversation info service
|
||||
mock_app_info_service = AsyncMock()
|
||||
mock_app_info = MagicMock()
|
||||
mock_app_info.sandbox_id = 'sandbox-123'
|
||||
mock_app_info_service.get_app_conversation_info.return_value = mock_app_info
|
||||
mock_get_app_info_service.return_value.__aenter__.return_value = (
|
||||
mock_app_info_service
|
||||
)
|
||||
|
||||
# Mock sandbox service with paused sandbox that gets resumed
|
||||
mock_sandbox_service = AsyncMock()
|
||||
mock_paused_sandbox = MagicMock()
|
||||
mock_paused_sandbox.status = SandboxStatus.PAUSED
|
||||
mock_paused_sandbox.session_api_key = 'test-api-key'
|
||||
mock_paused_sandbox.exposed_urls = [
|
||||
MagicMock(name='AGENT_SERVER', url='http://localhost:8000')
|
||||
]
|
||||
|
||||
# After resume, sandbox becomes running
|
||||
mock_running_sandbox = MagicMock()
|
||||
mock_running_sandbox.status = SandboxStatus.RUNNING
|
||||
mock_running_sandbox.session_api_key = 'test-api-key'
|
||||
mock_running_sandbox.exposed_urls = [
|
||||
MagicMock(name='AGENT_SERVER', url='http://localhost:8000')
|
||||
]
|
||||
|
||||
mock_sandbox_service.get_sandbox.side_effect = [
|
||||
mock_paused_sandbox,
|
||||
mock_running_sandbox,
|
||||
]
|
||||
mock_sandbox_service.resume_sandbox = AsyncMock()
|
||||
mock_get_sandbox_service.return_value.__aenter__.return_value = (
|
||||
mock_sandbox_service
|
||||
)
|
||||
|
||||
# Mock ensure_running_sandbox to first raise RuntimeError, then return running sandbox
|
||||
mock_ensure_running_sandbox.side_effect = [
|
||||
RuntimeError('Sandbox not running: sandbox-123'),
|
||||
mock_running_sandbox,
|
||||
]
|
||||
|
||||
# Mock agent server URL
|
||||
mock_get_agent_server_url.return_value = 'http://localhost:8000'
|
||||
|
||||
# Mock HTTP client
|
||||
mock_httpx_client = AsyncMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_httpx_client.post.return_value = mock_response
|
||||
mock_get_httpx_client.return_value.__aenter__.return_value = mock_httpx_client
|
||||
|
||||
# Execute
|
||||
await slack_update_conversation_view_v1.send_message_to_v1_conversation(
|
||||
mock_jinja_env
|
||||
)
|
||||
|
||||
# Verify sandbox was resumed
|
||||
mock_sandbox_service.resume_sandbox.assert_called_once_with('sandbox-123')
|
||||
mock_httpx_client.post.assert_called_once()
|
||||
mock_response.raise_for_status.assert_called_once()
|
||||
@@ -4,7 +4,9 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from integrations.utils import (
|
||||
HOST_URL,
|
||||
append_conversation_footer,
|
||||
get_session_expired_message,
|
||||
get_summary_for_agent_state,
|
||||
)
|
||||
|
||||
@@ -164,6 +166,68 @@ class TestGetSummaryForAgentState:
|
||||
assert self.conversation_link not in result
|
||||
|
||||
|
||||
class TestGetSessionExpiredMessage:
|
||||
"""Test cases for get_session_expired_message function."""
|
||||
|
||||
def test_message_with_username_contains_at_prefix(self):
|
||||
"""Test that the message contains the username with @ prefix."""
|
||||
result = get_session_expired_message('testuser')
|
||||
assert '@testuser' in result
|
||||
|
||||
def test_message_with_username_contains_session_expired_text(self):
|
||||
"""Test that the message contains session expired text."""
|
||||
result = get_session_expired_message('testuser')
|
||||
assert 'session has expired' in result
|
||||
|
||||
def test_message_with_username_contains_login_instruction(self):
|
||||
"""Test that the message contains login instruction."""
|
||||
result = get_session_expired_message('testuser')
|
||||
assert 'login again' in result
|
||||
|
||||
def test_message_with_username_contains_host_url(self):
|
||||
"""Test that the message contains the OpenHands Cloud URL."""
|
||||
result = get_session_expired_message('testuser')
|
||||
assert HOST_URL in result
|
||||
assert 'OpenHands Cloud' in result
|
||||
|
||||
def test_different_usernames(self):
|
||||
"""Test that different usernames produce different messages."""
|
||||
result1 = get_session_expired_message('user1')
|
||||
result2 = get_session_expired_message('user2')
|
||||
assert '@user1' in result1
|
||||
assert '@user2' in result2
|
||||
assert '@user1' not in result2
|
||||
assert '@user2' not in result1
|
||||
|
||||
def test_message_without_username_contains_session_expired_text(self):
|
||||
"""Test that the message without username contains session expired text."""
|
||||
result = get_session_expired_message()
|
||||
assert 'session has expired' in result
|
||||
|
||||
def test_message_without_username_contains_login_instruction(self):
|
||||
"""Test that the message without username contains login instruction."""
|
||||
result = get_session_expired_message()
|
||||
assert 'login again' in result
|
||||
|
||||
def test_message_without_username_contains_host_url(self):
|
||||
"""Test that the message without username contains the OpenHands Cloud URL."""
|
||||
result = get_session_expired_message()
|
||||
assert HOST_URL in result
|
||||
assert 'OpenHands Cloud' in result
|
||||
|
||||
def test_message_without_username_does_not_contain_at_prefix(self):
|
||||
"""Test that the message without username does not contain @ prefix."""
|
||||
result = get_session_expired_message()
|
||||
assert not result.startswith('@')
|
||||
assert 'Your session' in result
|
||||
|
||||
def test_message_with_none_username(self):
|
||||
"""Test that passing None explicitly works the same as no argument."""
|
||||
result = get_session_expired_message(None)
|
||||
assert not result.startswith('@')
|
||||
assert 'Your session' in result
|
||||
|
||||
|
||||
class TestAppendConversationFooter:
|
||||
"""Test cases for append_conversation_footer function."""
|
||||
|
||||
|
||||
@@ -84,7 +84,8 @@ async def test_verify_email_with_auth_flow(mock_request):
|
||||
call_args = mock_keycloak_admin.a_send_verify_email.call_args
|
||||
assert call_args.kwargs['user_id'] == user_id
|
||||
assert (
|
||||
call_args.kwargs['redirect_uri'] == 'http://localhost:8000?email_verified=true'
|
||||
call_args.kwargs['redirect_uri']
|
||||
== 'http://localhost:8000/login?email_verified=true'
|
||||
)
|
||||
assert 'client_id' in call_args.kwargs
|
||||
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
"""Unit tests for GitLab integration routes."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException, status
|
||||
from integrations.gitlab.gitlab_service import SaaSGitLabService
|
||||
from integrations.gitlab.webhook_installation import BreakLoopException
|
||||
from integrations.types import GitLabResourceType
|
||||
from server.routes.integration.gitlab import (
|
||||
ReinstallWebhookRequest,
|
||||
ResourceIdentifier,
|
||||
get_gitlab_resources,
|
||||
reinstall_gitlab_webhook,
|
||||
)
|
||||
from storage.gitlab_webhook import GitlabWebhook
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_gitlab_service():
|
||||
"""Create a mock SaaSGitLabService instance."""
|
||||
service = MagicMock(spec=SaaSGitLabService)
|
||||
service.get_user_resources_with_admin_access = AsyncMock(
|
||||
return_value=(
|
||||
[
|
||||
{
|
||||
'id': 1,
|
||||
'name': 'Test Project',
|
||||
'path_with_namespace': 'user/test-project',
|
||||
'namespace': {'kind': 'user'},
|
||||
},
|
||||
{
|
||||
'id': 2,
|
||||
'name': 'Group Project',
|
||||
'path_with_namespace': 'group/group-project',
|
||||
'namespace': {'kind': 'group'},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
'id': 10,
|
||||
'name': 'Test Group',
|
||||
'full_path': 'test-group',
|
||||
},
|
||||
],
|
||||
)
|
||||
)
|
||||
service.check_webhook_exists_on_resource = AsyncMock(return_value=(True, None))
|
||||
service.check_user_has_admin_access_to_resource = AsyncMock(
|
||||
return_value=(True, None)
|
||||
)
|
||||
return service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_webhook():
|
||||
"""Create a mock webhook object."""
|
||||
webhook = MagicMock(spec=GitlabWebhook)
|
||||
webhook.webhook_uuid = 'test-uuid'
|
||||
webhook.last_synced = None
|
||||
return webhook
|
||||
|
||||
|
||||
class TestGetGitLabResources:
|
||||
"""Test cases for get_gitlab_resources endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.integration.gitlab.GitLabServiceImpl')
|
||||
@patch('server.routes.integration.gitlab.webhook_store')
|
||||
@patch('server.routes.integration.gitlab.isinstance')
|
||||
async def test_get_resources_success(
|
||||
self,
|
||||
mock_isinstance,
|
||||
mock_webhook_store,
|
||||
mock_gitlab_service_impl,
|
||||
mock_gitlab_service,
|
||||
):
|
||||
"""Test successfully retrieving GitLab resources with webhook status."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
mock_gitlab_service_impl.return_value = mock_gitlab_service
|
||||
mock_isinstance.return_value = True
|
||||
mock_webhook_store.get_webhooks_by_resources = AsyncMock(
|
||||
return_value=({}, {}) # Empty maps for simplicity
|
||||
)
|
||||
|
||||
# Act
|
||||
response = await get_gitlab_resources(user_id=user_id)
|
||||
|
||||
# Assert
|
||||
assert len(response.resources) == 2 # 1 project (filtered) + 1 group
|
||||
assert response.resources[0].type == 'project'
|
||||
assert response.resources[0].id == '1'
|
||||
assert response.resources[0].name == 'Test Project'
|
||||
assert response.resources[1].type == 'group'
|
||||
assert response.resources[1].id == '10'
|
||||
mock_gitlab_service.get_user_resources_with_admin_access.assert_called_once()
|
||||
mock_webhook_store.get_webhooks_by_resources.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.integration.gitlab.GitLabServiceImpl')
|
||||
@patch('server.routes.integration.gitlab.webhook_store')
|
||||
@patch('server.routes.integration.gitlab.isinstance')
|
||||
async def test_get_resources_filters_nested_projects(
|
||||
self,
|
||||
mock_isinstance,
|
||||
mock_webhook_store,
|
||||
mock_gitlab_service_impl,
|
||||
mock_gitlab_service,
|
||||
):
|
||||
"""Test that projects nested under groups are filtered out."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
mock_gitlab_service_impl.return_value = mock_gitlab_service
|
||||
mock_isinstance.return_value = True
|
||||
mock_webhook_store.get_webhooks_by_resources = AsyncMock(return_value=({}, {}))
|
||||
|
||||
# Act
|
||||
response = await get_gitlab_resources(user_id=user_id)
|
||||
|
||||
# Assert
|
||||
# Should only include the user project, not the group project
|
||||
project_resources = [r for r in response.resources if r.type == 'project']
|
||||
assert len(project_resources) == 1
|
||||
assert project_resources[0].id == '1'
|
||||
assert project_resources[0].name == 'Test Project'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.integration.gitlab.GitLabServiceImpl')
|
||||
@patch('server.routes.integration.gitlab.webhook_store')
|
||||
@patch('server.routes.integration.gitlab.isinstance')
|
||||
async def test_get_resources_includes_webhook_metadata(
|
||||
self,
|
||||
mock_isinstance,
|
||||
mock_webhook_store,
|
||||
mock_gitlab_service_impl,
|
||||
mock_gitlab_service,
|
||||
mock_webhook,
|
||||
):
|
||||
"""Test that webhook metadata is included in the response."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
mock_gitlab_service_impl.return_value = mock_gitlab_service
|
||||
mock_isinstance.return_value = True
|
||||
mock_webhook_store.get_webhooks_by_resources = AsyncMock(
|
||||
return_value=({'1': mock_webhook}, {'10': mock_webhook})
|
||||
)
|
||||
|
||||
# Act
|
||||
response = await get_gitlab_resources(user_id=user_id)
|
||||
|
||||
# Assert
|
||||
assert response.resources[0].webhook_uuid == 'test-uuid'
|
||||
assert response.resources[1].webhook_uuid == 'test-uuid'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.integration.gitlab.GitLabServiceImpl')
|
||||
async def test_get_resources_non_saas_service(
|
||||
self, mock_gitlab_service_impl, mock_gitlab_service
|
||||
):
|
||||
"""Test that non-SaaS GitLab service raises an error."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
non_saas_service = AsyncMock()
|
||||
mock_gitlab_service_impl.return_value = non_saas_service
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_gitlab_resources(user_id=user_id)
|
||||
|
||||
assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert 'Only SaaS GitLab service is supported' in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.integration.gitlab.GitLabServiceImpl')
|
||||
@patch('server.routes.integration.gitlab.webhook_store')
|
||||
@patch('server.routes.integration.gitlab.isinstance')
|
||||
async def test_get_resources_parallel_api_calls(
|
||||
self,
|
||||
mock_isinstance,
|
||||
mock_webhook_store,
|
||||
mock_gitlab_service_impl,
|
||||
mock_gitlab_service,
|
||||
):
|
||||
"""Test that webhook status checks are made in parallel."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
mock_gitlab_service_impl.return_value = mock_gitlab_service
|
||||
mock_isinstance.return_value = True
|
||||
mock_webhook_store.get_webhooks_by_resources = AsyncMock(return_value=({}, {}))
|
||||
call_count = 0
|
||||
|
||||
async def track_calls(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return (True, None)
|
||||
|
||||
mock_gitlab_service.check_webhook_exists_on_resource = AsyncMock(
|
||||
side_effect=track_calls
|
||||
)
|
||||
|
||||
# Act
|
||||
await get_gitlab_resources(user_id=user_id)
|
||||
|
||||
# Assert
|
||||
# Should be called for each resource (1 project + 1 group)
|
||||
assert call_count == 2
|
||||
|
||||
|
||||
class TestReinstallGitLabWebhook:
|
||||
"""Test cases for reinstall_gitlab_webhook endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.integration.gitlab.install_webhook_on_resource')
|
||||
@patch('server.routes.integration.gitlab.verify_webhook_conditions')
|
||||
@patch('server.routes.integration.gitlab.GitLabServiceImpl')
|
||||
@patch('server.routes.integration.gitlab.webhook_store')
|
||||
@patch('server.routes.integration.gitlab.isinstance')
|
||||
async def test_reinstall_webhook_success_existing_webhook(
|
||||
self,
|
||||
mock_isinstance,
|
||||
mock_webhook_store,
|
||||
mock_gitlab_service_impl,
|
||||
mock_verify_conditions,
|
||||
mock_install_webhook,
|
||||
mock_gitlab_service,
|
||||
mock_webhook,
|
||||
):
|
||||
"""Test successful webhook reinstallation when webhook record exists."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
resource_id = 'project-123'
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
|
||||
mock_gitlab_service_impl.return_value = mock_gitlab_service
|
||||
mock_isinstance.return_value = True
|
||||
mock_webhook_store.reset_webhook_for_reinstallation_by_resource = AsyncMock(
|
||||
return_value=True
|
||||
)
|
||||
mock_webhook_store.get_webhook_by_resource_only = AsyncMock(
|
||||
return_value=mock_webhook
|
||||
)
|
||||
mock_verify_conditions.return_value = None
|
||||
mock_install_webhook.return_value = ('webhook-id-123', None)
|
||||
|
||||
body = ReinstallWebhookRequest(
|
||||
resource=ResourceIdentifier(type=resource_type, id=resource_id)
|
||||
)
|
||||
|
||||
# Act
|
||||
result = await reinstall_gitlab_webhook(body=body, user_id=user_id)
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert result.resource_id == resource_id
|
||||
assert result.resource_type == resource_type.value
|
||||
assert result.error is None
|
||||
mock_gitlab_service.check_user_has_admin_access_to_resource.assert_called_once_with(
|
||||
resource_type, resource_id
|
||||
)
|
||||
mock_webhook_store.reset_webhook_for_reinstallation_by_resource.assert_called_once_with(
|
||||
resource_type, resource_id, user_id
|
||||
)
|
||||
mock_verify_conditions.assert_called_once()
|
||||
mock_install_webhook.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.integration.gitlab.install_webhook_on_resource')
|
||||
@patch('server.routes.integration.gitlab.verify_webhook_conditions')
|
||||
@patch('server.routes.integration.gitlab.GitLabServiceImpl')
|
||||
@patch('server.routes.integration.gitlab.webhook_store')
|
||||
@patch('server.routes.integration.gitlab.isinstance')
|
||||
async def test_reinstall_webhook_success_new_webhook_record(
|
||||
self,
|
||||
mock_isinstance,
|
||||
mock_webhook_store,
|
||||
mock_gitlab_service_impl,
|
||||
mock_verify_conditions,
|
||||
mock_install_webhook,
|
||||
mock_gitlab_service,
|
||||
):
|
||||
"""Test successful webhook reinstallation when webhook record doesn't exist."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
resource_id = 'project-456'
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
|
||||
mock_gitlab_service_impl.return_value = mock_gitlab_service
|
||||
mock_isinstance.return_value = True
|
||||
mock_webhook_store.reset_webhook_for_reinstallation_by_resource = (
|
||||
AsyncMock(return_value=False) # No existing webhook to reset
|
||||
)
|
||||
mock_webhook_store.get_webhook_by_resource_only = AsyncMock(
|
||||
side_effect=[
|
||||
None,
|
||||
MagicMock(),
|
||||
] # First call returns None, second returns new webhook
|
||||
)
|
||||
mock_webhook_store.store_webhooks = AsyncMock()
|
||||
mock_verify_conditions.return_value = None
|
||||
mock_install_webhook.return_value = ('webhook-id-456', None)
|
||||
|
||||
body = ReinstallWebhookRequest(
|
||||
resource=ResourceIdentifier(type=resource_type, id=resource_id)
|
||||
)
|
||||
|
||||
# Act
|
||||
result = await reinstall_gitlab_webhook(body=body, user_id=user_id)
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
mock_webhook_store.store_webhooks.assert_called_once()
|
||||
# Should fetch webhook twice: once to check, once after creating
|
||||
assert mock_webhook_store.get_webhook_by_resource_only.call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.integration.gitlab.GitLabServiceImpl')
|
||||
@patch('server.routes.integration.gitlab.isinstance')
|
||||
async def test_reinstall_webhook_no_admin_access(
|
||||
self, mock_isinstance, mock_gitlab_service_impl, mock_gitlab_service
|
||||
):
|
||||
"""Test reinstallation when user doesn't have admin access."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
resource_id = 'project-789'
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
|
||||
mock_gitlab_service_impl.return_value = mock_gitlab_service
|
||||
mock_isinstance.return_value = True
|
||||
mock_gitlab_service.check_user_has_admin_access_to_resource = AsyncMock(
|
||||
return_value=(False, None)
|
||||
)
|
||||
|
||||
body = ReinstallWebhookRequest(
|
||||
resource=ResourceIdentifier(type=resource_type, id=resource_id)
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await reinstall_gitlab_webhook(body=body, user_id=user_id)
|
||||
|
||||
assert exc_info.value.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert 'does not have admin access' in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.integration.gitlab.GitLabServiceImpl')
|
||||
async def test_reinstall_webhook_non_saas_service(self, mock_gitlab_service_impl):
|
||||
"""Test reinstallation with non-SaaS GitLab service."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
resource_id = 'project-999'
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
|
||||
non_saas_service = AsyncMock()
|
||||
mock_gitlab_service_impl.return_value = non_saas_service
|
||||
|
||||
body = ReinstallWebhookRequest(
|
||||
resource=ResourceIdentifier(type=resource_type, id=resource_id)
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await reinstall_gitlab_webhook(body=body, user_id=user_id)
|
||||
|
||||
assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert 'Only SaaS GitLab service is supported' in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.integration.gitlab.install_webhook_on_resource')
|
||||
@patch('server.routes.integration.gitlab.verify_webhook_conditions')
|
||||
@patch('server.routes.integration.gitlab.GitLabServiceImpl')
|
||||
@patch('server.routes.integration.gitlab.webhook_store')
|
||||
@patch('server.routes.integration.gitlab.isinstance')
|
||||
async def test_reinstall_webhook_conditions_not_met(
|
||||
self,
|
||||
mock_isinstance,
|
||||
mock_webhook_store,
|
||||
mock_gitlab_service_impl,
|
||||
mock_verify_conditions,
|
||||
mock_install_webhook,
|
||||
mock_gitlab_service,
|
||||
mock_webhook,
|
||||
):
|
||||
"""Test reinstallation when webhook conditions are not met."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
resource_id = 'project-111'
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
|
||||
mock_gitlab_service_impl.return_value = mock_gitlab_service
|
||||
mock_isinstance.return_value = True
|
||||
mock_webhook_store.reset_webhook_for_reinstallation_by_resource = AsyncMock(
|
||||
return_value=True
|
||||
)
|
||||
mock_webhook_store.get_webhook_by_resource_only = AsyncMock(
|
||||
return_value=mock_webhook
|
||||
)
|
||||
mock_verify_conditions.side_effect = BreakLoopException()
|
||||
|
||||
body = ReinstallWebhookRequest(
|
||||
resource=ResourceIdentifier(type=resource_type, id=resource_id)
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await reinstall_gitlab_webhook(body=body, user_id=user_id)
|
||||
|
||||
assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert 'conditions not met' in exc_info.value.detail.lower()
|
||||
mock_install_webhook.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.integration.gitlab.install_webhook_on_resource')
|
||||
@patch('server.routes.integration.gitlab.verify_webhook_conditions')
|
||||
@patch('server.routes.integration.gitlab.GitLabServiceImpl')
|
||||
@patch('server.routes.integration.gitlab.webhook_store')
|
||||
@patch('server.routes.integration.gitlab.isinstance')
|
||||
async def test_reinstall_webhook_installation_fails(
|
||||
self,
|
||||
mock_isinstance,
|
||||
mock_webhook_store,
|
||||
mock_gitlab_service_impl,
|
||||
mock_verify_conditions,
|
||||
mock_install_webhook,
|
||||
mock_gitlab_service,
|
||||
mock_webhook,
|
||||
):
|
||||
"""Test reinstallation when webhook installation fails."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
resource_id = 'project-222'
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
|
||||
mock_gitlab_service_impl.return_value = mock_gitlab_service
|
||||
mock_isinstance.return_value = True
|
||||
mock_webhook_store.reset_webhook_for_reinstallation_by_resource = AsyncMock(
|
||||
return_value=True
|
||||
)
|
||||
mock_webhook_store.get_webhook_by_resource_only = AsyncMock(
|
||||
return_value=mock_webhook
|
||||
)
|
||||
mock_verify_conditions.return_value = None
|
||||
mock_install_webhook.return_value = (None, None) # Installation failed
|
||||
|
||||
body = ReinstallWebhookRequest(
|
||||
resource=ResourceIdentifier(type=resource_type, id=resource_id)
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await reinstall_gitlab_webhook(body=body, user_id=user_id)
|
||||
|
||||
assert exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
assert 'Failed to install webhook' in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.integration.gitlab.install_webhook_on_resource')
|
||||
@patch('server.routes.integration.gitlab.verify_webhook_conditions')
|
||||
@patch('server.routes.integration.gitlab.GitLabServiceImpl')
|
||||
@patch('server.routes.integration.gitlab.webhook_store')
|
||||
@patch('server.routes.integration.gitlab.isinstance')
|
||||
async def test_reinstall_webhook_group_resource(
|
||||
self,
|
||||
mock_isinstance,
|
||||
mock_webhook_store,
|
||||
mock_gitlab_service_impl,
|
||||
mock_verify_conditions,
|
||||
mock_install_webhook,
|
||||
mock_gitlab_service,
|
||||
mock_webhook,
|
||||
):
|
||||
"""Test reinstallation for a group resource."""
|
||||
# Arrange
|
||||
user_id = 'test_user_id'
|
||||
resource_id = 'group-333'
|
||||
resource_type = GitLabResourceType.GROUP
|
||||
|
||||
mock_gitlab_service_impl.return_value = mock_gitlab_service
|
||||
mock_isinstance.return_value = True
|
||||
mock_webhook_store.reset_webhook_for_reinstallation_by_resource = AsyncMock(
|
||||
return_value=True
|
||||
)
|
||||
mock_webhook_store.get_webhook_by_resource_only = AsyncMock(
|
||||
return_value=mock_webhook
|
||||
)
|
||||
mock_verify_conditions.return_value = None
|
||||
mock_install_webhook.return_value = ('webhook-id-group', None)
|
||||
|
||||
body = ReinstallWebhookRequest(
|
||||
resource=ResourceIdentifier(type=resource_type, id=resource_id)
|
||||
)
|
||||
|
||||
# Act
|
||||
result = await reinstall_gitlab_webhook(body=body, user_id=user_id)
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert result.resource_id == resource_id
|
||||
assert result.resource_type == resource_type.value
|
||||
mock_webhook_store.reset_webhook_for_reinstallation_by_resource.assert_called_once_with(
|
||||
resource_type, resource_id, user_id
|
||||
)
|
||||
388
enterprise/tests/unit/storage/test_gitlab_webhook_store.py
Normal file
388
enterprise/tests/unit/storage/test_gitlab_webhook_store.py
Normal file
@@ -0,0 +1,388 @@
|
||||
"""Unit tests for GitlabWebhookStore."""
|
||||
|
||||
import pytest
|
||||
from integrations.types import GitLabResourceType
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from storage.base import Base
|
||||
from storage.gitlab_webhook import GitlabWebhook
|
||||
from storage.gitlab_webhook_store import GitlabWebhookStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_engine():
|
||||
"""Create an async SQLite engine for testing."""
|
||||
engine = create_async_engine(
|
||||
'sqlite+aiosqlite:///:memory:',
|
||||
poolclass=StaticPool,
|
||||
connect_args={'check_same_thread': False},
|
||||
echo=False,
|
||||
)
|
||||
|
||||
# Create all tables
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
yield engine
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_session_maker(async_engine):
|
||||
"""Create an async session maker for testing."""
|
||||
return async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def webhook_store(async_session_maker):
|
||||
"""Create a GitlabWebhookStore instance for testing."""
|
||||
return GitlabWebhookStore(a_session_maker=async_session_maker)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def sample_webhooks(async_session_maker):
|
||||
"""Create sample webhook records for testing."""
|
||||
async with async_session_maker() as session:
|
||||
# Create webhooks for user_1
|
||||
webhook1 = GitlabWebhook(
|
||||
project_id='project-1',
|
||||
group_id=None,
|
||||
user_id='user_1',
|
||||
webhook_exists=True,
|
||||
webhook_url='https://example.com/webhook',
|
||||
webhook_secret='secret-1',
|
||||
webhook_uuid='uuid-1',
|
||||
)
|
||||
webhook2 = GitlabWebhook(
|
||||
project_id='project-2',
|
||||
group_id=None,
|
||||
user_id='user_1',
|
||||
webhook_exists=True,
|
||||
webhook_url='https://example.com/webhook',
|
||||
webhook_secret='secret-2',
|
||||
webhook_uuid='uuid-2',
|
||||
)
|
||||
webhook3 = GitlabWebhook(
|
||||
project_id=None,
|
||||
group_id='group-1',
|
||||
user_id='user_1',
|
||||
webhook_exists=False, # Already marked for reinstallation
|
||||
webhook_url='https://example.com/webhook',
|
||||
webhook_secret='secret-3',
|
||||
webhook_uuid='uuid-3',
|
||||
)
|
||||
|
||||
# Create webhook for user_2
|
||||
webhook4 = GitlabWebhook(
|
||||
project_id='project-3',
|
||||
group_id=None,
|
||||
user_id='user_2',
|
||||
webhook_exists=True,
|
||||
webhook_url='https://example.com/webhook',
|
||||
webhook_secret='secret-4',
|
||||
webhook_uuid='uuid-4',
|
||||
)
|
||||
|
||||
session.add_all([webhook1, webhook2, webhook3, webhook4])
|
||||
await session.commit()
|
||||
|
||||
# Refresh to get IDs (outside of begin() context)
|
||||
await session.refresh(webhook1)
|
||||
await session.refresh(webhook2)
|
||||
await session.refresh(webhook3)
|
||||
await session.refresh(webhook4)
|
||||
|
||||
return [webhook1, webhook2, webhook3, webhook4]
|
||||
|
||||
|
||||
class TestGetWebhookByResourceOnly:
|
||||
"""Test cases for get_webhook_by_resource_only method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_project_webhook_by_resource_only(
|
||||
self, webhook_store, async_session_maker, sample_webhooks
|
||||
):
|
||||
"""Test getting a project webhook by resource ID without user_id filter."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-1'
|
||||
|
||||
# Act
|
||||
webhook = await webhook_store.get_webhook_by_resource_only(
|
||||
resource_type, resource_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert webhook is not None
|
||||
assert webhook.project_id == resource_id
|
||||
assert webhook.user_id == 'user_1'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_group_webhook_by_resource_only(
|
||||
self, webhook_store, async_session_maker, sample_webhooks
|
||||
):
|
||||
"""Test getting a group webhook by resource ID without user_id filter."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.GROUP
|
||||
resource_id = 'group-1'
|
||||
|
||||
# Act
|
||||
webhook = await webhook_store.get_webhook_by_resource_only(
|
||||
resource_type, resource_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert webhook is not None
|
||||
assert webhook.group_id == resource_id
|
||||
assert webhook.user_id == 'user_1'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_webhook_by_resource_only_not_found(
|
||||
self, webhook_store, async_session_maker
|
||||
):
|
||||
"""Test getting a webhook that doesn't exist."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'non-existent-project'
|
||||
|
||||
# Act
|
||||
webhook = await webhook_store.get_webhook_by_resource_only(
|
||||
resource_type, resource_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert webhook is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_webhook_by_resource_only_organization_wide(
|
||||
self, webhook_store, async_session_maker, sample_webhooks
|
||||
):
|
||||
"""Test that webhook lookup works regardless of which user originally created it."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-3' # Created by user_2
|
||||
|
||||
# Act
|
||||
webhook = await webhook_store.get_webhook_by_resource_only(
|
||||
resource_type, resource_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert webhook is not None
|
||||
assert webhook.project_id == resource_id
|
||||
# Should find webhook even though it was created by a different user
|
||||
assert webhook.user_id == 'user_2'
|
||||
|
||||
|
||||
class TestResetWebhookForReinstallationByResource:
|
||||
"""Test cases for reset_webhook_for_reinstallation_by_resource method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_project_webhook_by_resource(
|
||||
self, webhook_store, async_session_maker, sample_webhooks
|
||||
):
|
||||
"""Test resetting a project webhook by resource without user_id filter."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-1'
|
||||
updating_user_id = 'user_2' # Different user can reset it
|
||||
|
||||
# Act
|
||||
result = await webhook_store.reset_webhook_for_reinstallation_by_resource(
|
||||
resource_type, resource_id, updating_user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
|
||||
# Verify webhook was reset
|
||||
async with async_session_maker() as session:
|
||||
result_query = await session.execute(
|
||||
select(GitlabWebhook).where(GitlabWebhook.project_id == resource_id)
|
||||
)
|
||||
webhook = result_query.scalars().first()
|
||||
assert webhook.webhook_exists is False
|
||||
assert webhook.webhook_uuid is None
|
||||
assert (
|
||||
webhook.user_id == updating_user_id
|
||||
) # Updated to track who modified it
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_group_webhook_by_resource(
|
||||
self, webhook_store, async_session_maker, sample_webhooks
|
||||
):
|
||||
"""Test resetting a group webhook by resource without user_id filter."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.GROUP
|
||||
resource_id = 'group-1'
|
||||
updating_user_id = 'user_2'
|
||||
|
||||
# Act
|
||||
result = await webhook_store.reset_webhook_for_reinstallation_by_resource(
|
||||
resource_type, resource_id, updating_user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
|
||||
# Verify webhook was reset
|
||||
async with async_session_maker() as session:
|
||||
result_query = await session.execute(
|
||||
select(GitlabWebhook).where(GitlabWebhook.group_id == resource_id)
|
||||
)
|
||||
webhook = result_query.scalars().first()
|
||||
assert webhook.webhook_exists is False
|
||||
assert webhook.webhook_uuid is None
|
||||
assert webhook.user_id == updating_user_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_webhook_by_resource_not_found(
|
||||
self, webhook_store, async_session_maker
|
||||
):
|
||||
"""Test resetting a webhook that doesn't exist."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'non-existent-project'
|
||||
updating_user_id = 'user_1'
|
||||
|
||||
# Act
|
||||
result = await webhook_store.reset_webhook_for_reinstallation_by_resource(
|
||||
resource_type, resource_id, updating_user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_webhook_by_resource_organization_wide(
|
||||
self, webhook_store, async_session_maker, sample_webhooks
|
||||
):
|
||||
"""Test that any user can reset a webhook regardless of original creator."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-3' # Created by user_2
|
||||
updating_user_id = 'user_1' # Different user resetting it
|
||||
|
||||
# Act
|
||||
result = await webhook_store.reset_webhook_for_reinstallation_by_resource(
|
||||
resource_type, resource_id, updating_user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
|
||||
# Verify webhook was reset and user_id updated
|
||||
async with async_session_maker() as session:
|
||||
result_query = await session.execute(
|
||||
select(GitlabWebhook).where(GitlabWebhook.project_id == resource_id)
|
||||
)
|
||||
webhook = result_query.scalars().first()
|
||||
assert webhook.webhook_exists is False
|
||||
assert webhook.user_id == updating_user_id
|
||||
|
||||
|
||||
class TestGetWebhooksByResources:
|
||||
"""Test cases for get_webhooks_by_resources method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_webhooks_by_resources_projects_only(
|
||||
self, webhook_store, async_session_maker, sample_webhooks
|
||||
):
|
||||
"""Test bulk fetching webhooks for multiple projects."""
|
||||
# Arrange
|
||||
project_ids = ['project-1', 'project-2', 'project-3']
|
||||
group_ids: list[str] = []
|
||||
|
||||
# Act
|
||||
project_map, group_map = await webhook_store.get_webhooks_by_resources(
|
||||
project_ids, group_ids
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert len(project_map) == 3
|
||||
assert 'project-1' in project_map
|
||||
assert 'project-2' in project_map
|
||||
assert 'project-3' in project_map
|
||||
assert len(group_map) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_webhooks_by_resources_groups_only(
|
||||
self, webhook_store, async_session_maker, sample_webhooks
|
||||
):
|
||||
"""Test bulk fetching webhooks for multiple groups."""
|
||||
# Arrange
|
||||
project_ids: list[str] = []
|
||||
group_ids = ['group-1']
|
||||
|
||||
# Act
|
||||
project_map, group_map = await webhook_store.get_webhooks_by_resources(
|
||||
project_ids, group_ids
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert len(project_map) == 0
|
||||
assert len(group_map) == 1
|
||||
assert 'group-1' in group_map
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_webhooks_by_resources_mixed(
|
||||
self, webhook_store, async_session_maker, sample_webhooks
|
||||
):
|
||||
"""Test bulk fetching webhooks for both projects and groups."""
|
||||
# Arrange
|
||||
project_ids = ['project-1', 'project-2']
|
||||
group_ids = ['group-1']
|
||||
|
||||
# Act
|
||||
project_map, group_map = await webhook_store.get_webhooks_by_resources(
|
||||
project_ids, group_ids
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert len(project_map) == 2
|
||||
assert len(group_map) == 1
|
||||
assert 'project-1' in project_map
|
||||
assert 'project-2' in project_map
|
||||
assert 'group-1' in group_map
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_webhooks_by_resources_empty_lists(
|
||||
self, webhook_store, async_session_maker
|
||||
):
|
||||
"""Test bulk fetching with empty ID lists."""
|
||||
# Arrange
|
||||
project_ids: list[str] = []
|
||||
group_ids: list[str] = []
|
||||
|
||||
# Act
|
||||
project_map, group_map = await webhook_store.get_webhooks_by_resources(
|
||||
project_ids, group_ids
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert len(project_map) == 0
|
||||
assert len(group_map) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_webhooks_by_resources_partial_matches(
|
||||
self, webhook_store, async_session_maker, sample_webhooks
|
||||
):
|
||||
"""Test bulk fetching when some IDs don't exist."""
|
||||
# Arrange
|
||||
project_ids = ['project-1', 'non-existent-project']
|
||||
group_ids = ['group-1', 'non-existent-group']
|
||||
|
||||
# Act
|
||||
project_map, group_map = await webhook_store.get_webhooks_by_resources(
|
||||
project_ids, group_ids
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert len(project_map) == 1
|
||||
assert 'project-1' in project_map
|
||||
assert 'non-existent-project' not in project_map
|
||||
assert len(group_map) == 1
|
||||
assert 'group-1' in group_map
|
||||
assert 'non-existent-group' not in group_map
|
||||
438
enterprise/tests/unit/sync/test_install_gitlab_webhooks.py
Normal file
438
enterprise/tests/unit/sync/test_install_gitlab_webhooks.py
Normal file
@@ -0,0 +1,438 @@
|
||||
"""Unit tests for install_gitlab_webhooks module."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from integrations.gitlab.webhook_installation import (
|
||||
BreakLoopException,
|
||||
install_webhook_on_resource,
|
||||
verify_webhook_conditions,
|
||||
)
|
||||
from integrations.types import GitLabResourceType
|
||||
from integrations.utils import GITLAB_WEBHOOK_URL
|
||||
from storage.gitlab_webhook import GitlabWebhook, WebhookStatus
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_gitlab_service():
|
||||
"""Create a mock GitLab service."""
|
||||
service = MagicMock()
|
||||
service.check_resource_exists = AsyncMock(return_value=(True, None))
|
||||
service.check_user_has_admin_access_to_resource = AsyncMock(
|
||||
return_value=(True, None)
|
||||
)
|
||||
service.check_webhook_exists_on_resource = AsyncMock(return_value=(False, None))
|
||||
service.install_webhook = AsyncMock(return_value=('webhook-id-123', None))
|
||||
return service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_webhook_store():
|
||||
"""Create a mock webhook store."""
|
||||
store = MagicMock()
|
||||
store.delete_webhook = AsyncMock()
|
||||
store.update_webhook = AsyncMock()
|
||||
return store
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_webhook():
|
||||
"""Create a sample webhook object."""
|
||||
webhook = MagicMock(spec=GitlabWebhook)
|
||||
webhook.user_id = 'test_user_id'
|
||||
webhook.webhook_exists = False
|
||||
webhook.webhook_uuid = None
|
||||
return webhook
|
||||
|
||||
|
||||
class TestVerifyWebhookConditions:
|
||||
"""Test cases for verify_webhook_conditions function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_conditions_all_pass(
|
||||
self, mock_gitlab_service, mock_webhook_store, sample_webhook
|
||||
):
|
||||
"""Test when all conditions are met for webhook installation."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-123'
|
||||
|
||||
# Act
|
||||
# Should not raise any exception
|
||||
await verify_webhook_conditions(
|
||||
gitlab_service=mock_gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=mock_webhook_store,
|
||||
webhook=sample_webhook,
|
||||
)
|
||||
|
||||
# Assert
|
||||
mock_gitlab_service.check_resource_exists.assert_called_once_with(
|
||||
resource_type, resource_id
|
||||
)
|
||||
mock_gitlab_service.check_user_has_admin_access_to_resource.assert_called_once_with(
|
||||
resource_type, resource_id
|
||||
)
|
||||
mock_gitlab_service.check_webhook_exists_on_resource.assert_called_once_with(
|
||||
resource_type, resource_id, GITLAB_WEBHOOK_URL
|
||||
)
|
||||
mock_webhook_store.delete_webhook.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_conditions_resource_does_not_exist(
|
||||
self, mock_gitlab_service, mock_webhook_store, sample_webhook
|
||||
):
|
||||
"""Test when resource does not exist."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-999'
|
||||
mock_gitlab_service.check_resource_exists = AsyncMock(
|
||||
return_value=(False, None)
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(BreakLoopException):
|
||||
await verify_webhook_conditions(
|
||||
gitlab_service=mock_gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=mock_webhook_store,
|
||||
webhook=sample_webhook,
|
||||
)
|
||||
|
||||
# Assert webhook is deleted
|
||||
mock_webhook_store.delete_webhook.assert_called_once_with(sample_webhook)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_conditions_rate_limited_on_resource_check(
|
||||
self, mock_gitlab_service, mock_webhook_store, sample_webhook
|
||||
):
|
||||
"""Test when rate limited during resource existence check."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-123'
|
||||
mock_gitlab_service.check_resource_exists = AsyncMock(
|
||||
return_value=(False, WebhookStatus.RATE_LIMITED)
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(BreakLoopException):
|
||||
await verify_webhook_conditions(
|
||||
gitlab_service=mock_gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=mock_webhook_store,
|
||||
webhook=sample_webhook,
|
||||
)
|
||||
|
||||
# Should not delete webhook on rate limit
|
||||
mock_webhook_store.delete_webhook.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_conditions_user_no_admin_access(
|
||||
self, mock_gitlab_service, mock_webhook_store, sample_webhook
|
||||
):
|
||||
"""Test when user does not have admin access."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.GROUP
|
||||
resource_id = 'group-456'
|
||||
mock_gitlab_service.check_user_has_admin_access_to_resource = AsyncMock(
|
||||
return_value=(False, None)
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(BreakLoopException):
|
||||
await verify_webhook_conditions(
|
||||
gitlab_service=mock_gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=mock_webhook_store,
|
||||
webhook=sample_webhook,
|
||||
)
|
||||
|
||||
# Assert webhook is deleted
|
||||
mock_webhook_store.delete_webhook.assert_called_once_with(sample_webhook)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_conditions_rate_limited_on_admin_check(
|
||||
self, mock_gitlab_service, mock_webhook_store, sample_webhook
|
||||
):
|
||||
"""Test when rate limited during admin access check."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-123'
|
||||
mock_gitlab_service.check_user_has_admin_access_to_resource = AsyncMock(
|
||||
return_value=(False, WebhookStatus.RATE_LIMITED)
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(BreakLoopException):
|
||||
await verify_webhook_conditions(
|
||||
gitlab_service=mock_gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=mock_webhook_store,
|
||||
webhook=sample_webhook,
|
||||
)
|
||||
|
||||
# Should not delete webhook on rate limit
|
||||
mock_webhook_store.delete_webhook.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_conditions_webhook_already_exists(
|
||||
self, mock_gitlab_service, mock_webhook_store, sample_webhook
|
||||
):
|
||||
"""Test when webhook already exists on resource."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-123'
|
||||
mock_gitlab_service.check_webhook_exists_on_resource = AsyncMock(
|
||||
return_value=(True, None)
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(BreakLoopException):
|
||||
await verify_webhook_conditions(
|
||||
gitlab_service=mock_gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=mock_webhook_store,
|
||||
webhook=sample_webhook,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_conditions_rate_limited_on_webhook_check(
|
||||
self, mock_gitlab_service, mock_webhook_store, sample_webhook
|
||||
):
|
||||
"""Test when rate limited during webhook existence check."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-123'
|
||||
mock_gitlab_service.check_webhook_exists_on_resource = AsyncMock(
|
||||
return_value=(False, WebhookStatus.RATE_LIMITED)
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(BreakLoopException):
|
||||
await verify_webhook_conditions(
|
||||
gitlab_service=mock_gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=mock_webhook_store,
|
||||
webhook=sample_webhook,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_conditions_updates_webhook_status_mismatch(
|
||||
self, mock_gitlab_service, mock_webhook_store, sample_webhook
|
||||
):
|
||||
"""Test that webhook status is updated when database and API don't match."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-123'
|
||||
sample_webhook.webhook_exists = True # DB says exists
|
||||
mock_gitlab_service.check_webhook_exists_on_resource = AsyncMock(
|
||||
return_value=(False, None) # API says doesn't exist
|
||||
)
|
||||
|
||||
# Act
|
||||
# Should not raise BreakLoopException when webhook doesn't exist (allows installation)
|
||||
await verify_webhook_conditions(
|
||||
gitlab_service=mock_gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=mock_webhook_store,
|
||||
webhook=sample_webhook,
|
||||
)
|
||||
|
||||
# Assert webhook status was updated to match API
|
||||
mock_webhook_store.update_webhook.assert_called_once_with(
|
||||
sample_webhook, {'webhook_exists': False}
|
||||
)
|
||||
|
||||
|
||||
class TestInstallWebhookOnResource:
|
||||
"""Test cases for install_webhook_on_resource function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_webhook_success(
|
||||
self, mock_gitlab_service, mock_webhook_store, sample_webhook
|
||||
):
|
||||
"""Test successful webhook installation."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-123'
|
||||
|
||||
# Act
|
||||
webhook_id, status = await install_webhook_on_resource(
|
||||
gitlab_service=mock_gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=mock_webhook_store,
|
||||
webhook=sample_webhook,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert webhook_id == 'webhook-id-123'
|
||||
assert status is None
|
||||
mock_gitlab_service.install_webhook.assert_called_once()
|
||||
mock_webhook_store.update_webhook.assert_called_once()
|
||||
# Verify update_webhook was called with correct fields (using keyword arguments)
|
||||
call_args = mock_webhook_store.update_webhook.call_args
|
||||
assert call_args[1]['webhook'] == sample_webhook
|
||||
update_fields = call_args[1]['update_fields']
|
||||
assert update_fields['webhook_exists'] is True
|
||||
assert update_fields['webhook_url'] == GITLAB_WEBHOOK_URL
|
||||
assert 'webhook_secret' in update_fields
|
||||
assert 'webhook_uuid' in update_fields
|
||||
assert 'scopes' in update_fields
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_webhook_group_resource(
|
||||
self, mock_gitlab_service, mock_webhook_store, sample_webhook
|
||||
):
|
||||
"""Test webhook installation for a group resource."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.GROUP
|
||||
resource_id = 'group-456'
|
||||
|
||||
# Act
|
||||
webhook_id, status = await install_webhook_on_resource(
|
||||
gitlab_service=mock_gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=mock_webhook_store,
|
||||
webhook=sample_webhook,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert webhook_id == 'webhook-id-123'
|
||||
# Verify install_webhook was called with GROUP type
|
||||
call_args = mock_gitlab_service.install_webhook.call_args
|
||||
assert call_args[1]['resource_type'] == resource_type
|
||||
assert call_args[1]['resource_id'] == resource_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_webhook_rate_limited(
|
||||
self, mock_gitlab_service, mock_webhook_store, sample_webhook
|
||||
):
|
||||
"""Test when installation is rate limited."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-123'
|
||||
mock_gitlab_service.install_webhook = AsyncMock(
|
||||
return_value=(None, WebhookStatus.RATE_LIMITED)
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(BreakLoopException):
|
||||
await install_webhook_on_resource(
|
||||
gitlab_service=mock_gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=mock_webhook_store,
|
||||
webhook=sample_webhook,
|
||||
)
|
||||
|
||||
# Should not update webhook on rate limit
|
||||
mock_webhook_store.update_webhook.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_webhook_installation_fails(
|
||||
self, mock_gitlab_service, mock_webhook_store, sample_webhook
|
||||
):
|
||||
"""Test when webhook installation fails."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-123'
|
||||
mock_gitlab_service.install_webhook = AsyncMock(return_value=(None, None))
|
||||
|
||||
# Act
|
||||
webhook_id, status = await install_webhook_on_resource(
|
||||
gitlab_service=mock_gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=mock_webhook_store,
|
||||
webhook=sample_webhook,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert webhook_id is None
|
||||
assert status is None
|
||||
# Should not update webhook when installation fails
|
||||
mock_webhook_store.update_webhook.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_webhook_generates_unique_secrets(
|
||||
self, mock_gitlab_service, mock_webhook_store, sample_webhook
|
||||
):
|
||||
"""Test that unique webhook secrets and UUIDs are generated."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-123'
|
||||
|
||||
# Act - First call
|
||||
webhook_id1, _ = await install_webhook_on_resource(
|
||||
gitlab_service=mock_gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=mock_webhook_store,
|
||||
webhook=sample_webhook,
|
||||
)
|
||||
|
||||
# Capture first call's values before resetting
|
||||
call1_secret = mock_webhook_store.update_webhook.call_args_list[0][1][
|
||||
'update_fields'
|
||||
]['webhook_secret']
|
||||
call1_uuid = mock_webhook_store.update_webhook.call_args_list[0][1][
|
||||
'update_fields'
|
||||
]['webhook_uuid']
|
||||
|
||||
# Reset mocks and call again
|
||||
mock_gitlab_service.install_webhook.reset_mock()
|
||||
mock_webhook_store.update_webhook.reset_mock()
|
||||
|
||||
# Act - Second call
|
||||
webhook_id2, _ = await install_webhook_on_resource(
|
||||
gitlab_service=mock_gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=mock_webhook_store,
|
||||
webhook=sample_webhook,
|
||||
)
|
||||
|
||||
# Capture second call's values
|
||||
call2_secret = mock_webhook_store.update_webhook.call_args_list[0][1][
|
||||
'update_fields'
|
||||
]['webhook_secret']
|
||||
call2_uuid = mock_webhook_store.update_webhook.call_args_list[0][1][
|
||||
'update_fields'
|
||||
]['webhook_uuid']
|
||||
|
||||
# Assert - Secrets and UUIDs should be different
|
||||
assert call1_secret != call2_secret
|
||||
assert call1_uuid != call2_uuid
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_webhook_uses_correct_webhook_name_and_url(
|
||||
self, mock_gitlab_service, mock_webhook_store, sample_webhook
|
||||
):
|
||||
"""Test that correct webhook name and URL are used."""
|
||||
# Arrange
|
||||
resource_type = GitLabResourceType.PROJECT
|
||||
resource_id = 'project-123'
|
||||
|
||||
# Act
|
||||
await install_webhook_on_resource(
|
||||
gitlab_service=mock_gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=mock_webhook_store,
|
||||
webhook=sample_webhook,
|
||||
)
|
||||
|
||||
# Assert
|
||||
call_args = mock_gitlab_service.install_webhook.call_args
|
||||
assert call_args[1]['webhook_name'] == 'OpenHands Resolver'
|
||||
assert call_args[1]['webhook_url'] == GITLAB_WEBHOOK_URL
|
||||
@@ -1,3 +1,5 @@
|
||||
import base64
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import jwt
|
||||
@@ -8,6 +10,7 @@ from pydantic import SecretStr
|
||||
from server.auth.auth_error import AuthError
|
||||
from server.auth.saas_user_auth import SaasUserAuth
|
||||
from server.routes.auth import (
|
||||
_extract_recaptcha_state,
|
||||
authenticate,
|
||||
keycloak_callback,
|
||||
keycloak_offline_callback,
|
||||
@@ -546,7 +549,6 @@ async def test_keycloak_callback_blocked_email_domain(mock_request):
|
||||
)
|
||||
mock_token_manager.disable_keycloak_user = AsyncMock()
|
||||
|
||||
mock_domain_blocker.is_active.return_value = True
|
||||
mock_domain_blocker.is_domain_blocked.return_value = True
|
||||
|
||||
# Act
|
||||
@@ -600,7 +602,6 @@ async def test_keycloak_callback_allowed_email_domain(mock_request):
|
||||
mock_token_manager.store_idp_tokens = AsyncMock()
|
||||
mock_token_manager.validate_offline_token = AsyncMock(return_value=True)
|
||||
|
||||
mock_domain_blocker.is_active.return_value = True
|
||||
mock_domain_blocker.is_domain_blocked.return_value = False
|
||||
|
||||
mock_verifier.is_active.return_value = True
|
||||
@@ -621,7 +622,7 @@ async def test_keycloak_callback_allowed_email_domain(mock_request):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keycloak_callback_domain_blocking_inactive(mock_request):
|
||||
"""Test keycloak_callback when domain blocking is not active."""
|
||||
"""Test keycloak_callback when email domain is not blocked."""
|
||||
# Arrange
|
||||
with (
|
||||
patch('server.routes.auth.token_manager') as mock_token_manager,
|
||||
@@ -654,7 +655,7 @@ async def test_keycloak_callback_domain_blocking_inactive(mock_request):
|
||||
mock_token_manager.store_idp_tokens = AsyncMock()
|
||||
mock_token_manager.validate_offline_token = AsyncMock(return_value=True)
|
||||
|
||||
mock_domain_blocker.is_active.return_value = False
|
||||
mock_domain_blocker.is_domain_blocked.return_value = False
|
||||
|
||||
mock_verifier.is_active.return_value = True
|
||||
mock_verifier.is_user_allowed.return_value = True
|
||||
@@ -666,7 +667,7 @@ async def test_keycloak_callback_domain_blocking_inactive(mock_request):
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, RedirectResponse)
|
||||
mock_domain_blocker.is_domain_blocked.assert_not_called()
|
||||
mock_domain_blocker.is_domain_blocked.assert_called_once_with('user@colsch.us')
|
||||
mock_token_manager.disable_keycloak_user.assert_not_called()
|
||||
|
||||
|
||||
@@ -705,8 +706,6 @@ async def test_keycloak_callback_missing_email(mock_request):
|
||||
mock_token_manager.store_idp_tokens = AsyncMock()
|
||||
mock_token_manager.validate_offline_token = AsyncMock(return_value=True)
|
||||
|
||||
mock_domain_blocker.is_active.return_value = True
|
||||
|
||||
mock_verifier.is_active.return_value = True
|
||||
mock_verifier.is_user_allowed.return_value = True
|
||||
|
||||
@@ -938,3 +937,764 @@ async def test_keycloak_callback_no_email_in_user_info(mock_request):
|
||||
assert result.status_code == 302
|
||||
# Should not check for duplicate when email is missing
|
||||
mock_token_manager.check_duplicate_base_email.assert_not_called()
|
||||
|
||||
|
||||
class TestExtractRecaptchaState:
|
||||
"""Tests for _extract_recaptcha_state() helper function."""
|
||||
|
||||
def test_should_extract_redirect_url_and_token_from_new_json_format(self):
|
||||
"""Test extraction from new base64-encoded JSON format."""
|
||||
# Arrange
|
||||
state_data = {
|
||||
'redirect_url': 'https://example.com',
|
||||
'recaptcha_token': 'test-token',
|
||||
}
|
||||
encoded_state = base64.urlsafe_b64encode(
|
||||
json.dumps(state_data).encode()
|
||||
).decode()
|
||||
|
||||
# Act
|
||||
redirect_url, token = _extract_recaptcha_state(encoded_state)
|
||||
|
||||
# Assert
|
||||
assert redirect_url == 'https://example.com'
|
||||
assert token == 'test-token'
|
||||
|
||||
def test_should_handle_old_format_plain_redirect_url(self):
|
||||
"""Test handling of old format (plain redirect URL string)."""
|
||||
# Arrange
|
||||
state = 'https://example.com'
|
||||
|
||||
# Act
|
||||
redirect_url, token = _extract_recaptcha_state(state)
|
||||
|
||||
# Assert
|
||||
assert redirect_url == 'https://example.com'
|
||||
assert token is None
|
||||
|
||||
def test_should_handle_none_state(self):
|
||||
"""Test handling of None state."""
|
||||
# Arrange
|
||||
state = None
|
||||
|
||||
# Act
|
||||
redirect_url, token = _extract_recaptcha_state(state)
|
||||
|
||||
# Assert
|
||||
assert redirect_url == ''
|
||||
assert token is None
|
||||
|
||||
def test_should_handle_invalid_base64_gracefully(self):
|
||||
"""Test handling of invalid base64/JSON (fallback to old format)."""
|
||||
# Arrange
|
||||
state = 'not-valid-base64!!!'
|
||||
|
||||
# Act
|
||||
redirect_url, token = _extract_recaptcha_state(state)
|
||||
|
||||
# Assert
|
||||
assert redirect_url == state
|
||||
assert token is None
|
||||
|
||||
def test_should_handle_missing_redirect_url_in_json(self):
|
||||
"""Test handling when redirect_url is missing in JSON."""
|
||||
# Arrange
|
||||
state_data = {'recaptcha_token': 'test-token'}
|
||||
encoded_state = base64.urlsafe_b64encode(
|
||||
json.dumps(state_data).encode()
|
||||
).decode()
|
||||
|
||||
# Act
|
||||
redirect_url, token = _extract_recaptcha_state(encoded_state)
|
||||
|
||||
# Assert
|
||||
assert redirect_url == ''
|
||||
assert token == 'test-token'
|
||||
|
||||
|
||||
class TestKeycloakCallbackRecaptcha:
|
||||
"""Tests for reCAPTCHA integration in keycloak_callback()."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_verify_recaptcha_and_allow_login_when_score_is_high(
|
||||
self, mock_request
|
||||
):
|
||||
"""Test that login proceeds when reCAPTCHA score is high."""
|
||||
# Arrange
|
||||
state_data = {
|
||||
'redirect_url': 'https://example.com',
|
||||
'recaptcha_token': 'test-token',
|
||||
}
|
||||
encoded_state = base64.urlsafe_b64encode(
|
||||
json.dumps(state_data).encode()
|
||||
).decode()
|
||||
|
||||
mock_assessment_result = MagicMock()
|
||||
mock_assessment_result.allowed = True
|
||||
mock_assessment_result.score = 0.9
|
||||
|
||||
with (
|
||||
patch('server.routes.auth.token_manager') as mock_token_manager,
|
||||
patch('server.routes.auth.user_verifier') as mock_verifier,
|
||||
patch('server.routes.auth.recaptcha_service') as mock_recaptcha_service,
|
||||
patch('server.routes.auth.RECAPTCHA_SITE_KEY', 'test-site-key'),
|
||||
patch('server.routes.auth.session_maker') as mock_session_maker,
|
||||
patch('server.routes.auth.domain_blocker') as mock_domain_blocker,
|
||||
patch('server.routes.auth.set_response_cookie'),
|
||||
patch('server.routes.auth.posthog'),
|
||||
patch('server.routes.email.verify_email', new_callable=AsyncMock),
|
||||
):
|
||||
mock_session = MagicMock()
|
||||
mock_session_maker.return_value.__enter__.return_value = mock_session
|
||||
mock_query = MagicMock()
|
||||
mock_session.query.return_value = mock_query
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_user_settings = MagicMock()
|
||||
mock_user_settings.accepted_tos = '2025-01-01'
|
||||
mock_query.first.return_value = mock_user_settings
|
||||
|
||||
mock_token_manager.get_keycloak_tokens = AsyncMock(
|
||||
return_value=('test_access_token', 'test_refresh_token')
|
||||
)
|
||||
mock_token_manager.get_user_info = AsyncMock(
|
||||
return_value={
|
||||
'sub': 'test_user_id',
|
||||
'preferred_username': 'test_user',
|
||||
'email': 'user@example.com',
|
||||
'identity_provider': 'github',
|
||||
'email_verified': True,
|
||||
}
|
||||
)
|
||||
mock_token_manager.store_idp_tokens = AsyncMock()
|
||||
mock_token_manager.validate_offline_token = AsyncMock(return_value=True)
|
||||
mock_token_manager.check_duplicate_base_email = AsyncMock(
|
||||
return_value=False
|
||||
)
|
||||
|
||||
mock_verifier.is_active.return_value = True
|
||||
mock_verifier.is_user_allowed.return_value = True
|
||||
|
||||
mock_domain_blocker.is_domain_blocked.return_value = False
|
||||
|
||||
# Patch the module-level recaptcha_service instance
|
||||
mock_recaptcha_service.create_assessment.return_value = (
|
||||
mock_assessment_result
|
||||
)
|
||||
|
||||
# Act
|
||||
result = await keycloak_callback(
|
||||
code='test_code', state=encoded_state, request=mock_request
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, RedirectResponse)
|
||||
assert result.status_code == 302
|
||||
mock_recaptcha_service.create_assessment.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_block_login_when_recaptcha_score_is_low(self, mock_request):
|
||||
"""Test that login is blocked and redirected when reCAPTCHA score is low."""
|
||||
# Arrange
|
||||
state_data = {
|
||||
'redirect_url': 'https://example.com',
|
||||
'recaptcha_token': 'test-token',
|
||||
}
|
||||
encoded_state = base64.urlsafe_b64encode(
|
||||
json.dumps(state_data).encode()
|
||||
).decode()
|
||||
|
||||
mock_assessment_result = MagicMock()
|
||||
mock_assessment_result.allowed = False
|
||||
mock_assessment_result.score = 0.2
|
||||
|
||||
with (
|
||||
patch('server.routes.auth.token_manager') as mock_token_manager,
|
||||
patch('server.routes.auth.recaptcha_service') as mock_recaptcha_service,
|
||||
patch('server.routes.auth.RECAPTCHA_SITE_KEY', 'test-site-key'),
|
||||
patch('server.routes.auth.domain_blocker') as mock_domain_blocker,
|
||||
):
|
||||
mock_token_manager.get_keycloak_tokens = AsyncMock(
|
||||
return_value=('test_access_token', 'test_refresh_token')
|
||||
)
|
||||
mock_token_manager.get_user_info = AsyncMock(
|
||||
return_value={
|
||||
'sub': 'test_user_id',
|
||||
'preferred_username': 'test_user',
|
||||
'email': 'user@example.com',
|
||||
}
|
||||
)
|
||||
mock_token_manager.check_duplicate_base_email = AsyncMock(
|
||||
return_value=False
|
||||
)
|
||||
|
||||
mock_domain_blocker.is_domain_blocked.return_value = False
|
||||
|
||||
# Patch the module-level recaptcha_service instance
|
||||
mock_recaptcha_service.create_assessment.return_value = (
|
||||
mock_assessment_result
|
||||
)
|
||||
|
||||
# Act
|
||||
result = await keycloak_callback(
|
||||
code='test_code', state=encoded_state, request=mock_request
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, RedirectResponse)
|
||||
assert result.status_code == 302
|
||||
assert 'recaptcha_blocked=true' in result.headers['location']
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_extract_ip_from_x_forwarded_for_header(self, mock_request):
|
||||
"""Test that IP is extracted from X-Forwarded-For header when present."""
|
||||
# Arrange
|
||||
state_data = {
|
||||
'redirect_url': 'https://example.com',
|
||||
'recaptcha_token': 'test-token',
|
||||
}
|
||||
encoded_state = base64.urlsafe_b64encode(
|
||||
json.dumps(state_data).encode()
|
||||
).decode()
|
||||
|
||||
mock_request.headers = {'X-Forwarded-For': '192.168.1.1, 10.0.0.1'}
|
||||
mock_request.client = None
|
||||
|
||||
mock_assessment_result = MagicMock()
|
||||
mock_assessment_result.allowed = True
|
||||
|
||||
with (
|
||||
patch('server.routes.auth.token_manager') as mock_token_manager,
|
||||
patch('server.routes.auth.recaptcha_service') as mock_recaptcha_service,
|
||||
patch('server.routes.auth.RECAPTCHA_SITE_KEY', 'test-site-key'),
|
||||
patch('server.routes.auth.domain_blocker') as mock_domain_blocker,
|
||||
patch('server.routes.auth.user_verifier') as mock_verifier,
|
||||
patch('server.routes.auth.session_maker') as mock_session_maker,
|
||||
patch('server.routes.auth.set_response_cookie'),
|
||||
patch('server.routes.auth.posthog'),
|
||||
patch('server.routes.email.verify_email', new_callable=AsyncMock),
|
||||
):
|
||||
mock_session = MagicMock()
|
||||
mock_session_maker.return_value.__enter__.return_value = mock_session
|
||||
mock_query = MagicMock()
|
||||
mock_session.query.return_value = mock_query
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_user_settings = MagicMock()
|
||||
mock_user_settings.accepted_tos = '2025-01-01'
|
||||
mock_query.first.return_value = mock_user_settings
|
||||
|
||||
mock_token_manager.get_keycloak_tokens = AsyncMock(
|
||||
return_value=('test_access_token', 'test_refresh_token')
|
||||
)
|
||||
mock_token_manager.get_user_info = AsyncMock(
|
||||
return_value={
|
||||
'sub': 'test_user_id',
|
||||
'preferred_username': 'test_user',
|
||||
'email': 'user@example.com',
|
||||
'identity_provider': 'github',
|
||||
'email_verified': True,
|
||||
}
|
||||
)
|
||||
mock_token_manager.store_idp_tokens = AsyncMock()
|
||||
mock_token_manager.validate_offline_token = AsyncMock(return_value=True)
|
||||
mock_token_manager.check_duplicate_base_email = AsyncMock(
|
||||
return_value=False
|
||||
)
|
||||
|
||||
mock_verifier.is_active.return_value = True
|
||||
mock_verifier.is_user_allowed.return_value = True
|
||||
|
||||
mock_domain_blocker.is_domain_blocked.return_value = False
|
||||
|
||||
# Patch the module-level recaptcha_service instance
|
||||
mock_recaptcha_service.create_assessment.return_value = (
|
||||
mock_assessment_result
|
||||
)
|
||||
|
||||
# Act
|
||||
await keycloak_callback(
|
||||
code='test_code', state=encoded_state, request=mock_request
|
||||
)
|
||||
|
||||
# Assert
|
||||
call_args = mock_recaptcha_service.create_assessment.call_args
|
||||
assert call_args[1]['user_ip'] == '192.168.1.1'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_use_client_host_when_x_forwarded_for_missing(
|
||||
self, mock_request
|
||||
):
|
||||
"""Test that client.host is used when X-Forwarded-For is missing."""
|
||||
# Arrange
|
||||
state_data = {
|
||||
'redirect_url': 'https://example.com',
|
||||
'recaptcha_token': 'test-token',
|
||||
}
|
||||
encoded_state = base64.urlsafe_b64encode(
|
||||
json.dumps(state_data).encode()
|
||||
).decode()
|
||||
|
||||
mock_request.headers = {}
|
||||
mock_request.client = MagicMock()
|
||||
mock_request.client.host = '192.168.1.2'
|
||||
|
||||
mock_assessment_result = MagicMock()
|
||||
mock_assessment_result.allowed = True
|
||||
|
||||
with (
|
||||
patch('server.routes.auth.token_manager') as mock_token_manager,
|
||||
patch('server.routes.auth.recaptcha_service') as mock_recaptcha_service,
|
||||
patch('server.routes.auth.RECAPTCHA_SITE_KEY', 'test-site-key'),
|
||||
patch('server.routes.auth.domain_blocker') as mock_domain_blocker,
|
||||
patch('server.routes.auth.user_verifier') as mock_verifier,
|
||||
patch('server.routes.auth.session_maker') as mock_session_maker,
|
||||
patch('server.routes.auth.set_response_cookie'),
|
||||
patch('server.routes.auth.posthog'),
|
||||
patch('server.routes.email.verify_email', new_callable=AsyncMock),
|
||||
):
|
||||
mock_session = MagicMock()
|
||||
mock_session_maker.return_value.__enter__.return_value = mock_session
|
||||
mock_query = MagicMock()
|
||||
mock_session.query.return_value = mock_query
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_user_settings = MagicMock()
|
||||
mock_user_settings.accepted_tos = '2025-01-01'
|
||||
mock_query.first.return_value = mock_user_settings
|
||||
|
||||
mock_token_manager.get_keycloak_tokens = AsyncMock(
|
||||
return_value=('test_access_token', 'test_refresh_token')
|
||||
)
|
||||
mock_token_manager.get_user_info = AsyncMock(
|
||||
return_value={
|
||||
'sub': 'test_user_id',
|
||||
'preferred_username': 'test_user',
|
||||
'email': 'user@example.com',
|
||||
'identity_provider': 'github',
|
||||
'email_verified': True,
|
||||
}
|
||||
)
|
||||
mock_token_manager.store_idp_tokens = AsyncMock()
|
||||
mock_token_manager.validate_offline_token = AsyncMock(return_value=True)
|
||||
mock_token_manager.check_duplicate_base_email = AsyncMock(
|
||||
return_value=False
|
||||
)
|
||||
|
||||
mock_verifier.is_active.return_value = True
|
||||
mock_verifier.is_user_allowed.return_value = True
|
||||
|
||||
mock_domain_blocker.is_domain_blocked.return_value = False
|
||||
|
||||
# Patch the module-level recaptcha_service instance
|
||||
mock_recaptcha_service.create_assessment.return_value = (
|
||||
mock_assessment_result
|
||||
)
|
||||
|
||||
# Act
|
||||
await keycloak_callback(
|
||||
code='test_code', state=encoded_state, request=mock_request
|
||||
)
|
||||
|
||||
# Assert
|
||||
call_args = mock_recaptcha_service.create_assessment.call_args
|
||||
assert call_args[1]['user_ip'] == '192.168.1.2'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_use_unknown_ip_when_client_is_none(self, mock_request):
|
||||
"""Test that 'unknown' IP is used when client is None."""
|
||||
# Arrange
|
||||
state_data = {
|
||||
'redirect_url': 'https://example.com',
|
||||
'recaptcha_token': 'test-token',
|
||||
}
|
||||
encoded_state = base64.urlsafe_b64encode(
|
||||
json.dumps(state_data).encode()
|
||||
).decode()
|
||||
|
||||
mock_request.headers = {}
|
||||
mock_request.client = None
|
||||
|
||||
mock_assessment_result = MagicMock()
|
||||
mock_assessment_result.allowed = True
|
||||
|
||||
with (
|
||||
patch('server.routes.auth.token_manager') as mock_token_manager,
|
||||
patch('server.routes.auth.recaptcha_service') as mock_recaptcha_service,
|
||||
patch('server.routes.auth.RECAPTCHA_SITE_KEY', 'test-site-key'),
|
||||
patch('server.routes.auth.domain_blocker') as mock_domain_blocker,
|
||||
patch('server.routes.auth.user_verifier') as mock_verifier,
|
||||
patch('server.routes.auth.session_maker') as mock_session_maker,
|
||||
patch('server.routes.auth.set_response_cookie'),
|
||||
patch('server.routes.auth.posthog'),
|
||||
patch('server.routes.email.verify_email', new_callable=AsyncMock),
|
||||
):
|
||||
mock_session = MagicMock()
|
||||
mock_session_maker.return_value.__enter__.return_value = mock_session
|
||||
mock_query = MagicMock()
|
||||
mock_session.query.return_value = mock_query
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_user_settings = MagicMock()
|
||||
mock_user_settings.accepted_tos = '2025-01-01'
|
||||
mock_query.first.return_value = mock_user_settings
|
||||
|
||||
mock_token_manager.get_keycloak_tokens = AsyncMock(
|
||||
return_value=('test_access_token', 'test_refresh_token')
|
||||
)
|
||||
mock_token_manager.get_user_info = AsyncMock(
|
||||
return_value={
|
||||
'sub': 'test_user_id',
|
||||
'preferred_username': 'test_user',
|
||||
'email': 'user@example.com',
|
||||
'identity_provider': 'github',
|
||||
'email_verified': True,
|
||||
}
|
||||
)
|
||||
mock_token_manager.store_idp_tokens = AsyncMock()
|
||||
mock_token_manager.validate_offline_token = AsyncMock(return_value=True)
|
||||
mock_token_manager.check_duplicate_base_email = AsyncMock(
|
||||
return_value=False
|
||||
)
|
||||
|
||||
mock_verifier.is_active.return_value = True
|
||||
mock_verifier.is_user_allowed.return_value = True
|
||||
|
||||
mock_domain_blocker.is_domain_blocked.return_value = False
|
||||
|
||||
# Patch the module-level recaptcha_service instance
|
||||
mock_recaptcha_service.create_assessment.return_value = (
|
||||
mock_assessment_result
|
||||
)
|
||||
|
||||
# Act
|
||||
await keycloak_callback(
|
||||
code='test_code', state=encoded_state, request=mock_request
|
||||
)
|
||||
|
||||
# Assert
|
||||
call_args = mock_recaptcha_service.create_assessment.call_args
|
||||
assert call_args[1]['user_ip'] == 'unknown'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_include_email_in_assessment_when_available(
|
||||
self, mock_request
|
||||
):
|
||||
"""Test that email is included in assessment when available."""
|
||||
# Arrange
|
||||
state_data = {
|
||||
'redirect_url': 'https://example.com',
|
||||
'recaptcha_token': 'test-token',
|
||||
}
|
||||
encoded_state = base64.urlsafe_b64encode(
|
||||
json.dumps(state_data).encode()
|
||||
).decode()
|
||||
|
||||
mock_assessment_result = MagicMock()
|
||||
mock_assessment_result.allowed = True
|
||||
|
||||
with (
|
||||
patch('server.routes.auth.token_manager') as mock_token_manager,
|
||||
patch('server.routes.auth.recaptcha_service') as mock_recaptcha_service,
|
||||
patch('server.routes.auth.RECAPTCHA_SITE_KEY', 'test-site-key'),
|
||||
patch('server.routes.auth.domain_blocker') as mock_domain_blocker,
|
||||
patch('server.routes.auth.user_verifier') as mock_verifier,
|
||||
patch('server.routes.auth.session_maker') as mock_session_maker,
|
||||
patch('server.routes.auth.set_response_cookie'),
|
||||
patch('server.routes.auth.posthog'),
|
||||
patch('server.routes.email.verify_email', new_callable=AsyncMock),
|
||||
):
|
||||
mock_session = MagicMock()
|
||||
mock_session_maker.return_value.__enter__.return_value = mock_session
|
||||
mock_query = MagicMock()
|
||||
mock_session.query.return_value = mock_query
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_user_settings = MagicMock()
|
||||
mock_user_settings.accepted_tos = '2025-01-01'
|
||||
mock_query.first.return_value = mock_user_settings
|
||||
|
||||
mock_token_manager.get_keycloak_tokens = AsyncMock(
|
||||
return_value=('test_access_token', 'test_refresh_token')
|
||||
)
|
||||
mock_token_manager.get_user_info = AsyncMock(
|
||||
return_value={
|
||||
'sub': 'test_user_id',
|
||||
'preferred_username': 'test_user',
|
||||
'email': 'user@example.com',
|
||||
'identity_provider': 'github',
|
||||
'email_verified': True,
|
||||
}
|
||||
)
|
||||
mock_token_manager.store_idp_tokens = AsyncMock()
|
||||
mock_token_manager.validate_offline_token = AsyncMock(return_value=True)
|
||||
mock_token_manager.check_duplicate_base_email = AsyncMock(
|
||||
return_value=False
|
||||
)
|
||||
|
||||
mock_verifier.is_active.return_value = True
|
||||
mock_verifier.is_user_allowed.return_value = True
|
||||
|
||||
mock_domain_blocker.is_domain_blocked.return_value = False
|
||||
|
||||
# Patch the module-level recaptcha_service instance
|
||||
mock_recaptcha_service.create_assessment.return_value = (
|
||||
mock_assessment_result
|
||||
)
|
||||
|
||||
# Act
|
||||
await keycloak_callback(
|
||||
code='test_code', state=encoded_state, request=mock_request
|
||||
)
|
||||
|
||||
# Assert
|
||||
call_args = mock_recaptcha_service.create_assessment.call_args
|
||||
assert call_args[1]['email'] == 'user@example.com'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_skip_recaptcha_when_site_key_not_configured(
|
||||
self, mock_request
|
||||
):
|
||||
"""Test that reCAPTCHA is skipped when RECAPTCHA_SITE_KEY is not configured."""
|
||||
# Arrange
|
||||
state_data = {
|
||||
'redirect_url': 'https://example.com',
|
||||
'recaptcha_token': 'test-token',
|
||||
}
|
||||
encoded_state = base64.urlsafe_b64encode(
|
||||
json.dumps(state_data).encode()
|
||||
).decode()
|
||||
|
||||
with (
|
||||
patch('server.routes.auth.token_manager') as mock_token_manager,
|
||||
patch('server.routes.auth.recaptcha_service') as mock_recaptcha_service,
|
||||
patch('server.routes.auth.RECAPTCHA_SITE_KEY', ''),
|
||||
patch('server.routes.auth.user_verifier') as mock_verifier,
|
||||
patch('server.routes.auth.session_maker') as mock_session_maker,
|
||||
patch('server.routes.auth.domain_blocker') as mock_domain_blocker,
|
||||
patch('server.routes.auth.set_response_cookie'),
|
||||
patch('server.routes.auth.posthog'),
|
||||
patch('server.routes.email.verify_email', new_callable=AsyncMock),
|
||||
):
|
||||
mock_session = MagicMock()
|
||||
mock_session_maker.return_value.__enter__.return_value = mock_session
|
||||
mock_query = MagicMock()
|
||||
mock_session.query.return_value = mock_query
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_user_settings = MagicMock()
|
||||
mock_user_settings.accepted_tos = '2025-01-01'
|
||||
mock_query.first.return_value = mock_user_settings
|
||||
|
||||
mock_token_manager.get_keycloak_tokens = AsyncMock(
|
||||
return_value=('test_access_token', 'test_refresh_token')
|
||||
)
|
||||
mock_token_manager.get_user_info = AsyncMock(
|
||||
return_value={
|
||||
'sub': 'test_user_id',
|
||||
'preferred_username': 'test_user',
|
||||
'email': 'user@example.com',
|
||||
'identity_provider': 'github',
|
||||
'email_verified': True,
|
||||
}
|
||||
)
|
||||
mock_token_manager.store_idp_tokens = AsyncMock()
|
||||
mock_token_manager.validate_offline_token = AsyncMock(return_value=True)
|
||||
mock_token_manager.check_duplicate_base_email = AsyncMock(
|
||||
return_value=False
|
||||
)
|
||||
|
||||
mock_verifier.is_active.return_value = True
|
||||
mock_verifier.is_user_allowed.return_value = True
|
||||
|
||||
mock_domain_blocker.is_domain_blocked.return_value = False
|
||||
|
||||
# Act
|
||||
await keycloak_callback(
|
||||
code='test_code', state=encoded_state, request=mock_request
|
||||
)
|
||||
|
||||
# Assert
|
||||
mock_recaptcha_service.create_assessment.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_skip_recaptcha_when_token_is_missing(self, mock_request):
|
||||
"""Test that reCAPTCHA is skipped when token is missing from state."""
|
||||
# Arrange
|
||||
state = 'https://example.com' # Old format without token
|
||||
|
||||
with (
|
||||
patch('server.routes.auth.token_manager') as mock_token_manager,
|
||||
patch('server.routes.auth.recaptcha_service') as mock_recaptcha_service,
|
||||
patch('server.routes.auth.RECAPTCHA_SITE_KEY', 'test-site-key'),
|
||||
patch('server.routes.auth.user_verifier') as mock_verifier,
|
||||
patch('server.routes.auth.session_maker') as mock_session_maker,
|
||||
patch('server.routes.auth.domain_blocker') as mock_domain_blocker,
|
||||
patch('server.routes.auth.set_response_cookie'),
|
||||
patch('server.routes.auth.posthog'),
|
||||
patch('server.routes.email.verify_email', new_callable=AsyncMock),
|
||||
):
|
||||
mock_session = MagicMock()
|
||||
mock_session_maker.return_value.__enter__.return_value = mock_session
|
||||
mock_query = MagicMock()
|
||||
mock_session.query.return_value = mock_query
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_user_settings = MagicMock()
|
||||
mock_user_settings.accepted_tos = '2025-01-01'
|
||||
mock_query.first.return_value = mock_user_settings
|
||||
|
||||
mock_token_manager.get_keycloak_tokens = AsyncMock(
|
||||
return_value=('test_access_token', 'test_refresh_token')
|
||||
)
|
||||
mock_token_manager.get_user_info = AsyncMock(
|
||||
return_value={
|
||||
'sub': 'test_user_id',
|
||||
'preferred_username': 'test_user',
|
||||
'email': 'user@example.com',
|
||||
'identity_provider': 'github',
|
||||
'email_verified': True,
|
||||
}
|
||||
)
|
||||
mock_token_manager.store_idp_tokens = AsyncMock()
|
||||
mock_token_manager.validate_offline_token = AsyncMock(return_value=True)
|
||||
mock_token_manager.check_duplicate_base_email = AsyncMock(
|
||||
return_value=False
|
||||
)
|
||||
|
||||
mock_verifier.is_active.return_value = True
|
||||
mock_verifier.is_user_allowed.return_value = True
|
||||
|
||||
mock_domain_blocker.is_domain_blocked.return_value = False
|
||||
|
||||
# Act
|
||||
await keycloak_callback(code='test_code', state=state, request=mock_request)
|
||||
|
||||
# Assert
|
||||
mock_recaptcha_service.create_assessment.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_fail_open_when_recaptcha_service_throws_exception(
|
||||
self, mock_request
|
||||
):
|
||||
"""Test that login proceeds (fail open) when reCAPTCHA service throws exception."""
|
||||
# Arrange
|
||||
state_data = {
|
||||
'redirect_url': 'https://example.com',
|
||||
'recaptcha_token': 'test-token',
|
||||
}
|
||||
encoded_state = base64.urlsafe_b64encode(
|
||||
json.dumps(state_data).encode()
|
||||
).decode()
|
||||
|
||||
with (
|
||||
patch('server.routes.auth.token_manager') as mock_token_manager,
|
||||
patch('server.routes.auth.recaptcha_service') as mock_recaptcha_service,
|
||||
patch('server.routes.auth.RECAPTCHA_SITE_KEY', 'test-site-key'),
|
||||
patch('server.routes.auth.user_verifier') as mock_verifier,
|
||||
patch('server.routes.auth.session_maker') as mock_session_maker,
|
||||
patch('server.routes.auth.domain_blocker') as mock_domain_blocker,
|
||||
patch('server.routes.auth.set_response_cookie'),
|
||||
patch('server.routes.auth.posthog'),
|
||||
patch('server.routes.auth.logger') as mock_logger,
|
||||
):
|
||||
mock_session = MagicMock()
|
||||
mock_session_maker.return_value.__enter__.return_value = mock_session
|
||||
mock_query = MagicMock()
|
||||
mock_session.query.return_value = mock_query
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_user_settings = MagicMock()
|
||||
mock_user_settings.accepted_tos = '2025-01-01'
|
||||
mock_query.first.return_value = mock_user_settings
|
||||
|
||||
mock_token_manager.get_keycloak_tokens = AsyncMock(
|
||||
return_value=('test_access_token', 'test_refresh_token')
|
||||
)
|
||||
mock_token_manager.get_user_info = AsyncMock(
|
||||
return_value={
|
||||
'sub': 'test_user_id',
|
||||
'preferred_username': 'test_user',
|
||||
'email': 'user@example.com',
|
||||
'identity_provider': 'github',
|
||||
'email_verified': True,
|
||||
}
|
||||
)
|
||||
mock_token_manager.store_idp_tokens = AsyncMock()
|
||||
mock_token_manager.validate_offline_token = AsyncMock(return_value=True)
|
||||
mock_token_manager.check_duplicate_base_email = AsyncMock(
|
||||
return_value=False
|
||||
)
|
||||
|
||||
mock_verifier.is_active.return_value = True
|
||||
mock_verifier.is_user_allowed.return_value = True
|
||||
|
||||
mock_domain_blocker.is_domain_blocked.return_value = False
|
||||
|
||||
mock_recaptcha_service.create_assessment.side_effect = Exception(
|
||||
'Service error'
|
||||
)
|
||||
|
||||
# Act
|
||||
result = await keycloak_callback(
|
||||
code='test_code', state=encoded_state, request=mock_request
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, RedirectResponse)
|
||||
# Check that reCAPTCHA error was logged (may be called multiple times due to other errors)
|
||||
recaptcha_error_calls = [
|
||||
call
|
||||
for call in mock_logger.exception.call_args_list
|
||||
if 'reCAPTCHA verification error' in str(call)
|
||||
]
|
||||
assert len(recaptcha_error_calls) > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_log_warning_when_recaptcha_blocks_user(self, mock_request):
|
||||
"""Test that warning is logged when reCAPTCHA blocks user."""
|
||||
# Arrange
|
||||
state_data = {
|
||||
'redirect_url': 'https://example.com',
|
||||
'recaptcha_token': 'test-token',
|
||||
}
|
||||
encoded_state = base64.urlsafe_b64encode(
|
||||
json.dumps(state_data).encode()
|
||||
).decode()
|
||||
|
||||
mock_assessment_result = MagicMock()
|
||||
mock_assessment_result.allowed = False
|
||||
mock_assessment_result.score = 0.2
|
||||
|
||||
with (
|
||||
patch('server.routes.auth.token_manager') as mock_token_manager,
|
||||
patch('server.routes.auth.recaptcha_service') as mock_recaptcha_service,
|
||||
patch('server.routes.auth.RECAPTCHA_SITE_KEY', 'test-site-key'),
|
||||
patch('server.routes.auth.domain_blocker') as mock_domain_blocker,
|
||||
patch('server.routes.auth.logger') as mock_logger,
|
||||
patch('server.routes.email.verify_email', new_callable=AsyncMock),
|
||||
):
|
||||
mock_token_manager.get_keycloak_tokens = AsyncMock(
|
||||
return_value=('test_access_token', 'test_refresh_token')
|
||||
)
|
||||
mock_token_manager.get_user_info = AsyncMock(
|
||||
return_value={
|
||||
'sub': 'test_user_id',
|
||||
'preferred_username': 'test_user',
|
||||
'email': 'user@example.com',
|
||||
}
|
||||
)
|
||||
mock_token_manager.check_duplicate_base_email = AsyncMock(
|
||||
return_value=False
|
||||
)
|
||||
|
||||
mock_domain_blocker.is_domain_blocked.return_value = False
|
||||
|
||||
# Patch the module-level recaptcha_service instance
|
||||
mock_recaptcha_service.create_assessment.return_value = (
|
||||
mock_assessment_result
|
||||
)
|
||||
|
||||
# Act
|
||||
await keycloak_callback(
|
||||
code='test_code', state=encoded_state, request=mock_request
|
||||
)
|
||||
|
||||
# Assert
|
||||
mock_logger.warning.assert_called_once()
|
||||
call_kwargs = mock_logger.warning.call_args
|
||||
assert call_kwargs[0][0] == 'recaptcha_blocked_at_callback'
|
||||
assert call_kwargs[1]['extra']['score'] == 0.2
|
||||
assert call_kwargs[1]['extra']['user_id'] == 'test_user_id'
|
||||
|
||||
@@ -1,33 +1,21 @@
|
||||
"""Unit tests for DomainBlocker class."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from server.auth.domain_blocker import DomainBlocker
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def domain_blocker():
|
||||
"""Create a DomainBlocker instance for testing."""
|
||||
return DomainBlocker()
|
||||
def mock_store():
|
||||
"""Create a mock BlockedEmailDomainStore for testing."""
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'blocked_domains,expected',
|
||||
[
|
||||
(['colsch.us', 'other-domain.com'], True),
|
||||
(['example.com'], True),
|
||||
([], False),
|
||||
],
|
||||
)
|
||||
def test_is_active(domain_blocker, blocked_domains, expected):
|
||||
"""Test that is_active returns correct value based on blocked domains configuration."""
|
||||
# Arrange
|
||||
domain_blocker.blocked_domains = blocked_domains
|
||||
|
||||
# Act
|
||||
result = domain_blocker.is_active()
|
||||
|
||||
# Assert
|
||||
assert result == expected
|
||||
@pytest.fixture
|
||||
def domain_blocker(mock_store):
|
||||
"""Create a DomainBlocker instance for testing with a mocked store."""
|
||||
return DomainBlocker(store=mock_store)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -69,94 +57,104 @@ def test_extract_domain_invalid_emails(domain_blocker, email, expected):
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_is_domain_blocked_when_inactive(domain_blocker):
|
||||
"""Test that is_domain_blocked returns False when blocking is not active."""
|
||||
# Arrange
|
||||
domain_blocker.blocked_domains = []
|
||||
|
||||
# Act
|
||||
result = domain_blocker.is_domain_blocked('user@colsch.us')
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_is_domain_blocked_with_none_email(domain_blocker):
|
||||
def test_is_domain_blocked_with_none_email(domain_blocker, mock_store):
|
||||
"""Test that is_domain_blocked returns False when email is None."""
|
||||
# Arrange
|
||||
domain_blocker.blocked_domains = ['colsch.us']
|
||||
mock_store.is_domain_blocked.return_value = True
|
||||
|
||||
# Act
|
||||
result = domain_blocker.is_domain_blocked(None)
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
mock_store.is_domain_blocked.assert_not_called()
|
||||
|
||||
|
||||
def test_is_domain_blocked_with_empty_email(domain_blocker):
|
||||
def test_is_domain_blocked_with_empty_email(domain_blocker, mock_store):
|
||||
"""Test that is_domain_blocked returns False when email is empty."""
|
||||
# Arrange
|
||||
domain_blocker.blocked_domains = ['colsch.us']
|
||||
mock_store.is_domain_blocked.return_value = True
|
||||
|
||||
# Act
|
||||
result = domain_blocker.is_domain_blocked('')
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
mock_store.is_domain_blocked.assert_not_called()
|
||||
|
||||
|
||||
def test_is_domain_blocked_with_invalid_email(domain_blocker):
|
||||
def test_is_domain_blocked_with_invalid_email(domain_blocker, mock_store):
|
||||
"""Test that is_domain_blocked returns False when email format is invalid."""
|
||||
# Arrange
|
||||
domain_blocker.blocked_domains = ['colsch.us']
|
||||
mock_store.is_domain_blocked.return_value = True
|
||||
|
||||
# Act
|
||||
result = domain_blocker.is_domain_blocked('invalid-email')
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
mock_store.is_domain_blocked.assert_not_called()
|
||||
|
||||
|
||||
def test_is_domain_blocked_domain_not_blocked(domain_blocker):
|
||||
"""Test that is_domain_blocked returns False when domain is not in blocked list."""
|
||||
def test_is_domain_blocked_domain_not_blocked(domain_blocker, mock_store):
|
||||
"""Test that is_domain_blocked returns False when domain is not blocked."""
|
||||
# Arrange
|
||||
domain_blocker.blocked_domains = ['colsch.us', 'other-domain.com']
|
||||
mock_store.is_domain_blocked.return_value = False
|
||||
|
||||
# Act
|
||||
result = domain_blocker.is_domain_blocked('user@example.com')
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
mock_store.is_domain_blocked.assert_called_once_with('example.com')
|
||||
|
||||
|
||||
def test_is_domain_blocked_domain_blocked(domain_blocker):
|
||||
"""Test that is_domain_blocked returns True when domain is in blocked list."""
|
||||
def test_is_domain_blocked_domain_blocked(domain_blocker, mock_store):
|
||||
"""Test that is_domain_blocked returns True when domain is blocked."""
|
||||
# Arrange
|
||||
domain_blocker.blocked_domains = ['colsch.us', 'other-domain.com']
|
||||
mock_store.is_domain_blocked.return_value = True
|
||||
|
||||
# Act
|
||||
result = domain_blocker.is_domain_blocked('user@colsch.us')
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
mock_store.is_domain_blocked.assert_called_once_with('colsch.us')
|
||||
|
||||
|
||||
def test_is_domain_blocked_case_insensitive(domain_blocker):
|
||||
"""Test that is_domain_blocked performs case-insensitive domain matching."""
|
||||
def test_is_domain_blocked_case_insensitive(domain_blocker, mock_store):
|
||||
"""Test that is_domain_blocked performs case-insensitive domain extraction."""
|
||||
# Arrange
|
||||
domain_blocker.blocked_domains = ['colsch.us']
|
||||
mock_store.is_domain_blocked.return_value = True
|
||||
|
||||
# Act
|
||||
result = domain_blocker.is_domain_blocked('user@COLSCH.US')
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
mock_store.is_domain_blocked.assert_called_once_with('colsch.us')
|
||||
|
||||
|
||||
def test_is_domain_blocked_multiple_blocked_domains(domain_blocker):
|
||||
"""Test that is_domain_blocked correctly checks against multiple blocked domains."""
|
||||
def test_is_domain_blocked_with_whitespace(domain_blocker, mock_store):
|
||||
"""Test that is_domain_blocked handles emails with whitespace correctly."""
|
||||
# Arrange
|
||||
domain_blocker.blocked_domains = ['colsch.us', 'other-domain.com', 'blocked.org']
|
||||
mock_store.is_domain_blocked.return_value = True
|
||||
|
||||
# Act
|
||||
result = domain_blocker.is_domain_blocked(' user@colsch.us ')
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
mock_store.is_domain_blocked.assert_called_once_with('colsch.us')
|
||||
|
||||
|
||||
def test_is_domain_blocked_multiple_blocked_domains(domain_blocker, mock_store):
|
||||
"""Test that is_domain_blocked correctly checks multiple domains."""
|
||||
# Arrange
|
||||
mock_store.is_domain_blocked.side_effect = lambda domain: domain in [
|
||||
'other-domain.com',
|
||||
'blocked.org',
|
||||
]
|
||||
|
||||
# Act
|
||||
result1 = domain_blocker.is_domain_blocked('user@other-domain.com')
|
||||
@@ -167,109 +165,71 @@ def test_is_domain_blocked_multiple_blocked_domains(domain_blocker):
|
||||
assert result1 is True
|
||||
assert result2 is True
|
||||
assert result3 is False
|
||||
assert mock_store.is_domain_blocked.call_count == 3
|
||||
|
||||
|
||||
def test_is_domain_blocked_with_whitespace(domain_blocker):
|
||||
"""Test that is_domain_blocked handles emails with whitespace correctly."""
|
||||
# Arrange
|
||||
domain_blocker.blocked_domains = ['colsch.us']
|
||||
|
||||
# Act
|
||||
result = domain_blocker.is_domain_blocked(' user@colsch.us ')
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TLD Blocking Tests (patterns starting with '.')
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_is_domain_blocked_tld_pattern_blocks_matching_domain(domain_blocker):
|
||||
def test_is_domain_blocked_tld_pattern_blocks_matching_domain(
|
||||
domain_blocker, mock_store
|
||||
):
|
||||
"""Test that TLD pattern blocks domains ending with that TLD."""
|
||||
# Arrange
|
||||
domain_blocker.blocked_domains = ['.us']
|
||||
mock_store.is_domain_blocked.return_value = True
|
||||
|
||||
# Act
|
||||
result = domain_blocker.is_domain_blocked('user@company.us')
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
mock_store.is_domain_blocked.assert_called_once_with('company.us')
|
||||
|
||||
|
||||
def test_is_domain_blocked_tld_pattern_blocks_subdomain_with_tld(domain_blocker):
|
||||
def test_is_domain_blocked_tld_pattern_blocks_subdomain_with_tld(
|
||||
domain_blocker, mock_store
|
||||
):
|
||||
"""Test that TLD pattern blocks subdomains with that TLD."""
|
||||
# Arrange
|
||||
domain_blocker.blocked_domains = ['.us']
|
||||
mock_store.is_domain_blocked.return_value = True
|
||||
|
||||
# Act
|
||||
result = domain_blocker.is_domain_blocked('user@subdomain.company.us')
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
mock_store.is_domain_blocked.assert_called_once_with('subdomain.company.us')
|
||||
|
||||
|
||||
def test_is_domain_blocked_tld_pattern_does_not_block_different_tld(domain_blocker):
|
||||
def test_is_domain_blocked_tld_pattern_does_not_block_different_tld(
|
||||
domain_blocker, mock_store
|
||||
):
|
||||
"""Test that TLD pattern does not block domains with different TLD."""
|
||||
# Arrange
|
||||
domain_blocker.blocked_domains = ['.us']
|
||||
mock_store.is_domain_blocked.return_value = False
|
||||
|
||||
# Act
|
||||
result = domain_blocker.is_domain_blocked('user@company.com')
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
mock_store.is_domain_blocked.assert_called_once_with('company.com')
|
||||
|
||||
|
||||
def test_is_domain_blocked_tld_pattern_does_not_block_substring_match(
|
||||
domain_blocker,
|
||||
):
|
||||
"""Test that TLD pattern does not block domains that contain but don't end with the TLD."""
|
||||
# Arrange
|
||||
domain_blocker.blocked_domains = ['.us']
|
||||
|
||||
# Act
|
||||
result = domain_blocker.is_domain_blocked('user@focus.com')
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_is_domain_blocked_tld_pattern_case_insensitive(domain_blocker):
|
||||
def test_is_domain_blocked_tld_pattern_case_insensitive(domain_blocker, mock_store):
|
||||
"""Test that TLD pattern matching is case-insensitive."""
|
||||
# Arrange
|
||||
domain_blocker.blocked_domains = ['.us']
|
||||
mock_store.is_domain_blocked.return_value = True
|
||||
|
||||
# Act
|
||||
result = domain_blocker.is_domain_blocked('user@COMPANY.US')
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
mock_store.is_domain_blocked.assert_called_once_with('company.us')
|
||||
|
||||
|
||||
def test_is_domain_blocked_multiple_tld_patterns(domain_blocker):
|
||||
"""Test blocking with multiple TLD patterns."""
|
||||
# Arrange
|
||||
domain_blocker.blocked_domains = ['.us', '.vn', '.com']
|
||||
|
||||
# Act
|
||||
result_us = domain_blocker.is_domain_blocked('user@test.us')
|
||||
result_vn = domain_blocker.is_domain_blocked('user@test.vn')
|
||||
result_com = domain_blocker.is_domain_blocked('user@test.com')
|
||||
result_org = domain_blocker.is_domain_blocked('user@test.org')
|
||||
|
||||
# Assert
|
||||
assert result_us is True
|
||||
assert result_vn is True
|
||||
assert result_com is True
|
||||
assert result_org is False
|
||||
|
||||
|
||||
def test_is_domain_blocked_tld_pattern_with_multi_level_tld(domain_blocker):
|
||||
def test_is_domain_blocked_tld_pattern_with_multi_level_tld(domain_blocker, mock_store):
|
||||
"""Test that TLD pattern works with multi-level TLDs like .co.uk."""
|
||||
# Arrange
|
||||
domain_blocker.blocked_domains = ['.co.uk']
|
||||
mock_store.is_domain_blocked.side_effect = lambda domain: domain.endswith('.co.uk')
|
||||
|
||||
# Act
|
||||
result_match = domain_blocker.is_domain_blocked('user@example.co.uk')
|
||||
@@ -282,81 +242,87 @@ def test_is_domain_blocked_tld_pattern_with_multi_level_tld(domain_blocker):
|
||||
assert result_no_match is False
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Subdomain Blocking Tests (domain patterns now block subdomains)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_is_domain_blocked_domain_pattern_blocks_exact_match(domain_blocker):
|
||||
def test_is_domain_blocked_domain_pattern_blocks_exact_match(
|
||||
domain_blocker, mock_store
|
||||
):
|
||||
"""Test that domain pattern blocks exact domain match."""
|
||||
# Arrange
|
||||
domain_blocker.blocked_domains = ['example.com']
|
||||
mock_store.is_domain_blocked.return_value = True
|
||||
|
||||
# Act
|
||||
result = domain_blocker.is_domain_blocked('user@example.com')
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
mock_store.is_domain_blocked.assert_called_once_with('example.com')
|
||||
|
||||
|
||||
def test_is_domain_blocked_domain_pattern_blocks_subdomain(domain_blocker):
|
||||
def test_is_domain_blocked_domain_pattern_blocks_subdomain(domain_blocker, mock_store):
|
||||
"""Test that domain pattern blocks subdomains of that domain."""
|
||||
# Arrange
|
||||
domain_blocker.blocked_domains = ['example.com']
|
||||
mock_store.is_domain_blocked.return_value = True
|
||||
|
||||
# Act
|
||||
result = domain_blocker.is_domain_blocked('user@subdomain.example.com')
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
mock_store.is_domain_blocked.assert_called_once_with('subdomain.example.com')
|
||||
|
||||
|
||||
def test_is_domain_blocked_domain_pattern_blocks_multi_level_subdomain(
|
||||
domain_blocker,
|
||||
domain_blocker, mock_store
|
||||
):
|
||||
"""Test that domain pattern blocks multi-level subdomains."""
|
||||
# Arrange
|
||||
domain_blocker.blocked_domains = ['example.com']
|
||||
mock_store.is_domain_blocked.return_value = True
|
||||
|
||||
# Act
|
||||
result = domain_blocker.is_domain_blocked('user@api.v2.example.com')
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
mock_store.is_domain_blocked.assert_called_once_with('api.v2.example.com')
|
||||
|
||||
|
||||
def test_is_domain_blocked_domain_pattern_does_not_block_similar_domain(
|
||||
domain_blocker,
|
||||
domain_blocker, mock_store
|
||||
):
|
||||
"""Test that domain pattern does not block domains that contain but don't match the pattern."""
|
||||
# Arrange
|
||||
domain_blocker.blocked_domains = ['example.com']
|
||||
mock_store.is_domain_blocked.return_value = False
|
||||
|
||||
# Act
|
||||
result = domain_blocker.is_domain_blocked('user@notexample.com')
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
mock_store.is_domain_blocked.assert_called_once_with('notexample.com')
|
||||
|
||||
|
||||
def test_is_domain_blocked_domain_pattern_does_not_block_different_tld(
|
||||
domain_blocker,
|
||||
domain_blocker, mock_store
|
||||
):
|
||||
"""Test that domain pattern does not block same domain with different TLD."""
|
||||
# Arrange
|
||||
domain_blocker.blocked_domains = ['example.com']
|
||||
mock_store.is_domain_blocked.return_value = False
|
||||
|
||||
# Act
|
||||
result = domain_blocker.is_domain_blocked('user@example.org')
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
mock_store.is_domain_blocked.assert_called_once_with('example.org')
|
||||
|
||||
|
||||
def test_is_domain_blocked_subdomain_pattern_blocks_exact_and_nested(domain_blocker):
|
||||
def test_is_domain_blocked_subdomain_pattern_blocks_exact_and_nested(
|
||||
domain_blocker, mock_store
|
||||
):
|
||||
"""Test that blocking a subdomain also blocks its nested subdomains."""
|
||||
# Arrange
|
||||
domain_blocker.blocked_domains = ['api.example.com']
|
||||
mock_store.is_domain_blocked.side_effect = (
|
||||
lambda domain: 'api.example.com' in domain
|
||||
)
|
||||
|
||||
# Act
|
||||
result_exact = domain_blocker.is_domain_blocked('user@api.example.com')
|
||||
@@ -369,80 +335,10 @@ def test_is_domain_blocked_subdomain_pattern_blocks_exact_and_nested(domain_bloc
|
||||
assert result_parent is False
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Mixed Pattern Tests (TLD + domain patterns together)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_is_domain_blocked_mixed_patterns_tld_and_domain(domain_blocker):
|
||||
"""Test blocking with both TLD and domain patterns."""
|
||||
# Arrange
|
||||
domain_blocker.blocked_domains = ['.us', 'openhands.dev']
|
||||
|
||||
# Act
|
||||
result_tld = domain_blocker.is_domain_blocked('user@company.us')
|
||||
result_domain = domain_blocker.is_domain_blocked('user@openhands.dev')
|
||||
result_subdomain = domain_blocker.is_domain_blocked('user@api.openhands.dev')
|
||||
result_allowed = domain_blocker.is_domain_blocked('user@example.com')
|
||||
|
||||
# Assert
|
||||
assert result_tld is True
|
||||
assert result_domain is True
|
||||
assert result_subdomain is True
|
||||
assert result_allowed is False
|
||||
|
||||
|
||||
def test_is_domain_blocked_overlapping_patterns(domain_blocker):
|
||||
"""Test that overlapping patterns (TLD and specific domain) both work."""
|
||||
# Arrange
|
||||
domain_blocker.blocked_domains = ['.us', 'test.us']
|
||||
|
||||
# Act
|
||||
result_specific = domain_blocker.is_domain_blocked('user@test.us')
|
||||
result_other_us = domain_blocker.is_domain_blocked('user@other.us')
|
||||
|
||||
# Assert
|
||||
assert result_specific is True
|
||||
assert result_other_us is True
|
||||
|
||||
|
||||
def test_is_domain_blocked_complex_multi_pattern_scenario(domain_blocker):
|
||||
"""Test complex scenario with multiple TLD and domain patterns."""
|
||||
# Arrange
|
||||
domain_blocker.blocked_domains = [
|
||||
'.us',
|
||||
'.vn',
|
||||
'test.com',
|
||||
'openhands.dev',
|
||||
]
|
||||
|
||||
# Act & Assert
|
||||
# TLD patterns
|
||||
assert domain_blocker.is_domain_blocked('user@anything.us') is True
|
||||
assert domain_blocker.is_domain_blocked('user@company.vn') is True
|
||||
|
||||
# Domain patterns (exact)
|
||||
assert domain_blocker.is_domain_blocked('user@test.com') is True
|
||||
assert domain_blocker.is_domain_blocked('user@openhands.dev') is True
|
||||
|
||||
# Domain patterns (subdomains)
|
||||
assert domain_blocker.is_domain_blocked('user@api.test.com') is True
|
||||
assert domain_blocker.is_domain_blocked('user@staging.openhands.dev') is True
|
||||
|
||||
# Not blocked
|
||||
assert domain_blocker.is_domain_blocked('user@allowed.com') is False
|
||||
assert domain_blocker.is_domain_blocked('user@example.org') is False
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Edge Case Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_is_domain_blocked_domain_with_hyphens(domain_blocker):
|
||||
def test_is_domain_blocked_domain_with_hyphens(domain_blocker, mock_store):
|
||||
"""Test that domain patterns work with hyphenated domains."""
|
||||
# Arrange
|
||||
domain_blocker.blocked_domains = ['my-company.com']
|
||||
mock_store.is_domain_blocked.return_value = True
|
||||
|
||||
# Act
|
||||
result_exact = domain_blocker.is_domain_blocked('user@my-company.com')
|
||||
@@ -451,12 +347,13 @@ def test_is_domain_blocked_domain_with_hyphens(domain_blocker):
|
||||
# Assert
|
||||
assert result_exact is True
|
||||
assert result_subdomain is True
|
||||
assert mock_store.is_domain_blocked.call_count == 2
|
||||
|
||||
|
||||
def test_is_domain_blocked_domain_with_numbers(domain_blocker):
|
||||
def test_is_domain_blocked_domain_with_numbers(domain_blocker, mock_store):
|
||||
"""Test that domain patterns work with numeric domains."""
|
||||
# Arrange
|
||||
domain_blocker.blocked_domains = ['test123.com']
|
||||
mock_store.is_domain_blocked.return_value = True
|
||||
|
||||
# Act
|
||||
result_exact = domain_blocker.is_domain_blocked('user@test123.com')
|
||||
@@ -465,24 +362,13 @@ def test_is_domain_blocked_domain_with_numbers(domain_blocker):
|
||||
# Assert
|
||||
assert result_exact is True
|
||||
assert result_subdomain is True
|
||||
assert mock_store.is_domain_blocked.call_count == 2
|
||||
|
||||
|
||||
def test_is_domain_blocked_short_tld(domain_blocker):
|
||||
"""Test that short TLD patterns work correctly."""
|
||||
# Arrange
|
||||
domain_blocker.blocked_domains = ['.io']
|
||||
|
||||
# Act
|
||||
result = domain_blocker.is_domain_blocked('user@company.io')
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_is_domain_blocked_very_long_subdomain_chain(domain_blocker):
|
||||
def test_is_domain_blocked_very_long_subdomain_chain(domain_blocker, mock_store):
|
||||
"""Test that blocking works with very long subdomain chains."""
|
||||
# Arrange
|
||||
domain_blocker.blocked_domains = ['example.com']
|
||||
mock_store.is_domain_blocked.return_value = True
|
||||
|
||||
# Act
|
||||
result = domain_blocker.is_domain_blocked(
|
||||
@@ -491,3 +377,19 @@ def test_is_domain_blocked_very_long_subdomain_chain(domain_blocker):
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
mock_store.is_domain_blocked.assert_called_once_with(
|
||||
'level4.level3.level2.level1.example.com'
|
||||
)
|
||||
|
||||
|
||||
def test_is_domain_blocked_handles_store_exception(domain_blocker, mock_store):
|
||||
"""Test that is_domain_blocked returns False when store raises an exception."""
|
||||
# Arrange
|
||||
mock_store.is_domain_blocked.side_effect = Exception('Database connection error')
|
||||
|
||||
# Act
|
||||
result = domain_blocker.is_domain_blocked('user@example.com')
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
mock_store.is_domain_blocked.assert_called_once_with('example.com')
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"""Unit tests for get_user_v1_enabled_setting function."""
|
||||
|
||||
import os
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from integrations.github.github_view import get_user_v1_enabled_setting
|
||||
from integrations.utils import get_user_v1_enabled_setting
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -16,10 +15,9 @@ def mock_user_settings():
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_settings_store(mock_user_settings):
|
||||
def mock_settings_store():
|
||||
"""Create a mock settings store."""
|
||||
store = MagicMock()
|
||||
store.get_user_settings_by_keycloak_id = AsyncMock(return_value=mock_user_settings)
|
||||
return store
|
||||
|
||||
|
||||
@@ -40,15 +38,16 @@ def mock_dependencies(
|
||||
mock_settings_store, mock_config, mock_session_maker, mock_user_settings
|
||||
):
|
||||
"""Fixture that patches all the common dependencies."""
|
||||
# Patch at the source module since SaasSettingsStore is imported inside the function
|
||||
with patch(
|
||||
'integrations.github.github_view.SaasSettingsStore',
|
||||
'storage.saas_settings_store.SaasSettingsStore',
|
||||
return_value=mock_settings_store,
|
||||
) as mock_store_class, patch(
|
||||
'integrations.github.github_view.get_config', return_value=mock_config
|
||||
'integrations.utils.get_config', return_value=mock_config
|
||||
) as mock_get_config, patch(
|
||||
'integrations.github.github_view.session_maker', mock_session_maker
|
||||
'integrations.utils.session_maker', mock_session_maker
|
||||
), patch(
|
||||
'integrations.github.github_view.call_sync_from_async',
|
||||
'integrations.utils.call_sync_from_async',
|
||||
return_value=mock_user_settings,
|
||||
) as mock_call_sync:
|
||||
yield {
|
||||
@@ -66,67 +65,64 @@ class TestGetUserV1EnabledSetting:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
'env_var_enabled,user_setting_enabled,expected_result',
|
||||
'user_setting_enabled,expected_result',
|
||||
[
|
||||
(False, True, False), # Env var disabled, user enabled -> False
|
||||
(True, False, False), # Env var enabled, user disabled -> False
|
||||
(True, True, True), # Both enabled -> True
|
||||
(False, False, False), # Both disabled -> False
|
||||
(True, True), # User enabled -> True
|
||||
(False, False), # User disabled -> False
|
||||
],
|
||||
)
|
||||
async def test_v1_enabled_combinations(
|
||||
self, mock_dependencies, env_var_enabled, user_setting_enabled, expected_result
|
||||
async def test_v1_enabled_user_setting(
|
||||
self, mock_dependencies, user_setting_enabled, expected_result
|
||||
):
|
||||
"""Test all combinations of environment variable and user setting values."""
|
||||
"""Test that the function returns the user's v1_enabled setting."""
|
||||
mock_dependencies['user_settings'].v1_enabled = user_setting_enabled
|
||||
|
||||
with patch(
|
||||
'integrations.github.github_view.ENABLE_V1_GITHUB_RESOLVER', env_var_enabled
|
||||
):
|
||||
result = await get_user_v1_enabled_setting('test_user_id')
|
||||
assert result is expected_result
|
||||
result = await get_user_v1_enabled_setting('test_user_id')
|
||||
assert result is expected_result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
'env_var_value,env_var_bool,expected_result',
|
||||
[
|
||||
('false', False, False), # Environment variable 'false' -> False
|
||||
('true', True, True), # Environment variable 'true' -> True
|
||||
],
|
||||
)
|
||||
async def test_environment_variable_integration(
|
||||
self, mock_dependencies, env_var_value, env_var_bool, expected_result
|
||||
):
|
||||
"""Test that the function properly reads the ENABLE_V1_GITHUB_RESOLVER environment variable."""
|
||||
mock_dependencies['user_settings'].v1_enabled = True
|
||||
async def test_returns_false_when_no_user_id(self):
|
||||
"""Test that the function returns False when no user_id is provided."""
|
||||
result = await get_user_v1_enabled_setting(None)
|
||||
assert result is False
|
||||
|
||||
with patch.dict(
|
||||
os.environ, {'ENABLE_V1_GITHUB_RESOLVER': env_var_value}
|
||||
), patch('integrations.utils.os.getenv', return_value=env_var_value), patch(
|
||||
'integrations.github.github_view.ENABLE_V1_GITHUB_RESOLVER', env_var_bool
|
||||
):
|
||||
result = await get_user_v1_enabled_setting('test_user_id')
|
||||
assert result is expected_result
|
||||
result = await get_user_v1_enabled_setting('')
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_false_when_settings_is_none(self, mock_dependencies):
|
||||
"""Test that the function returns False when settings is None."""
|
||||
mock_dependencies['call_sync'].return_value = None
|
||||
|
||||
result = await get_user_v1_enabled_setting('test_user_id')
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_false_when_v1_enabled_is_none(self, mock_dependencies):
|
||||
"""Test that the function returns False when v1_enabled is None."""
|
||||
mock_dependencies['user_settings'].v1_enabled = None
|
||||
|
||||
result = await get_user_v1_enabled_setting('test_user_id')
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_function_calls_correct_methods(self, mock_dependencies):
|
||||
"""Test that the function calls the correct methods with correct parameters."""
|
||||
mock_dependencies['user_settings'].v1_enabled = True
|
||||
|
||||
with patch('integrations.github.github_view.ENABLE_V1_GITHUB_RESOLVER', True):
|
||||
result = await get_user_v1_enabled_setting('test_user_123')
|
||||
result = await get_user_v1_enabled_setting('test_user_123')
|
||||
|
||||
# Verify the result
|
||||
assert result is True
|
||||
# Verify the result
|
||||
assert result is True
|
||||
|
||||
# Verify correct methods were called with correct parameters
|
||||
mock_dependencies['get_config'].assert_called_once()
|
||||
mock_dependencies['store_class'].assert_called_once_with(
|
||||
user_id='test_user_123',
|
||||
session_maker=mock_dependencies['session_maker'],
|
||||
config=mock_dependencies['get_config'].return_value,
|
||||
)
|
||||
mock_dependencies['call_sync'].assert_called_once_with(
|
||||
mock_dependencies['settings_store'].get_user_settings_by_keycloak_id,
|
||||
'test_user_123',
|
||||
)
|
||||
# Verify correct methods were called with correct parameters
|
||||
mock_dependencies['get_config'].assert_called_once()
|
||||
mock_dependencies['store_class'].assert_called_once_with(
|
||||
user_id='test_user_123',
|
||||
session_maker=mock_dependencies['session_maker'],
|
||||
config=mock_dependencies['get_config'].return_value,
|
||||
)
|
||||
mock_dependencies['call_sync'].assert_called_once_with(
|
||||
mock_dependencies['settings_store'].get_user_settings_by_keycloak_id,
|
||||
'test_user_123',
|
||||
)
|
||||
|
||||
@@ -86,12 +86,12 @@ class TestGithubV1ConversationRouting(TestCase):
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
# Create a proper UserData instance instead of MagicMock
|
||||
user_data = UserData(
|
||||
self.user_data = UserData(
|
||||
user_id=123, username='testuser', keycloak_user_id='test-keycloak-id'
|
||||
)
|
||||
|
||||
# Create a mock raw_payload
|
||||
raw_payload = Message(
|
||||
self.raw_payload = Message(
|
||||
source=SourceType.GITHUB,
|
||||
message={
|
||||
'payload': {
|
||||
@@ -101,8 +101,10 @@ class TestGithubV1ConversationRouting(TestCase):
|
||||
},
|
||||
)
|
||||
|
||||
self.github_issue = GithubIssue(
|
||||
user_info=user_data,
|
||||
def _create_github_issue(self):
|
||||
"""Create a GithubIssue instance for testing."""
|
||||
return GithubIssue(
|
||||
user_info=self.user_data,
|
||||
full_repo_name='test/repo',
|
||||
issue_number=123,
|
||||
installation_id=456,
|
||||
@@ -110,35 +112,72 @@ class TestGithubV1ConversationRouting(TestCase):
|
||||
should_extract=True,
|
||||
send_summary_instruction=False,
|
||||
is_public_repo=True,
|
||||
raw_payload=raw_payload,
|
||||
raw_payload=self.raw_payload,
|
||||
uuid='test-uuid',
|
||||
title='Test Issue',
|
||||
description='Test issue description',
|
||||
previous_comments=[],
|
||||
v1=False,
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('integrations.github.github_view.initialize_conversation')
|
||||
@patch('integrations.github.github_view.get_user_v1_enabled_setting')
|
||||
async def test_initialize_sets_v1_enabled_from_setting_when_false(
|
||||
self, mock_get_v1_setting, mock_initialize_conversation
|
||||
):
|
||||
"""Test that initialize_new_conversation sets v1_enabled from get_user_v1_enabled_setting."""
|
||||
mock_get_v1_setting.return_value = False
|
||||
mock_initialize_conversation.return_value = MagicMock(
|
||||
conversation_id='new-conversation-id'
|
||||
)
|
||||
|
||||
github_issue = self._create_github_issue()
|
||||
await github_issue.initialize_new_conversation()
|
||||
|
||||
# Verify get_user_v1_enabled_setting was called with correct user ID
|
||||
mock_get_v1_setting.assert_called_once_with('test-keycloak-id')
|
||||
# Verify v1_enabled was set to False
|
||||
self.assertFalse(github_issue.v1_enabled)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('integrations.github.github_view.get_user_v1_enabled_setting')
|
||||
async def test_initialize_sets_v1_enabled_from_setting_when_true(
|
||||
self, mock_get_v1_setting
|
||||
):
|
||||
"""Test that initialize_new_conversation sets v1_enabled to True when setting returns True."""
|
||||
mock_get_v1_setting.return_value = True
|
||||
|
||||
github_issue = self._create_github_issue()
|
||||
await github_issue.initialize_new_conversation()
|
||||
|
||||
# Verify get_user_v1_enabled_setting was called with correct user ID
|
||||
mock_get_v1_setting.assert_called_once_with('test-keycloak-id')
|
||||
# Verify v1_enabled was set to True
|
||||
self.assertTrue(github_issue.v1_enabled)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch.object(GithubIssue, '_create_v0_conversation')
|
||||
@patch.object(GithubIssue, '_create_v1_conversation')
|
||||
async def test_create_new_conversation_routes_to_v0_when_disabled(
|
||||
self, mock_create_v1, mock_create_v0, mock_get_v1_setting
|
||||
self, mock_create_v1, mock_create_v0
|
||||
):
|
||||
"""Test that conversation creation routes to V0 when v1_enabled is False."""
|
||||
# Mock v1_enabled as False
|
||||
mock_get_v1_setting.return_value = False
|
||||
mock_create_v0.return_value = None
|
||||
mock_create_v1.return_value = None
|
||||
|
||||
github_issue = self._create_github_issue()
|
||||
github_issue.v1_enabled = False
|
||||
|
||||
# Mock parameters
|
||||
jinja_env = MagicMock()
|
||||
git_provider_tokens = MagicMock()
|
||||
conversation_metadata = MagicMock()
|
||||
saas_user_auth = MagicMock()
|
||||
|
||||
# Call the method
|
||||
await self.github_issue.create_new_conversation(
|
||||
jinja_env, git_provider_tokens, conversation_metadata
|
||||
await github_issue.create_new_conversation(
|
||||
jinja_env, git_provider_tokens, conversation_metadata, saas_user_auth
|
||||
)
|
||||
|
||||
# Verify V0 was called and V1 was not
|
||||
@@ -148,62 +187,31 @@ class TestGithubV1ConversationRouting(TestCase):
|
||||
mock_create_v1.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('integrations.github.github_view.get_user_v1_enabled_setting')
|
||||
@patch.object(GithubIssue, '_create_v0_conversation')
|
||||
@patch.object(GithubIssue, '_create_v1_conversation')
|
||||
async def test_create_new_conversation_routes_to_v1_when_enabled(
|
||||
self, mock_create_v1, mock_create_v0, mock_get_v1_setting
|
||||
self, mock_create_v1, mock_create_v0
|
||||
):
|
||||
"""Test that conversation creation routes to V1 when v1_enabled is True."""
|
||||
# Mock v1_enabled as True
|
||||
mock_get_v1_setting.return_value = True
|
||||
mock_create_v0.return_value = None
|
||||
mock_create_v1.return_value = None
|
||||
|
||||
github_issue = self._create_github_issue()
|
||||
github_issue.v1_enabled = True
|
||||
|
||||
# Mock parameters
|
||||
jinja_env = MagicMock()
|
||||
git_provider_tokens = MagicMock()
|
||||
conversation_metadata = MagicMock()
|
||||
saas_user_auth = MagicMock()
|
||||
|
||||
# Call the method
|
||||
await self.github_issue.create_new_conversation(
|
||||
jinja_env, git_provider_tokens, conversation_metadata
|
||||
await github_issue.create_new_conversation(
|
||||
jinja_env, git_provider_tokens, conversation_metadata, saas_user_auth
|
||||
)
|
||||
|
||||
# Verify V1 was called and V0 was not
|
||||
mock_create_v1.assert_called_once_with(
|
||||
jinja_env, git_provider_tokens, conversation_metadata
|
||||
jinja_env, saas_user_auth, conversation_metadata
|
||||
)
|
||||
mock_create_v0.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('integrations.github.github_view.get_user_v1_enabled_setting')
|
||||
@patch.object(GithubIssue, '_create_v0_conversation')
|
||||
@patch.object(GithubIssue, '_create_v1_conversation')
|
||||
async def test_create_new_conversation_fallback_on_v1_setting_error(
|
||||
self, mock_create_v1, mock_create_v0, mock_get_v1_setting
|
||||
):
|
||||
"""Test that conversation creation falls back to V0 when _create_v1_conversation fails."""
|
||||
# Mock v1_enabled as True so V1 is attempted
|
||||
mock_get_v1_setting.return_value = True
|
||||
# Mock _create_v1_conversation to raise an exception
|
||||
mock_create_v1.side_effect = Exception('V1 conversation creation failed')
|
||||
mock_create_v0.return_value = None
|
||||
|
||||
# Mock parameters
|
||||
jinja_env = MagicMock()
|
||||
git_provider_tokens = MagicMock()
|
||||
conversation_metadata = MagicMock()
|
||||
|
||||
# Call the method
|
||||
await self.github_issue.create_new_conversation(
|
||||
jinja_env, git_provider_tokens, conversation_metadata
|
||||
)
|
||||
|
||||
# Verify V1 was attempted first, then V0 was called as fallback
|
||||
mock_create_v1.assert_called_once_with(
|
||||
jinja_env, git_provider_tokens, conversation_metadata
|
||||
)
|
||||
mock_create_v0.assert_called_once_with(
|
||||
jinja_env, git_provider_tokens, conversation_metadata
|
||||
)
|
||||
|
||||
293
enterprise/tests/unit/test_recaptcha_service.py
Normal file
293
enterprise/tests/unit/test_recaptcha_service.py
Normal file
@@ -0,0 +1,293 @@
|
||||
"""Tests for RecaptchaService."""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from server.auth.recaptcha_service import AssessmentResult, RecaptchaService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_gcp_client():
|
||||
"""Mock GCP reCAPTCHA Enterprise client."""
|
||||
with patch(
|
||||
'server.auth.recaptcha_service.recaptchaenterprise_v1.RecaptchaEnterpriseServiceClient'
|
||||
) as mock_client_class:
|
||||
mock_client = MagicMock()
|
||||
mock_client_class.return_value = mock_client
|
||||
yield mock_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def recaptcha_service(mock_gcp_client):
|
||||
"""Create RecaptchaService instance with mocked dependencies."""
|
||||
with (
|
||||
patch('server.auth.recaptcha_service.RECAPTCHA_PROJECT_ID', 'test-project'),
|
||||
patch('server.auth.recaptcha_service.RECAPTCHA_SITE_KEY', 'test-site-key'),
|
||||
patch('server.auth.recaptcha_service.RECAPTCHA_HMAC_SECRET', 'test-secret'),
|
||||
patch('server.auth.recaptcha_service.RECAPTCHA_BLOCK_THRESHOLD', 0.3),
|
||||
):
|
||||
# Create new instance - constants are imported at module level, so we patch the imported names
|
||||
return RecaptchaService()
|
||||
|
||||
|
||||
class TestRecaptchaServiceHashAccountId:
|
||||
"""Tests for RecaptchaService.hash_account_id()."""
|
||||
|
||||
def test_should_hash_email_with_hmac_sha256(self, recaptcha_service):
|
||||
"""Test that hash_account_id produces correct HMAC-SHA256 hash."""
|
||||
# Arrange
|
||||
email = 'user@example.com'
|
||||
# The service reads RECAPTCHA_HMAC_SECRET from the imported constants
|
||||
# We need to verify it uses the constant correctly
|
||||
from server.auth.recaptcha_service import RECAPTCHA_HMAC_SECRET
|
||||
|
||||
# Act
|
||||
result = recaptcha_service.hash_account_id(email)
|
||||
|
||||
# Assert
|
||||
# Verify the hash is correct using the actual secret from the patched constant
|
||||
expected_hash = hmac.new(
|
||||
RECAPTCHA_HMAC_SECRET.encode(),
|
||||
email.lower().encode(),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
assert result == expected_hash
|
||||
assert len(result) == 64 # SHA256 produces 64 hex characters
|
||||
|
||||
def test_should_normalize_email_to_lowercase(self, recaptcha_service):
|
||||
"""Test that hash_account_id normalizes email to lowercase."""
|
||||
# Arrange
|
||||
email1 = 'User@Example.com'
|
||||
email2 = 'user@example.com'
|
||||
|
||||
# Act
|
||||
hash1 = recaptcha_service.hash_account_id(email1)
|
||||
hash2 = recaptcha_service.hash_account_id(email2)
|
||||
|
||||
# Assert
|
||||
assert hash1 == hash2
|
||||
|
||||
def test_should_produce_different_hashes_for_different_emails(
|
||||
self, recaptcha_service
|
||||
):
|
||||
"""Test that different emails produce different hashes."""
|
||||
# Arrange
|
||||
email1 = 'user1@example.com'
|
||||
email2 = 'user2@example.com'
|
||||
|
||||
# Act
|
||||
hash1 = recaptcha_service.hash_account_id(email1)
|
||||
hash2 = recaptcha_service.hash_account_id(email2)
|
||||
|
||||
# Assert
|
||||
assert hash1 != hash2
|
||||
|
||||
|
||||
class TestRecaptchaServiceCreateAssessment:
|
||||
"""Tests for RecaptchaService.create_assessment()."""
|
||||
|
||||
def test_should_create_assessment_and_allow_when_score_is_high(
|
||||
self, recaptcha_service, mock_gcp_client
|
||||
):
|
||||
"""Test that assessment allows request when score is above threshold."""
|
||||
# Arrange
|
||||
mock_response = MagicMock()
|
||||
mock_response.token_properties.valid = True
|
||||
mock_response.token_properties.action = 'LOGIN'
|
||||
mock_response.risk_analysis.score = 0.9
|
||||
mock_response.risk_analysis.reasons = []
|
||||
mock_gcp_client.create_assessment.return_value = mock_response
|
||||
|
||||
# Act
|
||||
result = recaptcha_service.create_assessment(
|
||||
token='test-token',
|
||||
action='LOGIN',
|
||||
user_ip='192.168.1.1',
|
||||
user_agent='Mozilla/5.0',
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, AssessmentResult)
|
||||
assert result.allowed is True
|
||||
assert result.score == 0.9
|
||||
assert result.valid is True
|
||||
assert result.action_valid is True
|
||||
mock_gcp_client.create_assessment.assert_called_once()
|
||||
|
||||
def test_should_block_when_score_is_below_threshold(
|
||||
self, recaptcha_service, mock_gcp_client
|
||||
):
|
||||
"""Test that assessment blocks request when score is below threshold."""
|
||||
# Arrange
|
||||
mock_response = MagicMock()
|
||||
mock_response.token_properties.valid = True
|
||||
mock_response.token_properties.action = 'LOGIN'
|
||||
mock_response.risk_analysis.score = 0.2
|
||||
mock_response.risk_analysis.reasons = []
|
||||
mock_gcp_client.create_assessment.return_value = mock_response
|
||||
|
||||
# Act
|
||||
result = recaptcha_service.create_assessment(
|
||||
token='test-token',
|
||||
action='LOGIN',
|
||||
user_ip='192.168.1.1',
|
||||
user_agent='Mozilla/5.0',
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.allowed is False
|
||||
assert result.score == 0.2
|
||||
|
||||
def test_should_block_when_token_is_invalid(
|
||||
self, recaptcha_service, mock_gcp_client
|
||||
):
|
||||
"""Test that assessment blocks request when token is invalid."""
|
||||
# Arrange
|
||||
mock_response = MagicMock()
|
||||
mock_response.token_properties.valid = False
|
||||
mock_response.token_properties.action = 'LOGIN'
|
||||
mock_response.risk_analysis.score = 0.9
|
||||
mock_response.risk_analysis.reasons = []
|
||||
mock_gcp_client.create_assessment.return_value = mock_response
|
||||
|
||||
# Act
|
||||
result = recaptcha_service.create_assessment(
|
||||
token='invalid-token',
|
||||
action='LOGIN',
|
||||
user_ip='192.168.1.1',
|
||||
user_agent='Mozilla/5.0',
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.allowed is False
|
||||
assert result.valid is False
|
||||
|
||||
def test_should_block_when_action_does_not_match(
|
||||
self, recaptcha_service, mock_gcp_client
|
||||
):
|
||||
"""Test that assessment blocks request when action doesn't match."""
|
||||
# Arrange
|
||||
mock_response = MagicMock()
|
||||
mock_response.token_properties.valid = True
|
||||
mock_response.token_properties.action = 'SIGNUP'
|
||||
mock_response.risk_analysis.score = 0.9
|
||||
mock_response.risk_analysis.reasons = []
|
||||
mock_gcp_client.create_assessment.return_value = mock_response
|
||||
|
||||
# Act
|
||||
result = recaptcha_service.create_assessment(
|
||||
token='test-token',
|
||||
action='LOGIN',
|
||||
user_ip='192.168.1.1',
|
||||
user_agent='Mozilla/5.0',
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.allowed is False
|
||||
assert result.action_valid is False
|
||||
|
||||
def test_should_include_email_in_user_info_when_provided(
|
||||
self, recaptcha_service, mock_gcp_client
|
||||
):
|
||||
"""Test that email is included in user_info when provided."""
|
||||
# Arrange
|
||||
mock_response = MagicMock()
|
||||
mock_response.token_properties.valid = True
|
||||
mock_response.token_properties.action = 'LOGIN'
|
||||
mock_response.risk_analysis.score = 0.9
|
||||
mock_response.risk_analysis.reasons = []
|
||||
mock_gcp_client.create_assessment.return_value = mock_response
|
||||
|
||||
# Act
|
||||
recaptcha_service.create_assessment(
|
||||
token='test-token',
|
||||
action='LOGIN',
|
||||
user_ip='192.168.1.1',
|
||||
user_agent='Mozilla/5.0',
|
||||
email='user@example.com',
|
||||
)
|
||||
|
||||
# Assert
|
||||
call_args = mock_gcp_client.create_assessment.call_args
|
||||
assessment = call_args[0][0].assessment
|
||||
assert assessment.event.user_info is not None
|
||||
assert assessment.event.user_info.account_id is not None
|
||||
assert len(assessment.event.user_info.user_ids) == 1
|
||||
assert assessment.event.user_info.user_ids[0].email == 'user@example.com'
|
||||
|
||||
def test_should_not_include_user_info_when_email_is_none(
|
||||
self, recaptcha_service, mock_gcp_client
|
||||
):
|
||||
"""Test that user_info is not included when email is None."""
|
||||
# Arrange
|
||||
mock_response = MagicMock()
|
||||
mock_response.token_properties.valid = True
|
||||
mock_response.token_properties.action = 'LOGIN'
|
||||
mock_response.risk_analysis.score = 0.9
|
||||
mock_response.risk_analysis.reasons = []
|
||||
mock_gcp_client.create_assessment.return_value = mock_response
|
||||
|
||||
# Act
|
||||
recaptcha_service.create_assessment(
|
||||
token='test-token',
|
||||
action='LOGIN',
|
||||
user_ip='192.168.1.1',
|
||||
user_agent='Mozilla/5.0',
|
||||
email=None,
|
||||
)
|
||||
|
||||
# Assert
|
||||
call_args = mock_gcp_client.create_assessment.call_args
|
||||
assessment = call_args[0][0].assessment
|
||||
# When email is None, user_info should not be set
|
||||
# Check that user_info was not explicitly set (protobuf objects may have default empty values)
|
||||
# The key is that account_id should not be set when email is None
|
||||
if hasattr(assessment.event, 'user_info') and assessment.event.user_info:
|
||||
# If user_info exists, verify account_id is empty (not set)
|
||||
assert not assessment.event.user_info.account_id
|
||||
|
||||
def test_should_log_assessment_details(self, recaptcha_service, mock_gcp_client):
|
||||
"""Test that assessment details are logged."""
|
||||
# Arrange
|
||||
mock_response = MagicMock()
|
||||
mock_response.token_properties.valid = True
|
||||
mock_response.token_properties.action = 'LOGIN'
|
||||
mock_response.risk_analysis.score = 0.9
|
||||
mock_response.risk_analysis.reasons = ['AUTOMATION']
|
||||
mock_gcp_client.create_assessment.return_value = mock_response
|
||||
|
||||
with patch('server.auth.recaptcha_service.logger') as mock_logger:
|
||||
# Act
|
||||
recaptcha_service.create_assessment(
|
||||
token='test-token',
|
||||
action='LOGIN',
|
||||
user_ip='192.168.1.1',
|
||||
user_agent='Mozilla/5.0',
|
||||
)
|
||||
|
||||
# Assert
|
||||
mock_logger.info.assert_called_once()
|
||||
call_kwargs = mock_logger.info.call_args
|
||||
assert call_kwargs[0][0] == 'recaptcha_assessment'
|
||||
assert call_kwargs[1]['extra']['score'] == 0.9
|
||||
assert call_kwargs[1]['extra']['valid'] is True
|
||||
assert call_kwargs[1]['extra']['action_valid'] is True
|
||||
assert call_kwargs[1]['extra']['user_ip'] == '192.168.1.1'
|
||||
|
||||
def test_should_raise_exception_when_gcp_client_fails(
|
||||
self, recaptcha_service, mock_gcp_client
|
||||
):
|
||||
"""Test that exceptions from GCP client are propagated."""
|
||||
# Arrange
|
||||
mock_gcp_client.create_assessment.side_effect = Exception('GCP error')
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(Exception, match='GCP error'):
|
||||
recaptcha_service.create_assessment(
|
||||
token='test-token',
|
||||
action='LOGIN',
|
||||
user_ip='192.168.1.1',
|
||||
user_agent='Mozilla/5.0',
|
||||
)
|
||||
@@ -65,6 +65,15 @@ def mock_stripe():
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_redis_client():
|
||||
"""Mock Redis client for testing create_default_settings locking."""
|
||||
mock_redis = AsyncMock()
|
||||
# By default, allow proceeding with create (lock acquired successfully)
|
||||
mock_redis.set = AsyncMock(return_value=True)
|
||||
return mock_redis
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_github_user():
|
||||
with patch(
|
||||
@@ -200,7 +209,12 @@ async def test_store_and_load_keycloak_user(settings_store):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_returns_default_when_not_found(
|
||||
settings_store, mock_litellm_api, mock_stripe, mock_github_user, session_maker
|
||||
settings_store,
|
||||
mock_litellm_api,
|
||||
mock_stripe,
|
||||
mock_github_user,
|
||||
session_maker,
|
||||
mock_redis_client,
|
||||
):
|
||||
file_store = MagicMock()
|
||||
file_store.read.side_effect = FileNotFoundError()
|
||||
@@ -211,6 +225,9 @@ async def test_load_returns_default_when_not_found(
|
||||
MagicMock(return_value=file_store),
|
||||
),
|
||||
patch('storage.saas_settings_store.session_maker', session_maker),
|
||||
patch.object(
|
||||
settings_store, '_get_redis_client', return_value=mock_redis_client
|
||||
),
|
||||
):
|
||||
loaded_settings = await settings_store.load()
|
||||
assert loaded_settings is not None
|
||||
@@ -263,7 +280,7 @@ async def test_create_default_settings_no_user_id():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_default_settings_require_payment_enabled(
|
||||
settings_store, mock_stripe
|
||||
settings_store, mock_stripe, mock_redis_client
|
||||
):
|
||||
# Mock stripe_service.has_payment_method to return False
|
||||
with (
|
||||
@@ -275,6 +292,9 @@ async def test_create_default_settings_require_payment_enabled(
|
||||
patch(
|
||||
'integrations.stripe_service.session_maker', settings_store.session_maker
|
||||
),
|
||||
patch.object(
|
||||
settings_store, '_get_redis_client', return_value=mock_redis_client
|
||||
),
|
||||
):
|
||||
settings = await settings_store.create_default_settings(None)
|
||||
assert settings is None
|
||||
@@ -282,7 +302,12 @@ async def test_create_default_settings_require_payment_enabled(
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_default_settings_require_payment_disabled(
|
||||
settings_store, mock_stripe, mock_github_user, mock_litellm_api, session_maker
|
||||
settings_store,
|
||||
mock_stripe,
|
||||
mock_github_user,
|
||||
mock_litellm_api,
|
||||
session_maker,
|
||||
mock_redis_client,
|
||||
):
|
||||
# Even without payment method, should get default settings when REQUIRE_PAYMENT is False
|
||||
file_store = MagicMock()
|
||||
@@ -298,12 +323,60 @@ async def test_create_default_settings_require_payment_disabled(
|
||||
MagicMock(return_value=file_store),
|
||||
),
|
||||
patch('storage.saas_settings_store.session_maker', session_maker),
|
||||
patch.object(
|
||||
settings_store, '_get_redis_client', return_value=mock_redis_client
|
||||
),
|
||||
):
|
||||
settings = await settings_store.create_default_settings(None)
|
||||
assert settings is not None
|
||||
assert settings.language == 'en'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_default_settings_waits_when_lock_held(
|
||||
settings_store, mock_stripe, mock_github_user, mock_litellm_api, session_maker
|
||||
):
|
||||
"""Test that create_default_settings waits and retries when another process holds the lock."""
|
||||
file_store = MagicMock()
|
||||
file_store.read.side_effect = FileNotFoundError()
|
||||
|
||||
# Create a mock Redis client that fails to acquire lock on first attempt, succeeds on second
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.set = AsyncMock(side_effect=[False, True])
|
||||
|
||||
# Track if sleep was called
|
||||
sleep_called = False
|
||||
|
||||
async def mock_sleep(delay):
|
||||
nonlocal sleep_called
|
||||
sleep_called = True
|
||||
# Don't actually sleep - just verify it was called with correct delay
|
||||
from storage.saas_settings_store import _RETRY_LOAD_DELAY_SECONDS
|
||||
|
||||
assert delay == _RETRY_LOAD_DELAY_SECONDS
|
||||
|
||||
with (
|
||||
patch('storage.saas_settings_store.REQUIRE_PAYMENT', False),
|
||||
patch(
|
||||
'stripe.Customer.list_payment_methods_async',
|
||||
AsyncMock(return_value=MagicMock(data=[])),
|
||||
),
|
||||
patch(
|
||||
'storage.saas_settings_store.get_file_store',
|
||||
MagicMock(return_value=file_store),
|
||||
),
|
||||
patch('storage.saas_settings_store.session_maker', session_maker),
|
||||
patch.object(settings_store, '_get_redis_client', return_value=mock_redis),
|
||||
patch('storage.saas_settings_store.asyncio.sleep', mock_sleep),
|
||||
):
|
||||
settings = await settings_store.create_default_settings(None)
|
||||
# Should have called sleep while waiting for lock
|
||||
assert sleep_called
|
||||
# Should eventually succeed and return settings
|
||||
assert settings is not None
|
||||
assert settings.language == 'en'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_default_lite_llm_settings_no_api_config(settings_store):
|
||||
with (
|
||||
@@ -981,7 +1054,7 @@ async def test_has_custom_settings_matches_old_default_model(settings_store):
|
||||
),
|
||||
):
|
||||
settings = Settings(
|
||||
llm_model='litellm_proxy/prod/claude-3-5-sonnet-20241022',
|
||||
llm_model='litellm_proxy/claude-3-5-sonnet-20241022',
|
||||
llm_base_url='http://default.url',
|
||||
)
|
||||
|
||||
@@ -1116,7 +1189,7 @@ async def test_update_settings_upgrades_user_from_old_defaults(
|
||||
):
|
||||
# Arrange: User with old version using old defaults
|
||||
old_version = 1
|
||||
old_model = 'litellm_proxy/prod/claude-3-5-sonnet-20241022'
|
||||
old_model = 'litellm_proxy/claude-3-5-sonnet-20241022'
|
||||
settings = Settings(llm_model=old_model, llm_base_url=LITE_LLM_API_URL)
|
||||
|
||||
# Use a consistent test URL
|
||||
@@ -1137,7 +1210,7 @@ async def test_update_settings_upgrades_user_from_old_defaults(
|
||||
patch('storage.saas_settings_store.LITE_LLM_API_URL', test_base_url),
|
||||
patch(
|
||||
'storage.saas_settings_store.get_default_litellm_model',
|
||||
return_value='litellm_proxy/prod/claude-opus-4-5-20251101',
|
||||
return_value='litellm_proxy/claude-opus-4-5-20251101',
|
||||
),
|
||||
patch(
|
||||
'server.auth.token_manager.TokenManager.get_user_info_from_user_id',
|
||||
@@ -1168,9 +1241,7 @@ async def test_update_settings_upgrades_user_from_old_defaults(
|
||||
|
||||
# Assert: Settings upgraded to new defaults
|
||||
assert updated_settings is not None
|
||||
assert (
|
||||
updated_settings.llm_model == 'litellm_proxy/prod/claude-opus-4-5-20251101'
|
||||
)
|
||||
assert updated_settings.llm_model == 'litellm_proxy/claude-opus-4-5-20251101'
|
||||
assert updated_settings.llm_base_url == test_base_url
|
||||
|
||||
|
||||
|
||||
@@ -673,7 +673,6 @@ async def test_saas_user_auth_from_signed_token_blocked_domain(mock_config):
|
||||
signed_token = jwt.encode(token_payload, 'test_secret', algorithm='HS256')
|
||||
|
||||
with patch('server.auth.saas_user_auth.domain_blocker') as mock_domain_blocker:
|
||||
mock_domain_blocker.is_active.return_value = True
|
||||
mock_domain_blocker.is_domain_blocked.return_value = True
|
||||
|
||||
# Act & Assert
|
||||
@@ -703,7 +702,6 @@ async def test_saas_user_auth_from_signed_token_allowed_domain(mock_config):
|
||||
signed_token = jwt.encode(token_payload, 'test_secret', algorithm='HS256')
|
||||
|
||||
with patch('server.auth.saas_user_auth.domain_blocker') as mock_domain_blocker:
|
||||
mock_domain_blocker.is_active.return_value = True
|
||||
mock_domain_blocker.is_domain_blocked.return_value = False
|
||||
|
||||
# Act
|
||||
@@ -720,7 +718,7 @@ async def test_saas_user_auth_from_signed_token_allowed_domain(mock_config):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_saas_user_auth_from_signed_token_domain_blocking_inactive(mock_config):
|
||||
"""Test that saas_user_auth_from_signed_token succeeds when domain blocking is not active."""
|
||||
"""Test that saas_user_auth_from_signed_token succeeds when email domain is not blocked."""
|
||||
# Arrange
|
||||
access_payload = {
|
||||
'sub': 'test_user_id',
|
||||
@@ -737,7 +735,7 @@ async def test_saas_user_auth_from_signed_token_domain_blocking_inactive(mock_co
|
||||
signed_token = jwt.encode(token_payload, 'test_secret', algorithm='HS256')
|
||||
|
||||
with patch('server.auth.saas_user_auth.domain_blocker') as mock_domain_blocker:
|
||||
mock_domain_blocker.is_active.return_value = False
|
||||
mock_domain_blocker.is_domain_blocked.return_value = False
|
||||
|
||||
# Act
|
||||
result = await saas_user_auth_from_signed_token(signed_token)
|
||||
@@ -745,4 +743,4 @@ async def test_saas_user_auth_from_signed_token_domain_blocking_inactive(mock_co
|
||||
# Assert
|
||||
assert isinstance(result, SaasUserAuth)
|
||||
assert result.user_id == 'test_user_id'
|
||||
mock_domain_blocker.is_domain_blocked.assert_not_called()
|
||||
mock_domain_blocker.is_domain_blocked.assert_called_once_with('user@colsch.us')
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"""Tests for SharedEventService."""
|
||||
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import AsyncMock
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from server.sharing.filesystem_shared_event_service import (
|
||||
SharedEventServiceImpl,
|
||||
from server.sharing.google_cloud_shared_event_service import (
|
||||
GoogleCloudSharedEventService,
|
||||
GoogleCloudSharedEventServiceInjector,
|
||||
)
|
||||
from server.sharing.shared_conversation_info_service import (
|
||||
SharedConversationInfoService,
|
||||
@@ -25,18 +27,24 @@ def mock_shared_conversation_info_service():
|
||||
return AsyncMock(spec=SharedConversationInfoService)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_bucket():
|
||||
"""Create a mock GCS bucket."""
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_event_service():
|
||||
"""Create a mock EventService."""
|
||||
"""Create a mock EventService for returned by get_event_service."""
|
||||
return AsyncMock(spec=EventService)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shared_event_service(mock_shared_conversation_info_service, mock_event_service):
|
||||
def shared_event_service(mock_shared_conversation_info_service, mock_bucket):
|
||||
"""Create a SharedEventService for testing."""
|
||||
return SharedEventServiceImpl(
|
||||
return GoogleCloudSharedEventService(
|
||||
shared_conversation_info_service=mock_shared_conversation_info_service,
|
||||
event_service=mock_event_service,
|
||||
bucket=mock_bucket,
|
||||
)
|
||||
|
||||
|
||||
@@ -79,11 +87,16 @@ class TestSharedEventService:
|
||||
):
|
||||
"""Test that get_shared_event returns an event for a public conversation."""
|
||||
conversation_id = sample_public_conversation.id
|
||||
event_id = 'test_event_id'
|
||||
event_id = uuid4()
|
||||
|
||||
# Mock the public conversation service to return a public conversation
|
||||
mock_shared_conversation_info_service.get_shared_conversation_info.return_value = sample_public_conversation
|
||||
|
||||
# Mock get_event_service to return our mock event service
|
||||
shared_event_service.get_event_service = AsyncMock(
|
||||
return_value=mock_event_service
|
||||
)
|
||||
|
||||
# Mock the event service to return an event
|
||||
mock_event_service.get_event.return_value = sample_event
|
||||
|
||||
@@ -92,10 +105,8 @@ class TestSharedEventService:
|
||||
|
||||
# Verify the result
|
||||
assert result == sample_event
|
||||
mock_shared_conversation_info_service.get_shared_conversation_info.assert_called_once_with(
|
||||
conversation_id
|
||||
)
|
||||
mock_event_service.get_event.assert_called_once_with(event_id)
|
||||
shared_event_service.get_event_service.assert_called_once_with(conversation_id)
|
||||
mock_event_service.get_event.assert_called_once_with(conversation_id, event_id)
|
||||
|
||||
async def test_get_shared_event_returns_none_for_private_conversation(
|
||||
self,
|
||||
@@ -105,20 +116,18 @@ class TestSharedEventService:
|
||||
):
|
||||
"""Test that get_shared_event returns None for a private conversation."""
|
||||
conversation_id = uuid4()
|
||||
event_id = 'test_event_id'
|
||||
event_id = uuid4()
|
||||
|
||||
# Mock the public conversation service to return None (private conversation)
|
||||
mock_shared_conversation_info_service.get_shared_conversation_info.return_value = None
|
||||
# Mock get_event_service to return None (private conversation)
|
||||
shared_event_service.get_event_service = AsyncMock(return_value=None)
|
||||
|
||||
# Call the method
|
||||
result = await shared_event_service.get_shared_event(conversation_id, event_id)
|
||||
|
||||
# Verify the result
|
||||
assert result is None
|
||||
mock_shared_conversation_info_service.get_shared_conversation_info.assert_called_once_with(
|
||||
conversation_id
|
||||
)
|
||||
# Event service should not be called
|
||||
shared_event_service.get_event_service.assert_called_once_with(conversation_id)
|
||||
# Event service should not be called since get_event_service returns None
|
||||
mock_event_service.get_event.assert_not_called()
|
||||
|
||||
async def test_search_shared_events_returns_events_for_public_conversation(
|
||||
@@ -132,8 +141,10 @@ class TestSharedEventService:
|
||||
"""Test that search_shared_events returns events for a public conversation."""
|
||||
conversation_id = sample_public_conversation.id
|
||||
|
||||
# Mock the public conversation service to return a public conversation
|
||||
mock_shared_conversation_info_service.get_shared_conversation_info.return_value = sample_public_conversation
|
||||
# Mock get_event_service to return our mock event service
|
||||
shared_event_service.get_event_service = AsyncMock(
|
||||
return_value=mock_event_service
|
||||
)
|
||||
|
||||
# Mock the event service to return events
|
||||
mock_event_page = EventPage(items=[], next_page_id=None)
|
||||
@@ -150,11 +161,9 @@ class TestSharedEventService:
|
||||
assert result == mock_event_page
|
||||
assert len(result.items) == 0 # Empty list as we mocked
|
||||
|
||||
mock_shared_conversation_info_service.get_shared_conversation_info.assert_called_once_with(
|
||||
conversation_id
|
||||
)
|
||||
shared_event_service.get_event_service.assert_called_once_with(conversation_id)
|
||||
mock_event_service.search_events.assert_called_once_with(
|
||||
conversation_id__eq=conversation_id,
|
||||
conversation_id=conversation_id,
|
||||
kind__eq='ActionEvent',
|
||||
timestamp__gte=None,
|
||||
timestamp__lt=None,
|
||||
@@ -172,8 +181,8 @@ class TestSharedEventService:
|
||||
"""Test that search_shared_events returns empty page for a private conversation."""
|
||||
conversation_id = uuid4()
|
||||
|
||||
# Mock the public conversation service to return None (private conversation)
|
||||
mock_shared_conversation_info_service.get_shared_conversation_info.return_value = None
|
||||
# Mock get_event_service to return None (private conversation)
|
||||
shared_event_service.get_event_service = AsyncMock(return_value=None)
|
||||
|
||||
# Call the method
|
||||
result = await shared_event_service.search_shared_events(
|
||||
@@ -186,9 +195,7 @@ class TestSharedEventService:
|
||||
assert len(result.items) == 0
|
||||
assert result.next_page_id is None
|
||||
|
||||
mock_shared_conversation_info_service.get_shared_conversation_info.assert_called_once_with(
|
||||
conversation_id
|
||||
)
|
||||
shared_event_service.get_event_service.assert_called_once_with(conversation_id)
|
||||
# Event service should not be called
|
||||
mock_event_service.search_events.assert_not_called()
|
||||
|
||||
@@ -202,8 +209,10 @@ class TestSharedEventService:
|
||||
"""Test that count_shared_events returns count for a public conversation."""
|
||||
conversation_id = sample_public_conversation.id
|
||||
|
||||
# Mock the public conversation service to return a public conversation
|
||||
mock_shared_conversation_info_service.get_shared_conversation_info.return_value = sample_public_conversation
|
||||
# Mock get_event_service to return our mock event service
|
||||
shared_event_service.get_event_service = AsyncMock(
|
||||
return_value=mock_event_service
|
||||
)
|
||||
|
||||
# Mock the event service to return a count
|
||||
mock_event_service.count_events.return_value = 5
|
||||
@@ -217,15 +226,12 @@ class TestSharedEventService:
|
||||
# Verify the result
|
||||
assert result == 5
|
||||
|
||||
mock_shared_conversation_info_service.get_shared_conversation_info.assert_called_once_with(
|
||||
conversation_id
|
||||
)
|
||||
shared_event_service.get_event_service.assert_called_once_with(conversation_id)
|
||||
mock_event_service.count_events.assert_called_once_with(
|
||||
conversation_id__eq=conversation_id,
|
||||
conversation_id=conversation_id,
|
||||
kind__eq='ActionEvent',
|
||||
timestamp__gte=None,
|
||||
timestamp__lt=None,
|
||||
sort_order=EventSortOrder.TIMESTAMP,
|
||||
)
|
||||
|
||||
async def test_count_shared_events_returns_zero_for_private_conversation(
|
||||
@@ -237,8 +243,8 @@ class TestSharedEventService:
|
||||
"""Test that count_shared_events returns 0 for a private conversation."""
|
||||
conversation_id = uuid4()
|
||||
|
||||
# Mock the public conversation service to return None (private conversation)
|
||||
mock_shared_conversation_info_service.get_shared_conversation_info.return_value = None
|
||||
# Mock get_event_service to return None (private conversation)
|
||||
shared_event_service.get_event_service = AsyncMock(return_value=None)
|
||||
|
||||
# Call the method
|
||||
result = await shared_event_service.count_shared_events(
|
||||
@@ -248,9 +254,7 @@ class TestSharedEventService:
|
||||
# Verify the result
|
||||
assert result == 0
|
||||
|
||||
mock_shared_conversation_info_service.get_shared_conversation_info.assert_called_once_with(
|
||||
conversation_id
|
||||
)
|
||||
shared_event_service.get_event_service.assert_called_once_with(conversation_id)
|
||||
# Event service should not be called
|
||||
mock_event_service.count_events.assert_not_called()
|
||||
|
||||
@@ -264,10 +268,12 @@ class TestSharedEventService:
|
||||
):
|
||||
"""Test that batch_get_shared_events returns events for a public conversation."""
|
||||
conversation_id = sample_public_conversation.id
|
||||
event_ids = ['event1', 'event2']
|
||||
event_ids = [uuid4(), uuid4()]
|
||||
|
||||
# Mock the public conversation service to return a public conversation
|
||||
mock_shared_conversation_info_service.get_shared_conversation_info.return_value = sample_public_conversation
|
||||
# Mock get_event_service to return our mock event service
|
||||
shared_event_service.get_event_service = AsyncMock(
|
||||
return_value=mock_event_service
|
||||
)
|
||||
|
||||
# Mock the event service to return events
|
||||
mock_event_service.get_event.side_effect = [sample_event, None]
|
||||
@@ -282,11 +288,8 @@ class TestSharedEventService:
|
||||
assert result[0] == sample_event
|
||||
assert result[1] is None
|
||||
|
||||
# Verify that get_shared_conversation_info was called for each event
|
||||
assert (
|
||||
mock_shared_conversation_info_service.get_shared_conversation_info.call_count
|
||||
== 2
|
||||
)
|
||||
# Verify that get_event_service was called for each event
|
||||
assert shared_event_service.get_event_service.call_count == 2
|
||||
# Verify that get_event was called for each event
|
||||
assert mock_event_service.get_event.call_count == 2
|
||||
|
||||
@@ -298,10 +301,10 @@ class TestSharedEventService:
|
||||
):
|
||||
"""Test that batch_get_shared_events returns None for a private conversation."""
|
||||
conversation_id = uuid4()
|
||||
event_ids = ['event1', 'event2']
|
||||
event_ids = [uuid4(), uuid4()]
|
||||
|
||||
# Mock the public conversation service to return None (private conversation)
|
||||
mock_shared_conversation_info_service.get_shared_conversation_info.return_value = None
|
||||
# Mock get_event_service to return None (private conversation)
|
||||
shared_event_service.get_event_service = AsyncMock(return_value=None)
|
||||
|
||||
# Call the method
|
||||
result = await shared_event_service.batch_get_shared_events(
|
||||
@@ -313,11 +316,8 @@ class TestSharedEventService:
|
||||
assert result[0] is None
|
||||
assert result[1] is None
|
||||
|
||||
# Verify that get_shared_conversation_info was called for each event
|
||||
assert (
|
||||
mock_shared_conversation_info_service.get_shared_conversation_info.call_count
|
||||
== 2
|
||||
)
|
||||
# Verify that get_event_service was called for each event
|
||||
assert shared_event_service.get_event_service.call_count == 2
|
||||
# Event service should not be called
|
||||
mock_event_service.get_event.assert_not_called()
|
||||
|
||||
@@ -333,8 +333,10 @@ class TestSharedEventService:
|
||||
timestamp_gte = datetime(2023, 1, 1, tzinfo=UTC)
|
||||
timestamp_lt = datetime(2023, 12, 31, tzinfo=UTC)
|
||||
|
||||
# Mock the public conversation service to return a public conversation
|
||||
mock_shared_conversation_info_service.get_shared_conversation_info.return_value = sample_public_conversation
|
||||
# Mock get_event_service to return our mock event service
|
||||
shared_event_service.get_event_service = AsyncMock(
|
||||
return_value=mock_event_service
|
||||
)
|
||||
|
||||
# Mock the event service to return events
|
||||
mock_event_page = EventPage(items=[], next_page_id='next_page')
|
||||
@@ -355,7 +357,7 @@ class TestSharedEventService:
|
||||
assert result == mock_event_page
|
||||
|
||||
mock_event_service.search_events.assert_called_once_with(
|
||||
conversation_id__eq=conversation_id,
|
||||
conversation_id=conversation_id,
|
||||
kind__eq='ObservationEvent',
|
||||
timestamp__gte=timestamp_gte,
|
||||
timestamp__lt=timestamp_lt,
|
||||
@@ -363,3 +365,224 @@ class TestSharedEventService:
|
||||
page_id='current_page',
|
||||
limit=50,
|
||||
)
|
||||
|
||||
|
||||
class TestGoogleCloudSharedEventServiceGetEventService:
|
||||
"""Test cases for GoogleCloudSharedEventService.get_event_service method."""
|
||||
|
||||
async def test_get_event_service_returns_event_service_for_shared_conversation(
|
||||
self,
|
||||
shared_event_service,
|
||||
mock_shared_conversation_info_service,
|
||||
sample_public_conversation,
|
||||
):
|
||||
"""Test that get_event_service returns an EventService for a shared conversation."""
|
||||
conversation_id = sample_public_conversation.id
|
||||
|
||||
# Mock the shared conversation info service to return a shared conversation
|
||||
mock_shared_conversation_info_service.get_shared_conversation_info.return_value = sample_public_conversation
|
||||
|
||||
# Call the method
|
||||
result = await shared_event_service.get_event_service(conversation_id)
|
||||
|
||||
# Verify the result
|
||||
assert result is not None
|
||||
mock_shared_conversation_info_service.get_shared_conversation_info.assert_called_once_with(
|
||||
conversation_id
|
||||
)
|
||||
|
||||
async def test_get_event_service_returns_none_for_non_shared_conversation(
|
||||
self,
|
||||
shared_event_service,
|
||||
mock_shared_conversation_info_service,
|
||||
):
|
||||
"""Test that get_event_service returns None for a non-shared conversation."""
|
||||
conversation_id = uuid4()
|
||||
|
||||
# Mock the shared conversation info service to return None
|
||||
mock_shared_conversation_info_service.get_shared_conversation_info.return_value = None
|
||||
|
||||
# Call the method
|
||||
result = await shared_event_service.get_event_service(conversation_id)
|
||||
|
||||
# Verify the result
|
||||
assert result is None
|
||||
mock_shared_conversation_info_service.get_shared_conversation_info.assert_called_once_with(
|
||||
conversation_id
|
||||
)
|
||||
|
||||
|
||||
class TestGoogleCloudSharedEventServiceInjector:
|
||||
"""Test cases for GoogleCloudSharedEventServiceInjector."""
|
||||
|
||||
def test_bucket_name_from_environment_variable(self):
|
||||
"""Test that bucket_name is read from FILE_STORE_PATH environment variable."""
|
||||
test_bucket_name = 'test-bucket-name'
|
||||
with patch.dict(os.environ, {'FILE_STORE_PATH': test_bucket_name}):
|
||||
# Create a new injector instance to pick up the environment variable
|
||||
# Note: The class attribute is evaluated at class definition time,
|
||||
# so we need to test that the attribute exists and can be overridden
|
||||
injector = GoogleCloudSharedEventServiceInjector()
|
||||
injector.bucket_name = os.environ.get('FILE_STORE_PATH')
|
||||
assert injector.bucket_name == test_bucket_name
|
||||
|
||||
def test_bucket_name_default_value_when_env_not_set(self):
|
||||
"""Test that bucket_name is None when FILE_STORE_PATH is not set."""
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
# Remove FILE_STORE_PATH if it exists
|
||||
os.environ.pop('FILE_STORE_PATH', None)
|
||||
injector = GoogleCloudSharedEventServiceInjector()
|
||||
# The bucket_name will be whatever was set at class definition time
|
||||
# or None if FILE_STORE_PATH was not set when the class was defined
|
||||
assert hasattr(injector, 'bucket_name')
|
||||
|
||||
async def test_injector_yields_google_cloud_shared_event_service(self):
|
||||
"""Test that the injector yields a GoogleCloudSharedEventService instance."""
|
||||
mock_state = MagicMock()
|
||||
mock_request = MagicMock()
|
||||
mock_db_session = AsyncMock()
|
||||
|
||||
# Create the injector
|
||||
injector = GoogleCloudSharedEventServiceInjector()
|
||||
injector.bucket_name = 'test-bucket'
|
||||
|
||||
# Mock the get_db_session context manager
|
||||
mock_db_context = AsyncMock()
|
||||
mock_db_context.__aenter__.return_value = mock_db_session
|
||||
mock_db_context.__aexit__.return_value = None
|
||||
|
||||
# Mock storage.Client and bucket
|
||||
mock_storage_client = MagicMock()
|
||||
mock_bucket = MagicMock()
|
||||
mock_storage_client.bucket.return_value = mock_bucket
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.sharing.google_cloud_shared_event_service.storage.Client',
|
||||
return_value=mock_storage_client,
|
||||
),
|
||||
patch(
|
||||
'openhands.app_server.config.get_db_session',
|
||||
return_value=mock_db_context,
|
||||
),
|
||||
):
|
||||
# Call the inject method
|
||||
async for service in injector.inject(mock_state, mock_request):
|
||||
# Verify the service is an instance of GoogleCloudSharedEventService
|
||||
assert isinstance(service, GoogleCloudSharedEventService)
|
||||
assert service.bucket == mock_bucket
|
||||
|
||||
# Verify the storage client was called with the correct bucket name
|
||||
mock_storage_client.bucket.assert_called_once_with('test-bucket')
|
||||
|
||||
async def test_injector_uses_bucket_name_from_instance(self):
|
||||
"""Test that the injector uses the bucket_name from the instance."""
|
||||
mock_state = MagicMock()
|
||||
mock_request = MagicMock()
|
||||
mock_db_session = AsyncMock()
|
||||
|
||||
# Create the injector with a specific bucket name
|
||||
injector = GoogleCloudSharedEventServiceInjector()
|
||||
injector.bucket_name = 'my-custom-bucket'
|
||||
|
||||
# Mock the get_db_session context manager
|
||||
mock_db_context = AsyncMock()
|
||||
mock_db_context.__aenter__.return_value = mock_db_session
|
||||
mock_db_context.__aexit__.return_value = None
|
||||
|
||||
# Mock storage.Client and bucket
|
||||
mock_storage_client = MagicMock()
|
||||
mock_bucket = MagicMock()
|
||||
mock_storage_client.bucket.return_value = mock_bucket
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.sharing.google_cloud_shared_event_service.storage.Client',
|
||||
return_value=mock_storage_client,
|
||||
),
|
||||
patch(
|
||||
'openhands.app_server.config.get_db_session',
|
||||
return_value=mock_db_context,
|
||||
),
|
||||
):
|
||||
# Call the inject method
|
||||
async for service in injector.inject(mock_state, mock_request):
|
||||
pass
|
||||
|
||||
# Verify the storage client was called with the custom bucket name
|
||||
mock_storage_client.bucket.assert_called_once_with('my-custom-bucket')
|
||||
|
||||
async def test_injector_creates_sql_shared_conversation_info_service(self):
|
||||
"""Test that the injector creates SQLSharedConversationInfoService with db_session."""
|
||||
mock_state = MagicMock()
|
||||
mock_request = MagicMock()
|
||||
mock_db_session = AsyncMock()
|
||||
|
||||
# Create the injector
|
||||
injector = GoogleCloudSharedEventServiceInjector()
|
||||
injector.bucket_name = 'test-bucket'
|
||||
|
||||
# Mock the get_db_session context manager
|
||||
mock_db_context = AsyncMock()
|
||||
mock_db_context.__aenter__.return_value = mock_db_session
|
||||
mock_db_context.__aexit__.return_value = None
|
||||
|
||||
# Mock storage.Client and bucket
|
||||
mock_storage_client = MagicMock()
|
||||
mock_bucket = MagicMock()
|
||||
mock_storage_client.bucket.return_value = mock_bucket
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.sharing.google_cloud_shared_event_service.storage.Client',
|
||||
return_value=mock_storage_client,
|
||||
),
|
||||
patch(
|
||||
'openhands.app_server.config.get_db_session',
|
||||
return_value=mock_db_context,
|
||||
),
|
||||
patch(
|
||||
'server.sharing.google_cloud_shared_event_service.SQLSharedConversationInfoService'
|
||||
) as mock_sql_service_class,
|
||||
):
|
||||
mock_sql_service = MagicMock()
|
||||
mock_sql_service_class.return_value = mock_sql_service
|
||||
|
||||
# Call the inject method
|
||||
async for service in injector.inject(mock_state, mock_request):
|
||||
# Verify the service has the correct shared_conversation_info_service
|
||||
assert service.shared_conversation_info_service == mock_sql_service
|
||||
|
||||
# Verify SQLSharedConversationInfoService was created with db_session
|
||||
mock_sql_service_class.assert_called_once_with(db_session=mock_db_session)
|
||||
|
||||
async def test_injector_works_without_request(self):
|
||||
"""Test that the injector works when request is None."""
|
||||
mock_state = MagicMock()
|
||||
mock_db_session = AsyncMock()
|
||||
|
||||
# Create the injector
|
||||
injector = GoogleCloudSharedEventServiceInjector()
|
||||
injector.bucket_name = 'test-bucket'
|
||||
|
||||
# Mock the get_db_session context manager
|
||||
mock_db_context = AsyncMock()
|
||||
mock_db_context.__aenter__.return_value = mock_db_session
|
||||
mock_db_context.__aexit__.return_value = None
|
||||
|
||||
# Mock storage.Client and bucket
|
||||
mock_storage_client = MagicMock()
|
||||
mock_bucket = MagicMock()
|
||||
mock_storage_client.bucket.return_value = mock_bucket
|
||||
|
||||
with patch(
|
||||
'server.sharing.google_cloud_shared_event_service.storage.Client',
|
||||
return_value=mock_storage_client,
|
||||
):
|
||||
with patch(
|
||||
'openhands.app_server.config.get_db_session',
|
||||
return_value=mock_db_context,
|
||||
):
|
||||
# Call the inject method with request=None
|
||||
async for service in injector.inject(mock_state, request=None):
|
||||
assert isinstance(service, GoogleCloudSharedEventService)
|
||||
|
||||
@@ -49,7 +49,7 @@ temperature = 0.0
|
||||
|
||||
### Configuring Condensers for Evaluation
|
||||
|
||||
For benchmarks that support condenser configuration (like SWE-Bench), you can define multiple condenser configurations in your `config.toml` file. A condenser is responsible for managing conversation history to maintain context while staying within token limits - you can learn more about how it works [here](https://www.all-hands.dev/blog/openhands-context-condensensation-for-more-efficient-ai-agents):
|
||||
For benchmarks that support condenser configuration (like SWE-Bench), you can define multiple condenser configurations in your `config.toml` file. A condenser is responsible for managing conversation history to maintain context while staying within token limits - you can learn more about how it works [here](https://www.openhands.dev/blog/openhands-context-condensensation-for-more-efficient-ai-agents):
|
||||
|
||||
```toml
|
||||
# LLM-based summarizing condenser for evaluation
|
||||
@@ -143,7 +143,7 @@ You can start your own fork of [our huggingface evaluation outputs](https://hugg
|
||||
|
||||
## For Benchmark Developers
|
||||
|
||||
To learn more about how to integrate your benchmark into OpenHands, check out [tutorial here](https://docs.all-hands.dev/usage/how-to/evaluation-harness). Briefly,
|
||||
To learn more about how to integrate your benchmark into OpenHands, check out [tutorial here](https://docs.openhands.dev/usage/how-to/evaluation-harness). Briefly,
|
||||
|
||||
- Each subfolder contains a specific benchmark or experiment. For example, [`evaluation/benchmarks/swe_bench`](./benchmarks/swe_bench) should contain
|
||||
all the preprocessing/evaluation/analysis scripts.
|
||||
|
||||
@@ -33,10 +33,10 @@ npm run dev:mock:saas
|
||||
These commands set `VITE_MOCK_API=true` which activates the MSW Service Worker to intercept requests.
|
||||
|
||||
> [!NOTE]
|
||||
> **OSS vs SaaS Mode**
|
||||
> **OpenHands vs SaaS Mode**
|
||||
>
|
||||
> OpenHands runs in two modes:
|
||||
> - **OSS mode**: For local/self-hosted deployments where users provide their own LLM API keys and configure git providers manually
|
||||
> - **OpenHands mode**: For local/self-hosted deployments where users provide their own LLM API keys and configure git providers manually
|
||||
> - **SaaS mode**: For the cloud offering with billing, managed API keys, and OAuth-based GitHub integration
|
||||
>
|
||||
> Use `dev:mock:saas` when working on SaaS-specific features like billing, API key management, or subscription flows.
|
||||
|
||||
48
frontend/__tests__/components/chat-status-indicator.test.tsx
Normal file
48
frontend/__tests__/components/chat-status-indicator.test.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import ChatStatusIndicator from "#/components/features/chat/chat-status-indicator";
|
||||
|
||||
vi.mock("#/icons/debug-stackframe-dot.svg?react", () => ({
|
||||
default: (props: any) => (
|
||||
<svg data-testid="debug-stackframe-dot" {...props} />
|
||||
),
|
||||
}));
|
||||
|
||||
describe("ChatStatusIndicator", () => {
|
||||
it("renders the status indicator with status text", () => {
|
||||
render(
|
||||
<ChatStatusIndicator
|
||||
status="Waiting for sandbox"
|
||||
statusColor="#FFD600"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId("chat-status-indicator"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Waiting for sandbox")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("passes the statusColor to the DebugStackframeDot icon", () => {
|
||||
render(
|
||||
<ChatStatusIndicator
|
||||
status="Error"
|
||||
statusColor="#FF684E"
|
||||
/>
|
||||
);
|
||||
|
||||
const icon = screen.getByTestId("debug-stackframe-dot");
|
||||
expect(icon).toHaveAttribute("color", "#FF684E");
|
||||
});
|
||||
|
||||
it("renders the DebugStackframeDot icon", () => {
|
||||
render(
|
||||
<ChatStatusIndicator
|
||||
status="Loading"
|
||||
statusColor="#FFD600"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("debug-stackframe-dot")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -10,13 +10,14 @@ import {
|
||||
} from "vitest";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { MemoryRouter, Route, Routes } from "react-router";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { renderWithProviders, useParamsMock } from "test-utils";
|
||||
import type { Message } from "#/message";
|
||||
import { SUGGESTIONS } from "#/utils/suggestions";
|
||||
import { ChatInterface } from "#/components/features/chat/chat-interface";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useErrorMessageStore } from "#/stores/error-message-store";
|
||||
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
@@ -24,24 +25,15 @@ import { useGetTrajectory } from "#/hooks/mutation/use-get-trajectory";
|
||||
import { useUnifiedUploadFiles } from "#/hooks/mutation/use-unified-upload-files";
|
||||
import { OpenHandsAction } from "#/types/core/actions";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
|
||||
vi.mock("#/context/ws-client-provider");
|
||||
vi.mock("#/hooks/query/use-config");
|
||||
vi.mock("#/hooks/mutation/use-get-trajectory");
|
||||
vi.mock("#/hooks/mutation/use-unified-upload-files");
|
||||
vi.mock("#/hooks/use-conversation-id");
|
||||
|
||||
// Mock React Router hooks at the top level
|
||||
vi.mock("react-router", async () => {
|
||||
const actual = await vi.importActual("react-router");
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => vi.fn(),
|
||||
useParams: () => ({ conversationId: "test-conversation-id" }),
|
||||
useRouteLoaderData: vi.fn(() => ({})),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock other hooks that might be used by the component
|
||||
vi.mock("#/hooks/use-user-providers", () => ({
|
||||
useUserProviders: () => ({
|
||||
providers: [],
|
||||
@@ -59,6 +51,12 @@ vi.mock("#/hooks/use-conversation-name-context-menu", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-agent-state", () => ({
|
||||
useAgentState: vi.fn(() => ({
|
||||
curAgentState: AgentState.AWAITING_USER_INPUT,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Helper function to render with Router context
|
||||
const renderChatInterfaceWithRouter = () =>
|
||||
renderWithProviders(
|
||||
@@ -79,13 +77,26 @@ const renderChatInterface = (messages: Message[]) =>
|
||||
const renderWithQueryClient = (
|
||||
ui: React.ReactElement,
|
||||
queryClient: QueryClient,
|
||||
route = "/test-conversation-id",
|
||||
) =>
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
<MemoryRouter initialEntries={[route]}>
|
||||
<Routes>
|
||||
<Route path="/:conversationId" element={ui} />
|
||||
<Route path="/" element={ui} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
useParamsMock.mockReturnValue({ conversationId: "test-conversation-id" });
|
||||
vi.mocked(useConversationId).mockReturnValue({
|
||||
conversationId: "test-conversation-id",
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatInterface - Chat Suggestions", () => {
|
||||
// Create a new QueryClient for each test
|
||||
let queryClient: QueryClient;
|
||||
@@ -121,7 +132,9 @@ describe("ChatInterface - Chat Suggestions", () => {
|
||||
mutateAsync: vi.fn(),
|
||||
isLoading: false,
|
||||
});
|
||||
(useUnifiedUploadFiles as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
(
|
||||
useUnifiedUploadFiles as unknown as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue({
|
||||
mutateAsync: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ skipped_files: [], uploaded_files: [] }),
|
||||
@@ -252,7 +265,9 @@ describe("ChatInterface - Empty state", () => {
|
||||
mutateAsync: vi.fn(),
|
||||
isLoading: false,
|
||||
});
|
||||
(useUnifiedUploadFiles as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
(
|
||||
useUnifiedUploadFiles as unknown as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue({
|
||||
mutateAsync: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ skipped_files: [], uploaded_files: [] }),
|
||||
@@ -344,6 +359,28 @@ describe("ChatInterface - Empty state", () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('ChatInterface - Status Indicator', () => {
|
||||
it("should render ChatStatusIndicator when agent is not awaiting user input / conversation is NOT ready", () => {
|
||||
vi.mocked(useAgentState).mockReturnValue({
|
||||
curAgentState: AgentState.LOADING,
|
||||
});
|
||||
|
||||
renderChatInterfaceWithRouter();
|
||||
|
||||
expect(screen.getByTestId("chat-status-indicator")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should NOT render ChatStatusIndicator when agent is awaiting user input / conversation is ready", () => {
|
||||
vi.mocked(useAgentState).mockReturnValue({
|
||||
curAgentState: AgentState.AWAITING_USER_INPUT,
|
||||
});
|
||||
|
||||
renderChatInterfaceWithRouter();
|
||||
|
||||
expect(screen.queryByTestId("chat-status-indicator")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe.skip("ChatInterface - General functionality", () => {
|
||||
beforeAll(() => {
|
||||
// mock useScrollToBottom hook
|
||||
@@ -605,3 +642,43 @@ describe.skip("ChatInterface - General functionality", () => {
|
||||
expect(screen.getByTestId("feedback-actions")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatInterface – skeleton loading state", () => {
|
||||
test("renders chat message skeleton when loading existing conversation", () => {
|
||||
(useWsClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
send: vi.fn(),
|
||||
isLoadingMessages: true,
|
||||
parsedEvents: [],
|
||||
});
|
||||
|
||||
renderWithQueryClient(<ChatInterface />, new QueryClient());
|
||||
|
||||
expect(screen.getByTestId("chat-messages-skeleton")).toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByTestId("loading-spinner")).not.toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByTestId("chat-suggestions")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("does not render skeleton for new conversation (shows spinner instead)", () => {
|
||||
useParamsMock.mockReturnValue({ conversationId: undefined } as unknown as {
|
||||
conversationId: string;
|
||||
});
|
||||
(useConversationId as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
conversationId: "",
|
||||
});
|
||||
(useWsClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
send: vi.fn(),
|
||||
isLoadingMessages: true,
|
||||
parsedEvents: [],
|
||||
});
|
||||
|
||||
renderWithQueryClient(<ChatInterface />, new QueryClient(), "/");
|
||||
|
||||
expect(screen.getAllByTestId("loading-spinner").length).toBeGreaterThan(0);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("chat-messages-skeleton"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { it, describe, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { AuthModal } from "#/components/features/waitlist/auth-modal";
|
||||
|
||||
// Mock the useAuthUrl hook
|
||||
vi.mock("#/hooks/use-auth-url", () => ({
|
||||
useAuthUrl: () => "https://gitlab.com/oauth/authorize",
|
||||
}));
|
||||
|
||||
// Mock the useTracking hook
|
||||
vi.mock("#/hooks/use-tracking", () => ({
|
||||
useTracking: () => ({
|
||||
trackLoginButtonClick: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("AuthModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("location", { href: "" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("should render the GitHub and GitLab buttons", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<AuthModal
|
||||
githubAuthUrl="mock-url"
|
||||
appMode="saas"
|
||||
providersConfigured={["github", "gitlab"]}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const githubButton = screen.getByRole("button", {
|
||||
name: "GITHUB$CONNECT_TO_GITHUB",
|
||||
});
|
||||
const gitlabButton = screen.getByRole("button", {
|
||||
name: "GITLAB$CONNECT_TO_GITLAB",
|
||||
});
|
||||
|
||||
expect(githubButton).toBeInTheDocument();
|
||||
expect(gitlabButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should redirect to GitHub auth URL when GitHub button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockUrl = "https://github.com/login/oauth/authorize";
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<AuthModal
|
||||
githubAuthUrl={mockUrl}
|
||||
appMode="saas"
|
||||
providersConfigured={["github"]}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const githubButton = screen.getByRole("button", {
|
||||
name: "GITHUB$CONNECT_TO_GITHUB",
|
||||
});
|
||||
await user.click(githubButton);
|
||||
|
||||
expect(window.location.href).toBe(mockUrl);
|
||||
});
|
||||
|
||||
it("should render Terms of Service and Privacy Policy text with correct links", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<AuthModal githubAuthUrl="mock-url" appMode="saas" />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// Find the terms of service section using data-testid
|
||||
const termsSection = screen.getByTestId("terms-and-privacy-notice");
|
||||
expect(termsSection).toBeInTheDocument();
|
||||
|
||||
// Check that all text content is present in the paragraph
|
||||
expect(termsSection).toHaveTextContent(
|
||||
"AUTH$BY_SIGNING_UP_YOU_AGREE_TO_OUR",
|
||||
);
|
||||
expect(termsSection).toHaveTextContent("COMMON$TERMS_OF_SERVICE");
|
||||
expect(termsSection).toHaveTextContent("COMMON$AND");
|
||||
expect(termsSection).toHaveTextContent("COMMON$PRIVACY_POLICY");
|
||||
|
||||
// Check Terms of Service link
|
||||
const tosLink = screen.getByRole("link", {
|
||||
name: "COMMON$TERMS_OF_SERVICE",
|
||||
});
|
||||
expect(tosLink).toBeInTheDocument();
|
||||
expect(tosLink).toHaveAttribute("href", "https://www.all-hands.dev/tos");
|
||||
expect(tosLink).toHaveAttribute("target", "_blank");
|
||||
expect(tosLink).toHaveClass("underline", "hover:text-primary");
|
||||
|
||||
// Check Privacy Policy link
|
||||
const privacyLink = screen.getByRole("link", {
|
||||
name: "COMMON$PRIVACY_POLICY",
|
||||
});
|
||||
expect(privacyLink).toBeInTheDocument();
|
||||
expect(privacyLink).toHaveAttribute(
|
||||
"href",
|
||||
"https://www.all-hands.dev/privacy",
|
||||
);
|
||||
expect(privacyLink).toHaveAttribute("target", "_blank");
|
||||
expect(privacyLink).toHaveClass("underline", "hover:text-primary");
|
||||
|
||||
// Verify that both links are within the terms section
|
||||
expect(termsSection).toContainElement(tosLink);
|
||||
expect(termsSection).toContainElement(privacyLink);
|
||||
});
|
||||
|
||||
it("should display email verified message when emailVerified prop is true", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<AuthModal
|
||||
githubAuthUrl="mock-url"
|
||||
appMode="saas"
|
||||
emailVerified={true}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("AUTH$EMAIL_VERIFIED_PLEASE_LOGIN"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not display email verified message when emailVerified prop is false", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<AuthModal
|
||||
githubAuthUrl="mock-url"
|
||||
appMode="saas"
|
||||
emailVerified={false}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByText("AUTH$EMAIL_VERIFIED_PLEASE_LOGIN"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should open Terms of Service link in new tab", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<AuthModal githubAuthUrl="mock-url" appMode="saas" />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const tosLink = screen.getByRole("link", {
|
||||
name: "COMMON$TERMS_OF_SERVICE",
|
||||
});
|
||||
expect(tosLink).toHaveAttribute("target", "_blank");
|
||||
});
|
||||
|
||||
it("should open Privacy Policy link in new tab", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<AuthModal githubAuthUrl="mock-url" appMode="saas" />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const privacyLink = screen.getByRole("link", {
|
||||
name: "COMMON$PRIVACY_POLICY",
|
||||
});
|
||||
expect(privacyLink).toHaveAttribute("target", "_blank");
|
||||
});
|
||||
|
||||
describe("Duplicate email error message", () => {
|
||||
const renderAuthModalWithRouter = (initialEntries: string[]) => {
|
||||
const hasDuplicatedEmail = initialEntries.includes(
|
||||
"/?duplicated_email=true",
|
||||
);
|
||||
|
||||
return render(
|
||||
<MemoryRouter initialEntries={initialEntries}>
|
||||
<AuthModal
|
||||
githubAuthUrl="mock-url"
|
||||
appMode="saas"
|
||||
providersConfigured={["github"]}
|
||||
hasDuplicatedEmail={hasDuplicatedEmail}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
};
|
||||
|
||||
it("should display error message when duplicated_email query parameter is true", () => {
|
||||
// Arrange
|
||||
const initialEntries = ["/?duplicated_email=true"];
|
||||
|
||||
// Act
|
||||
renderAuthModalWithRouter(initialEntries);
|
||||
|
||||
// Assert
|
||||
const errorMessage = screen.getByText("AUTH$DUPLICATE_EMAIL_ERROR");
|
||||
expect(errorMessage).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not display error message when duplicated_email query parameter is missing", () => {
|
||||
// Arrange
|
||||
const initialEntries = ["/"];
|
||||
|
||||
// Act
|
||||
renderAuthModalWithRouter(initialEntries);
|
||||
|
||||
// Assert
|
||||
const errorMessage = screen.queryByText("AUTH$DUPLICATE_EMAIL_ERROR");
|
||||
expect(errorMessage).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { LoginContent } from "#/components/features/auth/login-content";
|
||||
|
||||
vi.mock("#/hooks/use-auth-url", () => ({
|
||||
useAuthUrl: (config: {
|
||||
identityProvider: string;
|
||||
appMode: string | null;
|
||||
authUrl?: string;
|
||||
}) => {
|
||||
const urls: Record<string, string> = {
|
||||
gitlab: "https://gitlab.com/oauth/authorize",
|
||||
bitbucket: "https://bitbucket.org/site/oauth2/authorize",
|
||||
};
|
||||
if (config.appMode === "saas") {
|
||||
return urls[config.identityProvider] || null;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-tracking", () => ({
|
||||
useTracking: () => ({
|
||||
trackLoginButtonClick: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-config", () => ({
|
||||
useConfig: () => ({
|
||||
data: undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-recaptcha", () => ({
|
||||
useRecaptcha: () => ({
|
||||
isReady: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
executeRecaptcha: vi.fn().mockResolvedValue(null),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/utils/custom-toast-handlers", () => ({
|
||||
displayErrorToast: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("LoginContent", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("location", { href: "" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should render login content with heading", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LoginContent
|
||||
githubAuthUrl="https://github.com/oauth/authorize"
|
||||
appMode="saas"
|
||||
providersConfigured={["github", "gitlab", "bitbucket"]}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("login-content")).toBeInTheDocument();
|
||||
expect(screen.getByText("AUTH$LETS_GET_STARTED")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display all configured provider buttons", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LoginContent
|
||||
githubAuthUrl="https://github.com/oauth/authorize"
|
||||
appMode="saas"
|
||||
authUrl="https://auth.example.com"
|
||||
providersConfigured={["github", "gitlab", "bitbucket"]}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: "GITHUB$CONNECT_TO_GITHUB" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "GITLAB$CONNECT_TO_GITLAB" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const bitbucketButton = screen.getByRole("button", {
|
||||
name: /BITBUCKET\$CONNECT_TO_BITBUCKET/i,
|
||||
});
|
||||
expect(bitbucketButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should only display configured providers", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LoginContent
|
||||
githubAuthUrl="https://github.com/oauth/authorize"
|
||||
appMode="saas"
|
||||
providersConfigured={["github"]}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: "GITHUB$CONNECT_TO_GITHUB" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "GITLAB$CONNECT_TO_GITLAB" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display message when no providers are configured", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LoginContent
|
||||
githubAuthUrl="https://github.com/oauth/authorize"
|
||||
appMode="saas"
|
||||
providersConfigured={[]}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("AUTH$NO_PROVIDERS_CONFIGURED"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should redirect to GitHub auth URL when GitHub button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockUrl = "https://github.com/login/oauth/authorize";
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LoginContent
|
||||
githubAuthUrl={mockUrl}
|
||||
appMode="saas"
|
||||
providersConfigured={["github"]}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const githubButton = screen.getByRole("button", {
|
||||
name: "GITHUB$CONNECT_TO_GITHUB",
|
||||
});
|
||||
await user.click(githubButton);
|
||||
|
||||
// Wait for async handleAuthRedirect to complete
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe(mockUrl);
|
||||
});
|
||||
});
|
||||
|
||||
it("should display email verified message when emailVerified is true", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LoginContent
|
||||
githubAuthUrl="https://github.com/oauth/authorize"
|
||||
appMode="saas"
|
||||
providersConfigured={["github"]}
|
||||
emailVerified
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("AUTH$EMAIL_VERIFIED_PLEASE_LOGIN"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display duplicate email error when hasDuplicatedEmail is true", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LoginContent
|
||||
githubAuthUrl="https://github.com/oauth/authorize"
|
||||
appMode="saas"
|
||||
providersConfigured={["github"]}
|
||||
hasDuplicatedEmail
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("AUTH$DUPLICATE_EMAIL_ERROR")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display Terms and Privacy notice", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LoginContent
|
||||
githubAuthUrl="https://github.com/oauth/authorize"
|
||||
appMode="saas"
|
||||
providersConfigured={["github"]}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("terms-and-privacy-notice")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,551 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { ToolItem } from "#/components/features/conversation-panel/system-message-modal/tool-item";
|
||||
import type { ChatCompletionToolParam } from "#/types/v1/core";
|
||||
|
||||
describe("ToolItem", () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggleMock = vi.fn();
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Name/Title Extraction", () => {
|
||||
it("should display name from V0 format function.name", () => {
|
||||
// Arrange
|
||||
const v0Tool: ChatCompletionToolParam = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "test_function",
|
||||
description: "Test description",
|
||||
parameters: {},
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
renderWithProviders(
|
||||
<ToolItem
|
||||
tool={v0Tool}
|
||||
index={0}
|
||||
isExpanded={false}
|
||||
onToggle={onToggleMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert
|
||||
const toggleButton = screen.getByTestId("toggle-button");
|
||||
expect(toggleButton).toHaveTextContent("test_function");
|
||||
});
|
||||
|
||||
it("should display title from V1 format root level", () => {
|
||||
// Arrange
|
||||
const v1Tool = {
|
||||
title: "V1 Tool Title",
|
||||
description: "V1 description",
|
||||
parameters: { type: "object" },
|
||||
};
|
||||
|
||||
// Act
|
||||
renderWithProviders(
|
||||
<ToolItem
|
||||
tool={v1Tool}
|
||||
index={0}
|
||||
isExpanded={false}
|
||||
onToggle={onToggleMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert
|
||||
const toggleButton = screen.getByTestId("toggle-button");
|
||||
expect(toggleButton).toHaveTextContent("V1 Tool Title");
|
||||
});
|
||||
|
||||
it("should prioritize root title over annotations.title in V1 format", () => {
|
||||
// Arrange
|
||||
const v1Tool = {
|
||||
title: "Root Title",
|
||||
annotations: {
|
||||
title: "Annotations Title",
|
||||
},
|
||||
description: "Description",
|
||||
};
|
||||
|
||||
// Act
|
||||
renderWithProviders(
|
||||
<ToolItem
|
||||
tool={v1Tool}
|
||||
index={0}
|
||||
isExpanded={false}
|
||||
onToggle={onToggleMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert
|
||||
const toggleButton = screen.getByTestId("toggle-button");
|
||||
expect(toggleButton).toHaveTextContent("Root Title");
|
||||
});
|
||||
|
||||
it("should fallback to annotations.title when root title is missing", () => {
|
||||
// Arrange
|
||||
const v1Tool = {
|
||||
annotations: {
|
||||
title: "Annotations Title",
|
||||
},
|
||||
description: "Description",
|
||||
};
|
||||
|
||||
// Act
|
||||
renderWithProviders(
|
||||
<ToolItem
|
||||
tool={v1Tool}
|
||||
index={0}
|
||||
isExpanded={false}
|
||||
onToggle={onToggleMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert
|
||||
const toggleButton = screen.getByTestId("toggle-button");
|
||||
expect(toggleButton).toHaveTextContent("Annotations Title");
|
||||
});
|
||||
|
||||
it("should display empty string when no name or title is available", () => {
|
||||
// Arrange
|
||||
const toolWithoutName = {
|
||||
description: "Description only",
|
||||
};
|
||||
|
||||
// Act
|
||||
renderWithProviders(
|
||||
<ToolItem
|
||||
tool={toolWithoutName}
|
||||
index={0}
|
||||
isExpanded={false}
|
||||
onToggle={onToggleMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert
|
||||
const toggleButton = screen.getByTestId("toggle-button");
|
||||
expect(toggleButton).toHaveTextContent("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Description Extraction", () => {
|
||||
it("should display description from V0 format function.description when expanded", () => {
|
||||
// Arrange
|
||||
const v0Tool: ChatCompletionToolParam = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "test_function",
|
||||
description: "V0 function description",
|
||||
parameters: {},
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
renderWithProviders(
|
||||
<ToolItem
|
||||
tool={v0Tool}
|
||||
index={0}
|
||||
isExpanded={true}
|
||||
onToggle={onToggleMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert
|
||||
const markdownRenderer = screen.getByTestId("markdown-renderer");
|
||||
expect(markdownRenderer).toHaveTextContent("V0 function description");
|
||||
});
|
||||
|
||||
it("should display description from V1 format root level when expanded", () => {
|
||||
// Arrange
|
||||
const v1Tool = {
|
||||
title: "V1 Tool",
|
||||
description: "V1 root description",
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
// Act
|
||||
renderWithProviders(
|
||||
<ToolItem
|
||||
tool={v1Tool}
|
||||
index={0}
|
||||
isExpanded={true}
|
||||
onToggle={onToggleMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert
|
||||
const markdownRenderer = screen.getByTestId("markdown-renderer");
|
||||
expect(markdownRenderer).toHaveTextContent("V1 root description");
|
||||
});
|
||||
|
||||
it("should prioritize root description over function.description in V1 format", () => {
|
||||
// Arrange
|
||||
const v1Tool = {
|
||||
title: "V1 Tool",
|
||||
description: "Root description",
|
||||
function: {
|
||||
description: "Function description",
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
renderWithProviders(
|
||||
<ToolItem
|
||||
tool={v1Tool}
|
||||
index={0}
|
||||
isExpanded={true}
|
||||
onToggle={onToggleMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert
|
||||
const markdownRenderer = screen.getByTestId("markdown-renderer");
|
||||
expect(markdownRenderer).toHaveTextContent("Root description");
|
||||
});
|
||||
|
||||
it("should display empty string when no description is available", () => {
|
||||
// Arrange
|
||||
const toolWithoutDescription = {
|
||||
title: "Tool Name",
|
||||
};
|
||||
|
||||
// Act
|
||||
renderWithProviders(
|
||||
<ToolItem
|
||||
tool={toolWithoutDescription}
|
||||
index={0}
|
||||
isExpanded={true}
|
||||
onToggle={onToggleMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert
|
||||
const markdownRenderer = screen.getByTestId("markdown-renderer");
|
||||
expect(markdownRenderer).toHaveTextContent("");
|
||||
});
|
||||
|
||||
it("should not display description when collapsed", () => {
|
||||
// Arrange
|
||||
const v0Tool: ChatCompletionToolParam = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "test_function",
|
||||
description: "Should not be visible",
|
||||
parameters: {},
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
renderWithProviders(
|
||||
<ToolItem
|
||||
tool={v0Tool}
|
||||
index={0}
|
||||
isExpanded={false}
|
||||
onToggle={onToggleMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByTestId("markdown-renderer")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Parameters Extraction", () => {
|
||||
it("should display parameters from V0 format function.parameters when expanded", () => {
|
||||
// Arrange
|
||||
const v0Tool: ChatCompletionToolParam = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "test_function",
|
||||
description: "Description",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
param1: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
renderWithProviders(
|
||||
<ToolItem
|
||||
tool={v0Tool}
|
||||
index={0}
|
||||
isExpanded={true}
|
||||
onToggle={onToggleMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert
|
||||
const toolParameters = screen.getByTestId("tool-parameters");
|
||||
expect(toolParameters).toBeInTheDocument();
|
||||
// Verify that the parameters are rendered (ReactJsonView will render the JSON)
|
||||
expect(toolParameters).toHaveTextContent("param1");
|
||||
});
|
||||
|
||||
it("should display parameters from V1 format root level when expanded", () => {
|
||||
// Arrange
|
||||
const v1Tool = {
|
||||
title: "V1 Tool",
|
||||
description: "Description",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
param2: { type: "number" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
renderWithProviders(
|
||||
<ToolItem
|
||||
tool={v1Tool}
|
||||
index={0}
|
||||
isExpanded={true}
|
||||
onToggle={onToggleMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert
|
||||
const toolParameters = screen.getByTestId("tool-parameters");
|
||||
expect(toolParameters).toBeInTheDocument();
|
||||
// Verify that the parameters are rendered (ReactJsonView will render the JSON)
|
||||
expect(toolParameters).toHaveTextContent("param2");
|
||||
});
|
||||
|
||||
it("should prioritize function.parameters over root parameters in V0 format", () => {
|
||||
// Arrange
|
||||
const v0Tool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "test_function",
|
||||
description: "Description",
|
||||
parameters: {
|
||||
type: "object",
|
||||
source: "function",
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
type: "object",
|
||||
source: "root",
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
renderWithProviders(
|
||||
<ToolItem
|
||||
tool={v0Tool}
|
||||
index={0}
|
||||
isExpanded={true}
|
||||
onToggle={onToggleMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert
|
||||
const toolParameters = screen.getByTestId("tool-parameters");
|
||||
expect(toolParameters).toBeInTheDocument();
|
||||
// Verify that function parameters are used (not root parameters)
|
||||
expect(toolParameters).toHaveTextContent("function");
|
||||
expect(toolParameters).not.toHaveTextContent("root");
|
||||
});
|
||||
|
||||
it("should not display parameters when they are null", () => {
|
||||
// Arrange
|
||||
const toolWithoutParameters = {
|
||||
title: "Tool Name",
|
||||
description: "Description",
|
||||
};
|
||||
|
||||
// Act
|
||||
renderWithProviders(
|
||||
<ToolItem
|
||||
tool={toolWithoutParameters}
|
||||
index={0}
|
||||
isExpanded={true}
|
||||
onToggle={onToggleMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByTestId("tool-parameters")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not display parameters when collapsed", () => {
|
||||
// Arrange
|
||||
const v0Tool: ChatCompletionToolParam = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "test_function",
|
||||
description: "Description",
|
||||
parameters: {
|
||||
type: "object",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
renderWithProviders(
|
||||
<ToolItem
|
||||
tool={v0Tool}
|
||||
index={0}
|
||||
isExpanded={false}
|
||||
onToggle={onToggleMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByTestId("tool-parameters")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Toggle Functionality", () => {
|
||||
it("should call onToggle with correct index when toggle button is clicked", async () => {
|
||||
// Arrange
|
||||
const v0Tool: ChatCompletionToolParam = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "test_function",
|
||||
description: "Description",
|
||||
parameters: {},
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
renderWithProviders(
|
||||
<ToolItem
|
||||
tool={v0Tool}
|
||||
index={2}
|
||||
isExpanded={false}
|
||||
onToggle={onToggleMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const toggleButton = screen.getByTestId("toggle-button");
|
||||
await user.click(toggleButton);
|
||||
|
||||
// Assert
|
||||
expect(onToggleMock).toHaveBeenCalledOnce();
|
||||
expect(onToggleMock).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it("should show expanded content when isExpanded is true", () => {
|
||||
// Arrange
|
||||
const v0Tool: ChatCompletionToolParam = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "test_function",
|
||||
description: "Test description",
|
||||
parameters: { type: "object" },
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
renderWithProviders(
|
||||
<ToolItem
|
||||
tool={v0Tool}
|
||||
index={0}
|
||||
isExpanded={true}
|
||||
onToggle={onToggleMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId("markdown-renderer")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tool-parameters")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should hide expanded content when isExpanded is false", () => {
|
||||
// Arrange
|
||||
const v0Tool: ChatCompletionToolParam = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "test_function",
|
||||
description: "Test description",
|
||||
parameters: { type: "object" },
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
renderWithProviders(
|
||||
<ToolItem
|
||||
tool={v0Tool}
|
||||
index={0}
|
||||
isExpanded={false}
|
||||
onToggle={onToggleMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByTestId("markdown-renderer")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("tool-parameters")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Complex Scenarios", () => {
|
||||
it("should handle V0 format with type field correctly", () => {
|
||||
// Arrange
|
||||
const v0Tool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "typed_function",
|
||||
description: "Typed description",
|
||||
parameters: { type: "object" },
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
renderWithProviders(
|
||||
<ToolItem
|
||||
tool={v0Tool}
|
||||
index={0}
|
||||
isExpanded={true}
|
||||
onToggle={onToggleMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId("toggle-button")).toHaveTextContent(
|
||||
"typed_function",
|
||||
);
|
||||
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent(
|
||||
"Typed description",
|
||||
);
|
||||
expect(screen.getByTestId("tool-parameters")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle tool data where function is at root level (fallback behavior)", () => {
|
||||
// Arrange
|
||||
const toolWithFunctionAtRoot = {
|
||||
name: "root_function",
|
||||
description: "Root function description",
|
||||
parameters: { type: "object" },
|
||||
};
|
||||
|
||||
// Act
|
||||
renderWithProviders(
|
||||
<ToolItem
|
||||
tool={toolWithFunctionAtRoot}
|
||||
index={0}
|
||||
isExpanded={true}
|
||||
onToggle={onToggleMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId("toggle-button")).toHaveTextContent(
|
||||
"root_function",
|
||||
);
|
||||
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent(
|
||||
"Root function description",
|
||||
);
|
||||
expect(screen.getByTestId("tool-parameters")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,22 +1,49 @@
|
||||
import { screen, within } from "@testing-library/react";
|
||||
import { screen, within, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { ConversationName } from "#/components/features/conversation/conversation-name";
|
||||
import { ConversationNameContextMenu } from "#/components/features/conversation/conversation-name-context-menu";
|
||||
import { BrowserRouter } from "react-router";
|
||||
import type { Conversation } from "#/api/open-hands.types";
|
||||
|
||||
// Mock the hooks and utilities
|
||||
const mockMutate = vi.fn();
|
||||
|
||||
vi.mock("#/hooks/query/use-active-conversation", () => ({
|
||||
useActiveConversation: () => ({
|
||||
// Hoisted mocks for controllable return values
|
||||
const {
|
||||
mockMutate,
|
||||
mockDisplaySuccessToast,
|
||||
useActiveConversationMock,
|
||||
useConfigMock,
|
||||
} = vi.hoisted(() => ({
|
||||
mockMutate: vi.fn(),
|
||||
mockDisplaySuccessToast: vi.fn(),
|
||||
useActiveConversationMock: vi.fn(() => ({
|
||||
data: {
|
||||
conversation_id: "test-conversation-id",
|
||||
title: "Test Conversation",
|
||||
status: "RUNNING",
|
||||
},
|
||||
}),
|
||||
})),
|
||||
useConfigMock: vi.fn(() => ({
|
||||
data: {
|
||||
APP_MODE: "oss",
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-active-conversation", () => ({
|
||||
useActiveConversation: () => useActiveConversationMock(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-config", () => ({
|
||||
useConfig: () => useConfigMock(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/mutation/use-update-conversation", () => ({
|
||||
@@ -26,7 +53,7 @@ vi.mock("#/hooks/mutation/use-update-conversation", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("#/utils/custom-toast-handlers", () => ({
|
||||
displaySuccessToast: vi.fn(),
|
||||
displaySuccessToast: mockDisplaySuccessToast,
|
||||
}));
|
||||
|
||||
// Mock react-i18next
|
||||
@@ -47,6 +74,10 @@ vi.mock("react-i18next", async () => {
|
||||
COMMON$CLOSE_CONVERSATION_STOP_RUNTIME:
|
||||
"Close Conversation (Stop Runtime)",
|
||||
COMMON$DELETE_CONVERSATION: "Delete Conversation",
|
||||
CONVERSATION$SHARE_PUBLICLY: "Share Publicly",
|
||||
CONVERSATION$LINK_COPIED: "Link copied to clipboard",
|
||||
BUTTON$COPY_TO_CLIPBOARD: "Copy to Clipboard",
|
||||
BUTTON$OPEN_IN_NEW_TAB: "Open in New Tab",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
@@ -72,6 +103,9 @@ describe("ConversationName", () => {
|
||||
open: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
location: {
|
||||
origin: "http://localhost:3000",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -569,3 +603,313 @@ describe("ConversationNameContextMenu", () => {
|
||||
expect(onClose).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ConversationNameContextMenu - Share Link Functionality", () => {
|
||||
const { mockWriteText, featureFlagMock } = vi.hoisted(() => ({
|
||||
mockWriteText: vi.fn().mockResolvedValue(undefined),
|
||||
featureFlagMock: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
const mockOnCopyShareLink = vi.fn();
|
||||
const mockOnTogglePublic = vi.fn();
|
||||
const mockOnClose = vi.fn();
|
||||
|
||||
const defaultProps = {
|
||||
onClose: mockOnClose,
|
||||
onTogglePublic: mockOnTogglePublic,
|
||||
onCopyShareLink: mockOnCopyShareLink,
|
||||
shareUrl: "https://example.com/shared/conversations/test-id",
|
||||
};
|
||||
|
||||
vi.mock("#/utils/feature-flags", () => ({
|
||||
ENABLE_PUBLIC_CONVERSATION_SHARING: () => featureFlagMock(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/mutation/use-update-conversation-public-flag", () => ({
|
||||
useUpdateConversationPublicFlag: () => ({
|
||||
mutate: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
beforeAll(() => {
|
||||
// Mock navigator.clipboard
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: {
|
||||
writeText: mockWriteText,
|
||||
readText: vi.fn(),
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockWriteText.mockClear();
|
||||
mockDisplaySuccessToast.mockClear();
|
||||
featureFlagMock.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should display copy and open buttons when conversation is public", () => {
|
||||
// Arrange
|
||||
useActiveConversationMock.mockReturnValue({
|
||||
data: {
|
||||
conversation_id: "test-id",
|
||||
title: "Test Conversation",
|
||||
status: "STOPPED",
|
||||
conversation_version: "V1" as const,
|
||||
public: true,
|
||||
} as Conversation,
|
||||
});
|
||||
|
||||
useConfigMock.mockReturnValue({
|
||||
data: {
|
||||
APP_MODE: "saas",
|
||||
},
|
||||
});
|
||||
|
||||
// Act
|
||||
renderWithProviders(<ConversationNameContextMenu {...defaultProps} />);
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId("copy-share-link-button")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("open-share-link-button")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not display share buttons when conversation is not public", () => {
|
||||
// Arrange
|
||||
useActiveConversationMock.mockReturnValue({
|
||||
data: {
|
||||
conversation_id: "test-id",
|
||||
title: "Test Conversation",
|
||||
status: "STOPPED",
|
||||
conversation_version: "V1" as const,
|
||||
public: false,
|
||||
} as Conversation,
|
||||
});
|
||||
|
||||
useConfigMock.mockReturnValue({
|
||||
data: {
|
||||
APP_MODE: "saas",
|
||||
},
|
||||
});
|
||||
|
||||
// Act
|
||||
renderWithProviders(<ConversationNameContextMenu {...defaultProps} />);
|
||||
|
||||
// Assert
|
||||
expect(
|
||||
screen.queryByTestId("copy-share-link-button"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("open-share-link-button"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call copy handler when copy button is clicked", async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup();
|
||||
const shareUrl = "https://example.com/shared/conversations/test-id";
|
||||
|
||||
useActiveConversationMock.mockReturnValue({
|
||||
data: {
|
||||
conversation_id: "test-id",
|
||||
title: "Test Conversation",
|
||||
status: "STOPPED",
|
||||
conversation_version: "V1" as const,
|
||||
public: true,
|
||||
} as Conversation,
|
||||
});
|
||||
|
||||
useConfigMock.mockReturnValue({
|
||||
data: {
|
||||
APP_MODE: "saas",
|
||||
},
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<ConversationNameContextMenu {...defaultProps} shareUrl={shareUrl} />,
|
||||
);
|
||||
|
||||
const copyButton = screen.getByTestId("copy-share-link-button");
|
||||
|
||||
// Act
|
||||
await user.click(copyButton);
|
||||
|
||||
// Assert
|
||||
expect(mockOnCopyShareLink).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should have correct attributes for open share link button", () => {
|
||||
// Arrange
|
||||
const shareUrl = "https://example.com/shared/conversations/test-id";
|
||||
|
||||
useActiveConversationMock.mockReturnValue({
|
||||
data: {
|
||||
conversation_id: "test-id",
|
||||
title: "Test Conversation",
|
||||
status: "STOPPED",
|
||||
conversation_version: "V1" as const,
|
||||
public: true,
|
||||
} as Conversation,
|
||||
});
|
||||
|
||||
useConfigMock.mockReturnValue({
|
||||
data: {
|
||||
APP_MODE: "saas",
|
||||
},
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<ConversationNameContextMenu {...defaultProps} shareUrl={shareUrl} />,
|
||||
);
|
||||
|
||||
const openButton = screen.getByTestId("open-share-link-button");
|
||||
|
||||
// Assert
|
||||
expect(openButton).toHaveAttribute("href", shareUrl);
|
||||
expect(openButton).toHaveAttribute("target", "_blank");
|
||||
expect(openButton).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
|
||||
it("should display correct tooltips for share buttons", () => {
|
||||
// Arrange
|
||||
useActiveConversationMock.mockReturnValue({
|
||||
data: {
|
||||
conversation_id: "test-id",
|
||||
title: "Test Conversation",
|
||||
status: "STOPPED",
|
||||
conversation_version: "V1" as const,
|
||||
public: true,
|
||||
} as Conversation,
|
||||
});
|
||||
|
||||
useConfigMock.mockReturnValue({
|
||||
data: {
|
||||
APP_MODE: "saas",
|
||||
},
|
||||
});
|
||||
|
||||
renderWithProviders(<ConversationNameContextMenu {...defaultProps} />);
|
||||
|
||||
// Assert
|
||||
const copyButton = screen.getByTestId("copy-share-link-button");
|
||||
const openButton = screen.getByTestId("open-share-link-button");
|
||||
|
||||
expect(copyButton).toHaveAttribute("title", "Copy to Clipboard");
|
||||
expect(openButton).toHaveAttribute("title", "Open in New Tab");
|
||||
});
|
||||
|
||||
describe("Integration with ConversationName component", () => {
|
||||
beforeEach(() => {
|
||||
// Default mocks for public V1 conversation in SAAS mode
|
||||
useActiveConversationMock.mockReturnValue({
|
||||
data: {
|
||||
conversation_id: "test-conversation-id",
|
||||
title: "Test Conversation",
|
||||
status: "STOPPED",
|
||||
conversation_version: "V1" as const,
|
||||
public: true,
|
||||
} as Conversation,
|
||||
});
|
||||
|
||||
useConfigMock.mockReturnValue({
|
||||
data: {
|
||||
APP_MODE: "saas",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should copy share URL to clipboard and show success toast when copy button is clicked through ConversationName", async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup();
|
||||
const expectedUrl =
|
||||
"http://localhost:3000/shared/conversations/test-conversation-id";
|
||||
|
||||
// Ensure navigator.clipboard is properly mocked
|
||||
if (!navigator.clipboard) {
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: {
|
||||
writeText: mockWriteText,
|
||||
readText: vi.fn(),
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
} else {
|
||||
vi.spyOn(navigator.clipboard, "writeText").mockImplementation(
|
||||
mockWriteText,
|
||||
);
|
||||
}
|
||||
|
||||
renderConversationNameWithRouter();
|
||||
|
||||
// Open context menu by clicking ellipsis
|
||||
const ellipsisButton = screen.getByRole("button", { hidden: true });
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
// Wait for context menu to appear and find share publicly button
|
||||
const sharePubliclyButton = await screen.findByTestId(
|
||||
"share-publicly-button",
|
||||
);
|
||||
expect(sharePubliclyButton).toBeInTheDocument();
|
||||
|
||||
// Find copy button
|
||||
const copyButton = screen.getByTestId("copy-share-link-button");
|
||||
|
||||
// Act
|
||||
await user.click(copyButton);
|
||||
|
||||
// Assert - clipboard.writeText is async, so we need to wait
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(mockWriteText).toHaveBeenCalledWith(expectedUrl);
|
||||
expect(mockDisplaySuccessToast).toHaveBeenCalledWith(
|
||||
"Link copied to clipboard",
|
||||
);
|
||||
},
|
||||
{ timeout: 2000, container: document.body },
|
||||
);
|
||||
});
|
||||
|
||||
it("should not show share buttons when feature flag is disabled", () => {
|
||||
// Arrange
|
||||
featureFlagMock.mockReturnValue(false);
|
||||
|
||||
renderConversationNameWithRouter();
|
||||
|
||||
// Act - try to find share buttons (should not exist even if conversation is public)
|
||||
const copyButton = screen.queryByTestId("copy-share-link-button");
|
||||
const openButton = screen.queryByTestId("open-share-link-button");
|
||||
|
||||
// Assert
|
||||
expect(copyButton).not.toBeInTheDocument();
|
||||
expect(openButton).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show both copy and open buttons when conversation is public and feature flag is enabled", async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup();
|
||||
featureFlagMock.mockReturnValue(true);
|
||||
|
||||
renderConversationNameWithRouter();
|
||||
|
||||
// Act - open context menu
|
||||
const ellipsisButton = screen.getByRole("button", { hidden: true });
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
// Wait for context menu
|
||||
const sharePubliclyButton = await screen.findByTestId(
|
||||
"share-publicly-button",
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(sharePubliclyButton).toBeInTheDocument();
|
||||
expect(screen.getByTestId("copy-share-link-button")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("open-share-link-button")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter, Route, Routes } from "react-router";
|
||||
import { ConversationTabsContextMenu } from "#/components/features/conversation/conversation-tabs/conversation-tabs-context-menu";
|
||||
|
||||
function renderWithRouter(conversationId: string, onClose: () => void) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[`/conversations/${conversationId}`]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/conversations/:conversationId"
|
||||
element={<ConversationTabsContextMenu isOpen onClose={onClose} />}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ConversationTabsContextMenu", () => {
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("should use per-conversation localStorage key for unpinned tabs", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = () => {};
|
||||
|
||||
// Render for conversation-1
|
||||
const { unmount } = renderWithRouter("conversation-1", onClose);
|
||||
|
||||
// Unpin the terminal tab in conversation-1
|
||||
const terminalItem = screen.getByText("COMMON$TERMINAL");
|
||||
await user.click(terminalItem);
|
||||
|
||||
// Verify localStorage key is per-conversation
|
||||
const stored1 = JSON.parse(
|
||||
localStorage.getItem("conversation-unpinned-tabs-conversation-1") || "[]",
|
||||
);
|
||||
expect(stored1).toContain("terminal");
|
||||
|
||||
unmount();
|
||||
|
||||
// Switch to conversation-2
|
||||
renderWithRouter("conversation-2", onClose);
|
||||
|
||||
// conversation-2 should have its own empty state
|
||||
const stored2 = JSON.parse(
|
||||
localStorage.getItem("conversation-unpinned-tabs-conversation-2") || "[]",
|
||||
);
|
||||
expect(stored2).toEqual([]);
|
||||
|
||||
// conversation-1 state should still have terminal unpinned
|
||||
const stored1Again = JSON.parse(
|
||||
localStorage.getItem("conversation-unpinned-tabs-conversation-1") || "[]",
|
||||
);
|
||||
expect(stored1Again).toContain("terminal");
|
||||
});
|
||||
|
||||
it("should toggle tab pin state when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = () => {};
|
||||
|
||||
renderWithRouter("conversation-1", onClose);
|
||||
|
||||
const terminalItem = screen.getByText("COMMON$TERMINAL");
|
||||
|
||||
// Click to unpin
|
||||
await user.click(terminalItem);
|
||||
let stored = JSON.parse(
|
||||
localStorage.getItem("conversation-unpinned-tabs-conversation-1") || "[]",
|
||||
);
|
||||
expect(stored).toContain("terminal");
|
||||
|
||||
// Click again to pin
|
||||
await user.click(terminalItem);
|
||||
stored = JSON.parse(
|
||||
localStorage.getItem("conversation-unpinned-tabs-conversation-1") || "[]",
|
||||
);
|
||||
expect(stored).not.toContain("terminal");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { GitLabWebhookManagerState } from "#/components/features/settings/git-settings/gitlab-webhook-manager-state";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("GitLabWebhookManagerState", () => {
|
||||
it("should render title and message with translated keys", () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
titleKey: I18nKey.GITLAB$WEBHOOK_MANAGER_TITLE,
|
||||
messageKey: I18nKey.GITLAB$WEBHOOK_MANAGER_LOADING,
|
||||
};
|
||||
|
||||
// Act
|
||||
render(<GitLabWebhookManagerState {...props} />);
|
||||
|
||||
// Assert
|
||||
expect(
|
||||
screen.getByText(I18nKey.GITLAB$WEBHOOK_MANAGER_TITLE),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(I18nKey.GITLAB$WEBHOOK_MANAGER_LOADING),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should apply custom className to container", () => {
|
||||
// Arrange
|
||||
const customClassName = "custom-container-class";
|
||||
const props = {
|
||||
titleKey: I18nKey.GITLAB$WEBHOOK_MANAGER_TITLE,
|
||||
messageKey: I18nKey.GITLAB$WEBHOOK_MANAGER_LOADING,
|
||||
className: customClassName,
|
||||
};
|
||||
|
||||
// Act
|
||||
const { container } = render(<GitLabWebhookManagerState {...props} />);
|
||||
|
||||
// Assert
|
||||
const containerElement = container.firstChild as HTMLElement;
|
||||
expect(containerElement).toHaveClass(customClassName);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,416 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { GitLabWebhookManager } from "#/components/features/settings/git-settings/gitlab-webhook-manager";
|
||||
import { integrationService } from "#/api/integration-service/integration-service.api";
|
||||
import type {
|
||||
GitLabResource,
|
||||
ResourceInstallationResult,
|
||||
} from "#/api/integration-service/integration-service.types";
|
||||
import * as ToastHandlers from "#/utils/custom-toast-handlers";
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock toast handlers
|
||||
vi.mock("#/utils/custom-toast-handlers", () => ({
|
||||
displaySuccessToast: vi.fn(),
|
||||
displayErrorToast: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockResources: GitLabResource[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Test Project",
|
||||
full_path: "user/test-project",
|
||||
type: "project",
|
||||
webhook_installed: false,
|
||||
webhook_uuid: null,
|
||||
last_synced: null,
|
||||
},
|
||||
{
|
||||
id: "10",
|
||||
name: "Test Group",
|
||||
full_path: "test-group",
|
||||
type: "group",
|
||||
webhook_installed: true,
|
||||
webhook_uuid: "uuid-123",
|
||||
last_synced: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
describe("GitLabWebhookManager", () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderComponent = () => {
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<GitLabWebhookManager />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
it("should display loading state when fetching resources", async () => {
|
||||
// Arrange
|
||||
vi.spyOn(integrationService, "getGitLabResources").mockImplementation(
|
||||
() => new Promise(() => {}), // Never resolves
|
||||
);
|
||||
|
||||
// Act
|
||||
renderComponent();
|
||||
|
||||
// Assert
|
||||
expect(
|
||||
screen.getByText("GITLAB$WEBHOOK_MANAGER_LOADING"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display error state when fetching fails", async () => {
|
||||
// Arrange
|
||||
vi.spyOn(integrationService, "getGitLabResources").mockRejectedValue(
|
||||
new Error("Failed to fetch"),
|
||||
);
|
||||
|
||||
// Act
|
||||
renderComponent();
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("GITLAB$WEBHOOK_MANAGER_ERROR"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display no resources message when list is empty", async () => {
|
||||
// Arrange
|
||||
vi.spyOn(integrationService, "getGitLabResources").mockResolvedValue({
|
||||
resources: [],
|
||||
});
|
||||
|
||||
// Act
|
||||
renderComponent();
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("GITLAB$WEBHOOK_MANAGER_NO_RESOURCES"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display resources table when resources are available", async () => {
|
||||
// Arrange
|
||||
vi.spyOn(integrationService, "getGitLabResources").mockResolvedValue({
|
||||
resources: mockResources,
|
||||
});
|
||||
|
||||
// Act
|
||||
renderComponent();
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Project")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Group")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("user/test-project")).toBeInTheDocument();
|
||||
expect(screen.getByText("test-group")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display correct resource types in table", async () => {
|
||||
// Arrange
|
||||
vi.spyOn(integrationService, "getGitLabResources").mockResolvedValue({
|
||||
resources: mockResources,
|
||||
});
|
||||
|
||||
// Act
|
||||
renderComponent();
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
const projectType = screen.getByText("project");
|
||||
const groupType = screen.getByText("group");
|
||||
expect(projectType).toBeInTheDocument();
|
||||
expect(groupType).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should disable reinstall button when webhook is already installed", async () => {
|
||||
// Arrange
|
||||
vi.spyOn(integrationService, "getGitLabResources").mockResolvedValue({
|
||||
resources: [
|
||||
{
|
||||
id: "10",
|
||||
name: "Test Group",
|
||||
full_path: "test-group",
|
||||
type: "group",
|
||||
webhook_installed: true,
|
||||
webhook_uuid: "uuid-123",
|
||||
last_synced: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Act
|
||||
renderComponent();
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
const reinstallButton = screen.getByTestId(
|
||||
"reinstall-webhook-button-group:10",
|
||||
);
|
||||
expect(reinstallButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should enable reinstall button when webhook is not installed", async () => {
|
||||
// Arrange
|
||||
vi.spyOn(integrationService, "getGitLabResources").mockResolvedValue({
|
||||
resources: [
|
||||
{
|
||||
id: "1",
|
||||
name: "Test Project",
|
||||
full_path: "user/test-project",
|
||||
type: "project",
|
||||
webhook_installed: false,
|
||||
webhook_uuid: null,
|
||||
last_synced: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Act
|
||||
renderComponent();
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
const reinstallButton = screen.getByTestId(
|
||||
"reinstall-webhook-button-project:1",
|
||||
);
|
||||
expect(reinstallButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should call reinstall service when reinstall button is clicked", async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup();
|
||||
const reinstallSpy = vi.spyOn(integrationService, "reinstallGitLabWebhook");
|
||||
|
||||
vi.spyOn(integrationService, "getGitLabResources").mockResolvedValue({
|
||||
resources: [
|
||||
{
|
||||
id: "1",
|
||||
name: "Test Project",
|
||||
full_path: "user/test-project",
|
||||
type: "project",
|
||||
webhook_installed: false,
|
||||
webhook_uuid: null,
|
||||
last_synced: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Act
|
||||
renderComponent();
|
||||
const reinstallButton = await screen.findByTestId(
|
||||
"reinstall-webhook-button-project:1",
|
||||
);
|
||||
await user.click(reinstallButton);
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(reinstallSpy).toHaveBeenCalledWith({
|
||||
resource: {
|
||||
type: "project",
|
||||
id: "1",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should show loading state on button during reinstallation", async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup();
|
||||
let resolveReinstall: (value: ResourceInstallationResult) => void;
|
||||
const reinstallPromise = new Promise<ResourceInstallationResult>(
|
||||
(resolve) => {
|
||||
resolveReinstall = resolve;
|
||||
},
|
||||
);
|
||||
|
||||
vi.spyOn(integrationService, "reinstallGitLabWebhook").mockReturnValue(
|
||||
reinstallPromise,
|
||||
);
|
||||
|
||||
vi.spyOn(integrationService, "getGitLabResources").mockResolvedValue({
|
||||
resources: [
|
||||
{
|
||||
id: "1",
|
||||
name: "Test Project",
|
||||
full_path: "user/test-project",
|
||||
type: "project",
|
||||
webhook_installed: false,
|
||||
webhook_uuid: null,
|
||||
last_synced: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Act
|
||||
renderComponent();
|
||||
const reinstallButton = await screen.findByTestId(
|
||||
"reinstall-webhook-button-project:1",
|
||||
);
|
||||
await user.click(reinstallButton);
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("GITLAB$WEBHOOK_REINSTALLING"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
resolveReinstall!({
|
||||
resource_id: "1",
|
||||
resource_type: "project",
|
||||
success: true,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should display error message when reinstallation fails", async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup();
|
||||
const errorMessage = "Permission denied";
|
||||
vi.spyOn(integrationService, "reinstallGitLabWebhook").mockResolvedValue({
|
||||
resource_id: "1",
|
||||
resource_type: "project",
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
});
|
||||
|
||||
vi.spyOn(integrationService, "getGitLabResources").mockResolvedValue({
|
||||
resources: [
|
||||
{
|
||||
id: "1",
|
||||
name: "Test Project",
|
||||
full_path: "user/test-project",
|
||||
type: "project",
|
||||
webhook_installed: false,
|
||||
webhook_uuid: null,
|
||||
last_synced: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Act
|
||||
renderComponent();
|
||||
const reinstallButton = await screen.findByTestId(
|
||||
"reinstall-webhook-button-project:1",
|
||||
);
|
||||
await user.click(reinstallButton);
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(errorMessage)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display success toast when reinstallation succeeds", async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup();
|
||||
const displaySuccessToastSpy = vi.spyOn(
|
||||
ToastHandlers,
|
||||
"displaySuccessToast",
|
||||
);
|
||||
|
||||
vi.spyOn(integrationService, "reinstallGitLabWebhook").mockResolvedValue({
|
||||
resource_id: "1",
|
||||
resource_type: "project",
|
||||
success: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
vi.spyOn(integrationService, "getGitLabResources").mockResolvedValue({
|
||||
resources: [
|
||||
{
|
||||
id: "1",
|
||||
name: "Test Project",
|
||||
full_path: "user/test-project",
|
||||
type: "project",
|
||||
webhook_installed: false,
|
||||
webhook_uuid: null,
|
||||
last_synced: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Act
|
||||
renderComponent();
|
||||
const reinstallButton = await screen.findByTestId(
|
||||
"reinstall-webhook-button-project:1",
|
||||
);
|
||||
await user.click(reinstallButton);
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(displaySuccessToastSpy).toHaveBeenCalledWith(
|
||||
"GITLAB$WEBHOOK_REINSTALL_SUCCESS",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should display error toast when reinstallation throws error", async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup();
|
||||
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
|
||||
const errorMessage = "Network error";
|
||||
|
||||
vi.spyOn(integrationService, "reinstallGitLabWebhook").mockRejectedValue(
|
||||
new Error(errorMessage),
|
||||
);
|
||||
|
||||
vi.spyOn(integrationService, "getGitLabResources").mockResolvedValue({
|
||||
resources: [
|
||||
{
|
||||
id: "1",
|
||||
name: "Test Project",
|
||||
full_path: "user/test-project",
|
||||
type: "project",
|
||||
webhook_installed: false,
|
||||
webhook_uuid: null,
|
||||
last_synced: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Act
|
||||
renderComponent();
|
||||
const reinstallButton = await screen.findByTestId(
|
||||
"reinstall-webhook-button-project:1",
|
||||
);
|
||||
await user.click(reinstallButton);
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(displayErrorToastSpy).toHaveBeenCalledWith(errorMessage);
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user