diff --git a/cmd/adapter/main_test.go b/cmd/adapter/main_test.go index ee0a57b8..f868f8b3 100644 --- a/cmd/adapter/main_test.go +++ b/cmd/adapter/main_test.go @@ -74,6 +74,16 @@ func (m *MockPluginManager) Cache(ctx context.Context, cfg *plugin.Config) (defi return nil, nil } +// Mapper returns a mock implementation of the Mapper interface. +func (m *MockPluginManager) Mapper(ctx context.Context, cfg *plugin.Config) (definition.Mapper, error) { + return nil, nil +} + +// ProviderStep returns a mock implementation of the provider Step interface. +func (m *MockPluginManager) ProviderStep(ctx context.Context, registry definition.ProviderRecordLookup, mapper definition.Mapper, cfg *plugin.Config) (definition.Step, error) { + return nil, nil +} + // Registry returns a mock implementation of the RegistryLookup interface. func (m *MockPluginManager) Registry(ctx context.Context, cache definition.Cache, cfg *plugin.Config) (definition.RegistryLookup, error) { return nil, nil diff --git a/config/mappings/agmarknet/mandi-price.select.yaml b/config/mappings/agmarknet/mandi-price.select.yaml new file mode 100644 index 00000000..81e11115 --- /dev/null +++ b/config/mappings/agmarknet/mandi-price.select.yaml @@ -0,0 +1,367 @@ +# Agmarknet Vistaar, openagrinet:MandiPrice, select. Both directions, one file. +# +# One file per binding-action rather than one per direction, because both legs of +# an exchange are one contract: the response has to answer the request that was +# sent, and splitting them lets one change without the other. +# +# The upstream is Agmarknet's Vistaar select. It takes governed codes -- state, +# district, market, commodity -- plus a date range, and every one of them is in +# the payload, so nothing here needs resolving before the call. That is why the +# mandi plugin has no prerequisites: a MandiPrice select names the market it +# wants rather than a point to search from. +# +# THE ANSWER IS WITHIN THE PACK; THE REQUEST IS NOT, AND CANNOT BE YET. +# +# Every field the response sets is declared by openagrinet:MandiPrice v0.1 and +# correctly placed, inside the closed market and prices property sets. Where +# the upstream reports something the pack has no home for, it is dropped rather +# than invented. +# +# The REQUEST half is a different matter, and an earlier version of this note +# claimed otherwise. It reads market and validity, and the pack's OnDemand +# branch EXCLUDES both -- see the note on the guards below. That is a gap in +# the pack rather than a mistake here: OnDemand describes what a provider can +# obtain and Direct describes an obtained reading, and a request is neither. + +# What this capability cannot serve, refused before the provider is called. +# +# THE PACK AND THESE GUARDS DISAGREE TODAY, and an earlier version of this note +# said the pack leaves market and validity OPTIONAL. It does not: the OnDemand +# branch EXCLUDES them, alongside source, commodity, commodityGroup, grade, +# variety, arrivalDate, prices and generatedAt. So a payload that satisfies +# these guards cannot validate against the pack, and one that validates cannot +# satisfy them. +# +# Nothing breaks in practice: the exclusion sits under if/then, which the +# validator parses and never evaluates. That is an accident to rely on rather +# than a design, and it is written down here so it is not mistaken for one. +# +# Resolving it is a pack change. For the market, coverageAreas is the likely +# home -- inherited from AgricultureResource, not excluded in OnDemand, and it +# accepts an AdministrativeAreaReference. For the date range there is no +# candidate at all: historyPeriod and updateFrequency are durations describing +# provider capability, not a window a caller asks for. +# +# What IS true, and is the reason this block exists: the pack defines +# market.district and market.state as "name or governed code", so a payload can +# be perfectly valid and still be unanswerable by this upstream, which wants +# codes and a date range. +# +# Refusing here names what is missing. Sending it anyway earns a 400 from +# Agmarknet, or worse an empty result that reads as "no prices". +required: + - check: | + ( + $ra := beckn.message.contract.commitments[0].resources[0].resourceAttributes; + /* Exactly one. Everything downstream reads supportedCommodities[0]: + this guard, the outbound query, and the commodity stamped on each + resource. So a caller sending three commodities passed validation, was + queried for the first, and got a confident signed answer to a third of + what it asked -- the same failure capabilitybinding refuses at the + commitment level, where guessing would silently serve part of a + request. */ + $exists($ra.supportedCommodities[0].code) + and $count($ra.supportedCommodities) = 1 + ) + message: "this capability needs exactly one commodity code in supportedCommodities; send several requests rather than have all but the first dropped" + - check: | + ( + $ra := beckn.message.contract.commitments[0].resources[0].resourceAttributes; + /* Shape, not just presence. The pack describes district and state as + "name or governed code", so the names form is pack-legal -- and the + existence-only check passed it, after which it went to Agmarknet + verbatim as codes. Agmarknet answered nothing, and the caller got a + signed, spec-valid "no prices" for a market that had prices. + + Agmarknet wants a numeric district and a short alphabetic state (96, + CG), so a district that is not all digits or a state longer than four + letters is a name and is refused with the reason. */ + $match($ra.market.district, /^[0-9]+$/) + and $match($ra.market.state, /^[A-Za-z]{2,4}$/) + ) + message: "this capability needs Agmarknet's codes in market: a numeric district and a short alphabetic state, not their names" + - check: | + ( + $ra := beckn.message.contract.commitments[0].resources[0].resourceAttributes; + $exists($ra.validity.startsAt) and $exists($ra.validity.endsAt) + ) + message: "this capability needs a validity window; it reports prices over a date range" + +# The upstream is a GET, so this object becomes the query string. The token is +# not here and must never be: it comes from the adapter's authScheme query, +# whose value is read from an environment variable. This file is published. +# +# marketcode is sent when the payload names one and omitted otherwise, which is +# what the upstream expects: without it the query widens from one market to the +# whole district. +request: | + ( + $ra := beckn.message.contract.commitments[0].resources[0].resourceAttributes; + + /* The upstream wants dd-MM-yyyy; the pack's validity is an ISO date. A + substring reorder rather than a date library, because these are dates + with no time and no zone, and $fromMillis would invent both. */ + $ddmmyyyy := function($iso) { + $substring($iso, 8, 2) & "-" & $substring($iso, 5, 2) & "-" & $substring($iso, 0, 4) + }; + + $base := { + "statecode": $ra.market.state, + "districtcode": $ra.market.district, + "commoditycode": $ra.supportedCommodities[0].code, + "from_date": $ddmmyyyy($ra.validity.startsAt), + "to_date": $ddmmyyyy($ra.validity.endsAt) + }; + + $exists($ra.market.marketCode) + ? $merge([$base, {"marketcode": $ra.market.marketCode}]) + : $base + ) + +# One resource per price record, in Direct mode. +# +# Direct is what the pack requires of an answer: the resource now CARRIES the +# observation rather than advertising that it could obtain one. It requires +# source, commodity, market, arrivalDate, prices and generatedAt, and all six +# are set below. +# +# The upstream's records use Title Case keys WITH SPACES -- `Modal Price`, not +# modalPrice -- so they need backticks, and its prices are STRINGS, so they need +# $number() before they satisfy the pack's numeric types. +response: | + ( + $records := $type(response) = "array" ? response + : $exists(response.data) ? response.data + : $exists(response.records) ? response.records + : []; + + $selected := beckn.message.contract.commitments[0]; + + /* The caller's own @context, echoed back rather than restated here. A + mapping that hardcodes it has to be reissued whenever the pack URL + moves, and it can disagree with what the request actually declared. + Backticks because @ is an operator in JSONata. */ + $ctx := beckn.message.contract.commitments[0].resources[0].resourceAttributes.`@context`; + $ra := $selected.resources[0].resourceAttributes; + + /* dd-MM-yyyy back to ISO, so the answer speaks the pack's date format + rather than the upstream's. */ + $iso := function($ddmmyyyy) { + $substring($ddmmyyyy, 6, 4) & "-" & $substring($ddmmyyyy, 3, 2) & "-" & $substring($ddmmyyyy, 0, 2) + }; + + /* The market code is optional: without it the query widened to the whole + district, so the district code is what identifies the scope. */ + $scope := $exists($ra.market.marketCode) ? $ra.market.marketCode : $ra.market.district; + /* SLUG for the parts that are names rather than codes, so an id has no + spaces and no case surprises. */ + $slug := function($v) { $exists($v) ? $replace($lowercase($v), " ", "-") }; + + /* Bound once because it is used twice -- for a resource's own id and for + the offer's reference to it. Two copies of one expression is how a + dangling reference gets reintroduced. + + Built from CODES, not the names the upstream reports. A market name + carries spaces and a commodity name carries brackets -- "Kasdol APMC", + "Paddy(Common)" -- and an identifier that a consumer may put in a URL or + a filter should not. The codes are already in the payload, so they cost + nothing, and they are stable where a display name is not. + + Every field the upstream distinguishes rows by is in the id. + + It was scope:commodity:date only, and Agmarknet routinely reports + several rows for the same market, commodity and date differing by + Variety and Grade -- the fixture in this package is exactly that pair. + Two distinct resources then shared one id, and the offer referenced it + twice, so a consumer resolving resourceIds could not tell which price + it had. Market is in it too: district-wide $scope is the district, and + the rows come from several markets inside it. + + Built with $join over a list, because an absent Variety or Grade drops + out of an array literal rather than leaving an empty segment. */ + $resourceId := function($r) { + "res:agmarknet:" & $join([ + $scope, + $ra.supportedCommodities[0].code, + $iso($r.`Arrival Date`), + $slug($r.Market), + $slug($r.Variety), + $slug($r.Grade) + ], ":") + }; + + /* Absent rather than present-and-empty: a consumer must be able to tell + "the market reported no minimum" from "the minimum was zero". + + GUARDS SHAPE, NOT JUST PRESENCE. Agmarknet writes an unreported price as + a marker -- "NR", "-", "" -- and $exists() is true for all of them, so + an existence-only guard handed them to $number() and it threw D3030. + That failed the WHOLE response: one unreported cell in one row turned a + good multi-row answer into an adapter error, which is the opposite of + the distinction this function exists to preserve. + + The number branch is not redundant. $match() throws T0410 on a + non-string, so testing the regex first would break the day this + upstream sends a real number instead of a quoted one. */ + /* The price fields the caller asked for. Absent means all three: the + pack requires supportedPriceFields on an OnDemand request, so absence + is a payload this mapping does not have to serve well -- but it should + not silently return nothing either. */ + $fields := $exists($ra.supportedPriceFields) + ? $ra.supportedPriceFields + : ["Minimum", "Maximum", "Modal"]; + + $priced := function($value) { + $type($value) = "number" + ? $value + : ($type($value) = "string" and $match($value, /^[0-9]+(\.[0-9]+)?$/) + ? $number($value)) + }; + + /* EMIT ONLY RECORDS THAT CAN PRODUCE A CONFORMANT RESOURCE. + Absent is honest; present-and-degenerate is a lie in the shape of an + answer, and it is worse here than elsewhere because the answer is + signed. Three ways a record cannot be made conformant: + + no Arrival Date $iso is substring-and-concatenate, and JSONata casts + undefined to "" -- so an absent date became the + string "--", which the pack refuses (format: date) + and which degraded the resource id along with it. + arrivalDate is on the Direct required list, so it + cannot be omitted either. + + no Price Unit prices.required is [currency, unit]. JSONata drops + an absent key rather than emitting null, so the unit + silently vanished and took the whole resource's + validity with it. Not defaulted: Rs./Qtl and Rs./Kg + are both real, so inventing one would misreport a + price by a factor of a hundred. + + no usable price prices carries anyOf [minimum, maximum, modal], so a + row whose three prices are all unreported markers + has nothing to report and cannot satisfy it. + + Dropped rather than refused: the other rows in the same answer are good, + and failing the request would discard them too -- which is the mistake + the price guard above just fixed. */ + $conformant := function($r) { + /* $count(...) > 0 rather than $exists($match(...)): this engine returns + an EMPTY ARRAY from $match when nothing matches, and $exists([]) is + true -- so the $exists form silently accepted every record and the + filter did nothing. Caught by the test, not by reading. */ + $count($match($iso($r.`Arrival Date`), /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/)) > 0 + and $exists($r.`Price Unit`) + /* Judged over the fields the caller asked for, not all three. A row + whose Modal is a marker cannot serve a request for Modal alone, + even though its Minimum is fine. */ + and (("Minimum" in $fields and $exists($priced($r.`Min Price`))) + or ("Maximum" in $fields and $exists($priced($r.`Max Price`))) + or ("Modal" in $fields and $exists($priced($r.`Modal Price`)))) + }; + $usable := $filter($records, $conformant); + + { + "context": { + "version": beckn.context.version, + "action": "on_select", + "networkId": beckn.context.networkId, + "transactionId": beckn.context.transactionId, + "messageId": beckn.context.messageId, + "timestamp": $now() + }, + "message": { + "contract": { + "commitments": [ + { + "status": { + "descriptor": { "code": "DRAFT", "name": "Draft" } + }, + /* The offer is echoed, but its references are not: the request + named an abstract price enquiry and the answer returns the + concrete observations. Leaving resourceIds as they arrived + would point the offer at an id appearing nowhere here. */ + "offer": $merge([ + $selected.offer, + { "resourceIds": [$map($usable, function($r) { $resourceId($r) })] } + ]), + /* Wrapped: JSONata collapses a one-element sequence to a bare + value, so a single-record answer would return an object where + every other count returns a list. */ + "resources": [$map($usable, function($r) { + { + "id": $resourceId($r), + /* Required by Commitment.resources in the Beckn v2 spec, + which defines no quantity property and carries no Quantity + schema at all -- a defect upstream. One resource is one + market's observation for one day, so one. */ + "quantity": 1, + "resourceAttributes": { + "@context": $ctx, + "@type": "openagrinet:MandiPrice", + "informationMode": "Direct", + /* Stated, not echoed. subjectCategories is a closed enum on + AgricultureResource, and "Market" is what a MandiPrice resource + IS -- the pack states it in both of its own examples. Echoing the + request meant a caller sending ["Weather"] on a MandiPrice select + got it faithfully republished over this adapter's signature, and + nothing caught it: the value is enum-legal, so it validates. The + sibling weather mapping has always stated ["Weather"]. */ + "subjectCategories": ["Market"], + "source": { + "sourceId": "agmarknet", + "sourceName": "Agmarknet Vistaar" + }, + "commodity": { + "code": $ra.supportedCommodities[0].code, + "name": $r.Commodity + }, + "commodityGroup": $r.Group, + "variety": $r.Variety, + "grade": $r.Grade, + /* Every member comes from the RECORD, so the answer describes what + the provider reported rather than what was asked for. + + marketCode is absent, and that is not an omission. This upstream + TAKES a market code as a query parameter and reports none back -- + there is no such field anywhere in its response. Restating the + requested code against a returned row would assert something + unverified, and district-wide there is no requested code at all + while the rows come from several markets, so one code would have + been wrong for most of them. The pack requires only marketName + and calls marketCode "when available"; here it is not. */ + "market": { + "marketName": $r.Market, + "district": $r.District, + "state": $r.State + }, + "arrivalDate": $iso($r.`Arrival Date`), + "prices": { + /* Only what was asked for. supportedPriceFields was validated on the + way in and then ignored on the way out, so a caller asking for Modal + alone got all three. A ternary with no else yields nothing and the key + drops, which is the same mechanism an unreported price already uses. */ + "minimum": "Minimum" in $fields ? $priced($r.`Min Price`), + "maximum": "Maximum" in $fields ? $priced($r.`Max Price`), + "modal": "Modal" in $fields ? $priced($r.`Modal Price`), + /* INR is a fact about THIS UPSTREAM, not about the pack, and it is + stated rather than read because Agmarknet reports no currency field + to read -- unlike the unit on the next line, which it does report. + The pack requires currency, so it cannot be omitted either. + + Worth being deliberate about the difference: on_select, DRAFT and the + @type values are PACK constants, correctly stated. INR and the + agmarknet sourceId are PROVIDER constants -- they change if this file + is pointed at another upstream, and the pack ones do not. */ + "currency": "INR", + "unit": $r.`Price Unit` + }, + "generatedAt": $now() + } + } + })] + } + ] + } + } + } + ) diff --git a/config/mappings/mausamgram/weather-observation.select.yaml b/config/mappings/mausamgram/weather-observation.select.yaml new file mode 100644 index 00000000..f8cdb410 --- /dev/null +++ b/config/mappings/mausamgram/weather-observation.select.yaml @@ -0,0 +1,311 @@ +# Mausamgram, openagrinet:WeatherObservation, select. Both directions, one file. +# +# One file per binding-action rather than one per direction, because both legs of +# one upstream call are one unit of configuration: they are published, reviewed +# and retired together, and a reference to one is a reference to the other. +# +# The registry entry pointing here decides which action this serves, so nothing +# in the file names it. The filename's action segment must match that entry -- +# a mismatch would apply a correct mapping to the wrong call, silently. +# +# Both halves read: +# beckn the inbound Beckn payload -- the context to echo, the offer to +# quote against +# and the response half additionally reads: +# response the provider's answer, in its own shape +# +# and both halves also read: +# _local whatever the step's prerequisites resolved before the call -- a +# code looked up from a name, a point resolved to a market. EMPTY +# here: this plugin declares no prerequisites, so nothing in this +# file reads it. +# +# Nothing else is in scope. In particular, values the step already holds and +# merely used to make the call are not in _local: reading those back would be a +# second name for the same data. Where the answer needs one, it takes it from +# what the provider echoed. + +# The response half follows the openagrinet:WeatherObservation v0.1 schema pack, +# Direct mode. The pack lives in OpenAgriNet/network-specs; it is referred to +# here by name and version rather than by a path, because a path pins a branch +# and a branch moves. +# +# @context is not stated here at all. The response echoes whatever the request +# declared, so this file never has to know which pack URL is current and cannot +# contradict the caller. +# +# Direct mode requires observationType, source, location, generatedAt and +# parameters. informationMode is what selects those requirements: a catalog +# resource advertising this capability is OnDemand instead, and carries +# supportedParameters rather than values. +# +# The answer returns ONE RESOURCE PER FORECAST DAY, each with its own id derived +# from its date. That is the shape the pack describes: every WeatherObservation +# example carries a single validity and a flat parameters array, so a period is a +# resource and there is no form for several in one. +# +# The ids are therefore new -- the request named an abstract point forecast, the +# answer returns the concrete days that satisfy it. Which is why the offer's +# resourceIds are rewritten below rather than echoed: the offer arrives naming +# the id that was asked for, and leaving it would point the offer at something +# that appears nowhere in the answer. +# +# ONE FIELD HERE IS NOT IN THE PACK, and this does NOT validate against it. +# An earlier version of this comment said the pack set no additionalProperties. +# It does: WeatherObservation v0.1 closes parameters.items with +# `additionalProperties: false`, so the field below is refused, not merely +# ungoverned. +# +# aggregation The pack's parameter entry is parameter/value/unit only, and +# its `parameter` enum has no minimum or maximum variants. This +# provider reports a minimum AND a maximum for temperature and +# humidity every day, so without this field the two arrive as +# two identical Temperature readings and a consumer cannot tell +# which is which. +# +# It is kept because dropping it loses information the pack cannot express any +# other way -- removing it would make the answer conformant and useless. The +# resolution is a pack change, an aggregation field or enum variants, and this +# mapping follows once that lands. Nothing validates a response today, so it +# does not bite until a consumer checks. +# +# Fields that are the same for every day -- the point, the source, the +# observation type -- sit once at the top. Only what varies per day repeats. + +# What this capability requires of a payload, checked before either half runs. +# A predicate that is false refuses the request with the message beside it, so +# the caller is told what is wrong with their payload rather than that an +# expression somewhere returned false. +# +# This rule used to be Go: the step read the geometry and required a Point, which +# meant a capability with a different rule needed a different build. It is here +# now, beside the extraction it guards. +# +# NOTE the consequence: nothing in the adapter enforces a geometry any more. A +# mapping that declares no preconditions accepts whatever arrives and hands it to +# the request half, which is exactly the configurability that was asked for -- +# and exactly why the responsibility sits in this file. +# +# One check, because there is one thing to say. $exists guards the type test, so +# a request carrying no location and a request carrying a Polygon both land here +# and both learn what this capability needs -- splitting them would be two +# entries repeating the same sentence. +# +# Each check is its own expression and binds $ra for itself; there is no shared +# scope with the halves below. Where several checks say genuinely different +# things, they are separate entries and the first failure is the one reported. +required: + - check: | + ( + $ra := beckn.message.contract.commitments[0].resources[0].resourceAttributes; + $exists($ra.location) and $ra.location.type = "Point" + ) + message: "this capability needs a Point location; the provider forecasts one point at a time" + + # NOTE, and it is not a small one: the pack and this check disagree today. + # WeatherObservation v0.1's OnDemand branch does not leave `location` + # optional -- it EXCLUDES it, alongside observationType, source, generatedAt, + # observedAt, modelRunAt, validity and parameters. So a request conforming to + # the pack carries no location and this check refuses it, while a request + # satisfying this check does not conform. + # + # Nothing breaks in practice: the exclusion sits under if/then, which the + # validator parses and never evaluates. That is an accident to rely on rather + # than a design. + # + # The pack has no third mode for a question -- OnDemand describes what a + # provider can obtain, Direct describes an obtained reading, and a request is + # neither. Resolving it is a pack change. `coverageAreas` is inherited from + # AgricultureResource, is NOT excluded in OnDemand, and accepts a GeoJSON + # geometry, so it is the likely home for the point once that is settled. + +# The request half decides what the provider is asked for. Whatever it produces +# IS the request: query parameters for a method with no body, a body for one that +# takes it. +# +# This is where the extraction lives, deliberately. The step reads only the +# geometry's type -- enough to refuse a Polygon with a clear error, because a +# mapping cannot refuse -- and nothing else. So when this provider wants another +# parameter, it is an edit here and nothing else: no Go, no rebuild, live on the +# next cache expiry. +# +# A date range, for instance, is already in the payload and would be two lines: +# +# "from": $ra.validity.startsAt, +# "to": $ra.validity.endsAt +# +# $ra is bound once so the rest reads as plain field access rather than four +# repetitions of the same path. +# +# GeoJSON is [lon, lat] -- longitude first. Reading them the other way round +# gives a point in the wrong hemisphere that is still a valid request, so it +# fails as wrong data rather than as an error. +request: | + ( + $ra := beckn.message.contract.commitments[0].resources[0].resourceAttributes; + { + "lat": $ra.location.coordinates[1], + "lon": $ra.location.coordinates[0] + } + ) + +# Keyed by direction, not by the action it produces: a select is answered by an +# on_select over the same HTTP round trip, so the callback is this half rather +# than an action of its own. +response: | + ( + $lat := response.location.lat; + $lon := response.location.lon; + /* However many days the provider sent. It answers fcstday1..fcstdayN and N + is whatever the forecast ran to, so naming five would truncate a ten-day + answer and mis-handle a one-day one. + + Sorted on the numeric suffix, not the key: the keys sort lexically as + fcstday1, fcstday10, fcstday2, and a ten-day forecast delivered in that + order would be wrong in a way nothing downstream could detect. */ + $days := $each(response, function($v, $k) { + $contains($k, "fcstday") ? { + "n": $number($substringAfter($k, "fcstday")), + "day": $v + } + })^(n).day; + + $reading := function($name, $aggregation, $unit, $value) { + $exists($value) ? { + "parameter": $name, + "aggregation": $aggregation, + "unit": $unit, + "value": $value + } + }; + + /* A warning is a parameter, not a field of its own: the pack has no + advisory property but does have an Alert parameter. Unit "1" is what it + prescribes for a value that has no unit. */ + $alert := function($value) { + $exists($value) ? { + "parameter": "Alert", + "unit": "1", + "value": $value + } + }; + + $selected := beckn.message.contract.commitments[0]; + + /* The caller's own @context, echoed back rather than restated here. A + mapping that hardcodes it has to be reissued whenever the pack URL + moves, and it can disagree with what the request actually declared. + Backticks because @ is an operator in JSONata. */ + $ctx := beckn.message.contract.commitments[0].resources[0].resourceAttributes.`@context`; + + /* Bound once because it is used twice -- for a resource's own id and for the + offer's reference to it. Two copies of the same expression is how a + dangling reference gets reintroduced. */ + $resourceId := function($day) { "res:mausamgram:forecast:" & $day.date }; + + { + /* Correlation only: the ids that tie this answer to the request that + asked for it, and nothing that asserts who anybody is. + + bapId, bapUri, bppId and bppUri are deliberately absent. A mapping is + a payload transformation -- it has no business asserting network + identity, and the two Uri fields it could copy are whatever the caller + happened to send, which in a deployed stack is a container-internal + address that means nothing to anyone outside it. Echoing them would + republish another party's routing details as if they were ours. + + Identity on the wire is the adapter's own: it signs what it answers + with, using the key the registry publishes for it. */ + "context": { + "version": beckn.context.version, + "action": "on_select", + "networkId": beckn.context.networkId, + "transactionId": beckn.context.transactionId, + "messageId": beckn.context.messageId, + "timestamp": $now() + }, + "message": { + "contract": { + "commitments": [ + { + /* DRAFT, not QUOTED. The Beckn v2 status enum is DRAFT, ACTIVE + and CLOSED, and a quote is still a draft: nothing is committed + until init and confirm. QUOTED read better and validated + nowhere -- base schema validation refuses it. */ + "status": { + "descriptor": { "code": "DRAFT", "name": "Draft" } + }, + /* The offer is echoed, but its references are not: the request + named the abstract point forecast, and the answer returns the + concrete days. Leaving resourceIds as they arrived would point + the offer at an id that appears nowhere in the answer. + + $merge keeps everything else the request offered -- the id, the + descriptor, the provider -- and replaces one key. */ + "offer": $merge([ + $selected.offer, + { "resourceIds": [$map($days, function($day) { $resourceId($day) })] } + ]), + /* One resource per forecast day, which is what the pack describes: + every WeatherObservation example carries a single validity and a + flat parameters array, so a period is a resource and there is no + form for several in one. + + Wrapped for the same reason as the resourceIds above: JSONata + collapses a one-element sequence to a bare value, so a one-day + forecast would answer with an object where every other N answers + with a list. */ + "resources": [$map($days, function($day) { + { + "id": $resourceId($day), + /* Required by Commitment.resources in the spec, which defines + no quantity property and no Quantity schema anywhere -- a + defect upstream. One resource is one day's observation, so + one. Omitting it makes every answer fail validation for a + consumer who validates. */ + "quantity": 1, + "resourceAttributes": { + "@context": $ctx, + "@type": "openagrinet:WeatherObservation", + "informationMode": "Direct", + "observationType": "Forecast", + "subjectCategories": ["Weather"], + "source": { + "sourceId": "mausamgram", + "sourceName": "IMD Mausamgram NWP" + }, + /* Emitted only when the provider echoed both coordinates. + JSONata drops undefined values inside an array, so a + provider that answered without its location echo would + otherwise produce "coordinates": [] -- an invalid Point, + signed and delivered. Absent is honest; empty is a lie + in the shape of an answer. */ + "location": $exists($lat) and $exists($lon) ? { + "type": "Point", + "coordinates": [$lon, $lat] + }, + "generatedAt": $now(), + /* This resource reports one day, so its validity opens and + closes on it. */ + "validity": { + "startsAt": $day.date, + "endsAt": $day.date + }, + "parameters": [ + $reading("Rainfall", "Total", "mm", $day.rain), + $reading("Temperature", "Minimum", "Cel", $day.tmin), + $reading("Temperature", "Maximum", "Cel", $day.tmax), + $reading("Humidity", "Minimum", "%", $day.rhmin), + $reading("Humidity", "Maximum", "%", $day.rhmax), + $reading("WindSpeed", "Average", "m/s", $day.wspd), + $alert($day.weather_warning ? $day.weather_warning : $day.cloud_message) + ] + } + } + })] + } + ] + } + } + } + ) diff --git a/config/provider-adapter.yaml b/config/provider-adapter.yaml new file mode 100644 index 00000000..b2329ab0 --- /dev/null +++ b/config/provider-adapter.yaml @@ -0,0 +1,421 @@ +# Provider adapter. +# +# Serves the Beckn actions synchronously: verifies the sender, resolves the +# capability's call plan from the registry, calls the provider, and answers with +# the mapped result. There is no callback -- the answer is the HTTP response. +# +# Two capabilities are served here, weather and mandi, by two domain packages +# in one pipeline. They share this module, the registry client and the mapper, +# and share nothing else: which one answers is decided by the payload, not by +# the URL, the domain or the order they appear in. +# +# Adding a provider is three things, and none of them is a Go change here: +# 1. a registry row binding "|" to a call plan +# 2. one mapping file per action, published at the URL that row names +# 3. one more entry under providerSteps, and its id added to steps +# +# WHAT IS CONFIGURED HERE AND WHAT IS NOT. This file holds how to reach the +# registry, how to present credentials, and which capabilities this module +# serves. It does NOT hold where a provider lives or how long to wait for it -- +# that is the registry's ProviderSchema row, read per request. Repointing a +# provider is a registry write, not an edit here and a restart. +appName: "provider-adapter" + +log: + level: debug + destinations: + - type: stdout + contextKeys: [transaction_id, message_id, subscriber_id, module_id] + +http: + port: 8080 + timeout: + read: 30 + write: 30 + idle: 30 + +pluginManager: + root: ./plugins + +modules: + - name: oanProvider + # A subtree, not one action. The trailing slash matters: Go's ServeMux + # treats a path without one as an exact match, so "/select" would mount + # that one action and 404 every other. At "/" every action lands here. + # + # WHICH ACTION IT IS COMES FROM THE URL, not the payload. The mount path is + # stripped off the request path and what remains -- "select", "discover" -- + # is what the routing config matches on. The schema validator is the one + # exception: it is handed that same stripped path, ignores it, and reads + # context.action out of the body instead. Nothing reconciles the two, though + # a mismatch usually fails validation anyway, since two actions rarely + # accept the same body. + # + # A consequence worth knowing before mounting anything on an exact path: + # stripping "/publish" off "/publish" leaves the empty string, so a module + # mounted there sees an empty endpoint and the router rejects it unless its + # routing rule keys on "" with excludeAction set. + path: / + handler: + type: std + role: bpp + + # This adapter's own network identity -- the id a peer verifies its + # signatures against. A placeholder rather than a working value, + # because one deployment's identity is not everyone's, and a reader + # who copies this signs as somebody else. + # + # The registry requires it to be hostname-shaped, and it is never + # resolved by DNS -- routing between adapters is the router plugin's + # config. Pick it once and deliberately: the registry is append-only, + # delete is soft, and a soft-deleted id keeps the unique index, so an + # id can never be reused. + # + # was provider-network-vistaar.da.gov.in + # dev provider.oan.dev + # + # Must match the keyManager's subscriberId below -- they are the same + # identity, named twice because the plugins are configured separately. + subscriberId: <> + + plugins: + # ------------------------------------------------------------------ + # sunbirdRegistry -- the SunbirdRC registry client. + # + # Serves both halves of the lookup: the sender's signing key for + # validateSign, and the capability call plans the provider steps + # resolve against. Two interfaces, deliberately kept apart -- they + # answer different questions, and a failure of one does not mean the + # other. + # ------------------------------------------------------------------ + registry: + id: sunbirdRegistry + config: + # REQUIRED, and the only key with no default. Include the API + # version prefix; the plugin appends /{entity}/search. + # + # A placeholder rather than a working value, because the + # working value is per-deployment. The one that used to be + # here resolved only inside a Docker network that happened to + # name the service "registry" -- not in Kubernetes, not in a + # local run, not against a hosted registry -- so a reader who + # copied it into any of those got a DNS failure on every + # inbound message, inside signature validation. + # + # Whatever goes here, use a hostname the ADAPTER can resolve, + # not localhost: inside a container localhost is the adapter + # itself. + # + # compose http://registry:8081/api/v1 + # kubernetes http://registry.oan.svc.cluster.local:8081/api/v1 + # hosted https://registry.example.org/api/v1 + url: <> + + # The two entity names, both defaulted. Only worth setting if a + # deployment renamed the schemas. + entity: Participant # default: Participant + providerEntity: ProviderSchema # default: ProviderSchema + + # Tighter than a general-purpose HTTP client on purpose. This runs + # INSIDE signature validation on every inbound message, so + # timeout x (retry_max + 1) is how long a request can wait before + # it is even allowed to be rejected. + timeout: 2 # default: 2 (seconds) + retry_max: 1 # default: 1 + retry_wait_min: 100ms # default: 100ms + retry_wait_max: 500ms # default: 500ms + + # cacheTTL is deliberately unset. It needs a cache plugin + # alongside it -- this client caches nothing without one -- and + # this sample runs no redis, so a TTL here would look like + # caching while every message still made its registry calls + # inside signature validation's budget. The plugin logs a warning + # at startup if the two are not configured together. + # + # Read the number carefully before setting it. It is exactly how + # long a suspended participant keeps verifying, and a withdrawn + # capability keeps being called. + # + # cacheTTL: 60s # default: unset, meaning off + + keyManager: + id: simplekeymanager + config: + # The same identity as the handler's subscriberId above, and it + # has to be: this is the id the keys are looked up under. Set one + # and not the other and the adapter signs as an id the registry + # has no keys for, which fails at the peer rather than here. + # + # was provider-network-vistaar.da.gov.in + subscriberId: <> + + signer: + id: signer + signValidator: + id: signvalidator + + # ------------------------------------------------------------------ + # schemaValidator -- two layers, both on. + # + # BASE validates the envelope against the pinned Beckn v2 LTS spec. + # To it, resourceAttributes is a free-form object: the envelope is + # correct whatever a capability puts inside. + # + # EXTENDED validates that inside. It walks the payload for every + # object carrying both @context and @type, resolves the schema that + # @type names, and validates the object against it. That is what makes + # a wrong unit or a missing required attribute a rejected payload + # rather than a provider's problem to discover later. + # + # The schemas are not in this repository and are not mounted: they + # are fetched from the @context a payload declares, so the revision + # is the payload's choice and nothing here can go stale against it. + # + # What extended validation DOES enforce: types, string formats + # (date-time, duration, uri), enum, const, required, minItems, + # additionalProperties, not, and allOf/anyOf/oneOf. + # + # What it does NOT: if/then/else. The validator library parses those + # keywords but never evaluates them, so a pack's conditional rules -- + # in the capability packs, everything predicated on informationMode + # -- are not checked. Worth knowing before treating a pass here as + # full conformance to a pack. + # ------------------------------------------------------------------ + schemaValidator: + id: schemav2validator + config: + type: url + location: "https://raw.githubusercontent.com/beckn/protocol-specifications-v2/refs/tags/core-v2.0.0-lts/api/v2.0.0/beckn.yaml" + cacheTTL: "3600" + + extendedSchema_enabled: "true" + + # Resolution is a FETCH of the @context each resource declares, + # not a directory mounted here. The validator swaps context.jsonld + # for attributes.yaml to get the schema beside it: + # + # @context .../schema/MandiPrice/v0.1/context.jsonld + # fetched .../schema/MandiPrice/v0.1/attributes.yaml + # + # So a payload names the pack revision it wants to be judged + # against, and no copy of the schemas here can drift from the + # published ones. The packs' relative $refs (into + # AgricultureResource) resolve against that same base; their + # absolute ones resolve directly, against whichever host they name. + # + # Fetched once per @context and cached for the TTL below, so only + # the first payload after a restart pays for it. A fetch that + # FAILS rejects the payload -- it does not skip validation, which + # is the right way round, but it does mean this adapter needs + # egress to every host allowed below. + # + # THIS IS THE WHOLE TRUST BOUNDARY, and it is checked on every + # read: the entry @context and every $ref under it. That matters + # because the document a payload names is NOT trusted -- it comes + # from a URL the payload chose, on a host anyone can publish to -- + # so without the check its $refs could send this process at an + # internal service or a cloud metadata endpoint. + # + # ALL THREE HOSTS ARE REQUIRED. Loading one capability pack pulls + # 13-16 documents across exactly these three (measured, not + # assumed): the pack itself and AgricultureResource from the raw + # CDN, then Descriptor/GeoJSONGeometry/Location/Address from + # schema.beckn.io, which in turn $ref schema.nfh.global. Remove + # any one and no pack loads at all -- the failure is + # SCH_SCHEMA_ADAPTATION_FAILED on every payload, not a partial + # validation. + # + # Keep it as tight as the packs allow. raw.githubusercontent.com + # is world-writable, so this trusts every GitHub account for + # schema content; narrowing it to a path prefix, or mirroring the + # packs on a host we control, is the real fix and is not a + # one-line change. + extendedSchema_allowedDomains: "raw.githubusercontent.com,schema.beckn.io,schema.nfh.global" + + extendedSchema_cacheTTL: "86400" # 24h + extendedSchema_maxCacheSize: "100" + extendedSchema_downloadTimeout: "30" + + # ------------------------------------------------------------------ + # jsonmapper -- the JSONata mapper. + # + # Generic, and named for what it is rather than for a network: it knows + # nothing about any provider. It fetches whatever URL the registry's + # mappings field names, compiles the JSONata, caches the compiled + # form, and runs it in both directions. + # + # Every key here is optional. + # ------------------------------------------------------------------ + mapper: + id: jsonmapper + config: + # How long to wait for the mapping itself. This is on the request + # path on a cache miss, so it is a ceiling on a select's latency + # the first time a mapping is used. + fetchTimeout: 5s # default: 5s + + # How long a compiled mapping is reused. The practical effect is + # how long an edit to a published mapping takes to appear. + cacheTTL: 1h # default: 1h + + # How long a FAILED fetch is remembered. Without it, a mapping + # URL that 404s is re-fetched on every single request. + negativeTTL: 1m # default: 1m + + # A ceiling on the mapping document, not the payload. 256 KiB is + # far above any realistic mapping; it is here so a wrong URL + # pointing at something enormous fails fast instead of buffering. + maxMappingBytes: 262144 # default: 262144 (256 KiB) + + # How many compiled mappings to keep. One entry per + # binding-key-and-action, so the useful floor is the number of + # capabilities times the actions each serves. + maxCacheEntries: 200 # default: 200 + + # ------------------------------------------------------------------ + # providerSteps -- one entry per provider capability. + # + # DISPATCH NEEDS NO MECHANISM. Each step builds a binding key out of + # the incoming payload -- the provider id and the capability @type it + # carries -- serves the request if that key is one of its own, and + # returns nil untouched if it is not. So several sit in one pipeline + # and each recognises its own work, with no routing table to keep in + # step with the registry. + # + # If NO step claims a payload the answer is 404 NET_ENTITY_NOT_FOUND + # -- deliberately not an ACK, which would tell the caller an answer is + # coming and leave it waiting for a callback nobody will send. + # + # CREDENTIALS ARE NAMED, NEVER HELD. authScheme says how to present + # one; the *Env keys name an ENVIRONMENT VARIABLE to read it from. No + # secret is in this file, and none is in the registry either. A + # configured credential whose variable is absent fails the request + # rather than calling the provider unauthenticated. + # ------------------------------------------------------------------ + providerSteps: + - id: WeatherObservation + config: + # REQUIRED. Comma-separated, because a plugin config value is a + # string and one provider may serve several capabilities. A + # binding key contains "|" and ":", so a comma is unambiguous. + bindingKeys: "mausamgram|openagrinet:WeatherObservation" + + # Where in the payload to read the two halves of the key. Both + # default to where core-v2.0.0-lts puts them, so they are only + # worth setting for a payload that differs. Note "[]": it flattens + # an array at that segment and is the ONLY operator the walk + # understands -- the same path without it matches nothing. + # + # Set both or neither; one alone is refused at startup rather + # than left to fail as every request quietly going unserved. + # + # providerIdAt: "message.contract.commitments[].offer.provider.id" + # capabilityCodeAt: "message.contract.commitments[].resources[].resourceAttributes.@type" + + # none | basic | header | query + # + # none the upstream needs no credential + # basic HTTP basic; usernameEnv + passwordEnv + # header a named header; headerName + headerValueEnv + # query a named query parameter; queryName + queryValueEnv. + # Redacted from the URL the step logs. + authScheme: basic + usernameEnv: MAUSAMGRAM_USER + passwordEnv: MAUSAMGRAM_X_API_KEY + + # The other two schemes, for reference: + # + # authScheme: header + # headerName: X-API-Key + # headerValueEnv: MAUSAMGRAM_X_API_KEY + # + # authScheme: query + # queryName: token + # queryValueEnv: MAUSAMGRAM_TOKEN + + # A ceiling on the provider's RESPONSE, so one that streams or + # misbehaves cannot exhaust memory. The body is rejected past + # this rather than truncated -- truncated JSON would fail + # mapping with a parse error that says nothing about the cause. + # + # maxResponseBytes: 4194304 # default: 4194304 (4 MiB) + + # A second capability in the same pipeline, from a different domain + # package. Nothing about it is weather's business: a different + # upstream, a different mapping, a different set of prerequisites -- + # and the same two registry rows. This entry, plus "MandiPrice" + # in steps below, is the entire cost of adding it. + - id: MandiPrice + config: + bindingKeys: "agmarknet|openagrinet:MandiPrice" + + # Agmarknet's Vistaar API takes its token as a QUERY parameter, + # which is what authScheme query is for. The adapter holds the + # parameter's NAME and the name of the variable carrying the + # value -- never the value -- and redacts it from the URL it + # logs, so a token cannot reach the log by way of the request. + authScheme: query + queryName: token + queryValueEnv: MANDI_TOKEN + + # MANDI_TOKEN MUST BE SET IN THE ADAPTER'S ENVIRONMENT. Without it + # every mandi call fails immediately and permanently: the step + # refuses to call an upstream unauthenticated, and marks the failure + # as one no retry can fix. + # + # There is no default and there cannot be one -- a credential is not + # something this file may hold. So the failure mode is a plugin that + # loads cleanly, registers cleanly, passes startup validation, and + # then fails 100% of requests. Worth knowing before deploying: + # + # docker: -e MANDI_TOKEN=... or an env_file + # kubernetes: a Secret, mounted as an environment variable + # local: MANDI_TOKEN=... ./server --config=... + # + # The error names the SCHEME rather than the variable, deliberately: + # this runs behind a signed network call and a peer has no business + # learning which variables this deployment reads. The variable name + # is in the adapter's own log at error level. + + # Declaring a step above is not enough: THIS list is what runs. A step + # that appears under providerSteps but not here never executes, and the + # request falls through to the 404 above -- which looks like a registry + # problem and is not. + steps: + - validateSign # the sender's key, from the registry + - validateSchema # the pinned Beckn v2 spec + - WeatherObservation # its binding key, or pass through + - MandiPrice # its binding key, or pass through + - signAck # signs whatever the step answered with + +# ---------------------------------------------------------------------------- +# NOT CONFIGURED HERE: the call plan. +# +# Everything about how to reach a provider lives in its registry +# ProviderSchema row, read per request, one entry per Beckn action: +# +# method GET or POST +# path appended to the participant's baseUrl +# mappings the URL of the mapping file for this action +# timeoutMs per-call timeout. Omitted: 15000 +# retryMax retries after the first attempt. Omitted: 0 +# +# So a provider moving host, a slow one needing longer, or a flaky one needing +# retries are all registry writes. Nothing here changes and nothing restarts. +# +# Retry classification is not configurable at all: 4xx is permanent and is not +# retried -- EXCEPT 429, which is, because "you are going too fast" is the one +# 4xx that says try again. 5xx and transport errors are retried too, and the +# backoff rises from 50ms to a 800ms ceiling. +# +# A non-2xx reaches the caller as an error carrying THE STATUS ONLY -- not the +# provider's response body. That is deliberate, and worth knowing before you go +# looking for the body in a NACK: what a provider puts in a failure body is its +# own business, and it has been known to be a stack trace, an internal hostname +# or a database error. The body goes to the adapter's log instead, at warn +# level, with the configured credential redacted. +# +# The mapping never runs on a non-2xx either -- which is why an upstream that +# signals "no data" with a 4xx surfaces as a failure rather than an empty +# result. +# ---------------------------------------------------------------------------- diff --git a/core/module/handler/config.go b/core/module/handler/config.go index e7560365..78e10b86 100644 --- a/core/module/handler/config.go +++ b/core/module/handler/config.go @@ -31,6 +31,8 @@ type PluginManager interface { PayloadStore(ctx context.Context, cache definition.Cache, namespace string, cfg *plugin.Config) (definition.PayloadStore, error) CatalogPublisher(ctx context.Context, km definition.KeyManager, blobStore definition.CatalogBlobStore, registry definition.RegistryLookup, cfg *plugin.Config) (definition.CatalogPublisher, error) CatalogBlobStore(ctx context.Context, cfg *plugin.Config) (definition.CatalogBlobStore, error) + Mapper(ctx context.Context, cfg *plugin.Config) (definition.Mapper, error) + ProviderStep(ctx context.Context, registry definition.ProviderRecordLookup, mapper definition.Mapper, cfg *plugin.Config) (definition.Step, error) } // Type defines different handler types for processing requests. @@ -85,8 +87,14 @@ type PluginCfg struct { PayloadStore *plugin.Config `yaml:"payloadStore,omitempty"` CatalogPublisher *plugin.Config `yaml:"catalogPublisher,omitempty"` CatalogBlobStore *plugin.Config `yaml:"catalogBlobStore,omitempty"` + Mapper *plugin.Config `yaml:"mapper,omitempty"` Middleware []plugin.Config `yaml:"middleware,omitempty"` Steps []plugin.Config + // ProviderSteps are steps that serve one provider capability end to end. + // Separate from Steps because they are handed a registry and a mapper, which + // the plain StepProvider contract cannot do. They resolve by id in a step + // list exactly as Steps entries do. + ProviderSteps []plugin.Config `yaml:"providerSteps,omitempty"` } // PluginEntries returns a flat list of all configured plugins in this PluginCfg. @@ -115,11 +123,17 @@ func (p *PluginCfg) PluginEntries() []telemetry.PluginEntry { add("payload_store", p.PayloadStore) add("catalog_publisher", p.CatalogPublisher) add("catalog_blob_store", p.CatalogBlobStore) + add("mapper", p.Mapper) for i := range p.Steps { if p.Steps[i].ID != "" { entries = append(entries, telemetry.PluginEntry{Type: "step", ID: p.Steps[i].ID}) } } + for i := range p.ProviderSteps { + if p.ProviderSteps[i].ID != "" { + entries = append(entries, telemetry.PluginEntry{Type: "provider_step", ID: p.ProviderSteps[i].ID}) + } + } for i := range p.Middleware { if p.Middleware[i].ID != "" { entries = append(entries, telemetry.PluginEntry{Type: "middleware", ID: p.Middleware[i].ID}) diff --git a/core/module/handler/responsebody_test.go b/core/module/handler/responsebody_test.go new file mode 100644 index 00000000..829ab973 --- /dev/null +++ b/core/module/handler/responsebody_test.go @@ -0,0 +1,557 @@ +package handler + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/beckn-one/beckn-onix/pkg/model" + "github.com/beckn-one/beckn-onix/pkg/plugin" + "github.com/beckn-one/beckn-onix/pkg/plugin/definition" +) + +// answeringStep is a step that produces a synchronous answer, the way a +// provider plugin does once it has called upstream itself. +type answeringStep struct { + answer []byte +} + +func (s *answeringStep) Run(ctx *model.StepContext) error { + ctx.ResponseBody = s.answer + return nil +} + +// routeSettingStep sets a route, putting the request on the proxy path. +type routeSettingStep struct{} + +func (s *routeSettingStep) Run(ctx *model.StepContext) error { + target, err := url.Parse("http://upstream.invalid/get-daily") + if err != nil { + return err + } + ctx.Route = &model.Route{TargetType: "url", URL: target} + return nil +} + +// stubProviderMapper and stubProviderRegistry satisfy the two dependencies +// loadProviderStep requires before it will build a provider step. The registry +// implements ProviderRecordLookup as well, which loadProviderStep narrows to. +type stubProviderMapper struct{} + +func (stubProviderMapper) Verify(context.Context, string, any) error { return nil } + +func (stubProviderMapper) Transform(context.Context, string, definition.Direction, any) ([]byte, error) { + return nil, nil +} + +type stubProviderRegistry struct{} + +func (stubProviderRegistry) Lookup(context.Context, *model.Subscription) ([]model.Subscription, error) { + return nil, nil +} + +func (stubProviderRegistry) ProviderRecord(context.Context, string) (*model.ProviderRecord, error) { + return nil, nil +} + +var errStepFailed = errors.New("step failed") + +const v2SelectBody = `{"context":{"version":"2.0.0","action":"select","messageId":"msg-1"}}` + +func serve(t *testing.T, h *stdHandler, body string) *httptest.ResponseRecorder { + t.Helper() + req, err := http.NewRequest(http.MethodPost, "/select", strings.NewReader(body)) + if err != nil { + t.Fatalf("failed to build request: %v", err) + } + recorder := httptest.NewRecorder() + h.ServeHTTP(recorder, req) + return recorder +} + +// The behaviour every existing module depends on: no ResponseBody means the +// generated ACK, unchanged. This is the regression guard for the whole change. +func TestServeHTTPWritesTheGeneratedAckWhenNoStepAnswers(t *testing.T) { + h := &stdHandler{ + SubscriberID: "test-sub", + role: model.RoleBPP, + moduleName: "test-module", + steps: []definition.Step{}, + } + + recorder := serve(t, h, v2SelectBody) + + if recorder.Code != http.StatusOK { + t.Errorf("status = %d, want 200", recorder.Code) + } + var got model.Response + if err := json.Unmarshal(recorder.Body.Bytes(), &got); err != nil { + t.Fatalf("failed to decode the response: %v", err) + } + if got.Message.Status != model.StatusACK { + t.Errorf("status = %q, want %q", got.Message.Status, model.StatusACK) + } + if got.Message.MessageID != "msg-1" { + t.Errorf("message id = %q, want %q", got.Message.MessageID, "msg-1") + } +} + +// A step that answered gets its answer written verbatim, in place of the ACK. +func TestServeHTTPWritesAStepsAnswerInPlaceOfTheAck(t *testing.T) { + answer := []byte(`{"context":{"action":"on_select"},"message":{"catalogs":[]}}`) + h := &stdHandler{ + SubscriberID: "test-sub", + role: model.RoleBPP, + moduleName: "test-module", + steps: []definition.Step{&answeringStep{answer: answer}}, + } + + recorder := serve(t, h, v2SelectBody) + + if recorder.Code != http.StatusOK { + t.Errorf("status = %d, want 200", recorder.Code) + } + if got := recorder.Body.String(); got != string(answer) { + t.Errorf("body = %q, want %q", got, string(answer)) + } + if contentType := recorder.Header().Get("Content-Type"); contentType != "application/json" { + t.Errorf("content type = %q, want application/json", contentType) + } +} + +// An answer is only for the no-route path. A step that both answers and routes +// is contradicting itself, and routing wins because the proxy owns the response +// from that point on -- silently discarding one or the other would be worse. +func TestServeHTTPPrefersRoutingOverAnAnswer(t *testing.T) { + h := &stdHandler{ + SubscriberID: "test-sub", + role: model.RoleBPP, + moduleName: "test-module", + // proxy() reaches straight for httpClient.Transport, so a routed handler + // without one panics rather than failing. + httpClient: http.DefaultClient, + steps: []definition.Step{&answeringStep{answer: []byte(`{"answered":true}`)}, &routeSettingStep{}}, + } + + recorder := serve(t, h, v2SelectBody) + + if strings.Contains(recorder.Body.String(), `"answered"`) { + t.Error("expected routing to own the response once a route is set") + } +} + +// A step that fails after answering must still NACK: a half-built answer is not +// an answer, and an error has to reach the caller as one. +func TestServeHTTPNacksWhenAStepFailsAfterAnswering(t *testing.T) { + h := &stdHandler{ + SubscriberID: "test-sub", + role: model.RoleBPP, + moduleName: "test-module", + steps: []definition.Step{ + &answeringStep{answer: []byte(`{"answered":true}`)}, + &mockFailStep{err: model.NewBadReqErr("", errStepFailed)}, + }, + } + + recorder := serve(t, h, v2SelectBody) + + if recorder.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", recorder.Code) + } + if strings.Contains(recorder.Body.String(), `"answered"`) { + t.Error("expected a NACK, not the partial answer") + } +} + +// An empty answer is not an answer. A step that sets no body leaves the ACK +// exactly as it was. +func TestServeHTTPTreatsAnEmptyAnswerAsNoAnswer(t *testing.T) { + h := &stdHandler{ + SubscriberID: "test-sub", + role: model.RoleBPP, + moduleName: "test-module", + steps: []definition.Step{&answeringStep{answer: []byte{}}}, + } + + recorder := serve(t, h, v2SelectBody) + + var got model.Response + if err := json.Unmarshal(recorder.Body.Bytes(), &got); err != nil { + t.Fatalf("failed to decode the response: %v", err) + } + if got.Message.Status != model.StatusACK { + t.Errorf("expected the generated ACK, got status %q", got.Message.Status) + } +} + +// Provider steps land in the same id-keyed map as plain steps, so two entries +// sharing an id would leave one silently overwritten -- a capability lost with +// no error anywhere. Refused at startup instead. +// +// With binding keys a list, one entry serves several capabilities, so a repeated +// id is now a mistake rather than the way to configure a second one. +func TestInitStepsRefusesTwoProviderStepsWithTheSameID(t *testing.T) { + // The mapper and registry are not decoration. loadProviderStep checks for + // both up front, so without them startup fails on the FIRST entry with + // "Mapper plugin not configured" and the loop never reaches the second -- + // meaning the duplicate guard never runs. This test passed with the guard + // deleted outright, because that error also contains the id, which was all + // the assertion checked. + h := &stdHandler{ + moduleName: "test-module", + mapper: &stubProviderMapper{}, + registry: &stubProviderRegistry{}, + } + cfg := &Config{ + Plugins: PluginCfg{ + ProviderSteps: []plugin.Config{ + {ID: "mausamgram", Config: map[string]string{}}, + {ID: "mausamgram", Config: map[string]string{}}, + }, + }, + } + + err := h.initSteps(context.Background(), noopPluginManager{}, cfg) + if err == nil { + t.Fatal("expected two provider steps with the same id to be refused") + } + // Asserted on the duplicate wording, not just the id: the id appears in + // every loadProviderStep failure too, so matching it alone cannot tell the + // duplicate refusal from a missing dependency. + if !strings.Contains(err.Error(), "configured more than once") { + t.Errorf("error %q should be the duplicate-id refusal", err) + } + if !strings.Contains(err.Error(), "mausamgram") { + t.Errorf("error %q should name the id that repeats", err) + } +} + +// A single entry must still load, or the test above would pass on any failure +// at all rather than specifically on the duplicate. +func TestInitStepsAcceptsOneProviderStep(t *testing.T) { + h := &stdHandler{ + moduleName: "test-module", + mapper: &stubProviderMapper{}, + registry: &stubProviderRegistry{}, + } + cfg := &Config{ + Plugins: PluginCfg{ + ProviderSteps: []plugin.Config{{ID: "mausamgram", Config: map[string]string{}}}, + }, + } + + if err := h.initSteps(context.Background(), noopPluginManager{}, cfg); err != nil { + t.Fatalf("one provider step should load: %v", err) + } + if !h.hasProviderSteps { + t.Error("hasProviderSteps should be set once a provider step is configured") + } +} + +// --- an unanswered request in a provider module ------------------------------ + +// silentStep is the dispatch no-op: a provider step recognising the request as +// none of its business. Succeeding without answering is how several provider +// steps coexist in one pipeline. +type silentStep struct{} + +func (s *silentStep) Run(*model.StepContext) error { return nil } + +// A module that serves capabilities itself has no proxy behind it. When nothing +// answered and no route was set, nothing ever will: there is nobody to send a +// callback. An ACK there tells the caller "accepted, answer follows" and leaves +// it waiting forever, so this is a NACK. +func TestServeHTTPNacksAnUnansweredRequestInAProviderModule(t *testing.T) { + h := &stdHandler{ + SubscriberID: "test-sub", + role: model.RoleBPP, + moduleName: "test-module", + steps: []definition.Step{&silentStep{}}, + hasProviderSteps: true, + } + + recorder := serve(t, h, v2SelectBody) + + if recorder.Code == http.StatusOK { + t.Fatalf("status = 200 for a request nothing answered; that ACK promises a callback nobody will send") + } + var got model.Response + if err := json.Unmarshal(recorder.Body.Bytes(), &got); err != nil { + t.Fatalf("failed to decode the response: %v", err) + } + if got.Message.Status == model.StatusACK { + t.Errorf("status = %q, want a NACK", got.Message.Status) + } +} + +// The guard must not touch a request that was answered. +func TestServeHTTPStillWritesAnAnswerInAProviderModule(t *testing.T) { + answer := []byte(`{"context":{"action":"on_select"}}`) + h := &stdHandler{ + SubscriberID: "test-sub", + role: model.RoleBPP, + moduleName: "test-module", + steps: []definition.Step{&answeringStep{answer: answer}}, + hasProviderSteps: true, + } + + recorder := serve(t, h, v2SelectBody) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", recorder.Code) + } + if recorder.Body.String() != string(answer) { + t.Errorf("body = %s, want the step's own answer", recorder.Body.String()) + } +} + +// A routed request is untouched even in a provider module. This is the ordinary +// case for a module that serves one capability itself and proxies everything +// else: the provider step passes through, the router sets a route, and the proxy +// owns the response -- so the ACK it produces means what it says. +func TestServeHTTPLeavesARoutedRequestAloneInAProviderModule(t *testing.T) { + h := &stdHandler{ + SubscriberID: "test-sub", + role: model.RoleBPP, + moduleName: "test-module", + // proxy() reaches straight for httpClient.Transport, so a routed handler + // without one panics rather than failing. + httpClient: http.DefaultClient, + steps: []definition.Step{&silentStep{}, &routeSettingStep{}}, + hasProviderSteps: true, + } + + recorder := serve(t, h, v2SelectBody) + + // The route points at an unreachable host, so what comes back is the proxy's + // own failure. What matters is that it is the proxy answering at all: the + // guard belongs to the no-route branch and must not have fired. + if strings.Contains(recorder.Body.String(), "NET_ENTITY_NOT_FOUND") { + t.Errorf("body = %s -- the unanswered guard fired on a routed request", recorder.Body.String()) + } +} + +// A module with no provider steps is untouched. Its ACK still means what it has +// always meant: a proxy or a publisher carries the work on from here. +func TestServeHTTPStillAcksInAModuleWithoutProviderSteps(t *testing.T) { + h := &stdHandler{ + SubscriberID: "test-sub", + role: model.RoleBAP, + moduleName: "test-module", + steps: []definition.Step{&silentStep{}}, + } + + recorder := serve(t, h, v2SelectBody) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", recorder.Code) + } + var got model.Response + if err := json.Unmarshal(recorder.Body.Bytes(), &got); err != nil { + t.Fatalf("failed to decode the response: %v", err) + } + if got.Message.Status != model.StatusACK { + t.Errorf("status = %q, want the generated ACK", got.Message.Status) + } +} + +// The instrumentor shallow-copies the context in but copies only named fields +// back out. An answer written by an instrumented step has to survive that, or +// it works unwrapped and vanishes wrapped -- and wrapped is the default. +func TestInstrumentedStepCarriesAnAnswerBack(t *testing.T) { + answer := []byte(`{"answered":true}`) + instrumented, err := NewInstrumentedStep(&answeringStep{answer: answer}, "answer", "test-module") + if err != nil { + t.Fatalf("failed to instrument the step: %v", err) + } + + ctx := &model.StepContext{Context: t.Context()} + if err := instrumented.Run(ctx); err != nil { + t.Fatalf("Run() returned an unexpected error: %v", err) + } + if string(ctx.ResponseBody) != string(answer) { + t.Errorf("response body = %q, want %q -- the instrumentor dropped it", ctx.ResponseBody, answer) + } +} + +// signAck must cover what is actually sent. Signing the generated ACK while +// sending something else would put a valid signature over the wrong bytes. +func TestAckSignerSignsTheAnswerThatWillBeSent(t *testing.T) { + signer := &mockSigner{returnSig: "sig-over-the-answer"} + step, err := newAckSignerStep(signer, &mockKM{keyset: &model.Keyset{UniqueKeyID: "k1", SigningPrivate: "priv"}}) + if err != nil { + t.Fatalf("failed to build the ack signer: %v", err) + } + + ctx := &model.StepContext{ + Context: t.Context(), + SubID: "test-sub", + ProtocolVersion: model.ProtocolVersionV2, + MessageID: "msg-1", + RespHeader: http.Header{}, + ResponseBody: []byte(`{"context":{"action":"on_select"}}`), + } + if err := step.RunOnResponse(ctx, nil); err != nil { + t.Fatalf("RunOnResponse() returned an unexpected error: %v", err) + } + + if !signer.signAckCalled { + t.Fatal("expected the answer to be signed") + } + if got := ctx.RespHeader.Get("Signature"); !strings.Contains(got, "sig-over-the-answer") { + t.Errorf("Signature header = %q, want it to carry the answer's signature", got) + } + if string(signer.signedBody) != string(ctx.ResponseBody) { + t.Errorf("signed %q, want the body that will be sent, %q", signer.signedBody, ctx.ResponseBody) + } +} + +// With no answer, signAck still covers the generated ACK, exactly as before. +func TestAckSignerStillSignsTheGeneratedAckWhenNoStepAnswered(t *testing.T) { + signer := &mockSigner{returnSig: "sig-over-the-ack"} + step, err := newAckSignerStep(signer, &mockKM{keyset: &model.Keyset{UniqueKeyID: "k1", SigningPrivate: "priv"}}) + if err != nil { + t.Fatalf("failed to build the ack signer: %v", err) + } + + ctx := &model.StepContext{ + Context: t.Context(), + SubID: "test-sub", + ProtocolVersion: model.ProtocolVersionV2, + MessageID: "msg-1", + RespHeader: http.Header{}, + } + if err := step.RunOnResponse(ctx, nil); err != nil { + t.Fatalf("RunOnResponse() returned an unexpected error: %v", err) + } + + wantAck, err := buildAckBody(model.ProtocolVersionV2, "msg-1") + if err != nil { + t.Fatalf("failed to build the expected ack: %v", err) + } + if string(signer.signedBody) != string(wantAck) { + t.Errorf("signed %q, want the generated ack %q", signer.signedBody, wantAck) + } +} + +// A step's answer has to be an envelope. A mapping whose response half is +// written as `$.response.temperature` rather than as an object produces `28.5`, +// which is valid JSON -- so Content-Type was not a lie -- and the adapter +// answered 200 with it and then SIGNED it. A consumer looking for +// message.contract finds nothing and cannot tell that from a protocol change. +// +// Refused rather than passed on, because a signed confident non-answer is worse +// than a NACK: the caller cannot retry what it does not know failed, and the +// signature says this adapter meant it. +// +// Driven through ServeHTTP with a real ack signer installed, which is the whole +// point. The check used to sit in sendResponse, after the response steps -- so +// it refused an answer the signer had already covered, and shipped a NACK body +// under a Signature over the scalar. Testing sendResponse directly could not +// see that, because the signer is not in that call. +func TestServeHTTPDoesNotSignAnAnswerItRefuses(t *testing.T) { + signer := &mockSigner{returnSig: "sig"} + as, err := newAckSignerStep(signer, &mockKM{ + keyset: &model.Keyset{UniqueKeyID: "k1", SigningPrivate: "priv"}}) + if err != nil { + t.Fatalf("failed to build the ack signer: %v", err) + } + // Asserted to the concrete type exactly as initSteps does, so h.ackSigner + // is populated and the NACK path can sign -- which is what is under test. + ackSigner, ok := as.(*ackSignerStep) + if !ok { + t.Fatalf("newAckSignerStep returned %T, want *ackSignerStep", as) + } + + h := &stdHandler{ + SubscriberID: "test-sub", + role: model.RoleBPP, + moduleName: "test-module", + steps: []definition.Step{&answeringStep{answer: []byte(`28.5`)}}, + responseSteps: []definition.ResponseStep{as}, + ackSigner: ackSigner, + hasProviderSteps: true, + } + + recorder := serve(t, h, v2SelectBody) + + // The scalar must not reach the wire, and the caller must be told. + if recorder.Code == http.StatusOK { + t.Errorf("status = %d; a body that cannot carry a message must not be a 200", recorder.Code) + } + if strings.Contains(recorder.Body.String(), "28.5") { + t.Error("the scalar was written to the wire") + } + var got model.Response + if err := json.Unmarshal(recorder.Body.Bytes(), &got); err != nil { + t.Fatalf("failed to decode the response: %v", err) + } + if got.Message.Status != model.StatusNACK { + t.Errorf("status = %q, want a NACK", got.Message.Status) + } + + // And the part that was actually broken: the signature that ships must + // cover the body that ships. Before this fix it covered the refused + // scalar while a NACK went out beneath it, and a peer verifying that sees + // a digest mismatch -- reading a mapping bug as suspected tampering. + // + // Asserted on the bytes signed, not on the header text: mockSigner returns + // a fixed signature string whatever it is given, so the header alone + // cannot tell the two bodies apart. + if string(signer.signedBody) == "28.5" { + t.Error("the refused scalar was signed; the refusal has to happen before the signer runs") + } + if signer.signAckCalled && string(signer.signedBody) != recorder.Body.String() { + t.Errorf("signed %q but sent %q -- the signature must cover what ships", + signer.signedBody, recorder.Body.String()) + } +} + +// The ordinary path must be untouched: a real envelope is answered, signed, and +// sent, so the guard above cannot be satisfied by refusing everything. +func TestServeHTTPStillSignsAndSendsARealEnvelope(t *testing.T) { + const answer = `{"context":{"action":"on_select"},"message":{"contract":{}}}` + + signer := &mockSigner{returnSig: "sig-over-the-answer"} + as, err := newAckSignerStep(signer, &mockKM{ + keyset: &model.Keyset{UniqueKeyID: "k1", SigningPrivate: "priv"}}) + if err != nil { + t.Fatalf("failed to build the ack signer: %v", err) + } + // Asserted to the concrete type exactly as initSteps does, so h.ackSigner + // is populated and the NACK path can sign -- which is what is under test. + ackSigner, ok := as.(*ackSignerStep) + if !ok { + t.Fatalf("newAckSignerStep returned %T, want *ackSignerStep", as) + } + + h := &stdHandler{ + SubscriberID: "test-sub", + role: model.RoleBPP, + moduleName: "test-module", + steps: []definition.Step{&answeringStep{answer: []byte(answer)}}, + responseSteps: []definition.ResponseStep{as}, + ackSigner: ackSigner, + hasProviderSteps: true, + } + + recorder := serve(t, h, v2SelectBody) + + if recorder.Code != http.StatusOK { + t.Errorf("status = %d, want 200", recorder.Code) + } + if recorder.Body.String() != answer { + t.Errorf("body = %s, want the answer unchanged", recorder.Body.String()) + } + if string(signer.signedBody) != answer { + t.Errorf("signed %q, want the answer that was sent", signer.signedBody) + } + if sig := recorder.Header().Get("Signature"); !strings.Contains(sig, "sig-over-the-answer") { + t.Errorf("Signature header = %q, want the answer's signature", sig) + } +} diff --git a/core/module/handler/responsestep.go b/core/module/handler/responsestep.go index 714b4047..a8835b18 100644 --- a/core/module/handler/responsestep.go +++ b/core/module/handler/responsestep.go @@ -35,6 +35,49 @@ type preV2Response struct { Message preV2Message `json:"message"` } +// sendResponse writes the synchronous response for the no-route path: a step's +// own answer when it produced one, and the generated ACK otherwise. +// +// Kept separate from sendAck rather than folded into it, because sendAck is also +// reached from the routing path where a step's answer has no meaning -- the +// proxy owns the response there. +func sendResponse(ctx *model.StepContext, w http.ResponseWriter) []byte { + if len(ctx.ResponseBody) == 0 { + return sendAck(ctx, w) + } + // The envelope check is NOT here. It is in ServeHTTP, ahead of the response + // steps, because ackSigner is one of those steps: refusing at this point + // means the Signature header is already set, over the body being refused. + return writeJSONResponse(ctx, w, ctx.ResponseBody) +} + +// verifyEnvelope checks that a step's answer is a JSON object. +// +// Only the shape, not the contents: which members belong in a response is the +// spec's business and the schema validator's, and this runs on every answer. +// What it catches is the class of mapping mistake that yields a scalar or an +// array -- valid JSON that cannot carry a Beckn message however it is read. +func verifyEnvelope(body []byte) error { + var envelope map[string]json.RawMessage + if err := json.Unmarshal(body, &envelope); err != nil { + return fmt.Errorf("response body is not a JSON object: %w", err) + } + if len(envelope) == 0 { + return errors.New("response body is an empty JSON object, so it carries no message") + } + return nil +} + +// writeJSONResponse writes body as a 200 JSON response, reporting what it wrote. +func writeJSONResponse(ctx context.Context, w http.ResponseWriter, body []byte) []byte { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if _, err := w.Write(body); err != nil { + log.Errorf(ctx, err, "failed to write response body: %v", err) + } + return body +} + // sendAck sends a synchronous ACK response to the client. // For context.version "2.0.0" and later the response uses the v2 envelope: // @@ -271,9 +314,17 @@ func (a *ackSignerStep) RunOnResponse(ctx *model.StepContext, rctx *model.Respon // Publisher / no-route path: ONIX writes the ACK — build the deterministic // body that sendAck will write so the digest matches. - ackBody, err := buildAckBody(ctx.ProtocolVersion, ctx.MessageID) - if err != nil { - return fmt.Errorf("ackSigner: failed to build ack body: %w", err) + // A step that answered supplies the body; otherwise rebuild the deterministic + // ACK. Either way this signs exactly what sendResponse will write -- signing + // the ACK while sending an answer would put a valid signature over the wrong + // bytes. + ackBody := ctx.ResponseBody + if len(ackBody) == 0 { + built, err := buildAckBody(ctx.ProtocolVersion, ctx.MessageID) + if err != nil { + return fmt.Errorf("ackSigner: failed to build ack body: %w", err) + } + ackBody = built } // signBodyAndSetHeader writes to ctx.RespHeader which IS the http.ResponseWriter // header map — the Signature header will be flushed when WriteHeader is called. diff --git a/core/module/handler/responsestep_test.go b/core/module/handler/responsestep_test.go index eca2b9a4..a64df2da 100644 --- a/core/module/handler/responsestep_test.go +++ b/core/module/handler/responsestep_test.go @@ -487,6 +487,7 @@ type mockSigner struct { signAckErr error returnSig string // returned by SignAck returnSignSig string // returned by Sign (default "") + signedBody []byte // the body SignAck was last asked to cover } func (m *mockSigner) Sign(_ context.Context, _ []byte, _ string, _, _ int64) (string, error) { @@ -494,8 +495,9 @@ func (m *mockSigner) Sign(_ context.Context, _ []byte, _ string, _, _ int64) (st return m.returnSignSig, nil } -func (m *mockSigner) SignAck(_ context.Context, _ []byte, _ string, _ string, _, _ int64) (string, error) { +func (m *mockSigner) SignAck(_ context.Context, body []byte, _ string, _ string, _, _ int64) (string, error) { m.signAckCalled = true + m.signedBody = body if m.signAckErr != nil { return "", m.signAckErr } @@ -1077,3 +1079,68 @@ func TestInitSteps_ValidateAckSignAppendsToResponseSteps(t *testing.T) { t.Errorf("expected 1 response step, got %d", len(h.responseSteps)) } } + +// sendResponse checked only that the body was non-empty, so a mapping whose +// response half is written as `$.response.temperature` rather than as an +// object produced `28.5` -- valid JSON, so Content-Type was not a lie -- and +// the adapter answered 200 with it and then signed it. A consumer looking for +// message.contract finds nothing and cannot tell that from a protocol change. +func TestVerifyEnvelopeRefusesWhatCannotCarryAMessage(t *testing.T) { + t.Parallel() + + refused := map[string]string{ + "a bare number": `28.5`, + "a bare string": `"no data"`, + "a bare boolean": `true`, + "null": `null`, + "an array": `[{"message":{}}]`, + "an empty object": `{}`, + "not json at all": `28.5 and then some`, + } + for name, body := range refused { + t.Run(name, func(t *testing.T) { + t.Parallel() + if err := verifyEnvelope([]byte(body)); err == nil { + t.Errorf("verifyEnvelope(%s) = nil, want it refused", body) + } + }) + } + + accepted := map[string]string{ + "a full envelope": `{"context":{"action":"on_select"},"message":{"contract":{}}}`, + "one member is enough": `{"message":{}}`, + // The shape is all this checks. Which members belong in a response is + // the spec's business and the schema validator's. + "an unexpected member": `{"whatever":1}`, + } + for name, body := range accepted { + t.Run(name, func(t *testing.T) { + t.Parallel() + if err := verifyEnvelope([]byte(body)); err != nil { + t.Errorf("verifyEnvelope(%s) = %v, want nil", body, err) + } + }) + } +} + +// A real envelope is untouched -- the check must not cost the ordinary path. +func TestSendResponseWritesAnEnvelopeUnchanged(t *testing.T) { + t.Parallel() + + const body = `{"context":{"action":"on_select"},"message":{"contract":{}}}` + ctx := makeStepCtx("2.0.0", "msg-1", "sub-1", "") + ctx.ResponseBody = []byte(body) + + w := httptest.NewRecorder() + written := sendResponse(ctx, w) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } + if string(written) != body { + t.Errorf("written = %s, want the body unchanged", written) + } + if got := w.Header().Get("Content-Type"); got != "application/json" { + t.Errorf("Content-Type = %q, want application/json", got) + } +} diff --git a/core/module/handler/stdHandler.go b/core/module/handler/stdHandler.go index cad19dc6..62ecd53b 100644 --- a/core/module/handler/stdHandler.go +++ b/core/module/handler/stdHandler.go @@ -55,11 +55,16 @@ type stdHandler struct { transportWrapper definition.TransportWrapper payloadTransformer definition.Step payloadStore definition.PayloadStore + mapper definition.Mapper // ackSigner is non-nil only when the "signAck" step is configured (Receiver // modules). It is also used to sign pipeline-NACK responses so that ALL // synchronous responses carry a Signature header per NFH-007 CON-004-02. - ackSigner *ackSignerStep - SubscriberID string + ackSigner *ackSignerStep + // hasProviderSteps records whether this module serves capabilities itself. + // Such a module has no proxy behind it, which is what makes an unanswered + // request a dead end rather than work in flight -- see ServeHTTP. + hasProviderSteps bool + SubscriberID string role model.Role basePath string httpClient *http.Client @@ -215,6 +220,55 @@ func (h *stdHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Restore request body and metadata before forwarding or publishing. syncRequestBody(r, stepCtx.Body) if stepCtx.Route == nil { + // A module that serves capabilities itself has no proxy behind it, so + // an unanswered request here is a dead end: no route to forward it, and + // nobody to send a callback. An ACK would tell the caller "accepted, + // answer follows" and leave it waiting for a message nobody will send, + // which is how a stale binding key hides as a healthy response. + // + // 404 rather than AckNoCallbackErr, which exists for this shape and + // would be the obvious pick: it maps to 202 Accepted, and a 2xx is what + // let this hide in the first place. It is also for a business outcome + // -- no inventory, provider closed -- where this is "nothing here + // serves that", which is what a 404 says. + // + // Checked before the response steps rather than after, because + // ackSigner signs the body it expects to be written; NACKing later + // would ship a signature over the ACK with a NACK body. + // + // Only for modules with provider steps. Elsewhere an unanswered + // request is the publisher path doing exactly what it should. + if h.hasProviderSteps && len(stepCtx.ResponseBody) == 0 { + err = model.NewNotFoundErr("", fmt.Errorf( + "this module serves no capability matching the request")) + log.Errorf(stepCtx, err, "No step answered and no route was set: %v", err) + h.signNackResponse(stepCtx, err) + responseBody = sendNack(stepCtx, wrapped, err) + return + } + + // Checked here for the same reason as the 404 above, and it is the + // same failure: ackSigner signs the body it expects to be written, + // so refusing after the response steps ships a Signature computed + // over the answer we just rejected, with a NACK body under it. A + // peer verifying that signature sees a digest mismatch, and reads a + // mapping bug as suspected tampering. + // + // It lived in sendResponse, which runs after the loop -- one step + // too late, on the wrong side of signing. + if len(stepCtx.ResponseBody) > 0 { + if err = verifyEnvelope(stepCtx.ResponseBody); err != nil { + log.Errorf(stepCtx, err, "a step produced a response that is not a Beckn envelope; refusing to sign it") + // A plain error on purpose: nackBecknError's default branch + // turns it into a generic 500, so the caller learns the + // answer failed without being handed the internals of a + // mapping it does not own. The detail is in the log above. + h.signNackResponse(stepCtx, err) + responseBody = sendNack(stepCtx, wrapped, err) + return + } + } + // No routing — ONIX writes the ACK directly. Run response steps here // with resp=nil (publisher path semantics). for _, step := range h.responseSteps { @@ -227,7 +281,7 @@ func (h *stdHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } } - responseBody = sendAck(stepCtx, wrapped) + responseBody = sendResponse(stepCtx, wrapped) return } // Handle routing based on the defined route type. @@ -596,11 +650,39 @@ func (h *stdHandler) initPlugins(ctx context.Context, mgr PluginManager, cfg *Pl if h.payloadTransformer, err = loadPayloadTransformerStep(ctx, mgr, cfg.PayloadTransformer); err != nil { return err } + if h.mapper, err = LoadPlugin(ctx, "Mapper", cfg.Mapper, mgr.Mapper); err != nil { + return err + } log.Debugf(ctx, "All required plugins successfully loaded for stdHandler") return nil } +// loadProviderStep loads one provider step, checking up front for the +// dependencies it cannot be built without. Each produces a clear startup +// failure rather than a nil dereference on the first request to reach the step. +func (h *stdHandler) loadProviderStep(ctx context.Context, mgr PluginManager, cfg *plugin.Config) (definition.Step, error) { + if h.mapper == nil { + return nil, fmt.Errorf("failed to load ProviderStep plugin (%s): Mapper plugin not configured", cfg.ID) + } + if h.registry == nil { + return nil, fmt.Errorf("failed to load ProviderStep plugin (%s): Registry plugin not configured", cfg.ID) + } + // A registry serving signing keys need not also serve call plans -- they are + // separate interfaces for that reason -- so this narrowing is checked rather + // than assumed. + recordLookup, ok := h.registry.(definition.ProviderRecordLookup) + if !ok { + return nil, fmt.Errorf("failed to load ProviderStep plugin (%s): Registry plugin does not implement ProviderRecordLookup", cfg.ID) + } + step, err := mgr.ProviderStep(ctx, recordLookup, h.mapper, cfg) + if err != nil { + return nil, fmt.Errorf("failed to load ProviderStep plugin (%s): %w", cfg.ID, err) + } + log.Debugf(ctx, "Loaded ProviderStep plugin: %s", cfg.ID) + return step, nil +} + // initSteps initializes and validates processing steps for the processor. func (h *stdHandler) initSteps(ctx context.Context, mgr PluginManager, cfg *Config) error { steps := make(map[string]definition.Step) @@ -614,6 +696,26 @@ func (h *stdHandler) initSteps(ctx context.Context, mgr PluginManager, cfg *Conf steps[c.ID] = step } + // Load provider steps, which are handed the registry and mapper that plain + // plugin steps cannot receive. They land in the same id-keyed map, so a step + // list names them exactly like any other plugin step. + for _, c := range cfg.Plugins.ProviderSteps { + // The same map as plain steps, so a repeated id would leave one entry + // silently overwritten -- a capability lost with no error anywhere. A + // provider step serving several capabilities says so in its own config + // rather than by appearing twice. + if _, taken := steps[c.ID]; taken { + return fmt.Errorf("provider step %q is configured more than once; "+ + "a step serving several capabilities lists them in its own config", c.ID) + } + step, err := h.loadProviderStep(ctx, mgr, &c) + if err != nil { + return err + } + steps[c.ID] = step + } + h.hasProviderSteps = len(cfg.Plugins.ProviderSteps) > 0 + // Register processing steps for _, step := range cfg.Steps { var s definition.Step diff --git a/core/module/handler/stdHandler_test.go b/core/module/handler/stdHandler_test.go index a50e0ec5..00214353 100644 --- a/core/module/handler/stdHandler_test.go +++ b/core/module/handler/stdHandler_test.go @@ -169,6 +169,14 @@ func (noopPluginManager) CatalogPublisher(_ context.Context, _ definition.KeyMan return nil, nil } +func (noopPluginManager) Mapper(_ context.Context, _ *plugin.Config) (definition.Mapper, error) { + return nil, nil +} + +func (noopPluginManager) ProviderStep(_ context.Context, _ definition.ProviderRecordLookup, _ definition.Mapper, _ *plugin.Config) (definition.Step, error) { + return nil, nil +} + func (noopPluginManager) CatalogBlobStore(_ context.Context, _ *plugin.Config) (definition.CatalogBlobStore, error) { return nil, nil } diff --git a/core/module/handler/step_instrumentor.go b/core/module/handler/step_instrumentor.go index dcfaee0b..152d9054 100644 --- a/core/module/handler/step_instrumentor.go +++ b/core/module/handler/step_instrumentor.go @@ -93,6 +93,7 @@ func (is *InstrumentedStep) Run(ctx *model.StepContext) error { } ctx.Body = stepCtx.Body + ctx.ResponseBody = stepCtx.ResponseBody ctx.Route = stepCtx.Route ctx.SubID = stepCtx.SubID ctx.Role = stepCtx.Role diff --git a/core/module/module_test.go b/core/module/module_test.go index acb5b432..b22d7bbf 100644 --- a/core/module/module_test.go +++ b/core/module/module_test.go @@ -93,6 +93,14 @@ func (m *mockPluginManager) CatalogPublisher(_ context.Context, _ definition.Key return nil, nil } +func (m *mockPluginManager) Mapper(_ context.Context, _ *plugin.Config) (definition.Mapper, error) { + return nil, nil +} + +func (m *mockPluginManager) ProviderStep(_ context.Context, _ definition.ProviderRecordLookup, _ definition.Mapper, _ *plugin.Config) (definition.Step, error) { + return nil, nil +} + func (m *mockPluginManager) CatalogBlobStore(_ context.Context, _ *plugin.Config) (definition.CatalogBlobStore, error) { return nil, nil } diff --git a/install/build-plugins.sh b/install/build-plugins.sh index 36e42ec7..b7e64256 100755 --- a/install/build-plugins.sh +++ b/install/build-plugins.sh @@ -30,6 +30,10 @@ plugins=( "publisher" "registry" "dediregistry" + "sunbirdRegistry" + "jsonmapper" + "WeatherObservation" + "MandiPrice" "manifestloader" "reqpreprocessor" "otelsetup" diff --git a/pkg/model/model.go b/pkg/model/model.go index 7e1b0255..85744272 100644 --- a/pkg/model/model.go +++ b/pkg/model/model.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/url" + "sort" "strconv" "strings" "time" @@ -71,6 +72,66 @@ type SubscriberRecord struct { MetaArrays map[string][]string // array-shaped meta values (e.g. NFH-014's meta.catalog_index_urls: [{url}, ...]) — kept separate from Meta rather than widening it to map[string]any, so every existing caller of Meta[key] keeps working unchanged } +// ProviderRecord is the resolved call plan for one provider capability: where +// the provider is, and per Beckn action, how to reach it. It is assembled from +// two registry records -- the capability binding and the participant that owns +// it -- so a caller resolves a whole plan in one lookup rather than knowing how +// the registry splits them. +type ProviderRecord struct { + BindingKey string // "|" + ParticipantID string + CapabilityCode string + + // BaseURL comes from the participant and is shared by every action: one + // provider, one host. + BaseURL string + + // Actions is the call plan per Beckn action. A capability serves several -- + // a select that reads and a confirm that commits -- and they rarely share an + // endpoint, a method or a mapping, so each carries its own. + // + // An action absent here is one this capability does not serve. + Actions map[string]ActionPlan +} + +// ServedActions lists the actions this record serves, sorted. +// +// A method on the type rather than a helper beside each caller: its whole +// purpose is that the same record reads the same way wherever it is reported, +// and two copies of it would have had to agree on sort order across two +// packages. Sorted because it goes into log lines and error messages, and map +// iteration would make the same record read differently on each request. +func (r *ProviderRecord) ServedActions() []string { + names := make([]string, 0, len(r.Actions)) + for action := range r.Actions { + names = append(names, action) + } + sort.Strings(names) + return names +} + +// ActionPlan is how to make one action's upstream call. +type ActionPlan struct { + Method string + Path string + + // Mappings references one file carrying BOTH directions for this action. + // One file rather than two because the response mapping usually depends on + // what the request mapping did -- swapping GeoJSON coordinates into named + // lat/lon, say -- and splitting them across two references hides that. + // + // Carried verbatim: it is a URL the mapper fetches, and this type does not + // interpret it. + Mappings string + + // TimeoutMs and RetryMax are this action's own budget, and are zero when the + // registry does not set them -- the caller applies its defaults. They are + // per action because a confirm that commits deserves a different budget from + // a select that reads. + TimeoutMs int + RetryMax int +} + // Authorization-related constants for headers. const ( AuthHeaderSubscriber string = "Authorization" @@ -326,6 +387,16 @@ type StepContext struct { MessageID string // Message ID parsed from context.messageId in the request body InboundAuthSignature string // Raw Base64 signature from the inbound Authorization header's signature="..." attribute IsCallerHandler bool // True when the handler is a Caller (outbound); false for Receiver (inbound) + + // ResponseBody, when non-empty, is written as the synchronous response in + // place of the generated ACK envelope. It is how a step that has already + // obtained an answer -- a provider plugin that called upstream itself, rather + // than routing -- returns that answer to the caller. + // + // Empty means "generate the ACK", which is every module that does not set it. + // It is only consulted on the no-route path: once a Route is set the proxy + // owns the response. + ResponseBody []byte } // WithContext updates the existing StepContext with a new context. diff --git a/pkg/model/model_test.go b/pkg/model/model_test.go index 3aa92080..ff19e96e 100644 --- a/pkg/model/model_test.go +++ b/pkg/model/model_test.go @@ -3,6 +3,7 @@ package model import ( "encoding/json" "errors" + "slices" "strings" "testing" ) @@ -305,3 +306,35 @@ func TestWrapExtractContextErr(t *testing.T) { } }) } + +// The reason this is one method and not a helper beside each caller: the same +// record has to read the same way wherever it is reported, and map iteration +// would make it read differently on each request. +func TestProviderRecordServedActionsIsSorted(t *testing.T) { + t.Parallel() + + record := &ProviderRecord{Actions: map[string]ActionPlan{ + "select": {}, + "catalog/publish": {}, + "confirm": {}, + "discover": {}, + }} + want := []string{"catalog/publish", "confirm", "discover", "select"} + + // Repeated, because one pass cannot tell a sorted result from a lucky map + // iteration order. + for i := 0; i < 20; i++ { + got := record.ServedActions() + if !slices.Equal(got, want) { + t.Fatalf("ServedActions() = %v, want %v", got, want) + } + } +} + +func TestProviderRecordServedActionsOnAnEmptyRecord(t *testing.T) { + t.Parallel() + + if got := (&ProviderRecord{}).ServedActions(); len(got) != 0 { + t.Errorf("ServedActions() = %v, want empty", got) + } +} diff --git a/pkg/plugin/definition/mapper.go b/pkg/plugin/definition/mapper.go new file mode 100644 index 00000000..e1a3790e --- /dev/null +++ b/pkg/plugin/definition/mapper.go @@ -0,0 +1,67 @@ +package definition + +import ( + "context" +) + +// Direction names which half of a mapping to run. A mapping file carries both, +// because both legs of one upstream call belong together. +type Direction string + +const ( + // DirectionRequest translates an inbound payload into what the upstream wants. + DirectionRequest Direction = "request" + // DirectionResponse translates the upstream's answer back. + DirectionResponse Direction = "response" +) + +// Mapper transforms a document with a mapping fetched from a reference. +// +// It exists so that translating between the network's Beckn payloads and a provider's +// own shape is configuration rather than code: a new provider ships mapping +// files, not a new transformation routine. The mapper itself knows nothing +// about any provider, and nothing about what a mapping says -- it fetches, +// compiles and runs whatever the reference points at. +type Mapper interface { + // Transform runs the mapping at mappingRef over input and returns the + // result. + // + // mappingRef is what the registry carries verbatim: the URL of one published + // file holding both directions. + // + // Which action the mapping serves is settled by the registry entry that + // named it, so only the direction is passed here. + // + // input carries what a party sent -- the inbound payload, and on the way + // back the provider's answer -- plus, under _local, any values the caller + // resolved before making the call. + // + // _local is for what a payload cannot carry and a mapping cannot obtain: a + // code looked up from a name, a point resolved to a market. A caller with + // nothing to add passes an empty map, so a mapping referring to _local + // reads nothing rather than failing. Values the caller already holds and + // merely used to make the call do NOT belong here -- routing those back + // through a mapping is a second name for the same data. + // + // A direction the file has no transform for produces nothing, with no error. + // What nothing means belongs to the caller: on the request leg it means there + // is no document to send. + Transform(ctx context.Context, mappingRef string, direction Direction, input any) ([]byte, error) + + // Verify checks the preconditions the mapping at mappingRef declares, and + // returns an error carrying the mapping's own explanation when one fails. + // + // It exists because a mapping otherwise cannot refuse. Without it, every + // judgement about whether a payload can be served at all lives in Go, so a + // provider with its own rule needs its own build -- and the rule and the + // extraction it guards end up in different places. + // + // A mapping declaring no preconditions imposes none. That is what lets the + // facility be adopted per provider rather than all at once. + Verify(ctx context.Context, mappingRef string, input any) error +} + +// MapperProvider initializes a new Mapper. +type MapperProvider interface { + New(ctx context.Context, config map[string]string) (Mapper, func() error, error) +} diff --git a/pkg/plugin/definition/registry.go b/pkg/plugin/definition/registry.go index 20d7bb98..57c3d69c 100644 --- a/pkg/plugin/definition/registry.go +++ b/pkg/plugin/definition/registry.go @@ -2,6 +2,7 @@ package definition import ( "context" + "errors" "github.com/beckn-one/beckn-onix/pkg/model" ) @@ -47,3 +48,35 @@ type RegistryMetadataLookup interface { type RegistryLookupProvider interface { New(context.Context, Cache, map[string]string) (RegistryLookup, func() error, error) } + +// ErrProviderRecordNotFound reports that no usable call plan exists for a +// binding key. It is returned for an absent binding, an absent participant, and +// for either of them being inactive -- all of which mean the same thing to a +// caller: this capability cannot be served right now. The distinction between +// them is observable in the plugin's own logs and metrics, and is not something +// a caller can act on differently. +var ErrProviderRecordNotFound = errors.New("provider record not found") + +// ProviderRecordLookup resolves a provider capability into a call plan. +// +// It is separate from RegistryLookup because it answers a different question +// about a different party. RegistryLookup answers "what is the SENDER's public +// key", keyed by the identity in an inbound Authorization header. +// ProviderRecordLookup answers "how do I call the UPSTREAM provider", keyed by +// a capability binding taken from the request body. The two have different +// subjects, different cache lifetimes, and different failure meanings, so they +// are not folded together. +// +// A registry plugin may implement one, the other, or both. Callers obtain this +// by type-asserting a RegistryLookup, the same way RegistryMetadataLookup is +// obtained -- a plugin that does not implement it yields nil, and the consumer +// decides whether that is fatal. +type ProviderRecordLookup interface { + // ProviderRecord resolves bindingKey ("|") + // into everything needed to call the provider. + // + // Returns ErrProviderRecordNotFound when no usable plan exists. Any other + // error is a transport or decoding failure -- the registry could not be + // consulted, which is not the same as it answering "no". + ProviderRecord(ctx context.Context, bindingKey string) (*model.ProviderRecord, error) +} diff --git a/pkg/plugin/definition/step.go b/pkg/plugin/definition/step.go index 7f19115b..63f66b7f 100644 --- a/pkg/plugin/definition/step.go +++ b/pkg/plugin/definition/step.go @@ -25,3 +25,21 @@ type ResponseStep interface { type StepProvider interface { New(context.Context, map[string]string) (Step, func(), error) } + +// ProviderStep is a Step that serves one provider capability end to end: it +// resolves whatever the provider needs beyond the Beckn payload, calls it, and +// turns the answer back into Beckn. +// +// It is a plain Step at the pipeline's edge -- ProviderStepProvider exists only +// because it needs a registry and a mapper handed to it, which StepProvider +// cannot do. Everything provider-specific lives inside: the prerequisites the +// old per-provider services performed before a call (a station id resolved from +// coordinates, a token minted from credentials), and the call itself. +// +// A step that is handed a request for a capability it does not serve must do +// nothing and return nil. That is the whole dispatch mechanism: several provider +// steps sit in one pipeline, each recognises its own work, and adding a provider +// is one more entry rather than a change to a routing table. +type ProviderStepProvider interface { + New(ctx context.Context, registry ProviderRecordLookup, mapper Mapper, config map[string]string) (Step, func() error, error) +} diff --git a/pkg/plugin/implementation/MandiPrice/MandiPrice.go b/pkg/plugin/implementation/MandiPrice/MandiPrice.go new file mode 100644 index 00000000..38fff611 --- /dev/null +++ b/pkg/plugin/implementation/MandiPrice/MandiPrice.go @@ -0,0 +1,38 @@ +// Package MandiPrice serves the network's market price capabilities. +// +// One package per capability, named for the capability it serves, so which +// plugin owns a payload is readable from its binding key without a lookup: +// openagrinet:MandiPrice is this one's, openagrinet:WeatherObservation is not. +// +// Almost nothing lives here, and that is the point. Recognising a capability, +// resolving the call plan, authenticating, calling with the registry's budget +// and translating in both directions are all internal/upstream's, because none +// of them differ by domain. What this package owns is its name, and +// prerequisites -- the work a mapping cannot express, which is domain knowledge +// by definition. +// +// The upstream this was written against is Agmarknet's Vistaar API, whose +// select takes governed codes for state, district, market and commodity plus a +// date range, all of which a MandiPrice payload carries. So the package is a +// name and nothing else: see prerequisites.go for why that is worth stating. +package MandiPrice + +import ( + "context" + + "github.com/beckn-one/beckn-onix/pkg/plugin/definition" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/upstream" +) + +// Config is upstream's, unchanged. Aliased here so a domain plugin's cmd package +// need not know where the machinery lives. +type Config = upstream.Config + +// New creates the mandi step. +// +// Which capabilities it answers to is configuration, with no default: a package +// serving a family cannot guess which of them a deployment has providers for. +func New(ctx context.Context, registry definition.ProviderRecordLookup, mapper definition.Mapper, + cfg *Config) (definition.Step, func() error, error) { + return upstream.New(ctx, registry, mapper, prerequisites, cfg) +} diff --git a/pkg/plugin/implementation/MandiPrice/cmd/plugin.go b/pkg/plugin/implementation/MandiPrice/cmd/plugin.go new file mode 100644 index 00000000..8dcce23f --- /dev/null +++ b/pkg/plugin/implementation/MandiPrice/cmd/plugin.go @@ -0,0 +1,104 @@ +// Command plugin builds the mandi provider step as a loadable plugin. +// +// The filename of the built .so is the id a deployment names in providerSteps, +// so this package is mandi's whole public surface: a config map in, a step out. +package main + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + + "github.com/beckn-one/beckn-onix/pkg/log" + "github.com/beckn-one/beckn-onix/pkg/plugin/definition" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/MandiPrice" +) + +// mandiProvider implements definition.ProviderStepProvider. +type mandiProvider struct{} + +// newStepFunc creates a new step. Indirected for tests. +var newStepFunc = MandiPrice.New + +// parseConfig turns the plugin config map into a typed Config. Anything absent +// is left zero: MandiPrice.New applies the defaults and validates the auth scheme, +// so those rules live in one place. +func (p mandiProvider) parseConfig(config map[string]string) (*MandiPrice.Config, error) { + cfg := &MandiPrice.Config{ + BindingKeys: splitList(config["bindingKeys"]), + // Absent means the Beckn v2 convention. See upstream.Config for why + // this is a default rather than something to set. + ProviderIDAt: config["providerIdAt"], + CapabilityCodeAt: config["capabilityCodeAt"], + AuthScheme: config["authScheme"], + UsernameEnv: config["usernameEnv"], + PasswordEnv: config["passwordEnv"], + HeaderName: config["headerName"], + HeaderValueEnv: config["headerValueEnv"], + QueryName: config["queryName"], + QueryValueEnv: config["queryValueEnv"], + } + + if raw, exists := config["maxResponseBytes"]; exists && raw != "" { + value, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid maxResponseBytes value '%s': %w", raw, err) + } + if value <= 0 { + return nil, fmt.Errorf("maxResponseBytes must be positive, got %d", value) + } + cfg.MaxResponseBytes = value + } + + return cfg, nil +} + +// New creates a new mandi provider step instance. +func (p mandiProvider) New(ctx context.Context, registry definition.ProviderRecordLookup, mapper definition.Mapper, config map[string]string) (definition.Step, func() error, error) { + if ctx == nil { + return nil, nil, errors.New("context cannot be nil") + } + + cfg, err := p.parseConfig(config) + if err != nil { + log.Errorf(ctx, err, "Failed to parse mandi configuration") + return nil, nil, fmt.Errorf("failed to parse mandi configuration: %w", err) + } + + step, closer, err := newStepFunc(ctx, registry, mapper, cfg) + if err != nil { + log.Errorf(ctx, err, "Failed to create mandi step") + return nil, nil, err + } + + log.Infof(ctx, "Mandi step created successfully") + return step, closer, nil +} + +// splitList reads a comma-separated config value, which is how a list reaches a +// plugin -- the config is map[string]string. Blanks are dropped and spaces +// trimmed, so a trailing comma or a wrapped line is not a config error. +// +// A comma is unambiguous here: a binding key separates its own halves with a +// pipe. +func splitList(raw string) []string { + if strings.TrimSpace(raw) == "" { + return nil + } + var out []string + for _, part := range strings.Split(raw, ",") { + if trimmed := strings.TrimSpace(part); trimmed != "" { + out = append(out, trimmed) + } + } + return out +} + +// Provider is the exported plugin instance. +var Provider = mandiProvider{} + +// Compile-time proof the provider satisfies the interface the manager asserts +// against. A mismatch is otherwise a runtime cast failure at startup. +var _ definition.ProviderStepProvider = Provider diff --git a/pkg/plugin/implementation/MandiPrice/cmd/plugin_test.go b/pkg/plugin/implementation/MandiPrice/cmd/plugin_test.go new file mode 100644 index 00000000..0a3c1a50 --- /dev/null +++ b/pkg/plugin/implementation/MandiPrice/cmd/plugin_test.go @@ -0,0 +1,282 @@ +package main + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + + "github.com/beckn-one/beckn-onix/pkg/model" + "github.com/beckn-one/beckn-onix/pkg/plugin/definition" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/MandiPrice" +) + +type stubRegistry struct{} + +func (stubRegistry) ProviderRecord(context.Context, string) (*model.ProviderRecord, error) { + return nil, nil +} + +type stubMapper struct{} + +func (stubMapper) Verify(context.Context, string, any) error { return nil } + +func (stubMapper) Transform(context.Context, string, definition.Direction, any) ([]byte, error) { + return nil, nil +} + +func TestParseConfig(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + config map[string]string + expected *MandiPrice.Config + expectedErr string + }{ + { + // Everything absent is left zero: MandiPrice.New defaults it, so the + // rules are defined in exactly one place. + name: "leaves everything unset for New to default", + config: map[string]string{}, + expected: &MandiPrice.Config{}, + }, + { + // Query auth is why this capability has its own entry rather than + // sharing weather's: Agmarknet's Vistaar API takes its token as a + // QUERY parameter, and these two keys are the only place that is + // expressible. + name: "reads the query auth scheme this capability needs", + config: map[string]string{ + "bindingKeys": "agmarknet|openagrinet:MandiPrice", + "authScheme": "query", + "queryName": "api-key", + "queryValueEnv": "MANDI_TOKEN", + }, + expected: &MandiPrice.Config{ + BindingKeys: []string{"agmarknet|openagrinet:MandiPrice"}, + AuthScheme: "query", + QueryName: "api-key", + QueryValueEnv: "MANDI_TOKEN", + }, + }, + { + name: "reads every supported setting", + config: map[string]string{ + "bindingKeys": "other|capability", + "authScheme": "basic", + "usernameEnv": "U", + "passwordEnv": "P", + "headerName": "X-Key", + "headerValueEnv": "V", + "queryName": "q", + "queryValueEnv": "Q", + "maxResponseBytes": "2048", + }, + expected: &MandiPrice.Config{ + BindingKeys: []string{"other|capability"}, + AuthScheme: "basic", + UsernameEnv: "U", + PasswordEnv: "P", + HeaderName: "X-Key", + HeaderValueEnv: "V", + QueryName: "q", + QueryValueEnv: "Q", + MaxResponseBytes: 2048, + }, + }, + { + name: "rejects a malformed response cap", + config: map[string]string{"maxResponseBytes": "lots"}, + expectedErr: "invalid maxResponseBytes value 'lots'", + }, + { + name: "rejects a non-positive response cap", + config: map[string]string{"maxResponseBytes": "0"}, + expectedErr: "maxResponseBytes must be positive", + }, + { + // Present but empty is not the same as malformed. A rendered + // config with an unset variable produces this, and it should read + // as "unset" rather than failing startup. + name: "treats an empty response cap as unset", + config: map[string]string{"maxResponseBytes": ""}, + expected: &MandiPrice.Config{}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := mandiProvider{}.parseConfig(tc.config) + + if tc.expectedErr != "" { + if err == nil { + t.Fatalf("expected error %q but got none", tc.expectedErr) + } + if !strings.Contains(err.Error(), tc.expectedErr) { + t.Errorf("expected error containing %q, got %q", tc.expectedErr, err.Error()) + } + return + } + + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if !reflect.DeepEqual(got, tc.expected) { + t.Errorf("expected config %+v, got %+v", tc.expected, got) + } + }) + } +} + +// A plugin config is map[string]string, so a list arrives comma-separated -- +// the convention reqpreprocessor and schemav2validator already use. Binding keys +// separate their own halves with a pipe, so a comma is unambiguous. +func TestSplitList(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + raw string + want []string + }{ + {name: "empty is nil, not a one-element list of nothing", raw: "", want: nil}, + {name: "whitespace only is nil", raw: " ", want: nil}, + {name: "one value", raw: "a|openagrinet:One", want: []string{"a|openagrinet:One"}}, + { + name: "several, with blanks and spacing a wrapped config line produces", + raw: "a|openagrinet:One, b|openagrinet:Two ,, ", + want: []string{"a|openagrinet:One", "b|openagrinet:Two"}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if got := splitList(tc.raw); !reflect.DeepEqual(got, tc.want) { + t.Errorf("splitList(%q) = %v, want %v", tc.raw, got, tc.want) + } + }) + } +} + +// The override is two keys, both or neither. Absent leaves the step on the +// Beckn v2 convention, which is what every deployment should be running. +func TestParseConfigReadsTheBindingKeyOverride(t *testing.T) { + t.Parallel() + + cfg, err := mandiProvider{}.parseConfig(map[string]string{ + "bindingKeys": "a|openagrinet:One", + "providerIdAt": "who.provider", + "capabilityCodeAt": "what[].type", + }) + if err != nil { + t.Fatalf("parseConfig() returned an unexpected error: %v", err) + } + if cfg.ProviderIDAt != "who.provider" || cfg.CapabilityCodeAt != "what[].type" { + t.Errorf("override = %q / %q, want the configured paths", cfg.ProviderIDAt, cfg.CapabilityCodeAt) + } +} + +// Absent leaves them empty, and upstream reads that as "use the convention". +func TestParseConfigLeavesTheOverrideUnsetByDefault(t *testing.T) { + t.Parallel() + + cfg, err := mandiProvider{}.parseConfig(map[string]string{"bindingKeys": "a|openagrinet:One"}) + if err != nil { + t.Fatalf("parseConfig() returned an unexpected error: %v", err) + } + if cfg.ProviderIDAt != "" || cfg.CapabilityCodeAt != "" { + t.Errorf("override = %q / %q, want both empty", cfg.ProviderIDAt, cfg.CapabilityCodeAt) + } +} + +func TestNew(t *testing.T) { + t.Parallel() + + t.Run("rejects a nil context", func(t *testing.T) { + t.Parallel() + + //nolint:staticcheck // deliberately passing a nil context to assert the guard. + _, _, err := mandiProvider{}.New(nil, stubRegistry{}, stubMapper{}, map[string]string{}) + if err == nil { + t.Fatal("expected an error for a nil context, got none") + } + }) + + t.Run("rejects an unparseable config", func(t *testing.T) { + t.Parallel() + + _, _, err := mandiProvider{}.New(context.Background(), stubRegistry{}, stubMapper{}, + map[string]string{"maxResponseBytes": "lots"}) + if err == nil { + t.Fatal("expected an error for an invalid cap, got none") + } + }) + + t.Run("propagates an invalid auth scheme from New", func(t *testing.T) { + t.Parallel() + + _, _, err := mandiProvider{}.New(context.Background(), stubRegistry{}, stubMapper{}, + map[string]string{"authScheme": "oauth"}) + if err == nil { + t.Fatal("expected an unknown auth scheme to be refused") + } + }) + + // A domain plugin serves a family of capabilities, so it cannot guess which + // of them a deployment has providers for. Refused at startup rather than + // answering to nothing. + t.Run("refuses a config naming no capability", func(t *testing.T) { + t.Parallel() + + if _, _, err := (mandiProvider{}).New(context.Background(), stubRegistry{}, stubMapper{}, + map[string]string{}); err == nil { + t.Fatal("expected a config with no bindingKeys to be refused") + } + }) + + t.Run("returns the step and its closer on a config that loads", func(t *testing.T) { + // Not parallel: it swaps the package-level newStepFunc. + closed := false + original := newStepFunc + newStepFunc = func(context.Context, definition.ProviderRecordLookup, definition.Mapper, + *MandiPrice.Config) (definition.Step, func() error, error) { + return nil, func() error { closed = true; return nil }, nil + } + defer func() { newStepFunc = original }() + + _, closer, err := mandiProvider{}.New(context.Background(), stubRegistry{}, stubMapper{}, + map[string]string{"bindingKeys": "agmarknet|openagrinet:MandiPrice"}) + if err != nil { + t.Fatalf("New() returned an unexpected error: %v", err) + } + if closer == nil { + t.Fatal("New() returned no closer, so nothing can release the step") + } + if err := closer(); err != nil { + t.Errorf("closer() returned %v, want nil", err) + } + if !closed { + t.Error("closer() did not reach the step's own closer") + } + }) + + t.Run("propagates a failure from the step constructor", func(t *testing.T) { + original := newStepFunc + wanted := errors.New("upstream refused the config") + newStepFunc = func(context.Context, definition.ProviderRecordLookup, definition.Mapper, + *MandiPrice.Config) (definition.Step, func() error, error) { + return nil, nil, wanted + } + defer func() { newStepFunc = original }() + + _, _, err := mandiProvider{}.New(context.Background(), stubRegistry{}, stubMapper{}, + map[string]string{"bindingKeys": "agmarknet|openagrinet:MandiPrice"}) + if !errors.Is(err, wanted) { + t.Errorf("New() error = %v, want it to wrap %v", err, wanted) + } + }) +} diff --git a/pkg/plugin/implementation/MandiPrice/mappings_test.go b/pkg/plugin/implementation/MandiPrice/mappings_test.go new file mode 100644 index 00000000..ef91a40c --- /dev/null +++ b/pkg/plugin/implementation/MandiPrice/mappings_test.go @@ -0,0 +1,844 @@ +package MandiPrice_test + +// mappings_test.go runs the shipped mandi mapping through the real mapper and +// the real provider step. It is the only test that proves the three pieces fit: +// a mapping is JSONata inside YAML fetched over HTTP, and nothing but running +// it establishes that what is published actually produces valid Beckn. +// +// An external test package on purpose -- it uses the plugins exactly as the +// adapter does, through their exported surface and nothing else. + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/beckn-one/beckn-onix/pkg/model" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/MandiPrice" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/jsonmapper" +) + +// mappingsDir is where the shipped mappings live, relative to this package. +const mappingsDir = "../../../../config/mappings/agmarknet" + +// shippedMapping is the file this binding-action publishes: one file, both +// directions. The action segment of the name must match the action the registry +// entry declares -- a mismatch would apply a correct mapping to the wrong call, +// silently. +const shippedMapping = "mandi-price.select.yaml" + +// shippedCapability is what the pack calls this capability, and the second half +// of the binding key the registry indexes the provider's record by. +const shippedCapability = "openagrinet:MandiPrice" + +const shippedBindingKey = "agmarknet|" + shippedCapability + +// selectRequest is a MandiPrice select in OnDemand mode: it names the market and +// commodity it wants prices for, and carries no prices of its own -- the pack +// forbids that combination. +const selectRequest = `{ + "context": { + "version": "2.0.0", + "action": "select", + "networkId": "oan-dev", + "transactionId": "9f2c1a8e-4b70-4d31-9c55-6f2e0b1d7a44", + "messageId": "7d41b9e0-52a6-4c18-8b73-1e9f0a4c6d22", + "timestamp": "2026-09-03T06:12:01.330Z" + }, + "message": { + "contract": { + "commitments": [ + { + "status": { "descriptor": { "code": "DRAFT", "name": "Draft" } }, + "resources": [ + { + "id": "res:agmarknet:price-enquiry", + "quantity": 1, + "resourceAttributes": { + "@context": "https://schemas.openagrinet.global/schema/MandiPrice/v0.1/context.jsonld", + "@type": "openagrinet:MandiPrice", + "informationMode": "OnDemand", + "subjectCategories": ["Market"], + "supportedCommodities": [{ "code": "2", "name": "Paddy(Common)" }], + "supportedPriceFields": ["Minimum", "Maximum", "Modal"], + "market": { + "marketName": "Kasdol APMC", + "marketCode": "2056", + "district": "96", + "state": "CG" + }, + "validity": { "startsAt": "2025-08-20", "endsAt": "2025-08-21" } + } + } + ], + "offer": { + "id": "offer:agmarknet:open-data", + "resourceIds": ["res:agmarknet:price-enquiry"], + "provider": { + "id": "agmarknet", + "descriptor": { "code": "AGMARKNET-01", "name": "Agmarknet Vistaar" } + } + } + } + ] + } + } +}` + +// providerResponse is a verbatim Agmarknet Vistaar answer, taken from the +// working example in the provider backend's own documentation. Two records, so +// the mapping is exercised on a list rather than a single object. +// +// Note what it is: Title Case keys WITH SPACES, and prices as STRINGS. Both are +// the reason the mapping needs backticks and $number, and pinning a real +// capture here is what keeps that honest. +const providerResponse = `[ + { + "Grade": "Non-FAQ", + "Group": "Cereals", + "State": "Chattisgarh", + "Market": "Kasdol APMC", + "Variety": "D.B.", + "District": "Balodabazar", + "Commodity": "Paddy(Common)", + "Max Price": "2100", + "Min Price": "1900", + "Price Unit": "Rs./Qtl", + "Modal Price": "2000", + "Arrival Date": "20-08-2025" + }, + { + "Grade": "FAQ", + "Group": "Cereals", + "State": "Chattisgarh", + "Market": "Kasdol APMC", + "Variety": "Common", + "District": "Balodabazar", + "Commodity": "Paddy(Common)", + "Price Unit": "Rs./Qtl", + "Modal Price": "2050", + "Arrival Date": "21-08-2025" + } +]` + +// serveMappings publishes the shipped mapping files over HTTP, which is how the +// mapper fetches them in production. +func serveMappings(t *testing.T) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := os.ReadFile(filepath.Join(mappingsDir, filepath.Base(r.URL.Path))) + if err != nil { + t.Errorf("could not read the mapping %q: %v", r.URL.Path, err) + w.WriteHeader(http.StatusNotFound) + return + } + fmt.Fprint(w, string(body)) + })) +} + +// stubRegistry answers with the call plan the live registry holds for this +// capability. +type stubRegistry struct{ plan *model.ProviderRecord } + +func (s *stubRegistry) ProviderRecord(context.Context, string) (*model.ProviderRecord, error) { + return s.plan, nil +} + +// runShipped drives the real step over the real mapping and returns the query +// the provider saw and the answer produced. +func runShipped(t *testing.T, request string) (url.Values, map[string]any) { + return runShippedWith(t, request, providerResponse) +} + +// runShippedWith is runShipped with the upstream's answer under the test's +// control, for the cases where what Agmarknet returns is the thing being +// exercised rather than the request that fetched it. +func runShippedWith(t *testing.T, request, providerBody string) (url.Values, map[string]any) { + t.Helper() + + mappings := serveMappings(t) + defer mappings.Close() + + var gotQuery url.Values + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.Query() + fmt.Fprint(w, providerBody) + })) + defer upstream.Close() + + mapper, closeMapper, err := jsonmapper.New(context.Background(), &jsonmapper.Config{}) + if err != nil { + t.Fatalf("failed to build the mapper: %v", err) + } + defer closeMapper() + + registry := &stubRegistry{plan: &model.ProviderRecord{ + BindingKey: shippedBindingKey, + ParticipantID: "agmarknet", + CapabilityCode: shippedCapability, + BaseURL: upstream.URL, + Actions: map[string]model.ActionPlan{ + "select": {Method: http.MethodGet, Path: "/v1/fetch-agmarknet-vistaar", + Mappings: mappings.URL + "/" + shippedMapping, TimeoutMs: 30000, RetryMax: 3}, + }, + }} + + step, closeStep, err := MandiPrice.New(context.Background(), registry, mapper, + &MandiPrice.Config{BindingKeys: []string{shippedBindingKey}}) + if err != nil { + t.Fatalf("failed to build the step: %v", err) + } + defer closeStep() + + stepCtx := &model.StepContext{Context: t.Context(), Body: []byte(request)} + if err := step.Run(stepCtx); err != nil { + t.Fatalf("Run() returned an unexpected error: %v", err) + } + if len(stepCtx.ResponseBody) == 0 { + t.Fatal("the step produced no answer") + } + var answer map[string]any + if err := json.Unmarshal(stepCtx.ResponseBody, &answer); err != nil { + t.Fatalf("the answer is not JSON: %v\n%s", err, stepCtx.ResponseBody) + } + return gotQuery, answer +} + +func TestShippedMappingServesARealSelect(t *testing.T) { + gotQuery, answer := runShipped(t, selectRequest) + + // --- the request reached the provider as Agmarknet expects -------------- + // Every one of these comes off the payload. Nothing was resolved before the + // call, which is the whole claim of this plugin having no prerequisites. + for param, want := range map[string]string{ + "statecode": "CG", + "districtcode": "96", + "marketcode": "2056", + "commoditycode": "2", + // dd-MM-yyyy, not the ISO the payload carried. + "from_date": "20-08-2025", + "to_date": "21-08-2025", + } { + if got := gotQuery.Get(param); got != want { + t.Errorf("upstream query %s = %q, want %q", param, got, want) + } + } + // The credential is the adapter's business, never the mapping's. + if gotQuery.Has("token") { + t.Error("the mapping must not put a token in the query; authScheme does that") + } + + // --- the answer is Beckn ----------------------------------------------- + beckncontext, _ := answer["context"].(map[string]any) + if beckncontext["action"] != "on_select" { + t.Errorf("action = %v, want on_select", beckncontext["action"]) + } + if beckncontext["transactionId"] != "9f2c1a8e-4b70-4d31-9c55-6f2e0b1d7a44" { + t.Errorf("transactionId = %v, want the one from the request", beckncontext["transactionId"]) + } + // A mapping transforms a payload; it does not assert who anyone is. + for _, field := range []string{"bapId", "bapUri", "bppId", "bppUri"} { + if _, present := beckncontext[field]; present { + t.Errorf("response context carries %q; a mapping must not assert identity", field) + } + } + + // Written out so the answer can be validated against the Beckn v2 spec and + // the MandiPrice pack by tooling outside Go. Skipped unless asked for. + if path := os.Getenv("MANDI_DUMP_ANSWER"); path != "" { + raw, _ := json.MarshalIndent(answer, "", " ") + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatalf("could not write the answer: %v", err) + } + } + + commitment := firstCommitment(t, answer) + if status := commitment["status"].(map[string]any)["descriptor"].(map[string]any); status["code"] != "DRAFT" { + t.Errorf("status = %v, want DRAFT -- the spec's enum is DRAFT, ACTIVE, CLOSED", status["code"]) + } + + // --- one resource per price record -------------------------------------- + resources, _ := commitment["resources"].([]any) + if len(resources) != 2 { + t.Fatalf("got %d resources, want 2 -- one per record the provider answered with", len(resources)) + } + + returned := make([]string, 0, len(resources)) + for _, entry := range resources { + resource, _ := entry.(map[string]any) + id, _ := resource["id"].(string) + // Codes, not names: no spaces or brackets in an identifier. + if !strings.HasPrefix(id, "res:agmarknet:2056:2:") { + t.Errorf("resource id = %q, want one built from the market and commodity codes", id) + } + if strings.ContainsAny(id, " ()") { + t.Errorf("resource id %q contains a space or bracket; use codes, not display names", id) + } + // Required by Commitment.resources in the spec even though the spec + // defines no quantity property. + if _, present := resource["quantity"]; !present { + t.Errorf("resource %s carries no quantity", id) + } + returned = append(returned, id) + } + + // The offer must reference what was actually returned, not what was asked + // for. This is the assertion that fails the moment the offer is echoed. + offer, _ := commitment["offer"].(map[string]any) + referenced, _ := offer["resourceIds"].([]any) + if len(referenced) != len(returned) { + t.Fatalf("offer references %d resources, want %d", len(referenced), len(returned)) + } + for _, reference := range referenced { + if !slices.Contains(returned, reference.(string)) { + t.Errorf("offer references %v, which is not among the resources returned", reference) + } + } + if offer["id"] != "offer:agmarknet:open-data" { + t.Errorf("offer id = %v, want the one the request offered", offer["id"]) + } + + // --- the MandiPrice pack, Direct mode ----------------------------------- + first, _ := resources[0].(map[string]any) + attributes, _ := first["resourceAttributes"].(map[string]any) + for _, f := range []struct{ key, want string }{ + {"@type", "openagrinet:MandiPrice"}, + {"informationMode", "Direct"}, + } { + if attributes[f.key] != f.want { + t.Errorf("%s = %v, want %v", f.key, attributes[f.key], f.want) + } + } + // Direct requires all six of these. + for _, required := range []string{"source", "commodity", "market", "arrivalDate", "prices", "generatedAt"} { + if attributes[required] == nil { + t.Errorf("resourceAttributes carries no %q", required) + } + } + // OnDemand's fields must NOT appear: the pack forbids prices alongside + // them, and an answer advertising a capability is a category error. + for _, absent := range []string{"supportedCommodities", "supportedPriceFields"} { + if _, present := attributes[absent]; present { + t.Errorf("a Direct answer must not carry %q", absent) + } + } + + // --- the prices, converted from strings --------------------------------- + prices, _ := attributes["prices"].(map[string]any) + for field, want := range map[string]float64{"minimum": 1900, "maximum": 2100, "modal": 2000} { + got, ok := prices[field].(float64) + if !ok { + t.Errorf("prices.%s = %#v, want a number -- the upstream sends strings", field, prices[field]) + continue + } + if got != want { + t.Errorf("prices.%s = %v, want %v", field, got, want) + } + } + if prices["currency"] != "INR" || prices["unit"] != "Rs./Qtl" { + t.Errorf("prices currency/unit = %v/%v, want INR/Rs./Qtl", prices["currency"], prices["unit"]) + } + + // arrivalDate is ISO in the answer, though the upstream reported dd-MM-yyyy. + if attributes["arrivalDate"] != "2025-08-20" { + t.Errorf("arrivalDate = %v, want 2025-08-20 in ISO", attributes["arrivalDate"]) + } + + // The pack's enum is Crop, Livestock, Weather, Market, Scheme, Knowledge, + // Service -- so "Market", not "MarketPrice". Echoed from the request, which + // is why getting it wrong there would produce an invalid answer here. + categories, _ := attributes["subjectCategories"].([]any) + if len(categories) != 1 || categories[0] != "Market" { + t.Errorf("subjectCategories = %v, want [Market] from the pack's enum", categories) + } + + market, _ := attributes["market"].(map[string]any) + if market["marketName"] != "Kasdol APMC" || market["state"] != "Chattisgarh" { + t.Errorf("market = %v, want the names the provider reported", market) + } + + // --- a record the market reported only partially ------------------------- + // The second record has no Min or Max Price. Those must be absent, not zero: + // a consumer must be able to tell "not reported" from "reported as zero". + second, _ := resources[1].(map[string]any) + secondPrices, _ := second["resourceAttributes"].(map[string]any)["prices"].(map[string]any) + for _, absent := range []string{"minimum", "maximum"} { + if _, present := secondPrices[absent]; present { + t.Errorf("prices.%s is present for a record that did not report it", absent) + } + } + if secondPrices["modal"] != float64(2050) { + t.Errorf("the second record's modal price = %v, want 2050", secondPrices["modal"]) + } +} + +// The pack leaves every field this upstream needs optional, so a spec-valid +// select can still be unanswerable. The mapping refuses those before the +// provider is called, with its own message. +func TestShippedMappingRefusesWhatItCannotServe(t *testing.T) { + for _, tc := range []struct{ name, drop, expect string }{ + {"no commodity code", "supportedCommodities", "commodity code"}, + {"no market codes", "market", "codes in market"}, + {"no validity window", "validity", "validity window"}, + } { + t.Run(tc.name, func(t *testing.T) { + // Built by deleting a key from the decoded fixture rather than by + // editing its text: removing the last member of an object leaves a + // trailing comma, and the resulting parse error would look like a + // mapping failure. + var payload map[string]any + if err := json.Unmarshal([]byte(selectRequest), &payload); err != nil { + t.Fatalf("the fixture is not JSON: %v", err) + } + attributes := payload["message"].(map[string]any)["contract"].(map[string]any)["commitments"].([]any)[0].(map[string]any)["resources"].([]any)[0].(map[string]any)["resourceAttributes"].(map[string]any) + if _, present := attributes[tc.drop]; !present { + t.Fatalf("the fixture has no %q, so this case tests nothing", tc.drop) + } + delete(attributes, tc.drop) + body, err := json.Marshal(payload) + if err != nil { + t.Fatalf("could not rebuild the payload: %v", err) + } + + mappings := serveMappings(t) + defer mappings.Close() + + called := false + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + fmt.Fprint(w, providerResponse) + })) + defer upstream.Close() + + mapper, closeMapper, err := jsonmapper.New(context.Background(), &jsonmapper.Config{}) + if err != nil { + t.Fatalf("failed to build the mapper: %v", err) + } + defer closeMapper() + + registry := &stubRegistry{plan: &model.ProviderRecord{ + BindingKey: shippedBindingKey, BaseURL: upstream.URL, + Actions: map[string]model.ActionPlan{ + "select": {Method: http.MethodGet, Path: "/v1/fetch-agmarknet-vistaar", + Mappings: mappings.URL + "/" + shippedMapping, TimeoutMs: 30000}, + }, + }} + step, closeStep, err := MandiPrice.New(context.Background(), registry, mapper, + &MandiPrice.Config{BindingKeys: []string{shippedBindingKey}}) + if err != nil { + t.Fatalf("failed to build the step: %v", err) + } + defer closeStep() + + stepCtx := &model.StepContext{Context: t.Context(), Body: body} + if err := step.Run(stepCtx); err == nil { + t.Fatal("expected an unserviceable payload to be refused") + } else if !strings.Contains(err.Error(), tc.expect) { + t.Errorf("error %q should carry the mapping's own message about %q", err, tc.expect) + } + if called { + t.Error("the provider was called for a payload the mapping refuses") + } + }) + } +} + +// firstCommitment reaches the one commitment an answer carries. +func firstCommitment(t *testing.T, answer map[string]any) map[string]any { + t.Helper() + message, _ := answer["message"].(map[string]any) + contract, _ := message["contract"].(map[string]any) + commitments, _ := contract["commitments"].([]any) + if len(commitments) != 1 { + t.Fatalf("got %d commitments, want 1", len(commitments)) + } + commitment, _ := commitments[0].(map[string]any) + return commitment +} + +// resourcesOf returns the answer's resources as maps, so a test can look at +// each record the provider's rows became. +func resourcesOf(t *testing.T, answer map[string]any) []map[string]any { + t.Helper() + + raw, _ := firstCommitment(t, answer)["resources"].([]any) + out := make([]map[string]any, 0, len(raw)) + for _, r := range raw { + m, ok := r.(map[string]any) + if !ok { + t.Fatalf("resource is not an object: %#v", r) + } + out = append(out, m) + } + return out +} + +// Agmarknet writes an unreported price as a marker rather than omitting the +// field -- "NR", "-", "". $exists() is true for all of them, so an +// existence-only guard handed them to $number() and it threw D3030, which +// failed the WHOLE response: one unreported cell in one row turned a good +// multi-row answer into an adapter error. +func TestShippedMappingSurvivesUnreportedPrices(t *testing.T) { + t.Parallel() + + // Row one has a marker in TWO price fields and a real modal, so it stays + // and its markers must come back absent. Row two is ordinary. Row three + // has markers in all three, so it has nothing to report and is dropped. + const withMarkers = `[ + { + "Grade": "Non-FAQ", "Group": "Cereals", "State": "Chattisgarh", + "Market": "Kasdol APMC", "Variety": "D.B.", "District": "Balodabazar", + "Commodity": "Paddy(Common)", + "Min Price": "NR", "Max Price": "-", "Modal Price": "2000", + "Price Unit": "Rs./Qtl", "Arrival Date": "20-08-2025" + }, + { + "Grade": "FAQ", "Group": "Cereals", "State": "Chattisgarh", + "Market": "Kasdol APMC", "Variety": "Common", "District": "Balodabazar", + "Commodity": "Paddy(Common)", + "Min Price": "1900", "Max Price": "2100", "Modal Price": "2050", + "Price Unit": "Rs./Qtl", "Arrival Date": "21-08-2025" + }, + { + "Grade": "FAQ", "Group": "Cereals", "State": "Chattisgarh", + "Market": "Kasdol APMC", "Variety": "Common", "District": "Balodabazar", + "Commodity": "Paddy(Common)", + "Min Price": "NR", "Max Price": "NR", "Modal Price": "NR", + "Price Unit": "Rs./Qtl", "Arrival Date": "22-08-2025" + } + ]` + + _, answer := runShippedWith(t, selectRequest, withMarkers) + + byDate := map[string]map[string]any{} + for _, r := range resourcesOf(t, answer) { + ra := r["resourceAttributes"].(map[string]any) + byDate[ra["arrivalDate"].(string)] = ra + } + + // The row with a real modal survives -- one unreported cell must not + // discard it, and must not discard the rows beside it either. + partial, ok := byDate["2025-08-20"] + if !ok { + t.Fatal("the partially-priced row is missing; an unreported cell discarded it") + } + prices := partial["prices"].(map[string]any) + if _, isNum := prices["modal"].(float64); !isNum { + t.Errorf("prices.modal = %#v, want the reported number", prices["modal"]) + } + // Absent, not zero: a consumer must tell "no minimum reported" from + // "the minimum was zero". + for _, field := range []string{"minimum", "maximum"} { + if v, present := prices[field]; present { + t.Errorf("prices.%s = %#v for an unreported price; absent is honest, zero is a lie", field, v) + } + } + + if _, ok := byDate["2025-08-21"]; !ok { + t.Error("the fully-priced row is missing from the answer") + } + + // Nothing to report at all: the pack's prices.anyOf cannot be satisfied, + // so the row is dropped rather than emitted as an invalid resource. + if _, ok := byDate["2025-08-22"]; ok { + t.Error("a row whose every price is unreported was emitted; it cannot satisfy prices.anyOf") + } +} + +// A record that cannot produce a conformant resource is dropped, not emitted +// with a degenerate value. Absent is honest; present-and-wrong is a lie in the +// shape of an answer, and it is signed. +func TestShippedMappingDropsRecordsItCannotMakeConformant(t *testing.T) { + t.Parallel() + + // One good row, then one for each way a record fails the pack. + const mixed = `[ + { + "Grade": "FAQ", "Group": "Cereals", "State": "Chattisgarh", + "Market": "Kasdol APMC", "Variety": "Common", "District": "Balodabazar", + "Commodity": "Paddy(Common)", + "Min Price": "1900", "Max Price": "2100", "Modal Price": "2000", + "Price Unit": "Rs./Qtl", "Arrival Date": "20-08-2025" + }, + { + "Grade": "FAQ", "Group": "Cereals", "State": "Chattisgarh", + "Market": "Kasdol APMC", "Variety": "Common", "District": "Balodabazar", + "Commodity": "Paddy(Common)", + "Min Price": "1800", "Max Price": "2000", "Modal Price": "1900", + "Price Unit": "Rs./Qtl" + }, + { + "Grade": "FAQ", "Group": "Cereals", "State": "Chattisgarh", + "Market": "Kasdol APMC", "Variety": "Common", "District": "Balodabazar", + "Commodity": "Paddy(Common)", + "Min Price": "1700", "Max Price": "1900", "Modal Price": "1800", + "Arrival Date": "23-08-2025" + } + ]` + + _, answer := runShippedWith(t, selectRequest, mixed) + + resources := resourcesOf(t, answer) + if len(resources) != 1 { + var dates []string + for _, r := range resources { + ra := r["resourceAttributes"].(map[string]any) + dates = append(dates, fmt.Sprint(ra["arrivalDate"])) + } + t.Fatalf("got %d resources with dates %v, want only the conformant one", len(resources), dates) + } + + ra := resources[0]["resourceAttributes"].(map[string]any) + if ra["arrivalDate"] != "2025-08-20" { + t.Errorf("arrivalDate = %v, want the one good record", ra["arrivalDate"]) + } + // The degenerate date the old mapping produced, specifically. + if ra["arrivalDate"] == "--" { + t.Error(`arrivalDate = "--", which the pack refuses as format: date`) + } + if _, ok := ra["prices"].(map[string]any)["unit"]; !ok { + t.Error("prices.unit is missing, which the pack requires") + } + + // The offer must not reference a resource the filter removed -- dropping a + // record and leaving its id in resourceIds would trade an invalid resource + // for a dangling reference. + ids := map[string]bool{} + for _, r := range resources { + ids[r["id"].(string)] = true + } + offer, _ := firstCommitment(t, answer)["offer"].(map[string]any) + referenced, _ := offer["resourceIds"].([]any) + if len(referenced) != len(resources) { + t.Errorf("offer references %d resources, want %d", len(referenced), len(resources)) + } + for _, ref := range referenced { + if !ids[fmt.Sprint(ref)] { + t.Errorf("the offer references %q, which is not among the answer's resources", ref) + } + } +} + +// The answer must assert what it knows, not repeat what it was told. Both of +// these were echoes of the request, and an echo is a claim this adapter signs. +func TestShippedMappingStatesWhatItKnowsRatherThanEchoing(t *testing.T) { + t.Parallel() + + // A caller asking a MandiPrice question with the WRONG category. It is + // enum-legal, so nothing downstream would reject it -- which is exactly + // why echoing it was dangerous. + wrongCategory := strings.Replace(selectRequest, + `"subjectCategories": ["Market"]`, `"subjectCategories": ["Weather"]`, 1) + if wrongCategory == selectRequest { + t.Fatal("the fixture no longer states subjectCategories; this test needs updating") + } + + _, answer := runShippedWith(t, wrongCategory, providerResponse) + + for _, r := range resourcesOf(t, answer) { + ra := r["resourceAttributes"].(map[string]any) + + // Stated from the pack, not taken from the caller. + cats, _ := ra["subjectCategories"].([]any) + if len(cats) != 1 || cats[0] != "Market" { + t.Errorf(`subjectCategories = %#v, want ["Market"] regardless of what the request said`, ra["subjectCategories"]) + } + + market, _ := ra["market"].(map[string]any) + // The upstream reports no market code, so the answer must not claim one. + if code, present := market["marketCode"]; present { + t.Errorf("market.marketCode = %#v; this upstream reports no code, so asserting one is unfounded", code) + } + // And what is there comes from the record. + if market["marketName"] != "Kasdol APMC" || market["state"] != "Chattisgarh" { + t.Errorf("market = %#v, want the values the provider reported", market) + } + // marketName is the one member the pack requires. + if _, ok := market["marketName"]; !ok { + t.Error("market.marketName is missing, which the pack requires") + } + } +} + +// The guards now check what they claim to. Both of these payloads are legal +// against the pack and unanswerable by this upstream, which is the gap between +// "valid" and "serviceable" the required block exists to close. +func TestShippedMappingRefusesPayloadsItCannotAnswer(t *testing.T) { + for _, tc := range []struct { + name string + mutate func(map[string]any) + expect string + }{ + { + // The pack describes district and state as "name or governed + // code", so this validates -- and went to Agmarknet verbatim as + // codes, which answered nothing. The caller then received a + // signed, spec-valid "no prices" for a market that had prices. + name: "names where the upstream wants codes", + mutate: func(ra map[string]any) { + ra["market"] = map[string]any{ + "marketName": "Kasdol APMC", + "district": "Balodabazar", + "state": "Chattisgarh", + } + }, + expect: "codes in market", + }, + { + // Everything downstream reads supportedCommodities[0], so this + // used to be queried for Paddy alone and answered confidently. + name: "more commodities than one request can serve", + mutate: func(ra map[string]any) { + ra["supportedCommodities"] = []any{ + map[string]any{"code": "2", "name": "Paddy(Common)"}, + map[string]any{"code": "3", "name": "Wheat"}, + } + }, + expect: "exactly one commodity", + }, + } { + t.Run(tc.name, func(t *testing.T) { + var payload map[string]any + if err := json.Unmarshal([]byte(selectRequest), &payload); err != nil { + t.Fatalf("the fixture is not JSON: %v", err) + } + ra := payload["message"].(map[string]any)["contract"].(map[string]any)["commitments"].([]any)[0].(map[string]any)["resources"].([]any)[0].(map[string]any)["resourceAttributes"].(map[string]any) + tc.mutate(ra) + body, err := json.Marshal(payload) + if err != nil { + t.Fatalf("could not rebuild the payload: %v", err) + } + + mappings := serveMappings(t) + defer mappings.Close() + + called := false + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + called = true + fmt.Fprint(w, providerResponse) + })) + defer upstream.Close() + + mapper, closeMapper, err := jsonmapper.New(context.Background(), &jsonmapper.Config{}) + if err != nil { + t.Fatalf("failed to build the mapper: %v", err) + } + defer closeMapper() + + registry := &stubRegistry{plan: &model.ProviderRecord{ + BindingKey: shippedBindingKey, BaseURL: upstream.URL, + Actions: map[string]model.ActionPlan{ + "select": {Method: http.MethodGet, Path: "/v1/fetch-agmarknet-vistaar", + Mappings: mappings.URL + "/" + shippedMapping, TimeoutMs: 30000}, + }, + }} + step, closeStep, err := MandiPrice.New(context.Background(), registry, mapper, + &MandiPrice.Config{BindingKeys: []string{shippedBindingKey}}) + if err != nil { + t.Fatalf("failed to build the step: %v", err) + } + defer closeStep() + + stepCtx := &model.StepContext{Context: t.Context(), Body: body} + if err := step.Run(stepCtx); err == nil { + t.Fatal("expected a payload this upstream cannot answer to be refused") + } else if !strings.Contains(err.Error(), tc.expect) { + t.Errorf("error %q should explain the refusal in terms of %q", err, tc.expect) + } + // The point of refusing early: the upstream is never troubled with + // a request that cannot produce an answer. + if called { + t.Error("the provider was called for a payload the mapping refuses") + } + }) + } +} + +// Agmarknet routinely reports several rows for the same market, commodity and +// date differing only by Variety and Grade. The id was built from +// scope:commodity:date, so those rows collided: two distinct resources under +// one id, referenced twice by the offer, and a consumer resolving resourceIds +// could not tell which price it had. +func TestShippedMappingGivesCollidingRowsDistinctIDs(t *testing.T) { + t.Parallel() + + // The same pair as this package's own fixture -- FAQ/Common and + // Non-FAQ/D.B. -- but sharing an arrival date, which is what the fixture + // was accidentally saved by not doing. + const sameDay = `[ + { + "Grade": "Non-FAQ", "Group": "Cereals", "State": "Chattisgarh", + "Market": "Kasdol APMC", "Variety": "D.B.", "District": "Balodabazar", + "Commodity": "Paddy(Common)", + "Min Price": "1900", "Max Price": "2100", "Modal Price": "2000", + "Price Unit": "Rs./Qtl", "Arrival Date": "20-08-2025" + }, + { + "Grade": "FAQ", "Group": "Cereals", "State": "Chattisgarh", + "Market": "Kasdol APMC", "Variety": "Common", "District": "Balodabazar", + "Commodity": "Paddy(Common)", + "Min Price": "1600", "Max Price": "1800", "Modal Price": "1700", + "Price Unit": "Rs./Qtl", "Arrival Date": "20-08-2025" + } + ]` + + _, answer := runShippedWith(t, selectRequest, sameDay) + + resources := resourcesOf(t, answer) + if len(resources) != 2 { + t.Fatalf("got %d resources, want both rows", len(resources)) + } + seen := map[string]int{} + for _, r := range resources { + seen[r["id"].(string)]++ + } + for id, n := range seen { + if n > 1 { + t.Errorf("id %q is shared by %d resources; the offer cannot reference one of them unambiguously", id, n) + } + } + if len(seen) != 2 { + t.Errorf("got %d distinct ids for 2 rows: %v", len(seen), seen) + } +} + +// supportedPriceFields was validated on the way in and ignored on the way out, +// so a caller asking for one field got all three. +func TestShippedMappingHonoursTheRequestedPriceFields(t *testing.T) { + t.Parallel() + + modalOnly := strings.Replace(selectRequest, + `"supportedPriceFields": ["Minimum", "Maximum", "Modal"]`, + `"supportedPriceFields": ["Modal"]`, 1) + if modalOnly == selectRequest { + t.Fatal("the fixture no longer lists all three price fields; this test needs updating") + } + + _, answer := runShippedWith(t, modalOnly, providerResponse) + + for _, r := range resourcesOf(t, answer) { + prices := r["resourceAttributes"].(map[string]any)["prices"].(map[string]any) + if _, ok := prices["modal"]; !ok { + t.Error("prices.modal is missing, and it is the field that was asked for") + } + for _, unasked := range []string{"minimum", "maximum"} { + if v, present := prices[unasked]; present { + t.Errorf("prices.%s = %#v was returned though the request did not ask for it", unasked, v) + } + } + // The pack requires these whatever was asked for. + for _, required := range []string{"currency", "unit"} { + if _, ok := prices[required]; !ok { + t.Errorf("prices.%s is missing, which the pack requires", required) + } + } + } +} diff --git a/pkg/plugin/implementation/MandiPrice/prerequisites.go b/pkg/plugin/implementation/MandiPrice/prerequisites.go new file mode 100644 index 00000000..d7c1f8aa --- /dev/null +++ b/pkg/plugin/implementation/MandiPrice/prerequisites.go @@ -0,0 +1,20 @@ +package MandiPrice + +import "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/upstream" + +// prerequisites is what a mandi capability needs that its payload does not +// carry, keyed by binding key. +// +// Empty, and for a better reason than weather's: a market price select names +// the market it wants. The MandiPrice pack has no top-level location -- only +// market.marketCode, market.district and market.state -- so there is nothing +// to resolve. Agmarknet's Vistaar select takes exactly those codes, and the +// mapping reads them straight off the payload. +// +// An entry would be needed only for real I/O: a commodity name to resolve to a +// code, a token to exchange, a point to turn into a market. Each of those is a +// different upstream than the one this was written against, and each would +// bring the question of where the provider-to-function binding belongs -- see +// the note in WeatherObservation/prerequisites.go and prefer keeping the payload explicit +// over adding an entry here. +var prerequisites = upstream.Prerequisites{} diff --git a/pkg/plugin/implementation/WeatherObservation/WeatherObservation.go b/pkg/plugin/implementation/WeatherObservation/WeatherObservation.go new file mode 100644 index 00000000..65820e84 --- /dev/null +++ b/pkg/plugin/implementation/WeatherObservation/WeatherObservation.go @@ -0,0 +1,35 @@ +// Package WeatherObservation serves the network's weather capabilities. +// +// One package per capability, named for the capability it serves, so which +// plugin owns a payload is readable from its binding key without a lookup: +// openagrinet:WeatherObservation is this one's, openagrinet:MandiPrice is not. +// Which keys it answers to is still configuration -- a deployment can point it +// at a related pack such as openagrinet:WeatherAdvisory -- but the name says +// what it was built against. +// +// Almost nothing lives here. Recognising a capability, resolving the call plan, +// authenticating, calling with the registry's budget and translating in both +// directions are all internal/upstream's, because none of them differ by domain. +// What this package owns is its name, and prerequisites -- the work a mapping +// cannot express, which is domain knowledge by definition. +package WeatherObservation + +import ( + "context" + + "github.com/beckn-one/beckn-onix/pkg/plugin/definition" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/upstream" +) + +// Config is upstream's, unchanged. Aliased here so a domain plugin's cmd package +// need not know where the machinery lives. +type Config = upstream.Config + +// New creates the weather step. +// +// Which capabilities it answers to is configuration, with no default: a package +// serving a family cannot guess which of them a deployment has providers for. +func New(ctx context.Context, registry definition.ProviderRecordLookup, mapper definition.Mapper, + cfg *Config) (definition.Step, func() error, error) { + return upstream.New(ctx, registry, mapper, prerequisites, cfg) +} diff --git a/pkg/plugin/implementation/WeatherObservation/cmd/plugin.go b/pkg/plugin/implementation/WeatherObservation/cmd/plugin.go new file mode 100644 index 00000000..9a53bf97 --- /dev/null +++ b/pkg/plugin/implementation/WeatherObservation/cmd/plugin.go @@ -0,0 +1,100 @@ +package main + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + + "github.com/beckn-one/beckn-onix/pkg/log" + "github.com/beckn-one/beckn-onix/pkg/plugin/definition" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/WeatherObservation" +) + +// weatherProvider implements definition.ProviderStepProvider. +type weatherProvider struct{} + +// newStepFunc creates a new step. Indirected for tests. +var newStepFunc = WeatherObservation.New + +// parseConfig turns the plugin config map into a typed Config. Anything absent +// is left zero: WeatherObservation.New applies the defaults and validates the auth +// scheme, so those rules live in one place. +func (p weatherProvider) parseConfig(config map[string]string) (*WeatherObservation.Config, error) { + cfg := &WeatherObservation.Config{ + BindingKeys: splitList(config["bindingKeys"]), + // Absent means the Beckn v2 convention. See upstream.Config for why + // this is a default rather than something to set. + ProviderIDAt: config["providerIdAt"], + CapabilityCodeAt: config["capabilityCodeAt"], + AuthScheme: config["authScheme"], + UsernameEnv: config["usernameEnv"], + PasswordEnv: config["passwordEnv"], + HeaderName: config["headerName"], + HeaderValueEnv: config["headerValueEnv"], + QueryName: config["queryName"], + QueryValueEnv: config["queryValueEnv"], + } + + if raw, exists := config["maxResponseBytes"]; exists && raw != "" { + value, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid maxResponseBytes value '%s': %w", raw, err) + } + if value <= 0 { + return nil, fmt.Errorf("maxResponseBytes must be positive, got %d", value) + } + cfg.MaxResponseBytes = value + } + + return cfg, nil +} + +// New creates a new weather provider step instance. +func (p weatherProvider) New(ctx context.Context, registry definition.ProviderRecordLookup, mapper definition.Mapper, config map[string]string) (definition.Step, func() error, error) { + if ctx == nil { + return nil, nil, errors.New("context cannot be nil") + } + + cfg, err := p.parseConfig(config) + if err != nil { + log.Errorf(ctx, err, "Failed to parse weather configuration") + return nil, nil, fmt.Errorf("failed to parse weather configuration: %w", err) + } + + step, closer, err := newStepFunc(ctx, registry, mapper, cfg) + if err != nil { + log.Errorf(ctx, err, "Failed to create weather step") + return nil, nil, err + } + + log.Infof(ctx, "Weather step created successfully") + return step, closer, nil +} + +// splitList reads a comma-separated config value, which is how a list reaches a +// plugin -- the config is map[string]string. Blanks are dropped and spaces +// trimmed, so a trailing comma or a wrapped line is not a config error. +// +// A comma is unambiguous here: a binding key separates its own halves with a +// pipe. +func splitList(raw string) []string { + if strings.TrimSpace(raw) == "" { + return nil + } + var out []string + for _, part := range strings.Split(raw, ",") { + if trimmed := strings.TrimSpace(part); trimmed != "" { + out = append(out, trimmed) + } + } + return out +} + +// Provider is the exported plugin instance. +var Provider = weatherProvider{} + +// Compile-time proof the provider satisfies the interface the manager asserts +// against. A mismatch is otherwise a runtime cast failure at startup. +var _ definition.ProviderStepProvider = Provider diff --git a/pkg/plugin/implementation/WeatherObservation/cmd/plugin_test.go b/pkg/plugin/implementation/WeatherObservation/cmd/plugin_test.go new file mode 100644 index 00000000..f95323da --- /dev/null +++ b/pkg/plugin/implementation/WeatherObservation/cmd/plugin_test.go @@ -0,0 +1,234 @@ +package main + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + + "github.com/beckn-one/beckn-onix/pkg/model" + "github.com/beckn-one/beckn-onix/pkg/plugin/definition" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/WeatherObservation" +) + +type stubRegistry struct{} + +func (stubRegistry) ProviderRecord(context.Context, string) (*model.ProviderRecord, error) { + return nil, nil +} + +type stubMapper struct{} + +func (stubMapper) Verify(context.Context, string, any) error { return nil } + +func (stubMapper) Transform(context.Context, string, definition.Direction, any) ([]byte, error) { + return nil, nil +} + +func TestParseConfig(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + config map[string]string + expected *WeatherObservation.Config + expectedErr string + }{ + { + // Everything absent is left zero: WeatherObservation.New defaults it, so the + // rules are defined in exactly one place. + name: "leaves everything unset for New to default", + config: map[string]string{}, + expected: &WeatherObservation.Config{}, + }, + { + name: "reads every supported setting", + config: map[string]string{ + "bindingKeys": "other|capability", + "authScheme": "basic", + "usernameEnv": "U", + "passwordEnv": "P", + "headerName": "X-Key", + "headerValueEnv": "V", + "maxResponseBytes": "2048", + }, + expected: &WeatherObservation.Config{ + BindingKeys: []string{"other|capability"}, + AuthScheme: "basic", + UsernameEnv: "U", + PasswordEnv: "P", + HeaderName: "X-Key", + HeaderValueEnv: "V", + MaxResponseBytes: 2048, + }, + }, + { + name: "rejects a malformed response cap", + config: map[string]string{"maxResponseBytes": "lots"}, + expectedErr: "invalid maxResponseBytes value 'lots'", + }, + { + name: "rejects a non-positive response cap", + config: map[string]string{"maxResponseBytes": "0"}, + expectedErr: "maxResponseBytes must be positive", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := weatherProvider{}.parseConfig(tc.config) + + if tc.expectedErr != "" { + if err == nil { + t.Fatalf("expected error %q but got none", tc.expectedErr) + } + if !strings.Contains(err.Error(), tc.expectedErr) { + t.Errorf("expected error containing %q, got %q", tc.expectedErr, err.Error()) + } + return + } + + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if !reflect.DeepEqual(got, tc.expected) { + t.Errorf("expected config %+v, got %+v", tc.expected, got) + } + }) + } +} + +// A plugin config is map[string]string, so a list arrives comma-separated -- +// the convention reqpreprocessor and schemav2validator already use. Binding keys +// separate their own halves with a pipe, so a comma is unambiguous. +func TestParseConfigReadsSeveralBindingKeys(t *testing.T) { + t.Parallel() + + cfg, err := weatherProvider{}.parseConfig(map[string]string{ + "bindingKeys": "a|openagrinet:One, b|openagrinet:Two ,, ", + }) + if err != nil { + t.Fatalf("parseConfig() returned an unexpected error: %v", err) + } + want := []string{"a|openagrinet:One", "b|openagrinet:Two"} + if len(cfg.BindingKeys) != len(want) { + t.Fatalf("binding keys = %v, want %v -- blanks should be dropped and spaces trimmed", cfg.BindingKeys, want) + } + for i, key := range want { + if cfg.BindingKeys[i] != key { + t.Errorf("binding key %d = %q, want %q", i, cfg.BindingKeys[i], key) + } + } +} + +// The override is two keys, both or neither. Absent leaves the step on the +// Beckn v2 convention, which is what every deployment should be running. +func TestParseConfigReadsTheBindingKeyOverride(t *testing.T) { + t.Parallel() + + cfg, err := weatherProvider{}.parseConfig(map[string]string{ + "bindingKeys": "a|openagrinet:One", + "providerIdAt": "who.provider", + "capabilityCodeAt": "what[].type", + }) + if err != nil { + t.Fatalf("parseConfig() returned an unexpected error: %v", err) + } + if cfg.ProviderIDAt != "who.provider" || cfg.CapabilityCodeAt != "what[].type" { + t.Errorf("override = %q / %q, want the configured paths", cfg.ProviderIDAt, cfg.CapabilityCodeAt) + } +} + +// Absent leaves them empty, and upstream reads that as "use the convention". +func TestParseConfigLeavesTheOverrideUnsetByDefault(t *testing.T) { + t.Parallel() + + cfg, err := weatherProvider{}.parseConfig(map[string]string{"bindingKeys": "a|openagrinet:One"}) + if err != nil { + t.Fatalf("parseConfig() returned an unexpected error: %v", err) + } + if cfg.ProviderIDAt != "" || cfg.CapabilityCodeAt != "" { + t.Errorf("override = %q / %q, want both empty", cfg.ProviderIDAt, cfg.CapabilityCodeAt) + } +} + +func TestNew(t *testing.T) { + t.Parallel() + + t.Run("rejects a nil context", func(t *testing.T) { + t.Parallel() + + //nolint:staticcheck // deliberately passing a nil context to assert the guard. + _, _, err := weatherProvider{}.New(nil, stubRegistry{}, stubMapper{}, map[string]string{}) + if err == nil { + t.Fatal("expected an error for a nil context, got none") + } + }) + + t.Run("rejects an unparseable config", func(t *testing.T) { + t.Parallel() + + _, _, err := weatherProvider{}.New(context.Background(), stubRegistry{}, stubMapper{}, + map[string]string{"maxResponseBytes": "lots"}) + if err == nil { + t.Fatal("expected an error for an invalid cap, got none") + } + }) + + t.Run("propagates an invalid auth scheme from New", func(t *testing.T) { + t.Parallel() + + _, _, err := weatherProvider{}.New(context.Background(), stubRegistry{}, stubMapper{}, + map[string]string{"authScheme": "oauth"}) + if err == nil { + t.Fatal("expected an unknown auth scheme to be refused") + } + }) + + // A domain plugin serves a family of capabilities, so it cannot guess which + // of them a deployment has providers for. Refused at startup rather than + // answering to nothing. + t.Run("refuses a config naming no capability", func(t *testing.T) { + t.Parallel() + + if _, _, err := (weatherProvider{}).New(context.Background(), stubRegistry{}, stubMapper{}, + map[string]string{}); err == nil { + t.Fatal("expected a config with no bindingKeys to be refused") + } + }) + + t.Run("builds a step from the capabilities it is given", func(t *testing.T) { + t.Parallel() + + step, closer, err := weatherProvider{}.New(context.Background(), stubRegistry{}, stubMapper{}, + map[string]string{"bindingKeys": "imd|openagrinet:WeatherObservation"}) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if step == nil { + t.Fatal("expected a step, got nil") + } + if err := closer(); err != nil { + t.Errorf("expected the closer to succeed, got: %v", err) + } + }) + + // Deliberately NOT parallel: this swaps the package-level newStepFunc. + t.Run("propagates a construction failure", func(t *testing.T) { + original := newStepFunc + t.Cleanup(func() { newStepFunc = original }) + + wantErr := errors.New("boom") + newStepFunc = func(context.Context, definition.ProviderRecordLookup, definition.Mapper, *WeatherObservation.Config) (definition.Step, func() error, error) { + return nil, nil, wantErr + } + + _, _, err := weatherProvider{}.New(context.Background(), stubRegistry{}, stubMapper{}, map[string]string{}) + if !errors.Is(err, wantErr) { + t.Errorf("expected the construction error to propagate, got %v", err) + } + }) +} diff --git a/pkg/plugin/implementation/WeatherObservation/mappings_test.go b/pkg/plugin/implementation/WeatherObservation/mappings_test.go new file mode 100644 index 00000000..d8688193 --- /dev/null +++ b/pkg/plugin/implementation/WeatherObservation/mappings_test.go @@ -0,0 +1,537 @@ +package WeatherObservation_test + +// mappings_test.go runs the shipped mapping files through the real mapper and +// the real provider step. It is the only test that proves the three pieces fit: +// a mapping is JSONata inside YAML fetched over HTTP, and nothing but running +// it establishes that what is published actually produces valid Beckn. +// +// An external test package on purpose -- it uses the plugins exactly as the +// adapter does, through their exported surface and nothing else. + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/beckn-one/beckn-onix/pkg/model" + "github.com/beckn-one/beckn-onix/pkg/plugin/definition" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/WeatherObservation" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/jsonmapper" +) + +// mappingsDir is where the shipped mappings live, relative to this package. +const mappingsDir = "../../../../config/mappings/mausamgram" + +// selectedResourceID is the resource the request selects, and therefore the one +// the answer quotes. It is the same string in both directions on purpose. +const selectedResourceID = "res:mausamgram:point-forecast" + +// shippedMapping is the file this binding-action publishes: one file, both +// directions. The registry carries its full URL; the action segment of the name +// must match the action that registry entry declares -- a mismatch would apply a +// correct mapping to the wrong call, silently. +// shippedBindingKey is the capability these tests exercise. Named here because +// the package has no default: it serves whatever a deployment configures. +const shippedBindingKey = "mausamgram|openagrinet:WeatherObservation" + +const shippedMapping = "weather-observation.select.yaml" + +// selectRequest is the verbatim /select captured from the network. +const selectRequest = `{ + "context": { "version": "2.0.0", "action": "select", + "networkId": "da.gov.in/vistaar", + "bapId": "seeker-network-vistaar.da.gov.in", + "bapUri": "https://seeker-network-vistaar.da.gov.in/beckn", + "bppId": "provider-network-vistaar.da.gov.in", + "bppUri": "https://provider-network-vistaar.da.gov.in/beckn", + "transactionId": "9f2c1a8e-4b70-4d31-9c55-6f2e0b1d7a44", + "messageId": "7d41b9e0-52a6-4c18-8b73-1e9f0a4c6d22", + "timestamp": "2026-08-26T06:12:01.330Z" }, + "message": { "contract": { "commitments": [{ + "status": { "descriptor": { "code": "DRAFT", "name": "Draft" } }, + "resources": [{ + "id": "res:mausamgram:point-forecast", + "resourceAttributes": { + "@context": "https://schemas.openagrinet.global/schema/WeatherObservation/v0.1/context.jsonld", + "@type": "openagrinet:WeatherObservation", + "subjectCategories": ["Weather"], + "location": { "type": "Point", "coordinates": [73.7898, 19.9975] }, + "validity": { "startsAt": "2026-08-26", "endsAt": "2026-08-30" } + } + }], + "offer": { + "id": "offer:mausamgram:open-data", + "resourceIds": ["res:mausamgram:point-forecast"], + "provider": { "id": "mausamgram", + "descriptor": { "code": "IMD-NWP-01", "name": "IMD Mausamgram NWP" } } + } + }] } } +}` + +// providerResponse is Mausamgram's own shape, with the field names the old +// per-provider service read: fcstdayN carrying date, rain, tmin, tmax, rhmin, +// rhmax, wspd and a warning. Three days, not five, so the mapping is exercised +// against a provider that returned fewer than the maximum. +const providerResponse = `{ + "location": { "lat": 19.9975, "lon": 73.7898 }, + "fcstday1": { "date": "2026-08-26", "rain": 12.4, "tmin": 22.1, "tmax": 30.6, + "rhmin": 55, "rhmax": 92, "wspd": 4.2, + "weather_warning": "Heavy rainfall warning" }, + "fcstday2": { "date": "2026-08-27", "rain": 3.1, "tmin": 23.0, "tmax": 31.2, + "rhmin": 50, "rhmax": 88, "wspd": 3.4, + "cloud_message": "Partly cloudy" }, + "fcstday3": { "date": "2026-08-28", "tmin": 23.4, "tmax": 32.0 } +}` + +// serveMappings publishes the shipped mapping files over HTTP, the way the +// registry's references point at them. +func serveMappings(t *testing.T) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := os.ReadFile(filepath.Join(mappingsDir, filepath.Base(r.URL.Path))) + if err != nil { + t.Errorf("could not read the mapping %q: %v", r.URL.Path, err) + w.WriteHeader(http.StatusNotFound) + return + } + fmt.Fprint(w, string(body)) + })) +} + +// stubRegistry answers with the call plan the live registry holds for this +// capability. +type stubRegistry struct{ plan *model.ProviderRecord } + +func (s *stubRegistry) ProviderRecord(context.Context, string) (*model.ProviderRecord, error) { + return s.plan, nil +} + +// TestShippedMappingsServeARealSelect runs the published mappings end to end. +func TestShippedMappingsServeARealSelect(t *testing.T) { + mappings := serveMappings(t) + defer mappings.Close() + + var gotQuery string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.RawQuery + fmt.Fprint(w, providerResponse) + })) + defer upstream.Close() + + mapper, closeMapper, err := jsonmapper.New(context.Background(), &jsonmapper.Config{}) + if err != nil { + t.Fatalf("failed to build the mapper: %v", err) + } + defer closeMapper() + + registry := &stubRegistry{plan: &model.ProviderRecord{ + BindingKey: shippedBindingKey, + ParticipantID: "mausamgram", + CapabilityCode: "openagrinet:WeatherObservation", + BaseURL: upstream.URL, + Actions: map[string]model.ActionPlan{ + "select": {Method: http.MethodGet, Path: "/get-daily", + Mappings: mappings.URL + "/" + shippedMapping, TimeoutMs: 30000, RetryMax: 3}, + }, + }} + + step, closeStep, err := WeatherObservation.New(context.Background(), registry, mapper, + &WeatherObservation.Config{BindingKeys: []string{shippedBindingKey}}) + if err != nil { + t.Fatalf("failed to build the step: %v", err) + } + defer closeStep() + + stepCtx := &model.StepContext{Context: t.Context(), Body: []byte(selectRequest)} + if err := step.Run(stepCtx); err != nil { + t.Fatalf("Run() returned an unexpected error: %v", err) + } + + // --- the request reached the provider correctly ------------------------- + // The request half is empty, so these parameters are + // the point the step resolved, not something a mapping produced. + for _, want := range []string{"lat=19.9975", "lon=73.7898"} { + if !strings.Contains(gotQuery, want) { + t.Errorf("upstream query %q is missing %q", gotQuery, want) + } + } + + // --- the response mapping produced Beckn -------------------------------- + if len(stepCtx.ResponseBody) == 0 { + t.Fatal("the step produced no answer") + } + var answer map[string]any + if err := json.Unmarshal(stepCtx.ResponseBody, &answer); err != nil { + t.Fatalf("the answer is not JSON: %v\n%s", err, stepCtx.ResponseBody) + } + + beckncontext, _ := answer["context"].(map[string]any) + if beckncontext["action"] != "on_select" { + t.Errorf("action = %v, want on_select", beckncontext["action"]) + } + // The transaction has to survive the round trip, or the caller cannot match + // the answer to what it asked. + if beckncontext["transactionId"] != "9f2c1a8e-4b70-4d31-9c55-6f2e0b1d7a44" { + t.Errorf("transactionId = %v, want the one from the request", beckncontext["transactionId"]) + } + // A mapping transforms a payload; it does not assert who anyone is. The two + // Uri fields in particular are only whatever the caller sent -- in a + // deployed stack a container-internal address -- so echoing them would + // republish another party's routing details as ours. The adapter signs what + // it answers with instead, and that signature is what carries identity. + for _, field := range []string{"bapId", "bapUri", "bppId", "bppUri"} { + if _, present := beckncontext[field]; present { + t.Errorf("response context carries %q; a mapping must not assert identity", field) + } + } + + commitment := firstCommitment(t, answer) + status, _ := commitment["status"].(map[string]any) + descriptor, _ := status["descriptor"].(map[string]any) + // DRAFT rather than QUOTED: the Beckn v2 status enum is DRAFT, ACTIVE and + // CLOSED, so QUOTED was refused by base schema validation. A quote is a + // draft commitment -- nothing is committed until init and confirm. + if descriptor["code"] != "DRAFT" { + t.Errorf("status = %v, want DRAFT -- QUOTED is not in the spec's enum", descriptor["code"]) + } + if commitment["offer"] == nil { + t.Error("the quoted commitment carries no offer") + } + + // One resource per forecast day, each with its own id. That is what the + // openagrinet:WeatherObservation pack describes -- every one of its examples + // carries a single validity and a flat parameters array, so a period is a + // resource and there is no form for several in one. + // + // The ids are new: the consumer selected an abstract point forecast and gets + // back the concrete days that answer it. Which means the offer's references + // have to be rewritten, because the offer is echoed from the request and its + // resourceIds still name the id that was asked for. Leaving them was the + // dangling reference this file used to carry. + resources, _ := commitment["resources"].([]any) + if len(resources) != 3 { + t.Fatalf("got %d resources, want 3 -- one per day the provider answered with", len(resources)) + } + + returned := make([]string, 0, len(resources)) + for _, entry := range resources { + resource, _ := entry.(map[string]any) + id, _ := resource["id"].(string) + if !strings.HasPrefix(id, "res:mausamgram:forecast:") { + t.Errorf("resource id = %q, want one derived from the forecast date", id) + } + // Required by Commitment.resources in the spec even though the spec + // defines no quantity property -- a consumer that validates refuses an + // answer without it. + if _, present := resource["quantity"]; !present { + t.Errorf("resource %s carries no quantity; the spec requires one on every commitment resource", id) + } + returned = append(returned, id) + } + + // The offer must reference the resources actually returned, not the one that + // was asked for. This is the assertion that fails the moment the offer is + // echoed unchanged. + offer, _ := commitment["offer"].(map[string]any) + referenced, _ := offer["resourceIds"].([]any) + if len(referenced) != len(returned) { + t.Fatalf("offer.resourceIds has %d entries, want %d -- one per resource returned", + len(referenced), len(returned)) + } + for _, reference := range referenced { + if !slices.Contains(returned, reference.(string)) { + t.Errorf("offer references %v, which is not among the resources returned", reference) + } + } + // And the descriptor the request offered is still there: only the references + // are rewritten, not the offer. + if offer["id"] != "offer:mausamgram:open-data" { + t.Errorf("offer id = %v, want the one the request offered", offer["id"]) + } + + // --- the WeatherObservation schema pack, Direct mode --------------------- + // openagrinet:WeatherObservation v0.1 requires all five of these when + // informationMode is Direct, and each resource is one Direct observation. + first, _ := resources[0].(map[string]any) + attributes, _ := first["resourceAttributes"].(map[string]any) + for _, f := range []struct{ key, want string }{ + {"@type", "openagrinet:WeatherObservation"}, + {"informationMode", "Direct"}, + {"observationType", "Forecast"}, + } { + if attributes[f.key] != f.want { + t.Errorf("%s = %v, want %v", f.key, attributes[f.key], f.want) + } + } + for _, required := range []string{"source", "location", "generatedAt", "validity", "parameters"} { + if attributes[required] == nil { + t.Errorf("resourceAttributes carries no %q", required) + } + } + + // GeoJSON order, and the provider's own echo of the point: the mapping reads + // response.location rather than anything the step resolved. + location, _ := attributes["location"].(map[string]any) + coordinates, _ := location["coordinates"].([]any) + if len(coordinates) != 2 || coordinates[0] != 73.7898 || coordinates[1] != 19.9975 { + t.Errorf("coordinates = %v, want [73.7898, 19.9975] in GeoJSON order", coordinates) + } + + // This resource covers one day, so its validity opens and closes on it. + validity, _ := attributes["validity"].(map[string]any) + if validity["startsAt"] != "2026-08-26" || validity["endsAt"] != "2026-08-26" { + t.Errorf("validity = %v, want the single day this resource reports", validity) + } + + parameters, _ := attributes["parameters"].([]any) + if len(parameters) != 7 { + t.Errorf("got %d parameters, want 7 for a fully-reported day with a warning", len(parameters)) + } + assertParameter(t, parameters, "Rainfall", "Total", "mm", 12.4) + assertParameter(t, parameters, "Temperature", "Minimum", "Cel", 22.1) + assertParameter(t, parameters, "WindSpeed", "Average", "m/s", 4.2) + + // A warning is a parameter, not a field of its own: the pack has no advisory + // property but does have an Alert parameter, and unit "1" is what it + // prescribes for a value that has no unit. + assertAlert(t, parameters, "Heavy rainfall warning") + + // --- a day the provider reported only partially -------------------------- + // Readings it did not take are absent, not present and empty: a consumer + // must be able to tell "no rainfall recorded" from "zero rainfall". A day + // with no warning carries no Alert parameter at all. + third, _ := resources[2].(map[string]any) + thirdAttributes, _ := third["resourceAttributes"].(map[string]any) + thirdParameters, _ := thirdAttributes["parameters"].([]any) + if len(thirdParameters) != 2 { + t.Errorf("got %d parameters for a partly-reported day, want only the 2 taken", len(thirdParameters)) + } + for _, entry := range thirdParameters { + if p, _ := entry.(map[string]any); p["parameter"] == "Alert" { + t.Error("a day the provider gave no warning for must carry no Alert parameter") + } + } +} + +// assertAlert finds the Alert parameter and checks its value and unit. +func assertAlert(t *testing.T, parameters []any, want string) { + t.Helper() + for _, entry := range parameters { + p, _ := entry.(map[string]any) + if p["parameter"] != "Alert" { + continue + } + if p["value"] != want { + t.Errorf("Alert value = %v, want %q", p["value"], want) + } + if p["unit"] != "1" { + t.Errorf("Alert unit = %v, want \"1\" -- the pack's code for a unitless value", p["unit"]) + } + return + } + t.Errorf("no Alert parameter; want one carrying %q", want) +} + +// The shipped file's request half extracts what the provider is asked for. That +// is the point of it living in the mapping: when this provider wants another +// parameter -- a date range, say -- it is an edit here and nothing else. +func TestShippedMappingsExtractTheQueryFromThePayload(t *testing.T) { + mappings := serveMappings(t) + defer mappings.Close() + + mapper, closeMapper, err := jsonmapper.New(context.Background(), &jsonmapper.Config{}) + if err != nil { + t.Fatalf("failed to build the mapper: %v", err) + } + defer closeMapper() + + var beckn any + if err := json.Unmarshal([]byte(selectRequest), &beckn); err != nil { + t.Fatalf("failed to decode the request: %v", err) + } + + got, err := mapper.Transform(context.Background(), mappings.URL+"/"+shippedMapping, + definition.DirectionRequest, map[string]any{"beckn": beckn}) + if err != nil { + t.Fatalf("the request half returned an unexpected error: %v", err) + } + + var query map[string]any + if err := json.Unmarshal(got, &query); err != nil { + t.Fatalf("the request half produced something that is not an object: %v", err) + } + + // GeoJSON is [lon, lat]. Reading them the other way round yields a point in + // the wrong hemisphere that is still a valid request. + if query["lat"] != 19.9975 { + t.Errorf("lat = %v, want 19.9975 taken from coordinates[1]", query["lat"]) + } + if query["lon"] != 73.7898 { + t.Errorf("lon = %v, want 73.7898 taken from coordinates[0]", query["lon"]) + } +} + +// The shipped mapping's own preconditions, against the published file. This is +// where "which geometries does this capability serve" is now answered -- in +// configuration, not in Go. +func TestShippedMappingsPreconditions(t *testing.T) { + mappings := serveMappings(t) + defer mappings.Close() + + mapper, closeMapper, err := jsonmapper.New(context.Background(), &jsonmapper.Config{}) + if err != nil { + t.Fatalf("failed to build the mapper: %v", err) + } + defer closeMapper() + ref := mappings.URL + "/" + shippedMapping + + payload := func(t *testing.T, geometry string) map[string]any { + t.Helper() + location := "" + if geometry != "" { + location = `"location": ` + geometry + `,` + } + body := `{"context":{"action":"select"},"message":{"contract":{"commitments":[{"resources":[{"resourceAttributes":{` + + location + `"@type":"openagrinet:WeatherObservation"}}]}]}}}` + var beckn any + if err := json.Unmarshal([]byte(body), &beckn); err != nil { + t.Fatalf("failed to build the payload: %v", err) + } + return map[string]any{"beckn": beckn} + } + + t.Run("a Point is served", func(t *testing.T) { + if err := mapper.Verify(context.Background(), ref, + payload(t, `{"type":"Point","coordinates":[73.7898,19.9975]}`)); err != nil { + t.Errorf("a Point must be served: %v", err) + } + }) + + for _, tc := range []struct{ name, geometry string }{ + {"a polygon", `{"type":"Polygon","coordinates":[[[73.0,19.0],[74.0,19.0],[74.0,20.0],[73.0,19.0]]]}`}, + {"a line string", `{"type":"LineString","coordinates":[[73.0,19.0],[74.0,20.0]]}`}, + {"several points", `{"type":"MultiPoint","coordinates":[[73.7898,19.9975]]}`}, + {"no location at all", ``}, + } { + t.Run(tc.name+" is refused", func(t *testing.T) { + err := mapper.Verify(context.Background(), ref, payload(t, tc.geometry)) + if err == nil { + t.Fatalf("expected %s to be refused", tc.name) + } + // The message is the mapping's, and has to name what is needed. + if !strings.Contains(err.Error(), "Point") { + t.Errorf("error %q should say a Point is what this capability needs", err) + } + }) + } +} + +// How many days the provider answers with is the provider's business, not the +// mapping's. It returns fcstday1..fcstdayN and N is whatever the forecast ran +// to, so a mapping naming five would truncate a ten-day answer and pad a +// three-day one. +// +// The ordering matters as much as the count: the keys sort lexically as +// fcstday1, fcstday10, fcstday2, so the mapping sorts on the numeric suffix. A +// ten-day forecast delivered in that order would be wrong in a way nothing +// downstream could detect. +func TestShippedMappingsTakeHoweverManyDaysTheProviderSent(t *testing.T) { + mappings := serveMappings(t) + defer mappings.Close() + + mapper, closeMapper, err := jsonmapper.New(context.Background(), &jsonmapper.Config{}) + if err != nil { + t.Fatalf("failed to build the mapper: %v", err) + } + defer closeMapper() + + var beckn any + if err := json.Unmarshal([]byte(selectRequest), &beckn); err != nil { + t.Fatalf("failed to decode the request: %v", err) + } + + for _, days := range []int{1, 3, 10} { + t.Run(fmt.Sprintf("%d days", days), func(t *testing.T) { + provider := map[string]any{"location": map[string]any{"lat": 19.9975, "lon": 73.7898}} + for i := 1; i <= days; i++ { + provider[fmt.Sprintf("fcstday%d", i)] = map[string]any{ + "date": fmt.Sprintf("2026-09-%02d", i), + "rain": float64(i), + } + } + + got, err := mapper.Transform(context.Background(), mappings.URL+"/"+shippedMapping, + definition.DirectionResponse, + map[string]any{"beckn": beckn, "response": provider}) + if err != nil { + t.Fatalf("Transform() returned an unexpected error: %v", err) + } + + var answer map[string]any + if err := json.Unmarshal(got, &answer); err != nil { + t.Fatalf("failed to decode the answer: %v", err) + } + commitment := firstCommitment(t, answer) + resources, _ := commitment["resources"].([]any) + + if len(resources) != days { + t.Fatalf("got %d resources, want %d -- the mapping is not reading the provider's own count", + len(resources), days) + } + + // In the provider's order, not the keys' lexical order. + for i, entry := range resources { + attributes := entry.(map[string]any)["resourceAttributes"].(map[string]any) + validity := attributes["validity"].(map[string]any) + want := fmt.Sprintf("2026-09-%02d", i+1) + if validity["startsAt"] != want { + t.Errorf("resource %d covers %v, want %s -- days are out of order", + i, validity["startsAt"], want) + } + } + + // However many resources there are, the offer references all of them. + offer, _ := commitment["offer"].(map[string]any) + referenced, _ := offer["resourceIds"].([]any) + if len(referenced) != days { + t.Errorf("offer references %d resources, want %d", len(referenced), days) + } + }) + } +} + +func firstCommitment(t *testing.T, answer map[string]any) map[string]any { + t.Helper() + message, _ := answer["message"].(map[string]any) + contract, _ := message["contract"].(map[string]any) + commitments, _ := contract["commitments"].([]any) + if len(commitments) == 0 { + t.Fatalf("the answer carries no commitments: %v", answer) + } + commitment, _ := commitments[0].(map[string]any) + return commitment +} + +func assertParameter(t *testing.T, parameters []any, name, aggregation, unit string, value float64) { + t.Helper() + for _, raw := range parameters { + parameter, _ := raw.(map[string]any) + if parameter["parameter"] == name && parameter["aggregation"] == aggregation { + if parameter["unit"] != unit { + t.Errorf("%s/%s unit = %v, want %v", name, aggregation, parameter["unit"], unit) + } + if parameter["value"] != value { + t.Errorf("%s/%s value = %v, want %v", name, aggregation, parameter["value"], value) + } + return + } + } + t.Errorf("no %s/%s parameter in %v", name, aggregation, parameters) +} diff --git a/pkg/plugin/implementation/WeatherObservation/prerequisites.go b/pkg/plugin/implementation/WeatherObservation/prerequisites.go new file mode 100644 index 00000000..3492d577 --- /dev/null +++ b/pkg/plugin/implementation/WeatherObservation/prerequisites.go @@ -0,0 +1,24 @@ +package WeatherObservation + +import "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/upstream" + +// prerequisites is what a weather capability needs that its payload does not +// carry, keyed by binding key. +// +// Empty, and that is the point: every weather capability so far is served by +// reading the payload, which the mapping does. An entry is needed only for real +// I/O -- a station id from a spatial lookup, a session token from an exchange -- +// because no expression language should be able to do those. +// +// Adding one is a function and a line here. Nothing else in the package moves, +// and capabilities that need nothing are untouched. +// +// Example, when a provider needs a station id: +// +// "imd-city|openagrinet:WeatherObservation": resolveStation, +// +// whose result the mapping then reads: +// +// request: | +// { "station": _local.stationId } +var prerequisites = upstream.Prerequisites{} diff --git a/pkg/plugin/implementation/catalogpublisher/handler_test.go b/pkg/plugin/implementation/catalogpublisher/handler_test.go index ee98707c..334a3485 100644 --- a/pkg/plugin/implementation/catalogpublisher/handler_test.go +++ b/pkg/plugin/implementation/catalogpublisher/handler_test.go @@ -145,6 +145,14 @@ func (m *catalogPublishTestManager) Registry(context.Context, definition.Cache, func (m *catalogPublishTestManager) KeyManager(context.Context, definition.RegistryLookup, *plugin.Config) (definition.KeyManager, error) { return fakeHandlerKeyManager{}, nil } +func (m *catalogPublishTestManager) Mapper(context.Context, *plugin.Config) (definition.Mapper, error) { + return nil, nil +} + +func (m *catalogPublishTestManager) ProviderStep(context.Context, definition.ProviderRecordLookup, definition.Mapper, *plugin.Config) (definition.Step, error) { + return nil, nil +} + func (m *catalogPublishTestManager) CatalogBlobStore(context.Context, *plugin.Config) (definition.CatalogBlobStore, error) { return fakeHandlerCatalogBlobStore{}, nil } diff --git a/pkg/plugin/implementation/internal/capabilitybinding/capabilitybinding.go b/pkg/plugin/implementation/internal/capabilitybinding/capabilitybinding.go new file mode 100644 index 00000000..e72b1dd7 --- /dev/null +++ b/pkg/plugin/implementation/internal/capabilitybinding/capabilitybinding.go @@ -0,0 +1,111 @@ +// Package capabilitybinding derives the capability binding a Beckn request is asking +// for, so a provider step can tell whether the request is its work and, if it +// is, which registry row describes the call. +// +// It is shared by every provider step rather than living in one, because the +// binding is a property of the network's payloads and not of any provider. +package capabilitybinding + +import ( + "encoding/json" + "errors" + "fmt" + "strings" +) + +// separator joins a binding key's two halves. +const separator = "|" + +// ErrNoBinding reports a payload that names no capability binding. It is not a +// fault: a request for something else entirely reaches a provider step too, and +// the step's answer is to do nothing. +var ErrNoBinding = errors.New("capabilitybinding: payload names no capability binding") + +// Binding identifies one provider capability. +type Binding struct { + ParticipantID string + CapabilityCode string +} + +// Key renders the binding in the form the registry indexes on. +func (b Binding) Key() string { + return b.ParticipantID + separator + b.CapabilityCode +} + +// From derives the capability binding a payload is asking for. +// +// Returns ErrNoBinding when the payload names no provider or no type, which is +// the ordinary case for a request a provider step is not meant to serve. +// +// A payload carrying more than one distinct provider or type is refused rather +// than resolved to its first: the two halves index one registry row describing +// one upstream call, so a request spanning several is asking for something this +// design cannot express. Guessing would silently serve part of it. +// +// Where the halves live is BecknV2 unless a deployment says otherwise -- see +// Paths for why that is a default and not a setting. +func From(paths Paths, body []byte) (Binding, error) { + var payload any + if err := json.Unmarshal(body, &payload); err != nil { + return Binding{}, fmt.Errorf("capabilitybinding: payload could not be read: %w", err) + } + + // Before distinctness: N commitments naming the SAME provider and type + // collapse to one binding key, so they would resolve here without + // complaint -- and then the mapping reads commitments[0] and the rest are + // dropped, leaving the caller a confident, signed, spec-valid answer to + // part of what it asked. One request maps to one call, so several is a + // request this design cannot express and is refused rather than halved. + // Counted at the array, not over the values it yields. valuesAt returns + // resolved strings and walk drops a leaf that is absent or is not a + // string, so two commitments where one carries no provider id produced one + // value -- which read as one commitment, passed this guard, and left the + // mapping to answer commitments[0] and drop the other. Exactly the outcome + // the paragraph above says is refused. + if commitments := countAt(payload, paths.ProviderID); commitments > 1 { + return Binding{}, fmt.Errorf( + "capabilitybinding: payload carries %d commitments; one request maps to one call, "+ + "so send them separately rather than have all but the first dropped", + commitments) + } + + providers := distinct(valuesAt(payload, paths.ProviderID)) + types := distinct(valuesAt(payload, paths.CapabilityCode)) + + if len(providers) == 0 || len(types) == 0 { + return Binding{}, ErrNoBinding + } + if len(providers) > 1 { + return Binding{}, fmt.Errorf("capabilitybinding: payload names %d providers (%s); one request maps to one call", + len(providers), strings.Join(providers, ", ")) + } + if len(types) > 1 { + return Binding{}, fmt.Errorf("capabilitybinding: payload names %d resource types (%s); one request maps to one call", + len(types), strings.Join(types, ", ")) + } + + return Binding{ParticipantID: providers[0], CapabilityCode: types[0]}, nil +} + +// distinct drops blanks and repeats, keeping the order they were found in so a +// refusal names them the way the payload did. +func distinct(values []string) []string { + var out []string + for _, value := range values { + out = appendDistinct(out, value) + } + return out +} + +// appendDistinct adds value if it is neither empty nor already present. +func appendDistinct(values []string, value string) []string { + if value == "" { + return values + } + for _, existing := range values { + if existing == value { + return values + } + } + return append(values, value) +} diff --git a/pkg/plugin/implementation/internal/capabilitybinding/capabilitybinding_test.go b/pkg/plugin/implementation/internal/capabilitybinding/capabilitybinding_test.go new file mode 100644 index 00000000..43213053 --- /dev/null +++ b/pkg/plugin/implementation/internal/capabilitybinding/capabilitybinding_test.go @@ -0,0 +1,432 @@ +package capabilitybinding + +import ( + "encoding/json" + "errors" + "strings" + "testing" +) + +// realSelectPayload is a verbatim /select request captured from the network +// on 29 Aug 2026. It is the reason this package reads through contract and +// commitments rather than off message directly: the design notes showed the +// shallower message.offer.provider.id, and the wire does not. +const realSelectPayload = `{ + "context": { "version": "2.0.0", "action": "select", + "networkId": "da.gov.in/vistaar", + "bapId": "seeker-network-vistaar.da.gov.in", + "bppId": "provider-network-vistaar.da.gov.in", + "transactionId": "9f2c1a8e-4b70-4d31-9c55-6f2e0b1d7a44", + "messageId": "7d41b9e0-52a6-4c18-8b73-1e9f0a4c6d22", + "timestamp": "2026-08-26T06:12:01.330Z" }, + "message": { "contract": { "commitments": [{ + "status": { "descriptor": { "code": "DRAFT", "name": "Draft" } }, + "resources": [{ + "id": "res:mausamgram:point-forecast", + "resourceAttributes": { + "@context": "https://schemas.openagrinet.global/schema/WeatherObservation/v0.1/context.jsonld", + "@type": "openagrinet:WeatherObservation", + "subjectCategories": ["Weather"], + "location": { "type": "Point", "coordinates": [73.7898, 19.9975] }, + "validity": { "startsAt": "2026-08-26", "endsAt": "2026-08-30" } + } + }], + "offer": { + "id": "offer:mausamgram:open-data", + "resourceIds": ["res:mausamgram:point-forecast"], + "provider": { "id": "mausamgram", + "descriptor": { "code": "IMD-NWP-01", "name": "IMD Mausamgram NWP" } } + } + }] } } +}` + +func TestFromReadsARealSelectPayload(t *testing.T) { + t.Parallel() + + got, err := From(BecknV2, []byte(realSelectPayload)) + if err != nil { + t.Fatalf("From() returned an unexpected error: %v", err) + } + if got.ParticipantID != "mausamgram" { + t.Errorf("participant = %q, want mausamgram", got.ParticipantID) + } + if got.CapabilityCode != "openagrinet:WeatherObservation" { + t.Errorf("capability = %q, want openagrinet:WeatherObservation", got.CapabilityCode) + } + if want := "mausamgram|openagrinet:WeatherObservation"; got.Key() != want { + t.Errorf("key = %q, want %q", got.Key(), want) + } +} + +// A payload that names no capability is the ordinary case for a request a +// provider step is not meant to serve, so it is reported as a sentinel a caller +// can recognise rather than as a fault. +func TestFromReportsAPayloadWithNoBinding(t *testing.T) { + t.Parallel() + + testCases := []struct{ name, body string }{ + {"an empty object", `{}`}, + {"no message", `{"context":{"action":"select"}}`}, + {"no contract", `{"message":{}}`}, + {"no commitments", `{"message":{"contract":{}}}`}, + {"an empty commitments array", `{"message":{"contract":{"commitments":[]}}}`}, + {"a commitment naming no provider", `{"message":{"contract":{"commitments":[{"resources":[{"resourceAttributes":{"@type":"t"}}]}]}}}`}, + {"a commitment with no resources", `{"message":{"contract":{"commitments":[{"offer":{"provider":{"id":"p"}}}]}}}`}, + {"a resource with no type", `{"message":{"contract":{"commitments":[{"offer":{"provider":{"id":"p"}},"resources":[{"resourceAttributes":{}}]}]}}}`}, + {"an empty provider id", `{"message":{"contract":{"commitments":[{"offer":{"provider":{"id":""}},"resources":[{"resourceAttributes":{"@type":"t"}}]}]}}}`}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if _, err := From(BecknV2, []byte(tc.body)); !errors.Is(err, ErrNoBinding) { + t.Errorf("expected ErrNoBinding, got %v", err) + } + }) + } +} + +// Both levels are arrays, so a payload can carry several. One binding key +// describes one upstream call, so a request spanning more than one is refused +// rather than silently resolved to whichever came first. +func TestFromRefusesAnAmbiguousPayload(t *testing.T) { + t.Parallel() + + testCases := []struct{ name, body, wants string }{ + { + name: "two providers across commitments", + body: `{"message":{"contract":{"commitments":[ + {"offer":{"provider":{"id":"one"}},"resources":[{"resourceAttributes":{"@type":"t"}}]}, + {"offer":{"provider":{"id":"two"}},"resources":[{"resourceAttributes":{"@type":"t"}}]}]}}}`, + // Two providers means two commitments under the Beckn v2 paths, so + // the commitment check refuses it first -- and its advice is the + // more useful of the two. The provider count still guards a + // deployment whose overridden path yields several within one. + wants: "2 commitments", + }, + { + name: "two types within one commitment", + body: `{"message":{"contract":{"commitments":[ + {"offer":{"provider":{"id":"one"}},"resources":[ + {"resourceAttributes":{"@type":"a"}}, + {"resourceAttributes":{"@type":"b"}}]}]}}}`, + wants: "2 resource types", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + _, err := From(BecknV2, []byte(tc.body)) + if err == nil { + t.Fatal("expected an ambiguous payload to be refused") + } + if errors.Is(err, ErrNoBinding) { + t.Error("ambiguity is not absence: it must not report ErrNoBinding") + } + if !strings.Contains(err.Error(), tc.wants) { + t.Errorf("error %q should say %q", err, tc.wants) + } + }) + } +} + +func TestFromReportsAnUnreadablePayload(t *testing.T) { + t.Parallel() + + _, err := From(BecknV2, []byte(`{"message":`)) + if err == nil { + t.Fatal("expected unreadable JSON to be reported") + } + if errors.Is(err, ErrNoBinding) { + t.Error("a broken payload is not an absent binding") + } +} + +// --- where the binding key lives --------------------------------------------- +// +// The two halves of a binding key sit at a fixed place in a Beckn v2 payload. +// That is a NETWORK convention: every participant must agree, or two adapters +// disagree about what a binding key even is and requests silently fail to match. +// +// So it is a default, not a setting. BecknV2 is what every deployment uses. An +// override exists only so a spec change can be tracked without waiting for a +// release, and it has to be typed deliberately -- absent means correct. + +func TestBecknV2IsTheDefault(t *testing.T) { + t.Parallel() + + if BecknV2.ProviderID == "" || BecknV2.CapabilityCode == "" { + t.Fatal("the default paths must be set") + } + got, err := From(BecknV2, []byte(realSelectPayload)) + if err != nil { + t.Fatalf("From() returned an unexpected error: %v", err) + } + if got.ParticipantID != "mausamgram" || got.CapabilityCode != "openagrinet:WeatherObservation" { + t.Errorf("binding = %+v, want the Beckn v2 convention's answer", got) + } +} + +// An override reads the halves from somewhere else entirely, which is what makes +// a spec change survivable without a build. +func TestFromReadsAnOverriddenPath(t *testing.T) { + t.Parallel() + + body := `{"who":{"provider":"agmarknet"},"what":[{"type":"openagrinet:MandiPrice"}]}` + got, err := From(Paths{ + ProviderID: "who.provider", + CapabilityCode: "what[].type", + }, []byte(body)) + if err != nil { + t.Fatalf("From() returned an unexpected error: %v", err) + } + if got.Key() != "agmarknet|openagrinet:MandiPrice" { + t.Errorf("binding key = %q, want it read from the overridden paths", got.Key()) + } +} + +// A path that matches nothing is a request this step is not meant to serve -- +// the ordinary case, and the same answer the typed walk gave. +func TestFromReportsNoBindingWhenAPathMatchesNothing(t *testing.T) { + t.Parallel() + + _, err := From(Paths{ProviderID: "nowhere.at.all", CapabilityCode: "what[].type"}, + []byte(`{"what":[{"type":"x"}]}`)) + if !errors.Is(err, ErrNoBinding) { + t.Errorf("expected ErrNoBinding, got %v", err) + } +} + +// Several distinct values stays a refusal whatever path found them: one request +// maps to one call, and guessing which would serve part of it silently. +func TestFromStillRefusesSeveralValuesUnderAnOverride(t *testing.T) { + t.Parallel() + + _, err := From(Paths{ProviderID: "who[].provider", CapabilityCode: "what[].type"}, + []byte(`{"who":[{"provider":"a"},{"provider":"b"}],"what":[{"type":"x"}]}`)) + if err == nil || errors.Is(err, ErrNoBinding) { + t.Errorf("expected a refusal naming both providers, got %v", err) + } +} + +// The walk is deliberately small: dotted segments, and [] to flatten an array. +// No wildcards, no filters, no indices -- it is an escape hatch, not a query +// language, and every one of those would be a way to write something subtly +// wrong in config nobody reviews. +func TestPathWalk(t *testing.T) { + t.Parallel() + + doc := map[string]any{ + "a": map[string]any{"b": "flat"}, + "list": []any{ + map[string]any{"v": "one"}, + map[string]any{"v": "two"}, + }, + "nested": []any{ + map[string]any{"inner": []any{map[string]any{"v": "deep"}}}, + }, + "number": 42, + } + + testCases := []struct { + name string + path string + want []string + }{ + {"a flat field", "a.b", []string{"flat"}}, + {"through an array", "list[].v", []string{"one", "two"}}, + {"through two arrays", "nested[].inner[].v", []string{"deep"}}, + {"a path that is not there", "a.missing", nil}, + {"a value that is not a string", "number", nil}, + {"an array not marked", "list.v", nil}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := valuesAt(doc, tc.path) + if len(got) != len(tc.want) { + t.Fatalf("valuesAt(%q) = %v, want %v", tc.path, got, tc.want) + } + for i, want := range tc.want { + if got[i] != want { + t.Errorf("valuesAt(%q)[%d] = %q, want %q", tc.path, i, got[i], want) + } + } + }) + } +} + +// An override that names no path at all would match nothing and make every +// request unservable, silently. Refused where it is configured instead. +func TestPathsValidate(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + paths Paths + }{ + {"no provider path", Paths{CapabilityCode: "a.b"}}, + {"no capability path", Paths{ProviderID: "a.b"}}, + {"a blank segment", Paths{ProviderID: "a..b", CapabilityCode: "a.b"}}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if err := tc.paths.Validate(); err == nil { + t.Error("expected an unusable path pair to be refused") + } + }) + } + + if err := BecknV2.Validate(); err != nil { + t.Errorf("the default paths must validate: %v", err) + } +} + +// Several commitments naming the same provider and capability used to resolve +// to one binding key without complaint -- and then the mapping read +// commitments[0] and the rest were dropped, leaving the caller a confident, +// signed, spec-valid answer to part of what it asked. One request maps to one +// call, so it is refused. +func TestFromRefusesSeveralCommitments(t *testing.T) { + t.Parallel() + + body := `{"message":{"contract":{"commitments":[ + {"offer":{"provider":{"id":"mausamgram"}}, + "resources":[{"resourceAttributes":{"@type":"openagrinet:WeatherObservation"}}]}, + {"offer":{"provider":{"id":"mausamgram"}}, + "resources":[{"resourceAttributes":{"@type":"openagrinet:WeatherObservation"}}]} + ]}}}` + + _, err := From(BecknV2, []byte(body)) + if err == nil { + t.Fatal("expected two commitments to be refused rather than halved") + } + if errors.Is(err, ErrNoBinding) { + t.Error("this is not an absent binding; it is one request asking for two calls") + } + if !strings.Contains(err.Error(), "2 commitments") { + t.Errorf("error %q should say how many were sent", err) + } + + // One commitment still resolves, obviously. + single := `{"message":{"contract":{"commitments":[ + {"offer":{"provider":{"id":"mausamgram"}}, + "resources":[{"resourceAttributes":{"@type":"openagrinet:WeatherObservation"}}]} + ]}}}` + binding, err := From(BecknV2, []byte(single)) + if err != nil { + t.Fatalf("one commitment must still resolve: %v", err) + } + if binding.Key() != "mausamgram|openagrinet:WeatherObservation" { + t.Errorf("binding key = %q", binding.Key()) + } +} + +// The guard counted resolved provider-id VALUES, and walk drops a leaf that is +// absent or is not a string -- so a payload whose second commitment carries no +// provider id yielded one value, read as one commitment, and passed. The +// mapping then answered commitments[0] and dropped the other, which is the +// confident, signed, partial answer the guard exists to prevent. +func TestFromRefusesSeveralCommitmentsEvenWhenOneDoesNotResolve(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + payload string + }{ + { + name: "the second commitment has no provider id at all", + payload: `{"message":{"contract":{"commitments":[ + {"offer":{"provider":{"id":"mausamgram"}}, + "resources":[{"resourceAttributes":{"@type":"openagrinet:WeatherObservation"}}]}, + {"offer":{"provider":{}}, + "resources":[{"resourceAttributes":{"@type":"openagrinet:WeatherObservation"}}]} + ]}}}`, + }, + { + name: "the second commitment's provider id is not a string", + payload: `{"message":{"contract":{"commitments":[ + {"offer":{"provider":{"id":"mausamgram"}}, + "resources":[{"resourceAttributes":{"@type":"openagrinet:WeatherObservation"}}]}, + {"offer":{"provider":{"id":42}}, + "resources":[{"resourceAttributes":{"@type":"openagrinet:WeatherObservation"}}]} + ]}}}`, + }, + { + name: "the second commitment has no offer", + payload: `{"message":{"contract":{"commitments":[ + {"offer":{"provider":{"id":"mausamgram"}}, + "resources":[{"resourceAttributes":{"@type":"openagrinet:WeatherObservation"}}]}, + {"resources":[{"resourceAttributes":{"@type":"openagrinet:WeatherObservation"}}]} + ]}}}`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := From(BecknV2, []byte(tt.payload)) + if err == nil { + t.Fatal("two commitments must be refused, not half answered") + } + if errors.Is(err, ErrNoBinding) { + t.Errorf("err = %v; this is a refusal, not a payload for another step", err) + } + if !strings.Contains(err.Error(), "2 commitments") { + t.Errorf("err = %v, want it to report both commitments", err) + } + }) + } +} + +// One commitment whose provider id does not resolve is not this step's work -- +// it must stay ErrNoBinding rather than becoming a refusal, so the next step +// in the pipeline still sees it. +func TestFromStillPassesThroughASingleUnresolvableCommitment(t *testing.T) { + t.Parallel() + + payload := `{"message":{"contract":{"commitments":[ + {"offer":{"provider":{}}, + "resources":[{"resourceAttributes":{"@type":"openagrinet:WeatherObservation"}}]} + ]}}}` + _, err := From(BecknV2, []byte(payload)) + if !errors.Is(err, ErrNoBinding) { + t.Errorf("err = %v, want ErrNoBinding so the payload passes through", err) + } +} + +func TestCountAt(t *testing.T) { + t.Parallel() + + const two = `{"message":{"contract":{"commitments":[{"a":1},{"b":2}]}}}` + tests := []struct { + name string + payload string + path string + want int + }{ + {"counts the array regardless of the leaf", two, BecknV2.ProviderID, 2}, + {"an absent path counts nothing", `{"message":{}}`, BecknV2.ProviderID, 0}, + {"a non-array at the marker counts nothing", + `{"message":{"contract":{"commitments":{"a":1}}}}`, BecknV2.ProviderID, 0}, + {"an empty array counts nothing", + `{"message":{"contract":{"commitments":[]}}}`, BecknV2.ProviderID, 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var payload any + if err := json.Unmarshal([]byte(tt.payload), &payload); err != nil { + t.Fatalf("bad test payload: %v", err) + } + if got := countAt(payload, tt.path); got != tt.want { + t.Errorf("countAt = %d, want %d", got, tt.want) + } + }) + } +} diff --git a/pkg/plugin/implementation/internal/capabilitybinding/paths.go b/pkg/plugin/implementation/internal/capabilitybinding/paths.go new file mode 100644 index 00000000..f1f89a2a --- /dev/null +++ b/pkg/plugin/implementation/internal/capabilitybinding/paths.go @@ -0,0 +1,129 @@ +package capabilitybinding + +import ( + "fmt" + "strings" +) + +// Paths says where the two halves of a binding key live in a payload. +// +// This is a NETWORK convention, not a deployment's preference: every participant +// has to agree, or two adapters disagree about what a binding key is and +// requests silently fail to match. So BecknV2 is the answer, and overriding is +// something an operator has to type deliberately -- absent means correct. +// +// The override exists for one situation: the spec moves a field and a deployment +// needs to track it without waiting for a release. It is deliberately not +// something to reach for otherwise. +type Paths struct { + ProviderID string + CapabilityCode string +} + +// BecknV2 is where core-v2.0.0-lts puts them. +var BecknV2 = Paths{ + ProviderID: "message.contract.commitments[].offer.provider.id", + CapabilityCode: "message.contract.commitments[].resources[].resourceAttributes.@type", +} + +// arrayMarker flattens an array at that segment. It is the only operator the +// walk understands. +const arrayMarker = "[]" + +// Validate refuses a pair that could never match, so a mistake surfaces where it +// was configured rather than as every request quietly going unserved. +func (p Paths) Validate() error { + for name, path := range map[string]string{ + "providerIdAt": p.ProviderID, + "capabilityCodeAt": p.CapabilityCode, + } { + if strings.TrimSpace(path) == "" { + return fmt.Errorf("capabilitybinding: %s is empty", name) + } + for _, segment := range strings.Split(path, ".") { + if strings.TrimSpace(strings.TrimSuffix(segment, arrayMarker)) == "" { + return fmt.Errorf("capabilitybinding: %s (%q) has a blank segment", name, path) + } + } + } + return nil +} + +// valuesAt collects every string the path reaches. +// +// The grammar is two things: segments separated by ".", and a "[]" suffix +// meaning "this is an array, look in each element". No wildcards, no filters, no +// indices. Each of those would be another way to write something subtly wrong in +// config nobody reviews, to buy an expressiveness a payload shape has never +// needed. +func valuesAt(node any, path string) []string { + return walk(node, strings.Split(path, ".")) +} + +// countAt reports how many elements the first array segment of path holds, +// whether or not the leaf beyond it resolves to anything. +// +// valuesAt cannot answer this. It returns resolved STRINGS, and walk drops a +// leaf that is missing or is not a string -- so two commitments where one +// carries no provider id yield one value, and a count of those values reads as +// one commitment. That is the difference between refusing a request this +// design cannot express and silently answering half of it. +func countAt(node any, path string) int { + for _, segment := range strings.Split(path, ".") { + fields, ok := node.(map[string]any) + if !ok { + return 0 + } + child, present := fields[strings.TrimSuffix(segment, arrayMarker)] + if !present { + return 0 + } + if strings.HasSuffix(segment, arrayMarker) { + elements, ok := child.([]any) + if !ok { + return 0 + } + return len(elements) + } + node = child + } + return 0 +} + +func walk(node any, segments []string) []string { + if len(segments) == 0 { + // The leaf. Only strings are binding-key material; a number or an + // object here means the path landed somewhere unintended. + if value, ok := node.(string); ok { + return []string{value} + } + return nil + } + + segment := segments[0] + rest := segments[1:] + + fields, ok := node.(map[string]any) + if !ok { + return nil + } + child, present := fields[strings.TrimSuffix(segment, arrayMarker)] + if !present { + return nil + } + + if !strings.HasSuffix(segment, arrayMarker) { + return walk(child, rest) + } + + // An array segment: every element contributes. + elements, ok := child.([]any) + if !ok { + return nil + } + var found []string + for _, element := range elements { + found = append(found, walk(element, rest)...) + } + return found +} diff --git a/pkg/plugin/implementation/internal/upstream/dispatch_test.go b/pkg/plugin/implementation/internal/upstream/dispatch_test.go new file mode 100644 index 00000000..09cceb3e --- /dev/null +++ b/pkg/plugin/implementation/internal/upstream/dispatch_test.go @@ -0,0 +1,113 @@ +package upstream_test + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/beckn-one/beckn-onix/pkg/model" + "github.com/beckn-one/beckn-onix/pkg/plugin/definition" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/upstream" +) + +// dispatchMappingRef stands in for the one reference an action carries. This +// test is about dispatch, so what is behind it never matters. +const dispatchMappingRef = "https://m.example.com/mausamgram/weather-observation.select.yaml" + +// dispatchRequest names a provider and a capability, which is all dispatch reads. +// Kept minimal on purpose: this test is about which step claims a request, not +// about what any of them would do with it. +const dispatchRequest = `{ + "context": { "version": "2.0.0", "action": "select" }, + "message": { "contract": { "commitments": [ { + "resources": [ { "resourceAttributes": { "@type": "openagrinet:WeatherObservation" } } ], + "offer": { "provider": { "id": "mausamgram" } } + } ] } } +}` + +// stubRegistry answers with one call plan, whatever is asked. This file's own, +// because it is an external test: it exercises the package exactly as the +// adapter does, through the exported surface and nothing else. +type stubRegistry struct{ plan *model.ProviderRecord } + +func (s *stubRegistry) ProviderRecord(context.Context, string) (*model.ProviderRecord, error) { + return s.plan, nil +} + +// fixedMapper returns canned results, so this test is about dispatch and +// nothing else. +type fixedMapper struct{ answer string } + +func (m fixedMapper) Verify(context.Context, string, any) error { return nil } + +func (m fixedMapper) Transform(_ context.Context, mappingRef string, _ definition.Direction, _ any) ([]byte, error) { + if strings.Contains(mappingRef, "request") { + return []byte(`{}`), nil + } + return []byte(m.answer), nil +} + +// Two provider steps in one pipeline, as a second provider would be added. +// Each must serve its own capability and leave the other's alone -- that is the +// whole dispatch mechanism, so it is worth a test rather than an assumption. +func TestTwoProviderStepsDispatchByBindingKey(t *testing.T) { + var calledA, calledB bool + providerA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calledA = true + fmt.Fprint(w, `{"from":"A"}`) + })) + defer providerA.Close() + providerB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calledB = true + fmt.Fprint(w, `{"from":"B"}`) + })) + defer providerB.Close() + + newProviderStep := func(t *testing.T, bindingKey, upstreamURL, answer string) definition.Step { + t.Helper() + plan := &model.ProviderRecord{ + BindingKey: bindingKey, + BaseURL: upstreamURL, + Actions: map[string]model.ActionPlan{ + "select": {Method: http.MethodGet, Path: "/x", Mappings: dispatchMappingRef, RetryMax: 1}, + }, + } + step, closer, err := upstream.New(context.Background(), + &stubRegistry{plan: plan}, + fixedMapper{answer: answer}, + nil, + &upstream.Config{BindingKeys: []string{bindingKey}}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = closer() }) + return step + } + + steps := []definition.Step{ + newProviderStep(t, "mausamgram|openagrinet:WeatherObservation", providerA.URL, `{"served":"A"}`), + newProviderStep(t, "agmarknet|openagrinet:MarketPrice", providerB.URL, `{"served":"B"}`), + } + + // A request for the FIRST capability, run through both steps in order, as a + // pipeline would. + ctx := &model.StepContext{Context: t.Context(), Body: []byte(dispatchRequest)} + for i, step := range steps { + if err := step.Run(ctx); err != nil { + t.Fatalf("step %d: %v", i, err) + } + } + + if !calledA { + t.Error("provider A was not called for its own capability") + } + if calledB { + t.Error("provider B was called for a capability that is not its own") + } + if got := string(ctx.ResponseBody); !strings.Contains(got, `"A"`) { + t.Errorf("answer = %s, want A's -- a later step overwrote it", got) + } +} diff --git a/pkg/plugin/implementation/internal/upstream/upstream.go b/pkg/plugin/implementation/internal/upstream/upstream.go new file mode 100644 index 00000000..2d70ea0d --- /dev/null +++ b/pkg/plugin/implementation/internal/upstream/upstream.go @@ -0,0 +1,1070 @@ +// Package upstream serves a Beckn capability by calling an ordinary API that has +// never heard of Beckn. +// +// "upstream" is the registry's own word for such an API -- a Participant of type +// upstream, as against a node that speaks Beckn. This package is the machinery +// for calling one: recognise the capability, resolve the call plan, translate +// out, call, translate back. +// +// It holds nothing about any provider or any domain. What varies per capability +// comes from the registry (endpoint, method, budget, which mapping) and from the +// mapping itself (what the payload must satisfy, what to send, what to return). +// A domain package wraps this, supplying only its name and whatever prerequisite +// work a mapping cannot express. +package upstream + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "slices" + "sort" + "strconv" + "strings" + "time" + + "github.com/beckn-one/beckn-onix/pkg/log" + "github.com/beckn-one/beckn-onix/pkg/model" + "github.com/beckn-one/beckn-onix/pkg/plugin/definition" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/capabilitybinding" +) + +// Defaults applied when the registry or the operator leaves a setting out. +const ( + // DefaultTimeout and DefaultRetryMax are the registry contract's defaults + // for an action that leaves timeoutMs or retryMax out. Zero retries is + // deliberate: a provider that failed is retried only where the operator + // said so, because a retry on a non-idempotent action is a second booking. + DefaultTimeout = 15 * time.Second + DefaultRetryMax = 0 + // DefaultMaxResponseBytes caps what is read from the provider. The response + // is mapped in memory, so an unbounded one is an unbounded allocation. + DefaultMaxResponseBytes = 4 << 20 // 4 MiB + + // MaxTimeout and MaxRetryMax bound what a registry row may ask for. + // + // Both come from DATA, not from this deployment's config, and neither is + // cheap: an attempt holds a goroutine and the inbound connection for its + // whole timeout, and http.Server's write timeout does not cancel the + // request context. So a row reading retryMax 1000, timeoutMs 60000 pins + // both for roughly seventeen hours, and a handful of such requests is the + // adapter. The registry is trusted to say where a provider is; it is not a + // reason to let one row decide how long this process is busy. + // + // Clamped rather than refused. A row that overreaches is a configuration + // mistake, and failing every request for that capability is a worse answer + // than serving it with a sane budget and saying so in the log. + MaxTimeout = 30 * time.Second + MaxRetryMax = 5 +) + +// Auth schemes this step can present upstream. Credentials themselves are never +// configured here or held in the registry -- config names the environment +// variable to read, so a secret reaches the process through its environment and +// nothing else. +const ( + // RetryBackoffBase is the first wait between attempts, doubling from there + // up to RetryBackoffMax. Short, because the retry budget comes from the + // registry and an operator setting 5 retries did not ask for seconds of + // latency -- only for the provider's brief unavailability to be ridden out. + RetryBackoffBase = 50 * time.Millisecond + RetryBackoffMax = 800 * time.Millisecond + + // redactedMarker stands in for a credential in anything logged or returned. + redactedMarker = "REDACTED" + + AuthSchemeNone = "none" + AuthSchemeBasic = "basic" + AuthSchemeHeader = "header" + // AuthSchemeQuery puts the credential in the query string, which some + // upstreams are built around whatever anyone thinks of it. It is the least + // safe of the four -- a query string is logged by proxies and appears in a + // transport error -- so the value is redacted from anything this package + // logs or returns. See redact. + AuthSchemeQuery = "query" +) + +// codeUpstreamUnavailable reports a provider that could not be reached or +// answered with a failure. It is not this adapter's fault and not the caller's. +const codeUpstreamUnavailable = "NET_DOWNSTREAM_UNAVAILABLE" + +// Prerequisites are the values a capability needs that its payload does not +// carry, keyed by binding key. +// +// A mapping cannot produce them: a station id comes from a spatial lookup, a +// session token from an exchange, a market code from a table. That is real I/O, +// and no expression language should be able to do it. +// +// Whatever a function returns is handed to the mapping as _local, so the mapping +// still decides what the provider is finally asked for. A capability with no +// entry needs nothing, which is the common case. +type Prerequisites map[string]func(context.Context, any) (map[string]any, error) + +// Config holds configuration parameters for the step. +type Config struct { + // BindingKeys are the capabilities this step answers to. A request for + // anything else passes through untouched. + // + // A list because a provider can serve more than one: the registry contract + // says a provider serving two capabilities is one Participant and two + // ProviderSchema rows. Configuring a second entry with the same plugin id + // instead would collide in the handler's id-keyed step map, and one + // capability would be lost with no error anywhere. + // + // What differs per capability -- the endpoint, the mapping, the budget -- + // comes from the registry, so one step serving several needs nothing else. + BindingKeys []string `yaml:"bindingKeys" json:"bindingKeys"` + + // ProviderIDAt and CapabilityCodeAt override where the two halves of a + // binding key sit in a payload. Absent means the Beckn v2 convention, which + // is what every deployment should be using. + // + // This is a network convention rather than a deployment's preference -- + // every participant must agree, or two adapters disagree about what a + // binding key is and requests silently fail to match. It is configurable + // only so that a spec change can be tracked without waiting for a release, + // and both must be given together. + ProviderIDAt string `yaml:"providerIdAt" json:"providerIdAt"` + CapabilityCodeAt string `yaml:"capabilityCodeAt" json:"capabilityCodeAt"` + + // AuthScheme is how credentials are presented upstream: none, basic or + // header. Providers differ here -- basic auth, a raw token header, a field + // in the body -- which is why it is configuration and not an assumption. + AuthScheme string `yaml:"authScheme" json:"authScheme"` + + // UsernameEnv and PasswordEnv name the environment variables holding basic + // credentials. They are variable NAMES, never the values. + UsernameEnv string `yaml:"usernameEnv" json:"usernameEnv"` + PasswordEnv string `yaml:"passwordEnv" json:"passwordEnv"` + + // HeaderName and HeaderValueEnv configure the header scheme: which header to + // set, and which environment variable holds its value. + HeaderName string `yaml:"headerName" json:"headerName"` + HeaderValueEnv string `yaml:"headerValueEnv" json:"headerValueEnv"` + + // QueryName and QueryValueEnv configure authScheme query: the parameter + // name to add, and the environment variable holding its value. Named the + // same way as the header pair, for the same reason -- the credential is + // never in this config, only the name of the variable carrying it. + QueryName string `yaml:"queryName" json:"queryName"` + QueryValueEnv string `yaml:"queryValueEnv" json:"queryValueEnv"` + + // MaxResponseBytes caps what is read from the provider. + MaxResponseBytes int64 `yaml:"maxResponseBytes" json:"maxResponseBytes"` +} + +// Step serves whatever capabilities a domain package configures it for. It is +// safe for concurrent use. +type Step struct { + config *Config + paths capabilitybinding.Paths + prerequisites Prerequisites + registry definition.ProviderRecordLookup + mapper definition.Mapper + httpClient *http.Client +} + +// New creates the step. +func New(ctx context.Context, registry definition.ProviderRecordLookup, mapper definition.Mapper, + prerequisites Prerequisites, cfg *Config) (*Step, func() error, error) { + if registry == nil { + return nil, nil, errors.New("upstream: a provider record lookup is required") + } + if mapper == nil { + return nil, nil, errors.New("upstream: a mapper is required") + } + if cfg == nil { + cfg = &Config{} + } + if err := applyDefaults(cfg); err != nil { + return nil, nil, err + } + + paths, err := bindingPaths(cfg) + if err != nil { + return nil, nil, err + } + + step := &Step{ + config: cfg, + paths: paths, + prerequisites: prerequisites, + registry: registry, + mapper: mapper, + // Timeout is set per request from the registry's own budget, so the + // client carries none of its own. + httpClient: &http.Client{}, + } + + closer := func() error { + log.Debugf(ctx, "Cleaning up upstream step resources") + step.httpClient.CloseIdleConnections() + return nil + } + + log.Infof(ctx, "Upstream step created for %s", strings.Join(cfg.BindingKeys, ", ")) + return step, closer, nil +} + +// bindingPaths resolves where this step reads a binding key from. +// +// Both halves or neither: overriding one and leaving the other on the default +// is a half-configured deployment that would match nothing, and it would do so +// silently on every request rather than once at startup. +func bindingPaths(cfg *Config) (capabilitybinding.Paths, error) { + if cfg.ProviderIDAt == "" && cfg.CapabilityCodeAt == "" { + return capabilitybinding.BecknV2, nil + } + if cfg.ProviderIDAt == "" { + return capabilitybinding.Paths{}, errors.New("upstream: capabilityCodeAt is set without providerIdAt") + } + if cfg.CapabilityCodeAt == "" { + return capabilitybinding.Paths{}, errors.New("upstream: providerIdAt is set without capabilityCodeAt") + } + paths := capabilitybinding.Paths{ProviderID: cfg.ProviderIDAt, CapabilityCode: cfg.CapabilityCodeAt} + if err := paths.Validate(); err != nil { + return capabilitybinding.Paths{}, err + } + return paths, nil +} + +// applyDefaults fills in what was left out and rejects what cannot be defaulted. +func applyDefaults(cfg *Config) error { + // No default. This package serves whatever a domain package configures it + // for, so a default would have to name one provider's capability -- wrong + // for every other domain built on it, and silently wrong rather than loudly. + if len(cfg.BindingKeys) == 0 { + return errors.New("upstream: bindingKeys is required: it is what this step answers to") + } + for _, key := range cfg.BindingKeys { + if strings.TrimSpace(key) == "" { + return errors.New("upstream: bindingKeys carries an empty entry") + } + } + if cfg.AuthScheme == "" { + cfg.AuthScheme = AuthSchemeNone + } + if cfg.MaxResponseBytes <= 0 { + cfg.MaxResponseBytes = DefaultMaxResponseBytes + } + + switch cfg.AuthScheme { + case AuthSchemeNone: + case AuthSchemeBasic: + if cfg.UsernameEnv == "" || cfg.PasswordEnv == "" { + return errors.New("upstream: authScheme basic requires usernameEnv and passwordEnv") + } + case AuthSchemeHeader: + if cfg.HeaderName == "" || cfg.HeaderValueEnv == "" { + return errors.New("upstream: authScheme header requires headerName and headerValueEnv") + } + case AuthSchemeQuery: + if cfg.QueryName == "" || cfg.QueryValueEnv == "" { + return errors.New("upstream: authScheme query requires queryName and queryValueEnv") + } + default: + return fmt.Errorf( + "upstream: unknown authScheme %q: must be none, basic, header or query", cfg.AuthScheme) + } + return nil +} + +// Run serves the request when it is for this step's capability, and does +// nothing when it is not. +// +// Doing nothing is the dispatch mechanism: several provider steps sit in one +// pipeline and each recognises its own work, so adding a provider is one more +// entry rather than a change to a routing table. +func (s *Step) Run(ctx *model.StepContext) error { + binding, err := capabilitybinding.From(s.paths, ctx.Body) + if errors.Is(err, capabilitybinding.ErrNoBinding) { + return nil + } + if err != nil { + // Everything From refuses is a statement about the payload: unreadable + // JSON, or a request naming more than one call. Unclassified it becomes + // a 500, which says this adapter broke and leaves the reason in a log + // the caller cannot read. + return model.NewBadReqErr("", err) + } + if !s.serves(binding.Key()) { + log.Debugf(ctx, "upstream: %s is not one of this step's capabilities, passing through", binding.Key()) + return nil + } + + plan, err := s.registry.ProviderRecord(ctx, binding.Key()) + if err != nil { + // A definite "no such binding" is the caller naming something that is + // not there, so 404 -- the same reasoning the no-route path uses to + // refuse an unrecognised capability rather than ACK it. A registry that + // could not be consulted is different and stays a 500: unclassified, + // because it is this adapter that failed. + if errors.Is(err, definition.ErrProviderRecordNotFound) { + // %w, not %v: the sentinel has to stay unwrappable, or anything + // upstream testing errors.Is against it silently stops matching. + return model.NewNotFoundErr("", fmt.Errorf( + "upstream: the registry publishes no active binding for %s: %w", binding.Key(), err)) + } + return fmt.Errorf("upstream: no call plan for %s: %w", binding.Key(), err) + } + + return s.serve(ctx, plan) +} + +// resolve runs whatever prerequisite work this capability needs, and returns the +// values for the mapping to read under _local. +// +// Empty rather than nil when there is nothing: a mapping referring to _local on +// a capability that resolves nothing should read a missing field, not fail. +func (s *Step) resolve(ctx context.Context, bindingKey string, beckn any) (map[string]any, error) { + prerequisite, needed := s.prerequisites[bindingKey] + if !needed { + return map[string]any{}, nil + } + local, err := prerequisite(ctx, beckn) + if err != nil { + return nil, fmt.Errorf("upstream: %s could not resolve what it needs before the call: %w", bindingKey, err) + } + if local == nil { + return map[string]any{}, nil + } + return local, nil +} + +// serves reports whether a binding key is one this step answers to. +// +// A slice rather than a set: a step serves a handful of capabilities at most, so +// the scan costs less than the map would, and the config order is preserved in +// the log line above. +func (s *Step) serves(key string) bool { + return slices.Contains(s.config.BindingKeys, key) +} + +// serve runs the exchange this step exists for: resolve, map out, call, map back. +func (s *Step) serve(ctx *model.StepContext, plan *model.ProviderRecord) error { + action := extractAction(ctx.Body) + call, served := plan.Actions[action] + if !served { + // The capability publishes no endpoint for this action, so it does not + // serve it. Refused here rather than after a call to whichever endpoint + // happened to be on the record -- naming what it does serve turns a + // registry mistake into a one-line fix. + return model.NewBadReqErr("", fmt.Errorf( + "upstream: %s does not serve action %q; it serves %s", + plan.BindingKey, action, strings.Join(plan.ServedActions(), ", "))) + } + + beckn, err := decodeBody(ctx.Body) + if err != nil { + return err + } + + // What this provider requires of a payload is declared by its mapping, not + // by this step. A capability with a different rule is a different mapping + // file rather than a different build -- and the rule sits beside the + // extraction it guards. + if err := s.mapper.Verify(ctx, call.Mappings, map[string]any{"beckn": beckn}); err != nil { + return err + } + + // Whatever this capability needs that its payload does not carry. Empty for + // most: the mapping reads the payload directly and needs nothing resolved. + local, err := s.resolve(ctx, plan.BindingKey, beckn) + if err != nil { + return err + } + + upstreamRequest, err := s.buildRequest(ctx, call, beckn, local) + if err != nil { + return err + } + + upstreamResponse, err := s.call(ctx, plan.BaseURL, call, upstreamRequest) + if err != nil { + return err + } + + answer, err := decodeBody(upstreamResponse) + if err != nil { + return fmt.Errorf("upstream: provider answered with something that is not JSON: %w", err) + } + + // The same mapping reference as the request, other half: one file carries + // both directions for this action. + // + // The mapping is handed what each party sent, plus whatever prerequisites + // resolved, under _local. Empty when there are none. + becknResponse, err := s.mapper.Transform(ctx, call.Mappings, definition.DirectionResponse, map[string]any{ + "beckn": beckn, + "_local": local, + "response": answer, + }) + if err != nil { + return err + } + if len(becknResponse) == 0 { + // Either the file has no response half, or its transform matched nothing + // in this answer. Both leave no Beckn response to return, and returning + // the provider's own shape instead would be worse than failing. The + // message says what was observed rather than guessing which it was. + return fmt.Errorf("upstream: the response half of %s produced nothing, so %s cannot be answered", + call.Mappings, plan.BindingKey) + } + + ctx.ResponseBody = becknResponse + log.Infof(ctx, "upstream: served %s in %d bytes", plan.BindingKey, len(becknResponse)) + return nil +} + +// buildRequest produces what the provider is sent. +// +// Whatever the mapping produces IS the request: a body for a method that takes +// one, query parameters for a method that does not. Nothing is substituted when +// it produces nothing, so an empty request half means an empty request. +// +// This step used to extract a point from the payload and fall back to sending +// that. It meant the choice of which payload fields reach the provider lived in +// Go, so adding a parameter -- a date range, say -- was a rebuild. Now it is a +// mapping edit and nothing else. +func (s *Step) buildRequest(ctx context.Context, call model.ActionPlan, beckn any, local map[string]any) ([]byte, error) { + mapped, err := s.mapper.Transform(ctx, call.Mappings, definition.DirectionRequest, map[string]any{ + "beckn": beckn, + "_local": local, + }) + if err != nil { + return nil, err + } + if len(mapped) == 0 { + log.Debugf(ctx, "upstream: the request half of %s produced nothing; sending an empty request", call.Mappings) + } + return mapped, nil +} + +// extractAction reads the Beckn action a request is for. +func extractAction(body []byte) string { + var payload struct { + Context struct { + Action string `json:"action"` + } `json:"context"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return "" + } + return payload.Context.Action +} + +// decodeBody turns raw JSON into the generic value a mapping reads. +func decodeBody(body []byte) (any, error) { + var decoded any + if err := json.Unmarshal(body, &decoded); err != nil { + return nil, fmt.Errorf("upstream: could not read JSON: %w", err) + } + return decoded, nil +} + +// call makes the upstream request described by the plan, retrying within its +// budget. +// budget resolves how long one attempt may take and how many retries follow +// it, applying the registry's values within this deployment's ceilings. +// +// Pure and separate from call so both bounds can be asserted without a server +// that sleeps for the timeout it is testing. +// +// retryMax counts retries, not attempts, so the call itself is always made +// once. An absent retryMax and an explicit 0 are the same instruction. +func budget(call model.ActionPlan) (time.Duration, int) { + timeout := DefaultTimeout + if call.TimeoutMs > 0 { + timeout = time.Duration(call.TimeoutMs) * time.Millisecond + } + if timeout > MaxTimeout { + timeout = MaxTimeout + } + + retries := DefaultRetryMax + if call.RetryMax > 0 { + retries = call.RetryMax + } + if retries > MaxRetryMax { + retries = MaxRetryMax + } + return timeout, retries +} + +func (s *Step) call(ctx context.Context, baseURL string, call model.ActionPlan, mapped []byte) ([]byte, error) { + endpoint, err := buildEndpoint(baseURL, call, mapped) + if err != nil { + return nil, err + } + + timeout, retries := budget(call) + if d := time.Duration(call.TimeoutMs) * time.Millisecond; d > timeout { + log.Warnf(ctx, "upstream: registry asks for a %v timeout; using the %v ceiling", d, timeout) + } + if call.RetryMax > retries { + log.Warnf(ctx, "upstream: registry asks for %d retries; using the %d ceiling", + call.RetryMax, retries) + } + attempts := retries + 1 + + var lastErr error + for attempt := 1; attempt <= attempts; attempt++ { + // A caller that has gone away is not worth another attempt, and neither + // is a budget already spent. Checked before the call rather than after, + // so a cancelled request costs nothing. + if err := ctx.Err(); err != nil { + if lastErr == nil { + lastErr = err + } + break + } + + body, err := s.attempt(ctx, call, endpoint, mapped, timeout) + if err == nil { + return body, nil + } + lastErr = s.redact(err) + log.Warnf(ctx, "upstream: attempt %d/%d failed: %v", attempt, attempts, lastErr) + + // Only some failures are worth repeating. A 4xx, a request this step + // could not build and a credential it could not read will fail + // identically however many times they are tried -- and retrying the + // credential case is the worst of them, because it reports an + // operator's missing environment variable as the provider being down. + if isPermanent(err) { + break + } + if attempt < attempts { + if err := sleep(ctx, backoff(attempt)); err != nil { + break + } + } + } + return nil, model.NewCodedErr(http.StatusBadGateway, codeUpstreamUnavailable, + fmt.Errorf("upstream: provider did not answer after %d attempts: %w", attempts, lastErr)) +} + +// permanentErr marks a failure no retry can fix. Kept unexported and detected +// with errors.As, so a caller of this package sees only the underlying error. +type permanentErr struct{ error } + +func (p permanentErr) Unwrap() error { return p.error } + +// doNotRetry marks err as not worth repeating. +func doNotRetry(err error) error { return permanentErr{err} } + +// isPermanent reports whether err is one no further attempt would change. +func isPermanent(err error) bool { + var permanent permanentErr + return errors.As(err, &permanent) +} + +// backoff is how long to wait before the next attempt. +// +// Exponential from a short base and capped, because the provider being briefly +// busy is the case worth waiting out; anything longer is a timeout's job. With +// no wait at all a retryMax of 5 spends its whole budget inside a couple of +// milliseconds, which is not a retry so much as the same failure six times. +func backoff(attempt int) time.Duration { + if attempt <= 1 { + return RetryBackoffBase + } + // Doubled in a loop that stops at the ceiling rather than shifted and then + // clamped. `RetryBackoffBase << (attempt - 1)` overflows int64 once the + // shift reaches 38 at a 50ms base, and the wrapped value is NEGATIVE -- so + // it passes the `> RetryBackoffMax` check, is returned, and a sleep on a + // negative duration returns immediately. The retry loop then spins as fast + // as the provider can refuse. Stopping at the ceiling cannot overflow, + // because it never doubles a value already at or past it. + wait := RetryBackoffBase + for i := 1; i < attempt && wait < RetryBackoffMax; i++ { + wait *= 2 + } + if wait > RetryBackoffMax { + return RetryBackoffMax + } + return wait +} + +// sleep waits, or reports that the context ended first. +func sleep(ctx context.Context, d time.Duration) error { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +// attempt makes one upstream request. +func (s *Step) attempt(ctx context.Context, call model.ActionPlan, endpoint string, mapped []byte, timeout time.Duration) ([]byte, error) { + attemptCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + method := canonicalMethod(call.Method) + req, err := http.NewRequestWithContext(attemptCtx, method, endpoint, requestBody(method, mapped)) + if err != nil { + return nil, doNotRetry(fmt.Errorf("could not build the request: %w", err)) + } + if hasBody(method) { + req.Header.Set("Content-Type", "application/json") + } + if err := s.authenticate(req); err != nil { + // A missing or unreadable credential is configuration, not weather. + return nil, doNotRetry(err) + } + + // The URL as it actually went on the wire, credential removed. At info + // rather than debug because this is the line that answers "what did we ask, + // and what came back" -- the question every provider problem starts with. + requested := s.redactString(req.URL.String()) + + resp, err := s.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, s.config.MaxResponseBytes+1)) + if err != nil { + return nil, fmt.Errorf("could not read the response: %w", err) + } + log.Infof(ctx, "upstream: %s %s -> %s, %d bytes", method, requested, resp.Status, len(body)) + if int64(len(body)) > s.config.MaxResponseBytes { + // Asking again will not make the answer smaller. + return nil, doNotRetry(fmt.Errorf("response exceeds the %d byte limit", s.config.MaxResponseBytes)) + } + // Any 2xx, not 200 alone. A provider is entitled to answer 202 for work it + // accepted, 204 for nothing to report, or 201 for something it created, and + // treating those as failures would refuse a perfectly good exchange. 3xx + // does not reach here: the client follows redirects. + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + // The body is logged, not returned. It goes into a 502 that is signed + // and sent to the network caller, and what a provider puts in a failure + // body is its own business -- a stack trace, an internal hostname, a + // database error. The status is the caller's business and stays; the + // body is the operator's, and the log is where the operator looks. + // Redacted on the way to the log too. A provider that rejects a + // request often quotes it back, credential and all -- so the body is + // exactly where a query-string token turns up, and moving it from the + // error to the log would only move the leak. + log.Warnf(ctx, "upstream: provider returned %s for %s %s: %s", + resp.Status, method, requested, s.redactString(explain(body))) + err := fmt.Errorf("provider returned %s", resp.Status) + // 5xx and 429 are the provider asking to be tried again. Every other + // 4xx is a statement about the request, which will not improve. + if resp.StatusCode < http.StatusInternalServerError && resp.StatusCode != http.StatusTooManyRequests { + return nil, doNotRetry(err) + } + return nil, err + } + return body, nil +} + +// explainLimit is how much of a failed response is quoted. Enough for a +// provider's own message, short enough not to put a page of HTML in a log line +// or a NACK. +const explainLimit = 300 + +// explain renders a failed response body for a human. +// +// The body was already read and then thrown away, so a provider's own account +// of what was wrong -- Agmarknet says "no data" in the body of a 400 -- never +// reached anyone. The status alone says a call failed and nothing about why, +// which is the first thing an operator needs and the thing that makes a real +// provider's behaviour observable at all. +func explain(body []byte) string { + text := strings.TrimSpace(string(body)) + if text == "" { + return "(no body)" + } + // Collapse whitespace: a provider that answers with indented JSON or an + // HTML error page should not spread one failure over forty log lines. + text = strings.Join(strings.Fields(text), " ") + if len(text) > explainLimit { + return text[:explainLimit] + "... (truncated)" + } + return text +} + +// authenticate presents this provider's credentials, read from the environment +// at call time so a rotated secret takes effect without a restart. +// missingCredential reports an unset credential without naming the variable on +// the wire. +// +// The variable name is deployment configuration, and this error is wrapped into +// a 502 that is signed and returned to a network peer. Telling a peer that +// MANDI_TOKEN is what this deployment reads describes the inside of somebody +// else's stack for no benefit to the caller -- the caller cannot set it, and +// the fix is entirely the operator's. So the name goes to the log, where the +// operator is, and the wire gets the scheme that failed. +func (s *Step) missingCredential(ctx context.Context, scheme, envNames string) error { + err := fmt.Errorf("upstream: this provider's %s credential is not configured", scheme) + log.Errorf(ctx, err, "upstream: %s auth is configured but %s is not set", scheme, envNames) + return err +} + +func (s *Step) authenticate(req *http.Request) error { + switch s.config.AuthScheme { + case AuthSchemeBasic: + username, password := os.Getenv(s.config.UsernameEnv), os.Getenv(s.config.PasswordEnv) + if username == "" || password == "" { + return s.missingCredential(req.Context(), "basic", + s.config.UsernameEnv+" and "+s.config.PasswordEnv) + } + req.SetBasicAuth(username, password) + case AuthSchemeHeader: + value := os.Getenv(s.config.HeaderValueEnv) + if value == "" { + return s.missingCredential(req.Context(), "header", s.config.HeaderValueEnv) + } + req.Header.Set(s.config.HeaderName, value) + case AuthSchemeQuery: + value := os.Getenv(s.config.QueryValueEnv) + if value == "" { + return s.missingCredential(req.Context(), "query", s.config.QueryValueEnv) + } + // Set rather than Add: a second copy of the parameter is not a + // credential, it is an ambiguity, and which one an upstream reads is + // its own business. + query := req.URL.Query() + query.Set(s.config.QueryName, value) + req.URL.RawQuery = query.Encode() + } + return nil +} + +// redact removes a query-string credential from an error's text. +// +// Go's transport errors quote the whole URL -- `Get "http://host/p?token=..." +// dial tcp: ...` -- so without this, one unreachable host writes the credential +// into the log at warn level. Nothing else in this package puts a URL in a +// message, which is why this is the only place it is needed. +// +// A plain string replacement, because the value is what leaks and the value is +// what we hold. Parsing the error to find it would assume a shape net/http does +// not promise. +func (s *Step) redact(err error) error { + if err == nil { + return nil + } + text := s.redactString(err.Error()) + if text == err.Error() { + return err + } + return redactedErr{text: text, err: err} +} + +// redactedErr reports a redacted message while keeping the original reachable +// for errors.Is and errors.As. +// +// errors.New(text) was the obvious thing and it broke the chain: the redacted +// value is what gets %w-wrapped into the final 502, so under a query-string +// scheme -- and only then, since nothing else redacts -- errors.Is(err, +// context.DeadlineExceeded) silently stopped matching. Retry classification +// was never affected, because isPermanent tests the error before redaction, +// which is why nothing failed visibly. +// +// fmt.Errorf("%s: %w", text, err) would have restored the chain and undone the +// redaction with it: %w formats the original, credential included. Reporting +// the redacted text from Error() and the original from Unwrap() keeps both. +// +// The original's text is reachable through errors.Unwrap, which is a +// deliberate act by a caller who wants the cause -- and %v, %s and %w on the +// value itself all go through Error() and stay redacted. +type redactedErr struct { + text string + err error +} + +func (e redactedErr) Error() string { return e.text } +func (e redactedErr) Unwrap() error { return e.err } + +// redactString removes the configured credential from any text about to be +// logged or returned -- an error, a provider's response body, or the URL that +// was requested. +// +// Logging those is deliberate: they say what was asked of whom and what came +// back, which is the first thing anyone wants when a provider misbehaves. This +// is what makes that safe to do at info and warn level. +// +// EVERY scheme, not just query. This used to return early unless the scheme was +// query, on the reasoning that only a query credential reaches a URL -- true of +// the URL, and wrong about the body. A provider quoting the request it rejected +// is the ordinary shape of a 401 or 403 body, an API gateway echoing the +// Authorization header is routine, and a wrong-credential 4xx is not retried, +// so it lands in the log once per request for as long as the credential is +// wrong. basic is the scheme the reference config ships. +func (s *Step) redactString(text string) string { + for _, secret := range s.secretForms() { + text = strings.ReplaceAll(text, secret, redactedMarker) + } + return text +} + +// secretForms returns every form the configured credential can appear in, +// longest first so a value that contains another is replaced before its +// substring turns the longer one into a partial redaction. +// +// Per scheme, because the schemes leak differently and redacting the value we +// hold is not enough on its own: +// +// - basic wraps the pair: SetBasicAuth sends base64(user:pass), so the +// password alone does not appear on the wire and replacing it misses the +// echoed header entirely. +// - query escapes: authenticate goes through url.Values.Encode, so a base64 +// token carrying "+", "/" or "=" appears as "a%2Bb%2Fc%3D". Escaping what +// we hold is exact -- same function Encode used, so the two agree by +// construction rather than by a guess about which characters matter. +// - header sends the value as-is. +// +// The raw form is kept alongside the wrapped one in both cases: an error built +// from the config rather than from the request still quotes the credential +// unwrapped. +func (s *Step) secretForms() []string { + switch s.config.AuthScheme { + case AuthSchemeBasic: + username, password := os.Getenv(s.config.UsernameEnv), os.Getenv(s.config.PasswordEnv) + if password == "" { + return nil + } + forms := []string{password} + if username != "" { + // The wire form, which is what a gateway echoes back. + forms = append(forms, + base64.StdEncoding.EncodeToString([]byte(username+":"+password))) + } + // The username is deliberately NOT redacted. It identifies rather than + // authenticates, and it is routinely a short common word -- redacting + // "user" or "admin" would eat unrelated text and cost the operator the + // log line they came for. The pair and the password are the secrets. + return longestFirst(forms) + case AuthSchemeHeader: + value := os.Getenv(s.config.HeaderValueEnv) + if value == "" { + return nil + } + return []string{value} + case AuthSchemeQuery: + value := os.Getenv(s.config.QueryValueEnv) + if value == "" { + return nil + } + forms := []string{value} + if encoded := url.QueryEscape(value); encoded != value { + forms = append(forms, encoded) + } + return longestFirst(forms) + } + return nil +} + +// longestFirst orders replacement candidates so a longer form is substituted +// before any shorter one it contains. +func longestFirst(forms []string) []string { + sort.Slice(forms, func(i, j int) bool { return len(forms[i]) > len(forms[j]) }) + return forms +} + +// buildEndpoint joins the plan's base URL and path, carrying the mapped request +// as query parameters when the method takes no body. +func buildEndpoint(baseURL string, call model.ActionPlan, mapped []byte) (string, error) { + if err := verifyBaseURL(baseURL); err != nil { + return "", err + } + if err := verifyPath(call.Path); err != nil { + return "", err + } + + // baseUrl cannot end in a slash and path must begin with one, so exactly one + // separator appears between them. The trim is belt and braces: the registry + // refuses a trailing slash on baseUrl, and this keeps a row that predates + // that from producing a doubled one. + endpoint := strings.TrimSuffix(baseURL, "/") + call.Path + if hasBody(call.Method) { + return endpoint, nil + } + + query, err := asQuery(mapped) + if err != nil { + return "", err + } + if query == "" { + return endpoint, nil + } + if strings.Contains(endpoint, "?") { + return endpoint + "&" + query, nil + } + return endpoint + "?" + query, nil +} + +// verifyPath refuses a published path nobody could have meant. +// +// The registry constrains this, but it is a separate deployable that may not be +// updated in step, so a row that slipped through has to fail here with something +// an operator can act on rather than as a provider's 404 three hops away. +// +// An empty segment is the case worth catching: "//get-daily" is never +// deliberate, and plenty of servers answer it differently from "/get-daily". A +// trailing slash is deliberately left alone -- "/api/" and "/api" are a +// distinction some APIs genuinely make, so stripping it would silently change +// the URL the operator published. +func verifyPath(path string) error { + if path == "" { + return model.NewBadReqErr("", errors.New("upstream: the registry publishes no path for this action")) + } + if !strings.HasPrefix(path, "/") { + return model.NewBadReqErr("", fmt.Errorf( + "upstream: path %q does not begin with a slash, so it cannot be joined to a base url", path)) + } + if strings.Contains(path, "//") { + return model.NewBadReqErr("", fmt.Errorf( + "upstream: path %q has an empty segment; write it with single slashes", path)) + } + // A dot segment is refused rather than resolved. The registry says which + // path answers an action, and a row that climbs out of it is either a + // mistake or an attempt to reach something the row does not name -- and + // net/url would quietly resolve it either way, so the request that left + // would not be the request the row described. + for _, segment := range strings.Split(path, "/") { + if segment == ".." || segment == "." { + return model.NewBadReqErr("", fmt.Errorf( + "upstream: path %q contains the %q segment; publish the path it resolves to instead", + path, segment)) + } + } + // A fragment is never sent, so a row carrying one describes a request that + // cannot be made. Refused here rather than silently dropped by the + // transport, which would make the row look honoured. + if strings.Contains(path, "#") { + return model.NewBadReqErr("", fmt.Errorf( + "upstream: path %q contains a fragment, which is never sent to a server", path)) + } + return nil +} + +// verifyBaseURL checks the participant's base url before it is joined to a +// path, so a row that cannot produce a request says so as a bad request rather +// than as the provider being unreachable. +// +// Without this, `baseUrl: "registry:8081"` -- a scheme left off -- failed +// inside http.NewRequestWithContext and arrived as a 502 "provider did not +// answer after 1 attempts: could not build the request". That names the +// provider for an error in the row describing it, and it is retried on the way +// there. jsonmapper has always checked its own reference this way; this is the +// same check on the other url the registry publishes. +func verifyBaseURL(baseURL string) error { + if baseURL == "" { + return model.NewBadReqErr("", errors.New("upstream: the registry publishes no base url for this provider")) + } + parsed, err := url.Parse(baseURL) + if err != nil { + return model.NewBadReqErr("", fmt.Errorf("upstream: invalid base url %q: %w", baseURL, err)) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return model.NewBadReqErr("", fmt.Errorf( + "upstream: base url %q must be http or https", baseURL)) + } + if parsed.Host == "" { + return model.NewBadReqErr("", fmt.Errorf("upstream: base url %q names no host", baseURL)) + } + return nil +} + +// asQuery renders a mapped request as query parameters. +// +// A method with no body still needs the mapping's output somewhere, and the +// query string is the only place it can go. Only scalars are carried: a nested +// value has no single obvious encoding, and inventing one here would put a +// convention in Go that belongs in the mapping. +func asQuery(mapped []byte) (string, error) { + if len(bytes.TrimSpace(mapped)) == 0 { + return "", nil + } + var fields map[string]any + if err := json.Unmarshal(mapped, &fields); err != nil { + return "", fmt.Errorf("upstream: mapped request is not an object, so it cannot become a query: %w", err) + } + + values := url.Values{} + for name, value := range fields { + rendered, ok := renderScalar(value) + if !ok { + return "", fmt.Errorf("upstream: mapped field %q is not a scalar and cannot become a query parameter", name) + } + values.Set(name, rendered) + } + return values.Encode(), nil +} + +// renderScalar renders a JSON scalar as a query parameter value. +func renderScalar(value any) (string, bool) { + switch typed := value.(type) { + case string: + return typed, true + case bool: + return strconv.FormatBool(typed), true + case float64: + // 'g' with -1 precision round-trips without inventing trailing zeros, so + // 19.9975 stays 19.9975 rather than becoming 19.997500. + return strconv.FormatFloat(typed, 'g', -1, 64), true + default: + return "", false + } +} + +// requestBody returns the body to send, which is none for methods that take none. +func requestBody(method string, mapped []byte) io.Reader { + if !hasBody(method) { + return nil + } + return bytes.NewReader(mapped) +} + +// hasBody reports whether a method carries a request body. +func hasBody(method string) bool { + switch canonicalMethod(method) { + case http.MethodGet, http.MethodHead, http.MethodDelete, "": + return false + default: + return true + } +} + +// canonicalMethod returns a known HTTP method in the spelling the RFC gives +// it, and anything else unchanged. +// +// hasBody used to upper-case privately, which made the method look +// case-insensitive when it is not: NewRequestWithContext transmits it verbatim, +// so a registry row reading `method: "post"` sent `post /path HTTP/1.1`. The +// body was attached correctly -- hasBody had normalised -- but nginx and most +// gateways answer 405 to a lowercase method, which classifies permanent and +// surfaces as a 502 "provider did not answer". A row that is right in every +// respect but its capitalisation is a bad way to spend an afternoon. +// +// Only known methods are rewritten. Upper-casing everything would be a new +// restriction on an upstream entitled to a method this list has not heard of, +// and net/http already refuses one that is not a valid token. +// +// An empty method is left empty: net/http documents "" as GET and substitutes +// it, and hasBody agrees that it carries no body, so the two are already +// consistent and inventing a value here would only hide where it comes from. +func canonicalMethod(method string) string { + upper := strings.ToUpper(method) + switch upper { + case http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut, + http.MethodPatch, http.MethodDelete, http.MethodConnect, + http.MethodOptions, http.MethodTrace: + return upper + } + return method +} diff --git a/pkg/plugin/implementation/internal/upstream/upstream_test.go b/pkg/plugin/implementation/internal/upstream/upstream_test.go new file mode 100644 index 00000000..2e871dea --- /dev/null +++ b/pkg/plugin/implementation/internal/upstream/upstream_test.go @@ -0,0 +1,2140 @@ +package upstream + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "slices" + "sort" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/beckn-one/beckn-onix/pkg/model" + "github.com/beckn-one/beckn-onix/pkg/plugin/definition" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/capabilitybinding" +) + +const selectBody = `{ + "context": { "version": "2.0.0", "action": "select", "transactionId": "txn-1" }, + "message": { "contract": { "commitments": [{ + "resources": [{ "resourceAttributes": { + "@type": "openagrinet:WeatherObservation", + "location": { "type": "Point", "coordinates": [73.7898, 19.9975] } } }], + "offer": { "provider": { "id": "mausamgram" } } + }] } } +}` + +// --- test doubles ----------------------------------------------------------- + +type stubRegistry struct { + plan *model.ProviderRecord + err error +} + +func (s *stubRegistry) ProviderRecord(context.Context, string) (*model.ProviderRecord, error) { + return s.plan, s.err +} + +// stubMapper records what it was asked and returns canned results, so a test can +// testMappingRef is the one reference an action carries: the URL of a single +// published file holding both halves. +// testBindingKey is what these tests configure the step for. There is no +// default any more: this package serves whatever a domain package points it at. +const testBindingKey = "mausamgram|openagrinet:WeatherObservation" + +const testMappingRef = "https://mappings.example.com/mausamgram/weather-observation.select.yaml" + +// assert what reached the mapping without writing one. +type stubMapper struct { + requestResult []byte + responseResult []byte + err error + requestErr error + + verifyErr error + verified bool + + requestInput any + responseInput any + directions []definition.Direction + refs []string +} + +// verifyErr is what Verify answers with, so a test can stand in for a mapping +// whose precondition refused. +func (s *stubMapper) Verify(_ context.Context, mappingRef string, input any) error { + s.verified = true + return s.verifyErr +} + +func (s *stubMapper) Transform(_ context.Context, mappingRef string, direction definition.Direction, input any) ([]byte, error) { + s.directions = append(s.directions, direction) + s.refs = append(s.refs, mappingRef) + if s.err != nil { + return nil, s.err + } + if direction == definition.DirectionRequest { + s.requestInput = input + if s.requestErr != nil { + return nil, s.requestErr + } + return s.requestResult, nil + } + s.responseInput = input + return s.responseResult, nil +} + +func testPlan(baseURL, method string) *model.ProviderRecord { + return &model.ProviderRecord{ + BindingKey: testBindingKey, + ParticipantID: "mausamgram", + CapabilityCode: "openagrinet:WeatherObservation", + BaseURL: baseURL, + Actions: map[string]model.ActionPlan{ + "select": {Method: method, Path: "/get-daily", Mappings: testMappingRef, + TimeoutMs: 2000, RetryMax: 1}, + }, + } +} + +func newStep(t *testing.T, registry definition.ProviderRecordLookup, mapper definition.Mapper, tweak ...func(*Config)) *Step { + t.Helper() + + cfg := &Config{BindingKeys: []string{testBindingKey}} + for _, apply := range tweak { + apply(cfg) + } + step, closer, err := New(context.Background(), registry, mapper, nil, cfg) + if err != nil { + t.Fatalf("New() returned an unexpected error: %v", err) + } + t.Cleanup(func() { _ = closer() }) + return step +} + +func runStep(t *testing.T, step *Step, body string) (*model.StepContext, error) { + t.Helper() + ctx := &model.StepContext{Context: t.Context(), Body: []byte(body)} + return ctx, step.Run(ctx) +} + +// --- construction ----------------------------------------------------------- + +func TestNewRequiresItsDependencies(t *testing.T) { + t.Parallel() + + if _, _, err := New(context.Background(), nil, &stubMapper{}, nil, minimalConfig()); err == nil { + t.Error("expected a missing registry to be refused") + } + if _, _, err := New(context.Background(), &stubRegistry{}, nil, nil, minimalConfig()); err == nil { + t.Error("expected a missing mapper to be refused") + } +} + +func TestNewValidatesTheAuthScheme(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + config *Config + valid bool + }{ + {"none by default", &Config{}, true}, + {"basic with both variables", &Config{AuthScheme: AuthSchemeBasic, UsernameEnv: "U", PasswordEnv: "P"}, true}, + {"basic missing the password variable", &Config{AuthScheme: AuthSchemeBasic, UsernameEnv: "U"}, false}, + {"basic missing the username variable", &Config{AuthScheme: AuthSchemeBasic, PasswordEnv: "P"}, false}, + {"header with both settings", &Config{AuthScheme: AuthSchemeHeader, HeaderName: "X-Key", HeaderValueEnv: "V"}, true}, + {"header missing the value variable", &Config{AuthScheme: AuthSchemeHeader, HeaderName: "X-Key"}, false}, + {"an unknown scheme", &Config{AuthScheme: "oauth"}, false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + tc.config.BindingKeys = []string{testBindingKey} + _, _, err := New(context.Background(), &stubRegistry{}, &stubMapper{}, nil, tc.config) + if tc.valid && err != nil { + t.Errorf("expected the config to be accepted, got %v", err) + } + if !tc.valid && err == nil { + t.Error("expected the config to be refused") + } + }) + } +} + +// A payload this step cannot derive a binding from is the caller's mistake, so +// it must not surface as a 500 with the reason only in our log. Found by +// checking the live stack after refusing multi-commitment payloads: the refusal +// was right and the status was not. +func TestRunReportsAnUnservablePayloadAsABadRequest(t *testing.T) { + t.Parallel() + + twoCommitments := `{"context":{"action":"select"},"message":{"contract":{"commitments":[ + {"offer":{"provider":{"id":"mausamgram"}}, + "resources":[{"resourceAttributes":{"@type":"openagrinet:WeatherObservation"}}]}, + {"offer":{"provider":{"id":"mausamgram"}}, + "resources":[{"resourceAttributes":{"@type":"openagrinet:WeatherObservation"}}]} + ]}}}` + + for _, tc := range []struct{ name, body, wants string }{ + {"two commitments", twoCommitments, "2 commitments"}, + {"unreadable json", `{"message":`, "could not be read"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + _, err := runStep(t, newStep(t, &stubRegistry{}, &stubMapper{}), tc.body) + if err == nil { + t.Fatal("expected the payload to be refused") + } + var coded *model.CodedErr + if !errors.As(err, &coded) || coded.HTTPStatus() != http.StatusBadRequest { + t.Errorf("got %v, want a 400 -- a 500 blames this adapter for the caller's payload", err) + } + if !strings.Contains(err.Error(), tc.wants) { + t.Errorf("error %q should say %q so the caller can act on it", err, tc.wants) + } + }) + } +} + +// --- what is worth retrying ------------------------------------------------- + +// A 4xx is a statement about the request. Repeating it changes nothing, and +// the whole budget was previously spent inside a couple of milliseconds. +func TestRunDoesNotRetryAClientError(t *testing.T) { + t.Parallel() + + var attempts int + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempts++ + w.WriteHeader(http.StatusBadRequest) + })) + defer upstream.Close() + + plan := testPlan(upstream.URL, http.MethodGet) + plan.Actions["select"] = model.ActionPlan{ + Method: http.MethodGet, Path: "/x", Mappings: testMappingRef, RetryMax: 5, + } + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{}`)} + step := newStep(t, &stubRegistry{plan: plan}, mapper) + + if _, err := runStep(t, step, selectBody); err == nil { + t.Fatal("expected a 400 from the provider to be reported") + } + if attempts != 1 { + t.Errorf("the provider was called %d times, want 1 -- a 400 is not worth retrying", attempts) + } +} + +// 5xx and 429 are the provider asking to be tried again, so those still are. +func TestRunRetriesWhatTheProviderAsksItTo(t *testing.T) { + t.Parallel() + + for _, status := range []int{http.StatusInternalServerError, http.StatusTooManyRequests} { + t.Run(http.StatusText(status), func(t *testing.T) { + t.Parallel() + + var attempts int + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempts++ + w.WriteHeader(status) + })) + defer upstream.Close() + + plan := testPlan(upstream.URL, http.MethodGet) + plan.Actions["select"] = model.ActionPlan{ + Method: http.MethodGet, Path: "/x", Mappings: testMappingRef, RetryMax: 2, + } + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{}`)} + step := newStep(t, &stubRegistry{plan: plan}, mapper) + + if _, err := runStep(t, step, selectBody); err == nil { + t.Fatal("expected the failure to be reported") + } + if attempts != 3 { + t.Errorf("the provider was called %d times, want 3 (1 + retryMax 2)", attempts) + } + }) + } +} + +// An operator's missing environment variable is configuration, not the provider +// being down. Retrying it reported the wrong system as broken. +func TestRunDoesNotRetryAMissingCredential(t *testing.T) { + t.Parallel() + + var called int + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called++ + fmt.Fprint(w, `{}`) + })) + defer upstream.Close() + + plan := testPlan(upstream.URL, http.MethodGet) + plan.Actions["select"] = model.ActionPlan{ + Method: http.MethodGet, Path: "/x", Mappings: testMappingRef, RetryMax: 4, + } + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{}`)} + step := newStep(t, &stubRegistry{plan: plan}, mapper, func(c *Config) { + c.AuthScheme = AuthSchemeBasic + c.UsernameEnv = "TEST_ABSENT_USER_FOR_RETRY" + c.PasswordEnv = "TEST_ABSENT_PASS_FOR_RETRY" + }) + + _, err := runStep(t, step, selectBody) + if err == nil { + t.Fatal("expected a missing credential to be reported") + } + if called != 0 { + t.Errorf("the provider was called %d times; a credential this step cannot read never reaches it", called) + } + // The variable name is deployment configuration and this error is signed + // and returned to a network peer, so the name belongs in the log and not + // on the wire. The scheme stays, which is what makes it diagnosable. + if strings.Contains(err.Error(), "TEST_ABSENT_USER_FOR_RETRY") || + strings.Contains(err.Error(), "TEST_ABSENT_PASS_FOR_RETRY") { + t.Errorf("error %q must not name the environment variable", err) + } + if !strings.Contains(err.Error(), "basic") { + t.Errorf("error %q should say which auth scheme could not be presented", err) + } +} + +// A caller that has gone away gets no further attempts. +func TestRunStopsWhenTheCallerHasGone(t *testing.T) { + t.Parallel() + + var attempts int + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempts++ + w.WriteHeader(http.StatusInternalServerError) + })) + defer upstream.Close() + + plan := testPlan(upstream.URL, http.MethodGet) + plan.Actions["select"] = model.ActionPlan{ + Method: http.MethodGet, Path: "/x", Mappings: testMappingRef, RetryMax: 5, + } + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{}`)} + step := newStep(t, &stubRegistry{plan: plan}, mapper) + + cancelled, cancel := context.WithCancel(context.Background()) + cancel() + stepCtx := &model.StepContext{Context: cancelled, Body: []byte(selectBody)} + if err := step.Run(stepCtx); err == nil { + t.Fatal("expected a cancelled request to be reported") + } + if attempts != 0 { + t.Errorf("the provider was called %d times for an abandoned request, want 0", attempts) + } +} + +// A withdrawn binding is the caller naming something that is not there, so 404 +// -- the same reasoning the no-route path uses. A registry that could not be +// consulted is this adapter failing, and stays unclassified. +func TestRunSeparatesAWithdrawnBindingFromAnUnreachableRegistry(t *testing.T) { + t.Parallel() + + withdrawn := &stubRegistry{err: definition.ErrProviderRecordNotFound} + _, err := runStep(t, newStep(t, withdrawn, &stubMapper{}), selectBody) + if err == nil { + t.Fatal("expected a withdrawn binding to be refused") + } + var coded *model.CodedErr + if !errors.As(err, &coded) || coded.HTTPStatus() != http.StatusNotFound { + t.Errorf("a withdrawn binding gave %v, want a 404 -- a 500 hides it as this adapter's fault", err) + } + if !strings.Contains(err.Error(), testBindingKey) { + t.Errorf("error %q should name the binding with no record", err) + } + + unreachable := &stubRegistry{err: errors.New("registry unreachable")} + _, err = runStep(t, newStep(t, unreachable, &stubMapper{}), selectBody) + if err == nil { + t.Fatal("expected an unreachable registry to be reported") + } + if errors.As(err, &coded) && coded.HTTPStatus() == http.StatusNotFound { + t.Error("an unreachable registry must not report as not-found; it is this adapter failing") + } +} + +// --- what counts as an answer ------------------------------------------------ + +// Any 2xx is an answer. Only 200 used to be, so a provider entitled to reply +// 202 for accepted work or 201 for something created had its perfectly good +// exchange refused. +func TestRunAcceptsAnyTwoHundred(t *testing.T) { + t.Parallel() + + for _, status := range []int{http.StatusOK, http.StatusCreated, http.StatusAccepted} { + t.Run(http.StatusText(status), func(t *testing.T) { + t.Parallel() + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(status) + fmt.Fprint(w, `{"answered":true}`) + })) + defer upstream.Close() + + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{"ok":1}`)} + step := newStep(t, &stubRegistry{plan: testPlan(upstream.URL, http.MethodGet)}, mapper) + + ctx, err := runStep(t, step, selectBody) + if err != nil { + t.Fatalf("%d should be an answer, got %v", status, err) + } + if len(ctx.ResponseBody) == 0 { + t.Errorf("%d produced no answer", status) + } + }) + } +} + +// A 204 passes the status check and then fails decoding, because there is no +// JSON to map. Asserted rather than left to be discovered: the failure names +// the empty body instead of the status, and if a provider ever uses 204 for +// "nothing to report" this is the line that will need a decision. +func TestRunReportsAnEmptyBodyRatherThanTheStatus(t *testing.T) { + t.Parallel() + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer upstream.Close() + + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{}`)} + step := newStep(t, &stubRegistry{plan: testPlan(upstream.URL, http.MethodGet)}, mapper) + + _, err := runStep(t, step, selectBody) + if err == nil { + t.Fatal("expected a 204 with no body to be reported") + } + if !strings.Contains(err.Error(), "not JSON") { + t.Errorf("error %q should say the body could not be read, not blame the status", err) + } +} + +// A provider's own account of the failure has to survive. The body was read and +// then thrown away, so a 400 carrying {"message":"no data"} reached an operator +// as "provider returned 400 Bad Request" and nothing else -- which is the first +// thing anyone needs and the thing that makes a real provider observable. +func TestRunKeepsTheProvidersBodyOffTheWire(t *testing.T) { + t.Parallel() + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, "{\n \"message\": \"no data available\"\n}") + })) + defer upstream.Close() + + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{}`)} + step := newStep(t, &stubRegistry{plan: testPlan(upstream.URL, http.MethodGet)}, mapper) + + _, err := runStep(t, step, selectBody) + if err == nil { + t.Fatal("expected the failure to be reported") + } + // The status is the caller's business and stays. The body is not: this + // error is signed and sent to a network peer, and what a provider puts in + // a failure body -- a stack trace, an internal hostname, a database + // error -- is nobody else's. It goes to the log instead. + if strings.Contains(err.Error(), "no data available") { + t.Errorf("error %q must not carry the provider's response body", err) + } + if !strings.Contains(err.Error(), "400 Bad Request") { + t.Errorf("error %q should still name the status the provider returned", err) + } +} + +// explain still collapses whitespace, because the body it prepares now goes to +// a log line rather than an error -- an indented body would spread one failure +// over several lines either way. +func TestExplainCollapsesWhitespace(t *testing.T) { + t.Parallel() + + got := explain([]byte("{\n \"message\": \"no data available\"\n}")) + if strings.Contains(got, "\n") { + t.Errorf("explain(%q) left a newline in", got) + } + if !strings.Contains(got, "no data available") { + t.Errorf("explain = %q, want the provider's message preserved", got) + } +} + +// A body is quoted, not dumped: a provider answering with a page of HTML must +// not put all of it in a log line or a NACK. +func TestExplainTruncatesAndHandlesAnEmptyBody(t *testing.T) { + t.Parallel() + + if got := explain(nil); got != "(no body)" { + t.Errorf("explain(nil) = %q, want a marker rather than an empty string", got) + } + long := explain([]byte(strings.Repeat("x", explainLimit+50))) + if len(long) > explainLimit+len("... (truncated)") { + t.Errorf("explain kept %d characters, want it truncated near %d", len(long), explainLimit) + } + if !strings.HasSuffix(long, "(truncated)") { + t.Errorf("a truncated body should say so, got %q", long[len(long)-20:]) + } +} + +// The quoted body goes through the same redaction as everything else, or a +// provider that echoes the query string back would defeat it. +func TestRunRedactsACredentialEchoedInABody(t *testing.T) { + // No t.Parallel: t.Setenv forbids it. + t.Setenv("TEST_ECHO_TOKEN", "s3cr3t") + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + // A provider quoting the request it rejected, credential and all. + fmt.Fprintf(w, `{"rejected":%q}`, r.URL.RawQuery) + })) + defer upstream.Close() + + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{}`)} + step := newStep(t, &stubRegistry{plan: testPlan(upstream.URL, http.MethodGet)}, mapper, + func(c *Config) { + c.AuthScheme = AuthSchemeQuery + c.QueryName = "token" + c.QueryValueEnv = "TEST_ECHO_TOKEN" + }) + + _, err := runStep(t, step, selectBody) + if err == nil { + t.Fatal("expected the failure to be reported") + } + if strings.Contains(err.Error(), "s3cr3t") { + t.Errorf("the credential leaked through the quoted body: %v", err) + } + // The body no longer reaches the error at all, so its absence from the + // wire is not what needs proving here -- the LOG is where it goes now, and + // a provider echoing the request back is exactly where a query-string + // token turns up. Assert on the same expression the code logs, so moving + // the body from the error to the log cannot quietly move the leak with it. + echoed := `{"rejected":"token=s3cr3t"}` + logged := step.redactString(explain([]byte(echoed))) + if strings.Contains(logged, "s3cr3t") { + t.Errorf("the credential survives into the log line: %s", logged) + } + if !strings.Contains(logged, "REDACTED") { + t.Errorf("logged body = %q, want the credential replaced", logged) + } +} + +// --- query-string auth ------------------------------------------------------ + +// Some upstreams take their credential as a query parameter. It arrives on the +// request, alongside whatever the mapping produced rather than replacing it. +func TestRunSendsTheCredentialAsAQueryParameter(t *testing.T) { + // No t.Parallel: t.Setenv forbids it, and the credential has to come + // from the environment for this to be testing anything. + t.Setenv("TEST_MANDI_TOKEN", "s3cr3t") + + var got url.Values + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r.URL.Query() + fmt.Fprint(w, `{}`) + })) + defer upstream.Close() + + mapper := &stubMapper{requestResult: []byte(`{"statecode":"CG"}`), responseResult: []byte(`{}`)} + step := newStep(t, &stubRegistry{plan: testPlan(upstream.URL, http.MethodGet)}, mapper, + func(c *Config) { + c.AuthScheme = AuthSchemeQuery + c.QueryName = "token" + c.QueryValueEnv = "TEST_MANDI_TOKEN" + }) + + if _, err := runStep(t, step, selectBody); err != nil { + t.Fatalf("Run() returned an unexpected error: %v", err) + } + if got.Get("token") != "s3cr3t" { + t.Errorf("token = %q, want the value from the environment", got.Get("token")) + } + // The mapping's own parameters must survive: the credential is added, not + // substituted for the request. + if got.Get("statecode") != "CG" { + t.Errorf("statecode = %q, want the mapped request to be intact", got.Get("statecode")) + } +} + +// The whole reason this scheme is treated as the least safe of the four: Go +// quotes the full URL in a transport error, so an unreachable host would +// otherwise write the credential into the log at warn level. +func TestRunRedactsAQueryCredentialFromAnError(t *testing.T) { + // No t.Parallel: t.Setenv forbids it, and the credential has to come + // from the environment for this to be testing anything. + t.Setenv("TEST_MANDI_TOKEN", "s3cr3t") + + plan := testPlan("http://upstream.invalid", http.MethodGet) + plan.Actions["select"] = model.ActionPlan{ + Method: http.MethodGet, Path: "/get", Mappings: testMappingRef, RetryMax: 0, + } + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{}`)} + step := newStep(t, &stubRegistry{plan: plan}, mapper, func(c *Config) { + c.AuthScheme = AuthSchemeQuery + c.QueryName = "token" + c.QueryValueEnv = "TEST_MANDI_TOKEN" + }) + + _, err := runStep(t, step, selectBody) + if err == nil { + t.Fatal("expected an unreachable host to fail") + } + if strings.Contains(err.Error(), "s3cr3t") { + t.Errorf("the credential leaked into the error: %v", err) + } + if !strings.Contains(err.Error(), "REDACTED") { + t.Errorf("error %q should show the credential was removed", err) + } +} + +// The URL is logged so a provider problem can be diagnosed from what was asked +// of whom. That makes the credential's absence from it load-bearing, not +// incidental: with a query-string scheme the token is in the URL by definition. +func TestRedactStringRemovesTheCredentialFromTheURL(t *testing.T) { + // No t.Parallel: t.Setenv forbids it. + t.Setenv("TEST_MANDI_TOKEN", "s3cr3t") + + step := &Step{config: &Config{ + AuthScheme: AuthSchemeQuery, + QueryName: "token", + QueryValueEnv: "TEST_MANDI_TOKEN", + }} + got := step.redactString("http://host/v1/x?statecode=CG&token=s3cr3t") + if strings.Contains(got, "s3cr3t") { + t.Errorf("the credential survived redaction: %s", got) + } + if !strings.Contains(got, "REDACTED") || !strings.Contains(got, "statecode=CG") { + t.Errorf("redacted url = %q, want the credential replaced and the rest intact", got) + } + + // Any other scheme has nothing to hide in a URL, so the text is untouched. + plain := &Step{config: &Config{AuthScheme: AuthSchemeNone}} + if out := plain.redactString("http://host/v1/x?statecode=CG"); out != "http://host/v1/x?statecode=CG" { + t.Errorf("a url with no credential must pass through unchanged, got %q", out) + } +} + +// A credential that percent-encodes is the case the raw-value replacement +// missed, and it is not an exotic one: base64 routinely contains "+", "/" and +// "=", and a URL-safe token contains "-" and "_". authenticate builds the query +// with url.Values.Encode, so the escaped form is what reaches the wire and the +// error text -- redacting only what os.Getenv returned walked straight past it. +func TestRedactStringRemovesThePercentEncodedCredential(t *testing.T) { + // No t.Parallel: t.Setenv forbids it. + const token = "a+b/c=d e" // every character Encode treats specially + t.Setenv("TEST_MANDI_TOKEN", token) + + step := &Step{config: &Config{ + AuthScheme: AuthSchemeQuery, + QueryName: "token", + QueryValueEnv: "TEST_MANDI_TOKEN", + }} + + // Exactly how the credential appears once authenticate has run: Encode + // escapes it, so this is the string a transport error quotes. + query := url.Values{} + query.Set("token", token) + requested := "http://host/v1/x?statecode=CG&" + query.Encode() + + got := step.redactString(requested) + if strings.Contains(got, url.QueryEscape(token)) { + t.Errorf("the encoded credential survived redaction: %s", got) + } + if strings.Contains(got, token) { + t.Errorf("the raw credential survived redaction: %s", got) + } + if !strings.Contains(got, "REDACTED") || !strings.Contains(got, "statecode=CG") { + t.Errorf("redacted url = %q, want the credential replaced and the rest intact", got) + } +} + +// A value needing no escaping must still be redacted -- QueryEscape leaves it +// alone, so the encoded pass is a no-op and the raw pass has to carry it. +func TestRedactStringStillRemovesAnUnescapedCredential(t *testing.T) { + // No t.Parallel: t.Setenv forbids it. + t.Setenv("TEST_MANDI_TOKEN", "plaintoken123") + + step := &Step{config: &Config{ + AuthScheme: AuthSchemeQuery, + QueryName: "token", + QueryValueEnv: "TEST_MANDI_TOKEN", + }} + got := step.redactString("http://host/v1/x?token=plaintoken123") + if strings.Contains(got, "plaintoken123") { + t.Errorf("the credential survived redaction: %s", got) + } +} + +// Half a configuration is refused at startup, the same way the header scheme's +// is: a scheme that cannot present a credential would fail on every call. +func TestNewRefusesAHalfConfiguredQueryScheme(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + cfg *Config + }{ + {"no queryName", &Config{BindingKeys: []string{testBindingKey}, + AuthScheme: AuthSchemeQuery, QueryValueEnv: "TEST_MANDI_TOKEN"}}, + {"no queryValueEnv", &Config{BindingKeys: []string{testBindingKey}, + AuthScheme: AuthSchemeQuery, QueryName: "token"}}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + _, _, err := New(context.Background(), &stubRegistry{}, &stubMapper{}, nil, tc.cfg) + if err == nil { + t.Fatal("expected a half-configured query scheme to be refused") + } + if !strings.Contains(err.Error(), "queryName") { + t.Errorf("error %q should name what is missing", err) + } + }) + } +} + +// --- dispatch --------------------------------------------------------------- + +// Passing through is how dispatch works: several provider steps share a +// pipeline, and each must leave alone what is not its own. +func TestRunPassesThroughWhatIsNotItsCapability(t *testing.T) { + t.Parallel() + + testCases := []struct{ name, body string }{ + {"another provider", strings.Replace(selectBody, `"id": "mausamgram"`, `"id": "agmarknet"`, 1)}, + {"another capability", strings.Replace(selectBody, "openagrinet:WeatherObservation", "openagrinet:MarketPrice", 1)}, + {"a payload with no binding at all", `{"context":{"action":"select"},"message":{}}`}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + registry := &stubRegistry{err: errors.New("the registry must not be consulted")} + ctx, err := runStep(t, newStep(t, registry, &stubMapper{}), tc.body) + if err != nil { + t.Fatalf("Run() returned an unexpected error: %v", err) + } + if ctx.ResponseBody != nil { + t.Error("a passed-through request must not be answered") + } + }) + } +} + +func TestRunReportsAnUnreadablePayload(t *testing.T) { + t.Parallel() + + if _, err := runStep(t, newStep(t, &stubRegistry{}, &stubMapper{}), `{"message":`); err == nil { + t.Error("expected unreadable JSON to be reported") + } +} + +// --- the exchange ----------------------------------------------------------- + +func TestRunServesItsCapabilityEndToEnd(t *testing.T) { + t.Parallel() + + var gotQuery string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.RawQuery + fmt.Fprint(w, `{"fcstday1":{"rain":12.4}}`) + })) + defer upstream.Close() + + mapper := &stubMapper{ + requestResult: []byte(`{"lat":19.9975,"lon":73.7898}`), + responseResult: []byte(`{"context":{"action":"on_select"}}`), + } + registry := &stubRegistry{plan: testPlan(upstream.URL, http.MethodGet)} + + ctx, err := runStep(t, newStep(t, registry, mapper), selectBody) + if err != nil { + t.Fatalf("Run() returned an unexpected error: %v", err) + } + + if string(ctx.ResponseBody) != `{"context":{"action":"on_select"}}` { + t.Errorf("response body = %q, want the mapped answer", ctx.ResponseBody) + } + if !strings.Contains(gotQuery, "lat=19.9975") || !strings.Contains(gotQuery, "lon=73.7898") { + t.Errorf("upstream query = %q, want the mapped fields", gotQuery) + } + // Each leg asks for the action it deals in: the request translates a select, + // the response produces an on_select. Asking for the same name on both would + // make one file unable to hold both directions. + if want := []definition.Direction{definition.DirectionRequest, definition.DirectionResponse}; !slices.Equal(mapper.directions, want) { + t.Errorf("mapper was asked for %v, want %v", mapper.directions, want) + } + // Both halves come from the one file the action names. Two references here + // would mean the step had gone back to treating the legs as separate. + if want := []string{testMappingRef, testMappingRef}; !slices.Equal(mapper.refs, want) { + t.Errorf("mapper was handed %v, want both halves from %q", mapper.refs, testMappingRef) + } +} + +// The response mapping sees the point resolved before the call. The provider +// does not echo it back, so nothing else can supply it. +func TestRunKeepsResolvedValuesInScopeForTheResponse(t *testing.T) { + t.Parallel() + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"fcstday1":{"rain":12.4}}`) + })) + defer upstream.Close() + + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{}`)} + _, err := runStep(t, newStep(t, &stubRegistry{plan: testPlan(upstream.URL, http.MethodGet)}, mapper), selectBody) + if err != nil { + t.Fatalf("Run() returned an unexpected error: %v", err) + } + + input, ok := mapper.responseInput.(map[string]any) + if !ok { + t.Fatalf("response input = %T, want a map", mapper.responseInput) + } + for _, key := range []string{"beckn", "_local", "response"} { + if _, present := input[key]; !present { + t.Errorf("response mapping cannot see %q", key) + } + } + if len(input) != 3 { + t.Errorf("response input carries %v, want beckn, _local and response", keysOf(input)) + } + + // _local is empty when nothing was resolved, not absent: a mapping reading + // it on a capability with no prerequisites should find a missing field + // rather than fail. + local, ok := input["_local"].(map[string]any) + if !ok { + t.Fatalf("_local = %T, want a map", input["_local"]) + } + if len(local) != 0 { + t.Errorf("_local = %v, want empty -- this capability resolves nothing", local) + } +} + +// keysOf names what an input document carries, for a readable failure. +func keysOf(input map[string]any) []string { + names := make([]string, 0, len(input)) + for name := range input { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// The request leg is handed the payload and nothing else. +func TestRunGivesTheRequestMappingOnlyTheInboundPayload(t *testing.T) { + t.Parallel() + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{}`) + })) + defer upstream.Close() + + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{}`)} + if _, err := runStep(t, newStep(t, &stubRegistry{plan: testPlan(upstream.URL, http.MethodGet)}, mapper), selectBody); err != nil { + t.Fatalf("Run() returned an unexpected error: %v", err) + } + + input, ok := mapper.requestInput.(map[string]any) + if !ok { + t.Fatalf("request input = %T, want a map", mapper.requestInput) + } + if len(input) != 2 { + t.Errorf("request input carries %v, want beckn and _local", keysOf(input)) + } + for _, key := range []string{"beckn", "_local"} { + if _, present := input[key]; !present { + t.Errorf("request mapping cannot see %q", key) + } + } +} + +func TestRunSendsTheMappedBodyForAMethodThatTakesOne(t *testing.T) { + t.Parallel() + + var gotBody, gotType string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body := make([]byte, r.ContentLength) + _, _ = r.Body.Read(body) + gotBody = string(body) + gotType = r.Header.Get("Content-Type") + fmt.Fprint(w, `{}`) + })) + defer upstream.Close() + + mapper := &stubMapper{requestResult: []byte(`{"lat":19.9975}`), responseResult: []byte(`{}`)} + if _, err := runStep(t, newStep(t, &stubRegistry{plan: testPlan(upstream.URL, http.MethodPost)}, mapper), selectBody); err != nil { + t.Fatalf("Run() returned an unexpected error: %v", err) + } + + if gotBody != `{"lat":19.9975}` { + t.Errorf("upstream body = %q, want the mapped request", gotBody) + } + if gotType != "application/json" { + t.Errorf("content type = %q, want application/json", gotType) + } +} + +// The mapping decides what the provider is asked, so what it produces IS the +// request. Nothing is substituted when it produces nothing: an empty request +// half means an empty request, on a method with a body or without one. +// +// This step used to extract a point itself and fall back to sending it. That put +// the choice of which payload fields reach the provider in Go, so adding a +// parameter meant a rebuild. It is the mapping's now. +func TestRunSendsWhatTheMappingProducedAsQueryParameters(t *testing.T) { + t.Parallel() + + var gotQuery string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.RawQuery + fmt.Fprint(w, `{}`) + })) + defer upstream.Close() + + // Four fields, none of them known to this step: whatever the mapping named. + mapper := &stubMapper{ + requestResult: []byte(`{"lat":19.9975,"lon":73.7898,"from":"2026-08-30","to":"2026-09-03"}`), + responseResult: []byte(`{}`), + } + if _, err := runStep(t, newStep(t, &stubRegistry{plan: testPlan(upstream.URL, http.MethodGet)}, mapper), selectBody); err != nil { + t.Fatalf("Run() returned an unexpected error: %v", err) + } + + for _, want := range []string{"lat=19.9975", "lon=73.7898", "from=2026-08-30", "to=2026-09-03"} { + if !strings.Contains(gotQuery, want) { + t.Errorf("query %q is missing %q", gotQuery, want) + } + } +} + +// An empty request half on a method with no body means no query parameters. The +// step has nothing of its own to send in their place. +func TestRunSendsNoQueryWhenTheMappingProducesNothing(t *testing.T) { + t.Parallel() + + var gotQuery string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.RawQuery + fmt.Fprint(w, `{}`) + })) + defer upstream.Close() + + mapper := &stubMapper{requestResult: nil, responseResult: []byte(`{}`)} + if _, err := runStep(t, newStep(t, &stubRegistry{plan: testPlan(upstream.URL, http.MethodGet)}, mapper), selectBody); err != nil { + t.Fatalf("Run() returned an unexpected error: %v", err) + } + if gotQuery != "" { + t.Errorf("query = %q, want none -- nothing is substituted for an empty mapping", gotQuery) + } +} + +// A method that takes a body, and a mapping that produces nothing, means no +// body -- not the resolved values dressed up as one. Query parameters are the +// step's own doing; a body is the mapping's, and there is nothing to send. +func TestRunSendsNoBodyWhenTheMappingProducesNothing(t *testing.T) { + t.Parallel() + + var gotBody string + var gotLength int64 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotLength = r.ContentLength + body := make([]byte, 64) + n, _ := r.Body.Read(body) + gotBody = string(body[:n]) + fmt.Fprint(w, `{}`) + })) + defer upstream.Close() + + mapper := &stubMapper{requestResult: nil, responseResult: []byte(`{}`)} + if _, err := runStep(t, newStep(t, &stubRegistry{plan: testPlan(upstream.URL, http.MethodPost)}, mapper), selectBody); err != nil { + t.Fatalf("Run() returned an unexpected error: %v", err) + } + + if gotLength > 0 || gotBody != "" { + t.Errorf("upstream got a %d byte body (%q), want none", gotLength, gotBody) + } +} + +// A response half that produces nothing leaves no Beckn answer to return. +// Failing is the only honest outcome: answering with the provider's own shape +// would put a non-Beckn body on the wire under a valid signature. +func TestRunRefusesWhenTheResponseMappingProducesNothing(t *testing.T) { + t.Parallel() + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"fcstday1":{"rain":12.4}}`) + })) + defer upstream.Close() + + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: nil} + ctx, err := runStep(t, newStep(t, &stubRegistry{plan: testPlan(upstream.URL, http.MethodGet)}, mapper), selectBody) + if err == nil { + t.Fatal("expected an empty response mapping to be refused") + } + if len(ctx.ResponseBody) != 0 { + t.Errorf("ResponseBody = %q, want nothing written", ctx.ResponseBody) + } +} + +// --- the endpoint the registry publishes ------------------------------------- +// +// baseUrl and path are joined and sent. The registry constrains both, but it is +// a separate deployable that may not be updated in step, so a row that slipped +// through has to fail here with something an operator can act on rather than as +// a provider's 404 three hops away. +// +// An EMPTY SEGMENT is the case worth catching: "//get-daily" is never what +// anyone meant, and many servers answer it differently from "/get-daily". A +// TRAILING slash is left alone deliberately -- "/api/" and "/api" are a +// distinction some APIs genuinely make, so silently stripping it would change +// the URL the operator asked for. +func TestRunRefusesANonCanonicalPath(t *testing.T) { + t.Parallel() + + for _, path := range []string{"//get-daily", "/v1//get-daily", "/get-daily//"} { + t.Run(path, func(t *testing.T) { + t.Parallel() + + upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("the provider must not be called with a path nobody meant") + })) + defer upstreamServer.Close() + + plan := testPlan(upstreamServer.URL, http.MethodGet) + plan.Actions["select"] = model.ActionPlan{Method: http.MethodGet, Path: path, Mappings: testMappingRef} + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{}`)} + + _, err := runStep(t, newStep(t, &stubRegistry{plan: plan}, mapper), selectBody) + if err == nil { + t.Fatal("expected a path with an empty segment to be refused") + } + // Naming the path and the binding key is what makes this a one-line + // fix in the registry rather than a hunt. + if !strings.Contains(err.Error(), path) { + t.Errorf("error %q should name the path that is wrong", err) + } + }) + } +} + +// A trailing slash is meaningful, so it goes through untouched. +func TestRunKeepsATrailingSlashOnThePath(t *testing.T) { + t.Parallel() + + var gotPath string + upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + fmt.Fprint(w, `{}`) + })) + defer upstreamServer.Close() + + plan := testPlan(upstreamServer.URL, http.MethodGet) + plan.Actions["select"] = model.ActionPlan{Method: http.MethodGet, Path: "/get-daily/", Mappings: testMappingRef} + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{}`)} + + if _, err := runStep(t, newStep(t, &stubRegistry{plan: plan}, mapper), selectBody); err != nil { + t.Fatalf("Run() returned an unexpected error: %v", err) + } + if gotPath != "/get-daily/" { + t.Errorf("provider was called at %q, want the path exactly as published", gotPath) + } +} + +// The join itself: baseUrl cannot end in a slash and path must begin with one, +// so exactly one separator appears between them. Asserted so a change to either +// side cannot quietly produce a doubled or missing slash. +func TestBuildEndpointJoinsWithOneSlash(t *testing.T) { + t.Parallel() + + for _, tc := range []struct{ base, path, want string }{ + {"http://host:9100", "/get-daily", "http://host:9100/get-daily"}, + {"http://host:9100/api", "/get-daily", "http://host:9100/api/get-daily"}, + {"http://host:9100/", "/get-daily", "http://host:9100/get-daily"}, + } { + got, err := buildEndpoint(tc.base, model.ActionPlan{Method: http.MethodPost, Path: tc.path}, nil) + if err != nil { + t.Fatalf("buildEndpoint(%q, %q) returned an unexpected error: %v", tc.base, tc.path, err) + } + if got != tc.want { + t.Errorf("buildEndpoint(%q, %q) = %q, want %q", tc.base, tc.path, got, tc.want) + } + } +} + +// --- where the binding key lives ---------------------------------------------- +// +// A default, not a setting: every participant must agree where the halves of a +// binding key sit, or two adapters disagree about what a binding key is and +// requests silently fail to match. The override exists so a spec change can be +// tracked without waiting for a release, and has to be typed deliberately. + +func TestNewUsesTheBecknConventionByDefault(t *testing.T) { + t.Parallel() + + step := newStep(t, &stubRegistry{}, &stubMapper{}) + if step.paths != capabilitybinding.BecknV2 { + t.Errorf("paths = %+v, want the Beckn v2 convention", step.paths) + } +} + +// An override reads the halves from somewhere else, end to end through the step. +func TestRunReadsTheBindingKeyFromOverriddenPaths(t *testing.T) { + t.Parallel() + + upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{}`) + })) + defer upstreamServer.Close() + + plan := testPlan(upstreamServer.URL, http.MethodGet) + plan.BindingKey = "agmarknet|openagrinet:MandiPrice" + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{}`)} + + step := newStep(t, &stubRegistry{plan: plan}, mapper, func(c *Config) { + c.BindingKeys = []string{"agmarknet|openagrinet:MandiPrice"} + c.ProviderIDAt = "who.provider" + c.CapabilityCodeAt = "what[].type" + }) + + ctx, err := runStep(t, step, `{"context":{"action":"select"},"who":{"provider":"agmarknet"},"what":[{"type":"openagrinet:MandiPrice"}]}`) + if err != nil { + t.Fatalf("Run() returned an unexpected error: %v", err) + } + if len(ctx.ResponseBody) == 0 { + t.Error("the step did not recognise a binding key at the overridden paths") + } +} + +// Overriding one half and not the other is a half-configured deployment that +// would match nothing. Refused at startup rather than at every request. +func TestNewRefusesAHalfConfiguredOverride(t *testing.T) { + t.Parallel() + + _, _, err := New(context.Background(), &stubRegistry{}, &stubMapper{}, nil, &Config{ + BindingKeys: []string{testBindingKey}, + ProviderIDAt: "who.provider", + }) + if err == nil { + t.Fatal("expected one path without the other to be refused") + } + if !strings.Contains(err.Error(), "capabilityCodeAt") { + t.Errorf("error %q should name the path that is missing", err) + } +} + +// --- several capabilities, one step ------------------------------------------- +// +// A provider can serve more than one capability -- the registry contract says so +// outright: "a provider serving two capabilities is one Participant and two +// ProviderSchema rows". The step has to be able to answer to all of them. +// +// It used to hold a single binding key, so a second capability meant a second +// providerSteps entry with the same plugin id. Those collide in the handler's +// id-keyed step map and the second silently wins, which loses a capability with +// no error anywhere. + +func TestRunServesEveryBindingKeyItIsConfiguredFor(t *testing.T) { + t.Parallel() + + for _, capability := range []string{ + "openagrinet:WeatherObservation", + "openagrinet:WeatherAdvisory", + } { + t.Run(capability, func(t *testing.T) { + t.Parallel() + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{}`) + })) + defer upstream.Close() + + key := "mausamgram|" + capability + plan := testPlan(upstream.URL, http.MethodGet) + plan.BindingKey = key + + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{}`)} + step := newStep(t, &stubRegistry{plan: plan}, mapper, func(c *Config) { + c.BindingKeys = []string{ + "mausamgram|openagrinet:WeatherObservation", + "mausamgram|openagrinet:WeatherAdvisory", + } + }) + + body := strings.Replace(selectBody, "openagrinet:WeatherObservation", capability, 1) + ctx, err := runStep(t, step, body) + if err != nil { + t.Fatalf("Run() returned an unexpected error: %v", err) + } + if len(ctx.ResponseBody) == 0 { + t.Errorf("%s was not served, though the step is configured for it", key) + } + }) + } +} + +// A capability the step is not configured for still passes through untouched -- +// that is the dispatch mechanism, and widening to a list must not widen it into +// answering for everything. +func TestRunStillPassesThroughACapabilityItIsNotConfiguredFor(t *testing.T) { + t.Parallel() + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("the provider must not be called for another capability") + })) + defer upstream.Close() + + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{}`)} + step := newStep(t, &stubRegistry{plan: testPlan(upstream.URL, http.MethodGet)}, mapper, + func(c *Config) { c.BindingKeys = []string{"someone-else|openagrinet:MandiPrice"} }) + + ctx, err := runStep(t, step, selectBody) + if err != nil { + t.Fatalf("passing through must not be an error: %v", err) + } + if len(ctx.ResponseBody) != 0 { + t.Error("the step answered for a capability it is not configured for") + } +} + +// minimalConfig is the least a step needs: what it answers to. +func minimalConfig() *Config { + return &Config{BindingKeys: []string{testBindingKey}} +} + +// There is no default capability, and there cannot be a sensible one: this +// package serves whatever a domain package points it at, so a default would name +// one provider's capability and be wrong for every other domain built on it. +// Refused at startup, where an operator is watching. +func TestNewRequiresBindingKeys(t *testing.T) { + t.Parallel() + + _, _, err := New(context.Background(), &stubRegistry{}, &stubMapper{}, nil, &Config{}) + if err == nil { + t.Fatal("expected a step configured for no capability to be refused") + } + if !strings.Contains(err.Error(), "bindingKeys") { + t.Errorf("error %q should name the setting that is missing", err) + } +} + +// A binding key naming no capability is a config mistake that would otherwise +// make the step answer to a key nothing can produce. +func TestNewRefusesAnEmptyBindingKey(t *testing.T) { + t.Parallel() + + _, _, err := New(context.Background(), &stubRegistry{}, &stubMapper{}, nil, + &Config{BindingKeys: []string{testBindingKey, " "}}) + if err == nil { + t.Error("expected an empty binding key to be refused") + } +} + +// --- preconditions ---------------------------------------------------------- + +// selectWithLocation renders a select payload whose resource carries the given +// GeoJSON geometry verbatim, or none at all when geometry is empty. +func selectWithLocation(t *testing.T, geometry string) string { + t.Helper() + location := "" + if geometry != "" { + location = `"location": ` + geometry + `,` + } + return `{ + "context": { "version": "2.0.0", "action": "select", "transactionId": "txn-1" }, + "message": { "contract": { "commitments": [ { + "resources": [ { "id": "res:x", "resourceAttributes": { + ` + location + ` + "@type": "openagrinet:WeatherObservation" + } } ], + "offer": { "id": "offer:x", "provider": { "id": "mausamgram" } } + } ] } } +}` +} + +// What a payload must satisfy is the mapping's rule, not this step's. The step's +// job is to ask, and to stop when the answer is no -- without calling the +// provider. +// +// This step used to hold the rule itself: it read the geometry and required a +// Point. That meant a capability with a different rule needed a different build. +// Which geometries the shipped mapping accepts is now asserted in +// mappings_test.go, against the published file. +func TestRunRefusesWhenTheMappingsPreconditionFails(t *testing.T) { + t.Parallel() + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("the provider must not be called when a precondition failed") + })) + defer upstream.Close() + + refusal := model.NewBadReqErr("", errors.New("this capability needs a Point location")) + mapper := &stubMapper{verifyErr: refusal, requestResult: []byte(`{}`), responseResult: []byte(`{}`)} + + _, err := runStep(t, newStep(t, &stubRegistry{plan: testPlan(upstream.URL, http.MethodGet)}, mapper), + selectWithLocation(t, `{"type":"Polygon","coordinates":[[[73.0,19.0],[74.0,19.0],[74.0,20.0],[73.0,19.0]]]}`)) + if err == nil { + t.Fatal("expected a failed precondition to be refused") + } + // Propagated verbatim: the mapping's message is what the caller reads. + if !errors.Is(err, refusal) { + t.Errorf("error = %v, want the mapping's own refusal propagated", err) + } +} + +// Preconditions are checked before the request is built, so a mapping can refuse +// a payload its own request half could not have read. +func TestRunChecksPreconditionsBeforeBuildingTheRequest(t *testing.T) { + t.Parallel() + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("the provider must not be called when a precondition failed") + })) + defer upstream.Close() + + mapper := &stubMapper{ + verifyErr: model.NewBadReqErr("", errors.New("nope")), + requestResult: []byte(`{}`), + responseResult: []byte(`{}`), + } + if _, err := runStep(t, newStep(t, &stubRegistry{plan: testPlan(upstream.URL, http.MethodGet)}, mapper), + selectBody); err == nil { + t.Fatal("expected a refusal") + } + if mapper.requestInput != nil { + t.Error("the request half ran despite a failed precondition") + } +} + +// A mapping declaring no preconditions imposes none, and the step asks anyway -- +// so adopting the facility is per provider, not all at once. +func TestRunProceedsWhenTheMappingImposesNothing(t *testing.T) { + t.Parallel() + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{}`) + })) + defer upstream.Close() + + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{}`)} + if _, err := runStep(t, newStep(t, &stubRegistry{plan: testPlan(upstream.URL, http.MethodGet)}, mapper), + selectWithLocation(t, `{"type":"Polygon","coordinates":[[[1.0,2.0]]]}`)); err != nil { + t.Fatalf("a mapping imposing nothing must let a request through: %v", err) + } + if !mapper.verified { + t.Error("the step did not ask the mapping at all") + } +} + +// A mapping that failed is a failure, and must not be papered over by sending +// the resolved values instead. +func TestRunDoesNotSubstituteForARealMappingFailure(t *testing.T) { + t.Parallel() + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("the provider must not be called when the mapping failed") + })) + defer upstream.Close() + + wantErr := errors.New("mapping is broken") + mapper := &stubMapper{requestErr: wantErr, responseResult: []byte(`{}`)} + + _, err := runStep(t, newStep(t, &stubRegistry{plan: testPlan(upstream.URL, http.MethodGet)}, mapper), selectBody) + if !errors.Is(err, wantErr) { + t.Errorf("expected the mapping failure to propagate, got %v", err) + } +} + +// --- authentication --------------------------------------------------------- + +func TestRunPresentsBasicCredentialsFromTheEnvironment(t *testing.T) { + t.Setenv("TEST_MAUSAMGRAM_USER", "user-1") + t.Setenv("TEST_MAUSAMGRAM_KEY", "key-1") + + var gotUser, gotPass string + var hadAuth bool + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotUser, gotPass, hadAuth = r.BasicAuth() + fmt.Fprint(w, `{}`) + })) + defer upstream.Close() + + step := newStep(t, &stubRegistry{plan: testPlan(upstream.URL, http.MethodGet)}, + &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{}`)}, + func(c *Config) { + c.AuthScheme = AuthSchemeBasic + c.UsernameEnv = "TEST_MAUSAMGRAM_USER" + c.PasswordEnv = "TEST_MAUSAMGRAM_KEY" + }) + + if _, err := runStep(t, step, selectBody); err != nil { + t.Fatalf("Run() returned an unexpected error: %v", err) + } + if !hadAuth || gotUser != "user-1" || gotPass != "key-1" { + t.Errorf("basic auth = (%q, %q, present=%v), want the configured credentials", gotUser, gotPass, hadAuth) + } +} + +func TestRunPresentsAHeaderCredentialFromTheEnvironment(t *testing.T) { + t.Setenv("TEST_MAUSAMGRAM_TOKEN", "token-1") + + var gotHeader string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotHeader = r.Header.Get("X-Api-Key") + fmt.Fprint(w, `{}`) + })) + defer upstream.Close() + + step := newStep(t, &stubRegistry{plan: testPlan(upstream.URL, http.MethodGet)}, + &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{}`)}, + func(c *Config) { + c.AuthScheme = AuthSchemeHeader + c.HeaderName = "X-Api-Key" + c.HeaderValueEnv = "TEST_MAUSAMGRAM_TOKEN" + }) + + if _, err := runStep(t, step, selectBody); err != nil { + t.Fatalf("Run() returned an unexpected error: %v", err) + } + if gotHeader != "token-1" { + t.Errorf("X-Api-Key = %q, want token-1", gotHeader) + } +} + +// A configured credential that is not in the environment is a deployment fault, +// and must fail rather than call the provider unauthenticated. +func TestRunFailsWhenAConfiguredCredentialIsAbsent(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("the provider must not be called without its credentials") + })) + defer upstream.Close() + + step := newStep(t, &stubRegistry{plan: testPlan(upstream.URL, http.MethodGet)}, + &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{}`)}, + func(c *Config) { + c.AuthScheme = AuthSchemeBasic + c.UsernameEnv = "TEST_MAUSAMGRAM_ABSENT_USER" + c.PasswordEnv = "TEST_MAUSAMGRAM_ABSENT_KEY" + }) + + if _, err := runStep(t, step, selectBody); err == nil { + t.Error("expected a missing credential to fail the request") + } +} + +// --- failures --------------------------------------------------------------- + +func TestRunPropagatesAFailedLookup(t *testing.T) { + t.Parallel() + + registry := &stubRegistry{err: definition.ErrProviderRecordNotFound} + _, err := runStep(t, newStep(t, registry, &stubMapper{}), selectBody) + if !errors.Is(err, definition.ErrProviderRecordNotFound) { + t.Errorf("expected the lookup failure to propagate, got %v", err) + } +} + +func TestRunPropagatesAFailedMapping(t *testing.T) { + t.Parallel() + + wantErr := errors.New("mapping is broken") + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer upstream.Close() + + registry := &stubRegistry{plan: testPlan(upstream.URL, http.MethodGet)} + _, err := runStep(t, newStep(t, registry, &stubMapper{err: wantErr}), selectBody) + if !errors.Is(err, wantErr) { + t.Errorf("expected the mapping failure to propagate, got %v", err) + } +} + +func TestRunReportsAProviderThatWillNotAnswer(t *testing.T) { + t.Parallel() + + var attempts atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempts.Add(1) + w.WriteHeader(http.StatusInternalServerError) + })) + defer upstream.Close() + + plan := testPlan(upstream.URL, http.MethodGet) + plan.Actions["select"] = model.ActionPlan{Method: http.MethodGet, Path: "/get-daily", + Mappings: testMappingRef, RetryMax: 3} + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{}`)} + + _, err := runStep(t, newStep(t, &stubRegistry{plan: plan}, mapper), selectBody) + if err == nil { + t.Fatal("expected a failing provider to be reported") + } + // retryMax is retries, so the plan's 3 is the first call plus three more. + if got := attempts.Load(); got != 4 { + t.Errorf("made %d attempts, want 4 -- the call plus the plan's 3 retries", got) + } + + var coded *model.CodedErr + if !errors.As(err, &coded) || coded.HTTPStatus() != http.StatusBadGateway { + t.Errorf("expected a 502 coded error, got %v", err) + } +} + +// An action that leaves retryMax out is called once. The contract's default is +// zero retries, and it has to stay zero: a retry on a non-idempotent action is +// a second booking, so retrying is only ever what the operator asked for. +func TestRunDoesNotRetryUnlessTheActionSaysSo(t *testing.T) { + t.Parallel() + + var attempts atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempts.Add(1) + w.WriteHeader(http.StatusInternalServerError) + })) + defer upstream.Close() + + plan := testPlan(upstream.URL, http.MethodGet) + plan.Actions["select"] = model.ActionPlan{Method: http.MethodGet, Path: "/get-daily", + Mappings: testMappingRef} + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{}`)} + + if _, err := runStep(t, newStep(t, &stubRegistry{plan: plan}, mapper), selectBody); err == nil { + t.Fatal("expected a failing provider to be reported") + } + if got := attempts.Load(); got != 1 { + t.Errorf("made %d attempts, want 1 -- an absent retryMax must mean no retries", got) + } +} + +// A capability that publishes no endpoint for an action does not serve it. The +// refusal has to come before the call, not after one to the wrong place. +func TestRunRefusesAnActionWithNoEndpoint(t *testing.T) { + t.Parallel() + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("the provider must not be called for an action it does not serve") + })) + defer upstream.Close() + + // The plan serves select; the request is a confirm for the same capability. + confirmBody := strings.Replace(selectBody, `"action": "select"`, `"action": "confirm"`, 1) + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{}`)} + + _, err := runStep(t, newStep(t, &stubRegistry{plan: testPlan(upstream.URL, http.MethodGet)}, mapper), confirmBody) + if err == nil { + t.Fatal("expected an action with no endpoint to be refused") + } + if !strings.Contains(err.Error(), "confirm") { + t.Errorf("error %q should name the action that was asked for", err) + } + // Naming what the capability does serve turns a registry mistake into a + // one-line fix. + if !strings.Contains(err.Error(), "select") { + t.Errorf("error %q should say which actions the capability does serve", err) + } +} + +// Each action carries its own endpoint and budget: a confirm that commits +// rarely posts where a select that reads gets. +func TestRunUsesTheEndpointForTheRequestedAction(t *testing.T) { + t.Parallel() + + var gotPath, gotMethod string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath, gotMethod = r.URL.Path, r.Method + fmt.Fprint(w, `{}`) + })) + defer upstream.Close() + + plan := testPlan(upstream.URL, http.MethodGet) + plan.Actions["confirm"] = model.ActionPlan{Method: http.MethodPost, Path: "/book", TimeoutMs: 2000, RetryMax: 1} + + confirmBody := strings.Replace(selectBody, `"action": "select"`, `"action": "confirm"`, 1) + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{}`)} + + if _, err := runStep(t, newStep(t, &stubRegistry{plan: plan}, mapper), confirmBody); err != nil { + t.Fatalf("Run() returned an unexpected error: %v", err) + } + if gotMethod != http.MethodPost || gotPath != "/book" { + t.Errorf("called %s %s, want POST /book -- the select endpoint was used", gotMethod, gotPath) + } +} + +func TestRunReportsAProviderAnsweringWithNonJSON(t *testing.T) { + t.Parallel() + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "down for maintenance") + })) + defer upstream.Close() + + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{}`)} + _, err := runStep(t, newStep(t, &stubRegistry{plan: testPlan(upstream.URL, http.MethodGet)}, mapper), selectBody) + if err == nil { + t.Error("expected a non-JSON answer to be reported") + } +} + +func TestRunReportsARequestWithNoCoordinates(t *testing.T) { + t.Parallel() + + body := strings.Replace(selectBody, `"location": { "type": "Point", "coordinates": [73.7898, 19.9975] }`, `"location": {}`, 1) + registry := &stubRegistry{plan: testPlan("http://upstream.invalid", http.MethodGet)} + + _, err := runStep(t, newStep(t, registry, &stubMapper{}), body) + if err == nil { + t.Error("expected a request with no coordinates to be refused") + } +} + +// A mapping producing something that cannot become a query has to fail loudly: +// dropping the field would call the provider with the wrong question. +func TestRunRefusesAMappedQueryItCannotRender(t *testing.T) { + t.Parallel() + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("the provider must not be called with an incomplete query") + })) + defer upstream.Close() + + mapper := &stubMapper{requestResult: []byte(`{"box":{"nested":true}}`), responseResult: []byte(`{}`)} + _, err := runStep(t, newStep(t, &stubRegistry{plan: testPlan(upstream.URL, http.MethodGet)}, mapper), selectBody) + if err == nil { + t.Error("expected a non-scalar mapped field to be refused") + } +} + +// --- query rendering -------------------------------------------------------- + +func TestAsQueryRendersScalarsWithoutInventingPrecision(t *testing.T) { + t.Parallel() + + got, err := asQuery([]byte(`{"lat":19.9975,"count":3,"name":"imd","live":true}`)) + if err != nil { + t.Fatalf("asQuery() returned an unexpected error: %v", err) + } + for _, want := range []string{"lat=19.9975", "count=3", "name=imd", "live=true"} { + if !strings.Contains(got, want) { + t.Errorf("query %q is missing %q", got, want) + } + } +} + +func TestAsQueryHandlesAnEmptyMapping(t *testing.T) { + t.Parallel() + + got, err := asQuery([]byte(`{}`)) + if err != nil || got != "" { + t.Errorf("asQuery({}) = (%q, %v), want an empty query and no error", got, err) + } +} + +// Both bounds come from a registry row, so both are data. An attempt holds a +// goroutine and the inbound connection for its whole timeout, and the server's +// write timeout does not cancel the request context -- so retryMax 1000 with +// timeoutMs 60000 is one row deciding this process is busy for seventeen hours. +func TestBudgetClampsWhatTheRegistryAsksFor(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + timeoutMs int + retryMax int + wantTimeout time.Duration + wantRetries int + }{ + {"absent uses the contract's defaults", 0, 0, DefaultTimeout, DefaultRetryMax}, + {"within the ceilings is honoured", 2000, 3, 2 * time.Second, 3}, + {"exactly at the ceilings is honoured", int(MaxTimeout / time.Millisecond), MaxRetryMax, MaxTimeout, MaxRetryMax}, + {"a timeout past the ceiling is clamped", 600000, 0, MaxTimeout, DefaultRetryMax}, + {"retries past the ceiling are clamped", 0, 1000, DefaultTimeout, MaxRetryMax}, + {"both past the ceiling are clamped", 600000, 1000, MaxTimeout, MaxRetryMax}, + {"negative values fall back to the defaults", -1, -1, DefaultTimeout, DefaultRetryMax}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + gotTimeout, gotRetries := budget(model.ActionPlan{ + TimeoutMs: tt.timeoutMs, RetryMax: tt.retryMax, + }) + if gotTimeout != tt.wantTimeout { + t.Errorf("timeout = %v, want %v", gotTimeout, tt.wantTimeout) + } + if gotRetries != tt.wantRetries { + t.Errorf("retries = %d, want %d", gotRetries, tt.wantRetries) + } + }) + } +} + +// The shift this replaced overflowed int64 once it reached 38 at a 50ms base. +// The wrapped value is NEGATIVE, so it passed the ceiling check and was +// returned, and a sleep on a negative duration returns immediately -- the +// retry loop then spun as fast as the provider could refuse. Past 64 it +// yielded 0, with the same effect. Worse, it was not monotonic: attempt 40 +// wrapped back to a sane 800ms, so the symptom came and went by attempt count. +// +// The clamp on retryMax now keeps attempts to MaxRetryMax+1, which puts the +// overflow out of reach through call(). This is asserted anyway, because +// backoff is a package function and a ceiling somewhere else is not a +// property of this one. +func TestBackoffNeverReturnsANonPositiveDuration(t *testing.T) { + t.Parallel() + + for _, attempt := range []int{0, 1, 2, 3, 4, 5, 6, 37, 38, 39, 40, 63, 64, 65, 100, 1000} { + got := backoff(attempt) + if got <= 0 { + t.Errorf("backoff(%d) = %v; a non-positive wait makes the retry loop spin", attempt, got) + } + if got > RetryBackoffMax { + t.Errorf("backoff(%d) = %v, above the %v ceiling", attempt, got, RetryBackoffMax) + } + } +} + +// The doubling itself, which the overflow fix must not have changed. +func TestBackoffDoublesToTheCeiling(t *testing.T) { + t.Parallel() + + want := []time.Duration{ + 50 * time.Millisecond, // attempt 1 + 100 * time.Millisecond, // 2 + 200 * time.Millisecond, // 3 + 400 * time.Millisecond, // 4 + 800 * time.Millisecond, // 5, at the ceiling + 800 * time.Millisecond, // 6, held there + } + for i, w := range want { + if got := backoff(i + 1); got != w { + t.Errorf("backoff(%d) = %v, want %v", i+1, got, w) + } + } +} + +// redact used to return errors.New(text), which reported the right thing and +// broke errors.Is. The redacted value is what gets %w-wrapped into the final +// 502, so under a query-string scheme -- and only then -- a caller testing for +// a timeout stopped matching. Nothing failed visibly because retry +// classification tests the error before redaction. +func TestRedactKeepsTheErrorChainMatchable(t *testing.T) { + // No t.Parallel: t.Setenv forbids it. + t.Setenv("TEST_CHAIN_TOKEN", "s3cr3t") + + step := &Step{config: &Config{ + AuthScheme: AuthSchemeQuery, + QueryName: "token", + QueryValueEnv: "TEST_CHAIN_TOKEN", + }} + + // The shape net/http produces: the cause wrapped behind text that quotes + // the whole URL, credential and all. + original := fmt.Errorf(`Get "http://host/x?token=s3cr3t": %w`, context.DeadlineExceeded) + got := step.redact(original) + + if strings.Contains(got.Error(), "s3cr3t") { + t.Errorf("the credential survived redaction: %v", got) + } + if !strings.Contains(got.Error(), "REDACTED") { + t.Errorf("redacted error = %q, want the credential replaced", got) + } + if !errors.Is(got, context.DeadlineExceeded) { + t.Errorf("errors.Is lost the cause through redaction: %v", got) + } + // And wrapping it again, which is what the 502 does, must not undo either + // property. + wrapped := fmt.Errorf("upstream: provider did not answer: %w", got) + if strings.Contains(wrapped.Error(), "s3cr3t") { + t.Errorf("the credential reappeared once wrapped: %v", wrapped) + } + if !errors.Is(wrapped, context.DeadlineExceeded) { + t.Errorf("errors.Is lost the cause once wrapped: %v", wrapped) + } +} + +// Nothing to redact must return the error itself, not a copy: an error that +// needed no change should keep its identity so == and errors.Is on the value +// both still work. +func TestRedactLeavesAnUnchangedErrorAlone(t *testing.T) { + // No t.Parallel: t.Setenv forbids it. + t.Setenv("TEST_CHAIN_TOKEN_2", "s3cr3t") + + step := &Step{config: &Config{ + AuthScheme: AuthSchemeQuery, + QueryName: "token", + QueryValueEnv: "TEST_CHAIN_TOKEN_2", + }} + original := errors.New("nothing sensitive here") + if got := step.redact(original); got != original { + t.Errorf("redact returned a different error for text it did not change: %v", got) + } + if step.redact(nil) != nil { + t.Error("redact(nil) must stay nil") + } +} + +// hasBody upper-cased privately, which made the method look case-insensitive +// when it is not: NewRequestWithContext transmits it verbatim, so a registry +// row reading `method: "post"` sent `post /path HTTP/1.1`. The body was +// attached correctly, and nginx answered 405 -- classified permanent, and +// surfacing as a 502 "provider did not answer". +func TestCanonicalMethodFixesTheCaseTheRowWasWrittenIn(t *testing.T) { + t.Parallel() + + tests := []struct{ in, want string }{ + {"post", http.MethodPost}, + {"PoSt", http.MethodPost}, + {"POST", http.MethodPost}, + {"get", http.MethodGet}, + {"delete", http.MethodDelete}, + {"patch", http.MethodPatch}, + // Left alone: upper-casing everything would restrict an upstream + // entitled to a method this list has not heard of. + {"FrobNicate", "FrobNicate"}, + // Empty stays empty; net/http documents "" as GET and substitutes it. + {"", ""}, + } + for _, tt := range tests { + if got := canonicalMethod(tt.in); got != tt.want { + t.Errorf("canonicalMethod(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +// The end of it: what the provider actually receives on the request line. +func TestRunSendsTheMethodInCanonicalCase(t *testing.T) { + t.Parallel() + + var seen string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen = r.Method + fmt.Fprint(w, `{}`) + })) + defer upstream.Close() + + plan := testPlan(upstream.URL, http.MethodGet) + // A row written in the case an operator happened to type. + plan.Actions["select"] = model.ActionPlan{ + Method: "post", Path: "/x", Mappings: testMappingRef, + } + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{}`)} + step := newStep(t, &stubRegistry{plan: plan}, mapper) + + if _, err := runStep(t, step, selectBody); err != nil { + t.Fatalf("runStep returned an unexpected error: %v", err) + } + if seen != http.MethodPost { + t.Errorf("the provider saw method %q, want %q", seen, http.MethodPost) + } +} + +// The registry publishes two urls per action and only one of them was checked. +// jsonmapper has always validated its mapping reference this way; this is the +// same check on the base url beside it. +func TestVerifyBaseURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + baseURL string + wantErr bool + }{ + {"http is fine", "http://provider:9100", false}, + {"https is fine", "https://provider.example.com/api", false}, + {"empty is refused", "", true}, + // The case from the review: a scheme left off. Without this check it + // failed inside NewRequestWithContext and arrived as a 502. + {"a host and port with no scheme is refused", "registry:8081", true}, + {"a bare host is refused", "provider", true}, + {"a scheme that is not http is refused", "file:///etc/passwd", true}, + {"a scheme with no host is refused", "http://", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := verifyBaseURL(tt.baseURL) + if tt.wantErr && err == nil { + t.Errorf("verifyBaseURL(%q) = nil, want an error", tt.baseURL) + } + if !tt.wantErr && err != nil { + t.Errorf("verifyBaseURL(%q) = %v, want nil", tt.baseURL, err) + } + }) + } +} + +// A dot segment would be resolved by net/url, so the request that left would +// not be the request the row described. A fragment is never sent at all. +func TestVerifyPathRefusesDotSegmentsAndFragments(t *testing.T) { + t.Parallel() + + for _, path := range []string{ + "/../admin", + "/v1/../../etc", + "/v1/./get-daily", + "/get-daily#section", + } { + if err := verifyPath(path); err == nil { + t.Errorf("verifyPath(%q) = nil, want it refused", path) + } + } + // A dot inside a segment is an ordinary character and must still pass. + for _, path := range []string{"/v1/get-daily", "/v1/data.json", "/a..b"} { + if err := verifyPath(path); err != nil { + t.Errorf("verifyPath(%q) = %v, want nil", path, err) + } + } +} + +// The classification is the point. A row that cannot produce a request is a +// bad request, not a provider that failed to answer -- and it must not be +// retried on the way to being reported. +func TestRunReportsAnUnusableBaseURLAsABadRequest(t *testing.T) { + t.Parallel() + + plan := testPlan("registry:8081", http.MethodGet) // scheme left off + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{}`)} + step := newStep(t, &stubRegistry{plan: plan}, mapper) + + _, err := runStep(t, step, selectBody) + if err == nil { + t.Fatal("expected an unusable base url to be reported") + } + var coded *model.CodedErr + if !errors.As(err, &coded) || coded.HTTPStatus() != http.StatusBadRequest { + t.Errorf("error = %v, want a bad request rather than a bad gateway", err) + } + if strings.Contains(err.Error(), "did not answer") { + t.Errorf("error = %v; a row that cannot build a request is not the provider failing", err) + } +} + +// Redaction has to cover the scheme a deployment actually configured, and the +// reference config ships basic. It used to cover only query, so a basic or +// header credential echoed by a provider went to the log in the clear at warn +// level -- and a wrong-credential 4xx is not retried, so that repeats once per +// request for as long as the credential is wrong. +// +// The three bodies here are the ordinary shapes: an API gateway quoting the +// Authorization header it rejected, a provider naming the password, and one +// naming the custom header's value. +func TestRedactStringCoversEveryScheme(t *testing.T) { + // No t.Parallel: t.Setenv forbids it. + t.Setenv("TEST_USER", "mausam") + t.Setenv("TEST_PASS", "s3cr3t") + t.Setenv("TEST_HDR", "hdr-k3y") + t.Setenv("TEST_QRY", "a+b/c=") + + // What SetBasicAuth actually puts on the wire. + wire := base64.StdEncoding.EncodeToString([]byte("mausam:s3cr3t")) + + for _, tc := range []struct { + name string + tweak func(*Config) + body string + secret string + wantOut string + }{ + { + name: "basic, the wire form a gateway echoes", + tweak: func(c *Config) { + c.AuthScheme = AuthSchemeBasic + c.UsernameEnv, c.PasswordEnv = "TEST_USER", "TEST_PASS" + }, + body: `{"error":"invalid Authorization: Basic ` + wire + `"}`, + secret: wire, + }, + { + name: "basic, the password quoted raw", + tweak: func(c *Config) { + c.AuthScheme = AuthSchemeBasic + c.UsernameEnv, c.PasswordEnv = "TEST_USER", "TEST_PASS" + }, + body: `{"error":"bad password s3cr3t"}`, + secret: "s3cr3t", + }, + { + name: "header, the value as sent", + tweak: func(c *Config) { + c.AuthScheme = AuthSchemeHeader + c.HeaderName, c.HeaderValueEnv = "X-API-Key", "TEST_HDR" + }, + body: `{"error":"bad X-API-Key: hdr-k3y"}`, + secret: "hdr-k3y", + }, + { + name: "query, still covered, raw form", + tweak: func(c *Config) { + c.AuthScheme = AuthSchemeQuery + c.QueryName, c.QueryValueEnv = "token", "TEST_QRY" + }, + body: `{"rejected":"token=a+b/c="}`, + secret: "a+b/c=", + }, + { + name: "query, still covered, percent-encoded form", + tweak: func(c *Config) { + c.AuthScheme = AuthSchemeQuery + c.QueryName, c.QueryValueEnv = "token", "TEST_QRY" + }, + body: `{"rejected":"token=` + url.QueryEscape("a+b/c=") + `"}`, + secret: url.QueryEscape("a+b/c="), + }, + } { + t.Run(tc.name, func(t *testing.T) { + step := newStep(t, &stubRegistry{plan: testPlan("http://provider.invalid", http.MethodGet)}, + &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{}`)}, tc.tweak) + + // The same expression the non-2xx path logs. + logged := step.redactString(explain([]byte(tc.body))) + + if strings.Contains(logged, tc.secret) { + t.Errorf("the credential survives into the log line: %s", logged) + } + if !strings.Contains(logged, redactedMarker) { + t.Errorf("logged = %q, want the credential replaced", logged) + } + }) + } +} + +// A username identifies rather than authenticates, and is routinely a short +// common word -- redacting it would eat unrelated text and cost the operator +// the line they came for. Pinned so the choice is deliberate rather than an +// oversight someone "fixes" without noticing what it costs. +func TestRedactStringLeavesTheBasicUsername(t *testing.T) { + t.Setenv("TEST_USER", "mausam") + t.Setenv("TEST_PASS", "s3cr3t") + + step := newStep(t, &stubRegistry{plan: testPlan("http://provider.invalid", http.MethodGet)}, + &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{}`)}, + func(c *Config) { + c.AuthScheme = AuthSchemeBasic + c.UsernameEnv, c.PasswordEnv = "TEST_USER", "TEST_PASS" + }) + + got := step.redactString(`user mausam failed to authenticate with s3cr3t`) + if strings.Contains(got, "s3cr3t") { + t.Errorf("the password survived: %s", got) + } + if !strings.Contains(got, "mausam") { + t.Errorf("got %q, want the username kept -- it is what makes the line useful", got) + } +} + +// Nothing configured, nothing to hide: an unset credential must not turn every +// empty string in the text into a redaction marker. +func TestRedactStringWithNoCredentialConfigured(t *testing.T) { + for _, scheme := range []string{AuthSchemeNone, AuthSchemeBasic, AuthSchemeHeader, AuthSchemeQuery} { + t.Run(scheme, func(t *testing.T) { + const text = `{"error":"provider said no"}` + step := newStep(t, &stubRegistry{plan: testPlan("http://provider.invalid", http.MethodGet)}, + &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{}`)}, + func(c *Config) { + c.AuthScheme = scheme + // Env vars named but deliberately unset. + c.UsernameEnv, c.PasswordEnv = "TEST_UNSET_U", "TEST_UNSET_P" + c.HeaderName, c.HeaderValueEnv = "X-K", "TEST_UNSET_H" + c.QueryName, c.QueryValueEnv = "t", "TEST_UNSET_Q" + }) + + if got := step.redactString(text); got != text { + t.Errorf("redactString() = %q, want the text unchanged", got) + } + }) + } +} + +// _local is part of the mapping interface, and until now nothing asserted it. +// Every shipped plugin declares an empty Prerequisites map, so the whole path +// was dead code: the first provider to add a real prerequisite would have found +// out at runtime whether its resolved values reach the mapping at all. +// +// Both legs, because a resolved value is usually needed on the way back too -- +// a code looked up to make the call is what names the thing in the answer. +func TestRunHandsResolvedPrerequisitesToTheMappingAsLocal(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"ok":true}`) + })) + defer upstream.Close() + + prerequisites := Prerequisites{ + testBindingKey: func(context.Context, any) (map[string]any, error) { + return map[string]any{"stationId": "42", "marketCode": "2056"}, nil + }, + } + + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{"answered":true}`)} + step, closer, err := New(context.Background(), + &stubRegistry{plan: testPlan(upstream.URL, http.MethodGet)}, mapper, prerequisites, + &Config{BindingKeys: []string{testBindingKey}}) + if err != nil { + t.Fatalf("New() returned an unexpected error: %v", err) + } + t.Cleanup(func() { _ = closer() }) + + if _, err := runStep(t, step, selectBody); err != nil { + t.Fatalf("Run() returned an unexpected error: %v", err) + } + + for _, leg := range []struct { + name string + input any + }{ + {name: "request", input: mapper.requestInput}, + {name: "response", input: mapper.responseInput}, + } { + t.Run(leg.name+" leg", func(t *testing.T) { + asMap, ok := leg.input.(map[string]any) + if !ok { + t.Fatalf("%s leg input = %T, want a map", leg.name, leg.input) + } + local, present := asMap["_local"].(map[string]any) + if !present { + t.Fatalf("%s leg carries no _local; a resolved prerequisite never reaches the mapping", leg.name) + } + if local["stationId"] != "42" || local["marketCode"] != "2056" { + t.Errorf("_local = %v, want both resolved values", local) + } + }) + } +} + +// With no prerequisites -- every plugin shipped today -- _local is present and +// empty rather than absent, so a mapping referring to it reads nothing instead +// of failing on an unknown name. +func TestRunPassesAnEmptyLocalWhenThereAreNoPrerequisites(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"ok":true}`) + })) + defer upstream.Close() + + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{"answered":true}`)} + step := newStep(t, &stubRegistry{plan: testPlan(upstream.URL, http.MethodGet)}, mapper) + + if _, err := runStep(t, step, selectBody); err != nil { + t.Fatalf("Run() returned an unexpected error: %v", err) + } + + asMap, ok := mapper.requestInput.(map[string]any) + if !ok { + t.Fatalf("request input = %T, want a map", mapper.requestInput) + } + local, present := asMap["_local"].(map[string]any) + if !present { + t.Fatal("_local is absent; a mapping referring to it would fail rather than read nothing") + } + if len(local) != 0 { + t.Errorf("_local = %v, want it empty", local) + } +} diff --git a/pkg/plugin/implementation/jsonmapper/README.md b/pkg/plugin/implementation/jsonmapper/README.md new file mode 100644 index 00000000..e837331a --- /dev/null +++ b/pkg/plugin/implementation/jsonmapper/README.md @@ -0,0 +1,208 @@ +# JSON Mapper Plugin + +A **mapper plugin** for Beckn-ONIX that transforms one JSON document into another +using a JSONata mapping fetched at runtime. + +## Overview + +Implements `definition.Mapper`. Given a mapping reference and an input, it +fetches, compiles, caches and runs whatever is there. + +It is domain-free by design: it knows nothing about who is calling, nothing about +what a mapping says, and nothing about the payloads passing through. Anything +specific to a network or a provider belongs in the caller, which is what lets one +mapper serve all of them. + +Its first caller is the provider flow, where it translates between Beckn +payloads and each provider's own request and response shapes -- so adding a +provider is one mapping file and a registry row rather than another +transformation routine. + +It is **not** a pipeline step. A provider plugin holds it and calls it twice -- +once to build the upstream request, once to turn the answer back into Beckn. +That is what lets one plugin own the whole exchange while the translation stays +generic. + +## Mapping files + +One file per binding-action, carrying **both directions** and what the capability +requires of a payload: + +```yaml +# mappings/mausamgram/weather-observation.select.yaml +required: + - check: | + ( $ra := beckn.message.contract.commitments[0].resources[0].resourceAttributes; + $ra.location.type = "Point" ) + message: "this capability needs a Point location" + +request: | + { + "lat": beckn.message.contract.commitments[0].resources[0].resourceAttributes.location.coordinates[1] + } + +response: | + { + "rainfall": response.fcstday1.rain, + "at": response.location + } +``` + +The three keys are checked and run in the order they appear. + +**One file rather than two because both legs of one upstream call are one unit of +configuration.** They are published, reviewed and retired together, and a +reference to one is a reference to the other. It also means the response leg is +already fetched and compiled by the time it is needed — one round trip, one fetch. + +**A half that is absent or empty produces nothing, with no error.** What nothing +means belongs to the caller: on the request leg it means there is no document to +send. A half that will not compile is a different thing and reported as an error — +the two must not collapse, or an unmapped upstream answer would go out as a Beckn +response. + +A broken half takes down only itself: a typo in the response mapping is no reason +to stop making the call, and finding out on the way back beats finding out before +the call was made. + +### `required` + +A list of preconditions, each a JSONata predicate and the message to refuse with. +Named `check`/`message` so each field says what it is: `check` is what has to +hold, `message` is what the caller is told when it does not. A neutral name like +`test` says nothing about which way the predicate must answer, and `otherwise` +reads like an alternative value rather than an error. + +**It exists because a mapping otherwise cannot refuse.** Without it, every +judgement about whether a payload can be served at all lives in Go — so a +provider with its own rule needs its own build, and the rule ends up in a +different place from the extraction it guards. + +`Verify` evaluates them in order and **the first failure is the one reported**. +Reporting the last, or all of them, buries the thing to fix. + +Four ways this refuses rather than passing quietly: + +- a predicate that is false → the caller gets its `message`, as a `400` +- a predicate answering anything but `true`/`false` → a mapping fault, **not** + permission. A typo yielding nothing would otherwise wave through every request + the check existed to stop. +- a predicate with no `message` → a mapping fault. Refusing without saying why + is the failure this key exists to avoid. +- a predicate that will not compile → its own error, and it does not take the + halves down with it + +Each predicate is a separate expression, so each binds its own variables — there +is no shared scope with the halves. + +**A mapping declaring no `required` imposes nothing.** That is deliberate: it lets +one provider adopt preconditions while others have not, so a second provider +arriving with its own rules does not force every existing mapping to be rewritten. + +The consequence is worth stating plainly: **nothing in the adapter enforces a +payload rule any more.** Whatever a mapping does not require, it accepts. That is +the point — the rule is configuration — and it is why the responsibility sits in +the published file. + +Which action a file serves is settled by the registry entry pointing at it, so +nothing inside names it and the filename carries no meaning to this plugin. (The +registry contract does require the filename's action segment to match the action +it sits under — that is checked where the records are written.) + +### References + +The registry carries the full URL of one published file, and this plugin fetches +it verbatim. Anything that is not a fetchable `http`/`https` URL — a bare path, a +`file://`, a URL with no host — is refused: a reference is external input, and an +unchecked one would let a registry record name a local file and have the adapter +read it. + +**What that check cannot do is constrain which host.** A registry record chooses +that, and this plugin fetches, *compiles* and runs what comes back. So who may +write a registry record is part of this plugin's threat model. (A reference +carried as a path under an operator-configured root would close that off; the +network has not settled on a fixed location for published mappings, so the URL +stays in the record for now.) + +## What a mapping can read + +| key | request leg | response leg | +|---|---|---| +| `beckn` | the inbound Beckn payload | the inbound Beckn payload | +| `response` | — | the provider's raw answer | +| `_local` | what prerequisites resolved | what prerequisites resolved | + +**What a party sent, plus what a payload could not carry.** `_local` holds values +the provider plugin resolved before the call — a code looked up from a name, a +point resolved to a market — for the case where a mapping needs them and neither +party sent them. It is an empty map when the plugin has no prerequisites, which is +every plugin shipped today, so a mapping referring to `_local` reads nothing rather +than failing. + +What does **not** go in `_local`: values the plugin already holds and merely used to +make the call. Reading those back through a mapping is a detour and a second name for +the same data. Where the answer needs one, it takes it from what the provider echoed. + +## Why the direction is a parameter, not a convention + +The caller makes one round trip and needs both halves of it, and nothing in the +file distinguishes them by position. Passing the direction explicitly is what +keeps a response mapping from ever being applied to an outbound request — +which would succeed quietly and produce nonsense. + +Making the action a key in the file rather than a part of its name means the file +states which actions it serves, instead of a convention someone has to remember. + +## Configuration + +```yaml +mapper: + id: jsonmapper + config: + fetchTimeout: 5s + cacheTTL: 1h + negativeTTL: 1m + maxMappingBytes: "262144" + maxCacheEntries: "200" +``` + +Every setting is optional; the defaults above are what the plugin applies. + +`negativeTTL` is how long a failed fetch is remembered. Without it a broken +reference turns every inbound request into an outbound one. + +`maxMappingBytes` caps what is read from a mapping host. References come from the +registry, so an unbounded read is an unbounded allocation driven by whoever can +write a registry record. + +## Caching, and why evaluation takes a lock + +A compiled expression is code, not data, so it cannot live in the shared Redis +cache. It is held in memory, keyed by reference, bounded by `maxCacheEntries`. + +`jsonata.Expression.Evaluate` **mutates the expression it is called on** — it +binds into the expression's own frame — so one compiled mapping cannot serve two +requests at once. Confirmed with the race detector, not assumed. + +Evaluation therefore takes a per-mapping lock. That is the cheaper trade by a +wide margin: evaluation is ~22µs against ~184µs to compile, and both are dwarfed +by the upstream call the mapped request goes on to make. Different mappings still +run in parallel. A pool of compiled expressions would remove even that, and is +the upgrade if one mapping ever becomes hot enough to matter. + +## Failure + +A mapping that cannot be fetched, parsed or compiled is an operator or registry +fault and surfaces as a plain error. A mapping that ran but could not be applied +surfaces as `SCH_SCHEMA_ADAPTATION_FAILED` — but **the HTTP status depends on +which half failed**, and the difference matters if you classify on that code: + +- **request half → 400.** The input is the caller's own payload, so its shape + being wrong is the caller's to fix. +- **response half → 502.** The input there is the PROVIDER's answer, not + anything the caller sent. A provider that changed shape, or a bug in the + response mapping, is nothing the caller did — telling them to fix a request + that was fine sends them after the wrong thing. + +So do not treat the code as uniformly 4xx for retry or alerting: an +upstream-shape failure carrying it is a 502. diff --git a/pkg/plugin/implementation/jsonmapper/cmd/plugin.go b/pkg/plugin/implementation/jsonmapper/cmd/plugin.go new file mode 100644 index 00000000..a58ea44e --- /dev/null +++ b/pkg/plugin/implementation/jsonmapper/cmd/plugin.go @@ -0,0 +1,115 @@ +package main + +import ( + "context" + "errors" + "fmt" + "strconv" + "time" + + "github.com/beckn-one/beckn-onix/pkg/log" + "github.com/beckn-one/beckn-onix/pkg/plugin/definition" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/jsonmapper" +) + +// jsonMapperProvider implements definition.MapperProvider. +type jsonMapperProvider struct{} + +// newMapperFunc creates a new mapper. Indirected for tests. +var newMapperFunc = jsonmapper.New + +// parseConfig turns the plugin config map into a typed Config. Anything absent +// is left zero: jsonmapper.New applies the defaults, so they live in one place. + +func (o jsonMapperProvider) parseConfig(config map[string]string) (*jsonmapper.Config, error) { + cfg := &jsonmapper.Config{} + + if err := parseDuration(config, "fetchTimeout", &cfg.FetchTimeout); err != nil { + return nil, err + } + if err := parseDuration(config, "cacheTTL", &cfg.CacheTTL); err != nil { + return nil, err + } + if err := parseDuration(config, "negativeTTL", &cfg.NegativeTTL); err != nil { + return nil, err + } + if err := parseInt64(config, "maxMappingBytes", &cfg.MaxMappingBytes); err != nil { + return nil, err + } + + if raw, exists := config["maxCacheEntries"]; exists && raw != "" { + value, err := strconv.Atoi(raw) + if err != nil { + return nil, fmt.Errorf("invalid maxCacheEntries value '%s': %w", raw, err) + } + if value <= 0 { + return nil, fmt.Errorf("maxCacheEntries must be positive, got %d", value) + } + cfg.MaxCacheEntries = value + } + + return cfg, nil +} + +// parseDuration reads an optional duration setting into target. +func parseDuration(config map[string]string, key string, target *time.Duration) error { + raw, exists := config[key] + if !exists || raw == "" { + return nil + } + value, err := time.ParseDuration(raw) + if err != nil { + return fmt.Errorf("invalid %s value '%s': %w", key, raw, err) + } + if value <= 0 { + return fmt.Errorf("%s must be positive, got %v", key, value) + } + *target = value + return nil +} + +// parseInt64 reads an optional byte-count setting into target. +func parseInt64(config map[string]string, key string, target *int64) error { + raw, exists := config[key] + if !exists || raw == "" { + return nil + } + value, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return fmt.Errorf("invalid %s value '%s': %w", key, raw, err) + } + if value <= 0 { + return fmt.Errorf("%s must be positive, got %d", key, value) + } + *target = value + return nil +} + +// New creates a new JSON mapper plugin instance. +func (o jsonMapperProvider) New(ctx context.Context, config map[string]string) (definition.Mapper, func() error, error) { + if ctx == nil { + return nil, nil, errors.New("context cannot be nil") + } + + cfg, err := o.parseConfig(config) + if err != nil { + log.Errorf(ctx, err, "Failed to parse JSON mapper configuration") + return nil, nil, fmt.Errorf("failed to parse oan mapper configuration: %w", err) + } + + mapper, closer, err := newMapperFunc(ctx, cfg) + if err != nil { + log.Errorf(ctx, err, "Failed to create JSON mapper instance") + return nil, nil, err + } + + log.Infof(ctx, "JSON mapper instance created successfully") + return mapper, closer, nil +} + +// Provider is the exported plugin instance. +var Provider = jsonMapperProvider{} + +// Compile-time proof the provider satisfies the interface the manager asserts +// against. A mismatch is otherwise a runtime cast failure at startup. +var _ definition.MapperProvider = Provider diff --git a/pkg/plugin/implementation/jsonmapper/cmd/plugin_test.go b/pkg/plugin/implementation/jsonmapper/cmd/plugin_test.go new file mode 100644 index 00000000..3b979202 --- /dev/null +++ b/pkg/plugin/implementation/jsonmapper/cmd/plugin_test.go @@ -0,0 +1,168 @@ +package main + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + "time" + + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/jsonmapper" +) + +func TestParseConfig(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + config map[string]string + expected *jsonmapper.Config + expectedErr string + }{ + { + // Everything absent is left zero on purpose: jsonmapper.New applies + // the defaults, so they are defined in exactly one place. + name: "leaves everything unset for New to default", + config: map[string]string{}, + expected: &jsonmapper.Config{}, + }, + { + name: "reads every supported setting", + config: map[string]string{ + "fetchTimeout": "3s", + "cacheTTL": "30m", + "negativeTTL": "45s", + "maxMappingBytes": "1024", + "maxCacheEntries": "50", + }, + expected: &jsonmapper.Config{ + FetchTimeout: 3 * time.Second, + CacheTTL: 30 * time.Minute, + NegativeTTL: 45 * time.Second, + MaxMappingBytes: 1024, + MaxCacheEntries: 50, + }, + }, + { + name: "ignores empty values", + config: map[string]string{"fetchTimeout": "", "maxCacheEntries": ""}, + expected: &jsonmapper.Config{}, + }, + { + name: "rejects a malformed duration", + config: map[string]string{"fetchTimeout": "soon"}, + expectedErr: "invalid fetchTimeout value 'soon'", + }, + { + // Zero would mean "no timeout", which is the opposite of what an + // operator writing 0 expects. + name: "rejects a non-positive duration", + config: map[string]string{"fetchTimeout": "0s"}, + expectedErr: "fetchTimeout must be positive", + }, + { + name: "rejects a malformed byte cap", + config: map[string]string{"maxMappingBytes": "lots"}, + expectedErr: "invalid maxMappingBytes value 'lots'", + }, + { + name: "rejects a non-positive byte cap", + config: map[string]string{"maxMappingBytes": "0"}, + expectedErr: "maxMappingBytes must be positive", + }, + { + name: "rejects a malformed cache size", + config: map[string]string{"maxCacheEntries": "many"}, + expectedErr: "invalid maxCacheEntries value 'many'", + }, + { + name: "rejects a non-positive cache size", + config: map[string]string{"maxCacheEntries": "0"}, + expectedErr: "maxCacheEntries must be positive", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := jsonMapperProvider{}.parseConfig(tc.config) + + if tc.expectedErr != "" { + if err == nil { + t.Fatalf("expected error %q but got none", tc.expectedErr) + } + if !strings.Contains(err.Error(), tc.expectedErr) { + t.Errorf("expected error containing %q, got %q", tc.expectedErr, err.Error()) + } + return + } + + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if !reflect.DeepEqual(got, tc.expected) { + t.Errorf("expected config %+v, got %+v", tc.expected, got) + } + }) + } +} + +func TestNew(t *testing.T) { + t.Parallel() + + t.Run("rejects a nil context", func(t *testing.T) { + t.Parallel() + + //nolint:staticcheck // deliberately passing a nil context to assert the guard. + _, _, err := jsonMapperProvider{}.New(nil, map[string]string{}) + if err == nil { + t.Fatal("expected an error for a nil context, got none") + } + }) + + t.Run("rejects an unparseable config", func(t *testing.T) { + t.Parallel() + + _, _, err := jsonMapperProvider{}.New(context.Background(), map[string]string{"cacheTTL": "soon"}) + if err == nil { + t.Fatal("expected an error for an invalid cacheTTL, got none") + } + }) + + t.Run("builds a mapper from an empty config", func(t *testing.T) { + t.Parallel() + + mapper, closer, err := jsonMapperProvider{}.New(context.Background(), map[string]string{}) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if mapper == nil { + t.Fatal("expected a mapper, got nil") + } + if closer == nil { + t.Fatal("expected a closer, got nil") + } + if err := closer(); err != nil { + t.Errorf("expected the closer to succeed, got: %v", err) + } + }) + + // Deliberately NOT parallel: this swaps the package-level newMapperFunc, so + // running it alongside its parallel siblings would race on that variable. + t.Run("propagates a construction failure", func(t *testing.T) { + original := newMapperFunc + t.Cleanup(func() { newMapperFunc = original }) + + wantErr := errors.New("boom") + newMapperFunc = func(context.Context, *jsonmapper.Config) (*jsonmapper.Mapper, func() error, error) { + return nil, nil, wantErr + } + + _, _, err := jsonMapperProvider{}.New(context.Background(), map[string]string{}) + if !errors.Is(err, wantErr) { + t.Errorf("expected the construction error to propagate, got %v", err) + } + }) +} diff --git a/pkg/plugin/implementation/jsonmapper/jsonmapper.go b/pkg/plugin/implementation/jsonmapper/jsonmapper.go new file mode 100644 index 00000000..4ade3194 --- /dev/null +++ b/pkg/plugin/implementation/jsonmapper/jsonmapper.go @@ -0,0 +1,657 @@ +// Package jsonmapper transforms one JSON document into another using a JSONata +// mapping fetched at runtime, so that translating between two parties' shapes is +// configuration rather than code. +// +// It is domain-free by design: it knows nothing about who is calling, nothing +// about what a mapping says, and nothing about the payloads passing through. It +// is handed a reference and an input, and it fetches, compiles, caches and runs +// whatever is there. Anything specific to a network or a provider belongs in the +// caller, which is what lets one mapper serve all of them. +package jsonmapper + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "golang.org/x/sync/singleflight" + + "github.com/beckn-one/beckn-onix/pkg/log" + "github.com/beckn-one/beckn-onix/pkg/model" + "github.com/beckn-one/beckn-onix/pkg/plugin/definition" + "github.com/jsonata-go/jsonata" + "gopkg.in/yaml.v2" +) + +// Defaults applied when an operator leaves a setting out. +const ( + DefaultFetchTimeout = 5 * time.Second + DefaultMaxMappingBytes = 256 << 10 // 256 KiB, far above any realistic mapping + DefaultCacheTTL = time.Hour + DefaultNegativeTTL = time.Minute + DefaultMaxCacheEntries = 200 +) + +// codeAdaptationFailed reports a mapping that ran but could not produce a +// result. It is the payload's shape that is wrong, so it is a bad request +// rather than a fault of this adapter. +const codeAdaptationFailed = "SCH_SCHEMA_ADAPTATION_FAILED" + +// mappingFile is the published form of a mapping: one binding-action, both +// directions. +// +// One file rather than two because both legs of one upstream call are one unit +// of configuration: they are published, reviewed and retired together, and a +// reference to one is a reference to the other. Which action the file serves is +// decided by the registry entry that points at it, so nothing inside names it. +// +// A half may be absent or empty. That is not an omission to report -- it means +// there is no transform for that direction, and what that means belongs to the +// caller. +type mappingFile struct { + // Required are the preconditions this binding-action imposes on a payload, + // verified before either half runs. Absent means none. + Required []requirement `yaml:"required"` + Request string `yaml:"request"` + Response string `yaml:"response"` +} + +// requirement is one precondition: what must hold, and what to tell the caller +// when it does not. +// +// Named check/message so each field says what it is: check is the predicate +// that has to hold, message is what the caller is told when it does not. A +// neutral name like "test" says nothing about which way the predicate must +// answer, and "otherwise" reads like an alternative value rather than an error. +// +// The message is required. A precondition that refuses without saying why is +// the failure this whole facility exists to avoid -- the caller is left with +// "rejected" and no way to act on it. +type requirement struct { + Check string `yaml:"check"` + Message string `yaml:"message"` +} + +// Config holds configuration parameters for the mapper. +type Config struct { + // FetchTimeout bounds a single mapping fetch. A mapping host that accepts + // the connection and then goes quiet must not hold a request open. + FetchTimeout time.Duration `yaml:"fetchTimeout" json:"fetchTimeout"` + + // MaxMappingBytes caps what is read from a mapping host. References come + // from the registry, so an unbounded read is an unbounded allocation driven + // by whoever can write a registry record. + MaxMappingBytes int64 `yaml:"maxMappingBytes" json:"maxMappingBytes"` + + // CacheTTL is how long a compiled mapping is reused. It is also how long a + // corrected mapping takes to take effect. + CacheTTL time.Duration `yaml:"cacheTTL" json:"cacheTTL"` + + // NegativeTTL is how long a failed fetch is remembered. Without it a broken + // reference turns every inbound request into an outbound one. + NegativeTTL time.Duration `yaml:"negativeTTL" json:"negativeTTL"` + + // MaxCacheEntries bounds the cache, which would otherwise grow with the + // number of capabilities ever seen. + MaxCacheEntries int `yaml:"maxCacheEntries" json:"maxCacheEntries"` +} + +// evaluateLocked runs one expression under the package lock. +// +// The unlock is DEFERRED, not written after the call. A panic inside the +// library -- and this is a library we have already found a data race in -- +// would otherwise leave the mutex held with nothing to release it. Because the +// lock is package-wide, that is not one wedged mapping but every mapping in +// the process, for every provider step, until a restart. Widening the lock +// widened that blast radius, so the defer matters more here than it did when +// the lock was per mapping. +// +// A helper rather than a defer at each call site, because the precondition +// loop evaluates once per check: a defer there would release only when the +// whole loop returned, holding the lock across every check in the file. +// +// The panic is converted to an error rather than re-raised. net/http recovers +// a panic per connection, so re-raising costs the caller its connection with +// no NACK and nothing in our log naming the mapping -- for what is, from the +// caller's side, indistinguishable from a mapping that could not be applied. +// Reported as one, so it lands where the fault is. +func evaluateLocked(expr jsonata.Expression, document []byte) (result []byte, err error) { + evaluating.Lock() + defer evaluating.Unlock() + + // Registered after the unlock so it runs BEFORE it: recover, name the + // failure, then release. + defer func() { + if recovered := recover(); recovered != nil { + result = nil + err = fmt.Errorf("jsonata evaluation panicked: %v", recovered) + } + }() + + return expr.Evaluate(document, nil) +} + +// evaluating serialises every Evaluate in this package, across all mappings. +// +// It has to be this wide. Evaluate mutates more than the expression it is +// called on: the library keeps its built-in functions in a package-level frame +// (v206's staticFrame), and applying one writes token and position onto that +// shared *Function for error reporting. Every mapping uses built-ins, so any +// two concurrent evaluations race -- including two DIFFERENT mappings, which a +// per-mapping lock explicitly allowed to run in parallel. That was this code's +// previous shape, and it was wrong. +// +// Measured, not reasoned about: eight goroutines, each with its own +// jsonata.OpenLatest() instance and its own compiled expression, evaluating an +// expression shaped like the shipped mappings, produce race reports under +// -race. Separate instances are not separate state, so nothing narrower than +// package scope is sufficient. +// +// The cost is real and bounded: mapping evaluation no longer overlaps, at ~22us +// a call, while the upstream HTTP request each mapped call goes on to make is +// outside this lock and dominates. If it ever does matter, the fix is upstream +// -- the shared writes are error-reporting metadata that could be carried on +// the call rather than the function -- not a narrower lock here. +// +// reqmapper and schemaversionmediator evaluate JSONata too and have the same +// exposure. Not addressed here: they are separate plugins with their own +// owners, and this lock cannot reach across a .so boundary anyway. +var evaluating sync.Mutex + +// cacheEntry is one compiled mapping, or the failure that stopped it compiling. +// Failures are cached too, which is the whole point of the negative TTL. +// +// Evaluation is serialised process-wide by evaluating, above -- see there for +// why it cannot be per mapping. +// +// Evaluating under a lock rather than compiling per request is the cheaper +// trade by a wide margin: evaluation is ~22us against ~184us to compile, and +// both are dwarfed by the upstream call the mapped request goes on to make. +type cacheEntry struct { + // directions holds the compiled halves the file carries. A file is fetched + // and compiled as a whole, so both are ready after the first request for + // either. + directions map[definition.Direction]*compiledMapping + // checks are the file's preconditions, in the order it declared them. + checks []*compiledRequirement + // err is a failure that applies to the whole file -- it could not be + // fetched, or not parsed -- as opposed to one action failing to compile. + err error + expiresAt time.Time +} + +// compiledRequirement is one precondition, or the failure that stopped it +// compiling. Held per requirement for the same reason a half is: a broken +// precondition is the mapping's fault and should be reported as one, without +// taking the halves down with it. +type compiledRequirement struct { + expression jsonata.Expression + message string + err error +} + +// compiledMapping is one half of a mapping, or the failure that stopped it +// compiling. Failures are held per half deliberately: a typo in the response +// mapping is no reason for the request half to stop working, and finding out on +// the way out beats finding out before the call was even made. +// +// A nil expression with no error is a half the file carries no transform for -- +// held rather than treated as absent, so "there is no request half" and "the +// file could not be read" stay different answers. +type compiledMapping struct { + expression jsonata.Expression + err error +} + +// hasTransform reports whether this half has something to run. +func (m *compiledMapping) hasTransform() bool { + return m != nil && m.expression != nil +} + +// Mapper fetches, compiles and runs mappings. It is safe for concurrent use: +// one mapper serves every inbound request. +type Mapper struct { + config *Config + httpClient *http.Client + instance jsonata.JSONataInstance + + mu sync.RWMutex + entries map[string]cacheEntry + + // inflight collapses concurrent misses for the same reference into one + // fetch. Not part of the cache: it holds work in progress, and an entry + // that has been stored is served by entries above without reaching it. + inflight singleflight.Group +} + +// New creates a Mapper, applying defaults for anything left unset. +func New(ctx context.Context, cfg *Config) (*Mapper, func() error, error) { + if cfg == nil { + return nil, nil, errors.New("jsonmapper: config cannot be nil") + } + applyDefaults(cfg) + + instance, err := jsonata.OpenLatest() + if err != nil { + return nil, nil, fmt.Errorf("jsonmapper: failed to open jsonata: %w", err) + } + + mapper := &Mapper{ + config: cfg, + httpClient: &http.Client{Timeout: cfg.FetchTimeout}, + instance: instance, + entries: make(map[string]cacheEntry), + } + + closer := func() error { + log.Debugf(ctx, "Cleaning up JSON mapper resources") + mapper.httpClient.CloseIdleConnections() + return nil + } + + log.Infof(ctx, "JSON mapper created successfully") + return mapper, closer, nil +} + +// applyDefaults fills in every setting an operator left out. +func applyDefaults(cfg *Config) { + if cfg.FetchTimeout <= 0 { + cfg.FetchTimeout = DefaultFetchTimeout + } + if cfg.MaxMappingBytes <= 0 { + cfg.MaxMappingBytes = DefaultMaxMappingBytes + } + if cfg.CacheTTL <= 0 { + cfg.CacheTTL = DefaultCacheTTL + } + if cfg.NegativeTTL <= 0 { + cfg.NegativeTTL = DefaultNegativeTTL + } + if cfg.MaxCacheEntries <= 0 { + cfg.MaxCacheEntries = DefaultMaxCacheEntries + } +} + +// Transform runs one direction of the mapping at mappingRef over input. +func (m *Mapper) Transform(ctx context.Context, mappingRef string, direction definition.Direction, input any) ([]byte, error) { + if direction != definition.DirectionRequest && direction != definition.DirectionResponse { + return nil, fmt.Errorf("jsonmapper: mapping %q: %q is not a direction; want %q or %q", + mappingRef, direction, definition.DirectionRequest, definition.DirectionResponse) + } + + entry, err := m.compiled(ctx, mappingRef) + if err != nil { + return nil, err + } + + mapping, present := entry.directions[direction] + if !present { + return nil, fmt.Errorf("jsonmapper: mapping %q carries no %s half", mappingRef, direction) + } + if mapping.err != nil { + return nil, mapping.err + } + if !mapping.hasTransform() { + // Nothing to apply. That is an answer, not a failure: the caller decides + // what an absent transform means for the leg it is on. + log.Debugf(ctx, "JSON mapping %s carries no %s transform", mappingRef, direction) + return nil, nil + } + return m.evaluate(ctx, mapping, mappingRef, direction, input) +} + +// Verify checks the preconditions the mapping declares, in the order declared. +// +// The first failure is the one reported. Reporting the last, or all of them, +// buries the thing to fix. +func (m *Mapper) Verify(ctx context.Context, mappingRef string, input any) error { + entry, err := m.compiled(ctx, mappingRef) + if err != nil { + return err + } + if len(entry.checks) == 0 { + return nil + } + + document, err := marshalInput(input) + if err != nil { + return fmt.Errorf("jsonmapper: mapping %q: %w", mappingRef, err) + } + + for _, precondition := range entry.checks { + if precondition.err != nil { + return precondition.err + } + + // See evaluateLocked: serialised across the package, and released even + // if the library panics. + result, evalErr := evaluateLocked(precondition.expression, document) + if evalErr != nil { + log.Errorf(ctx, evalErr, "JSON mapping %s precondition failed to evaluate: %v", mappingRef, evalErr) + return model.NewBadReqErr(codeAdaptationFailed, fmt.Errorf( + "mapping %q precondition could not be applied: %w", mappingRef, evalErr)) + } + + holds, answered := asBool(result) + if !answered { + // Not read as permission. A typo yielding nothing would otherwise + // wave every request through the check meant to stop it. + return fmt.Errorf("jsonmapper: mapping %q precondition answered %q, want true or false", + mappingRef, result) + } + if !holds { + // The mapping's own words: the caller is told what is wrong with + // their payload, not that an expression somewhere returned false. + return model.NewBadReqErr("", errors.New(precondition.message)) + } + } + return nil +} + +// asBool reads a precondition's answer. JSONata yields JSON, so a predicate +// answers with the two literals and nothing else counts. +func asBool(result []byte) (bool, bool) { + switch strings.TrimSpace(string(result)) { + case "true": + return true, true + case "false": + return false, true + default: + return false, false + } +} + +// compiled returns the compiled mapping for a reference, fetching and compiling +// it on first use. A failure is cached too, for a shorter time. +func (m *Mapper) compiled(ctx context.Context, mappingRef string) (cacheEntry, error) { + if entry, found := m.cached(mappingRef); found { + return entry, entry.err + } + + // One fetch per reference, however many requests miss at once. + // + // Without this, a cold start sends every concurrent request for the same + // capability to fetch the mapping over HTTP and then compile it. The + // compile is the cheaper half: the round trip is bounded by fetchTimeout, + // so fifty requests arriving together waited fifty times for the same + // document instead of once, and the publisher saw fifty identical reads. + // + // The shared call inherits the FIRST caller's context, which is + // singleflight's known trade: if that caller goes away the work is + // cancelled for everyone waiting on it. Bounded here by fetchTimeout, and + // the losers see a cancellation they can retry rather than a wrong answer. + shared, err, _ := m.inflight.Do(mappingRef, func() (any, error) { + // Re-checked inside the group: a concurrent store may have landed + // between the miss above and the turn to run, and reusing it is both + // cheaper and more consistent than fetching a second copy. + if entry, found := m.cached(mappingRef); found { + return entry, entry.err + } + directions, checks, err := m.fetchAndCompile(ctx, mappingRef) + return m.remember(mappingRef, directions, checks, err), err + }) + entry, _ := shared.(cacheEntry) + return entry, err +} + +// cached returns a live cache entry, if there is one. +func (m *Mapper) cached(mappingRef string) (cacheEntry, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + + entry, found := m.entries[mappingRef] + if !found || time.Now().After(entry.expiresAt) { + return cacheEntry{}, false + } + return entry, true +} + +// remember caches a compiled mapping, or the failure that stopped it compiling. +// A failure gets the shorter TTL: it should stop hammering a broken reference +// without outlasting the fix. +func (m *Mapper) remember(mappingRef string, directions map[definition.Direction]*compiledMapping, + checks []*compiledRequirement, err error) cacheEntry { + ttl := m.config.CacheTTL + if err != nil { + ttl = m.config.NegativeTTL + } + entry := cacheEntry{ + directions: directions, + checks: checks, + err: err, + expiresAt: time.Now().Add(ttl), + } + + m.mu.Lock() + defer m.mu.Unlock() + + // Expired entries are dropped before the cap is measured. Without this the + // map only ever grows: cached() treats an expiry as a miss but leaves the + // entry behind, so the count reaches the cap and then refuses every ref it + // is not already holding -- permanently, for the life of the process. The + // cost is not "compile again next time" but compile every time, for every + // mapping the deployment has. + // + // A sweep rather than an eviction policy: the entries are few, this runs + // only on a store, and it drops nothing that was still usable. + m.purgeExpired() + + // Bounded rather than evicting: references come from the registry, and a + // deployment serving more live capabilities than the cap wants a bigger + // cap, not a cache that silently thrashes. The entry is still returned to + // its caller when it is not stored, so a request over the cap is served + // rather than refused -- it just pays to compile again next time. + if len(m.entries) >= m.config.MaxCacheEntries { + if _, replacing := m.entries[mappingRef]; !replacing { + return entry + } + } + m.entries[mappingRef] = entry + return entry +} + +// purgeExpired drops entries past their TTL. The caller holds m.mu. +func (m *Mapper) purgeExpired() { + now := time.Now() + for ref, entry := range m.entries { + if now.After(entry.expiresAt) { + delete(m.entries, ref) + } + } +} + +// cachedCount reports how many mappings are held. Used by tests to assert the +// cache stays bounded. +func (m *Mapper) cachedCount() int { + m.mu.RLock() + defer m.mu.RUnlock() + return len(m.entries) +} + +// fetchAndCompile retrieves a mapping and turns it into a runnable expression. +func (m *Mapper) fetchAndCompile(ctx context.Context, mappingRef string) ( + map[definition.Direction]*compiledMapping, []*compiledRequirement, error) { + body, err := m.fetch(ctx, mappingRef) + if err != nil { + return nil, nil, err + } + file, err := parseMapping(body) + if err != nil { + return nil, nil, fmt.Errorf("jsonmapper: mapping %q: %w", mappingRef, err) + } + + // Preconditions compile with the halves, so one round trip leaves the whole + // file ready and a precondition costs no extra fetch. + checks := make([]*compiledRequirement, 0, len(file.Required)) + for _, declared := range file.Required { + checks = append(checks, m.compileRequirement(ctx, mappingRef, declared)) + } + + // Both halves are compiled now rather than on first use, so one fetch leaves + // the file ready in both directions. A compile failure is recorded against + // its own half and goes no further. + directions := make(map[definition.Direction]*compiledMapping, 2) + directions[definition.DirectionRequest] = m.compileMapping(ctx, mappingRef, definition.DirectionRequest, file.Request) + directions[definition.DirectionResponse] = m.compileMapping(ctx, mappingRef, definition.DirectionResponse, file.Response) + log.Debugf(ctx, "JSON mapper compiled mapping: %s (%d precondition(s))", mappingRef, len(checks)) + return directions, checks, nil +} + +// compileRequirement compiles one precondition, keeping any failure local to it. +func (m *Mapper) compileRequirement(ctx context.Context, mappingRef string, declared requirement) *compiledRequirement { + if strings.TrimSpace(declared.Message) == "" { + return &compiledRequirement{err: fmt.Errorf( + "jsonmapper: mapping %q declares a precondition with no message", mappingRef)} + } + if strings.TrimSpace(declared.Check) == "" { + return &compiledRequirement{err: fmt.Errorf( + "jsonmapper: mapping %q declares a precondition with no check", mappingRef)} + } + expression, err := m.instance.Compile(declared.Check, false) + if err != nil { + log.Errorf(ctx, err, "JSON mapper could not compile a precondition of %s: %v", mappingRef, err) + return &compiledRequirement{err: fmt.Errorf( + "jsonmapper: mapping %q precondition %q failed to compile: %w", mappingRef, declared.Check, err)} + } + return &compiledRequirement{ + expression: expression, + message: declared.Message, + } +} + +// compileMapping compiles one half, keeping any failure local to it. +func (m *Mapper) compileMapping(ctx context.Context, mappingRef string, direction definition.Direction, source string) *compiledMapping { + if strings.TrimSpace(source) == "" { + // No transform for this direction. Not an error: a request half is + // legitimately empty when the caller builds its own request. + return &compiledMapping{} + } + expression, err := m.instance.Compile(source, false) + if err != nil { + log.Errorf(ctx, err, "JSON mapper could not compile the %s half of %s: %v", direction, mappingRef, err) + return &compiledMapping{err: fmt.Errorf("jsonmapper: mapping %q %s half failed to compile: %w", mappingRef, direction, err)} + } + return &compiledMapping{expression: expression} +} + +// fetch retrieves a mapping's bytes, bounded in both time and size. +func (m *Mapper) fetch(ctx context.Context, mappingRef string) ([]byte, error) { + if err := verifyFetchable(mappingRef); err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, mappingRef, nil) + if err != nil { + return nil, fmt.Errorf("jsonmapper: failed to build request for mapping %q: %w", mappingRef, err) + } + + log.Debugf(ctx, "Fetching mapping: %s", mappingRef) + resp, err := m.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("jsonmapper: failed to fetch mapping %q: %w", mappingRef, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + log.Errorf(ctx, nil, "Mapping fetch failed with status: %s, mapping: %s", resp.Status, mappingRef) + return nil, fmt.Errorf("jsonmapper: mapping %q returned %s", mappingRef, resp.Status) + } + + // LimitReader with one spare byte, so an oversized body is refused rather + // than silently truncated into a mapping that compiles to something else. + body, err := io.ReadAll(io.LimitReader(resp.Body, m.config.MaxMappingBytes+1)) + if err != nil { + return nil, fmt.Errorf("jsonmapper: failed to read mapping %q: %w", mappingRef, err) + } + if int64(len(body)) > m.config.MaxMappingBytes { + return nil, fmt.Errorf("jsonmapper: mapping %q exceeds the %d byte limit", mappingRef, m.config.MaxMappingBytes) + } + return body, nil +} + +// verifyFetchable rejects a reference this mapper will not retrieve. +// +// A reference is a fully-qualified http or https URL, carried verbatim from the +// registry. That makes it external input, so it is checked rather than trusted: +// without this a registry record could name a file path or an internal scheme +// and have the adapter read it. What the check cannot constrain is WHICH host -- +// a registry record chooses that, and this mapper compiles and runs what comes +// back from it. Who may write a registry record is therefore part of this +// plugin's threat model, not an unrelated concern. +func verifyFetchable(mappingRef string) error { + if mappingRef == "" { + return errors.New("jsonmapper: mapping reference is empty") + } + parsed, err := url.Parse(mappingRef) + if err != nil { + return fmt.Errorf("jsonmapper: invalid mapping reference %q: %w", mappingRef, err) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return fmt.Errorf("jsonmapper: mapping reference %q must be http or https", mappingRef) + } + if parsed.Host == "" { + return fmt.Errorf("jsonmapper: mapping reference %q names no host", mappingRef) + } + return nil +} + +// parseMapping reads the two halves a published mapping carries. +func parseMapping(body []byte) (mappingFile, error) { + var file mappingFile + if err := yaml.Unmarshal(body, &file); err != nil { + return mappingFile{}, fmt.Errorf("could not be parsed: %w", err) + } + if strings.TrimSpace(file.Request) == "" && strings.TrimSpace(file.Response) == "" { + // Neither half present at all -- not an empty request, which is + // meaningful, but a file that says nothing. + return mappingFile{}, errors.New("carries neither a request nor a response half") + } + return file, nil +} + +// marshalInput renders the named inputs a mapping reads -- beckn and, on the +// response leg, response -- as the single JSON document JSONata evaluates +// against. +func marshalInput(input any) ([]byte, error) { + document, err := json.Marshal(input) + if err != nil { + return nil, fmt.Errorf("input could not be encoded: %w", err) + } + return document, nil +} + +// evaluate runs a compiled mapping over the input document. +func (m *Mapper) evaluate(ctx context.Context, mapping *compiledMapping, mappingRef string, direction definition.Direction, input any) ([]byte, error) { + document, err := marshalInput(input) + if err != nil { + return nil, fmt.Errorf("jsonmapper: mapping %q: %w", mappingRef, err) + } + + // See evaluateLocked: serialised across the package, because the library's + // shared built-ins make even two different mappings unsafe to overlap. + // Marshalling above is deliberately outside the lock. + result, err := evaluateLocked(mapping.expression, document) + if err != nil { + log.Errorf(ctx, err, "JSON mapping %s %s half failed to evaluate: %v", mappingRef, direction, err) + wrapped := fmt.Errorf("mapping %q %s half could not be applied: %w", mappingRef, direction, err) + if direction == definition.DirectionResponse { + // The input here is the PROVIDER's answer, not the caller's + // request. A provider that changed shape, or a bug in the response + // half, is nothing the caller did -- reporting 400 sends them off + // to fix a request that was fine. 502: the upstream exchange is + // what failed. + return nil, model.NewCodedErr(http.StatusBadGateway, codeAdaptationFailed, wrapped) + } + // On the request leg the mapping is valid and the payload is not what it + // expected, so this is the caller's request being wrong. + return nil, model.NewBadReqErr(codeAdaptationFailed, wrapped) + } + return result, nil +} diff --git a/pkg/plugin/implementation/jsonmapper/jsonmapper_test.go b/pkg/plugin/implementation/jsonmapper/jsonmapper_test.go new file mode 100644 index 00000000..04313d6d --- /dev/null +++ b/pkg/plugin/implementation/jsonmapper/jsonmapper_test.go @@ -0,0 +1,1085 @@ +package jsonmapper + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/beckn-one/beckn-onix/pkg/model" + "github.com/beckn-one/beckn-onix/pkg/plugin/definition" +) + +// bothDirections is the published form: one binding-action, both halves. +// +// A mapping reads only what a party sent -- the inbound payload, and on the way +// back the provider's answer. Values a provider plugin resolved before the call +// are not passed in: the plugin holds them already and uses them directly, so +// routing them through the mapping would be a detour. +const bothDirections = `request: | + { "txn": beckn.context.transactionId } + +response: | + { "txn": beckn.context.transactionId, "rain": response.fcstday1.rain } +` + +// requestOnly is the shape of a mapping whose answer needs no translation, or +// whose response half has not been written yet. +const requestOnly = `request: | + { "txn": beckn.context.transactionId } +` + +func requestInput() map[string]any { + return map[string]any{ + "beckn": map[string]any{"context": map[string]any{"transactionId": "txn-123", "messageId": "msg-1"}}, + } +} + +func responseInput() map[string]any { + input := requestInput() + input["response"] = map[string]any{"fcstday1": map[string]any{"rain": 12.4}} + return input +} + +// newMappingServer serves body at every path and counts what was asked for. +func newMappingServer(t *testing.T, body string, fetches *atomic.Int32) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if fetches != nil { + fetches.Add(1) + } + fmt.Fprint(w, body) + })) +} + +func newTestMapper(t *testing.T, tweak ...func(*Config)) *Mapper { + t.Helper() + + cfg := &Config{ + FetchTimeout: 2 * time.Second, + MaxMappingBytes: DefaultMaxMappingBytes, + CacheTTL: time.Minute, + NegativeTTL: time.Minute, + MaxCacheEntries: DefaultMaxCacheEntries, + } + for _, apply := range tweak { + apply(cfg) + } + + mapper, closer, err := New(context.Background(), cfg) + if err != nil { + t.Fatalf("New() returned an unexpected error: %v", err) + } + t.Cleanup(func() { _ = closer() }) + return mapper +} + +// ref is what the registry carries: the fully-qualified URL of one published +// file. Which action it serves is decided by the registry entry pointing at it, +// so the name carries no meaning here. +func ref(base string) string { + return base + "/mappings/mausamgram/weather-observation.select.yaml" +} + +// --- transformation -------------------------------------------------------- + +func TestTransformRunsTheRequestHalf(t *testing.T) { + t.Parallel() + + srv := newMappingServer(t, bothDirections, nil) + defer srv.Close() + + got, err := newTestMapper(t).Transform(context.Background(), ref(srv.URL), definition.DirectionRequest, requestInput()) + if err != nil { + t.Fatalf("Transform() returned an unexpected error: %v", err) + } + + var result map[string]any + if err := json.Unmarshal(got, &result); err != nil { + t.Fatalf("failed to decode the result: %v", err) + } + if result["txn"] != "txn-123" { + t.Errorf("txn = %v, want txn-123 -- beckn was not reachable", result["txn"]) + } +} + +// The response half reads the upstream answer under response, alongside the +// original payload under beckn -- the context to echo and the offer to quote +// against are only in the request that produced the answer. +func TestTransformRunsTheResponseHalfAlongsideTheRequest(t *testing.T) { + t.Parallel() + + srv := newMappingServer(t, bothDirections, nil) + defer srv.Close() + + got, err := newTestMapper(t).Transform(context.Background(), ref(srv.URL), definition.DirectionResponse, responseInput()) + if err != nil { + t.Fatalf("Transform() returned an unexpected error: %v", err) + } + + var result map[string]any + if err := json.Unmarshal(got, &result); err != nil { + t.Fatalf("failed to decode the result: %v", err) + } + for _, field := range []struct { + key string + want any + }{{"txn", "txn-123"}, {"rain", 12.4}} { + if result[field.key] != field.want { + t.Errorf("%s = %v, want %v", field.key, result[field.key], field.want) + } + } +} + +// The two halves are separate expressions, not one applied twice. +func TestTransformKeepsTheHalvesApart(t *testing.T) { + t.Parallel() + + mapping := `request: | + { "leg": "out" } + +response: | + { "leg": "back" } +` + srv := newMappingServer(t, mapping, nil) + defer srv.Close() + mapper := newTestMapper(t) + + out, err := mapper.Transform(context.Background(), ref(srv.URL), definition.DirectionRequest, requestInput()) + if err != nil { + t.Fatalf("request: %v", err) + } + back, err := mapper.Transform(context.Background(), ref(srv.URL), definition.DirectionResponse, responseInput()) + if err != nil { + t.Fatalf("response: %v", err) + } + + if !strings.Contains(string(out), `"out"`) { + t.Errorf("request produced %s, want the request half's output", out) + } + if !strings.Contains(string(back), `"back"`) { + t.Errorf("response produced %s, want the response half's output", back) + } +} + +// --- a half with no transform ---------------------------------------------- + +// A half that is absent, or present and empty, has no transform to apply. That +// is not a failure and not a special case: it produces nothing, and the caller +// decides what nothing means for the leg it is on. A request half with no +// transform means no request document -- so no body. +func TestTransformProducesNothingForAHalfWithNoTransform(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + mapping string + }{ + {"the half is absent", requestOnly}, + {"the half is present and empty", "request: |\n { \"txn\": beckn.context.transactionId }\nresponse: \"\"\n"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + srv := newMappingServer(t, tc.mapping, nil) + defer srv.Close() + mapper := newTestMapper(t) + + got, err := mapper.Transform(context.Background(), ref(srv.URL), definition.DirectionResponse, responseInput()) + if err != nil { + t.Errorf("a half with no transform is not an error, got %v", err) + } + if len(got) != 0 { + t.Errorf("produced %q, want nothing", got) + } + + // The other half is unaffected: one direction having no transform + // says nothing about the other. + out, err := mapper.Transform(context.Background(), ref(srv.URL), definition.DirectionRequest, requestInput()) + if err != nil { + t.Errorf("the other half must still be served: %v", err) + } + if len(out) == 0 { + t.Error("the other half produced nothing, want its output") + } + }) + } +} + +// Nothing and a failure must stay distinguishable. A half that will not compile +// produces an error, not nothing -- reading it as nothing would send an unmapped +// upstream answer out as a Beckn response. +func TestTransformSeparatesNothingFromAFailure(t *testing.T) { + t.Parallel() + + mapping := `request: "" + +response: | + {{{ +` + srv := newMappingServer(t, mapping, nil) + defer srv.Close() + mapper := newTestMapper(t) + + got, err := mapper.Transform(context.Background(), ref(srv.URL), definition.DirectionRequest, requestInput()) + if err != nil || len(got) != 0 { + t.Errorf("the empty half: got %q, %v -- want nothing and no error", got, err) + } + + if _, err := mapper.Transform(context.Background(), ref(srv.URL), definition.DirectionResponse, responseInput()); err == nil { + t.Error("the uncompilable half must report an error, not nothing") + } +} + +// --- preconditions ---------------------------------------------------------- +// +// A mapping decides what a provider is asked for. It has to be able to decide +// that a request cannot be served at all, or that judgement stays in Go and +// every provider with its own rule needs its own build. +// +// required is a list of predicates over the same payload the request half reads. +// A predicate that is false refuses the request, carrying the message the +// mapping supplied -- so the caller gets a sentence about their payload rather +// than a mapping error about ours. + +const withChecks = `required: + - check: beckn.message.location.type = "Point" + message: "this capability needs a Point location" + - check: $exists(beckn.message.validity) + message: "this capability needs a validity window" + +request: | + { "txn": beckn.context.transactionId } + +response: | + { "txn": beckn.context.transactionId } +` + +// verifyInput is a payload that satisfies withChecks. The beckn wrapper is +// what the caller passes, so a precondition reads the payload by the same name +// the request half does. +func verifyInput() map[string]any { + return map[string]any{ + "beckn": map[string]any{ + "context": map[string]any{"transactionId": "txn-123"}, + "message": map[string]any{ + "location": map[string]any{"type": "Point", "coordinates": []any{73.7898, 19.9975}}, + "validity": map[string]any{"startsAt": "2026-09-01"}, + }, + }, + } +} + +// becknOf reaches into the wrapper, so a test can spoil one field. +func becknOf(input map[string]any) map[string]any { + return input["beckn"].(map[string]any) +} + +func TestVerifyPassesWhenEveryPredicateHolds(t *testing.T) { + t.Parallel() + + srv := newMappingServer(t, withChecks, nil) + defer srv.Close() + + if err := newTestMapper(t).Verify(context.Background(), ref(srv.URL), verifyInput()); err != nil { + t.Errorf("Verify() refused a payload that satisfies every predicate: %v", err) + } +} + +// The message belongs to the mapping, so the caller is told what is wrong with +// their payload rather than that an expression failed. +func TestVerifyRefusesWithTheMappingsOwnMessage(t *testing.T) { + t.Parallel() + + srv := newMappingServer(t, withChecks, nil) + defer srv.Close() + + input := verifyInput() + becknOf(input)["message"].(map[string]any)["location"] = map[string]any{"type": "Polygon"} + + err := newTestMapper(t).Verify(context.Background(), ref(srv.URL), input) + if err == nil { + t.Fatal("expected a payload failing a predicate to be refused") + } + if !strings.Contains(err.Error(), "needs a Point location") { + t.Errorf("error %q should carry the mapping's own message", err) + } + + // A bad request: the payload is the caller's, so this must not read as a + // fault of this adapter. + var coded *model.CodedErr + if !errors.As(err, &coded) || coded.HTTPStatus() != http.StatusBadRequest { + t.Errorf("error is %T, want a 400 so the caller is not blamed for our fault: %v", err, err) + } +} + +// Predicates are checked in order and the first failure is the one reported. +// Reporting the last, or all of them, buries the thing to fix. +func TestVerifyReportsTheFirstFailure(t *testing.T) { + t.Parallel() + + srv := newMappingServer(t, withChecks, nil) + defer srv.Close() + + // Both predicates fail. + input := verifyInput() + becknOf(input)["message"] = map[string]any{"location": map[string]any{"type": "Polygon"}} + + err := newTestMapper(t).Verify(context.Background(), ref(srv.URL), input) + if err == nil { + t.Fatal("expected a refusal") + } + if !strings.Contains(err.Error(), "needs a Point location") { + t.Errorf("error %q should report the first failing predicate", err) + } + if strings.Contains(err.Error(), "validity window") { + t.Error("only the first failure should be reported") + } +} + +// A mapping that declares no preconditions imposes none. That is what lets one +// provider adopt the key while others have not, so a second provider arriving +// with its own rules does not force every existing mapping to be rewritten. +func TestVerifyAllowsAMappingWithNoChecks(t *testing.T) { + t.Parallel() + + srv := newMappingServer(t, bothDirections, nil) + defer srv.Close() + + if err := newTestMapper(t).Verify(context.Background(), ref(srv.URL), requestInput()); err != nil { + t.Errorf("a mapping with no required block must impose nothing: %v", err) + } +} + +// A predicate has to answer true or false. Anything else -- a string, a number, +// nothing at all -- is a mapping fault, and must not be read as permission: a +// typo that yields undefined would otherwise wave every request through. +func TestVerifyRefusesAPredicateThatIsNotABoolean(t *testing.T) { + t.Parallel() + + testCases := []struct{ name, check string }{ + {"a string", `"yes"`}, + {"a number", `1`}, + {"a field that does not exist", `beckn.message.nothing.here`}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + mapping := "required:\n - check: " + tc.check + "\n message: \"nope\"\n\nrequest: |\n { \"a\": 1 }\n" + srv := newMappingServer(t, mapping, nil) + defer srv.Close() + + if err := newTestMapper(t).Verify(context.Background(), ref(srv.URL), verifyInput()); err == nil { + t.Error("a predicate that is not a boolean must be refused, not treated as permission") + } + }) + } +} + +// A predicate that will not compile is the mapping's fault, and is reported as +// one -- but it must not take the halves down with it, exactly as a broken half +// does not take its sibling down. +func TestVerifyIsolatesAPredicateThatWillNotCompile(t *testing.T) { + t.Parallel() + + mapping := `required: + - check: "{{{" + message: "unreachable" + +request: | + { "txn": beckn.context.transactionId } +` + srv := newMappingServer(t, mapping, nil) + defer srv.Close() + mapper := newTestMapper(t) + + if err := mapper.Verify(context.Background(), ref(srv.URL), verifyInput()); err == nil { + t.Error("expected an uncompilable predicate to be reported") + } + // The request half still works: a broken precondition is not a broken file. + if _, err := mapper.Transform(context.Background(), ref(srv.URL), + definition.DirectionRequest, verifyInput()); err != nil { + t.Errorf("the request half must still be served: %v", err) + } +} + +// An entry with no message is a mapping that refuses without saying why, which +// is the failure this whole key exists to avoid. +func TestVerifyRefusesAPredicateWithNoMessage(t *testing.T) { + t.Parallel() + + srv := newMappingServer(t, "required:\n - check: 'false'\n\nrequest: |\n { \"a\": 1 }\n", nil) + defer srv.Close() + + if err := newTestMapper(t).Verify(context.Background(), ref(srv.URL), verifyInput()); err == nil { + t.Error("a predicate with no reject message must be refused as a mapping fault") + } +} + +// Preconditions are fetched and compiled with the halves: one round trip leaves +// the whole file ready. +func TestVerifyCompilesWithTheRestOfTheFile(t *testing.T) { + t.Parallel() + + var fetches atomic.Int32 + srv := newMappingServer(t, withChecks, &fetches) + defer srv.Close() + mapper := newTestMapper(t) + + if err := mapper.Verify(context.Background(), ref(srv.URL), verifyInput()); err != nil { + t.Fatalf("Verify() returned an unexpected error: %v", err) + } + if _, err := mapper.Transform(context.Background(), ref(srv.URL), + definition.DirectionRequest, verifyInput()); err != nil { + t.Fatalf("Transform() returned an unexpected error: %v", err) + } + if got := fetches.Load(); got != 1 { + t.Errorf("fetched %d times, want 1 -- preconditions refetched the file", got) + } +} + +// --- direction validation --------------------------------------------------- + +// A direction outside the two is a caller bug, not a mapping problem, and must +// not be read as either half. +func TestTransformRefusesAnUnknownDirection(t *testing.T) { + t.Parallel() + + srv := newMappingServer(t, bothDirections, nil) + defer srv.Close() + mapper := newTestMapper(t) + + for _, direction := range []definition.Direction{"", "on_select", "REQUEST"} { + if _, err := mapper.Transform(context.Background(), ref(srv.URL), direction, requestInput()); err == nil { + t.Errorf("expected direction %q to be refused", direction) + } + } +} + +// The filename says nothing: the registry entry that points at a file decides +// which action it serves, so a mapper reading meaning into the path would give +// the same file two answers. +func TestTransformIgnoresTheFilename(t *testing.T) { + t.Parallel() + + srv := newMappingServer(t, bothDirections, nil) + defer srv.Close() + mapper := newTestMapper(t) + + for _, name := range []string{"/anything.yaml", "/confirm.yaml", "/x/y/z"} { + if _, err := mapper.Transform(context.Background(), srv.URL+name, definition.DirectionRequest, requestInput()); err != nil { + t.Errorf("Transform(%q) returned an unexpected error: %v", name, err) + } + } +} + +// --- reference validation --------------------------------------------------- + +// A reference comes from the registry, so it is external input rather than +// something to trust. This cannot constrain WHICH host -- a registry record +// chooses that -- but it can refuse a reference that is not a fetchable http +// URL at all, which is what stops a record naming a local file and having the +// adapter read it. +func TestTransformRefusesAnUnusableReference(t *testing.T) { + t.Parallel() + + testCases := []struct{ name, ref string }{ + {"empty", ""}, + {"a bare path with no scheme", "/mappings/select.yaml"}, + {"a relative path", "mappings/select.yaml"}, + {"a file url", "file:///etc/passwd"}, + {"a scheme that is not http", "ftp://example.com/select.yaml"}, + {"no host", "http:///select.yaml"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if _, err := newTestMapper(t).Transform(context.Background(), tc.ref, definition.DirectionRequest, requestInput()); err == nil { + t.Errorf("expected reference %q to be refused", tc.ref) + } + }) + } +} + +// --- fetch and parse failures ----------------------------------------------- + +func TestTransformReportsAFailedFetch(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + status int + body string + }{ + {name: "a not-found status", status: http.StatusNotFound}, + {name: "a server error status", status: http.StatusInternalServerError}, + {name: "malformed yaml", status: http.StatusOK, body: "request: [unclosed"}, + {name: "neither half", status: http.StatusOK, body: "other: value\n"}, + {name: "an empty document", status: http.StatusOK, body: "\n"}, + // Both halves present and empty is a file that says nothing, and is + // refused whole rather than per half -- there is no half left to serve. + {name: "both halves empty", status: http.StatusOK, body: "request: \"\"\nresponse: \"\"\n"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if tc.status != http.StatusOK { + w.WriteHeader(tc.status) + return + } + fmt.Fprint(w, tc.body) + })) + defer srv.Close() + + if _, err := newTestMapper(t).Transform(context.Background(), ref(srv.URL), definition.DirectionRequest, requestInput()); err == nil { + t.Error("expected an error") + } + }) + } +} + +// One unusable half must not take the other down with it: a typo in the response +// mapping is no reason to stop making the call, and finding out on the way back +// beats finding out before the call was made. +func TestTransformIsolatesABrokenHalf(t *testing.T) { + t.Parallel() + + mapping := `request: | + { "txn": beckn.context.transactionId } + +response: | + {{{ +` + srv := newMappingServer(t, mapping, nil) + defer srv.Close() + mapper := newTestMapper(t) + + if _, err := mapper.Transform(context.Background(), ref(srv.URL), definition.DirectionRequest, requestInput()); err != nil { + t.Errorf("the healthy half must still be served: %v", err) + } + err := func() error { + _, err := mapper.Transform(context.Background(), ref(srv.URL), definition.DirectionResponse, responseInput()) + return err + }() + if err == nil { + t.Fatal("expected an uncompilable half to be refused") + } +} + +// A mapping is fetched into memory and compiled, so an unbounded one is an +// unbounded allocation driven by whoever can write the registry record. +func TestTransformEnforcesASizeCap(t *testing.T) { + t.Parallel() + + oversized := "request: |\n " + strings.Repeat("x", 2048) + "\n" + srv := newMappingServer(t, oversized, nil) + defer srv.Close() + + mapper := newTestMapper(t, func(c *Config) { c.MaxMappingBytes = 512 }) + if _, err := mapper.Transform(context.Background(), ref(srv.URL), definition.DirectionRequest, requestInput()); err == nil { + t.Fatal("expected an oversized mapping to be refused") + } +} + +// A mapping host that accepts the connection and then goes quiet must not hold +// a request open indefinitely. +func TestTransformBoundsTheFetch(t *testing.T) { + t.Parallel() + + release := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-release + })) + defer srv.Close() + defer close(release) + + mapper := newTestMapper(t, func(c *Config) { c.FetchTimeout = 50 * time.Millisecond }) + + done := make(chan error, 1) + go func() { + _, err := mapper.Transform(context.Background(), ref(srv.URL), definition.DirectionRequest, requestInput()) + done <- err + }() + + select { + case err := <-done: + if err == nil { + t.Error("expected a stalled fetch to fail") + } + case <-time.After(3 * time.Second): + t.Fatal("Transform() did not return: the fetch is unbounded") + } +} + +// --- caching ---------------------------------------------------------------- + +// Compiling is the expensive half, and it cannot be cached anywhere but in +// memory: a compiled expression is code, not data. +func TestTransformCompilesEachMappingOnce(t *testing.T) { + t.Parallel() + + var fetches atomic.Int32 + srv := newMappingServer(t, bothDirections, &fetches) + defer srv.Close() + + mapper := newTestMapper(t) + for i := 0; i < 3; i++ { + if _, err := mapper.Transform(context.Background(), ref(srv.URL), definition.DirectionRequest, requestInput()); err != nil { + t.Fatalf("Transform() returned an unexpected error: %v", err) + } + } + if got := fetches.Load(); got != 1 { + t.Errorf("fetched %d times, want 1 -- the mapping is being refetched per request", got) + } +} + +// One fetch serves both halves. This is the practical gain of one file: the +// response leg of a round trip does not pay a second round trip to be mapped. +func TestTransformFetchesOnceForBothHalves(t *testing.T) { + t.Parallel() + + var fetches atomic.Int32 + srv := newMappingServer(t, bothDirections, &fetches) + defer srv.Close() + + mapper := newTestMapper(t) + if _, err := mapper.Transform(context.Background(), ref(srv.URL), definition.DirectionRequest, requestInput()); err != nil { + t.Fatalf("request: %v", err) + } + if _, err := mapper.Transform(context.Background(), ref(srv.URL), definition.DirectionResponse, responseInput()); err != nil { + t.Fatalf("response: %v", err) + } + if got := fetches.Load(); got != 1 { + t.Errorf("fetched %d times, want 1 -- the response half refetched the file", got) + } +} + +// Two references are two mappings even when they compile to the same thing. +func TestTransformCachesPerReference(t *testing.T) { + t.Parallel() + + var fetches atomic.Int32 + srv := newMappingServer(t, bothDirections, &fetches) + defer srv.Close() + + mapper := newTestMapper(t) + for _, r := range []string{srv.URL + "/a.yaml", srv.URL + "/b.yaml"} { + if _, err := mapper.Transform(context.Background(), r, definition.DirectionRequest, requestInput()); err != nil { + t.Fatalf("Transform() returned an unexpected error: %v", err) + } + } + if got := fetches.Load(); got != 2 { + t.Errorf("fetched %d times, want 2 -- distinct references shared a cache entry", got) + } +} + +// A reference that cannot be fetched must not be retried on every request: a +// broken mapping would otherwise turn each inbound message into an outbound one. +func TestTransformNegativeCachesAFailure(t *testing.T) { + t.Parallel() + + var fetches atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fetches.Add(1) + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + mapper := newTestMapper(t) + for i := 0; i < 3; i++ { + if _, err := mapper.Transform(context.Background(), ref(srv.URL), definition.DirectionRequest, requestInput()); err == nil { + t.Fatal("expected a missing mapping to fail") + } + } + if got := fetches.Load(); got != 1 { + t.Errorf("fetched %d times, want 1 -- a broken reference is being retried per request", got) + } +} + +// An expired entry is refetched, so a corrected mapping takes effect without a +// restart. +func TestTransformRefetchesAfterTheTTL(t *testing.T) { + t.Parallel() + + var fetches atomic.Int32 + srv := newMappingServer(t, bothDirections, &fetches) + defer srv.Close() + + mapper := newTestMapper(t, func(c *Config) { c.CacheTTL = 20 * time.Millisecond }) + if _, err := mapper.Transform(context.Background(), ref(srv.URL), definition.DirectionRequest, requestInput()); err != nil { + t.Fatalf("Transform() returned an unexpected error: %v", err) + } + time.Sleep(60 * time.Millisecond) + if _, err := mapper.Transform(context.Background(), ref(srv.URL), definition.DirectionRequest, requestInput()); err != nil { + t.Fatalf("Transform() returned an unexpected error: %v", err) + } + + if got := fetches.Load(); got != 2 { + t.Errorf("fetched %d times, want 2 -- an expired mapping was not refetched", got) + } +} + +// The cache is bounded: references come from the registry, so an unbounded one +// would grow with the number of capabilities ever seen. +func TestTransformBoundsTheCache(t *testing.T) { + t.Parallel() + + srv := newMappingServer(t, bothDirections, nil) + defer srv.Close() + + mapper := newTestMapper(t, func(c *Config) { c.MaxCacheEntries = 2 }) + for i := 0; i < 5; i++ { + if _, err := mapper.Transform(context.Background(), + fmt.Sprintf("%s/%d.yaml", srv.URL, i), definition.DirectionRequest, requestInput()); err != nil { + t.Fatalf("Transform() returned an unexpected error: %v", err) + } + } + if got := mapper.cachedCount(); got > 2 { + t.Errorf("cache holds %d entries, want at most 2", got) + } +} + +// Expired entries must not hold the cap. They used to: cached() treats an +// expiry as a miss but left the entry in the map, nothing ever deleted one, so +// the count only grew -- and once it reached MaxCacheEntries the cache refused +// every reference it was not already holding, permanently. The effect was not +// "compile again next time" but compile every time, for every mapping the +// deployment had. +func TestTransformKeepsCachingAfterEntriesExpire(t *testing.T) { + t.Parallel() + + var fetches int + var mu sync.Mutex + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + fetches++ + mu.Unlock() + fmt.Fprint(w, bothDirections) + })) + defer srv.Close() + + // A TTL short enough to expire between calls, and a cap small enough that + // stale entries would fill it. + mapper := newTestMapper(t, func(c *Config) { + c.MaxCacheEntries = 3 + c.CacheTTL = time.Millisecond + }) + + // Fill the cache and let everything in it go stale. + for i := 0; i < 3; i++ { + if _, err := mapper.Transform(context.Background(), + fmt.Sprintf("%s/%d.yaml", srv.URL, i), definition.DirectionRequest, requestInput()); err != nil { + t.Fatalf("Transform() returned an unexpected error: %v", err) + } + } + time.Sleep(20 * time.Millisecond) + + mu.Lock() + before := fetches + mu.Unlock() + + // A fourth reference, asked for repeatedly. It should be cached after the + // first fetch, because the three stale entries no longer occupy the cap. + fourth := srv.URL + "/fourth.yaml" + for i := 0; i < 5; i++ { + if _, err := mapper.Transform(context.Background(), fourth, + definition.DirectionRequest, requestInput()); err != nil { + t.Fatalf("Transform() returned an unexpected error: %v", err) + } + } + + mu.Lock() + after := fetches + mu.Unlock() + if got := after - before; got != 1 { + t.Errorf("a new reference was fetched %d times over 5 requests, want 1 -- "+ + "stale entries are holding the cap", got) + } +} + +// A failure on the response leg is not the caller's fault. The input there is +// the PROVIDER's answer, so a provider that changed shape, or a bug in the +// response half, used to return 400 and send the caller off to fix a request +// that was fine. +func TestTransformBlamesTheRightPartyForEachDirection(t *testing.T) { + t.Parallel() + + // A mapping whose halves both fail at evaluation: $number over a value that + // is not a number. + failing := "request: |\n $number(beckn.notANumber)\nresponse: |\n $number(response.notANumber)\n" + srv := newMappingServer(t, failing, nil) + defer srv.Close() + + mapper := newTestMapper(t) + ref := srv.URL + "/failing.yaml" + + _, err := mapper.Transform(context.Background(), ref, definition.DirectionRequest, + map[string]any{"beckn": map[string]any{"notANumber": "abc"}}) + if err == nil { + t.Fatal("expected the request half to fail") + } + var coded *model.CodedErr + if !errors.As(err, &coded) || coded.HTTPStatus() != http.StatusBadRequest { + t.Errorf("request leg gave %v, want a 400 -- the caller's payload is what the mapping could not read", err) + } + + _, err = mapper.Transform(context.Background(), ref, definition.DirectionResponse, + map[string]any{"response": map[string]any{"notANumber": "abc"}}) + if err == nil { + t.Fatal("expected the response half to fail") + } + if !errors.As(err, &coded) || coded.HTTPStatus() != http.StatusBadGateway { + t.Errorf("response leg gave %v, want a 502 -- the provider's answer is what failed, not the request", err) + } +} + +// --- concurrency ------------------------------------------------------------ + +// Every inbound request shares one mapper, so the cache is read and written +// concurrently, and jsonata.Expression.Evaluate mutates what it is called on. +// Both halves are exercised: they hold separate locks, so a request and a +// response leg of the same mapping do run at the same time. Run with -race. +func TestTransformIsSafeUnderConcurrentUse(t *testing.T) { + t.Parallel() + + srv := newMappingServer(t, bothDirections, nil) + defer srv.Close() + + mapper := newTestMapper(t) + errs := make(chan error, 20) + for i := 0; i < 20; i++ { + go func(i int) { + direction, input := definition.DirectionRequest, requestInput() + if i%2 == 1 { + direction, input = definition.DirectionResponse, responseInput() + } + _, err := mapper.Transform(context.Background(), + fmt.Sprintf("%s/%d.yaml", srv.URL, i%3), direction, input) + errs <- err + }(i) + } + for i := 0; i < 20; i++ { + if err := <-errs; err != nil { + t.Errorf("concurrent Transform() failed: %v", err) + } + } +} + +// A cold cache used to send every concurrent miss for the same reference to +// fetch the mapping and compile it. The compile is the cheaper half -- the +// round trip is bounded by fetchTimeout, so N requests arriving together +// waited N times for the same document, and the publisher saw N identical +// reads for one capability coming up. +func TestCompiledFetchesOnceForConcurrentMisses(t *testing.T) { + t.Parallel() + + var fetches atomic.Int32 + // Slow enough that the callers genuinely overlap; without single-flight + // they all get past the miss check before the first store lands. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fetches.Add(1) + time.Sleep(50 * time.Millisecond) + fmt.Fprint(w, bothDirections) + })) + defer srv.Close() + + mapper := newTestMapper(t) + + const callers = 25 + var wg sync.WaitGroup + errs := make([]error, callers) + for i := range callers { + wg.Add(1) + go func() { + defer wg.Done() + _, errs[i] = mapper.compiled(context.Background(), srv.URL) + }() + } + wg.Wait() + + for i, err := range errs { + if err != nil { + t.Fatalf("caller %d got an error: %v", i, err) + } + } + if got := fetches.Load(); got != 1 { + t.Errorf("the mapping was fetched %d times for %d concurrent misses, want 1", got, callers) + } +} + +// Single-flight must not turn a second, later request into a second fetch -- +// that is what the cache is for -- nor make a different reference wait behind +// an unrelated one. +func TestCompiledStillCachesAndKeepsReferencesIndependent(t *testing.T) { + t.Parallel() + + var fetchesA, fetchesB atomic.Int32 + srvA := newMappingServer(t, bothDirections, &fetchesA) + defer srvA.Close() + srvB := newMappingServer(t, bothDirections, &fetchesB) + defer srvB.Close() + + mapper := newTestMapper(t) + + for range 3 { + if _, err := mapper.compiled(context.Background(), srvA.URL); err != nil { + t.Fatalf("unexpected error for A: %v", err) + } + } + if _, err := mapper.compiled(context.Background(), srvB.URL); err != nil { + t.Fatalf("unexpected error for B: %v", err) + } + + if got := fetchesA.Load(); got != 1 { + t.Errorf("reference A was fetched %d times across three sequential calls, want 1", got) + } + if got := fetchesB.Load(); got != 1 { + t.Errorf("reference B was fetched %d times, want 1 -- it must not share A's result", got) + } +} + +// namedFunctionMapping is shaped like the shipped mappings: it binds functions +// to variables and passes one to $filter by name. That matters, because the +// library's shared writes happen when a function is APPLIED -- a mapping of +// only field lookups never reaches them, which is why +// TestTransformIsSafeUnderConcurrentUse ran green over a real race for as long +// as its fixtures stayed simple. +const namedFunctionMapping = `request: | + ( + $ok := function($v) { $exists($v) and $v != "" }; + { "txn": $ok(beckn.context.transactionId) ? beckn.context.transactionId : "none" } + ) + +response: | + ( + $ok := function($r) { $exists($r) }; + $tag := function($v) { $exists($v) ? $lowercase($v) }; + { + "txn": beckn.context.transactionId, + "kept": $count($filter([response.fcstday1.rain, 1, 2], $ok)), + "tag": $tag("MM") + } + ) +` + +// Two DIFFERENT mappings, evaluated at the same time. This is the case the +// per-mapping lock deliberately allowed to run in parallel, and the library's +// built-ins are package-level state, so it raced: applying $exists or $count +// writes error-reporting fields onto one shared *Function. A provider adapter +// serving both its capabilities at once does exactly this. +func TestConcurrentDifferentMappingsDoNotRace(t *testing.T) { + t.Parallel() + + srv := newMappingServer(t, namedFunctionMapping, nil) + defer srv.Close() + + mapper := newTestMapper(t) + errs := make(chan error, 24) + var wg sync.WaitGroup + for i := 0; i < 24; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + direction, input := definition.DirectionRequest, requestInput() + if i%2 == 1 { + direction, input = definition.DirectionResponse, responseInput() + } + // A distinct reference per goroutine: distinct cache entries, so + // nothing but package-level state is shared between them. + _, err := mapper.Transform(context.Background(), + fmt.Sprintf("%s/distinct-%d.yaml", srv.URL, i), direction, input) + errs <- err + }(i) + } + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Errorf("concurrent Transform() over distinct mappings failed: %v", err) + } + } +} + +// panickingExpression stands in for the library misbehaving. jsonata.Expression +// is an interface, so this needs no cooperation from the library -- which is +// the point: the failure being guarded against is one we cannot ask it for. +type panickingExpression struct{} + +func (panickingExpression) Evaluate([]byte, map[string]interface{}) ([]byte, error) { + panic("library exploded mid-evaluation") +} +func (panickingExpression) SetMaxDepth(int) {} +func (panickingExpression) SetMaxTime(int) {} +func (panickingExpression) SetMaxRange(int) {} +func (panickingExpression) Assign(string, interface{}) {} +func (panickingExpression) RegisterFunction(string, interface{}, string) error { return nil } +func (panickingExpression) AST() interface{} { return nil } +func (panickingExpression) Errors() []error { return nil } + +// A panic inside Evaluate must not leave the lock held. It is package-wide, so +// a wedged mutex is not one broken mapping -- it is every mapping in the +// process, for every provider step, until someone restarts it. Writing the +// unlock after the call rather than deferring it is what would cause that, and +// widening the lock is what turned it from a bounded fault into an outage. +func TestEvaluateLockedReleasesTheLockOnPanic(t *testing.T) { + // Not parallel: it asserts on the state of a package-level lock. + result, err := evaluateLocked(panickingExpression{}, []byte(`{}`)) + + if err == nil { + t.Fatal("a panic must surface as an error, not be swallowed") + } + if !strings.Contains(err.Error(), "panicked") { + t.Errorf("error = %v, want it to say the evaluation panicked", err) + } + if result != nil { + t.Errorf("result = %q, want nothing on a failed evaluation", result) + } + + // The part that matters. If the unlock were not deferred, this would block + // forever rather than fail. + done := make(chan struct{}) + go func() { + evaluating.Lock() + evaluating.Unlock() + close(done) + }() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("the lock was never released: every mapping in the process is now wedged") + } +} + +// And the ordinary path still works through the same helper, so the guard above +// cannot be satisfied by a helper that never evaluates anything. +func TestEvaluateLockedRunsAnOrdinaryExpression(t *testing.T) { + mapper := newTestMapper(t) + expr, err := mapper.instance.Compile(`{ "kept": $count([1,2,3]) }`, false) + if err != nil { + t.Fatalf("failed to compile: %v", err) + } + + got, err := evaluateLocked(expr, []byte(`{}`)) + if err != nil { + t.Fatalf("evaluateLocked() returned an unexpected error: %v", err) + } + if !strings.Contains(string(got), `"kept"`) { + t.Errorf("got %q, want the evaluated object", got) + } +} diff --git a/pkg/plugin/implementation/schemav2validator/extended_schema.go b/pkg/plugin/implementation/schemav2validator/extended_schema.go index a002b019..1c3d7169 100644 --- a/pkg/plugin/implementation/schemav2validator/extended_schema.go +++ b/pkg/plugin/implementation/schemav2validator/extended_schema.go @@ -9,6 +9,7 @@ import ( "net/url" "os" "path/filepath" + "slices" "strings" "sync" "time" @@ -78,7 +79,29 @@ type referencedObject struct { Path string Context string Type string - Data map[string]interface{} + // Types is every @type the object carries, in payload order. JSON-LD + // permits the array form and the packs allow it explicitly, so which + // entry names the capability is not known until a document is consulted. + Types []string + Data map[string]interface{} + // Unusable is set when the object claims a domain type but carries it in + // a shape this validator cannot resolve. It is a coded error rather than + // a bool because the object is then rejected, not skipped: a layer whose + // purpose is to turn a missing attribute into a rejection must not answer + // "valid" for an object it never looked at. + Unusable error +} + +// candidateTypes returns the @type values to try, tolerating an object built +// with Type alone -- which every caller outside findReferencedObjects does. +func (o referencedObject) candidateTypes() []string { + if len(o.Types) > 0 { + return o.Types + } + if o.Type == "" { + return nil + } + return []string{o.Type} } // schemaCache caches loaded domain schemas with LRU eviction. @@ -312,7 +335,7 @@ func (c *schemaCache) cleanupExpired() int { return len(expired) } -func (c *schemaCache) loadSchemaFromPath(ctx context.Context, schemaPath string, ttl, timeout time.Duration, localSchema bool) (*openapi3.T, error) { +func (c *schemaCache) loadSchemaFromPath(ctx context.Context, schemaPath string, ttl, timeout time.Duration, allowedDomains []string, localSchema bool) (*openapi3.T, error) { urlHash := hashURL(schemaPath) u, parseErr := url.Parse(schemaPath) @@ -322,6 +345,18 @@ func (c *schemaCache) loadSchemaFromPath(ctx context.Context, schemaPath string, loader := newFreshLoader() loader.Context = ctx + // Installed on BOTH branches, with the local-file allowance following + // localSchema. The check at the one @context runs once, on the entry + // document; the $refs inside whatever comes back are resolved by the + // loader, and a pack pulls 13-16 documents, so the refs are the great + // majority of the reads. + // + // localSchema is included because its rawSchemas path falls back to the + // NETWORK for a ref it does not hold -- so a local document could reach an + // arbitrary host, the same exposure by a longer route. What it keeps is + // the file read itself, which in that mode is the operator's stated + // intent rather than something a payload asked for. + loader.ReadFromURIFunc = payloadDirectedReader(allowedDomains, localSchema) var doc *openapi3.T var err error @@ -408,22 +443,80 @@ func (c *schemaCache) loadSchemaFromPath(ctx context.Context, schemaPath string, return doc, nil } +// payloadDirectedReader returns a reader for schema documents whose location a +// payload chose, enforcing the allowlist on EVERY read -- the entry document +// and every $ref under it. +// +// Two separate refusals, for two separate reasons. +// +// SCHEME: freshReadFromURI falls through to os.ReadFile for every scheme but +// http and https, so a $ref of "file:///etc/passwd" -- or a bare path, which +// parses with no scheme at all -- is an instruction from the network to read +// this container's disk and parse it as a schema. The base spec loader keeps +// that fallthrough deliberately: its location is operator-configured, where a +// local file is the point. Here it never is. +// +// HOST: the entry @context is allowlisted, but the document it returns is not +// trusted -- it came from a payload-named URL on a host anyone can publish to. +// Its $refs used to reach any http host at all, so a payload could name an +// attacker's document and have this process fetch whatever that document +// pointed at: an internal service, a cloud metadata endpoint. Checking the +// same allowlist on every read closes that, and makes the allowlist mean what +// it says -- the hosts this deployment will read schemas from, not the hosts +// it will read the FIRST schema from. +// +// This is why the allowlist cannot be a single host: loading one capability +// pack touches raw.githubusercontent.com, schema.beckn.io and +// schema.nfh.global (13-16 reads, measured), so all three have to be named or +// no pack loads at all. An empty allowlist still means "unset, do not check", +// as it does at the @context. +// +// allowLocal follows localSchema: an operator who configured +// extendedSchema_localSchemaPath is asking for files to be read, so the scheme +// refusal does not apply to them. The HOST check still does, because that +// mode falls back to the network for a ref it does not hold locally. +func payloadDirectedReader(allowedDomains []string, allowLocal bool) func(*openapi3.Loader, *url.URL) ([]byte, error) { + return func(loader *openapi3.Loader, u *url.URL) ([]byte, error) { + remote := u.Scheme == "http" || u.Scheme == "https" + if !remote && !allowLocal { + return nil, fmt.Errorf("refusing to read schema from %q: only http and https are read for a location a payload chose", u.String()) + } + if remote && len(allowedDomains) > 0 && !isAllowedDomain(u, allowedDomains) { + return nil, fmt.Errorf("refusing to read schema from %q: host is not in extendedSchema_allowedDomains", u.String()) + } + return freshReadFromURI(loader, u) + } +} + // findReferencedObjects recursively finds domain-specific objects with @context. func findReferencedObjects(data interface{}, path string) []referencedObject { var results []referencedObject switch v := data.(type) { case map[string]interface{}: - // Check for @context and @type - if contextVal, hasContext := v["@context"].(string); hasContext { - if typeVal, hasType := v["@type"].(string); hasType { - results = append(results, referencedObject{ - Path: path, - Context: contextVal, - Type: typeVal, - Data: v, - }) + // @type ABSENT is not the same as @type unreadable. An object with a + // context and no type makes no claim about which schema applies, and + // there is nothing to validate it against, so it is passed over as + // before. An object that does claim a type is validated or rejected. + rawContext, hasContext := v["@context"] + rawType, hasType := v["@type"] + if hasContext && hasType { + obj := referencedObject{Path: path, Data: v} + contextVal, contextOK := jsonLDLocation(rawContext) + types, typesOK := jsonLDTypes(rawType) + switch { + case !contextOK: + obj.Unusable = model.NewCodedError("SCH_INVALID_JSONLD_CONTEXT", + "@context is not a URL this validator can resolve a schema from") + case !typesOK: + obj.Unusable = model.NewCodedError("SCH_INVALID_ENTITY_TYPE", + "@type is present but is not a type name or a list of them") + default: + obj.Context = contextVal + obj.Type = types[0] + obj.Types = types } + results = append(results, obj) } // Recurse into nested objects @@ -452,6 +545,70 @@ func transformContextToSchemaURL(contextURL string) string { return strings.Replace(contextURL, "context.jsonld", "attributes.yaml", 1) } +// jsonLDLocation returns the @context entry a schema can be located from. +// +// JSON-LD allows a string, an array mixing strings and inline objects, or a +// single inline object. Only a URL locates a schema, so the first string is +// taken and an inline object yields nothing -- there is no document to fetch. +func jsonLDLocation(raw interface{}) (string, bool) { + switch v := raw.(type) { + case string: + if v != "" { + return v, true + } + case []interface{}: + for _, entry := range v { + if s, ok := entry.(string); ok && s != "" { + return s, true + } + } + } + return "", false +} + +// jsonLDTypes returns every @type the object carries, in payload order. +// +// The array form is not exotic: the packs declare @type as a oneOf whose +// second branch is an array containing the canonical OAN type plus +// provider-defined ones. Reading only the string form left those objects +// matching nothing, so they were dropped before validation and the layer +// reported a pass over an object it had not looked at. +func jsonLDTypes(raw interface{}) ([]string, bool) { + switch v := raw.(type) { + case string: + if v != "" { + return []string{v}, true + } + case []interface{}: + types := make([]string, 0, len(v)) + for _, entry := range v { + if s, ok := entry.(string); ok && s != "" { + types = append(types, s) + } + } + if len(types) > 0 { + return types, true + } + } + return nil, false +} + +// findSchemaForAnyType resolves the first @type the document declares a schema +// for, and returns which one matched. With the array form the capability type +// sits among provider-defined ones and its position is not fixed, so the +// document decides rather than the payload's ordering. +func findSchemaForAnyType(ctx context.Context, doc *openapi3.T, types []string) (*openapi3.SchemaRef, string, error) { + var lastErr error + for _, typeName := range types { + schema, err := findSchemaByType(ctx, doc, typeName) + if err == nil { + return schema, typeName, nil + } + lastErr = err + } + return nil, "", lastErr +} + // findSchemaByType finds a schema in the document by @type value. func findSchemaByType(ctx context.Context, doc *openapi3.T, typeName string) (*openapi3.SchemaRef, error) { if doc.Components == nil || doc.Components.Schemas == nil { @@ -498,6 +655,69 @@ func isAllowedDomain(u *url.URL, allowedDomains []string) bool { return false } +// jsonLDKeys are the JSON-LD control keys that travel inside a domain object +// rather than beside it. +var jsonLDKeys = []string{"@context", "@type"} + +// stripUnaccountedJSONLDKeys removes those JSON-LD keys the target schema does +// not declare, and keeps the ones it does. +// +// The two schema styles in use need opposite treatment, and removing both keys +// unconditionally only served the first: +// +// - a schema that closes itself with additionalProperties:false and never +// mentions @type rejects the payload if @type is left in; +// - a schema pack that declares @type and lists it in required rejects the +// payload if @type is taken out. +// +// Asking the schema, per key, satisfies both without a config switch and +// without either style having to know about the other. +func stripUnaccountedJSONLDKeys(schema *openapi3.SchemaRef, data map[string]interface{}) map[string]interface{} { + domainData := make(map[string]interface{}, len(data)) + for k, v := range data { + if slices.Contains(jsonLDKeys, k) && !schemaDeclaresProperty(schema, k, map[*openapi3.Schema]bool{}) { + continue + } + domainData[k] = v + } + return domainData +} + +// schemaDeclaresProperty reports whether name is declared as a property, or +// listed as required, anywhere in a schema's composition tree. +// +// allOf, anyOf, oneOf and the then/else branches can each introduce a property, +// so all of them are walked -- the capability packs declare @type one level down, in +// allOf. "not" is skipped because naming a property there forbids it rather +// than permitting it, and "if" is skipped because it only selects a branch. +// seen guards against schemas that reference themselves. +func schemaDeclaresProperty(ref *openapi3.SchemaRef, name string, seen map[*openapi3.Schema]bool) bool { + if ref == nil || ref.Value == nil || seen[ref.Value] { + return false + } + seen[ref.Value] = true + + if _, ok := ref.Value.Properties[name]; ok { + return true + } + if slices.Contains(ref.Value.Required, name) { + return true + } + for _, group := range []openapi3.SchemaRefs{ref.Value.AllOf, ref.Value.AnyOf, ref.Value.OneOf} { + for _, sub := range group { + if schemaDeclaresProperty(sub, name, seen) { + return true + } + } + } + for _, sub := range []*openapi3.SchemaRef{ref.Value.Then, ref.Value.Else} { + if schemaDeclaresProperty(sub, name, seen) { + return true + } + } + return false +} + // validateReferencedObject validates a single object with @context. func (c *schemaCache) validateReferencedObject( ctx context.Context, @@ -506,18 +726,31 @@ func (c *schemaCache) validateReferencedObject( allowedDomains []string, localSchema bool, ) error { + // An object that claims a domain type in a shape we cannot resolve is + // rejected here rather than dropped in findReferencedObjects. Dropping it + // meant the extended layer reported a pass over an object it never + // validated, which is the one outcome this layer exists to prevent. + if obj.Unusable != nil { + log.Warnf(ctx, "refusing an object at %s that carries @context in an unusable shape: %v", obj.Path, obj.Unusable) + return obj.Unusable + } + var doc *openapi3.T if localSchema { - typeName := obj.Type - if idx := strings.LastIndex(typeName, ":"); idx >= 0 { - typeName = typeName[idx+1:] - } - if typeName != "" && !strings.ContainsAny(typeName, "/\\") { - if localDoc, localErr := c.loadSchemaFromPath(ctx, typeName+"/attributes.yaml", ttl, timeout, localSchema); localErr != nil { - log.Debugf(ctx, "local @type lookup failed for %s: %v", obj.Type, localErr) + for _, candidate := range obj.candidateTypes() { + typeName := candidate + if idx := strings.LastIndex(typeName, ":"); idx >= 0 { + typeName = typeName[idx+1:] + } + if typeName == "" || strings.ContainsAny(typeName, "/\\") { + continue + } + if localDoc, localErr := c.loadSchemaFromPath(ctx, typeName+"/attributes.yaml", ttl, timeout, allowedDomains, localSchema); localErr != nil { + log.Debugf(ctx, "local @type lookup failed for %s: %v", candidate, localErr) } else { doc = localDoc + break } } } @@ -539,26 +772,24 @@ func (c *schemaCache) validateReferencedObject( schemaPath := transformContextToSchemaURL(obj.Context) log.Debugf(ctx, "Transformed %s -> %s (localSchema=%v)", obj.Context, schemaPath, localSchema) var err error - doc, err = c.loadSchemaFromPath(ctx, schemaPath, ttl, timeout, false) + doc, err = c.loadSchemaFromPath(ctx, schemaPath, ttl, timeout, allowedDomains, false) if err != nil { return model.NewCodedErrorWithCause("SCH_SCHEMA_ADAPTATION_FAILED", err.Error(), obj.Path, err) } } // Find schema by @type - schema, err := findSchemaByType(ctx, doc, obj.Type) + schema, matched, err := findSchemaForAnyType(ctx, doc, obj.candidateTypes()) + if err == nil && matched != obj.Type { + log.Debugf(ctx, "resolved @type %s from the array at %s", matched, obj.Path) + } if err != nil { log.Errorf(ctx, err, "Schema not found for @type: %s at path: %s", obj.Type, obj.Path) return model.NewCodedErrorWithCause("SCH_INVALID_ENTITY_TYPE", err.Error(), obj.Path, err) } - // Strip JSON-LD metadata before validation - domainData := make(map[string]interface{}, len(obj.Data)-2) - for k, v := range obj.Data { - if k != "@context" && k != "@type" { - domainData[k] = v - } - } + // Strip only the JSON-LD keys this schema does not account for itself. + domainData := stripUnaccountedJSONLDKeys(schema, obj.Data) // Validate domain-specific data against schema opts := []openapi3.SchemaValidationOption{ diff --git a/pkg/plugin/implementation/schemav2validator/extended_schema_test.go b/pkg/plugin/implementation/schemav2validator/extended_schema_test.go index 6adc99be..9facca44 100644 --- a/pkg/plugin/implementation/schemav2validator/extended_schema_test.go +++ b/pkg/plugin/implementation/schemav2validator/extended_schema_test.go @@ -9,6 +9,8 @@ import ( "os" "path/filepath" "reflect" + "sort" + "strings" "sync/atomic" "testing" "time" @@ -155,10 +157,10 @@ func TestHashURL(t *testing.T) { t.Run(tt.name, func(t *testing.T) { hash1 := hashURL(tt.url) hash2 := hashURL(tt.url) - + // Same URL should produce same hash assert.Equal(t, hash1, hash2) - + // Hash should be 64 characters (SHA256 hex) assert.Equal(t, 64, len(hash1)) }) @@ -246,23 +248,23 @@ func TestNewSchemaCache(t *testing.T) { func TestSchemaCache_GetSet(t *testing.T) { cache := newSchemaCache(10) - + // Create a simple schema doc doc := &openapi3.T{ OpenAPI: "3.1.0", } - + urlHash := hashURL("https://example.com/schema.yaml") ttl := 1 * time.Hour - + // Test Set cache.set(urlHash, doc, ttl) - + // Test Get - should find it retrieved, found := cache.get(urlHash) assert.True(t, found) assert.Equal(t, doc, retrieved) - + // Test Get - non-existent key _, found = cache.get("non-existent-hash") assert.False(t, found) @@ -270,28 +272,28 @@ func TestSchemaCache_GetSet(t *testing.T) { func TestSchemaCache_LRUEviction(t *testing.T) { cache := newSchemaCache(2) // Small cache for testing - + doc1 := &openapi3.T{OpenAPI: "3.1.0"} doc2 := &openapi3.T{OpenAPI: "3.1.1"} doc3 := &openapi3.T{OpenAPI: "3.1.2"} - + ttl := 1 * time.Hour - + // Add first two items cache.set("hash1", doc1, ttl) cache.set("hash2", doc2, ttl) - + // Access first item to make it more recent cache.get("hash1") - + // Add third item - should evict hash2 (least recently used) cache.set("hash3", doc3, ttl) - + // Verify hash1 and hash3 exist, hash2 was evicted _, found1 := cache.get("hash1") _, found2 := cache.get("hash2") _, found3 := cache.get("hash3") - + assert.True(t, found1, "hash1 should exist (recently accessed)") assert.False(t, found2, "hash2 should be evicted (LRU)") assert.True(t, found3, "hash3 should exist (just added)") @@ -299,20 +301,20 @@ func TestSchemaCache_LRUEviction(t *testing.T) { func TestSchemaCache_TTLExpiry(t *testing.T) { cache := newSchemaCache(10) - + doc := &openapi3.T{OpenAPI: "3.1.0"} urlHash := "test-hash" - + // Set with very short TTL cache.set(urlHash, doc, 1*time.Millisecond) - + // Should be found immediately _, found := cache.get(urlHash) assert.True(t, found) - + // Wait for expiry time.Sleep(10 * time.Millisecond) - + // Should not be found after expiry _, found = cache.get(urlHash) assert.False(t, found) @@ -320,23 +322,23 @@ func TestSchemaCache_TTLExpiry(t *testing.T) { func TestSchemaCache_CleanupExpired(t *testing.T) { cache := newSchemaCache(10) - + doc := &openapi3.T{OpenAPI: "3.1.0"} - + // Add items with short TTL cache.set("hash1", doc, 1*time.Millisecond) cache.set("hash2", doc, 1*time.Millisecond) cache.set("hash3", doc, 1*time.Hour) // This one won't expire - + // Wait for expiry time.Sleep(10 * time.Millisecond) - + // Cleanup expired count := cache.cleanupExpired() - + // Should have cleaned up 2 expired items assert.Equal(t, 2, count) - + // Verify only hash3 remains cache.mu.RLock() assert.Equal(t, 1, len(cache.schemas)) @@ -447,7 +449,7 @@ func TestFindReferencedObjects_PathBuilding(t *testing.T) { } objects := findReferencedObjects(data, "message") - + assert.Equal(t, 1, len(objects)) assert.Equal(t, "message.order.beckn:orderItems[0].beckn:acceptedOffer.beckn:offerAttributes", objects[0].Path) assert.Equal(t, "ChargingOffer", objects[0].Type) @@ -458,11 +460,11 @@ func TestFindReferencedObjects_PathBuilding(t *testing.T) { func TestLoadSchemaFromPath_LocalFile(t *testing.T) { cache := newSchemaCache(10) ctx := context.Background() - + tmpFile, err := os.CreateTemp("", "test-schema-*.yaml") assert.NoError(t, err) defer os.Remove(tmpFile.Name()) - + schemaContent := `openapi: 3.1.0 info: title: Test Schema @@ -474,58 +476,74 @@ components: properties: field1: type: string` - + _, err = tmpFile.Write([]byte(schemaContent)) assert.NoError(t, err) tmpFile.Close() - - doc, err := cache.loadSchemaFromPath(ctx, tmpFile.Name(), 1*time.Hour, 30*time.Second, false) + + // localSchema=false means the location came from a payload's @context -- + // the only way the production caller passes it. A local file is not + // something the network may ask this process to open, so it is refused. + doc, err := cache.loadSchemaFromPath(ctx, tmpFile.Name(), 1*time.Hour, 30*time.Second, nil, false) + if err == nil { + t.Fatal("a payload-directed load opened a local file") + } + assert.Contains(t, err.Error(), "only http and https are read") + assert.Nil(t, doc) + + // localSchema=true is an operator naming a path in the adapter's own + // config, which is the one case where opening a file is the intent. + doc, err = cache.loadSchemaFromPath(ctx, tmpFile.Name(), 1*time.Hour, 30*time.Second, nil, true) assert.NoError(t, err) assert.NotNil(t, doc) assert.Equal(t, "3.1.0", doc.OpenAPI) } func TestLoadSchemaFromPath_CacheHit(t *testing.T) { + // A temp file is just the fixture here, so this loads in operator mode: + // a payload-directed load refuses local files by design. cache := newSchemaCache(10) ctx := context.Background() - + tmpFile, err := os.CreateTemp("", "test-schema-*.yaml") assert.NoError(t, err) defer os.Remove(tmpFile.Name()) - + schemaContent := `openapi: 3.1.0 info: title: Test Schema version: 1.0.0` - + tmpFile.Write([]byte(schemaContent)) tmpFile.Close() - - doc1, err := cache.loadSchemaFromPath(ctx, tmpFile.Name(), 1*time.Hour, 30*time.Second, false) + + doc1, err := cache.loadSchemaFromPath(ctx, tmpFile.Name(), 1*time.Hour, 30*time.Second, nil, true) assert.NoError(t, err) - doc2, err := cache.loadSchemaFromPath(ctx, tmpFile.Name(), 1*time.Hour, 30*time.Second, false) + doc2, err := cache.loadSchemaFromPath(ctx, tmpFile.Name(), 1*time.Hour, 30*time.Second, nil, true) assert.NoError(t, err) - + assert.Equal(t, doc1, doc2) } func TestLoadSchemaFromPath_InvalidPath(t *testing.T) { cache := newSchemaCache(10) ctx := context.Background() - - _, err := cache.loadSchemaFromPath(ctx, "/nonexistent/schema.yaml", 1*time.Hour, 30*time.Second, false) + + _, err := cache.loadSchemaFromPath(ctx, "/nonexistent/schema.yaml", 1*time.Hour, 30*time.Second, nil, false) assert.Error(t, err) } func TestFindSchemaByType_DirectMatch(t *testing.T) { + // A temp file is just the fixture here, so this loads in operator mode: + // a payload-directed load refuses local files by design. cache := newSchemaCache(10) ctx := context.Background() - + tmpFile, err := os.CreateTemp("", "test-schema-*.yaml") assert.NoError(t, err) defer os.Remove(tmpFile.Name()) - + schemaContent := `openapi: 3.1.0 info: title: Test Schema @@ -537,11 +555,11 @@ components: properties: field1: type: string` - + tmpFile.Write([]byte(schemaContent)) tmpFile.Close() - - doc, err := cache.loadSchemaFromPath(ctx, tmpFile.Name(), 1*time.Hour, 30*time.Second, false) + + doc, err := cache.loadSchemaFromPath(ctx, tmpFile.Name(), 1*time.Hour, 30*time.Second, nil, true) assert.NoError(t, err) schema, err := findSchemaByType(ctx, doc, "TestType") @@ -550,13 +568,15 @@ components: } func TestFindSchemaByType_NotFound(t *testing.T) { + // A temp file is just the fixture here, so this loads in operator mode: + // a payload-directed load refuses local files by design. cache := newSchemaCache(10) ctx := context.Background() - + tmpFile, err := os.CreateTemp("", "test-schema-*.yaml") assert.NoError(t, err) defer os.Remove(tmpFile.Name()) - + schemaContent := `openapi: 3.1.0 info: title: Test Schema @@ -565,11 +585,11 @@ components: schemas: TestType: type: object` - + tmpFile.Write([]byte(schemaContent)) tmpFile.Close() - - doc, err := cache.loadSchemaFromPath(ctx, tmpFile.Name(), 1*time.Hour, 30*time.Second, false) + + doc, err := cache.loadSchemaFromPath(ctx, tmpFile.Name(), 1*time.Hour, 30*time.Second, nil, true) assert.NoError(t, err) _, err = findSchemaByType(ctx, doc, "NonExistentType") @@ -580,11 +600,7 @@ components: func TestValidateReferencedObject_Valid(t *testing.T) { cache := newSchemaCache(10) ctx := context.Background() - - tmpFile, err := os.CreateTemp("", "test-schema-*.yaml") - assert.NoError(t, err) - defer os.Remove(tmpFile.Name()) - + schemaContent := `openapi: 3.1.0 info: title: Test Schema @@ -602,33 +618,28 @@ components: type: string required: - field1` - - tmpFile.Write([]byte(schemaContent)) - tmpFile.Close() - + + ctxURL := serveTempSchema(t, schemaContent) + obj := referencedObject{ Path: "message.test", - Context: tmpFile.Name(), + Context: ctxURL, Type: "TestType", Data: map[string]interface{}{ - "@context": tmpFile.Name(), + "@context": ctxURL, "@type": "TestType", "field1": "value1", }, } - - err = cache.validateReferencedObject(ctx, obj, 1*time.Hour, 30*time.Second, nil, false) + + err := cache.validateReferencedObject(ctx, obj, 1*time.Hour, 30*time.Second, nil, false) assert.NoError(t, err) } func TestValidateReferencedObject_Invalid(t *testing.T) { cache := newSchemaCache(10) ctx := context.Background() - - tmpFile, err := os.CreateTemp("", "test-schema-*.yaml") - assert.NoError(t, err) - defer os.Remove(tmpFile.Name()) - + schemaContent := `openapi: 3.1.0 info: title: Test Schema @@ -646,21 +657,20 @@ components: type: string required: - field1` - - tmpFile.Write([]byte(schemaContent)) - tmpFile.Close() - + + ctxURL := serveTempSchema(t, schemaContent) + obj := referencedObject{ Path: "message.test", - Context: tmpFile.Name(), + Context: ctxURL, Type: "TestType", Data: map[string]interface{}{ - "@context": tmpFile.Name(), + "@context": ctxURL, "@type": "TestType", }, } - - err = cache.validateReferencedObject(ctx, obj, 1*time.Hour, 30*time.Second, nil, false) + + err := cache.validateReferencedObject(ctx, obj, 1*time.Hour, 30*time.Second, nil, false) assert.Error(t, err) schemaErrors := []model.Error{} @@ -697,10 +707,6 @@ func TestValidateReferencedObject_EntityTypeNotFound(t *testing.T) { cache := newSchemaCache(10) ctx := context.Background() - tmpFile, err := os.CreateTemp("", "test-schema-*.yaml") - assert.NoError(t, err) - defer os.Remove(tmpFile.Name()) - schemaContent := `openapi: 3.1.0 info: title: Test Schema @@ -710,20 +716,19 @@ components: TestType: type: object` - tmpFile.Write([]byte(schemaContent)) - tmpFile.Close() + ctxURL := serveTempSchema(t, schemaContent) obj := referencedObject{ Path: "message.test", - Context: tmpFile.Name(), + Context: ctxURL, Type: "NonExistentType", Data: map[string]interface{}{ - "@context": tmpFile.Name(), + "@context": ctxURL, "@type": "NonExistentType", }, } - err = cache.validateReferencedObject(ctx, obj, 1*time.Hour, 30*time.Second, nil, false) + err := cache.validateReferencedObject(ctx, obj, 1*time.Hour, 30*time.Second, nil, false) assert.Error(t, err) becknErr, ok := err.(*model.Error) @@ -830,7 +835,7 @@ components: - field1` tests := []struct { - name string + name string allowedDomains []string }{ {name: "file scheme allowed when no allowlist (nil)", allowedDomains: nil}, @@ -849,6 +854,8 @@ components: ctx := context.Background() // Use file:// scheme — would be rejected by scheme check if allowlist were set. + // It is still refused, one layer down: the reader takes http and + // https only. What an empty allowlist skips is the HOST check. obj := referencedObject{ Path: "message.test", Context: "file://" + tmpFile.Name(), @@ -857,7 +864,8 @@ components: } err = cache.validateReferencedObject(ctx, obj, 1*time.Hour, 30*time.Second, tt.allowedDomains, false) - // No domain or scheme error — allowlist check was skipped entirely. + // So: no domain error and no @context scheme error, which is what + // an empty allowlist means. Not "anything is readable". if err != nil { assert.NotContains(t, err.Error(), "domain not allowed") assert.NotContains(t, err.Error(), "invalid scheme in @context") @@ -874,14 +882,14 @@ func TestValidateExtendedSchemas_NoObjects(t *testing.T) { }, schemaCache: newSchemaCache(10), } - + ctx := context.Background() body := map[string]interface{}{ "message": map[string]interface{}{ "field": "value", }, } - + err := v.validateExtendedSchemas(ctx, body) assert.NoError(t, err) } @@ -893,12 +901,12 @@ func TestValidateExtendedSchemas_MissingMessage(t *testing.T) { }, schemaCache: newSchemaCache(10), } - + ctx := context.Background() body := map[string]interface{}{ "context": map[string]interface{}{}, } - + err := v.validateExtendedSchemas(ctx, body) assert.Error(t, err) assert.Contains(t, err.Error(), "missing 'message' field") @@ -1054,9 +1062,9 @@ func TestIsSchemaVersionSegment(t *testing.T) { func TestExtractRelativeSchemaPath(t *testing.T) { tests := []struct { - name string + name string rawURL string - want string + want string }{ { name: "URL with /schema/ marker and version", @@ -1201,7 +1209,7 @@ components: cache.rawSchemas["TestType/attributes.yaml"] = []byte(schemaContent) - doc, err := cache.loadSchemaFromPath(ctx, "TestType/attributes.yaml", 1*time.Hour, 30*time.Second, true) + doc, err := cache.loadSchemaFromPath(ctx, "TestType/attributes.yaml", 1*time.Hour, 30*time.Second, nil, true) assert.NoError(t, err) assert.NotNil(t, doc) assert.Equal(t, "3.1.0", doc.OpenAPI) @@ -1215,7 +1223,7 @@ func TestLoadSchemaFromPath_LRUHit(t *testing.T) { cache.set(hashURL("TestType/attributes.yaml"), expected, 1*time.Hour) // localSchema=false skips rawSchemas step, goes straight to LRU - doc, err := cache.loadSchemaFromPath(ctx, "TestType/attributes.yaml", 1*time.Hour, 30*time.Second, false) + doc, err := cache.loadSchemaFromPath(ctx, "TestType/attributes.yaml", 1*time.Hour, 30*time.Second, nil, false) assert.NoError(t, err) assert.Equal(t, expected, doc) } @@ -1235,7 +1243,7 @@ info: tmpFile.Close() // rawSchemas empty, localSchema=true — local miss, falls through to file load - doc, err := cache.loadSchemaFromPath(ctx, tmpFile.Name(), 1*time.Hour, 30*time.Second, true) + doc, err := cache.loadSchemaFromPath(ctx, tmpFile.Name(), 1*time.Hour, 30*time.Second, nil, true) assert.NoError(t, err) assert.NotNil(t, doc) } @@ -1274,11 +1282,7 @@ func TestValidateReferencedObject_LocalMissFallsBackToContext(t *testing.T) { cache := newSchemaCache(10) ctx := context.Background() - tmpFile, err := os.CreateTemp("", "test-schema-*.yaml") - assert.NoError(t, err) - defer os.Remove(tmpFile.Name()) - - tmpFile.Write([]byte(`openapi: 3.1.0 + ctxURL := serveTempSchema(t, `openapi: 3.1.0 info: title: Test Schema version: 1.0.0 @@ -1288,22 +1292,21 @@ components: type: object properties: field1: - type: string`)) - tmpFile.Close() + type: string`) obj := referencedObject{ Path: "message.test", - Context: tmpFile.Name(), + Context: ctxURL, Type: "TestType", Data: map[string]interface{}{ - "@context": tmpFile.Name(), + "@context": ctxURL, "@type": "TestType", "field1": "value1", }, } - // rawSchemas empty, localSchema=true — local miss, falls back to @context file path - err = cache.validateReferencedObject(ctx, obj, 1*time.Hour, 30*time.Second, nil, true) + // rawSchemas empty, localSchema=true — local miss, falls back to fetching the @context + err := cache.validateReferencedObject(ctx, obj, 1*time.Hour, 30*time.Second, nil, true) assert.NoError(t, err) } @@ -1364,7 +1367,7 @@ func TestLoadSchemaFromPath_TTLExpiry_FetchesFresh(t *testing.T) { ctx := context.Background() // Load v1 with a 1ms TTL so the LRU entry expires almost immediately. - doc1, err := cache.loadSchemaFromPath(ctx, server.URL, 1*time.Millisecond, 30*time.Second, false) + doc1, err := cache.loadSchemaFromPath(ctx, server.URL, 1*time.Millisecond, 30*time.Second, nil, false) assert.NoError(t, err) assert.Equal(t, "Schema v1", doc1.Info.Title) @@ -1373,7 +1376,614 @@ func TestLoadSchemaFromPath_TTLExpiry_FetchesFresh(t *testing.T) { serveV2.Store(true) // Re-load — LRU miss (expired), freshReadFromURI fetches from the server and gets v2. - doc2, err := cache.loadSchemaFromPath(ctx, server.URL, 1*time.Hour, 30*time.Second, false) + doc2, err := cache.loadSchemaFromPath(ctx, server.URL, 1*time.Hour, 30*time.Second, nil, false) assert.NoError(t, err) assert.Equal(t, "Schema v2", doc2.Info.Title, "expected v2 after TTL expiry — global URIMapCache not bypassed") } + +// packStyleSchema mirrors how the capability schema packs are shaped: the capability +// declares @type one level down in allOf and lists it as required, and nothing +// closes the object with additionalProperties:false. +const packStyleSchema = `openapi: 3.1.0 +info: + title: Pack Style + version: 1.0.0 +components: + schemas: + WeatherObservation: + type: object + x-jsonld: + "@context": https://schemas.example.org/schema/WeatherObservation/v0.1/context.jsonld + "@type": openagrinet:WeatherObservation + allOf: + - type: object + required: + - informationMode + properties: + informationMode: + type: string + enum: [OnDemand, Direct] + - type: object + required: + - "@type" + properties: + "@type": + type: string + const: openagrinet:WeatherObservation` + +// serveTempSchema serves content over http and returns a URL usable as an +// @context. Served rather than written to disk because a payload-directed load +// reads http and https only -- and because fetching is what production does. +func serveTempSchema(t *testing.T, content string) string { + t.Helper() + return serveSchema(t, content).URL + "/context.jsonld" +} + +// A pack that requires @type must receive it. This is the case that could not +// validate while both JSON-LD keys were removed unconditionally: the payload +// carries @type, the schema requires it, and stripping it produced a spurious +// "@type is required". +func TestValidateReferencedObject_PackStyleKeepsAtType(t *testing.T) { + cache := newSchemaCache(10) + path := serveTempSchema(t, packStyleSchema) + + obj := referencedObject{ + Path: "message.catalogs[0].resources[0].resourceAttributes", + Context: path, + Type: "openagrinet:WeatherObservation", + Data: map[string]interface{}{ + "@context": "https://schemas.example.org/schema/WeatherObservation/v0.1/context.jsonld", + "@type": "openagrinet:WeatherObservation", + "informationMode": "OnDemand", + }, + } + + err := cache.validateReferencedObject(context.Background(), obj, 1*time.Hour, 30*time.Second, nil, false) + assert.NoError(t, err) +} + +// packStyleTypeListSchema mirrors how the packs really declare @type: a oneOf +// whose first branch is the canonical string and whose second is a list +// carrying that type alongside provider-defined ones, which must not take the +// openagrinet: prefix. +const packStyleTypeListSchema = `openapi: 3.1.0 +info: + title: Pack Style With Type List + version: 1.0.0 +components: + schemas: + WeatherObservation: + type: object + x-jsonld: + "@context": https://schemas.example.org/schema/WeatherObservation/v0.1/context.jsonld + "@type": openagrinet:WeatherObservation + allOf: + - type: object + required: + - informationMode + properties: + informationMode: + type: string + enum: [OnDemand, Direct] + - type: object + required: + - "@type" + properties: + "@type": + oneOf: + - type: string + const: openagrinet:WeatherObservation + - type: array + minItems: 2 + uniqueItems: true + contains: + const: openagrinet:WeatherObservation + items: + oneOf: + - const: openagrinet:WeatherObservation + - type: string + minLength: 1 + not: + pattern: "^openagrinet:"` + +// resourceBody wraps resourceAttributes the way a payload carries them, so +// discovery runs over the same shape production sees. +func resourceBody(ctxURL string, atType interface{}, informationMode string) map[string]interface{} { + attrs := map[string]interface{}{"@context": ctxURL, "@type": atType} + if informationMode != "" { + attrs["informationMode"] = informationMode + } + return map[string]interface{}{ + "message": map[string]interface{}{ + "catalogs": []interface{}{ + map[string]interface{}{"resources": []interface{}{ + map[string]interface{}{"resourceAttributes": attrs}, + }}, + }, + }, + } +} + +// theObjectIn runs the production discovery over a body and returns the single +// domain object in it. Tests go through this rather than building a +// referencedObject by hand: Context, Type and Data all come off one map there, +// so a hand-built object can assert a state the real path cannot produce. +func theObjectIn(t *testing.T, body map[string]interface{}) referencedObject { + t.Helper() + objects := findReferencedObjects(body["message"], "message") + if len(objects) != 1 { + t.Fatalf("expected exactly one domain object from discovery, got %d", len(objects)) + } + return objects[0] +} + +// The pack allows @type to be a list, and reading only the string form meant +// such an object matched nothing, was dropped before validation, and the layer +// reported a pass over a payload it had not looked at. +func TestValidateReferencedObject_AcceptsAndChecksATypeList(t *testing.T) { + ctxURL := serveTempSchema(t, packStyleTypeListSchema) + const canonical = "openagrinet:WeatherObservation" + + for _, tt := range []struct { + name string + atType interface{} + wantErr bool + wantErrHas string + }{ + { + name: "the canonical type alone, as a string", + atType: canonical, + }, + { + name: "the canonical type beside a provider type", + atType: []interface{}{canonical, "vendor:GriddedForecast"}, + }, + { + name: "provider type first -- the document decides which entry names the capability", + atType: []interface{}{"vendor:GriddedForecast", canonical}, + }, + { + // A real payload-level rejection, and one that only bites because + // @type is kept in the data rather than stripped: the list branch + // forbids a second openagrinet: type. + name: "a second openagrinet type, which the pack forbids", + atType: []interface{}{canonical, "openagrinet:MandiPrice"}, + wantErr: true, + }, + { + // The string branch does not match a list and the list branch + // requires two entries, so neither is satisfied. + name: "a single-entry list, which satisfies neither branch", + atType: []interface{}{canonical}, + wantErr: true, + }, + { + name: "a list naming no type the document declares", + atType: []interface{}{"vendor:One", "vendor:Two"}, + wantErr: true, + wantErrHas: "no schema found", + }, + { + name: "@type present but not a type name", + atType: 42, + wantErr: true, + wantErrHas: "not a type name", + }, + { + name: "@type an empty list", + atType: []interface{}{}, + wantErr: true, + wantErrHas: "not a type name", + }, + } { + t.Run(tt.name, func(t *testing.T) { + obj := theObjectIn(t, resourceBody(ctxURL, tt.atType, "OnDemand")) + err := newSchemaCache(10).validateReferencedObject( + context.Background(), obj, 1*time.Hour, 30*time.Second, nil, false) + + if !tt.wantErr { + assert.NoError(t, err) + return + } + if err == nil { + t.Fatal("expected a rejection; a skipped object is reported as valid") + } + if tt.wantErrHas != "" { + assert.Contains(t, err.Error(), tt.wantErrHas) + } + }) + } +} + +// An object claiming a type this validator cannot read must be rejected, not +// passed over. Skipping is what let unvalidated resourceAttributes through. +func TestFindReferencedObjects_TypeShapes(t *testing.T) { + const ctxURL = "https://schemas.example.org/schema/WeatherObservation/v0.1/context.jsonld" + + for _, tt := range []struct { + name string + attrs map[string]interface{} + wantFound bool + wantTypes []string + wantCode string + }{ + { + name: "string @type", + attrs: map[string]interface{}{"@context": ctxURL, "@type": "openagrinet:WeatherObservation"}, + wantFound: true, + wantTypes: []string{"openagrinet:WeatherObservation"}, + }, + { + name: "list @type keeps every entry, in payload order", + attrs: map[string]interface{}{"@context": ctxURL, "@type": []interface{}{"a", "b"}}, + wantFound: true, + wantTypes: []string{"a", "b"}, + }, + { + name: "list @context takes the first string, since only a URL locates a schema", + attrs: map[string]interface{}{"@context": []interface{}{ctxURL, map[string]interface{}{"inline": "term"}}, "@type": "T"}, + wantFound: true, + wantTypes: []string{"T"}, + }, + { + name: "inline-object @context names no document to fetch", + attrs: map[string]interface{}{"@context": map[string]interface{}{"inline": "term"}, "@type": "T"}, + wantFound: true, + wantCode: "SCH_INVALID_JSONLD_CONTEXT", + }, + { + name: "@type a number", + attrs: map[string]interface{}{"@context": ctxURL, "@type": 42}, + wantFound: true, + wantCode: "SCH_INVALID_ENTITY_TYPE", + }, + { + // No claim about which schema applies, so there is nothing to + // validate against. Passed over, as before. + name: "@context with no @type at all", + attrs: map[string]interface{}{"@context": ctxURL, "field": "value"}, + wantFound: false, + }, + } { + t.Run(tt.name, func(t *testing.T) { + objects := findReferencedObjects(map[string]interface{}{"resourceAttributes": tt.attrs}, "message") + if !tt.wantFound { + assert.Empty(t, objects) + return + } + if len(objects) != 1 { + t.Fatalf("expected one object, got %d", len(objects)) + } + obj := objects[0] + + if tt.wantCode != "" { + if obj.Unusable == nil { + t.Fatal("expected the object to be marked unusable, so it is rejected rather than skipped") + } + becknErr, ok := obj.Unusable.(*model.Error) + if !ok { + t.Fatalf("Unusable = %T, want *model.Error", obj.Unusable) + } + assert.Equal(t, tt.wantCode, becknErr.Code) + + // and it must actually reject when validated + err := newSchemaCache(10).validateReferencedObject( + context.Background(), obj, 1*time.Hour, 30*time.Second, nil, false) + assert.Error(t, err) + return + } + + assert.Nil(t, obj.Unusable) + assert.Equal(t, tt.wantTypes, obj.Types) + assert.Equal(t, tt.wantTypes[0], obj.Type) + assert.Equal(t, ctxURL, obj.Context) + }) + } +} + +func TestStripUnaccountedJSONLDKeys(t *testing.T) { + declaresType := &openapi3.SchemaRef{Value: &openapi3.Schema{ + AllOf: openapi3.SchemaRefs{ + {Value: &openapi3.Schema{ + Required: []string{"@type"}, + Properties: openapi3.Schemas{"@type": {Value: &openapi3.Schema{}}}, + }}, + }, + }} + declaresNeither := &openapi3.SchemaRef{Value: &openapi3.Schema{ + Properties: openapi3.Schemas{"field1": {Value: &openapi3.Schema{}}}, + }} + declaresBoth := &openapi3.SchemaRef{Value: &openapi3.Schema{ + Properties: openapi3.Schemas{ + "@context": {Value: &openapi3.Schema{}}, + "@type": {Value: &openapi3.Schema{}}, + }, + }} + + data := map[string]interface{}{ + "@context": "https://example.com/context.jsonld", + "@type": "openagrinet:WeatherObservation", + "field1": "value1", + } + + tests := []struct { + name string + schema *openapi3.SchemaRef + want []string + }{ + {"pack declares @type, so only @context goes", declaresType, []string{"@type", "field1"}}, + {"schema declares neither, so both go", declaresNeither, []string{"field1"}}, + {"schema declares both, so neither goes", declaresBoth, []string{"@context", "@type", "field1"}}, + {"nil schema is treated as declaring nothing", nil, []string{"field1"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := stripUnaccountedJSONLDKeys(tt.schema, data) + keys := make([]string, 0, len(got)) + for k := range got { + keys = append(keys, k) + } + sort.Strings(keys) + assert.Equal(t, tt.want, keys) + // the input must not be mutated -- obj.Data is shared with the caller + assert.Len(t, data, 3) + }) + } +} + +func TestSchemaDeclaresProperty(t *testing.T) { + leaf := func(required ...string) *openapi3.SchemaRef { + return &openapi3.SchemaRef{Value: &openapi3.Schema{Required: required}} + } + + cyclic := &openapi3.SchemaRef{Value: &openapi3.Schema{}} + cyclic.Value.AllOf = openapi3.SchemaRefs{cyclic} + + tests := []struct { + name string + schema *openapi3.SchemaRef + want bool + }{ + {"nil ref", nil, false}, + {"nil value", &openapi3.SchemaRef{}, false}, + {"declared directly as a property", &openapi3.SchemaRef{Value: &openapi3.Schema{ + Properties: openapi3.Schemas{"@type": {Value: &openapi3.Schema{}}}, + }}, true}, + {"required directly", leaf("@type"), true}, + {"required inside allOf", &openapi3.SchemaRef{Value: &openapi3.Schema{ + AllOf: openapi3.SchemaRefs{leaf("other"), leaf("@type")}, + }}, true}, + {"required inside anyOf", &openapi3.SchemaRef{Value: &openapi3.Schema{ + AnyOf: openapi3.SchemaRefs{leaf("@type")}, + }}, true}, + {"required inside oneOf", &openapi3.SchemaRef{Value: &openapi3.Schema{ + OneOf: openapi3.SchemaRefs{leaf("@type")}, + }}, true}, + {"required inside then", &openapi3.SchemaRef{Value: &openapi3.Schema{ + Then: leaf("@type"), + }}, true}, + {"required inside else", &openapi3.SchemaRef{Value: &openapi3.Schema{ + Else: leaf("@type"), + }}, true}, + // naming a property under "not" forbids it, so it must not count as declared + {"named under not does not count", &openapi3.SchemaRef{Value: &openapi3.Schema{ + Not: leaf("@type"), + }}, false}, + // "if" only selects a branch; it does not permit the property + {"named under if does not count", &openapi3.SchemaRef{Value: &openapi3.Schema{ + If: leaf("@type"), + }}, false}, + {"absent everywhere", &openapi3.SchemaRef{Value: &openapi3.Schema{ + AllOf: openapi3.SchemaRefs{leaf("informationMode")}, + }}, false}, + {"self-referencing schema terminates", cyclic, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := schemaDeclaresProperty(tt.schema, "@type", map[*openapi3.Schema]bool{}) + assert.Equal(t, tt.want, got) + }) + } +} + +// A payload chooses the @context, so it chooses every document the loader then +// reads to resolve that document's $refs. The allowlist is consulted once, on +// the entry URL; these tests cover what happens after it. + +const entrySchemaRefTemplate = `openapi: 3.1.0 +info: + title: entry + version: "1" +paths: {} +components: + schemas: + TestType: + type: object + properties: + field1: + $ref: "REF_TARGET#/components/schemas/Borrowed" +` + +const borrowedSchema = `openapi: 3.1.0 +info: + title: borrowed + version: "1" +paths: {} +components: + schemas: + Borrowed: + type: string +` + +// serveSchema returns an https-less test server answering every path with body. +func serveSchema(t *testing.T, body string) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/yaml") + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + return srv +} + +func TestValidateReferencedObject_RefusesARefThatWouldReadTheDisk(t *testing.T) { + // A real file, so a successful read would be indistinguishable from a + // legitimate schema and the test could not tell the two apart. + onDisk := filepath.Join(t.TempDir(), "borrowed.yaml") + if err := os.WriteFile(onDisk, []byte(borrowedSchema), 0o600); err != nil { + t.Fatalf("failed to write the file under test: %v", err) + } + + for _, tt := range []struct { + name string + ref string + }{ + {name: "file scheme", ref: "file://" + onDisk}, + {name: "bare path, which parses with no scheme at all", ref: onDisk}, + } { + t.Run(tt.name, func(t *testing.T) { + entry := serveSchema(t, strings.Replace(entrySchemaRefTemplate, "REF_TARGET", tt.ref, 1)) + host, err := url.Parse(entry.URL) + if err != nil { + t.Fatalf("failed to parse the test server URL: %v", err) + } + + cache := newSchemaCache(10) + obj := referencedObject{ + Path: "message.test", + Context: entry.URL + "/context.jsonld", + Type: "TestType", + Data: map[string]interface{}{"field1": "value1"}, + } + + err = cache.validateReferencedObject(context.Background(), obj, + 1*time.Hour, 30*time.Second, []string{host.Host}, false) + + // The entry document is allowlisted and https, so nothing before + // the $ref refuses this. Only the reader can. + if err == nil { + t.Fatal("the $ref was read, so a payload can name any file on disk") + } + assert.Contains(t, err.Error(), "refusing to read schema from") + }) + } +} + +// A $ref may not reach a host the allowlist does not name. +// +// The entry @context being allowlisted is not enough. The document it returns +// is NOT trusted -- it came from a URL the payload chose, on a host anyone can +// publish to -- so its $refs used to reach any http host at all. That let a +// payload name an attacker's document and have this process fetch whatever +// that document pointed at: an internal service, a cloud metadata endpoint. +// +// This is the case the allowlist has to cover to mean anything, because the +// refs are the great majority of the reads: one pack pulls 13-16 documents. +func TestValidateReferencedObject_RefusesARefToAHostOutsideTheAllowlist(t *testing.T) { + borrowed := serveSchema(t, borrowedSchema) + entry := serveSchema(t, strings.Replace(entrySchemaRefTemplate, "REF_TARGET", borrowed.URL+"/borrowed.yaml", 1)) + + entryHost, err := url.Parse(entry.URL) + if err != nil { + t.Fatalf("failed to parse the test server URL: %v", err) + } + borrowedHost, err := url.Parse(borrowed.URL) + if err != nil { + t.Fatalf("failed to parse the test server URL: %v", err) + } + if entryHost.Port() == borrowedHost.Port() { + t.Fatal("the two servers must differ, or this proves nothing") + } + + cache := newSchemaCache(10) + obj := referencedObject{ + Path: "message.test", + Context: entry.URL + "/context.jsonld", + Type: "TestType", + Types: []string{"TestType"}, + Data: map[string]interface{}{"field1": "value1"}, + } + + // Only the entry host is allowlisted. The $ref host is not. + err = cache.validateReferencedObject(context.Background(), obj, + 1*time.Hour, 30*time.Second, []string{entryHost.Host}, false) + if err == nil { + t.Fatal("the cross-host $ref was fetched; a payload can point this process at any http host") + } + assert.Contains(t, err.Error(), "not in extendedSchema_allowedDomains") +} + +// And naming both hosts loads it, which is the case the packs need: a +// capability pack $refs schema.beckn.io, which $refs schema.nfh.global, so the +// allowlist has to carry every host in the chain or nothing loads. +func TestValidateReferencedObject_AllowsARefWhenBothHostsAreAllowlisted(t *testing.T) { + borrowed := serveSchema(t, borrowedSchema) + entry := serveSchema(t, strings.Replace(entrySchemaRefTemplate, "REF_TARGET", borrowed.URL+"/borrowed.yaml", 1)) + + entryHost, err := url.Parse(entry.URL) + if err != nil { + t.Fatalf("failed to parse the test server URL: %v", err) + } + borrowedHost, err := url.Parse(borrowed.URL) + if err != nil { + t.Fatalf("failed to parse the test server URL: %v", err) + } + + cache := newSchemaCache(10) + obj := referencedObject{ + Path: "message.test", + Context: entry.URL + "/context.jsonld", + Type: "TestType", + Types: []string{"TestType"}, + Data: map[string]interface{}{"field1": "value1"}, + } + + if err := cache.validateReferencedObject(context.Background(), obj, + 1*time.Hour, 30*time.Second, []string{entryHost.Host, borrowedHost.Host}, false); err != nil { + t.Fatalf("both hosts allowlisted, so the chain must load: %v", err) + } +} + +func TestPayloadDirectedReader(t *testing.T) { + onDisk := filepath.Join(t.TempDir(), "schema.yaml") + if err := os.WriteFile(onDisk, []byte(borrowedSchema), 0o600); err != nil { + t.Fatalf("failed to write the file under test: %v", err) + } + + for _, tt := range []struct { + name string + raw string + refused bool + }{ + {name: "file scheme", raw: "file://" + onDisk, refused: true}, + {name: "bare path", raw: onDisk, refused: true}, + {name: "a scheme nobody serves schemas over", raw: "gopher://example.test/schema.yaml", refused: true}, + {name: "http is read", raw: "", refused: false}, + } { + t.Run(tt.name, func(t *testing.T) { + raw := tt.raw + if raw == "" { + raw = serveSchema(t, borrowedSchema).URL + "/schema.yaml" + } + u, err := url.Parse(raw) + if err != nil { + t.Fatalf("failed to parse %q: %v", raw, err) + } + + data, err := payloadDirectedReader(nil, false)(openapi3.NewLoader(), u) + if tt.refused { + if err == nil { + t.Fatalf("%q was read, and must not have been", raw) + } + assert.Contains(t, err.Error(), "only http and https are read") + // The point is that nothing was read, not merely that it errored. + assert.Empty(t, data) + return + } + assert.NoError(t, err) + assert.Contains(t, string(data), "Borrowed") + }) + } +} diff --git a/pkg/plugin/implementation/sunbirdRegistry/README.md b/pkg/plugin/implementation/sunbirdRegistry/README.md new file mode 100644 index 00000000..459d51fe --- /dev/null +++ b/pkg/plugin/implementation/sunbirdRegistry/README.md @@ -0,0 +1,265 @@ +# SunbirdRC Registry Plugin + +A **registry type plugin** for Beckn-ONIX that reads a SunbirdRC registry, a +[SunbirdRC](https://docs.sunbirdrc.dev/) deployment. + +## Overview + +It answers two questions, and they are deliberately kept apart. + +**Who sent this?** `definition.RegistryLookup` — given the `subscriber_id` and +`key_id` carried in an inbound request's `Authorization` header, it returns that +**sender's** public key so the signature can be verified. + +**Who do I call next?** `definition.ProviderRecordLookup` — given a capability +binding taken from the request body, it returns the **upstream provider's** call +plan: where to call, how, and which mappings translate in and out. + +Different subject, different cache, different meaning of failure. They share +transport and nothing else. A caller reaches the second by type-asserting the +first, the same way `RegistryMetadataLookup` is reached elsewhere. + +It is read-only. Onboarding, key publication and status changes all happen +through the registry's own Participant APIs, not through this plugin. + +This call sits inside signature validation, so it runs on **every inbound +message**. Its timeout and retry budget are deliberately tighter than the +sibling registry plugins' for that reason: `timeout × (retry_max + 1)` is time a +request spends waiting before it can even be rejected. + +## Configuration + +```yaml +registry: + id: sunbirdRegistry + config: + url: http://registry:8081/api/v1 + entity: Participant + providerEntity: ProviderSchema + timeout: 2 + retry_max: 1 + retry_wait_min: 100ms + retry_wait_max: 500ms + cacheTTL: 60s +``` + +| Parameter | Required | Description | Default | +|-----------|----------|-------------|---------| +| `url` | **Yes** | Registry base URL, including the API version prefix. The plugin appends `/{entity}/search`. | — | +| `entity` | No | Registry entity to search. | `Participant` | +| `timeout` | No | Per-attempt request timeout, in seconds. Must be positive. | `2` | +| `retry_max` | No | Retry attempts after the first. `0` means do not retry, and is honoured as such. | `1` | +| `retry_wait_min` | No | Minimum backoff between attempts. | `100ms` | +| `retry_wait_max` | No | Maximum backoff between attempts. Also the ceiling a `Retry-After` header is clamped to. | `500ms` | +| `cacheTTL` | No | How long a resolved participant is reused. **Absent or `0` disables caching entirely.** | off | + +A `cache` plugin must also be configured for `cacheTTL` to have any effect. + +Startup fails on: a missing `url`, a `url` with no scheme or host, a +non-positive `timeout`, a negative `retry_max` or wait, an unparseable +`cacheTTL`, or `retry_wait_min` exceeding `retry_wait_max`. Catching +`registry:8081` (no scheme) at startup is cheaper than watching every lookup +fail once traffic arrives. + +### On `cacheTTL` + +The TTL is the **suspension-propagation window**: a cached participant keeps +verifying until the entry expires, even after the Network Operator suspends it. +That is why caching is off by default rather than something a deployment +inherits. + +The TTL is never taken from the key's own `validUntil`. That window is +typically a year, which would keep a suspended participant verifying for a year. + +Misses and refusals are never cached. Caching a miss would extend an outage; +caching a refusal would delay a reinstatement. + +## How a lookup works + +``` +Authorization: Signature keyId="||ed25519", ... + │ │ + ▼ ▼ + POST {url}/{entity}/search {"filters":{"participantId":{"eq":""}}} + │ + ▼ + walk node.keys[] for one whose osid == key_id, and whose use is signing + │ + ▼ + participant status == "active" + AND key status == "active" + AND key material present? → SUBSCRIBED + anything else → UNSUBSCRIBED +``` + +`key_id` is the **key's** `osid` (`node.keys[].osid`), not the participant's or +the node's. A record carries all three and they look alike; matching either of +the other two resolves the wrong thing, and keeps doing so the moment a second +key is published. + +**Only `participantId` is filtered on.** It is the schema's +`uniqueIndexFields`, so the registry already guarantees at most one match. +`osid` is system-generated and not indexed at all — on an Elasticsearch-backed +deployment, filtering on it matches nothing, which would turn every lookup into +a not-found. It is also nested inside the record, which a flat filter could not +reach in any case. The key identity is therefore checked client-side, where it +works on any backend and enforces exactly the same property. + +**`status` is not filtered on either.** Excluding suspended participants +server-side would return an empty result, making "suspended" indistinguishable +from "unknown" and losing the reason the caller reports. + +## How a provider record resolves + +``` +message.offer.provider.id ─┐ +message.resourceAttributes["@type"] ─┴─▶ bindingKey "|" + │ + ▼ + POST {url}/{providerEntity}/search {"filters":{"bindingKey":{"eq":"..."}}} + │ + ▼ the binding names its owner + POST {url}/{entity}/search {"filters":{"participantId":{"eq":"..."}}} + │ + ▼ + both statuses "active" and an upstream url present? → a call plan + anything else → ErrProviderRecordNotFound +``` + +Two reads, joined into one `model.ProviderRecord`: `baseUrl` from the +participant, a **call plan per action** from the binding. + +```json +{ + "bindingKey": "mausamgram|openagrinet:WeatherObservation", + "participantId": "mausamgram", + "capabilityCode": "openagrinet:WeatherObservation", + "status": "active", + "actions": [ + { "action": "select", "method": "GET", "path": "/get-daily", + "mappings": "https://.../mausamgram/weather-observation.select.yaml", + "timeoutMs": 30000, "retryMax": 3, "status": "active" }, + { "action": "confirm", "method": "POST", "path": "/book", + "mappings": "https://.../mausamgram/weather-observation.confirm.yaml", + "status": "inactive" } + ] +} +``` + +A capability serves several actions and they rarely share an endpoint, a method +or a mapping — a `confirm` that commits does not post where a `select` that reads +gets — so all of it is per action. An action absent from `actions` is one the +capability does not serve, and a binding serving none at all is refused outright +rather than failing one action at a time. + +**An array rather than a keyed object, for two reasons.** A per-action `status` +is how one action is retired while the capability and every other action stay +live, and an entry that is not `active` is skipped exactly as if it were absent. +And the registry treats every nested object as an entity and injects an `osid` +into it, which a keyed map cannot carry. + +`mappings` is one reference per action carrying **both directions**, because the +response mapping usually depends on what the request mapping did. It is the +published file's URL, passed through verbatim — this plugin does not interpret +or resolve it. + +The owning participant is the one the **binding names**, not one parsed out of +the binding key — the registry owns that relationship, not the key format. + +Mapping references are carried **verbatim**. They are URLs the mapper resolves; +this plugin does not read, fetch or interpret them. + +`timeoutMs` and `retryMax` are zero when the registry omits them, meaning "the +caller applies its own default" — not "no timeout and no retries". + +Every way of saying *this capability cannot be served* — absent, withdrawn, +suspended, unroutable — returns `ErrProviderRecordNotFound`, because a caller +does the same thing with all of them. A registry that could not be **consulted** +returns its own error instead: that is an outage, not an answer. The two are +separated in metrics, never in the returned type. + +## What a caller gets back + +| Situation | Result | +|---|---| +| Registered, active, has a key | One `Subscription`, `Status: SUBSCRIBED` | +| Registered but suspended, or has no key | One `Subscription`, `Status: UNSUBSCRIBED` | +| Not registered | Empty slice, `nil` error | +| Registry unreachable or unreadable | `nil`, error | + +"Not found" is a legitimate answer, not an error — the caller turns an empty +slice into its own not-found. A participant that exists but may not sign comes +back with a status the caller rejects, so that **"unknown" and "suspended" stay +distinguishable** instead of collapsing into the same empty result. + +### Deny by default + +`model.IsKeyStatusUsable` is a deny-list: any status it does not recognise +counts as usable. Passing the registry's own `"inactive"` through unchanged +would therefore let a suspended participant's signature verify. Status mapping +here is a **security control, not a formatting step** — everything denies unless +explicitly allowed. + +Status is checked at **both** levels. A participant stays active while one of its +keys is retired, so a key carrying its own non-active status is refused even +though the participant is trading normally. + +The key validity window (`validFrom` / `validUntil`) is deliberately **not** +enforced. The Network Operator takes a participant off the network by setting +`status`, not by this plugin timing a key out. Both fields are mapped onto the +result for a caller to read, and nothing acts on them. + +Key material is published with an encoding label, e.g. +`"key": "base64:xq4+..."`. The label is stripped before the value reaches +`model.Subscription`, which carries the bare base64 that `signvalidator` hands +straight to `base64.StdEncoding.DecodeString`. Left on, it fails every +verification with a decode error pointing nowhere near the registry. + +## Observability + +Every lookup emits its duration and, when it did not resolve a key, the shared +plugin error counter. The `error_type` dimension is one of: + +Provider-record lookups report under `operation=provider_record`, with their own +outcomes: `binding_not_found` · `binding_inactive` · `binding_unowned` · +`binding_no_actions` · `participant_not_found` · `participant_inactive` · +`no_upstream_url` · `no_binding_key`. Each refusal is kept distinct: they all +deny the call, but a withdrawn capability and a suspended provider are different +operational events. + +`binding_no_actions` is the easiest of these to leave out of a dashboard and the +least obvious to reproduce: the binding is active and owned by this provider, +and it still serves nothing, because its record carries no actions. Counting it +under one of the others would merge "misconfigured" into "withdrawn". + +Signing-key lookups report under `operation=lookup`: + +`found` · `cache_hit` · `not_found` · `key_id_mismatch` · `key_not_signing` · +`inactive` · `key_inactive` · +`no_key` · `timeout` · `registry_error` · `decode_error` · `transport_error` + +Split on `error_type` when alerting. "Not a success" includes outcomes that are +the plugin working correctly — refusing a suspended participant is a successful +denial, and a routine suspension should not read as an incident. + +Two of these are worth watching separately. `not_found` means the caller is not +registered. `key_id_mismatch` means the participant **is** registered and the +key identity model is wrong — a sustained rate of that is a total outage that +would otherwise hide inside routine misses. + +## Notes for operators + +- **Service name, not `localhost`.** In a container the registry is reached by + its service name; `localhost` resolves to the adapter itself. +- **`Retry-After` is clamped** to `retry_wait_max`. The retry library honours it + unclamped, so a registry — or any ingress in front of one — answering + `Retry-After: 3600` would otherwise park a goroutine for an hour inside + signature validation, with no deadline on the inbound request to cut it short. +- **A record declaring an algorithm other than `ed25519` is logged as a + warning**, not refused. The header's algorithm is validated upstream, so a + disagreement cannot let a bad signature through — but it means the record and + the caller disagree about the key, which is worth seeing before it becomes a + verification failure nobody can explain. +- **More than one record for a `participantId`** is a registry integrity fault. + It is logged at error level and the lookup carries on, since the key check + still decides. diff --git a/pkg/plugin/implementation/sunbirdRegistry/cmd/plugin.go b/pkg/plugin/implementation/sunbirdRegistry/cmd/plugin.go new file mode 100644 index 00000000..5992bf4b --- /dev/null +++ b/pkg/plugin/implementation/sunbirdRegistry/cmd/plugin.go @@ -0,0 +1,163 @@ +package main + +import ( + "context" + "errors" + "fmt" + "strconv" + "time" + + "github.com/beckn-one/beckn-onix/pkg/log" + "github.com/beckn-one/beckn-onix/pkg/plugin/definition" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/sunbirdRegistry" +) + +// Defaults for settings an operator leaves out. Only parseConfig can tell +// "absent" from "explicitly zero" -- retry_max of 0 is a legitimate "do not +// retry" -- so they are applied here. The values themselves live in the +// sunbirdRegistry package so there is exactly one place to change them. +const ( + defaultEntity = sunbirdRegistry.DefaultEntity + defaultProviderEntity = sunbirdRegistry.DefaultProviderEntity + defaultTimeout = sunbirdRegistry.DefaultTimeoutSeconds + defaultRetryMax = sunbirdRegistry.DefaultRetryMax + defaultRetryWaitMin = sunbirdRegistry.DefaultRetryWaitMin + defaultRetryWaitMax = sunbirdRegistry.DefaultRetryWaitMax +) + +// sunbirdRegistryProvider implements the RegistryLookupProvider interface for the +// registry plugin. +type sunbirdRegistryProvider struct{} + +// newSunbirdRegistryFunc creates a new registry client. Indirected for tests. +var newSunbirdRegistryFunc = sunbirdRegistry.New + +// parseConfig parses the configuration map into an sunbirdRegistry.Config, starting +// from the defaults and overriding whatever the operator supplied. +func (o sunbirdRegistryProvider) parseConfig(config map[string]string) (*sunbirdRegistry.Config, error) { + cfg := &sunbirdRegistry.Config{ + URL: config["url"], + Entity: defaultEntity, + ProviderEntity: defaultProviderEntity, + Timeout: defaultTimeout, + RetryMax: defaultRetryMax, + RetryWaitMin: defaultRetryWaitMin, + RetryWaitMax: defaultRetryWaitMax, + } + + // Parse entity + if entity, exists := config["entity"]; exists && entity != "" { + cfg.Entity = entity + } + + // Parse providerEntity + if providerEntity, exists := config["providerEntity"]; exists && providerEntity != "" { + cfg.ProviderEntity = providerEntity + } + + // Parse cacheTTL. Absent means caching is off: the TTL is how long a + // suspended participant keeps verifying, so it is opt-in. + if cacheTTLStr, exists := config["cacheTTL"]; exists && cacheTTLStr != "" { + cacheTTL, err := time.ParseDuration(cacheTTLStr) + if err != nil { + return nil, fmt.Errorf("invalid cacheTTL value '%s': %w", cacheTTLStr, err) + } + if cacheTTL < 0 { + return nil, fmt.Errorf("cacheTTL must be non-negative, got %v", cacheTTL) + } + cfg.CacheTTL = cacheTTL + } + + // Parse timeout + if timeoutStr, exists := config["timeout"]; exists && timeoutStr != "" { + timeout, err := strconv.Atoi(timeoutStr) + if err != nil { + return nil, fmt.Errorf("invalid timeout value '%s': %w", timeoutStr, err) + } + if timeout <= 0 { + return nil, fmt.Errorf("timeout must be positive, got %d", timeout) + } + cfg.Timeout = timeout + } + + // Parse retry_max + if retryMaxStr, exists := config["retry_max"]; exists && retryMaxStr != "" { + retryMax, err := strconv.Atoi(retryMaxStr) + if err != nil { + return nil, fmt.Errorf("invalid retry_max value '%s': %w", retryMaxStr, err) + } + if retryMax < 0 { + return nil, fmt.Errorf("retry_max must be non-negative, got %d", retryMax) + } + cfg.RetryMax = retryMax + } + + // Parse maxResponseBytes + if maxBytesStr, exists := config["maxResponseBytes"]; exists && maxBytesStr != "" { + maxBytes, err := strconv.ParseInt(maxBytesStr, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid maxResponseBytes value '%s': %w", maxBytesStr, err) + } + if maxBytes <= 0 { + return nil, fmt.Errorf("maxResponseBytes must be positive, got %d", maxBytes) + } + cfg.MaxResponseBytes = maxBytes + } + + // Parse retry_wait_min + if retryWaitMinStr, exists := config["retry_wait_min"]; exists && retryWaitMinStr != "" { + retryWaitMin, err := time.ParseDuration(retryWaitMinStr) + if err != nil { + return nil, fmt.Errorf("invalid retry_wait_min value '%s': %w", retryWaitMinStr, err) + } + if retryWaitMin < 0 { + return nil, fmt.Errorf("retry_wait_min must be non-negative, got %v", retryWaitMin) + } + cfg.RetryWaitMin = retryWaitMin + } + + // Parse retry_wait_max + if retryWaitMaxStr, exists := config["retry_wait_max"]; exists && retryWaitMaxStr != "" { + retryWaitMax, err := time.ParseDuration(retryWaitMaxStr) + if err != nil { + return nil, fmt.Errorf("invalid retry_wait_max value '%s': %w", retryWaitMaxStr, err) + } + if retryWaitMax < 0 { + return nil, fmt.Errorf("retry_wait_max must be non-negative, got %v", retryWaitMax) + } + cfg.RetryWaitMax = retryWaitMax + } + + if cfg.RetryWaitMin > cfg.RetryWaitMax { + return nil, fmt.Errorf("retry_wait_min (%v) must not exceed retry_wait_max (%v)", cfg.RetryWaitMin, cfg.RetryWaitMax) + } + + return cfg, nil +} + +// New creates a new registry plugin instance. +func (o sunbirdRegistryProvider) New(ctx context.Context, cache definition.Cache, config map[string]string) (definition.RegistryLookup, func() error, error) { + if ctx == nil { + return nil, nil, errors.New("context cannot be nil") + } + + cfg, err := o.parseConfig(config) + if err != nil { + log.Errorf(ctx, err, "Failed to parse OAN registry configuration") + return nil, nil, fmt.Errorf("failed to parse registry configuration: %w", err) + } + + log.Debugf(ctx, "OAN registry config mapped: %+v", cfg) + + client, closer, err := newSunbirdRegistryFunc(ctx, cache, cfg) + if err != nil { + log.Errorf(ctx, err, "Failed to create OAN registry instance") + return nil, nil, err + } + + log.Infof(ctx, "OAN registry instance created successfully") + return client, closer, nil +} + +// Provider is the exported plugin instance. +var Provider = sunbirdRegistryProvider{} diff --git a/pkg/plugin/implementation/sunbirdRegistry/cmd/plugin_test.go b/pkg/plugin/implementation/sunbirdRegistry/cmd/plugin_test.go new file mode 100644 index 00000000..fb2dfec5 --- /dev/null +++ b/pkg/plugin/implementation/sunbirdRegistry/cmd/plugin_test.go @@ -0,0 +1,249 @@ +package main + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + "time" + + "github.com/beckn-one/beckn-onix/pkg/plugin/definition" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/sunbirdRegistry" +) + +func defaultConfig() *sunbirdRegistry.Config { + return &sunbirdRegistry.Config{ + Entity: defaultEntity, + ProviderEntity: defaultProviderEntity, + Timeout: defaultTimeout, + RetryMax: defaultRetryMax, + RetryWaitMin: defaultRetryWaitMin, + RetryWaitMax: defaultRetryWaitMax, + } +} + +func TestParseConfig(t *testing.T) { + t.Parallel() + + withDefaults := func(apply func(*sunbirdRegistry.Config)) *sunbirdRegistry.Config { + cfg := defaultConfig() + apply(cfg) + return cfg + } + + testCases := []struct { + name string + config map[string]string + expected *sunbirdRegistry.Config + expectedErr string + }{ + { + name: "applies defaults when only a URL is given", + config: map[string]string{"url": "http://registry:8081/api/v1"}, + expected: withDefaults(func(c *sunbirdRegistry.Config) { c.URL = "http://registry:8081/api/v1" }), + }, + { + name: "reads every supported setting", + config: map[string]string{ + "url": "http://registry:8081/api/v1", + "entity": "Subscriber", + "cacheTTL": "30s", + "timeout": "5", + "retry_max": "3", + "retry_wait_min": "200ms", + "retry_wait_max": "1s", + }, + expected: &sunbirdRegistry.Config{ + URL: "http://registry:8081/api/v1", + Entity: "Subscriber", + ProviderEntity: defaultProviderEntity, + CacheTTL: 30 * time.Second, + Timeout: 5, + RetryMax: 3, + RetryWaitMin: 200 * time.Millisecond, + RetryWaitMax: time.Second, + }, + }, + { + name: "reads an overridden provider entity", + config: map[string]string{ + "url": "http://registry:8081", + "providerEntity": "ProviderCapability", + }, + expected: withDefaults(func(c *sunbirdRegistry.Config) { + c.URL = "http://registry:8081" + c.ProviderEntity = "ProviderCapability" + }), + }, + { + name: "ignores an empty provider entity and keeps the default", + config: map[string]string{ + "url": "http://registry:8081", + "providerEntity": "", + }, + expected: withDefaults(func(c *sunbirdRegistry.Config) { + c.URL = "http://registry:8081" + }), + }, + { + // Caching is off unless asked for: the TTL is how long a suspended + // participant keeps verifying. + name: "leaves caching disabled when no TTL is set", + config: map[string]string{"url": "http://registry:8081"}, + expected: withDefaults(func(c *sunbirdRegistry.Config) { c.URL = "http://registry:8081" }), + }, + { + // Distinct from "unset", which yields the default of 1. + name: "honours an explicit retry_max of zero", + config: map[string]string{"url": "http://registry:8081", "retry_max": "0"}, + expected: withDefaults(func(c *sunbirdRegistry.Config) { c.URL = "http://registry:8081"; c.RetryMax = 0 }), + }, + { + name: "ignores empty values and keeps the defaults", + config: map[string]string{"url": "http://registry:8081", "entity": "", "timeout": ""}, + expected: withDefaults(func(c *sunbirdRegistry.Config) { c.URL = "http://registry:8081" }), + }, + { + name: "rejects a non-numeric timeout", + config: map[string]string{"url": "http://registry:8081", "timeout": "soon"}, + expectedErr: "invalid timeout value 'soon'", + }, + { + name: "rejects a non-positive timeout", + config: map[string]string{"url": "http://registry:8081", "timeout": "0"}, + expectedErr: "timeout must be positive, got 0", + }, + { + name: "rejects a negative retry_max", + config: map[string]string{"url": "http://registry:8081", "retry_max": "-1"}, + expectedErr: "retry_max must be non-negative, got -1", + }, + { + name: "rejects a malformed cacheTTL", + config: map[string]string{"url": "http://registry:8081", "cacheTTL": "3600"}, + expectedErr: "invalid cacheTTL value '3600'", + }, + { + name: "rejects a malformed retry_wait_min", + config: map[string]string{"url": "http://registry:8081", "retry_wait_min": "quick"}, + expectedErr: "invalid retry_wait_min value 'quick'", + }, + { + name: "rejects a minimum backoff above the maximum", + config: map[string]string{ + "url": "http://registry:8081", + "retry_wait_min": "2s", + "retry_wait_max": "1s", + }, + expectedErr: "retry_wait_min (2s) must not exceed retry_wait_max (1s)", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := sunbirdRegistryProvider{}.parseConfig(tc.config) + + if tc.expectedErr != "" { + if err == nil { + t.Fatalf("expected error %q but got none", tc.expectedErr) + } + if !strings.Contains(err.Error(), tc.expectedErr) { + t.Errorf("expected error containing %q, got %q", tc.expectedErr, err.Error()) + } + return + } + + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if !reflect.DeepEqual(got, tc.expected) { + t.Errorf("expected config %+v, got %+v", tc.expected, got) + } + }) + } +} + +func TestNew(t *testing.T) { + t.Parallel() + + t.Run("rejects a nil context", func(t *testing.T) { + t.Parallel() + + //nolint:staticcheck // deliberately passing a nil context to assert the guard. + _, _, err := sunbirdRegistryProvider{}.New(nil, nil, map[string]string{"url": "http://registry:8081"}) + if err == nil { + t.Fatal("expected an error for a nil context, got none") + } + }) + + t.Run("rejects a missing URL", func(t *testing.T) { + t.Parallel() + + _, _, err := sunbirdRegistryProvider{}.New(context.Background(), nil, map[string]string{}) + if err == nil { + t.Fatal("expected an error for a missing URL, got none") + } + }) + + t.Run("rejects an unparseable config", func(t *testing.T) { + t.Parallel() + + _, _, err := sunbirdRegistryProvider{}.New(context.Background(), nil, map[string]string{ + "url": "http://registry:8081", + "timeout": "soon", + }) + if err == nil { + t.Fatal("expected an error for an invalid timeout, got none") + } + }) + + t.Run("builds a client from a valid config", func(t *testing.T) { + t.Parallel() + + client, closer, err := sunbirdRegistryProvider{}.New(context.Background(), nil, map[string]string{ + "url": "http://registry:8081/api/v1", + }) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if client == nil { + t.Fatal("expected a client, got nil") + } + if closer == nil { + t.Fatal("expected a closer, got nil") + } + if err := closer(); err != nil { + t.Errorf("expected the closer to succeed, got: %v", err) + } + }) + + // Deliberately NOT parallel: this swaps the package-level newSunbirdRegistryFunc, + // so running it alongside its parallel siblings would race on that variable. + // Go never schedules a non-parallel subtest concurrently with parallel ones, + // which is what makes this safe -- do not add t.Parallel() "for consistency". + t.Run("propagates a client construction failure", func(t *testing.T) { + original := newSunbirdRegistryFunc + t.Cleanup(func() { newSunbirdRegistryFunc = original }) + + wantErr := errors.New("boom") + newSunbirdRegistryFunc = func(context.Context, definition.Cache, *sunbirdRegistry.Config) (*sunbirdRegistry.Client, func() error, error) { + return nil, nil, wantErr + } + + _, _, err := sunbirdRegistryProvider{}.New(context.Background(), nil, map[string]string{"url": "http://registry:8081"}) + if !errors.Is(err, wantErr) { + t.Fatalf("expected the underlying error to be propagated, got: %v", err) + } + }) +} + +// TestProviderSatisfiesTheInterface fails at compile time if the exported +// Provider ever stops matching what the plugin loader looks up. +func TestProviderSatisfiesTheInterface(t *testing.T) { + t.Parallel() + + var _ definition.RegistryLookupProvider = Provider +} diff --git a/pkg/plugin/implementation/sunbirdRegistry/providerrecord.go b/pkg/plugin/implementation/sunbirdRegistry/providerrecord.go new file mode 100644 index 00000000..03e621d0 --- /dev/null +++ b/pkg/plugin/implementation/sunbirdRegistry/providerrecord.go @@ -0,0 +1,421 @@ +package sunbirdRegistry + +// providerrecord.go resolves a capability binding into a call plan: what to +// call, how to call it, and which mappings translate in and out. +// +// This is the second thing the registry is asked for, and it is a different +// question from the signing-key lookup in sunbirdRegistry.go. That one asks "who +// sent this", keyed by an inbound Authorization header. This one asks "who do I +// call next", keyed by a binding taken from the request body. Different subject, +// different cache, different meaning of failure -- so they share transport and +// nothing else. + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/beckn-one/beckn-onix/pkg/log" + "github.com/beckn-one/beckn-onix/pkg/model" + "github.com/beckn-one/beckn-onix/pkg/plugin/definition" + "github.com/beckn-one/beckn-onix/pkg/telemetry" + "github.com/hashicorp/go-retryablehttp" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" +) + +// Provider-record outcomes, used as the error_type metric dimension and in logs. +// Each refusal is kept distinct: they all deny the call, but "this capability was +// withdrawn" and "this provider was suspended" are different operational events +// and must not collapse into one series. +const ( + outcomeBindingNotFound = "binding_not_found" + outcomeBindingInactive = "binding_inactive" + outcomeBindingUnowned = "binding_unowned" + outcomeBindingNoActions = "binding_no_actions" + outcomeParticipantNotFound = "participant_not_found" + outcomeParticipantInactive = "participant_inactive" + outcomeNoUpstreamURL = "no_upstream_url" + outcomeNoBindingKey = "no_binding_key" +) + +// providerBinding is the subset of a capability-binding record this plugin +// reads. As with participant, the registry carries more -- Sunbird audit fields, +// the enricher name -- and none of it is modelled: the enricher is resolved by +// the provider plugin from its own code, not from the registry. +type providerBinding struct { + BindingKey string `json:"bindingKey"` + ParticipantID string `json:"participantId"` + CapabilityCode string `json:"capabilityCode"` + Actions []actionPlan `json:"actions"` + Status string `json:"status"` +} + +// actionPlan is one action's upstream call, as the registry publishes it. +// +// A list rather than a map keyed by action, because the registry models nested +// collections as arrays and injects its own osid/osCreatedAt fields into every +// object it stores. A map would have to hold those alongside real actions; a +// list of structs ignores them, the same way the key list on a participant +// already does. +type actionPlan struct { + Action string `json:"action"` + Method string `json:"method"` + Path string `json:"path"` + Mappings string `json:"mappings"` + TimeoutMs int `json:"timeoutMs"` + RetryMax int `json:"retryMax"` + Status string `json:"status"` +} + +var ( + _ definition.RegistryLookup = (*Client)(nil) + _ definition.ProviderRecordLookup = (*Client)(nil) +) + +// searchURLFor builds the search endpoint for one registry entity. +func searchURLFor(baseURL, entity string) string { + return fmt.Sprintf("%s/%s/%s", strings.TrimSuffix(baseURL, "/"), entity, searchPath) +} + +// ProviderRecord resolves bindingKey into everything needed to call the +// provider, reading the capability binding and then the participant that owns +// it. +// +// Every way of saying "this capability cannot be served" -- absent, withdrawn, +// suspended, unroutable -- returns ErrProviderRecordNotFound, because a caller +// does the same thing with all of them. A registry that could not be consulted +// returns its own error instead: that is an outage, not an answer. +func (c *Client) ProviderRecord(ctx context.Context, bindingKey string) (*model.ProviderRecord, error) { + start := time.Now() + tracer := otel.Tracer(telemetry.ScopeName, trace.WithInstrumentationVersion(telemetry.ScopeVersion)) + ctx, span := tracer.Start(ctx, "registry provider record") + defer span.End() + + if bindingKey == "" { + // A caller bug rather than a registry miss: logged at Error so it is not + // mistaken for routine traffic, and refused without a round trip. + log.Errorf(ctx, nil, "OAN registry provider record requested with an empty binding key") + return nil, c.refuse(ctx, span, start, outcomeNoBindingKey) + } + + cacheKey := providerRecordCacheKey(bindingKey) + if plan, found := c.cachedProviderRecord(ctx, tracer, cacheKey); found { + log.Debugf(ctx, "OAN registry provider record cache hit for key: %s", cacheKey) + span.SetAttributes(telemetry.AttrErrorType.String(outcomeCacheHit)) + c.emitMetrics(ctx, start, operationProviderRecord, outcomeCacheHit) + return plan, nil + } + + binding, outcome, err := c.activeBinding(ctx, tracer, bindingKey) + if err != nil { + return nil, c.fail(ctx, span, start, err) + } + if outcome != outcomeFound { + return nil, c.refuse(ctx, span, start, outcome) + } + + owner, outcome, err := c.activeUpstream(ctx, tracer, binding.ParticipantID) + if err != nil { + return nil, c.fail(ctx, span, start, err) + } + if outcome != outcomeFound { + return nil, c.refuse(ctx, span, start, outcome) + } + + plan := toProviderRecord(binding, owner) + log.Debugf(ctx, "OAN registry resolved bindingKey=%s to %s serving %s", bindingKey, plan.BaseURL, strings.Join(plan.ServedActions(), ", ")) + c.cacheProviderRecord(ctx, cacheKey, plan) + + span.SetAttributes(telemetry.AttrErrorType.String(outcomeFound)) + c.emitMetrics(ctx, start, operationProviderRecord, outcomeFound) + return plan, nil +} + +// refuse records a deliberate denial and returns the caller's sentinel. The +// registry answered; the answer was no. +func (c *Client) refuse(ctx context.Context, span trace.Span, start time.Time, outcome string) error { + span.SetAttributes(telemetry.AttrErrorType.String(outcome)) + c.emitMetrics(ctx, start, operationProviderRecord, outcome) + return definition.ErrProviderRecordNotFound +} + +// fail records a registry that could not be consulted at all, which is not an +// answer and must never read as one. +func (c *Client) fail(ctx context.Context, span trace.Span, start time.Time, err error) error { + outcome := classify(err) + span.RecordError(err) + span.SetStatus(codes.Error, outcome) + span.SetAttributes(telemetry.AttrErrorType.String(outcome)) + c.emitMetrics(ctx, start, operationProviderRecord, outcome) + return err +} + +// activeBinding reads the capability binding and reports whether it may be used. +func (c *Client) activeBinding(ctx context.Context, tracer trace.Tracer, bindingKey string) (providerBinding, string, error) { + bindings, err := searchRecords[providerBinding](ctx, c, tracer, c.providerSearchURL, map[string]eqFilter{ + fieldBindingKey: {Eq: bindingKey}, + }) + if err != nil { + return providerBinding{}, "", err + } + + if len(bindings) == 0 { + log.Infof(ctx, "OAN registry has no capability binding for bindingKey=%s", bindingKey) + return providerBinding{}, outcomeBindingNotFound, nil + } + if len(bindings) > 1 { + // bindingKey is the schema's unique index, so this is a registry + // integrity fault. The first row is used rather than refusing outright: + // duplicates are near-always identical, and denying would turn a registry + // hiccup into a total outage for the capability. Said loudly either way. + log.Errorf(ctx, nil, "OAN registry returned %d bindings for bindingKey=%s, expected at most 1", + len(bindings), bindingKey) + } + + binding := bindings[0] + if !isActive(binding.Status) { + log.Infof(ctx, "OAN registry capability binding bindingKey=%s is not usable: status=%q", bindingKey, binding.Status) + return providerBinding{}, outcomeBindingInactive, nil + } + if binding.ParticipantID == "" { + // Nothing to look up next, so the plan can never be completed. + log.Errorf(ctx, nil, "OAN registry capability binding bindingKey=%s names no participant", bindingKey) + return providerBinding{}, outcomeBindingUnowned, nil + } + if len(servableActions(binding)) == 0 { + // Active, owned, and callable for nothing. Refusing here says so, rather + // than letting every action fail one at a time further down. + log.Errorf(ctx, nil, "OAN registry capability binding bindingKey=%s serves no active actions", bindingKey) + return providerBinding{}, outcomeBindingNoActions, nil + } + return binding, outcomeFound, nil +} + +// servableActions returns the actions a binding can actually serve. +// +// An entry has to be named to be reachable at all, and active to be served: a +// per-action status is how one action is retired while the capability and every +// other action stay live, so an inactive entry is skipped rather than failing +// the whole record. +func servableActions(binding providerBinding) []actionPlan { + servable := make([]actionPlan, 0, len(binding.Actions)) + for _, plan := range binding.Actions { + if plan.Action != "" && isActive(plan.Status) { + servable = append(servable, plan) + } + } + return servable +} + +// activeUpstream reads the participant that owns a binding and reports whether +// its upstream may be called. +func (c *Client) activeUpstream(ctx context.Context, tracer trace.Tracer, participantID string) (participant, string, error) { + participants, err := searchRecords[participant](ctx, c, tracer, c.searchURL, map[string]eqFilter{ + fieldParticipantID: {Eq: participantID}, + }) + if err != nil { + return participant{}, "", err + } + + if len(participants) == 0 { + log.Errorf(ctx, nil, "OAN registry has no participant %s, named by a live capability binding", participantID) + return participant{}, outcomeParticipantNotFound, nil + } + if len(participants) > 1 { + log.Errorf(ctx, nil, "OAN registry returned %d records for participantId=%s, expected at most 1", + len(participants), participantID) + } + + owner := participants[0] + if !isActive(owner.Status) { + log.Infof(ctx, "OAN registry participantId=%s is not usable: status=%q", participantID, owner.Status) + return participant{}, outcomeParticipantInactive, nil + } + if owner.BaseURL == "" { + // Active but unroutable. Denying here gives a clear reason rather than a + // request sent to an empty host further down. + log.Errorf(ctx, nil, "OAN registry participantId=%s publishes no upstream base url", participantID) + return participant{}, outcomeNoUpstreamURL, nil + } + return owner, outcomeFound, nil +} + +// isActive reports whether a registry status permits use. +// +// An allow-list, deliberately, and for the same reason resolveStatus is one: a +// deny-list lets every status nobody thought of through, so "withdrawn" or +// "draft" would read as callable. +func isActive(status string) bool { + return strings.EqualFold(status, statusActive) +} + +// toProviderRecord joins the two records into the plan a caller consumes. +// Mapping references are carried verbatim: they are URLs the mapper resolves, +// and this plugin does not interpret them. +func toProviderRecord(binding providerBinding, owner participant) *model.ProviderRecord { + // Keyed by action for the caller, which looks one up rather than scanning. + // An entry naming no action is skipped: it cannot be reached, and refusing + // the whole record over one malformed row would take down the actions that + // are fine. + actions := make(map[string]model.ActionPlan, len(binding.Actions)) + for _, plan := range servableActions(binding) { + actions[plan.Action] = model.ActionPlan{ + Method: plan.Method, + Path: plan.Path, + Mappings: plan.Mappings, + TimeoutMs: plan.TimeoutMs, + RetryMax: plan.RetryMax, + } + } + + return &model.ProviderRecord{ + BindingKey: binding.BindingKey, + ParticipantID: binding.ParticipantID, + CapabilityCode: binding.CapabilityCode, + BaseURL: owner.BaseURL, + Actions: actions, + } +} + +// searchRecords posts a filter to one registry entity and decodes the matching +// records. It is the single transport path for both entities: they differ only +// in URL, filter and record type. +func searchRecords[T any](ctx context.Context, c *Client, tracer trace.Tracer, url string, filters map[string]eqFilter) ([]T, error) { + body, err := json.Marshal(searchRequest{Filters: filters}) + if err != nil { + return nil, fmt.Errorf("failed to marshal search request: %w", err) + } + + // No Authorization header: the registry's search endpoint is public, and + // sending a malformed or empty bearer is rejected before the endpoint's own + // permit rule is reached. + req, err := retryablehttp.NewRequest(http.MethodPost, url, bytes.NewBuffer(body)) + if err != nil { + return nil, fmt.Errorf("failed to create search request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + httpCtx, httpSpan := tracer.Start(ctx, "http search") + defer httpSpan.End() + req = req.WithContext(httpCtx) + + log.Debugf(ctx, "Making OAN registry search request to: %s", url) + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send search request: %w", err) + } + defer resp.Body.Close() + + // Bounded, like every other response this deployment reads. searchRecords + // serves the signing-key lookup as well as the two provider lookups, and + // the signing-key one runs inside validateSign on EVERY inbound message -- + // so an unbounded read here is an unbounded allocation on the request path, + // against a URL that a sample config points at plain http. + // + // One byte past the limit is read so exceeding it can be told from meeting + // it exactly, and the response is then refused rather than truncated: + // half a JSON document fails to decode with an error about syntax, which + // says nothing about the cause. + respBody, err := io.ReadAll(io.LimitReader(resp.Body, c.maxResponseBytes+1)) + if err != nil { + return nil, fmt.Errorf("failed to read search response: %w", err) + } + if int64(len(respBody)) > c.maxResponseBytes { + return nil, fmt.Errorf("search response exceeds the %d byte limit", c.maxResponseBytes) + } + if resp.StatusCode != http.StatusOK { + // The body can carry registry internals, so it is logged but never + // returned in the error. + log.Errorf(ctx, nil, "OAN registry search failed with status: %s, response: %s", resp.Status, string(respBody)) + return nil, fmt.Errorf("%w: %s", errRegistryStatus, resp.Status) + } + return decodeRecords[T](respBody) +} + +// decodeRecords accepts either shape the registry answers with: a bare array on +// some search backends, a data envelope on others. Depending on which one a +// deployment happens to run would be a needless coupling. +func decodeRecords[T any](body []byte) ([]T, error) { + var records []T + if err := json.Unmarshal(body, &records); err == nil { + return records, nil + } else { + // Data is a pointer so an absent "data" key is distinguishable from an + // empty one. Without that, any unrecognised JSON object -- an error body + // returned with a 200, say -- would decode to zero records and be + // reported as "no such record", hiding a real failure as a benign miss. + var envelope struct { + Data *[]T `json:"data"` + } + if envelopeErr := json.Unmarshal(body, &envelope); envelopeErr != nil || envelope.Data == nil { + // Both attempts are reported. The envelope error is the one that + // usually matters -- a registry answering with an envelope whose + // records do not fit is a schema mismatch, and reporting only the + // array failure ("cannot unmarshal object into []T") sends a reader + // looking at the wrong level entirely. + if envelopeErr != nil { + return nil, fmt.Errorf("%w: as an array: %v; as a data envelope: %v", errDecodeResponse, err, envelopeErr) + } + return nil, fmt.Errorf("%w: %v", errDecodeResponse, err) + } + return *envelope.Data, nil + } +} + +// providerRecordCacheKey namespaces plans away from signing keys. The two share +// one cache but have different subjects and lifetimes, and a collision would +// serve one as the other. +func providerRecordCacheKey(bindingKey string) string { + return "registry_provider_" + bindingKey +} + +func (c *Client) cachedProviderRecord(ctx context.Context, tracer trace.Tracer, key string) (*model.ProviderRecord, bool) { + if !c.cachingEnabled() { + return nil, false + } + + cacheCtx, span := tracer.Start(ctx, "cache lookup") + defer span.End() + + raw, err := c.cache.Get(cacheCtx, key) + if err != nil || raw == "" { + return nil, false + } + var plan model.ProviderRecord + if err := json.Unmarshal([]byte(raw), &plan); err != nil { + log.Warnf(ctx, "Discarding unreadable cache entry for key %s: %v", key, err) + return nil, false + } + // A plan with nothing to call is unusable however it got here. The cache is + // shared and outlives a deploy, so entries written by another version are + // re-checked rather than trusted. + if plan.BaseURL == "" { + log.Warnf(ctx, "Discarding malformed cache entry for key %s", key) + return nil, false + } + return &plan, true +} + +// cacheProviderRecord caches a usable plan. Refusals are never passed here: +// caching one would keep a capability dark for the whole TTL after it is +// reinstated. +func (c *Client) cacheProviderRecord(ctx context.Context, key string, plan *model.ProviderRecord) { + if !c.cachingEnabled() { + return + } + data, err := json.Marshal(plan) + if err != nil { + log.Warnf(ctx, "Failed to encode OAN registry provider record for caching, key %s: %v", key, err) + return + } + if err := c.cache.Set(ctx, key, string(data), c.cacheTTL); err != nil { + log.Warnf(ctx, "Failed to cache OAN registry provider record for key %s: %v", key, err) + } +} diff --git a/pkg/plugin/implementation/sunbirdRegistry/providerrecord_test.go b/pkg/plugin/implementation/sunbirdRegistry/providerrecord_test.go new file mode 100644 index 00000000..aab2c4c8 --- /dev/null +++ b/pkg/plugin/implementation/sunbirdRegistry/providerrecord_test.go @@ -0,0 +1,654 @@ +package sunbirdRegistry + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/beckn-one/beckn-onix/pkg/model" + "github.com/beckn-one/beckn-onix/pkg/plugin/definition" +) + +const ( + testBindingKey = "mausamgram|openagrinet:WeatherObservation" + testCapabilityCode = "openagrinet:WeatherObservation" + testProviderID = "mausamgram" + testBaseURL = "https://mausamgram.imd.gov.in/nwpapi" + testMappings = "https://mappings.example.com/mausamgram/weather-observation.select.yaml" +) + +// envelopeJSON renders a registry search response in the data-envelope form. +func envelopeJSON[T any](t *testing.T, records ...T) string { + t.Helper() + if records == nil { + records = []T{} + } + data, err := json.Marshal(struct { + Data []T `json:"data"` + }{Data: records}) + if err != nil { + t.Fatalf("failed to marshal test records: %v", err) + } + return string(data) +} + +// arrayJSON renders the same records as a bare array, the shape some search +// backends return instead of an envelope. +func arrayJSON[T any](t *testing.T, records ...T) string { + t.Helper() + if records == nil { + records = []T{} + } + data, err := json.Marshal(records) + if err != nil { + t.Fatalf("failed to marshal test records: %v", err) + } + return string(data) +} + +// upstreamRecord is an active participant publishing a callable upstream. It is +// the provider half of a Participant record: no node, and so no keys. +func upstreamRecord() participant { + return participant{ + ParticipantID: testProviderID, + Type: "upstream", + Status: "active", + BaseURL: testBaseURL, + } +} + +// bindingRecord is a healthy ProviderSchema row for testBindingKey. +func bindingRecord() providerBinding { + return providerBinding{ + BindingKey: testBindingKey, + ParticipantID: testProviderID, + CapabilityCode: testCapabilityCode, + Actions: []actionPlan{ + {Action: "select", Method: "GET", Path: "/get-daily", Mappings: testMappings, + TimeoutMs: 30000, RetryMax: 3, Status: "active"}, + }, + Status: "active", + } +} + +// bodyForPath picks the response body for an entity search path. +func bodyForPath(t *testing.T, path, bindings, participants string) string { + t.Helper() + switch { + case strings.Contains(path, "/"+DefaultProviderEntity+"/"): + return bindings + case strings.Contains(path, "/"+DefaultEntity+"/"): + return participants + default: + t.Errorf("unexpected request path %q", path) + return "" + } +} + +// newRegistryServer serves both entities from one server, routing on the path +// the client builds for each. +func newRegistryServer(t *testing.T, bindings, participants string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, bodyForPath(t, r.URL.Path, bindings, participants)) + })) +} + +func resolvePlan(t *testing.T, c *Client) (*model.ProviderRecord, error) { + t.Helper() + return c.ProviderRecord(context.Background(), testBindingKey) +} + +// --- happy path ------------------------------------------------------------ + +func TestProviderRecordResolvesACallPlan(t *testing.T) { + t.Parallel() + + srv := newRegistryServer(t, envelopeJSON(t, bindingRecord()), envelopeJSON(t, upstreamRecord())) + defer srv.Close() + + got, err := resolvePlan(t, newTestClient(t, srv.URL, nil)) + if err != nil { + t.Fatalf("ProviderRecord() returned an unexpected error: %v", err) + } + if got == nil { + t.Fatal("expected a call plan, got nil") + } + + for _, field := range []struct{ name, got, want string }{ + {"binding key", got.BindingKey, testBindingKey}, + {"participant id", got.ParticipantID, testProviderID}, + {"capability code", got.CapabilityCode, testCapabilityCode}, + {"base url", got.BaseURL, testBaseURL}, + } { + if field.got != field.want { + t.Errorf("%s = %q, want %q", field.name, field.got, field.want) + } + } + + call, served := got.Actions["select"] + if !served { + t.Fatalf("no call plan for select, got actions %v", got.Actions) + } + if call.Mappings != testMappings { + t.Errorf("mappings = %q, want %q", call.Mappings, testMappings) + } + if call.Method != "GET" || call.Path != "/get-daily" { + t.Errorf("select call = %s %s, want GET /get-daily", call.Method, call.Path) + } + if call.TimeoutMs != 30000 || call.RetryMax != 3 { + t.Errorf("select budget = timeout %d retry %d, want 30000 and 3", call.TimeoutMs, call.RetryMax) + } +} + +// One capability, several actions, each with its own endpoint. This is what the +// per-action plan exists for: a confirm posting somewhere a select does not. +func TestProviderRecordResolvesAnEndpointPerAction(t *testing.T) { + t.Parallel() + + binding := bindingRecord() + binding.Actions = append(binding.Actions, + actionPlan{Action: "confirm", Method: "POST", Path: "/book", Mappings: testMappings, + TimeoutMs: 60000, RetryMax: 1, Status: "active"}) + + srv := newRegistryServer(t, envelopeJSON(t, binding), envelopeJSON(t, upstreamRecord())) + defer srv.Close() + + got, err := resolvePlan(t, newTestClient(t, srv.URL, nil)) + if err != nil { + t.Fatalf("ProviderRecord() returned an unexpected error: %v", err) + } + + for _, want := range []struct { + action, method, path string + }{ + {"select", "GET", "/get-daily"}, + {"confirm", "POST", "/book"}, + } { + call, served := got.Actions[want.action] + if !served { + t.Errorf("no call plan for %s", want.action) + continue + } + if call.Method != want.method || call.Path != want.path { + t.Errorf("%s call = %s %s, want %s %s", want.action, call.Method, call.Path, want.method, want.path) + } + } +} + +// The registry may omit the call budget. Zero means "the caller applies its own +// default", and must not be mistaken for "no timeout and no retries". +func TestProviderRecordLeavesAnAbsentBudgetAtZero(t *testing.T) { + t.Parallel() + + binding := bindingRecord() + binding.Actions = []actionPlan{{Action: "select", Method: "GET", Path: "/get-daily", + Mappings: testMappings, Status: "active"}} + + srv := newRegistryServer(t, envelopeJSON(t, binding), envelopeJSON(t, upstreamRecord())) + defer srv.Close() + + got, err := resolvePlan(t, newTestClient(t, srv.URL, nil)) + if err != nil { + t.Fatalf("ProviderRecord() returned an unexpected error: %v", err) + } + call := got.Actions["select"] + if call.TimeoutMs != 0 || call.RetryMax != 0 { + t.Errorf("expected an absent budget to stay zero, got timeout=%d retry=%d", call.TimeoutMs, call.RetryMax) + } +} + +// The participant read is the one the binding names, not one parsed out of the +// binding key -- the registry owns that relationship, not the key format. +func TestProviderRecordReadsTheParticipantNamedByTheBinding(t *testing.T) { + t.Parallel() + + var askedFor string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/"+DefaultEntity+"/") { + var req searchRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Errorf("failed to decode participant search: %v", err) + } + askedFor = req.Filters[fieldParticipantID].Eq + fmt.Fprint(w, envelopeJSON(t, upstreamRecord())) + return + } + fmt.Fprint(w, envelopeJSON(t, bindingRecord())) + })) + defer srv.Close() + + if _, err := resolvePlan(t, newTestClient(t, srv.URL, nil)); err != nil { + t.Fatalf("ProviderRecord() returned an unexpected error: %v", err) + } + if askedFor != testProviderID { + t.Errorf("participant searched for = %q, want %q", askedFor, testProviderID) + } +} + +// --- refusals -------------------------------------------------------------- + +// Every refusal means the same thing to a caller -- this capability cannot be +// served -- so all report ErrProviderRecordNotFound rather than an error a +// caller would have to string-match. +func TestProviderRecordRefusals(t *testing.T) { + t.Parallel() + + inactiveBinding := bindingRecord() + inactiveBinding.Status = "inactive" + + unknownStatusBinding := bindingRecord() + unknownStatusBinding.Status = "draft" + + emptyStatusBinding := bindingRecord() + emptyStatusBinding.Status = "" + + noParticipantBinding := bindingRecord() + noParticipantBinding.ParticipantID = "" + + noActionsBinding := bindingRecord() + noActionsBinding.Actions = nil + + inactiveActionBinding := bindingRecord() + inactiveActionBinding.Actions = []actionPlan{{Action: "select", Method: "GET", + Path: "/get-daily", Mappings: testMappings, Status: "inactive"}} + + unnamedActionBinding := bindingRecord() + unnamedActionBinding.Actions = []actionPlan{{Method: "GET", Path: "/get-daily", Status: "active"}} + + inactiveUpstream := upstreamRecord() + inactiveUpstream.Status = "inactive" + + emptyStatusUpstream := upstreamRecord() + emptyStatusUpstream.Status = "" + + noBaseURL := upstreamRecord() + noBaseURL.BaseURL = "" + + testCases := []struct { + name string + bindings string + participants string + }{ + {"no binding for the key", envelopeJSON[providerBinding](t), envelopeJSON(t, upstreamRecord())}, + {"an inactive binding", envelopeJSON(t, inactiveBinding), envelopeJSON(t, upstreamRecord())}, + {"a binding with an unrecognised status", envelopeJSON(t, unknownStatusBinding), envelopeJSON(t, upstreamRecord())}, + {"a binding with an empty status", envelopeJSON(t, emptyStatusBinding), envelopeJSON(t, upstreamRecord())}, + {"a binding naming no participant", envelopeJSON(t, noParticipantBinding), envelopeJSON(t, upstreamRecord())}, + {"a binding serving no action", envelopeJSON(t, noActionsBinding), envelopeJSON(t, upstreamRecord())}, + {"a binding whose only action is unnamed", envelopeJSON(t, unnamedActionBinding), envelopeJSON(t, upstreamRecord())}, + {"a binding whose only action is retired", envelopeJSON(t, inactiveActionBinding), envelopeJSON(t, upstreamRecord())}, + {"no participant owning the binding", envelopeJSON(t, bindingRecord()), envelopeJSON[participant](t)}, + {"an inactive participant", envelopeJSON(t, bindingRecord()), envelopeJSON(t, inactiveUpstream)}, + {"a participant with an empty status", envelopeJSON(t, bindingRecord()), envelopeJSON(t, emptyStatusUpstream)}, + {"a participant with no upstream url", envelopeJSON(t, bindingRecord()), envelopeJSON(t, noBaseURL)}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + srv := newRegistryServer(t, tc.bindings, tc.participants) + defer srv.Close() + + got, err := resolvePlan(t, newTestClient(t, srv.URL, nil)) + if !errors.Is(err, definition.ErrProviderRecordNotFound) { + t.Errorf("expected ErrProviderRecordNotFound, got %v", err) + } + if got != nil { + t.Errorf("expected no call plan alongside a refusal, got %+v", got) + } + }) + } +} + +// An empty key cannot match anything, so it is refused without troubling the +// registry -- one fewer round trip on what is a caller bug. +func TestProviderRecordRefusesAnEmptyKeyWithoutCallingTheRegistry(t *testing.T) { + t.Parallel() + + var requests atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + })) + defer srv.Close() + + got, err := newTestClient(t, srv.URL, nil).ProviderRecord(context.Background(), "") + if !errors.Is(err, definition.ErrProviderRecordNotFound) { + t.Errorf("expected ErrProviderRecordNotFound, got %v", err) + } + if got != nil { + t.Errorf("expected no call plan, got %+v", got) + } + if requests.Load() != 0 { + t.Errorf("expected the registry not to be called, got %d request(s)", requests.Load()) + } +} + +// --- failures that are not refusals ---------------------------------------- + +// A registry that could not be consulted is not a registry that answered "no". +// Collapsing the two would report an outage as a routine miss. +func TestProviderRecordDistinguishesFailureFromRefusal(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + body string + status int + }{ + {"a registry error status", "", http.StatusInternalServerError}, + {"a not-found status", "", http.StatusNotFound}, + {"an undecodable body", `{"data":`, http.StatusOK}, + {"a body that is neither array nor envelope", `"not-a-record-set"`, http.StatusOK}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if tc.status != http.StatusOK { + w.WriteHeader(tc.status) + return + } + fmt.Fprint(w, tc.body) + })) + defer srv.Close() + + got, err := resolvePlan(t, newTestClient(t, srv.URL, nil)) + if err == nil { + t.Fatal("expected an error") + } + if errors.Is(err, definition.ErrProviderRecordNotFound) { + t.Error("a registry that could not be consulted must not report not-found") + } + if got != nil { + t.Errorf("expected no call plan, got %+v", got) + } + }) + } +} + +// The binding resolves but the participant read fails: still a failure, not a +// refusal. The capability may well be fine. +func TestProviderRecordReportsAFailingParticipantRead(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/"+DefaultEntity+"/") { + w.WriteHeader(http.StatusInternalServerError) + return + } + fmt.Fprint(w, envelopeJSON(t, bindingRecord())) + })) + defer srv.Close() + + _, err := resolvePlan(t, newTestClient(t, srv.URL, nil)) + if err == nil { + t.Fatal("expected an error") + } + if errors.Is(err, definition.ErrProviderRecordNotFound) { + t.Error("a failing participant read must not report not-found") + } +} + +// --- both response envelopes ------------------------------------------------ + +func TestProviderRecordAcceptsEitherEnvelope(t *testing.T) { + t.Parallel() + + testCases := []struct{ name, bindings, participants string }{ + {"data envelope", envelopeJSON(t, bindingRecord()), envelopeJSON(t, upstreamRecord())}, + {"bare array", arrayJSON(t, bindingRecord()), arrayJSON(t, upstreamRecord())}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + srv := newRegistryServer(t, tc.bindings, tc.participants) + defer srv.Close() + + if _, err := resolvePlan(t, newTestClient(t, srv.URL, nil)); err != nil { + t.Errorf("ProviderRecord() returned an unexpected error: %v", err) + } + }) + } +} + +// --- caching ---------------------------------------------------------------- + +// storingCache round-trips what it is given, so a test can prove a second +// resolve is served without touching the registry. mockCache cannot: it is a +// spy whose Get always misses, which is right for asserting what was written +// but cannot demonstrate a hit. +type storingCache struct { + mu sync.Mutex + entries map[string]string + getCalls int + setCalls int + setKey string +} + +func newStoringCache() *storingCache { + return &storingCache{entries: make(map[string]string)} +} + +func (c *storingCache) Get(ctx context.Context, key string) (string, error) { + c.mu.Lock() + defer c.mu.Unlock() + c.getCalls++ + value, found := c.entries[key] + if !found { + return "", errors.New("cache miss") + } + return value, nil +} + +func (c *storingCache) Set(ctx context.Context, key, value string, ttl time.Duration) error { + c.mu.Lock() + defer c.mu.Unlock() + c.setCalls++ + c.setKey = key + c.entries[key] = value + return nil +} + +func (c *storingCache) Delete(ctx context.Context, key string) error { return nil } +func (c *storingCache) Clear(ctx context.Context) error { return nil } + +// countingRegistry serves both entities and counts every request, so a test can +// tell a cache hit from a second round trip. +func countingRegistry(t *testing.T, requests *atomic.Int32, bindings, participants string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + fmt.Fprint(w, bodyForPath(t, r.URL.Path, bindings, participants)) + })) +} + +func TestProviderRecordCachesAResolvedPlan(t *testing.T) { + t.Parallel() + + var requests atomic.Int32 + srv := countingRegistry(t, &requests, envelopeJSON(t, bindingRecord()), envelopeJSON(t, upstreamRecord())) + defer srv.Close() + + client := newTestClient(t, srv.URL, newStoringCache(), func(c *Config) { c.CacheTTL = 30 * time.Second }) + + if _, err := resolvePlan(t, client); err != nil { + t.Fatalf("first ProviderRecord() returned an unexpected error: %v", err) + } + afterFirst := requests.Load() + if afterFirst == 0 { + t.Fatal("expected the first resolve to consult the registry") + } + + if _, err := resolvePlan(t, client); err != nil { + t.Fatalf("second ProviderRecord() returned an unexpected error: %v", err) + } + if requests.Load() != afterFirst { + t.Errorf("expected the second resolve to be served from cache, registry was called %d more time(s)", requests.Load()-afterFirst) + } +} + +// A refusal must never be cached: it would keep a capability dark for the whole +// TTL after the operator reinstates it. +func TestProviderRecordDoesNotCacheARefusal(t *testing.T) { + t.Parallel() + + cache := &mockCache{} + srv := newRegistryServer(t, envelopeJSON[providerBinding](t), envelopeJSON(t, upstreamRecord())) + defer srv.Close() + + client := newTestClient(t, srv.URL, cache, func(c *Config) { c.CacheTTL = 30 * time.Second }) + if _, err := resolvePlan(t, client); !errors.Is(err, definition.ErrProviderRecordNotFound) { + t.Fatalf("expected ErrProviderRecordNotFound, got %v", err) + } + if cache.setCalls != 0 { + t.Errorf("expected a refusal not to be cached, got %d write(s)", cache.setCalls) + } +} + +func TestProviderRecordSkipsTheCacheWhenDisabled(t *testing.T) { + t.Parallel() + + cache := &mockCache{} + srv := newRegistryServer(t, envelopeJSON(t, bindingRecord()), envelopeJSON(t, upstreamRecord())) + defer srv.Close() + + // No CacheTTL is set: caching is opt-in, because the TTL is exactly how long + // a withdrawn capability keeps being called. + if _, err := resolvePlan(t, newTestClient(t, srv.URL, cache)); err != nil { + t.Fatalf("ProviderRecord() returned an unexpected error: %v", err) + } + if cache.getCalls != 0 || cache.setCalls != 0 { + t.Errorf("expected the cache to be untouched, got %d read(s) and %d write(s)", cache.getCalls, cache.setCalls) + } +} + +func TestProviderRecordDiscardsAnUnreadableCacheEntry(t *testing.T) { + t.Parallel() + + var requests atomic.Int32 + srv := countingRegistry(t, &requests, envelopeJSON(t, bindingRecord()), envelopeJSON(t, upstreamRecord())) + defer srv.Close() + + cache := &mockCache{ + getFunc: func(ctx context.Context, key string) (string, error) { return "not-json", nil }, + } + client := newTestClient(t, srv.URL, cache, func(c *Config) { c.CacheTTL = 30 * time.Second }) + + got, err := resolvePlan(t, client) + if err != nil { + t.Fatalf("ProviderRecord() returned an unexpected error: %v", err) + } + if got.BaseURL != testBaseURL { + t.Errorf("expected the registry's plan to be used, got base url %q", got.BaseURL) + } + if requests.Load() == 0 { + t.Error("expected the unreadable entry to be discarded and the registry consulted") + } +} + +// Two capabilities of one provider must not share a cache entry, so the binding +// key has to appear in the key. +func TestProviderRecordCachesPerBindingKey(t *testing.T) { + t.Parallel() + + cache := &mockCache{} + srv := newRegistryServer(t, envelopeJSON(t, bindingRecord()), envelopeJSON(t, upstreamRecord())) + defer srv.Close() + + client := newTestClient(t, srv.URL, cache, func(c *Config) { c.CacheTTL = 30 * time.Second }) + if _, err := resolvePlan(t, client); err != nil { + t.Fatalf("ProviderRecord() returned an unexpected error: %v", err) + } + if !strings.Contains(cache.setKey, testBindingKey) { + t.Errorf("cache key %q does not identify the binding", cache.setKey) + } +} + +// A cached plan must not collide with a cached signing key: different subjects, +// different lifetimes, one shared cache. +func TestProviderRecordCacheKeyIsDistinctFromTheKeyLookupCacheKey(t *testing.T) { + t.Parallel() + + cache := &mockCache{} + srv := newRegistryServer(t, envelopeJSON(t, bindingRecord()), envelopeJSON(t, upstreamRecord())) + defer srv.Close() + + client := newTestClient(t, srv.URL, cache, func(c *Config) { c.CacheTTL = 30 * time.Second }) + if _, err := resolvePlan(t, client); err != nil { + t.Fatalf("ProviderRecord() returned an unexpected error: %v", err) + } + if strings.HasPrefix(cache.setKey, "registry_lookup_") { + t.Errorf("provider plan cache key %q shares the signing-key namespace", cache.setKey) + } +} + +// searchRecords serves the signing-key lookup as well as the two provider +// lookups, and the signing-key one runs inside validateSign on every inbound +// message -- so an unbounded read here is an unbounded allocation on the +// request path. The other two reads this deployment makes, in upstream and in +// jsonmapper, have always been bounded; this one was not. +func TestSearchRefusesAResponsePastTheLimit(t *testing.T) { + const limit = 512 + + // Valid JSON, and far too much of it: the size is what is refused, not the + // shape, which is what makes the error worth reading. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `[{"osid":"1","bindingKey":"k","padding":%q}]`, strings.Repeat("x", limit*4)) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL, nil, func(c *Config) { c.MaxResponseBytes = limit }) + _, err := client.ProviderRecord(context.Background(), "imd-mock|openagrinet:WeatherObservation") + if err == nil { + t.Fatal("expected an oversized search response to be refused") + } + if !strings.Contains(err.Error(), "exceeds") { + t.Errorf("error = %v, want it to name the limit rather than a decode failure", err) + } + // Refused, not truncated. A truncated body fails to decode with an error + // about JSON syntax, which says nothing about the cause. + if strings.Contains(err.Error(), "unexpected end of JSON") { + t.Errorf("error = %v; the body was truncated and then decoded, not refused", err) + } +} + +// A response inside the limit is unaffected -- the cap must not cost a byte of +// headroom to the ordinary case. +func TestSearchAcceptsAResponseInsideTheLimit(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `[]`) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL, nil, func(c *Config) { c.MaxResponseBytes = 512 }) + _, err := client.ProviderRecord(context.Background(), "imd-mock|openagrinet:WeatherObservation") + // An empty result is its own not-found error, not a size complaint. + if err != nil && strings.Contains(err.Error(), "exceeds") { + t.Errorf("a small response was refused for size: %v", err) + } +} + +// An unset limit must not mean an unbounded one. +func TestNewAppliesTheDefaultResponseLimit(t *testing.T) { + client := newTestClient(t, "http://127.0.0.1:1/api/v1", nil) + if client.maxResponseBytes != DefaultMaxResponseBytes { + t.Errorf("maxResponseBytes = %d, want the %d default rather than unbounded", + client.maxResponseBytes, DefaultMaxResponseBytes) + } +} diff --git a/pkg/plugin/implementation/sunbirdRegistry/sunbirdRegistry.go b/pkg/plugin/implementation/sunbirdRegistry/sunbirdRegistry.go new file mode 100644 index 00000000..0d598eb5 --- /dev/null +++ b/pkg/plugin/implementation/sunbirdRegistry/sunbirdRegistry.go @@ -0,0 +1,680 @@ +// Package sunbirdRegistry resolves participant signing keys from a SunbirdRC +// registry so inbound Beckn signatures can be verified. +// +// It implements definition.RegistryLookup only. Onboarding, key publication and +// status changes all happen through the registry's own Participant APIs. +package sunbirdRegistry + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/beckn-one/beckn-onix/pkg/log" + "github.com/beckn-one/beckn-onix/pkg/model" + "github.com/beckn-one/beckn-onix/pkg/plugin/definition" + "github.com/beckn-one/beckn-onix/pkg/telemetry" + "github.com/hashicorp/go-retryablehttp" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/trace" +) + +// Defaults applied when an operator leaves a setting out. The timeout and retry +// budget are deliberately tighter than the sibling registry plugins': this call +// sits inside signature validation on every inbound message, so timeout x +// (retry_max + 1) is time a request spends waiting before it can be rejected. +// Exported so cmd/plugin.go applies the same values -- these are the single +// source of truth for them. +const ( + DefaultEntity = "Participant" + DefaultProviderEntity = "ProviderSchema" + DefaultTimeoutSeconds = 2 + DefaultRetryMax = 1 + DefaultRetryWaitMin = 100 * time.Millisecond + DefaultRetryWaitMax = 500 * time.Millisecond + + // DefaultMaxResponseBytes caps a registry search response. Generous for a + // handful of records, and a ceiling rather than an expectation: the read + // happens inside signature validation on every inbound message, so an + // unbounded one is an unbounded allocation on the request path. + DefaultMaxResponseBytes = 1 << 20 // 1 MiB +) + +// Registry field names. They live here rather than in config because they +// change only when the registry API changes -- never between two deployments of +// this code -- and a typo in a config key would surface at runtime as a +// misleading "no key found" rather than at startup. +const ( + fieldParticipantID = "participantId" + fieldBindingKey = "bindingKey" + searchPath = "search" +) + +// Key "use" values. A key is scoped to a single purpose, so the signing key is +// the only one resolvable by the id in a request header; an encryption key is +// picked up alongside it by use. +const ( + useSign = "sign" + useEncr = "encr" +) + +// keyEncodingPrefix labels the encoding of a published key value, e.g. +// "base64:xq4+...". model.Subscription carries the bare base64 that signvalidator +// hands straight to base64.StdEncoding.DecodeString, so the label is stripped on +// the way through -- left on, it fails every verification with a decode error +// pointing nowhere near the registry. +const keyEncodingPrefix = "base64:" + +// statusActive is the only registry status that permits verification. It is +// checked at both levels: a participant stays active while one of its keys is +// retired, which is the whole point of per-key status. +const statusActive = "active" + +// expectedAlgorithm is the only signing algorithm on this network. The request +// header is checked against it upstream; this is only used to flag a record that +// disagrees. +const expectedAlgorithm = "ed25519" + +// Beckn subscription statuses, as understood by model.IsKeyStatusUsable. +const ( + statusSubscribed = "SUBSCRIBED" + statusUnsubscribed = "UNSUBSCRIBED" +) + +// Lookup outcomes, used as the error_type metric dimension and in logs. Failure +// outcomes are kept distinct so a dead registry and a malformed body do not +// collapse into one series -- splitting on error_type is the whole point of +// recording it. +const ( + outcomeFound = "found" + outcomeCacheHit = "cache_hit" + outcomeNotFound = "not_found" + outcomeKeyIDMismatch = "key_id_mismatch" + outcomeKeyNotSigning = "key_not_signing" + outcomeInactive = "inactive" + outcomeKeyInactive = "key_inactive" + outcomeNoKey = "no_key" + outcomeTimeout = "timeout" + outcomeRegistryError = "registry_error" + outcomeDecodeError = "decode_error" + outcomeTransportError = "transport_error" +) + +// Failure classes, wrapped so Lookup can tell them apart without inspecting +// error strings. +var ( + errRegistryStatus = errors.New("registry returned an error status") + errDecodeResponse = errors.New("registry response could not be decoded") +) + +// classify maps a search failure onto the outcome vocabulary above. +func classify(err error) string { + switch { + case errors.Is(err, context.DeadlineExceeded), errors.Is(err, context.Canceled): + return outcomeTimeout + case errors.Is(err, errRegistryStatus): + return outcomeRegistryError + case errors.Is(err, errDecodeResponse): + return outcomeDecodeError + default: + return outcomeTransportError + } +} + +const ( + pluginID = "sunbirdRegistry" + pluginType = "registry" + operationLookup = "lookup" + operationProviderRecord = "provider_record" +) + +// Config holds configuration parameters for the registry client. +type Config struct { + // URL is the registry base including any API version prefix, + // e.g. "http://registry:8081/api/v1". + URL string `yaml:"url" json:"url"` + // Entity names the participant entity, ProviderEntity the capability-binding + // entity. They are separate registry collections and are searched separately. + Entity string `yaml:"entity" json:"entity"` + ProviderEntity string `yaml:"providerEntity" json:"providerEntity"` + CacheTTL time.Duration `yaml:"cacheTTL" json:"cacheTTL"` + Timeout int `yaml:"timeout" json:"timeout"` + RetryMax int `yaml:"retry_max" json:"retry_max"` + RetryWaitMin time.Duration `yaml:"retry_wait_min" json:"retry_wait_min"` + RetryWaitMax time.Duration `yaml:"retry_wait_max" json:"retry_wait_max"` + // MaxResponseBytes caps a search response. Zero means + // DefaultMaxResponseBytes; a response past it is refused rather than + // truncated, because half a JSON document fails to decode with an error + // that says nothing about the cause. + MaxResponseBytes int64 `yaml:"maxResponseBytes" json:"maxResponseBytes"` +} + +// Client resolves participants from the registry. It is safe for concurrent +// use: every field is set once in New and never mutated afterwards. +type Client struct { + searchURL string + providerSearchURL string + client *retryablehttp.Client + cache definition.Cache + cacheTTL time.Duration + maxResponseBytes int64 +} + +// participant is the subset of a registry record this plugin reads. The +// registry carries more -- Sunbird audit fields, the display name, an auth +// block -- and none of it is modelled here. encoding/json drops what it cannot +// place, so every field left out is one less thing to break when the schema +// moves. +// +// Flat, with no wrapper object, because type is the discriminator rather than +// the shape: a node speaks Beckn and publishes keys, an upstream is an ordinary +// API and publishes auth instead. baseUrl serves both -- for a node it is where +// Beckn messages go, for an upstream it is what a binding's path is appended to. +// +// The auth block is left out DELIBERATELY, not pending. An upstream's credential +// is the provider plugin's own configuration -- which scheme, and which +// environment variables hold the values -- so nothing is gained by also reading +// the registry's copy, and reading both would create two places that can +// disagree about how to authenticate a call. The registry publishes it as +// documentation of what a provider expects; the adapter presents what its +// operator configured. +type participant struct { + ParticipantID string `json:"participantId"` + Type string `json:"type"` + Role string `json:"role"` + Status string `json:"status"` + BaseURL string `json:"baseUrl"` + Keys []key `json:"keys"` +} + +// key is one published key. A participant publishes several -- separate signing +// and encryption keys, and more than one signing key while a rotation is in +// flight -- so a key is identified by its own OSID rather than by its position. +type key struct { + OSID string `json:"osid"` + KeyID string `json:"keyId"` + Use string `json:"use"` + Algorithm string `json:"alg"` + Value string `json:"key"` + Status string `json:"status"` + ValidFrom string `json:"validFrom"` + ValidUntil string `json:"validUntil"` +} + +// publicKey returns the key material with its encoding label removed, ready for +// the base64 decode the caller performs. +func (k key) publicKey() string { + return strings.TrimPrefix(k.Value, keyEncodingPrefix) +} + +// isSigning reports whether this key may verify a signature. +// +// An absent use is accepted: it predates the discriminator, and if the guess is +// wrong the signature simply fails to verify, which is the safe direction. An +// explicitly non-signing use is refused, so an encryption key's osid arriving in +// a signing header is reported as exactly that rather than as a missing key. +func (k key) isSigning() bool { + return k.Use == "" || strings.EqualFold(k.Use, useSign) +} + +type eqFilter struct { + Eq string `json:"eq"` +} + +type searchRequest struct { + Filters map[string]eqFilter `json:"filters"` +} + +// validate checks if the provided registry configuration is valid. +func validate(cfg *Config) error { + if cfg == nil { + return fmt.Errorf("registry config cannot be nil") + } + if cfg.URL == "" { + return fmt.Errorf("registry URL cannot be empty") + } + // url.Parse accepts almost anything, so check the parts that actually have + // to be there. Catching "registry:8081" (no scheme) at startup is far + // cheaper than watching every lookup fail once traffic arrives. + parsed, err := url.Parse(cfg.URL) + if err != nil { + return fmt.Errorf("invalid registry URL %q: %w", cfg.URL, err) + } + if parsed.Scheme == "" || parsed.Host == "" { + return fmt.Errorf("registry URL %q must include a scheme and host, e.g. http://:/api/v1", cfg.URL) + } + return nil +} + +// New creates a new instance of Client. +func New(ctx context.Context, cache definition.Cache, cfg *Config) (*Client, func() error, error) { + log.Debugf(ctx, "Initializing OAN registry client with config: %+v", cfg) + + if err := validate(cfg); err != nil { + return nil, nil, err + } + + // A TTL with no cache plugin is caching silently switched off, and the cost + // is three registry round trips inside every request -- key lookup, binding + // search, participant search -- each inside signature validation's budget. + // Said out loud at startup, because nothing downstream ever complains. + if cfg.CacheTTL > 0 && cache == nil { + log.Warnf(ctx, "OAN registry: cacheTTL is %s but no cache plugin is configured, "+ + "so nothing is cached and every message makes its registry calls again", cfg.CacheTTL) + } + + entity := cfg.Entity + if entity == "" { + entity = DefaultEntity + } + providerEntity := cfg.ProviderEntity + if providerEntity == "" { + providerEntity = DefaultProviderEntity + } + + rc := retryablehttp.NewClient() + + // retryablehttp logs every attempt and retry straight to stderr, outside + // pkg/log, so it is neither structured nor filterable. The same information is + // already emitted by this plugin's own logging and metrics. + rc.Logger = nil + + // Always bounded. The sibling registry plugins apply their timeout only when + // one is configured, which leaves it at zero -- meaning no timeout at all -- + // when it is not. That matters because the transport retryablehttp ships with + // sets no ResponseHeaderTimeout, so a peer that accepts the connection and + // then goes quiet is otherwise bounded by nothing. + timeout := cfg.Timeout + if timeout <= 0 { + timeout = DefaultTimeoutSeconds + } + rc.HTTPClient.Timeout = time.Duration(timeout) * time.Second + + // Retry settings are taken as given: RetryMax of 0 is a legitimate "do not + // retry", so it must not be confused with "unset". parseConfig supplies the + // defaults, since only it can tell the two apart. + rc.RetryMax = cfg.RetryMax + if cfg.RetryWaitMin > 0 { + rc.RetryWaitMin = cfg.RetryWaitMin + } + if cfg.RetryWaitMax > 0 { + rc.RetryWaitMax = cfg.RetryWaitMax + } + + // DefaultBackoff honours a Retry-After header on 429 and 503 and returns it + // *unclamped* -- it bypasses its own RetryWaitMax ceiling. A registry, or any + // ingress in front of one, answering "Retry-After: 3600" would park this + // goroutine for an hour inside signature validation. The inbound request + // context typically has no deadline of its own, so nothing else would cut it + // short. Clamp it back to the configured ceiling. + rc.Backoff = func(min, max time.Duration, attempt int, resp *http.Response) time.Duration { + if wait := retryablehttp.DefaultBackoff(min, max, attempt, resp); wait < max { + return wait + } + return max + } + + maxResponseBytes := cfg.MaxResponseBytes + if maxResponseBytes <= 0 { + maxResponseBytes = DefaultMaxResponseBytes + } + + client := &Client{ + searchURL: searchURLFor(cfg.URL, entity), + providerSearchURL: searchURLFor(cfg.URL, providerEntity), + client: rc, + cache: cache, + cacheTTL: cfg.CacheTTL, + maxResponseBytes: maxResponseBytes, + } + + closer := func() error { + log.Debugf(ctx, "Cleaning up OAN registry client resources") + if client.client != nil { + client.client.HTTPClient.CloseIdleConnections() + } + return nil + } + + log.Infof(ctx, "OAN registry client is created successfully") + return client, closer, nil +} + +// Lookup resolves the signing key for the participant and key named in the +// request. The caller populates only SubscriberID and KeyID; every other field +// on the request is zero. +// +// A missing participant returns (nil, nil) rather than an error: "not found" is +// a legitimate answer, and the caller turns an empty slice into its own +// not-found error. A participant that exists but may not sign is returned with +// a status the caller rejects, so that "unknown" and "suspended" stay +// distinguishable instead of collapsing into the same empty result. +func (c *Client) Lookup(ctx context.Context, req *model.Subscription) ([]model.Subscription, error) { + start := time.Now() + tracer := otel.Tracer(telemetry.ScopeName, trace.WithInstrumentationVersion(telemetry.ScopeVersion)) + ctx, span := tracer.Start(ctx, "registry lookup") + defer span.End() + + // M2: an empty key id would match any record whose OSID is absent. Unreachable + // under the current shape, where every record carries one -- but cheap, and it + // stops being unreachable the moment OSID maps to a field that can be missing. + if req.SubscriberID == "" || req.KeyID == "" { + span.SetAttributes(telemetry.AttrErrorType.String(outcomeNotFound)) + c.emitMetrics(ctx, start, operationLookup, outcomeNotFound) + return nil, nil + } + + cacheKey := fmt.Sprintf("registry_lookup_%s_%s", req.SubscriberID, req.KeyID) + if cached, ok := c.cached(ctx, tracer, cacheKey); ok { + log.Debugf(ctx, "OAN registry lookup cache hit for key: %s", cacheKey) + span.SetAttributes(telemetry.AttrErrorType.String(outcomeCacheHit)) + c.emitMetrics(ctx, start, operationLookup, outcomeCacheHit) + return cached, nil + } + + found, matched, searchOutcome, err := c.search(ctx, tracer, req.SubscriberID, req.KeyID) + if err != nil { + outcome := classify(err) + span.RecordError(err) + span.SetStatus(codes.Error, outcome) + span.SetAttributes(telemetry.AttrErrorType.String(outcome)) + c.emitMetrics(ctx, start, operationLookup, outcome) + return nil, err + } + span.SetAttributes(telemetry.AttrErrorType.String(searchOutcome)) + if searchOutcome != outcomeFound { + // Both of these give the caller an empty result, but they are very + // different facts and must not share a metric. "not_found" means this + // caller is not registered; "key_id_mismatch" means the participant is + // registered and the key identity model is wrong -- a sustained rate of + // the latter is a total outage that would otherwise hide inside routine + // misses. + c.emitMetrics(ctx, start, operationLookup, searchOutcome) + return nil, nil + } + + status, outcome := resolveStatus(found, matched) + results := []model.Subscription{toSubscription(found, matched, status)} + + // The header's algorithm was already validated upstream, so a disagreement + // here cannot let a bad signature through -- but it means the record and the + // caller disagree about the key, which is worth surfacing before it becomes a + // verification failure nobody can explain. + if matched.Algorithm != "" && !strings.EqualFold(matched.Algorithm, expectedAlgorithm) { + log.Warnf(ctx, "OAN registry participantId=%s osid=%s declares algorithm %q, expected %q", + req.SubscriberID, req.KeyID, matched.Algorithm, expectedAlgorithm) + } + + if outcome == outcomeFound { + log.Debugf(ctx, "OAN registry resolved participantId=%s osid=%s", req.SubscriberID, req.KeyID) + c.cacheResult(ctx, cacheKey, results) + } else { + // Not an error -- the plugin looked, and correctly declined. Logged at + // Info so a refused signature is traceable without reading as a fault. + log.Infof(ctx, "OAN registry participantId=%s osid=%s is not usable: %s", req.SubscriberID, req.KeyID, outcome) + } + + span.SetAttributes(telemetry.AttrErrorType.String(outcome)) + c.emitMetrics(ctx, start, operationLookup, outcome) + return results, nil +} + +// search asks the registry for the participant holding this business id, and +// returns it only if it carries the key the caller asked about. +// +// Only participantId is filtered on, deliberately. It is the schema's +// uniqueIndexFields, so the registry already guarantees at most one match. osid +// is a system-generated field and is not indexed at all, so adding it as a +// second filter does not narrow anything -- on an Elasticsearch-backed registry +// it matches nothing, which would turn every lookup into a not-found. The key +// identity is therefore checked below, client-side, where it works on any +// backend and enforces exactly the same property. +// +// status is deliberately not filtered on either. Excluding suspended +// participants server-side would return an empty result, making "suspended" +// indistinguishable from "unknown" and losing the reason the caller reports. +func (c *Client) search(ctx context.Context, tracer trace.Tracer, participantID, keyID string) (participant, key, string, error) { + records, err := searchRecords[participant](ctx, c, tracer, c.searchURL, map[string]eqFilter{ + fieldParticipantID: {Eq: participantID}, + }) + if err != nil { + return participant{}, key{}, "", err + } + + if len(records) == 0 { + log.Infof(ctx, "OAN registry has no record for participantId=%s", participantID) + return participant{}, key{}, outcomeNotFound, nil + } + if len(records) > 1 { + // participantId is the schema's unique index, so this is a registry + // integrity fault rather than something to resolve. Carry on -- the key + // check below still decides -- but say so loudly. + log.Errorf(ctx, nil, "OAN registry returned %d records for participantId=%s, expected at most 1", + len(records), participantID) + } + + // The key identity check the filter cannot do. Keys hang off the node, so this + // walks both levels. Scanning records rather than taking records[0] also covers + // the case the second filter was originally meant to guard: a stale or + // soft-deleted record sharing the participantId. + for _, record := range records { + for _, k := range record.Keys { + if k.OSID != keyID { + continue + } + if !k.isSigning() { + // The osid resolved -- to a key that may not sign. Kept apart from a + // miss because it is a different fact: the caller is registered and + // sent a real key id, just one scoped to another purpose. + log.Errorf(ctx, nil, "OAN registry participantId=%s osid=%s is a %q key, not a signing key", + participantID, keyID, k.Use) + return participant{}, key{}, outcomeKeyNotSigning, nil + } + return record, k, outcomeFound, nil + } + } + + // Reported separately from not-found on purpose: the participant exists, so + // this says the key identity model is wrong rather than that the caller is + // unregistered. Logged at Error because a sustained rate of it is an outage. + log.Errorf(ctx, nil, "OAN registry has %d record(s) for participantId=%s but none carrying key osid %s", + len(records), participantID, keyID) + return participant{}, key{}, outcomeKeyIDMismatch, nil +} + +// resolveStatus maps the registry's own vocabulary onto the Beckn status the +// caller checks, and reports which outcome was reached. +// +// Only `status` is consulted -- at both levels. The key validity window +// (validFrom / validUntil) is deliberately NOT enforced: the Network Operator controls +// participation entirely through `status`, so an expired key is taken off the +// network by setting status rather than by this plugin timing it out. Those two +// fields are mapped onto the result for a caller to read, and nothing acts on +// them. Decided 20 Aug 2026; flagged as provisional. +// +// This is a security control, not a formatting step. model.IsKeyStatusUsable is +// a deny-list, so any status it does not recognise counts as usable -- passing +// the registry's "inactive" through unchanged would let a suspended +// participant's signature verify. Everything therefore denies unless explicitly +// allowed. +func resolveStatus(p participant, k key) (status, outcome string) { + if !strings.EqualFold(p.Status, statusActive) { + return statusUnsubscribed, outcomeInactive + } + // Checked separately from the participant's: a participant stays active while a + // single key is retired, and a retired key has to stop verifying on its own. + if !strings.EqualFold(k.Status, statusActive) { + return statusUnsubscribed, outcomeKeyInactive + } + if k.publicKey() == "" { + // Active but unusable. Denying here gives the caller a clear reason + // instead of an empty key that fails opaquely further down. + return statusUnsubscribed, outcomeNoKey + } + return statusSubscribed, outcomeFound +} + +// parseTime reads an RFC3339 timestamp, reporting whether it was present and +// well formed. An absent or unparseable value yields the zero time rather than +// an error: these timestamps are informational, so a malformed one must not +// fail a lookup. +func parseTime(value string) (time.Time, bool) { + if value == "" { + return time.Time{}, false + } + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + return time.Time{}, false + } + return parsed, true +} + +// toSubscription builds the value the sign-validation step consumes. +// +// This is the only place a model.Subscription is constructed, which is what +// guarantees Status is always set: its zero value "" is absent from +// IsKeyStatusUsable's deny-list and would therefore authorise the caller. +func toSubscription(p participant, k key, status string) model.Subscription { + // Informational only: nothing acts on these. The validity window is not + // enforced -- participation is controlled entirely through `status`. + validFrom, _ := parseTime(k.ValidFrom) + validUntil, _ := parseTime(k.ValidUntil) + + // Domain is absent from the registry record and so is left unset. Nothing on the + // signature-validation path reads it. + return model.Subscription{ + Subscriber: model.Subscriber{ + SubscriberID: p.ParticipantID, + URL: p.BaseURL, + // role is the Beckn role -- BAP, BPP or NETWORK. type is the + // registry's own discriminator (node or upstream) and means + // something else entirely, so it is not what a subscriber's Type is. + Type: p.Role, + }, + KeyID: k.OSID, + SigningPublicKey: k.publicKey(), + EncrPublicKey: encryptionKey(p), + ValidFrom: validFrom, + ValidUntil: validUntil, + Status: status, + } +} + +// encryptionKey returns the participant's active encryption key, or "" when it +// publishes none. It is resolved by use rather than by id: the request header +// names the signing key only, so there is nothing to match an encryption key +// against. +func encryptionKey(p participant) string { + for _, k := range p.Keys { + if strings.EqualFold(k.Use, useEncr) && strings.EqualFold(k.Status, statusActive) { + return k.publicKey() + } + } + return "" +} + +// cachingEnabled reports whether the cache should be consulted at all. +// +// cacheTTL is 0 by default, which disables caching entirely rather than writing +// entries with a zero TTL. A cached entry keeps a suspended participant +// verifying until it expires, so the TTL is exactly the suspension-propagation +// window and is left to the operator to opt into. +func (c *Client) cachingEnabled() bool { + return c.cache != nil && c.cacheTTL > 0 +} + +func (c *Client) cached(ctx context.Context, tracer trace.Tracer, key string) ([]model.Subscription, bool) { + if !c.cachingEnabled() { + return nil, false + } + + cacheCtx, span := tracer.Start(ctx, "cache lookup") + defer span.End() + + raw, err := c.cache.Get(cacheCtx, key) + if err != nil { + return nil, false + } + var results []model.Subscription + if err := json.Unmarshal([]byte(raw), &results); err != nil { + log.Warnf(ctx, "Discarding unreadable cache entry for key %s: %v", key, err) + return nil, false + } + + // toSubscription guarantees Status is always set, but that invariant only + // covers values this process wrote. A cache is shared, outlives a deploy and + // can hold entries written by another version -- and an empty Status is + // absent from IsKeyStatusUsable's deny-list, so it would read as usable. + // Re-check on the way in rather than trusting the entry. + if len(results) != 1 || results[0].Status == "" { + log.Warnf(ctx, "Discarding malformed cache entry for key %s", key) + return nil, false + } + return results, true +} + +// cacheResult caches a usable result. Callers must not pass a not-found or unusable +// participant: caching those would extend an outage and delay a reinstatement. +// +// The TTL comes only from configuration, never from the record's own validity +// window -- that window is typically a year, which would keep a suspended +// participant verifying for a year. +func (c *Client) cacheResult(ctx context.Context, key string, results []model.Subscription) { + if !c.cachingEnabled() { + return + } + data, err := json.Marshal(results) + if err != nil { + log.Warnf(ctx, "Failed to encode OAN registry lookup for caching, key %s: %v", key, err) + return + } + if err := c.cache.Set(ctx, key, string(data), c.cacheTTL); err != nil { + log.Warnf(ctx, "Failed to cache OAN registry lookup for key %s: %v", key, err) + } +} + +// successOutcomes is an allow-list, deliberately: a new outcome counts as a +// failure until someone says otherwise. +// +// The inverse -- listing the failures and letting anything unlisted fall through +// as success -- is the same shape as model.IsKeyStatusUsable, which is the bug +// resolveStatus exists to work around. Here the blast radius is a dashboard +// rather than an auth decision, but the failure is just as silent: add a +// success-like outcome, forget to list it, and the error rate quietly stops +// being true. +var successOutcomes = map[string]bool{ + outcomeFound: true, + outcomeCacheHit: true, +} + +// emitMetrics emits the duration of every lookup, and the shared plugin error counter +// for anything that did not resolve a key. +// +// Note that "not a success" includes outcomes that are the plugin working +// correctly: refusing a suspended participant is a successful denial. Split on +// error_type when alerting, or a routine suspension reads as an incident. +func (c *Client) emitMetrics(ctx context.Context, start time.Time, operation, outcome string) { + m, err := telemetry.GetMetrics(ctx) + if err != nil { + return + } + + attrs := metric.WithAttributes( + telemetry.AttrPluginID.String(pluginID), + telemetry.AttrPluginType.String(pluginType), + telemetry.AttrOperation.String(operation), + telemetry.AttrErrorType.String(outcome), + ) + + m.PluginExecutionDuration.Record(ctx, time.Since(start).Seconds(), attrs) + if !successOutcomes[outcome] { + m.PluginErrorsTotal.Add(ctx, 1, attrs) + } +} diff --git a/pkg/plugin/implementation/sunbirdRegistry/sunbirdRegistry_test.go b/pkg/plugin/implementation/sunbirdRegistry/sunbirdRegistry_test.go new file mode 100644 index 00000000..ddbe9711 --- /dev/null +++ b/pkg/plugin/implementation/sunbirdRegistry/sunbirdRegistry_test.go @@ -0,0 +1,1681 @@ +package sunbirdRegistry + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/beckn-one/beckn-onix/pkg/model" + "github.com/beckn-one/beckn-onix/pkg/plugin/definition" + "github.com/beckn-one/beckn-onix/pkg/telemetry" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" +) + +const ( + testParticipantID = "provider-a-001" + testOSID = "1-d0442000-a677-4cfc-bd8f-02696c6088b3" + testPublicKey = "MCowBQYDK2VwAyEA3fS8bYhWEfmM7Zjk9x0EhAmvQKp3fMHXqTiA5xL1Qmw=" +) + +// mockCache is a test double for definition.Cache that records what it was asked +// to do, so tests can assert the cache was (or was not) used. +type mockCache struct { + getFunc func(ctx context.Context, key string) (string, error) + + getCalls int + setCalls int + setKey string + setVal string + setTTL time.Duration + setErr error +} + +func (m *mockCache) Get(ctx context.Context, key string) (string, error) { + m.getCalls++ + if m.getFunc != nil { + return m.getFunc(ctx, key) + } + return "", errors.New("cache miss") +} + +func (m *mockCache) Set(ctx context.Context, key, value string, ttl time.Duration) error { + m.setCalls++ + m.setKey = key + m.setVal = value + m.setTTL = ttl + return m.setErr +} + +func (m *mockCache) Delete(ctx context.Context, key string) error { return nil } +func (m *mockCache) Clear(ctx context.Context) error { return nil } + +// recordJSON renders a registry search response containing the given records. +func recordJSON(t *testing.T, records ...participant) string { + t.Helper() + if records == nil { + records = []participant{} + } + data, err := json.Marshal(records) + if err != nil { + t.Fatalf("failed to marshal test records: %v", err) + } + return string(data) +} + +// signingKey is a healthy signing key with an open validity window. Its value +// carries the registry's encoding label so every test that reaches +// toSubscription also exercises the prefix being stripped. +func signingKey() key { + return key{ + OSID: testOSID, + KeyID: "k1", + Use: useSign, + Algorithm: expectedAlgorithm, + Value: keyEncodingPrefix + testPublicKey, + Status: "active", + ValidFrom: time.Now().Add(-24 * time.Hour).UTC().Format(time.RFC3339), + ValidUntil: time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339), + } +} + +// activeRecord is a healthy participant publishing one active signing key. +func activeRecord() participant { + return participant{ + ParticipantID: testParticipantID, + Type: "node", + Role: "BPP", + Status: "active", + BaseURL: "https://providera.example.com/onix", + Keys: []key{signingKey()}, + } +} + +// newTestClient builds a client pointed at srvURL, with retries effectively off +// and sub-millisecond backoff so tests stay fast. +func newTestClient(t *testing.T, srvURL string, cache definition.Cache, tweak ...func(*Config)) *Client { + t.Helper() + + cfg := &Config{ + URL: srvURL, + Timeout: DefaultTimeoutSeconds, + RetryMax: 0, + RetryWaitMin: time.Millisecond, + RetryWaitMax: 2 * time.Millisecond, + } + for _, apply := range tweak { + apply(cfg) + } + + client, closer, err := New(context.Background(), cache, cfg) + if err != nil { + t.Fatalf("New() returned an unexpected error: %v", err) + } + t.Cleanup(func() { _ = closer() }) + return client +} + +func lookup(t *testing.T, c *Client) ([]model.Subscription, error) { + t.Helper() + return c.Lookup(context.Background(), &model.Subscription{ + Subscriber: model.Subscriber{SubscriberID: testParticipantID}, + KeyID: testOSID, + }) +} + +// --- configuration ------------------------------------------------------- + +func TestValidate(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + config *Config + expectedErr string + }{ + { + name: "should return error for nil config", + config: nil, + expectedErr: "registry config cannot be nil", + }, + { + name: "should return error for empty URL", + config: &Config{URL: ""}, + expectedErr: "registry URL cannot be empty", + }, + { + name: "should succeed for valid config", + config: &Config{URL: "http://localhost:8081/api/v1"}, + expectedErr: "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := validate(tc.config) + switch { + case tc.expectedErr == "" && err != nil: + t.Fatalf("expected no error, but got: %v", err) + case tc.expectedErr != "" && err == nil: + t.Fatalf("expected an error but got none") + case tc.expectedErr != "" && err.Error() != tc.expectedErr: + t.Errorf("expected error message %q, but got %q", tc.expectedErr, err.Error()) + } + }) + } +} + +// TestNewAlwaysBoundsTheTimeout guards the one deliberate difference from the +// sibling registry plugins. They apply the timeout only when it is configured, +// which leaves it infinite when it is not. Re-adding that guard here would look +// like harmless tidying, so it is asserted directly. +func TestNewAlwaysBoundsTheTimeout(t *testing.T) { + t.Parallel() + + client, closer, err := New(context.Background(), nil, &Config{URL: "http://localhost:8081"}) + if err != nil { + t.Fatalf("New() returned an unexpected error: %v", err) + } + defer func() { _ = closer() }() + + if got := client.client.HTTPClient.Timeout; got <= 0 { + t.Fatalf("expected a bounded timeout when none is configured, got %v", got) + } +} + +// TestNewRejectsAnInvalidConfig: a bad URL must stop the adapter at startup +// rather than failing every lookup once traffic arrives. +func TestNewRejectsAnInvalidConfig(t *testing.T) { + t.Parallel() + + for _, cfg := range []*Config{ + nil, + {URL: ""}, + {URL: "registry:8081"}, + } { + if _, _, err := New(context.Background(), nil, cfg); err == nil { + t.Errorf("expected New to reject config %+v", cfg) + } + } +} + +func TestNewBuildsSearchURL(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + config *Config + expected string + }{ + { + name: "defaults to the Participant entity", + config: &Config{URL: "http://registry:8081/api/v1"}, + expected: "http://registry:8081/api/v1/Participant/search", + }, + { + name: "honours a configured entity", + config: &Config{URL: "http://registry:8081/api/v1", Entity: "Subscriber"}, + expected: "http://registry:8081/api/v1/Subscriber/search", + }, + { + name: "tolerates a trailing slash on the base URL", + config: &Config{URL: "http://registry:8081/api/v1/"}, + expected: "http://registry:8081/api/v1/Participant/search", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + client, closer, err := New(context.Background(), nil, tc.config) + if err != nil { + t.Fatalf("New() returned an unexpected error: %v", err) + } + defer func() { _ = closer() }() + + if client.searchURL != tc.expected { + t.Errorf("expected search URL %q, got %q", tc.expected, client.searchURL) + } + }) + } +} + +// --- status mapping ------------------------------------------------------ + +// TestResolveStatus is the security regression test for this plugin. +// +// model.IsKeyStatusUsable is a deny-list, so a status it does not recognise +// counts as usable. Passing the registry's own "inactive" through unchanged +// would let a suspended participant's signature verify, which is why every case +// below asserts usability rather than just the mapped string. +func TestResolveStatus(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 8, 20, 12, 0, 0, 0, time.UTC) + rfc := func(d time.Duration) string { return now.Add(d).Format(time.RFC3339) } + + // active builds a usable signing key, so each case below varies only the one + // thing it is about. + active := func(mutate ...func(*key)) key { + k := key{OSID: testOSID, Use: useSign, Value: testPublicKey, Status: "active"} + for _, apply := range mutate { + apply(&k) + } + return k + } + + testCases := []struct { + name string + participantStatus string + key key + expectedStatus string + expectedReason string + expectUsable bool + }{ + { + name: "active within window is usable", + participantStatus: "active", + key: active(func(k *key) { k.ValidFrom, k.ValidUntil = rfc(-time.Hour), rfc(time.Hour) }), + expectedStatus: statusSubscribed, + expectedReason: outcomeFound, + expectUsable: true, + }, + { + name: "active with no window bounds is usable", + participantStatus: "active", + key: active(), + expectedStatus: statusSubscribed, + expectedReason: outcomeFound, + expectUsable: true, + }, + { + name: "participant status is matched case insensitively", + participantStatus: "ACTIVE", + key: active(), + expectedStatus: statusSubscribed, + expectedReason: outcomeFound, + expectUsable: true, + }, + { + name: "key status is matched case insensitively", + participantStatus: "active", + key: active(func(k *key) { k.Status = "ACTIVE" }), + expectedStatus: statusSubscribed, + expectedReason: outcomeFound, + expectUsable: true, + }, + { + name: "the encoding label is not mistaken for key material", + participantStatus: "active", + key: active(func(k *key) { k.Value = keyEncodingPrefix + testPublicKey }), + expectedStatus: statusSubscribed, + expectedReason: outcomeFound, + expectUsable: true, + }, + { + name: "an inactive participant is denied", + participantStatus: "inactive", + key: active(), + expectedStatus: statusUnsubscribed, + expectedReason: outcomeInactive, + expectUsable: false, + }, + { + name: "an unrecognised participant status is denied", + participantStatus: "approved", + key: active(), + expectedStatus: statusUnsubscribed, + expectedReason: outcomeInactive, + expectUsable: false, + }, + { + name: "an empty participant status is denied", + participantStatus: "", + key: active(), + expectedStatus: statusUnsubscribed, + expectedReason: outcomeInactive, + expectUsable: false, + }, + { + // The reason per-key status exists: the participant is trading normally, + // one of its keys has been retired, and that key alone must stop verifying. + name: "a retired key under an active participant is denied", + participantStatus: "active", + key: active(func(k *key) { k.Status = "inactive" }), + expectedStatus: statusUnsubscribed, + expectedReason: outcomeKeyInactive, + expectUsable: false, + }, + { + name: "an unrecognised key status is denied", + participantStatus: "active", + key: active(func(k *key) { k.Status = "rotating" }), + expectedStatus: statusUnsubscribed, + expectedReason: outcomeKeyInactive, + expectUsable: false, + }, + { + name: "an empty key status is denied", + participantStatus: "active", + key: active(func(k *key) { k.Status = "" }), + expectedStatus: statusUnsubscribed, + expectedReason: outcomeKeyInactive, + expectUsable: false, + }, + { + name: "an active key with no material is denied", + participantStatus: "active", + key: active(func(k *key) { k.Value = "" }), + expectedStatus: statusUnsubscribed, + expectedReason: outcomeNoKey, + expectUsable: false, + }, + { + // A value that is nothing but the encoding label carries no material. + name: "a key that is only an encoding label is denied", + participantStatus: "active", + key: active(func(k *key) { k.Value = keyEncodingPrefix }), + expectedStatus: statusUnsubscribed, + expectedReason: outcomeNoKey, + expectUsable: false, + }, + { + // The validity window is not enforced: participation is controlled + // through `status` alone, so an expired key still verifies until the + // Network Operator deactivates it. + name: "a window that has not opened yet is NOT enforced", + participantStatus: "active", + key: active(func(k *key) { k.ValidFrom = rfc(time.Hour) }), + expectedStatus: statusSubscribed, + expectedReason: outcomeFound, + expectUsable: true, + }, + { + name: "a window that has closed is NOT enforced", + participantStatus: "active", + key: active(func(k *key) { k.ValidUntil = rfc(-time.Hour) }), + expectedStatus: statusSubscribed, + expectedReason: outcomeFound, + expectUsable: true, + }, + { + name: "an unparseable window bound is treated as unbounded", + participantStatus: "active", + key: active(func(k *key) { k.ValidUntil = "not-a-timestamp" }), + expectedStatus: statusSubscribed, + expectedReason: outcomeFound, + expectUsable: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + status, reason := resolveStatus(participant{Status: tc.participantStatus}, tc.key) + if status != tc.expectedStatus { + t.Errorf("expected status %q, got %q", tc.expectedStatus, status) + } + if reason != tc.expectedReason { + t.Errorf("expected outcome %q, got %q", tc.expectedReason, reason) + } + if usable := model.IsKeyStatusUsable(status); usable != tc.expectUsable { + t.Errorf("expected IsKeyStatusUsable to be %v for status %q, got %v", tc.expectUsable, status, usable) + } + }) + } +} + +// TestToSubscriptionMapsOptionalFields covers the registry carrying, and not +// carrying, the fields it may or may not populate. +func TestToSubscriptionMapsOptionalFields(t *testing.T) { + t.Parallel() + + t.Run("maps optional fields when present", func(t *testing.T) { + t.Parallel() + + record := activeRecord() + record.Keys = append(record.Keys, key{ + OSID: "1-abcdef00-0000-0000-0000-000000000000", + Use: useEncr, + Value: keyEncodingPrefix + "encryption-key", + Status: "active", + }) + + got := toSubscription(record, record.Keys[0], statusSubscribed) + + if got.EncrPublicKey != "encryption-key" { + t.Errorf("expected encryption key to be mapped, got %q", got.EncrPublicKey) + } + if got.Type != "BPP" { + t.Errorf("expected type to be mapped, got %q", got.Type) + } + if got.SigningPublicKey != testPublicKey { + t.Errorf("expected the encoding label to be stripped, got %q", got.SigningPublicKey) + } + if got.ValidFrom.IsZero() || got.ValidUntil.IsZero() { + t.Error("expected the validity window to be parsed") + } + }) + + t.Run("leaves optional fields empty when absent", func(t *testing.T) { + t.Parallel() + + k := key{OSID: testOSID, Use: useSign, Value: testPublicKey, Status: "active"} + record := participant{ + ParticipantID: testParticipantID, + Keys: []key{k}, + } + got := toSubscription(record, k, statusSubscribed) + + // A node publishing only a signing key yields no encryption key, rather + // than falling back to the signing one. + if got.EncrPublicKey != "" { + t.Errorf("expected an empty encryption key, got %q", got.EncrPublicKey) + } + if got.Type != "" { + t.Errorf("expected an empty type, got %q", got.Type) + } + if got.SubscriberID != testParticipantID || got.KeyID != testOSID { + t.Errorf("expected identifiers to be mapped, got subscriber=%q key=%q", got.SubscriberID, got.KeyID) + } + }) + + t.Run("ignores a retired encryption key", func(t *testing.T) { + t.Parallel() + + record := activeRecord() + record.Keys = append(record.Keys, key{ + OSID: "1-abcdef00-0000-0000-0000-000000000000", + Use: useEncr, + Value: keyEncodingPrefix + "retired-encryption-key", + Status: "inactive", + }) + + got := toSubscription(record, record.Keys[0], statusSubscribed) + + if got.EncrPublicKey != "" { + t.Errorf("a retired encryption key must not be published, got %q", got.EncrPublicKey) + } + }) +} + +// TestClassify pins the outcome vocabulary. It is a pure function, and the +// value of the split -- a dead registry and a malformed body landing in +// different series -- is entirely lost if a branch silently stops matching. +func TestClassify(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + err error + expected string + }{ + {name: "deadline exceeded", err: context.DeadlineExceeded, expected: outcomeTimeout}, + {name: "cancelled", err: context.Canceled, expected: outcomeTimeout}, + {name: "wrapped deadline", err: fmt.Errorf("sending: %w", context.DeadlineExceeded), expected: outcomeTimeout}, + {name: "registry status", err: fmt.Errorf("%w: 503", errRegistryStatus), expected: outcomeRegistryError}, + {name: "decode failure", err: fmt.Errorf("%w: bad json", errDecodeResponse), expected: outcomeDecodeError}, + {name: "anything else is transport", err: errors.New("connection refused"), expected: outcomeTransportError}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := classify(tc.err); got != tc.expected { + t.Errorf("expected outcome %q, got %q", tc.expected, got) + } + }) + } +} + +// --- lookup -------------------------------------------------------------- + +func TestLookupResolvesAnActiveParticipant(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, recordJSON(t, activeRecord())) + })) + defer srv.Close() + + results, err := lookup(t, newTestClient(t, srv.URL, nil)) + if err != nil { + t.Fatalf("Lookup() returned an unexpected error: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected exactly 1 result, got %d", len(results)) + } + if results[0].SigningPublicKey != testPublicKey { + t.Errorf("expected the signing key to be returned, got %q", results[0].SigningPublicKey) + } + if !model.IsKeyStatusUsable(results[0].Status) { + t.Errorf("expected an active participant to be usable, got status %q", results[0].Status) + } +} + +// TestLookupSendsTheExpectedRequest pins the wire contract: both filters, and +// no Authorization header. The registry's search endpoint is public, and a +// malformed bearer is rejected before its permit rule is evaluated -- so +// accidentally sending one would break every lookup. +func TestLookupSendsTheExpectedRequest(t *testing.T) { + t.Parallel() + + var gotPath, gotMethod, gotAuth string + var gotBody searchRequest + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath, gotMethod, gotAuth = r.URL.Path, r.Method, r.Header.Get("Authorization") + _ = json.NewDecoder(r.Body).Decode(&gotBody) + fmt.Fprint(w, recordJSON(t, activeRecord())) + })) + defer srv.Close() + + if _, err := lookup(t, newTestClient(t, srv.URL, nil)); err != nil { + t.Fatalf("Lookup() returned an unexpected error: %v", err) + } + + if gotMethod != http.MethodPost { + t.Errorf("expected a POST, got %s", gotMethod) + } + if gotPath != "/Participant/search" { + t.Errorf("expected path /Participant/search, got %s", gotPath) + } + if gotAuth != "" { + t.Errorf("expected no Authorization header, got %q", gotAuth) + } + if got := gotBody.Filters[fieldParticipantID].Eq; got != testParticipantID { + t.Errorf("expected participant_id filter %q, got %q", testParticipantID, got) + } + // osid must NOT be filtered on: it is not an indexed field, so an + // Elasticsearch-backed registry matches nothing and every lookup becomes a + // not-found. The key identity is checked client-side instead. + if _, present := gotBody.Filters["osid"]; present { + t.Error("expected osid NOT to be sent as a filter; it is not an indexed field") + } + if len(gotBody.Filters) != 1 { + t.Errorf("expected exactly 1 filter, got %d: %v", len(gotBody.Filters), gotBody.Filters) + } +} + +// TestLookupRejectsEmptyIdentifiers: an empty key id would match any record +// whose OSID is absent, so it is refused before the registry is called. +func TestLookupRejectsEmptyIdentifiers(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + subscriberID string + keyID string + }{ + {name: "no subscriber id", subscriberID: "", keyID: testOSID}, + {name: "no key id", subscriberID: testParticipantID, keyID: ""}, + {name: "neither", subscriberID: "", keyID: ""}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var requests atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + fmt.Fprint(w, recordJSON(t, activeRecord())) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL, nil) + results, err := client.Lookup(context.Background(), &model.Subscription{ + Subscriber: model.Subscriber{SubscriberID: tc.subscriberID}, + KeyID: tc.keyID, + }) + if err != nil { + t.Fatalf("Lookup() returned an unexpected error: %v", err) + } + if len(results) != 0 { + t.Errorf("expected no results, got %d", len(results)) + } + if requests.Load() != 0 { + t.Error("expected the registry not to be called for an empty identifier") + } + }) + } +} + +// TestLookupWarnsOnAlgorithmMismatch: a record declaring an unexpected algorithm +// still resolves. The header's algorithm is validated upstream, so this cannot +// admit a bad signature -- it is surfaced as a warning, not a refusal. +func TestLookupWarnsOnAlgorithmMismatch(t *testing.T) { + t.Parallel() + + record := activeRecord() + record.Keys[0].Algorithm = "rsa-2048" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, recordJSON(t, record)) + })) + defer srv.Close() + + results, err := lookup(t, newTestClient(t, srv.URL, nil)) + if err != nil { + t.Fatalf("Lookup() returned an unexpected error: %v", err) + } + if len(results) != 1 || !model.IsKeyStatusUsable(results[0].Status) { + t.Fatal("expected an algorithm mismatch to warn, not to refuse the key") + } +} + +// TestLookupOnEmptyResult covers the registry answering "no such record". That +// is a legitimate answer, not a failure: the caller turns an empty slice into +// its own not-found error. +func TestLookupOnEmptyResult(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "[]") + })) + defer srv.Close() + + results, err := lookup(t, newTestClient(t, srv.URL, nil)) + if err != nil { + t.Fatalf("expected no error for an empty result, got: %v", err) + } + if len(results) != 0 { + t.Fatalf("expected no results, got %d", len(results)) + } +} + +// TestLookupAcceptsEitherResponseEnvelope: the registry answers with a bare +// array on some search backends and a {"data":[...]} envelope on others. Which +// one a deployment gets depends on its configured search provider, so both have +// to decode. +func TestLookupAcceptsEitherResponseEnvelope(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + body string + expectResults int + }{ + {name: "bare array", body: recordJSON(t, activeRecord()), expectResults: 1}, + {name: "bare empty array", body: `[]`, expectResults: 0}, + { + name: "data envelope", + body: fmt.Sprintf(`{"totalCount":1,"data":%s}`, recordJSON(t, activeRecord())), + expectResults: 1, + }, + {name: "empty data envelope", body: `{"totalCount":0,"data":[]}`, expectResults: 0}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, tc.body) + })) + defer srv.Close() + + results, err := lookup(t, newTestClient(t, srv.URL, nil)) + if err != nil { + t.Fatalf("Lookup() returned an unexpected error: %v", err) + } + if len(results) != tc.expectResults { + t.Fatalf("expected %d results, got %d", tc.expectResults, len(results)) + } + if tc.expectResults == 1 && results[0].SigningPublicKey != testPublicKey { + t.Errorf("expected the signing key to be returned, got %q", results[0].SigningPublicKey) + } + }) + } +} + +// TestLookupOnSuspendedParticipant is the end-to-end counterpart to +// TestResolveStatus: a suspended participant must come back as a refusal the +// caller can distinguish from "unknown", not as an empty result. +func TestLookupOnSuspendedParticipant(t *testing.T) { + t.Parallel() + + record := activeRecord() + record.Status = "inactive" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, recordJSON(t, record)) + })) + defer srv.Close() + + results, err := lookup(t, newTestClient(t, srv.URL, nil)) + if err != nil { + t.Fatalf("Lookup() returned an unexpected error: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected the record to be returned so the reason is reportable, got %d results", len(results)) + } + if model.IsKeyStatusUsable(results[0].Status) { + t.Fatalf("a suspended participant must not be usable, got status %q", results[0].Status) + } +} + +// TestLookupRejectsAKeyIdMismatch is the client-side replacement for the osid +// filter. osid is not an indexed field, so it cannot be filtered on server-side; +// the identity check has to happen here or a caller could present a valid +// participant id with someone else's key id. +func TestLookupRejectsAKeyIdMismatch(t *testing.T) { + t.Parallel() + + record := activeRecord() + record.Keys[0].OSID = "1-99999999-0000-0000-0000-000000000000" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, recordJSON(t, record)) + })) + defer srv.Close() + + results, err := lookup(t, newTestClient(t, srv.URL, nil)) + if err != nil { + t.Fatalf("Lookup() returned an unexpected error: %v", err) + } + if len(results) != 0 { + t.Fatalf("expected a key id mismatch to resolve to not-found, got %d results", len(results)) + } +} + +// TestSearchDistinguishesMismatchFromNotFound: both give the caller an empty +// result, but they are different facts. If the deployed key identity model is +// ever wrong, every lookup takes the mismatch path -- a total outage that would +// be invisible if it shared a metric with routine misses. +func TestSearchDistinguishesMismatchFromNotFound(t *testing.T) { + t.Parallel() + + otherKey := activeRecord() + otherKey.Keys[0].OSID = "1-99999999-0000-0000-0000-000000000000" + + testCases := []struct { + name string + body string + expected string + }{ + {name: "no such participant", body: `[]`, expected: outcomeNotFound}, + {name: "participant exists, key id does not match", body: recordJSON(t, otherKey), expected: outcomeKeyIDMismatch}, + {name: "participant and key id both match", body: recordJSON(t, activeRecord()), expected: outcomeFound}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, tc.body) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL, nil) + tracer := otel.Tracer("test") + + _, _, outcome, err := client.search(context.Background(), tracer, testParticipantID, testOSID) + if err != nil { + t.Fatalf("search() returned an unexpected error: %v", err) + } + if outcome != tc.expected { + t.Errorf("expected outcome %q, got %q", tc.expected, outcome) + } + }) + } +} + +// TestLookupSelectsTheRecordCarryingTheKey covers the case the osid filter was +// originally meant to guard: more than one record sharing a participant_id, e.g. +// a soft-deleted one alongside the live record. +func TestLookupSelectsTheRecordCarryingTheKey(t *testing.T) { + t.Parallel() + + stale := activeRecord() + stale.Keys[0].OSID = "1-00000000-0000-0000-0000-000000000000" + stale.Keys[0].Value = "stale-key" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, recordJSON(t, stale, activeRecord())) + })) + defer srv.Close() + + results, err := lookup(t, newTestClient(t, srv.URL, nil)) + if err != nil { + t.Fatalf("Lookup() returned an unexpected error: %v", err) + } + if len(results) != 1 || results[0].SigningPublicKey != testPublicKey { + t.Fatalf("expected the record matching the requested key id to be chosen, got %+v", results) + } +} + +// TestLookupOnDuplicateRecords covers a registry integrity fault. osid is +// unique, so this cannot happen against a healthy registry -- but returning +// traffic-stopping errors on it would be worse than carrying on with the first +// record and logging loudly. +func TestLookupOnDuplicateRecords(t *testing.T) { + t.Parallel() + + first, second := activeRecord(), activeRecord() + second.Keys[0].Value = "a-different-key" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, recordJSON(t, first, second)) + })) + defer srv.Close() + + results, err := lookup(t, newTestClient(t, srv.URL, nil)) + if err != nil { + t.Fatalf("Lookup() returned an unexpected error: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected exactly 1 result, got %d", len(results)) + } + if results[0].SigningPublicKey != testPublicKey { + t.Errorf("expected the first record to be used, got key %q", results[0].SigningPublicKey) + } +} + +// --- transport failures -------------------------------------------------- + +func TestLookupOnMalformedResponses(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + body string + }{ + {name: "not JSON at all", body: "this is not json"}, + {name: "an object with no data field", body: `{"participant_id":"provider-a-001"}`}, + {name: "a truncated array", body: `[{"participant_id":`}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, tc.body) + })) + defer srv.Close() + + if _, err := lookup(t, newTestClient(t, srv.URL, nil)); err == nil { + t.Fatal("expected an error for a malformed response, got none") + } + }) + } +} + +// TestLookupRetryBehaviour pins which status codes are retried. +// +// A 4xx means the request itself was wrong, so retrying it just wastes the +// caller's budget. The exception is 429, which means "too fast, try later" -- +// retryablehttp's default policy already draws exactly this line, which is why +// this plugin sets no custom retry policy. +func TestLookupRetryBehaviour(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + status int + retryMax int + expectedAttempts int32 + }{ + {name: "400 is not retried", status: http.StatusBadRequest, retryMax: 2, expectedAttempts: 1}, + {name: "404 is not retried", status: http.StatusNotFound, retryMax: 2, expectedAttempts: 1}, + {name: "429 is retried", status: http.StatusTooManyRequests, retryMax: 2, expectedAttempts: 3}, + {name: "503 is retried", status: http.StatusServiceUnavailable, retryMax: 2, expectedAttempts: 3}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var attempts atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempts.Add(1) + w.WriteHeader(tc.status) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL, nil, func(c *Config) { c.RetryMax = tc.retryMax }) + if _, err := lookup(t, client); err == nil { + t.Fatal("expected an error for a failing registry, got none") + } + if got := attempts.Load(); got != tc.expectedAttempts { + t.Errorf("expected %d attempts, got %d", tc.expectedAttempts, got) + } + }) + } +} + +// TestLookupClampsRetryAfter covers a hostile or misconfigured registry. +// +// retryablehttp's DefaultBackoff honours Retry-After on 429/503 and returns it +// without applying its own ceiling, so an hour-long header would park the +// request for an hour inside signature validation. +func TestLookupClampsRetryAfter(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Retry-After", "3600") + w.WriteHeader(http.StatusTooManyRequests) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL, nil, func(c *Config) { + c.RetryMax = 1 + c.RetryWaitMax = 50 * time.Millisecond + }) + + start := time.Now() + if _, err := lookup(t, client); err == nil { + t.Fatal("expected an error after retries were exhausted, got none") + } + + // Generous bound: the point is that it is not honouring 3600s, not the + // precise backoff. + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Fatalf("Retry-After was not clamped: lookup took %v", elapsed) + } +} + +// TestLookupRejectsCacheEntryWithoutStatus: an empty Status is absent from +// IsKeyStatusUsable's deny-list, so a cache entry carrying one would be treated +// as verifiable. The cache is shared and outlives a deploy, so the construction +// invariant has to be re-checked on read. +func TestLookupRejectsCacheEntryWithoutStatus(t *testing.T) { + t.Parallel() + + var requests atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + fmt.Fprint(w, recordJSON(t, activeRecord())) + })) + defer srv.Close() + + poisoned, err := json.Marshal([]model.Subscription{{ + Subscriber: model.Subscriber{SubscriberID: testParticipantID}, + KeyID: testOSID, + SigningPublicKey: "attacker-supplied-key", + }}) + if err != nil { + t.Fatalf("failed to build the test cache entry: %v", err) + } + + cache := &mockCache{ + getFunc: func(ctx context.Context, key string) (string, error) { return string(poisoned), nil }, + } + client := newTestClient(t, srv.URL, cache, func(c *Config) { c.CacheTTL = 30 * time.Second }) + + results, err := lookup(t, client) + if err != nil { + t.Fatalf("Lookup() returned an unexpected error: %v", err) + } + if requests.Load() != 1 { + t.Error("expected the statusless cache entry to be discarded and the registry consulted") + } + if len(results) != 1 || results[0].SigningPublicKey != testPublicKey { + t.Fatal("expected the registry's key to be returned, not the cached one") + } +} + +// TestLookupDoesNotHangOnAStalledRegistry is the dead-peer case: the registry +// accepts the connection and then never answers. Without a bounded client +// timeout this would hang the calling request, and with it the adapter. +func TestLookupDoesNotHangOnAStalledRegistry(t *testing.T) { + t.Parallel() + + release := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-release + })) + defer srv.Close() + defer close(release) + + client := newTestClient(t, srv.URL, nil, func(c *Config) { c.Timeout = 1 }) + + done := make(chan error, 1) + go func() { + _, err := lookup(t, client) + done <- err + }() + + select { + case err := <-done: + if err == nil { + t.Fatal("expected a timeout error, got none") + } + // classify() must see this as a timeout, not fall through to + // transport_error. It depends on errors.Is holding through + // http.Client.Timeout -> *url.Error -> retryablehttp's wrapper, which is + // exactly the sort of chain that regresses quietly on a dependency bump. + if got := classify(err); got != outcomeTimeout { + t.Errorf("expected a stalled registry to classify as %q, got %q (%v)", outcomeTimeout, got, err) + } + case <-time.After(10 * time.Second): + t.Fatal("Lookup() did not return; the client timeout is not being applied") + } +} + +func TestLookupHonoursContextCancellation(t *testing.T) { + t.Parallel() + + release := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-release + })) + defer srv.Close() + defer close(release) + + // Timeout deliberately far longer than the assertion window: with the default + // 2s the client timeout fires first and this test passes even if context + // propagation is deleted, which makes it a green test protecting nothing. + client := newTestClient(t, srv.URL, nil, func(c *Config) { c.Timeout = 30 }) + ctx, cancel := context.WithCancel(context.Background()) + + done := make(chan error, 1) + go func() { + _, err := client.Lookup(ctx, &model.Subscription{ + Subscriber: model.Subscriber{SubscriberID: testParticipantID}, + KeyID: testOSID, + }) + done <- err + }() + + cancel() + + select { + case err := <-done: + if err == nil { + t.Fatal("expected an error after cancellation, got none") + } + if !errors.Is(err, context.Canceled) { + t.Errorf("expected a context.Canceled error, got: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("Lookup() ignored context cancellation") + } +} + +func TestLookupOnUnreachableRegistry(t *testing.T) { + t.Parallel() + + // A server that is closed immediately, so the port refuses connections. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + url := srv.URL + srv.Close() + + if _, err := lookup(t, newTestClient(t, url, nil)); err == nil { + t.Fatal("expected an error for an unreachable registry, got none") + } +} + +// --- caching ------------------------------------------------------------- + +// TestLookupCachingDisabledByDefault matters because the TTL is exactly the +// window in which a suspended participant keeps verifying. Caching is therefore +// opt-in, and "off" must mean the cache is not touched at all. +func TestLookupCachingDisabledByDefault(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, recordJSON(t, activeRecord())) + })) + defer srv.Close() + + cache := &mockCache{} + if _, err := lookup(t, newTestClient(t, srv.URL, cache)); err != nil { + t.Fatalf("Lookup() returned an unexpected error: %v", err) + } + + if cache.getCalls != 0 || cache.setCalls != 0 { + t.Errorf("expected the cache to be untouched by default, got %d reads and %d writes", cache.getCalls, cache.setCalls) + } +} + +func TestLookupCachesUsableResults(t *testing.T) { + t.Parallel() + + const ttl = 30 * time.Second + var requests atomic.Int32 + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + fmt.Fprint(w, recordJSON(t, activeRecord())) + })) + defer srv.Close() + + cache := &mockCache{} + client := newTestClient(t, srv.URL, cache, func(c *Config) { c.CacheTTL = ttl }) + + if _, err := lookup(t, client); err != nil { + t.Fatalf("Lookup() returned an unexpected error: %v", err) + } + + if cache.setCalls != 1 { + t.Fatalf("expected exactly 1 cache write, got %d", cache.setCalls) + } + // The TTL must come from configuration, never from the record's own validity + // window -- that window is typically a year, which would keep a suspended + // participant verifying for a year. + if cache.setTTL != ttl { + t.Errorf("expected the configured TTL %v, got %v", ttl, cache.setTTL) + } + if expected := fmt.Sprintf("registry_lookup_%s_%s", testParticipantID, testOSID); cache.setKey != expected { + t.Errorf("expected cache key %q, got %q", expected, cache.setKey) + } + + // A second lookup should be served from the cache without another round trip. + cached := cache.setVal + cache.getFunc = func(ctx context.Context, key string) (string, error) { return cached, nil } + + if _, err := lookup(t, client); err != nil { + t.Fatalf("Lookup() returned an unexpected error on the cached path: %v", err) + } + if got := requests.Load(); got != 1 { + t.Errorf("expected the second lookup to be served from cache, but the registry saw %d requests", got) + } +} + +// TestLookupDoesNotCacheUnusableResults: caching a refusal would delay a +// reinstatement, and caching a miss would extend an outage. +func TestLookupDoesNotCacheUnusableResults(t *testing.T) { + t.Parallel() + + suspended := activeRecord() + suspended.Status = "inactive" + + testCases := []struct { + name string + body string + }{ + {name: "a suspended participant", body: recordJSON(t, suspended)}, + {name: "a participant that does not exist", body: "[]"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, tc.body) + })) + defer srv.Close() + + cache := &mockCache{} + client := newTestClient(t, srv.URL, cache, func(c *Config) { c.CacheTTL = 30 * time.Second }) + + if _, err := lookup(t, client); err != nil { + t.Fatalf("Lookup() returned an unexpected error: %v", err) + } + if cache.setCalls != 0 { + t.Errorf("expected nothing to be cached, got %d writes", cache.setCalls) + } + }) + } +} + +// TestLookupSurvivesCacheFailures: the cache is a performance aid, so neither an +// unreadable entry nor a failing write may break verification. +func TestLookupSurvivesCacheFailures(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, recordJSON(t, activeRecord())) + })) + defer srv.Close() + + cache := &mockCache{ + getFunc: func(ctx context.Context, key string) (string, error) { return "{not-json", nil }, + setErr: errors.New("cache is down"), + } + client := newTestClient(t, srv.URL, cache, func(c *Config) { c.CacheTTL = 30 * time.Second }) + + results, err := lookup(t, client) + if err != nil { + t.Fatalf("expected cache failures to be survivable, got: %v", err) + } + if len(results) != 1 || results[0].SigningPublicKey != testPublicKey { + t.Error("expected the lookup to fall back to the registry and return the key") + } +} + +// TestEmitMetricsSuccessPartition pins which outcomes count against +// onix_plugin_errors_total. +// +// The partition is an allow-list on purpose (see successOutcomes): an unlisted +// outcome must count as a failure, because the alternative -- a new success-like +// outcome silently falling through as success -- makes the error rate quietly +// untrue, and nothing else would catch it. +// +// This test must NOT call t.Parallel(). otel.SetMeterProvider is global and +// telemetry.GetMetrics caches instruments against the provider pointer, so +// running alongside the parallel tests would mix their measurements into this +// reader. Go never runs a non-parallel test concurrently with a parallel one, so +// the sequential phase gives this exclusive use of the global provider -- but +// that safety is invisible, hence this comment. +func TestEmitMetricsSuccessPartition(t *testing.T) { + ctx := context.Background() + + for _, tc := range []struct { + name string + outcome string + expectErrors bool + }{ + {name: "found is a success", outcome: outcomeFound, expectErrors: false}, + {name: "cache hit is a success", outcome: outcomeCacheHit, expectErrors: false}, + {name: "inactive counts as a failure", outcome: outcomeInactive, expectErrors: true}, + {name: "key id mismatch counts as a failure", outcome: outcomeKeyIDMismatch, expectErrors: true}, + {name: "an unlisted outcome counts as a failure", outcome: "some_future_outcome", expectErrors: true}, + } { + t.Run(tc.name, func(t *testing.T) { + previous := otel.GetMeterProvider() + reader := sdkmetric.NewManualReader() + mp := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + otel.SetMeterProvider(mp) + t.Cleanup(func() { + otel.SetMeterProvider(previous) + _ = mp.Shutdown(ctx) + }) + + (&Client{}).emitMetrics(ctx, time.Now(), operationLookup, tc.outcome) + + var rm metricdata.ResourceMetrics + if err := reader.Collect(ctx, &rm); err != nil { + t.Fatalf("failed to collect metrics: %v", err) + } + + var sawDuration, sawErrors bool + for _, scope := range rm.ScopeMetrics { + for _, m := range scope.Metrics { + switch m.Name { + case "onix_plugin_execution_duration_seconds": + sawDuration = true + assertOutcomeAttribute(t, m, tc.outcome) + case "onix_plugin_errors_total": + sawErrors = true + assertOutcomeAttribute(t, m, tc.outcome) + } + } + } + + if !sawDuration { + t.Error("expected the duration histogram to be recorded for every outcome") + } + if sawErrors != tc.expectErrors { + t.Errorf("expected errors-counter recorded=%v for outcome %q, got %v", tc.expectErrors, tc.outcome, sawErrors) + } + }) + } +} + +// assertOutcomeAttribute checks the four attributes every measurement carries. +func assertOutcomeAttribute(t *testing.T, m metricdata.Metrics, outcome string) { + t.Helper() + + want := map[string]string{ + string(telemetry.AttrPluginID): pluginID, + string(telemetry.AttrPluginType): pluginType, + string(telemetry.AttrOperation): operationLookup, + string(telemetry.AttrErrorType): outcome, + } + + var attrSets []attribute.Set + switch data := m.Data.(type) { + case metricdata.Histogram[float64]: + for _, dp := range data.DataPoints { + attrSets = append(attrSets, dp.Attributes) + } + case metricdata.Sum[int64]: + for _, dp := range data.DataPoints { + attrSets = append(attrSets, dp.Attributes) + } + default: + t.Fatalf("unexpected metric data type for %s: %T", m.Name, m.Data) + } + + if len(attrSets) == 0 { + t.Fatalf("expected at least one data point for %s", m.Name) + } + for key, expected := range want { + value, ok := attrSets[0].Value(attribute.Key(key)) + if !ok { + t.Errorf("%s: missing attribute %q", m.Name, key) + continue + } + if value.AsString() != expected { + t.Errorf("%s: attribute %q = %q, want %q", m.Name, key, value.AsString(), expected) + } + } +} + +// TestLookupAgainstCapturedRegistryResponse runs the plugin against a verbatim +// response captured from a live registry on 31 Aug 2026, reformatted for +// readability with field order and values untouched. +// +// It pins the deployed shape: the data envelope, one flat level with the keys +// array beside participantId rather than under a wrapper, the camelCase field +// names, the "base64:" encoding label, the osid the registry injects into every +// nested object, and the "active" status vocabulary at both levels. The capture +// it replaces described a record wrapped in a "node" object, and this is the +// test that said so. +func TestLookupAgainstCapturedRegistryResponse(t *testing.T) { + t.Parallel() + + const ( + capturedParticipantID = "provider.oan.local" + capturedKeyOSID = "1-d1a4a2b7-7bf5-42f5-bfc2-2c77119c4d64" + capturedParticipantOSID = "1-19087a97-f886-4fe4-bf14-3875437dc6f8" + capturedKey = "w1wDdr/xnO2yQYxdR/88enTkg0B//vVeIkXOfreClUQ=" + capturedURL = "https://provider.oan.local/beckn" + ) + + const captured = `{ + "totalCount": 1, + "data": [ + { + "osUpdatedAt": "2026-08-31T07:36:33.407Z", + "role": "BPP", + "osUpdatedBy": "89bf9fcb-c6f7-4f08-80f9-18f47ce7667d", + "osid": "1-19087a97-f886-4fe4-bf14-3875437dc6f8", + "type": "node", + "osOwner": [ + "89bf9fcb-c6f7-4f08-80f9-18f47ce7667d" + ], + "keys": [ + { + "osUpdatedAt": "2026-08-31T07:36:33.407Z", + "osUpdatedBy": "89bf9fcb-c6f7-4f08-80f9-18f47ce7667d", + "use": "sign", + "keyId": "k1", + "osid": "1-d1a4a2b7-7bf5-42f5-bfc2-2c77119c4d64", + "validFrom": "2026-01-01T00:00:00Z", + "osCreatedAt": "2026-08-31T07:36:33.407Z", + "osCreatedBy": "89bf9fcb-c6f7-4f08-80f9-18f47ce7667d", + "validUntil": "2030-01-01T00:00:00Z", + "alg": "ed25519", + "key": "base64:w1wDdr/xnO2yQYxdR/88enTkg0B//vVeIkXOfreClUQ=", + "status": "active" + } + ], + "participantId": "provider.oan.local", + "baseUrl": "https://provider.oan.local/beckn", + "osCreatedAt": "2026-08-31T07:36:33.407Z", + "name": "OAN provider layer adapter", + "osCreatedBy": "89bf9fcb-c6f7-4f08-80f9-18f47ce7667d", + "status": "active" + } + ] +}` + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, captured) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL, nil) + resolve := func(keyID string) ([]model.Subscription, error) { + return client.Lookup(context.Background(), &model.Subscription{ + Subscriber: model.Subscriber{SubscriberID: capturedParticipantID}, + KeyID: keyID, + }) + } + + results, err := resolve(capturedKeyOSID) + if err != nil { + t.Fatalf("Lookup() returned an unexpected error: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected exactly 1 result, got %d", len(results)) + } + + got := results[0] + if got.SigningPublicKey != capturedKey { + t.Errorf("signing key = %q, want %q (the encoding label must be stripped)", got.SigningPublicKey, capturedKey) + } + if !model.IsKeyStatusUsable(got.Status) { + t.Errorf("an active participant with an active key must be usable, got status %q", got.Status) + } + if got.SubscriberID != capturedParticipantID { + t.Errorf("subscriber id = %q, want %q", got.SubscriberID, capturedParticipantID) + } + if got.KeyID != capturedKeyOSID { + t.Errorf("key id = %q, want %q", got.KeyID, capturedKeyOSID) + } + if got.URL != capturedURL { + t.Errorf("endpoint url = %q, want the captured baseUrl %q", got.URL, capturedURL) + } + if got.Type != "BPP" { + t.Errorf("type = %q, want %q", got.Type, "BPP") + } + if got.EncrPublicKey != "" { + t.Errorf("this record publishes no encryption key, got %q", got.EncrPublicKey) + } + if got.ValidFrom.IsZero() || got.ValidUntil.IsZero() { + t.Error("expected the validity window to be parsed from the key's validFrom/validUntil") + } + + // The record carries two osids -- the participant's and the key's -- and only + // the key's identifies a signing key. Matching the participant's would + // resolve the wrong thing, and would keep resolving it as soon as a second + // key were published. + for _, tc := range []struct{ name, keyID string }{ + {"participant osid", capturedParticipantOSID}, + {"an unrelated osid", "1-00000000-0000-0000-0000-000000000000"}, + } { + mismatched, err := resolve(tc.keyID) + if err != nil { + t.Fatalf("Lookup() with the %s returned an unexpected error: %v", tc.name, err) + } + if len(mismatched) != 0 { + t.Errorf("expected the %s not to resolve a signing key, got %d results", tc.name, len(mismatched)) + } + } +} + +// TestLookupAgainstCurrentRegistryResponse runs the plugin against a verbatim +// response captured from a live registry on 2 Sep 2026, after the Participant +// schema dropped three things from a published key. +// +// It pins the shape a registry writes TODAY, and every difference from the +// capture above is deliberate: +// +// role one of consumer, provider and network. The Beckn acronyms are +// gone; a role now says what a party does. +// keyId absent. Nothing could look one up: the registry assigns an +// osid on write, and that is what a sender names in the +// Authorization header, so the friendly id was decoration. +// use absent. alg carries the purpose -- ed25519 signs -- and the +// plugin already treats a missing use as "may sign". +// key bare base64, no "base64:" label. The label is still tolerated +// by the test above, because a row written before this change +// keeps it forever: the registry is append-only. +func TestLookupAgainstCurrentRegistryResponse(t *testing.T) { + t.Parallel() + + const ( + capturedParticipantID = "provider.oan.dev" + capturedKeyOSID = "1-d5b6c5ee-206c-4529-bf9d-803138ff067a" + capturedKey = "Hcmx3AEVSeHT+1J3ggqhzlbTTtTYP0tQ2eUfotR5lUI=" + capturedURL = "https://provider.oan.dev/beckn" + ) + + const captured = `{ + "totalCount": 1, + "data": [ + { + "osUpdatedAt": "2026-09-02T07:25:29.977Z", + "role": "provider", + "osUpdatedBy": "1e52cfea-50a1-4a64-814e-0d44aaa38c29", + "osid": "1-686a5071-b300-4899-94e2-7f95155ca41d", + "type": "node", + "keys": [ + { + "osUpdatedAt": "2026-09-02T07:25:29.977Z", + "osCreatedAt": "2026-09-02T07:25:29.977Z", + "osUpdatedBy": "1e52cfea-50a1-4a64-814e-0d44aaa38c29", + "osCreatedBy": "1e52cfea-50a1-4a64-814e-0d44aaa38c29", + "validUntil": "2030-01-01T00:00:00Z", + "osid": "1-d5b6c5ee-206c-4529-bf9d-803138ff067a", + "validFrom": "2026-01-01T00:00:00Z", + "alg": "ed25519", + "key": "Hcmx3AEVSeHT+1J3ggqhzlbTTtTYP0tQ2eUfotR5lUI=", + "status": "active" + } + ], + "osOwner": [ + "1e52cfea-50a1-4a64-814e-0d44aaa38c29" + ], + "participantId": "provider.oan.dev", + "baseUrl": "https://provider.oan.dev/beckn", + "osCreatedAt": "2026-09-02T07:25:29.977Z", + "name": "OAN provider layer adapter", + "osCreatedBy": "1e52cfea-50a1-4a64-814e-0d44aaa38c29", + "status": "active" + } + ] +}` + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, captured) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL, nil) + results, err := client.Lookup(context.Background(), &model.Subscription{ + Subscriber: model.Subscriber{SubscriberID: capturedParticipantID}, + KeyID: capturedKeyOSID, + }) + if err != nil { + t.Fatalf("Lookup() returned an unexpected error: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected exactly 1 result, got %d", len(results)) + } + + got := results[0] + // The point of the whole test: a key with no encoding label arrives intact. + // Trimming a prefix that is not there must not disturb the value, because + // what reaches the verifier goes straight to a base64 decoder. + if got.SigningPublicKey != capturedKey { + t.Errorf("signing key = %q, want the bare base64 %q", got.SigningPublicKey, capturedKey) + } + // A key with no "use" still resolves. Were that treated as "unknown, so + // refuse", every key this registry now writes would be unusable. + if !model.IsKeyStatusUsable(got.Status) { + t.Errorf("an active participant with an active key must be usable, got status %q", got.Status) + } + if got.KeyID != capturedKeyOSID { + t.Errorf("key id = %q, want the key's osid %q", got.KeyID, capturedKeyOSID) + } + if got.SubscriberID != capturedParticipantID { + t.Errorf("subscriber id = %q, want %q", got.SubscriberID, capturedParticipantID) + } + if got.URL != capturedURL { + t.Errorf("endpoint url = %q, want the captured baseUrl %q", got.URL, capturedURL) + } + if got.Type != "provider" { + t.Errorf("role = %q, want %q -- not a Beckn acronym", got.Type, "provider") + } + if got.ValidFrom.IsZero() || got.ValidUntil.IsZero() { + t.Error("expected the validity window to be parsed from the key's validFrom/validUntil") + } +} + +// --- cache write and metrics edge cases ----------------------------------- + +// TestCacheResultSkipsWhenDisabled: cacheTTL of 0 means the cache is not +// touched at all, rather than written with a zero TTL whose meaning would +// depend on the cache implementation. +func TestCacheResultSkipsWhenDisabled(t *testing.T) { + t.Parallel() + + cache := &mockCache{} + c := &Client{cache: cache, cacheTTL: 0} + c.cacheResult(context.Background(), "some-key", []model.Subscription{{Status: statusSubscribed}}) + + if cache.setCalls != 0 { + t.Errorf("expected no cache write when disabled, got %d", cache.setCalls) + } +} + +// TestCacheResultSkipsWithoutACache covers the cache plugin being absent +// entirely, which is legal -- the adapter may run without one. +func TestCacheResultSkipsWithoutACache(t *testing.T) { + t.Parallel() + + // Nil cache with a positive TTL: must not panic. + c := &Client{cache: nil, cacheTTL: 30 * time.Second} + c.cacheResult(context.Background(), "some-key", []model.Subscription{{Status: statusSubscribed}}) +} + +// TestCacheResultSurvivesAFailingWrite: the cache is a performance aid, so a +// write failure is logged and swallowed rather than propagated. +func TestCacheResultSurvivesAFailingWrite(t *testing.T) { + t.Parallel() + + cache := &mockCache{setErr: errors.New("cache is down")} + c := &Client{cache: cache, cacheTTL: 30 * time.Second} + c.cacheResult(context.Background(), "some-key", []model.Subscription{{Status: statusSubscribed}}) + + if cache.setCalls != 1 { + t.Errorf("expected the write to be attempted once, got %d", cache.setCalls) + } +} + +// TestCachedSkipsWhenDisabled: with caching off the cache must not even be +// read, so a stale entry from a previous run cannot be served. +func TestCachedSkipsWhenDisabled(t *testing.T) { + t.Parallel() + + cache := &mockCache{ + getFunc: func(ctx context.Context, key string) (string, error) { + t.Error("cache was read despite being disabled") + return "", nil + }, + } + c := &Client{cache: cache, cacheTTL: 0} + + if _, ok := c.cached(context.Background(), otel.Tracer("test"), "some-key"); ok { + t.Error("expected no cache hit when caching is disabled") + } +} + +// TestParseTimeRejectsMalformedValues: a bad timestamp yields "absent" rather +// than an error, since these values are informational and must not fail a +// lookup. +func TestParseTimeRejectsMalformedValues(t *testing.T) { + t.Parallel() + + for _, value := range []string{"", "not-a-timestamp", "2026-08-19", "19/08/2026"} { + if _, ok := parseTime(value); ok { + t.Errorf("expected %q to be rejected as a timestamp", value) + } + } + if _, ok := parseTime("2026-08-19T00:00:00Z"); !ok { + t.Error("expected a valid RFC3339 timestamp to parse") + } +} + +// TestValidateRejectsAMalformedURL covers url.Parse itself failing, which it +// only does for genuinely broken input such as a control character. +func TestValidateRejectsAMalformedURL(t *testing.T) { + t.Parallel() + + if err := validate(&Config{URL: "http://registry:8081/\x7f"}); err == nil { + t.Error("expected a malformed URL to be rejected") + } + for _, u := range []string{"registry:8081", "/api/v1", "registry.example.com"} { + if err := validate(&Config{URL: u}); err == nil { + t.Errorf("expected %q to be rejected for missing scheme or host", u) + } + } +} diff --git a/pkg/plugin/manager.go b/pkg/plugin/manager.go index 9623ae2e..38df7a97 100644 --- a/pkg/plugin/manager.go +++ b/pkg/plugin/manager.go @@ -700,6 +700,47 @@ func (m *Manager) Crawler(ctx context.Context, registry definition.RegistryLooku return crawler, nil } +// Mapper returns a Mapper instance based on the provided configuration. +func (m *Manager) Mapper(ctx context.Context, cfg *Config) (definition.Mapper, error) { + mp, err := provider[definition.MapperProvider](m.plugins, cfg.ID) + if err != nil { + return nil, fmt.Errorf("failed to load provider for %s: %w", cfg.ID, err) + } + mapper, closer, err := mp.New(ctx, cfg.Config) + if err != nil { + return nil, err + } + if closer != nil { + m.closers = append(m.closers, func() { + if err := closer(); err != nil { + log.Errorf(context.Background(), err, "Failed to close mapper plugin") + } + }) + } + return mapper, nil +} + +// ProviderStep returns a ProviderStep instance based on the provided +// configuration, handing it the registry and mapper it needs. +func (m *Manager) ProviderStep(ctx context.Context, registry definition.ProviderRecordLookup, mapper definition.Mapper, cfg *Config) (definition.Step, error) { + pp, err := provider[definition.ProviderStepProvider](m.plugins, cfg.ID) + if err != nil { + return nil, fmt.Errorf("failed to load provider for %s: %w", cfg.ID, err) + } + step, closer, err := pp.New(ctx, registry, mapper, cfg.Config) + if err != nil { + return nil, err + } + if closer != nil { + m.closers = append(m.closers, func() { + if err := closer(); err != nil { + log.Errorf(context.Background(), err, "Failed to close provider step plugin %s", cfg.ID) + } + }) + } + return step, nil +} + // Validator implements handler.PluginManager. func (m *Manager) Validator(ctx context.Context, cfg *Config) (definition.SchemaValidator, error) { panic("unimplemented")