Release v0.1.0 Beta
This commit is contained in:
167
tests/conftest.py
Normal file
167
tests/conftest.py
Normal file
@@ -0,0 +1,167 @@
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote, urlparse
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
import pytest
|
||||
from werkzeug.serving import make_server
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from app import create_api_app
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def app(tmp_path: Path):
|
||||
storage_root = tmp_path / "data"
|
||||
iam_config = tmp_path / "iam.json"
|
||||
bucket_policies = tmp_path / "bucket_policies.json"
|
||||
iam_payload = {
|
||||
"users": [
|
||||
{
|
||||
"access_key": "test",
|
||||
"secret_key": "secret",
|
||||
"display_name": "Test User",
|
||||
"policies": [{"bucket": "*", "actions": ["list", "read", "write", "delete", "policy"]}],
|
||||
}
|
||||
]
|
||||
}
|
||||
iam_config.write_text(json.dumps(iam_payload))
|
||||
flask_app = create_api_app(
|
||||
{
|
||||
"TESTING": True,
|
||||
"STORAGE_ROOT": storage_root,
|
||||
"IAM_CONFIG": iam_config,
|
||||
"BUCKET_POLICY_PATH": bucket_policies,
|
||||
"API_BASE_URL": "http://testserver",
|
||||
}
|
||||
)
|
||||
yield flask_app
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(app):
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def live_server(app):
|
||||
server = make_server("127.0.0.1", 0, app)
|
||||
host, port = server.server_address
|
||||
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
time.sleep(0.05)
|
||||
|
||||
try:
|
||||
yield f"http://{host}:{port}"
|
||||
finally:
|
||||
server.shutdown()
|
||||
thread.join(timeout=1)
|
||||
|
||||
|
||||
def _sign(key, msg):
|
||||
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
|
||||
|
||||
|
||||
def _get_signature_key(key, date_stamp, region_name, service_name):
|
||||
k_date = _sign(("AWS4" + key).encode("utf-8"), date_stamp)
|
||||
k_region = _sign(k_date, region_name)
|
||||
k_service = _sign(k_region, service_name)
|
||||
k_signing = _sign(k_service, "aws4_request")
|
||||
return k_signing
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def signer():
|
||||
def _signer(
|
||||
method,
|
||||
path,
|
||||
headers=None,
|
||||
body=None,
|
||||
access_key="test",
|
||||
secret_key="secret",
|
||||
region="us-east-1",
|
||||
service="s3",
|
||||
):
|
||||
if headers is None:
|
||||
headers = {}
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
|
||||
date_stamp = now.strftime("%Y%m%d")
|
||||
|
||||
headers["X-Amz-Date"] = amz_date
|
||||
|
||||
# Host header is required for SigV4
|
||||
if "Host" not in headers:
|
||||
headers["Host"] = "localhost" # Default for Flask test client
|
||||
|
||||
# Payload hash
|
||||
if body is None:
|
||||
body = b""
|
||||
elif isinstance(body, str):
|
||||
body = body.encode("utf-8")
|
||||
|
||||
payload_hash = hashlib.sha256(body).hexdigest()
|
||||
headers["X-Amz-Content-Sha256"] = payload_hash
|
||||
|
||||
# Canonical Request
|
||||
canonical_uri = quote(path.split("?")[0])
|
||||
|
||||
# Query string
|
||||
parsed = urlparse(path)
|
||||
query_args = []
|
||||
if parsed.query:
|
||||
for pair in parsed.query.split("&"):
|
||||
if "=" in pair:
|
||||
k, v = pair.split("=", 1)
|
||||
else:
|
||||
k, v = pair, ""
|
||||
query_args.append((k, v))
|
||||
query_args.sort(key=lambda x: (x[0], x[1]))
|
||||
|
||||
canonical_query_parts = []
|
||||
for k, v in query_args:
|
||||
canonical_query_parts.append(f"{quote(k, safe='')}={quote(v, safe='')}")
|
||||
canonical_query_string = "&".join(canonical_query_parts)
|
||||
|
||||
# Canonical Headers
|
||||
canonical_headers_parts = []
|
||||
signed_headers_parts = []
|
||||
for k, v in sorted(headers.items(), key=lambda x: x[0].lower()):
|
||||
k_lower = k.lower()
|
||||
v_trim = " ".join(str(v).split())
|
||||
canonical_headers_parts.append(f"{k_lower}:{v_trim}\n")
|
||||
signed_headers_parts.append(k_lower)
|
||||
|
||||
canonical_headers = "".join(canonical_headers_parts)
|
||||
signed_headers = ";".join(signed_headers_parts)
|
||||
|
||||
canonical_request = (
|
||||
f"{method}\n{canonical_uri}\n{canonical_query_string}\n{canonical_headers}\n{signed_headers}\n{payload_hash}"
|
||||
)
|
||||
|
||||
# String to Sign
|
||||
credential_scope = f"{date_stamp}/{region}/{service}/aws4_request"
|
||||
string_to_sign = (
|
||||
f"AWS4-HMAC-SHA256\n{amz_date}\n{credential_scope}\n{hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()}"
|
||||
)
|
||||
|
||||
# Signature
|
||||
signing_key = _get_signature_key(secret_key, date_stamp, region, service)
|
||||
signature = hmac.new(signing_key, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
|
||||
authorization = (
|
||||
f"AWS4-HMAC-SHA256 Credential={access_key}/{credential_scope}, SignedHeaders={signed_headers}, Signature={signature}"
|
||||
)
|
||||
headers["Authorization"] = authorization
|
||||
|
||||
return headers
|
||||
|
||||
return _signer
|
||||
485
tests/test_api.py
Normal file
485
tests/test_api.py
Normal file
@@ -0,0 +1,485 @@
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
def test_bucket_and_object_lifecycle(client, signer):
|
||||
headers = signer("PUT", "/photos")
|
||||
response = client.put("/photos", headers=headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
headers = signer("GET", "/")
|
||||
response = client.get("/", headers=headers)
|
||||
assert response.status_code == 200
|
||||
assert b"photos" in response.data
|
||||
|
||||
data = b"hello world"
|
||||
headers = signer("PUT", "/photos/image.txt", body=data)
|
||||
response = client.put("/photos/image.txt", headers=headers, data=data)
|
||||
assert response.status_code == 200
|
||||
assert "ETag" in response.headers
|
||||
|
||||
headers = signer("GET", "/photos")
|
||||
response = client.get("/photos", headers=headers)
|
||||
assert response.status_code == 200
|
||||
assert b"image.txt" in response.data
|
||||
|
||||
headers = signer("GET", "/photos/image.txt")
|
||||
response = client.get("/photos/image.txt", headers=headers)
|
||||
assert response.status_code == 200
|
||||
assert response.data == b"hello world"
|
||||
|
||||
headers = signer("DELETE", "/photos/image.txt")
|
||||
response = client.delete("/photos/image.txt", headers=headers)
|
||||
assert response.status_code == 204
|
||||
|
||||
headers = signer("DELETE", "/photos")
|
||||
response = client.delete("/photos", headers=headers)
|
||||
assert response.status_code == 204
|
||||
|
||||
|
||||
def test_bulk_delete_objects(client, signer):
|
||||
headers = signer("PUT", "/bulk")
|
||||
assert client.put("/bulk", headers=headers).status_code == 200
|
||||
|
||||
headers = signer("PUT", "/bulk/first.txt", body=b"first")
|
||||
assert client.put("/bulk/first.txt", headers=headers, data=b"first").status_code == 200
|
||||
|
||||
headers = signer("PUT", "/bulk/second.txt", body=b"second")
|
||||
assert client.put("/bulk/second.txt", headers=headers, data=b"second").status_code == 200
|
||||
|
||||
delete_xml = b"""
|
||||
<Delete>
|
||||
<Object><Key>first.txt</Key></Object>
|
||||
<Object><Key>missing.txt</Key></Object>
|
||||
</Delete>
|
||||
"""
|
||||
# Note: query_string is part of the path for signing
|
||||
headers = signer("POST", "/bulk?delete", headers={"Content-Type": "application/xml"}, body=delete_xml)
|
||||
response = client.post(
|
||||
"/bulk",
|
||||
headers=headers,
|
||||
query_string={"delete": ""},
|
||||
data=delete_xml,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert b"<DeleteResult>" in response.data
|
||||
|
||||
headers = signer("GET", "/bulk")
|
||||
listing = client.get("/bulk", headers=headers)
|
||||
assert b"first.txt" not in listing.data
|
||||
assert b"missing.txt" not in listing.data
|
||||
assert b"second.txt" in listing.data
|
||||
|
||||
|
||||
def test_bulk_delete_rejects_version_ids(client, signer):
|
||||
headers = signer("PUT", "/bulkv")
|
||||
assert client.put("/bulkv", headers=headers).status_code == 200
|
||||
|
||||
headers = signer("PUT", "/bulkv/keep.txt", body=b"keep")
|
||||
assert client.put("/bulkv/keep.txt", headers=headers, data=b"keep").status_code == 200
|
||||
|
||||
delete_xml = b"""
|
||||
<Delete>
|
||||
<Object><Key>keep.txt</Key><VersionId>123</VersionId></Object>
|
||||
</Delete>
|
||||
"""
|
||||
headers = signer("POST", "/bulkv?delete", headers={"Content-Type": "application/xml"}, body=delete_xml)
|
||||
response = client.post(
|
||||
"/bulkv",
|
||||
headers=headers,
|
||||
query_string={"delete": ""},
|
||||
data=delete_xml,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert b"InvalidRequest" in response.data
|
||||
|
||||
headers = signer("GET", "/bulkv")
|
||||
listing = client.get("/bulkv", headers=headers)
|
||||
assert b"keep.txt" in listing.data
|
||||
|
||||
|
||||
def test_request_id_header_present(client, signer):
|
||||
headers = signer("GET", "/")
|
||||
response = client.get("/", headers=headers)
|
||||
assert response.status_code == 200
|
||||
assert response.headers.get("X-Request-ID")
|
||||
|
||||
|
||||
def test_healthcheck_returns_version(client):
|
||||
response = client.get("/healthz")
|
||||
data = response.get_json()
|
||||
assert response.status_code == 200
|
||||
assert data["status"] == "ok"
|
||||
assert "version" in data
|
||||
|
||||
|
||||
def test_missing_credentials_denied(client):
|
||||
response = client.get("/")
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_presign_and_bucket_policies(client, signer):
|
||||
# Create bucket and object
|
||||
headers = signer("PUT", "/docs")
|
||||
assert client.put("/docs", headers=headers).status_code == 200
|
||||
|
||||
headers = signer("PUT", "/docs/readme.txt", body=b"content")
|
||||
assert client.put("/docs/readme.txt", headers=headers, data=b"content").status_code == 200
|
||||
|
||||
# Generate presigned GET URL and follow it
|
||||
json_body = {"method": "GET", "expires_in": 120}
|
||||
# Flask test client json parameter automatically sets Content-Type and serializes body
|
||||
# But for signing we need the body bytes.
|
||||
import json
|
||||
body_bytes = json.dumps(json_body).encode("utf-8")
|
||||
headers = signer("POST", "/presign/docs/readme.txt", headers={"Content-Type": "application/json"}, body=body_bytes)
|
||||
|
||||
response = client.post(
|
||||
"/presign/docs/readme.txt",
|
||||
headers=headers,
|
||||
json=json_body,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
presigned_url = response.get_json()["url"]
|
||||
parts = urlsplit(presigned_url)
|
||||
presigned_path = f"{parts.path}?{parts.query}"
|
||||
download = client.get(presigned_path)
|
||||
assert download.status_code == 200
|
||||
assert download.data == b"content"
|
||||
|
||||
# Attach a deny policy for GETs
|
||||
policy = {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "DenyReads",
|
||||
"Effect": "Deny",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:GetObject"],
|
||||
"Resource": ["arn:aws:s3:::docs/*"],
|
||||
}
|
||||
],
|
||||
}
|
||||
policy_bytes = json.dumps(policy).encode("utf-8")
|
||||
headers = signer("PUT", "/bucket-policy/docs", headers={"Content-Type": "application/json"}, body=policy_bytes)
|
||||
assert client.put("/bucket-policy/docs", headers=headers, json=policy).status_code == 204
|
||||
|
||||
headers = signer("GET", "/bucket-policy/docs")
|
||||
fetched = client.get("/bucket-policy/docs", headers=headers)
|
||||
assert fetched.status_code == 200
|
||||
assert fetched.get_json()["Version"] == "2012-10-17"
|
||||
|
||||
# Reads are now denied by bucket policy
|
||||
headers = signer("GET", "/docs/readme.txt")
|
||||
denied = client.get("/docs/readme.txt", headers=headers)
|
||||
assert denied.status_code == 403
|
||||
|
||||
# Presign attempts are also denied
|
||||
json_body = {"method": "GET", "expires_in": 60}
|
||||
body_bytes = json.dumps(json_body).encode("utf-8")
|
||||
headers = signer("POST", "/presign/docs/readme.txt", headers={"Content-Type": "application/json"}, body=body_bytes)
|
||||
response = client.post(
|
||||
"/presign/docs/readme.txt",
|
||||
headers=headers,
|
||||
json=json_body,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_trailing_slash_returns_xml(client):
|
||||
response = client.get("/ghost/")
|
||||
assert response.status_code == 403
|
||||
assert response.mimetype == "application/xml"
|
||||
assert b"<Error>" in response.data
|
||||
|
||||
|
||||
def test_public_policy_allows_anonymous_list_and_read(client, signer):
|
||||
headers = signer("PUT", "/public")
|
||||
assert client.put("/public", headers=headers).status_code == 200
|
||||
|
||||
headers = signer("PUT", "/public/hello.txt", body=b"hi")
|
||||
assert client.put("/public/hello.txt", headers=headers, data=b"hi").status_code == 200
|
||||
|
||||
assert client.get("/public").status_code == 403
|
||||
assert client.get("/public/hello.txt").status_code == 403
|
||||
|
||||
policy = {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "AllowList",
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:ListBucket"],
|
||||
"Resource": ["arn:aws:s3:::public"],
|
||||
},
|
||||
{
|
||||
"Sid": "AllowRead",
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:GetObject"],
|
||||
"Resource": ["arn:aws:s3:::public/*"],
|
||||
},
|
||||
],
|
||||
}
|
||||
import json
|
||||
policy_bytes = json.dumps(policy).encode("utf-8")
|
||||
headers = signer("PUT", "/bucket-policy/public", headers={"Content-Type": "application/json"}, body=policy_bytes)
|
||||
assert client.put("/bucket-policy/public", headers=headers, json=policy).status_code == 204
|
||||
|
||||
list_response = client.get("/public")
|
||||
assert list_response.status_code == 200
|
||||
assert b"hello.txt" in list_response.data
|
||||
|
||||
obj_response = client.get("/public/hello.txt")
|
||||
assert obj_response.status_code == 200
|
||||
assert obj_response.data == b"hi"
|
||||
|
||||
headers = signer("DELETE", "/public/hello.txt")
|
||||
assert client.delete("/public/hello.txt", headers=headers).status_code == 204
|
||||
|
||||
headers = signer("DELETE", "/bucket-policy/public")
|
||||
assert client.delete("/bucket-policy/public", headers=headers).status_code == 204
|
||||
|
||||
headers = signer("DELETE", "/public")
|
||||
assert client.delete("/public", headers=headers).status_code == 204
|
||||
|
||||
|
||||
def test_principal_dict_with_object_get_only(client, signer):
|
||||
headers = signer("PUT", "/mixed")
|
||||
assert client.put("/mixed", headers=headers).status_code == 200
|
||||
|
||||
headers = signer("PUT", "/mixed/only.txt", body=b"ok")
|
||||
assert client.put("/mixed/only.txt", headers=headers, data=b"ok").status_code == 200
|
||||
|
||||
policy = {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "AllowObjects",
|
||||
"Effect": "Allow",
|
||||
"Principal": {"AWS": ["*"]},
|
||||
"Action": ["s3:GetObject"],
|
||||
"Resource": ["arn:aws:s3:::mixed/*"],
|
||||
},
|
||||
{
|
||||
"Sid": "DenyList",
|
||||
"Effect": "Deny",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:ListBucket"],
|
||||
"Resource": ["arn:aws:s3:::mixed"],
|
||||
},
|
||||
],
|
||||
}
|
||||
import json
|
||||
policy_bytes = json.dumps(policy).encode("utf-8")
|
||||
headers = signer("PUT", "/bucket-policy/mixed", headers={"Content-Type": "application/json"}, body=policy_bytes)
|
||||
assert client.put("/bucket-policy/mixed", headers=headers, json=policy).status_code == 204
|
||||
|
||||
assert client.get("/mixed").status_code == 403
|
||||
allowed = client.get("/mixed/only.txt")
|
||||
assert allowed.status_code == 200
|
||||
assert allowed.data == b"ok"
|
||||
|
||||
headers = signer("DELETE", "/mixed/only.txt")
|
||||
assert client.delete("/mixed/only.txt", headers=headers).status_code == 204
|
||||
|
||||
headers = signer("DELETE", "/bucket-policy/mixed")
|
||||
assert client.delete("/bucket-policy/mixed", headers=headers).status_code == 204
|
||||
|
||||
headers = signer("DELETE", "/mixed")
|
||||
assert client.delete("/mixed", headers=headers).status_code == 204
|
||||
|
||||
|
||||
def test_bucket_policy_wildcard_resource_allows_object_get(client, signer):
|
||||
headers = signer("PUT", "/test")
|
||||
assert client.put("/test", headers=headers).status_code == 200
|
||||
|
||||
headers = signer("PUT", "/test/vid.mp4", body=b"video")
|
||||
assert client.put("/test/vid.mp4", headers=headers, data=b"video").status_code == 200
|
||||
|
||||
policy = {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"AWS": ["*"]},
|
||||
"Action": ["s3:GetObject"],
|
||||
"Resource": ["arn:aws:s3:::*/*"],
|
||||
},
|
||||
{
|
||||
"Effect": "Deny",
|
||||
"Principal": {"AWS": ["*"]},
|
||||
"Action": ["s3:ListBucket"],
|
||||
"Resource": ["arn:aws:s3:::*"],
|
||||
},
|
||||
],
|
||||
}
|
||||
import json
|
||||
policy_bytes = json.dumps(policy).encode("utf-8")
|
||||
headers = signer("PUT", "/bucket-policy/test", headers={"Content-Type": "application/json"}, body=policy_bytes)
|
||||
assert client.put("/bucket-policy/test", headers=headers, json=policy).status_code == 204
|
||||
|
||||
listing = client.get("/test")
|
||||
assert listing.status_code == 403
|
||||
payload = client.get("/test/vid.mp4")
|
||||
assert payload.status_code == 200
|
||||
assert payload.data == b"video"
|
||||
|
||||
headers = signer("DELETE", "/test/vid.mp4")
|
||||
assert client.delete("/test/vid.mp4", headers=headers).status_code == 204
|
||||
|
||||
headers = signer("DELETE", "/bucket-policy/test")
|
||||
assert client.delete("/bucket-policy/test", headers=headers).status_code == 204
|
||||
|
||||
headers = signer("DELETE", "/test")
|
||||
assert client.delete("/test", headers=headers).status_code == 204
|
||||
|
||||
|
||||
def test_head_object_returns_metadata(client, signer):
|
||||
headers = signer("PUT", "/media")
|
||||
assert client.put("/media", headers=headers).status_code == 200
|
||||
|
||||
payload = b"metadata"
|
||||
upload_headers = {"X-Amz-Meta-Test": "demo"}
|
||||
# Signer needs to know about custom headers
|
||||
headers = signer("PUT", "/media/info.txt", headers=upload_headers, body=payload)
|
||||
assert client.put("/media/info.txt", headers=headers, data=payload).status_code == 200
|
||||
|
||||
headers = signer("HEAD", "/media/info.txt")
|
||||
head = client.head("/media/info.txt", headers=headers)
|
||||
assert head.status_code == 200
|
||||
assert head.data == b""
|
||||
assert head.headers["Content-Length"] == str(len(payload))
|
||||
assert head.headers["X-Amz-Meta-Test"] == "demo"
|
||||
|
||||
|
||||
def test_bucket_versioning_endpoint(client, signer):
|
||||
headers = signer("PUT", "/history")
|
||||
assert client.put("/history", headers=headers).status_code == 200
|
||||
|
||||
headers = signer("GET", "/history?versioning")
|
||||
response = client.get("/history", headers=headers, query_string={"versioning": ""})
|
||||
assert response.status_code == 200
|
||||
assert b"<Status>Suspended</Status>" in response.data
|
||||
|
||||
storage = client.application.extensions["object_storage"]
|
||||
storage.set_bucket_versioning("history", True)
|
||||
|
||||
headers = signer("GET", "/history?versioning")
|
||||
enabled = client.get("/history", headers=headers, query_string={"versioning": ""})
|
||||
assert enabled.status_code == 200
|
||||
assert b"<Status>Enabled</Status>" in enabled.data
|
||||
|
||||
|
||||
def test_bucket_tagging_cors_and_encryption_round_trip(client, signer):
|
||||
headers = signer("PUT", "/config")
|
||||
assert client.put("/config", headers=headers).status_code == 200
|
||||
|
||||
headers = signer("GET", "/config?tagging")
|
||||
missing_tags = client.get("/config", headers=headers, query_string={"tagging": ""})
|
||||
assert missing_tags.status_code == 404
|
||||
|
||||
tagging_xml = b"""
|
||||
<Tagging>
|
||||
<TagSet>
|
||||
<Tag><Key>env</Key><Value>dev</Value></Tag>
|
||||
<Tag><Key>team</Key><Value>platform</Value></Tag>
|
||||
</TagSet>
|
||||
</Tagging>
|
||||
"""
|
||||
headers = signer("PUT", "/config?tagging", headers={"Content-Type": "application/xml"}, body=tagging_xml)
|
||||
assert (
|
||||
client.put(
|
||||
"/config",
|
||||
headers=headers,
|
||||
query_string={"tagging": ""},
|
||||
data=tagging_xml,
|
||||
content_type="application/xml",
|
||||
).status_code
|
||||
== 204
|
||||
)
|
||||
|
||||
headers = signer("GET", "/config?tagging")
|
||||
tags = client.get("/config", headers=headers, query_string={"tagging": ""})
|
||||
assert tags.status_code == 200
|
||||
assert b"<Key>env</Key>" in tags.data
|
||||
assert b"<Value>platform</Value>" in tags.data
|
||||
|
||||
headers = signer("GET", "/config?cors")
|
||||
missing_cors = client.get("/config", headers=headers, query_string={"cors": ""})
|
||||
assert missing_cors.status_code == 404
|
||||
|
||||
cors_xml = b"""
|
||||
<CORSConfiguration>
|
||||
<CORSRule>
|
||||
<AllowedOrigin>*</AllowedOrigin>
|
||||
<AllowedMethod>GET</AllowedMethod>
|
||||
<AllowedHeader>*</AllowedHeader>
|
||||
<ExposeHeader>X-Test</ExposeHeader>
|
||||
<MaxAgeSeconds>600</MaxAgeSeconds>
|
||||
</CORSRule>
|
||||
</CORSConfiguration>
|
||||
"""
|
||||
headers = signer("PUT", "/config?cors", headers={"Content-Type": "application/xml"}, body=cors_xml)
|
||||
assert (
|
||||
client.put(
|
||||
"/config",
|
||||
headers=headers,
|
||||
query_string={"cors": ""},
|
||||
data=cors_xml,
|
||||
content_type="application/xml",
|
||||
).status_code
|
||||
== 204
|
||||
)
|
||||
|
||||
headers = signer("GET", "/config?cors")
|
||||
cors = client.get("/config", headers=headers, query_string={"cors": ""})
|
||||
assert cors.status_code == 200
|
||||
assert b"<AllowedOrigin>*</AllowedOrigin>" in cors.data
|
||||
assert b"<AllowedMethod>GET</AllowedMethod>" in cors.data
|
||||
|
||||
# Clearing CORS rules with an empty payload removes the configuration
|
||||
headers = signer("PUT", "/config?cors", body=b"")
|
||||
assert (
|
||||
client.put(
|
||||
"/config",
|
||||
headers=headers,
|
||||
query_string={"cors": ""},
|
||||
data=b"",
|
||||
).status_code
|
||||
== 204
|
||||
)
|
||||
|
||||
headers = signer("GET", "/config?cors")
|
||||
cleared_cors = client.get("/config", headers=headers, query_string={"cors": ""})
|
||||
assert cleared_cors.status_code == 404
|
||||
|
||||
headers = signer("GET", "/config?encryption")
|
||||
missing_enc = client.get("/config", headers=headers, query_string={"encryption": ""})
|
||||
assert missing_enc.status_code == 404
|
||||
|
||||
encryption_xml = b"""
|
||||
<ServerSideEncryptionConfiguration>
|
||||
<Rule>
|
||||
<ApplyServerSideEncryptionByDefault>
|
||||
<SSEAlgorithm>AES256</SSEAlgorithm>
|
||||
</ApplyServerSideEncryptionByDefault>
|
||||
</Rule>
|
||||
</ServerSideEncryptionConfiguration>
|
||||
"""
|
||||
headers = signer("PUT", "/config?encryption", headers={"Content-Type": "application/xml"}, body=encryption_xml)
|
||||
assert (
|
||||
client.put(
|
||||
"/config",
|
||||
headers=headers,
|
||||
query_string={"encryption": ""},
|
||||
data=encryption_xml,
|
||||
content_type="application/xml",
|
||||
).status_code
|
||||
== 204
|
||||
)
|
||||
|
||||
headers = signer("GET", "/config?encryption")
|
||||
encryption = client.get("/config", headers=headers, query_string={"encryption": ""})
|
||||
assert encryption.status_code == 200
|
||||
assert b"AES256" in encryption.data
|
||||
54
tests/test_aws_sdk_compat.py
Normal file
54
tests/test_aws_sdk_compat.py
Normal file
@@ -0,0 +1,54 @@
|
||||
import uuid
|
||||
|
||||
import boto3
|
||||
import pytest
|
||||
from botocore.client import Config
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_boto3_basic_operations(live_server):
|
||||
bucket_name = f"boto3-test-{uuid.uuid4().hex[:8]}"
|
||||
object_key = "folder/hello.txt"
|
||||
|
||||
s3 = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=live_server,
|
||||
aws_access_key_id="test",
|
||||
aws_secret_access_key="secret",
|
||||
region_name="us-east-1",
|
||||
use_ssl=False,
|
||||
config=Config(
|
||||
signature_version="s3v4",
|
||||
retries={"max_attempts": 1},
|
||||
s3={"addressing_style": "path"},
|
||||
),
|
||||
)
|
||||
|
||||
# No need to inject custom headers anymore, as we support SigV4
|
||||
# def _inject_headers(params, **_kwargs):
|
||||
# headers = params.setdefault("headers", {})
|
||||
# headers["X-Access-Key"] = "test"
|
||||
# headers["X-Secret-Key"] = "secret"
|
||||
|
||||
# s3.meta.events.register("before-call.s3", _inject_headers)
|
||||
|
||||
s3.create_bucket(Bucket=bucket_name)
|
||||
|
||||
try:
|
||||
put_response = s3.put_object(Bucket=bucket_name, Key=object_key, Body=b"hello from boto3")
|
||||
assert "ETag" in put_response
|
||||
|
||||
obj = s3.get_object(Bucket=bucket_name, Key=object_key)
|
||||
assert obj["Body"].read() == b"hello from boto3"
|
||||
|
||||
listing = s3.list_objects_v2(Bucket=bucket_name)
|
||||
contents = listing.get("Contents", [])
|
||||
assert contents, "list_objects_v2 should return at least the object we uploaded"
|
||||
keys = {entry["Key"] for entry in contents}
|
||||
assert object_key in keys
|
||||
|
||||
s3.delete_object(Bucket=bucket_name, Key=object_key)
|
||||
post_delete = s3.list_objects_v2(Bucket=bucket_name)
|
||||
assert not post_delete.get("Contents"), "Object should be removed before deleting bucket"
|
||||
finally:
|
||||
s3.delete_bucket(Bucket=bucket_name)
|
||||
67
tests/test_edge_cases.py
Normal file
67
tests/test_edge_cases.py
Normal file
@@ -0,0 +1,67 @@
|
||||
import io
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from app.storage import ObjectStorage, StorageError
|
||||
|
||||
def test_concurrent_bucket_deletion(tmp_path: Path):
|
||||
# This is a simplified test since true concurrency is hard to simulate deterministically in this setup
|
||||
# We verify that deleting a non-existent bucket raises StorageError
|
||||
storage = ObjectStorage(tmp_path)
|
||||
storage.create_bucket("race")
|
||||
storage.delete_bucket("race")
|
||||
|
||||
with pytest.raises(StorageError, match="Bucket does not exist"):
|
||||
storage.delete_bucket("race")
|
||||
|
||||
def test_maximum_object_key_length(tmp_path: Path):
|
||||
storage = ObjectStorage(tmp_path)
|
||||
storage.create_bucket("maxkey")
|
||||
|
||||
# AWS S3 max key length is 1024 bytes (UTF-8)
|
||||
# Our implementation relies on the filesystem, so we might hit OS limits before 1024
|
||||
# But let's test a reasonably long key that should work
|
||||
long_key = "a" * 200
|
||||
storage.put_object("maxkey", long_key, io.BytesIO(b"data"))
|
||||
assert storage.get_object_path("maxkey", long_key).exists()
|
||||
|
||||
def test_unicode_bucket_and_object_names(tmp_path: Path):
|
||||
storage = ObjectStorage(tmp_path)
|
||||
# Bucket names must be lowercase, numbers, hyphens, periods
|
||||
# So unicode in bucket names is NOT allowed by our validation
|
||||
with pytest.raises(StorageError):
|
||||
storage.create_bucket("café")
|
||||
|
||||
storage.create_bucket("unicode-test")
|
||||
# Unicode in object keys IS allowed
|
||||
key = "café/image.jpg"
|
||||
storage.put_object("unicode-test", key, io.BytesIO(b"data"))
|
||||
assert storage.get_object_path("unicode-test", key).exists()
|
||||
|
||||
# Verify listing
|
||||
objects = storage.list_objects("unicode-test")
|
||||
assert any(o.key == key for o in objects)
|
||||
|
||||
def test_special_characters_in_metadata(tmp_path: Path):
|
||||
storage = ObjectStorage(tmp_path)
|
||||
storage.create_bucket("meta-test")
|
||||
|
||||
metadata = {"key": "value with spaces", "special": "!@#$%^&*()"}
|
||||
storage.put_object("meta-test", "obj", io.BytesIO(b"data"), metadata=metadata)
|
||||
|
||||
meta = storage.get_object_metadata("meta-test", "obj")
|
||||
assert meta["key"] == "value with spaces"
|
||||
assert meta["special"] == "!@#$%^&*()"
|
||||
|
||||
def test_disk_full_scenario(tmp_path: Path, monkeypatch):
|
||||
# Simulate disk full by mocking write to fail
|
||||
storage = ObjectStorage(tmp_path)
|
||||
storage.create_bucket("full")
|
||||
|
||||
def mock_copyfileobj(*args, **kwargs):
|
||||
raise OSError(28, "No space left on device")
|
||||
|
||||
import shutil
|
||||
monkeypatch.setattr(shutil, "copyfileobj", mock_copyfileobj)
|
||||
|
||||
with pytest.raises(OSError, match="No space left on device"):
|
||||
storage.put_object("full", "file", io.BytesIO(b"data"))
|
||||
58
tests/test_iam_lockout.py
Normal file
58
tests/test_iam_lockout.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import json
|
||||
import time
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from app.iam import IamError, IamService
|
||||
|
||||
|
||||
def _make_service(tmp_path, *, max_attempts=3, lockout_seconds=2):
|
||||
config = tmp_path / "iam.json"
|
||||
payload = {
|
||||
"users": [
|
||||
{
|
||||
"access_key": "test",
|
||||
"secret_key": "secret",
|
||||
"display_name": "Test User",
|
||||
"policies": [
|
||||
{
|
||||
"bucket": "*",
|
||||
"actions": ["list", "read", "write", "delete", "policy"],
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
config.write_text(json.dumps(payload))
|
||||
service = IamService(config, auth_max_attempts=max_attempts, auth_lockout_minutes=lockout_seconds/60)
|
||||
return service
|
||||
|
||||
|
||||
def test_lockout_triggers_after_failed_attempts(tmp_path):
|
||||
service = _make_service(tmp_path, max_attempts=3, lockout_seconds=30)
|
||||
|
||||
for _ in range(service.auth_max_attempts):
|
||||
with pytest.raises(IamError) as exc:
|
||||
service.authenticate("test", "bad-secret")
|
||||
assert "Invalid credentials" in str(exc.value)
|
||||
|
||||
with pytest.raises(IamError) as exc:
|
||||
service.authenticate("test", "bad-secret")
|
||||
assert "Access temporarily locked" in str(exc.value)
|
||||
|
||||
|
||||
def test_lockout_expires_and_allows_auth(tmp_path):
|
||||
service = _make_service(tmp_path, max_attempts=2, lockout_seconds=1)
|
||||
|
||||
for _ in range(service.auth_max_attempts):
|
||||
with pytest.raises(IamError):
|
||||
service.authenticate("test", "bad-secret")
|
||||
|
||||
with pytest.raises(IamError) as exc:
|
||||
service.authenticate("test", "secret")
|
||||
assert "Access temporarily locked" in str(exc.value)
|
||||
|
||||
time.sleep(1.1)
|
||||
principal = service.authenticate("test", "secret")
|
||||
assert principal.access_key == "test"
|
||||
234
tests/test_storage_features.py
Normal file
234
tests/test_storage_features.py
Normal file
@@ -0,0 +1,234 @@
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.storage import ObjectStorage, StorageError
|
||||
|
||||
|
||||
def test_multipart_upload_round_trip(tmp_path):
|
||||
storage = ObjectStorage(tmp_path)
|
||||
storage.create_bucket("media")
|
||||
upload_id = storage.initiate_multipart_upload("media", "large.bin", metadata={"env": "test"})
|
||||
|
||||
first_etag = storage.upload_multipart_part("media", upload_id, 1, io.BytesIO(b"hello "))
|
||||
second_etag = storage.upload_multipart_part("media", upload_id, 2, io.BytesIO(b"world"))
|
||||
|
||||
meta = storage.complete_multipart_upload(
|
||||
"media",
|
||||
upload_id,
|
||||
[
|
||||
{"part_number": 1, "etag": first_etag},
|
||||
{"part_number": 2, "etag": second_etag},
|
||||
],
|
||||
)
|
||||
|
||||
assert meta.key == "large.bin"
|
||||
assert meta.size == len(b"hello world")
|
||||
assert meta.metadata == {"env": "test"}
|
||||
assert (tmp_path / "media" / "large.bin").read_bytes() == b"hello world"
|
||||
|
||||
|
||||
def test_abort_multipart_upload(tmp_path):
|
||||
storage = ObjectStorage(tmp_path)
|
||||
storage.create_bucket("docs")
|
||||
upload_id = storage.initiate_multipart_upload("docs", "draft.txt")
|
||||
|
||||
storage.abort_multipart_upload("docs", upload_id)
|
||||
|
||||
with pytest.raises(StorageError):
|
||||
storage.upload_multipart_part("docs", upload_id, 1, io.BytesIO(b"data"))
|
||||
|
||||
|
||||
def test_bucket_versioning_toggle_and_restore(tmp_path):
|
||||
storage = ObjectStorage(tmp_path)
|
||||
storage.create_bucket("history")
|
||||
assert storage.is_versioning_enabled("history") is False
|
||||
storage.set_bucket_versioning("history", True)
|
||||
assert storage.is_versioning_enabled("history") is True
|
||||
|
||||
storage.put_object("history", "note.txt", io.BytesIO(b"v1"))
|
||||
storage.put_object("history", "note.txt", io.BytesIO(b"v2"))
|
||||
versions = storage.list_object_versions("history", "note.txt")
|
||||
assert versions
|
||||
assert versions[0]["size"] == len(b"v1")
|
||||
|
||||
storage.delete_object("history", "note.txt")
|
||||
versions = storage.list_object_versions("history", "note.txt")
|
||||
assert len(versions) >= 2
|
||||
|
||||
target_version = versions[-1]["version_id"]
|
||||
storage.restore_object_version("history", "note.txt", target_version)
|
||||
restored = (tmp_path / "history" / "note.txt").read_bytes()
|
||||
assert restored == b"v1"
|
||||
|
||||
|
||||
def test_bucket_configuration_helpers(tmp_path):
|
||||
storage = ObjectStorage(tmp_path)
|
||||
storage.create_bucket("cfg")
|
||||
|
||||
assert storage.get_bucket_tags("cfg") == []
|
||||
storage.set_bucket_tags("cfg", [{"Key": "env", "Value": "dev"}])
|
||||
tags = storage.get_bucket_tags("cfg")
|
||||
assert tags == [{"Key": "env", "Value": "dev"}]
|
||||
storage.set_bucket_tags("cfg", None)
|
||||
assert storage.get_bucket_tags("cfg") == []
|
||||
|
||||
assert storage.get_bucket_cors("cfg") == []
|
||||
cors_rules = [{"AllowedOrigins": ["*"], "AllowedMethods": ["GET"], "AllowedHeaders": ["*"]}]
|
||||
storage.set_bucket_cors("cfg", cors_rules)
|
||||
assert storage.get_bucket_cors("cfg") == cors_rules
|
||||
storage.set_bucket_cors("cfg", None)
|
||||
assert storage.get_bucket_cors("cfg") == []
|
||||
|
||||
assert storage.get_bucket_encryption("cfg") == {}
|
||||
encryption = {"Rules": [{"SSEAlgorithm": "AES256"}]}
|
||||
storage.set_bucket_encryption("cfg", encryption)
|
||||
assert storage.get_bucket_encryption("cfg") == encryption
|
||||
storage.set_bucket_encryption("cfg", None)
|
||||
assert storage.get_bucket_encryption("cfg") == {}
|
||||
|
||||
|
||||
def test_delete_object_retries_when_locked(tmp_path, monkeypatch):
|
||||
storage = ObjectStorage(tmp_path)
|
||||
storage.create_bucket("demo")
|
||||
storage.put_object("demo", "video.mp4", io.BytesIO(b"data"))
|
||||
|
||||
target_path = tmp_path / "demo" / "video.mp4"
|
||||
original_unlink = Path.unlink
|
||||
attempts = {"count": 0}
|
||||
|
||||
def flaky_unlink(self):
|
||||
if self == target_path and attempts["count"] < 1:
|
||||
attempts["count"] += 1
|
||||
raise PermissionError("locked")
|
||||
return original_unlink(self)
|
||||
|
||||
monkeypatch.setattr(Path, "unlink", flaky_unlink)
|
||||
|
||||
storage.delete_object("demo", "video.mp4")
|
||||
assert attempts["count"] == 1
|
||||
|
||||
|
||||
def test_delete_bucket_handles_metadata_residue(tmp_path):
|
||||
storage = ObjectStorage(tmp_path)
|
||||
storage.create_bucket("demo")
|
||||
storage.put_object("demo", "file.txt", io.BytesIO(b"data"), metadata={"env": "test"})
|
||||
storage.delete_object("demo", "file.txt")
|
||||
meta_dir = tmp_path / ".myfsio.sys" / "buckets" / "demo" / "meta"
|
||||
assert meta_dir.exists()
|
||||
|
||||
storage.delete_bucket("demo")
|
||||
assert not (tmp_path / "demo").exists()
|
||||
assert not (tmp_path / ".myfsio.sys" / "buckets" / "demo").exists()
|
||||
|
||||
|
||||
def test_delete_bucket_requires_archives_removed(tmp_path):
|
||||
storage = ObjectStorage(tmp_path)
|
||||
storage.create_bucket("demo")
|
||||
storage.set_bucket_versioning("demo", True)
|
||||
storage.put_object("demo", "file.txt", io.BytesIO(b"data"))
|
||||
storage.delete_object("demo", "file.txt")
|
||||
versions_dir = tmp_path / ".myfsio.sys" / "buckets" / "demo" / "versions"
|
||||
assert versions_dir.exists()
|
||||
|
||||
with pytest.raises(StorageError):
|
||||
storage.delete_bucket("demo")
|
||||
|
||||
storage.purge_object("demo", "file.txt")
|
||||
storage.delete_bucket("demo")
|
||||
assert not (tmp_path / "demo").exists()
|
||||
assert not (tmp_path / ".myfsio.sys" / "buckets" / "demo").exists()
|
||||
|
||||
|
||||
def test_delete_bucket_handles_multipart_residue(tmp_path):
|
||||
storage = ObjectStorage(tmp_path)
|
||||
storage.create_bucket("demo")
|
||||
upload_id = storage.initiate_multipart_upload("demo", "file.txt")
|
||||
# Leave upload incomplete so the system multipart directory sticks around.
|
||||
multipart_dir = tmp_path / ".myfsio.sys" / "multipart" / "demo"
|
||||
assert multipart_dir.exists()
|
||||
assert (multipart_dir / upload_id).exists()
|
||||
|
||||
with pytest.raises(StorageError):
|
||||
storage.delete_bucket("demo")
|
||||
|
||||
storage.abort_multipart_upload("demo", upload_id)
|
||||
storage.delete_bucket("demo")
|
||||
assert not (tmp_path / "demo").exists()
|
||||
assert not multipart_dir.exists()
|
||||
|
||||
|
||||
def test_purge_object_raises_when_file_in_use(tmp_path, monkeypatch):
|
||||
storage = ObjectStorage(tmp_path)
|
||||
storage.create_bucket("demo")
|
||||
storage.put_object("demo", "clip.mp4", io.BytesIO(b"data"))
|
||||
|
||||
target_path = tmp_path / "demo" / "clip.mp4"
|
||||
original_unlink = Path.unlink
|
||||
|
||||
def always_locked(self):
|
||||
if self == target_path:
|
||||
raise PermissionError("still locked")
|
||||
return original_unlink(self)
|
||||
|
||||
monkeypatch.setattr(Path, "unlink", always_locked)
|
||||
|
||||
with pytest.raises(StorageError) as exc:
|
||||
storage.purge_object("demo", "clip.mp4")
|
||||
assert "in use" in str(exc.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"object_key",
|
||||
[
|
||||
"../secret.txt",
|
||||
"folder/../secret.txt",
|
||||
"/absolute.txt",
|
||||
"\\backslash.txt",
|
||||
"bad\x00key",
|
||||
],
|
||||
)
|
||||
def test_object_key_sanitization_blocks_traversal(object_key):
|
||||
with pytest.raises(StorageError):
|
||||
ObjectStorage._sanitize_object_key(object_key)
|
||||
|
||||
|
||||
def test_object_key_length_limit_enforced():
|
||||
key = "a" * 1025
|
||||
with pytest.raises(StorageError):
|
||||
ObjectStorage._sanitize_object_key(key)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"object_key",
|
||||
[
|
||||
".meta/data.bin",
|
||||
".versions/foo.bin",
|
||||
".multipart/upload.part",
|
||||
".myfsio.sys/system.bin",
|
||||
],
|
||||
)
|
||||
def test_object_key_blocks_reserved_paths(object_key):
|
||||
with pytest.raises(StorageError):
|
||||
ObjectStorage._sanitize_object_key(object_key)
|
||||
|
||||
|
||||
def test_bucket_config_filename_allowed(tmp_path):
|
||||
storage = ObjectStorage(tmp_path)
|
||||
storage.create_bucket("demo")
|
||||
storage.put_object("demo", ".bucket.json", io.BytesIO(b"{}"))
|
||||
|
||||
objects = storage.list_objects("demo")
|
||||
assert any(meta.key == ".bucket.json" for meta in objects)
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "nt", reason="Windows-specific filename rules")
|
||||
def test_windows_filename_rules_enforced():
|
||||
with pytest.raises(StorageError):
|
||||
ObjectStorage._sanitize_object_key("CON/file.txt")
|
||||
with pytest.raises(StorageError):
|
||||
ObjectStorage._sanitize_object_key("folder/spaces ")
|
||||
with pytest.raises(StorageError):
|
||||
ObjectStorage._sanitize_object_key("C:drivepath.txt")
|
||||
96
tests/test_ui_bulk_delete.py
Normal file
96
tests/test_ui_bulk_delete.py
Normal file
@@ -0,0 +1,96 @@
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from app import create_app
|
||||
|
||||
|
||||
def _build_app(tmp_path: Path):
|
||||
storage_root = tmp_path / "data"
|
||||
iam_config = tmp_path / "iam.json"
|
||||
bucket_policies = tmp_path / "bucket_policies.json"
|
||||
iam_payload = {
|
||||
"users": [
|
||||
{
|
||||
"access_key": "test",
|
||||
"secret_key": "secret",
|
||||
"display_name": "Bulk Tester",
|
||||
"policies": [{"bucket": "*", "actions": ["list", "read", "write", "delete", "policy"]}],
|
||||
}
|
||||
]
|
||||
}
|
||||
iam_config.write_text(json.dumps(iam_payload))
|
||||
app = create_app(
|
||||
{
|
||||
"TESTING": True,
|
||||
"STORAGE_ROOT": storage_root,
|
||||
"IAM_CONFIG": iam_config,
|
||||
"BUCKET_POLICY_PATH": bucket_policies,
|
||||
"API_BASE_URL": "http://localhost",
|
||||
"SECRET_KEY": "testing",
|
||||
}
|
||||
)
|
||||
return app
|
||||
|
||||
|
||||
def _login(client):
|
||||
return client.post(
|
||||
"/ui/login",
|
||||
data={"access_key": "test", "secret_key": "secret"},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
|
||||
def test_bulk_delete_json_route(tmp_path: Path):
|
||||
app = _build_app(tmp_path)
|
||||
storage = app.extensions["object_storage"]
|
||||
storage.create_bucket("demo")
|
||||
storage.put_object("demo", "first.txt", io.BytesIO(b"first"))
|
||||
storage.put_object("demo", "second.txt", io.BytesIO(b"second"))
|
||||
|
||||
client = app.test_client()
|
||||
assert _login(client).status_code == 200
|
||||
|
||||
response = client.post(
|
||||
"/ui/buckets/demo/objects/bulk-delete",
|
||||
json={"keys": ["first.txt", "missing.txt"]},
|
||||
headers={"X-Requested-With": "XMLHttpRequest"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
payload = response.get_json()
|
||||
assert payload["status"] == "ok"
|
||||
assert set(payload["deleted"]) == {"first.txt", "missing.txt"}
|
||||
assert payload["errors"] == []
|
||||
|
||||
listing = storage.list_objects("demo")
|
||||
assert {meta.key for meta in listing} == {"second.txt"}
|
||||
|
||||
|
||||
def test_bulk_delete_validation(tmp_path: Path):
|
||||
app = _build_app(tmp_path)
|
||||
storage = app.extensions["object_storage"]
|
||||
storage.create_bucket("demo")
|
||||
storage.put_object("demo", "keep.txt", io.BytesIO(b"keep"))
|
||||
|
||||
client = app.test_client()
|
||||
assert _login(client).status_code == 200
|
||||
|
||||
bad_response = client.post(
|
||||
"/ui/buckets/demo/objects/bulk-delete",
|
||||
json={"keys": []},
|
||||
headers={"X-Requested-With": "XMLHttpRequest"},
|
||||
)
|
||||
assert bad_response.status_code == 400
|
||||
assert bad_response.get_json()["status"] == "error"
|
||||
|
||||
too_many = [f"obj-{index}.txt" for index in range(501)]
|
||||
limit_response = client.post(
|
||||
"/ui/buckets/demo/objects/bulk-delete",
|
||||
json={"keys": too_many},
|
||||
headers={"X-Requested-With": "XMLHttpRequest"},
|
||||
)
|
||||
assert limit_response.status_code == 400
|
||||
assert limit_response.get_json()["status"] == "error"
|
||||
|
||||
still_there = storage.list_objects("demo")
|
||||
assert {meta.key for meta in still_there} == {"keep.txt"}
|
||||
56
tests/test_ui_docs.py
Normal file
56
tests/test_ui_docs.py
Normal file
@@ -0,0 +1,56 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from app import create_app
|
||||
|
||||
|
||||
def _build_ui_app(tmp_path: Path):
|
||||
storage_root = tmp_path / "data"
|
||||
iam_config = tmp_path / "iam.json"
|
||||
bucket_policies = tmp_path / "bucket_policies.json"
|
||||
iam_payload = {
|
||||
"users": [
|
||||
{
|
||||
"access_key": "test",
|
||||
"secret_key": "secret",
|
||||
"display_name": "Test User",
|
||||
"policies": [{"bucket": "*", "actions": ["list", "read", "write", "delete", "policy"]}],
|
||||
}
|
||||
]
|
||||
}
|
||||
iam_config.write_text(json.dumps(iam_payload))
|
||||
return create_app(
|
||||
{
|
||||
"TESTING": True,
|
||||
"STORAGE_ROOT": storage_root,
|
||||
"IAM_CONFIG": iam_config,
|
||||
"BUCKET_POLICY_PATH": bucket_policies,
|
||||
"API_BASE_URL": "http://example.test:9000",
|
||||
"SECRET_KEY": "testing",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_docs_requires_login(tmp_path: Path):
|
||||
app = _build_ui_app(tmp_path)
|
||||
client = app.test_client()
|
||||
response = client.get("/ui/docs")
|
||||
assert response.status_code == 302
|
||||
assert response.headers["Location"].endswith("/ui/login")
|
||||
|
||||
|
||||
def test_docs_render_for_authenticated_user(tmp_path: Path):
|
||||
app = _build_ui_app(tmp_path)
|
||||
client = app.test_client()
|
||||
# Prime session by signing in
|
||||
login_response = client.post(
|
||||
"/ui/login",
|
||||
data={"access_key": "test", "secret_key": "secret"},
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert login_response.status_code == 200
|
||||
|
||||
response = client.get("/ui/docs")
|
||||
assert response.status_code == 200
|
||||
assert b"Your guide to MyFSIO" in response.data
|
||||
assert b"http://example.test:9000" in response.data
|
||||
113
tests/test_ui_policy.py
Normal file
113
tests/test_ui_policy.py
Normal file
@@ -0,0 +1,113 @@
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app import create_app
|
||||
|
||||
|
||||
DENY_LIST_ALLOW_GET_POLICY = {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"AWS": ["*"]},
|
||||
"Action": ["s3:GetObject"],
|
||||
"Resource": ["arn:aws:s3:::testbucket/*"],
|
||||
},
|
||||
{
|
||||
"Effect": "Deny",
|
||||
"Principal": {"AWS": ["*"]},
|
||||
"Action": ["s3:ListBucket"],
|
||||
"Resource": ["arn:aws:s3:::testbucket"],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _make_ui_app(tmp_path: Path, *, enforce_policies: bool):
|
||||
storage_root = tmp_path / "data"
|
||||
iam_config = tmp_path / "iam.json"
|
||||
bucket_policies = tmp_path / "bucket_policies.json"
|
||||
iam_payload = {
|
||||
"users": [
|
||||
{
|
||||
"access_key": "test",
|
||||
"secret_key": "secret",
|
||||
"display_name": "Test User",
|
||||
"policies": [{"bucket": "*", "actions": ["list", "read", "write", "delete", "policy"]}],
|
||||
}
|
||||
]
|
||||
}
|
||||
iam_config.write_text(json.dumps(iam_payload))
|
||||
app = create_app(
|
||||
{
|
||||
"TESTING": True,
|
||||
"STORAGE_ROOT": storage_root,
|
||||
"IAM_CONFIG": iam_config,
|
||||
"BUCKET_POLICY_PATH": bucket_policies,
|
||||
"API_BASE_URL": "http://testserver",
|
||||
"SECRET_KEY": "testing",
|
||||
"UI_ENFORCE_BUCKET_POLICIES": enforce_policies,
|
||||
}
|
||||
)
|
||||
storage = app.extensions["object_storage"]
|
||||
storage.create_bucket("testbucket")
|
||||
storage.put_object("testbucket", "vid.mp4", io.BytesIO(b"video"))
|
||||
policy_store = app.extensions["bucket_policies"]
|
||||
policy_store.set_policy("testbucket", DENY_LIST_ALLOW_GET_POLICY)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enforce", [True, False])
|
||||
def test_ui_bucket_policy_enforcement_toggle(tmp_path: Path, enforce: bool):
|
||||
app = _make_ui_app(tmp_path, enforce_policies=enforce)
|
||||
client = app.test_client()
|
||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||
response = client.get("/ui/buckets/testbucket", follow_redirects=True)
|
||||
if enforce:
|
||||
assert b"Access denied by bucket policy" in response.data
|
||||
else:
|
||||
assert response.status_code == 200
|
||||
assert b"vid.mp4" in response.data
|
||||
assert b"Access denied by bucket policy" not in response.data
|
||||
|
||||
|
||||
def test_ui_bucket_policy_disabled_by_default(tmp_path: Path):
|
||||
storage_root = tmp_path / "data"
|
||||
iam_config = tmp_path / "iam.json"
|
||||
bucket_policies = tmp_path / "bucket_policies.json"
|
||||
iam_payload = {
|
||||
"users": [
|
||||
{
|
||||
"access_key": "test",
|
||||
"secret_key": "secret",
|
||||
"display_name": "Test User",
|
||||
"policies": [{"bucket": "*", "actions": ["list", "read", "write", "delete", "policy"]}],
|
||||
}
|
||||
]
|
||||
}
|
||||
iam_config.write_text(json.dumps(iam_payload))
|
||||
app = create_app(
|
||||
{
|
||||
"TESTING": True,
|
||||
"STORAGE_ROOT": storage_root,
|
||||
"IAM_CONFIG": iam_config,
|
||||
"BUCKET_POLICY_PATH": bucket_policies,
|
||||
"API_BASE_URL": "http://testserver",
|
||||
"SECRET_KEY": "testing",
|
||||
}
|
||||
)
|
||||
storage = app.extensions["object_storage"]
|
||||
storage.create_bucket("testbucket")
|
||||
storage.put_object("testbucket", "vid.mp4", io.BytesIO(b"video"))
|
||||
policy_store = app.extensions["bucket_policies"]
|
||||
policy_store.set_policy("testbucket", DENY_LIST_ALLOW_GET_POLICY)
|
||||
|
||||
client = app.test_client()
|
||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||
response = client.get("/ui/buckets/testbucket", follow_redirects=True)
|
||||
assert response.status_code == 200
|
||||
assert b"vid.mp4" in response.data
|
||||
assert b"Access denied by bucket policy" not in response.data
|
||||
Reference in New Issue
Block a user