Implement attended Railiance Clock host key delivery
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a09cbb-87c6-7900-a145-4ce53ba9f1a6
This commit is contained in:
parent
3b11773469
commit
e70ef2f32a
7 changed files with 284 additions and 49 deletions
|
|
@ -1,14 +1,15 @@
|
|||
id: CCR-2026-0028
|
||||
kind: credential-change-request
|
||||
schema_version: 1
|
||||
request_type: workload-kv-read
|
||||
request_type: attended-host-key-delivery
|
||||
title: Railiance Clock authority signing key custody and host delivery
|
||||
status: approved
|
||||
created: '2026-09-15'
|
||||
updated: '2026-09-15'
|
||||
requester:
|
||||
agent: codex
|
||||
reason: User requested production admission of the Railiance Clock authority after the host time baseline and disposable native acceptance passed.
|
||||
reason: User requested production admission of the Railiance Clock authority after
|
||||
the host time baseline and disposable native acceptance passed.
|
||||
review:
|
||||
required: true
|
||||
required_approvers:
|
||||
|
|
@ -22,71 +23,83 @@ review:
|
|||
- at: '2026-09-15'
|
||||
reviewer: User (railiance-clock-owner)
|
||||
decision: approved
|
||||
comment: User explicitly approved CCR-2026-0028 for the railiance-clock-owner role.
|
||||
comment: User explicitly approved CCR-2026-0028 for the railiance-clock-owner
|
||||
role.
|
||||
- at: '2026-09-15'
|
||||
reviewer: User (platform-operator and railiance-clock-owner)
|
||||
decision: approved_host_delivery
|
||||
comment: User accepted the corrected systemd host-file design and instructed then
|
||||
lets go; existing two-role approval retained.
|
||||
target:
|
||||
domain: infotech
|
||||
tenant: platform
|
||||
workload: railiance-clock
|
||||
environment: production
|
||||
purpose: Hold one ES256 authority private signing key in OpenBao and deliver it only to the private railiance01 Railiance Clock service; no public listener or consumer-wide distribution.
|
||||
purpose: Hold one ES256 authority private signing key in OpenBao and deliver it
|
||||
only to the private railiance01 Railiance Clock service; no public listener or
|
||||
consumer-wide distribution.
|
||||
openbao:
|
||||
mount: platform
|
||||
kv_path: platform/workloads/railiance-clock/authority-signing
|
||||
fields:
|
||||
- PRIVATE_KEY_PEM
|
||||
- KEY_ID
|
||||
metadata_read: false
|
||||
token_self_lifecycle: true
|
||||
policy_name: workload-kv-read-railiance-clock-authority-signing
|
||||
policy_file: openbao/policies/workload-kv-read-railiance-clock-authority-signing.hcl
|
||||
auth:
|
||||
method: kubernetes
|
||||
mount: kubernetes
|
||||
role: external-secrets-railiance-clock-authority
|
||||
bound_claims:
|
||||
service_account_names:
|
||||
- railiance-clock
|
||||
service_account_namespaces:
|
||||
- railiance-clock
|
||||
bound_claims_confirmed: false
|
||||
policies:
|
||||
- workload-kv-read-railiance-clock-authority-signing
|
||||
ttl: 15m
|
||||
audience: openbao
|
||||
token_max_ttl: 15m
|
||||
token_explicit_max_ttl: 15m
|
||||
token_no_default_policy: true
|
||||
method: attended-oidc
|
||||
mount: netkingdom
|
||||
role: platform-admin
|
||||
runtime_access: false
|
||||
initial_cas: 0
|
||||
access_frontdoor:
|
||||
type: host-file-delivery
|
||||
catalog_id: railiance-clock-authority-signing
|
||||
selector: Private Railiance Clock authority key on railiance01
|
||||
readiness: pending-review
|
||||
type: attended-host-file
|
||||
catalog_id: openbao-platform-admin-login
|
||||
readiness: approved-pending-apply
|
||||
resolvable: false
|
||||
delivery:
|
||||
surface: attended-host-file
|
||||
target: railiance01:/etc/railiance-clock/authority-key.pem, owner railiance-clock, mode 0600; key material must be generated and delivered through the governed OpenBao platform-admin lane and never appear in Git, argv, logs, or chat.
|
||||
bootstrap_command: See the attended platform custody runbook to be added with this CCR; configure the service only after key admission, public-key metadata readback, and exact artifact hash verification.
|
||||
host: 92.205.62.239
|
||||
ssh_user: tegwick
|
||||
target: /var/lib/railiance-clock/signing.pem
|
||||
owner: railiance-clock
|
||||
mode: '0600'
|
||||
directory_mode: '0700'
|
||||
procedure: scripts/railiance_clock_custody.py
|
||||
risk:
|
||||
classification: high
|
||||
notes:
|
||||
- Compromise permits forging Railiance Clock samples until the key is revoked and every consumer trust file is replaced.
|
||||
- The authority remains loopback-only on railiance01; no workstation-wide or public endpoint is admitted.
|
||||
- Key generation, OpenBao write, host delivery, and rotation require an attended platform operator and railiance-clock owner review.
|
||||
- Compromise permits forging Railiance Clock samples until the key is revoked and
|
||||
every consumer trust file is replaced.
|
||||
- The authority remains loopback-only on railiance01; no workstation-wide or public
|
||||
endpoint is admitted.
|
||||
- Key generation, OpenBao write, host delivery, and rotation require an attended
|
||||
platform operator and railiance-clock owner review.
|
||||
verification:
|
||||
positive:
|
||||
- Exact service identity reads only PRIVATE_KEY_PEM and KEY_ID; authority readiness and signed sample verification pass.
|
||||
- Host file ownership and mode are exact; private key value is absent from all command output and logs.
|
||||
- Attended platform-admin identity and OpenBao audit enabled; initial key stored
|
||||
with CAS zero.
|
||||
- Host account, private directory, exact key ownership and permissions verified;
|
||||
public key matches custody.
|
||||
- Authority readiness and independent signed sample verification pass.
|
||||
negative:
|
||||
- Other service accounts, namespaces, sibling paths, metadata/listing, and public listeners are denied.
|
||||
- Wrong key id, wrong authority/environment/epoch, expired trust, rollback, and stale health fail closed.
|
||||
- Existing key is never overwritten during initial admission; interrupted delivery
|
||||
resumes only with the admitted custody version.
|
||||
- Wrong key id, authority, environment, epoch, stale health and expired trust are
|
||||
refused.
|
||||
- No new workload role, policy or token; no public authority listener.
|
||||
activation_conditions:
|
||||
- CCR approved by platform-operator and railiance-clock-owner.
|
||||
- Dedicated OpenBao policy, Kubernetes role, and host delivery path are applied and read back without secret disclosure.
|
||||
- Fresh public-key metadata, artifact SHA-256, /readyz, and colocated sample verification are recorded.
|
||||
- Existing two-role approval covers attended host delivery.
|
||||
- Ansible prepares the service account and private directory before custody.
|
||||
- Reviewed helper runs through warden access openbao-platform-admin-login --exec;
|
||||
host key verification is required.
|
||||
- Fresh public key, artifact SHA256, readiness and signed sample acceptance are
|
||||
recorded.
|
||||
evidence: []
|
||||
lifecycle:
|
||||
deactivate: Stop the authority, remove the host key, revoke the workload policy/role, and replace consumer trust files.
|
||||
rotate: Stop authority and consumers, generate a new key under attended custody, deliver mode 0600, then replace trust files and verify old signatures fail.
|
||||
compromised: Stop the authority immediately, revoke the policy and key version, replace all trust files, and restart only after fresh verification.
|
||||
deactivate: Stop authority and withdraw consumer trust. Retain custody and host
|
||||
key until owner retention decision.
|
||||
rotate: Stop authority, perform separately versioned attended CAS rotation and atomic
|
||||
host replacement, then admit new public key and epoch for consumers.
|
||||
compromised: Stop authority immediately and withdraw all consumer trust; replace
|
||||
key forward under attended custody before restarting.
|
||||
state_hub:
|
||||
workplan_id: RCLK-WP-0005
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
# Railiance Clock attended host delivery — CCR-2026-0028
|
||||
|
||||
The operator approved CCR-2026-0028 for both platform and Clock ownership, then
|
||||
accepted the correction to systemd host delivery. Kubernetes is present on
|
||||
Railiance01, but this authority runs as a host service and needs no Kubernetes
|
||||
identity or standing OpenBao read grant.
|
||||
|
||||
Ansible's `railiance_clock_prepare_only=true` prepares the nologin system account,
|
||||
root-owned installation directory, and service-owned mode-0700 state directory.
|
||||
It does not require a key or activate the authority.
|
||||
|
||||
Run the committed `scripts/railiance_clock_custody.py --receipt <new-private-file>`
|
||||
inside `warden access openbao-platform-admin-login --exec -- python3 ...`.
|
||||
The helper requires the exact approved CCR, a non-root platform-admin session
|
||||
from auth/netkingdom, and enabled audit. It generates an ES256 key in memory,
|
||||
creates the exact KV path with CAS zero, and verifies version 1 before delivery.
|
||||
No workload policy or role is created. SSH uses strict known-host verification;
|
||||
the receiver writes a complete mode-0600 file atomically to
|
||||
`/var/lib/railiance-clock/signing.pem`, owned by `railiance-clock`, and refuses to
|
||||
overwrite any different existing key. Only public-key metadata enters the receipt.
|
||||
|
||||
If custody succeeded and host delivery failed, inspect the receipt stage/version.
|
||||
Use `--resume-version 1` through a fresh attended envelope to deliver that same
|
||||
initial version. This never creates or rotates a key. A changed custody version,
|
||||
existing different host key, or permission drift requires reconciliation.
|
||||
|
||||
After successful delivery, Ansible uses `railiance_clock_key_preinstalled=true`,
|
||||
checks private key metadata, installs the pinned wheel and health probe, and
|
||||
starts the localhost-only authority. `railiance_clock_private_key_source` is not
|
||||
needed in this mode. Readiness and independent signature verification are required
|
||||
before marking the CCR verified. No client trust is inferred from readiness.
|
||||
|
||||
Rotation is a separate explicit operation: stop the authority, CAS against the
|
||||
known current custody version, replace the host key atomically, and distribute
|
||||
new public key/epoch trust before resuming clients. This initial-admission helper
|
||||
deliberately does not implement rotation or overwrite keys.
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
path "platform/data/workloads/railiance-clock/authority-signing" {
|
||||
capabilities = ["read"]
|
||||
}
|
||||
|
|
@ -38,6 +38,7 @@ allowed_statuses:
|
|||
|
||||
allowed_request_types:
|
||||
- workload-kv-read
|
||||
- attended-host-key-delivery
|
||||
|
||||
secret_markers_rejected:
|
||||
- AGE-SECRET-KEY-1
|
||||
|
|
@ -157,3 +158,7 @@ guardrails:
|
|||
disallowed_path_fragments:
|
||||
- "*"
|
||||
- ".."
|
||||
|
||||
# Attended host delivery is currently restricted to CCR-2026-0028.
|
||||
# Exact contract validation: scripts/railiance_clock_custody.py.
|
||||
# Delegated workload appliers must refuse this request type.
|
||||
|
|
|
|||
|
|
@ -464,10 +464,16 @@ def validate_ccr(path: Path) -> tuple[dict[str, Any], list[str], list[str]]:
|
|||
if isinstance(status, str) and status not in ALLOWED_STATUSES:
|
||||
errors.append(f"status must be one of {sorted(ALLOWED_STATUSES)}")
|
||||
request_type = ccr.get("request_type")
|
||||
if request_type != "workload-kv-read":
|
||||
errors.append("request_type must be workload-kv-read")
|
||||
else:
|
||||
if request_type == "workload-kv-read":
|
||||
validate_workload_kv_read(ccr, errors, warnings)
|
||||
elif request_type == "attended-host-key-delivery":
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location("railiance_clock_custody", REPO_DIR / "scripts/railiance_clock_custody.py")
|
||||
owner = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(owner)
|
||||
errors.extend(owner.validate_contract(ccr))
|
||||
else:
|
||||
errors.append("unsupported request_type")
|
||||
return ccr, errors, warnings
|
||||
|
||||
|
||||
|
|
@ -554,6 +560,8 @@ def render_summary(ccr: dict[str, Any], warnings: list[str]) -> str:
|
|||
|
||||
|
||||
def generated_policy_hcl(ccr: dict[str, Any]) -> str:
|
||||
if ccr.get("request_type") != "workload-kv-read":
|
||||
fail("attended host delivery uses its owner procedure; no workload policy")
|
||||
openbao = ccr["openbao"]
|
||||
mount = openbao["mount"]
|
||||
suffix = openbao["kv_path"][len(mount) + 1 :]
|
||||
|
|
|
|||
132
scripts/railiance_clock_custody.py
Normal file
132
scripts/railiance_clock_custody.py
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Attended CCR-2026-0028 initial custody and host delivery. Silent, resumable."""
|
||||
from pathlib import Path
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CCR = ROOT / 'credential-change-requests/CCR-2026-0028-railiance-clock-authority-signing.yaml'
|
||||
KV = 'platform/data/workloads/railiance-clock/authority-signing'
|
||||
KID = 'railiance01-clock-20260915-v1'
|
||||
TARGET = '/var/lib/railiance-clock/signing.pem'
|
||||
|
||||
|
||||
def validate_contract(ccr):
|
||||
errors=[]
|
||||
expected={'mount':'platform','kv_path':'platform/workloads/railiance-clock/authority-signing',
|
||||
'fields':['PRIVATE_KEY_PEM','KEY_ID'],'auth':{'method':'attended-oidc','mount':'netkingdom','role':'platform-admin'},
|
||||
'runtime_access':False,'initial_cas':0}
|
||||
delivery={'surface':'attended-host-file','host':'92.205.62.239','ssh_user':'tegwick',
|
||||
'target':TARGET,'owner':'railiance-clock','mode':'0600','directory_mode':'0700',
|
||||
'procedure':'scripts/railiance_clock_custody.py'}
|
||||
if ccr.get('id')!='CCR-2026-0028' or ccr.get('request_type')!='attended-host-key-delivery':
|
||||
errors.append('unsupported attended host contract')
|
||||
if ccr.get('openbao')!=expected or ccr.get('delivery')!=delivery:
|
||||
errors.append('exact Clock custody and host binding required')
|
||||
if ccr.get('access_frontdoor',{}).get('resolvable') is not False:
|
||||
errors.append('runtime credential frontdoor must remain disabled')
|
||||
if ccr.get('status') in {'approved','applied','verified','active'}:
|
||||
comments=ccr.get('review',{}).get('comments',[])
|
||||
for role in ['platform-operator','railiance-clock-owner']:
|
||||
if not any(c.get('reviewer')==f'User ({role})' and c.get('decision')=='approved' for c in comments):
|
||||
errors.append('explicit role approval missing: '+role)
|
||||
for section,fields in {'verification':['positive','negative','activation_conditions'],
|
||||
'lifecycle':['deactivate','rotate','compromised']}.items():
|
||||
for field in fields:
|
||||
if not ccr.get(section,{}).get(field):errors.append(section+'.'+field+' required')
|
||||
return errors
|
||||
|
||||
class CustodyError(Exception): pass
|
||||
|
||||
def run_cmd(argv,payload=None):
|
||||
r=subprocess.run(argv,input=payload,capture_output=True,timeout=90)
|
||||
if r.returncode:raise CustodyError('contained_command_failed')
|
||||
return r.stdout
|
||||
|
||||
def bao(args,payload=None):
|
||||
return json.loads(run_cmd(['bao',*args],None if payload is None else json.dumps(payload).encode()))
|
||||
|
||||
REMOTE = '''import os,sys,stat,pwd,subprocess,json,hashlib
|
||||
p='/var/lib/railiance-clock/signing.pem'
|
||||
u=pwd.getpwnam('railiance-clock')
|
||||
d=os.lstat(os.path.dirname(p))
|
||||
assert stat.S_ISDIR(d.st_mode) and d.st_uid==u.pw_uid and stat.S_IMODE(d.st_mode)==0o700
|
||||
key=sys.stdin.buffer.read(8193)
|
||||
assert 0<len(key)<=8192
|
||||
# Publish the complete key atomically; initial delivery never overwrites a key.
|
||||
if not os.path.lexists(p):
|
||||
tmp=p+'.admission-'+str(os.getpid())
|
||||
fd=os.open(tmp,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
try:
|
||||
os.fchown(fd,u.pw_uid,u.pw_gid)
|
||||
with os.fdopen(fd,'wb') as f:
|
||||
f.write(key);f.flush();os.fsync(f.fileno())
|
||||
os.link(tmp,p)
|
||||
finally:
|
||||
os.unlink(tmp)
|
||||
fd=os.open(p,os.O_RDONLY|os.O_NOFOLLOW)
|
||||
with os.fdopen(fd,'rb') as f:
|
||||
st=os.fstat(f.fileno())
|
||||
assert stat.S_ISREG(st.st_mode) and st.st_uid==u.pw_uid and stat.S_IMODE(st.st_mode)==0o600
|
||||
assert f.read(8193)==key
|
||||
r=subprocess.run(['openssl','pkey','-pubout'],input=key,capture_output=True,check=True)
|
||||
print(json.dumps({'public_key_sha256':hashlib.sha256(r.stdout).hexdigest(),'owner':'railiance-clock','mode':'0600'}))
|
||||
'''
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--receipt',required=True)
|
||||
p.add_argument('--resume-version',type=int)
|
||||
a=p.parse_args()
|
||||
fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
receipt={'schema':'platform.railiance-clock-host-custody.v1','status':'failed','stage':'contract','ccr':'CCR-2026-0028'}
|
||||
try:
|
||||
import yaml
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
ccr=yaml.safe_load(CCR.read_text())
|
||||
if validate_contract(ccr) or ccr['status'] not in {'approved','applied','verified','active'}:
|
||||
raise CustodyError('approved_exact_contract_required')
|
||||
receipt['stage']='attended_identity'
|
||||
who=bao(['token','lookup','-format=json'])['data']
|
||||
if 'platform-admin' not in who['policies'] or 'root' in who['policies'] or not who['path'].startswith('auth/netkingdom/'):
|
||||
raise CustodyError('attended_platform_admin_required')
|
||||
if not bao(['audit','list','-format=json']):raise CustodyError('audit_required')
|
||||
receipt['stage']='custody'
|
||||
if a.resume_version is None:
|
||||
key=ec.generate_private_key(ec.SECP256R1())
|
||||
pem=key.private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.PKCS8,serialization.NoEncryption()).decode()
|
||||
result=bao(['write','-format=json',KV,'-'],{'options':{'cas':0},'data':{'PRIVATE_KEY_PEM':pem,'KEY_ID':KID}})
|
||||
version=result['data']['version']
|
||||
else:
|
||||
if a.resume_version!=1:raise CustodyError('only_initial_version_resume_supported')
|
||||
version=a.resume_version
|
||||
receipt['kv_version']=version
|
||||
native=bao(['read','-format=json',KV])['data']
|
||||
if native['metadata']['version']!=version or version!=1:raise CustodyError('custody_version_changed')
|
||||
values=native['data']
|
||||
if set(values)!={'PRIVATE_KEY_PEM','KEY_ID'} or values['KEY_ID']!=KID:raise CustodyError('custody_shape_mismatch')
|
||||
pem=values['PRIVATE_KEY_PEM'].encode()
|
||||
key=serialization.load_pem_private_key(pem,password=None)
|
||||
if not isinstance(key,ec.EllipticCurvePrivateKey) or not isinstance(key.curve,ec.SECP256R1):raise CustodyError('es256_key_required')
|
||||
public=key.public_key().public_bytes(serialization.Encoding.PEM,serialization.PublicFormat.SubjectPublicKeyInfo)
|
||||
receipt.update(kid=KID,public_key_pem=public.decode(),public_key_sha256=hashlib.sha256(public).hexdigest())
|
||||
receipt['stage']='host_delivery'
|
||||
remote=json.loads(run_cmd(['ssh','-o','BatchMode=yes','-o','StrictHostKeyChecking=yes','-o','ConnectTimeout=10',
|
||||
'tegwick@92.205.62.239','sudo -n python3 -c '+shlex.quote(REMOTE)],pem))
|
||||
if remote['public_key_sha256']!=receipt['public_key_sha256']:raise CustodyError('host_public_key_mismatch')
|
||||
receipt['host']=remote
|
||||
receipt['status']='delivered_pending_authority_acceptance'
|
||||
receipt['stage']='complete'
|
||||
except Exception as e:
|
||||
receipt['error']=str(e) if isinstance(e,CustodyError) else 'internal_error'
|
||||
finally:
|
||||
with os.fdopen(fd,'w') as f:json.dump(receipt,f,indent=2);f.write('\n')
|
||||
return 0 if receipt['status']!='failed' else 1
|
||||
|
||||
if __name__=='__main__':sys.exit(main())
|
||||
44
tests/test_railiance_clock_custody.py
Normal file
44
tests/test_railiance_clock_custody.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import yaml
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
spec=importlib.util.spec_from_file_location('clock_custody',ROOT/'scripts/railiance_clock_custody.py')
|
||||
m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m)
|
||||
|
||||
class CustodyTests(unittest.TestCase):
|
||||
def setUp(self):self.contract=yaml.safe_load(m.CCR.read_text())
|
||||
def test_valid(self):self.assertEqual(m.validate_contract(self.contract),[])
|
||||
def test_scope_and_approval_drift(self):
|
||||
for change in [lambda c:c['delivery'].update(host='other'),lambda c:c['openbao'].update(runtime_access=True),lambda c:c['review'].update(comments=[]),lambda c:c['openbao'].update(initial_cas=1)]:
|
||||
c=copy.deepcopy(self.contract);change(c);self.assertTrue(m.validate_contract(c))
|
||||
def run_failure(self,reply,expected_stage):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
out=Path(d)/'receipt.json'
|
||||
with patch.object(sys,'argv',['helper','--receipt',str(out)]),patch.object(m,'bao',side_effect=reply),patch.object(m,'run_cmd') as cmd:
|
||||
self.assertEqual(m.main(),1);cmd.assert_not_called()
|
||||
receipt=json.loads(out.read_text());self.assertEqual(receipt['stage'],expected_stage)
|
||||
self.assertNotIn('PRIVATE KEY',out.read_text())
|
||||
def test_root_denied(self):
|
||||
self.run_failure([{'data':{'policies':['root','platform-admin'],'path':'auth/netkingdom/login'}}],'attended_identity')
|
||||
def test_wrong_auth_mount_denied(self):
|
||||
self.run_failure([{'data':{'policies':['platform-admin'],'path':'auth/token/create'}}],'attended_identity')
|
||||
def test_audit_required(self):
|
||||
self.run_failure([{'data':{'policies':['platform-admin'],'path':'auth/netkingdom/login'}},{}],'attended_identity')
|
||||
def test_cas_failure_prevents_delivery(self):
|
||||
self.run_failure([{'data':{'policies':['platform-admin'],'path':'auth/netkingdom/login'}},{'file/':{}},m.CustodyError('contained_command_failed')],'custody')
|
||||
def test_receipt_never_overwrites(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
out=Path(d)/'receipt.json';out.write_text('previous evidence')
|
||||
with patch.object(sys,'argv',['helper','--receipt',str(out)]),patch.object(m,'bao') as b:
|
||||
with self.assertRaises(FileExistsError):m.main()
|
||||
b.assert_not_called()
|
||||
self.assertEqual(out.read_text(),'previous evidence')
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue