Release v0.1.0 Beta

This commit is contained in:
2025-11-21 22:01:34 +08:00
commit f400cedf02
40 changed files with 10720 additions and 0 deletions

167
tests/conftest.py Normal file
View 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
View 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

View 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
View 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
View 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"

View 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")

View 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
View 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
View 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