634 lines
21 KiB
Python
634 lines
21 KiB
Python
from __future__ import annotations
|
|
|
|
import io
|
|
import json
|
|
from datetime import datetime, timezone
|
|
|
|
import pytest
|
|
from sqlmodel import Session, SQLModel, create_engine, select
|
|
|
|
from backend.app.db.models import AuditEvent, Secret
|
|
from backend.app.services import workmesh_secret_import as importer
|
|
from backend.app.services.secret_store import get_secret, set_secret
|
|
|
|
REVISION = "a" * 40
|
|
CREDENTIAL = "W" * 48
|
|
PREFLIGHT_SHA = "b" * 64
|
|
BACKUP_SHA = "c" * 64
|
|
SECRET_REF = "WORKMESH_DEPLOYMENT_BROKER"
|
|
TARGET = "jens/workmesh"
|
|
CHECKS = {
|
|
"exactMain": "passed",
|
|
"pushValidation": "passed",
|
|
"controllerPreflight": "passed",
|
|
"dataBackup": "passed",
|
|
}
|
|
|
|
|
|
def _valid_preflight(revision: str = REVISION) -> dict[str, object]:
|
|
return {
|
|
"apiVersion": "itworx.workmesh-promotion/v2",
|
|
"status": "preflight_ok",
|
|
"repository": TARGET,
|
|
"revision": revision,
|
|
"preflightSha256": PREFLIGHT_SHA,
|
|
"backupInputsSha256": BACKUP_SHA,
|
|
"checks": dict(CHECKS),
|
|
"diagnostic": CREDENTIAL,
|
|
}
|
|
|
|
|
|
class FakeBroker:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
response: object | None = None,
|
|
error: Exception | None = None,
|
|
base_url: str = "https://controller.example",
|
|
verify_tls: bool = True,
|
|
config_error: str | None = None,
|
|
before_response=None,
|
|
):
|
|
self.response = _valid_preflight() if response is None else response
|
|
self.error = error
|
|
self.base_url = base_url
|
|
self.verify_tls = verify_tls
|
|
self._config_error = config_error
|
|
self.before_response = before_response
|
|
self.preflight_calls: list[tuple[str, str]] = []
|
|
self.apply_calls = 0
|
|
|
|
def configuration_error(self):
|
|
return self._config_error
|
|
|
|
def workmesh_promotion_preflight(self, revision: str, *, secret: str):
|
|
self.preflight_calls.append((revision, secret))
|
|
if self.before_response is not None:
|
|
self.before_response()
|
|
if self.error is not None:
|
|
raise self.error
|
|
return self.response
|
|
|
|
def workmesh_promotion_apply(self, *args, **kwargs):
|
|
self.apply_calls += 1
|
|
raise AssertionError("the credential importer must not invoke promotion apply")
|
|
|
|
|
|
@pytest.fixture
|
|
def database(tmp_path, monkeypatch):
|
|
engine = create_engine(f"sqlite:///{tmp_path / 'workmesh-secret-import.db'}")
|
|
SQLModel.metadata.create_all(engine)
|
|
monkeypatch.setattr(
|
|
importer, "secret_storage_status", lambda: {"productionReady": True}
|
|
)
|
|
with Session(engine) as session:
|
|
yield engine, session
|
|
engine.dispose()
|
|
|
|
|
|
def _rows(session: Session) -> tuple[Secret | None, list[AuditEvent]]:
|
|
return (
|
|
session.get(Secret, SECRET_REF),
|
|
list(session.exec(select(AuditEvent)).all()),
|
|
)
|
|
|
|
|
|
def _database_dump(session: Session) -> str:
|
|
secret, audits = _rows(session)
|
|
return repr(
|
|
{
|
|
"secret": secret.model_dump() if secret is not None else None,
|
|
"audits": [event.model_dump() for event in audits],
|
|
}
|
|
)
|
|
|
|
|
|
def _import(
|
|
session: Session,
|
|
*,
|
|
credential: str = CREDENTIAL,
|
|
revision: str = REVISION,
|
|
broker: FakeBroker | None = None,
|
|
):
|
|
return importer.import_workmesh_deployment_secret(
|
|
session,
|
|
credential,
|
|
revision=revision,
|
|
broker=broker or FakeBroker(),
|
|
)
|
|
|
|
|
|
def test_import_creates_encrypted_secret_after_verified_fixed_preflight_and_audits(
|
|
database,
|
|
):
|
|
_, session = database
|
|
broker = FakeBroker()
|
|
|
|
result = _import(session, broker=broker)
|
|
|
|
row = session.get(Secret, SECRET_REF)
|
|
assert result["status"] == "imported"
|
|
assert result["secretRef"] == SECRET_REF
|
|
assert result["repository"] == TARGET
|
|
assert result["revision"] == REVISION
|
|
assert result["preflightSha256"] == PREFLIGHT_SHA
|
|
assert result["readback"] == "verified"
|
|
assert row is not None
|
|
assert row.encrypted_value != CREDENTIAL
|
|
assert get_secret(session, SECRET_REF, touch=False) == CREDENTIAL
|
|
assert broker.preflight_calls == [(REVISION, CREDENTIAL)]
|
|
assert broker.apply_calls == 0
|
|
audits = session.exec(select(AuditEvent)).all()
|
|
assert len(audits) == 1
|
|
audit = audits[0]
|
|
assert audit.target == TARGET
|
|
assert audit.result == "success"
|
|
assert audit.redacted_metadata["storeReference"] == SECRET_REF
|
|
assert audit.redacted_metadata["revision"] == REVISION
|
|
assert audit.redacted_metadata["preflightSha256"] == PREFLIGHT_SHA
|
|
assert CREDENTIAL not in _database_dump(session)
|
|
|
|
|
|
def test_identical_import_verifies_and_audits_without_mutating_secret(database):
|
|
_, session = database
|
|
assert _import(session)["status"] == "imported"
|
|
row = session.get(Secret, SECRET_REF)
|
|
assert row is not None
|
|
before = {
|
|
"encrypted_value": row.encrypted_value,
|
|
"created_at": row.created_at,
|
|
"updated_at": row.updated_at,
|
|
"last_used_at": row.last_used_at,
|
|
"used_by_json": list(row.used_by_json),
|
|
"metadata_json": dict(row.metadata_json),
|
|
}
|
|
|
|
result = _import(session)
|
|
|
|
session.refresh(row)
|
|
assert result["status"] == "already_present"
|
|
assert {
|
|
"encrypted_value": row.encrypted_value,
|
|
"created_at": row.created_at,
|
|
"updated_at": row.updated_at,
|
|
"last_used_at": row.last_used_at,
|
|
"used_by_json": list(row.used_by_json),
|
|
"metadata_json": dict(row.metadata_json),
|
|
} == before
|
|
assert len(session.exec(select(AuditEvent)).all()) == 2
|
|
|
|
|
|
@pytest.mark.parametrize("credential", ["W" * 32, "W" * 256])
|
|
def test_credential_length_contract_accepts_exact_boundaries(database, credential):
|
|
_, session = database
|
|
|
|
result = _import(session, credential=credential)
|
|
|
|
assert result["status"] == "imported"
|
|
assert get_secret(session, SECRET_REF, touch=False) == credential
|
|
|
|
|
|
def test_different_existing_value_is_rejected_before_controller_preflight(database):
|
|
_, session = database
|
|
set_secret(session, SECRET_REF, "D" * 48, metadata={"owner": "keep"})
|
|
original = session.get(Secret, SECRET_REF)
|
|
assert original is not None
|
|
before = original.model_dump()
|
|
broker = FakeBroker()
|
|
|
|
with pytest.raises(importer.WorkMeshSecretImportError):
|
|
_import(session, broker=broker)
|
|
|
|
session.rollback()
|
|
current = session.get(Secret, SECRET_REF)
|
|
assert current is not None and current.model_dump() == before
|
|
assert broker.preflight_calls == []
|
|
assert session.exec(select(AuditEvent)).all() == []
|
|
|
|
|
|
def test_undecryptable_existing_value_is_rejected_without_replacement(database):
|
|
_, session = database
|
|
set_secret(session, SECRET_REF, "D" * 48)
|
|
row = session.get(Secret, SECRET_REF)
|
|
assert row is not None
|
|
row.encrypted_value = "unreadable-ciphertext"
|
|
session.add(row)
|
|
session.commit()
|
|
broker = FakeBroker()
|
|
|
|
with pytest.raises(importer.WorkMeshSecretImportError):
|
|
_import(session, broker=broker)
|
|
|
|
assert session.get(Secret, SECRET_REF).encrypted_value == "unreadable-ciphertext"
|
|
assert broker.preflight_calls == []
|
|
|
|
|
|
def test_concurrent_identical_insert_winner_is_retained_and_audited(database):
|
|
engine, session = database
|
|
winner_timestamp = datetime(2026, 10, 4, 12, 0, 0, tzinfo=timezone.utc)
|
|
|
|
def concurrent_winner():
|
|
with Session(engine) as winner_session:
|
|
set_secret(
|
|
winner_session,
|
|
SECRET_REF,
|
|
CREDENTIAL,
|
|
used_by=["concurrent-owner"],
|
|
metadata={"owner": "concurrent-winner"},
|
|
)
|
|
winner = winner_session.get(Secret, SECRET_REF)
|
|
assert winner is not None
|
|
winner.created_at = winner_timestamp
|
|
winner.updated_at = winner_timestamp
|
|
winner_session.add(winner)
|
|
winner_session.commit()
|
|
|
|
broker = FakeBroker(before_response=concurrent_winner)
|
|
result = _import(session, broker=broker)
|
|
|
|
winner = session.get(Secret, SECRET_REF)
|
|
assert result["status"] == "already_present"
|
|
assert winner is not None
|
|
assert winner.created_at.replace(tzinfo=timezone.utc) == winner_timestamp
|
|
assert winner.updated_at.replace(tzinfo=timezone.utc) == winner_timestamp
|
|
assert winner.used_by_json == ["concurrent-owner"]
|
|
assert winner.metadata_json == {"owner": "concurrent-winner"}
|
|
assert get_secret(session, SECRET_REF, touch=False) == CREDENTIAL
|
|
assert len(session.exec(select(AuditEvent)).all()) == 1
|
|
|
|
|
|
def test_concurrent_different_insert_winner_is_never_overwritten(database):
|
|
engine, session = database
|
|
|
|
def concurrent_winner():
|
|
with Session(engine) as winner_session:
|
|
set_secret(
|
|
winner_session,
|
|
SECRET_REF,
|
|
"D" * 48,
|
|
metadata={"owner": "concurrent-winner"},
|
|
)
|
|
|
|
broker = FakeBroker(before_response=concurrent_winner)
|
|
with pytest.raises(importer.WorkMeshSecretImportError):
|
|
_import(session, broker=broker)
|
|
|
|
session.rollback()
|
|
assert get_secret(session, SECRET_REF, touch=False) == "D" * 48
|
|
assert session.get(Secret, SECRET_REF).metadata_json == {
|
|
"owner": "concurrent-winner"
|
|
}
|
|
assert session.exec(select(AuditEvent)).all() == []
|
|
|
|
|
|
def test_production_secret_storage_must_be_ready(database, monkeypatch):
|
|
_, session = database
|
|
monkeypatch.setattr(
|
|
importer, "secret_storage_status", lambda: {"productionReady": False}
|
|
)
|
|
broker = FakeBroker()
|
|
|
|
with pytest.raises(importer.WorkMeshSecretImportError):
|
|
_import(session, broker=broker)
|
|
|
|
assert broker.preflight_calls == []
|
|
assert session.get(Secret, SECRET_REF) is None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"credential",
|
|
[
|
|
"W" * 31,
|
|
"W" * 257,
|
|
"W" * 31 + " ",
|
|
"W" * 31 + "\n",
|
|
"W" * 31 + "\u00e9",
|
|
"W" * 31 + "\x00",
|
|
],
|
|
)
|
|
def test_credential_must_be_bounded_printable_ascii_without_whitespace(
|
|
database, credential
|
|
):
|
|
_, session = database
|
|
broker = FakeBroker()
|
|
|
|
with pytest.raises(importer.WorkMeshSecretImportError):
|
|
_import(session, credential=credential, broker=broker)
|
|
|
|
assert broker.preflight_calls == []
|
|
assert session.get(Secret, SECRET_REF) is None
|
|
|
|
|
|
@pytest.mark.parametrize("revision", ["main", "A" * 40, "a" * 39, "a" * 41])
|
|
def test_revision_must_be_an_exact_full_lowercase_commit(database, revision):
|
|
_, session = database
|
|
broker = FakeBroker()
|
|
|
|
with pytest.raises(importer.WorkMeshSecretImportError):
|
|
_import(session, revision=revision, broker=broker)
|
|
|
|
assert broker.preflight_calls == []
|
|
assert session.get(Secret, SECRET_REF) is None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("base_url", "verify_tls", "config_error"),
|
|
[
|
|
("http://controller.example", True, None),
|
|
("https://controller.example", False, None),
|
|
("https://controller.example", True, "deployment_broker_url_invalid"),
|
|
],
|
|
)
|
|
def test_preflight_requires_verified_https_configuration(
|
|
database, base_url, verify_tls, config_error
|
|
):
|
|
_, session = database
|
|
broker = FakeBroker(
|
|
base_url=base_url,
|
|
verify_tls=verify_tls,
|
|
config_error=config_error,
|
|
)
|
|
|
|
with pytest.raises(importer.WorkMeshSecretImportError):
|
|
_import(session, broker=broker)
|
|
|
|
assert broker.preflight_calls == []
|
|
assert session.get(Secret, SECRET_REF) is None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"mutate",
|
|
[
|
|
lambda value: value.update(status="preflight_failed"),
|
|
lambda value: value.update(apiVersion="wrong/v1"),
|
|
lambda value: value.update(repository="jens/other"),
|
|
lambda value: value.update(revision="f" * 40),
|
|
lambda value: value.update(preflightSha256="invalid"),
|
|
lambda value: value.update(backupInputsSha256="invalid"),
|
|
lambda value: value.update(checks={"exactMain": "passed"}),
|
|
lambda value: value["checks"].update(pushValidation="blocked"),
|
|
],
|
|
)
|
|
def test_invalid_fixed_preflight_never_writes_secret_or_audit(database, mutate):
|
|
_, session = database
|
|
preflight = _valid_preflight()
|
|
mutate(preflight)
|
|
broker = FakeBroker(response=preflight)
|
|
|
|
with pytest.raises(importer.WorkMeshSecretImportError):
|
|
_import(session, broker=broker)
|
|
|
|
session.rollback()
|
|
assert session.get(Secret, SECRET_REF) is None
|
|
assert session.exec(select(AuditEvent)).all() == []
|
|
assert broker.apply_calls == 0
|
|
|
|
|
|
def test_non_mapping_preflight_is_rejected_without_storage_write(database):
|
|
_, session = database
|
|
broker = FakeBroker(response=["not", "a", "mapping"])
|
|
|
|
with pytest.raises(importer.WorkMeshSecretImportError):
|
|
_import(session, broker=broker)
|
|
|
|
session.rollback()
|
|
assert session.get(Secret, SECRET_REF) is None
|
|
|
|
|
|
def test_broker_exception_is_sanitized_and_never_persisted(database):
|
|
_, session = database
|
|
broker = FakeBroker(error=RuntimeError(f"upstream leaked {CREDENTIAL}"))
|
|
|
|
with pytest.raises(importer.WorkMeshSecretImportError) as error:
|
|
_import(session, broker=broker)
|
|
|
|
assert CREDENTIAL not in str(error.value)
|
|
assert broker.preflight_calls == [(REVISION, CREDENTIAL)]
|
|
session.rollback()
|
|
assert session.get(Secret, SECRET_REF) is None
|
|
assert session.exec(select(AuditEvent)).all() == []
|
|
|
|
|
|
def test_audit_insert_failure_rolls_back_secret_and_audit_together(database):
|
|
engine, session = database
|
|
|
|
def fail_audit_insert(conn, cursor, statement, parameters, context, executemany):
|
|
if statement.lstrip().lower().startswith("insert into auditevent"):
|
|
raise RuntimeError(f"database detail contained {CREDENTIAL}")
|
|
|
|
from sqlalchemy import event
|
|
|
|
event.listen(engine, "before_cursor_execute", fail_audit_insert)
|
|
try:
|
|
with pytest.raises(importer.WorkMeshSecretImportError) as error:
|
|
_import(session)
|
|
finally:
|
|
event.remove(engine, "before_cursor_execute", fail_audit_insert)
|
|
|
|
assert CREDENTIAL not in str(error.value)
|
|
session.rollback()
|
|
with Session(engine) as verify:
|
|
assert verify.get(Secret, SECRET_REF) is None
|
|
assert verify.exec(select(AuditEvent)).all() == []
|
|
|
|
|
|
def test_uncertain_readback_returns_blocked_without_replacing_committed_secret(
|
|
database, monkeypatch
|
|
):
|
|
engine, session = database
|
|
actual_get_secret = importer.get_secret
|
|
calls = 0
|
|
|
|
def fail_readback_once(db_session, name, *, touch=False):
|
|
nonlocal calls
|
|
calls += 1
|
|
if calls == 2:
|
|
return None
|
|
return actual_get_secret(db_session, name, touch=touch)
|
|
|
|
monkeypatch.setattr(importer, "get_secret", fail_readback_once)
|
|
broker = FakeBroker()
|
|
|
|
with pytest.raises(importer.WorkMeshSecretImportError) as error:
|
|
_import(session, broker=broker)
|
|
|
|
assert CREDENTIAL not in str(error.value)
|
|
monkeypatch.setattr(importer, "get_secret", actual_get_secret)
|
|
session.rollback()
|
|
with Session(engine) as verify:
|
|
row = verify.get(Secret, SECRET_REF)
|
|
assert row is not None
|
|
before = row.model_dump()
|
|
assert get_secret(verify, SECRET_REF, touch=False) == CREDENTIAL
|
|
assert len(verify.exec(select(AuditEvent)).all()) == 1
|
|
|
|
outcome = _import(session, broker=FakeBroker())
|
|
assert outcome["status"] == "already_present"
|
|
session.expire_all()
|
|
after = session.get(Secret, SECRET_REF)
|
|
assert after is not None and after.model_dump() == before
|
|
|
|
|
|
def test_uncertain_commit_returns_blocked_and_same_value_replay_does_not_replace(
|
|
database, monkeypatch
|
|
):
|
|
engine, session = database
|
|
actual_commit = session.commit
|
|
|
|
def commit_then_lose_acknowledgement():
|
|
if any(isinstance(item, AuditEvent) for item in session.new):
|
|
actual_commit()
|
|
raise RuntimeError(f"commit acknowledgement lost for {CREDENTIAL}")
|
|
actual_commit()
|
|
|
|
monkeypatch.setattr(session, "commit", commit_then_lose_acknowledgement)
|
|
with pytest.raises(importer.WorkMeshSecretImportError) as error:
|
|
_import(session)
|
|
assert CREDENTIAL not in str(error.value)
|
|
|
|
monkeypatch.setattr(session, "commit", actual_commit)
|
|
with Session(engine) as verify:
|
|
row = verify.get(Secret, SECRET_REF)
|
|
assert row is not None
|
|
before = row.model_dump()
|
|
assert get_secret(verify, SECRET_REF, touch=False) == CREDENTIAL
|
|
assert len(verify.exec(select(AuditEvent)).all()) == 1
|
|
|
|
result = _import(session, broker=FakeBroker())
|
|
assert result["status"] == "already_present"
|
|
session.expire_all()
|
|
after = session.get(Secret, SECRET_REF)
|
|
assert after is not None and after.model_dump() == before
|
|
assert len(session.exec(select(AuditEvent)).all()) == 2
|
|
|
|
|
|
def test_cli_imports_stdin_only_and_emits_sanitized_verification(database, monkeypatch):
|
|
engine, _ = database
|
|
broker = FakeBroker()
|
|
monkeypatch.setattr(importer, "engine", engine)
|
|
monkeypatch.setattr(importer, "DeploymentBrokerClient", lambda: broker)
|
|
output = io.StringIO()
|
|
|
|
exit_code = importer.main(
|
|
[REVISION],
|
|
stdin=io.BytesIO(CREDENTIAL.encode("ascii")),
|
|
stdout=output,
|
|
)
|
|
|
|
result = json.loads(output.getvalue())
|
|
assert exit_code == 0
|
|
assert result["status"] == "imported"
|
|
assert result["secretRef"] == SECRET_REF
|
|
assert result["revision"] == REVISION
|
|
assert CREDENTIAL not in output.getvalue()
|
|
assert broker.preflight_calls == [(REVISION, CREDENTIAL)]
|
|
|
|
|
|
def test_cli_rejects_extra_arguments_before_reading_secret(database, monkeypatch):
|
|
engine, _ = database
|
|
broker = FakeBroker()
|
|
monkeypatch.setattr(importer, "engine", engine)
|
|
monkeypatch.setattr(importer, "DeploymentBrokerClient", lambda: broker)
|
|
output = io.StringIO()
|
|
|
|
class UnreadableInput:
|
|
def read(self, _size):
|
|
pytest.fail("invalid command line must be rejected before reading stdin")
|
|
|
|
exit_code = importer.main(
|
|
[REVISION, CREDENTIAL],
|
|
stdin=UnreadableInput(),
|
|
stdout=output,
|
|
)
|
|
|
|
assert exit_code != 0
|
|
assert json.loads(output.getvalue()) == {
|
|
"reason": "invalid_arguments",
|
|
"status": "blocked",
|
|
}
|
|
assert broker.preflight_calls == []
|
|
|
|
|
|
def test_cli_rejects_non_ascii_stdin_without_calling_controller(database, monkeypatch):
|
|
engine, _ = database
|
|
broker = FakeBroker()
|
|
monkeypatch.setattr(importer, "engine", engine)
|
|
monkeypatch.setattr(importer, "DeploymentBrokerClient", lambda: broker)
|
|
output = io.StringIO()
|
|
|
|
exit_code = importer.main(
|
|
[REVISION],
|
|
stdin=io.BytesIO(b"W" * 47 + b"\xff"),
|
|
stdout=output,
|
|
)
|
|
|
|
assert exit_code != 0
|
|
assert json.loads(output.getvalue()) == {
|
|
"reason": "invalid_input",
|
|
"status": "blocked",
|
|
}
|
|
assert broker.preflight_calls == []
|
|
|
|
|
|
def test_cli_rejects_oversized_stdin_without_calling_controller(database, monkeypatch):
|
|
engine, _ = database
|
|
broker = FakeBroker()
|
|
monkeypatch.setattr(importer, "engine", engine)
|
|
monkeypatch.setattr(importer, "DeploymentBrokerClient", lambda: broker)
|
|
output = io.StringIO()
|
|
|
|
exit_code = importer.main(
|
|
[REVISION],
|
|
stdin=io.BytesIO(b"W" * 4097),
|
|
stdout=output,
|
|
)
|
|
|
|
assert exit_code != 0
|
|
assert json.loads(output.getvalue())["status"] == "blocked"
|
|
assert "W" * 48 not in output.getvalue()
|
|
assert broker.preflight_calls == []
|
|
|
|
|
|
def test_cli_unexpected_error_never_echoes_secret(database, monkeypatch):
|
|
engine, _ = database
|
|
broker = FakeBroker(error=RuntimeError(f"unexpected {CREDENTIAL}"))
|
|
monkeypatch.setattr(importer, "engine", engine)
|
|
monkeypatch.setattr(importer, "DeploymentBrokerClient", lambda: broker)
|
|
output = io.StringIO()
|
|
|
|
exit_code = importer.main(
|
|
[REVISION],
|
|
stdin=io.BytesIO(CREDENTIAL.encode("ascii")),
|
|
stdout=output,
|
|
)
|
|
|
|
result = json.loads(output.getvalue())
|
|
assert exit_code != 0
|
|
assert result["status"] == "blocked"
|
|
assert CREDENTIAL not in output.getvalue()
|
|
assert "unexpected" not in output.getvalue()
|
|
assert "traceback" not in output.getvalue().lower()
|
|
|
|
|
|
@pytest.mark.parametrize("tampered", [False, True])
|
|
def test_cli_unknown_typed_error_code_is_normalized(database, monkeypatch, tampered):
|
|
engine, _ = database
|
|
monkeypatch.setattr(importer, "engine", engine)
|
|
monkeypatch.setattr(importer, "DeploymentBrokerClient", FakeBroker)
|
|
|
|
def fail_import(*args, **kwargs):
|
|
error = importer.WorkMeshSecretImportError(CREDENTIAL)
|
|
if tampered:
|
|
error.code = CREDENTIAL
|
|
raise error
|
|
|
|
monkeypatch.setattr(importer, "import_workmesh_deployment_secret", fail_import)
|
|
output = io.StringIO()
|
|
assert importer.main(
|
|
[REVISION], stdin=io.BytesIO(CREDENTIAL.encode("ascii")), stdout=output
|
|
) == 2
|
|
assert json.loads(output.getvalue()) == {
|
|
"status": "blocked", "reason": "import_failed"
|
|
}
|
|
assert CREDENTIAL not in output.getvalue()
|