|
| 1 | +"""Tests for the S3 sync (SPEC Phase E + ADR-0001). |
| 2 | +
|
| 3 | +Uses moto's in-process S3 mock so the tests run offline + don't |
| 4 | +need real AWS credentials. Per ADR-0001, the function refuses to |
| 5 | +upload without explicit auth — we set fake AWS creds via monkeypatch |
| 6 | +to satisfy that gate. |
| 7 | +""" |
| 8 | + |
| 9 | +from __future__ import annotations |
| 10 | + |
| 11 | +import pytest |
| 12 | + |
| 13 | +# moto[s3] required; declared in [dev] extras |
| 14 | +moto = pytest.importorskip("moto") |
| 15 | +boto3 = pytest.importorskip("boto3") |
| 16 | + |
| 17 | +from ai_config_kit import ClaudeConfig, ConfigError, S3SyncReport # noqa: E402 |
| 18 | + |
| 19 | + |
| 20 | +@pytest.fixture |
| 21 | +def mock_aws_creds(monkeypatch: pytest.MonkeyPatch) -> None: |
| 22 | + """Set fake AWS env vars so the auth gate passes.""" |
| 23 | + monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing") |
| 24 | + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing") |
| 25 | + monkeypatch.setenv("AWS_SESSION_TOKEN", "testing") |
| 26 | + monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1") |
| 27 | + monkeypatch.delenv("AWS_PROFILE", raising=False) |
| 28 | + |
| 29 | + |
| 30 | +# --- auth-gate path ------------------------------------------------------ |
| 31 | + |
| 32 | + |
| 33 | +def test_sync_to_s3_refuses_without_auth( |
| 34 | + cfg: ClaudeConfig, monkeypatch: pytest.MonkeyPatch |
| 35 | +) -> None: |
| 36 | + monkeypatch.delenv("AWS_PROFILE", raising=False) |
| 37 | + monkeypatch.delenv("AWS_ACCESS_KEY_ID", raising=False) |
| 38 | + monkeypatch.delenv("AWS_SECRET_ACCESS_KEY", raising=False) |
| 39 | + with pytest.raises(ConfigError, match="no AWS auth"): |
| 40 | + cfg.sync_to_s3("s3://bucket/path", dry_run=True) |
| 41 | + |
| 42 | + |
| 43 | +def test_sync_to_s3_refuses_non_s3_target( |
| 44 | + cfg: ClaudeConfig, mock_aws_creds: None |
| 45 | +) -> None: |
| 46 | + with pytest.raises(ConfigError, match="must be s3"): |
| 47 | + cfg.sync_to_s3("https://example.com/", dry_run=True) |
| 48 | + |
| 49 | + |
| 50 | +def test_sync_to_s3_refuses_target_without_bucket( |
| 51 | + cfg: ClaudeConfig, mock_aws_creds: None |
| 52 | +) -> None: |
| 53 | + with pytest.raises(ConfigError, match="missing bucket"): |
| 54 | + cfg.sync_to_s3("s3:///key-only", dry_run=True) |
| 55 | + |
| 56 | + |
| 57 | +# --- dry-run + actual upload via moto ------------------------------------ |
| 58 | + |
| 59 | + |
| 60 | +def test_sync_to_s3_dry_run_no_upload( |
| 61 | + cfg: ClaudeConfig, mock_aws_creds: None |
| 62 | +) -> None: |
| 63 | + cfg.src_dir.mkdir(parents=True, exist_ok=True) |
| 64 | + (cfg.src_dir / "CLAUDE.md").write_text("test content\n", encoding="utf-8") |
| 65 | + r = cfg.sync_to_s3("s3://my-bucket/prefix", dry_run=True) |
| 66 | + assert r.dry_run |
| 67 | + assert r.uploaded |
| 68 | + assert r.bytes_transferred == 0 # dry-run uploaded nothing |
| 69 | + |
| 70 | + |
| 71 | +def test_sync_to_s3_real_upload_via_moto( |
| 72 | + cfg: ClaudeConfig, mock_aws_creds: None |
| 73 | +) -> None: |
| 74 | + from moto import mock_aws |
| 75 | + |
| 76 | + cfg.src_dir.mkdir(parents=True, exist_ok=True) |
| 77 | + (cfg.src_dir / "CLAUDE.md").write_text("a CLAUDE.md\n", encoding="utf-8") |
| 78 | + (cfg.src_dir / "commands").mkdir(exist_ok=True) |
| 79 | + (cfg.src_dir / "commands" / "demo.md").write_text("a command\n", encoding="utf-8") |
| 80 | + |
| 81 | + with mock_aws(): |
| 82 | + s3 = boto3.client("s3", region_name="us-east-1") |
| 83 | + s3.create_bucket(Bucket="my-bucket") |
| 84 | + |
| 85 | + r = cfg.sync_to_s3("s3://my-bucket/prefix", dry_run=False) |
| 86 | + |
| 87 | + # Report: both files uploaded, bytes > 0 |
| 88 | + assert isinstance(r, S3SyncReport) |
| 89 | + assert not r.dry_run |
| 90 | + assert sorted(r.uploaded) == ["CLAUDE.md", "commands/demo.md"] |
| 91 | + assert r.bytes_transferred > 0 |
| 92 | + |
| 93 | + # And actually present in the (mocked) bucket. |
| 94 | + listed = { |
| 95 | + obj["Key"] for obj in s3.list_objects_v2(Bucket="my-bucket").get("Contents", []) |
| 96 | + } |
| 97 | + assert "prefix/CLAUDE.md" in listed |
| 98 | + assert "prefix/commands/demo.md" in listed |
| 99 | + |
| 100 | + |
| 101 | +def test_sync_to_s3_root_no_prefix( |
| 102 | + cfg: ClaudeConfig, mock_aws_creds: None |
| 103 | +) -> None: |
| 104 | + """Target without a trailing key-prefix lands files at bucket root.""" |
| 105 | + from moto import mock_aws |
| 106 | + |
| 107 | + cfg.src_dir.mkdir(parents=True, exist_ok=True) |
| 108 | + (cfg.src_dir / "CLAUDE.md").write_text("x", encoding="utf-8") |
| 109 | + |
| 110 | + with mock_aws(): |
| 111 | + s3 = boto3.client("s3", region_name="us-east-1") |
| 112 | + s3.create_bucket(Bucket="test-bucket") |
| 113 | + cfg.sync_to_s3("s3://test-bucket", dry_run=False) |
| 114 | + keys = { |
| 115 | + obj["Key"] for obj in s3.list_objects_v2(Bucket="test-bucket").get("Contents", []) |
| 116 | + } |
| 117 | + assert "CLAUDE.md" in keys |
| 118 | + assert all("prefix" not in k for k in keys) |
| 119 | + |
| 120 | + |
| 121 | +def test_sync_to_s3_filters_secret_files( |
| 122 | + cfg: ClaudeConfig, mock_aws_creds: None |
| 123 | +) -> None: |
| 124 | + """Files matching the secret patterns are NEVER uploaded.""" |
| 125 | + from moto import mock_aws |
| 126 | + |
| 127 | + cfg.src_dir.mkdir(parents=True, exist_ok=True) |
| 128 | + (cfg.src_dir / "CLAUDE.md").write_text("safe", encoding="utf-8") |
| 129 | + (cfg.src_dir / ".env").write_text("API_KEY=hunter2", encoding="utf-8") |
| 130 | + (cfg.src_dir / ".credentials.json").write_text('{"token":"x"}', encoding="utf-8") |
| 131 | + |
| 132 | + with mock_aws(): |
| 133 | + s3 = boto3.client("s3", region_name="us-east-1") |
| 134 | + s3.create_bucket(Bucket="test-bucket") |
| 135 | + r = cfg.sync_to_s3("s3://test-bucket/cfg", dry_run=False) |
| 136 | + |
| 137 | + assert "CLAUDE.md" in r.uploaded |
| 138 | + assert ".env" not in r.uploaded |
| 139 | + assert ".credentials.json" not in r.uploaded |
| 140 | + assert any(".env" in s for s in r.skipped_secrets) |
| 141 | + assert any("credentials" in s for s in r.skipped_secrets) |
| 142 | + |
| 143 | + listed = { |
| 144 | + obj["Key"] for obj in s3.list_objects_v2(Bucket="test-bucket").get("Contents", []) |
| 145 | + } |
| 146 | + # Confirm the bucket only has the safe file (under the prefix). |
| 147 | + assert listed == {"cfg/CLAUDE.md"} |
| 148 | + |
| 149 | + |
| 150 | +def test_sync_to_s3_endpoint_url_passes_through( |
| 151 | + cfg: ClaudeConfig, mock_aws_creds: None, monkeypatch: pytest.MonkeyPatch |
| 152 | +) -> None: |
| 153 | + """endpoint_url is forwarded to boto3.Session.client(...). |
| 154 | +
|
| 155 | + Moto only intercepts AWS-style endpoints, so we can't end-to-end |
| 156 | + test a non-AWS upload without a separate mock. Instead, capture |
| 157 | + the kwargs handed to `client()` and assert endpoint_url is wired. |
| 158 | + """ |
| 159 | + captured: dict[str, object] = {} |
| 160 | + |
| 161 | + real_session_client = boto3.Session.client |
| 162 | + |
| 163 | + def fake_client(self, service_name, **kwargs): # type: ignore[no-untyped-def] |
| 164 | + captured["service_name"] = service_name |
| 165 | + captured["endpoint_url"] = kwargs.get("endpoint_url") |
| 166 | + return real_session_client(self, service_name, **kwargs) |
| 167 | + |
| 168 | + monkeypatch.setattr(boto3.Session, "client", fake_client) |
| 169 | + |
| 170 | + cfg.src_dir.mkdir(parents=True, exist_ok=True) |
| 171 | + (cfg.src_dir / "CLAUDE.md").write_text("x", encoding="utf-8") |
| 172 | + |
| 173 | + # Dry-run so we don't need to mock the actual upload. |
| 174 | + cfg.sync_to_s3( |
| 175 | + "s3://test-bucket/k", |
| 176 | + endpoint_url="https://minio.example.com", |
| 177 | + dry_run=True, |
| 178 | + ) |
| 179 | + assert captured["service_name"] == "s3" |
| 180 | + assert captured["endpoint_url"] == "https://minio.example.com" |
| 181 | + |
| 182 | + |
| 183 | +def test_sync_to_s3_audit_event_recorded( |
| 184 | + cfg: ClaudeConfig, mock_aws_creds: None |
| 185 | +) -> None: |
| 186 | + """A real upload emits a sync_to_s3 audit event.""" |
| 187 | + import json as _json |
| 188 | + |
| 189 | + from moto import mock_aws |
| 190 | + |
| 191 | + cfg.src_dir.mkdir(parents=True, exist_ok=True) |
| 192 | + (cfg.src_dir / "CLAUDE.md").write_text("x", encoding="utf-8") |
| 193 | + |
| 194 | + with mock_aws(): |
| 195 | + s3 = boto3.client("s3", region_name="us-east-1") |
| 196 | + s3.create_bucket(Bucket="test-bucket") |
| 197 | + cfg.sync_to_s3("s3://test-bucket/k", dry_run=False) |
| 198 | + |
| 199 | + log = cfg.audit_log_path |
| 200 | + assert log.is_file() |
| 201 | + lines = [_json.loads(line) for line in log.read_text(encoding="utf-8").splitlines() if line] |
| 202 | + events = [r for r in lines if r["event"] == "sync_to_s3"] |
| 203 | + assert events |
| 204 | + e = events[-1] |
| 205 | + assert e["target"] == "s3://test-bucket/k" |
| 206 | + assert e["files"] == 1 |
0 commit comments