-
Notifications
You must be signed in to change notification settings - Fork 53
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #760 from conda-incubator/add-user-journey-test
Add user journey test
- Loading branch information
Showing
8 changed files
with
299 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,83 @@ | ||
# User Journey Tests | ||
|
||
This repository contains user journey tests for the API. User journey tests | ||
are end-to-end tests that simulate real user scenarios to ensure the API | ||
functions correctly in different scenarios. | ||
|
||
These tests will use the high-privileged token to create a randomly-named | ||
namespace (using a UUID) to prevent conflicts with existing namespaces. At the | ||
end of the test, it will delete any environments created and then delete the | ||
namespace. | ||
|
||
## Prerequisites | ||
|
||
These tests are blackbox tests and need a running server to test against. This | ||
can be a local conda-store instance started using docker compose, or a remote | ||
instance. You will need the base url of the server and a token for an admin | ||
user to run these tests. | ||
|
||
## Setup | ||
|
||
### Local setup | ||
|
||
To run locally using docker compose all you need to do is start conda-store. | ||
|
||
From the project base, run `docker compose up`. | ||
|
||
### Remote setup | ||
|
||
To run these tests against a remote server, you need to set 2 environment | ||
variables: | ||
|
||
1. `CONDA_STORE_BASE_URL` - this is the base url of your conda-store-server. | ||
|
||
For example, if you access your conda-store-server at `https://example.com`, | ||
you would run `export CONDA_STORE_BASE_URL='https://example.com'`. | ||
|
||
**Do not include the `/conda-store/` suffix.** | ||
|
||
Do include the port if needed. | ||
For example: `export CONDA_STORE_BASE_URL='http://localhost:8080'`. | ||
|
||
2. `CONDA_STORE_TOKEN` - this should be the token of an admin user. | ||
|
||
This token will let the tests create the tokens, permissions, namespaces, | ||
and environments needed for these tests to run successfully. | ||
|
||
To generate a token, while logged in as a high-privileged user, go to | ||
`https://<your-conda-store-url>/conda-store/admin/user/` and click on | ||
`Create token`. | ||
|
||
Copy that token value and export it: | ||
`export CONDA_STORE_TOKEN='my_token_value'`. | ||
|
||
## Running the tests | ||
|
||
To run the tests, run `pytest -m user_journey` from the `conda-store-server` | ||
directory. | ||
|
||
## Current scenarios tested | ||
|
||
* An admin user can create a simple environment in a shared namespace and, once | ||
the environment is built, can delete the environment. | ||
|
||
## Planned scenarios to be implemented | ||
|
||
* An admin can create a complex environment in a shared namespace and, once the | ||
environment is built, can delete the environment | ||
|
||
* A developer can create a simple environment in a shared namespace and, once | ||
the environment is built, can delete the environment | ||
|
||
* A developer can create a complex environment in a shared namespace and, once | ||
the environment is built, can delete the environment | ||
|
||
* A developer can create an environment in a shared namespace and, once the | ||
environment is built, can modify the environment, then can mark the first | ||
build as active | ||
|
||
* A developer can create a simple environment in a shared namespace and, once | ||
the environment is built, can get the lockfile for the environment | ||
|
||
* A developer can create a failing environment in a shared namespace and, once | ||
the environment has failed, can get the logs for the failed build. |
6 changes: 6 additions & 0 deletions
6
conda-store-server/tests/user_journeys/test_data/simple_environment.yaml
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
name: simple-test-environment | ||
channels: | ||
- conda-forge | ||
dependencies: | ||
- python ==3.10 | ||
- fastapi |
43 changes: 43 additions & 0 deletions
43
conda-store-server/tests/user_journeys/test_user_journeys.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,43 @@ | ||
"""User journey tests for the API.""" | ||
import os | ||
|
||
import pytest | ||
import utils.api_utils as utils | ||
|
||
|
||
@pytest.fixture(scope="session") | ||
def base_url() -> str: | ||
"""Get the base URL for the API.""" | ||
base = os.getenv("CONDA_STORE_BASE_URL", "http://localhost:8080") | ||
return f"{base}/conda-store" | ||
|
||
|
||
@pytest.fixture(scope="session") | ||
def token(base_url) -> str: | ||
"""Get the token for the API.""" | ||
return os.getenv("CONDA_STORE_TOKEN", "") | ||
|
||
|
||
@pytest.mark.user_journey | ||
@pytest.mark.parametrize( | ||
"specification_path", | ||
[ | ||
("tests/user_journeys/test_data/simple_environment.yaml"), | ||
], | ||
) | ||
def test_admin_user_can_create_environment( | ||
base_url: str, token: str, specification_path: str | ||
) -> None: | ||
"""Test that an admin user can create an environment.""" | ||
namespace = utils.API.gen_random_namespace() | ||
api = utils.API(base_url=base_url, token=token) | ||
api.create_namespace(namespace) | ||
response = api.create_environment(namespace, specification_path) | ||
data = response.json()["data"] | ||
assert "build_id" in data | ||
build_id = data["build_id"] | ||
assert build_id is not None | ||
build = api.wait_for_successful_build(build_id) | ||
environment_name = build.json()["data"]["specification"]["name"] | ||
api.delete_environment(namespace, environment_name) | ||
api.delete_namespace(namespace) |
Empty file.
133 changes: 133 additions & 0 deletions
133
conda-store-server/tests/user_journeys/utils/api_utils.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,133 @@ | ||
"""Helper functions for user journeys.""" | ||
import time | ||
import uuid | ||
from enum import Enum | ||
|
||
import requests | ||
import utils.time_utils as time_utils | ||
|
||
TIMEOUT = 10 | ||
|
||
|
||
class BuildStatus(Enum): | ||
"""Enum for API build status.""" | ||
QUEUED = "QUEUED" | ||
BUILDING = "BUILDING" | ||
COMPLETED = "COMPLETED" | ||
FAILED = "FAILED" | ||
|
||
|
||
class API: | ||
""" | ||
Helper class for making requests to the API. | ||
These methods are used to build tests for user journeys | ||
""" | ||
|
||
def __init__(self, | ||
base_url: str, token: str = "", | ||
username: str = "username", password: str = "password" | ||
) -> None: | ||
self.base_url = base_url | ||
self.token = token | ||
if not token: | ||
# Log in if no token is provided to set the token | ||
self._login(username, password) | ||
|
||
def _make_request( | ||
self, endpoint: str, method: str = "GET", json_data: dict = None, | ||
headers: dict = None, timeout: int = TIMEOUT) -> requests.Response: | ||
""" Make a request to the API. """ | ||
url = f"{self.base_url}/{endpoint}" | ||
headers = headers or {} | ||
headers["Authorization"] = f"Bearer {self.token}" | ||
response = requests.request( | ||
method, url, json=json_data, headers=headers, timeout=timeout) | ||
response.raise_for_status() | ||
return response | ||
|
||
def _login(self, username: str, password: str) -> None: | ||
""" Log in to the API and set an access token.""" | ||
json_data = {"username": username, "password": password} | ||
response = requests.post( | ||
f"{self.base_url}/login", json=json_data, timeout=TIMEOUT) | ||
cookies = response.cookies.get_dict() | ||
token_response = requests.post( | ||
f"{self.base_url}/api/v1/token", cookies=cookies, timeout=TIMEOUT) | ||
data = token_response.json() | ||
self.token = data["data"]["token"] | ||
|
||
def create_namespace(self, namespace: str) -> requests.Response: | ||
""" Create a namespace.""" | ||
return self._make_request( | ||
f"api/v1/namespace/{namespace}", method="POST") | ||
|
||
def create_token( | ||
self, namespace: str, role: str, | ||
default_namespace: str = "default" | ||
) -> requests.Response: | ||
""" Create a token with a specified role in a specified namespace. """ | ||
json_data = { | ||
"primary_namespace": default_namespace, | ||
"expiration": time_utils.get_iso8601_time(1), | ||
"role_bindings": { | ||
f"{namespace}/*": [role] | ||
} | ||
} | ||
return self._make_request( | ||
"api/v1/token", method="POST", json_data=json_data) | ||
|
||
def create_environment( | ||
self, namespace: str, specification_path: str | ||
) -> requests.Response: | ||
""" | ||
Create an environment. | ||
The environment specification is read | ||
from a conda environment.yaml file. | ||
""" | ||
with open(specification_path, "r", encoding="utf-8") as file: | ||
specification_content = file.read() | ||
|
||
json_data = { | ||
"namespace": namespace, | ||
"specification": specification_content | ||
} | ||
|
||
return self._make_request( | ||
"api/v1/specification", method="POST", json_data=json_data) | ||
|
||
def wait_for_successful_build( | ||
self, build_id: str, | ||
max_iterations: int = 100, sleep_time: int = 5 | ||
) -> requests.Response: | ||
""" Wait for a build to complete.""" | ||
status = BuildStatus.QUEUED.value | ||
iterations = 0 | ||
while status != BuildStatus.COMPLETED.value: | ||
if iterations > max_iterations: | ||
raise TimeoutError("Timed out waiting for build") | ||
response = self._make_request( | ||
f"api/v1/build/{build_id}", method="GET") | ||
status = response.json()["data"]["status"] | ||
assert status != BuildStatus.FAILED.value, "Build failed" | ||
iterations += 1 | ||
time.sleep(sleep_time) | ||
return response | ||
|
||
def delete_environment( | ||
self, namespace: str, environment_name: str | ||
) -> requests.Response: | ||
""" Delete an environment.""" | ||
return self._make_request( | ||
f"api/v1/environment/{namespace}/{environment_name}", | ||
method="DELETE") | ||
|
||
def delete_namespace(self, namespace: str) -> requests.Response: | ||
""" Delete a namespace.""" | ||
return self._make_request( | ||
f"api/v1/namespace/{namespace}", method="DELETE" | ||
) | ||
|
||
@staticmethod | ||
def gen_random_namespace() -> str: | ||
""" Generate a random namespace.""" | ||
return uuid.uuid4().hex |
20 changes: 20 additions & 0 deletions
20
conda-store-server/tests/user_journeys/utils/time_utils.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
from datetime import datetime, timedelta, timezone | ||
|
||
|
||
def get_current_time() -> datetime: | ||
""" Get the current time. """ | ||
return datetime.now(timezone.utc) | ||
|
||
|
||
def get_time_in_future(hours: int) -> datetime: | ||
""" Get the time in the future.""" | ||
current_time = get_current_time() | ||
future_time = current_time + timedelta(hours=hours) | ||
return future_time | ||
|
||
|
||
def get_iso8601_time(hours: int) -> str: | ||
""" Get the time in the future in ISO 8601 format. """ | ||
future_time = get_time_in_future(hours) | ||
iso_format = future_time.isoformat() | ||
return iso_format |