From 6bbc2b749cbfeaf1644fba4ecf9934cf65da0529 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 13:29:31 +0000 Subject: [PATCH] Add spec-driven test suite and run it in CI Implements the testing strategy from issue #1: - spec-driven tests for each core feature (crypto, capsule, draft, ics, config, errors) exercised through their public api - cli command tests assert the final printed output with the core mocked - 90% coverage gate (suite currently sits at ~99%) - a `tests` workflow runs pytest across python 3.10-3.13 on push and PR Test config and the gate live in pyproject; pytest/pytest-cov are added to the dev extra. Tests bind to public contracts and the documented .mcap wire format, not to private helpers, so refactors stay free. Closes #1 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ARYv6iMTZ7UiZnbUL1LnJC --- .github/workflows/tests.yml | 29 +++ .gitignore | 5 + README.md | 13 ++ pyproject.toml | 25 ++- tests/commands/test_capsule_commands.py | 246 ++++++++++++++++++++++ tests/commands/test_config_and_version.py | 76 +++++++ tests/commands/test_draft_commands.py | 152 +++++++++++++ tests/conftest.py | 73 +++++++ tests/core/test_capsule.py | 187 ++++++++++++++++ tests/core/test_config.py | 68 ++++++ tests/core/test_crypto.py | 79 +++++++ tests/core/test_draft.py | 104 +++++++++ tests/core/test_errors.py | 23 ++ tests/core/test_ics.py | 70 ++++++ tests/core/test_util.py | 82 ++++++++ tests/test_cli.py | 66 ++++++ tests/test_style.py | 32 +++ 17 files changed, 1329 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/tests.yml create mode 100644 tests/commands/test_capsule_commands.py create mode 100644 tests/commands/test_config_and_version.py create mode 100644 tests/commands/test_draft_commands.py create mode 100644 tests/conftest.py create mode 100644 tests/core/test_capsule.py create mode 100644 tests/core/test_config.py create mode 100644 tests/core/test_crypto.py create mode 100644 tests/core/test_draft.py create mode 100644 tests/core/test_errors.py create mode 100644 tests/core/test_ics.py create mode 100644 tests/core/test_util.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_style.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..add069a --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,29 @@ +name: tests + +# runs the pytest suite (with its 90% coverage gate) on every push and PR, +# across the python versions the package declares support for. +on: + push: + branches: ["**"] + pull_request: + +jobs: + pytest: + name: pytest (py${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: install package with test deps + run: python -m pip install --upgrade pip && pip install -e ".[dev]" + + - name: run tests + run: pytest diff --git a/.gitignore b/.gitignore index f6b32fb..c9f97db 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,8 @@ env/ # os .DS_Store + +# test / coverage artifacts +.coverage +htmlcov/ +.pytest_cache/ diff --git a/README.md b/README.md index c102c9b..01510c7 100644 --- a/README.md +++ b/README.md @@ -150,3 +150,16 @@ magicicapsula config unset # remove from the config file absolute `YYYY-MM-DD` or `YYYY-MM-DDTHH:MM` (`2030-01-01`, `2030-01-01T08:00`), or relative from now: `+30d`, `+2w`, `+6m`, `+1y`. +## development + +``` +pip install -e ".[dev]" # installs ruff + pytest +ruff check . && ruff format --check . +pytest # runs the suite with a 90% coverage gate +``` + +tests are spec-driven: each core feature (`crypto`, `capsule`, `draft`, +`ics`, `config`) is exercised through its public api, and the command layer +is tested by asserting the printed output with the core mocked out. lint and +tests both run in CI on every push and pull request. + diff --git a/pyproject.toml b/pyproject.toml index e651c92..970d1da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ Repository = "https://github.com/iDavi/magicicapsula" Issues = "https://github.com/iDavi/magicicapsula/issues" [project.optional-dependencies] -dev = ["ruff>=0.15"] +dev = ["ruff>=0.15", "pytest>=8", "pytest-cov>=5"] [project.scripts] magicicapsula = "magicicapsula.cli:main" @@ -81,3 +81,26 @@ ignore = [ [tool.ruff.lint.flake8-bugbear] # KdfParams is a frozen (immutable) dataclass, so it's safe as a default. extend-immutable-calls = ["magicicapsula.core.crypto.KdfParams"] + +[tool.ruff.lint.per-file-ignores] +# tests lean on private helpers, asserts, mock lambdas, and inline literals; +# the strict ruleset is aimed at the package, not the test scaffolding. +"tests/**" = [ + "S101", # asserts are the point of a test + "SLF001", # tests may touch private helpers + "PLR2004", # magic numbers in assertions read fine + "PLW0108", # mock lambdas don't need to be named functions + "SIM115", # terse open().read() in an assert is fine + "C408", # dict(...) factories are readable here +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "--cov=magicicapsula --cov-report=term-missing --cov-fail-under=90" + +[tool.coverage.run] +branch = true +omit = ["magicicapsula/__main__.py"] + +[tool.coverage.report] +exclude_lines = ["pragma: no cover", "if __name__ == .__main__.:"] diff --git a/tests/commands/test_capsule_commands.py b/tests/commands/test_capsule_commands.py new file mode 100644 index 0000000..ecc9be2 --- /dev/null +++ b/tests/commands/test_capsule_commands.py @@ -0,0 +1,246 @@ +"""spec: seal/open/info/verify/remind print the right thing. + +per the testing strategy, these assert the final printed result with the core +calls mocked out — the command layer's job is wiring and presentation, so +that's exactly the contract under test here. +""" + +import json +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace + +import pytest + +from magicicapsula.commands import info as info_cmd +from magicicapsula.commands import open as open_cmd +from magicicapsula.commands import remind as remind_cmd +from magicicapsula.commands import seal as seal_cmd +from magicicapsula.commands import verify as verify_cmd + + +def _info(**kw): + base = dict( + created_at=datetime(2025, 1, 1, tzinfo=timezone.utc), + unlock_at=datetime(2030, 1, 1, tzinfo=timezone.utc), + cipher="aes-256-gcm", + note="", + ) + base.update(kw) + is_open = base.pop("_open", False) + ns = SimpleNamespace(**base) + ns.is_open = lambda now=None: is_open + ns.remaining = lambda now=None: timedelta(days=10) + return ns + + +# --- seal --------------------------------------------------------------- + + +def test_seal_writes_blob_and_reports(tmp_path, monkeypatch, capsys): + d = SimpleNamespace(unlock_at="2030-01-01T00:00:00", note="", out="capsule.mcap", staged=["/a"], root=str(tmp_path)) + monkeypatch.setattr(seal_cmd.draft, "load", lambda: d) + monkeypatch.setattr(seal_cmd.draft, "save", lambda d: None) + monkeypatch.setattr(seal_cmd.draft, "missing", lambda d: []) + monkeypatch.setattr(seal_cmd, "parse_unlock", lambda s: datetime(2030, 1, 1, tzinfo=timezone.utc)) + monkeypatch.setattr(seal_cmd, "ask_password", lambda confirm=False: "pw") + monkeypatch.setattr(seal_cmd.capsule, "seal", lambda *a, **k: b"BLOB") + + args = SimpleNamespace(unlock=None, note=None, out=None, force=False, no_password=False) + seal_cmd.run(args) + + out = capsys.readouterr().out + assert "sealed 1 item(s)" in out + assert (tmp_path / "capsule.mcap").read_bytes() == b"BLOB" + + +def test_seal_without_password_notes_it(tmp_path, monkeypatch, capsys): + d = SimpleNamespace(unlock_at="2030-01-01T00:00:00", note="", out="c.mcap", staged=["/a"], root=str(tmp_path)) + monkeypatch.setattr(seal_cmd.draft, "load", lambda: d) + monkeypatch.setattr(seal_cmd.draft, "save", lambda d: None) + monkeypatch.setattr(seal_cmd.draft, "missing", lambda d: []) + monkeypatch.setattr(seal_cmd, "parse_unlock", lambda s: datetime(2030, 1, 1, tzinfo=timezone.utc)) + monkeypatch.setattr(seal_cmd.capsule, "seal", lambda *a, **k: b"B") + + args = SimpleNamespace(unlock=None, note=None, out=None, force=False, no_password=True) + seal_cmd.run(args) + assert "anyone can open it" in capsys.readouterr().out + + +def test_seal_applies_flag_overrides(tmp_path, monkeypatch, capsys): + d = SimpleNamespace(unlock_at=None, note="", out="orig.mcap", staged=["/a"], root=str(tmp_path)) + monkeypatch.setattr(seal_cmd.draft, "load", lambda: d) + monkeypatch.setattr(seal_cmd.draft, "save", lambda d: None) + monkeypatch.setattr(seal_cmd.draft, "missing", lambda d: []) + monkeypatch.setattr(seal_cmd, "parse_unlock", lambda s: datetime(2030, 1, 1, tzinfo=timezone.utc)) + monkeypatch.setattr(seal_cmd.capsule, "seal", lambda *a, **k: b"B") + + args = SimpleNamespace(unlock="2030-01-01", note="overridden", out="new.mcap", force=False, no_password=True) + seal_cmd.run(args) + # the flags overrode the draft and stuck + assert d.note == "overridden" and d.out == "new.mcap" + assert (tmp_path / "new.mcap").exists() + + +def test_seal_errors_on_missing_staged_files(tmp_path, monkeypatch): + d = SimpleNamespace(unlock_at="2030-01-01T00:00:00", note="", out="c.mcap", staged=["/gone"], root=str(tmp_path)) + monkeypatch.setattr(seal_cmd.draft, "load", lambda: d) + monkeypatch.setattr(seal_cmd.draft, "save", lambda d: None) + monkeypatch.setattr(seal_cmd.draft, "missing", lambda d: ["/gone"]) + monkeypatch.setattr(seal_cmd, "parse_unlock", lambda s: datetime(2030, 1, 1, tzinfo=timezone.utc)) + with pytest.raises(SystemExit, match="no longer exist"): + seal_cmd.run(SimpleNamespace(unlock=None, note=None, out=None, force=False, no_password=True)) + + +def test_seal_warns_when_unlock_in_the_past(tmp_path, monkeypatch, capsys): + d = SimpleNamespace(unlock_at="2000-01-01T00:00:00", note="", out="c.mcap", staged=["/a"], root=str(tmp_path)) + monkeypatch.setattr(seal_cmd.draft, "load", lambda: d) + monkeypatch.setattr(seal_cmd.draft, "save", lambda d: None) + monkeypatch.setattr(seal_cmd.draft, "missing", lambda d: []) + monkeypatch.setattr(seal_cmd, "parse_unlock", lambda s: datetime(2000, 1, 1, tzinfo=timezone.utc)) + monkeypatch.setattr(seal_cmd.capsule, "seal", lambda *a, **k: b"B") + seal_cmd.run(SimpleNamespace(unlock=None, note=None, out=None, force=False, no_password=True)) + assert "not in the future" in capsys.readouterr().err + + +def test_seal_errors_without_unlock_date(tmp_path, monkeypatch): + d = SimpleNamespace(unlock_at=None, note="", out="c.mcap", staged=["/a"], root=str(tmp_path)) + monkeypatch.setattr(seal_cmd.draft, "load", lambda: d) + monkeypatch.setattr(seal_cmd.draft, "save", lambda d: None) + with pytest.raises(SystemExit, match="no unlock date"): + seal_cmd.run(SimpleNamespace(unlock=None, note=None, out=None, force=False, no_password=True)) + + +def test_seal_errors_when_nothing_staged(tmp_path, monkeypatch): + d = SimpleNamespace(unlock_at="2030-01-01", note="", out="c.mcap", staged=[], root=str(tmp_path)) + monkeypatch.setattr(seal_cmd.draft, "load", lambda: d) + monkeypatch.setattr(seal_cmd.draft, "save", lambda d: None) + with pytest.raises(SystemExit, match="nothing staged"): + seal_cmd.run(SimpleNamespace(unlock=None, note=None, out=None, force=False, no_password=True)) + + +def test_seal_refuses_to_clobber_without_force(tmp_path, monkeypatch): + (tmp_path / "c.mcap").write_bytes(b"old") + d = SimpleNamespace(unlock_at="2030-01-01T00:00:00", note="", out="c.mcap", staged=["/a"], root=str(tmp_path)) + monkeypatch.setattr(seal_cmd.draft, "load", lambda: d) + monkeypatch.setattr(seal_cmd.draft, "save", lambda d: None) + monkeypatch.setattr(seal_cmd.draft, "missing", lambda d: []) + monkeypatch.setattr(seal_cmd, "parse_unlock", lambda s: datetime(2030, 1, 1, tzinfo=timezone.utc)) + with pytest.raises(SystemExit, match="already exists"): + seal_cmd.run(SimpleNamespace(unlock=None, note=None, out=None, force=False, no_password=True)) + + +# --- open --------------------------------------------------------------- + + +def test_open_extracts_and_lists_names(monkeypatch, capsys): + monkeypatch.setattr(open_cmd, "read_capsule", lambda f: b"blob") + monkeypatch.setattr(open_cmd.capsule, "inspect", lambda blob: _info(_open=True, cipher="none")) + monkeypatch.setattr(open_cmd.capsule, "open_capsule", lambda *a, **k: ["a.txt", "b.txt"]) + + open_cmd.run(SimpleNamespace(file="c.mcap", dest="out")) + out = capsys.readouterr().out + assert "opened into out/" in out + assert "a.txt" in out and "b.txt" in out + + +def test_open_locked_capsule_errors(monkeypatch): + monkeypatch.setattr(open_cmd, "read_capsule", lambda f: b"blob") + monkeypatch.setattr(open_cmd.capsule, "inspect", lambda blob: _info(_open=False)) + with pytest.raises(SystemExit, match="locked until"): + open_cmd.run(SimpleNamespace(file="c.mcap", dest=".")) + + +def test_open_prompts_password_for_encrypted(monkeypatch, capsys): + seen = {} + monkeypatch.setattr(open_cmd, "read_capsule", lambda f: b"blob") + monkeypatch.setattr(open_cmd.capsule, "inspect", lambda blob: _info(_open=True, cipher="aes-256-gcm")) + monkeypatch.setattr(open_cmd, "ask_password", lambda: "pw") + monkeypatch.setattr(open_cmd.capsule, "open_capsule", lambda blob, pw, dest: seen.setdefault("pw", pw) or []) + open_cmd.run(SimpleNamespace(file="c.mcap", dest=".")) + assert seen["pw"] == "pw" + + +# --- info --------------------------------------------------------------- + + +def test_info_human_readable_locked(monkeypatch, capsys): + monkeypatch.setattr(info_cmd, "read_capsule", lambda f: b"blob") + monkeypatch.setattr(info_cmd.capsule, "inspect", lambda blob: _info(_open=False, note="hi")) + info_cmd.run(SimpleNamespace(file="c.mcap", json=False)) + out = capsys.readouterr().out + assert "cipher:" in out and "locked" in out and "note: hi" in out + + +def test_info_open_status(monkeypatch, capsys): + monkeypatch.setattr(info_cmd, "read_capsule", lambda f: b"blob") + monkeypatch.setattr(info_cmd.capsule, "inspect", lambda blob: _info(_open=True, cipher="none")) + info_cmd.run(SimpleNamespace(file="c.mcap", json=False)) + out = capsys.readouterr().out + assert "open" in out and "no password" in out + + +def test_info_json_output(monkeypatch, capsys): + monkeypatch.setattr(info_cmd, "read_capsule", lambda f: b"blob") + monkeypatch.setattr(info_cmd.capsule, "inspect", lambda blob: _info(_open=False, note="n")) + info_cmd.run(SimpleNamespace(file="c.mcap", json=True)) + payload = json.loads(capsys.readouterr().out) + assert payload["cipher"] == "aes-256-gcm" + assert payload["open"] is False + assert payload["note"] == "n" + + +# --- verify ------------------------------------------------------------- + + +def test_verify_reports_password_checked(monkeypatch, capsys): + monkeypatch.setattr(verify_cmd, "read_capsule", lambda f: b"blob") + monkeypatch.setattr(verify_cmd.capsule, "inspect", lambda blob: _info(cipher="aes-256-gcm")) + monkeypatch.setattr(verify_cmd, "ask_password", lambda: "pw") + monkeypatch.setattr(verify_cmd.capsule, "verify", lambda blob, pw: True) + verify_cmd.run(SimpleNamespace(file="c.mcap")) + assert "password is correct" in capsys.readouterr().out + + +def test_verify_without_password(monkeypatch, capsys): + monkeypatch.setattr(verify_cmd, "read_capsule", lambda f: b"blob") + monkeypatch.setattr(verify_cmd.capsule, "inspect", lambda blob: _info(cipher="none")) + monkeypatch.setattr(verify_cmd.capsule, "verify", lambda blob, pw: True) + verify_cmd.run(SimpleNamespace(file="c.mcap")) + out = capsys.readouterr().out + assert "capsule is intact" in out and "password" not in out + + +# --- remind ------------------------------------------------------------- + + +def test_remind_writes_ics(tmp_path, monkeypatch, capsys): + out = tmp_path / "c.ics" + monkeypatch.setattr(remind_cmd, "read_capsule", lambda f: b"blob") + monkeypatch.setattr(remind_cmd.capsule, "inspect", lambda blob: _info(_open=False)) + monkeypatch.setattr(remind_cmd.ics, "build", lambda *a, **k: "ICSDATA") + remind_cmd.run(SimpleNamespace(file="c.mcap", out=str(out), before=0, force=False)) + assert out.read_text() == "ICSDATA" + assert "reminder written" in capsys.readouterr().out + + +def test_remind_notes_already_open_capsule(tmp_path, monkeypatch, capsys): + out = tmp_path / "c.ics" + monkeypatch.setattr(remind_cmd, "read_capsule", lambda f: b"blob") + monkeypatch.setattr(remind_cmd.capsule, "inspect", lambda blob: _info(_open=True)) + monkeypatch.setattr(remind_cmd.ics, "build", lambda *a, **k: "ICS") + remind_cmd.run(SimpleNamespace(file="c.mcap", out=str(out), before=0, force=False)) + assert "already open" in capsys.readouterr().out + + +def test_remind_rejects_negative_before(monkeypatch): + with pytest.raises(SystemExit, match="cannot be negative"): + remind_cmd.run(SimpleNamespace(file="c.mcap", out=None, before=-1, force=False)) + + +def test_remind_refuses_to_clobber(tmp_path, monkeypatch): + out = tmp_path / "c.ics" + out.write_text("old") + monkeypatch.setattr(remind_cmd, "read_capsule", lambda f: b"blob") + monkeypatch.setattr(remind_cmd.capsule, "inspect", lambda blob: _info(_open=False)) + with pytest.raises(SystemExit, match="already exists"): + remind_cmd.run(SimpleNamespace(file="c.mcap", out=str(out), before=0, force=False)) diff --git a/tests/commands/test_config_and_version.py b/tests/commands/test_config_and_version.py new file mode 100644 index 0000000..a007baf --- /dev/null +++ b/tests/commands/test_config_and_version.py @@ -0,0 +1,76 @@ +"""spec: the `config` command's list/get/set/unset and `version` output.""" + +from types import SimpleNamespace + +import pytest + +from magicicapsula.commands import config as config_cmd +from magicicapsula.commands import version as version_cmd + + +def test_config_list(isolated_config, capsys): + config_cmd.run(SimpleNamespace(action="list", key=None, value=None, reveal=False)) + out = capsys.readouterr().out + assert "config file:" in out + assert "password = (not set)" in out + + +def test_config_set_then_get(isolated_config, capsys): + config_cmd.run(SimpleNamespace(action="set", key="password", value="s3cret", reveal=False)) + config_cmd.run(SimpleNamespace(action="get", key="password", value=None, reveal=True)) + out = capsys.readouterr().out + assert "set password" in out + assert "s3cret" in out # revealed + + +def test_config_get_masks_by_default(isolated_config, capsys): + config_cmd.run(SimpleNamespace(action="set", key="password", value="s3cret", reveal=False)) + capsys.readouterr() + config_cmd.run(SimpleNamespace(action="get", key="password", value=None, reveal=False)) + assert "***" in capsys.readouterr().out + + +def test_config_unset(isolated_config, capsys): + config_cmd.run(SimpleNamespace(action="set", key="password", value="x", reveal=False)) + capsys.readouterr() + config_cmd.run(SimpleNamespace(action="unset", key="password", value=None, reveal=False)) + assert "unset password" in capsys.readouterr().out + + +def test_config_unset_absent(isolated_config, capsys): + config_cmd.run(SimpleNamespace(action="unset", key="password", value=None, reveal=False)) + assert "was not set" in capsys.readouterr().out + + +def test_config_requires_key(isolated_config): + with pytest.raises(SystemExit, match="needs a key"): + config_cmd.run(SimpleNamespace(action="get", key=None, value=None, reveal=False)) + + +def test_config_unknown_key(isolated_config): + with pytest.raises(SystemExit, match="unknown setting"): + config_cmd.run(SimpleNamespace(action="get", key="ghost", value=None, reveal=False)) + + +def test_config_set_needs_value(isolated_config): + with pytest.raises(SystemExit, match="needs a value"): + config_cmd.run(SimpleNamespace(action="set", key="password", value=None, reveal=False)) + + +def test_version_prints_name_and_number(capsys): + version_cmd.run() + out = capsys.readouterr().out + from magicicapsula import __version__ + + assert __version__ in out + assert "magicicapsula" in out + + +def test_version_action_exits(capsys): + import argparse + + parser = argparse.ArgumentParser() + action = version_cmd.VersionPrintAction(option_strings=["--version"], dest="version", nargs=0) + with pytest.raises(SystemExit): + action(parser, argparse.Namespace(), None, None) + assert "magicicapsula" in capsys.readouterr().out diff --git a/tests/commands/test_draft_commands.py b/tests/commands/test_draft_commands.py new file mode 100644 index 0000000..a6bcd93 --- /dev/null +++ b/tests/commands/test_draft_commands.py @@ -0,0 +1,152 @@ +"""spec: init/add/rm/status print the right thing, with draft core mocked.""" + +from types import SimpleNamespace + +import pytest + +from magicicapsula.commands import add as add_cmd +from magicicapsula.commands import init as init_cmd +from magicicapsula.commands import rm as rm_cmd +from magicicapsula.commands import status as status_cmd + + +def _draft(**kw): + base = dict(unlock_at=None, note="", out="capsule.mcap", staged=[], root="/r") + base.update(kw) + d = SimpleNamespace(**base) + d.dir = "/r/.capsule" + return d + + +# --- init --------------------------------------------------------------- + + +def test_init_creates_and_reports(monkeypatch, capsys): + d = _draft() + monkeypatch.setattr(init_cmd.draft, "init", lambda: d) + monkeypatch.setattr(init_cmd.draft, "save", lambda d: None) + init_cmd.run(SimpleNamespace(unlock=None, note="hi", out="x.mcap")) + out = capsys.readouterr().out + assert "new capsule draft" in out + assert d.note == "hi" and d.out == "x.mcap" + + +def test_init_resolves_unlock(monkeypatch, capsys): + from datetime import datetime, timezone + + d = _draft() + monkeypatch.setattr(init_cmd.draft, "init", lambda: d) + monkeypatch.setattr(init_cmd.draft, "save", lambda d: None) + monkeypatch.setattr(init_cmd, "parse_unlock", lambda s: datetime(2030, 1, 1, tzinfo=timezone.utc)) + init_cmd.run(SimpleNamespace(unlock="+1y", note="", out="c.mcap")) + assert d.unlock_at.startswith("2030-01-01") + + +def test_init_existing_draft_errors(monkeypatch): + def boom(): + raise FileExistsError("/r/.capsule") + + monkeypatch.setattr(init_cmd.draft, "init", boom) + with pytest.raises(SystemExit, match="already exists"): + init_cmd.run(SimpleNamespace(unlock=None, note="", out="c.mcap")) + + +# --- add ---------------------------------------------------------------- + + +def test_add_nothing_errors(monkeypatch): + with pytest.raises(SystemExit, match="nothing to add"): + add_cmd.run(SimpleNamespace(paths=[], text=None, name="note.txt")) + + +def test_add_files_reports_total(monkeypatch, capsys): + d = _draft(staged=["/r/a", "/r/b"]) + monkeypatch.setattr(add_cmd.draft, "load", lambda: d) + monkeypatch.setattr(add_cmd.draft, "add", lambda d, files: ["/r/a", "/r/b"]) + add_cmd.run(SimpleNamespace(paths=["a", "b"], text=None, name="note.txt")) + out = capsys.readouterr().out + assert "staged /r/a" in out + assert "2 item(s) staged in total" in out + + +def test_add_text_stages_inline(monkeypatch, capsys): + d = _draft(staged=["/r/.capsule/files/note.txt"]) + monkeypatch.setattr(add_cmd.draft, "load", lambda: d) + monkeypatch.setattr(add_cmd.draft, "stage_text", lambda d, content, name: "/r/.capsule/files/note.txt") + add_cmd.run(SimpleNamespace(paths=[], text="dear future me", name="note.txt")) + assert "staged /r/.capsule/files/note.txt" in capsys.readouterr().out + + +def test_add_stdin(monkeypatch, capsys): + import io + + d = _draft(staged=["/r/.capsule/files/in.txt"]) + monkeypatch.setattr(add_cmd.draft, "load", lambda: d) + monkeypatch.setattr(add_cmd.draft, "stage_text", lambda d, content, name: "/r/.capsule/files/in.txt") + monkeypatch.setattr("sys.stdin", SimpleNamespace(buffer=io.BytesIO(b"piped"))) + add_cmd.run(SimpleNamespace(paths=["-"], text=None, name="in.txt")) + assert "staged" in capsys.readouterr().out + + +def test_add_nothing_new(monkeypatch, capsys): + d = _draft(staged=["/r/a"]) + monkeypatch.setattr(add_cmd.draft, "load", lambda: d) + monkeypatch.setattr(add_cmd.draft, "add", lambda d, files: []) + add_cmd.run(SimpleNamespace(paths=["a"], text=None, name="note.txt")) + assert "nothing new to stage" in capsys.readouterr().out + + +# --- rm ----------------------------------------------------------------- + + +def test_rm_reports_unstaged(monkeypatch, capsys): + monkeypatch.setattr(rm_cmd.draft, "load", lambda: _draft()) + monkeypatch.setattr(rm_cmd.draft, "remove", lambda d, paths: ["/r/a"]) + rm_cmd.run(SimpleNamespace(paths=["a"])) + assert "unstaged /r/a" in capsys.readouterr().out + + +def test_rm_nothing_matched(monkeypatch, capsys): + monkeypatch.setattr(rm_cmd.draft, "load", lambda: _draft()) + monkeypatch.setattr(rm_cmd.draft, "remove", lambda d, paths: []) + rm_cmd.run(SimpleNamespace(paths=["a"])) + assert "none of those were staged" in capsys.readouterr().out + + +# --- status ------------------------------------------------------------- + + +def test_status_empty_draft(monkeypatch, capsys): + monkeypatch.setattr(status_cmd.draft, "load", lambda: _draft()) + status_cmd.run(SimpleNamespace()) + out = capsys.readouterr().out + assert "not set" in out and "nothing staged" in out + + +def test_status_with_staged_files(tmp_path, monkeypatch, capsys): + f = tmp_path / "a.txt" + f.write_text("hello") + d = _draft(unlock_at="2030-01-01T00:00:00", note="hi", staged=[str(f)]) + monkeypatch.setattr(status_cmd.draft, "load", lambda: d) + monkeypatch.setattr(status_cmd.draft, "missing", lambda d: []) + status_cmd.run(SimpleNamespace()) + out = capsys.readouterr().out + assert "note: hi" in out + assert "1 item(s) staged" in out + assert str(f) in out + + +def test_status_warns_about_missing_files(monkeypatch, capsys): + d = _draft(unlock_at="2030-01-01T00:00:00", staged=["/r/gone"]) + monkeypatch.setattr(status_cmd.draft, "load", lambda: d) + monkeypatch.setattr(status_cmd.draft, "missing", lambda d: ["/r/gone"]) + status_cmd.run(SimpleNamespace()) + out = capsys.readouterr().out + assert "(missing)" in out and "warning" in out + + +def test_fmt_size_units(): + assert status_cmd._fmt_size(0) == "0 B" + assert status_cmd._fmt_size(2048) == "2.0 KB" + assert status_cmd._fmt_size(5 * 1024**4) == "5.0 TB" + assert status_cmd._fmt_size(3 * 1024**5) == "3.0 PB" # beyond TB falls back to PB diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b219ab6 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,73 @@ +"""shared fixtures. + +these keep config and the draft state hermetic: every test gets its own +XDG_CONFIG_HOME and cwd under tmp_path, so nothing touches the real machine. +""" + +import argparse +from datetime import datetime, timedelta, timezone + +import pytest + +from magicicapsula.core import capsule + + +@pytest.fixture +def ns(): + """Build an argparse.Namespace the way the cli would hand one to run().""" + return lambda **kw: argparse.Namespace(**kw) + + +@pytest.fixture +def isolated_config(tmp_path, monkeypatch): + """Point the config file at a throwaway dir and clear the env override.""" + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg")) + monkeypatch.delenv("MAGICICAPSULA_PASSWORD", raising=False) + return tmp_path / "xdg" + + +@pytest.fixture +def in_tmp(tmp_path, monkeypatch): + """Run the test with cwd inside an empty tmp dir.""" + monkeypatch.chdir(tmp_path) + return tmp_path + + +@pytest.fixture +def sample_files(tmp_path): + """A couple of real files on disk to seal.""" + a = tmp_path / "letter.txt" + a.write_text("dear future me\n", encoding="utf-8") + b = tmp_path / "note.txt" + b.write_text("second\n", encoding="utf-8") + return [str(a), str(b)] + + +@pytest.fixture +def past(): + return datetime.now(timezone.utc) - timedelta(days=1) + + +@pytest.fixture +def future(): + return datetime.now(timezone.utc) + timedelta(days=30) + + +@pytest.fixture +def make_capsule(sample_files): + """Seal `sample_files` into a blob; defaults to an already-unlocked capsule.""" + + def build(password="hunter2", unlock_at=None, note="", paths=None): + unlock_at = unlock_at or (datetime.now(timezone.utc) - timedelta(days=1)) + return capsule.seal(paths or sample_files, password, unlock_at, note=note) + + return build + + +@pytest.fixture +def capsule_file(tmp_path, make_capsule): + """A sealed, unlocked capsule written to disk; returns (path, password).""" + blob = make_capsule() + path = tmp_path / "capsule.mcap" + path.write_bytes(blob) + return str(path), "hunter2" diff --git a/tests/core/test_capsule.py b/tests/core/test_capsule.py new file mode 100644 index 0000000..fa0d2b1 --- /dev/null +++ b/tests/core/test_capsule.py @@ -0,0 +1,187 @@ +"""spec: the .mcap container — seal, inspect, verify, open. + +the header is plaintext (inspect needs no password); the contents are inside +the ciphertext. for v2 the header is bound as aad, so tampering is caught. +the reader accepts versions in [MIN_READ_VERSION, VERSION] and rejects the +rest with InvalidCapsule instead of misparsing. +""" + +import base64 +import io +import json +import struct +import tarfile +from datetime import datetime, timedelta, timezone + +import pytest + +from magicicapsula.core import capsule, crypto +from magicicapsula.core.errors import ( + CapsuleLocked, + InvalidCapsule, + WrongPasswordOrCorrupt, +) + + +def _now(): + return datetime.now(timezone.utc) + + +# --- helpers that build capsule bytes from the *documented wire format* only, +# --- so these tests bind to the format contract, not to capsule's privates. + + +def _iso(dt): + return dt.astimezone(timezone.utc).isoformat() + + +def _frame(version, header: dict, payload: bytes) -> bytes: + hb = json.dumps(header).encode() + return capsule.MAGIC + bytes([version]) + struct.pack(">I", len(hb)) + hb + payload + + +def _targz(name, text) -> bytes: + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tar: + data = text.encode() + ti = tarfile.TarInfo(name) + ti.size = len(data) + tar.addfile(ti, io.BytesIO(data)) + return buf.getvalue() + + +def test_seal_open_roundtrip_with_password(make_capsule, tmp_path): + blob = make_capsule(note="hi") + dest = tmp_path / "out" + names = capsule.open_capsule(blob, "hunter2", str(dest)) + assert sorted(names) == ["letter.txt", "note.txt"] + assert (dest / "letter.txt").read_text() == "dear future me\n" + + +def test_seal_without_password_uses_cipher_none(make_capsule, tmp_path): + blob = make_capsule(password=None) + assert capsule.inspect(blob).cipher == "none" + # no password needed to open + names = capsule.open_capsule(blob, None, str(tmp_path / "o")) + assert "letter.txt" in names + + +def test_inspect_reads_metadata_without_password(make_capsule, future): + blob = make_capsule(unlock_at=future, note="later") + info = capsule.inspect(blob) + assert info.cipher == "aes-256-gcm" + assert info.note == "later" + assert not info.is_open() + assert info.remaining() > timedelta(0) + + +def test_open_locked_capsule_raises(make_capsule, future, tmp_path): + blob = make_capsule(unlock_at=future) + with pytest.raises(CapsuleLocked): + capsule.open_capsule(blob, "hunter2", str(tmp_path / "o")) + + +def test_allow_locked_bypasses_the_date(make_capsule, future, tmp_path): + blob = make_capsule(unlock_at=future) + names = capsule.open_capsule(blob, "hunter2", str(tmp_path / "o"), allow_locked=True) + assert "letter.txt" in names + + +def test_wrong_password_raises(make_capsule, tmp_path): + blob = make_capsule() + with pytest.raises(WrongPasswordOrCorrupt): + capsule.open_capsule(blob, "nope", str(tmp_path / "o")) + + +def test_password_capsule_needs_a_password(make_capsule, tmp_path): + blob = make_capsule() + with pytest.raises(WrongPasswordOrCorrupt, match="needs a password"): + capsule.open_capsule(blob, None, str(tmp_path / "o")) + + +def test_tampered_header_is_detected(make_capsule): + blob = bytearray(make_capsule(note="original")) + i = blob.find(b"original") + blob[i : i + len(b"original")] = b"TAMPERED" # same length, valid json + with pytest.raises(WrongPasswordOrCorrupt): + capsule.open_capsule(bytes(blob), "hunter2", "/tmp/should-not-happen", allow_locked=True) + + +def test_verify_returns_true_then_rejects_bad_password(make_capsule): + blob = make_capsule() + assert capsule.verify(blob, "hunter2") is True + with pytest.raises(WrongPasswordOrCorrupt): + capsule.verify(blob, "wrong") + + +def test_bad_magic_rejected(): + with pytest.raises(InvalidCapsule, match="bad magic"): + capsule.inspect(b"XXXX" + b"\x00" * 20) + + +def test_too_small_rejected(): + with pytest.raises(InvalidCapsule, match="too small"): + capsule.inspect(b"MC") + + +def test_future_version_rejected_with_upgrade_hint(): + with pytest.raises(InvalidCapsule, match="upgrade"): + capsule.inspect(_frame(capsule.VERSION + 1, {}, b"")) + + +def test_header_length_past_end_rejected(): + body = capsule.MAGIC + bytes([capsule.VERSION]) + struct.pack(">I", 9999) + with pytest.raises(InvalidCapsule, match="runs past end"): + capsule.inspect(body) + + +def test_corrupt_header_json_rejected(): + bad = b"{not json" + body = capsule.MAGIC + bytes([capsule.VERSION]) + struct.pack(">I", len(bad)) + bad + with pytest.raises(InvalidCapsule, match="corrupt header"): + capsule.inspect(body) + + +def test_missing_required_field_rejected(): + body = _frame(capsule.VERSION, {"unlock_at": _iso(_now())}, b"") # no created_at + with pytest.raises(InvalidCapsule, match="missing field"): + capsule.inspect(body) + + +def test_seal_rejects_missing_input_file(tmp_path, past): + with pytest.raises(FileNotFoundError): + capsule.seal([str(tmp_path / "ghost.txt")], "pw", past) + + +def test_v1_fernet_capsule_still_opens(tmp_path, past): + """A hand-built v1 (fernet) capsule must still read, for backward compat. + + Built from the documented wire format + the public crypto API only — the + .mcap layout is itself a contract ("old files still open"), so binding to + it here is intentional; binding to capsule's private helpers would not be. + """ + salt = bytes(range(16)) + params = crypto.KdfParams(n=2**4, r=1, p=1) + header = { + "created_at": _iso(_now()), + "unlock_at": _iso(past), + "note": "", + "cipher": "fernet", + "kdf": {**params.to_dict(), "salt": base64.b64encode(salt).decode()}, + } + token = crypto.encrypt(_targz("old.txt", "legacy"), "pw", salt, params) + blob = _frame(1, header, token) + + assert capsule.inspect(blob).cipher == "fernet" + assert capsule.open_capsule(blob, "pw", str(tmp_path / "o")) == ["old.txt"] + + +def test_unknown_cipher_rejected(tmp_path, past): + header = {"created_at": _iso(_now()), "unlock_at": _iso(past), "cipher": "rot13"} + with pytest.raises(InvalidCapsule, match="unknown cipher"): + capsule.open_capsule(_frame(capsule.VERSION, header, b"junk"), "pw", str(tmp_path / "o"), allow_locked=True) + + +def test_list_names_on_corrupt_payload_raises(): + with pytest.raises(WrongPasswordOrCorrupt): + capsule.list_names(b"not a tar") diff --git a/tests/core/test_config.py b/tests/core/test_config.py new file mode 100644 index 0000000..60e82eb --- /dev/null +++ b/tests/core/test_config.py @@ -0,0 +1,68 @@ +"""spec: settings resolve default < config file < environment variable. + +every test runs against an isolated XDG_CONFIG_HOME, so the real user config +is never read or written. +""" + +import pytest + +from magicicapsula.core import config + + +def test_known_keys(isolated_config): + assert config.keys() == ["password"] + assert config.is_known("password") + assert not config.is_known("nope") + + +def test_unset_key_resolves_to_default(isolated_config): + value, source = config.resolve("password") + assert value is None + assert source == "default" + + +def test_file_value_takes_over_default(isolated_config): + config.set_value("password", "fromfile") + assert config.resolve("password") == ("fromfile", "file") + assert config.get("password") == "fromfile" + + +def test_env_overrides_file(isolated_config, monkeypatch): + config.set_value("password", "fromfile") + monkeypatch.setenv("MAGICICAPSULA_PASSWORD", "fromenv") + assert config.resolve("password") == ("fromenv", "env") + + +def test_unset_reports_presence(isolated_config): + assert config.unset("password") is False # nothing to remove yet + config.set_value("password", "x") + assert config.unset("password") is True + assert config.get("password") is None + + +def test_set_unknown_key_raises(isolated_config): + with pytest.raises(KeyError): + config.set_value("ghost", "x") + + +def test_unset_unknown_key_raises(isolated_config): + with pytest.raises(KeyError): + config.unset("ghost") + + +def test_display_masks_secret_unless_revealed(isolated_config): + assert config.display("password", "s3cret") == "***" + assert config.display("password", "s3cret", reveal=True) == "s3cret" + assert config.display("password", None) == "(not set)" + + +def test_corrupt_config_file_is_ignored(isolated_config): + path = config.config_path() + path_dir = path.rsplit("/", 1)[0] + import os + + os.makedirs(path_dir, exist_ok=True) + with open(path, "w") as fh: + fh.write("{ not json") + # a broken file resolves as if empty rather than blowing up + assert config.resolve("password") == (None, "default") diff --git a/tests/core/test_crypto.py b/tests/core/test_crypto.py new file mode 100644 index 0000000..de8561a --- /dev/null +++ b/tests/core/test_crypto.py @@ -0,0 +1,79 @@ +"""spec: password -> scrypt key -> authenticated encryption. + +v2 is aes-256-gcm binding the header as aad; v1 is fernet with no aad. both +must round-trip, and both must reject a wrong password or altered bytes with +WrongPasswordOrCorrupt rather than leaking a raw crypto exception. +""" + +import os + +import pytest + +from magicicapsula.core import crypto +from magicicapsula.core.errors import WrongPasswordOrCorrupt + +# tiny scrypt cost so the suite stays fast; correctness is independent of n. +FAST = crypto.KdfParams(n=2**4, r=1, p=1) + + +def test_kdf_params_roundtrip_through_dict(): + p = crypto.KdfParams(n=1024, r=4, p=2) + assert crypto.KdfParams.from_dict(p.to_dict()) == p + + +def test_kdf_params_from_dict_defaults_algo(): + assert crypto.KdfParams.from_dict({"n": 16, "r": 1, "p": 1}).algo == "scrypt" + + +def test_gcm_roundtrip_with_aad(): + salt, aad = os.urandom(16), b"the-header" + token = crypto.encrypt_gcm(b"secret", "pw", salt, aad, FAST) + assert crypto.decrypt_gcm(token, "pw", salt, aad, FAST) == b"secret" + + +def test_gcm_wrong_password_raises(): + salt = os.urandom(16) + token = crypto.encrypt_gcm(b"secret", "right", salt, b"h", FAST) + with pytest.raises(WrongPasswordOrCorrupt): + crypto.decrypt_gcm(token, "wrong", salt, b"h", FAST) + + +def test_gcm_altered_aad_raises(): + salt = os.urandom(16) + token = crypto.encrypt_gcm(b"secret", "pw", salt, b"header", FAST) + with pytest.raises(WrongPasswordOrCorrupt): + crypto.decrypt_gcm(token, "pw", salt, b"HEADER", FAST) + + +def test_gcm_altered_ciphertext_raises(): + salt = os.urandom(16) + token = bytearray(crypto.encrypt_gcm(b"secret", "pw", salt, b"h", FAST)) + token[-1] ^= 0x01 + with pytest.raises(WrongPasswordOrCorrupt): + crypto.decrypt_gcm(bytes(token), "pw", salt, b"h", FAST) + + +def test_fernet_roundtrip(): + salt = os.urandom(16) + token = crypto.encrypt(b"v1 data", "pw", salt, FAST) + assert crypto.decrypt(token, "pw", salt, FAST) == b"v1 data" + + +def test_fernet_wrong_password_raises(): + salt = os.urandom(16) + token = crypto.encrypt(b"v1 data", "right", salt, FAST) + with pytest.raises(WrongPasswordOrCorrupt): + crypto.decrypt(token, "wrong", salt, FAST) + + +def test_derive_key_is_urlsafe_base64_of_32_bytes(): + import base64 + + key = crypto.derive_key("pw", os.urandom(16), FAST) + assert len(base64.urlsafe_b64decode(key)) == crypto.KEY_LEN + + +def test_unsupported_kdf_algo_rejected(): + # surfaced through the public encrypt path, not by poking _scrypt directly. + with pytest.raises(ValueError, match="unsupported KDF"): + crypto.encrypt_gcm(b"x", "pw", os.urandom(16), b"h", crypto.KdfParams(algo="argon2")) diff --git a/tests/core/test_draft.py b/tests/core/test_draft.py new file mode 100644 index 0000000..2d4e431 --- /dev/null +++ b/tests/core/test_draft.py @@ -0,0 +1,104 @@ +"""spec: the .capsule/ staging area — init, find, add, stage_text, remove. + +staged entries are absolute paths to files on disk; contents are read at seal +time, not copied on add. the root is found by walking up from the cwd. +""" + +import os + +import pytest + +from magicicapsula.core import draft +from magicicapsula.core.errors import NoDraft + + +def test_init_creates_draft_dir(in_tmp): + d = draft.init() + assert os.path.isdir(d.dir) + assert os.path.exists(d.config_path) + + +def test_init_twice_raises(in_tmp): + draft.init() + with pytest.raises(FileExistsError): + draft.init() + + +def test_find_root_walks_up_from_subdir(in_tmp): + draft.init() + sub = in_tmp / "a" / "b" + sub.mkdir(parents=True) + assert draft.find_root(str(sub)) == str(in_tmp) + + +def test_find_root_without_draft_raises(in_tmp): + with pytest.raises(NoDraft): + draft.find_root(str(in_tmp)) + + +def test_load_roundtrips_saved_fields(in_tmp): + d = draft.init() + d.unlock_at = "2030-01-01T00:00:00" + d.note = "hello" + d.out = "x.mcap" + draft.save(d) + loaded = draft.load(str(in_tmp)) + assert (loaded.unlock_at, loaded.note, loaded.out) == ("2030-01-01T00:00:00", "hello", "x.mcap") + + +def test_add_stages_absolute_paths_and_dedupes(in_tmp): + d = draft.init() + f = in_tmp / "f.txt" + f.write_text("x") + added = draft.add(d, ["f.txt"]) + assert added == [str(f)] + # adding the same path again stages nothing new + assert draft.add(d, ["f.txt"]) == [] + assert d.staged == [str(f)] + + +def test_add_missing_file_raises(in_tmp): + d = draft.init() + with pytest.raises(FileNotFoundError): + draft.add(d, ["ghost.txt"]) + + +def test_stage_text_writes_and_stages_a_file(in_tmp): + d = draft.init() + path = draft.stage_text(d, "dear future me", "letter.txt") + assert os.path.basename(path) == "letter.txt" + assert open(path, encoding="utf-8").read() == "dear future me" + assert path in d.staged + + +def test_stage_text_accepts_bytes_and_avoids_clobber(in_tmp): + d = draft.init() + first = draft.stage_text(d, b"one", "n.txt") + second = draft.stage_text(d, b"two", "n.txt") + assert first != second # _unique picked a fresh name + assert open(second, "rb").read() == b"two" + + +def test_stage_text_falls_back_to_default_name(in_tmp): + d = draft.init() + path = draft.stage_text(d, "x", name="") + assert os.path.basename(path) == "note.txt" + + +def test_remove_unstages_only_matching_paths(in_tmp): + d = draft.init() + (in_tmp / "a").write_text("a") + (in_tmp / "b").write_text("b") + draft.add(d, ["a", "b"]) + removed = draft.remove(d, ["a", "missing"]) + assert removed == [os.path.abspath("a")] + assert d.staged == [os.path.abspath("b")] + + +def test_missing_lists_vanished_files(in_tmp): + d = draft.init() + f = in_tmp / "gone.txt" + f.write_text("x") + draft.add(d, ["gone.txt"]) + os.remove(f) + assert draft.missing(d) == [str(f)] diff --git a/tests/core/test_errors.py b/tests/core/test_errors.py new file mode 100644 index 0000000..f516cf7 --- /dev/null +++ b/tests/core/test_errors.py @@ -0,0 +1,23 @@ +"""spec: the error hierarchy the cli relies on to print short messages.""" + +from datetime import datetime, timezone + +from magicicapsula.core.errors import ( + CapsuleError, + CapsuleLocked, + InvalidCapsule, + NoDraft, + WrongPasswordOrCorrupt, +) + + +def test_all_errors_are_capsule_errors(): + for cls in (InvalidCapsule, WrongPasswordOrCorrupt, NoDraft, CapsuleLocked): + assert issubclass(cls, CapsuleError) + + +def test_capsule_locked_carries_unlock_date_and_message(): + when = datetime(2030, 1, 1, tzinfo=timezone.utc) + err = CapsuleLocked(when) + assert err.unlock_at == when + assert when.isoformat() in str(err) diff --git a/tests/core/test_ics.py b/tests/core/test_ics.py new file mode 100644 index 0000000..04a3620 --- /dev/null +++ b/tests/core/test_ics.py @@ -0,0 +1,70 @@ +"""spec: rfc 5545 .ics generation — utc stamps, escaping, line folding, uid. + +these assert only on the public contract: the text `build()` returns. escaping +and folding are observable there, so there's no need to reach for the private +`_escape`/`_fold` helpers — that would freeze implementation details the +public output already pins down. +""" + +from datetime import datetime, timezone + +from magicicapsula.core import ics + + +def _dt(): + return datetime(2030, 1, 1, 8, 0, tzinfo=timezone.utc) + + +def test_build_has_calendar_envelope_and_event(): + text = ics.build("capsule.mcap", _dt(), now=_dt()) + assert text.startswith("BEGIN:VCALENDAR\r\n") + assert text.endswith("END:VCALENDAR\r\n") + assert "BEGIN:VEVENT" in text + assert "DTSTART:20300101T080000Z" in text + assert "DTEND:20300101T083000Z" in text # +30 min + + +def test_uid_is_stable_for_same_inputs(): + a = ics.build("c.mcap", _dt(), now=_dt()) + b = ics.build("c.mcap", _dt(), now=_dt()) + assert a == b + + +def test_uid_changes_with_name(): + assert _uid_line(ics.build("a.mcap", _dt(), now=_dt())) != _uid_line(ics.build("b.mcap", _dt(), now=_dt())) + + +def test_default_trigger_fires_on_the_day(): + assert "TRIGGER:PT0S" in ics.build("c.mcap", _dt(), now=_dt()) + + +def test_before_days_sets_negative_trigger(): + assert "TRIGGER:-P3D" in ics.build("c.mcap", _dt(), before_days=3, now=_dt()) + + +def test_note_overrides_description(): + text = ics.build("c.mcap", _dt(), note="open me gently", now=_dt()) + assert "DESCRIPTION:open me gently" in text + + +def test_special_characters_are_escaped_in_output(): + text = ics.build("c.mcap", _dt(), note="a;b,c\\d", now=_dt()) + assert r"DESCRIPTION:a\;b\,c\\d" in text + + +def test_long_value_is_folded_within_the_octet_limit(): + text = ics.build("c.mcap", _dt(), note="z" * 200, now=_dt()) + assert "\r\n " in text # a continuation line was produced + for i, physical in enumerate(text.split("\r\n")): + # first line caps at 75 octets; folded lines add one for the leading space + assert len(physical.encode("utf-8")) <= (75 if i == 0 else 76) + + +def test_folding_never_corrupts_a_multibyte_char(): + text = ics.build("c.mcap", _dt(), note="é" * 80, now=_dt()) # 2 octets each + unfolded = text.replace("\r\n ", "") + assert "DESCRIPTION:" + "é" * 80 in unfolded + + +def _uid_line(text): + return next(line for line in text.split("\r\n") if line.startswith("UID:")) diff --git a/tests/core/test_util.py b/tests/core/test_util.py new file mode 100644 index 0000000..2908360 --- /dev/null +++ b/tests/core/test_util.py @@ -0,0 +1,82 @@ +"""spec: shared cli helpers — date parsing, password prompting, formatting.""" + +from datetime import datetime, timedelta + +import pytest + +from magicicapsula.commands import _util + + +def test_parse_absolute_date(): + dt = _util.parse_unlock("2030-01-01") + assert (dt.year, dt.month, dt.day) == (2030, 1, 1) + + +def test_parse_absolute_datetime(): + dt = _util.parse_unlock("2030-01-01T08:30") + assert (dt.hour, dt.minute) == (8, 30) + + +@pytest.mark.parametrize("unit,attr", [("d", "days"), ("w", "weeks")]) +def test_parse_relative_days_and_weeks(unit, attr): + before = datetime.now().astimezone() + dt = _util.parse_unlock(f"+3{unit}") + delta = dt - before + assert delta >= timedelta(**{attr: 3}) - timedelta(seconds=5) + + +def test_parse_relative_months_clamps_day(): + # Jan 31 + 1 month must land on a real February day, not overflow. + base = datetime(2030, 1, 31) + assert _util._add_months(base, 1).day <= 28 + + +def test_parse_relative_year(): + before = datetime.now().astimezone() + dt = _util.parse_unlock("+1y") + assert dt.year >= before.year + 1 + + +def test_parse_bad_date_exits(): + with pytest.raises(SystemExit, match="bad date"): + _util.parse_unlock("not-a-date") + + +def test_read_capsule_returns_bytes(tmp_path): + f = tmp_path / "c.mcap" + f.write_bytes(b"\x00\x01") + assert _util.read_capsule(str(f)) == b"\x00\x01" + + +def test_fmt_remaining_breaks_down_duration(): + assert _util.fmt_remaining(timedelta(days=2, hours=3, minutes=4)) == "2d 3h 4m" + + +def test_fmt_remaining_floors_negative_to_zero(): + assert _util.fmt_remaining(timedelta(seconds=-10)) == "0d 0h 0m" + + +def test_ask_password_uses_configured_value(monkeypatch): + monkeypatch.setattr(_util.config, "get", lambda key: "from-config") + assert _util.ask_password() == "from-config" + + +def test_ask_password_prompts_when_unconfigured(monkeypatch): + monkeypatch.setattr(_util.config, "get", lambda key: None) + monkeypatch.setattr(_util.getpass, "getpass", lambda *a: "typed") + assert _util.ask_password() == "typed" + + +def test_ask_password_rejects_empty(monkeypatch): + monkeypatch.setattr(_util.config, "get", lambda key: None) + monkeypatch.setattr(_util.getpass, "getpass", lambda *a: "") + with pytest.raises(SystemExit, match="empty password"): + _util.ask_password() + + +def test_ask_password_confirm_mismatch(monkeypatch): + monkeypatch.setattr(_util.config, "get", lambda key: None) + answers = iter(["one", "two"]) + monkeypatch.setattr(_util.getpass, "getpass", lambda *a: next(answers)) + with pytest.raises(SystemExit, match="do not match"): + _util.ask_password(confirm=True) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..1bb77ce --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,66 @@ +"""spec: the argparse wiring and top-level error handling in cli.main. + +main() catches CapsuleError and FileNotFoundError and turns them into a short +`error: ...` exit, so a stack trace never reaches the user. +""" + +import pytest + +from magicicapsula import cli +from magicicapsula.core.errors import CapsuleError + + +def test_parser_discovers_subcommands(): + parser = cli.build_parser() + args = parser.parse_args(["version"]) + assert hasattr(args, "func") + + +def test_no_command_errors_out(monkeypatch): + monkeypatch.setattr("sys.argv", ["magicicapsula"]) + with pytest.raises(SystemExit): + cli.main() + + +def test_version_runs(monkeypatch, capsys): + monkeypatch.setattr("sys.argv", ["magicicapsula", "version"]) + cli.main() + assert "magicicapsula" in capsys.readouterr().out + + +def test_capsule_error_is_reported_cleanly(monkeypatch): + parser = cli.build_parser() + args = parser.parse_args(["version"]) + + def boom(_): + raise CapsuleError("kaboom") + + args.func = boom + monkeypatch.setattr(cli, "build_parser", lambda: _FixedParser(args)) + with pytest.raises(SystemExit) as exc: + cli.main() + assert "kaboom" in str(exc.value) + + +def test_missing_file_is_reported_cleanly(monkeypatch): + parser = cli.build_parser() + args = parser.parse_args(["version"]) + + def boom(_): + raise FileNotFoundError("capsule.mcap") + + args.func = boom + monkeypatch.setattr(cli, "build_parser", lambda: _FixedParser(args)) + with pytest.raises(SystemExit) as exc: + cli.main() + assert "no such file" in str(exc.value) + + +class _FixedParser: + """A stand-in parser whose parse_args ignores argv and returns fixed args.""" + + def __init__(self, args): + self._args = args + + def parse_args(self): + return self._args diff --git a/tests/test_style.py b/tests/test_style.py new file mode 100644 index 0000000..aa5369f --- /dev/null +++ b/tests/test_style.py @@ -0,0 +1,32 @@ +"""spec: color is opt-in to a real terminal and stripped otherwise. + +NO_COLOR / a non-tty / TERM=dumb must yield plain text, so piped output stays +clean. the logo loads from package assets either way. +""" + +from magicicapsula.commands import _style + + +def test_no_color_when_not_a_tty(monkeypatch): + # capsys/pytest already make stdout a non-tty, but be explicit. + monkeypatch.setattr("sys.stdout.isatty", lambda: False, raising=False) + assert _style.red("x") == "x" + assert not _style.enabled() + + +def test_color_codes_applied_when_enabled(monkeypatch): + monkeypatch.setattr(_style, "enabled", lambda: True) + assert _style.green("hi") == "\x1b[32mhi\x1b[0m" + assert _style.bold("hi") == "\x1b[1mhi\x1b[0m" + + +def test_no_color_env_disables(monkeypatch): + monkeypatch.setattr("sys.stdout.isatty", lambda: True, raising=False) + monkeypatch.setenv("NO_COLOR", "1") + assert not _style.enabled() + + +def test_logo_loads_and_is_plain_without_color(): + logo = _style.logo() + assert isinstance(logo, str) and logo.strip() + assert "\x1b[" not in logo # ansi stripped for non-tty