diff --git a/src/keria/app/agenting.py b/src/keria/app/agenting.py index 1adc0423..be367e28 100644 --- a/src/keria/app/agenting.py +++ b/src/keria/app/agenting.py @@ -12,7 +12,6 @@ import datetime from dataclasses import asdict, dataclass, field from typing import List, Union -from urllib.parse import urlparse, urljoin from types import MappingProxyType from deprecation import deprecated @@ -33,7 +32,6 @@ habbing, storing, signaling, - oobiing, agenting, forwarding, querying, @@ -45,8 +43,7 @@ from keri.app.keeping import Algos from keri.core import coring, parsing, eventing, routing, serdering from keri.core.coring import Ilks -from keri.core.signing import Salter -from keri.db.basing import OobiRecord +from keri.app import oobiing from keri.vc import protocoling from keria.end import ending @@ -59,7 +56,7 @@ from keria.utils.openapi import dataclassFromFielddom -from . import aiding, notifying, indirecting, credentialing, ipexing, delegating +from . import aiding, notifying, indirecting, credentialing, ipexing, delegating, oobier from . import grouping as keriagrouping from .serving import GracefulShutdownDoer from .. import log_name, ogler, set_log_level @@ -589,6 +586,7 @@ def __init__(self, hby, rgy, agentHab, agency, caid, **opts): .exc (Exchanger): Handles peer-to-peer message routing and processing. .submitter (Submitter): Submits the last event from a KEL to the witnesses to obtain receipts and propagate to all other witnesses. .monitor (Monitor): Monitors the agent's state and performs long-running tasks like credential issuance and revocation. + .oobier (Oobier): Generates managed-identifier OOBIs and submits OOBI resolution operations. .rvy (Revery): Reply event message processor for routing and processing 'rpy' messages. .kvy (Kevery): Key Event Log (KEL) event processor for routing and processing KEL messages. .tvy (Tevery): TEL event processor for routing and processing TEL messages. @@ -741,6 +739,7 @@ def __init__(self, hby, rgy, agentHab, agency, caid, **opts): submitter=self.submitter, exchanger=self.exc, ) + self.oobier = oobier.Oobier(hby=self.hby, monitor=self.monitor) self.rvy = routing.Revery(db=hby.db, cues=self.cues) self.kvy = eventing.Kevery( @@ -1591,8 +1590,6 @@ def loadEnds(app): oobiColEnd = OOBICollectionEnd() app.add_route("/oobis", oobiColEnd) - oobiResEnd = OobiResourceEnd() - app.add_route("/oobis/{alias}", oobiResEnd) statesEnd = KeyStateCollectionEnd() app.add_route("/states", statesEnd) @@ -1940,9 +1937,6 @@ def on_get(req, rep): class OOBICollectionEnd: - def __init__(self): - """Create OOBI Collection endpoint instance""" - @staticmethod def on_post(req, rep): """Resolve OOBI endpoint. @@ -1987,147 +1981,18 @@ def on_post(req, rep): agent = req.context.agent body = req.get_media() - if "url" in body: - oobi = body["url"] - dt = helping.nowUTC() - - obr = OobiRecord(date=helping.toIso8601(dt)) - if "oobialias" in body: - obr.oobialias = body["oobialias"] - - agent.hby.db.oobis.pin(keys=(oobi,), val=obr) - - elif "rpy" in body: - raise falcon.HTTPNotImplemented( - description="'rpy' support not implemented yet" - ) - - else: - raise falcon.HTTPBadRequest( - description="invalid OOBI request body, either 'rpy' or 'url' is required" - ) - - oid = Salter().qb64 - op = agent.monitor.submit( - oid, longrunning.OpTypes.oobi, metadata=dict(oobi=oobi) - ) + try: + op = agent.oobier.resolve(body) + except NotImplementedError as ex: + raise falcon.HTTPNotImplemented(description=str(ex)) + except kering.ValidationError as ex: + raise falcon.HTTPBadRequest(description=str(ex)) rep.status = falcon.HTTP_202 rep.content_type = "application/json" rep.data = op.to_json().encode("utf-8") -class OobiResourceEnd: - @staticmethod - def on_get(req, rep, alias): - """OOBI GET endpoint - - Parameters: - req: falcon.Request HTTP request - rep: falcon.Response HTTP response - alias: option route parameter for specific identifier to get - - --- - summary: Get OOBI for specific identifier - description: Generate OOBI for the identifier of the specified alias and role - tags: - - OOBIs - parameters: - - in: path - name: alias - schema: - type: string - required: true - description: human readable alias for the identifier generate OOBI for - - in: query - name: role - schema: - type: string - required: true - description: role for which to generate OOBI - responses: - 200: - description: An array of Identifier key state information - content: - application/json: - schema: - $ref: '#/components/schemas/OOBI' - """ - agent = req.context.agent - hab = agent.hby.habByName(alias) - if hab is None: - raise falcon.HTTPBadRequest(description="Invalid alias to generate OOBI") - - role = req.params["role"] - - res = dict(role=role) - if role in (kering.Roles.witness,): # Fetch URL OOBIs for all witnesses - oobis = [] - for wit in hab.kever.wits: - urls = hab.fetchUrls( - eid=wit, scheme=kering.Schemes.http - ) or hab.fetchUrls(eid=wit, scheme=kering.Schemes.https) - if not urls: - raise falcon.HTTPNotFound( - description=f"unable to query witness {wit}, no http endpoint" - ) - - url = ( - urls[kering.Schemes.http] - if kering.Schemes.http in urls - else urls[kering.Schemes.https] - ) - up = urlparse(url) - oobis.append(urljoin(up.geturl(), f"/oobi/{hab.pre}/witness/{wit}")) - res["oobis"] = oobis - elif role in (kering.Roles.controller,): # Fetch any controller URL OOBIs - oobis = [] - urls = hab.fetchUrls( - eid=hab.pre, scheme=kering.Schemes.http - ) or hab.fetchUrls(eid=hab.pre, scheme=kering.Schemes.https) - if not urls: - raise falcon.HTTPNotFound( - description=f"unable to query controller {hab.pre}, no http endpoint" - ) - - url = ( - urls[kering.Schemes.http] - if kering.Schemes.http in urls - else urls[kering.Schemes.https] - ) - up = urlparse(url) - oobis.append(urljoin(up.geturl(), f"/oobi/{hab.pre}/controller")) - res["oobis"] = oobis - elif role in (kering.Roles.agent,): - oobis = [] - roleUrls = hab.fetchRoleUrls( - hab.pre, scheme=kering.Schemes.http, role=kering.Roles.agent - ) or hab.fetchRoleurls( - hab.pre, scheme=kering.Schemes.https, role=kering.Roles.agent - ) - if not roleUrls: - raise falcon.HTTPNotFound( - description=f"unable to query controller {hab.pre}, no http endpoint" - ) - - for eid, urls in roleUrls["agent"].items(): - url = ( - urls[kering.Schemes.http] - if kering.Schemes.http in urls - else urls[kering.Schemes.https] - ) - up = urlparse(url) - oobis.append(urljoin(up.geturl(), f"/oobi/{hab.pre}/agent/{eid}")) - res["oobis"] = oobis - else: - rep.status = falcon.HTTP_404 - return - - rep.status = falcon.HTTP_200 - rep.content_type = "application/json" - rep.data = json.dumps(res).encode("utf-8") - - class QueryCollectionEnd: @staticmethod def on_post(req, rep): diff --git a/src/keria/app/aiding.py b/src/keria/app/aiding.py index 30f558d1..c69ed2bd 100644 --- a/src/keria/app/aiding.py +++ b/src/keria/app/aiding.py @@ -9,7 +9,6 @@ import json from dataclasses import asdict, dataclass, field from typing import Dict, Optional, List, Union -from urllib.parse import urlparse, urljoin from keri import kering from keri import core from keri.app import habbing @@ -1298,20 +1297,6 @@ def info(hab, rm, full=False): return data -Role = namedtupleToEnum(kering.Roles, "Role") - - -@dataclass -class OOBI: - """Data class for OOBI URLs""" - - role: Role # type: ignore - oobis: List[str] = field( - default_factory=list, - metadata={"marshmallow_field": fields.List(fields.String(), required=True)}, - ) - - class IdentifierOOBICollectionEnd: """ This class represents the OOBI subresource collection endpoint for identifiers @@ -1344,6 +1329,13 @@ def on_get(req, rep, name): type: string required: true description: The role for which to fetch the OOBI URLs. Can be a witness, controller, agent, or mailbox. + - in: query + name: includeEid + schema: + type: boolean + default: false + required: false + description: Include endpoint-qualified agent OOBIs for multisig identifiers. responses: 200: description: Successfully fetched the OOBI URLs. The response body contains the OOBI URLs. @@ -1360,121 +1352,22 @@ def on_get(req, rep, name): if not name: raise falcon.HTTPBadRequest(description="name is required") - hab = ( - agent.hby.habs[name] - if name in agent.hby.habs - else agent.hby.habByName(name) - ) - if not hab: - raise falcon.HTTPNotFound(description="invalid alias or prefix {name}") - if "role" not in req.params: raise falcon.HTTPBadRequest(description="role parameter required") role = req.params["role"] + include_eid = req.params.get("includeEid", "").lower() in ("true", "1") - res = dict(role=role) - if role in (kering.Roles.witness,): # Fetch URL OOBIs for all witnesses - oobis = [] - for wit in hab.kever.wits: - urls = hab.fetchUrls( - eid=wit, scheme=kering.Schemes.http - ) or hab.fetchUrls(eid=wit, scheme=kering.Schemes.https) - if not urls: - raise falcon.HTTPNotFound( - description=f"unable to query witness {wit}, no http endpoint" - ) - - url = ( - urls[kering.Schemes.http] - if kering.Schemes.http in urls - else urls[kering.Schemes.https] - ) - up = urlparse(url) - oobis.append(urljoin(up.geturl(), f"/oobi/{hab.pre}/witness/{wit}")) - res["oobis"] = oobis - elif role in (kering.Roles.controller,): # Fetch any controller URL OOBIs - oobis = [] - urls = hab.fetchUrls( - eid=hab.pre, scheme=kering.Schemes.http - ) or hab.fetchUrls(eid=hab.pre, scheme=kering.Schemes.https) - if not urls: - raise falcon.HTTPNotFound( - description=f"unable to query controller {hab.pre}, no http endpoint" - ) - - url = ( - urls[kering.Schemes.http] - if kering.Schemes.http in urls - else urls[kering.Schemes.https] - ) - up = urlparse(url) - oobis.append(urljoin(up.geturl(), f"/oobi/{hab.pre}/controller")) - res["oobis"] = oobis - elif role in (kering.Roles.agent,): # Fetch URL OOBIs for all witnesses - roleUrls = hab.fetchRoleUrls( - cid=hab.pre, role=kering.Roles.agent, scheme=kering.Schemes.http - ) or hab.fetchRoleUrls( - cid=hab.pre, role=kering.Roles.agent, scheme=kering.Schemes.https - ) - if kering.Roles.agent not in roleUrls: - res["oobis"] = [] - else: - aoobis = roleUrls[kering.Roles.agent] - - oobis = list() - for agent in set(aoobis.keys()): - murls = aoobis.naball(agent) - for murl in murls: - urls = [] - if kering.Schemes.http in murl: - urls.extend(murl.naball(kering.Schemes.http)) - if kering.Schemes.https in murl: - urls.extend(murl.naball(kering.Schemes.https)) - for url in urls: - up = urlparse(url) - oobis.append( - urljoin(up.geturl(), f"/oobi/{hab.pre}/agent/{agent}") - ) - - res["oobis"] = oobis - elif role in (kering.Roles.mailbox,): # Fetch URL OOBIs for all witnesses - roleUrls = hab.fetchRoleUrls( - cid=hab.pre, role=kering.Roles.mailbox, scheme=kering.Schemes.http - ) or hab.fetchRoleUrls( - cid=hab.pre, role=kering.Roles.mailbox, scheme=kering.Schemes.https - ) - if kering.Roles.mailbox not in roleUrls: - res["oobis"] = [] - else: - aoobis = roleUrls[kering.Roles.mailbox] - - oobis = list() - for mailbox in set(aoobis.keys()): - murls = aoobis.naball(mailbox) - for murl in murls: - urls = [] - if kering.Schemes.http in murl: - urls.extend(murl.naball(kering.Schemes.http)) - if kering.Schemes.https in murl: - urls.extend(murl.naball(kering.Schemes.https)) - for url in urls: - up = urlparse(url) - oobis.append( - urljoin( - up.geturl(), f"/oobi/{hab.pre}/mailbox/{mailbox}" - ) - ) - - res["oobis"] = oobis - else: - raise falcon.HTTPBadRequest( - description=f"unsupport role type {role} for oobi request" - ) + try: + result = agent.oobier.get(name, role, include_eid=include_eid) + except kering.MissingEntryError as ex: + raise falcon.HTTPNotFound(description=str(ex)) + except kering.ValidationError as ex: + raise falcon.HTTPBadRequest(description=str(ex)) rep.status = falcon.HTTP_200 rep.content_type = "application/json" - rep.data = json.dumps(res).encode("utf-8") + rep.data = json.dumps(asdict(result)).encode("utf-8") @dataclass diff --git a/src/keria/app/oobier.py b/src/keria/app/oobier.py new file mode 100644 index 00000000..cf429880 --- /dev/null +++ b/src/keria/app/oobier.py @@ -0,0 +1,365 @@ +# -*- encoding: utf-8 -*- +""" +KERIA +keria.app.oobier module + +""" + +from collections.abc import Iterable, Mapping +from dataclasses import dataclass, field +from functools import partial +from typing import List +from urllib.parse import urljoin + +from keri import kering +from keri.app import habbing +from keri.core.signing import Salter +from keri.db.basing import OobiRecord +from keri.help import helping +from marshmallow import fields + +from ..core import longrunning +from ..utils.openapi import namedtupleToEnum + + +Role = namedtupleToEnum(kering.Roles, "Role") + + +@dataclass +class OOBI: + """OOBI URLs generated for an endpoint role.""" + + role: Role # type: ignore + oobis: List[str] = field( + default_factory=list, + metadata={"marshmallow_field": fields.List(fields.String(), required=True)}, + ) + + +def endpointUrl(hab, eid): + """Select one location URL for an endpoint, preferring HTTP over HTTPS. + + Parameters: + hab (Hab): Habitat whose location records are queried. + eid (str): Endpoint identifier whose location is requested. + + Returns: + str | None: The HTTP URL, the HTTPS fallback, or None when neither exists. + """ + for scheme in (kering.Schemes.http, kering.Schemes.https): + urls = hab.fetchUrls(eid=eid, scheme=scheme) + if scheme in urls: + return urls[scheme] + + return None + + +def _endpointIds(endpointGroup): + """Expose the distinct endpoint IDs contained in one endpoint-role group. + + Parameters: + endpointGroup (Mict): Mapping from endpoint IDs to nested scheme maps. + + Returns: + dict[str, None]: Ordered endpoint IDs, such as + ``{"EID1": None, "EID2": None}``. + """ + return dict.fromkeys(endpointGroup.keys()) + + +def _endpointSchemeUrls(endpointGroup, eid, scheme): + """Flatten one ``endpointGroup[eid][scheme]`` branch into URL strings. + + Parameters: + endpointGroup (Mict): Mapping from endpoint IDs to nested scheme maps. + eid (str): Endpoint identifier selecting one branch of the group. + scheme (str): Location scheme to select, such as ``http`` or ``https``. + + Yields: + str: Each matching URL, such as ``"http://agent.example"``. + """ + for schemeUrls in endpointGroup.getall(eid): + yield from schemeUrls.getall(scheme) + + +def _endpointGroupUrls(endpointGroup, scheme): + """Attach endpoint IDs to every URL flattened from one role group. + + Parameters: + endpointGroup (Mict): Mapping from endpoint IDs to nested scheme maps. + scheme (str): Location scheme to select from every endpoint branch. + + Yields: + tuple[str, str]: An ``(eid, url)`` pair, such as + ``("EID1", "http://agent.example")``. + """ + for eid in _endpointIds(endpointGroup): + for url in _endpointSchemeUrls(endpointGroup, eid, scheme): + yield eid, url + + +def _schemeRoleEndpointUrls(hab, role, scheme): + """Flatten all endpoint groups authorized for one role and scheme. + + Parameters: + hab (Hab): Habitat whose authorized endpoint roles are queried. + role (str): Authorized endpoint role, such as ``agent`` or ``mailbox``. + scheme (str): Location scheme to select, such as ``http`` or ``https``. + + Yields: + tuple[str, str]: Each authorized ``(eid, url)`` pair for the scheme. + """ + roleUrls = hab.fetchRoleUrls(cid=hab.pre, role=role, scheme=scheme) + if role not in roleUrls: + return + + for endpointGroup in roleUrls.getall(role): + yield from _endpointGroupUrls(endpointGroup, scheme) + + +def roleEndpointUrls(hab, role): + """Materialize authorized role endpoints with HTTP pairs before HTTPS pairs. + + Parameters: + hab (Hab): Habitat whose authorized endpoint roles are queried. + role (str): Authorized endpoint role, such as ``agent`` or ``mailbox``. + + Returns: + list[tuple[str, str]]: Flattened ``(eid, url)`` pairs, for example + ``[("EID1", "http://one"), ("EID1", "https://two")]``. + """ + urls = [] + for scheme in (kering.Schemes.http, kering.Schemes.https): + urls.extend(_schemeRoleEndpointUrls(hab, role, scheme)) + + return urls + + +def oobiUrl(base_url, aid, role, eid=None): + """Build an OOBI URL for an identifier, role, and optional endpoint ID.""" + path = f"/oobi/{aid}/{role}" + if eid is not None: + path = f"{path}/{eid}" + + return urljoin(base_url, path) + + +def agentOobiUrl(hab, base_url, eid, include_eid=False): + """Build an agent OOBI using the single-signature or group URL policy.""" + endpoint_id = eid + if isinstance(hab, habbing.SignifyGroupHab) and not include_eid: + endpoint_id = None + + return oobiUrl(base_url, hab.pre, kering.Roles.agent, endpoint_id) + + +def uniqueOobis(oobis: Iterable[str]): + """Return OOBIs without duplicates while preserving first-seen order.""" + return list(dict.fromkeys(oobis)) + + +class Oobier: + """Generate managed-identifier OOBIs and submit OOBI resolution requests.""" + + def __init__(self, hby, monitor): + """Initialize OOBI services for one KERIA Agent. + + Parameters: + hby (Habery): The Agent Habery containing its managed identifiers and + OOBI resolution escrows. + monitor (Monitor): The Agent operation monitor used to track + asynchronous OOBI resolution. + """ + self.hby = hby + self.monitor = monitor + + def get(self, name: str, role: str, include_eid: bool = False) -> OOBI: + """Generate OOBIs for one managed identifier and endpoint role. + + Parameters: + name (str): Managed identifier alias or AID prefix. + role (str): OOBI role to generate: witness, controller, agent, or + mailbox. + include_eid (bool): Include endpoint IDs in multisig agent OOBIs when + True. + + Returns: + OOBI: The requested role and its generated OOBI URLs. + + Raises: + MissingEntryError: The identifier or a required endpoint is missing. + ValidationError: The name or role is invalid. + """ + hab = self._findHab(name) + generators = { + kering.Roles.witness: self._witnessOobis, + kering.Roles.controller: self._controllerOobis, + kering.Roles.agent: partial(self._agentOobis, include_eid=include_eid), + kering.Roles.mailbox: self._mailboxOobis, + } + + if role not in generators: + raise kering.ValidationError(f"unsupport role type {role} for oobi request") + + return OOBI(role=role, oobis=generators[role](hab)) + + def resolve(self, body: Mapping[str, object]) -> longrunning.Operation: + """Queue an OOBI URL and create an operation that tracks its resolution. + + Parameters: + body (Mapping[str, object]): Resolution request containing a required + ``url`` and optional ``oobialias``; ``rpy`` is reserved for future + support. + + Returns: + Operation: The pending or completed OOBI resolution operation. + + Raises: + ValidationError: The body contains neither ``url`` nor ``rpy``. + NotImplementedError: The body requests unsupported ``rpy`` resolution. + """ + if not isinstance(body, Mapping): + raise kering.ValidationError( + "invalid OOBI request body, either 'rpy' or 'url' is required" + ) + + if "url" in body: + url = body["url"] + self._queueResolution(url=url, alias=body.get("oobialias")) + return self._submitOp(url) + + if "rpy" in body: + raise NotImplementedError("'rpy' support not implemented yet") + + raise kering.ValidationError( + "invalid OOBI request body, either 'rpy' or 'url' is required" + ) + + def _findHab(self, name): + """Find a managed identifier habitat by prefix or alias. + + Parameters: + name (str): Managed identifier alias or AID prefix. + + Returns: + Hab: The matching habitat from the Agent Habery. + + Raises: + ValidationError: The name is empty. + MissingEntryError: No managed identifier matches the name. + """ + if not name: + raise kering.ValidationError("name is required") + + hab = self.hby.habs[name] if name in self.hby.habs else self.hby.habByName(name) + if hab is None: + raise kering.MissingEntryError(f"invalid alias or prefix {name}") + + return hab + + @staticmethod + def _witnessOobis(hab): + """Generate endpoint-qualified OOBIs for every current witness. + + Parameters: + hab (Hab): Managed identifier habitat whose witnesses are queried. + + Returns: + list[str]: Witness OOBI URLs in witness-list order. + + Raises: + MissingEntryError: A configured witness has no HTTP or HTTPS location. + """ + oobis = [] + for witness in hab.kever.wits: + url = endpointUrl(hab, witness) + if url is None: + raise kering.MissingEntryError( + f"unable to query witness {witness}, no http endpoint" + ) + + oobis.append(oobiUrl(url, hab.pre, kering.Roles.witness, witness)) + + return oobis + + @staticmethod + def _controllerOobis(hab): + """Generate the controller OOBI for a managed identifier. + + Parameters: + hab (Hab): Managed identifier habitat whose controller URL is queried. + + Returns: + list[str]: A single controller OOBI URL. + + Raises: + MissingEntryError: The controller has no HTTP or HTTPS location. + """ + url = endpointUrl(hab, hab.pre) + if url is None: + raise kering.MissingEntryError( + f"unable to query controller {hab.pre}, no http endpoint" + ) + + return [oobiUrl(url, hab.pre, kering.Roles.controller)] + + @staticmethod + def _agentOobis(hab, include_eid=False): + """Generate agent OOBIs using the single-signature or multisig URL policy. + + Parameters: + hab (Hab): Managed identifier habitat whose agent endpoints are queried. + include_eid (bool): Include endpoint IDs in multisig agent OOBIs when + True. + + Returns: + list[str]: Deduplicated agent OOBI URLs in endpoint discovery order. + """ + oobis = ( + agentOobiUrl(hab, url, eid, include_eid=include_eid) + for eid, url in roleEndpointUrls(hab, kering.Roles.agent) + ) + return uniqueOobis(oobis) + + @staticmethod + def _mailboxOobis(hab): + """Generate endpoint-qualified mailbox OOBIs for authorized endpoints. + + Parameters: + hab (Hab): Managed identifier habitat whose mailbox endpoints are + queried. + + Returns: + list[str]: Mailbox OOBI URLs in endpoint discovery order. + """ + return [ + oobiUrl(url, hab.pre, kering.Roles.mailbox, eid) + for eid, url in roleEndpointUrls(hab, kering.Roles.mailbox) + ] + + def _queueResolution(self, url, alias): + """Persist an OOBI URL request for asynchronous KERIpy resolution. + + Parameters: + url (str): OOBI URL to place in the Agent Habery's resolution escrow. + alias (str | None): Optional contact alias to assign after resolution. + + Returns: + None. + """ + record = OobiRecord(date=helping.nowIso8601(), oobialias=alias) + self.hby.db.oobis.pin(keys=(url,), val=record) + + def _submitOp(self, url): + """Create a monitor operation for an already-queued OOBI URL. + + Parameters: + url (str): Queued OOBI URL tracked by the operation. + + Returns: + Operation: The pending or completed OOBI resolution operation. + """ + oid = Salter().qb64 + return self.monitor.submit( + oid, longrunning.OpTypes.oobi, metadata=dict(oobi=url) + ) diff --git a/src/keria/app/specing.py b/src/keria/app/specing.py index 97db5178..44d6410f 100644 --- a/src/keria/app/specing.py +++ b/src/keria/app/specing.py @@ -5,7 +5,7 @@ from apispec.core import VALID_METHODS, APISpec from apispec.ext.marshmallow import MarshmallowPlugin -from keria.app import aiding, agenting, grouping, notifying +from keria.app import aiding, agenting, grouping, notifying, oobier from keria.peer import exchanging from ..core import optypes from ..utils.openapi import applyAltConstraintsToOpenApiSchema @@ -302,7 +302,7 @@ def __init__(self, app, title, version="1.0.1", openapi_version="3.1.0"): # OOBIS self.spec.components.schema( - "OOBI", schema=marshmallow_dataclass.class_schema(aiding.OOBI)() + "OOBI", schema=marshmallow_dataclass.class_schema(oobier.OOBI)() ) # End Roles diff --git a/src/keria/testing/testing_helper.py b/src/keria/testing/testing_helper.py index 0a56ee4b..5afa5943 100644 --- a/src/keria/testing/testing_helper.py +++ b/src/keria/testing/testing_helper.py @@ -655,6 +655,81 @@ def createAid(client, name, salt, wits=None, toad="0", delpre=None): assert res.status_code == 200 or res.status_code == 202 return res.json + @staticmethod + def createMultisigAid(clients, name, members): + """ + Helper to support creating a multisig with a single member per effective Signify client. + We should only ever make one multisig member per agent. + See https://github.com/WebOfTrust/keria/issues/165 + """ + assert len(clients) == len(members) + + member_habs = [] + member_serders = [] # inception events of members - used for key extraction + member_signers = [] + for client, (alias, salt) in zip(clients, members): + Helpers.createAid(client, alias, salt) + serder, signers = Helpers.incept(salt, "signify:aid", pidx=0) + assert len(signers) == 1 + member_serders.append(serder) + member_signers.append(signers[0]) + + result = client.simulate_get(path="/identifiers") + assert result.status_code == 200 + member_hab = next( + (hab for hab in result.json if hab["name"] == alias), + None, + ) + assert member_hab is not None + assert member_hab["prefix"] == serder.pre + member_habs.append(member_hab) + + states = [serder.ked for serder in member_serders] + keys = [state["k"][0] for state in states] + ndigs = [state["n"][0] for state in states] + + # Multisig incept + serder = eventing.incept( + keys=keys, + isith=str(len(members)), + nsith=str(len(members)), + ndigs=ndigs, + code=coring.MtrDex.Blake3_256, + toad=0, + wits=[], + ) + sigers = [ + signer.sign(ser=serder.raw, index=index).qb64 + for index, signer in enumerate(member_signers) + ] + smids = rmids = [state["i"] for state in states] + + body = { + "name": name, + "icp": serder.ked, + "sigs": sigers, + "smids": smids, + "rmids": rmids, + } + + group_habs = [] + for client, member_hab in zip(clients, member_habs): + group_body = dict(body) + group_body["group"] = {"mhab": member_hab, "keys": keys, "ndigs": ndigs} + result = client.simulate_post( + path="/identifiers", body=json.dumps(group_body) + ) + assert result.status_code == 202 + + result = client.simulate_get(path="/identifiers") + assert result.status_code == 200 + group_hab = next((hab for hab in result.json if hab["name"] == name), None) + assert group_hab is not None + assert group_hab["prefix"] == serder.pre + group_habs.append(group_hab) + + return group_habs + @staticmethod def createEndRole(client, agent, recp, name, salt): rpy = Helpers.endrole(recp, agent.agentHab.pre) diff --git a/tests/app/test_agenting.py b/tests/app/test_agenting.py index bcebdd80..4afa963a 100644 --- a/tests/app/test_agenting.py +++ b/tests/app/test_agenting.py @@ -13,6 +13,7 @@ import signal import time from base64 import b64encode +from unittest import mock import falcon import hio @@ -24,11 +25,11 @@ from hio.help import decking from keri import core from keri import kering -from keri.app import habbing, configing, indirecting, oobiing, querying +from keri.app import habbing, configing, indirecting, querying from keri.app.agenting import Receiptor, WitnessReceiptor from keri.core import serdering from keri.core.coring import MtrDex -from keri.db import basing, dbing +from keri.db import dbing from keri.help import nowIso8601 from keri.vdr import credentialing @@ -180,8 +181,7 @@ def test_load_ends(helpers): assert isinstance(end, longrunning.OperationResourceEnd) (end, *_) = app._router.find("/oobis") assert isinstance(end, agenting.OOBICollectionEnd) - (end, *_) = app._router.find("/oobis/ALIAS") - assert isinstance(end, agenting.OobiResourceEnd) + assert app._router.find("/oobis/ALIAS") is None (end, *_) = app._router.find("/states") assert isinstance(end, agenting.KeyStateCollectionEnd) (end, *_) = app._router.find("/events") @@ -660,178 +660,39 @@ def test_keystate_ends(helpers): } -def test_oobi_ends(seeder, helpers): - with ( - helpers.openKeria() as (agency, agent, app, client), - habbing.openHby( - name="wes", salt=core.Salter(raw=b"wess-the-witness").qb64 - ) as wesHby, - ): - wesHab = wesHby.makeHab(name="wes", transferable=False) - - result = client.simulate_get(path="/oobi/pal?role=witness") - assert result.status == falcon.HTTP_404 # Missing OOBI endpoints for witness - - # Add witness endpoints - url = "http://127.0.0.1:9999" - agent.hby.db.locs.put( - keys=(wesHab.pre, kering.Schemes.http), val=basing.LocationRecord(url=url) - ) - - # Register the identifier endpoint so we can create an AID for the test - end = aiding.IdentifierCollectionEnd() - app.add_route("/identifiers", end) - salt = b"0123456789abcdef" - helpers.createAid(client, "pal", salt, wits=[wesHab.pre], toad="1") - palPre = "EEkruFP-J0InOD9cYbNLlBxQtkLAbmJPNecSnBzJixP0" - - oobiery = oobiing.Oobiery(hby=agent.hby) - - oobiColEnd = agenting.OOBICollectionEnd() - app.add_route("/oobi", oobiColEnd) - oobiResEnd = agenting.OobiResourceEnd() - app.add_route("/oobi/{alias}", oobiResEnd) - - result = client.simulate_get(path="/oobi/test?role=witness") - assert result.status == falcon.HTTP_400 # Bad alias, does not exist - - result = client.simulate_get(path="/oobi/pal?role=watcher") - assert result.status == falcon.HTTP_404 # Bad role, watcher not supported yet - - result = client.simulate_get(path="/oobi/pal?role=witness") - assert result.status == falcon.HTTP_200 - - result = client.simulate_get(path="/oobi/pal?role=controller") - assert result.status == falcon.HTTP_404 # Missing OOBI controller endpoints - - # Add controller endpoints - url = "http://127.0.0.1:9999" - agent.hby.db.locs.put( - keys=(palPre, kering.Schemes.http), val=basing.LocationRecord(url=url) - ) - result = client.simulate_get(path="/oobi/pal?role=controller") - assert result.status == falcon.HTTP_200 # Missing OOBI controller endpoints - assert result.json == { - "oobis": [ - "http://127.0.0.1:9999/oobi/EEkruFP-J0InOD9cYbNLlBxQtkLAbmJPNecSnBzJixP0/controller" - ], - "role": "controller", - } - - # Seed with witness endpoints - seeder.seedWitEnds( - agent.hby.db, - witHabs=[wesHab], - protocols=[kering.Schemes.http, kering.Schemes.tcp], - ) - - result = client.simulate_get(path="/oobi/pal?role=witness") - assert result.status == falcon.HTTP_200 - assert result.json == { - "oobis": [ - "http://127.0.0.1:5644/oobi/EEkruFP-J0InOD9cYbNLlBxQtkLAbmJPNecSnBzJixP0/witness/BN8t3n1lxcV0SWGJIIF" - "46fpSUqA7Mqre5KJNN3nbx3mr" - ], - "role": "witness", - } - - # Post without a URL or RPY - data = dict() - b = json.dumps(data).encode("utf-8") - result = client.simulate_post(path="/oobi", body=b) - assert result.status == falcon.HTTP_400 - - # Post an RPY - data = dict(rpy={}) - b = json.dumps(data).encode("utf-8") - result = client.simulate_post(path="/oobi", body=b) - assert result.status == falcon.HTTP_501 - - # initiated from keria.json config file (iurls), so remove - oobiery.hby.db.oobis.rem( - keys=( - "http://127.0.0.1:5642/oobi/BBilc4-L3tFUnfM_wJr4S4OJanAv_VmF_dJNN6vkf2Ha/controller&tag=witness", - ) - ) - - data = dict( - url="http://127.0.0.1:5644/oobi/E6Dqo6tHmYTuQ3Lope4mZF_4hBoGJl93cBHRekr_iD_A/witness/" - ) - b = json.dumps(data).encode("utf-8") - result = client.simulate_post(path="/oobi", body=b) - assert result.status == falcon.HTTP_202 - assert oobiery.hby.db.oobis.cntAll() == 1 - (url,), item = next(oobiery.hby.db.oobis.getItemIter()) - assert item is not None - assert ( - url - == "http://127.0.0.1:5644/oobi/E6Dqo6tHmYTuQ3Lope4mZF_4hBoGJl93cBHRekr_iD_A/witness/" +def test_oobi_collection_end(helpers): + with helpers.openKeria() as (_, agent, app, client): + app.add_route("/oobis", agenting.OOBICollectionEnd()) + operation = mock.Mock() + operation.to_json.return_value = json.dumps( + {"name": "oobi.test", "done": False} ) - oobiery.hby.db.oobis.rem(keys=(url,)) + agent.oobier.resolve = mock.Mock(return_value=operation) - # Post an RPY - data = dict(oobialias="sal", rpy={}) - b = json.dumps(data).encode("utf-8") - result = client.simulate_post(path="/oobi", body=b) - assert result.status == falcon.HTTP_501 + body = {"url": "http://example.com/oobi/EAID/controller", "oobialias": "aid"} + result = client.simulate_post(path="/oobis", json=body) - # POST without an oobialias - data = dict( - url="http://127.0.0.1:5644/oobi/E6Dqo6tHmYTuQ3Lope4mZF_4hBoGJl93cBHRekr_iD_A/witness/" - ) - b = json.dumps(data).encode("utf-8") - result = client.simulate_post(path="/oobi", body=b) assert result.status == falcon.HTTP_202 - assert oobiery.hby.db.oobis.cntAll() == 1 - (url,), item = next(oobiery.hby.db.oobis.getItemIter()) - assert item is not None - assert ( - url - == "http://127.0.0.1:5644/oobi/E6Dqo6tHmYTuQ3Lope4mZF_4hBoGJl93cBHRekr_iD_A/witness/" - ) - assert item.oobialias is None - oobiery.hby.db.oobis.rem(keys=(url,)) - - data = dict( - oobialias="sal", - url="http://127.0.0.1:5644/oobi/E6Dqo6tHmYTuQ3Lope4mZF_4hBoGJl93cBHRekr_iD_A" - "/witness/", - ) - b = json.dumps(data).encode("utf-8") - result = client.simulate_post(path="/oobi", body=b) - assert result.status == falcon.HTTP_202 - assert oobiery.hby.db.oobis.cntAll() == 1 - (url,), item = next(oobiery.hby.db.oobis.getItemIter()) - assert item is not None - assert ( - url - == "http://127.0.0.1:5644/oobi/E6Dqo6tHmYTuQ3Lope4mZF_4hBoGJl93cBHRekr_iD_A/witness/" - ) - assert item.oobialias == "sal" - - op = helpers.createAid(client, "aggie", salt) - aid = op["response"] - aggiePre = aid["i"] - assert aggiePre == "EHgwVwQT15OJvilVvW57HE4w0-GPs_Stj2OFoAHZSysY" - - keys = (aggiePre, kering.Roles.agent, agent.agentHab.pre) - ender = basing.EndpointRecord(allowed=True) - agent.hby.db.ends.pin(keys=keys, val=ender) # overwrite - url = "http://127.0.0.1:3902" - agent.hby.db.locs.put( - keys=(agent.agentHab.pre, kering.Schemes.http), - val=basing.LocationRecord(url=url), - ) - - result = client.simulate_get(path="/oobi/aggie?role=agent") - assert result.status == falcon.HTTP_200 - assert result.json == { - "oobis": [ - "http://127.0.0.1:3902/oobi/EHgwVwQT15OJvilVvW57HE4w0-GPs_Stj2OFoAHZSysY/agent" - "/EI7AkI40M11MS7lkTCb10JC9-nDt-tXwQh44OHAFlv_9" - ], - "role": "agent", - } + assert result.json == {"name": "oobi.test", "done": False} + agent.oobier.resolve.assert_called_once_with(body) + + +@pytest.mark.parametrize( + "error, status", + [ + (kering.ValidationError("invalid body"), falcon.HTTP_400), + (NotImplementedError("rpy unsupported"), falcon.HTTP_501), + ], +) +def test_oobi_collection_end_maps_service_errors(error, status, helpers): + with helpers.openKeria() as (_, agent, app, client): + app.add_route("/oobis", agenting.OOBICollectionEnd()) + agent.oobier.resolve = mock.Mock(side_effect=error) + + result = client.simulate_post(path="/oobis", json={}) + + assert result.status == status + assert result.json["description"] == str(error) def test_querier(helpers): diff --git a/tests/app/test_aiding.py b/tests/app/test_aiding.py index 0fb9f408..c0252893 100644 --- a/tests/app/test_aiding.py +++ b/tests/app/test_aiding.py @@ -30,7 +30,7 @@ from keria.app import aiding, agenting -from keria.app.aiding import IdentifierOOBICollectionEnd, RpyEscrowCollectionEnd +from keria.app.aiding import RpyEscrowCollectionEnd from keria.core import longrunning from keria.testing.testing_helper import SCRIPTS_DIR @@ -1891,150 +1891,58 @@ def test_identifier_resource_end(helpers): assert res.json == {"title": "No AID with name or prefix EInvalidPrefix found"} -def test_oobi_ends(helpers): - with helpers.openKeria() as (agency, agent, app, client): - end = aiding.IdentifierCollectionEnd() - app.add_route("/identifiers", end) - - endRolesEnd = aiding.EndRoleCollectionEnd() - app.add_route("/identifiers/{name}/endroles", endRolesEnd) - aidOOBIsEnd = IdentifierOOBICollectionEnd() - app.add_route("/identifiers/{name}/oobis", aidOOBIsEnd) - - client = testing.TestClient(app) - # Create an AID to test against - salt = b"0123456789abcdef" - op = helpers.createAid(client, "pal", salt) - iserder = serdering.SerderKERI(sad=op["response"]) - assert iserder.pre == "EHgwVwQT15OJvilVvW57HE4w0-GPs_Stj2OFoAHZSysY" +def test_identifier_oobi_collection_end(helpers): + with helpers.openKeria() as (_, agent, app, client): + app.add_route("/identifiers", aiding.IdentifierCollectionEnd()) + app.add_route("/identifiers/{name}/oobis", aiding.IdentifierOOBICollectionEnd()) + created = helpers.createAid(client, "pal", b"0123456789abcdef") + aid = created["response"]["i"] + agent.hby.db.ends.pin( + keys=(aid, kering.Roles.agent, agent.agentHab.pre), + val=basing.EndpointRecord(allowed=True), + ) + agent.hby.db.locs.pin( + keys=(agent.agentHab.pre, kering.Schemes.http), + val=LocationRecord(url="http://127.0.0.1:3902"), + ) + agent.oobier.get = mock.Mock(wraps=agent.oobier.get) - # Test empty - res = client.simulate_get("/identifiers//oobis?role=agent") - assert res.status_code == 400 - assert res.json == { - "description": "name is required", - "title": "400 Bad Request", - } + res = client.simulate_get("/identifiers/pal/oobis?role=agent&includeEid=true") - # Test before endroles are added - res = client.simulate_get("/identifiers/pal/oobis?role=agent") assert res.status_code == 200 - assert res.json == {"oobis": [], "role": "agent"} - - rpy = helpers.endrole(iserder.pre, agent.agentHab.pre) - - # first try with bad signatures - sigs = helpers.sign(b"0123456789xyzxyz", 0, 0, rpy.raw) - body = dict(rpy=rpy.ked, sigs=sigs) - res = client.simulate_post(path="/identifiers/pal/endroles", json=body) - assert res.status_code == 400 assert res.json == { - "description": "unable to verify end role reply message", - "title": "400 Bad Request", + "oobis": [f"http://127.0.0.1:3902/oobi/{aid}/agent/{agent.agentHab.pre}"], + "role": "agent", } + agent.oobier.get.assert_called_once_with("pal", "agent", include_eid=True) - # now with correct - sigs = helpers.sign(salt, 0, 0, rpy.raw) - body = dict(rpy=rpy.ked, sigs=sigs) - res = client.simulate_post(path="/identifiers/pal/endroles", json=body) - op = res.json - ked = op["response"] - serder = serdering.SerderKERI(sad=ked) - assert serder.raw == rpy.raw - - # not valid calls - res = client.simulate_post(path="/identifiers/pal/endroles/agent", json=body) - assert res.status_code == 404 - - res = client.simulate_post(path="/endroles/pal", json=body) - assert res.status_code == 404 - - # must be a valid aid alias - res = client.simulate_get("/identifiers/bad/oobis") - assert res.status_code == 404 +def test_identifier_oobi_collection_end_validates_transport(helpers): + with helpers.openKeria() as (_, _, app, client): + app.add_route("/identifiers/{name}/oobis", aiding.IdentifierOOBICollectionEnd()) - # role parameter is required res = client.simulate_get("/identifiers/pal/oobis") - assert res.status_code == 400 - assert res.json == { - "description": "role parameter required", - "title": "400 Bad Request", - } - # role parameter must be valie - res = client.simulate_get("/identifiers/pal/oobis?role=banana") assert res.status_code == 400 - assert res.json == { - "description": "unsupport role type banana for oobi request", - "title": "400 Bad Request", - } - - res = client.simulate_get("/identifiers/pal/oobis?role=agent") - assert res.status_code == 200 - role = res.json["role"] - oobis = res.json["oobis"] + assert res.json["description"] == "role parameter required" - res = client.simulate_get("/identifiers/pal/oobis?role=witness") - assert res.status_code == 200 - - assert role == "agent" - assert len(oobis) == 1 - assert oobis[0] == ( - "http://127.0.0.1:3902/oobi/EHgwVwQT15OJvilVvW57HE4w0-GPs_Stj2OFoAHZSysY/agent/EI7AkI40M1" - "1MS7lkTCb10JC9-nDt-tXwQh44OHAFlv_9" - ) - - res = client.simulate_get("/identifiers/pal/oobis?role=witness") - assert res.status_code == 200 - role = res.json["role"] - oobis = res.json["oobis"] - assert role == "witness" - assert len(oobis) == 0 - - res = client.simulate_get("/identifiers/pal/oobis?role=controller") - assert res.status_code == 404 - - # Jam HTTP loc record for pre in database - agent.hby.db.locs.pin( - keys=(iserder.pre, kering.Schemes.http), - val=LocationRecord(url="http://localhost:1234/"), - ) - - res = client.simulate_get("/identifiers/pal/oobis?role=controller") - assert res.status_code == 200 - role = res.json["role"] - oobis = res.json["oobis"] - - assert role == "controller" - assert len(oobis) == 1 - assert ( - oobis[0] - == "http://localhost:1234/oobi/EHgwVwQT15OJvilVvW57HE4w0-GPs_Stj2OFoAHZSysY/controller" - ) - - rpy = helpers.endrole(iserder.pre, agent.agentHab.pre, role="mailbox") - sigs = helpers.sign(salt, 0, 0, rpy.raw) - body = dict(rpy=rpy.ked, sigs=sigs) - - res = client.simulate_post(path="/identifiers/pal/endroles", json=body) - op = res.json - ked = op["response"] - serder = serdering.SerderKERI(sad=ked) - assert serder.raw == rpy.raw +@pytest.mark.parametrize( + "error, status", + [ + (kering.MissingEntryError("missing identifier"), falcon.HTTP_404), + (kering.ValidationError("invalid role"), falcon.HTTP_400), + ], +) +def test_identifier_oobi_collection_end_maps_service_errors(error, status, helpers): + with helpers.openKeria() as (_, agent, app, client): + app.add_route("/identifiers/{name}/oobis", aiding.IdentifierOOBICollectionEnd()) + agent.oobier.get = mock.Mock(side_effect=error) - res = client.simulate_get("/identifiers/pal/oobis?role=mailbox") - assert res.status_code == 200 - role = res.json["role"] - oobis = res.json["oobis"] + res = client.simulate_get("/identifiers/pal/oobis?role=agent") - assert role == "mailbox" - assert len(oobis) == 1 - assert ( - oobis[0] - == "http://127.0.0.1:3902/oobi/EHgwVwQT15OJvilVvW57HE4w0-GPs_Stj2OFoAHZSysY/mailbox/EI7AkI40M11MS7lkTCb10JC9-nDt-tXwQh44OHAFlv_9" - ) + assert res.status == status + assert res.json["description"] == str(error) def test_rpy_escow_end(helpers): diff --git a/tests/app/test_oobier.py b/tests/app/test_oobier.py new file mode 100644 index 00000000..a1e1ddb5 --- /dev/null +++ b/tests/app/test_oobier.py @@ -0,0 +1,280 @@ +# -*- encoding: utf-8 -*- +"""Tests for managed-identifier OOBI generation and resolution submission.""" + +from types import SimpleNamespace +from unittest import mock + +import pytest +from hio.help import hicting +from keri import core, kering +from keri.app import habbing +from keri.db import basing + +from keria.app import aiding, oobier +from keria.core import longrunning + + +HTTP_ENDPOINT_URL = "http://preferred" +HTTPS_ENDPOINT_URL = "https://available" + + +@pytest.fixture +def endpointUrlResponses(): + """Provide successive ``Hab.fetchUrls`` results for memorable endpoint cases. + + Each result is KERIpy's ``Mict[scheme, url]`` shape. Its prefix lookup means + an HTTPS-only record may appear during both the HTTP and HTTPS lookup steps. + """ + bothSchemes = hicting.Mict( + [ + (kering.Schemes.http, HTTP_ENDPOINT_URL), + (kering.Schemes.https, HTTPS_ENDPOINT_URL), + ] + ) + httpsFromHttpPrefix = hicting.Mict([(kering.Schemes.https, HTTPS_ENDPOINT_URL)]) + httpsFromHttpsLookup = hicting.Mict([(kering.Schemes.https, HTTPS_ENDPOINT_URL)]) + + return { + "httpPreferred": [bothSchemes], + "httpsFallback": [httpsFromHttpPrefix, httpsFromHttpsLookup], + "noLocation": [hicting.Mict(), hicting.Mict()], + } + + +def _add_identifier_route(app): + app.add_route("/identifiers", aiding.IdentifierCollectionEnd()) + + +def _authorize_endpoint(agent, cid, role, eid, url, scheme=kering.Schemes.http): + agent.hby.db.ends.pin( + keys=(cid, role, eid), val=basing.EndpointRecord(allowed=True) + ) + agent.hby.db.locs.pin(keys=(eid, scheme), val=basing.LocationRecord(url=url)) + + +def _role_urls(role, eid, scheme, *urls): + locations = hicting.Mict((scheme, url) for url in urls) + endpoints = hicting.Mict([(eid, locations)]) + return hicting.Mict([(role, endpoints)]) + + +@pytest.mark.parametrize( + "eid, expected", + [ + (None, "https://example.com/oobi/EAID/controller"), + ("EEND", "https://example.com/oobi/EAID/agent/EEND"), + ], +) +def test_oobi_url(eid, expected): + assert ( + oobier.oobiUrl( + "https://example.com/base", + "EAID", + "controller" if eid is None else "agent", + eid, + ) + == expected + ) + + +def test_agent_oobi_url_and_unique_oobis(): + hab = SimpleNamespace(pre="EAID") + assert ( + oobier.agentOobiUrl(hab, "http://example.com", "EEND") + == "http://example.com/oobi/EAID/agent/EEND" + ) + assert oobier.uniqueOobis(["one", "two", "one"]) == ["one", "two"] + + +@pytest.mark.parametrize( + "case, expected", + [ + ("httpPreferred", HTTP_ENDPOINT_URL), + ("httpsFallback", HTTPS_ENDPOINT_URL), + ("noLocation", None), + ], +) +def test_endpoint_url_prefers_http_and_falls_back_to_https( + case, expected, endpointUrlResponses +): + hab = mock.Mock() + hab.fetchUrls.side_effect = endpointUrlResponses[case] + + assert oobier.endpointUrl(hab, "EEND") == expected + + +def test_role_endpoint_urls_includes_http_and_https(): + hab = mock.Mock(pre="EAID") + hab.fetchRoleUrls.side_effect = [ + _role_urls("agent", "EEND", "http", "http://one", "http://two"), + _role_urls("agent", "EEND", "https", "https://three"), + ] + + assert oobier.roleEndpointUrls(hab, "agent") == [ + ("EEND", "http://one"), + ("EEND", "http://two"), + ("EEND", "https://three"), + ] + assert hab.fetchRoleUrls.call_args_list == [ + mock.call(cid="EAID", role="agent", scheme="http"), + mock.call(cid="EAID", role="agent", scheme="https"), + ] + + +def test_oobier_generates_single_signature_roles(helpers): + with helpers.openKeria() as (_, agent, app, client): + _add_identifier_route(app) + salt = b"0123456789abcdef" + created = helpers.createAid(client, "pal", salt) + aid = created["response"]["i"] + hab = agent.hby.habs[aid] + + assert agent.oobier.get("pal", "agent").oobis == [] + assert agent.oobier.get(aid, "agent").oobis == [] + assert agent.oobier.get("pal", "witness").oobis == [] + + with pytest.raises(kering.MissingEntryError): + agent.oobier.get("pal", "controller") + + _authorize_endpoint( + agent, + cid=aid, + role=kering.Roles.agent, + eid=agent.agentHab.pre, + url="http://agent.example", + ) + _authorize_endpoint( + agent, + cid=aid, + role=kering.Roles.mailbox, + eid=agent.agentHab.pre, + url="http://agent.example", + ) + agent.hby.db.locs.pin( + keys=(agent.agentHab.pre, kering.Schemes.https), + val=basing.LocationRecord(url="https://agent.example"), + ) + agent.hby.db.locs.pin( + keys=(hab.pre, kering.Schemes.http), + val=basing.LocationRecord(url="http://controller.example"), + ) + + agent_oobis = [ + f"http://agent.example/oobi/{aid}/agent/{agent.agentHab.pre}", + f"https://agent.example/oobi/{aid}/agent/{agent.agentHab.pre}", + ] + mailbox_oobis = [ + f"http://agent.example/oobi/{aid}/mailbox/{agent.agentHab.pre}", + f"https://agent.example/oobi/{aid}/mailbox/{agent.agentHab.pre}", + ] + assert agent.oobier.get("pal", "agent").oobis == agent_oobis + assert agent.oobier.get("pal", "agent", include_eid=True).oobis == agent_oobis + assert agent.oobier.get("pal", "mailbox").oobis == mailbox_oobis + assert agent.oobier.get("pal", "controller").oobis == [ + f"http://controller.example/oobi/{aid}/controller" + ] + + with pytest.raises(kering.MissingEntryError): + agent.oobier.get("missing", "agent") + with pytest.raises(kering.ValidationError): + agent.oobier.get("pal", "banana") + + +def test_oobier_reports_missing_witness_endpoint(helpers): + with helpers.openKeria() as (_, agent, app, client): + _add_identifier_route(app) + witness = "BBilc4-L3tFUnfM_wJr4S4OJanAv_VmF_dJNN6vkf2Ha" + agent.hby.db.locs.pin( + keys=(witness, kering.Schemes.http), + val=basing.LocationRecord(url="http://witness.example"), + ) + helpers.createAid(client, "pal", b"0123456789abcdef", wits=[witness], toad="1") + aid = agent.hby.habByName("pal").pre + agent.hby.db.locs.rem(keys=(witness, kering.Schemes.http)) + + with pytest.raises(kering.MissingEntryError): + agent.oobier.get("pal", "witness") + + agent.hby.db.locs.pin( + keys=(witness, kering.Schemes.https), + val=basing.LocationRecord(url="https://witness.example"), + ) + assert agent.oobier.get("pal", "witness").oobis == [ + f"https://witness.example/oobi/{aid}/witness/{witness}" + ] + + +def test_oobier_applies_multisig_agent_policy(helpers): + with ( + helpers.openKeria() as (_, agent, app, client), + helpers.openKeria(salter=core.Salter(raw=b"0123456789abcM01")) as ( + _, + _, + other_app, + other_client, + ), + ): + _add_identifier_route(app) + _add_identifier_route(other_app) + group = helpers.createMultisigAid( + [client, other_client], + "multisig", + [ + ("multisig0", b"abcdef0123456789"), + ("multisig1", b"fedcba9876543210"), + ], + )[0] + group_aid = group["prefix"] + group_hab = agent.hby.habs[group_aid] + assert isinstance(group_hab, habbing.SignifyGroupHab) + + other = "EAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + for eid in (agent.agentHab.pre, other): + _authorize_endpoint( + agent, + cid=group_aid, + role=kering.Roles.agent, + eid=eid, + url="http://agent.example", + ) + + assert agent.oobier.get("multisig", "agent").oobis == [ + f"http://agent.example/oobi/{group_aid}/agent" + ] + assert set(agent.oobier.get("multisig", "agent", include_eid=True).oobis) == { + f"http://agent.example/oobi/{group_aid}/agent/{agent.agentHab.pre}", + f"http://agent.example/oobi/{group_aid}/agent/{other}", + } + + +def test_oobier_submits_url_resolution(helpers): + with helpers.openKeria() as (_, agent, _, _): + url = "http://example.com/oobi/EAID/controller" + operation = agent.oobier.resolve({"url": url, "oobialias": "example"}) + + record = agent.hby.db.oobis.get(keys=(url,)) + assert record is not None + assert record.oobialias == "example" + assert operation.name.startswith(f"{longrunning.OpTypes.oobi}.") + assert operation.metadata == {"oobi": url} + + second = "http://example.com/oobi/ESECOND/controller" + agent.oobier.resolve({"url": second}) + assert agent.hby.db.oobis.get(keys=(second,)).oobialias is None + + +@pytest.mark.parametrize("body", [None, {}, {"other": "value"}]) +def test_oobier_rejects_invalid_resolution(body, helpers): + with helpers.openKeria() as (_, agent, _, _): + with pytest.raises(kering.ValidationError): + agent.oobier.resolve(body) + + +def test_oobier_url_precedes_unimplemented_rpy(helpers): + with helpers.openKeria() as (_, agent, _, _): + url = "http://example.com/oobi/EAID/controller" + operation = agent.oobier.resolve({"url": url, "rpy": {}}) + assert operation.metadata == {"oobi": url} + + with pytest.raises(NotImplementedError): + agent.oobier.resolve({"rpy": {}}) diff --git a/tests/app/test_specing.py b/tests/app/test_specing.py index 5e978d01..9a05629e 100644 --- a/tests/app/test_specing.py +++ b/tests/app/test_specing.py @@ -94,11 +94,19 @@ def test_spec_resource(helpers): assert "/oobi/{aid}/{role}" in paths assert "/oobi/{aid}/{role}/{eid}" in paths assert "/oobis" in paths - assert "/oobis/{alias}" in paths + assert "/oobis/{alias}" not in paths assert "/operations" in paths assert "/operations/{name}" in paths assert "/queries" in paths assert "/states" in paths assert "/config" in paths + identifier_oobi_parameters = paths["/identifiers/{name}/oobis"]["get"][ + "parameters" + ] + assert any( + parameter["name"] == "includeEid" + and parameter["schema"] == {"type": "boolean", "default": False} + for parameter in identifier_oobi_parameters + ) _validate_openapi_semantics(sd)