diff --git a/config/mappings/pmfby/grievance.status.yaml b/config/mappings/pmfby/grievance.status.yaml new file mode 100644 index 000000000..f469a3868 --- /dev/null +++ b/config/mappings/pmfby/grievance.status.yaml @@ -0,0 +1,132 @@ +# PMFBY status -> POST /krphapi/FGMS/GetGrievenceTicketsStatus. +# Reads a grievance back by ticket number and phone. Only listed fields are +# returned; the farmer's personal details are dropped. +# Required fields and formats are checked by the Grievance pack. + +request: | + ( + $attributes := beckn.message.contract.commitments[0].commitmentAttributes; + { + "requestorMobileNo": $attributes.applicantMobile, + "GrievenceSupportTicketNo": $attributes.case.ticketNo + } + ) + +# Empty PMFBY fields are dropped. No record, or none naming the ticket asked +# for, is a 400 in our words; any other failure is a 502. PMFBY's message is +# logged -- stripped of the request it quoted -- and never returned. +response: | + ( + /* Constants */ + $PMFBY_SUCCESS_CODE := "1"; + $SCHEME := { "code": "PMFBY", "name": "Pradhan Mantri Fasal Bima Yojana" }; + $INFORMATION_MODE := "OnDemand"; + $COMMITMENT_STATUS_ACTIVE := "ACTIVE"; + $DEFAULT_CASE_STATUS := "UnderReview"; + $CASE_STATUS_CODES := { "open": "Registered", "resolved": "Resolved" }; + $SEASON_NAMES := { "1": "Kharif", "2": "Rabi", "3": "Zaid" }; + /* filedOn is a calendar date, and the day is the one the farmer filed on + -- PMFBY is an Indian portal, so a late-evening filing is that day in + IST and the day before in UTC. */ + $DATE_FORMAT := "[Y0001]-[M01]-[D01]"; + $IST_OFFSET := "+0530"; + + /* e.g. "2026-10-12T11:02:49.000Z" */ + $RFC3339_PATTERN := /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})$/; + $DIGITS_PATTERN := /^[0-9]+$/; + $YEAR_PATTERN := /^[0-9]{4}$/; + + $MSG_NOT_FOUND := "No grievance was found for this ticket number and phone number."; + $MSG_NO_FILING_DATE := "PMFBY returned the grievance without a usable filing date."; + $MSG_UNAVAILABLE := "PMFBY could not return the grievance status right now. Please try again later."; + + $ERROR_REFUSED := { "status": 400, "code": "BIZ_GENERIC_ERROR" }; + $ERROR_UNAVAILABLE := { "status": 502, "code": "NET_DOWNSTREAM_UNAVAILABLE" }; + /* PMFBY fails everything with responseCode "0", so its wording (lower case) + is the only way to tell a caller mistake from a PMFBY fault. */ + $KNOWN_REFUSALS := { "no record found": $MSG_NOT_FOUND }; + + /* Helpers */ + /* PMFBY sends null or "" for missing values. */ + $isText := function($value) { $type($value) = "string" and $value != "" }; + $asText := function($value) { $isText($value) ? $value }; + /* Ids come as numbers or digit strings, and 0 means none. */ + $asId := function($value) { + ($type($value) = "number" or $type($value) = "string") + and $contains($string($value), $DIGITS_PATTERN) and $number($string($value)) != 0 + ? $string($value) + }; + /* Drops an object with no fields instead of sending {}. */ + $omitIfEmpty := function($object) { $exists($keys($object)[0]) ? $object }; + $error := function($kind, $message, $cause) { + { "_error": $merge([$kind, { "message": $message, "cause": $cause }]) } + }; + + /* Inputs */ + $commitment := beckn.message.contract.commitments[0]; + $attributes := $commitment.commitmentAttributes; + $requestedTicket := $string($attributes.case.ticketNo); + /* The record that NAMES the ticket asked for, searched across every record + returned rather than taken from the first. PMFBY reads by phone, so the + list can hold this farmer's other grievances, and a record that does not + name its own ticket cannot be shown as the answer to this one. The + predicate reads a bare record and a list alike. */ + $record := response.responseDynamic[$string(GrievenceSupportTicketNo) = $requestedTicket][0]; + $pmfbyMessage := $type(response.responseMessage) = "string" ? $trim(response.responseMessage); + $refusalMessage := $pmfbyMessage ? $lookup($KNOWN_REFUSALS, $lowercase($pmfbyMessage)); + $isAccepted := $string(response.responseCode) = $PMFBY_SUCCESS_CODE; + $isFound := $isAccepted and ($isText($record.ApplicationNo) or $isText($record.TicketStatus)); + $mappedStatus := $isText($record.TicketStatus) ? $lookup($CASE_STATUS_CODES, $lowercase($trim($record.TicketStatus))); + $caseStatus := $exists($mappedStatus) ? $mappedStatus : $DEFAULT_CASE_STATUS; + $requestYear := $asId($record.RequestYear); + $cropYear := $requestYear and $contains($requestYear, $YEAR_PATTERN) ? $requestYear; + $filedOn := $isText($record.InsertDateTime) and $contains($record.InsertDateTime, $RFC3339_PATTERN) + ? $fromMillis($toMillis($record.InsertDateTime), $DATE_FORMAT, $IST_OFFSET); + /* The officer's answer; "" until there is one. */ + $reply := $asText($isText($record.latestRemark) ? $trim($record.latestRemark)); + /* The complaint rides only with its description: the pack requires one, + so a category on its own would be an answer the caller must refuse. */ + $description := $asText($record.GrievenceDescription); + /* filedOn is required on an answer, and a date is not ours to invent. */ + $isComplete := $isFound and $exists($filedOn); + + /* Output */ + $isComplete + ? { + "context": $merge([beckn.context, { "action": "on_status", "timestamp": $now() }]), + "message": { + "contract": $merge([beckn.message.contract, { + "commitments": [$merge([$commitment, { + "status": { "descriptor": { "code": $COMMITMENT_STATUS_ACTIVE } }, + "commitmentAttributes": { + "@context": $attributes.`@context`, + "@type": $attributes.`@type`, + "informationMode": $INFORMATION_MODE, + "scheme": $SCHEME, + "enrolmentId": $asText($record.ApplicationNo), + "cropYear": $cropYear, + "season": $lookup($SEASON_NAMES, $asId($record.RequestSeason)), + "grievance": $exists($description) ? { + "category": $omitIfEmpty({ "code": $asId($record.TicketCategoryID), "name": $asText($record.TicketCategoryName) }), + "subCategory": $omitIfEmpty({ "code": $asId($record.TicketSubCategoryID), "name": $asText($record.TicketSubCategoryName) }), + "description": $description + }, + "case": { + /* The record's own ticket, not the one asked for: the answer + reports what PMFBY returned, never what the caller said. */ + "ticketNo": $string($record.GrievenceSupportTicketNo), + "status": { "code": $caseStatus }, + "filedOn": $filedOn, + "reply": $reply, + "cropName": $asText($record.CropName) + } + } + }])] + }]) + } + } + : $isFound ? $error($ERROR_UNAVAILABLE, $MSG_NO_FILING_DATE, $pmfbyMessage) + : $isAccepted ? $error($ERROR_REFUSED, $MSG_NOT_FOUND) + : $exists($refusalMessage) ? $error($ERROR_REFUSED, $refusalMessage, $pmfbyMessage) + : $error($ERROR_UNAVAILABLE, $MSG_UNAVAILABLE, $pmfbyMessage) + ) diff --git a/config/mappings/pmfby/grievance.support.yaml b/config/mappings/pmfby/grievance.support.yaml new file mode 100644 index 000000000..a17c5f1fb --- /dev/null +++ b/config/mappings/pmfby/grievance.support.yaml @@ -0,0 +1,114 @@ +# PMFBY support -> POST /krphapi/FGMS/AddKRPHNCIPGrievenceSupportTicket. +# Files a grievance; no OTP. "Grievence" is PMFBY's spelling. +# Required fields and formats are checked by the Grievance pack. +# The two halves cannot share variables, so the default codes are in both. + +request: | + ( + /* Constants */ + $DEFAULT_CATEGORY_CODE := "3"; + $DEFAULT_SUB_CATEGORY_CODE := "10"; + $DEFAULT_RECEIPT_SOURCE_ID := "134306"; + $SEASON_CODES := { "Kharif": "1", "Rabi": "2", "Zaid": "3" }; + + $DATE_FORMAT := "[Y0001]-[M01]-[D01]"; + $IST_OFFSET := "+0530"; + + /* Helpers */ + $orDefault := function($value, $default) { $exists($value) ? $string($value) : $default }; + + /* Inputs */ + $channel := beckn.message.support.channels[0]; + $grievance := $channel.grievance; + $filedAt := $exists($channel.complaintDate) ? $toMillis($channel.complaintDate) : $millis(); + + /* Output */ + { + "requestorMobileNo": $channel.applicantMobile, + "applicationNo": $channel.enrolmentId, + "requestYear": $string($channel.cropYear), + "requestSeason": $lookup($SEASON_CODES, $channel.season), + "ticketCategoryID": $orDefault($grievance.category.code, $DEFAULT_CATEGORY_CODE), + "ticketSubCategoryID": $orDefault($grievance.subCategory.code, $DEFAULT_SUB_CATEGORY_CODE), + "grievenceDescription": $trim($grievance.description), + "complaintDate": $fromMillis($filedAt, $DATE_FORMAT, $IST_OFFSET), + "receiptSourceID": $orDefault($channel.receiptSourceId, $DEFAULT_RECEIPT_SOURCE_ID) + } + ) + +# Echoes the request and adds the ticket. A known PMFBY refusal is a 400 in our +# words; any other failure is a 502. PMFBY's message is logged, never returned. +response: | + ( + /* Constants */ + $DEFAULT_CATEGORY_CODE := "3"; + $DEFAULT_SUB_CATEGORY_CODE := "10"; + $PMFBY_SUCCESS_CODE := "1"; + $PROVIDER_NAME := "PMFBY Grievance Portal"; + $SCHEME := { "code": "PMFBY", "name": "Pradhan Mantri Fasal Bima Yojana" }; + $INFORMATION_MODE := "OnDemand"; + $CASE_STATUS_REGISTERED := "Registered"; + $DATE_FORMAT := "[Y0001]-[M01]-[D01]"; + $IST_OFFSET := "+0530"; + + $MSG_PHONE_MISMATCH := "The phone number does not match the one registered for this application number."; + $MSG_APPLICATION_NOT_FOUND := "No PMFBY application was found for this application number."; + $MSG_UNAVAILABLE := "PMFBY could not register the grievance right now. Please try again later."; + + $ERROR_REFUSED := { "status": 400, "code": "BIZ_GENERIC_ERROR" }; + $ERROR_UNAVAILABLE := { "status": 502, "code": "NET_DOWNSTREAM_UNAVAILABLE" }; + /* PMFBY fails everything with responseCode "0", so its wording (lower case) + is the only way to tell a caller mistake from a PMFBY fault. */ + $KNOWN_REFUSALS := { + "mobile number not matched": $MSG_PHONE_MISMATCH, + "application details not exists": $MSG_APPLICATION_NOT_FOUND + }; + + /* Helpers */ + $orDefault := function($value, $default) { $exists($value) ? $string($value) : $default }; + $error := function($kind, $message, $cause) { + { "_error": $merge([$kind, { "message": $message, "cause": $cause }]) } + }; + + /* Inputs */ + $channel := beckn.message.support.channels[0]; + $grievance := $channel.grievance; + $ticketNo := response.responseDynamic.GrievenceSupportTicketNo; + $pmfbyMessage := $type(response.responseMessage) = "string" ? $trim(response.responseMessage); + $refusalMessage := $pmfbyMessage ? $lookup($KNOWN_REFUSALS, $lowercase($pmfbyMessage)); + $hasTicket := $type($ticketNo) = "number" or ($type($ticketNo) = "string" and $ticketNo != ""); + /* "Ticket Already Raised" also comes back as success with a ticket. */ + $isLodged := $string(response.responseCode) = $PMFBY_SUCCESS_CODE and $hasTicket; + + /* Output */ + $isLodged + ? { + "context": $merge([beckn.context, { "action": "on_support", "timestamp": $now() }]), + "message": { + "support": { + "channels": [{ + "@context": $channel.`@context`, + "@type": $channel.`@type`, + "informationMode": $INFORMATION_MODE, + "provider": { "id": $channel.provider.id, "descriptor": { "name": $PROVIDER_NAME } }, + "scheme": $SCHEME, + "enrolmentId": $channel.enrolmentId, + "grievance": { + "category": { "code": $orDefault($grievance.category.code, $DEFAULT_CATEGORY_CODE) }, + "subCategory": { "code": $orDefault($grievance.subCategory.code, $DEFAULT_SUB_CATEGORY_CODE) }, + "description": $trim($grievance.description) + }, + "case": { + "ticketNo": $string($ticketNo), + "status": { "code": $CASE_STATUS_REGISTERED }, + /* Today, not the caller's complaintDate: a status read reports + PMFBY's InsertDateTime, which is when the row was written. */ + "filedOn": $fromMillis($millis(), $DATE_FORMAT, $IST_OFFSET) + } + }] + } + } + } + : $exists($refusalMessage) ? $error($ERROR_REFUSED, $refusalMessage, $pmfbyMessage) + : $error($ERROR_UNAVAILABLE, $MSG_UNAVAILABLE, $pmfbyMessage) + ) diff --git a/config/provider-adapter.yaml b/config/provider-adapter.yaml index e2684cb31..4c94f991d 100644 --- a/config/provider-adapter.yaml +++ b/config/provider-adapter.yaml @@ -505,6 +505,32 @@ modules: # concurrency for this capability. searchConcurrency: 1 + # ------------------------------------------------------------------ + # Grievance -- support files a grievance, status reads it back. + # ------------------------------------------------------------------ + - id: Grievance + config: + bindingKeys: "pmfby|openagrinet:Grievance" + + # Paired by position, tried in order: status names the binding on + # its contract, support on its channel. + providerIdAt: "message.contract.commitments[].offer.provider.id, message.support.channels[].provider.id" + capabilityCodeAt: "message.contract.commitments[].commitmentAttributes.@type, message.support.channels[].@type" + + # Logs in at PMFBY's NICUsersLogin and sends the token bare in + # Authorization, held until the login reply's validTo. + # tokenUrl is set per deployment. + pmfby: + authScheme: tokenHeader + tokenUrl: <> + tokenUserField: appAccessUID + tokenUserEnv: PMFBY_ACCESS_UID + tokenSecretField: appAccessPWD + tokenSecretEnv: PMFBY_ACCESS_PWD + tokenResponseField: responseDynamic.token.Token + tokenExpiryField: responseDynamic.token.validTo + headerName: Authorization + # 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 @@ -516,6 +542,7 @@ modules: - MandiPrice # its binding key, or pass through - KnowledgeAdvisory # its binding key, or pass through - AgricultureFacility # its binding key, or pass through + - Grievance # its binding key, or pass through - signAck # signs whatever the step answered with # ---------------------------------------------------------------------------- diff --git a/install/build-plugins.sh b/install/build-plugins.sh index 4e505cc05..519a22822 100755 --- a/install/build-plugins.sh +++ b/install/build-plugins.sh @@ -34,6 +34,7 @@ plugins=( "jsonmapper" "WeatherObservation" "MandiPrice" + "Grievance" "KnowledgeAdvisory" "AgricultureFacility" "manifestloader" diff --git a/pkg/plugin/implementation/Grievance/Grievance.go b/pkg/plugin/implementation/Grievance/Grievance.go new file mode 100644 index 000000000..7073e617d --- /dev/null +++ b/pkg/plugin/implementation/Grievance/Grievance.go @@ -0,0 +1,20 @@ +// Package Grievance serves the network's grievance capabilities: filing a +// grievance against a government scheme and checking on it. Provider details +// live in the registry, the mappings and the adapter config, not here. +package Grievance + +import ( + "context" + + "github.com/beckn-one/beckn-onix/pkg/plugin/definition" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common" +) + +// Config is the shared step config. +type Config = common.Config + +// New creates the grievance step. It has no prerequisites. +func New(ctx context.Context, registry definition.ProviderRecordLookup, mapper definition.Mapper, + cfg *Config) (definition.Step, func() error, error) { + return common.New(ctx, registry, mapper, nil, cfg) +} diff --git a/pkg/plugin/implementation/Grievance/cmd/plugin.go b/pkg/plugin/implementation/Grievance/cmd/plugin.go new file mode 100644 index 000000000..09b0c8498 --- /dev/null +++ b/pkg/plugin/implementation/Grievance/cmd/plugin.go @@ -0,0 +1,55 @@ +// Command plugin builds the grievance provider step as a loadable plugin. The +// .so's filename is the id a deployment names in providerSteps. +package main + +import ( + "context" + "errors" + "fmt" + + "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/Grievance" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common" +) + +// grievanceProvider implements definition.ProviderStepProvider. +type grievanceProvider struct{} + +// newStepFunc creates a new step. Indirected for tests. +var newStepFunc = Grievance.New + +// parseConfig turns the plugin config map into a typed Config. Every provider +// step reads the same settings the same way, so the reading is shared; what is +// defaulted and what is refused are left to Grievance.New. +func (p grievanceProvider) parseConfig(config map[string]string) (*Grievance.Config, error) { + return common.ParseStepConfig(config) +} + +// New creates a new grievance provider step instance. +func (p grievanceProvider) 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 grievance configuration") + return nil, nil, fmt.Errorf("failed to parse grievance configuration: %w", err) + } + + step, closer, err := newStepFunc(ctx, registry, mapper, cfg) + if err != nil { + log.Errorf(ctx, err, "Failed to create grievance step") + return nil, nil, err + } + + log.Infof(ctx, "Grievance step created successfully") + return step, closer, nil +} + +// Provider is the exported plugin instance. +var Provider = grievanceProvider{} + +// Compile-time check that Provider satisfies the interface. +var _ definition.ProviderStepProvider = Provider diff --git a/pkg/plugin/implementation/Grievance/cmd/plugin_test.go b/pkg/plugin/implementation/Grievance/cmd/plugin_test.go new file mode 100644 index 000000000..ec45f8fd4 --- /dev/null +++ b/pkg/plugin/implementation/Grievance/cmd/plugin_test.go @@ -0,0 +1,161 @@ +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/Grievance" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common" +) + +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 +} + +const ( + pmfbyBindingKey = "pmfby|openagrinet:Grievance" + providerIDAt = "message.contract.commitments[].offer.provider.id, message.support.channels[].provider.id" + capabilityCodeAt = "message.contract.commitments[].commitmentAttributes.@type, message.support.channels[].@type" +) + +// pmfbyConfig is the block config/provider-adapter.yaml ships, as the plugin +// receives it: flattened. +func pmfbyConfig() map[string]string { + return map[string]string{ + "bindingKeys": pmfbyBindingKey, + "providerIdAt": providerIDAt, + "capabilityCodeAt": capabilityCodeAt, + "authScheme-pmfby": "tokenHeader", + "tokenUrl-pmfby": "https://pmfby.example/krphapi/FGMS/NICUsersLogin", + "tokenUserField-pmfby": "appAccessUID", + "tokenUserEnv-pmfby": "PMFBY_USER", + "tokenSecretField-pmfby": "appAccessPWD", + "tokenSecretEnv-pmfby": "PMFBY_PASSWORD", + "tokenResponseField-pmfby": "token", + "tokenTtl-pmfby": "10m", + "headerName-pmfby": "Authorization", + } +} + +func TestParseConfig_PMFBYBlock_ReadsEverySetting(t *testing.T) { + got, err := grievanceProvider{}.parseConfig(pmfbyConfig()) + if err != nil { + t.Fatal(err) + } + want := &Grievance.Config{ + BindingKeys: []string{pmfbyBindingKey}, + ProviderIDAt: providerIDAt, + CapabilityCodeAt: capabilityCodeAt, + AuthByProvider: map[string]*common.AuthProfile{"pmfby": { + Provider: "pmfby", Scheme: "tokenHeader", TokenURL: "https://pmfby.example/krphapi/FGMS/NICUsersLogin", + TokenUserField: "appAccessUID", TokenUserEnv: "PMFBY_USER", + TokenSecretName: "appAccessPWD", TokenSecretEnv: "PMFBY_PASSWORD", + TokenResponseField: "token", TokenTTLRaw: "10m", HeaderName: "Authorization", + }}, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("parseConfig() = %+v, want %+v", got, want) + } +} + +func TestParseConfig_ResponseCap_ValidatesValue(t *testing.T) { + for raw, wantErr := range map[string]string{ + "lots": "invalid maxResponseBytes value 'lots'", + "0": "maxResponseBytes must be positive", + "-1": "maxResponseBytes must be positive", + "": "", + "2048": "", + } { + t.Run(raw, func(t *testing.T) { + _, err := grievanceProvider{}.parseConfig(map[string]string{"maxResponseBytes": raw}) + if wantErr == "" && err != nil { + t.Errorf("parseConfig() = %v, want no error", err) + } + if wantErr != "" && (err == nil || !strings.Contains(err.Error(), wantErr)) { + t.Errorf("parseConfig() = %v, want %q", err, wantErr) + } + }) + } +} + +func TestParseConfig_MisspelledAuthSetting_Refused(t *testing.T) { + config := pmfbyConfig() + config["tokenUrll-pmfby"] = "https://typo.example" + if _, err := (grievanceProvider{}).parseConfig(config); err == nil { + t.Error("parseConfig() accepted a misspelled credential setting") + } +} + +func TestNew_ShippedPMFBYConfig_BuildsStep(t *testing.T) { + step, closer, err := grievanceProvider{}.New(context.Background(), stubRegistry{}, stubMapper{}, pmfbyConfig()) + if err != nil { + t.Fatalf("New() = %v, want the shipped config to load", err) + } + if step == nil || closer == nil { + t.Fatal("New() returned no step or no closer") + } + if err := closer(); err != nil { + t.Errorf("closer() = %v", err) + } +} + +func TestNew_InvalidConfig_Refused(t *testing.T) { + for name, edit := range map[string]func(map[string]string){ + "no binding keys": func(c map[string]string) { delete(c, "bindingKeys") }, + "providerIdAt alone": func(c map[string]string) { delete(c, "capabilityCodeAt") }, + "capabilityCodeAt alone": func(c map[string]string) { delete(c, "providerIdAt") }, + "unpaired path lists": func(c map[string]string) { c["capabilityCodeAt"] = "a.@type" }, + "blank listed path": func(c map[string]string) { c["providerIdAt"] = "a.id, " }, + "no auth block for pmfby": func(c map[string]string) { delete(c, "authScheme-pmfby") }, + "unknown auth scheme": func(c map[string]string) { c["authScheme-pmfby"] = "loginHeader" }, + "tokenHeader without ttl": func(c map[string]string) { delete(c, "tokenTtl-pmfby") }, + "tokenHeader without login": func(c map[string]string) { delete(c, "tokenUrl-pmfby") }, + "tokenHeader without header": func(c map[string]string) { delete(c, "headerName-pmfby") }, + "auth for unserved provider": func(c map[string]string) { c["authScheme-pmkisan"] = "none" }, + "bad response cap": func(c map[string]string) { c["maxResponseBytes"] = "lots" }, + } { + t.Run(name, func(t *testing.T) { + config := pmfbyConfig() + edit(config) + if _, _, err := (grievanceProvider{}).New(context.Background(), stubRegistry{}, stubMapper{}, config); err == nil { + t.Error("New() accepted an invalid config") + } + }) + } +} + +func TestNew_NilContext_Refused(t *testing.T) { + //nolint:staticcheck // deliberately passing a nil context to assert the guard. + if _, _, err := (grievanceProvider{}).New(nil, stubRegistry{}, stubMapper{}, pmfbyConfig()); err == nil { + t.Error("New() accepted a nil context") + } +} + +func TestNew_StepConstructorFails_PropagatesError(t *testing.T) { + original := newStepFunc + wanted := errors.New("upstream refused the config") + newStepFunc = func(context.Context, definition.ProviderRecordLookup, definition.Mapper, + *Grievance.Config) (definition.Step, func() error, error) { + return nil, nil, wanted + } + defer func() { newStepFunc = original }() + + if _, _, err := (grievanceProvider{}).New(context.Background(), stubRegistry{}, stubMapper{}, pmfbyConfig()); !errors.Is(err, wanted) { + t.Errorf("New() = %v, want it to wrap %v", err, wanted) + } +} diff --git a/pkg/plugin/implementation/Grievance/mappings_test.go b/pkg/plugin/implementation/Grievance/mappings_test.go new file mode 100644 index 000000000..21188b6af --- /dev/null +++ b/pkg/plugin/implementation/Grievance/mappings_test.go @@ -0,0 +1,1061 @@ +package Grievance_test + +// mappings_test.go runs the shipped PMFBY mappings through the real step, +// against a stub PMFBY that logs in, files a ticket and reads it back. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "reflect" + "regexp" + "strings" + "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/Grievance" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common/util" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/jsonmapper" +) + +const ( + mappingsDir = "../../../../config/mappings/pmfby" + shippedBindingKey = "pmfby|openagrinet:Grievance" + stubToken = "stub-login-token" + knownTicket = "100626000099001" + phone = "9876543210" + insertPath = "/krphapi/FGMS/AddKRPHNCIPGrievenceSupportTicket" + statusPath = "/krphapi/FGMS/GetGrievenceTicketsStatus" + loginPath = "/krphapi/FGMS/NICUsersLogin" +) + +// knownTicketRecord is PMFBY's record for knownTicket, personal data included. +// It carries the category by name only. +const knownTicketRecord = `{"responseCode":"1","responseMessage":"Fetched","recordCount":1,"responseDynamic":{ + "GrievenceSupportTicketNo":"` + knownTicket + `", + "GrievenceSupportTicketID":109301,"TicketStatus":"Under Review", + "ComplaintDate":"2026-10-04","InsertDateTime":"2026-10-04T10:00:00.000Z","ApplicationNo":"040108251010160770605", + "GrievenceDescription":"Claim not received", + "TicketCategoryName":"Claim","TicketSubCategoryName":"Claim not received","CropName":"Paddy", + "FarmerName":"Ramesh","StateMasterName":"Karnataka","DistrictMasterName":"Mysuru", + "InsuranceCompany":"Insurer"}}` + +// pmfbyStub stands in for PMFBY's FGMS. Zero fields give the ordinary answer. +type pmfbyStub struct { + down bool // 503 on every call but the login + loginDown bool // 401 on the login + answers map[string]string // overrides the answer for a path + statusFor map[string]int // answers a path with this status and no body + calls map[string]int // per path, login included + bodies map[string]map[string]any +} + +func (p *pmfbyStub) ServeHTTP(w http.ResponseWriter, r *http.Request) { + p.calls[r.URL.Path]++ + if r.URL.Path == loginPath { + var login map[string]string + _ = json.NewDecoder(r.Body).Decode(&login) + if p.loginDown || login["appAccessUID"] != "user" || login["appAccessPWD"] != "secret" { + w.WriteHeader(http.StatusUnauthorized) + return + } + fmt.Fprintf(w, `{"responseCode":"1","responseDynamic":{"token":{"Token":%q}}}`, stubToken) + return + } + if p.down { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + if r.Header.Get("Authorization") != stubToken { + w.WriteHeader(http.StatusUnauthorized) + return + } + if status, set := p.statusFor[r.URL.Path]; set { + w.WriteHeader(status) + return + } + raw, _ := io.ReadAll(r.Body) + var body map[string]any + _ = json.Unmarshal(raw, &body) + p.bodies[r.URL.Path] = body + + if answer, overridden := p.answers[r.URL.Path]; overridden { + fmt.Fprint(w, answer) + return + } + switch r.URL.Path { + case insertPath: + fmt.Fprintf(w, `{"responseCode":"1","responseMessage":"Ticket created","responseDynamic":{"GrievenceSupportTicketNo":%q,"GrievenceSupportTicketID":8842317}}`, knownTicket) + case statusPath: + if body["GrievenceSupportTicketNo"] != knownTicket { + fmt.Fprint(w, `{"responseCode":"0","responseMessage":"No record found","responseDynamic":[]}`) + return + } + fmt.Fprint(w, knownTicketRecord) + default: + w.WriteHeader(http.StatusNotFound) + } +} + +type stubRegistry struct{ plan *model.ProviderRecord } + +func (s *stubRegistry) ProviderRecord(context.Context, string) (*model.ProviderRecord, error) { + return s.plan, nil +} + +// harness is the real step over the shipped mappings, wired to a stub PMFBY. +type harness struct { + pmfby *pmfbyStub + step definition.Step +} + +// newHarness builds the step. statusRetries is status's retryMax (support +// gets 0); tweak edits the config first. +func newHarness(t *testing.T, pmfby *pmfbyStub, statusRetries int, tweak ...func(*Grievance.Config)) *harness { + t.Helper() + t.Setenv("PMFBY_USER", "user") + t.Setenv("PMFBY_PASSWORD", "secret") + pmfby.calls = map[string]int{} + pmfby.bodies = map[string]map[string]any{} + + mappings := httptest.NewServer(http.FileServer(http.Dir(mappingsDir))) + t.Cleanup(mappings.Close) + upstream := httptest.NewServer(pmfby) + t.Cleanup(upstream.Close) + + mapper, closeMapper, err := jsonmapper.New(context.Background(), &jsonmapper.Config{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = closeMapper() }) + + action := func(path, file string, retries int) model.ActionPlan { + return model.ActionPlan{Method: http.MethodPost, Path: path, + Mappings: mappings.URL + "/" + file, RetryMax: retries} + } + registry := &stubRegistry{plan: &model.ProviderRecord{ + BindingKey: shippedBindingKey, ParticipantID: "pmfby", CapabilityCode: "openagrinet:Grievance", + BaseURL: upstream.URL, + Actions: map[string]model.ActionPlan{ + "support": action(insertPath, "grievance.support.yaml", 0), + "status": action(statusPath, "grievance.status.yaml", statusRetries), + }, + }} + + cfg := &Grievance.Config{ + BindingKeys: []string{shippedBindingKey}, + ProviderIDAt: "message.contract.commitments[].offer.provider.id, message.support.channels[].provider.id", + CapabilityCodeAt: "message.contract.commitments[].commitmentAttributes.@type, message.support.channels[].@type", + AuthByProvider: map[string]*common.AuthProfile{"pmfby": { + Scheme: util.AuthSchemeTokenHeader, TokenURL: upstream.URL + loginPath, + TokenUserField: "appAccessUID", TokenUserEnv: "PMFBY_USER", + TokenSecretName: "appAccessPWD", TokenSecretEnv: "PMFBY_PASSWORD", + TokenResponseField: "responseDynamic.token.Token", TokenTTLRaw: "10m", HeaderName: "Authorization", + }}, + } + for _, apply := range tweak { + apply(cfg) + } + step, closeStep, err := Grievance.New(context.Background(), registry, mapper, cfg) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = closeStep() }) + return &harness{pmfby: pmfby, step: step} +} + +// answer is the part of a Beckn reply these tests read. +type answer struct { + Context map[string]any `json:"context"` + Message struct { + Contract struct { + ID string `json:"id"` + Commitments []struct { + Status struct { + Descriptor struct { + Code string `json:"code"` + } `json:"descriptor"` + } `json:"status"` + Offer map[string]any `json:"offer"` + CommitmentAttributes map[string]any `json:"commitmentAttributes"` + } `json:"commitments"` + } `json:"contract"` + Support struct { + Channels []map[string]any `json:"channels"` + } `json:"support"` + } `json:"message"` +} + +// channel is the case record on an on_support. +func (a *answer) channel() map[string]any { + return a.Message.Support.Channels[0] +} + +func (a *answer) attributes() map[string]any { + return a.Message.Contract.Commitments[0].CommitmentAttributes +} + +func (a *answer) status() string { + return a.Message.Contract.Commitments[0].Status.Descriptor.Code +} + +// band reads a nested object: the grievance, the case, or its status. +func band(of map[string]any, name string) map[string]any { + inner, _ := of[name].(map[string]any) + return inner +} + +// send runs body through the step. A nil answer means the step passed it on. +func (h *harness) send(t *testing.T, body []byte) (*answer, error) { + t.Helper() + stepCtx := &model.StepContext{Context: t.Context(), Body: body} + if err := h.step.Run(stepCtx); err != nil { + return nil, err + } + if len(stepCtx.ResponseBody) == 0 { + return nil, nil + } + var got answer + if err := json.Unmarshal(stepCtx.ResponseBody, &got); err != nil { + t.Fatalf("answer is not JSON: %v\n%s", err, stepCtx.ResponseBody) + } + return &got, nil +} + +// mustSend is send for a request that has to succeed. +func (h *harness) mustSend(t *testing.T, body []byte) *answer { + t.Helper() + got, err := h.send(t, body) + if err != nil { + t.Fatalf("Run() = %v, want success", err) + } + if got == nil { + t.Fatal("the step passed the request on instead of answering it") + } + if len(got.Message.Support.Channels) > 0 { + assertPackShape(t, got.channel()) + } + if len(got.Message.Contract.Commitments) > 0 { + assertPackShape(t, got.attributes()) + } + return got +} + +// calendarDate is what the pack's CalendarDate accepts, and the whole of it. +var calendarDate = regexp.MustCompile(`^[0-9]{4}-[0-9]{2}-[0-9]{2}$`) + +// assertPackShape checks the rules the Grievance pack enforces that a mapping +// can quietly break, because nothing in this harness validates against it: the +// step answers, and the pack is read by the peer that receives the answer. +// +// Only the hard ones are here -- a required member left out, or a date written +// as an instant. Both have shipped before, and neither shows up in an +// assertion about the field the test is actually about. +func assertPackShape(t *testing.T, attributes map[string]any) { + t.Helper() + if attributes["informationMode"] != "OnDemand" { + return + } + // case: required [ticketNo, status, filedOn] on an answer. + if record := band(attributes, "case"); record != nil { + for _, field := range []string{"ticketNo", "status", "filedOn"} { + if _, present := record[field]; !present { + t.Errorf("case.%s is missing; the pack requires it on an answer", field) + } + } + if filed, ok := record["filedOn"].(string); ok && !calendarDate.MatchString(filed) { + t.Errorf("case.filedOn = %q, want a calendar date (the pack's CalendarDate)", filed) + } + } + // Complaint: required [description]. + if complaint := band(attributes, "grievance"); complaint != nil { + if _, present := complaint["description"]; !present { + t.Errorf("grievance = %v, want a description or no grievance band at all", complaint) + } + } +} + +const packContext = "https://openagrinet.github.io/network-specs/api-schemas/Grievance/v0.1/context.jsonld" + +// scheme is what every PMFBY payload names. +var scheme = map[string]any{"code": "PMFBY", "name": "Pradhan Mantri Fasal Bima Yojana"} + +// provider is the portal a support channel names. +var provider = map[string]any{"id": "pmfby", "descriptor": map[string]any{"name": "PMFBY Grievance Portal"}} + +func becknContext(action string) map[string]any { + return map[string]any{"version": "2.0.0", "action": action, "transactionId": "txn-1", "messageId": "msg-1"} +} + +// request builds a status payload whose one commitment carries attributes. +func request(action string, attributes map[string]any) []byte { + attributes["@context"] = packContext + attributes["@type"] = "openagrinet:Grievance" + attributes["informationMode"] = "OnDemand" + if _, named := attributes["scheme"]; !named { + attributes["scheme"] = scheme + } + body, _ := json.Marshal(map[string]any{ + "context": becknContext(action), + "message": map[string]any{"contract": map[string]any{ + "id": "contract-1", + "commitments": []any{map[string]any{ + "status": map[string]any{"descriptor": map[string]any{"code": "ACTIVE"}}, + "resources": []any{map[string]any{"id": "res:pmfby:grievance"}}, + "offer": map[string]any{"id": "off:pmfby:grievance", "provider": map[string]any{"id": "pmfby"}}, + "commitmentAttributes": attributes, + }}, + }}, + }) + return body +} + +// support builds a support request from flat fields: categoryCode, +// subCategoryCode and description under grievance, the rest on channels[0]. +func support(fields map[string]any) []byte { + channel := map[string]any{"@context": packContext, "@type": "openagrinet:Grievance", + "informationMode": "OnDemand", "scheme": scheme, "provider": provider} + body := map[string]any{"channels": []any{channel}} + grievance := map[string]any{} + for field, value := range fields { + switch field { + case "categoryCode": + grievance["category"] = map[string]any{"code": value, "name": "Claim"} + case "subCategoryCode": + grievance["subCategory"] = map[string]any{"code": value, "name": "Claim not received"} + case "description": + grievance["description"] = value + default: + channel[field] = value + } + } + if len(grievance) > 0 { + channel["grievance"] = grievance + } + raw, _ := json.Marshal(map[string]any{"context": becknContext("support"), "message": map[string]any{"support": body}}) + return raw +} + +func supportFields() map[string]any { + return map[string]any{ + "enrolmentId": "040108251010160770605", "categoryCode": "3", "subCategoryCode": "10", + "description": " Claim not received ", "applicantMobile": phone, + "cropYear": "2025", "season": "Kharif", + } +} + +func statusAttributes() map[string]any { + return map[string]any{"applicantMobile": phone, "case": map[string]any{"ticketNo": knownTicket}} +} + +// refusal is the HTTP status and Beckn error a failed request is answered with. +func refusal(t *testing.T, err error) (int, *model.Error) { + t.Helper() + var invalid *model.SchemaValidationErr + var coded *model.CodedErr + switch { + case errors.As(err, &invalid): + return http.StatusBadRequest, invalid.BecknError() + case errors.As(err, &coded): + return coded.HTTPStatus(), coded.BecknError() + } + t.Fatalf("error = %v (%T), want a refusal", err, err) + return 0, nil +} + +// assertCoded fails unless err is answered with this status and code. +func assertCoded(t *testing.T, err error, status int, code string) { + t.Helper() + if got, refused := refusal(t, err); got != status || refused.Code != code { + t.Errorf("error = %d %s (%v), want %d %s", got, refused.Code, err, status, code) + } +} + +// assertRefused fails unless err is a 400 BIZ_GENERIC_ERROR carrying message. +func assertRefused(t *testing.T, err error, message string) { + t.Helper() + if status, refused := refusal(t, err); status != http.StatusBadRequest || + refused.Code != "BIZ_GENERIC_ERROR" || refused.Message != message { + t.Errorf("error = %d %+v, want 400 BIZ_GENERIC_ERROR %q", status, refused, message) + } +} + +// assertFailedUpstream fails unless err is a 502 NET_DOWNSTREAM_UNAVAILABLE carrying message. +func assertFailedUpstream(t *testing.T, err error, message string) { + t.Helper() + if status, refused := refusal(t, err); status != http.StatusBadGateway || + refused.Code != util.CodeUpstreamUnavailable || refused.Message != message { + t.Errorf("error = %d %+v, want 502 %s %q", status, refused, util.CodeUpstreamUnavailable, message) + } +} + +// assertFiledToday fails unless value is today's date in IST, which is the day +// the farmer filed on and the day PMFBY will report back. +func assertFiledToday(t *testing.T, value any) { + t.Helper() + if value != todayIST() { + t.Errorf("filedOn = %v, want today in IST (%s)", value, todayIST()) + } +} + +// todayIST is the date PMFBY expects complaintDate in. +func todayIST() string { + return time.Now().In(time.FixedZone("IST", 5*3600+1800)).Format("2006-01-02") +} + +// --- support --------------------------------------------------------------- + +func TestSupport_Lodged_SendsPMFBYFieldsAndReturnsTicket(t *testing.T) { + h := newHarness(t, &pmfbyStub{}, 0) + got := h.mustSend(t, support(supportFields())) + + // Every value goes as a string, the season as its code. + want := map[string]any{ + "requestorMobileNo": phone, "applicationNo": "040108251010160770605", + "requestYear": "2025", "requestSeason": "1", "ticketCategoryID": "3", "ticketSubCategoryID": "10", + "grievenceDescription": "Claim not received", "complaintDate": todayIST(), "receiptSourceID": "134306", + } + sent := h.pmfby.bodies[insertPath] + for field, value := range want { + if sent[field] != value { + t.Errorf("PMFBY %s = %v, want %v", field, sent[field], value) + } + } + if len(sent) != len(want) { + t.Errorf("PMFBY was sent %d fields (%v), want exactly %d", len(sent), sent, len(want)) + } + + if got.Context["action"] != "on_support" || got.Context["transactionId"] != "txn-1" { + t.Errorf("context = %v, want on_support on the request's transaction", got.Context) + } + // enrolmentId and the grievance are echoed; a name beside a code is dropped. + channel := got.channel() + grievance := band(channel, "grievance") + if channel["enrolmentId"] != "040108251010160770605" || len(band(grievance, "category")) != 1 || + band(grievance, "category")["code"] != "3" || band(grievance, "subCategory")["code"] != "10" || + grievance["description"] != "Claim not received" { + t.Errorf("channel = %v, want enrolmentId and grievance echoed, the description trimmed", channel) + } + filed := band(channel, "case") + status := band(filed, "status") + assertFiledToday(t, filed["filedOn"]) + if filed["ticketNo"] != knownTicket || len(status) != 1 || status["code"] != "Registered" || + band(channel, "provider")["id"] != "pmfby" || + channel["informationMode"] != "OnDemand" || channel["scheme"] == nil { + t.Errorf("channel = %v", channel) + } + for _, private := range []string{"applicantMobile", "ticketId"} { + if _, echoed := channel[private]; echoed { + t.Errorf("%s must not be echoed", private) + } + } +} + +// The pack holds ticketNo as a string of digits, whichever PMFBY sends. +func TestSupport_NumericTicketNo_ReturnedAsString(t *testing.T) { + h := newHarness(t, &pmfbyStub{answers: map[string]string{ + insertPath: fmt.Sprintf(`{"responseCode":1,"responseDynamic":{"GrievenceSupportTicketNo":%s}}`, knownTicket)}}, 0) + if got := band(h.mustSend(t, support(supportFields())).channel(), "case")["ticketNo"]; got != knownTicket { + t.Errorf("ticketNo = %#v, want %q", got, knownTicket) + } +} + +func TestSupport_NumericCropYear_SentAsString(t *testing.T) { + h := newHarness(t, &pmfbyStub{}, 0) + fields := supportFields() + fields["cropYear"] = 2025 + h.mustSend(t, support(fields)) + if got := h.pmfby.bodies[insertPath]["requestYear"]; got != "2025" { + t.Errorf("requestYear = %#v, want the string \"2025\" PMFBY takes", got) + } +} + +func TestSupport_EachSeason_SendsItsCode(t *testing.T) { + for season, code := range map[string]string{"Kharif": "1", "Rabi": "2", "Zaid": "3"} { + t.Run(season, func(t *testing.T) { + h := newHarness(t, &pmfbyStub{}, 0) + fields := supportFields() + fields["season"] = season + h.mustSend(t, support(fields)) + if got := h.pmfby.bodies[insertPath]["requestSeason"]; got != code { + t.Errorf("requestSeason = %v, want %v", got, code) + } + }) + } +} + +func TestSupport_CategoryCodes_SentAsGiven(t *testing.T) { + for codes, want := range map[[2]string][2]string{{"3", "10"}: {"3", "10"}, {"12", "5"}: {"12", "5"}, {"1", "1"}: {"1", "1"}} { + t.Run(codes[0]+"/"+codes[1], func(t *testing.T) { + h := newHarness(t, &pmfbyStub{}, 0) + fields := supportFields() + fields["categoryCode"], fields["subCategoryCode"] = codes[0], codes[1] + h.mustSend(t, support(fields)) + sent := h.pmfby.bodies[insertPath] + if sent["ticketCategoryID"] != want[0] || sent["ticketSubCategoryID"] != want[1] { + t.Errorf("sent %v / %v, want %v", sent["ticketCategoryID"], sent["ticketSubCategoryID"], want) + } + }) + } +} + +// Without category codes, PMFBY's published pair is sent and echoed. +func TestSupport_NoCategoryCodes_SendsAndEchoesDefaults(t *testing.T) { + h := newHarness(t, &pmfbyStub{}, 0) + fields := supportFields() + delete(fields, "categoryCode") + delete(fields, "subCategoryCode") + got := h.mustSend(t, support(fields)) + sent := h.pmfby.bodies[insertPath] + if sent["ticketCategoryID"] != "3" || sent["ticketSubCategoryID"] != "10" { + t.Errorf("sent %v / %v, want 3 / 10", sent["ticketCategoryID"], sent["ticketSubCategoryID"]) + } + grievance := band(got.channel(), "grievance") + if band(grievance, "category")["code"] != "3" || band(grievance, "subCategory")["code"] != "10" { + t.Errorf("grievance = %v, want the defaults echoed", grievance) + } +} + +// The pack lists provider and scheme.name as coming back; the caller may send +// only the ids. +func TestSupport_BareProviderAndScheme_ReturnsPortalNameAndSchemeName(t *testing.T) { + fields := supportFields() + fields["provider"] = map[string]any{"id": "pmfby", "availableAt": []any{}} + fields["scheme"] = map[string]any{"code": "PMFBY"} + channel := newHarness(t, &pmfbyStub{}, 0).mustSend(t, support(fields)).channel() + if got := band(channel, "provider"); !reflect.DeepEqual(got, provider) { + t.Errorf("provider = %v, want %v", got, provider) + } + if got := band(channel, "scheme"); !reflect.DeepEqual(got, scheme) { + t.Errorf("scheme = %v, want %v", got, scheme) + } +} + +// complaintDate goes to PMFBY as its IST date and comes back as filedOn in UTC. +// complaintDate goes to PMFBY as its IST date; filedOn stays the lodge time, +// so a later status read reports the same moment. +func TestSupport_CallerDateAndSource_SentInsteadOfDefaults(t *testing.T) { + for given, want := range map[string]string{ + "2026-09-20": "2026-09-20", + "2026-09-20T04:30:00Z": "2026-09-20", + "2026-09-21T01:00:00+05:30": "2026-09-21", + } { + t.Run(given, func(t *testing.T) { + h := newHarness(t, &pmfbyStub{}, 0) + fields := supportFields() + fields["complaintDate"], fields["receiptSourceId"] = given, "200001" + got := h.mustSend(t, support(fields)) + sent := h.pmfby.bodies[insertPath] + if sent["complaintDate"] != want || sent["receiptSourceID"] != "200001" { + t.Errorf("PMFBY was sent %v / %v, want %s and the caller's source", sent["complaintDate"], sent["receiptSourceID"], want) + } + assertFiledToday(t, band(got.channel(), "case")["filedOn"]) + }) + } +} + +// "Ticket Already Raised" is a success: the existing ticket comes back. +func TestSupport_TicketAlreadyRaised_ReturnsTheExistingTicket(t *testing.T) { + h := newHarness(t, &pmfbyStub{answers: map[string]string{insertPath: `{"responseCode":"1","responseMessage":"Ticket Already Raised for !", + "responseDynamic":{"GrievenceSupportTicketNo":"121026000099003","GrievenceSupportTicketID":13}}`}}, 0) + filed := band(h.mustSend(t, support(supportFields())).channel(), "case") + if filed["ticketNo"] != "121026000099003" || band(filed, "status")["code"] != "Registered" { + t.Errorf("case = %v, want the existing ticket, Registered", filed) + } + assertFiledToday(t, filed["filedOn"]) +} + +// A refusal PMFBY is known to give is a 400 in our words, never PMFBY's. +func TestSupport_KnownRefusal_Returns400InOurWords(t *testing.T) { + for name, tc := range map[string]struct{ reply, message string }{ + "phone not matched": {`{"responseObject":null,"responseDynamic":null,"responseCode":"0","responseMessage":"Mobile Number Not Matched","jsonString":null,"recordCount":0}`, + "The phone number does not match the one registered for this application number."}, + "no application": {`{"responseCode":"0","responseMessage":"Application Details not Exists","responseDynamic":null}`, + "No PMFBY application was found for this application number."}, + "case and spaces": {`{"responseCode":"0","responseMessage":" mobile number NOT matched ","responseDynamic":null}`, + "The phone number does not match the one registered for this application number."}, + } { + t.Run(name, func(t *testing.T) { + _, err := newHarness(t, &pmfbyStub{answers: map[string]string{insertPath: tc.reply}}, 0).send(t, support(supportFields())) + assertRefused(t, err, tc.message) + }) + } +} + +// Any other failure is PMFBY's own: a 502 in our words, PMFBY's text withheld. +func TestSupport_PortalFails_Returns502WithoutItsText(t *testing.T) { + for name, reply := range map[string]string{ + "success, no ticket": `{"responseCode":"1","responseDynamic":{}}`, + "success, blank ticket": `{"responseCode":"1","responseDynamic":{"GrievenceSupportTicketNo":""}}`, + "no message": `{"responseCode":"0","responseMessage":null}`, + "internal error": `{"responseCode":"0","responseMessage":"AxiosError: Request failed with status code 403","responseDynamic":null}`, + "token rejected": `{"responseCode":"0","responseMessage":"Token required!","responseDynamic":null}`, + "unknown wording": `{"responseCode":"0","responseMessage":"Invalid","responseDynamic":{"GrievenceSupportTicketNo":"100626000099001"}}`, + } { + t.Run(name, func(t *testing.T) { + _, err := newHarness(t, &pmfbyStub{answers: map[string]string{insertPath: reply}}, 0).send(t, support(supportFields())) + assertFailedUpstream(t, err, "PMFBY could not register the grievance right now. Please try again later.") + }) + } +} + +// A 4xx from PMFBY is its refusal. +func TestSupport_PortalAnswers4xx_Returns502(t *testing.T) { + for _, status := range []int{http.StatusBadRequest, http.StatusNotFound, http.StatusConflict, http.StatusUnprocessableEntity} { + t.Run(http.StatusText(status), func(t *testing.T) { + h := newHarness(t, &pmfbyStub{statusFor: map[string]int{insertPath: status}}, 0) + _, err := h.send(t, support(supportFields())) + assertCoded(t, err, http.StatusBadGateway, util.CodeUpstreamUnavailable) + if got := h.pmfby.calls[insertPath]; got != 1 { + t.Errorf("the lodge call was made %d times, want 1", got) + } + }) + } +} + +func TestStatus_PortalAnswers400_Returns502(t *testing.T) { + h := newHarness(t, &pmfbyStub{statusFor: map[string]int{statusPath: http.StatusBadRequest}}, 0) + _, err := h.send(t, request("status", statusAttributes())) + assertCoded(t, err, http.StatusBadGateway, util.CodeUpstreamUnavailable) +} + +func TestSupport_PortalDown_Returns502AfterOneAttempt(t *testing.T) { + h := newHarness(t, &pmfbyStub{down: true}, 0) + _, err := h.send(t, support(supportFields())) + assertCoded(t, err, http.StatusBadGateway, util.CodeUpstreamUnavailable) + if got := h.pmfby.calls[insertPath]; got != 1 { + t.Errorf("the lodge call was made %d times, want 1: a retry files a duplicate", got) + } +} + +func TestSupport_SecondChannel_Returns400WithoutCallingPMFBY(t *testing.T) { + var payload map[string]any + _ = json.Unmarshal(support(supportFields()), &payload) + body := payload["message"].(map[string]any)["support"].(map[string]any) + body["channels"] = append(body["channels"].([]any), body["channels"].([]any)[0]) + raw, _ := json.Marshal(payload) + + h := newHarness(t, &pmfbyStub{}, 0) + _, err := h.send(t, raw) + assertCoded(t, err, http.StatusBadRequest, "SCH_INVALID_FORMAT") + if h.pmfby.calls[insertPath] != 0 { + t.Error("PMFBY was called for a request naming two channels") + } +} + +// Without the channel paths a support request names no binding the step reads. +func TestSupport_ChannelPathsNotConfigured_PassesThroughUntouched(t *testing.T) { + h := newHarness(t, &pmfbyStub{}, 0, func(c *Grievance.Config) { + c.ProviderIDAt = "message.contract.commitments[].offer.provider.id" + c.CapabilityCodeAt = "message.contract.commitments[].commitmentAttributes.@type" + }) + got, err := h.send(t, support(supportFields())) + if err != nil || got != nil { + t.Errorf("Run() = %v, %v; want the support request passed on untouched", got, err) + } + if len(h.pmfby.calls) != 0 { + t.Errorf("PMFBY was called %v without a way to recognise the request", h.pmfby.calls) + } +} + +// Two channels are refused only when they name this step's binding. +func TestSupport_OtherProviderTwoChannels_PassesThrough(t *testing.T) { + var payload map[string]any + _ = json.Unmarshal(support(supportFields()), &payload) + body := payload["message"].(map[string]any)["support"].(map[string]any) + channel := body["channels"].([]any)[0].(map[string]any) + channel["provider"] = map[string]any{"id": "someone-else"} + body["channels"] = append(body["channels"].([]any), channel) + raw, _ := json.Marshal(payload) + + h := newHarness(t, &pmfbyStub{}, 0) + got, err := h.send(t, raw) + if err != nil || got != nil { + t.Errorf("Run() = %v, %v; want another provider's request passed on", got, err) + } +} + +func TestSupport_NoProvider_PassesThrough(t *testing.T) { + var payload map[string]any + _ = json.Unmarshal(support(supportFields()), &payload) + channels := payload["message"].(map[string]any)["support"].(map[string]any)["channels"].([]any) + delete(channels[0].(map[string]any), "provider") + body, _ := json.Marshal(payload) + h := newHarness(t, &pmfbyStub{}, 0) + got, err := h.send(t, body) + if err != nil || got != nil { + t.Errorf("Run() = %v, %v; want a support request naming no provider passed on", got, err) + } +} + +// --- status ---------------------------------------------------------------- + +func TestStatus_KnownTicket_ReturnsCaseRecordAndComplaint(t *testing.T) { + h := newHarness(t, &pmfbyStub{}, 0) + got := h.mustSend(t, request("status", statusAttributes())) + + sent := h.pmfby.bodies[statusPath] + if len(sent) != 2 || sent["GrievenceSupportTicketNo"] != knownTicket || sent["requestorMobileNo"] != phone { + t.Errorf("PMFBY was sent %v, want the ticket and the phone alone", sent) + } + attributes := got.attributes() + // The category comes back by name, with no code. + grievance := band(attributes, "grievance") + category, subCategory := band(grievance, "category"), band(grievance, "subCategory") + if len(category) != 1 || category["name"] != "Claim" || len(subCategory) != 1 || + subCategory["name"] != "Claim not received" || grievance["description"] != "Claim not received" { + t.Errorf("grievance = %v, want the complaint as PMFBY holds it", grievance) + } + filed := band(attributes, "case") + if attributes["enrolmentId"] != "040108251010160770605" || attributes["informationMode"] != "OnDemand" || + filed["ticketNo"] != knownTicket || filed["filedOn"] != "2026-10-04" || filed["cropName"] != "Paddy" { + t.Errorf("commitmentAttributes = %v", attributes) + } + // "Under Review" is not a phrase the lookup lists, so it is UnderReview. + if status := band(filed, "status"); len(status) != 1 || status["code"] != "UnderReview" { + t.Errorf("case.status = %v, want code UnderReview alone", status) + } + if got.Context["action"] != "on_status" || got.status() != "ACTIVE" { + t.Errorf("action %v, status %v; want on_status, ACTIVE", got.Context["action"], got.status()) + } +} + +// The live record carries ids, crop year and season as numbers, and the +// officer's reply in latestRemark. +func TestStatus_FullRecord_ReturnsCodesCropYearSeasonAndReply(t *testing.T) { + record := strings.Replace(knownTicketRecord, `"responseDynamic":{`, `"responseDynamic":{ + "TicketCategoryID":3,"TicketSubCategoryID":10,"RequestYear":2025,"RequestSeason":2, + "latestRemark":" Claim credited on 12 Oct ",`, 1) + attributes := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: record}}, 0). + mustSend(t, request("status", statusAttributes())).attributes() + + grievance := band(attributes, "grievance") + if category, subCategory := band(grievance, "category"), band(grievance, "subCategory"); category["code"] != "3" || + category["name"] != "Claim" || subCategory["code"] != "10" || subCategory["name"] != "Claim not received" { + t.Errorf("grievance = %v, want code and name on both levels", grievance) + } + if attributes["cropYear"] != "2025" || attributes["season"] != "Rabi" { + t.Errorf("cropYear, season = %v, %v; want 2025, Rabi", attributes["cropYear"], attributes["season"]) + } + if reply := band(attributes, "case")["reply"]; reply != "Claim credited on 12 Oct" { + t.Errorf("case.reply = %q, want the trimmed remark", reply) + } +} + +// Values PMFBY uses for "none", or that fit no pack field, are dropped. +func TestStatus_UnusableCodes_Dropped(t *testing.T) { + for name, fields := range map[string]string{ + "zeros": `"TicketCategoryID":0,"TicketSubCategoryID":"0","RequestYear":0,"RequestSeason":0,"latestRemark":"",`, + "out of range": `"RequestYear":25,"RequestSeason":4,"latestRemark":" ",`, + "not a number": `"TicketCategoryID":"x","RequestYear":"20x5","RequestSeason":"Kharif",`, + } { + t.Run(name, func(t *testing.T) { + record := strings.Replace(knownTicketRecord, `"responseDynamic":{`, `"responseDynamic":{`+fields, 1) + attributes := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: record}}, 0). + mustSend(t, request("status", statusAttributes())).attributes() + _, year := attributes["cropYear"] + _, season := attributes["season"] + _, reply := band(attributes, "case")["reply"] + if category := band(band(attributes, "grievance"), "category"); year || season || reply || category["code"] != nil { + t.Errorf("attributes = %v, want no cropYear, season, reply or category code", attributes) + } + }) + } +} + +func TestStatus_KnownTicket_DropsPersonalAndInternalFields(t *testing.T) { + got := newHarness(t, &pmfbyStub{}, 0).mustSend(t, request("status", statusAttributes())) + allowed := map[string]map[string]bool{ + "": {"@context": true, "@type": true, "informationMode": true, "scheme": true, + "enrolmentId": true, "grievance": true, "case": true}, + "grievance": {"category": true, "subCategory": true, "description": true}, + "case": {"ticketNo": true, "status": true, "filedOn": true, "cropName": true}, + } + attributes := got.attributes() + for name, fields := range allowed { + in := attributes + if name != "" { + in = band(attributes, name) + } + for field := range in { + if !fields[field] { + t.Errorf("%s %s is mapped; the response is an allow-list", name, field) + } + } + } +} + +// filedOn is the day in IST, whatever offset PMFBY states it in -- including +// a late-evening filing, which is the next day in UTC and must not shift. +func TestStatus_InsertDateTime_FiledOnIsTheISTDay(t *testing.T) { + for stated, want := range map[string]string{ + "2026-10-04T15:30:00+05:30": "2026-10-04", + "2026-10-04T10:00:00.000Z": "2026-10-04", + "2026-10-04T20:00:00.000Z": "2026-10-05", + "2026-10-05T01:30:00+05:30": "2026-10-05", + } { + t.Run(stated, func(t *testing.T) { + record := strings.Replace(knownTicketRecord, `"2026-10-04T10:00:00.000Z"`, `"`+stated+`"`, 1) + got := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: record}}, 0). + mustSend(t, request("status", statusAttributes())) + if filed := band(got.attributes(), "case")["filedOn"]; filed != want { + t.Errorf("filedOn = %v, want %s", filed, want) + } + }) + } +} + +func TestStatus_BareScheme_ReturnsSchemeName(t *testing.T) { + attributes := statusAttributes() + attributes["scheme"] = map[string]any{"code": "PMFBY"} + got := newHarness(t, &pmfbyStub{}, 0).mustSend(t, request("status", attributes)) + if got := band(got.attributes(), "scheme"); !reflect.DeepEqual(got, scheme) { + t.Errorf("scheme = %v, want %v", got, scheme) + } +} + +// A missing phrase still maps to UnderReview. +func TestStatus_NoStatusPhrase_UnderReviewWithoutName(t *testing.T) { + for name, phrase := range map[string]string{"null": `null`, "empty": `""`} { + t.Run(name, func(t *testing.T) { + record := strings.Replace(knownTicketRecord, `"TicketStatus":"Under Review"`, `"TicketStatus":`+phrase, 1) + got := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: record}}, 0). + mustSend(t, request("status", statusAttributes())) + status := band(band(got.attributes(), "case"), "status") + if len(status) != 1 || status["code"] != "UnderReview" { + t.Errorf("case.status = %v, want code UnderReview alone", status) + } + }) + } +} + +func TestStatus_NullFields_DroppedNotMappedOrFailed(t *testing.T) { + for name, tc := range map[string]struct { + from, to string + band, field string + }{ + "null category name": {`"TicketCategoryName":"Claim"`, `"TicketCategoryName":null`, "grievance", "category"}, + "empty sub-category": {`"TicketSubCategoryName":"Claim not received"`, `"TicketSubCategoryName":""`, "grievance", "subCategory"}, + "null application no": {`"ApplicationNo":"040108251010160770605"`, `"ApplicationNo":null`, "", "enrolmentId"}, + "empty crop": {`"CropName":"Paddy"`, `"CropName":""`, "case", "cropName"}, + } { + t.Run(name, func(t *testing.T) { + record := strings.Replace(knownTicketRecord, tc.from, tc.to, 1) + got := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: record}}, 0). + mustSend(t, request("status", statusAttributes())) + in := got.attributes() + if tc.band != "" { + in = band(in, tc.band) + } + if value, present := in[tc.field]; present { + t.Errorf("%s = %v, want it dropped", tc.field, value) + } + if band(got.attributes(), "case")["ticketNo"] != knownTicket { + t.Errorf("case = %v, want the rest of the answer intact", band(got.attributes(), "case")) + } + }) + } +} + +// filedOn is required on an answer and a date is not ours to invent, so a +// record PMFBY dates unusably is its fault, not a grievance we can report. +func TestStatus_NoUsableFilingDate_Returns502(t *testing.T) { + for name, stated := range map[string]string{ + "null": `null`, + "empty": `""`, + "date only": `"2026-10-04"`, + "not a time": `"04/10/2026"`, + } { + t.Run(name, func(t *testing.T) { + record := strings.Replace(knownTicketRecord, `"2026-10-04T10:00:00.000Z"`, stated, 1) + _, err := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: record}}, 0). + send(t, request("status", statusAttributes())) + assertFailedUpstream(t, err, "PMFBY returned the grievance without a usable filing date.") + }) + } +} + +// The complaint rides only with its description: the pack requires one, so a +// category on its own would be an answer the caller has to refuse. +func TestStatus_NoDescription_DropsTheWholeComplaint(t *testing.T) { + record := strings.Replace(knownTicketRecord, `"GrievenceDescription":"Claim not received"`, `"GrievenceDescription":null`, 1) + got := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: record}}, 0). + mustSend(t, request("status", statusAttributes())) + if complaint, present := got.attributes()["grievance"]; present { + t.Errorf("grievance = %v, want no grievance band at all", complaint) + } + if band(got.attributes(), "case")["ticketNo"] != knownTicket { + t.Error("the rest of the answer should survive") + } +} + +// PMFBY's TicketStatus maps to a CaseStatusCode; an unlisted phrase is UnderReview. +func TestStatus_TicketStatus_MapsToCaseStatusCode(t *testing.T) { + for phrase, want := range map[string]string{"Resolved": "Resolved", " resolved ": "Resolved", + "Open": "Registered", "Under Review": "UnderReview", "Escalated": "UnderReview"} { + t.Run(phrase, func(t *testing.T) { + record := strings.Replace(knownTicketRecord, `"TicketStatus":"Under Review"`, `"TicketStatus":"`+phrase+`"`, 1) + got := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: record}}, 0). + mustSend(t, request("status", statusAttributes())) + if status := band(band(got.attributes(), "case"), "status"); len(status) != 1 || status["code"] != want { + t.Errorf("case.status = %v, want code %s alone", status, want) + } + }) + } +} + +// No record, or one for another ticket (PMFBY reads by phone alone), is a 400 +// in our words. +func TestStatus_NoMatchingRecord_Returns400(t *testing.T) { + another := strings.Replace(knownTicketRecord, `"GrievenceSupportTicketNo":"`+knownTicket+`"`, `"GrievenceSupportTicketNo":"100626000099002"`, 1) + // A record that does not name its own ticket cannot be shown as the answer + // to the one asked for: PMFBY reads by phone, so it may be another case. + unnamed := strings.Replace(knownTicketRecord, `"GrievenceSupportTicketNo":"`+knownTicket+`",`, ``, 1) + for name, reply := range map[string]string{ + "no record": `{"responseCode":"0","responseMessage":"No record found","responseDynamic":[]}`, + "null": `{"responseCode":"1","responseDynamic":null}`, + "empty object": `{"responseCode":"1","responseDynamic":{}}`, + "empty in list": `{"responseCode":"1","responseDynamic":[{}]}`, + "all fields null": `{"responseCode":"1","responseDynamic":{"ApplicationNo":null,"TicketStatus":null}}`, + "another ticket": another, + "ticket unnamed": unnamed, + } { + t.Run(name, func(t *testing.T) { + _, err := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: reply}}, 0). + send(t, request("status", statusAttributes())) + assertRefused(t, err, "No grievance was found for this ticket number and phone number.") + }) + } +} + +// Any other failure is PMFBY's own: a 502 in our words, PMFBY's text withheld. +func TestStatus_PortalFails_Returns502WithoutItsText(t *testing.T) { + for name, reply := range map[string]string{ + "no envelope": `{"message":"An error has occurred."}`, + "no message": `{"responseCode":"0","responseMessage":"","responseDynamic":null}`, + "internal error": `{"responseCode":"0","responseMessage":"AxiosError: Request failed with status code 403","responseDynamic":null}`, + "token rejected": `{"responseCode":"0","responseMessage":"Token required!","responseDynamic":null}`, + } { + t.Run(name, func(t *testing.T) { + _, err := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: reply}}, 0). + send(t, request("status", statusAttributes())) + assertFailedUpstream(t, err, "PMFBY could not return the grievance status right now. Please try again later.") + }) + } +} + +func TestStatus_RecordNamingTheTicket_ReturnsIt(t *testing.T) { + got := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: knownTicketRecord}}, 0). + mustSend(t, request("status", statusAttributes())) + if no := band(got.attributes(), "case")["ticketNo"]; no != knownTicket { + t.Errorf("ticketNo = %v, want %s", no, knownTicket) + } +} + +// PMFBY reads by phone, so the list can hold this farmer's other grievances. +// The one naming the ticket asked for is the answer, wherever it sits. +func TestStatus_MatchingTicketAfterTheFirst_IsFound(t *testing.T) { + other := `{"GrievenceSupportTicketNo":"100626000099002","TicketStatus":"Resolved",` + + `"InsertDateTime":"2026-10-01T10:00:00.000Z","ApplicationNo":"040108251010160770999",` + + `"CropName":"Wheat"}` + one := strings.Replace(knownTicketRecord, `"responseDynamic":{`, `"responseDynamic":[`+other+`,{`, 1) + list := strings.Replace(one, `}}`, `}]}`, 1) + + got := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: list}}, 0). + mustSend(t, request("status", statusAttributes())) + record := band(got.attributes(), "case") + if record["ticketNo"] != knownTicket { + t.Errorf("ticketNo = %v, want %s: the second record is the one asked for", record["ticketNo"], knownTicket) + } + if record["cropName"] != "Paddy" { + t.Errorf("cropName = %v, want Paddy: the first record is somebody else's case", record["cropName"]) + } +} + +func TestStatus_RecordInAList_ReadAsTheRecord(t *testing.T) { + record := strings.Replace(strings.Replace(knownTicketRecord, `"responseDynamic":{`, `"responseDynamic":[{`, 1), `}}`, `}]}`, 1) + got := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: record}}, 0). + mustSend(t, request("status", statusAttributes())) + if crop := band(got.attributes(), "case")["cropName"]; crop != "Paddy" { + t.Errorf("case = %v, want the record read from the list", band(got.attributes(), "case")) + } +} + +func TestStatus_PortalDown_RetriedAsTheRegistrySays(t *testing.T) { + h := newHarness(t, &pmfbyStub{down: true}, 2) + _, err := h.send(t, request("status", statusAttributes())) + assertCoded(t, err, http.StatusBadGateway, util.CodeUpstreamUnavailable) + if got := h.pmfby.calls[statusPath]; got != 3 { + t.Errorf("status called %d times, want 3: a read is safe to retry", got) + } +} + +// --- across the flow ------------------------------------------------------- + +func TestFlow_SupportThenStatus_LogsInOnce(t *testing.T) { + h := newHarness(t, &pmfbyStub{}, 0) + ticket := band(h.mustSend(t, support(supportFields())).channel(), "case")["ticketNo"] + got := h.mustSend(t, request("status", map[string]any{"applicantMobile": phone, "case": map[string]any{"ticketNo": ticket}})) + + if status := band(band(got.attributes(), "case"), "status"); status["code"] != "UnderReview" { + t.Errorf("status of the filed ticket = %v", got.attributes()) + } + if logins := h.pmfby.calls[loginPath]; logins != 1 { + t.Errorf("logged in %d times over two calls, want 1", logins) + } +} + +func TestFlow_AnswerNotJSON_ReturnsErrorWithoutMapping(t *testing.T) { + h := newHarness(t, &pmfbyStub{answers: map[string]string{insertPath: `maintenance`}}, 0) + got, err := h.send(t, support(supportFields())) + if err == nil || got != nil { + t.Errorf("Run() = %v, %v; want an error and no answer", got, err) + } +} + +// init included: PMFBY has no OTP, so there is no init. +func TestFlow_UnservedAction_Returns400WithoutCallingPMFBY(t *testing.T) { + for _, action := range []string{"init", "select"} { + t.Run(action, func(t *testing.T) { + h := newHarness(t, &pmfbyStub{}, 0) + _, err := h.send(t, request(action, statusAttributes())) + assertCoded(t, err, http.StatusBadRequest, "SCH_INVALID_FORMAT") + if len(h.pmfby.calls) != 0 { + t.Errorf("PMFBY was called %v for an action it does not serve", h.pmfby.calls) + } + }) + } +} + +// PM-KISAN shares the Grievance type; only the provider tells the desks apart. +func TestFlow_OtherSchemeSameType_PassesThroughUntouched(t *testing.T) { + h := newHarness(t, &pmfbyStub{}, 0) + body := strings.Replace(string(request("status", statusAttributes())), `"id":"pmfby"`, `"id":"pmkisan"`, 1) + if !strings.Contains(body, `"id":"pmkisan"`) { + t.Fatal("the request names no pmfby provider to replace") + } + got, err := h.send(t, []byte(body)) + if err != nil || got != nil || len(h.pmfby.calls) != 0 { + t.Errorf("Run() = %v, %v, PMFBY calls %v; want PM-KISAN's request passed on untouched", got, err, h.pmfby.calls) + } +} + +func TestFlow_OtherCapability_PassesThroughUntouched(t *testing.T) { + h := newHarness(t, &pmfbyStub{}, 0) + body := strings.Replace(string(request("status", statusAttributes())), + "openagrinet:Grievance", "openagrinet:SchemeApplication", 1) + got, err := h.send(t, []byte(body)) + if err != nil || got != nil { + t.Errorf("Run() = %v, %v; want another capability passed on untouched", got, err) + } + if len(h.pmfby.calls) != 0 { + t.Errorf("PMFBY was called %v for another capability", h.pmfby.calls) + } +} diff --git a/pkg/plugin/implementation/internal/common/auth.go b/pkg/plugin/implementation/internal/common/auth.go index 4c780112a..b66aa8f4c 100644 --- a/pkg/plugin/implementation/internal/common/auth.go +++ b/pkg/plugin/implementation/internal/common/auth.go @@ -27,7 +27,7 @@ type AuthProfile struct { // operator guessing which block to look at. Provider string - // One of none, basic, header, query, oauth2 or tokenQuery. + // One of none, basic, header, query, oauth2, tokenQuery or tokenHeader. Scheme string // Variable NAMES, never the values. @@ -60,18 +60,18 @@ type AuthProfile struct { TokenSecretName string TokenSecretEnv string - // Where the token sits in the response body, as a top-level key. + // Where the token sits in the response body, as a dotted path. TokenResponseField string - // How long a token may be held, as written in config and as parsed. - // - // Configured because the response carries no expiry at all -- nothing in it - // says how long the token lives, so an operator states what they believe - // and this refuses to guess. Set it too long and calls fail with the - // provider's own rejection until it lapses; the cache is dropped on such a - // rejection so the next call re-exchanges rather than waiting that out. + // How long a token may be held, for a response that states no expiry. Too + // long costs one rejected call: the rejection drops the token. TokenTTLRaw string TokenTTL time.Duration + + // Where the response states the token's expiry, as a dotted path to Unix + // seconds or an RFC 3339 time. Set this or tokenTtl: the issuer's own + // expiry beats an estimate. + TokenExpiryField string } // authenticator is one provider's profile plus the token it holds. @@ -116,43 +116,61 @@ func (a *AuthProfile) validate() error { "%s: authScheme oauth2 requires tokenUrl, clientIdEnv and clientSecretEnv", a.Provider) } - case util.AuthSchemeTokenQuery: + case util.AuthSchemeTokenQuery, util.AuthSchemeTokenHeader: // Every one of these is required, and none can be defaulted: the JSON - // keys, the response key and the parameter name are all this + // keys, the response key and the parameter or header name are all this // provider's spelling, and guessing any of them sends a malformed // request whose rejection says nothing about the cause. if a.TokenURL == "" || a.TokenUserField == "" || a.TokenUserEnv == "" || a.TokenSecretName == "" || a.TokenSecretEnv == "" || - a.TokenResponseField == "" || a.QueryName == "" { - return fmt.Errorf("%s: authScheme tokenQuery requires tokenUrl, "+ - "tokenUserField, tokenUserEnv, tokenSecretField, tokenSecretEnv, "+ - "tokenResponseField and queryName", a.Provider) + a.TokenResponseField == "" { + return fmt.Errorf("%s: authScheme %s requires tokenUrl, "+ + "tokenUserField, tokenUserEnv, tokenSecretField, tokenSecretEnv "+ + "and tokenResponseField", a.Provider, a.Scheme) } - if a.TokenTTLRaw == "" { - return fmt.Errorf("%s: authScheme tokenQuery requires tokenTtl, "+ - "because the token response carries no expiry to read", a.Provider) + if a.Scheme == util.AuthSchemeTokenQuery && a.QueryName == "" { + return fmt.Errorf("%s: authScheme tokenQuery requires queryName", a.Provider) } - ttl, err := time.ParseDuration(a.TokenTTLRaw) - if err != nil { - return fmt.Errorf("%s: tokenTtl %q is not a duration: %w", - a.Provider, a.TokenTTLRaw, err) + if a.Scheme == util.AuthSchemeTokenHeader && a.HeaderName == "" { + return fmt.Errorf("%s: authScheme tokenHeader requires headerName", a.Provider) } - // Below the skew every token is already expired when it arrives, so - // each call would exchange one and then discard it -- two round trips - // per request and a token endpoint hit at the request rate. - if ttl <= util.TokenRefreshSkew { - return fmt.Errorf("%s: tokenTtl %s must exceed the %s refresh skew, "+ - "or every token is expired before it is used", - a.Provider, ttl, util.TokenRefreshSkew) + // The lifetime comes from exactly one of the two: a field the provider + // sets on each token, or a fixed duration configured here. + switch { + case a.TokenExpiryField != "" && a.TokenTTLRaw != "": + // Two lifetimes would leave an operator guessing which one holds. + return fmt.Errorf("%s: set tokenTtl or tokenExpiryField, not both", a.Provider) + + case a.TokenExpiryField != "": + // The provider states it per token, so there is nothing to parse. + + case a.TokenTTLRaw != "": + ttl, err := time.ParseDuration(a.TokenTTLRaw) + if err != nil { + return fmt.Errorf("%s: tokenTtl %q is not a duration: %w", + a.Provider, a.TokenTTLRaw, err) + } + // Below the skew every token is already expired when it arrives, + // so each call would exchange one and then discard it -- two round + // trips per request and a token endpoint hit at the request rate. + if ttl <= util.TokenRefreshSkew { + return fmt.Errorf("%s: tokenTtl %s must exceed the %s refresh skew, "+ + "or every token is expired before it is used", + a.Provider, ttl, util.TokenRefreshSkew) + } + a.TokenTTL = ttl + + default: + return fmt.Errorf("%s: authScheme %s requires tokenTtl or tokenExpiryField, "+ + "or the token's lifetime is unknown", a.Provider, a.Scheme) } - a.TokenTTL = ttl case "": return fmt.Errorf("%s: authScheme is required, "+ "and is none where the upstream needs no credential", a.Provider) default: return fmt.Errorf( "%s: unknown authScheme %q: must be none, basic, header, query, "+ - "oauth2 or tokenQuery", a.Provider, a.Scheme) + "oauth2, tokenQuery or tokenHeader", a.Provider, a.Scheme) } return nil } @@ -245,7 +263,8 @@ var authFields = map[string]func(*AuthProfile, string){ // Kept as written and turned into a duration by validate, so a bad value is // refused at startup naming the provider rather than read as zero -- which // would mean re-exchanging a token on every single call. - "tokenTtl": func(p *AuthProfile, v string) { p.TokenTTLRaw = v }, + "tokenTtl": func(p *AuthProfile, v string) { p.TokenTTLRaw = v }, + "tokenExpiryField": func(p *AuthProfile, v string) { p.TokenExpiryField = v }, } // providerIDFrom returns the provider half of "|". @@ -307,6 +326,14 @@ func (s *Step) authenticate(auth *authenticator, req *http.Request) error { } req.Header.Set("Authorization", "Bearer "+token) + case util.AuthSchemeTokenHeader: + // Exchanged like tokenQuery, sent bare in the header. + token, err := s.providerToken(req.Context(), auth) + if err != nil { + return err + } + req.Header.Set(cfg.HeaderName, token) + case util.AuthSchemeTokenQuery: // Exchanged like oauth2, placed like query: the same held token, put // where this provider reads it from. diff --git a/pkg/plugin/implementation/internal/common/binding.go b/pkg/plugin/implementation/internal/common/binding.go index 4a3253060..d5206aeae 100644 --- a/pkg/plugin/implementation/internal/common/binding.go +++ b/pkg/plugin/implementation/internal/common/binding.go @@ -44,11 +44,25 @@ func (b Binding) Key() string { // spanning several is asking for something this design cannot express, and // guessing would silently serve part of it. func BindingFrom(paths Paths, body []byte) (Binding, error) { + payload, err := decodePayload(body) + if err != nil { + return Binding{}, err + } + return bindingIn(paths, payload) +} + +// decodePayload reads a body into the shape the path walkers take. +func decodePayload(body []byte) (any, error) { var payload any if err := json.Unmarshal(body, &payload); err != nil { - return Binding{}, fmt.Errorf("payload could not be read: %w", err) + return nil, fmt.Errorf("payload could not be read: %w", err) } + return payload, nil +} +// bindingIn is BindingFrom over a payload that is already decoded, so a caller +// trying several path pairs reads the body once rather than once per pair. +func bindingIn(paths Paths, payload any) (Binding, error) { // Checked before distinctness: N commitments naming the SAME provider and // type collapse to one key, so they would pass unnoticed and the mapping // would answer commitments[0] and drop the rest -- a confident, signed, diff --git a/pkg/plugin/implementation/internal/common/common.go b/pkg/plugin/implementation/internal/common/common.go index 9390a9373..2ca755874 100644 --- a/pkg/plugin/implementation/internal/common/common.go +++ b/pkg/plugin/implementation/internal/common/common.go @@ -19,6 +19,7 @@ import ( "errors" "fmt" "net/http" + "strconv" "strings" "github.com/beckn-one/beckn-onix/pkg/log" @@ -52,6 +53,9 @@ type Config struct { // A network convention, not a preference: every participant must agree or // requests silently fail to match. Configurable only so a spec change can // be tracked without waiting for a release. Both or neither. + // + // Either may list several comma-separated paths, paired by position and + // tried in order until one finds a binding. ProviderIDAt string `yaml:"providerIdAt" json:"providerIdAt"` CapabilityCodeAt string `yaml:"capabilityCodeAt" json:"capabilityCodeAt"` @@ -79,7 +83,7 @@ type Config struct { // safe for concurrent use. type Step struct { config *Config - paths Paths + paths []Paths prerequisites Prerequisites registry definition.ProviderRecordLookup mapper definition.Mapper @@ -106,14 +110,14 @@ func New(ctx context.Context, registry definition.ProviderRecordLookup, mapper d return nil, nil, err } - paths, err := BindingPaths(cfg) + pathList, err := bindingPathList(cfg) if err != nil { return nil, nil, err } step := &Step{ config: cfg, - paths: paths, + paths: pathList, prerequisites: prerequisites, registry: registry, mapper: mapper, @@ -135,6 +139,53 @@ func New(ctx context.Context, registry definition.ProviderRecordLookup, mapper d return step, closer, nil } +// ParseStepConfig reads the flat map a deployment writes into a typed Config. +// Defaults and validation stay in the domain package's New, which is the one +// place that knows what the capability needs. +// +// EXPORTED for the cmd package that builds a plugin. Every provider step reads +// the same four settings the same way, and a copy per plugin is one more place +// for a key name to be misspelled with nothing to catch it. +func ParseStepConfig(config map[string]string) (*Config, error) { + cfg := &Config{ + BindingKeys: splitList(config["bindingKeys"]), + ProviderIDAt: config["providerIdAt"], + CapabilityCodeAt: config["capabilityCodeAt"], + } + + if raw := config["maxResponseBytes"]; 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 + } + + // One credential profile per provider. + auth, err := ParseProviderAuth(config) + if err != nil { + return nil, err + } + cfg.AuthByProvider = auth + + return cfg, nil +} + +// splitList reads a comma-separated config value, trimming spaces and dropping +// blanks. +func splitList(raw string) []string { + var out []string + for _, part := range strings.Split(raw, ",") { + if trimmed := strings.TrimSpace(part); trimmed != "" { + out = append(out, trimmed) + } + } + return out +} + // BindingPaths resolves where this step reads a binding key from. // // Both halves or neither: one overridden and one defaulted would match nothing, @@ -144,21 +195,45 @@ func New(ctx context.Context, registry definition.ProviderRecordLookup, mapper d // same way this step does -- AgricultureFacility, whose search.go fans one // request out over several capability types. The alternative is reading the // same two config fields a second time, which is how the two drift apart. +// +// A path list is refused: such a caller reads only one pair. func BindingPaths(cfg *Config) (Paths, error) { + pathList, err := bindingPathList(cfg) + if err != nil { + return Paths{}, err + } + if len(pathList) > 1 { + return Paths{}, errors.New("providerIdAt and capabilityCodeAt list several paths, which only the step itself reads") + } + return pathList[0], nil +} + +// bindingPathList returns the path pairs to try, in order: the comma-separated +// halves paired by position. +func bindingPathList(cfg *Config) ([]Paths, error) { if cfg.ProviderIDAt == "" && cfg.CapabilityCodeAt == "" { - return BecknV2, nil + return []Paths{BecknV2}, nil } if cfg.ProviderIDAt == "" { - return Paths{}, errors.New("capabilityCodeAt is set without providerIdAt") + return nil, errors.New("capabilityCodeAt is set without providerIdAt") } if cfg.CapabilityCodeAt == "" { - return Paths{}, errors.New("providerIdAt is set without capabilityCodeAt") + return nil, errors.New("providerIdAt is set without capabilityCodeAt") } - paths := Paths{ProviderID: cfg.ProviderIDAt, CapabilityCode: cfg.CapabilityCodeAt} - if err := paths.Validate(); err != nil { - return Paths{}, err + providers := strings.Split(cfg.ProviderIDAt, ",") + capabilities := strings.Split(cfg.CapabilityCodeAt, ",") + if len(providers) != len(capabilities) { + return nil, fmt.Errorf("providerIdAt lists %d paths and capabilityCodeAt %d; they pair by position", + len(providers), len(capabilities)) + } + pairs := make([]Paths, len(providers)) + for i := range providers { + pairs[i] = Paths{ProviderID: strings.TrimSpace(providers[i]), CapabilityCode: strings.TrimSpace(capabilities[i])} + if err := pairs[i].Validate(); err != nil { + return nil, err + } } - return paths, nil + return pairs, nil } // applyDefaults fills in what was left out and rejects what cannot be defaulted. diff --git a/pkg/plugin/implementation/internal/common/common_test.go b/pkg/plugin/implementation/internal/common/common_test.go index 2a18fbfe6..fb73b006c 100644 --- a/pkg/plugin/implementation/internal/common/common_test.go +++ b/pkg/plugin/implementation/internal/common/common_test.go @@ -1288,7 +1288,7 @@ func TestNewUsesTheBecknConventionByDefault(t *testing.T) { t.Parallel() step := newStep(t, &stubRegistry{}, &stubMapper{}) - if step.paths != BecknV2 { + if !slices.Equal(step.paths, []Paths{BecknV2}) { t.Errorf("paths = %+v, want the Beckn v2 convention", step.paths) } } @@ -2558,3 +2558,163 @@ func TestOAuth2DoesNotRetryAMissingCredential(t *testing.T) { t.Errorf("token endpoint called %d times, want 0", got) } } + +// refusalStep is a step over mapper whose provider answers {"ok":true}, +// counting calls. +func refusalStep(t *testing.T, mapper *stubMapper, calls *int) *Step { + t.Helper() + provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + *calls++ + fmt.Fprint(w, `{"ok":true}`) + })) + t.Cleanup(provider.Close) + return newStep(t, &stubRegistry{plan: testPlan(provider.URL, http.MethodPost)}, mapper) +} + +// refusedAs reads the HTTP status and Beckn error a refusal is answered with, +// whichever of the two step error types carries it. +func refusedAs(t *testing.T, err error) (int, *model.Error) { + t.Helper() + var invalid *model.SchemaValidationErr + var coded *model.CodedErr + switch { + case errors.As(err, &invalid): + return http.StatusBadRequest, invalid.BecknError() + case errors.As(err, &coded): + return coded.HTTPStatus(), coded.BecknError() + } + t.Fatalf("error = %v (%T), want a refusal", err, err) + return 0, nil +} + +func TestRefusal_RequestHalf_RefusesWithoutCallingProvider(t *testing.T) { + var calls int + mapper := &stubMapper{ + requestResult: []byte(`{"_error":{"status":400,"code":"SCH_REQUIRED_FIELD_MISSING", + "message":"phone is required","path":"$.message.phone"}}`), + responseResult: []byte(`{"a":1}`), + } + _, err := runStep(t, refusalStep(t, mapper, &calls), selectBody) + + status, refused := refusedAs(t, err) + if status != http.StatusBadRequest || refused.Code != "SCH_REQUIRED_FIELD_MISSING" || + refused.Message != "phone is required" || refused.Details == nil || refused.Details.Path != "$.message.phone" { + t.Fatalf("answer = %d %+v, want 400 SCH_REQUIRED_FIELD_MISSING at $.message.phone", status, refused) + } + if calls != 0 { + t.Errorf("provider called %d times for a refused request, want 0", calls) + } +} + +func TestRefusal_ResponseHalf_ReturnsItsStatusAndCode(t *testing.T) { + for name, tc := range map[string]struct { + refusal string + status int + code string + }{ + "explicit status": {`{"status":502,"code":"NET_DOWNSTREAM_UNAVAILABLE","message":"refused"}`, http.StatusBadGateway, "NET_DOWNSTREAM_UNAVAILABLE"}, + "status omitted": {`{"code":"BIZ_GENERIC_ERROR","message":"refused"}`, http.StatusBadRequest, "BIZ_GENERIC_ERROR"}, + } { + t.Run(name, func(t *testing.T) { + var calls int + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{"_error":` + tc.refusal + `}`)} + stepCtx, err := runStep(t, refusalStep(t, mapper, &calls), selectBody) + + if status, refused := refusedAs(t, err); status != tc.status || refused.Code != tc.code { + t.Fatalf("answer = %d %+v, want %d %s", status, refused, tc.status, tc.code) + } + if len(stepCtx.ResponseBody) != 0 { + t.Errorf("a refusal produced a response body: %s", stepCtx.ResponseBody) + } + }) + } +} + +func TestRefusal_UnreadableError_FailsRatherThanAnswering(t *testing.T) { + for name, refusal := range map[string]string{ + "no message": `{"_error":{"status":502,"code":"X"}}`, + "not object": `{"_error":"refused"}`, + } { + t.Run(name, func(t *testing.T) { + var calls int + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(refusal)} + stepCtx, err := runStep(t, refusalStep(t, mapper, &calls), selectBody) + if err == nil || len(stepCtx.ResponseBody) != 0 { + t.Errorf("Run() = %v with body %s, want an error and no answer", err, stepCtx.ResponseBody) + } + }) + } +} + +// cause is the provider's own wording, kept for the log: the caller never sees it. +func TestRefusal_Cause_NotSentToCaller(t *testing.T) { + var calls int + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte( + `{"_error":{"status":502,"code":"NET_DOWNSTREAM_UNAVAILABLE","message":"try again later","cause":"AxiosError: 403"}}`)} + _, err := runStep(t, refusalStep(t, mapper, &calls), selectBody) + + _, refused := refusedAs(t, err) + if refused.Message != "try again later" || strings.Contains(err.Error(), "AxiosError") { + t.Errorf("answer = %+v (%v), want the mapping's message without the cause", refused, err) + } +} + +// A mapping that writes "_error": null means no error, not an unreadable one. +func TestRefusal_NullErrorField_AnswersAsBefore(t *testing.T) { + var calls int + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{"_error":null,"message":{"ok":true}}`)} + stepCtx, err := runStep(t, refusalStep(t, mapper, &calls), selectBody) + if err != nil || calls != 1 || !strings.Contains(string(stepCtx.ResponseBody), `"ok":true`) { + t.Errorf("Run() = %v, body %s, %d calls; want the mapped answer after one call", err, stepCtx.ResponseBody, calls) + } +} + +// A caller of BindingPaths reads one pair, so a list is refused rather than +// handed over as one path that matches nothing. +func TestBindingPaths_PathList_Refused(t *testing.T) { + cfg := &Config{ProviderIDAt: "a.id, b.id", CapabilityCodeAt: "a.@type, b.@type"} + if _, err := BindingPaths(cfg); err == nil { + t.Error("BindingPaths() accepted a path list") + } + cfg = &Config{ProviderIDAt: "a.id", CapabilityCodeAt: "a.@type"} + if got, err := BindingPaths(cfg); err != nil || got != (Paths{ProviderID: "a.id", CapabilityCode: "a.@type"}) { + t.Errorf("BindingPaths() = %+v, %v; want the single pair", got, err) + } +} + +func TestRefusal_NoErrorField_AnswersAsBefore(t *testing.T) { + var calls int + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{"message":{"ok":true}}`)} + stepCtx, err := runStep(t, refusalStep(t, mapper, &calls), selectBody) + if err != nil || string(stepCtx.ResponseBody) != `{"message":{"ok":true}}` || calls != 1 { + t.Errorf("Run() = %v, body %s, %d calls; want the mapped answer after one call", err, stepCtx.ResponseBody, calls) + } +} + +// TestSplitList_CommaSeparated_TrimsAndDropsBlanks pins what an operator may +// write around the commas. Trailing and doubled separators are ordinary in a +// hand-edited config and must not produce an empty binding key. +func TestSplitList_CommaSeparated_TrimsAndDropsBlanks(t *testing.T) { + for raw, want := range map[string][]string{ + "": nil, + " ": nil, + "a|openagrinet:One, b|openagrinet:Two ,,": {"a|openagrinet:One", "b|openagrinet:Two"}, + } { + if got := splitList(raw); !reflect.DeepEqual(got, want) { + t.Errorf("splitList(%q) = %v, want %v", raw, got, want) + } + } +} + +// TestParseStepConfig_MaxResponseBytes_Refused covers the one setting that is +// parsed rather than copied: a cap that cannot be read, or that is not a cap. +func TestParseStepConfig_MaxResponseBytes_Refused(t *testing.T) { + for _, raw := range []string{"not-a-number", "0", "-1"} { + if _, err := ParseStepConfig(map[string]string{ + "bindingKeys": "a|openagrinet:One", + "maxResponseBytes": raw, + }); err == nil { + t.Errorf("ParseStepConfig(maxResponseBytes=%q) = nil, want an error", raw) + } + } +} diff --git a/pkg/plugin/implementation/internal/common/dispatch_test.go b/pkg/plugin/implementation/internal/common/dispatch_test.go index 727288014..8c741e957 100644 --- a/pkg/plugin/implementation/internal/common/dispatch_test.go +++ b/pkg/plugin/implementation/internal/common/dispatch_test.go @@ -119,3 +119,70 @@ func TestTwoProviderStepsDispatchByBindingKey(t *testing.T) { t.Errorf("answer = %s, want A's -- a later step overwrote it", got) } } + +// sharedContainerRequest names two bindings: the commitment is another step's +// work, and the support entry is this one's. Both are ordinary on a payload +// that rides through a pipeline of several provider steps. +const sharedContainerRequest = `{ + "context": { "version": "2.0.0", "action": "select" }, + "message": { "contract": { + "commitments": [ { + "resources": [ { "resourceAttributes": { "@type": "openagrinet:MarketPrice" } } ], + "offer": { "provider": { "id": "agmarknet" } } + } ], + "support": [ { + "provider": { "id": "mausamgram" }, + "resourceAttributes": { "@type": "openagrinet:WeatherObservation" } + } ] + } } +}` + +// A step configured with several path pairs must keep looking after a pair +// resolves a binding that belongs to some other step. Stopping at the first +// pair that resolved anything would make this step pass the request through, +// and nothing downstream would answer it -- a silent miss, not an error. +func TestLaterPathPairFindsThisStepsOwnBinding(t *testing.T) { + var called bool + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + fmt.Fprint(w, `{"from":"upstream"}`) + })) + defer upstream.Close() + + const bindingKey = "mausamgram|openagrinet:WeatherObservation" + step, closer, err := common.New(context.Background(), + &stubRegistry{plan: &model.ProviderRecord{ + BindingKey: bindingKey, + BaseURL: upstream.URL, + Actions: map[string]model.ActionPlan{ + "select": {Method: http.MethodGet, Path: "/x", Mappings: dispatchMappingRef, RetryMax: 1}, + }, + }}, + fixedMapper{answer: `{"served":"weather"}`}, + nil, + &common.Config{ + BindingKeys: []string{bindingKey}, + ProviderIDAt: "message.contract.commitments[].offer.provider.id," + + "message.contract.support[].provider.id", + CapabilityCodeAt: "message.contract.commitments[].resources[].resourceAttributes.@type," + + "message.contract.support[].resourceAttributes.@type", + AuthByProvider: map[string]*common.AuthProfile{ + "mausamgram": {Scheme: util.AuthSchemeNone}, + }, + }) + if err != nil { + t.Fatal(err) + } + defer func() { _ = closer() }() + + ctx := &model.StepContext{Context: t.Context(), Body: []byte(sharedContainerRequest)} + if err := step.Run(ctx); err != nil { + t.Fatalf("Run() = %v", err) + } + if !called { + t.Error("the step passed through a payload naming its own binding on a later path pair") + } + if got := string(ctx.ResponseBody); !strings.Contains(got, `"weather"`) { + t.Errorf("answer = %s, want the step's own", got) + } +} diff --git a/pkg/plugin/implementation/internal/common/redact.go b/pkg/plugin/implementation/internal/common/redact.go index 1faa93be1..f76fc9b19 100644 --- a/pkg/plugin/implementation/internal/common/redact.go +++ b/pkg/plugin/implementation/internal/common/redact.go @@ -3,9 +3,11 @@ package common import ( "encoding/base64" + "encoding/json" "net/url" "os" "sort" + "strconv" "strings" "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common/util" @@ -138,11 +140,13 @@ func (a *authenticator) secretForms() []string { forms = append(forms, encoded) } return forms - case util.AuthSchemeTokenQuery: - // oauth2's two halves, with query's escaping: the secret goes to the - // token endpoint, and the token it returns travels in a QUERY STRING -- - // which is the exposed placement, so its escaped form has to be - // covered too or a token containing "+" or "=" survives redaction. + case util.AuthSchemeTokenQuery, util.AuthSchemeTokenHeader: + // oauth2's two halves: the secret goes to the token endpoint, and the + // token it returns travels in a query string under tokenQuery or a + // header under tokenHeader. Both forms are covered for both schemes -- + // the escaped one matters in a query string, where a token containing + // "+" or "=" would otherwise survive redaction, and covering it on + // tokenHeader as well costs nothing and cannot miss. var forms []string if secret := os.Getenv(a.cfg.TokenSecretEnv); secret != "" { forms = append(forms, secret) @@ -167,3 +171,71 @@ func longestFirst(forms []string) []string { sort.Slice(forms, func(i, j int) bool { return len(forms[i]) > len(forms[j]) }) return forms } + +// minSentValue is the shortest sent value worth masking. A provider's message +// is read by a person, and masking every short code -- a category "1", a +// season "2" -- would shred the sentence around the data it was meant to +// protect. Everything the packs mark no-log is far longer: a mobile is ten +// digits, an enrolment id twenty-one, a complaint a sentence. +const minSentValue = 4 + +// redactSent masks, in text a provider sent back, every value this adapter +// sent it. +// +// A provider quoting the request it refused is the ordinary shape of a +// rejection, and what we send carries the farmer's mobile, enrolment id and +// complaint -- fields the capability packs mark no-log, no-trace. redactString +// covers credentials, which are configured; this covers the request's own +// data, which is different on every call and so cannot be. +// +// Only what was actually SENT, not the whole Beckn payload: masking every +// string in the envelope would eat the capability name, the provider id and +// the action, leaving a log line that protects nothing and explains nothing. +// Nothing is lost by masking it -- it is what we sent, so it is already known. +func redactSent(text string, sent []byte) string { + if text == "" || len(sent) == 0 { + return text + } + var payload any + if json.Unmarshal(sent, &payload) != nil { + return text + } + // Longest first, so a value containing another is replaced before its + // substring -- the same reason secretForms sorts. + for _, value := range longestFirst(sentValues(payload, nil)) { + text = strings.ReplaceAll(text, value, util.RedactedMarker) + } + return text +} + +// sentValues collects every scalar in a sent request, as the text a provider +// would quote it back as. Numbers included: an id mapped as a number echoes as +// digits, and digits are what identify a farmer. +func sentValues(node any, found []string) []string { + switch value := node.(type) { + case map[string]any: + for _, child := range value { + found = sentValues(child, found) + } + case []any: + for _, child := range value { + found = sentValues(child, found) + } + case string: + if len(value) >= minSentValue { + found = append(found, value) + } + case json.Number: + if text := value.String(); len(text) >= minSentValue { + found = append(found, text) + } + case float64: + // encoding/json's default for every number. %v gives 9.876543210e+09 + // for a mobile, which is not how a provider would quote it, so the + // integral case is formatted as the digits it was sent as. + if text := strconv.FormatFloat(value, 'f', -1, 64); len(text) >= minSentValue { + found = append(found, text) + } + } + return found +} diff --git a/pkg/plugin/implementation/internal/common/redact_sent_test.go b/pkg/plugin/implementation/internal/common/redact_sent_test.go new file mode 100644 index 000000000..6a6475ef7 --- /dev/null +++ b/pkg/plugin/implementation/internal/common/redact_sent_test.go @@ -0,0 +1,65 @@ +package common + +import ( + "strings" + "testing" + + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common/util" +) + +// A provider refusing a call commonly quotes the request back. What we sent it +// carries the farmer's mobile, enrolment id and complaint, which the packs +// mark no-log -- so none of it may survive into the log line. +func TestRedactSent_MasksWhatTheRequestCarried(t *testing.T) { + sent := []byte(`{"requestorMobileNo":"9876543210",` + + `"GrievenceSupportTicketNo":"100626000099001",` + + `"ApplicationNo":"040108251010160770605",` + + `"GrievenceDescription":"Claim not received for Kharif"}`) + + text := "No record for 9876543210 against ticket 100626000099001 " + + "(enrolment 040108251010160770605): Claim not received for Kharif" + + got := redactSent(text, sent) + for _, secret := range []string{"9876543210", "100626000099001", + "040108251010160770605", "Claim not received for Kharif"} { + if strings.Contains(got, secret) { + t.Errorf("redactSent() kept %q; the request's own data must not reach the log:\n%s", secret, got) + } + } + if !strings.Contains(got, util.RedactedMarker) { + t.Errorf("redactSent() = %q, want the values replaced by the marker", got) + } + if !strings.Contains(got, "No record for") { + t.Errorf("redactSent() = %q, want the provider's own wording kept", got) + } +} + +// Masking every short code would shred the sentence it was meant to protect. +func TestRedactSent_KeepsTextThatIsNotOurs(t *testing.T) { + sent := []byte(`{"season":"1","category":"2","requestorMobileNo":"9876543210"}`) + got := redactSent("Season 1 category 2 is closed", sent) + if got != "Season 1 category 2 is closed" { + t.Errorf("redactSent() = %q, want the sentence unchanged", got) + } +} + +// A number mapped as a number echoes as digits, and digits identify a farmer. +func TestRedactSent_MasksNumbersAsDigits(t *testing.T) { + got := redactSent("ticket 109301 not found", []byte(`{"GrievenceSupportTicketID":109301}`)) + if strings.Contains(got, "109301") { + t.Errorf("redactSent() = %q, want the sent id masked", got) + } +} + +func TestRedactSent_SurvivesNonJSONAndEmpty(t *testing.T) { + for name, sent := range map[string][]byte{ + "not json": []byte("no"), + "empty": nil, + } { + t.Run(name, func(t *testing.T) { + if got := redactSent("upstream said no", sent); got != "upstream said no" { + t.Errorf("redactSent() = %q, want the text unchanged", got) + } + }) + } +} diff --git a/pkg/plugin/implementation/internal/common/serve.go b/pkg/plugin/implementation/internal/common/serve.go index 6d34eca71..5ddc8c44c 100644 --- a/pkg/plugin/implementation/internal/common/serve.go +++ b/pkg/plugin/implementation/internal/common/serve.go @@ -3,10 +3,12 @@ package common import ( + "bytes" "context" "encoding/json" "errors" "fmt" + "net/http" "slices" "strings" @@ -22,7 +24,7 @@ import ( // recognises its own work, so adding a provider is one config entry rather than // a routing-table change. func (s *Step) Run(ctx *model.StepContext) error { - binding, err := BindingFrom(s.paths, ctx.Body) + binding, err := s.bindingFor(ctx.Body) if errors.Is(err, errNoBinding) { return nil } @@ -82,6 +84,65 @@ func (s *Step) serves(key string) bool { return slices.Contains(s.config.BindingKeys, key) } +// bindingFor derives the binding this step should answer for, trying the +// configured path pairs in the order they were written. +// +// A pair that resolves a binding some other step serves does not end the +// search. Several steps share one pipeline, and a later pair may read a +// container holding more than one step's entry, so this step keeps looking for +// its own work. When none of the pairs names it, the first binding found is +// what comes back, so the pass-through log says what the payload was asking for. +func (s *Step) bindingFor(body []byte) (Binding, error) { + payload, err := decodePayload(body) + if err != nil { + return Binding{}, err + } + var first Binding + found := false + for i, paths := range s.paths { + binding, err := bindingIn(paths, payload) + switch { + case errors.Is(err, errNoBinding): + continue + case err != nil: + // The first pair is this step's own, so its refusal always stands. + // A later pair reads a shared container, such as the support + // channels, where another step's entry can make the payload name + // several bindings without it being this caller's fault. + if i > 0 && !s.namesServed(paths, payload) { + continue + } + return Binding{}, err + } + if s.serves(binding.Key()) { + return binding, nil + } + if !found { + first, found = binding, true + } + } + if found { + return first, nil + } + return Binding{}, errNoBinding +} + +// namesServed reports whether the payload names a binding this step serves. +// +// Takes the decoded payload rather than the body: it is reached only after +// bindingIn has already refused one, and re-reading the bytes to say which +// binding was named would parse the same document twice. +func (s *Step) namesServed(paths Paths, payload any) bool { + for _, provider := range ValuesAt(payload, paths.ProviderID) { + for _, capability := range ValuesAt(payload, paths.CapabilityCode) { + if s.serves(Binding{ParticipantID: provider, CapabilityCode: capability}.Key()) { + return true + } + } + } + return false +} + // 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) @@ -145,6 +206,15 @@ func (s *Step) serve(ctx *model.StepContext, plan *model.ProviderRecord) error { if err != nil { return err } + // The provider body is not logged: it can carry the farmer's details. + // Its message is, so it is stripped of both our credentials and whatever + // of the request it quoted back -- a refusal naming the mobile it could + // not find is the ordinary shape of one. + if cause, err := refusalIn(becknResponse); err != nil { + log.Warnf(ctx, "%s refused the request: %v (provider said: %q)", + plan.BindingKey, err, s.redactString(redactSent(cause, upstreamRequest))) + return err + } if len(becknResponse) == 0 { // No response half, or its transform matched nothing. Either way there // is no Beckn response, and returning the provider's own shape would @@ -172,12 +242,55 @@ func (s *Step) buildRequest(ctx context.Context, call model.ActionPlan, beckn an if err != nil { return nil, err } + if _, err := refusalIn(mapped); err != nil { + return nil, err + } if len(mapped) == 0 { log.Debugf(ctx, "the request half of %s produced nothing; sending an empty request", call.Mappings) } return mapped, nil } +// refusalIn returns the error a mapping asks for under the reserved _error +// field {status, code, message, path, cause}, or nil if there is none. Status +// 0 or 400 is a 400 with details.path. cause is the provider's own wording, +// returned for the log and never sent to the caller. +func refusalIn(mapped []byte) (string, error) { + // Cheap text check first: almost no answer carries the field. + if !bytes.Contains(mapped, []byte(`"_error"`)) { + return "", nil + } + // Only _error is held raw. Decoding the whole document into + // map[string]json.RawMessage would copy every other top-level value's + // bytes, and an answer carrying a case or an application is most of them. + var document struct { + Error json.RawMessage `json:"_error"` + } + if json.Unmarshal(mapped, &document) != nil || len(document.Error) == 0 || + string(document.Error) == "null" { + return "", nil + } + // Decoded separately so a malformed _error is told apart from an absent + // one: a single pass would fail whole and read as "no refusal here". + var r struct { + Status int `json:"status"` + Code string `json:"code"` + Message string `json:"message"` + Path string `json:"path"` + Cause string `json:"cause"` + } + if json.Unmarshal(document.Error, &r) != nil || r.Message == "" { + return "", errors.New("a mapping refused the call with an unreadable _error; it needs status, code and message") + } + switch r.Status { + case 0, http.StatusBadRequest: + // CodedErr drops details, so the path needs SchemaValidationErr. + return r.Cause, &model.SchemaValidationErr{Errors: []model.Error{*model.NewCodedErrorWithCause(r.Code, r.Message, r.Path, nil)}} + default: + return r.Cause, model.NewCodedErr(r.Status, r.Code, errors.New(r.Message)) + } +} + // extractAction reads the Beckn action a request is for. func extractAction(body []byte) string { var payload struct { diff --git a/pkg/plugin/implementation/internal/common/token.go b/pkg/plugin/implementation/internal/common/token.go index 88b09f5d0..1a4766c90 100644 --- a/pkg/plugin/implementation/internal/common/token.go +++ b/pkg/plugin/implementation/internal/common/token.go @@ -87,7 +87,7 @@ type tokenResponse struct { // issuer that failed. Unclassified they would surface as 500, telling a peer // this adapter broke when it did not. func (s *Step) exchangeToken(ctx context.Context, auth *authenticator) (string, time.Duration, error) { - if auth.cfg.Scheme == util.AuthSchemeTokenQuery { + if auth.cfg.Scheme == util.AuthSchemeTokenQuery || auth.cfg.Scheme == util.AuthSchemeTokenHeader { return s.exchangeQueryToken(ctx, auth) } return s.exchangeOAuth2Token(ctx, auth) @@ -96,14 +96,13 @@ func (s *Step) exchangeToken(ctx context.Context, auth *authenticator) (string, // exchangeQueryToken POSTs a JSON body of configured field names and reads the // token out of a configured response field. // -// The lifetime comes from config, not from the response: this endpoint says -// nothing about how long its token lives. That is the whole reason tokenTtl is -// required rather than defaulted -- see AuthProfile. +// The lifetime is read from tokenExpiryField when the endpoint states one, and +// taken from tokenTtl when it does not -- see AuthProfile. func (s *Step) exchangeQueryToken(ctx context.Context, auth *authenticator) (string, time.Duration, error) { cfg := auth.cfg user, secret := os.Getenv(cfg.TokenUserEnv), os.Getenv(cfg.TokenSecretEnv) if user == "" || secret == "" { - return "", 0, s.missingCredential(ctx, cfg.Provider, util.AuthSchemeTokenQuery, + return "", 0, s.missingCredential(ctx, cfg.Provider, cfg.Scheme, cfg.TokenUserEnv+" and "+cfg.TokenSecretEnv) } @@ -123,18 +122,29 @@ func (s *Step) exchangeQueryToken(ctx context.Context, auth *authenticator) (str return "", 0, err } - // Decoded into a map because the field name is configured: a struct tag - // cannot be written for a key that is not known until config is read. - var fields map[string]any + // Decoded generically because the token's path is configured. + var fields any if err := json.Unmarshal(body, &fields); err != nil { return "", 0, s.permanentTokenErr(fmt.Errorf("token response from %s is not JSON: %w", cfg.TokenURL, err)) } - token, ok := fields[cfg.TokenResponseField].(string) - if !ok || token == "" { + tokens := ValuesAt(fields, cfg.TokenResponseField) + if len(tokens) == 0 || tokens[0] == "" { return "", 0, s.permanentTokenErr(fmt.Errorf( "token response from %s carries no %s", cfg.TokenURL, cfg.TokenResponseField)) } + token := tokens[0] + + if cfg.TokenExpiryField != "" { + lifetime, ok := expiryLifetime(fields, cfg.TokenExpiryField) + if !ok { + // Not cached for a guessed period, the same rule as oauth2. + return "", 0, s.permanentTokenErr(fmt.Errorf( + "token response from %s carries no future %s, so its lifetime is unknown", + cfg.TokenURL, cfg.TokenExpiryField)) + } + return token, lifetime, nil + } // The skew is subtracted here for the same reason oauth2 subtracts it from // expires_in: a request that passed the expiry check must not arrive after @@ -246,6 +256,30 @@ func (s *Step) permanentTokenErr(err error) error { return util.DoNotRetry(s.tokenErr(err)) } +// expiryLifetime reads an expiry stated as Unix seconds or an RFC 3339 time and +// returns how long the token may be held, false when it is absent, unreadable, +// or already past. +func expiryLifetime(fields any, path string) (time.Duration, bool) { + leaves := LeavesAt(fields, path) + if len(leaves) == 0 { + return 0, false + } + var expiry time.Time + switch value := leaves[0].(type) { + case float64: + expiry = time.Unix(int64(value), 0) + case string: + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + return 0, false + } + expiry = parsed + default: + return 0, false + } + return tokenLifetime(int(time.Until(expiry)/time.Second), util.TokenRefreshSkew) +} + // tokenLifetime turns expires_in into how long the token may be held, or // reports that it may not be held at all. // diff --git a/pkg/plugin/implementation/internal/common/tokenheader_test.go b/pkg/plugin/implementation/internal/common/tokenheader_test.go new file mode 100644 index 000000000..01236b4d5 --- /dev/null +++ b/pkg/plugin/implementation/internal/common/tokenheader_test.go @@ -0,0 +1,405 @@ +// tokenHeader: tokenQuery's exchange, the bare token sent in a configured header. +package common + +import ( + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/beckn-one/beckn-onix/pkg/model" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common/util" +) + +// assertStatus fails unless err is a CodedErr carrying status. +func assertStatus(t *testing.T, err error, status int) { + t.Helper() + var coded *model.CodedErr + if !errors.As(err, &coded) || coded.HTTPStatus() != status { + t.Fatalf("error = %v, want a coded %d", err, status) + } +} + +// tokenHeaderProfile is tokenQuery's profile under the header scheme. queryName +// is cleared to prove it is not needed. +func tokenHeaderProfile(tokenURL string) AuthProfile { + profile := tokenQueryProfile(tokenURL) + profile.Scheme = util.AuthSchemeTokenHeader + profile.QueryName = "" + profile.HeaderName = "Authorization" + return profile +} + +// tokenHeaderStep records the Authorization header and query each provider call +// arrived with. status, when non-zero, is what the provider answers. +func tokenHeaderStep(t *testing.T, tokenURL string, headers, queries *[]string, status *int) *Step { + t.Helper() + provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + *headers = append(*headers, r.Header.Get("Authorization")) + *queries = append(*queries, r.URL.RawQuery) + if status != nil && *status != 0 { + w.WriteHeader(*status) + return + } + fmt.Fprint(w, `{"ok":true}`) + })) + t.Cleanup(provider.Close) + + return newStep(t, &stubRegistry{plan: testPlan(provider.URL, http.MethodPost)}, + &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{"a":1}`)}, + func(c *Config) { + profile := tokenHeaderProfile(tokenURL) + if err := profile.validate(); err != nil { + t.Fatalf("profile does not validate: %v", err) + } + c.setProviderAuth(profile) + }) +} + +func TestTokenHeader_ValidLogin_SendsBareTokenInHeaderNotQuery(t *testing.T) { + t.Setenv("TEST_TQ_USER", "pmfby-user") + t.Setenv("TEST_TQ_SECRET", "pmfby-password") + + ts := &queryTokenServer{token: "the-token"} + var headers, queries []string + step := tokenHeaderStep(t, ts.start(t), &headers, &queries, nil) + + if _, err := runStep(t, step, selectBody); err != nil { + t.Fatalf("Run() returned an unexpected error: %v", err) + } + if len(headers) != 1 || headers[0] != "the-token" { + t.Errorf("provider saw Authorization %q, want the bare token, no Bearer prefix", headers) + } + if strings.Contains(queries[0], "the-token") { + t.Errorf("provider saw query %q; the token belongs in the header only", queries[0]) + } + if ts.gotUser != "pmfby-user" || ts.gotSecret != "pmfby-password" || ts.gotType != "application/json" { + t.Errorf("login saw %q/%q as %q, want the configured pair as JSON", ts.gotUser, ts.gotSecret, ts.gotType) + } +} + +// PMFBY's login wraps the token in its reply envelope, so the field is a path. +func TestTokenHeader_NestedTokenField_ReadsTheDottedPath(t *testing.T) { + t.Setenv("TEST_TQ_USER", "user") + t.Setenv("TEST_TQ_SECRET", "secret") + + login := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, `{"responseCode":"1","responseDynamic":{"token":{"Token":"nested-token"}}}`) + })) + t.Cleanup(login.Close) + var authorization string + provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authorization = r.Header.Get("Authorization") + fmt.Fprint(w, `{"ok":true}`) + })) + t.Cleanup(provider.Close) + + step := newStep(t, &stubRegistry{plan: testPlan(provider.URL, http.MethodPost)}, + &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{"a":1}`)}, + func(c *Config) { + profile := tokenHeaderProfile(login.URL) + profile.TokenResponseField = "responseDynamic.token.Token" + if err := profile.validate(); err != nil { + t.Fatalf("profile does not validate: %v", err) + } + c.setProviderAuth(profile) + }) + + if _, err := runStep(t, step, selectBody); err != nil { + t.Fatalf("Run() returned an unexpected error: %v", err) + } + if authorization != "nested-token" { + t.Errorf("provider saw Authorization %q, want the token read from the nested path", authorization) + } +} + +func TestTokenHeader_RepeatedCalls_LogsInOnce(t *testing.T) { + t.Setenv("TEST_TQ_USER", "user") + t.Setenv("TEST_TQ_SECRET", "secret") + + ts := &queryTokenServer{} + var headers, queries []string + step := tokenHeaderStep(t, ts.start(t), &headers, &queries, nil) + + for i := 0; i < 3; i++ { + if _, err := runStep(t, step, selectBody); err != nil { + t.Fatalf("request %d: %v", i, err) + } + } + if got := ts.calls.Load(); got != 1 { + t.Errorf("login called %d times for 3 requests, want 1", got) + } +} + +func TestTokenHeader_ProviderRejectsToken_LogsInAgainNextCall(t *testing.T) { + t.Setenv("TEST_TQ_USER", "user") + t.Setenv("TEST_TQ_SECRET", "secret") + + ts := &queryTokenServer{} + var headers, queries []string + status := http.StatusUnauthorized + step := tokenHeaderStep(t, ts.start(t), &headers, &queries, &status) + + if _, err := runStep(t, step, selectBody); err == nil { + t.Fatal("Run() succeeded against a 401, want an error") + } + status = 0 + if _, err := runStep(t, step, selectBody); err != nil { + t.Fatalf("second Run() = %v, want it to recover with a fresh token", err) + } + if got := ts.calls.Load(); got != 2 || headers[1] != "tok-2" { + t.Errorf("login called %d times, second call sent %q; want 2 and tok-2", got, headers[1]) + } +} + +func TestTokenHeader_LoginFails_Returns502WithoutCallingProvider(t *testing.T) { + t.Setenv("TEST_TQ_USER", "user") + t.Setenv("TEST_TQ_SECRET", "wrong") + + ts := &queryTokenServer{status: http.StatusUnauthorized, body: `{"message":"invalid credentials"}`} + var headers, queries []string + step := tokenHeaderStep(t, ts.start(t), &headers, &queries, nil) + + _, err := runStep(t, step, selectBody) + assertStatus(t, err, http.StatusBadGateway) + if len(headers) != 0 { + t.Errorf("provider was called %d times without a token, want 0", len(headers)) + } +} + +func TestTokenHeader_LoginResponseWithoutToken_Returns502(t *testing.T) { + t.Setenv("TEST_TQ_USER", "user") + t.Setenv("TEST_TQ_SECRET", "secret") + + ts := &queryTokenServer{field: "accessToken"} + var headers, queries []string + step := tokenHeaderStep(t, ts.start(t), &headers, &queries, nil) + + _, err := runStep(t, step, selectBody) + assertStatus(t, err, http.StatusBadGateway) +} + +func TestTokenHeader_CredentialUnset_FailsPermanentlyWithoutLogin(t *testing.T) { + t.Setenv("TEST_TQ_USER", "") + t.Setenv("TEST_TQ_SECRET", "") + + ts := &queryTokenServer{} + var headers, queries []string + step := tokenHeaderStep(t, ts.start(t), &headers, &queries, nil) + + _, err := runStep(t, step, selectBody) + if err == nil || ts.calls.Load() != 0 || len(headers) != 0 { + t.Fatalf("err %v, %d logins, %d provider calls; want an error and no calls", err, ts.calls.Load(), len(headers)) + } + if strings.Contains(err.Error(), "TEST_TQ_USER") { + t.Errorf("error %q names the environment variable", err) + } + if !strings.Contains(err.Error(), util.AuthSchemeTokenHeader) { + t.Errorf("error %q should name the scheme", err) + } +} + +func TestTokenHeader_AfterCall_RedactsSecretAndToken(t *testing.T) { + t.Setenv("TEST_TQ_USER", "an-identifier") + t.Setenv("TEST_TQ_SECRET", "the-secret-value") + + ts := &queryTokenServer{token: "the-token-value"} + var headers, queries []string + step := tokenHeaderStep(t, ts.start(t), &headers, &queries, nil) + if _, err := runStep(t, step, selectBody); err != nil { + t.Fatal(err) + } + + redacted := step.redactString("secret the-secret-value token the-token-value user an-identifier") + if strings.Contains(redacted, "the-secret-value") || strings.Contains(redacted, "the-token-value") { + t.Errorf("redacted %q still carries a credential", redacted) + } + if !strings.Contains(redacted, "an-identifier") { + t.Errorf("redacted %q hides the identifier, which is not a secret", redacted) + } +} + +func TestTokenHeader_IncompleteProfile_RefusedAtStartup(t *testing.T) { + for name, blank := range map[string]func(*AuthProfile){ + "tokenUrl": func(p *AuthProfile) { p.TokenURL = "" }, + "tokenUserField": func(p *AuthProfile) { p.TokenUserField = "" }, + "tokenUserEnv": func(p *AuthProfile) { p.TokenUserEnv = "" }, + "tokenSecretField": func(p *AuthProfile) { p.TokenSecretName = "" }, + "tokenSecretEnv": func(p *AuthProfile) { p.TokenSecretEnv = "" }, + "tokenResponseField": func(p *AuthProfile) { p.TokenResponseField = "" }, + "tokenTtl": func(p *AuthProfile) { p.TokenTTLRaw = "" }, + "headerName": func(p *AuthProfile) { p.HeaderName = "" }, + "tokenTtl not a duration": func(p *AuthProfile) { p.TokenTTLRaw = "ten minutes" }, + "tokenTtl within skew": func(p *AuthProfile) { p.TokenTTLRaw = "30s" }, + } { + t.Run(name, func(t *testing.T) { + profile := tokenHeaderProfile("https://login.example") + profile.Provider = "pmfby" + blank(&profile) + err := profile.validate() + if err == nil { + t.Fatal("validate() accepted an incomplete tokenHeader profile") + } + if !strings.Contains(err.Error(), "pmfby") { + t.Errorf("error %q should name the provider", err) + } + }) + } +} + +func TestTokenQuery_WithoutQueryName_StillRefused(t *testing.T) { + profile := tokenQueryProfile("https://login.example") + profile.QueryName = "" + if err := profile.validate(); err == nil || !strings.Contains(err.Error(), "queryName") { + t.Errorf("validate() = %v, want tokenQuery refused without queryName", err) + } +} + +func TestParseProviderAuth_TokenHeaderBlock_BuildsProfile(t *testing.T) { + profiles, err := ParseProviderAuth(map[string]string{ + "authScheme-pmfby": "tokenHeader", + "tokenUrl-pmfby": "https://login.example", + "tokenUserField-pmfby": "userName", + "tokenUserEnv-pmfby": "PMFBY_USER", + "tokenSecretField-pmfby": "password", + "tokenSecretEnv-pmfby": "PMFBY_PASSWORD", + "tokenResponseField-pmfby": "token", + "tokenTtl-pmfby": "10m", + "headerName-pmfby": "Authorization", + }) + if err != nil { + t.Fatal(err) + } + profile := profiles["pmfby"] + if profile == nil || profile.Scheme != util.AuthSchemeTokenHeader { + t.Fatalf("profiles = %+v, want a tokenHeader profile for pmfby", profiles) + } + if err := profile.validate(); err != nil { + t.Errorf("validate() = %v, want the parsed profile accepted", err) + } +} + +// expiryLoginStep logs in at a server whose reply states the token's expiry, as +// PMFBY's does, and counts the logins. +func expiryLoginStep(t *testing.T, reply string, logins *atomic.Int32) *Step { + t.Helper() + login := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + logins.Add(1) + fmt.Fprint(w, reply) + })) + t.Cleanup(login.Close) + provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, `{"ok":true}`) + })) + t.Cleanup(provider.Close) + + return newStep(t, &stubRegistry{plan: testPlan(provider.URL, http.MethodPost)}, + &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{"a":1}`)}, + func(c *Config) { + profile := tokenHeaderProfile(login.URL) + profile.TokenResponseField = "responseDynamic.token.Token" + profile.TokenTTLRaw = "" + profile.TokenExpiryField = "responseDynamic.token.validTo" + if err := profile.validate(); err != nil { + t.Fatalf("profile does not validate: %v", err) + } + c.setProviderAuth(profile) + }) +} + +// pmfbyLoginReply is PMFBY's login reply. Its expirationTime is the moment of +// issue, not the expiry; validTo is the expiry. +func pmfbyLoginReply(validTo string) string { + return `{"responseCode":"1","responseDynamic":{"token":{"Token":"tok",` + + fmt.Sprintf(`"expirationTime":%d,"validTo":%s}}}`, time.Now().Unix(), validTo) +} + +func TestTokenHeader_ExpiryInReply_HeldWithoutTokenTtl(t *testing.T) { + t.Setenv("TEST_TQ_USER", "user") + t.Setenv("TEST_TQ_SECRET", "secret") + + var logins atomic.Int32 + inHalfAnHour := `"` + time.Now().Add(30*time.Minute).UTC().Format("2006-01-02T15:04:05.000Z") + `"` + step := expiryLoginStep(t, pmfbyLoginReply(inHalfAnHour), &logins) + + for i := 0; i < 3; i++ { + if _, err := runStep(t, step, selectBody); err != nil { + t.Fatalf("request %d: %v", i, err) + } + } + if got := logins.Load(); got != 1 { + t.Errorf("login called %d times for 3 requests, want 1", got) + } +} + +func TestTokenHeader_ExpiryMissingOrPast_Returns502AndCachesNothing(t *testing.T) { + for name, validTo := range map[string]string{ + "missing": "null", + "not a time": `"soon"`, + "past": `"` + time.Now().Add(-time.Minute).UTC().Format(time.RFC3339) + `"`, + } { + t.Run(name, func(t *testing.T) { + t.Setenv("TEST_TQ_USER", "user") + t.Setenv("TEST_TQ_SECRET", "secret") + + var logins atomic.Int32 + step := expiryLoginStep(t, pmfbyLoginReply(validTo), &logins) + + _, err := runStep(t, step, selectBody) + assertStatus(t, err, http.StatusBadGateway) + _, _ = runStep(t, step, selectBody) + if got := logins.Load(); got != 2 { + t.Errorf("login called %d times for 2 requests, want 2: nothing is cached", got) + } + }) + } +} + +func TestExpiryLifetime_FutureExpiry_ReturnsTimeLeftLessSkew(t *testing.T) { + expiry := time.Now().Add(30 * time.Minute) + for name, stated := range map[string]any{ + "unix seconds": float64(expiry.Unix()), + "rfc 3339": expiry.UTC().Format(time.RFC3339Nano), + } { + t.Run(name, func(t *testing.T) { + lifetime, ok := expiryLifetime(map[string]any{"token": map[string]any{"expiry": stated}}, "token.expiry") + want := 30*time.Minute - util.TokenRefreshSkew + if !ok || lifetime > want || lifetime < want-2*time.Second { + t.Errorf("expiryLifetime() = %s, %v; want about %s", lifetime, ok, want) + } + }) + } +} + +func TestTokenHeader_TtlAndExpiryField_RefusedAtStartup(t *testing.T) { + profile := tokenHeaderProfile("https://login.example") + profile.Provider = "pmfby" + profile.TokenExpiryField = "responseDynamic.token.validTo" + if err := profile.validate(); err == nil || !strings.Contains(err.Error(), "not both") { + t.Errorf("validate() = %v, want tokenTtl and tokenExpiryField refused together", err) + } +} + +func TestParseProviderAuth_TokenExpiryField_BuildsProfileWithoutTtl(t *testing.T) { + profiles, err := ParseProviderAuth(map[string]string{ + "authScheme-pmfby": "tokenHeader", + "tokenUrl-pmfby": "https://login.example", + "tokenUserField-pmfby": "userName", + "tokenUserEnv-pmfby": "PMFBY_USER", + "tokenSecretField-pmfby": "password", + "tokenSecretEnv-pmfby": "PMFBY_PASSWORD", + "tokenResponseField-pmfby": "responseDynamic.token.Token", + "tokenExpiryField-pmfby": "responseDynamic.token.validTo", + "headerName-pmfby": "Authorization", + }) + if err != nil { + t.Fatal(err) + } + if err := profiles["pmfby"].validate(); err != nil { + t.Errorf("validate() = %v, want the profile accepted without tokenTtl", err) + } +} diff --git a/pkg/plugin/implementation/internal/common/util/constant.go b/pkg/plugin/implementation/internal/common/util/constant.go index df4cb1d12..5d305d6e8 100644 --- a/pkg/plugin/implementation/internal/common/util/constant.go +++ b/pkg/plugin/implementation/internal/common/util/constant.go @@ -80,6 +80,10 @@ const ( // Inherits query's exposure -- the token reaches proxy logs and transport // errors -- so it is redacted the same way. See redact. AuthSchemeTokenQuery = "tokenQuery" + + // tokenQuery's login exchange, but the token is sent bare (no "Bearer") + // in the configured header. + AuthSchemeTokenHeader = "tokenHeader" ) // CodeUpstreamUnavailable: the provider could not be reached or failed. Not diff --git a/pkg/plugin/implementation/schemav2validator/extended_schema.go b/pkg/plugin/implementation/schemav2validator/extended_schema.go index 1c3d7169f..894e08597 100644 --- a/pkg/plugin/implementation/schemav2validator/extended_schema.go +++ b/pkg/plugin/implementation/schemav2validator/extended_schema.go @@ -166,7 +166,11 @@ func preloadSchemasToCache(ctx context.Context, c *schemaCache, baseDir string) } // validateExtendedSchemas validates all objects with @context against their schemas. -func (v *schemav2Validator) validateExtendedSchemas(ctx context.Context, body interface{}) error { +// +// action is the Beckn action the payload was sent for. A pack that describes +// several calls with one schema may nominate a schema per action, and this is +// the layer that knows which one to ask for. +func (v *schemav2Validator) validateExtendedSchemas(ctx context.Context, body interface{}, action string) error { // Extract "message" object - scan inside message bodyMap, ok := body.(map[string]interface{}) if !ok { @@ -212,7 +216,7 @@ func (v *schemav2Validator) validateExtendedSchemas(ctx context.Context, body in log.Debugf(ctx, "Validating object at path: %s, @context: %s, @type: %s", obj.Path, obj.Context, obj.Type) - if err := v.schemaCache.validateReferencedObject(ctx, obj, ttl, timeout, allowedDomains, localSchema); err != nil { + if err := v.schemaCache.validateReferencedObject(ctx, obj, action, ttl, timeout, allowedDomains, localSchema); err != nil { var schemaErrors []model.Error v.extractSchemaErrors(err, &schemaErrors) @@ -228,6 +232,9 @@ func (v *schemav2Validator) validateExtendedSchemas(ctx context.Context, body in // prefixSchemaErrorPaths prefixes objPath onto each error's Details.Path, // updating it in place when Details already exists so any other field (e.g. // a chained Cause) set upstream is preserved rather than discarded. +// +// Where the object sits is supplied here, so a refusal about the object as a +// whole leaves its path empty rather than naming the same place twice. func prefixSchemaErrorPaths(schemaErrors []model.Error, objPath string) { for i := range schemaErrors { prefixed := objPath @@ -718,10 +725,70 @@ func schemaDeclaresProperty(ref *openapi3.SchemaRef, name string, seen map[*open return false } +// schemaForAction returns the schema a pack nominates for this action under +// x-oan-schema-by-action, or ("", nil) when it nominates none. +// +// One schema covering several calls can only tell them apart by which fields +// a payload happens to carry, and the keyword that does it -- anyOf -- is the +// one whose per-arm errors kin-openapi discards. A caller who omitted a single +// field is then told that nothing matched, with no field named, and a payload +// for one scheme draws complaints belonging to another. Naming a schema per +// action removes the guess: the call is already known here. +// +// Absent the extension nothing changes, so a pack that does not use it, and +// every other domain sharing this validator, resolves by @type as before. +func schemaForAction(doc *openapi3.T, schema *openapi3.SchemaRef, action string) (*openapi3.SchemaRef, string, error) { + byAction, declared := schema.Value.Extensions["x-oan-schema-by-action"].(map[string]interface{}) + if !declared { + return nil, "", nil + } + name, served := byAction[action].(string) + if !served { + // The pack lists the actions it serves, so one it does not list is + // refused by name rather than validated against a union that would + // accept it. This is the "PMFBY has no init" case, and the caller is + // better told that than handed a missing-field list for a call that + // does not exist. + return nil, "", fmt.Errorf("%s does not serve action %q; it serves %s", + schemaTitle(schema), action, strings.Join(sortedKeys(byAction), ", ")) + } + nominated, found := doc.Components.Schemas[name] + if !found || nominated.Value == nil { + return nil, "", fmt.Errorf("the schema nominates %q for action %q and the document does not define it", name, action) + } + return nominated, name, nil +} + +// schemaTitle names a schema for an error message, falling back to its +// JSON-LD type when it carries no title. +func schemaTitle(schema *openapi3.SchemaRef) string { + if schema.Value.Title != "" { + return schema.Value.Title + } + if xJSONLD, ok := schema.Value.Extensions["x-jsonld"].(map[string]interface{}); ok { + if atType, ok := xJSONLD["@type"].(string); ok { + return atType + } + } + return "this capability" +} + +// sortedKeys returns a map's keys in order, so an error message naming the +// served actions reads the same on every run. +func sortedKeys(m map[string]interface{}) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + slices.Sort(keys) + return keys +} + // validateReferencedObject validates a single object with @context. func (c *schemaCache) validateReferencedObject( ctx context.Context, obj referencedObject, + action string, ttl, timeout time.Duration, allowedDomains []string, localSchema bool, @@ -774,7 +841,7 @@ func (c *schemaCache) validateReferencedObject( var err error 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) + return model.NewCodedErrorWithCause("SCH_SCHEMA_ADAPTATION_FAILED", err.Error(), "", err) } } @@ -785,7 +852,16 @@ func (c *schemaCache) validateReferencedObject( } 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) + return model.NewCodedErrorWithCause("SCH_INVALID_ENTITY_TYPE", err.Error(), "", err) + } + + // @type names the entity; the action names the call made on it. A pack + // that tells the two apart is validated against the one call it is for. + if nominated, name, err := schemaForAction(doc, schema, action); err != nil { + return model.NewCodedErrorWithCause("SCH_ACTION_NOT_SUPPORTED", err.Error(), "", err) + } else if name != "" { + log.Debugf(ctx, "action %s on @type %s validates against %s", action, matched, name) + schema = nominated } // Strip only the JSON-LD keys this schema does not account for itself. diff --git a/pkg/plugin/implementation/schemav2validator/extended_schema_test.go b/pkg/plugin/implementation/schemav2validator/extended_schema_test.go index 9facca442..698c5445f 100644 --- a/pkg/plugin/implementation/schemav2validator/extended_schema_test.go +++ b/pkg/plugin/implementation/schemav2validator/extended_schema_test.go @@ -632,7 +632,7 @@ components: }, } - 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) } @@ -670,7 +670,7 @@ components: }, } - 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{} @@ -693,7 +693,7 @@ func TestValidateReferencedObject_DomainNotAllowed(t *testing.T) { allowedDomains := []string{"trusted.com"} - err := cache.validateReferencedObject(ctx, obj, 1*time.Hour, 30*time.Second, allowedDomains, false) + err := cache.validateReferencedObject(ctx, obj, "", 1*time.Hour, 30*time.Second, allowedDomains, false) assert.Error(t, err) assert.Contains(t, err.Error(), "domain not allowed") @@ -728,15 +728,17 @@ components: }, } - 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) if !ok || becknErr.Code != "SCH_INVALID_ENTITY_TYPE" { t.Errorf("err = %+v (%T), want *model.Error with Code=SCH_INVALID_ENTITY_TYPE", err, err) } - if becknErr.Details == nil || becknErr.Details.Path != obj.Path { - t.Errorf("Details.Path = %+v, want %q", becknErr.Details, obj.Path) + // Empty, not obj.Path: validateExtendedSchemas prefixes where the object + // sits, and naming it here too would deliver it twice over. + if becknErr.Details != nil && becknErr.Details.Path != "" { + t.Errorf("Details.Path = %q, want it left for the caller to fill", becknErr.Details.Path) } if becknErr.Unwrap() == nil { t.Error("expected Unwrap() to reach the underlying findSchemaByType error, got nil") @@ -754,15 +756,17 @@ func TestValidateReferencedObject_SchemaLoadFailure(t *testing.T) { Data: map[string]interface{}{}, } - 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) if !ok || becknErr.Code != "SCH_SCHEMA_ADAPTATION_FAILED" { t.Errorf("err = %+v (%T), want *model.Error with Code=SCH_SCHEMA_ADAPTATION_FAILED", err, err) } - if becknErr.Details == nil || becknErr.Details.Path != obj.Path { - t.Errorf("Details.Path = %+v, want %q", becknErr.Details, obj.Path) + // Empty, not obj.Path: validateExtendedSchemas prefixes where the object + // sits, and naming it here too would deliver it twice over. + if becknErr.Details != nil && becknErr.Details.Path != "" { + t.Errorf("Details.Path = %q, want it left for the caller to fill", becknErr.Details.Path) } if becknErr.Unwrap() == nil { t.Error("expected Unwrap() to reach the underlying loadSchemaFromPath error, got nil") @@ -803,7 +807,7 @@ func TestValidateReferencedObject_NonHttpSchemeRejectedWhenAllowlistSet(t *testi allowedDomains := []string{"raw.githubusercontent.com"} - err := cache.validateReferencedObject(ctx, obj, 1*time.Hour, 30*time.Second, allowedDomains, false) + err := cache.validateReferencedObject(ctx, obj, "", 1*time.Hour, 30*time.Second, allowedDomains, false) assert.Error(t, err) assert.Contains(t, err.Error(), "invalid scheme in @context") @@ -863,7 +867,7 @@ components: Data: map[string]interface{}{"field1": "value1"}, } - err = cache.validateReferencedObject(ctx, obj, 1*time.Hour, 30*time.Second, tt.allowedDomains, false) + err = cache.validateReferencedObject(ctx, obj, "", 1*time.Hour, 30*time.Second, tt.allowedDomains, false) // So: no domain error and no @context scheme error, which is what // an empty allowlist means. Not "anything is readable". if err != nil { @@ -890,7 +894,7 @@ func TestValidateExtendedSchemas_NoObjects(t *testing.T) { }, } - err := v.validateExtendedSchemas(ctx, body) + err := v.validateExtendedSchemas(ctx, body, "") assert.NoError(t, err) } @@ -907,7 +911,7 @@ func TestValidateExtendedSchemas_MissingMessage(t *testing.T) { "context": map[string]interface{}{}, } - err := v.validateExtendedSchemas(ctx, body) + err := v.validateExtendedSchemas(ctx, body, "") assert.Error(t, err) assert.Contains(t, err.Error(), "missing 'message' field") } @@ -939,7 +943,7 @@ func TestValidateExtendedSchemas_DomainNotAllowed_PropagatesCode(t *testing.T) { }, } - err := v.validateExtendedSchemas(ctx, body) + err := v.validateExtendedSchemas(ctx, body, "") if err == nil { t.Fatal("expected an error") } @@ -1274,7 +1278,7 @@ components: } // localSchema=true, no @context — relies entirely on rawSchemas lookup by @type - err := cache.validateReferencedObject(ctx, obj, 1*time.Hour, 30*time.Second, nil, true) + err := cache.validateReferencedObject(ctx, obj, "", 1*time.Hour, 30*time.Second, nil, true) assert.NoError(t, err) } @@ -1306,7 +1310,7 @@ components: } // 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) + err := cache.validateReferencedObject(ctx, obj, "", 1*time.Hour, 30*time.Second, nil, true) assert.NoError(t, err) } @@ -1348,7 +1352,7 @@ components: Data: map[string]interface{}{"field1": "value1"}, } - err := cache.validateReferencedObject(ctx, obj, 1*time.Hour, 30*time.Second, allowedDomains, false) + err := cache.validateReferencedObject(ctx, obj, "", 1*time.Hour, 30*time.Second, allowedDomains, false) assert.NoError(t, err, "valid http URL with allowlisted host should pass") } @@ -1438,7 +1442,7 @@ func TestValidateReferencedObject_PackStyleKeepsAtType(t *testing.T) { }, } - err := cache.validateReferencedObject(context.Background(), obj, 1*time.Hour, 30*time.Second, nil, false) + err := cache.validateReferencedObject(context.Background(), obj, "", 1*time.Hour, 30*time.Second, nil, false) assert.NoError(t, err) } @@ -1579,7 +1583,7 @@ func TestValidateReferencedObject_AcceptsAndChecksATypeList(t *testing.T) { 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) + context.Background(), obj, "", 1*time.Hour, 30*time.Second, nil, false) if !tt.wantErr { assert.NoError(t, err) @@ -1668,7 +1672,7 @@ func TestFindReferencedObjects_TypeShapes(t *testing.T) { // and it must actually reject when validated err := newSchemaCache(10).validateReferencedObject( - context.Background(), obj, 1*time.Hour, 30*time.Second, nil, false) + context.Background(), obj, "", 1*time.Hour, 30*time.Second, nil, false) assert.Error(t, err) return } @@ -1858,7 +1862,7 @@ func TestValidateReferencedObject_RefusesARefThatWouldReadTheDisk(t *testing.T) Data: map[string]interface{}{"field1": "value1"}, } - err = cache.validateReferencedObject(context.Background(), obj, + 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 @@ -1907,7 +1911,7 @@ func TestValidateReferencedObject_RefusesARefToAHostOutsideTheAllowlist(t *testi } // Only the entry host is allowlisted. The $ref host is not. - err = cache.validateReferencedObject(context.Background(), obj, + 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") @@ -1940,7 +1944,7 @@ func TestValidateReferencedObject_AllowsARefWhenBothHostsAreAllowlisted(t *testi Data: map[string]interface{}{"field1": "value1"}, } - if err := cache.validateReferencedObject(context.Background(), obj, + 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) } @@ -1987,3 +1991,63 @@ func TestPayloadDirectedReader(t *testing.T) { }) } } + +func TestSchemaForAction(t *testing.T) { + doc := &openapi3.T{Components: &openapi3.Components{Schemas: openapi3.Schemas{ + "GrievanceStatus": openapi3.NewSchemaRef("", openapi3.NewObjectSchema()), + "GrievanceInit": openapi3.NewSchemaRef("", openapi3.NewObjectSchema()), + }}} + nominating := func(byAction map[string]interface{}) *openapi3.SchemaRef { + schema := openapi3.NewObjectSchema() + schema.Title = "openagrinet:Grievance" + schema.Extensions = map[string]interface{}{"x-oan-schema-by-action": byAction} + return openapi3.NewSchemaRef("", schema) + } + served := map[string]interface{}{"init": "GrievanceInit", "status": "GrievanceStatus"} + + tests := []struct { + name string + schema *openapi3.SchemaRef + action string + wantName string + wantErr string + }{{ + name: "a pack nominating nothing resolves by @type as before", action: "status", + schema: openapi3.NewSchemaRef("", openapi3.NewObjectSchema()), + }, { + name: "the call names its schema", action: "status", + schema: nominating(served), wantName: "GrievanceStatus", + }, { + name: "a call the pack does not serve is refused by name", action: "confirm", + schema: nominating(served), + wantErr: `openagrinet:Grievance does not serve action "confirm"; it serves init, status`, + }, { + name: "nominating a schema the document lacks is the pack's mistake", action: "status", + schema: nominating(map[string]interface{}{"status": "Absent"}), + wantErr: `the schema nominates "Absent" for action "status" and the document does not define it`, + }} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + nominated, name, err := schemaForAction(doc, tt.schema, tt.action) + if tt.wantErr != "" { + if err == nil || err.Error() != tt.wantErr { + t.Fatalf("err = %v, want %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if name != tt.wantName { + t.Errorf("name = %q, want %q", name, tt.wantName) + } + if tt.wantName == "" && nominated != nil { + t.Errorf("nominated = %v, want nil", nominated) + } + if tt.wantName != "" && nominated != doc.Components.Schemas[tt.wantName] { + t.Errorf("nominated is not the document's %s", tt.wantName) + } + }) + } +} diff --git a/pkg/plugin/implementation/schemav2validator/schemav2validator.go b/pkg/plugin/implementation/schemav2validator/schemav2validator.go index ba4eb3a73..381641aa1 100644 --- a/pkg/plugin/implementation/schemav2validator/schemav2validator.go +++ b/pkg/plugin/implementation/schemav2validator/schemav2validator.go @@ -3,12 +3,14 @@ package schemav2validator import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" "net/url" "os" "path/filepath" + "reflect" "strings" "sync" "time" @@ -30,17 +32,17 @@ type payload struct { type schemav2Validator struct { config *Config specMutex sync.RWMutex - specsLoaded bool // true once at least one successful loadAllSpecs has completed - actionSchemas map[string]*openapi3.SchemaRef // merged across primary + all auxiliary specs - bodylessActions map[string]struct{} // merged across primary + all auxiliary specs - schemaCache *schemaCache // cache for extended schemas + specsLoaded bool // true once at least one successful loadAllSpecs has completed + actionSchemas map[string]*openapi3.SchemaRef // merged across primary + all auxiliary specs + bodylessActions map[string]struct{} // merged across primary + all auxiliary specs + schemaCache *schemaCache // cache for extended schemas } // cachedSpec holds a cached OpenAPI spec. type cachedSpec struct { doc *openapi3.T actionSchemas map[string]*openapi3.SchemaRef // body operations: action → schema (O(1) lookup) - bodylessActions map[string]struct{} // bodyless operations: path without leading slash → exists + bodylessActions map[string]struct{} // bodyless operations: path without leading slash → exists loadedAt time.Time } @@ -210,7 +212,7 @@ func (v *schemav2Validator) Validate(ctx context.Context, reqURL *url.URL, data // Extended Schema validation (if enabled) if v.config.EnableExtendedSchema && v.schemaCache != nil { log.Debugf(ctx, "Starting Extended Schema validation for action: %s", action) - if err := v.validateExtendedSchemas(ctx, jsonData); err != nil { + if err := v.validateExtendedSchemas(ctx, jsonData, action); err != nil { // Extended Schema failure - return error log.Debugf(ctx, "Extended Schema validation failed for action %s: %v", action, err) return err @@ -565,40 +567,20 @@ func (v *schemav2Validator) extractSchemaErrors(err error, schemaErrors *[]model // domain/JSON-LD checks) — pass it through as-is. *schemaErrors = append(*schemaErrors, *becknErr) } else if schemaErr, ok := err.(*openapi3.SchemaError); ok { - // Extract path from current error and message from Origin if available - pathParts := schemaErr.JSONPointer() - path := strings.Join(pathParts, "/") - if path == "" { - path = schemaErr.SchemaField - } - - message := schemaErr.Reason - if schemaErr.Origin != nil { - originMsg := schemaErr.Origin.Error() - // Extract specific field error from nested message - if strings.Contains(originMsg, "Error at \"/") { - // Find last "Error at" which has the specific field error - parts := strings.Split(originMsg, "Error at \"") - if len(parts) > 1 { - lastPart := parts[len(parts)-1] - // Extract field path and update both path and message - if idx := strings.Index(lastPart, "\":"); idx > 0 { - fieldPath := lastPart[:idx] - fieldMsg := strings.TrimSpace(lastPart[idx+2:]) - path = strings.TrimPrefix(fieldPath, "/") - message = fieldMsg - } - } - } else { - message = originMsg + // A composite keyword (allOf/oneOf) carries its branches' real causes + // under Origin. Report those instead of the composite itself: they + // name the field and carry the keyword that actually failed, so the + // caller is told "property X is missing" rather than that some + // unnamed branch did not match. + if leaves := schemaErrorLeaves(schemaErr); len(leaves) > 0 { + for _, leaf := range rankSchemaLeaves(leaves) { + *schemaErrors = append(*schemaErrors, *codedSchemaError(leaf)) } + return } - - errItem := model.NewCodedError(schemaFieldToCode(schemaErr.SchemaField), message) - if path != "" { - errItem.Details = &model.ErrorDetails{Path: path} - } - *schemaErrors = append(*schemaErrors, *errItem) + // No reachable cause — anyOf and not discard theirs, so the composite + // error is all there is to report. + *schemaErrors = append(*schemaErrors, *codedSchemaError(schemaErr)) } else if multiErr, ok := err.(openapi3.MultiError); ok { // Nested MultiError for _, e := range multiErr { @@ -610,6 +592,329 @@ func (v *schemav2Validator) extractSchemaErrors(err error, schemaErrors *[]model } } +// discriminatorSchemaFields are the keywords a schema uses to say which of +// several alternatives a payload is for: a const or an enum pinning a scalar +// to one value. Inside a failed composite they usually mean "this branch is +// not the one" rather than "this value is wrong", so they are reported after +// the causes a caller can act on. +var discriminatorSchemaFields = map[string]bool{"enum": true, "const": true} + +// rankSchemaLeaves puts the actionable causes first while keeping every one of +// them. Only the order changes: a SchemaValidationErr reports all its causes, +// but takes its single wire-level code from the first, so a missing property +// should outrank the enum on whichever guard the payload did not satisfy. +// +// This is a presentation heuristic, not a judgement about which branch of a +// failed oneOf the caller meant. That cannot be recovered here: kin-openapi +// evaluates a schema's set operations before its properties and returns as +// soon as the set operations fail, so a branch's discriminating property +// never gets evaluated and never appears in the tree to be matched against. +func rankSchemaLeaves(leaves []*openapi3.SchemaError) []*openapi3.SchemaError { + ranked := make([]*openapi3.SchemaError, 0, len(leaves)) + for _, leaf := range leaves { + if !discriminatorSchemaFields[leaf.SchemaField] { + ranked = append(ranked, leaf) + } + } + for _, leaf := range leaves { + if discriminatorSchemaFields[leaf.SchemaField] { + ranked = append(ranked, leaf) + } + } + return ranked +} + +// codedSchemaError renders one openapi3.SchemaError as a model.Error, taking +// the taxonomy code from the JSON-schema keyword that failed and the path from +// the error's own JSON pointer. +func codedSchemaError(se *openapi3.SchemaError) *model.Error { + path := strings.Join(se.JSONPointer(), "/") + if path == "" { + path = se.SchemaField + } + // SchemaError.Error() appends a full schema-and-value dump after the + // reason, which is what made a NACK body unreadable. Reason alone is the + // sentence worth returning; "not" is the one keyword that leaves it empty. + message := se.Reason + if message == "" { + // "not" is the one keyword kin-openapi leaves without a reason: it + // fires when the subschema it negates matched, and a match has + // nothing to say. + message = "value is not allowed here" + if se.SchemaField != "not" { + message = fmt.Sprintf("value does not satisfy %q", se.SchemaField) + } + } + errItem := model.NewCodedError(schemaFieldToCode(se.SchemaField), message) + if path != "" { + errItem.Details = &model.ErrorDetails{Path: path} + } + return errItem +} + +// compositeSchemaFields are the keywords that report only that some branch +// failed. They are never the cause a caller can act on, so a leaf walk steps +// through them to whatever they wrap. +// +// "not" is absent deliberately. A failed "not" has nothing underneath it by +// construction -- the subschema it negates SUCCEEDED, so there is no inner +// error -- but it is a cause in its own right: something is present that may +// not be, and the pointer says what. Stepping through it reaches nothing and +// loses the only thing the schema said. +var compositeSchemaFields = map[string]bool{ + "allOf": true, "anyOf": true, "oneOf": true, +} + +// disjunctiveSchemaFields are the composites whose branches are alternatives +// rather than conjuncts: one of them was meant to match, and the rest are +// shapes the payload was never claiming to be. +var disjunctiveSchemaFields = map[string]bool{"oneOf": true, "anyOf": true} + +// schemaErrorLeaves walks a kin-openapi error tree and returns the concrete +// assertion failures at its leaves, in encounter order and deduplicated. +// +// Two shapes have to be stepped through to reach them. kin-openapi stores a +// composite's nested causes as Origin = fmt.Errorf("...: %w", multiErr), so +// the slice of per-branch errors sits one errors.Unwrap below Origin; and +// those multierror types are named slice types rather than openapi3.MultiError +// itself, so they are walked by reflection over the slice. Reading +// Origin.Error() as text instead of walking it is what loses the per-branch +// attribution: the branches' messages are concatenated, and picking one out of +// the resulting string selects by position rather than by relevance. +// +// anyOf and not are returned empty: kin-openapi discards their branch errors +// before the caller ever sees them, so there is nothing underneath to find. +func schemaErrorLeaves(err error) []*openapi3.SchemaError { + se, ok := err.(*openapi3.SchemaError) + if !ok || se.Origin == nil { + return nil + } + // Start below the composite itself, so a composite with no reachable + // cause yields nothing and the caller falls back to reporting it. + return dedupeSchemaLeaves(leavesUnder(se.Origin, disjunctiveSchemaFields[se.SchemaField])) +} + +// leavesUnder returns the reportable leaves under one node of the tree. +// alternatives says whether e holds the branches of a oneOf or an anyOf, in +// which case the ones the schema ruled out are dropped; otherwise its members +// are conjuncts and every one of them is a cause in its own right. +func leavesUnder(e error, alternatives bool) []*openapi3.SchemaError { + if e == nil { + return nil + } + if se, ok := e.(*openapi3.SchemaError); ok { + if se.Origin != nil { + // Something more specific underneath; the composite that wrapped + // it adds nothing. + if sub := leavesUnder(se.Origin, disjunctiveSchemaFields[se.SchemaField]); len(sub) > 0 { + return sub + } + } + if compositeSchemaFields[se.SchemaField] { + return nil + } + // A "not" that fired on the object itself rather than on one of its + // members has no pointer and no reason, so it says only that + // something somewhere is wrong. Dropping it lets the caller fall + // back to whatever else the tree holds. + if se.SchemaField == "not" && len(se.JSONPointer()) == 0 { + return nil + } + return []*openapi3.SchemaError{se} + } + if branches, ok := errorBranches(e); ok { + var kept, every [][]*openapi3.SchemaError + for _, branch := range branches { + leaves := leavesUnder(branch, false) + every = append(every, leaves) + if alternatives && discriminatedAway(branch) { + continue + } + kept = append(kept, leaves) + } + // Every alternative was ruled out, so none of them tells the caller + // which was meant. Most of what they said is about shapes the payload + // was never going to be; what ruled each one out is the whole of the + // news, so that is what is reported when there is any. + if len(kept) == 0 { + if kept = discriminatorsOf(every); len(kept) == 0 { + kept = every + } + } + if alternatives { + kept = nearest(kept) + } + return flatten(kept) + } + if u := errors.Unwrap(e); u != nil { + return leavesUnder(u, alternatives) + } + return nil +} + +// discriminatedAway reports whether an alternative was ruled out by its own +// terms rather than by a fault in the payload: a const or an enum pinning the +// value that says which alternative applies. The rest of such a branch is +// about a shape the caller never claimed to be sending, and reporting it sends +// them to fix a field that is not theirs -- a PMFBY support call was told to +// supply PM-KISAN's challenge, on a branch its scheme code had already +// excluded. +// +// A const or an enum only: the payload carries the value, so the schema is +// saying which alternative it is. A tripped "not" is NOT read this way even +// though an alternative can be guarded with one, because the same keyword is +// how every pack says a field is not allowed here -- reading those as +// discrimination silences the arm that was actually meant. +// +// Only the alternative's own conjuncts count. The search stops at a nested +// oneOf or anyOf: a discriminator inside one of *its* alternatives rules that +// one out, not the branch that contains the choice. +func discriminatedAway(e error) bool { + if e == nil { + return false + } + if se, ok := e.(*openapi3.SchemaError); ok { + if disjunctiveSchemaFields[se.SchemaField] { + return false + } + if discriminatorSchemaFields[se.SchemaField] { + return true + } + if se.Origin == nil { + return false + } + e = se.Origin + } + if branches, ok := errorBranches(e); ok { + for _, branch := range branches { + if discriminatedAway(branch) { + return true + } + } + return false + } + if u := errors.Unwrap(e); u != nil { + return discriminatedAway(u) + } + return false +} + +// errorBranches returns the members of a multierror. openapi3.MultiError and +// the unexported multiErrorForOneOf / multiErrorForAllOf are all []error +// underneath but share no interface, so the slice is read by reflection. +func errorBranches(e error) ([]error, bool) { + v := reflect.ValueOf(e) + if v.Kind() != reflect.Slice { + return nil, false + } + branches := make([]error, 0, v.Len()) + for i := 0; i < v.Len(); i++ { + if sub, ok := v.Index(i).Interface().(error); ok { + branches = append(branches, sub) + } + } + return branches, true +} + +// discriminatorsOf keeps, from each alternative, only what said the +// alternative was not the one -- dropping the demands it would have made had +// it been. +func discriminatorsOf(arms [][]*openapi3.SchemaError) [][]*openapi3.SchemaError { + var out [][]*openapi3.SchemaError + for _, arm := range arms { + var why []*openapi3.SchemaError + for _, leaf := range arm { + if discriminatorSchemaFields[leaf.SchemaField] { + why = append(why, leaf) + } + } + if len(why) > 0 { + out = append(out, why) + } + } + return out +} + +// nearest drops the alternatives that ask for everything another one asks for +// and more. +// +// All of them failed, so the caller meant one of them and the schema cannot +// say which. The one that failed for fewest reasons is the one they came +// closest to sending; the extra demands of the others hold only if a different +// alternative was meant. Reporting those is telling a caller to add a field +// that may well not be theirs to send, next to one that certainly is. +// +// An alternative with no reachable cause rules nothing out -- it is a gap in +// what the validator reported, not an easier way to satisfy the schema. +func nearest(arms [][]*openapi3.SchemaError) [][]*openapi3.SchemaError { + demands := make([]map[string]bool, len(arms)) + for i, arm := range arms { + demands[i] = make(map[string]bool, len(arm)) + for _, leaf := range arm { + demands[i][leafKey(leaf)] = true + } + } + var out [][]*openapi3.SchemaError + for i, arm := range arms { + if !anyNearerThan(demands, i) { + out = append(out, arm) + } + } + return out +} + +// anyNearerThan reports whether some other alternative failed for a strict +// subset of alternative i's reasons. +func anyNearerThan(demands []map[string]bool, i int) bool { + for j, other := range demands { + if j == i || len(other) == 0 || len(other) >= len(demands[i]) { + continue + } + contained := true + for key := range other { + if !demands[i][key] { + contained = false + break + } + } + if contained { + return true + } + } + return false +} + +// flatten joins the alternatives' leaves back into one list, in branch order. +func flatten(arms [][]*openapi3.SchemaError) []*openapi3.SchemaError { + var out []*openapi3.SchemaError + for _, arm := range arms { + out = append(out, arm...) + } + return out +} + +// leafKey identifies a leaf by what it says, so the same complaint reached by +// two routes counts once. +func leafKey(leaf *openapi3.SchemaError) string { + return leaf.SchemaField + "\x00" + strings.Join(leaf.JSONPointer(), "/") + "\x00" + leaf.Reason +} + +// dedupeSchemaLeaves drops leaves that repeat an earlier one, keeping the +// first. Alternatives of the same gate routinely fail the same way. +func dedupeSchemaLeaves(leaves []*openapi3.SchemaError) []*openapi3.SchemaError { + out := make([]*openapi3.SchemaError, 0, len(leaves)) + seen := map[string]bool{} + for _, leaf := range leaves { + key := leafKey(leaf) + if seen[key] { + continue + } + seen[key] = true + out = append(out, leaf) + } + return out +} + // buildActionIndex builds two indexes from the loaded OpenAPI spec: // - actionSchemas: body-bearing operations (POST/PUT/PATCH) keyed by context.action value // - bodylessActions: GET/DELETE operations without a requestBody, keyed by path without leading slash diff --git a/pkg/plugin/implementation/schemav2validator/schemav2validator_test.go b/pkg/plugin/implementation/schemav2validator/schemav2validator_test.go index 3ff617398..e82360f1f 100644 --- a/pkg/plugin/implementation/schemav2validator/schemav2validator_test.go +++ b/pkg/plugin/implementation/schemav2validator/schemav2validator_test.go @@ -402,13 +402,13 @@ func TestValidate_NumericOverflowFailsSecondUnmarshal(t *testing.T) { } } -// TestExtractSchemaErrors_CompositeOriginFallsBackToGeneric exercises the -// Origin != nil branch (oneOf/anyOf/allOf composite failures), previously -// uncovered by any test. Since kin-openapi attributes the failure to the -// composite keyword itself (not the nested cause), classification correctly -// falls back to the generic SCH_SCHEMA_VALIDATION_FAILED rather than -// guessing at whichever nested branch failed. -func TestExtractSchemaErrors_CompositeOriginFallsBackToGeneric(t *testing.T) { +// TestExtractSchemaErrors_CompositeReportsNestedCause exercises the +// Origin != nil branch (oneOf/allOf composite failures). kin-openapi +// attributes the failure to the composite keyword itself, but keeps the +// branches' real causes under Origin, so the extracted error is the nested +// cause -- classified by the keyword that actually failed -- rather than the +// generic SCH_SCHEMA_VALIDATION_FAILED the composite would give. +func TestExtractSchemaErrors_CompositeReportsNestedCause(t *testing.T) { sub := openapi3.NewObjectSchema().WithProperty("name", openapi3.NewStringSchema()) sub.Required = []string{"name"} @@ -436,18 +436,18 @@ func TestExtractSchemaErrors_CompositeOriginFallsBackToGeneric(t *testing.T) { } got := schemaErrors[0] t.Logf("extracted = %+v", got) - if got.Code != "SCH_SCHEMA_VALIDATION_FAILED" { - t.Errorf("Code = %s, want SCH_SCHEMA_VALIDATION_FAILED", got.Code) + if got.Code != "SCH_REQUIRED_FIELD_MISSING" { + t.Errorf("Code = %s, want SCH_REQUIRED_FIELD_MISSING", got.Code) } - if got.Message == "" { - t.Error("Message = \"\", want a non-empty message describing the nested cause") + if !strings.HasPrefix(got.Message, `property "name" is missing`) { + t.Errorf("Message = %q, want it to name the missing property", got.Message) } } -// TestExtractSchemaErrors_CompositeOriginParsesNestedFieldPath exercises the -// "Error at \"/path\": reason" string-parsing branch specifically — triggered -// when the nested cause inside an allOf/oneOf failure occurred on a property -// deep enough to have a tagged reversePath. Previously uncovered by any test. +// TestExtractSchemaErrors_CompositeOriginParsesNestedFieldPath confirms a +// nested cause deep enough to carry a tagged reversePath keeps its full path +// through the leaf walk, so the caller is pointed at nested/name rather than +// at the allOf that wrapped it. func TestExtractSchemaErrors_CompositeOriginParsesNestedFieldPath(t *testing.T) { innerSub := openapi3.NewObjectSchema().WithProperty("name", openapi3.NewStringSchema()) innerSub.Required = []string{"name"} @@ -488,6 +488,201 @@ func TestExtractSchemaErrors_CompositeOriginParsesNestedFieldPath(t *testing.T) } } +// TestExtractSchemaErrors_OneOfReportsEveryBranchCause pins the behaviour a +// two-branch oneOf needs: when a payload matches neither branch, every +// branch's cause is reported, not just one of them. +// +// This is the shape a scheme-discriminated schema has -- one branch per +// scheme, each requiring its own fields -- and reading the composite's +// Origin as text instead of walking it used to collapse the result to +// whichever cause came last in the concatenated string. A caller who had +// omitted a field required by the branch their payload was actually for +// would then be told about a field belonging to the other branch entirely. +func TestExtractSchemaErrors_OneOfReportsEveryBranchCause(t *testing.T) { + first := openapi3.NewObjectSchema() + first.Required = []string{"season"} + second := openapi3.NewObjectSchema() + second.Required = []string{"challenge"} + + parent := openapi3.NewObjectSchema() + parent.OneOf = openapi3.SchemaRefs{ + openapi3.NewSchemaRef("", first), + openapi3.NewSchemaRef("", second), + } + + err := parent.VisitJSON(map[string]interface{}{}, openapi3.MultiErrors()) + if err == nil { + t.Fatal("expected a validation error") + } + + v := &schemav2Validator{} + var schemaErrors []model.Error + v.extractSchemaErrors(err, &schemaErrors) + + paths := map[string]string{} + for _, e := range schemaErrors { + if e.Details != nil { + paths[e.Details.Path] = e.Code + } + } + t.Logf("extracted = %+v", schemaErrors) + + for _, want := range []string{"season", "challenge"} { + code, ok := paths[want] + if !ok { + t.Errorf("no error reported for %q; got paths %v", want, paths) + continue + } + if code != "SCH_REQUIRED_FIELD_MISSING" { + t.Errorf("Code for %q = %s, want SCH_REQUIRED_FIELD_MISSING", want, code) + } + } +} + +// TestExtractSchemaErrors_DiscriminatedBranchesAreNotCauses builds the shape +// a capability pack uses to hold two schemes in one attribute object: an +// outer oneOf picking the scheme, each arm an allOf whose first conjunct +// pins scheme.code to the one it is for. +// +// Only the arm the payload selected has a cause the caller can act on. A +// PMFBY support call missing applicantMobile used to be answered with +// PM-KISAN's missing "challenge" as well, because every arm's errors were +// reported as though they were all causes. +func TestExtractSchemaErrors_DiscriminatedBranchesAreNotCauses(t *testing.T) { + schemeCode := func(code string) *openapi3.Schema { + guard := openapi3.NewObjectSchema() + guard.Properties = openapi3.Schemas{ + "scheme": openapi3.NewSchemaRef("", &openapi3.Schema{ + Properties: openapi3.Schemas{ + "code": openapi3.NewSchemaRef("", &openapi3.Schema{Const: code}), + }, + }), + } + return guard + } + + // The arm the payload selected, where applicantMobile is the real cause. + selectedBody := openapi3.NewObjectSchema() + selectedBody.Required = []string{"applicantMobile"} + selected := openapi3.NewObjectSchema() + selected.AllOf = openapi3.SchemaRefs{ + openapi3.NewSchemaRef("", schemeCode("PMFBY")), + openapi3.NewSchemaRef("", selectedBody), + } + + // The other scheme's arm, excluded by the code the payload does carry. + excludedBody := openapi3.NewObjectSchema() + excludedBody.Required = []string{"challenge"} + excluded := openapi3.NewObjectSchema() + excluded.AllOf = openapi3.SchemaRefs{ + openapi3.NewSchemaRef("", schemeCode("PM-KISAN")), + openapi3.NewSchemaRef("", excludedBody), + } + + parent := openapi3.NewObjectSchema() + parent.OneOf = openapi3.SchemaRefs{ + openapi3.NewSchemaRef("", selected), + openapi3.NewSchemaRef("", excluded), + } + + err := parent.VisitJSON(map[string]interface{}{ + "scheme": map[string]interface{}{"code": "PMFBY"}, + }, openapi3.MultiErrors()) + if err == nil { + t.Fatal("expected a validation error") + } + + v := &schemav2Validator{} + var schemaErrors []model.Error + v.extractSchemaErrors(err, &schemaErrors) + t.Logf("extracted = %+v", schemaErrors) + + if len(schemaErrors) != 1 { + t.Fatalf("want exactly the one cause the caller can act on, got %d: %+v", + len(schemaErrors), schemaErrors) + } + got := schemaErrors[0] + if got.Details == nil || got.Details.Path != "applicantMobile" { + t.Errorf("Path = %+v, want applicantMobile", got.Details) + } + if got.Code != "SCH_REQUIRED_FIELD_MISSING" { + t.Errorf("Code = %s, want SCH_REQUIRED_FIELD_MISSING", got.Code) + } +} + +// TestExtractSchemaErrors_AllBranchesDiscriminatedStillReports covers the +// fallback. When the payload picks no arm at all -- here the discriminator +// itself is absent -- every arm is excluded and dropping them all would leave +// a rejection with no stated cause. All of them are reported instead. +func TestExtractSchemaErrors_AllBranchesDiscriminatedStillReports(t *testing.T) { + first := openapi3.NewObjectSchema() + first.Properties = openapi3.Schemas{ + "mode": openapi3.NewSchemaRef("", &openapi3.Schema{Enum: []interface{}{"a"}}), + } + second := openapi3.NewObjectSchema() + second.Properties = openapi3.Schemas{ + "mode": openapi3.NewSchemaRef("", &openapi3.Schema{Enum: []interface{}{"b"}}), + } + + parent := openapi3.NewObjectSchema() + parent.OneOf = openapi3.SchemaRefs{ + openapi3.NewSchemaRef("", first), + openapi3.NewSchemaRef("", second), + } + + err := parent.VisitJSON(map[string]interface{}{"mode": "c"}, openapi3.MultiErrors()) + if err == nil { + t.Fatal("expected a validation error") + } + + v := &schemav2Validator{} + var schemaErrors []model.Error + v.extractSchemaErrors(err, &schemaErrors) + t.Logf("extracted = %+v", schemaErrors) + + if len(schemaErrors) == 0 { + t.Fatal("every branch was discriminated away and nothing was reported") + } + for _, e := range schemaErrors { + if e.Code != "SCH_INVALID_ENUM" { + t.Errorf("Code = %s, want SCH_INVALID_ENUM", e.Code) + } + } +} + +// TestExtractSchemaErrors_AnyOfHasNoReachableCause documents the one shape +// that cannot be improved: kin-openapi discards an anyOf's per-branch errors +// before returning, so there is nothing under Origin to walk and the +// composite itself is all that can be reported. +func TestExtractSchemaErrors_AnyOfHasNoReachableCause(t *testing.T) { + first := openapi3.NewObjectSchema() + first.Required = []string{"season"} + second := openapi3.NewObjectSchema() + second.Required = []string{"challenge"} + + parent := openapi3.NewObjectSchema() + parent.AnyOf = openapi3.SchemaRefs{ + openapi3.NewSchemaRef("", first), + openapi3.NewSchemaRef("", second), + } + + err := parent.VisitJSON(map[string]interface{}{}, openapi3.MultiErrors()) + if err == nil { + t.Fatal("expected a validation error") + } + + v := &schemav2Validator{} + var schemaErrors []model.Error + v.extractSchemaErrors(err, &schemaErrors) + + if len(schemaErrors) != 1 { + t.Fatalf("expected exactly one extracted error, got %d: %+v", len(schemaErrors), schemaErrors) + } + if got := schemaErrors[0].Code; got != "SCH_SCHEMA_VALIDATION_FAILED" { + t.Errorf("Code = %s, want SCH_SCHEMA_VALIDATION_FAILED", got) + } +} + // TestExtractSchemaErrors_ConstViolation confirms a JSON-schema "const" // violation — SchemaField "const", no dedicated SCH_* code in the taxonomy — // classifies as SCH_INVALID_ENUM, since const is enum-of-one.