From 73fbcab3f7b87ccb00dda7b4e4a24df9e266c796 Mon Sep 17 00:00:00 2001 From: Kent Bull Date: Thu, 2 Jul 2026 12:56:04 -0600 Subject: [PATCH 1/2] fix: drop agent EID suffix on multisig OOBI by default This has been a constant source of confusion for years and should return the intuitive result by default, the multisig Agent OOBI with no agent EID suffix. The includeEid URL parameter allows using the old behavior, if desired. --- src/keria/app/agenting.py | 22 +++++---- src/keria/app/aiding.py | 52 ++++++++++++++------ src/keria/testing/testing_helper.py | 75 +++++++++++++++++++++++++++++ tests/app/test_agenting.py | 57 ++++++++++++++++++++++ tests/app/test_aiding.py | 59 ++++++++++++++++++++++- 5 files changed, 239 insertions(+), 26 deletions(-) diff --git a/src/keria/app/agenting.py b/src/keria/app/agenting.py index 5b42079e..0c4a10cf 100644 --- a/src/keria/app/agenting.py +++ b/src/keria/app/agenting.py @@ -2099,6 +2099,7 @@ def on_get(req, rep, alias): oobis.append(urljoin(up.geturl(), f"/oobi/{hab.pre}/controller")) res["oobis"] = oobis elif role in (kering.Roles.agent,): + includeEid = aiding.includeEidParam(req) oobis = [] roleUrls = hab.fetchRoleUrls( hab.pre, scheme=kering.Schemes.http, role=kering.Roles.agent @@ -2110,15 +2111,18 @@ def on_get(req, rep, alias): 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 + # Outer for loops over multi-valued mict that could have multiple "agent" dict values + for eurls in roleUrls.naball(kering.Roles.agent): + for eid, urls in eurls.items(): + url = ( + urls[kering.Schemes.http] + if kering.Schemes.http in urls + else urls[kering.Schemes.https] + ) + oobi = aiding.agentOobiUrl(hab, url, eid, includeEid=includeEid) + if oobi not in oobis: + oobis.append(oobi) + res["oobis"] = oobis else: rep.status = falcon.HTTP_404 return diff --git a/src/keria/app/aiding.py b/src/keria/app/aiding.py index c8050faa..9eeadbb8 100644 --- a/src/keria/app/aiding.py +++ b/src/keria/app/aiding.py @@ -1308,6 +1308,24 @@ class OOBI: ) +def includeEidParam(req): + """Return True when the request explicitly asks for endpoint-qualified OOBIs.""" + return req.params.get("includeEid", "").lower() in ("true", "1") + + +def agentOobiUrl(hab, url, eid, includeEid=False): + """ + Only return agent AID suffix when AID is single sig, by default. + Agent AID suffix not included by default for multisig/group habs. + """ + up = urlparse(url) + path = f"/oobi/{hab.pre}/agent" + if includeEid or not isinstance(hab, habbing.SignifyGroupHab): + path = f"{path}/{eid}" + + return urljoin(up.geturl(), path) + + class IdentifierOOBICollectionEnd: """ This class represents the OOBI subresource collection endpoint for identifiers @@ -1407,7 +1425,8 @@ def on_get(req, rep, name): 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 + elif role in (kering.Roles.agent,): # Fetch URL OOBIs for all agent endpoints + includeEid = includeEidParam(req) roleUrls = hab.fetchRoleUrls( cid=hab.pre, role=kering.Roles.agent, scheme=kering.Schemes.http ) or hab.fetchRoleUrls( @@ -1416,22 +1435,23 @@ def on_get(req, rep, name): 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}") - ) + # Outer for loops over multi-valued mict that could have multiple "agent" dict values + for aoobis in roleUrls.naball(kering.Roles.agent): + 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: + oobi = agentOobiUrl( + hab, url, agent, includeEid=includeEid + ) + if oobi not in oobis: + oobis.append(oobi) res["oobis"] = oobis elif role in (kering.Roles.mailbox,): # Fetch URL OOBIs for all witnesses 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..47cec773 100644 --- a/tests/app/test_agenting.py +++ b/tests/app/test_agenting.py @@ -663,6 +663,12 @@ def test_keystate_ends(helpers): def test_oobi_ends(seeder, helpers): with ( helpers.openKeria() as (agency, agent, app, client), + helpers.openKeria(salter=core.Salter(raw=b"0123456789abcM01")) as ( + _, + _, + otherApp, + otherClient, + ), habbing.openHby( name="wes", salt=core.Salter(raw=b"wess-the-witness").qb64 ) as wesHby, @@ -681,6 +687,7 @@ def test_oobi_ends(seeder, helpers): # Register the identifier endpoint so we can create an AID for the test end = aiding.IdentifierCollectionEnd() app.add_route("/identifiers", end) + otherApp.add_route("/identifiers", aiding.IdentifierCollectionEnd()) salt = b"0123456789abcdef" helpers.createAid(client, "pal", salt, wits=[wesHab.pre], toad="1") palPre = "EEkruFP-J0InOD9cYbNLlBxQtkLAbmJPNecSnBzJixP0" @@ -833,6 +840,56 @@ def test_oobi_ends(seeder, helpers): "role": "agent", } + result = client.simulate_get(path="/oobi/aggie?role=agent&includeEid=true") + 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" + ] + + # Tests with actual multisig AID that Agent AID is not on OOBI unless includeEid is specified + group = helpers.createMultisigAid( + [client, otherClient], + "multisig", + [ + ("multisig0", b"abcdef0123456789"), + ("multisig1", b"fedcba9876543210"), + ], + )[0] + groupPre = group["prefix"] + assert isinstance(agent.hby.habs[groupPre], habbing.SignifyGroupHab) + other = "EAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + # Just pin the endpoint role and locs records to simplify the test code as we are testing + # OOBI generation, not endrole or loc scheme addition/authorization + agent.hby.db.ends.pin( + keys=(groupPre, kering.Roles.agent, agent.agentHab.pre), + val=basing.EndpointRecord(allowed=True), + ) + agent.hby.db.ends.pin( + keys=(groupPre, kering.Roles.agent, other), + val=basing.EndpointRecord(allowed=True), + ) + agent.hby.db.locs.put( + keys=(other, kering.Schemes.http), + val=basing.LocationRecord(url=url), + ) + + # without includeEid - should not have agent AID suffix + result = client.simulate_get(path="/oobi/multisig?role=agent") + assert result.status == falcon.HTTP_200 + assert result.json == { + "oobis": [f"http://127.0.0.1:3902/oobi/{groupPre}/agent"], + "role": "agent", + } + + # with includeEid - should have agent AID suffix + result = client.simulate_get(path="/oobi/multisig?role=agent&includeEid=true") + assert result.status == falcon.HTTP_200 + assert set(result.json["oobis"]) == { + f"http://127.0.0.1:3902/oobi/{groupPre}/agent/{agent.agentHab.pre}", + f"http://127.0.0.1:3902/oobi/{groupPre}/agent/{other}", + } + def test_querier(helpers): with helpers.openKeria() as (agency, agent, app, client): diff --git a/tests/app/test_aiding.py b/tests/app/test_aiding.py index 0780809d..1200fc69 100644 --- a/tests/app/test_aiding.py +++ b/tests/app/test_aiding.py @@ -1822,9 +1822,18 @@ def test_identifier_resource_end(helpers): def test_oobi_ends(helpers): - with helpers.openKeria() as (agency, agent, app, client): + with ( + helpers.openKeria() as (agency, agent, app, client), + helpers.openKeria(salter=core.Salter(raw=b"0123456789abcM01")) as ( + _, + _, + otherApp, + otherClient, + ), + ): end = aiding.IdentifierCollectionEnd() app.add_route("/identifiers", end) + otherApp.add_route("/identifiers", aiding.IdentifierCollectionEnd()) endRolesEnd = aiding.EndRoleCollectionEnd() app.add_route("/identifiers/{name}/endroles", endRolesEnd) @@ -1914,6 +1923,54 @@ def test_oobi_ends(helpers): "http://127.0.0.1:3902/oobi/EHgwVwQT15OJvilVvW57HE4w0-GPs_Stj2OFoAHZSysY/agent/EI7AkI40M1" "1MS7lkTCb10JC9-nDt-tXwQh44OHAFlv_9" ) + res = client.simulate_get("/identifiers/pal/oobis?role=agent&includeEid=true") + assert res.status_code == 200 + assert res.json["oobis"] == [oobis[0]] + + # Tests with actual multisig AID that Agent AID is not on OOBI unless includeEid is specified + group = helpers.createMultisigAid( + [client, otherClient], + "multisig", + [ + ("multisig0", b"abcdef0123456789"), + ("multisig1", b"fedcba9876543210"), + ], + )[0] + groupPre = group["prefix"] + assert isinstance(agent.hby.habs[groupPre], habbing.SignifyGroupHab) + other = "EAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + # Just pin the endpoint role and locs records to simplify the test code as we are testing + # OOBI generation, not endrole or loc scheme addition/authorization + agent.hby.db.ends.pin( + keys=(groupPre, kering.Roles.agent, agent.agentHab.pre), + val=basing.EndpointRecord(allowed=True), + ) + agent.hby.db.ends.pin( + keys=(groupPre, kering.Roles.agent, other), + val=basing.EndpointRecord(allowed=True), + ) + agent.hby.db.locs.put( + keys=(other, kering.Schemes.http), + val=LocationRecord(url="http://127.0.0.1:3902"), + ) + + # without includeEid - should not have agent AID suffix + res = client.simulate_get("/identifiers/multisig/oobis?role=agent") + assert res.status_code == 200 + assert res.json == { + "oobis": [f"http://127.0.0.1:3902/oobi/{groupPre}/agent"], + "role": "agent", + } + + # with includeEid - should have agent AID suffix + res = client.simulate_get( + "/identifiers/multisig/oobis?role=agent&includeEid=true" + ) + assert res.status_code == 200 + assert set(res.json["oobis"]) == { + f"http://127.0.0.1:3902/oobi/{groupPre}/agent/{agent.agentHab.pre}", + f"http://127.0.0.1:3902/oobi/{groupPre}/agent/{other}", + } res = client.simulate_get("/identifiers/pal/oobis?role=witness") assert res.status_code == 200 From 0bef6449ed941edf1236860434ce4ee7dda93806 Mon Sep 17 00:00:00 2001 From: Kent Bull Date: Fri, 24 Jul 2026 23:55:38 -0600 Subject: [PATCH 2/2] Clean up OOBI handling with Oobier --- src/keria/app/agenting.py | 159 +--------------- src/keria/app/aiding.py | 157 ++-------------- src/keria/app/oobier.py | 365 +++++++++++++++++++++++++++++++++++++ src/keria/app/specing.py | 4 +- tests/app/test_agenting.py | 262 ++++---------------------- tests/app/test_aiding.py | 219 ++++------------------ tests/app/test_oobier.py | 280 ++++++++++++++++++++++++++++ tests/app/test_specing.py | 10 +- 8 files changed, 749 insertions(+), 707 deletions(-) create mode 100644 src/keria/app/oobier.py create mode 100644 tests/app/test_oobier.py diff --git a/src/keria/app/agenting.py b/src/keria/app/agenting.py index f51a4f09..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,151 +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,): - includeEid = aiding.includeEidParam(req) - 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" - ) - - # Outer for loops over multi-valued mict that could have multiple "agent" dict values - for eurls in roleUrls.naball(kering.Roles.agent): - for eid, urls in eurls.items(): - url = ( - urls[kering.Schemes.http] - if kering.Schemes.http in urls - else urls[kering.Schemes.https] - ) - oobi = aiding.agentOobiUrl(hab, url, eid, includeEid=includeEid) - if oobi not in oobis: - oobis.append(oobi) - 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 14e1a939..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,38 +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)}, - ) - - -def includeEidParam(req): - """Return True when the request explicitly asks for endpoint-qualified OOBIs.""" - return req.params.get("includeEid", "").lower() in ("true", "1") - - -def agentOobiUrl(hab, url, eid, includeEid=False): - """ - Only return agent AID suffix when AID is single sig, by default. - Agent AID suffix not included by default for multisig/group habs. - """ - up = urlparse(url) - path = f"/oobi/{hab.pre}/agent" - if includeEid or not isinstance(hab, habbing.SignifyGroupHab): - path = f"{path}/{eid}" - - return urljoin(up.geturl(), path) - - class IdentifierOOBICollectionEnd: """ This class represents the OOBI subresource collection endpoint for identifiers @@ -1362,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. @@ -1378,123 +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 agent endpoints - includeEid = includeEidParam(req) - 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: - oobis = list() - # Outer for loops over multi-valued mict that could have multiple "agent" dict values - for aoobis in roleUrls.naball(kering.Roles.agent): - 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: - oobi = agentOobiUrl( - hab, url, agent, includeEid=includeEid - ) - if oobi not in oobis: - oobis.append(oobi) - - 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/tests/app/test_agenting.py b/tests/app/test_agenting.py index 47cec773..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,235 +660,39 @@ def test_keystate_ends(helpers): } -def test_oobi_ends(seeder, helpers): - with ( - helpers.openKeria() as (agency, agent, app, client), - helpers.openKeria(salter=core.Salter(raw=b"0123456789abcM01")) as ( - _, - _, - otherApp, - otherClient, - ), - 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) +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} ) + agent.oobier.resolve = mock.Mock(return_value=operation) - # Register the identifier endpoint so we can create an AID for the test - end = aiding.IdentifierCollectionEnd() - app.add_route("/identifiers", end) - otherApp.add_route("/identifiers", aiding.IdentifierCollectionEnd()) - 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 + body = {"url": "http://example.com/oobi/EAID/controller", "oobialias": "aid"} + result = client.simulate_post(path="/oobis", json=body) - # 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/" - ) - oobiery.hby.db.oobis.rem(keys=(url,)) - - # 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 - - # 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", - } - - result = client.simulate_get(path="/oobi/aggie?role=agent&includeEid=true") - 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" - ] - - # Tests with actual multisig AID that Agent AID is not on OOBI unless includeEid is specified - group = helpers.createMultisigAid( - [client, otherClient], - "multisig", - [ - ("multisig0", b"abcdef0123456789"), - ("multisig1", b"fedcba9876543210"), - ], - )[0] - groupPre = group["prefix"] - assert isinstance(agent.hby.habs[groupPre], habbing.SignifyGroupHab) - other = "EAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" - # Just pin the endpoint role and locs records to simplify the test code as we are testing - # OOBI generation, not endrole or loc scheme addition/authorization - agent.hby.db.ends.pin( - keys=(groupPre, kering.Roles.agent, agent.agentHab.pre), - val=basing.EndpointRecord(allowed=True), - ) - agent.hby.db.ends.pin( - keys=(groupPre, kering.Roles.agent, other), - val=basing.EndpointRecord(allowed=True), - ) - agent.hby.db.locs.put( - keys=(other, kering.Schemes.http), - val=basing.LocationRecord(url=url), - ) - - # without includeEid - should not have agent AID suffix - result = client.simulate_get(path="/oobi/multisig?role=agent") - assert result.status == falcon.HTTP_200 - assert result.json == { - "oobis": [f"http://127.0.0.1:3902/oobi/{groupPre}/agent"], - "role": "agent", - } - - # with includeEid - should have agent AID suffix - result = client.simulate_get(path="/oobi/multisig?role=agent&includeEid=true") - assert result.status == falcon.HTTP_200 - assert set(result.json["oobis"]) == { - f"http://127.0.0.1:3902/oobi/{groupPre}/agent/{agent.agentHab.pre}", - f"http://127.0.0.1:3902/oobi/{groupPre}/agent/{other}", - } + 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 731b7bf6..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,207 +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), - helpers.openKeria(salter=core.Salter(raw=b"0123456789abcM01")) as ( - _, - _, - otherApp, - otherClient, - ), - ): - end = aiding.IdentifierCollectionEnd() - app.add_route("/identifiers", end) - otherApp.add_route("/identifiers", aiding.IdentifierCollectionEnd()) - - 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" - - # 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", - } - - # 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", - } - - # 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 - - # 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"] - - 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=agent&includeEid=true") - assert res.status_code == 200 - assert res.json["oobis"] == [oobis[0]] - - # Tests with actual multisig AID that Agent AID is not on OOBI unless includeEid is specified - group = helpers.createMultisigAid( - [client, otherClient], - "multisig", - [ - ("multisig0", b"abcdef0123456789"), - ("multisig1", b"fedcba9876543210"), - ], - )[0] - groupPre = group["prefix"] - assert isinstance(agent.hby.habs[groupPre], habbing.SignifyGroupHab) - other = "EAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" - # Just pin the endpoint role and locs records to simplify the test code as we are testing - # OOBI generation, not endrole or loc scheme addition/authorization - agent.hby.db.ends.pin( - keys=(groupPre, kering.Roles.agent, agent.agentHab.pre), - val=basing.EndpointRecord(allowed=True), - ) +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=(groupPre, kering.Roles.agent, other), + keys=(aid, kering.Roles.agent, agent.agentHab.pre), val=basing.EndpointRecord(allowed=True), ) - agent.hby.db.locs.put( - keys=(other, kering.Schemes.http), + 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) + + res = client.simulate_get("/identifiers/pal/oobis?role=agent&includeEid=true") - # without includeEid - should not have agent AID suffix - res = client.simulate_get("/identifiers/multisig/oobis?role=agent") assert res.status_code == 200 assert res.json == { - "oobis": [f"http://127.0.0.1:3902/oobi/{groupPre}/agent"], + "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) - # with includeEid - should have agent AID suffix - res = client.simulate_get( - "/identifiers/multisig/oobis?role=agent&includeEid=true" - ) - assert res.status_code == 200 - assert set(res.json["oobis"]) == { - f"http://127.0.0.1:3902/oobi/{groupPre}/agent/{agent.agentHab.pre}", - f"http://127.0.0.1:3902/oobi/{groupPre}/agent/{other}", - } - - 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 +def test_identifier_oobi_collection_end_validates_transport(helpers): + with helpers.openKeria() as (_, _, app, client): + app.add_route("/identifiers/{name}/oobis", aiding.IdentifierOOBICollectionEnd()) - 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") - res = client.simulate_get("/identifiers/pal/oobis?role=controller") - assert res.status_code == 200 - role = res.json["role"] - oobis = res.json["oobis"] + assert res.status_code == 400 + assert res.json["description"] == "role parameter required" - 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)