Skip to content

Commit 3f22a7b

Browse files
(PTFE-3070) Improve tests coverage
1 parent 4b3288e commit 3f22a7b

13 files changed

+1129
-819
lines changed

tests/end2end/conftest.py

Lines changed: 142 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,152 @@
1+
import os
2+
import sys
3+
import tempfile
4+
import time
15

6+
# Ensure this directory is on sys.path so test files can import constants.py
7+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
28

39
import boto3
4-
import os
510
import pytest
11+
import requests
12+
from boto3.exceptions import S3UploadFailedError
13+
from botocore.exceptions import EndpointConnectionError
14+
15+
from constants import BUCKETS
16+
17+
18+
# ---------------------------------------------------------------------------
19+
# Internal helpers (not fixtures)
20+
# ---------------------------------------------------------------------------
21+
22+
def _delete_bucket(client, bucket: str) -> None:
23+
"""Delete a bucket and all its objects."""
24+
while True:
25+
objects = client.list_objects(Bucket=bucket)
26+
content = objects.get('Contents', [])
27+
if not content:
28+
break
29+
for obj in content:
30+
client.delete_object(Bucket=bucket, Key=obj['Key'])
31+
client.delete_bucket(Bucket=bucket)
32+
33+
34+
def _s3_healthcheck(client) -> None:
35+
"""Retry S3 connectivity until backend is ready."""
36+
bucket = 'artifacts-healthcheck'
37+
filename = tempfile.mktemp()
38+
with open(filename, 'wb') as fd:
39+
fd.write(os.urandom(1024))
40+
try:
41+
for attempt in range(10):
42+
try:
43+
client.create_bucket(Bucket=bucket)
44+
client.upload_file(filename, bucket, filename)
45+
_delete_bucket(client, bucket)
46+
return
47+
except (S3UploadFailedError, EndpointConnectionError):
48+
time.sleep(attempt + 1)
49+
raise RuntimeError("S3 backend never became healthy")
50+
finally:
51+
os.remove(filename)
652

