Files
FabledGrimoire/tests/test_config_secret_key.py
bvandeusenandClaude Opus 5.5 787685ac63
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m9s
CI & Build / Python tests (push) Successful in 2m7s
CI & Build / Build & push image (push) Successful in 30s
fix(6244): the instance keeps its own SECRET_KEY in /data, and docker-compose.yml is the compose users deploy from
With no SECRET_KEY set, Grimoire fell back to a fixed placeholder. The
quickstart compose substituted change-me-in-production, so the README's
own update step (pull, up -d) recreated the container on that placeholder,
and every credential sealed under the key the user started with stopped
opening.

- config: with neither SECRET_KEY nor SECRET_KEY_FILE, the first start
  writes a random key to DATA_DIR/secret_key (mode 0600, published with a
  hard link so concurrent starts agree), and later starts read it back.
  An explicit key wins and leaves the file alone. A missing
  SECRET_KEY_FILE or an empty key file stops the start instead of
  switching keys. With no data directory (a run outside the container)
  the process gets a temporary key and says so.
- app: warns on a public placeholder key, on a temporary key, and when
  /data is not a mounted volume.
- Dockerfile creates /data.
- docker-compose.yml is now the deploy compose (published image, both
  volumes, nothing to set). The build-from-source compose is
  docker-compose.dev.yml, and docker-compose.quickstart.yml is gone. The
  Swarm stack file gains the /data volume and keeps its explicit key,
  because a Swarm can move the app away from a node-local volume.
- README: a Deploy section with the compose inline, SECRET_KEY optional,
  "keep both volumes", and updates that keep the key. A test holds the
  README's copy equal to the file.
- Installs that ran on the change-me-in-production placeholder get a key
  of their own on the new compose; Settings then asks once for saved
  forge tokens and the backup secret.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:13:43 -04:00

84 lines
3.0 KiB
Python

"""The instance keeps its own SECRET_KEY in the data directory.
A deployment that never sets SECRET_KEY must get the same key on every start,
or everything sealed under the first one (forge tokens, the backup secret)
stops opening after the first container recreate.
"""
import stat
from unittest.mock import patch
import pytest
from grimoire import config
from grimoire.config import SECRET_KEY_FILENAME, load_or_create_secret_key
from grimoire.services import secrets
def test_the_first_start_writes_a_key_only_its_owner_can_read(tmp_path):
key = load_or_create_secret_key(tmp_path)
path = tmp_path / SECRET_KEY_FILENAME
assert key and len(key) == 64
assert path.read_text().strip() == key
assert stat.S_IMODE(path.stat().st_mode) == 0o600
assert [p.name for p in tmp_path.iterdir()] == [SECRET_KEY_FILENAME]
def test_every_later_start_reads_the_same_key_and_what_it_sealed_still_opens(tmp_path):
first = load_or_create_secret_key(tmp_path)
with patch.object(secrets.Config, "SECRET_KEY", first):
sealed = secrets.seal("forge-token")
second = load_or_create_secret_key(tmp_path)
assert second == first
with patch.object(secrets.Config, "SECRET_KEY", second):
assert secrets.unseal(sealed) == "forge-token"
def test_no_data_directory_means_no_kept_key(tmp_path):
assert load_or_create_secret_key(tmp_path / "absent") is None
def test_an_empty_key_file_is_refused_rather_than_replaced(tmp_path):
(tmp_path / SECRET_KEY_FILENAME).write_text("\n")
with pytest.raises(RuntimeError, match="is empty"):
load_or_create_secret_key(tmp_path)
assert (tmp_path / SECRET_KEY_FILENAME).read_text() == "\n"
def test_an_explicit_key_wins_and_leaves_the_kept_one_alone(tmp_path, monkeypatch):
monkeypatch.setenv("DATA_DIR", str(tmp_path))
monkeypatch.delenv("SECRET_KEY_FILE", raising=False)
monkeypatch.delenv("SECRET_KEY", raising=False)
kept, source = config._secret_key()
assert source == "data"
monkeypatch.setenv("SECRET_KEY", "an-operator-key")
assert config._secret_key() == ("an-operator-key", "env")
monkeypatch.delenv("SECRET_KEY")
assert config._secret_key() == (kept, "data")
def test_a_secret_key_file_that_is_missing_stops_the_start(tmp_path, monkeypatch):
monkeypatch.setenv("DATA_DIR", str(tmp_path))
monkeypatch.delenv("SECRET_KEY", raising=False)
monkeypatch.setenv("SECRET_KEY_FILE", str(tmp_path / "no-such-secret"))
with pytest.raises(RuntimeError, match="SECRET_KEY_FILE"):
config._secret_key()
assert not (tmp_path / SECRET_KEY_FILENAME).exists()
def test_with_nowhere_to_keep_a_key_the_process_gets_a_temporary_one(tmp_path, monkeypatch):
monkeypatch.setenv("DATA_DIR", str(tmp_path / "absent"))
monkeypatch.delenv("SECRET_KEY", raising=False)
monkeypatch.delenv("SECRET_KEY_FILE", raising=False)
key, source = config._secret_key()
assert source == "ephemeral"
assert key not in config.PLACEHOLDER_SECRET_KEYS and len(key) == 64