From fcc8ac086804e9ac66e6bf8a8eb9b01b78dfb02d Mon Sep 17 00:00:00 2001 From: KrutikaPhirangi <138781661+KrutikaPhirangi@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:38:05 +0530 Subject: [PATCH 1/2] feat: let an envelope verify on the provider's own host and make a follow-up read [#60] aesCbcKeyInBand can be a provider's main envelope, and such a provider verifies the challenge on its own host. followUpPath, followUpField and followUpActions make a second read after a successful call and add its answer under followUpField, on its own budget (followUpTimeoutMs); a failed follow-up leaves the field absent. --- .../internal/common/envelope/envelope.go | 85 +++++++++++++++++-- .../internal/common/envelope/envelope_test.go | 1 + .../internal/common/envelope/followup.go | 67 +++++++++++++++ .../internal/common/envelope/verify_test.go | 61 +++++++++++++ 4 files changed, 207 insertions(+), 7 deletions(-) create mode 100644 pkg/plugin/implementation/internal/common/envelope/followup.go diff --git a/pkg/plugin/implementation/internal/common/envelope/envelope.go b/pkg/plugin/implementation/internal/common/envelope/envelope.go index dd92fb8e..2d7aa67f 100644 --- a/pkg/plugin/implementation/internal/common/envelope/envelope.go +++ b/pkg/plugin/implementation/internal/common/envelope/envelope.go @@ -30,7 +30,7 @@ const ( const codeInternal = "NET_INTERNAL_ERROR" // envelopeFields maps each provider-block setting to its field. envelope* is the -// default codec, otp* a separate OTP service. +// default codec, otp* a separate OTP service, followUp* a second read. var envelopeFields = map[string]func(*envelopeSettings, string){ "envelope": func(s *envelopeSettings, v string) { s.kind = v }, "envelopeKeyEnv": func(s *envelopeSettings, v string) { s.keyEnv = v }, @@ -50,6 +50,11 @@ var envelopeFields = map[string]func(*envelopeSettings, string){ "otpVerifyPath": func(s *envelopeSettings, v string) { s.verifyPath = v }, "otpVerifyActions": func(s *envelopeSettings, v string) { s.verifyActions = v }, "otpVerifyTimeoutMs": func(s *envelopeSettings, v string) { s.verifyTimeoutMs = v }, + + "followUpPath": func(s *envelopeSettings, v string) { s.followUpPath = v }, + "followUpField": func(s *envelopeSettings, v string) { s.followUpField = v }, + "followUpActions": func(s *envelopeSettings, v string) { s.followUpActions = v }, + "followUpTimeoutMs": func(s *envelopeSettings, v string) { s.followUpTimeoutMs = v }, } // envelopeSettings is one provider's envelope as written in config. @@ -61,6 +66,8 @@ type envelopeSettings struct { verifyPath string verifyActions string verifyTimeoutMs string + + followUpPath, followUpField, followUpActions, followUpTimeoutMs string } // realmSettings configures a CBC codec. bindingKey names the registry binding @@ -128,6 +135,7 @@ type providerEnvelope struct { otpBindingKey string otpActions map[string]bool verify *verify + followUp *followUp } var _ common.Envelope = (*providerEnvelope)(nil) @@ -149,6 +157,15 @@ func (e *providerEnvelope) otpHost(ctx context.Context, exchange common.Exchange return common.HostOf(ctx, exchange.Registry, e.otpBindingKey) } +// hostFor is where an action's calls go: the OTP service for an OTP action, +// the participant for the rest. +func (e *providerEnvelope) hostFor(ctx context.Context, exchange common.Exchange) (string, error) { + if e.otpActions[exchange.Action] { + return e.otpHost(ctx, exchange) + } + return participantHost(ctx, exchange) +} + // Seal verifies the OTP if the action needs it, then seals the request. A wrong // OTP stops the call. func (e *providerEnvelope) Seal(ctx context.Context, exchange common.Exchange, mapped []byte) ([]byte, common.Opener, error) { @@ -164,7 +181,15 @@ func (e *providerEnvelope) Seal(ctx context.Context, exchange common.Exchange, m if c == nil { return mapped, func(_ context.Context, answer []byte) ([]byte, error) { return answer, nil }, nil } - return c.seal(ctx, mapped) + sealed, open, err := c.seal(ctx, mapped) + if err != nil || e.followUp == nil || !e.followUp.actions[exchange.Action] { + return sealed, open, err + } + base, err := e.hostFor(ctx, exchange) + if err != nil { + return nil, nil, err + } + return sealed, e.followUp.wrap(c, base, mapped, open), nil } // newProviderEnvelope validates the settings. Secrets are read at call time. @@ -174,8 +199,8 @@ func newProviderEnvelope(provider string, s envelopeSettings, hooks Hooks) (*pro } e := &providerEnvelope{provider: provider, otpBindingKey: strings.TrimSpace(s.otp.bindingKey), otpActions: listSet(s.otpActions)} - // The verify carries the farmer's identifier, so it refuses an off-host - // redirect as the main call does. + // The verify and follow-up calls carry the farmer's identifier, so they + // refuse an off-host redirect as the main call does. client := &http.Client{CheckRedirect: util.RefuseOffHostRedirect} switch s.kind { @@ -185,6 +210,18 @@ func newProviderEnvelope(provider string, s envelopeSettings, hooks Hooks) (*pro s.requestField != "" || s.responsePaths != "" { return nil, fmt.Errorf("%s: envelope settings are present but envelope is not set", provider) } + case envelopeAESCBCKeyInBand: + + if s.keyEnv != "" || s.nonceEnv != "" { + return nil, fmt.Errorf("%s: envelope %s carries its key in band; drop envelopeKeyEnv and envelopeNonceEnv", + provider, envelopeAESCBCKeyInBand) + } + base, err := newCBCCodecFrom(provider, "envelope", "envelope", realmSettings{kind: s.kind, + tokenEnv: s.tokenEnv, tokenField: s.tokenField, requestField: s.requestField, responsePaths: s.responsePaths}) + if err != nil { + return nil, err + } + e.base = base default: base, err := newGCMCodec(provider, s) if err != nil { @@ -215,6 +252,10 @@ func newProviderEnvelope(provider string, s envelopeSettings, hooks Hooks) (*pro if err != nil { return nil, err } + followUpTimeoutMs, err := timeoutSetting(provider, "followUpTimeoutMs", s.followUpTimeoutMs) + if err != nil { + return nil, err + } verifyActions := listSet(s.verifyActions) switch { @@ -223,11 +264,34 @@ func newProviderEnvelope(provider string, s envelopeSettings, hooks Hooks) (*pro return nil, fmt.Errorf("%s: otpVerifyPath and otpVerifyActions are set together or not at all", provider) case hooks.Challenge == nil: return nil, fmt.Errorf("%s: otpVerifyPath needs Hooks.Challenge, to read the OTP from a payload", provider) - case e.otp == nil: - return nil, fmt.Errorf("%s: otpVerifyPath needs otpEnvelope: the OTP is verified on the OTP service", provider) - default: + case e.otp != nil: + // Separate OTP service: verify there. e.verify = &verify{provider: provider, path: s.verifyPath, actions: verifyActions, codec: e.otp.(*cbcCodec), host: e.otpHost, hooks: hooks, client: client, budget: model.ActionPlan{TimeoutMs: verifyTimeoutMs}} + default: + // The participant is the OTP service: verify on its host. + cbc, onOTPService := e.base.(*cbcCodec) + if !onOTPService { + return nil, fmt.Errorf("%s: otpVerifyPath needs otpEnvelope, or envelope %s: the OTP is verified on the OTP service", + provider, envelopeAESCBCKeyInBand) + } + e.verify = &verify{provider: provider, path: s.verifyPath, actions: verifyActions, codec: cbc, + host: participantHost, hooks: hooks, client: client, budget: model.ActionPlan{TimeoutMs: verifyTimeoutMs}} + } + + followUpActions := listSet(s.followUpActions) + switch { + case s.followUpPath == "" && s.followUpField == "" && len(followUpActions) == 0: + case s.followUpPath == "" || s.followUpField == "" || len(followUpActions) == 0: + return nil, fmt.Errorf("%s: followUpPath, followUpField and followUpActions are set together or not at all", provider) + default: + for action := range followUpActions { + if e.otpActions[action] && e.otp == nil || !e.otpActions[action] && e.base == nil { + return nil, fmt.Errorf("%s: followUpActions names %s, which has no envelope to send a follow-up through", provider, action) + } + } + e.followUp = &followUp{provider: provider, path: s.followUpPath, field: s.followUpField, actions: followUpActions, + succeeded: hooks.Succeeded, client: client, budget: model.ActionPlan{TimeoutMs: followUpTimeoutMs}} } if e.base == nil && e.otp == nil { @@ -236,6 +300,13 @@ func newProviderEnvelope(provider string, s envelopeSettings, hooks Hooks) (*pro return e, nil } +// participantHost is the registry's base URL for the provider. +func participantHost(_ context.Context, exchange common.Exchange) (string, error) { + if exchange.Plan == nil || exchange.Plan.BaseURL == "" { + return "", errors.New("no call plan to find the provider's host on") + } + return exchange.Plan.BaseURL, nil +} // --- shared by the codecs ---------------------------------------------------- diff --git a/pkg/plugin/implementation/internal/common/envelope/envelope_test.go b/pkg/plugin/implementation/internal/common/envelope/envelope_test.go index 53e477f3..9ca7e310 100644 --- a/pkg/plugin/implementation/internal/common/envelope/envelope_test.go +++ b/pkg/plugin/implementation/internal/common/envelope/envelope_test.go @@ -390,6 +390,7 @@ func TestSealRefusesANonceShorterThanGCMs(t *testing.T) { func TestNewEnvelopeRefusesABadTimeout(t *testing.T) { for name, set := range map[string]func(*envelopeSettings){ "otpVerifyTimeoutMs": func(s *envelopeSettings) { s.verifyTimeoutMs = "soon" }, + "followUpTimeoutMs": func(s *envelopeSettings) { s.followUpTimeoutMs = "0" }, } { t.Run(name, func(t *testing.T) { s := pmkisanSettings() diff --git a/pkg/plugin/implementation/internal/common/envelope/followup.go b/pkg/plugin/implementation/internal/common/envelope/followup.go new file mode 100644 index 00000000..0c35d1a7 --- /dev/null +++ b/pkg/plugin/implementation/internal/common/envelope/followup.go @@ -0,0 +1,67 @@ +package envelope + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/beckn-one/beckn-onix/pkg/log" + "github.com/beckn-one/beckn-onix/pkg/model" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common" +) + +// followUp adds a second read's answer under field after a successful call; +// on failure the field is absent, never empty (ADR-0005). +type followUp struct { + provider string + path string + field string + actions map[string]bool + succeeded func([]byte) bool + client *http.Client + // budget is the follow-up's own time budget, not the first call's. + budget model.ActionPlan +} + +// wrap opens the first answer and, if it succeeded, adds the follow-up's. +func (f *followUp) wrap(c codec, base string, mapped []byte, open common.Opener) common.Opener { + return func(ctx context.Context, answer []byte) ([]byte, error) { + plain, err := open(ctx, answer) + if err != nil || !f.succeeded(plain) { + return plain, err + } + extra, ok := f.read(ctx, c, base, mapped) + if !ok { + return plain, nil + } + var first map[string]any + if json.Unmarshal(plain, &first) != nil { + return plain, nil + } + first[f.field] = extra + return json.Marshal(first) + } +} + +// read makes the follow-up call. A failure is logged, never returned. +func (f *followUp) read(ctx context.Context, c codec, base string, mapped []byte) (any, bool) { + sealed, open, err := c.seal(ctx, mapped) + if err != nil { + log.Warnf(ctx, "%s's follow-up %s was not sent: %v", f.provider, f.path, err) + return nil, false + } + answer, err := postSealed(ctx, f.client, f.provider, "the follow-up read", base, f.path, f.budget, sealed) + if err != nil { + return nil, false + } + plain, err := open(ctx, answer) + if err != nil { + log.Warnf(ctx, "%s's follow-up %s answered with nothing usable", f.provider, f.path) + return nil, false + } + var extra any + if json.Unmarshal(plain, &extra) != nil { + return nil, false + } + return extra, true +} diff --git a/pkg/plugin/implementation/internal/common/envelope/verify_test.go b/pkg/plugin/implementation/internal/common/envelope/verify_test.go index 05687f54..81c5e79a 100644 --- a/pkg/plugin/implementation/internal/common/envelope/verify_test.go +++ b/pkg/plugin/implementation/internal/common/envelope/verify_test.go @@ -275,6 +275,67 @@ func TestAProviderWithOnlyAnOTPRealmSendsItsOtherActionsPlain(t *testing.T) { } } +// chatbotSettings is a provider whose every call is on the OTP service, as +// PM-KISAN's application status is. +func chatbotSettings() envelopeSettings { + return envelopeSettings{kind: envelopeAESCBCKeyInBand, tokenEnv: testOTPTokenEnv, tokenField: "Token", + requestField: "EncryptedRequest", responsePaths: "d.output,output", + verifyPath: "/ChatbotOTPVerified", verifyActions: "status", + followUpPath: "/ChatbotBeneficiaryStatus", followUpField: "_beneficiaryStatus", followUpActions: "status"} +} + +func TestAProviderWhollyOnTheOTPServiceVerifiesOnItsOwnHost(t *testing.T) { + t.Setenv(testOTPTokenEnv, testOTPToken) + e, err := newProviderEnvelope("pmkisan-chatbot", chatbotSettings(), testHooks) + if err != nil { + t.Fatalf("newProviderEnvelope() = %v", err) + } + otp := &otpService{t: t} + server := httptest.NewServer(otp) + defer server.Close() + + payload := map[string]any{"message": map[string]any{"contract": map[string]any{"commitments": []any{ + map[string]any{"commitmentAttributes": map[string]any{ + "applicant": map[string]any{"mobile": "9812345670"}, + "challenge": map[string]any{"response": map[string]any{"method": "SMS_OTP", "value": validOTP}}, + }}}}}} + wire, _, err := e.Seal(context.Background(), common.Exchange{Action: "status", Beckn: payload, + Plan: &model.ProviderRecord{BaseURL: server.URL}}, []byte(`{"Types":"Mobile","Values":"9812345670"}`)) + if err != nil { + t.Fatalf("Seal() = %v", err) + } + if otp.calls != 1 || otp.path != "/ChatbotOTPVerified" { + t.Fatalf("the participant's host saw %d call(s) at %q, want the verify", otp.calls, otp.path) + } + if otp.sent["Types"] != "Mobile" || otp.sent["Values"] != "9812345670" || otp.sent["OTP"] != validOTP { + t.Errorf("verify sent %v, want the declared kind, the id and the OTP", otp.sent) + } + var body map[string]string + _ = json.Unmarshal(wire, &body) + if !strings.Contains(body["EncryptedRequest"], "@") { + t.Errorf("status sealed %q, want the chatbot service's cipher@key", body["EncryptedRequest"]) + } +} + +func TestNewProviderEnvelopeRefusesInconsistentChatbotSettings(t *testing.T) { + for name, tweak := range map[string]func(*envelopeSettings){ + "a fixed key on a key-in-band envelope": func(s *envelopeSettings) { s.keyEnv = "K" }, + "follow-up with no field": func(s *envelopeSettings) { s.followUpField = "" }, + "follow-up with no actions": func(s *envelopeSettings) { s.followUpActions = "" }, + "follow-up with no path": func(s *envelopeSettings) { s.followUpPath = "" }, + "verify with no OTP service": func(s *envelopeSettings) { *s = pmkisanSettings(); s.verifyPath, s.verifyActions = "/v", "status" }, + "no request field": func(s *envelopeSettings) { s.requestField = "" }, + } { + t.Run(name, func(t *testing.T) { + s := chatbotSettings() + tweak(&s) + if _, err := newProviderEnvelope("pmkisan-chatbot", s, testHooks); err == nil { + t.Fatal("newProviderEnvelope() accepted settings that cannot work") + } + }) + } +} + // The verify carries the farmer's identifier and OTP, so a redirect to another // host is refused rather than followed. func TestTheVerifyRefusesAnOffHostRedirect(t *testing.T) { From 8c5637c1c1715781cc5261d0a0c15572d58bbd32 Mon Sep 17 00:00:00 2001 From: KrutikaPhirangi <138781661+KrutikaPhirangi@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:38:05 +0530 Subject: [PATCH 2/2] feat: read a PM-KISAN registration's application status [#60] PM-KISAN application status on the SchemeApplication pack, served by the chatbot service (pmkisan-chatbot): init sends an OTP for an enrolmentId or applicant.mobile; status verifies it, reads /ChatbotUserDetails, then /ChatbotBeneficiaryStatus for what is holding payment up. Only allow-listed fields are returned; the mobile is never echoed. No registration is a 400 BIZ_GENERIC_ERROR. --- .../pmkisan/application-status.init.yaml | 49 ++ .../pmkisan/application-status.status.yaml | 109 ++++ config/provider-adapter.yaml | 19 +- .../Grievance/appstatus_mappings_test.go | 530 ++++++++++++++++++ .../Grievance/cmd/pmkisan_test.go | 6 + pkg/plugin/implementation/Grievance/otp.go | 11 +- .../implementation/Grievance/otp_test.go | 4 +- 7 files changed, 723 insertions(+), 5 deletions(-) create mode 100644 config/mappings/pmkisan/application-status.init.yaml create mode 100644 config/mappings/pmkisan/application-status.status.yaml create mode 100644 pkg/plugin/implementation/Grievance/appstatus_mappings_test.go diff --git a/config/mappings/pmkisan/application-status.init.yaml b/config/mappings/pmkisan/application-status.init.yaml new file mode 100644 index 00000000..683b4a9d --- /dev/null +++ b/config/mappings/pmkisan/application-status.init.yaml @@ -0,0 +1,49 @@ +# PM-KISAN application status init -> /ChatbotOTP: send an OTP. + +request: | + ( + $ca := beckn.message.contract.commitments[0].commitmentAttributes; + $exists($ca.enrolmentId) + ? { "Types": "Ben_id", "Values": $ca.enrolmentId } + : { "Types": "Mobile", "Values": $ca.applicant.mobile } + ) + +# "True" is sent (on_init DRAFT, challenge.issued); anything else is a 502. +response: | + $exists(_status) + ? {} + : ( + $true := function($v) { $exists($v) and $lowercase($string($v)) = "true" }; + $sent := $true(response.Rsponce) or $true(response.Responce) or $true(response.Status); + $commitment := beckn.message.contract.commitments[0]; + $ca := $commitment.commitmentAttributes; + + $otpWindowMillis := 10 * 60 * 1000; + $not($sent) + ? { "_error": { "status": 502, "code": "NET_DOWNSTREAM_UNAVAILABLE", + "message": "PM-KISAN could not send the OTP" } } + : { + "context": $merge([beckn.context, { "action": "on_init", "timestamp": $now() }]), + "message": { + "contract": $merge([beckn.message.contract, { + "commitments": [$merge([$commitment, { + "status": { "descriptor": { "code": "DRAFT" } }, + "commitmentAttributes": { + "@context": $ca.`@context`, + "@type": $ca.`@type`, + "informationMode": "OnDemand", + "scheme": $ca.scheme, + "challenge": { + "issued": { + "method": "SMS_OTP", + "length": 4, + "valueType": "NUMERIC", + "expiresAt": $fromMillis($millis() + $otpWindowMillis) + } + } + } + }])] + }]) + } + } + ) diff --git a/config/mappings/pmkisan/application-status.status.yaml b/config/mappings/pmkisan/application-status.status.yaml new file mode 100644 index 00000000..abbeabfa --- /dev/null +++ b/config/mappings/pmkisan/application-status.status.yaml @@ -0,0 +1,109 @@ +# PM-KISAN application status -> /ChatbotUserDetails, then the blockers (ADR-0005). + +# The OTP is the one check left here (ADR-0003). +request: | + ( + $ca := beckn.message.contract.commitments[0].commitmentAttributes; + $not($exists($ca.challenge.response.value)) + ? { "_error": { "status": 400, "code": "SCH_REQUIRED_FIELD_MISSING", + "message": "challenge.response.value is required", + "path": "$.message.contract.commitments[0].commitmentAttributes.challenge.response.value" } } + : $exists($ca.enrolmentId) + ? { "Types": "Ben_id", "Values": $ca.enrolmentId } + : { "Types": "Mobile", "Values": $ca.applicant.mobile } + ) + +# "False": 400; no "True" or no instalment count: 502. Only allow-listed fields are returned. +response: | + $exists(_status) + ? {} + : ( + $flag := function($r) { + $exists($r.Rsponce) ? $lowercase($string($r.Rsponce)) + : $exists($r.Responce) ? $lowercase($string($r.Responce)) + : $exists($r.Status) ? $lowercase($string($r.Status)) + }; + $text := function($v) { + $type($v) = "string" and $length($trim($v)) > 0 ? $trim($v) + }; + $count0 := ( + $n := response.LatestInstallmentPaid; + $type($n) = "number" ? $n + : $type($n) = "string" and $exists($match($trim($n), /^[0-9]+$/)[0]) ? $number($trim($n)) + ); + + /* ISO, /Date(ms)/ or M/D/YYYY, as IST; anything else is left out. */ + $istPicture := "[Y0001]-[M01]-[D01]T[H01]:[m01]:[s01][Z01:01]"; + $pad := function($s) { $length($s) = 1 ? "0" & $s : $s }; + $registeredOn := ( + $d := $text(response.DateOfRegistration); + $exists($match($d, /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}/)[0]) + ? $substring($d, 0, 10) & "T" & $substring($d, 11, 8) & "+05:30" + : $exists($match($d, /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/)[0]) + ? $d & "T00:00:00+05:30" + : $exists($match($d, /^\/Date\([0-9]+([+-][0-9]{4})?\)\/$/)[0]) + ? $fromMillis($number($split($substring($d, 6), /[^0-9]/)[0]), $istPicture, "+0530") + : $exists($match($d, /^[0-9]{1,2}\/[0-9]{1,2}\/[0-9]{4}/)[0]) + ? ( + $mdy := $split($split($d, " ")[0], "/"); + $mdy[2] & "-" & $pad($mdy[0]) & "-" & $pad($mdy[1]) & "T00:00:00+05:30" + ) + ); + + $bs := response._beneficiaryStatus; + $governed := { "income tax payee": "IncomeTaxPayee", "land seeding, kys": "LandSeedingPending" }; + $notBlockers := ["Rsponce", "Responce", "Status", "status", "Message", "message"]; + $blockers := $flag($bs) = "true" + ? ( + $phrases := $distinct($filter( + $map($sort($filter($keys($bs), function($k) { $not($k in $notBlockers) })), + function($k) { $text($lookup($bs, $k)) }), + function($p) { $exists($p) and $lowercase($p) != "no errors" })); + [$map($phrases, function($p) { + { + "code": ( + $known := $lookup($governed, $lowercase($p)); + $exists($known) ? $known : "Other" + ), + "name": $p + } + })] + ); + + $c := beckn.message.contract.commitments[0]; + $ca := $c.commitmentAttributes; + + $flag(response) = "false" + ? { "_error": { "status": 400, "code": "BIZ_GENERIC_ERROR", + "message": "PM-KISAN holds no registration for this applicant." } } + : $flag(response) != "true" or $not($exists($count0)) + ? { "_error": { "status": 502, "code": "NET_DOWNSTREAM_UNAVAILABLE", + "message": "PM-KISAN could not read the registration" } } + : { + "context": $merge([beckn.context, { "action": "on_status", "timestamp": $now() }]), + "message": { + "contract": { + "id": beckn.message.contract.id, + "commitments": [{ + "status": { "descriptor": { "code": "CLOSED" } }, + "offer": $c.offer, + "resources": $c.resources, + "commitmentAttributes": { + "@context": $ca.`@context`, + "@type": $ca.`@type`, + "informationMode": "Direct", + "scheme": $ca.scheme, + "enrolmentId": $text(response.Reg_No), + "application": { + "registeredOn": $registeredOn, + "latestInstalmentPaid": $count0, + "ekyc": $exists(response.eKYC_Status) + ? { "code": $uppercase($trim($string(response.eKYC_Status))) = "Y" ? "Done" : "Pending" }, + "blockers": $blockers + } + } + }] + } + } + } + ) diff --git a/config/provider-adapter.yaml b/config/provider-adapter.yaml index f6dd62b6..651663a5 100644 --- a/config/provider-adapter.yaml +++ b/config/provider-adapter.yaml @@ -511,10 +511,11 @@ modules: # ------------------------------------------------------------------ # Grievance -- support files a grievance, status reads it back. # PM-KISAN: init (OTP), then support or status with the OTP. + # PM-KISAN application status: init (OTP), then status. # ------------------------------------------------------------------ - id: Grievance config: - bindingKeys: "pmfby|openagrinet:Grievance,pmkisan|openagrinet:Grievance" + bindingKeys: "pmfby|openagrinet:Grievance,pmkisan|openagrinet:Grievance,pmkisan-chatbot|openagrinet:SchemeApplication" # Paired by position, tried in order: status names the binding on # its contract, support on its channel. @@ -556,6 +557,22 @@ modules: otpVerifyActions: "support,status" otpVerifyTimeoutMs: "10000" + # PM-KISAN application status: the chatbot service, with a follow-up read (ADR-0005). + pmkisan-chatbot: + authScheme: none + envelope: aesCbcKeyInBand + envelopeTokenEnv: PMKISAN_OTP_TOKEN + envelopeTokenField: Token + envelopeRequestField: EncryptedRequest + envelopeResponsePaths: "d.output,output" + otpVerifyPath: /ChatbotOTPVerified + otpVerifyActions: status + otpVerifyTimeoutMs: "10000" + followUpPath: /ChatbotBeneficiaryStatus + followUpField: _beneficiaryStatus + followUpActions: status + followUpTimeoutMs: "10000" + # 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 diff --git a/pkg/plugin/implementation/Grievance/appstatus_mappings_test.go b/pkg/plugin/implementation/Grievance/appstatus_mappings_test.go new file mode 100644 index 00000000..e3370905 --- /dev/null +++ b/pkg/plugin/implementation/Grievance/appstatus_mappings_test.go @@ -0,0 +1,530 @@ +package Grievance_test + +// PM-KISAN application status, end to end: the real step over the shipped +// mappings, against a stand-in for PM-KISAN's chatbot service, configured as +// config/provider-adapter.yaml configures it. + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/beckn-one/beckn-onix/pkg/model" + "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 ( + asCapability = "openagrinet:SchemeApplication" + asBindingKey = "pmkisan-chatbot|" + asCapability + + // The chatbot service, named by the participant's baseUrl; the registry + // rows and the plugin paths carry the method alone. + asService = "/services/chatbotservice.asmx" +) + +const asInitRequest = `{ + "context": { + "version": "2.0.0", "action": "init", "networkId": "openagrinet", + "transactionId": "4c810000-0000-4000-8000-000000000001", + "messageId": "9e270000-0000-4000-8000-000000000002", + "timestamp": "2026-10-07T10:58:00Z" + }, + "message": { "contract": { + "id": "a9f3b062-5e17-4c84-9d20-6b48f1c7e053", + "commitments": [{ + "status": { "descriptor": { "code": "DRAFT" } }, + "offer": { + "id": "off:pmkisan:application-status", + "provider": { "id": "pmkisan-chatbot", "descriptor": { "name": "PM-KISAN Chatbot Service" } }, + "resourceIds": ["res:pmkisan:application-status"] + }, + "resources": [{ "id": "res:pmkisan:application-status", "quantity": { "count": 1 } }], + "commitmentAttributes": { + "@context": "https://openagrinet.github.io/network-specs/api-schemas/SchemeApplication/v0.1/context.jsonld", + "@type": "openagrinet:SchemeApplication", + "informationMode": "OnDemand", + "scheme": { "code": "PM-KISAN", "name": "Pradhan Mantri Kisan Samman Nidhi" }, + "applicant": { "mobile": "9812345670" } + } + }] + }} +}` + +const asStatusRequest = `{ + "context": { + "version": "2.0.0", "action": "status", "networkId": "openagrinet", + "transactionId": "4c810000-0000-4000-8000-000000000001", + "messageId": "b5300000-0000-4000-8000-000000000003", + "timestamp": "2026-10-07T11:02:00Z" + }, + "message": { "contract": { + "id": "a9f3b062-5e17-4c84-9d20-6b48f1c7e053", + "commitments": [{ + "status": { "descriptor": { "code": "ACTIVE" } }, + "offer": { + "id": "off:pmkisan:application-status", + "provider": { "id": "pmkisan-chatbot", "descriptor": { "name": "PM-KISAN Chatbot Service" } }, + "resourceIds": ["res:pmkisan:application-status"] + }, + "resources": [{ "id": "res:pmkisan:application-status", "quantity": { "count": 1 } }], + "commitmentAttributes": { + "@context": "https://openagrinet.github.io/network-specs/api-schemas/SchemeApplication/v0.1/context.jsonld", + "@type": "openagrinet:SchemeApplication", + "informationMode": "OnDemand", + "scheme": { "code": "PM-KISAN", "name": "Pradhan Mantri Kisan Samman Nidhi" }, + "applicant": { "mobile": "9812345670" }, + "challenge": { "response": { "method": "SMS_OTP", "value": "4821" } } + } + }] + }} +}` + +// asRecord is what /ChatbotUserDetails decrypts to: the record, with the +// farmer's personal data beside it. +const asRecord = `{ + "Rsponce": "True", "Message": "Success", + "BeneficiaryName": "LAKSHMI DEVI", "FatherName": "RAMAIAH", "DOB": "1980-01-01", + "Address": "12 Main Road", "StateName": "UTTAR PRADESH", "DistrictName": "AGRA", + "SubDistrictName": "ETMADPUR", "VillageName": "BARHAN", + "Reg_No": "UP45678901C", "DateOfRegistration": "2021-06-14T00:00:00", + "LatestInstallmentPaid": 15, "eKYC_Status": "N" +}` + +// asBlocked is what /ChatbotBeneficiaryStatus decrypts to: one key per +// condition, each holding the portal's phrase. +const asBlocked = `{ + "Rsponce": "True", "Message": "Success", + "LandStatus": "Land Seeding, KYS", + "BankStatus": "Bank account details could not be verified", + "AadhaarStatus": "No Errors", + "TaxStatus": "" +}` + +// asChatbot stands in for PM-KISAN's chatbot service: every path AES-128-CBC +// with the key in band, answering each under the request's key. +type asChatbot struct { + t *testing.T + calls map[string]int + sent map[string]map[string]string + answers map[string]string // per path; empty means the defaults below + status map[string]int // per path; non-zero answers with it and no body +} + +func newASChatbot(t *testing.T) *asChatbot { + return &asChatbot{t: t, calls: map[string]int{}, sent: map[string]map[string]string{}, + answers: map[string]string{ + "/ChatbotUserDetails": asRecord, + "/ChatbotBeneficiaryStatus": asBlocked, + }, + status: map[string]int{}} +} + +func (c *asChatbot) ServeHTTP(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, asService) + if path == r.URL.Path { + c.t.Errorf("the chatbot service was called at %s, outside %s", r.URL.Path, asService) + } + c.calls[path]++ + if code := c.status[path]; code != 0 { + w.WriteHeader(code) + return + } + body, _ := io.ReadAll(r.Body) + var envelope map[string]string + _ = json.Unmarshal(body, &envelope) + sealed, keyString, found := strings.Cut(envelope["EncryptedRequest"], "@") + if !found { + c.t.Errorf("the chatbot service received %s, want base64(ciphertext)@key", body) + w.WriteHeader(http.StatusBadRequest) + return + } + raw, _ := base64.StdEncoding.DecodeString(sealed) + key := pkCBCKey(keyString) + block, _ := aes.NewCipher(key) + plain := make([]byte, len(raw)) + cipher.NewCBCDecrypter(block, key).CryptBlocks(plain, raw) + plain = plain[:len(plain)-int(plain[len(plain)-1])] + sent := map[string]string{} + _ = json.Unmarshal(plain, &sent) + c.sent[path] = sent + + answer := `{"Rsponce":"False","Message":"Request could not be processed"}` + switch { + case sent["Token"] != pkOTPToken: + answer = `{"Rsponce":"False","Message":"Invalid Token"}` + case c.answers[path] != "": + answer = c.answers[path] + case path == "/ChatbotOTP": + answer = `{"Rsponce":"True","Message":"OTP has been sent to the registered mobile number"}` + case path == "/ChatbotOTPVerified" && sent["OTP"] == pkValidOTP: + answer = `{"Rsponce":"True","Message":"OTP Verified"}` + } + padded := []byte(answer) + n := aes.BlockSize - len(padded)%aes.BlockSize + padded = append(padded, bytes.Repeat([]byte{byte(n)}, n)...) + out := make([]byte, len(padded)) + cipher.NewCBCEncrypter(block, key).CryptBlocks(out, padded) + fmt.Fprintf(w, `{"d":{"__type":"Chatbot.Output","output":%q}}`, base64.StdEncoding.EncodeToString(out)) +} + +// runAppStatus drives the real step over the shipped mappings against chatbot. +func runAppStatus(t *testing.T, chatbot *asChatbot, request string) ([]byte, error) { + t.Helper() + t.Setenv("TEST_PMKISAN_OTP_TOKEN", pkOTPToken) + + mappings := pkServeMappings(t) + host := httptest.NewServer(chatbot) + t.Cleanup(host.Close) + + mapper, closeMapper, err := jsonmapper.New(context.Background(), &jsonmapper.Config{}) + if err != nil { + t.Fatalf("failed to build the mapper: %v", err) + } + t.Cleanup(func() { _ = closeMapper() }) + + registry := &pkRegistry{plan: &model.ProviderRecord{ + BindingKey: asBindingKey, + ParticipantID: "pmkisan-chatbot", + CapabilityCode: asCapability, + BaseURL: host.URL + asService, + Actions: map[string]model.ActionPlan{ + "init": {Method: http.MethodPost, Path: "/ChatbotOTP", + Mappings: mappings.URL + "/application-status.init.yaml", TimeoutMs: 20000}, + "status": {Method: http.MethodPost, Path: "/ChatbotUserDetails", + Mappings: mappings.URL + "/application-status.status.yaml", TimeoutMs: 30000}, + }, + }} + + envelopes, _, err := Grievance.ParseEnvelopes(map[string]string{ + "envelope-pmkisan-chatbot": "aesCbcKeyInBand", + "envelopeTokenEnv-pmkisan-chatbot": "TEST_PMKISAN_OTP_TOKEN", + "envelopeTokenField-pmkisan-chatbot": "Token", + "envelopeRequestField-pmkisan-chatbot": "EncryptedRequest", + "envelopeResponsePaths-pmkisan-chatbot": "d.output,output", + "otpVerifyPath-pmkisan-chatbot": "/ChatbotOTPVerified", + "otpVerifyActions-pmkisan-chatbot": "status", + "followUpPath-pmkisan-chatbot": "/ChatbotBeneficiaryStatus", + "followUpField-pmkisan-chatbot": "_beneficiaryStatus", + "followUpActions-pmkisan-chatbot": "status", + }) + if err != nil { + t.Fatalf("ParseEnvelopes() = %v", err) + } + + step, closeStep, err := Grievance.New(context.Background(), registry, mapper, &Grievance.Config{ + BindingKeys: []string{asBindingKey}, + ProviderIDAt: "message.contract.commitments[].offer.provider.id", + CapabilityCodeAt: "message.contract.commitments[].commitmentAttributes.@type", + AuthByProvider: map[string]*common.AuthProfile{ + "pmkisan-chatbot": {Scheme: util.AuthSchemeNone}, + }, + EnvelopeByProvider: envelopes.Envelopes, + }) + if err != nil { + t.Fatalf("failed to build the step: %v", err) + } + t.Cleanup(func() { _ = closeStep() }) + + stepCtx := &model.StepContext{Context: t.Context(), Body: []byte(request)} + if err := step.Run(stepCtx); err != nil { + return nil, err + } + if len(stepCtx.ResponseBody) == 0 { + t.Fatal("the step produced no answer and no error") + } + return stepCtx.ResponseBody, nil +} + +func asAttributes(t *testing.T, body []byte) map[string]any { + t.Helper() + attributes, _ := pkAt(t, pkDecode(t, body), "message", "contract", "commitments", 0, "commitmentAttributes").(map[string]any) + return attributes +} + +// --- init ------------------------------------------------------------------- + +// The slot names the kind: enrolmentId or applicant.mobile. +func TestAppStatusInitAsksForAnOTPByTheSlotItIsIn(t *testing.T) { + for _, tc := range []struct{ name, slot, id, types string }{ + {"mobile", `"applicant": { "mobile": "9812345670" }`, "9812345670", "Mobile"}, + {"registration", `"enrolmentId": "UP45678901C"`, "UP45678901C", "Ben_id"}, + } { + t.Run(tc.name, func(t *testing.T) { + chatbot := newASChatbot(t) + request := strings.Replace(asInitRequest, `"applicant": { "mobile": "9812345670" }`, tc.slot, 1) + body, err := runAppStatus(t, chatbot, request) + if err != nil { + t.Fatalf("Run() = %v", err) + } + sent := chatbot.sent["/ChatbotOTP"] + if sent["Types"] != tc.types || sent["Values"] != tc.id || sent["Token"] != pkOTPToken { + t.Errorf("/ChatbotOTP got %v, want Types %s, the id and the token", sent, tc.types) + } + + answer := pkDecode(t, body) + if got := pkAt(t, answer, "context", "action"); got != "on_init" { + t.Errorf("action = %v, want on_init", got) + } + if got := pkAt(t, answer, "message", "contract", "commitments", 0, "status", "descriptor", "code"); got != "DRAFT" { + t.Errorf("commitment status = %v, want DRAFT", got) + } + attributes := asAttributes(t, body) + challenge, _ := attributes["challenge"].(map[string]any) + issued, _ := challenge["issued"].(map[string]any) + if issued["method"] != "SMS_OTP" || issued["length"] != float64(4) || issued["valueType"] != "NUMERIC" || + issued["expiresAt"] == nil || issued["sentTo"] != nil { + t.Errorf("challenge.issued = %v, want method, length 4, valueType NUMERIC, expiresAt and no sentTo", issued) + } + if attributes["applicant"] != nil || attributes["enrolmentId"] != nil || attributes["informationMode"] != "OnDemand" { + t.Errorf("attributes = %v, want OnDemand with the identifier not echoed", attributes) + } + if strings.Contains(string(body), tc.id) { + t.Errorf("on_init carries the applicant's id: %s", body) + } + }) + } +} + +func TestAppStatusInitReportsAnOTPServiceRefusal(t *testing.T) { + chatbot := newASChatbot(t) + chatbot.answers["/ChatbotOTP"] = `{"Rsponce":"False","Message":"OTP limit exceeded for 9812345670"}` + _, err := runAppStatus(t, chatbot, asInitRequest) + beckn, status := pkBecknErr(t, err) + if status != http.StatusBadGateway || beckn.Code != "NET_DOWNSTREAM_UNAVAILABLE" { + t.Errorf("got %s/%d, want NET_DOWNSTREAM_UNAVAILABLE/502", beckn.Code, status) + } + if strings.Contains(beckn.Message, "9812345670") { + t.Errorf("the error %q passes the portal's text through", beckn.Message) + } +} + +// --- status ----------------------------------------------------------------- + +func TestAppStatusReadsTheRecordAndWhatIsBlockingPayment(t *testing.T) { + chatbot := newASChatbot(t) + body, err := runAppStatus(t, chatbot, asStatusRequest) + if err != nil { + t.Fatalf("Run() = %v", err) + } + + // Verified first, then the record, then the blockers -- each once, all + // with the declared kind and the id, and the OTP to the verify alone. + for _, path := range []string{"/ChatbotOTPVerified", "/ChatbotUserDetails", "/ChatbotBeneficiaryStatus"} { + if chatbot.calls[path] != 1 { + t.Errorf("%s called %d time(s), want 1", path, chatbot.calls[path]) + } + sent := chatbot.sent[path] + if sent["Types"] != "Mobile" || sent["Values"] != "9812345670" || sent["Token"] != pkOTPToken { + t.Errorf("%s got %v, want Types Mobile, the id and the token", path, sent) + } + } + if chatbot.sent["/ChatbotOTPVerified"]["OTP"] != pkValidOTP { + t.Error("the verify did not carry the OTP") + } + for _, path := range []string{"/ChatbotUserDetails", "/ChatbotBeneficiaryStatus"} { + if _, carried := chatbot.sent[path]["OTP"]; carried { + t.Errorf("%s was sent the OTP; it goes to the verify alone", path) + } + } + + answer := pkDecode(t, body) + if got := pkAt(t, answer, "context", "action"); got != "on_status" { + t.Errorf("action = %v, want on_status", got) + } + if got := pkAt(t, answer, "message", "contract", "commitments", 0, "status", "descriptor", "code"); got != "CLOSED" { + t.Errorf("commitment status = %v, want CLOSED: the question is answered", got) + } + + attributes := asAttributes(t, body) + want := map[string]any{ + "@context": "https://openagrinet.github.io/network-specs/api-schemas/SchemeApplication/v0.1/context.jsonld", + "@type": "openagrinet:SchemeApplication", + "informationMode": "Direct", + "scheme": map[string]any{"code": "PM-KISAN", "name": "Pradhan Mantri Kisan Samman Nidhi"}, + "enrolmentId": "UP45678901C", + "application": map[string]any{ + "registeredOn": "2021-06-14T00:00:00+05:30", + "latestInstalmentPaid": float64(15), + "ekyc": map[string]any{"code": "Pending"}, + "blockers": []any{ + map[string]any{"code": "Other", "name": "Bank account details could not be verified"}, + map[string]any{"code": "LandSeedingPending", "name": "Land Seeding, KYS"}, + }, + }, + } + got, _ := json.Marshal(attributes) + wanted, _ := json.Marshal(want) + if string(got) != string(wanted) { + t.Errorf("attributes =\n%s\nwant\n%s", got, wanted) + } + + // An allow-list: none of the farmer's personal data, and not the id they + // were looked up by. + for _, leaked := range []string{"LAKSHMI", "RAMAIAH", "1980-01-01", "Main Road", "AGRA", "BARHAN", "9812345670", pkValidOTP} { + if strings.Contains(string(body), leaked) { + t.Errorf("on_status carries %q", leaked) + } + } +} + +func TestAppStatusSaysPaymentIsClearOnlyWhenTheCheckRan(t *testing.T) { + for _, tc := range []struct { + name string + blocked string + status int + blockers any // nil: absent + }{ + {"the check names nothing", `{"Rsponce":"True","Message":"Success","AadhaarStatus":"No Errors"}`, 0, []any{}}, + {"the check is unreachable", "", http.StatusInternalServerError, nil}, + {"the check refuses", `{"Rsponce":"False","Message":"Try later"}`, 0, nil}, + {"the check answers without a flag", `{"LandStatus":"Land Seeding, KYS"}`, 0, nil}, + {"the check names an income-tax payee", `{"Rsponce":"True","TaxStatus":"Income Tax Payee"}`, 0, + []any{map[string]any{"code": "IncomeTaxPayee", "name": "Income Tax Payee"}}}, + } { + t.Run(tc.name, func(t *testing.T) { + chatbot := newASChatbot(t) + chatbot.answers["/ChatbotBeneficiaryStatus"] = tc.blocked + chatbot.status["/ChatbotBeneficiaryStatus"] = tc.status + body, err := runAppStatus(t, chatbot, asStatusRequest) + if err != nil { + t.Fatalf("Run() = %v; a failed blocker check must not fail the read", err) + } + application, _ := asAttributes(t, body)["application"].(map[string]any) + if application["latestInstalmentPaid"] != float64(15) { + t.Errorf("application = %v, want the record either way", application) + } + blockers, present := application["blockers"] + switch { + case tc.blockers == nil && present: + t.Errorf("blockers = %v, want ABSENT: the check did not answer, which is not 'clear'", blockers) + case tc.blockers != nil: + got, _ := json.Marshal(blockers) + want, _ := json.Marshal(tc.blockers) + if !present || string(got) != string(want) { + t.Errorf("blockers = %s (present %v), want %s", got, present, want) + } + } + }) + } +} + +func TestAppStatusReadsTheRecordsFieldsAsTheyArrive(t *testing.T) { + for _, tc := range []struct { + name, from, to string + path []any + want any + }{ + {"a .NET date", `"2021-06-14T00:00:00"`, `"/Date(1623609000000)/"`, []any{"registeredOn"}, "2021-06-14T00:00:00+05:30"}, + {"a US date with a time", `"2021-06-14T00:00:00"`, `"6/14/2021 12:00:00 AM"`, []any{"registeredOn"}, "2021-06-14T00:00:00+05:30"}, + {"a bare ISO date", `"2021-06-14T00:00:00"`, `"2021-06-14"`, []any{"registeredOn"}, "2021-06-14T00:00:00+05:30"}, + {"a date it cannot read", `"2021-06-14T00:00:00"`, `"sometime in June"`, []any{"registeredOn"}, nil}, + {"a count as text", `15`, `"0"`, []any{"latestInstalmentPaid"}, float64(0)}, + {"eKYC done", `"eKYC_Status": "N"`, `"eKYC_Status": "Y"`, []any{"ekyc", "code"}, "Done"}, + {"eKYC unrecognised", `"eKYC_Status": "N"`, `"eKYC_Status": "maybe"`, []any{"ekyc", "code"}, "Pending"}, + {"eKYC absent", `, "eKYC_Status": "N"`, ``, []any{"ekyc"}, nil}, + } { + t.Run(tc.name, func(t *testing.T) { + chatbot := newASChatbot(t) + record := strings.Replace(asRecord, tc.from, tc.to, 1) + if record == asRecord { + t.Fatalf("the edit %q did not apply", tc.from) + } + chatbot.answers["/ChatbotUserDetails"] = record + body, err := runAppStatus(t, chatbot, asStatusRequest) + if err != nil { + t.Fatalf("Run() = %v", err) + } + var node any = asAttributes(t, body)["application"] + for _, key := range tc.path { + fields, _ := node.(map[string]any) + node = fields[key.(string)] + } + if fmt.Sprint(node) != fmt.Sprint(tc.want) { + t.Errorf("%v = %v, want %v", tc.path, node, tc.want) + } + }) + } +} + +func TestAppStatusReportsNoRegistrationAsARefusal(t *testing.T) { + chatbot := newASChatbot(t) + chatbot.answers["/ChatbotUserDetails"] = `{"Rsponce":"False","Message":"No record found for 9812345670"}` + _, err := runAppStatus(t, chatbot, asStatusRequest) + beckn, status := pkBecknErr(t, err) + if status != http.StatusBadRequest || beckn.Code != "BIZ_GENERIC_ERROR" { + t.Errorf("got %s/%d, want BIZ_GENERIC_ERROR/400", beckn.Code, status) + } + if strings.Contains(beckn.Message, "9812345670") { + t.Errorf("the answer %q passes the portal's text through", beckn.Message) + } + if chatbot.calls["/ChatbotBeneficiaryStatus"] != 0 { + t.Error("the blockers were read for a registration that does not exist") + } +} + +func TestAppStatusFailsClosedOnARecordItCannotUse(t *testing.T) { + for name, record := range map[string]string{ + "no flag": `{"Reg_No":"UP45678901C","LatestInstallmentPaid":3}`, + "no instalments": `{"Rsponce":"True","Reg_No":"UP45678901C"}`, + "a negative count": `{"Rsponce":"True","Reg_No":"UP45678901C","LatestInstallmentPaid":"-1"}`, + } { + t.Run(name, func(t *testing.T) { + chatbot := newASChatbot(t) + chatbot.answers["/ChatbotUserDetails"] = record + _, err := runAppStatus(t, chatbot, asStatusRequest) + beckn, status := pkBecknErr(t, err) + if status != http.StatusBadGateway || beckn.Code != "NET_DOWNSTREAM_UNAVAILABLE" { + t.Errorf("got %s/%d, want NET_DOWNSTREAM_UNAVAILABLE/502", beckn.Code, status) + } + }) + } +} + +func TestAppStatusAWrongOTPReadsNothing(t *testing.T) { + chatbot := newASChatbot(t) + _, err := runAppStatus(t, chatbot, strings.Replace(asStatusRequest, `"value": "4821"`, `"value": "0000"`, 1)) + beckn, status := pkBecknErr(t, err) + if status != http.StatusBadRequest || beckn.Code != "BIZ_GENERIC_ERROR" { + t.Errorf("got %s/%d, want BIZ_GENERIC_ERROR/400", beckn.Code, status) + } + if chatbot.calls["/ChatbotUserDetails"] != 0 || chatbot.calls["/ChatbotBeneficiaryStatus"] != 0 { + t.Errorf("the record was read on a wrong OTP: %v", chatbot.calls) + } +} + +// The OTP's format is the schema's to refuse; only its absence is checked +// here, because the schema cannot tell a status ask from an init ask. +func TestAppStatusRefusesAMissingOTP(t *testing.T) { + for _, tc := range []struct{ name, from, to, code string }{ + {"no OTP", `, + "challenge": { "response": { "method": "SMS_OTP", "value": "4821" } }`, ``, "SCH_REQUIRED_FIELD_MISSING"}, + } { + t.Run(tc.name, func(t *testing.T) { + chatbot := newASChatbot(t) + request := strings.Replace(asStatusRequest, tc.from, tc.to, 1) + if request == asStatusRequest { + t.Fatalf("the edit %q did not apply", tc.from) + } + _, err := runAppStatus(t, chatbot, request) + beckn, status := pkBecknErr(t, err) + if status != http.StatusBadRequest || beckn.Code != tc.code { + t.Errorf("got %s/%d, want %s/400", beckn.Code, status, tc.code) + } + if len(chatbot.calls) != 0 { + t.Errorf("the chatbot service was called %v", chatbot.calls) + } + }) + } +} diff --git a/pkg/plugin/implementation/Grievance/cmd/pmkisan_test.go b/pkg/plugin/implementation/Grievance/cmd/pmkisan_test.go index ca111d8b..e8911e2c 100644 --- a/pkg/plugin/implementation/Grievance/cmd/pmkisan_test.go +++ b/pkg/plugin/implementation/Grievance/cmd/pmkisan_test.go @@ -147,6 +147,12 @@ func TestShippedConfig_GrievanceEntry_BuildsStep(t *testing.T) { if _, ok := cfg.EnvelopeByProvider["pmkisan"]; !ok { t.Error("the shipped entry gives pmkisan no envelope; the portal would be sent plaintext") } + if _, ok := cfg.EnvelopeByProvider["pmkisan-chatbot"]; !ok { + t.Error("the shipped entry gives pmkisan-chatbot no envelope; the chatbot service would be sent plaintext") + } + if cfg.AuthByProvider["pmkisan-chatbot"] == nil { + t.Error("the shipped entry gives pmkisan-chatbot no auth profile; application status would not start") + } if _, _, err := (grievanceProvider{}).New(context.Background(), stubRegistry{}, stubMapper{}, shipped); err != nil { t.Fatalf("the shipped entry does not build a step: %v", err) } diff --git a/pkg/plugin/implementation/Grievance/otp.go b/pkg/plugin/implementation/Grievance/otp.go index 3741ef6e..769d4eaa 100644 --- a/pkg/plugin/implementation/Grievance/otp.go +++ b/pkg/plugin/implementation/Grievance/otp.go @@ -17,6 +17,9 @@ const challengeMethod = "SMS_OTP" // identityType is the OTP service's type for a registration number (enrolmentId). const identityType = "Ben_id" +// mobileType is the OTP service's type for applicant.mobile. +const mobileType = "Mobile" + // hooks are the grievance packs' side of the envelope: where the challenge // sits in a payload, and what PM-KISAN's "yes" looks like. var hooks = envelope.Hooks{Challenge: verifyRequest, Succeeded: succeeded} @@ -42,8 +45,8 @@ func verifyRequest(exchange common.Exchange) (map[string]any, error) { return map[string]any{"Types": kind, "Values": identity, "OTP": otp}, nil } -// challengeIn reads enrolmentId and the challenge from support's channel or -// the commitment's attributes. +// challengeIn reads enrolmentId (else applicant.mobile) and the challenge from +// support's channel or the commitment's attributes. func challengeIn(exchange common.Exchange) (kind, identity, method, otp string) { payload, _ := exchange.Beckn.(map[string]any) message, _ := payload["message"].(map[string]any) @@ -61,6 +64,10 @@ func challengeIn(exchange common.Exchange) (kind, identity, method, otp string) } if id := text(holder["enrolmentId"]); id != "" { kind, identity = identityType, id + } else if applicant, ok := holder["applicant"].(map[string]any); ok { + if id := text(applicant["mobile"]); id != "" { + kind, identity = mobileType, id + } } challenge, _ := holder["challenge"].(map[string]any) if response, ok := challenge["response"].(map[string]any); ok { diff --git a/pkg/plugin/implementation/Grievance/otp_test.go b/pkg/plugin/implementation/Grievance/otp_test.go index c2512a7c..1e37094d 100644 --- a/pkg/plugin/implementation/Grievance/otp_test.go +++ b/pkg/plugin/implementation/Grievance/otp_test.go @@ -24,14 +24,14 @@ func TestSucceededFailsClosed(t *testing.T) { } } -// The registration number is the only identifier. +// The slot names the kind: enrolmentId or applicant.mobile. func TestChallengeInReadsTheKindFromTheSlot(t *testing.T) { for name, tc := range map[string]struct { attributes map[string]any kind, id string }{ "enrolmentId": {map[string]any{"enrolmentId": "UP12345678A"}, "Ben_id", "UP12345678A"}, - "a mobile": {map[string]any{"applicant": map[string]any{"mobile": "9812345670"}}, "", ""}, + "mobile": {map[string]any{"applicant": map[string]any{"mobile": "9812345670"}}, "Mobile", "9812345670"}, "an Aadhaar": {map[string]any{"applicant": map[string]any{"aadhaar": "123456789012"}}, "", ""}, "the old idType shape": {map[string]any{"applicant": map[string]any{"idType": "Mobile", "id": "9812345670"}}, "", ""}, "nothing": {map[string]any{}, "", ""},