diff --git a/src/signify/app/aiding.py b/src/signify/app/aiding.py index 87b7a2a..6bc337a 100644 --- a/src/signify/app/aiding.py +++ b/src/signify/app/aiding.py @@ -44,8 +44,7 @@ def get(self, name): def rename(self, name, newName): """Rename an identifier alias without changing its underlying AID.""" - res = self.client.put(f"/identifiers/{name}", json={"name": newName}) - return res.json() + return self.update(name, {"name": newName}) def create(self, name, transferable=True, isith="1", nsith="1", wits=None, toad="0", proxy=None, delpre=None, dcode=MtrDex.Blake3_256, data=None, algo=Algos.salty, estOnly=False, DnD=False, **kwargs): @@ -116,8 +115,20 @@ def create(self, name, transferable=True, isith="1", nsith="1", wits=None, toad= res = self.client.post("/identifiers", json=body) return serder, sigs, res.json() - def update(self, name, typ, **kwas): - """Dispatch an identifier update to either ``interact`` or ``rotate``.""" + def update(self, name, info=None, typ=None, **kwas): + """Update identifier metadata or dispatch an interaction/rotation flow. + + ``update(name, {"name": "new-alias"})`` is the TS-compatible rename + path. The older dispatcher mode remains supported through either + ``update(name, typ="interact", ...)`` or ``update(name, "interact", ...)``. + """ + if isinstance(info, dict) and typ is None: + res = self.client.put(f"/identifiers/{name}", json=info) + return res.json() + + if typ is None: + typ = info + if typ == "interact": return self.interact(name, **kwas) elif typ == "rotate": @@ -131,6 +142,12 @@ def delete(self, name): def interact(self, name, data=None): """Create and submit a signed interaction event for an identifier.""" + serder, sigs, body = self.createInteract(name, data=data) + res = self.client.post(f"/identifiers/{name}/events", json=body) + return serder, sigs, res.json() + + def createInteract(self, name, data=None): + """Create the local interaction event payload without submitting it.""" hab = self.get(name) pre = hab["prefix"] @@ -148,9 +165,7 @@ def interact(self, name, data=None): ixn=serder.ked, sigs=sigs) body[keeper.algo] = keeper.params() - - res = self.client.post(f"/identifiers/{name}/events", json=body) - return serder, sigs, res.json() + return serder, sigs, body def rotate(self, name, *, transferable=True, nsith=None, toad=None, cuts=None, adds=None, data=None, ncode=MtrDex.Ed25519_Seed, ncount=1, ncodes=None, states=None, rstates=None): diff --git a/src/signify/app/clienting.py b/src/signify/app/clienting.py index 0a045d0..5964832 100644 --- a/src/signify/app/clienting.py +++ b/src/signify/app/clienting.py @@ -13,9 +13,11 @@ import sseclient from keri import kering from keri.core.coring import Tiers +from keri.end import ending from keri.help import helping from requests import HTTPError from requests.auth import AuthBase +from requests.structures import CaseInsensitiveDict from signify.core import keeping, authing, api from signify.signifying import SignifyState @@ -24,6 +26,8 @@ class SignifyClient: """Edge-signing client bound to one controller AID and delegated agent.""" + ExternalRequestFields = ["@method", "@path", "Signify-Resource", "Signify-Timestamp"] + def __init__(self, passcode, url=None, boot_url=None, tier=Tiers.low, extern_modules=None): """ Create a new SignifyClient. Connects to the KERIA instance and delegates from the local @@ -172,6 +176,10 @@ def states(self): return state + def state(self): + """Compatibility wrapper returning the current controller/agent state bundle.""" + return self.states() + def _save_old_salt(self, salt): """Persist the previous controller salt during passcode rotation flows.""" caid = self.ctrl.pre @@ -179,16 +187,22 @@ def _save_old_salt(self, salt): res = self.put(f"/salt/{caid}", json=body) return res.status_code == 204 + def saveOldPasscode(self, passcode): + """Persist a prior controller passcode during passcode rotation.""" + return self._save_old_salt(passcode) + def _delete_old_salt(self): """Delete the previously persisted controller salt after rotation.""" caid = self.ctrl.pre res = self.delete(f"/salt/{caid}") return res.status_code == 204 - def get(self, path, params=None, headers=None, body=None): - """Issue an authenticated ``GET`` request relative to the client base URL.""" - url = urljoin(self.base, path) + def deletePasscode(self): + """Delete any previously persisted controller passcode backup.""" + return self._delete_old_salt() + def _request_kwargs(self, *, params=None, headers=None, json=None): + """Build ``requests`` kwargs for an authenticated relative request.""" kwargs = dict() if params is not None: kwargs["params"] = params @@ -196,28 +210,114 @@ def get(self, path, params=None, headers=None, body=None): if headers is not None: kwargs["headers"] = headers - if body is not None: - kwargs["json"] = body + if json is not None: + kwargs["json"] = json + + return kwargs + + def _request(self, method, path, *, params=None, headers=None, json=None): + """Issue an authenticated HTTP request relative to the client base URL.""" + url = urljoin(self.base, path) + kwargs = self._request_kwargs(params=params, headers=headers, json=json) + + requester = getattr(self.session, method.lower(), None) + if requester is None: + res = self.session.request(method=method, url=url, **kwargs) + else: + res = requester(url, **kwargs) - res = self.session.get(url, **kwargs) if not res.ok: self.raiseForStatus(res) return res - def stream(self, path, params=None, headers=None, body=None): - """Open a server-sent-event stream against an authenticated endpoint.""" - url = urljoin(self.base, path) + @staticmethod + def _signature_path(url): + """Return the path component used when signing an external request URL.""" + path = urlsplit(url).path + return path if path else "/" - kwargs = dict() - if params is not None: - kwargs["params"] = params + @staticmethod + def _signature_headers_for_signer(headers, method, path, signer): + """Attach Signify signature headers using one explicit signer.""" + signed_headers = CaseInsensitiveDict(headers) + header, qsig = ending.siginput( + "signify", + method, + path, + signed_headers, + fields=SignifyClient.ExternalRequestFields, + signers=[signer], + alg="ed25519", + keyid=signer.verfer.qb64, + ) + for key, val in header.items(): + signed_headers[key] = val + + signage = ending.Signage( + markers=dict(signify=qsig), + indexed=False, + signer=None, + ordinal=None, + digest=None, + kind=None, + ) + for key, val in ending.signature([signage]).items(): + signed_headers[key] = val - if headers is not None: - kwargs["headers"] = headers + return signed_headers - if body is not None: - kwargs["json"] = body + def get(self, path, params=None, headers=None, body=None): + """Issue an authenticated ``GET`` request relative to the client base URL.""" + return self._request("GET", path, params=params, headers=headers, json=body) + + def fetch(self, path, method, data, headers=None): + """Compatibility wrapper for a unified signed request entrypoint.""" + method = method.upper() + payload = None if method == "GET" else data + return self._request(method, path, headers=headers, json=payload) + + def createSignedRequest(self, name, url, req=None): + """Build a ``PreparedRequest`` signed by the named managed identifier.""" + if self.manager is None: + raise kering.ConfigurationError("client must be connected before signing external requests") + + req = {} if req is None else dict(req) + method = req.get("method", "GET").upper() + headers = CaseInsensitiveDict(req.get("headers") or {}) + + hab = self.identifiers().get(name) + keeper = self.manager.get(aid=hab) + signer = keeper.signers()[0] + + headers["Signify-Resource"] = hab["prefix"] + headers["Signify-Timestamp"] = helping.nowIso8601() + headers = self._signature_headers_for_signer( + headers=headers, + method=method, + path=self._signature_path(url), + signer=signer, + ) + + body = req.get("data", req.get("body")) + json_body = req.get("json") + if body is not None and json_body is not None: + raise ValueError("req cannot contain both 'body'/'data' and 'json'") + + request = requests.Request( + method=method, + url=url, + headers=dict(headers), + params=req.get("params"), + data=body, + json=json_body, + ) + return request.prepare() + + def stream(self, path, params=None, headers=None, body=None): + """Open a server-sent-event stream against an authenticated endpoint.""" + url = urljoin(self.base, path) + kwargs = self._request_kwargs(params=params, headers=headers, json=body) client = sseclient.SSEClient(url, session=self.session, **kwargs) for event in client: @@ -225,57 +325,15 @@ def stream(self, path, params=None, headers=None, body=None): def delete(self, path, params=None, headers=None, body=None): """Issue an authenticated ``DELETE`` request relative to the client base URL.""" - url = urljoin(self.base, path) - - kwargs = dict() - if params is not None: - kwargs["params"] = params - - if headers is not None: - kwargs["headers"] = headers - - if body is not None: - kwargs["json"] = body - - res = self.session.delete(url, **kwargs) - if not res.ok: - self.raiseForStatus(res) - - return res + return self._request("DELETE", path, params=params, headers=headers, json=body) def post(self, path, json, params=None, headers=None): """Issue an authenticated ``POST`` request relative to the client base URL.""" - url = urljoin(self.base, path) - - kwargs = dict(json=json) - if params is not None: - kwargs["params"] = params - - if headers is not None: - kwargs["headers"] = headers - - res = self.session.post(url, **kwargs) - if not res.ok: - self.raiseForStatus(res) - - return res + return self._request("POST", path, params=params, headers=headers, json=json) def put(self, path, json, params=None, headers=None): """Issue an authenticated ``PUT`` request relative to the client base URL.""" - url = urljoin(self.base, path) - - kwargs = dict(json=json) - if params is not None: - kwargs["params"] = params - - if headers is not None: - kwargs["headers"] = headers - - res = self.session.put(url, **kwargs) - if not res.ok: - self.raiseForStatus(res) - - return res + return self._request("PUT", path, params=params, headers=headers, json=json) def identifiers(self): """Return the identifier lifecycle resource wrapper.""" @@ -342,6 +400,11 @@ def schemas(self): from signify.app.schemas import Schemas return Schemas(client=self) + def config(self): + """Return the agent-configuration read resource wrapper.""" + from signify.app.coring import Config + return Config(client=self) + def exchanges(self): """Return the exchange transport resource wrapper.""" from signify.app.exchanging import Exchanges diff --git a/src/signify/app/coring.py b/src/signify/app/coring.py index 0942094..68a519c 100644 --- a/src/signify/app/coring.py +++ b/src/signify/app/coring.py @@ -250,3 +250,16 @@ def get(self, pre): """Fetch KERI events for one AID prefix.""" res = self.client.get(f"/events?pre={pre}") return res.json() + + +class Config: + """Resource wrapper for reading agent configuration exposed by KERIA.""" + + def __init__(self, client: SignifyClient): + """Create an agent-configuration resource bound to one Signify client.""" + self.client = client + + def get(self): + """Fetch the public agent configuration subset exposed by KERIA.""" + res = self.client.get("/config") + return res.json() diff --git a/src/signify/app/grouping.py b/src/signify/app/grouping.py index 41efca6..cea143b 100644 --- a/src/signify/app/grouping.py +++ b/src/signify/app/grouping.py @@ -32,6 +32,10 @@ def get_request(self, said): res = self.client.get(f"/multisig/request/{said}") return res.json() + def getRequest(self, said): + """Compatibility alias for :meth:`get_request`.""" + return self.get_request(said) + def send_request(self, name, exn, sigs, atc): """ Send multisig exn peer-to-peer message to other members of the multisig group @@ -48,12 +52,16 @@ def send_request(self, name, exn, sigs, atc): body = dict( exn=exn, sigs=sigs, - atc=atc.decode("utf-8") + atc=atc.decode("utf-8") if isinstance(atc, bytes) else atc ) res = self.client.post(f"/identifiers/{name}/multisig/request", json=body) return res.json() + def sendRequest(self, name, exn, sigs, atc): + """Compatibility alias for :meth:`send_request`.""" + return self.send_request(name, exn, sigs, atc) + def join(self, name, rot, sigs, gid, smids, rmids): """Submit a multisig join approval using a received proposal event. diff --git a/src/signify/core/keeping.py b/src/signify/core/keeping.py index 8dd1a60..ba4b8be 100644 --- a/src/signify/core/keeping.py +++ b/src/signify/core/keeping.py @@ -126,6 +126,10 @@ def __sign__(ser, signers, indexed=False, indices=None, ondices=None): cigars.append(signer.sign(ser)) # assigns .verfer to cigar return [cigar.qb64 for cigar in cigars] + def signers(self): + """Return the active current signers for this keeper.""" + raise NotImplementedError("keeper does not expose current signers") + class SaltyKeeper(BaseKeeper): """ @@ -282,6 +286,11 @@ def sign(self, ser, indexed=True, indices=None, ondices=None): return self.__sign__(ser, signers=signers, indexed=indexed, indices=indices, ondices=ondices) + def signers(self): + """Return signer objects for the keeper's current signing keys.""" + return self.creator.create(codes=self.icodes, pidx=self.pidx, kidx=self.kidx, + transferable=self.transferable) + class RandyKeeper(BaseKeeper): def __init__(self, salter, code=MtrDex.Ed25519_Seed, count=1, icodes=None, transferable=False, @@ -344,6 +353,11 @@ def sign(self, ser, indexed=True, indices=None, ondices=None, **_): for prx in self.prxs] return self.__sign__(ser, signers=signers, indexed=indexed, indices=indices, ondices=ondices) + def signers(self): + """Return signer objects decrypted from the keeper's current key set.""" + return [self.decrypter.decrypt(ser=signing.Cipher(qb64=prx).qb64b, transferable=self.transferable) + for prx in self.prxs] + class GroupKeeper(BaseKeeper): @@ -380,6 +394,10 @@ def sign(self, ser, indexed=True, **_): return mkeeper.sign(ser, indexed=indexed, indices=[csi], ondices=[pni]) + def signers(self): + """Return the current signers for the member habitat backing this group.""" + return self.mgr.get(self.mhab).signers() + def params(self): return dict( mhab=self.mhab, diff --git a/tests/app/test_aiding.py b/tests/app/test_aiding.py index a903a15..d0f9d0d 100644 --- a/tests/app/test_aiding.py +++ b/tests/app/test_aiding.py @@ -214,6 +214,30 @@ def test_aiding_update_interact(): unstub() +def test_aiding_update_rename(): + from signify.app.clienting import SignifyClient + mock_client = mock(spec=SignifyClient, strict=True) + + from signify.core import keeping + mock_manager = mock(spec=keeping.Manager, strict=True) + mock_client.manager = mock_manager # type: ignore + + from signify.app.aiding import Identifiers + ids = Identifiers(client=mock_client) # type: ignore + + from requests import Response + mock_response = mock(spec=Response, strict=True) + expect(mock_client, times=1).put('/identifiers/aid1', json={'name': 'aid2'}).thenReturn(mock_response) + expect(mock_response, times=1).json().thenReturn({'name': 'aid2'}) + + out = ids.update('aid1', {'name': 'aid2'}) + + assert out == {'name': 'aid2'} + + verifyNoUnwantedInteractions() + unstub() + + def test_aiding_update_rotate(): from signify.app.clienting import SignifyClient mock_client = mock(spec=SignifyClient, strict=True) @@ -315,6 +339,43 @@ def test_aiding_interact_no_data(): unstub() +def test_aiding_create_interact_no_submit(): + from signify.app.clienting import SignifyClient + mock_client = mock(spec=SignifyClient, strict=True) + + from signify.core import keeping + mock_manager = mock(spec=keeping.Manager, strict=True) + mock_client.manager = mock_manager # type: ignore + + from signify.app.aiding import Identifiers + ids = Identifiers(client=mock_client) # type: ignore + + mock_hab = {'prefix': 'hab prefix', 'name': 'aid1', 'state': {'s': '0', 'd': 'hab digest'}} + expect(ids, times=1).get('aid1').thenReturn(mock_hab) + + from keri.core import eventing, serdering + mock_serder = mock({'ked': {'a': 'key event dictionary'}, 'raw': b'serder raw bytes'}, spec=serdering.SerderKERI, + strict=True) + expect(eventing, times=1).interact('hab prefix', sn=1, data=[None], dig='hab digest').thenReturn(mock_serder) + + mock_keeper = mock({'algo': 'salty', 'params': lambda: {'keeper': 'params'}}, spec=keeping.SaltyKeeper, strict=True) + expect(mock_manager, times=1).get(aid=mock_hab).thenReturn(mock_keeper) + expect(mock_keeper, times=1).sign(ser=mock_serder.raw).thenReturn(['a signature']) + + serder, sigs, body = ids.createInteract(name='aid1') + + assert serder == mock_serder + assert sigs == ['a signature'] + assert body == { + 'ixn': {'a': 'key event dictionary'}, + 'sigs': ['a signature'], + 'salty': {'keeper': 'params'} + } + + verifyNoUnwantedInteractions() + unstub() + + def test_aiding_interact_with_data(): from signify.app.clienting import SignifyClient mock_client = mock(spec=SignifyClient, strict=True) diff --git a/tests/app/test_clienting.py b/tests/app/test_clienting.py index 43d1a86..78905a6 100644 --- a/tests/app/test_clienting.py +++ b/tests/app/test_clienting.py @@ -291,6 +291,15 @@ def test_signify_client_states(data, expected_pidx): verifyNoUnwantedInteractions() unstub() +def test_signify_client_state_wrapper(make_signify_client): + client = make_signify_client() + expect(client, times=1).states().thenReturn("state bundle") + + assert client.state() == "state bundle" + + verifyNoUnwantedInteractions() + unstub() + def test_signify_client_states_agent_error(): from signify.core import authing from keri.core.coring import Tiers @@ -341,6 +350,15 @@ def test_signify_client_save_old_salt(status_code, expected): verifyNoUnwantedInteractions() unstub() +def test_signify_client_save_old_passcode(make_signify_client): + client = make_signify_client() + expect(client, times=1)._save_old_salt("salty").thenReturn(True) + + assert client.saveOldPasscode("salty") is True + + verifyNoUnwantedInteractions() + unstub() + @pytest.mark.parametrize("status_code,expected", [ (200, False), (204, True), @@ -366,6 +384,15 @@ def test_signify_client_delete_old_salt(status_code, expected): verifyNoUnwantedInteractions() unstub() +def test_signify_client_delete_passcode(make_signify_client): + client = make_signify_client() + expect(client, times=1)._delete_old_salt().thenReturn(True) + + assert client.deletePasscode() is True + + verifyNoUnwantedInteractions() + unstub() + def test_signify_client_get(): from signify.core import authing from keri.core.coring import Tiers @@ -389,6 +416,30 @@ def test_signify_client_get(): verifyNoUnwantedInteractions() unstub() +def test_signify_client_fetch_get(make_signify_client, make_mock_response): + client = make_signify_client() + mock_response = make_mock_response() + expect(client, times=1)._request("GET", "/contacts", headers={"a": "header"}, json=None).thenReturn(mock_response) + + out = client.fetch("/contacts", "GET", {"ignored": True}, headers={"a": "header"}) + + assert out == mock_response + + verifyNoUnwantedInteractions() + unstub() + +def test_signify_client_fetch_post(make_signify_client, make_mock_response): + client = make_signify_client() + mock_response = make_mock_response() + expect(client, times=1)._request("POST", "/contacts", headers={"a": "header"}, json={"foo": "bar"}).thenReturn(mock_response) + + out = client.fetch("/contacts", "POST", {"foo": "bar"}, headers={"a": "header"}) + + assert out == mock_response + + verifyNoUnwantedInteractions() + unstub() + def test_signify_client_get_not_ok(): from signify.core import authing from keri.core.coring import Tiers @@ -711,6 +762,16 @@ def test_signify_client_schemas(make_signify_client): assert out.client == client +def test_signify_client_config(make_signify_client): + client = make_signify_client() + + out = client.config() + + from signify.app.coring import Config + assert type(out) is Config + assert out.client == client + + def test_signify_client_exchanges(make_signify_client): client = make_signify_client() @@ -721,6 +782,76 @@ def test_signify_client_exchanges(make_signify_client): assert out.client == client +def test_signify_client_create_signed_request(mockHelpingNowIso8601): + import requests + from keri.app.keeping import Algos + from keri.core import eventing + from keri.end import ending + from signify.app.clienting import SignifyClient + from signify.core import keeping + + client = SignifyClient(passcode='abcdefghijklmnop01234') + client.mgr = keeping.Manager(salter=client.ctrl.salter) + + keeper = client.manager.new(Algos.salty, 0, bran='0123456789abcdefghijk') + keys, ndigs = keeper.incept(transferable=True) + signer = keeper.signers()[0] + serder = eventing.incept(keys=keys, isith='1', nsith='1', ndigs=ndigs, code='E', wits=[], toad='0', cnfg=[], data=[]) + hab = { + 'prefix': serder.pre, + 'state': {'k': keys, 'n': ndigs}, + 'salty': keeper.params(), + } + + mock_identifiers = mock(strict=True) + expect(client, times=1).identifiers().thenReturn(mock_identifiers) + expect(mock_identifiers, times=1).get('aid1').thenReturn(hab) + + prepared = client.createSignedRequest( + 'aid1', + 'http://example.com/test', + { + 'method': 'POST', + 'headers': {'Content-Type': 'application/json'}, + 'body': '{"foo": true}', + }, + ) + + assert isinstance(prepared, requests.PreparedRequest) + assert prepared.url == 'http://example.com/test' + assert prepared.method == 'POST' + assert prepared.body == '{"foo": true}' + assert prepared.headers['Signify-Resource'] == hab['prefix'] + assert prepared.headers['Signify-Timestamp'] == '2021-06-27T21:26:21.233257+00:00' + assert 'Signature-Input' in prepared.headers + assert 'Signature' in prepared.headers + assert f'keyid="{signer.verfer.qb64}"' in prepared.headers['Signature-Input'] + assert 'alg="ed25519"' in prepared.headers['Signature-Input'] + + inputage = ending.desiginput(prepared.headers['Signature-Input'].encode('utf-8'))[0] + items = [] + for field in inputage.fields: + if field == '@method': + items.append(f'"{field}": {prepared.method}') + elif field == '@path': + items.append(f'"{field}": /test') + else: + items.append(f'"{field}": {ending.normalize(prepared.headers[field.upper()])}') + + values = [f"({' '.join(inputage.fields)})", f"created={inputage.created}"] + if inputage.keyid is not None: + values.append(f"keyid={inputage.keyid}") + if inputage.alg is not None: + values.append(f"alg={inputage.alg}") + items.append(f'"@signature-params: {";".join(values)}"') + + signature = ending.designature(prepared.headers['Signature'])[0].markers[inputage.name] + assert signer.verfer.verify(sig=signature.raw, ser="\n".join(items).encode("utf-8")) + + unstub() + verifyNoUnwantedInteractions() + + @pytest.mark.parametrize("resp,err", [ ({'json': lambda : {'description': {'raise a description'}}, 'status_code': 400, 'url': 'http://example.com'}, "400 Client Error: {'raise a description'} for url: http://example.com"), ({'json': lambda : {'title': {'raise a title'}}, 'status_code': 400, 'url': 'http://example.com'}, "400 Client Error: {'raise a title'} for url: http://example.com"), diff --git a/tests/app/test_coring.py b/tests/app/test_coring.py index 107f430..4de0bd6 100644 --- a/tests/app/test_coring.py +++ b/tests/app/test_coring.py @@ -240,6 +240,22 @@ def fake_signal(): assert excinfo.value is abort_error + +def test_config_get(make_mock_response): + from signify.app.clienting import SignifyClient + client = mock(spec=SignifyClient, strict=True) + + from signify.app import coring + config = coring.Config(client=client) # type: ignore + + mock_response = make_mock_response() + expect(client, times=1).get('/config').thenReturn(mock_response) + expect(mock_response, times=1).json().thenReturn({'iurls': ['http://example.com/oobi']}) + + out = config.get() + + assert out == {'iurls': ['http://example.com/oobi']} + def test_oobis_get(make_mock_response): from signify.app.clienting import SignifyClient client = mock(spec=SignifyClient, strict=True) diff --git a/tests/app/test_grouping.py b/tests/app/test_grouping.py index 5434888..38a55f9 100644 --- a/tests/app/test_grouping.py +++ b/tests/app/test_grouping.py @@ -28,6 +28,20 @@ def test_grouping_get_request(make_mock_client_with_manager, make_mock_response) assert len(res) == 1 assert res[0]['d'] == "EKYLUMmNPZeEs77Zvclf0bSN5IN-mLfLpx2ySb-HDlk4" + +def test_grouping_getRequest_alias(make_mock_client_with_manager): + mock_client, _ = make_mock_client_with_manager() + + from signify.app.grouping import Groups + groups = Groups(client=mock_client) # type: ignore + + expect(groups, times=1).get_request("EKYLUMmNPZeEs77Zvclf0bSN5IN-mLfLpx2ySb-HDlk4").thenReturn( + [{'d': "EKYLUMmNPZeEs77Zvclf0bSN5IN-mLfLpx2ySb-HDlk4"}] + ) + + res = groups.getRequest("EKYLUMmNPZeEs77Zvclf0bSN5IN-mLfLpx2ySb-HDlk4") + assert res[0]['d'] == "EKYLUMmNPZeEs77Zvclf0bSN5IN-mLfLpx2ySb-HDlk4" + def test_grouping_send_request(make_mock_client_with_manager, make_mock_response): mock_client, _ = make_mock_client_with_manager() @@ -57,6 +71,21 @@ def test_grouping_send_request(make_mock_client_with_manager, make_mock_response assert res['t'] == 'exn' assert res['d'] == "EKYLUMmNPZeEs77Zvclf0bSN5IN-mLfLpx2ySb-HDlk4" + +def test_grouping_sendRequest_alias(make_mock_client_with_manager): + mock_client, _ = make_mock_client_with_manager() + + from signify.app.grouping import Groups + groups = Groups(client=mock_client) # type: ignore + + expect(groups, times=1).send_request("test", {}, ['sig'], '-attachment').thenReturn( + {'t': 'exn', 'd': "EKYLUMmNPZeEs77Zvclf0bSN5IN-mLfLpx2ySb-HDlk4"} + ) + + res = groups.sendRequest("test", {}, ['sig'], '-attachment') + assert res['t'] == 'exn' + assert res['d'] == "EKYLUMmNPZeEs77Zvclf0bSN5IN-mLfLpx2ySb-HDlk4" + def test_grouping_join(make_mock_client_with_manager, make_mock_response): mock_client, _ = make_mock_client_with_manager() @@ -93,4 +122,3 @@ def test_grouping_join(make_mock_client_with_manager, make_mock_response): assert res['d'] == "EKYLUMmNPZeEs77Zvclf0bSN5IN-mLfLpx2ySb-HDlk4" - diff --git a/tests/integration/test_provisioning_and_identifiers.py b/tests/integration/test_provisioning_and_identifiers.py index 1ac1477..7af7b51 100644 --- a/tests/integration/test_provisioning_and_identifiers.py +++ b/tests/integration/test_provisioning_and_identifiers.py @@ -61,6 +61,15 @@ def test_manual_agent_boot_and_connect(client_factory): assert client.session is not None +def test_agent_config_read_path(client_factory): + """Prove the client exposes the stack-local `/config` read path after connect.""" + client = client_factory() + + config = client.config().get() + + assert config == {"iurls": client._integration_live_stack["witness_config_iurls"]} + + def test_single_sig_identifier_lifecycle_smoke(client_factory): """Prove one plain single-sig identifier can be created, listed, and read back. @@ -87,6 +96,25 @@ def test_single_sig_identifier_lifecycle_smoke(client_factory): assert fetched["prefix"] == hab["prefix"] +def test_identifier_rename_update_compatibility(client_factory): + """Prove TS-style identifier rename works without dropping the Python wrappers.""" + client = client_factory() + name = alias("rename") + renamed_name = alias("renamed") + + hab = create_identifier(client, name, wits=[], add_end_role=False) + renamed = client.identifiers().update(name, {"name": renamed_name}) + fetched = client.identifiers().get(renamed_name) + identifiers = client.identifiers().list() + names = {aid["name"] for aid in identifiers["aids"]} + + assert hab["name"] == name + assert renamed["name"] == renamed_name + assert fetched["name"] == renamed_name + assert fetched["prefix"] == hab["prefix"] + assert renamed_name in names + + def test_schema_oobi_resolution_smoke(client_factory): """Prove schema OOBI resolution works against the stack-local vLEI server.