Skip to content

Commit cdec44c

Browse files
committed
LCORE-598: Add authorization e2e tests
This commit adds end-to-end tests for the authorization functionality. # Implementation details - Modified `.gitignore` to ignore leftover config backup files (unrelated to this change but useful) - Updated testing documentation to include instructions for running e2e tests - Added a JWK server container to `docker-compose.yaml` which is needed for e2e testing JWK auth - Added a `tests/e2e/configuration/lightspeed-stack-auth-jwk.yaml` config file for lightspeed-stack with JWK auth enabled to be used in the JWK e2e tests - Removed hard dependency on the `docker` command in e2e tests and instead use the `CONTAINER_CMD` environment variable if set (to allow using `podman` instead of `docker`) - Added `tests/e2e/features/authorization_jwk.feature` which contains the actual e2e tests for JWK authz - Added `tests/e2e/features/steps/jwk_auth.py` which implements the steps for the JWK authz tests - Modified `tests/e2e/features/environment.py` to handle the new JWK authz tests, including creating a temporary JWK key pair for the tests and writing the public key to a file served by the JWK server container (which lightspeed-stack is directed to access through the config file mentioned above) functionality to functions that can be reused in the e2e tests - Added `tests/e2e/configuration/test_jwk/.gitignore` to ignore generated JWK files - Updated `tests/e2e/test_list.txt` to include the new JWK authz tests
1 parent 690a6bc commit cdec44c

File tree

12 files changed

+264
-31
lines changed

12 files changed

+264
-31
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,3 +188,6 @@ dev/
188188

189189
# Database files
190190
*.sqlite
191+
192+
# A file sometimes leftover by the e2e tests
193+
lightspeed-stack.yaml.backup

docker-compose.yaml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,23 @@ services:
4242
retries: 3 # how many times to retry before marking as unhealthy
4343
start_period: 5s # time to wait before starting checks
4444

45+
# This server is used for the e2e JWK tests
46+
test-jwk-server:
47+
# Conveniently uses the same Containerfile as lightspeed-stack as it has
48+
# Python installed
49+
build:
50+
context: .
51+
dockerfile: Containerfile
52+
container_name: test-jwk-server
53+
ports:
54+
- "16161:16161"
55+
networks:
56+
- lightspeednet
57+
working_dir: /app-root/test_jwk
58+
entrypoint: ["python3", "-m", "http.server", "16161"]
59+
volumes:
60+
- ./tests/e2e/configuration/test_jwk:/app-root/test_jwk:Z
61+
4562
networks:
4663
lightspeednet:
4764
driver: bridge