7-
@pytest.fixture(scope="class")
8-
def s3_client(request):
53+
54+
# ---------------------------------------------------------------------------
55+
# Session-scoped fixtures (created once per pytest run)
56+
# ---------------------------------------------------------------------------
57+
58+
@pytest.fixture(scope="session")
59+
def s3_client():
60+
"""Boto3 S3 client pointed at the test cloudserver backend."""
961
session = boto3.session.Session()
10-
s3_client = session.client(
62+
client = session.client(
1163
service_name='s3',
1264
aws_access_key_id=os.getenv('AWS_ACCESS_KEY_ID', 'accessKey1'),
1365
aws_secret_access_key=os.getenv('AWS_SECRET_ACCESS_KEY', 'verySecretKey1'),
14-
endpoint_url=os.getenv('ENDPOINT_URL', 'http://cloudserver-front:8000')
66+
endpoint_url=os.getenv('ENDPOINT_URL', 'http://cloudserver-front:8000'),
1567
)
16-
request.cls.s3_client = s3_client
17-
18-
@pytest.fixture(scope="class")
19-
def artifacts_url(request):
20-
request.cls.artifacts_url = os.getenv('ARTIFACTS_URL', 'http://localhost')
21-
22-
@pytest.fixture(scope="class")
23-
def container(request):
24-
request.cls.container = 'githost:owner:repo:staging-8e50acc6a1.pre-merge.28.1'
25-
26-
@pytest.fixture(scope="class")
27-
def buckets(request):
28-
buckets = (
29-
'artifacts-staging',
30-
'artifacts-promoted',
31-
'artifacts-prolonged'
32-
)
33-
request.cls.buckets = buckets
68+
_s3_healthcheck(client)
69+
return client
70+
71+
72+
@pytest.fixture(scope="session")
73+
def artifacts_url() -> str:
74+
return os.getenv('ARTIFACTS_URL', 'http://artifacts')
75+
76+
77+
# ---------------------------------------------------------------------------
78+
# Function-scoped fixtures (created fresh for each test)
79+
# ---------------------------------------------------------------------------
80+
81+
@pytest.fixture(autouse=True)
82+
def managed_buckets(s3_client):
83+
"""Create the three artifact buckets before each test; destroy them after."""
84+
for bucket in BUCKETS:
85+
s3_client.create_bucket(Bucket=bucket)
86+
yield
87+
for bucket in BUCKETS:
88+
_delete_bucket(s3_client, bucket)
89+
90+
91+
@pytest.fixture
92+
def session():
93+
"""Requests session authenticated as a user with full upload rights."""
94+
s = requests.Session()
95+
s.auth = ('username-pass', 'fake-password')
96+
return s
97+
98+
99+
@pytest.fixture
100+
def restricted_session():
101+
"""Requests session authenticated as a read-only user (no upload/copy)."""
102+
s = requests.Session()
103+
s.auth = ('username-pass-no-restricted-paths', 'fake-password')
104+
return s
105+
106+
107+
@pytest.fixture
108+
def bot_session():
109+
"""Requests session using the local bot credentials."""
110+
s = requests.Session()
111+
s.auth = ('botuser', 'botpass')
112+
return s
113+
114+
115+
@pytest.fixture
116+
def anon_session():
117+
"""Unauthenticated requests session."""
118+
return requests.Session()
119+
120+
121+
# ---------------------------------------------------------------------------
122+
# Factory fixtures for common setup operations
123+
# ---------------------------------------------------------------------------
124+
125+
@pytest.fixture
126+
def upload_file(session, artifacts_url):
127+
"""Factory: upload bytes to ``/upload/<build>/<path>``."""
128+
def _upload(build: str, path: str, data: bytes = b'test content') -> requests.Response:
129+
url = f'{artifacts_url}/upload/{build}/{path}'
130+
resp = session.put(url, data=data)
131+
assert resp.status_code == 200, f'upload {path}: {resp.status_code} {resp.text}'
132+
return resp
133+
return _upload
134+
135+
136+
@pytest.fixture
137+
def finish_build(session, artifacts_url):
138+
"""Factory: mark a build finished by uploading ``.final_status``.
139+
140+
Also sends a ``ForceCacheUpdate`` GET to flush the nginx proxy cache for
141+
that object, so subsequent ``/copy/`` or ``/last_success/`` calls see it.
142+
"""
143+
def _finish(build: str, status: str = 'SUCCESSFUL') -> None:
144+
url = f'{artifacts_url}/upload/{build}/.final_status'
145+
resp = session.put(url, data=status.encode())
146+
assert resp.status_code == 200
147+
# Flush proxy cache
148+
session.get(
149+
f'{artifacts_url}/download/{build}/.final_status',
150+
headers={'ForceCacheUpdate': 'yes'},
151+
)
152+
return _finish

tests/end2end/constants.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
"""Shared constants for the artifacts end-to-end test suite."""
2+
3+
BUCKETS = ('artifacts-staging', 'artifacts-promoted', 'artifacts-prolonged')
4+
5+
# Build name examples that exercise each routing bucket.
6+
STAGING_BUILD = 'githost:owner:repo:staging-8e50acc6a1.pre-merge.28.1'
7+
PROMOTED_BUILD = 'githost:owner:repo:promoted-8e50acc6a1.rel.1'
8+
PROLONGED_BUILD = 'githost:owner:repo:1.0.28.1'

tests/end2end/test_auth.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""Tests for GitHub-based access control and local bot credentials."""
2+
3+
import pytest
4+
5+
from constants import STAGING_BUILD
6+
7+
8+
# ---------------------------------------------------------------------------
9+
# Authenticated users with full upload rights
10+
# ---------------------------------------------------------------------------
11+
12+
def test_authenticated_user_can_upload(session, artifacts_url):
13+
resp = session.put(
14+
f'{artifacts_url}/upload/{STAGING_BUILD}/.final_status',
15+
data=b'SUCCESSFUL',
16+
headers={'Script-Name': '/foo'},
17+
)
18+
assert resp.status_code == 200
19+
20+
21+
# ---------------------------------------------------------------------------
22+
# Local bot credentials
23+
# ---------------------------------------------------------------------------
24+
25+
def test_bot_credentials_can_download(bot_session, artifacts_url):
26+
"""Bot user (local creds) can hit /download/ — 404 is fine, not 401/403."""
27+
resp = bot_session.get(f'{artifacts_url}/download/{STAGING_BUILD}')
28+
# The build doesn't exist, so we get 404 (redirect to trailing slash → 404).
29+
# What matters is that we are not rejected with 401 or 403.
30+
assert resp.status_code not in (401, 403)
31+
32+
33+
# ---------------------------------------------------------------------------
34+
# Restricted users (can read, cannot write)
35+
# ---------------------------------------------------------------------------
36+
37+
def test_restricted_user_can_download(restricted_session, artifacts_url):
38+
"""User without upload permission can still browse /download/."""
39+
resp = restricted_session.get(f'{artifacts_url}/download/{STAGING_BUILD}/')
40+
# Build doesn't exist → 404; not 401 or 403
41+
assert resp.status_code == 404
42+
43+
44+
def test_restricted_user_cannot_upload(restricted_session, artifacts_url):
45+
resp = restricted_session.put(
46+
f'{artifacts_url}/upload/{STAGING_BUILD}/.final_status',
47+
data=b'SUCCESSFUL',
48+
)
49+
assert resp.status_code == 403
50+
51+
52+
def test_restricted_user_cannot_copy(restricted_session, artifacts_url):
53+
copy_build = f'copy_of_{STAGING_BUILD}'
54+
resp = restricted_session.get(
55+
f'{artifacts_url}/copy/{STAGING_BUILD}/{copy_build}/'
56+
)
57+
assert resp.status_code == 403
58+
59+
60+
def test_restricted_user_cannot_add_metadata(restricted_session, artifacts_url):
61+
resp = restricted_session.get(
62+
f'{artifacts_url}/add_metadata/fake/args'
63+
)
64+
assert resp.status_code == 403
65+
66+
67+
# ---------------------------------------------------------------------------
68+
# Failing / missing credentials
69+
# ---------------------------------------------------------------------------
70+
71+
def test_failing_github_user_is_forbidden(artifacts_url):
72+
"""A user rejected by the (fake) GitHub API receives 403."""
73+
s = __import__('requests').Session()
74+
s.auth = ('username-fail', 'fake-password')
75+
resp = s.put(
76+
f'{artifacts_url}/upload/{STAGING_BUILD}/.final_status',
77+
data=b'SUCCESSFUL',
78+
headers={'Script-Name': '/foo'},
79+
)
80+
assert resp.status_code == 403
81+
82+
83+
def test_unauthenticated_request_is_unauthorized(anon_session, artifacts_url):
84+
"""No Authorization header → 401."""
85+
resp = anon_session.put(
86+
f'{artifacts_url}/upload/{STAGING_BUILD}/.final_status',
87+
data=b'SUCCESSFUL',
88+
headers={'Script-Name': '/foo'},
89+
)
90+
assert resp.status_code == 401

tests/end2end/test_copy.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"""Tests for the build copy (/copy/) endpoint."""
2+
3+
import pytest
4+
5+
from constants import STAGING_BUILD
6+
7+
8+
COPY_BUILD = f'copy_of_{STAGING_BUILD}'
9+
10+
11+
def test_copy_aborted_when_no_final_status(session, artifacts_url, upload_file):
12+
"""Copy is refused when the source has no .final_status file."""
13+
for i in range(5):
14+
upload_file(STAGING_BUILD, f'obj-{i}', b'content')
15+
16+
resp = session.get(f'{artifacts_url}/copy/{STAGING_BUILD}/{COPY_BUILD}/')
17+
assert resp.status_code == 200
18+
last_line = resp.content.splitlines()[-1]
19+
assert last_line == b'SOURCE BUILD NOT FINISHED (NO ".final_status" FOUND), ABORTING'
20+
21+
22+
def test_copy_succeeds_after_final_status(
23+
session, artifacts_url, upload_file, finish_build
24+
):
25+
"""Copy succeeds once .final_status is present."""
26+
for i in range(5):
27+
upload_file(STAGING_BUILD, f'obj-{i}', b'content')
28+
finish_build(STAGING_BUILD)
29+
30+
resp = session.get(f'{artifacts_url}/copy/{STAGING_BUILD}/{COPY_BUILD}/')
31+
assert resp.status_code == 200
32+
assert resp.content.splitlines()[-1] == b'BUILD COPIED'
33+
34+
35+
def test_copy_source_and_target_listings_are_identical(
36+
session, artifacts_url, upload_file, finish_build
37+
):
38+
"""After copy, source and target flat listings are byte-for-byte equal."""
39+
for i in range(1024):
40+
upload_file(STAGING_BUILD, f'obj-{i}', b'x')
41+
finish_build(STAGING_BUILD)
42+
43+
session.get(f'{artifacts_url}/copy/{STAGING_BUILD}/{COPY_BUILD}/')
44+
45+
src = session.get(f'{artifacts_url}/download/{STAGING_BUILD}/?format=txt')
46+
tgt = session.get(f'{artifacts_url}/download/{COPY_BUILD}/?format=txt')
47+
assert src.status_code == 200
48+
assert tgt.status_code == 200
49+
assert len(src.content.splitlines()) == 1026 # 1024 objs + .final_status + .original_build
50+
assert src.content == tgt.content
51+
52+
53+
def test_copy_fails_when_target_already_exists(
54+
session, artifacts_url, upload_file, finish_build
55+
):
56+
"""A second copy to the same target is rejected with FAILED."""
57+
upload_file(STAGING_BUILD, 'file.txt', b'data')
58+
finish_build(STAGING_BUILD)
59+
60+
session.get(f'{artifacts_url}/copy/{STAGING_BUILD}/{COPY_BUILD}/')
61+
62+
# Second attempt — target is not empty
63+
resp = session.get(f'{artifacts_url}/copy/{STAGING_BUILD}/{COPY_BUILD}/')
64+
assert resp.status_code == 200
65+
lines = resp.content.splitlines()
66+
expected_check_line = (
67+
b"Checking if the target reference '%b' is empty"
68+
% COPY_BUILD.encode()
69+
)
70+
assert lines[-2] == expected_check_line
71+
assert lines[-1] == b'FAILED'
72+
73+
74+
def test_copy_behind_ingress(session, artifacts_url, upload_file, finish_build):
75+
"""Copy works correctly when a Script-Name ingress header is present."""
76+
upload_file(STAGING_BUILD, '.final_status', b'SUCCESSFUL',)
77+
# flush cache manually (finish_build would also work, but let's stay direct)
78+
finish_build(STAGING_BUILD)
79+
80+
resp = session.get(
81+
f'{artifacts_url}/copy/{STAGING_BUILD}/{COPY_BUILD}/',
82+
headers={'Script-Name': '/foo'},
83+
)
84+
assert resp.status_code == 200
85+
assert resp.content.splitlines()[-1] == b'BUILD COPIED'
86+
87+
# Download via ingress path should work too
88+
dl = session.get(
89+
f'{artifacts_url}/download/{STAGING_BUILD}/.final_status',
90+
headers={'Script-Name': '/foo', 'ForceCacheUpdate': 'yes'},
91+
)
92+
assert dl.status_code == 200

0 commit comments

Comments
 (0)