Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,8 @@ env/

# os
.DS_Store

# test / coverage artifacts
.coverage
htmlcov/
.pytest_cache/
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,16 @@ magicicapsula config unset <key> # 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.

25 changes: 24 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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__.:"]
246 changes: 246 additions & 0 deletions tests/commands/test_capsule_commands.py
Original file line number Diff line number Diff line change
@@ -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))
Loading
Loading