docs/testing.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,40 @@ End to end tests are based on [Behave](https://behave.readthedocs.io/en/stable/)
141141

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

144+
### Prerequisites for E2E tests
144145

146+
E2E tests require running services. You may run them using `podman-compose` or `docker-compose`.
147+
148+
Some tests also require an OpenAI API key. You can run specific tests that do not require an OpenAI API key without it.
149+
150+
First you need to build the images:
151+
152+
```bash
153+
podman-compose build
154+
```
155+
156+
Then to start the services:
157+
158+
```bash
159+
OPENAI_API_KEY=your-api-key podman-compose up
160+
```
161+
162+
And finally to run the actual tests:
163+
164+
```bash
165+
make test-e2e
166+
```
167+
168+
If using `podman`, set `CONTAINER_CMD` accordingly:
169+
170+
```bash
171+
CONTAINER_CMD=podman make test-e2e
172+
```
173+
174+
**Example of running a particular test with podman and live output:**
175+
```bash
176+
CONTAINER_CMD=podman uv run behave tests/e2e/features/authorization_jwk.feature --verbose --no-capture --no-capture-stderr
177+
```
145178

146179
## Tips and hints
147180

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
name: Lightspeed Core Service (LCS)
2+
service:
3+
host: 0.0.0.0
4+
port: 8080
5+
auth_enabled: false
6+
workers: 1
7+
color_log: true
8+
access_log: true
9+
llama_stack:
10+
# Uses a remote llama-stack service
11+
# The instance would have already been started with a llama-stack-run.yaml file
12+
use_as_library_client: false
13+
# Alternative for "as library use"
14+
# use_as_library_client: true
15+
# library_client_config_path: <path-to-llama-stack-run.yaml-file>
16+
url: http://llama-stack:8321
17+
api_key: xyzzy
18+
user_data_collection:
19+
feedback_enabled: true
20+
feedback_storage: "/tmp/data/feedback"
21+
transcripts_enabled: true
22+
transcripts_storage: "/tmp/data/transcripts"
23+
24+
authentication:
25+
module: "jwk-token"
26+
jwk_config:
27+
url: "http://test-jwk-server:16161/jwk.json"
28+
jwt_configuration:
29+
user_id_claim: "user_id"
30+
username_claim: "username"
31+
role_rules:
32+
- jsonpath: "$.roles[*]"
33+
operator: "contains"
34+
value: "admin"
35+
roles: ["administrator"]
36+
- jsonpath: "$.roles[*]"
37+
operator: "contains"
38+
value: "config"
39+
roles: ["config"]
40+
- jsonpath: "$.roles[*]"
41+
operator: "contains"
42+
value: "readonly"
43+
roles: ["readonly"]
44+
45+
authorization:
46+
access_rules:
47+
- role: "administrator"
48+
actions: ["admin"]
49+
- role: "config"
50+
actions: ["get_config", "info"]
51+
- role: "readonly"
52+
actions: ["info"]
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Ignore JWKs created for e2e tests
2+
*.json
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
@JWKAuth
2+
Feature: JWK authorization enforcement
3+
4+
Background:
5+
Given The service is started locally
6+
And REST API service hostname is localhost
7+
And REST API service port is 8080
8+
And REST API service prefix is /v1
9+
10+
Scenario: A user with the admin role can access the info endpoint
11+
Given I have a valid JWT token with the admin role
12+
When I access REST API endpoint "info" using HTTP GET method
13+
Then The status code of the response is 200
14+
15+
Scenario: A user with the admin role can access the config endpoint
16+
Given I have a valid JWT token with the admin role
17+
When I access REST API endpoint "config" using HTTP GET method
18+
Then The status code of the response is 200
19+
20+
Scenario: A user with the config role can access the config endpoint
21+
Given I have a valid JWT token with the config role
22+
When I access REST API endpoint "config" using HTTP GET method
23+
Then The status code of the response is 200
24+
25+
Scenario: A user with the config role can access the info endpoint
26+
Given I have a valid JWT token with the config role
27+
When I access REST API endpoint "info" using HTTP GET method
28+
Then The status code of the response is 200
29+
30+
Scenario: A user with the readonly role can access the info endpoint
31+
Given I have a valid JWT token with the readonly role
32+
When I access REST API endpoint "info" using HTTP GET method
33+
Then The status code of the response is 200
34+
35+
Scenario: A user with the readonly role can't access the config endpoint
36+
Given I have a valid JWT token with the readonly role
37+
When I access REST API endpoint "config" using HTTP GET method
38+
Then The status code of the response is 403
39+
And The body of the response contains Insufficient permissions

tests/e2e/features/environment.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@
77
4. after_scenario
88
"""
99

10+
import os
1011
import requests
1112
import subprocess
1213
import time
14+
import json
1315
from behave.model import Scenario, Feature
1416
from behave.runner import Context
1517

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

30+
from tests.unit.authentication.test_jwk_token import create_jwks_keys, make_key
31+
2832

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

6165
# Wait for the service to be healthy
@@ -67,7 +71,7 @@ def after_scenario(context: Context, scenario: Scenario) -> None:
6771
try:
6872
result = subprocess.run(
6973
[
70-
"docker",
74+
os.getenv("CONTAINER_CMD", "docker"),
7175
"exec",
7276
"llama-stack",
7377
"curl",
@@ -108,13 +112,26 @@ def before_feature(context: Context, feature: Feature) -> None:
108112
switch_config(context.feature_config)
109113
restart_container("lightspeed-stack")
110114

115+
elif "JWKAuth" in feature.tags:
116+
context.feature_config = (
117+
"tests/e2e/configuration/lightspeed-stack-auth-jwk.yaml"
118+
)
119+
with open("tests/e2e/configuration/test_jwk/jwk.json", "w") as f:
120+
context.test_key = make_key()
121+
keys = create_jwks_keys([context.test_key], ["RS256"])
122+
f.write(json.dumps(keys))
123+
124+
context.default_config_backup = create_config_backup("lightspeed-stack.yaml")
125+
switch_config("tests/e2e/configuration/lightspeed-stack-auth-jwk.yaml")
126+
restart_container("lightspeed-stack")
127+
111128
if "Feedback" in feature.tags:
112129
context.feedback_conversations = []
113130

114131

115132
def after_feature(context: Context, feature: Feature) -> None:
116133
"""Run after each feature file is exercised."""
117-
if "Authorized" in feature.tags:
134+
if "Authorized" in feature.tags or "JWKAuth" in feature.tags:
118135
switch_config(context.default_config_backup)
119136
restart_container("lightspeed-stack")
120137
remove_config_backup(context.default_config_backup)

tests/e2e/features/steps/health.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Implementation of common test steps."""
22

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

1516
try:
1617
result = subprocess.run(
17-
["docker", "inspect", "-f", "{{.State.Running}}", "llama-stack"],
18+
[
19+
os.getenv("CONTAINER_CMD", "docker"),
20+
"inspect",
21+
"-f",
22+
"{{.State.Running}}",
23+
"llama-stack",
24+
],
1825
capture_output=True,
1926
text=True,
2027
check=True,
@@ -23,7 +30,9 @@ def llama_stack_connection_broken(context: Context) -> None:
2330
if result.stdout.strip():
2431
context.llama_stack_was_running = True
2532
subprocess.run(
26-
["docker", "stop", "llama-stack"], check=True, capture_output=True
33+
[os.getenv("CONTAINER_CMD", "docker"), "stop", "llama-stack"],
34+
check=True,
35+
capture_output=True,
2736
)
2837

2938
# Wait a moment for the connection to be fully disrupted
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""JWK auth steps - reusing unit test primitives."""
2+
3+
from pathlib import Path
4+
import sys
5+
6+
from behave import given # pyright: ignore[reportAttributeAccessIssue]
7+
from behave.runner import Context
8+
9+
10+
from authlib.jose import JsonWebToken
11+
12+
sys.path.append(str(Path(__file__).resolve().parents[4]))
13+
14+
# Import at runtime to avoid module load issues
15+
from tests.unit.authentication.test_jwk_token import (
16+
create_token_header,
17+
create_token_payload,
18+
)
19+
20+
21+
@given("I have a valid JWT token with the {role} role")
22+
def create_role_token(context: Context, role: str) -> None:
23+
"""Create token with role using the shared test key."""
24+
test_key = context.test_key
25+
26+
header = create_token_header(test_key["kid"])
27+
payload = create_token_payload()
28+
29+
# This works thanks to the definitions in lightspeed-stack-auth-jwk.yaml
30+
payload["roles"] = [role] # Add role to existing payload
31+
32+
token = (
33+
JsonWebToken(algorithms=["RS256"])
34+
.encode(header, payload, test_key["private_key"])
35+
.decode()
36+
)
37+
38+
if not hasattr(context, "auth_headers"):
39+
context.auth_headers = {}
40+
41+
context.auth_headers["Authorization"] = f"Bearer {token}"

tests/e2e/test_list.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ features/info.feature
88
features/query.feature
99
features/streaming_query.feature
1010
features/rest_api.feature
11+
features/authorization_jwk.feature

0 commit comments

Comments
 (0)