Files
wehub-resource-sync e0e362d700
SDK Tests / SDK CI (push) Blocked by required conditions
SDK Tests / changes (push) Successful in 2m29s
Real E2E Tests / changes (push) Successful in 2m29s
SDK Tests / Python SDK Tests (sandbox) (push) Waiting to run
SDK Tests / CLI Quality (push) Waiting to run
SDK Tests / CLI Tests (push) Waiting to run
SDK Tests / Python SDK Quality (code-interpreter) (push) Waiting to run
SDK Tests / Python SDK Quality (sandbox) (push) Waiting to run
SDK Tests / Python SDK Tests (code-interpreter) (push) Waiting to run
SDK Tests / JavaScript SDK Quality And Tests (code-interpreter) (push) Waiting to run
SDK Tests / JavaScript SDK Quality And Tests (sandbox) (push) Waiting to run
SDK Tests / Kotlin SDK Quality And Tests (sandbox) (push) Waiting to run
SDK Tests / Kotlin SDK Quality And Tests (code-interpreter) (push) Waiting to run
SDK Tests / C# SDK Quality And Tests (code-interpreter) (push) Waiting to run
SDK Tests / C# SDK Quality And Tests (sandbox) (push) Waiting to run
SDK Tests / Go SDK Quality And Tests (push) Waiting to run
Deploy Docs Pages / build (push) Has been cancelled
Deploy Docs Pages / deploy (push) Has been cancelled
Real E2E Tests / JavaScript E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Python E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Java E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / C# E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Go E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Real E2E CI (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:39:33 +08:00

747 lines
31 KiB
Python

