Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -188,3 +188,6 @@ dev/

# Database files
*.sqlite

# A file sometimes leftover by the e2e tests
lightspeed-stack.yaml.backup
17 changes: 17 additions & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,23 @@ services:
retries: 3 # how many times to retry before marking as unhealthy
start_period: 5s # time to wait before starting checks

# This server is used for the e2e JWK tests
test-jwk-server:
# Conveniently uses the same Containerfile as lightspeed-stack as it has
# Python installed
build:
context: .
dockerfile: Containerfile
container_name: test-jwk-server
ports:
- "16161:16161"
networks:
- lightspeednet
working_dir: /app-root/test_jwk
entrypoint: ["python3", "-m", "http.server", "16161"]
volumes:
- ./tests/e2e/configuration/test_jwk:/app-root/test_jwk:Z

networks:
lightspeednet:
driver: bridge
33 changes: 33 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,40 @@ End to end tests are based on [Behave](https://behave.readthedocs.io/en/stable/)

* Defined in [tests/e2e](https://github.com/lightspeed-core/lightspeed-stack/tree/main/tests/e2e)

### Prerequisites for E2E tests

E2E tests require running services. You may run them using `podman-compose` or `docker-compose`.

Some tests also require an OpenAI API key. You can run specific tests that do not require an OpenAI API key without it.

First you need to build the images:

```bash
podman-compose build
```

Then to start the services:

```bash
OPENAI_API_KEY=your-api-key podman-compose up
```

And finally to run the actual tests:

```bash
make test-e2e
```

If using `podman`, set `CONTAINER_CMD` accordingly:

```bash
CONTAINER_CMD=podman make test-e2e
```

**Example of running a particular test with podman and live output:**
```bash
CONTAINER_CMD=podman uv run behave tests/e2e/features/authorization_jwk.feature --verbose --no-capture --no-capture-stderr
```

## Tips and hints

Expand Down
52 changes: 52 additions & 0 deletions tests/e2e/configuration/lightspeed-stack-auth-jwk.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: Lightspeed Core Service (LCS)
service:
host: 0.0.0.0
port: 8080
auth_enabled: false
workers: 1
color_log: true
access_log: true
llama_stack:
# Uses a remote llama-stack service
# The instance would have already been started with a llama-stack-run.yaml file
use_as_library_client: false
# Alternative for "as library use"
# use_as_library_client: true
# library_client_config_path: <path-to-llama-stack-run.yaml-file>
url: http://llama-stack:8321
api_key: xyzzy
user_data_collection:
feedback_enabled: true
feedback_storage: "/tmp/data/feedback"
transcripts_enabled: true
transcripts_storage: "/tmp/data/transcripts"

authentication:
module: "jwk-token"
jwk_config:
url: "http://test-jwk-server:16161/jwk.json"
jwt_configuration:
user_id_claim: "user_id"
username_claim: "username"
role_rules:
- jsonpath: "$.roles[*]"
operator: "contains"
value: "admin"
roles: ["administrator"]
- jsonpath: "$.roles[*]"
operator: "contains"
value: "config"
roles: ["config"]
- jsonpath: "$.roles[*]"
operator: "contains"
value: "readonly"
roles: ["readonly"]

authorization:
access_rules:
- role: "administrator"
actions: ["admin"]
- role: "config"
actions: ["get_config", "info"]
- role: "readonly"
actions: ["info"]
2 changes: 2 additions & 0 deletions tests/e2e/configuration/test_jwk/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Ignore JWKs created for e2e tests
*.json
39 changes: 39 additions & 0 deletions tests/e2e/features/authorization_jwk.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
@JWKAuth
Feature: JWK authorization enforcement

Background:
Given The service is started locally
And REST API service hostname is localhost
And REST API service port is 8080
And REST API service prefix is /v1

Scenario: A user with the admin role can access the info endpoint
Given I have a valid JWT token with the admin role
When I access REST API endpoint "info" using HTTP GET method
Then The status code of the response is 200

Scenario: A user with the admin role can access the config endpoint
Given I have a valid JWT token with the admin role
When I access REST API endpoint "config" using HTTP GET method
Then The status code of the response is 200

Scenario: A user with the config role can access the config endpoint
Given I have a valid JWT token with the config role
When I access REST API endpoint "config" using HTTP GET method
Then The status code of the response is 200

Scenario: A user with the config role can access the info endpoint
Given I have a valid JWT token with the config role
When I access REST API endpoint "info" using HTTP GET method
Then The status code of the response is 200

Scenario: A user with the readonly role can access the info endpoint
Given I have a valid JWT token with the readonly role
When I access REST API endpoint "info" using HTTP GET method
Then The status code of the response is 200

Scenario: A user with the readonly role can't access the config endpoint
Given I have a valid JWT token with the readonly role
When I access REST API endpoint "config" using HTTP GET method
Then The status code of the response is 403
And The body of the response contains Insufficient permissions
23 changes: 20 additions & 3 deletions tests/e2e/features/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@
4. after_scenario
"""

import os
import requests
import subprocess
import time
import json
from behave.model import Scenario, Feature
from behave.runner import Context

Expand All @@ -25,6 +27,8 @@
except ImportError as e:
print("Warning: unable to import module:", e)

from tests.unit.authentication.test_jwk_token import create_jwks_keys, make_key


def before_all(context: Context) -> None:
"""Run before and after the whole shooting match."""
Expand Down Expand Up @@ -55,7 +59,7 @@ def after_scenario(context: Context, scenario: Scenario) -> None:
try:
# Start the llama-stack container again
subprocess.run(
["docker", "start", "llama-stack"], check=True, capture_output=True
[os.getenv("CONTAINER_CMD", "docker"), "start", "llama-stack"],
)

# Wait for the service to be healthy
Expand All @@ -67,7 +71,7 @@ def after_scenario(context: Context, scenario: Scenario) -> None:
try:
result = subprocess.run(
[
"docker",
os.getenv("CONTAINER_CMD", "docker"),
"exec",
"llama-stack",
"curl",
Expand Down Expand Up @@ -108,13 +112,26 @@ def before_feature(context: Context, feature: Feature) -> None:
switch_config(context.feature_config)
restart_container("lightspeed-stack")

elif "JWKAuth" in feature.tags:
context.feature_config = (
"tests/e2e/configuration/lightspeed-stack-auth-jwk.yaml"
)
with open("tests/e2e/configuration/test_jwk/jwk.json", "w") as f:
context.test_key = make_key()
keys = create_jwks_keys([context.test_key], ["RS256"])
f.write(json.dumps(keys))

context.default_config_backup = create_config_backup("lightspeed-stack.yaml")
switch_config("tests/e2e/configuration/lightspeed-stack-auth-jwk.yaml")
restart_container("lightspeed-stack")

if "Feedback" in feature.tags:
context.feedback_conversations = []


def after_feature(context: Context, feature: Feature) -> None:
"""Run after each feature file is exercised."""
if "Authorized" in feature.tags:
if "Authorized" in feature.tags or "JWKAuth" in feature.tags:
switch_config(context.default_config_backup)
restart_container("lightspeed-stack")
remove_config_backup(context.default_config_backup)
Expand Down
13 changes: 11 additions & 2 deletions tests/e2e/features/steps/health.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Implementation of common test steps."""

import os
import subprocess
import time
from behave import given # pyright: ignore[reportAttributeAccessIssue]
Expand All @@ -14,7 +15,13 @@ def llama_stack_connection_broken(context: Context) -> None:

try:
result = subprocess.run(
["docker", "inspect", "-f", "{{.State.Running}}", "llama-stack"],
[
os.getenv("CONTAINER_CMD", "docker"),
"inspect",
"-f",
"{{.State.Running}}",
"llama-stack",
],
capture_output=True,
text=True,
check=True,
Expand All @@ -23,7 +30,9 @@ def llama_stack_connection_broken(context: Context) -> None:
if result.stdout.strip():
context.llama_stack_was_running = True
subprocess.run(
["docker", "stop", "llama-stack"], check=True, capture_output=True
[os.getenv("CONTAINER_CMD", "docker"), "stop", "llama-stack"],
check=True,
capture_output=True,
)

# Wait a moment for the connection to be fully disrupted
Expand Down
41 changes: 41 additions & 0 deletions tests/e2e/features/steps/jwk_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""JWK auth steps - reusing unit test primitives."""

from pathlib import Path
import sys

from behave import given # pyright: ignore[reportAttributeAccessIssue]
from behave.runner import Context


from authlib.jose import JsonWebToken

sys.path.append(str(Path(__file__).resolve().parents[4]))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Avoid sys.path.append for imports.

Manipulating sys.path at runtime is fragile and pollutes the global import state. Instead, structure your imports using relative imports or ensure the package is properly installed.

Apply this diff to use a relative import:

-sys.path.append(str(Path(__file__).resolve().parents[4]))
-
-# Import at runtime to avoid module load issues
 from tests.unit.authentication.test_jwk_token import (
     create_token_header,
     create_token_payload,
 )

If this approach causes import errors, verify that the test runner is invoked from the repository root or that the package structure includes proper __init__.py files.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
sys.path.append(str(Path(__file__).resolve().parents[4]))
from tests.unit.authentication.test_jwk_token import (
create_token_header,
create_token_payload,
)
🤖 Prompt for AI Agents
In tests/e2e/features/steps/jwk_auth.py around line 12, remove the runtime
sys.path.append modification and replace it with proper package-relative imports
(or absolute imports assuming the package is installed); update the import
statements in this file to use relative form (e.g., from ...module import X) or
full package paths, ensure any needed __init__.py files exist in the package
directories, and confirm the test runner is executed from the repository root
(or install the package in editable mode) so imports resolve without modifying
sys.path.


# Import at runtime to avoid module load issues
from tests.unit.authentication.test_jwk_token import (
create_token_header,
create_token_payload,
)


@given("I have a valid JWT token with the {role} role")
def create_role_token(context: Context, role: str) -> None:
"""Create token with role using the shared test key."""
test_key = context.test_key

header = create_token_header(test_key["kid"])
payload = create_token_payload()

# This works thanks to the definitions in lightspeed-stack-auth-jwk.yaml
payload["roles"] = [role] # Add role to existing payload

token = (
JsonWebToken(algorithms=["RS256"])
.encode(header, payload, test_key["private_key"])
.decode()
)

if not hasattr(context, "auth_headers"):
context.auth_headers = {}

context.auth_headers["Authorization"] = f"Bearer {token}"
1 change: 1 addition & 0 deletions tests/e2e/test_list.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ features/info.feature
features/query.feature
features/streaming_query.feature
features/rest_api.feature
features/authorization_jwk.feature
4 changes: 2 additions & 2 deletions tests/e2e/utils/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def wait_for_container_health(container_name: str, max_attempts: int = 3) -> Non
try:
result = subprocess.run(
[
"docker",
os.getenv("CONTAINER_CMD", "docker"),
"inspect",
"--format={{.State.Health.Status}}",
container_name,
Expand Down Expand Up @@ -130,7 +130,7 @@ def restart_container(container_name: str) -> None:
"""Restart a Docker container by name and wait until it is healthy."""
try:
subprocess.run(
["docker", "restart", container_name],
[os.getenv("CONTAINER_CMD", "docker"), "restart", container_name],
capture_output=True,
text=True,
check=True,
Expand Down
Loading
Loading