chore: import upstream snapshot with attribution
Integ / changes (push) Has been skipped
Pre-commit / pre-commit (push) Failing after 1s
CLI exit codes / changes (push) Has been skipped
Test (Install) / changes (push) Has been skipped
Test (Python) / changes (push) Has been skipped
Test (TypeScript) / changes (push) Has been skipped
CLI exit codes / cli-gate (push) Has been cancelled
Test (Install) / test-install-gate (push) Has been cancelled
Integ / integ-gate (push) Has been cancelled
Test (Python) / test-python-gate (push) Has been cancelled
Test (TypeScript) / test-typescript-gate (push) Has been cancelled
Test (Install) / python-minimal (3.12) (push) Has been cancelled
Test (Install) / python-minimal (3.11) (push) Has been cancelled
Test (Install) / python-extra (agno, mirage.agents.agno) (push) Has been cancelled
Test (Install) / python-extra (chroma, mirage.resource.chroma) (push) Has been cancelled
Test (Install) / python-extra (pdf, mirage.core.filetype.pdf) (push) Has been cancelled
Integ / integ (push) Has been cancelled
Integ / integ-database (push) Has been cancelled
Integ / integ-database-ts (push) Has been cancelled
Integ / integ-data (push) Has been cancelled
Integ / integ-ssh (push) Has been cancelled
Integ / integ-ssh-ts (push) Has been cancelled
Test (Python) / audit (push) Has been cancelled
Test (TypeScript) / test (push) Has been cancelled
Test (TypeScript) / python-fs-shim (push) Has been cancelled
CLI exit codes / Python CLI (push) Has been cancelled
CLI exit codes / TypeScript CLI (push) Has been cancelled
CLI exit codes / Cross-language snapshot interop (push) Has been cancelled
Test (Python) / test (push) Has been cancelled
Test (Python) / import-isolation (deepagents, openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Python) / import-isolation (deepagents, pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Integ / integ-ts (push) Has been cancelled
Integ / integ-fuse (push) Has been cancelled
Test (Install) / python-extra (databricks, mirage.resource.databricks_volume) (push) Has been cancelled
Test (Install) / python-extra (deepagents, mirage.agents.langchain) (push) Has been cancelled
Test (Install) / python-extra (email, mirage.resource.email) (push) Has been cancelled
Test (Install) / python-extra (fuse, mirage.fuse.mount) (push) Has been cancelled
Test (Install) / python-extra (hdf5, mirage.core.filetype.hdf5) (push) Has been cancelled
Test (Install) / python-extra (hf, mirage.resource.hf_buckets) (push) Has been cancelled
Test (Install) / python-extra (lancedb, mirage.resource.lancedb) (push) Has been cancelled
Test (Install) / python-extra (langfuse, mirage.resource.langfuse) (push) Has been cancelled
Test (Install) / python-extra (mongodb, mirage.resource.mongodb) (push) Has been cancelled
Test (Install) / python-extra (nextcloud, mirage.resource.nextcloud) (push) Has been cancelled
Test (Install) / python-extra (openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Install) / python-extra (openhands, mirage.agents.openhands, 3.12) (push) Has been cancelled
Test (Install) / python-extra (parquet, mirage.core.filetype.parquet) (push) Has been cancelled
Test (Install) / python-extra (postgres, mirage.resource.postgres) (push) Has been cancelled
Test (Install) / python-extra (pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Test (Install) / python-extra (qdrant, mirage.resource.qdrant) (push) Has been cancelled
Test (Install) / python-extra (redis, mirage.resource.redis) (push) Has been cancelled
Test (Install) / python-extra (s3, mirage.resource.s3) (push) Has been cancelled
Test (Install) / python-extra (ssh, mirage.resource.ssh) (push) Has been cancelled
Test (Install) / ts-minimal (push) Has been cancelled
Integ / changes (push) Has been skipped
Pre-commit / pre-commit (push) Failing after 1s
CLI exit codes / changes (push) Has been skipped
Test (Install) / changes (push) Has been skipped
Test (Python) / changes (push) Has been skipped
Test (TypeScript) / changes (push) Has been skipped
CLI exit codes / cli-gate (push) Has been cancelled
Test (Install) / test-install-gate (push) Has been cancelled
Integ / integ-gate (push) Has been cancelled
Test (Python) / test-python-gate (push) Has been cancelled
Test (TypeScript) / test-typescript-gate (push) Has been cancelled
Test (Install) / python-minimal (3.12) (push) Has been cancelled
Test (Install) / python-minimal (3.11) (push) Has been cancelled
Test (Install) / python-extra (agno, mirage.agents.agno) (push) Has been cancelled
Test (Install) / python-extra (chroma, mirage.resource.chroma) (push) Has been cancelled
Test (Install) / python-extra (pdf, mirage.core.filetype.pdf) (push) Has been cancelled
Integ / integ (push) Has been cancelled
Integ / integ-database (push) Has been cancelled
Integ / integ-database-ts (push) Has been cancelled
Integ / integ-data (push) Has been cancelled
Integ / integ-ssh (push) Has been cancelled
Integ / integ-ssh-ts (push) Has been cancelled
Test (Python) / audit (push) Has been cancelled
Test (TypeScript) / test (push) Has been cancelled
Test (TypeScript) / python-fs-shim (push) Has been cancelled
CLI exit codes / Python CLI (push) Has been cancelled
CLI exit codes / TypeScript CLI (push) Has been cancelled
CLI exit codes / Cross-language snapshot interop (push) Has been cancelled
Test (Python) / test (push) Has been cancelled
Test (Python) / import-isolation (deepagents, openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Python) / import-isolation (deepagents, pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Integ / integ-ts (push) Has been cancelled
Integ / integ-fuse (push) Has been cancelled
Test (Install) / python-extra (databricks, mirage.resource.databricks_volume) (push) Has been cancelled
Test (Install) / python-extra (deepagents, mirage.agents.langchain) (push) Has been cancelled
Test (Install) / python-extra (email, mirage.resource.email) (push) Has been cancelled
Test (Install) / python-extra (fuse, mirage.fuse.mount) (push) Has been cancelled
Test (Install) / python-extra (hdf5, mirage.core.filetype.hdf5) (push) Has been cancelled
Test (Install) / python-extra (hf, mirage.resource.hf_buckets) (push) Has been cancelled
Test (Install) / python-extra (lancedb, mirage.resource.lancedb) (push) Has been cancelled
Test (Install) / python-extra (langfuse, mirage.resource.langfuse) (push) Has been cancelled
Test (Install) / python-extra (mongodb, mirage.resource.mongodb) (push) Has been cancelled
Test (Install) / python-extra (nextcloud, mirage.resource.nextcloud) (push) Has been cancelled
Test (Install) / python-extra (openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Install) / python-extra (openhands, mirage.agents.openhands, 3.12) (push) Has been cancelled
Test (Install) / python-extra (parquet, mirage.core.filetype.parquet) (push) Has been cancelled
Test (Install) / python-extra (postgres, mirage.resource.postgres) (push) Has been cancelled
Test (Install) / python-extra (pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Test (Install) / python-extra (qdrant, mirage.resource.qdrant) (push) Has been cancelled
Test (Install) / python-extra (redis, mirage.resource.redis) (push) Has been cancelled
Test (Install) / python-extra (s3, mirage.resource.s3) (push) Has been cancelled
Test (Install) / python-extra (ssh, mirage.resource.ssh) (push) Has been cancelled
Test (Install) / ts-minimal (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
@@ -0,0 +1,208 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.server.auth.config import (AuthMode, JWTConfig,
|
||||
resolve_auth_config,
|
||||
resolve_local_token)
|
||||
from mirage.server.daemon_config import ALLOWED_KEYS
|
||||
|
||||
|
||||
@pytest.mark.no_host_override
|
||||
def test_resolve_local_token_env_wins(tmp_path):
|
||||
f = tmp_path / "auth_token"
|
||||
f.write_text("from-file")
|
||||
got = resolve_local_token(env={"MIRAGE_AUTH_TOKEN": "from-env"},
|
||||
token_file=f)
|
||||
assert got == "from-env"
|
||||
|
||||
|
||||
@pytest.mark.no_host_override
|
||||
def test_resolve_local_token_falls_back_to_file(tmp_path):
|
||||
f = tmp_path / "auth_token"
|
||||
f.write_text("from-file")
|
||||
got = resolve_local_token(env={}, token_file=f)
|
||||
assert got == "from-file"
|
||||
|
||||
|
||||
@pytest.mark.no_host_override
|
||||
def test_resolve_local_token_returns_none_when_no_source(tmp_path):
|
||||
f = tmp_path / "missing"
|
||||
got = resolve_local_token(env={}, token_file=f)
|
||||
assert got is None
|
||||
|
||||
|
||||
@pytest.mark.no_host_override
|
||||
def test_resolve_auth_config_default_is_local_no_token(tmp_path):
|
||||
cfg = resolve_auth_config(env={}, token_file=tmp_path / "missing")
|
||||
assert cfg.mode == "local"
|
||||
assert cfg.local_token is None
|
||||
assert cfg.bearer_token is None
|
||||
assert cfg.jwt is None
|
||||
|
||||
|
||||
@pytest.mark.no_host_override
|
||||
def test_resolve_auth_config_local_with_env(tmp_path):
|
||||
cfg = resolve_auth_config(env={"MIRAGE_AUTH_TOKEN": "lt"},
|
||||
token_file=tmp_path / "missing")
|
||||
assert cfg.mode == "local"
|
||||
assert cfg.local_token == "lt"
|
||||
|
||||
|
||||
@pytest.mark.no_host_override
|
||||
def test_resolve_auth_config_token_mode_requires_env(tmp_path):
|
||||
with pytest.raises(RuntimeError, match="MIRAGE_AUTH_TOKEN"):
|
||||
resolve_auth_config(env={"MIRAGE_AUTH_MODE": "token"},
|
||||
token_file=tmp_path / "missing")
|
||||
|
||||
|
||||
@pytest.mark.no_host_override
|
||||
def test_resolve_auth_config_token_mode_uses_env_token(tmp_path):
|
||||
cfg = resolve_auth_config(env={
|
||||
"MIRAGE_AUTH_MODE": "token",
|
||||
"MIRAGE_AUTH_TOKEN": "operator-pat"
|
||||
},
|
||||
token_file=tmp_path / "missing")
|
||||
assert cfg.mode == "token"
|
||||
assert cfg.bearer_token == "operator-pat"
|
||||
assert cfg.local_token is None
|
||||
assert cfg.jwt is None
|
||||
|
||||
|
||||
@pytest.mark.no_host_override
|
||||
def test_resolve_auth_config_jwt_mode_requires_key(tmp_path):
|
||||
with pytest.raises(RuntimeError, match="MIRAGE_JWT_PUBKEY"):
|
||||
resolve_auth_config(env={
|
||||
"MIRAGE_AUTH_MODE": "jwt",
|
||||
"MIRAGE_JWT_ALG": "RS256"
|
||||
},
|
||||
token_file=tmp_path / "missing")
|
||||
|
||||
|
||||
@pytest.mark.no_host_override
|
||||
def test_resolve_auth_config_jwt_mode_requires_alg(tmp_path):
|
||||
with pytest.raises(RuntimeError, match="MIRAGE_JWT_ALG"):
|
||||
resolve_auth_config(env={
|
||||
"MIRAGE_AUTH_MODE": "jwt",
|
||||
"MIRAGE_JWT_PUBKEY": "-----BEGIN"
|
||||
},
|
||||
token_file=tmp_path / "missing")
|
||||
|
||||
|
||||
@pytest.mark.no_host_override
|
||||
def test_resolve_auth_config_jwt_mode_inline_key(tmp_path):
|
||||
cfg = resolve_auth_config(env={
|
||||
"MIRAGE_AUTH_MODE": "jwt",
|
||||
"MIRAGE_JWT_PUBKEY":
|
||||
"-----BEGIN PUBLIC KEY-----\nFAKE\n-----END PUBLIC KEY-----",
|
||||
"MIRAGE_JWT_ALG": "RS256",
|
||||
"MIRAGE_JWT_ISSUER": "https://issuer.example",
|
||||
"MIRAGE_JWT_AUDIENCE": "mirage-daemon",
|
||||
"MIRAGE_JWT_AUTHORIZED_PARTIES":
|
||||
"https://app.example,https://other.example",
|
||||
"MIRAGE_JWT_CLOCK_SKEW_SECONDS": "12",
|
||||
},
|
||||
token_file=tmp_path / "missing")
|
||||
assert cfg.mode == "jwt"
|
||||
assert cfg.jwt is not None
|
||||
assert isinstance(cfg.jwt, JWTConfig)
|
||||
assert "FAKE" in cfg.jwt.key
|
||||
assert cfg.jwt.algorithm == "RS256"
|
||||
assert cfg.jwt.issuer == "https://issuer.example"
|
||||
assert cfg.jwt.audience == "mirage-daemon"
|
||||
assert cfg.jwt.authorized_parties == ("https://app.example",
|
||||
"https://other.example")
|
||||
assert cfg.jwt.clock_skew_seconds == 12
|
||||
|
||||
|
||||
@pytest.mark.no_host_override
|
||||
def test_resolve_auth_config_jwt_mode_pubkey_from_file(tmp_path):
|
||||
key_file = tmp_path / "jwt.pub"
|
||||
key_file.write_text(
|
||||
"-----BEGIN PUBLIC KEY-----\nFROMFILE\n-----END PUBLIC KEY-----")
|
||||
cfg = resolve_auth_config(env={
|
||||
"MIRAGE_AUTH_MODE": "jwt",
|
||||
"MIRAGE_JWT_PUBKEY_FILE": str(key_file),
|
||||
"MIRAGE_JWT_ALG": "RS256",
|
||||
},
|
||||
token_file=tmp_path / "missing")
|
||||
assert cfg.mode == "jwt"
|
||||
assert cfg.jwt is not None
|
||||
assert "FROMFILE" in cfg.jwt.key
|
||||
assert cfg.jwt.clock_skew_seconds == 5 # default mirrors Clerk
|
||||
|
||||
|
||||
@pytest.mark.no_host_override
|
||||
def test_resolve_auth_config_unknown_mode_raises(tmp_path):
|
||||
with pytest.raises(RuntimeError, match="MIRAGE_AUTH_MODE"):
|
||||
resolve_auth_config(env={"MIRAGE_AUTH_MODE": "wat"},
|
||||
token_file=tmp_path / "missing")
|
||||
|
||||
|
||||
def test_auth_mode_from_config_table(tmp_path):
|
||||
cfg = resolve_auth_config(env={"MIRAGE_AUTH_TOKEN": "tok"},
|
||||
table={"auth_mode": "token"})
|
||||
assert cfg.mode == AuthMode.TOKEN
|
||||
assert cfg.bearer_token == "tok"
|
||||
|
||||
|
||||
def test_env_auth_mode_beats_config_table(tmp_path):
|
||||
cfg = resolve_auth_config(env={"MIRAGE_AUTH_MODE": "local"},
|
||||
token_file=tmp_path / "missing",
|
||||
table={"auth_mode": "token"})
|
||||
assert cfg.mode == AuthMode.LOCAL
|
||||
|
||||
|
||||
def test_jwt_settings_from_config_table(tmp_path):
|
||||
key_file = tmp_path / "pub.pem"
|
||||
key_file.write_text("KEYDATA")
|
||||
cfg = resolve_auth_config(env={},
|
||||
table={
|
||||
"auth_mode": "jwt",
|
||||
"jwt_pubkey_file": str(key_file),
|
||||
"jwt_alg": "RS256",
|
||||
"jwt_issuer": "https://issuer",
|
||||
"jwt_audience": "aud",
|
||||
"jwt_authorized_parties": "a,b",
|
||||
"jwt_clock_skew": 9,
|
||||
})
|
||||
assert cfg.mode == AuthMode.JWT
|
||||
assert cfg.jwt is not None
|
||||
assert cfg.jwt.key == "KEYDATA"
|
||||
assert cfg.jwt.algorithm == "RS256"
|
||||
assert cfg.jwt.issuer == "https://issuer"
|
||||
assert cfg.jwt.audience == "aud"
|
||||
assert cfg.jwt.authorized_parties == ("a", "b")
|
||||
assert cfg.jwt.clock_skew_seconds == 9
|
||||
|
||||
|
||||
def test_env_jwt_alg_beats_config_table(tmp_path):
|
||||
key_file = tmp_path / "pub.pem"
|
||||
key_file.write_text("KEYDATA")
|
||||
cfg = resolve_auth_config(env={
|
||||
"MIRAGE_AUTH_MODE": "jwt",
|
||||
"MIRAGE_JWT_PUBKEY_FILE": str(key_file),
|
||||
"MIRAGE_JWT_ALG": "ES256",
|
||||
},
|
||||
table={"jwt_alg": "RS256"})
|
||||
assert cfg.jwt is not None
|
||||
assert cfg.jwt.algorithm == "ES256"
|
||||
|
||||
|
||||
def test_secret_keys_have_no_config_key():
|
||||
assert "jwt_pubkey" not in ALLOWED_KEYS
|
||||
assert "MIRAGE_AUTH_TOKEN" not in ALLOWED_KEYS
|
||||
assert "auth_mode" in ALLOWED_KEYS
|
||||
assert "jwt_alg" in ALLOWED_KEYS
|
||||
@@ -0,0 +1,202 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
import jwt as pyjwt
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
from mirage.server.auth.config import JWTConfig
|
||||
from mirage.server.auth.jwt import JWTVerificationError, verify_jwt
|
||||
|
||||
|
||||
@dataclass
|
||||
class KeyPair:
|
||||
private_pem: bytes
|
||||
public_pem: bytes
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def rsa_keys() -> KeyPair:
|
||||
private_key = rsa.generate_private_key(public_exponent=65537,
|
||||
key_size=2048)
|
||||
private_pem = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
public_pem = private_key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
return KeyPair(private_pem=private_pem, public_pem=public_pem)
|
||||
|
||||
|
||||
def _make_cfg(rsa_keys: KeyPair, **overrides) -> JWTConfig:
|
||||
base = dict(
|
||||
key=rsa_keys.public_pem.decode(),
|
||||
algorithm="RS256",
|
||||
issuer=None,
|
||||
audience=None,
|
||||
authorized_parties=(),
|
||||
clock_skew_seconds=5,
|
||||
)
|
||||
base.update(overrides)
|
||||
return JWTConfig(**base)
|
||||
|
||||
|
||||
def _sign(rsa_keys: KeyPair,
|
||||
claims: dict,
|
||||
*,
|
||||
alg: str = "RS256",
|
||||
headers: dict | None = None) -> str:
|
||||
return pyjwt.encode(claims,
|
||||
rsa_keys.private_pem,
|
||||
algorithm=alg,
|
||||
headers=headers)
|
||||
|
||||
|
||||
@pytest.mark.no_host_override
|
||||
def test_verify_jwt_accepts_valid_rs256(rsa_keys):
|
||||
cfg = _make_cfg(rsa_keys)
|
||||
token = _sign(rsa_keys, {"sub": "user-1", "exp": int(time.time()) + 60})
|
||||
claims = verify_jwt(token, cfg)
|
||||
assert claims["sub"] == "user-1"
|
||||
|
||||
|
||||
@pytest.mark.no_host_override
|
||||
def test_verify_jwt_rejects_alg_none(rsa_keys):
|
||||
cfg = _make_cfg(rsa_keys)
|
||||
token = pyjwt.encode({
|
||||
"sub": "x",
|
||||
"exp": int(time.time()) + 60
|
||||
},
|
||||
key="",
|
||||
algorithm="none")
|
||||
with pytest.raises(JWTVerificationError):
|
||||
verify_jwt(token, cfg)
|
||||
|
||||
|
||||
@pytest.mark.no_host_override
|
||||
def test_verify_jwt_rejects_alg_confusion(rsa_keys):
|
||||
cfg = _make_cfg(rsa_keys, algorithm="RS256")
|
||||
token = pyjwt.encode({
|
||||
"sub": "x",
|
||||
"exp": int(time.time()) + 60
|
||||
},
|
||||
key="shared-secret",
|
||||
algorithm="HS256")
|
||||
with pytest.raises(JWTVerificationError):
|
||||
verify_jwt(token, cfg)
|
||||
|
||||
|
||||
@pytest.mark.no_host_override
|
||||
def test_verify_jwt_rejects_missing_exp(rsa_keys):
|
||||
cfg = _make_cfg(rsa_keys)
|
||||
token = _sign(rsa_keys, {"sub": "x"})
|
||||
with pytest.raises(JWTVerificationError):
|
||||
verify_jwt(token, cfg)
|
||||
|
||||
|
||||
@pytest.mark.no_host_override
|
||||
def test_verify_jwt_rejects_expired(rsa_keys):
|
||||
cfg = _make_cfg(rsa_keys, clock_skew_seconds=0)
|
||||
token = _sign(rsa_keys, {"sub": "x", "exp": int(time.time()) - 60})
|
||||
with pytest.raises(JWTVerificationError):
|
||||
verify_jwt(token, cfg)
|
||||
|
||||
|
||||
@pytest.mark.no_host_override
|
||||
def test_verify_jwt_accepts_within_clock_skew(rsa_keys):
|
||||
cfg = _make_cfg(rsa_keys, clock_skew_seconds=30)
|
||||
token = _sign(rsa_keys, {"sub": "x", "exp": int(time.time()) - 5})
|
||||
claims = verify_jwt(token, cfg)
|
||||
assert claims["sub"] == "x"
|
||||
|
||||
|
||||
@pytest.mark.no_host_override
|
||||
def test_verify_jwt_rejects_wrong_issuer(rsa_keys):
|
||||
cfg = _make_cfg(rsa_keys, issuer="https://issuer.example")
|
||||
token = _sign(
|
||||
rsa_keys, {
|
||||
"sub": "x",
|
||||
"exp": int(time.time()) + 60,
|
||||
"iss": "https://attacker.example",
|
||||
})
|
||||
with pytest.raises(JWTVerificationError):
|
||||
verify_jwt(token, cfg)
|
||||
|
||||
|
||||
@pytest.mark.no_host_override
|
||||
def test_verify_jwt_rejects_wrong_audience(rsa_keys):
|
||||
cfg = _make_cfg(rsa_keys, audience="mirage-daemon")
|
||||
token = _sign(rsa_keys, {
|
||||
"sub": "x",
|
||||
"exp": int(time.time()) + 60,
|
||||
"aud": "something-else",
|
||||
})
|
||||
with pytest.raises(JWTVerificationError):
|
||||
verify_jwt(token, cfg)
|
||||
|
||||
|
||||
@pytest.mark.no_host_override
|
||||
def test_verify_jwt_rejects_unauthorized_party(rsa_keys):
|
||||
cfg = _make_cfg(rsa_keys, authorized_parties=("https://app.example", ))
|
||||
token = _sign(
|
||||
rsa_keys, {
|
||||
"sub": "x",
|
||||
"exp": int(time.time()) + 60,
|
||||
"azp": "https://attacker.example",
|
||||
})
|
||||
with pytest.raises(JWTVerificationError):
|
||||
verify_jwt(token, cfg)
|
||||
|
||||
|
||||
@pytest.mark.no_host_override
|
||||
def test_verify_jwt_accepts_matching_authorized_party(rsa_keys):
|
||||
cfg = _make_cfg(rsa_keys,
|
||||
authorized_parties=("https://app.example",
|
||||
"https://other.example"))
|
||||
token = _sign(rsa_keys, {
|
||||
"sub": "x",
|
||||
"exp": int(time.time()) + 60,
|
||||
"azp": "https://other.example",
|
||||
})
|
||||
claims = verify_jwt(token, cfg)
|
||||
assert claims["sub"] == "x"
|
||||
|
||||
|
||||
@pytest.mark.no_host_override
|
||||
def test_verify_jwt_rejects_bad_typ_header(rsa_keys):
|
||||
cfg = _make_cfg(rsa_keys)
|
||||
token = _sign(rsa_keys, {
|
||||
"sub": "x",
|
||||
"exp": int(time.time()) + 60
|
||||
},
|
||||
headers={"typ": "NotAJWT"})
|
||||
with pytest.raises(JWTVerificationError):
|
||||
verify_jwt(token, cfg)
|
||||
|
||||
|
||||
@pytest.mark.no_host_override
|
||||
def test_verify_jwt_accepts_missing_typ_header(rsa_keys):
|
||||
# Some issuers omit `typ` entirely. PyJWT does not require it,
|
||||
# and we should not force it when it's absent.
|
||||
cfg = _make_cfg(rsa_keys)
|
||||
token = _sign(rsa_keys, {"sub": "x", "exp": int(time.time()) + 60})
|
||||
claims = verify_jwt(token, cfg)
|
||||
assert claims["sub"] == "x"
|
||||
@@ -0,0 +1,200 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
import jwt as pyjwt
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from mirage.server import build_app
|
||||
from mirage.server.auth.config import AuthConfig, JWTConfig
|
||||
|
||||
|
||||
@dataclass
|
||||
class KeyPair:
|
||||
private_pem: bytes
|
||||
public_pem: bytes
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def rsa_keys() -> KeyPair:
|
||||
private_key = rsa.generate_private_key(public_exponent=65537,
|
||||
key_size=2048)
|
||||
private_pem = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
public_pem = private_key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
return KeyPair(private_pem=private_pem, public_pem=public_pem)
|
||||
|
||||
|
||||
def _client(app, headers=None):
|
||||
transport = ASGITransport(app=app)
|
||||
return AsyncClient(transport=transport,
|
||||
base_url="http://test",
|
||||
headers=headers or {})
|
||||
|
||||
|
||||
@pytest.mark.no_auth_override
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_mode_accepts_correct_bearer():
|
||||
app = build_app(idle_grace_seconds=10.0,
|
||||
auth_config=AuthConfig(mode="local",
|
||||
local_token="correct-token"))
|
||||
async with _client(app, {"Authorization": "Bearer correct-token"}) as c:
|
||||
r = await c.get("/v1/workspaces")
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.no_auth_override
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_mode_rejects_wrong_bearer():
|
||||
app = build_app(idle_grace_seconds=10.0,
|
||||
auth_config=AuthConfig(mode="local",
|
||||
local_token="correct-token"))
|
||||
async with _client(app, {"Authorization": "Bearer wrong-token"}) as c:
|
||||
r = await c.get("/v1/workspaces")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.no_auth_override
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_mode_rejects_missing_header():
|
||||
app = build_app(idle_grace_seconds=10.0,
|
||||
auth_config=AuthConfig(mode="local",
|
||||
local_token="correct-token"))
|
||||
async with _client(app) as c:
|
||||
r = await c.get("/v1/workspaces")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.no_auth_override
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_mode_no_token_lets_everything_through():
|
||||
app = build_app(idle_grace_seconds=10.0,
|
||||
auth_config=AuthConfig(mode="local", local_token=None))
|
||||
async with _client(app) as c:
|
||||
r = await c.get("/v1/workspaces")
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.no_auth_override
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_mode_accepts_correct_token():
|
||||
app = build_app(idle_grace_seconds=10.0,
|
||||
auth_config=AuthConfig(mode="token",
|
||||
bearer_token="operator-pat"))
|
||||
async with _client(app, {"Authorization": "Bearer operator-pat"}) as c:
|
||||
r = await c.get("/v1/workspaces")
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.no_auth_override
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_mode_rejects_wrong_token():
|
||||
app = build_app(idle_grace_seconds=10.0,
|
||||
auth_config=AuthConfig(mode="token",
|
||||
bearer_token="operator-pat"))
|
||||
async with _client(app, {"Authorization": "Bearer something-else"}) as c:
|
||||
r = await c.get("/v1/workspaces")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.no_auth_override
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_mode_rejects_jwt_shaped_value():
|
||||
app = build_app(idle_grace_seconds=10.0,
|
||||
auth_config=AuthConfig(mode="token",
|
||||
bearer_token="operator-pat"))
|
||||
fake_jwt = "aaaa.bbbb.cccc"
|
||||
async with _client(app, {"Authorization": f"Bearer {fake_jwt}"}) as c:
|
||||
r = await c.get("/v1/workspaces")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.no_auth_override
|
||||
@pytest.mark.asyncio
|
||||
async def test_jwt_mode_accepts_valid_signed(rsa_keys):
|
||||
jwt_cfg = JWTConfig(key=rsa_keys.public_pem.decode(), algorithm="RS256")
|
||||
app = build_app(idle_grace_seconds=10.0,
|
||||
auth_config=AuthConfig(mode="jwt", jwt=jwt_cfg))
|
||||
token = pyjwt.encode({
|
||||
"sub": "agent",
|
||||
"exp": int(time.time()) + 60
|
||||
},
|
||||
rsa_keys.private_pem,
|
||||
algorithm="RS256")
|
||||
async with _client(app, {"Authorization": f"Bearer {token}"}) as c:
|
||||
r = await c.get("/v1/workspaces")
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.no_auth_override
|
||||
@pytest.mark.asyncio
|
||||
async def test_jwt_mode_rejects_opaque_bearer(rsa_keys):
|
||||
jwt_cfg = JWTConfig(key=rsa_keys.public_pem.decode(), algorithm="RS256")
|
||||
app = build_app(idle_grace_seconds=10.0,
|
||||
auth_config=AuthConfig(mode="jwt", jwt=jwt_cfg))
|
||||
async with _client(app, {"Authorization": "Bearer not-a-jwt"}) as c:
|
||||
r = await c.get("/v1/workspaces")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.no_auth_override
|
||||
@pytest.mark.asyncio
|
||||
async def test_jwt_mode_rejects_expired(rsa_keys):
|
||||
jwt_cfg = JWTConfig(key=rsa_keys.public_pem.decode(),
|
||||
algorithm="RS256",
|
||||
clock_skew_seconds=0)
|
||||
app = build_app(idle_grace_seconds=10.0,
|
||||
auth_config=AuthConfig(mode="jwt", jwt=jwt_cfg))
|
||||
token = pyjwt.encode({
|
||||
"sub": "agent",
|
||||
"exp": int(time.time()) - 60
|
||||
},
|
||||
rsa_keys.private_pem,
|
||||
algorithm="RS256")
|
||||
async with _client(app, {"Authorization": f"Bearer {token}"}) as c:
|
||||
r = await c.get("/v1/workspaces")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.no_auth_override
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_endpoint_always_open():
|
||||
app = build_app(idle_grace_seconds=10.0,
|
||||
auth_config=AuthConfig(mode="local",
|
||||
local_token="some-token"))
|
||||
async with _client(app) as c:
|
||||
r = await c.get("/v1/health")
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.no_auth_override
|
||||
@pytest.mark.asyncio
|
||||
async def test_authorization_header_without_bearer_prefix_rejected():
|
||||
app = build_app(idle_grace_seconds=10.0,
|
||||
auth_config=AuthConfig(mode="local",
|
||||
local_token="correct-token"))
|
||||
async with _client(app, {"Authorization": "correct-token"}) as c:
|
||||
r = await c.get("/v1/workspaces")
|
||||
assert r.status_code == 401
|
||||
@@ -0,0 +1,60 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import os
|
||||
import stat
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.server.auth.storage import (default_token_file, ensure_token_file,
|
||||
read_token_file)
|
||||
from mirage.server.env import ENV_HOME
|
||||
|
||||
|
||||
@pytest.mark.no_host_override
|
||||
def test_ensure_token_file_creates_with_0o600(tmp_path):
|
||||
target = tmp_path / "subdir" / "auth_token"
|
||||
token = ensure_token_file(target)
|
||||
assert target.exists()
|
||||
assert target.read_text().strip() == token
|
||||
assert token, "minted token must be non-empty"
|
||||
mode = stat.S_IMODE(os.stat(target).st_mode)
|
||||
assert mode == 0o600, f"expected 0o600, got {oct(mode)}"
|
||||
|
||||
|
||||
@pytest.mark.no_host_override
|
||||
def test_ensure_token_file_is_idempotent(tmp_path):
|
||||
target = tmp_path / "auth_token"
|
||||
first = ensure_token_file(target)
|
||||
second = ensure_token_file(target)
|
||||
assert first == second, "second call must reuse existing token"
|
||||
|
||||
|
||||
@pytest.mark.no_host_override
|
||||
def test_read_token_file_returns_none_when_missing(tmp_path):
|
||||
target = tmp_path / "absent"
|
||||
assert read_token_file(target) is None
|
||||
|
||||
|
||||
@pytest.mark.no_host_override
|
||||
def test_read_token_file_returns_stripped_contents(tmp_path):
|
||||
target = tmp_path / "auth_token"
|
||||
target.write_text(" abc-def \n")
|
||||
assert read_token_file(target) == "abc-def"
|
||||
|
||||
|
||||
@pytest.mark.no_host_override
|
||||
def test_default_token_file_follows_mirage_home(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv(ENV_HOME, str(tmp_path))
|
||||
assert default_token_file() == tmp_path / "auth_token"
|
||||
@@ -0,0 +1,48 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _allow_asgi_test_host(monkeypatch, request):
|
||||
# Existing server tests drive the app through httpx.ASGITransport
|
||||
# with base_url="http://test", which sends Host=test. We extend
|
||||
# the allowlist to include that synthetic host rather than disabling
|
||||
# enforcement entirely, so the middleware still rejects anything
|
||||
# else (e.g. a future regression test that drops in an unexpected
|
||||
# Host header). Tests that exercise rejection paths opt out with
|
||||
# the @pytest.mark.no_host_override marker.
|
||||
if "no_host_override" in request.keywords:
|
||||
return
|
||||
monkeypatch.setenv("MIRAGE_ALLOWED_HOSTS", "test,127.0.0.1,localhost,::1")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _disable_auth_for_legacy_tests(monkeypatch, request, tmp_path_factory):
|
||||
# Existing server tests don't send Authorization headers. With the
|
||||
# auth middleware now installed by build_app, we keep them passing
|
||||
# by forcing the daemon into mode=local with no token configured
|
||||
# (env unset + MIRAGE_HOME pointed at an empty temp dir, so the
|
||||
# default token file does not exist), which the middleware treats
|
||||
# as "no auth" and lets every request through with a startup
|
||||
# warning. Redirecting MIRAGE_HOME also keeps these tests from
|
||||
# touching the real ~/.mirage tree. Tests that exercise auth paths
|
||||
# opt out with the @pytest.mark.no_auth_override marker.
|
||||
if "no_auth_override" in request.keywords:
|
||||
return
|
||||
monkeypatch.setenv("MIRAGE_AUTH_MODE", "local")
|
||||
monkeypatch.delenv("MIRAGE_AUTH_TOKEN", raising=False)
|
||||
monkeypatch.setenv("MIRAGE_HOME",
|
||||
str(tmp_path_factory.mktemp("mirage_home")))
|
||||
@@ -0,0 +1,68 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.server.app import _remove_pid_file, _write_pid_file, build_app
|
||||
from mirage.server.daemon_config import DaemonConfigError
|
||||
from mirage.server.env import ENV_HOME, ENV_PID_FILE
|
||||
|
||||
|
||||
def test_build_app_pid_file_explicit_wins(tmp_path):
|
||||
target = tmp_path / "custom" / "daemon.pid"
|
||||
app = build_app(pid_file=target)
|
||||
assert app.state.pid_file == target
|
||||
|
||||
|
||||
def test_build_app_pid_file_from_env(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv(ENV_PID_FILE, str(tmp_path / "env.pid"))
|
||||
app = build_app()
|
||||
assert app.state.pid_file == tmp_path / "env.pid"
|
||||
|
||||
|
||||
def test_build_app_roots_follow_mirage_home(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv(ENV_HOME, str(tmp_path))
|
||||
monkeypatch.delenv(ENV_PID_FILE, raising=False)
|
||||
app = build_app()
|
||||
assert app.state.pid_file == tmp_path / "daemon.pid"
|
||||
assert app.state.snapshot_root == tmp_path / "snapshots"
|
||||
|
||||
|
||||
def test_write_and_remove_pid_file_creates_parents(tmp_path):
|
||||
target = tmp_path / "nested" / "daemon.pid"
|
||||
_write_pid_file(target)
|
||||
assert target.read_text() == str(os.getpid())
|
||||
_remove_pid_file(target)
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_remove_pid_file_missing_is_quiet(tmp_path):
|
||||
_remove_pid_file(tmp_path / "does_not_exist.pid")
|
||||
|
||||
|
||||
def test_build_app_rejects_unknown_config_key(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv(ENV_HOME, str(tmp_path))
|
||||
monkeypatch.delenv(ENV_PID_FILE, raising=False)
|
||||
(tmp_path / "config.toml").write_text('[daemon]\ntypo_key = "x"\n')
|
||||
with pytest.raises(DaemonConfigError, match="typo_key"):
|
||||
build_app()
|
||||
|
||||
|
||||
def test_build_app_accepts_valid_config(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv(ENV_HOME, str(tmp_path))
|
||||
monkeypatch.delenv(ENV_PID_FILE, raising=False)
|
||||
(tmp_path / "config.toml").write_text('[daemon]\nurl = "http://h:1"\n')
|
||||
build_app()
|
||||
@@ -0,0 +1,67 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.server.daemon_config import (ALLOWED_KEYS, DaemonConfigError,
|
||||
read_daemon_table,
|
||||
validate_daemon_table)
|
||||
|
||||
|
||||
def test_read_daemon_table_missing_file(tmp_path):
|
||||
assert read_daemon_table(tmp_path) == {}
|
||||
|
||||
|
||||
def test_read_daemon_table_no_daemon_section(tmp_path):
|
||||
(tmp_path / "config.toml").write_text("[other]\nx = 1\n")
|
||||
assert read_daemon_table(tmp_path) == {}
|
||||
|
||||
|
||||
def test_read_daemon_table_reads_keys(tmp_path):
|
||||
(tmp_path / "config.toml"
|
||||
).write_text('[daemon]\nurl = "http://h:1"\npid_file = "/tmp/p.pid"\n')
|
||||
table = read_daemon_table(tmp_path)
|
||||
assert table["url"] == "http://h:1"
|
||||
assert table["pid_file"] == "/tmp/p.pid"
|
||||
|
||||
|
||||
def test_read_daemon_table_malformed_toml(tmp_path):
|
||||
(tmp_path / "config.toml").write_text("[daemon\nnot toml")
|
||||
with pytest.raises(DaemonConfigError, match="config.toml"):
|
||||
read_daemon_table(tmp_path)
|
||||
|
||||
|
||||
def test_allowed_keys_contents():
|
||||
assert "pid_file" in ALLOWED_KEYS
|
||||
assert "MIRAGE_HOME" not in ALLOWED_KEYS
|
||||
|
||||
|
||||
def test_validate_accepts_known_keys():
|
||||
validate_daemon_table({"url": "http://h:1", "idle_grace_seconds": 45})
|
||||
|
||||
|
||||
def test_validate_rejects_unknown_keys():
|
||||
with pytest.raises(DaemonConfigError, match="typo_key"):
|
||||
validate_daemon_table({"typo_key": "x", "url": "http://h:1"})
|
||||
|
||||
|
||||
def test_validate_rejects_wrong_type():
|
||||
with pytest.raises(DaemonConfigError, match="pid_file"):
|
||||
validate_daemon_table({"pid_file": 123})
|
||||
|
||||
|
||||
def test_validate_accepts_numeric_grace_and_rejects_string():
|
||||
validate_daemon_table({"idle_grace_seconds": 12.5})
|
||||
with pytest.raises(DaemonConfigError, match="idle_grace_seconds"):
|
||||
validate_daemon_table({"idle_grace_seconds": "soon"})
|
||||
@@ -0,0 +1,224 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from mirage.server import build_app
|
||||
|
||||
|
||||
def _minimal_config() -> dict:
|
||||
return {
|
||||
"config": {
|
||||
"mounts": {
|
||||
"/": {
|
||||
"resource": "ram",
|
||||
"mode": "WRITE"
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def _create_workspace(client: AsyncClient) -> str:
|
||||
r = await client.post("/v1/workspaces", json=_minimal_config())
|
||||
assert r.status_code == 201
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_sync_returns_io_result():
|
||||
app = build_app(idle_grace_seconds=10.0)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport,
|
||||
base_url="http://test") as client:
|
||||
wid = await _create_workspace(client)
|
||||
r = await client.post(
|
||||
f"/v1/workspaces/{wid}/execute",
|
||||
json={"command": "echo hello"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["kind"] == "io"
|
||||
assert body["exit_code"] == 0
|
||||
assert body["stdout"].startswith("hello")
|
||||
assert "X-Mirage-Job-Id" in r.headers
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_sync_records_a_job_in_done_state():
|
||||
app = build_app(idle_grace_seconds=10.0)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport,
|
||||
base_url="http://test") as client:
|
||||
wid = await _create_workspace(client)
|
||||
r = await client.post(
|
||||
f"/v1/workspaces/{wid}/execute",
|
||||
json={"command": "echo done-marker"},
|
||||
)
|
||||
job_id = r.headers["X-Mirage-Job-Id"]
|
||||
|
||||
rj = await client.get(f"/v1/jobs/{job_id}")
|
||||
assert rj.status_code == 200
|
||||
body = rj.json()
|
||||
assert body["status"] == "done"
|
||||
assert body["result"]["stdout"].startswith("done-marker")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_background_returns_job_id_immediately():
|
||||
app = build_app(idle_grace_seconds=10.0)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport,
|
||||
base_url="http://test") as client:
|
||||
wid = await _create_workspace(client)
|
||||
r = await client.post(
|
||||
f"/v1/workspaces/{wid}/execute?background=true",
|
||||
json={"command": "sleep 0.3 && echo bg-done"},
|
||||
)
|
||||
assert r.status_code == 202, r.text
|
||||
body = r.json()
|
||||
assert body["job_id"].startswith("job_")
|
||||
assert body["workspace_id"] == wid
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_job_completes_and_result_is_readable():
|
||||
app = build_app(idle_grace_seconds=10.0)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport,
|
||||
base_url="http://test") as client:
|
||||
wid = await _create_workspace(client)
|
||||
r = await client.post(
|
||||
f"/v1/workspaces/{wid}/execute?background=true",
|
||||
json={"command": "echo finished"},
|
||||
)
|
||||
job_id = r.json()["job_id"]
|
||||
rw = await client.post(f"/v1/jobs/{job_id}/wait", json={})
|
||||
body = rw.json()
|
||||
assert body["status"] == "done"
|
||||
assert body["result"]["stdout"].startswith("finished")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_with_timeout_returns_running_status():
|
||||
app = build_app(idle_grace_seconds=10.0)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport,
|
||||
base_url="http://test") as client:
|
||||
wid = await _create_workspace(client)
|
||||
r = await client.post(
|
||||
f"/v1/workspaces/{wid}/execute?background=true",
|
||||
json={"command": "sleep 1.0"},
|
||||
)
|
||||
job_id = r.json()["job_id"]
|
||||
rw = await client.post(f"/v1/jobs/{job_id}/wait",
|
||||
json={"timeout_s": 0.1})
|
||||
assert rw.status_code == 200
|
||||
assert rw.json()["status"] == "running"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_running_job():
|
||||
app = build_app(idle_grace_seconds=10.0)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport,
|
||||
base_url="http://test") as client:
|
||||
wid = await _create_workspace(client)
|
||||
r = await client.post(
|
||||
f"/v1/workspaces/{wid}/execute?background=true",
|
||||
json={"command": "sleep 5.0"},
|
||||
)
|
||||
job_id = r.json()["job_id"]
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
rd = await client.delete(f"/v1/jobs/{job_id}")
|
||||
assert rd.status_code == 200
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
rg = await client.get(f"/v1/jobs/{job_id}")
|
||||
status = rg.json()["status"]
|
||||
assert status in ("canceled", "failed")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_jobs_filtered_by_workspace():
|
||||
app = build_app(idle_grace_seconds=10.0)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport,
|
||||
base_url="http://test") as client:
|
||||
wid_a = await _create_workspace(client)
|
||||
wid_b = await _create_workspace(client)
|
||||
await client.post(
|
||||
f"/v1/workspaces/{wid_a}/execute",
|
||||
json={"command": "echo a"},
|
||||
)
|
||||
await client.post(
|
||||
f"/v1/workspaces/{wid_b}/execute",
|
||||
json={"command": "echo b"},
|
||||
)
|
||||
r = await client.get("/v1/jobs")
|
||||
assert len(r.json()) == 2
|
||||
|
||||
r = await client.get(f"/v1/jobs?workspace_id={wid_a}")
|
||||
jobs = r.json()
|
||||
assert len(jobs) == 1
|
||||
assert jobs[0]["workspace_id"] == wid_a
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_with_stdin_multipart():
|
||||
app = build_app(idle_grace_seconds=10.0)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport,
|
||||
base_url="http://test") as client:
|
||||
wid = await _create_workspace(client)
|
||||
r = await client.post(
|
||||
f"/v1/workspaces/{wid}/execute",
|
||||
data={"request": json.dumps({"command": "wc -l"})},
|
||||
files={
|
||||
"stdin":
|
||||
("stdin.bin", b"a\nb\nc\n", "application/octet-stream"),
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["exit_code"] == 0
|
||||
assert body["stdout"].strip().startswith("3")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_workspace_404():
|
||||
app = build_app(idle_grace_seconds=10.0)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport,
|
||||
base_url="http://test") as client:
|
||||
r = await client.post(
|
||||
"/v1/workspaces/ws_doesnotexist/execute",
|
||||
json={"command": "echo hi"},
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_job_404():
|
||||
app = build_app(idle_grace_seconds=10.0)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport,
|
||||
base_url="http://test") as client:
|
||||
r = await client.get("/v1/jobs/job_doesnotexist")
|
||||
assert r.status_code == 404
|
||||
@@ -0,0 +1,227 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from mirage.server import build_app
|
||||
from mirage.server.host_validation import (is_host_allowed,
|
||||
parse_allowed_hosts,
|
||||
resolve_allowed_hosts, strip_port)
|
||||
from mirage.server.host_validation_constants import DEFAULT_ALLOWED_HOSTS
|
||||
|
||||
|
||||
def test_parse_allowed_hosts_defaults_when_missing():
|
||||
assert parse_allowed_hosts(None) == list(DEFAULT_ALLOWED_HOSTS)
|
||||
assert parse_allowed_hosts("") == list(DEFAULT_ALLOWED_HOSTS)
|
||||
assert parse_allowed_hosts(" ") == list(DEFAULT_ALLOWED_HOSTS)
|
||||
|
||||
|
||||
def test_parse_allowed_hosts_csv():
|
||||
assert parse_allowed_hosts("a,b,c") == ["a", "b", "c"]
|
||||
assert parse_allowed_hosts(" a , b , c ") == ["a", "b", "c"]
|
||||
|
||||
|
||||
def test_parse_allowed_hosts_wildcard_passthrough():
|
||||
assert parse_allowed_hosts("*") == ["*"]
|
||||
assert parse_allowed_hosts("*,localhost") == ["*", "localhost"]
|
||||
|
||||
|
||||
def test_strip_port_well_formed():
|
||||
assert strip_port("127.0.0.1") == "127.0.0.1"
|
||||
assert strip_port("127.0.0.1:8765") == "127.0.0.1"
|
||||
assert strip_port("localhost:8765") == "localhost"
|
||||
|
||||
|
||||
def test_strip_port_ipv6_brackets():
|
||||
assert strip_port("[::1]") == "::1"
|
||||
assert strip_port("[::1]:8765") == "::1"
|
||||
|
||||
|
||||
def test_strip_port_malformed_bracketed_returns_raw():
|
||||
assert strip_port("[::1]evil") == "[::1]evil"
|
||||
assert strip_port("[::1]:8765x") == "[::1]:8765x"
|
||||
assert strip_port("[::1].attacker.tld") == "[::1].attacker.tld"
|
||||
|
||||
|
||||
def test_strip_port_unclosed_bracket_returns_raw():
|
||||
assert strip_port("[::1") == "[::1"
|
||||
|
||||
|
||||
def test_strip_port_non_digit_port_returns_raw():
|
||||
assert strip_port("127.0.0.1:8765x") == "127.0.0.1:8765x"
|
||||
|
||||
|
||||
def test_strip_port_empty_host_returns_raw():
|
||||
assert strip_port(":8765") == ":8765"
|
||||
assert strip_port("[]") == "[]"
|
||||
|
||||
|
||||
def test_is_host_allowed_malformed_fail_closed():
|
||||
allowed = list(DEFAULT_ALLOWED_HOSTS)
|
||||
assert is_host_allowed("[::1]evil", allowed) is False
|
||||
assert is_host_allowed("[::1]:8765x", allowed) is False
|
||||
assert is_host_allowed("[::1].attacker.tld", allowed) is False
|
||||
|
||||
|
||||
def test_is_host_allowed_accepts_loopback():
|
||||
allowed = list(DEFAULT_ALLOWED_HOSTS)
|
||||
assert is_host_allowed("[::1]", allowed) is True
|
||||
assert is_host_allowed("[::1]:8765", allowed) is True
|
||||
assert is_host_allowed("127.0.0.1:8765", allowed) is True
|
||||
|
||||
|
||||
def test_resolve_allowed_hosts_explicit_wins(monkeypatch):
|
||||
monkeypatch.setenv("MIRAGE_ALLOWED_HOSTS", "elsewhere")
|
||||
assert resolve_allowed_hosts(["override.example"]) == ["override.example"]
|
||||
|
||||
|
||||
def test_resolve_allowed_hosts_env_when_arg_missing(monkeypatch):
|
||||
monkeypatch.setenv("MIRAGE_ALLOWED_HOSTS", "foo,bar")
|
||||
assert resolve_allowed_hosts(None) == ["foo", "bar"]
|
||||
|
||||
|
||||
def test_resolve_allowed_hosts_defaults_when_env_unset(monkeypatch):
|
||||
monkeypatch.delenv("MIRAGE_ALLOWED_HOSTS", raising=False)
|
||||
assert resolve_allowed_hosts(None) == list(DEFAULT_ALLOWED_HOSTS)
|
||||
|
||||
|
||||
@pytest.mark.no_host_override
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_rejects_unknown_host(monkeypatch):
|
||||
# No env, no explicit arg: middleware enforces loopback allowlist.
|
||||
monkeypatch.delenv("MIRAGE_ALLOWED_HOSTS", raising=False)
|
||||
app = build_app(idle_grace_seconds=10.0)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport,
|
||||
base_url="http://attacker.example") as client:
|
||||
r = await client.get("/v1/workspaces")
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.no_host_override
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_accepts_loopback_host(monkeypatch):
|
||||
monkeypatch.delenv("MIRAGE_ALLOWED_HOSTS", raising=False)
|
||||
app = build_app(idle_grace_seconds=10.0)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport,
|
||||
base_url="http://127.0.0.1") as client:
|
||||
r = await client.get("/v1/workspaces")
|
||||
assert r.status_code == 200
|
||||
async with AsyncClient(transport=transport,
|
||||
base_url="http://localhost") as client:
|
||||
r = await client.get("/v1/workspaces")
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.no_host_override
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_accepts_ipv6_loopback_host(monkeypatch):
|
||||
monkeypatch.delenv("MIRAGE_ALLOWED_HOSTS", raising=False)
|
||||
app = build_app(idle_grace_seconds=10.0)
|
||||
transport = ASGITransport(app=app)
|
||||
for base in ("http://[::1]", "http://[::1]:8765"):
|
||||
async with AsyncClient(transport=transport, base_url=base) as client:
|
||||
r = await client.get("/v1/workspaces")
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.no_host_override
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_rejects_malformed_bracketed_host(monkeypatch):
|
||||
monkeypatch.delenv("MIRAGE_ALLOWED_HOSTS", raising=False)
|
||||
app = build_app(idle_grace_seconds=10.0)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport,
|
||||
base_url="http://127.0.0.1") as client:
|
||||
r = await client.get("/v1/workspaces", headers={"host": "[::1]evil"})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.no_host_override
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_rejects_non_digit_port(monkeypatch):
|
||||
monkeypatch.delenv("MIRAGE_ALLOWED_HOSTS", raising=False)
|
||||
app = build_app(idle_grace_seconds=10.0)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport,
|
||||
base_url="http://127.0.0.1") as client:
|
||||
r = await client.get("/v1/workspaces",
|
||||
headers={"host": "127.0.0.1:8765x"})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.no_host_override
|
||||
@pytest.mark.asyncio
|
||||
async def test_env_override_extends_allowlist(monkeypatch):
|
||||
monkeypatch.setenv("MIRAGE_ALLOWED_HOSTS",
|
||||
"127.0.0.1,localhost,daemon.mirage.local")
|
||||
app = build_app(idle_grace_seconds=10.0)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport,
|
||||
base_url="http://daemon.mirage.local") as client:
|
||||
r = await client.get("/v1/workspaces")
|
||||
assert r.status_code == 200
|
||||
async with AsyncClient(transport=transport,
|
||||
base_url="http://attacker.example") as client:
|
||||
r = await client.get("/v1/workspaces")
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.no_host_override
|
||||
@pytest.mark.asyncio
|
||||
async def test_explicit_wildcard_disables_enforcement(monkeypatch):
|
||||
monkeypatch.delenv("MIRAGE_ALLOWED_HOSTS", raising=False)
|
||||
app = build_app(idle_grace_seconds=10.0, allowed_hosts=["*"])
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport,
|
||||
base_url="http://anything.example") as client:
|
||||
r = await client.get("/v1/workspaces")
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.no_host_override
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejection_emits_log_warning(monkeypatch, caplog):
|
||||
monkeypatch.delenv("MIRAGE_ALLOWED_HOSTS", raising=False)
|
||||
app = build_app(idle_grace_seconds=10.0)
|
||||
transport = ASGITransport(app=app)
|
||||
caplog.set_level("WARNING", logger="mirage.server.host_validation")
|
||||
async with AsyncClient(transport=transport,
|
||||
base_url="http://attacker.example") as client:
|
||||
r = await client.get("/v1/workspaces")
|
||||
assert r.status_code == 400
|
||||
rejection_logs = [
|
||||
rec for rec in caplog.records
|
||||
if "attacker.example" in rec.getMessage()
|
||||
]
|
||||
assert rejection_logs, "expected a warning log for the rejected host"
|
||||
assert rejection_logs[0].levelname == "WARNING"
|
||||
|
||||
|
||||
def test_resolve_allowed_hosts_config_beats_default(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MIRAGE_HOME", str(tmp_path))
|
||||
monkeypatch.delenv("MIRAGE_ALLOWED_HOSTS", raising=False)
|
||||
(tmp_path / "config.toml"
|
||||
).write_text('[daemon]\nallowed_hosts = "example.com,api.example.com"\n')
|
||||
assert resolve_allowed_hosts() == ["example.com", "api.example.com"]
|
||||
|
||||
|
||||
def test_resolve_allowed_hosts_env_beats_config(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MIRAGE_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("MIRAGE_ALLOWED_HOSTS", "env.example.com")
|
||||
(tmp_path / "config.toml"
|
||||
).write_text('[daemon]\nallowed_hosts = "file.example.com"\n')
|
||||
assert resolve_allowed_hosts() == ["env.example.com"]
|
||||
@@ -0,0 +1,194 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.server.env import ENV_HOME, ENV_PID_FILE
|
||||
from mirage.server.paths import (PathOutsideRootError, mirage_home,
|
||||
pid_file_path, resolve_within_root,
|
||||
snapshot_root_path, validate_path_segment,
|
||||
version_root_path)
|
||||
|
||||
|
||||
def test_resolve_within_root_relative(tmp_path):
|
||||
out = resolve_within_root(tmp_path, "seed.tar")
|
||||
assert out == (tmp_path / "seed.tar")
|
||||
|
||||
|
||||
def test_resolve_within_root_absolute_inside(tmp_path):
|
||||
inside = tmp_path / "nested" / "a.tar"
|
||||
assert resolve_within_root(tmp_path, str(inside)) == inside
|
||||
|
||||
|
||||
def test_resolve_within_root_returns_root(tmp_path):
|
||||
assert resolve_within_root(tmp_path, ".") == tmp_path
|
||||
|
||||
|
||||
def test_resolve_within_root_rejects_traversal(tmp_path):
|
||||
with pytest.raises(PathOutsideRootError):
|
||||
resolve_within_root(tmp_path, "../../etc/passwd")
|
||||
|
||||
|
||||
def test_resolve_within_root_rejects_absolute_outside(tmp_path):
|
||||
with pytest.raises(PathOutsideRootError):
|
||||
resolve_within_root(tmp_path, "/etc/passwd")
|
||||
|
||||
|
||||
def test_resolve_within_root_rejects_sibling_prefix(tmp_path):
|
||||
sibling = str(tmp_path) + "-evil"
|
||||
with pytest.raises(PathOutsideRootError):
|
||||
resolve_within_root(tmp_path, sibling)
|
||||
|
||||
|
||||
def test_validate_path_segment_accepts_safe():
|
||||
assert validate_path_segment("ws_abc123") == "ws_abc123"
|
||||
assert validate_path_segment("a.b-c_d") == "a.b-c_d"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["", ".", "..", "a/b", "a\\b", "a b", "a$b"])
|
||||
def test_validate_path_segment_rejects_bad(bad):
|
||||
with pytest.raises(PathOutsideRootError):
|
||||
validate_path_segment(bad)
|
||||
|
||||
|
||||
def test_mirage_home_defaults_to_dot_mirage(monkeypatch):
|
||||
monkeypatch.delenv(ENV_HOME, raising=False)
|
||||
assert mirage_home() == Path.home() / ".mirage"
|
||||
|
||||
|
||||
def test_mirage_home_honors_env(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv(ENV_HOME, str(tmp_path))
|
||||
assert mirage_home() == tmp_path
|
||||
|
||||
|
||||
def test_pid_file_defaults_under_home(monkeypatch, tmp_path):
|
||||
monkeypatch.delenv(ENV_PID_FILE, raising=False)
|
||||
monkeypatch.setenv(ENV_HOME, str(tmp_path))
|
||||
assert pid_file_path() == tmp_path / "daemon.pid"
|
||||
|
||||
|
||||
def test_pid_file_env_wins_over_home(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv(ENV_HOME, str(tmp_path))
|
||||
monkeypatch.setenv(ENV_PID_FILE, "/run/mirage/daemon.pid")
|
||||
assert pid_file_path() == Path("/run/mirage/daemon.pid")
|
||||
|
||||
|
||||
def test_pid_file_explicit_wins_over_env(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv(ENV_PID_FILE, "/run/mirage/daemon.pid")
|
||||
assert pid_file_path(tmp_path / "x.pid") == tmp_path / "x.pid"
|
||||
|
||||
|
||||
def test_roots_follow_mirage_home(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv(ENV_HOME, str(tmp_path))
|
||||
assert version_root_path() == tmp_path / "repos"
|
||||
assert snapshot_root_path() == tmp_path / "snapshots"
|
||||
|
||||
|
||||
def test_mirage_home_relative_env_is_absolutized(monkeypatch, tmp_path):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setenv(ENV_HOME, "mhome")
|
||||
assert mirage_home() == tmp_path / "mhome"
|
||||
|
||||
|
||||
def test_pid_file_relative_env_is_absolutized(monkeypatch, tmp_path):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setenv(ENV_PID_FILE, "rel/daemon.pid")
|
||||
assert pid_file_path() == tmp_path / "rel" / "daemon.pid"
|
||||
|
||||
|
||||
def test_pid_file_explicit_relative_is_absolutized(monkeypatch, tmp_path):
|
||||
monkeypatch.delenv(ENV_PID_FILE, raising=False)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
assert pid_file_path("x.pid") == tmp_path / "x.pid"
|
||||
|
||||
|
||||
def _write_config(home, body):
|
||||
(home / "config.toml").write_text(f"[daemon]\n{body}\n")
|
||||
|
||||
|
||||
def test_pid_file_config_beats_default(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MIRAGE_HOME", str(tmp_path))
|
||||
monkeypatch.delenv("MIRAGE_PID_FILE", raising=False)
|
||||
_write_config(tmp_path, 'pid_file = "/tmp/from-config.pid"')
|
||||
assert pid_file_path() == Path("/tmp/from-config.pid")
|
||||
|
||||
|
||||
def test_pid_file_env_beats_config(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MIRAGE_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("MIRAGE_PID_FILE", "/tmp/from-env.pid")
|
||||
_write_config(tmp_path, 'pid_file = "/tmp/from-config.pid"')
|
||||
assert pid_file_path() == Path("/tmp/from-env.pid")
|
||||
|
||||
|
||||
def test_pid_file_explicit_beats_env(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MIRAGE_PID_FILE", "/tmp/from-env.pid")
|
||||
assert pid_file_path("/tmp/explicit.pid") == Path("/tmp/explicit.pid")
|
||||
|
||||
|
||||
def test_pid_file_default_when_unset(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MIRAGE_HOME", str(tmp_path))
|
||||
monkeypatch.delenv("MIRAGE_PID_FILE", raising=False)
|
||||
assert pid_file_path() == tmp_path / "daemon.pid"
|
||||
|
||||
|
||||
def test_version_root_config_beats_default(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MIRAGE_HOME", str(tmp_path))
|
||||
monkeypatch.delenv("MIRAGE_VERSION_ROOT", raising=False)
|
||||
_write_config(tmp_path, 'version_root = "/data/repos"')
|
||||
assert version_root_path() == Path("/data/repos")
|
||||
|
||||
|
||||
def test_version_root_env_beats_config(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MIRAGE_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("MIRAGE_VERSION_ROOT", "/env/repos")
|
||||
_write_config(tmp_path, 'version_root = "/data/repos"')
|
||||
assert version_root_path() == Path("/env/repos")
|
||||
|
||||
|
||||
def test_snapshot_root_config_beats_default(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MIRAGE_HOME", str(tmp_path))
|
||||
monkeypatch.delenv("MIRAGE_SNAPSHOT_ROOT", raising=False)
|
||||
_write_config(tmp_path, 'snapshot_root = "/data/snaps"')
|
||||
assert snapshot_root_path() == Path("/data/snaps")
|
||||
|
||||
|
||||
def test_snapshot_root_default_when_unset(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MIRAGE_HOME", str(tmp_path))
|
||||
monkeypatch.delenv("MIRAGE_SNAPSHOT_ROOT", raising=False)
|
||||
assert snapshot_root_path() == tmp_path / "snapshots"
|
||||
|
||||
|
||||
def test_version_root_explicit_beats_env(monkeypatch):
|
||||
monkeypatch.setenv("MIRAGE_VERSION_ROOT", "/env/repos")
|
||||
assert version_root_path("/explicit/repos") == Path("/explicit/repos")
|
||||
|
||||
|
||||
def test_version_root_default_when_unset(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MIRAGE_HOME", str(tmp_path))
|
||||
monkeypatch.delenv("MIRAGE_VERSION_ROOT", raising=False)
|
||||
assert version_root_path() == tmp_path / "repos"
|
||||
|
||||
|
||||
def test_snapshot_root_env_beats_config(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MIRAGE_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("MIRAGE_SNAPSHOT_ROOT", "/env/snaps")
|
||||
_write_config(tmp_path, 'snapshot_root = "/data/snaps"')
|
||||
assert snapshot_root_path() == Path("/env/snaps")
|
||||
|
||||
|
||||
def test_snapshot_root_explicit_beats_env(monkeypatch):
|
||||
monkeypatch.setenv("MIRAGE_SNAPSHOT_ROOT", "/env/snaps")
|
||||
assert snapshot_root_path("/explicit/snaps") == Path("/explicit/snaps")
|
||||
@@ -0,0 +1,137 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from mirage.server import build_app
|
||||
|
||||
|
||||
def _minimal_config() -> dict:
|
||||
return {
|
||||
"config": {
|
||||
"mounts": {
|
||||
"/": {
|
||||
"resource": "ram",
|
||||
"mode": "WRITE"
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def _create_workspace(client: AsyncClient) -> str:
|
||||
r = await client.post("/v1/workspaces", json=_minimal_config())
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_list_delete_session_round_trip():
|
||||
app = build_app(idle_grace_seconds=10.0)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport,
|
||||
base_url="http://test") as client:
|
||||
wid = await _create_workspace(client)
|
||||
|
||||
r = await client.post(f"/v1/workspaces/{wid}/sessions",
|
||||
json={"session_id": "agent_a"})
|
||||
assert r.status_code == 201, r.text
|
||||
assert r.json()["session_id"] == "agent_a"
|
||||
|
||||
r = await client.get(f"/v1/workspaces/{wid}/sessions")
|
||||
ids = {s["session_id"] for s in r.json()}
|
||||
assert "agent_a" in ids
|
||||
assert "default" in ids
|
||||
|
||||
r = await client.delete(f"/v1/workspaces/{wid}/sessions/agent_a")
|
||||
assert r.status_code == 200
|
||||
|
||||
r = await client.get(f"/v1/workspaces/{wid}/sessions")
|
||||
ids = {s["session_id"] for s in r.json()}
|
||||
assert "agent_a" not in ids
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_session_without_id_auto_assigns():
|
||||
app = build_app(idle_grace_seconds=10.0)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport,
|
||||
base_url="http://test") as client:
|
||||
wid = await _create_workspace(client)
|
||||
r = await client.post(f"/v1/workspaces/{wid}/sessions", json={})
|
||||
assert r.status_code == 201
|
||||
sid = r.json()["session_id"]
|
||||
assert sid.startswith("sess_")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_session_collision_409():
|
||||
app = build_app(idle_grace_seconds=10.0)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport,
|
||||
base_url="http://test") as client:
|
||||
wid = await _create_workspace(client)
|
||||
await client.post(f"/v1/workspaces/{wid}/sessions",
|
||||
json={"session_id": "dup"})
|
||||
r = await client.post(f"/v1/workspaces/{wid}/sessions",
|
||||
json={"session_id": "dup"})
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_unknown_session_404():
|
||||
app = build_app(idle_grace_seconds=10.0)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport,
|
||||
base_url="http://test") as client:
|
||||
wid = await _create_workspace(client)
|
||||
r = await client.delete(f"/v1/workspaces/{wid}/sessions/nonexistent")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_session_with_allowed_mounts():
|
||||
app = build_app(idle_grace_seconds=10.0)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport,
|
||||
base_url="http://test") as client:
|
||||
wid = await _create_workspace(client)
|
||||
r = await client.post(
|
||||
f"/v1/workspaces/{wid}/sessions",
|
||||
json={
|
||||
"session_id": "agent_a",
|
||||
"allowed_mounts": ["/"],
|
||||
},
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
|
||||
registry = app.state.registry
|
||||
sess = registry.get(wid).runner.ws.get_session("agent_a")
|
||||
assert sess.allowed_mounts is not None
|
||||
assert "/" in sess.allowed_mounts
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_isolated_per_workspace():
|
||||
app = build_app(idle_grace_seconds=10.0)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport,
|
||||
base_url="http://test") as client:
|
||||
wid_a = await _create_workspace(client)
|
||||
wid_b = await _create_workspace(client)
|
||||
await client.post(f"/v1/workspaces/{wid_a}/sessions",
|
||||
json={"session_id": "only_in_a"})
|
||||
r = await client.get(f"/v1/workspaces/{wid_b}/sessions")
|
||||
ids = {s["session_id"] for s in r.json()}
|
||||
assert "only_in_a" not in ids
|
||||
@@ -0,0 +1,271 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from mirage.server import build_app
|
||||
|
||||
|
||||
def _minimal_config() -> dict:
|
||||
return {
|
||||
"config": {
|
||||
"mounts": {
|
||||
"/": {
|
||||
"resource": "ram",
|
||||
"mode": "WRITE"
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _client(tmp_path):
|
||||
app = build_app(idle_grace_seconds=30.0,
|
||||
version_root=str(tmp_path / "repos"))
|
||||
return AsyncClient(transport=ASGITransport(app=app),
|
||||
base_url="http://test")
|
||||
|
||||
|
||||
async def _create_ws(client) -> str:
|
||||
r = await client.post("/v1/workspaces", json=_minimal_config())
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
async def _write(client, wid: str, command: str) -> None:
|
||||
r = await client.post(f"/v1/workspaces/{wid}/execute",
|
||||
json={"command": command})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
async def _cat(client, wid: str, path: str) -> str:
|
||||
r = await client.post(f"/v1/workspaces/{wid}/execute",
|
||||
json={"command": f"cat {path}"})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["stdout"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commit_log_checkout_flow(tmp_path):
|
||||
async with _client(tmp_path) as client:
|
||||
wid = await _create_ws(client)
|
||||
await _write(client, wid, "echo v1 > /notes.txt")
|
||||
|
||||
r = await client.post(f"/v1/workspaces/{wid}/commit",
|
||||
json={"message": "first"})
|
||||
assert r.status_code == 200, r.text
|
||||
v1 = r.json()["version"]
|
||||
assert r.json()["branch"] == "main"
|
||||
|
||||
await _write(client, wid, "echo v2 > /notes.txt")
|
||||
r = await client.post(f"/v1/workspaces/{wid}/commit",
|
||||
json={"message": "second"})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
r = await client.get(f"/v1/workspaces/{wid}/versions")
|
||||
assert r.status_code == 200
|
||||
log = r.json()
|
||||
assert [e["message"] for e in log] == ["second", "first"]
|
||||
|
||||
assert await _cat(client, wid, "/notes.txt") == "v2\n"
|
||||
r = await client.post(f"/v1/workspaces/{wid}/checkout",
|
||||
json={"ref": v1})
|
||||
assert r.status_code == 200, r.text
|
||||
assert await _cat(client, wid, "/notes.txt") == "v1\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diff_endpoint_follows_git(tmp_path):
|
||||
async with _client(tmp_path) as client:
|
||||
wid = await _create_ws(client)
|
||||
await _write(client, wid, "echo one > /a.txt")
|
||||
r = await client.post(f"/v1/workspaces/{wid}/commit",
|
||||
json={"message": "first"})
|
||||
v1 = r.json()["version"]
|
||||
|
||||
await _write(client, wid, "echo two > /a.txt")
|
||||
await _write(client, wid, "echo new > /b.txt")
|
||||
r = await client.post(f"/v1/workspaces/{wid}/commit",
|
||||
json={"message": "second"})
|
||||
v2 = r.json()["version"]
|
||||
|
||||
r = await client.get(f"/v1/workspaces/{wid}/diff",
|
||||
params={
|
||||
"a": v1,
|
||||
"b": v2
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["modified"] == ["a.txt"]
|
||||
assert r.json()["added"] == ["b.txt"]
|
||||
|
||||
await _write(client, wid, "echo three > /a.txt")
|
||||
r = await client.get(f"/v1/workspaces/{wid}/diff", params={"a": v2})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["modified"] == ["a.txt"]
|
||||
|
||||
r = await client.get(f"/v1/workspaces/{wid}/diff")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["modified"] == ["a.txt"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diff_bad_ref_404(tmp_path):
|
||||
async with _client(tmp_path) as client:
|
||||
wid = await _create_ws(client)
|
||||
await _write(client, wid, "echo x > /x.txt")
|
||||
await client.post(f"/v1/workspaces/{wid}/commit", json={})
|
||||
r = await client.get(f"/v1/workspaces/{wid}/diff",
|
||||
params={"a": "deadbeef" * 5})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_branch_endpoint_diverges(tmp_path):
|
||||
async with _client(tmp_path) as client:
|
||||
wid = await _create_ws(client)
|
||||
await _write(client, wid, "echo one > /a.txt")
|
||||
r = await client.post(f"/v1/workspaces/{wid}/commit",
|
||||
json={"message": "first"})
|
||||
v1 = r.json()["version"]
|
||||
|
||||
r = await client.post(f"/v1/workspaces/{wid}/branch",
|
||||
json={"name": "exp"})
|
||||
assert r.status_code == 201, r.text
|
||||
assert r.json()["branch"] == "exp"
|
||||
assert r.json()["version"] == v1
|
||||
|
||||
await _write(client, wid, "echo two > /a.txt")
|
||||
r = await client.post(f"/v1/workspaces/{wid}/commit",
|
||||
json={
|
||||
"message": "on exp",
|
||||
"branch": "exp"
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
exp_log = (await client.get(f"/v1/workspaces/{wid}/versions",
|
||||
params={"branch": "exp"})).json()
|
||||
main_log = (await client.get(f"/v1/workspaces/{wid}/versions",
|
||||
params={"branch": "main"})).json()
|
||||
assert [e["message"] for e in exp_log] == ["on exp", "first"]
|
||||
assert [e["message"] for e in main_log] == ["first"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_branch_duplicate_409(tmp_path):
|
||||
async with _client(tmp_path) as client:
|
||||
wid = await _create_ws(client)
|
||||
await _write(client, wid, "echo x > /x.txt")
|
||||
await client.post(f"/v1/workspaces/{wid}/commit", json={})
|
||||
await client.post(f"/v1/workspaces/{wid}/branch", json={"name": "exp"})
|
||||
r = await client.post(f"/v1/workspaces/{wid}/branch",
|
||||
json={"name": "exp"})
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_branch_from_missing_404(tmp_path):
|
||||
async with _client(tmp_path) as client:
|
||||
wid = await _create_ws(client)
|
||||
await _write(client, wid, "echo x > /x.txt")
|
||||
await client.post(f"/v1/workspaces/{wid}/commit", json={})
|
||||
r = await client.post(f"/v1/workspaces/{wid}/branch",
|
||||
json={
|
||||
"name": "exp",
|
||||
"from_branch": "ghost"
|
||||
})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commit_unknown_branch_404(tmp_path):
|
||||
async with _client(tmp_path) as client:
|
||||
wid = await _create_ws(client)
|
||||
await _write(client, wid, "echo x > /x.txt")
|
||||
await client.post(f"/v1/workspaces/{wid}/commit", json={})
|
||||
r = await client.post(f"/v1/workspaces/{wid}/commit",
|
||||
json={"branch": "ghost"})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clone_from_version_creates_new_workspace(tmp_path):
|
||||
async with _client(tmp_path) as client:
|
||||
wid = await _create_ws(client)
|
||||
await _write(client, wid, "echo base > /b.txt")
|
||||
r = await client.post(f"/v1/workspaces/{wid}/commit",
|
||||
json={"message": "base"})
|
||||
version = r.json()["version"]
|
||||
|
||||
r = await client.post("/v1/workspaces/clone",
|
||||
json={
|
||||
"source_id": wid,
|
||||
"at": version
|
||||
})
|
||||
assert r.status_code == 201, r.text
|
||||
new_id = r.json()["id"]
|
||||
assert new_id != wid
|
||||
assert await _cat(client, new_id, "/b.txt") == "base\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clone_live_workspace(tmp_path):
|
||||
async with _client(tmp_path) as client:
|
||||
wid = await _create_ws(client)
|
||||
await _write(client, wid, "echo live > /l.txt")
|
||||
|
||||
r = await client.post("/v1/workspaces/clone", json={"source_id": wid})
|
||||
assert r.status_code == 201, r.text
|
||||
new_id = r.json()["id"]
|
||||
assert new_id != wid
|
||||
assert await _cat(client, new_id, "/l.txt") == "live\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_log_empty_when_no_commits(tmp_path):
|
||||
async with _client(tmp_path) as client:
|
||||
wid = await _create_ws(client)
|
||||
r = await client.get(f"/v1/workspaces/{wid}/versions")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commit_unknown_workspace_404(tmp_path):
|
||||
async with _client(tmp_path) as client:
|
||||
r = await client.post("/v1/workspaces/nope/commit", json={})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_checkout_bad_ref_404(tmp_path):
|
||||
async with _client(tmp_path) as client:
|
||||
wid = await _create_ws(client)
|
||||
await _write(client, wid, "echo x > /x.txt")
|
||||
await client.post(f"/v1/workspaces/{wid}/commit", json={})
|
||||
r = await client.post(f"/v1/workspaces/{wid}/checkout",
|
||||
json={"ref": "deadbeef" * 5})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clone_duplicate_id_409(tmp_path):
|
||||
async with _client(tmp_path) as client:
|
||||
wid = await _create_ws(client)
|
||||
r = await client.post("/v1/workspaces/clone",
|
||||
json={
|
||||
"source_id": wid,
|
||||
"id": wid
|
||||
})
|
||||
assert r.status_code == 409
|
||||
@@ -0,0 +1,415 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from mirage.server import build_app
|
||||
from mirage.server.registry import WorkspaceRegistry
|
||||
|
||||
|
||||
def _minimal_config() -> dict:
|
||||
return {
|
||||
"config": {
|
||||
"mounts": {
|
||||
"/": {
|
||||
"resource": "ram",
|
||||
"mode": "WRITE"
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _make_app_with_short_grace(grace: float = 0.2, snapshot_root=None):
|
||||
exit_event = asyncio.Event()
|
||||
app = build_app(idle_grace_seconds=grace,
|
||||
exit_event=exit_event,
|
||||
snapshot_root=snapshot_root)
|
||||
return app, exit_event
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_list_get_delete_round_trip():
|
||||
app, _ = _make_app_with_short_grace(grace=10.0)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport,
|
||||
base_url="http://test") as client:
|
||||
r = await client.post("/v1/workspaces", json=_minimal_config())
|
||||
assert r.status_code == 201, r.text
|
||||
detail = r.json()
|
||||
wid = detail["id"]
|
||||
assert wid.startswith("ws_")
|
||||
assert detail["mode"] == "write"
|
||||
assert any(m["prefix"] == "/" for m in detail["mounts"])
|
||||
|
||||
r = await client.get("/v1/workspaces")
|
||||
assert r.status_code == 200
|
||||
briefs = r.json()
|
||||
assert len(briefs) == 1
|
||||
assert briefs[0]["id"] == wid
|
||||
assert briefs[0]["mount_count"] == 1
|
||||
|
||||
r = await client.get(f"/v1/workspaces/{wid}")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["id"] == wid
|
||||
|
||||
r = await client.delete(f"/v1/workspaces/{wid}")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["id"] == wid
|
||||
|
||||
r = await client.get(f"/v1/workspaces/{wid}")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_with_explicit_id():
|
||||
app, _ = _make_app_with_short_grace(grace=10.0)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport,
|
||||
base_url="http://test") as client:
|
||||
body = {**_minimal_config(), "id": "myws"}
|
||||
r = await client.post("/v1/workspaces", json=body)
|
||||
assert r.status_code == 201
|
||||
assert r.json()["id"] == "myws"
|
||||
|
||||
r = await client.post("/v1/workspaces", json=body)
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_verbose_includes_internals():
|
||||
app, _ = _make_app_with_short_grace(grace=10.0)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport,
|
||||
base_url="http://test") as client:
|
||||
r = await client.post("/v1/workspaces", json=_minimal_config())
|
||||
wid = r.json()["id"]
|
||||
|
||||
r = await client.get(f"/v1/workspaces/{wid}")
|
||||
assert r.json()["internals"] is None
|
||||
|
||||
r = await client.get(f"/v1/workspaces/{wid}?verbose=true")
|
||||
internals = r.json()["internals"]
|
||||
assert internals is not None
|
||||
assert "cache_bytes" in internals
|
||||
assert "cache_entries" in internals
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.skipif(not os.environ.get("REDIS_URL"),
|
||||
reason="REDIS_URL not set")
|
||||
async def test_get_verbose_internals_with_redis_cache():
|
||||
body = {
|
||||
"config": {
|
||||
"mounts": {
|
||||
"/": {
|
||||
"resource": "ram",
|
||||
"mode": "WRITE"
|
||||
}
|
||||
},
|
||||
"cache": {
|
||||
"type": "redis",
|
||||
"url": os.environ["REDIS_URL"],
|
||||
"key_prefix": "test:summary:",
|
||||
},
|
||||
},
|
||||
}
|
||||
app, _ = _make_app_with_short_grace(grace=10.0)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport,
|
||||
base_url="http://test") as client:
|
||||
r = await client.post("/v1/workspaces", json=body)
|
||||
assert r.status_code == 201, r.text
|
||||
wid = r.json()["id"]
|
||||
|
||||
r = await client.get(f"/v1/workspaces/{wid}?verbose=true")
|
||||
assert r.status_code == 200, r.text
|
||||
internals = r.json()["internals"]
|
||||
assert internals is not None
|
||||
# Redis cache does not track size or entries; the summary must
|
||||
# report them as untracked instead of reaching into RAM-store
|
||||
# internals or conflating "not tracked" with an empty cache.
|
||||
assert internals["cache_bytes"] is None
|
||||
assert internals["cache_entries"] is None
|
||||
|
||||
await client.delete(f"/v1/workspaces/{wid}")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clone_returns_new_workspace_with_same_mounts():
|
||||
app, _ = _make_app_with_short_grace(grace=10.0)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport,
|
||||
base_url="http://test") as client:
|
||||
r = await client.post("/v1/workspaces", json=_minimal_config())
|
||||
wid = r.json()["id"]
|
||||
|
||||
r = await client.post(f"/v1/workspaces/{wid}/clone", json={})
|
||||
assert r.status_code == 201, r.text
|
||||
clone = r.json()
|
||||
assert clone["id"] != wid
|
||||
assert clone["id"].startswith("ws_")
|
||||
assert {m["prefix"] for m in clone["mounts"]} == {"/"}
|
||||
|
||||
r = await client.get("/v1/workspaces")
|
||||
assert len(r.json()) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clone_with_explicit_id_409_on_collision():
|
||||
app, _ = _make_app_with_short_grace(grace=10.0)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport,
|
||||
base_url="http://test") as client:
|
||||
body = {**_minimal_config(), "id": "src"}
|
||||
await client.post("/v1/workspaces", json=body)
|
||||
|
||||
body2 = {**_minimal_config(), "id": "other"}
|
||||
await client.post("/v1/workspaces", json=body2)
|
||||
|
||||
r = await client.post("/v1/workspaces/src/clone", json={"id": "other"})
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_endpoint():
|
||||
app, _ = _make_app_with_short_grace(grace=10.0)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport,
|
||||
base_url="http://test") as client:
|
||||
r = await client.get("/v1/health")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["status"] == "ok"
|
||||
assert body["workspaces"] == 0
|
||||
assert body["uptime_s"] >= 0
|
||||
|
||||
await client.post("/v1/workspaces", json=_minimal_config())
|
||||
r = await client.get("/v1/health")
|
||||
assert r.json()["workspaces"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_snapshot_writes_tar_to_path(tmp_path):
|
||||
app, _ = _make_app_with_short_grace(grace=10.0, snapshot_root=tmp_path)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport,
|
||||
base_url="http://test") as client:
|
||||
r = await client.post("/v1/workspaces", json=_minimal_config())
|
||||
wid = r.json()["id"]
|
||||
target = tmp_path / "snap.tar"
|
||||
r = await client.post(f"/v1/workspaces/{wid}/snapshot",
|
||||
json={"path": str(target)})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["path"] == str(target)
|
||||
assert body["size"] > 0
|
||||
assert target.exists()
|
||||
assert target.stat().st_size == body["size"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_snapshot_load_round_trip(tmp_path):
|
||||
app, _ = _make_app_with_short_grace(grace=10.0, snapshot_root=tmp_path)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport,
|
||||
base_url="http://test") as client:
|
||||
r = await client.post("/v1/workspaces", json=_minimal_config())
|
||||
wid = r.json()["id"]
|
||||
target = tmp_path / "snap.tar"
|
||||
await client.post(f"/v1/workspaces/{wid}/snapshot",
|
||||
json={"path": str(target)})
|
||||
|
||||
r = await client.post("/v1/workspaces/load",
|
||||
json={"path": str(target)})
|
||||
assert r.status_code == 201, r.text
|
||||
new_id = r.json()["id"]
|
||||
assert new_id != wid
|
||||
|
||||
r = await client.get(f"/v1/workspaces/{new_id}")
|
||||
assert r.status_code == 200
|
||||
assert {m["prefix"] for m in r.json()["mounts"]} == {"/"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_snapshot_rejects_path_outside_root(tmp_path):
|
||||
app, _ = _make_app_with_short_grace(grace=10.0, snapshot_root=tmp_path)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport,
|
||||
base_url="http://test") as client:
|
||||
r = await client.post("/v1/workspaces", json=_minimal_config())
|
||||
wid = r.json()["id"]
|
||||
r = await client.post(f"/v1/workspaces/{wid}/snapshot",
|
||||
json={"path": "../escape.tar"})
|
||||
assert r.status_code == 400, r.text
|
||||
assert not (tmp_path.parent / "escape.tar").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_missing_path_returns_400(tmp_path):
|
||||
app, _ = _make_app_with_short_grace(grace=10.0, snapshot_root=tmp_path)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport,
|
||||
base_url="http://test") as client:
|
||||
r = await client.post("/v1/workspaces/load",
|
||||
json={"path": str(tmp_path / "nope.tar")})
|
||||
assert r.status_code == 400, r.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_workspaces_run_in_isolation():
|
||||
app, _ = _make_app_with_short_grace(grace=10.0)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport,
|
||||
base_url="http://test") as client:
|
||||
r = await client.post("/v1/workspaces", json=_minimal_config())
|
||||
wid_a = r.json()["id"]
|
||||
r = await client.post("/v1/workspaces", json=_minimal_config())
|
||||
wid_b = r.json()["id"]
|
||||
registry = app.state.registry
|
||||
runner_a = registry.get(wid_a).runner
|
||||
runner_b = registry.get(wid_b).runner
|
||||
|
||||
slow = asyncio.create_task(
|
||||
runner_a.call(runner_a.ws.execute("sleep 1.0")))
|
||||
await asyncio.sleep(0.05)
|
||||
start = time.monotonic()
|
||||
result = await runner_b.call(runner_b.ws.execute("echo quick"))
|
||||
elapsed = time.monotonic() - start
|
||||
assert result.exit_code == 0
|
||||
assert elapsed < 0.5, (
|
||||
f"workspace B took {elapsed:.2f}s while A was sleeping; "
|
||||
"isolation violated")
|
||||
await slow
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_idle_shutdown_event_fires_after_grace():
|
||||
app, exit_event = _make_app_with_short_grace(grace=0.2)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport,
|
||||
base_url="http://test") as client:
|
||||
r = await client.post("/v1/workspaces", json=_minimal_config())
|
||||
wid = r.json()["id"]
|
||||
await client.delete(f"/v1/workspaces/{wid}")
|
||||
await asyncio.wait_for(exit_event.wait(), timeout=2.0)
|
||||
assert exit_event.is_set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_idle_timer_canceled_when_new_workspace_created():
|
||||
app, exit_event = _make_app_with_short_grace(grace=0.5)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport,
|
||||
base_url="http://test") as client:
|
||||
r = await client.post("/v1/workspaces", json=_minimal_config())
|
||||
wid = r.json()["id"]
|
||||
await client.delete(f"/v1/workspaces/{wid}")
|
||||
await asyncio.sleep(0.1)
|
||||
assert not exit_event.is_set()
|
||||
await client.post("/v1/workspaces", json=_minimal_config())
|
||||
await asyncio.sleep(0.6)
|
||||
assert not exit_event.is_set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_workspace_bridges_fuse_through_manager(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def _fake_add(self, prefix, mountpoint=None):
|
||||
calls.append((prefix, mountpoint))
|
||||
return mountpoint or "/tmp/fake"
|
||||
|
||||
monkeypatch.setattr("mirage.workspace.workspace.Workspace.add_fuse_mount",
|
||||
_fake_add)
|
||||
app, _ = _make_app_with_short_grace(grace=10.0)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport,
|
||||
base_url="http://test") as client:
|
||||
body = {
|
||||
"config": {
|
||||
"mounts": {
|
||||
"/data/": {
|
||||
"resource": "ram",
|
||||
"fuse": True
|
||||
},
|
||||
"/pinned/": {
|
||||
"resource": "ram",
|
||||
"fuse": "/tmp/pinned"
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
r = await client.post("/v1/workspaces", json=body)
|
||||
assert r.status_code == 201, r.text
|
||||
assert ("/data/", None) in calls
|
||||
assert any(mp == "/tmp/pinned" for _, mp in calls)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_workspace_rolls_back_on_fuse_failure(monkeypatch):
|
||||
closed = []
|
||||
|
||||
def _boom_add(self, prefix, mountpoint=None):
|
||||
if not closed:
|
||||
orig = self.close
|
||||
|
||||
async def _spy():
|
||||
closed.append(True)
|
||||
await orig()
|
||||
|
||||
self.close = _spy
|
||||
raise ValueError("boom collision")
|
||||
|
||||
monkeypatch.setattr("mirage.workspace.workspace.Workspace.add_fuse_mount",
|
||||
_boom_add)
|
||||
app, _ = _make_app_with_short_grace(grace=10.0)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport,
|
||||
base_url="http://test") as client:
|
||||
body = {
|
||||
"config": {
|
||||
"mounts": {
|
||||
"/data/": {
|
||||
"resource": "ram",
|
||||
"fuse": True
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
r = await client.post("/v1/workspaces", json=body)
|
||||
assert r.status_code == 409, r.text
|
||||
|
||||
r = await client.get("/v1/workspaces")
|
||||
assert r.json() == []
|
||||
assert closed == [True]
|
||||
|
||||
|
||||
def test_registry_zero_grace_fires_immediately():
|
||||
|
||||
async def _run():
|
||||
registry = WorkspaceRegistry(idle_grace_seconds=0)
|
||||
from mirage import MountMode, Workspace
|
||||
from mirage.resource.ram import RAMResource
|
||||
ws = Workspace({"/": (RAMResource(), MountMode.WRITE)})
|
||||
entry = registry.add(ws)
|
||||
await registry.remove(entry.id)
|
||||
assert registry.exit_event.is_set()
|
||||
|
||||
asyncio.run(_run())
|
||||
@@ -0,0 +1,303 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.server.version.api import (branch, checkout, commit, commit_state,
|
||||
diff_live_vs_ref, read_version,
|
||||
resolve_ref, snapshot_tree, status,
|
||||
status_state, version_diff, version_log)
|
||||
from mirage.server.version.backend import LocalBackend
|
||||
from mirage.server.version.errors import NoSuchBranchError
|
||||
from mirage.server.version.state_tree import META_PATH
|
||||
from mirage.server.version.store import VersionStore
|
||||
from mirage.types import CacheKey, MountMode, StateKey
|
||||
from mirage.workspace import Workspace
|
||||
from mirage.workspace.snapshot import to_state_dict
|
||||
|
||||
|
||||
def _cache_entry(data: bytes) -> dict:
|
||||
return {
|
||||
CacheKey.KEY: "k",
|
||||
CacheKey.DATA: data,
|
||||
CacheKey.FINGERPRINT: None,
|
||||
CacheKey.TTL: None,
|
||||
CacheKey.CACHED_AT: 0.0,
|
||||
CacheKey.SIZE: len(data),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_snapshot_tree_contains_files_and_meta(tmp_path):
|
||||
ws = Workspace({"/m": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
await ws.execute("echo hello > /m/a.txt")
|
||||
store = await VersionStore.open(LocalBackend(tmp_path), "ws")
|
||||
|
||||
tree = await snapshot_tree(store, ws)
|
||||
contents = await store.read_tree(tree)
|
||||
|
||||
assert META_PATH in contents
|
||||
assert await store.read_blob(contents["m/a.txt"]) == b"hello\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commit_advances_branch_and_links_parent(tmp_path):
|
||||
ws = Workspace({"/m": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
store = await VersionStore.open(LocalBackend(tmp_path), "ws")
|
||||
|
||||
await ws.execute("echo one > /m/a.txt")
|
||||
c1 = await commit(store, ws, branch="main", message="first")
|
||||
await ws.execute("echo two > /m/a.txt")
|
||||
c2 = await commit(store, ws, branch="main", message="second")
|
||||
|
||||
assert await store.head("main") == c2
|
||||
assert (await store.read_commit(c2)).parents == [c1]
|
||||
assert await store.log("main") == [c2, c1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_version_log_lists_messages_newest_first(tmp_path):
|
||||
ws = Workspace({"/m": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
store = await VersionStore.open(LocalBackend(tmp_path), "ws")
|
||||
await ws.execute("echo one > /m/a.txt")
|
||||
await commit(store, ws, message="first")
|
||||
await ws.execute("echo two > /m/a.txt")
|
||||
await commit(store, ws, message="second")
|
||||
|
||||
log = await version_log(store, "main")
|
||||
assert [entry["message"] for entry in log] == ["second", "first"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_version_diff_reports_changed_files_only(tmp_path):
|
||||
ws = Workspace({"/m": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
store = await VersionStore.open(LocalBackend(tmp_path), "ws")
|
||||
await ws.execute("echo one > /m/a.txt")
|
||||
c1 = await commit(store, ws, message="first")
|
||||
await ws.execute("echo two > /m/a.txt")
|
||||
await ws.execute("echo new > /m/b.txt")
|
||||
c2 = await commit(store, ws, message="second")
|
||||
|
||||
diff = await version_diff(store, c1, c2)
|
||||
assert diff["modified"] == ["m/a.txt"]
|
||||
assert diff["added"] == ["m/b.txt"]
|
||||
assert META_PATH not in diff["modified"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diff_live_vs_ref_reports_changes_against_version(tmp_path):
|
||||
ws = Workspace({"/m": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
store = await VersionStore.open(LocalBackend(tmp_path), "ws")
|
||||
await ws.execute("echo one > /m/a.txt")
|
||||
c1 = await commit(store, ws, branch="main", message="first")
|
||||
await ws.execute("echo two > /m/a.txt")
|
||||
await ws.execute("echo new > /m/b.txt")
|
||||
|
||||
by_oid = await diff_live_vs_ref(store, await to_state_dict(ws), c1)
|
||||
assert by_oid["modified"] == ["m/a.txt"]
|
||||
assert by_oid["added"] == ["m/b.txt"]
|
||||
|
||||
by_branch = await diff_live_vs_ref(store, await to_state_dict(ws), "main")
|
||||
assert by_branch == by_oid
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_reports_uncommitted_changes(tmp_path):
|
||||
ws = Workspace({"/m": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
store = await VersionStore.open(LocalBackend(tmp_path), "ws")
|
||||
await ws.execute("echo one > /m/a.txt")
|
||||
await commit(store, ws, message="first")
|
||||
await ws.execute("echo changed > /m/a.txt")
|
||||
|
||||
st = await status(store, ws, "main")
|
||||
assert st["modified"] == ["m/a.txt"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diff_ignores_cache_churn(tmp_path):
|
||||
ws = Workspace({"/m": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
store = await VersionStore.open(LocalBackend(tmp_path), "ws")
|
||||
await ws.execute("echo one > /m/a.txt")
|
||||
|
||||
s1 = await to_state_dict(ws)
|
||||
s1[StateKey.CACHE][CacheKey.ENTRIES] = [_cache_entry(b"AAA")]
|
||||
c1 = await commit_state(store, s1, message="first")
|
||||
|
||||
s2 = await to_state_dict(ws)
|
||||
s2[StateKey.CACHE][CacheKey.ENTRIES] = [_cache_entry(b"BBB")]
|
||||
c2 = await commit_state(store, s2, message="second")
|
||||
|
||||
assert await version_diff(store, c1, c2) == {
|
||||
"added": [],
|
||||
"modified": [],
|
||||
"deleted": [],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_state_reports_uncommitted_changes(tmp_path):
|
||||
ws = Workspace({"/m": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
store = await VersionStore.open(LocalBackend(tmp_path), "ws")
|
||||
await ws.execute("echo one > /m/a.txt")
|
||||
await commit(store, ws, message="first")
|
||||
await ws.execute("echo changed > /m/a.txt")
|
||||
|
||||
st = await status_state(store, await to_state_dict(ws), "main")
|
||||
assert st["modified"] == ["m/a.txt"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_state_no_commit_yet_lists_all_as_added(tmp_path):
|
||||
ws = Workspace({"/m": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
store = await VersionStore.open(LocalBackend(tmp_path), "ws")
|
||||
await ws.execute("echo one > /m/a.txt")
|
||||
|
||||
st = await status_state(store, await to_state_dict(ws), "main")
|
||||
assert st == {"added": ["m/a.txt"], "modified": [], "deleted": []}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_ignores_cache_churn(tmp_path):
|
||||
ws = Workspace({"/m": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
store = await VersionStore.open(LocalBackend(tmp_path), "ws")
|
||||
await ws.execute("echo one > /m/a.txt")
|
||||
|
||||
s1 = await to_state_dict(ws)
|
||||
s1[StateKey.CACHE][CacheKey.ENTRIES] = [_cache_entry(b"AAA")]
|
||||
await commit_state(store, s1, message="first")
|
||||
|
||||
live = await to_state_dict(ws)
|
||||
live[StateKey.CACHE][CacheKey.ENTRIES] = [_cache_entry(b"BBB")]
|
||||
st = await status_state(store, live, "main")
|
||||
|
||||
assert st == {"added": [], "modified": [], "deleted": []}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_ref_branch_and_oid(tmp_path):
|
||||
ws = Workspace({"/m": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
store = await VersionStore.open(LocalBackend(tmp_path), "ws")
|
||||
await ws.execute("echo one > /m/a.txt")
|
||||
c1 = await commit(store, ws, branch="main", message="first")
|
||||
|
||||
assert await resolve_ref(store, "main") == c1
|
||||
assert await resolve_ref(store, c1) == c1
|
||||
assert await resolve_ref(store, c1.decode()) == c1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commit_state_creates_version_from_state(tmp_path):
|
||||
ws = Workspace({"/m": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
store = await VersionStore.open(LocalBackend(tmp_path), "ws")
|
||||
await ws.execute("echo hi > /m/a.txt")
|
||||
|
||||
version = await commit_state(store,
|
||||
await to_state_dict(ws),
|
||||
branch="main",
|
||||
message="from state")
|
||||
|
||||
entries, _ = await read_version(store, version)
|
||||
assert entries["m/a.txt"] == b"hi\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commit_to_unknown_branch_errors(tmp_path):
|
||||
ws = Workspace({"/m": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
store = await VersionStore.open(LocalBackend(tmp_path), "ws")
|
||||
await ws.execute("echo one > /m/a.txt")
|
||||
await commit(store, ws, branch="main", message="first")
|
||||
|
||||
with pytest.raises(NoSuchBranchError):
|
||||
await commit(store, ws, branch="exp", message="oops")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commit_diverges_after_branch_created(tmp_path):
|
||||
ws = Workspace({"/m": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
store = await VersionStore.open(LocalBackend(tmp_path), "ws")
|
||||
await ws.execute("echo one > /m/a.txt")
|
||||
main_head = await commit(store, ws, branch="main", message="first")
|
||||
|
||||
await branch(store, "exp", from_branch="main")
|
||||
await ws.execute("echo two > /m/a.txt")
|
||||
exp_head = await commit(store, ws, branch="exp", message="on exp")
|
||||
|
||||
assert (await store.read_commit(exp_head)).parents == [main_head]
|
||||
assert await store.head("main") == main_head
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_branch_creates_line_at_current(tmp_path):
|
||||
ws = Workspace({"/m": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
store = await VersionStore.open(LocalBackend(tmp_path), "ws")
|
||||
await ws.execute("echo one > /m/a.txt")
|
||||
c1 = await commit(store, ws, branch="main", message="first")
|
||||
|
||||
await branch(store, "exp", from_branch="main")
|
||||
|
||||
assert await store.head("exp") == c1
|
||||
assert "exp" in await store.branches()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_version_reads_back_files_and_meta(tmp_path):
|
||||
ws = Workspace({"/m": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
store = await VersionStore.open(LocalBackend(tmp_path), "ws")
|
||||
await ws.execute("echo hello > /m/a.txt")
|
||||
version = await commit(store, ws, message="first")
|
||||
|
||||
entries, meta = await read_version(store, version)
|
||||
|
||||
assert entries["m/a.txt"] == b"hello\n"
|
||||
assert META_PATH not in entries
|
||||
assert "/m/" in [m["prefix"] for m in meta["mounts"]]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_checkout_rebuilds_content_in_place(tmp_path):
|
||||
ws = Workspace({"/m": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
store = await VersionStore.open(LocalBackend(tmp_path), "ws")
|
||||
await ws.execute("echo original > /m/a.txt")
|
||||
await commit(store, ws, branch="main", message="first")
|
||||
|
||||
await ws.execute("echo mutated > /m/a.txt")
|
||||
await ws.execute("echo extra > /m/b.txt")
|
||||
|
||||
await checkout(store, ws, "main")
|
||||
|
||||
result = await ws.execute("cat /m/a.txt")
|
||||
assert (await result.stdout_str()) == "original\n"
|
||||
assert await status(store, ws, "main") == {
|
||||
"added": [],
|
||||
"modified": [],
|
||||
"deleted": [],
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from dulwich.objects import Blob
|
||||
|
||||
from mirage.server.version.backend import LocalBackend
|
||||
|
||||
|
||||
def test_open_repo_creates_bare_repo_under_workspace_id(tmp_path: Path):
|
||||
backend = LocalBackend(tmp_path)
|
||||
backend.open_repo("ws1")
|
||||
assert (tmp_path / "ws1" / "objects").is_dir()
|
||||
assert (tmp_path / "ws1" / "HEAD").exists()
|
||||
|
||||
|
||||
def test_open_repo_reuses_existing(tmp_path: Path):
|
||||
backend = LocalBackend(tmp_path)
|
||||
repo1 = backend.open_repo("ws1")
|
||||
blob = Blob.from_string(b"x")
|
||||
repo1.object_store.add_object(blob)
|
||||
repo2 = backend.open_repo("ws1")
|
||||
assert blob.id in repo2.object_store
|
||||
|
||||
|
||||
def test_distinct_workspace_ids_are_isolated(tmp_path: Path):
|
||||
backend = LocalBackend(tmp_path)
|
||||
repo_a = backend.open_repo("a")
|
||||
repo_b = backend.open_repo("b")
|
||||
blob = Blob.from_string(b"only-in-a")
|
||||
repo_a.object_store.add_object(blob)
|
||||
assert blob.id in repo_a.object_store
|
||||
assert blob.id not in repo_b.object_store
|
||||
@@ -0,0 +1,204 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import io
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.server.version.state_tree import (blob_to_meta, meta_to_blob,
|
||||
to_state, to_tree_inputs,
|
||||
tree_inputs_from_state)
|
||||
from mirage.types import (CacheKey, FingerprintKey, MountKey, MountMode,
|
||||
SessionKey, StateKey)
|
||||
from mirage.workspace import Workspace
|
||||
from mirage.workspace.snapshot.manifest import split_manifest_and_blobs
|
||||
from mirage.workspace.snapshot.state import to_state_dict
|
||||
from mirage.workspace.snapshot.tar_io import read_tar, write_tar
|
||||
|
||||
|
||||
def _mount_files(state: dict, prefix: str) -> dict:
|
||||
for mount in state["mounts"]:
|
||||
if mount["prefix"] == prefix:
|
||||
return mount["resource_state"]["files"]
|
||||
raise KeyError(prefix)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_to_tree_inputs_ram_files():
|
||||
ws = Workspace({"/m": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
await ws.execute("echo hello > /m/a.txt")
|
||||
await ws.execute("mkdir -p /m/sub && echo world > /m/sub/b.txt")
|
||||
|
||||
entries, meta = await to_tree_inputs(ws)
|
||||
|
||||
assert entries["m/a.txt"] == b"hello\n"
|
||||
assert entries["m/sub/b.txt"] == b"world\n"
|
||||
|
||||
prefixes = [m[MountKey.PREFIX] for m in meta["mounts"]]
|
||||
assert "/m/" in prefixes
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_to_state_round_trips_files():
|
||||
ws = Workspace({"/m": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
await ws.execute("echo hello > /m/a.txt")
|
||||
await ws.execute("mkdir -p /m/sub && echo world > /m/sub/b.txt")
|
||||
|
||||
original_files = _mount_files(await to_state_dict(ws), "/m/")
|
||||
entries, meta = await to_tree_inputs(ws)
|
||||
state = to_state(entries, meta)
|
||||
|
||||
assert _mount_files(state, "/m/") == original_files
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tree_inputs_from_state_matches_ws_path():
|
||||
ws = Workspace({"/m": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
await ws.execute("echo hi > /m/a.txt")
|
||||
|
||||
entries_ws, _ = await to_tree_inputs(ws)
|
||||
entries_state, _ = tree_inputs_from_state(await to_state_dict(ws))
|
||||
|
||||
assert entries_ws == entries_state
|
||||
assert entries_state["m/a.txt"] == b"hi\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_to_state_is_tar_loadable():
|
||||
ws = Workspace({"/m": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
await ws.execute("echo hello > /m/a.txt")
|
||||
|
||||
entries, meta = await to_tree_inputs(ws)
|
||||
state = to_state(entries, meta)
|
||||
|
||||
manifest, blobs = split_manifest_and_blobs(state)
|
||||
buf = io.BytesIO()
|
||||
write_tar(buf, manifest, blobs)
|
||||
buf.seek(0)
|
||||
restored = read_tar(buf)
|
||||
|
||||
assert _mount_files(restored, "/m/")["/a.txt"] == b"hello\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_fingerprints_sessions_round_trip():
|
||||
ws = Workspace({"/": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
await ws.execute("echo hi > /a.txt")
|
||||
state = await to_state_dict(ws)
|
||||
state[StateKey.CACHE][CacheKey.ENTRIES] = [{
|
||||
CacheKey.KEY: "/a.txt",
|
||||
CacheKey.DATA: b"cached-bytes",
|
||||
CacheKey.FINGERPRINT: "etag-1",
|
||||
CacheKey.TTL: None,
|
||||
CacheKey.CACHED_AT: 123.0,
|
||||
CacheKey.SIZE: 12,
|
||||
}]
|
||||
state[StateKey.FINGERPRINTS] = [{
|
||||
FingerprintKey.PATH: "/a.txt",
|
||||
FingerprintKey.MOUNT_PREFIX: "/",
|
||||
FingerprintKey.FINGERPRINT: "etag-1",
|
||||
FingerprintKey.REVISION: "v1",
|
||||
}]
|
||||
state[StateKey.SESSIONS] = [{
|
||||
SessionKey.SESSION_ID: "agent_a",
|
||||
SessionKey.CWD: "/sub",
|
||||
SessionKey.ENV: {
|
||||
"FOO": "bar"
|
||||
},
|
||||
}]
|
||||
|
||||
entries, meta = tree_inputs_from_state(state)
|
||||
meta = blob_to_meta(meta_to_blob(meta))
|
||||
restored = to_state(entries, meta)
|
||||
|
||||
cache_entries = restored[StateKey.CACHE][CacheKey.ENTRIES]
|
||||
assert len(cache_entries) == 1
|
||||
assert cache_entries[0][CacheKey.DATA] == b"cached-bytes"
|
||||
assert cache_entries[0][CacheKey.KEY] == "/a.txt"
|
||||
assert restored[StateKey.FINGERPRINTS][0][FingerprintKey.REVISION] == "v1"
|
||||
assert restored[StateKey.SESSIONS][0][SessionKey.CWD] == "/sub"
|
||||
assert restored[StateKey.SESSIONS][0][SessionKey.ENV] == {"FOO": "bar"}
|
||||
|
||||
files = _mount_files(restored, "/")
|
||||
assert files["/a.txt"] == b"hi\n"
|
||||
assert all(".mirage-cache" not in k for k in files)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_and_pins_survive_tar():
|
||||
ws = Workspace({"/": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
await ws.execute("echo hi > /a.txt")
|
||||
state = await to_state_dict(ws)
|
||||
state[StateKey.CACHE][CacheKey.ENTRIES] = [{
|
||||
CacheKey.KEY: "/a.txt",
|
||||
CacheKey.DATA: b"cached-bytes",
|
||||
CacheKey.FINGERPRINT: "etag-1",
|
||||
CacheKey.TTL: None,
|
||||
CacheKey.CACHED_AT: 1.0,
|
||||
CacheKey.SIZE: 12,
|
||||
}]
|
||||
state[StateKey.FINGERPRINTS] = [{
|
||||
FingerprintKey.PATH: "/a.txt",
|
||||
FingerprintKey.MOUNT_PREFIX: "/",
|
||||
FingerprintKey.REVISION: "v1",
|
||||
}]
|
||||
state[StateKey.SESSIONS] = [{
|
||||
SessionKey.SESSION_ID: "agent_a",
|
||||
SessionKey.CWD: "/sub",
|
||||
SessionKey.ENV: {
|
||||
"FOO": "bar"
|
||||
},
|
||||
}]
|
||||
|
||||
entries, meta = tree_inputs_from_state(state)
|
||||
meta = blob_to_meta(meta_to_blob(meta))
|
||||
rebuilt = to_state(entries, meta)
|
||||
|
||||
manifest, blobs = split_manifest_and_blobs(rebuilt)
|
||||
buf = io.BytesIO()
|
||||
write_tar(buf, manifest, blobs)
|
||||
buf.seek(0)
|
||||
restored = read_tar(buf)
|
||||
|
||||
ce = restored[StateKey.CACHE][CacheKey.ENTRIES]
|
||||
assert ce[0][CacheKey.DATA] == b"cached-bytes"
|
||||
assert restored[StateKey.FINGERPRINTS][0][FingerprintKey.REVISION] == "v1"
|
||||
sessions = {
|
||||
s[SessionKey.SESSION_ID]: s
|
||||
for s in restored[StateKey.SESSIONS]
|
||||
}
|
||||
assert sessions["agent_a"][SessionKey.CWD] == "/sub"
|
||||
|
||||
|
||||
def test_meta_blob_round_trip():
|
||||
meta = {
|
||||
"mounts": [],
|
||||
"pins": {
|
||||
"/s3/a.txt": {
|
||||
"rev": "v123",
|
||||
"fp": "etag-abc"
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
parsed = blob_to_meta(meta_to_blob(meta))
|
||||
|
||||
assert parsed["pins"]["/s3/a.txt"] == {"rev": "v123", "fp": "etag-abc"}
|
||||
@@ -0,0 +1,158 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.server.version.backend import LocalBackend
|
||||
from mirage.server.version.errors import HeadMovedError
|
||||
from mirage.server.version.store import VersionStore
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_creates_bare_repo(tmp_path: Path):
|
||||
store = await VersionStore.open(LocalBackend(tmp_path), "ws")
|
||||
assert store is not None
|
||||
assert (tmp_path / "ws" / "objects").is_dir()
|
||||
assert (tmp_path / "ws" / "HEAD").is_file()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_reuses_existing_repo(tmp_path: Path):
|
||||
backend = LocalBackend(tmp_path)
|
||||
await VersionStore.open(backend, "ws")
|
||||
reopened = await VersionStore.open(backend, "ws")
|
||||
assert reopened is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_blob_roundtrip(tmp_path: Path):
|
||||
store = await VersionStore.open(LocalBackend(tmp_path), "ws")
|
||||
oid = await store.write_blob(b"hello")
|
||||
assert await store.read_blob(oid) == b"hello"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_identical_blobs_dedup(tmp_path: Path):
|
||||
store = await VersionStore.open(LocalBackend(tmp_path), "ws")
|
||||
first = await store.write_blob(b"same-bytes")
|
||||
second = await store.write_blob(b"same-bytes")
|
||||
assert first == second
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tree_roundtrip(tmp_path: Path):
|
||||
store = await VersionStore.open(LocalBackend(tmp_path), "ws")
|
||||
a = await store.write_blob(b"aaa")
|
||||
b = await store.write_blob(b"bbb")
|
||||
tree = await store.write_tree({"a.txt": a, "dir/b.txt": b})
|
||||
assert await store.read_tree(tree) == {"a.txt": a, "dir/b.txt": b}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tree_nested_paths(tmp_path: Path):
|
||||
store = await VersionStore.open(LocalBackend(tmp_path), "ws")
|
||||
x = await store.write_blob(b"x")
|
||||
y = await store.write_blob(b"y")
|
||||
z = await store.write_blob(b"z")
|
||||
tree = await store.write_tree({
|
||||
"top.txt": x,
|
||||
"d/one.txt": y,
|
||||
"d/sub/two.txt": z,
|
||||
})
|
||||
assert await store.read_tree(tree) == {
|
||||
"top.txt": x,
|
||||
"d/one.txt": y,
|
||||
"d/sub/two.txt": z,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commit_advances_branch(tmp_path: Path):
|
||||
store = await VersionStore.open(LocalBackend(tmp_path), "ws")
|
||||
t1 = await store.write_tree({"a.txt": await store.write_blob(b"v1")})
|
||||
c1 = await store.commit(t1, parents=[], branch="main", message="first")
|
||||
assert await store.head("main") == c1
|
||||
|
||||
t2 = await store.write_tree({"a.txt": await store.write_blob(b"v2")})
|
||||
c2 = await store.commit(t2, parents=[c1], branch="main", message="second")
|
||||
assert await store.head("main") == c2
|
||||
|
||||
commit = await store.read_commit(c2)
|
||||
assert commit.parents == [c1]
|
||||
assert commit.message == b"second"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commit_rejects_stale_head(tmp_path: Path):
|
||||
store = await VersionStore.open(LocalBackend(tmp_path), "ws")
|
||||
c1 = await store.commit(await store.write_tree(
|
||||
{"a.txt": await store.write_blob(b"v1")}),
|
||||
parents=[],
|
||||
branch="main",
|
||||
message="first")
|
||||
c2 = await store.commit(await store.write_tree(
|
||||
{"a.txt": await store.write_blob(b"v2")}),
|
||||
parents=[c1],
|
||||
branch="main",
|
||||
message="second")
|
||||
assert await store.head("main") == c2
|
||||
|
||||
with pytest.raises(HeadMovedError):
|
||||
await store.commit(await store.write_tree(
|
||||
{"a.txt": await store.write_blob(b"v3")}),
|
||||
parents=[c1],
|
||||
branch="main",
|
||||
message="stale")
|
||||
assert await store.head("main") == c2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_branches_and_log(tmp_path: Path):
|
||||
store = await VersionStore.open(LocalBackend(tmp_path), "ws")
|
||||
t1 = await store.write_tree({"a.txt": await store.write_blob(b"v1")})
|
||||
c1 = await store.commit(t1, parents=[], branch="main", message="first")
|
||||
t2 = await store.write_tree({"a.txt": await store.write_blob(b"v2")})
|
||||
c2 = await store.commit(t2, parents=[c1], branch="main", message="second")
|
||||
|
||||
assert await store.branches() == ["main"]
|
||||
assert await store.log("main") == [c2, c1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tree_diff(tmp_path: Path):
|
||||
store = await VersionStore.open(LocalBackend(tmp_path), "ws")
|
||||
keep = await store.write_blob(b"keep")
|
||||
before = await store.write_blob(b"before")
|
||||
after = await store.write_blob(b"after")
|
||||
gone = await store.write_blob(b"gone")
|
||||
new = await store.write_blob(b"new")
|
||||
|
||||
tree_a = await store.write_tree({
|
||||
"keep.txt": keep,
|
||||
"change.txt": before,
|
||||
"gone.txt": gone,
|
||||
})
|
||||
tree_b = await store.write_tree({
|
||||
"keep.txt": keep,
|
||||
"change.txt": after,
|
||||
"new.txt": new,
|
||||
})
|
||||
|
||||
assert await store.diff(tree_a, tree_b) == {
|
||||
"added": ["new.txt"],
|
||||
"modified": ["change.txt"],
|
||||
"deleted": ["gone.txt"],
|
||||
}
|
||||
Reference in New Issue
Block a user