# Copyright 2025 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import pytest
from fastapi import HTTPException
from opensandbox_server.api.schema import Host, OSSFS, PVC, Volume, PlatformSpec
from opensandbox_server.services.constants import SandboxErrorCodes
from opensandbox_server.services.validators import (
ensure_credential_proxy_configured,
ensure_egress_runtime_compatible,
ensure_metadata_labels,
ensure_platform_valid,
ensure_timeout_within_limit,
ensure_valid_host_path,
ensure_valid_mount_path,
ensure_valid_pvc_name,
ensure_valid_sub_path,
ensure_valid_volume_name,
ensure_volumes_valid,
)
def test_ensure_platform_valid_accepts_windows_amd64():
platform = PlatformSpec(os="windows", arch="amd64")
assert ensure_platform_valid(platform) is None
assert platform.os == "windows"
assert platform.arch == "amd64"
def test_ensure_platform_valid_accepts_windows_arm64():
platform = PlatformSpec(os="windows", arch="arm64")
assert ensure_platform_valid(platform) is None
assert platform.os == "windows"
assert platform.arch == "arm64"
def test_ensure_platform_valid_rejects_unsupported_os():
platform = PlatformSpec(os="darwin", arch="amd64")
with pytest.raises(HTTPException) as exc_info:
assert ensure_platform_valid(platform) is None
assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == SandboxErrorCodes.INVALID_PARAMETER
def test_ensure_metadata_labels_accepts_common_k8s_forms():
valid_metadata = {
"app": "web",
"k8s.io/name": "app-1",
"example.com/label": "a.b_c-1",
"team": "A1_b-2.c",
"empty": "",
}
assert ensure_metadata_labels(valid_metadata) is None
def test_ensure_metadata_labels_allows_none_or_empty():
assert ensure_metadata_labels(None) is None
assert ensure_metadata_labels({}) is None
def test_ensure_metadata_labels_rejects_name_too_long():
"""Label name part exceeding 63 characters should be rejected."""
long_name = "a" * 64
with pytest.raises(HTTPException) as exc_info:
assert ensure_metadata_labels({long_name: "value"}) is None
assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == SandboxErrorCodes.INVALID_METADATA_LABEL
def test_ensure_metadata_labels_rejects_prefix_too_long():
"""Label prefix (DNS subdomain) exceeding 253 characters should be rejected."""
# Build a prefix that is longer than 253 chars: 5 labels of 62 chars = 314 chars
label_part = "a" * 62
long_prefix = ".".join([label_part] * 5) # 62*5 + 4 = 314 chars
key = f"{long_prefix}/name"
with pytest.raises(HTTPException) as exc_info:
assert ensure_metadata_labels({key: "value"}) is None
assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == SandboxErrorCodes.INVALID_METADATA_LABEL
def test_ensure_metadata_labels_accepts_key_with_max_length_prefix_and_name():
"""Valid key where prefix <= 253 chars and name <= 63 chars but total > 253 should be accepted."""
label_part = "a" * 62
prefix = ".".join([label_part] * 4)
assert len(prefix) == 251
key = f"{prefix}/valid-name"
assert ensure_metadata_labels({key: "value"}) is None
def test_ensure_metadata_labels_rejects_invalid_prefix_format():
"""Label prefix with invalid DNS subdomain characters should be rejected."""
with pytest.raises(HTTPException) as exc_info:
assert ensure_metadata_labels({"INVALID_PREFIX.io/name": "value"}) is None
assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == SandboxErrorCodes.INVALID_METADATA_LABEL
def test_ensure_metadata_labels_rejects_value_too_long():
"""Label value exceeding 63 characters should be rejected."""
long_value = "a" * 64
with pytest.raises(HTTPException) as exc_info:
assert ensure_metadata_labels({"app": long_value}) is None
assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == SandboxErrorCodes.INVALID_METADATA_LABEL
def test_ensure_metadata_labels_rejects_key_with_empty_prefix():
"""Key with an empty prefix (starts with '/') should be rejected."""
with pytest.raises(HTTPException) as exc_info:
assert ensure_metadata_labels({"/name": "value"}) is None
assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == SandboxErrorCodes.INVALID_METADATA_LABEL
def test_ensure_metadata_labels_rejects_reserved_prefix():
"""User metadata must not use the opensandbox.io/ reserved prefix."""
with pytest.raises(HTTPException) as exc_info:
assert ensure_metadata_labels({"opensandbox.io/expires-at": "2030-01-01T00:00:00Z"}) is None
assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == SandboxErrorCodes.INVALID_METADATA_LABEL
assert "reserved prefix" in exc_info.value.detail["message"]
def test_ensure_metadata_labels_rejects_manual_cleanup_key():
"""User must not inject the manual-cleanup lifecycle label."""
with pytest.raises(HTTPException) as exc_info:
assert ensure_metadata_labels({"opensandbox.io/manual-cleanup": "true"}) is None
assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == SandboxErrorCodes.INVALID_METADATA_LABEL
assert "reserved prefix" in exc_info.value.detail["message"]
def test_ensure_metadata_labels_rejects_arbitrary_reserved_key():
"""Any key under opensandbox.io/ should be rejected, not just known labels."""
with pytest.raises(HTTPException) as exc_info:
assert ensure_metadata_labels({"opensandbox.io/custom": "value"}) is None
assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == SandboxErrorCodes.INVALID_METADATA_LABEL
def test_ensure_timeout_within_limit_allows_equal_boundary():
assert ensure_timeout_within_limit(3600, 3600) is None
def test_ensure_timeout_within_limit_allows_disabled_upper_bound():
assert ensure_timeout_within_limit(7200, None) is None
def test_ensure_timeout_within_limit_rejects_timeout_above_limit():
with pytest.raises(HTTPException) as exc_info:
assert ensure_timeout_within_limit(3601, 3600) is None
assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == SandboxErrorCodes.INVALID_PARAMETER
def test_ensure_timeout_within_limit_rejects_unrepresentable_timeout():
with pytest.raises(HTTPException) as exc_info:
ensure_timeout_within_limit(10**20, None)
assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == SandboxErrorCodes.INVALID_PARAMETER
assert "too large" in exc_info.value.detail["message"]
class TestEnsureValidVolumeName:
def test_valid_simple_name(self):
"""Simple lowercase names should be valid."""
assert ensure_valid_volume_name("workdir") is None
assert ensure_valid_volume_name("data") is None
assert ensure_valid_volume_name("models") is None
def test_valid_name_with_numbers(self):
"""Names with numbers should be valid."""
assert ensure_valid_volume_name("data1") is None
assert ensure_valid_volume_name("vol2") is None
assert ensure_valid_volume_name("123") is None
def test_valid_name_with_hyphens(self):
"""Names with hyphens should be valid."""
assert ensure_valid_volume_name("my-volume") is None
assert ensure_valid_volume_name("data-cache-1") is None
assert ensure_valid_volume_name("a-b-c") is None
def test_empty_name_raises(self):
"""Empty name should raise HTTPException."""
with pytest.raises(HTTPException) as exc_info:
ensure_valid_volume_name("")
assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == SandboxErrorCodes.INVALID_VOLUME_NAME
def test_name_too_long_raises(self):
"""Name exceeding 63 characters should raise HTTPException."""
long_name = "a" * 64
with pytest.raises(HTTPException) as exc_info:
ensure_valid_volume_name(long_name)
assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == SandboxErrorCodes.INVALID_VOLUME_NAME
def test_uppercase_name_raises(self):
"""Uppercase letters should raise HTTPException."""
with pytest.raises(HTTPException) as exc_info:
ensure_valid_volume_name("MyVolume")
assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == SandboxErrorCodes.INVALID_VOLUME_NAME
def test_underscore_name_raises(self):
"""Underscores should raise HTTPException (not valid DNS label)."""
with pytest.raises(HTTPException) as exc_info:
ensure_valid_volume_name("my_volume")
assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == SandboxErrorCodes.INVALID_VOLUME_NAME
def test_name_starting_with_hyphen_raises(self):
"""Names starting with hyphen should raise HTTPException."""
with pytest.raises(HTTPException) as exc_info:
ensure_valid_volume_name("-volume")
assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == SandboxErrorCodes.INVALID_VOLUME_NAME
def test_name_ending_with_hyphen_raises(self):
"""Names ending with hyphen should raise HTTPException."""
with pytest.raises(HTTPException) as exc_info:
ensure_valid_volume_name("volume-")
assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == SandboxErrorCodes.INVALID_VOLUME_NAME
class TestEnsureValidMountPath:
def test_valid_absolute_path(self):
"""Absolute paths should be valid."""
assert ensure_valid_mount_path("/mnt/data") is None
assert ensure_valid_mount_path("/") is None
assert ensure_valid_mount_path("/home/user/work") is None
def test_empty_path_raises(self):
"""Empty path should raise HTTPException."""
with pytest.raises(HTTPException) as exc_info:
ensure_valid_mount_path("")
assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == SandboxErrorCodes.INVALID_MOUNT_PATH
def test_relative_path_raises(self):
"""Relative paths should raise HTTPException."""
with pytest.raises(HTTPException) as exc_info:
ensure_valid_mount_path("data/files")
assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == SandboxErrorCodes.INVALID_MOUNT_PATH
def test_path_not_starting_with_slash_raises(self):
"""Paths not starting with '/' should raise HTTPException."""
with pytest.raises(HTTPException) as exc_info:
ensure_valid_mount_path("mnt/data")
assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == SandboxErrorCodes.INVALID_MOUNT_PATH
class TestEnsureValidSubPath:
def test_none_subpath_valid(self):
"""None subpath should be valid."""
assert ensure_valid_sub_path(None) is None
def test_empty_subpath_valid(self):
"""Empty string subpath should be valid."""
assert ensure_valid_sub_path("") is None
def test_relative_subpath_valid(self):
"""Relative paths should be valid."""
assert ensure_valid_sub_path("task-001") is None
assert ensure_valid_sub_path("user/data") is None
assert ensure_valid_sub_path("a/b/c") is None
def test_absolute_subpath_raises(self):
"""Absolute paths should raise HTTPException."""
with pytest.raises(HTTPException) as exc_info:
ensure_valid_sub_path("/absolute/path")
assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == SandboxErrorCodes.INVALID_SUB_PATH
def test_path_traversal_raises(self):
"""Path traversal (..) should raise HTTPException."""
with pytest.raises(HTTPException) as exc_info:
ensure_valid_sub_path("../parent")
assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == SandboxErrorCodes.INVALID_SUB_PATH
def test_embedded_path_traversal_raises(self):
"""Embedded path traversal should raise HTTPException."""
with pytest.raises(HTTPException) as exc_info:
ensure_valid_sub_path("a/../b")
assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == SandboxErrorCodes.INVALID_SUB_PATH
class TestEnsureValidHostPath:
def test_valid_absolute_path(self):
"""Absolute paths should be valid."""
assert ensure_valid_host_path("/data/opensandbox") is None
assert ensure_valid_host_path("/tmp") is None
def test_valid_windows_absolute_path(self):
"""Windows absolute paths should be valid."""
assert ensure_valid_host_path(r"D:\sandbox-mnt\ReMe") is None
assert ensure_valid_host_path("D:/sandbox-mnt/ReMe") is None
def test_valid_windows_drive_root(self):
"""Windows drive roots should be valid absolute paths."""
assert ensure_valid_host_path("D:\\") is None
assert ensure_valid_host_path("D:/") is None
def test_empty_path_raises(self):
"""Empty path should raise HTTPException."""
with pytest.raises(HTTPException) as exc_info:
ensure_valid_host_path("")
assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == SandboxErrorCodes.INVALID_HOST_PATH
def test_relative_path_raises(self):
"""Relative paths should raise HTTPException."""
with pytest.raises(HTTPException) as exc_info:
ensure_valid_host_path("data/files")
assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == SandboxErrorCodes.INVALID_HOST_PATH
def test_path_with_traversal_raises(self):
"""Paths with traversal should raise HTTPException."""
with pytest.raises(HTTPException) as exc_info:
ensure_valid_host_path("/data/../etc/passwd")
assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == SandboxErrorCodes.INVALID_HOST_PATH
def test_path_with_double_slash_raises(self):
"""Paths with double slashes should raise HTTPException."""
with pytest.raises(HTTPException) as exc_info:
ensure_valid_host_path("/data//files")
assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == SandboxErrorCodes.INVALID_HOST_PATH
def test_allowed_prefix_match(self):
"""Paths under allowed prefixes should be valid."""
allowed = ["/data/opensandbox", "/tmp/sandbox"]
assert ensure_valid_host_path("/data/opensandbox/user-a", allowed) is None
assert ensure_valid_host_path("/tmp/sandbox/task-1", allowed) is None
def test_allowed_prefix_exact_match(self):
"""Exact prefix match should be valid."""
allowed = ["/data/opensandbox"]
assert ensure_valid_host_path("/data/opensandbox", allowed) is None
def test_allowed_prefix_match_windows_paths(self):
"""Windows paths under an allowed Windows prefix should be valid."""
allowed = [r"D:\sandbox-mnt"]
assert ensure_valid_host_path(r"D:\sandbox-mnt\ReMe", allowed) is None
assert ensure_valid_host_path("D:/sandbox-mnt/ReMe", allowed) is None
def test_allowed_prefix_match_windows_paths_is_case_insensitive_for_drive(self):
"""Drive-letter casing differences should not break allowlist checks."""
allowed = ["D:/sandbox-mnt"]
assert ensure_valid_host_path("d:/sandbox-mnt/ReMe", allowed) is None
def test_path_not_in_allowed_prefix_raises(self):
"""Paths not under allowed prefixes should raise HTTPException."""
allowed = ["/data/opensandbox"]
with pytest.raises(HTTPException) as exc_info:
ensure_valid_host_path("/etc/passwd", allowed)
assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == SandboxErrorCodes.HOST_PATH_NOT_ALLOWED
def test_partial_prefix_match_raises(self):
"""Partial prefix matches should not be allowed."""
allowed = ["/data/opensandbox"]
with pytest.raises(HTTPException) as exc_info:
ensure_valid_host_path("/data/opensandbox-evil", allowed)
assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == SandboxErrorCodes.HOST_PATH_NOT_ALLOWED
class TestEnsureValidPvcName:
def test_valid_simple_name(self):
"""Simple lowercase names should be valid."""
assert ensure_valid_pvc_name("my-pvc") is None
assert ensure_valid_pvc_name("data-volume") is None
assert ensure_valid_pvc_name("pvc1") is None
def test_empty_name_raises(self):
"""Empty name should raise HTTPException."""
with pytest.raises(HTTPException) as exc_info:
ensure_valid_pvc_name("")
assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == SandboxErrorCodes.INVALID_PVC_NAME
def test_name_too_long_raises(self):
"""Name exceeding 253 characters should raise HTTPException."""
long_name = "a" * 254
with pytest.raises(HTTPException) as exc_info:
ensure_valid_pvc_name(long_name)
assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == SandboxErrorCodes.INVALID_PVC_NAME
def test_uppercase_name_raises(self):
"""Uppercase letters should raise HTTPException."""
with pytest.raises(HTTPException) as exc_info:
ensure_valid_pvc_name("MyPVC")
assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == SandboxErrorCodes.INVALID_PVC_NAME
def test_underscore_name_raises(self):
"""Underscores should raise HTTPException."""
with pytest.raises(HTTPException) as exc_info:
ensure_valid_pvc_name("my_pvc")
assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == SandboxErrorCodes.INVALID_PVC_NAME
class TestEnsureVolumesValid:
def test_none_volumes_valid(self):
"""None volumes should be valid."""
assert ensure_volumes_valid(None) is None
def test_empty_volumes_valid(self):
"""Empty volumes list should be valid."""
assert ensure_volumes_valid([]) is None
def test_valid_host_volume(self):
"""Valid host volume should pass validation."""
volume = Volume(
name="workdir",
host=Host(path="/data/opensandbox"),
mount_path="/mnt/work",
read_only=False,
)
assert ensure_volumes_valid([volume]) is None
def test_valid_pvc_volume(self):
"""Valid PVC volume should pass validation."""
volume = Volume(
name="models",
pvc=PVC(claim_name="shared-models-pvc"),
mount_path="/mnt/models",
read_only=True,
)
assert ensure_volumes_valid([volume]) is None
def test_valid_ossfs_volume(self):
"""Valid OSSFS volume should pass validation."""
volume = Volume(
name="oss-data",
ossfs=OSSFS(
bucket="bucket-test-3",
endpoint="oss-cn-hangzhou.aliyuncs.com",
access_key_id="AKIDEXAMPLE",
access_key_secret="SECRETEXAMPLE",
),
mount_path="/mnt/data",
read_only=False,
sub_path="task-001",
)
assert ensure_volumes_valid([volume]) is None
def test_valid_volume_with_subpath(self):
"""Valid volume with subPath should pass validation."""
volume = Volume(
name="workdir",
host=Host(path="/data/opensandbox"),
mount_path="/mnt/work",
read_only=False,
sub_path="task-001",
)
assert ensure_volumes_valid([volume]) is None
def test_multiple_valid_volumes(self):
"""Multiple valid volumes should pass validation."""
volumes = [
Volume(
name="workdir",
host=Host(path="/data/opensandbox"),
mount_path="/mnt/work",
read_only=False,
),
Volume(
name="models",
pvc=PVC(claim_name="shared-models-pvc"),
mount_path="/mnt/models",
read_only=True,
),
]
assert ensure_volumes_valid(volumes) is None
def test_duplicate_volume_name_raises(self):
"""Duplicate volume names should raise HTTPException."""
volumes = [
Volume(
name="workdir",
host=Host(path="/data/a"),
mount_path="/mnt/a",
read_only=False,
),
Volume(
name="workdir", # Duplicate name
host=Host(path="/data/b"),
mount_path="/mnt/b",
read_only=False,
),
]
with pytest.raises(HTTPException) as exc_info:
ensure_volumes_valid(volumes)
assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == SandboxErrorCodes.DUPLICATE_VOLUME_NAME
def test_invalid_volume_name_rejected_by_pydantic(self):
"""Invalid volume name should be rejected by Pydantic pattern validation."""
from pydantic import ValidationError
# Pydantic validates the pattern before our validators run
with pytest.raises(ValidationError) as exc_info:
Volume(
name="Invalid_Name", # Invalid: uppercase and underscore
host=Host(path="/data/opensandbox"),
mount_path="/mnt/work",
read_only=False,
)
assert "name" in str(exc_info.value)
def test_invalid_mount_path_rejected_by_pydantic(self):
"""Invalid mount path should be rejected by Pydantic pattern validation."""
from pydantic import ValidationError
# Pydantic validates the pattern before our validators run
with pytest.raises(ValidationError) as exc_info:
Volume(
name="workdir",
host=Host(path="/data/opensandbox"),
mount_path="relative/path", # Invalid: not absolute
read_only=False,
)
assert "mount_path" in str(exc_info.value)
def test_invalid_subpath_raises(self):
"""Invalid subPath should raise HTTPException."""
volume = Volume(
name="workdir",
host=Host(path="/data/opensandbox"),
mount_path="/mnt/work",
read_only=False,
sub_path="../escape", # Invalid: path traversal
)
with pytest.raises(HTTPException) as exc_info:
ensure_volumes_valid([volume])
assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == SandboxErrorCodes.INVALID_SUB_PATH
def test_host_path_allowlist_enforced(self):
"""Host path allowlist should be enforced."""
volume = Volume(
name="workdir",
host=Host(path="/etc/passwd"), # Not in allowed list
mount_path="/mnt/work",
read_only=False,
)
with pytest.raises(HTTPException) as exc_info:
ensure_volumes_valid([volume], allowed_host_prefixes=["/data/opensandbox"])
assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == SandboxErrorCodes.HOST_PATH_NOT_ALLOWED
def test_ossfs_invalid_version_rejected_by_schema(self):
"""Unsupported OSSFS version should be rejected by schema validation."""
from pydantic import ValidationError
with pytest.raises(ValidationError):
OSSFS(
bucket="bucket-test-3",
endpoint="oss-cn-hangzhou.aliyuncs.com",
version="3.0", # type: ignore[arg-type]
access_key_id="AKIDEXAMPLE",
access_key_secret="SECRETEXAMPLE",
)
def test_ossfs_missing_inline_credentials_raises(self):
"""Missing inline credentials should raise HTTPException."""
volume = Volume(
name="oss-data",
ossfs=OSSFS(
bucket="bucket-test-3",
endpoint="oss-cn-hangzhou.aliyuncs.com",
access_key_id="AKIDEXAMPLE",
access_key_secret="SECRETEXAMPLE",
),
mount_path="/mnt/data",
)
volume.ossfs.access_key_id = None
with pytest.raises(HTTPException) as exc_info:
ensure_volumes_valid([volume])
assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == SandboxErrorCodes.INVALID_OSSFS_CREDENTIALS
def test_ossfs_v1_options_reject_prefixed_entries(self):
"""OSSFS options should reject prefixed entries for 1.0."""
volume = Volume(
name="oss-data",
ossfs=OSSFS(
bucket="bucket-test-3",
endpoint="oss-cn-hangzhou.aliyuncs.com",
version="1.0",
options=["--allow_other"],
access_key_id="AKIDEXAMPLE",
access_key_secret="SECRETEXAMPLE",
),
mount_path="/mnt/data",
)
with pytest.raises(HTTPException) as exc_info:
ensure_volumes_valid([volume])
assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == SandboxErrorCodes.INVALID_OSSFS_OPTION
def test_ossfs_v2_options_reject_prefixed_entries(self):
"""OSSFS options should reject prefixed entries for 2.0."""
volume = Volume(
name="oss-data",
ossfs=OSSFS(
bucket="bucket-test-3",
endpoint="oss-cn-hangzhou.aliyuncs.com",
version="2.0",
options=["-o allow_other"],
access_key_id="AKIDEXAMPLE",
access_key_secret="SECRETEXAMPLE",
),
mount_path="/mnt/data",
)
with pytest.raises(HTTPException) as exc_info:
ensure_volumes_valid([volume])
assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == SandboxErrorCodes.INVALID_OSSFS_OPTION
def test_invalid_pvc_name_rejected_by_pydantic(self):
"""Invalid PVC name should be rejected by Pydantic pattern validation."""
from pydantic import ValidationError
# Pydantic validates the pattern before our validators run
with pytest.raises(ValidationError) as exc_info:
PVC(claim_name="Invalid_PVC") # Invalid: uppercase and underscore
assert "claim_name" in str(exc_info.value)
class TestCredentialProxyConfiguration:
def test_allows_dns_nft_mode(self):
from opensandbox_server.api.schema import CredentialProxyConfig, NetworkPolicy
from opensandbox_server.config import EgressConfig
ensure_credential_proxy_configured(
CredentialProxyConfig(enabled=True),
NetworkPolicy(default_action="deny", egress=[]),
EgressConfig(image="egress:latest", mode="dns+nft"),
)
def test_warns_for_default_allow_policy(self, monkeypatch):
from opensandbox_server.api.schema import CredentialProxyConfig, NetworkPolicy
from opensandbox_server.config import EgressConfig
warnings = []
monkeypatch.setattr(
"opensandbox_server.services.validators.logger.warning",
lambda message, *args: warnings.append(message % args),
)
ensure_credential_proxy_configured(
CredentialProxyConfig(enabled=True),
NetworkPolicy(default_action="allow", egress=[]),
EgressConfig(image="egress:latest", mode="dns+nft"),
)
assert len(warnings) == 1
assert "allowed for backward compatibility but is deprecated" in warnings[0]
def test_rejects_dns_only_mode(self):
from opensandbox_server.api.schema import CredentialProxyConfig, NetworkPolicy
from opensandbox_server.config import EgressConfig
with pytest.raises(HTTPException) as exc_info:
ensure_credential_proxy_configured(
CredentialProxyConfig(enabled=True),
NetworkPolicy(default_action="deny", egress=[]),
EgressConfig(image="egress:latest", mode="dns"),
)
assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == SandboxErrorCodes.INVALID_PARAMETER
assert "dns+nft" in exc_info.value.detail["message"]
def test_ignores_disabled_proxy(self):
from opensandbox_server.api.schema import CredentialProxyConfig
ensure_credential_proxy_configured(
CredentialProxyConfig(enabled=False), None, None
)
class TestEgressRuntimeCompatibility:
def _network_policy(self):
from opensandbox_server.api.schema import NetworkPolicy
return NetworkPolicy(default_action="deny", egress=[])
def _secure_runtime(self, type_: str):
from opensandbox_server.config import SecureRuntimeConfig
if type_ == "gvisor":
return SecureRuntimeConfig(type=type_, k8s_runtime_class="gvisor")
if type_ == "kata":
return SecureRuntimeConfig(type=type_, k8s_runtime_class="kata-qemu")
return SecureRuntimeConfig(type=type_)
def test_rejects_gvisor_with_network_policy(self):
with pytest.raises(HTTPException) as exc_info:
ensure_egress_runtime_compatible(self._network_policy(), self._secure_runtime("gvisor"))
assert exc_info.value.status_code == 400
assert "gVisor" in exc_info.value.detail["message"]
assert exc_info.value.detail["code"] == SandboxErrorCodes.INVALID_PARAMETER
def test_allows_kata_with_network_policy(self):
ensure_egress_runtime_compatible(self._network_policy(), self._secure_runtime("kata"))
def test_allows_no_secure_runtime(self):
ensure_egress_runtime_compatible(self._network_policy(), None)
def test_allows_empty_secure_runtime(self):
ensure_egress_runtime_compatible(self._network_policy(), self._secure_runtime(""))
def test_allows_gvisor_without_network_policy(self):
ensure_egress_runtime_compatible(None, self._secure_runtime("gvisor"))
def test_rejects_template_gvisor_with_network_policy(self):
with pytest.raises(HTTPException) as exc_info:
ensure_egress_runtime_compatible(
self._network_policy(), None, effective_runtime_class="gvisor"
)
assert exc_info.value.status_code == 400
assert "gVisor" in exc_info.value.detail["message"]
def test_allows_template_kata_with_network_policy(self):
ensure_egress_runtime_compatible(
self._network_policy(), None, effective_runtime_class="kata-qemu"
)
def test_secure_runtime_takes_precedence_over_template(self):
with pytest.raises(HTTPException):
ensure_egress_runtime_compatible(
self._network_policy(),
self._secure_runtime("gvisor"),
effective_runtime_class="kata-qemu",
)