From e25d85d7dbfcd7e2e7e285aa6c8e70584b0f7ad7 Mon Sep 17 00:00:00 2001 From: KrutikaPhirangi <138781661+KrutikaPhirangi@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:37:48 +0530 Subject: [PATCH 1/3] feat: seal a provider's bodies in a shared envelope, and host an action on another participant [#60] internal/common/envelope seals a mapped request and opens the answer: aesGcm (fixed key and nonce) and aesCbcKeyInBand (fresh key per request), with the service token added after the mapping. It can verify a challenge on the provider before the guarded call, on its own budget, through a client that refuses an off-host redirect. A capability supplies Hooks: where its payload carries the challenge and what its provider's "yes" looks like. common.Config.HostedElsewhere sends an action to the participant behind another binding key, its host read from the registry per call (common.HostOf). --- .../implementation/internal/common/common.go | 56 +++ .../internal/common/envelope/call.go | 50 +++ .../internal/common/envelope/cbc.go | 196 +++++++++ .../internal/common/envelope/cbc_test.go | 154 +++++++ .../internal/common/envelope/envelope.go | 382 +++++++++++++++++ .../internal/common/envelope/envelope_test.go | 402 ++++++++++++++++++ .../internal/common/envelope/gcm.go | 158 +++++++ .../internal/common/envelope/hooks_test.go | 66 +++ .../internal/common/envelope/verify.go | 75 ++++ .../internal/common/envelope/verify_test.go | 329 ++++++++++++++ .../internal/common/envelope_test.go | 269 ++++++++++++ .../implementation/internal/common/hosts.go | 33 ++ .../implementation/internal/common/serve.go | 30 +- 13 files changed, 2198 insertions(+), 2 deletions(-) create mode 100644 pkg/plugin/implementation/internal/common/envelope/call.go create mode 100644 pkg/plugin/implementation/internal/common/envelope/cbc.go create mode 100644 pkg/plugin/implementation/internal/common/envelope/cbc_test.go create mode 100644 pkg/plugin/implementation/internal/common/envelope/envelope.go create mode 100644 pkg/plugin/implementation/internal/common/envelope/envelope_test.go create mode 100644 pkg/plugin/implementation/internal/common/envelope/gcm.go create mode 100644 pkg/plugin/implementation/internal/common/envelope/hooks_test.go create mode 100644 pkg/plugin/implementation/internal/common/envelope/verify.go create mode 100644 pkg/plugin/implementation/internal/common/envelope/verify_test.go create mode 100644 pkg/plugin/implementation/internal/common/envelope_test.go create mode 100644 pkg/plugin/implementation/internal/common/hosts.go diff --git a/pkg/plugin/implementation/internal/common/common.go b/pkg/plugin/implementation/internal/common/common.go index 3012eb41..07f57e56 100644 --- a/pkg/plugin/implementation/internal/common/common.go +++ b/pkg/plugin/implementation/internal/common/common.go @@ -22,6 +22,7 @@ import ( "strings" "github.com/beckn-one/beckn-onix/pkg/log" + "github.com/beckn-one/beckn-onix/pkg/model" "github.com/beckn-one/beckn-onix/pkg/plugin/definition" "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common/util" ) @@ -35,6 +36,24 @@ import ( // still decides what the provider is asked for. Most capabilities need none. type Prerequisites map[string]func(context.Context, any) (map[string]any, error) +// Envelope seals a provider's request once, before the retry loop, and +// returns the opener for its answer (ADR-0001). +type Envelope interface { + Seal(ctx context.Context, exchange Exchange, mapped []byte) (sealed []byte, open Opener, err error) +} + +// Opener decrypts the answer to one sealed request. +type Opener func(ctx context.Context, answer []byte) ([]byte, error) + +// Exchange is the call an envelope wraps: action, Beckn payload and call plan, +// and the registry, for an envelope that calls another participant too. +type Exchange struct { + Action string + Beckn any + Plan *model.ProviderRecord + Registry definition.ProviderRecordLookup +} + // Config holds configuration parameters for the step. type Config struct { // The capabilities this step answers to. A request for anything else @@ -58,6 +77,14 @@ type Config struct { ProviderIDAt string `yaml:"providerIdAt" json:"providerIdAt"` CapabilityCodeAt string `yaml:"capabilityCodeAt" json:"capabilityCodeAt"` + // EnvelopeByProvider, keyed by participant id. Unlisted providers get + // plain JSON. Built by the domain package. + EnvelopeByProvider map[string]Envelope `yaml:"-" json:"-"` + + // HostedElsewhere: provider -> action -> binding key of the participant + // that serves it (ADR-0002). + HostedElsewhere map[string]map[string]string `yaml:"-" json:"-"` + // One credential profile per provider, keyed by participant id -- the left // half of a binding key. // @@ -234,5 +261,34 @@ func applyDefaults(cfg *Config) error { return err } } + + // An envelope for an unserved provider is a typo; refuse it. + for provider, envelope := range cfg.EnvelopeByProvider { + if !served[provider] { + return fmt.Errorf( + "an envelope is configured for %q, which is not a provider in bindingKeys (%s)", + provider, strings.Join(cfg.BindingKeys, ", ")) + } + if envelope == nil { + return fmt.Errorf("the envelope configured for %q is nil", provider) + } + } + + // A host for an unserved provider is a typo too, and a key the registry + // cannot index would fail every call. + for provider, actions := range cfg.HostedElsewhere { + if !served[provider] { + return fmt.Errorf( + "an action host is configured for %q, which is not a provider in bindingKeys (%s)", + provider, strings.Join(cfg.BindingKeys, ", ")) + } + for action, key := range actions { + participant, capability, found := strings.Cut(key, separator) + if action == "" || !found || participant == "" || capability == "" { + return fmt.Errorf("%s: the host for %q must be a binding key, participant%scapability; got %q", + provider, action, separator, key) + } + } + } return nil } diff --git a/pkg/plugin/implementation/internal/common/envelope/call.go b/pkg/plugin/implementation/internal/common/envelope/call.go new file mode 100644 index 00000000..5efe74ba --- /dev/null +++ b/pkg/plugin/implementation/internal/common/envelope/call.go @@ -0,0 +1,50 @@ +package envelope + +import ( + "bytes" + "context" + "fmt" + "io" + "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/util" +) + +// maxAnswerBytes caps the OTP verify and follow-up answers. +const maxAnswerBytes = 64 << 10 + +// postSealed POSTs a sealed body to base+path once, within its own budget. +// Unreachable or non-2xx: 502. +func postSealed(ctx context.Context, client *http.Client, provider, what, base, path string, + call model.ActionPlan, sealed []byte) ([]byte, error) { + endpoint, err := util.BuildEndpoint(base, model.ActionPlan{Method: http.MethodPost, Path: path}, nil) + if err != nil { + return nil, err + } + timeout, _ := util.Budget(call) + attemptCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + req, err := http.NewRequestWithContext(attemptCtx, http.MethodPost, endpoint, bytes.NewReader(sealed)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + log.Warnf(ctx, "%s could not be reached for %s: %v", provider, what, err) + return nil, refused(fmt.Errorf("%s did not answer %s", provider, what)) + } + defer resp.Body.Close() + answer, err := io.ReadAll(io.LimitReader(resp.Body, maxAnswerBytes)) + if err != nil { + return nil, refused(fmt.Errorf("%s's answer to %s could not be read", provider, what)) + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + log.Warnf(ctx, "%s returned %s for %s", provider, resp.Status, what) + return nil, refused(fmt.Errorf("%s returned %s for %s", provider, resp.Status, what)) + } + return answer, nil +} diff --git a/pkg/plugin/implementation/internal/common/envelope/cbc.go b/pkg/plugin/implementation/internal/common/envelope/cbc.go new file mode 100644 index 00000000..2de7b15b --- /dev/null +++ b/pkg/plugin/implementation/internal/common/envelope/cbc.go @@ -0,0 +1,196 @@ +package envelope + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "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" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common/util" +) + +// keySeparator joins the ciphertext and its key on the wire: cipher@key. +const keySeparator = "@" + +// cbcCodec is AES-128-CBC with a fresh key per request, sent as cipher@key +// (key = IV = its first 16 bytes). Obfuscation only; the token is the secret. +type cbcCodec struct { + provider string + tokenEnv string + tokenField string + requestField string + responsePaths [][]string + + // crypto/rand outside tests. + random io.Reader +} + +// newCBCCodec validates otp* settings. +func newCBCCodec(provider string, s realmSettings) (*cbcCodec, error) { + return newCBCCodecFrom(provider, "otp", "otpEnvelope", s) +} + +// newCBCCodecFrom validates settings under the given prefix (otp or envelope). +func newCBCCodecFrom(provider, prefix, kindSetting string, s realmSettings) (*cbcCodec, error) { + if s.kind != envelopeAESCBCKeyInBand { + return nil, fmt.Errorf("%s: %s %q is not one this plugin knows; want %s", + provider, kindSetting, s.kind, envelopeAESCBCKeyInBand) + } + missing := missingSettings(map[string]string{ + prefix + "RequestField": s.requestField, + prefix + "ResponsePaths": s.responsePaths, + }) + missing = checkToken(s.tokenEnv, s.tokenField, missing, prefix) + if len(missing) > 0 { + return nil, fmt.Errorf("%s: %s %s needs %s", provider, kindSetting, envelopeAESCBCKeyInBand, + strings.Join(missing, ", ")) + } + paths, err := parsePaths(provider, prefix+"ResponsePaths", s.responsePaths) + if err != nil { + return nil, err + } + return &cbcCodec{provider: provider, tokenEnv: s.tokenEnv, tokenField: s.tokenField, + requestField: s.requestField, responsePaths: paths, random: rand.Reader}, nil +} + +// seal adds the token, encrypts under a fresh key, and returns its opener. +func (c *cbcCodec) seal(ctx context.Context, mapped []byte) ([]byte, common.Opener, error) { + plain, err := withToken(ctx, c.provider, mapped, c.tokenEnv, c.tokenField) + if err != nil { + return nil, nil, err + } + keyString, err := c.newKeyString() + if err != nil { + return nil, nil, fmt.Errorf("a key for %s could not be generated: %w", c.provider, err) + } + sealed, err := cbcEncrypt(keyString, plain) + if err != nil { + return nil, nil, fmt.Errorf("the %s request could not be encrypted: %w", c.provider, err) + } + body, err := json.Marshal(map[string]string{c.requestField: sealed + keySeparator + keyString}) + if err != nil { + return nil, nil, err + } + return body, func(ctx context.Context, answer []byte) ([]byte, error) { + return c.open(ctx, answer, keyString) + }, nil +} + +// open decrypts an answer; failures as in gcmCodec.Open (502/500/502). +func (c *cbcCodec) open(ctx context.Context, answer []byte, requestKey string) ([]byte, error) { + var wire any + if err := json.Unmarshal(answer, &wire); err != nil { + log.Warnf(ctx, "%s's OTP service answered with a body that is not JSON (%d bytes)", c.provider, len(answer)) + return nil, refused(fmt.Errorf("%s answered without an envelope", c.provider)) + } + sealed, found := sealedAnswer(wire, c.responsePaths) + if !found { + log.Warnf(ctx, "%s's OTP service answered with no envelope at any configured path", c.provider) + return nil, refused(fmt.Errorf("%s answered without an envelope", c.provider)) + } + + // base64 has no '@', so the last one, if any, starts the reply's key. + ciphertext, key := strings.TrimSpace(sealed), requestKey + if at := strings.LastIndex(ciphertext, keySeparator); at >= 0 { + if replyKey := ciphertext[at+1:]; replyKey != "" { + key = replyKey + } + ciphertext = ciphertext[:at] + } + raw, err := base64.StdEncoding.DecodeString(ciphertext) + if err != nil || len(raw) == 0 { + log.Warnf(ctx, "%s's OTP service answered with plain text where its envelope belongs: %s", + c.provider, redactToken(util.Explain([]byte(sealed)), c.tokenEnv)) + return nil, refused(fmt.Errorf("%s rejected the request", c.provider)) + } + + plain, err := cbcDecrypt(key, raw) + if err != nil { + log.Errorf(ctx, err, "%s's OTP service answer will not decrypt", c.provider) + return nil, model.NewCodedErr(http.StatusInternalServerError, codeInternal, + fmt.Errorf("the answer from %s could not be decrypted", c.provider)) + } + plain = bytes.Trim(plain, " \t\r\n\x00") + if !json.Valid(plain) { + log.Warnf(ctx, "%s's OTP service sealed something other than JSON (%d bytes)", c.provider, len(plain)) + return nil, refused(fmt.Errorf("%s answered with something that is not JSON", c.provider)) + } + return plain, nil +} + +// newKeyString is 16 random bytes as 32 lower-case hex characters. +func (c *cbcCodec) newKeyString() (string, error) { + raw := make([]byte, 16) + if _, err := io.ReadFull(c.random, raw); err != nil { + return "", err + } + return hex.EncodeToString(raw), nil +} + +// keyMaterial is the first 16 bytes of the key string, zero-padded. +func keyMaterial(keyString string) []byte { + key := make([]byte, aes.BlockSize) + copy(key, keyString) + return key +} + +// cbcEncrypt is base64(AES-128-CBC(PKCS#7(plain))) under a key string. +func cbcEncrypt(keyString string, plain []byte) (string, error) { + key := keyMaterial(keyString) + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + padded := pkcs7Pad(plain, aes.BlockSize) + out := make([]byte, len(padded)) + cipher.NewCBCEncrypter(block, key).CryptBlocks(out, padded) + return base64.StdEncoding.EncodeToString(out), nil +} + +// cbcDecrypt reverses cbcEncrypt. +func cbcDecrypt(keyString string, raw []byte) ([]byte, error) { + if len(raw)%aes.BlockSize != 0 { + return nil, errors.New("ciphertext is not a whole number of blocks") + } + key := keyMaterial(keyString) + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + out := make([]byte, len(raw)) + cipher.NewCBCDecrypter(block, key).CryptBlocks(out, raw) + return pkcs7Unpad(out, aes.BlockSize) +} + +func pkcs7Pad(data []byte, size int) []byte { + n := size - len(data)%size + return append(append([]byte{}, data...), bytes.Repeat([]byte{byte(n)}, n)...) +} + +func pkcs7Unpad(data []byte, size int) ([]byte, error) { + if len(data) == 0 { + return nil, errors.New("empty plaintext") + } + n := int(data[len(data)-1]) + if n == 0 || n > size || n > len(data) { + return nil, errors.New("bad padding") + } + for _, b := range data[len(data)-n:] { + if int(b) != n { + return nil, errors.New("bad padding") + } + } + return data[:len(data)-n], nil +} diff --git a/pkg/plugin/implementation/internal/common/envelope/cbc_test.go b/pkg/plugin/implementation/internal/common/envelope/cbc_test.go new file mode 100644 index 00000000..3dba40d2 --- /dev/null +++ b/pkg/plugin/implementation/internal/common/envelope/cbc_test.go @@ -0,0 +1,154 @@ +package envelope + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/hex" + "encoding/json" + "net/http" + "strings" + "testing" +) + +// Produced by Node's crypto the way the legacy PM-KISAN chatbot client does it: +// key string 32 hex characters, AES-128-CBC key and IV both its first 16 UTF-8 +// bytes, PKCS#7, base64. Matching them proves this codec speaks the OTP +// service's format byte for byte, not merely that it round-trips with itself. +const ( + cbcKeyString = "0123456789abcdef0123456789abcdef" + cbcPlain = `{"Types":"Ben_id","Values":"UP12345678A","Token":"test-otp-token"}` + cbcCipher = "MZOLY5yjTYkg9P+xMELaU7hdOIMvjSD5JeV5oUSATtmHsgftQ6GwY8yYERabCGTAZc5bxTopE+wxXWzxwD/e49OKZ5itMwwbCBThrF/svOE=" + + // An answer sealed under its own key, named after the '@'. + cbcReply = "DladryWQZWg3x/IX77sFz5fyvL8fZS58iyeovMdphFl1xs/uwnFWA3cdIOe8A3wd@fedcba9876543210fedcba9876543210" + cbcReplyPlain = `{"Rsponce":"True","Message":"OTP Verified"}` + + testOTPTokenEnv = "TEST_PMKISAN_OTP_TOKEN" + testOTPBindingKey = "pmkisan-chatbot|openagrinet:SchemeApplication" + testOTPToken = "test-otp-token" +) + +func otpRealm() realmSettings { + return realmSettings{kind: envelopeAESCBCKeyInBand, bindingKey: testOTPBindingKey, tokenEnv: testOTPTokenEnv, tokenField: "Token", + requestField: "EncryptedRequest", responsePaths: "d.output,output"} +} + +// testCBC is a CBC codec whose key strings are cbcKeyString, so its output is +// predictable. +func testCBC(t *testing.T) *cbcCodec { + t.Helper() + c, err := newCBCCodec("pmkisan", otpRealm()) + if err != nil { + t.Fatalf("newCBCCodec() = %v", err) + } + raw, _ := hex.DecodeString(cbcKeyString) + c.random = bytes.NewReader(bytes.Repeat(raw, 8)) + return c +} + +func TestCBCEncryptsWhatTheLegacyClientEncrypts(t *testing.T) { + got, err := cbcEncrypt(cbcKeyString, []byte(cbcPlain)) + if err != nil { + t.Fatalf("cbcEncrypt() = %v", err) + } + if got != cbcCipher { + t.Errorf("cbcEncrypt() = %s, want the legacy client's %s", got, cbcCipher) + } + raw, _ := base64.StdEncoding.DecodeString(cbcCipher) + plain, err := cbcDecrypt(cbcKeyString, raw) + if err != nil || string(plain) != cbcPlain { + t.Errorf("cbcDecrypt() = %s, %v; want the plaintext back", plain, err) + } +} + +func TestCBCSealSendsTheKeyBesideTheCiphertext(t *testing.T) { + t.Setenv(testOTPTokenEnv, testOTPToken) + c := testCBC(t) + + wire, open, err := c.seal(context.Background(), []byte(`{"Types":"Ben_id","Values":"UP12345678A"}`)) + if err != nil { + t.Fatalf("seal() = %v", err) + } + var body map[string]string + if err := json.Unmarshal(wire, &body); err != nil || len(body) != 1 { + t.Fatalf("seal() = %s, want exactly {\"EncryptedRequest\": ...}", wire) + } + sealed, key, found := strings.Cut(body["EncryptedRequest"], "@") + if !found || key != cbcKeyString { + t.Fatalf("EncryptedRequest = %q, want base64(ciphertext)@%s", body["EncryptedRequest"], cbcKeyString) + } + raw, _ := base64.StdEncoding.DecodeString(sealed) + plain, err := cbcDecrypt(key, raw) + if err != nil { + t.Fatalf("the sealed request does not decrypt under its own key: %v", err) + } + var sent map[string]string + _ = json.Unmarshal(plain, &sent) + if sent["Token"] != testOTPToken || sent["Values"] != "UP12345678A" { + t.Errorf("plaintext = %s, want the mapped fields plus Token", plain) + } + + // An answer with no key of its own opens under the request's. + reply, _ := cbcEncrypt(cbcKeyString, []byte(cbcReplyPlain)) + opened, err := open(context.Background(), []byte(`{"d":{"output":"`+reply+`"}}`)) + if err != nil || string(opened) != cbcReplyPlain { + t.Errorf("open() = %s, %v; want the reply opened under the request's key", opened, err) + } +} + +func TestCBCOpensAnAnswerUnderItsOwnKey(t *testing.T) { + c := testCBC(t) + plain, err := c.open(context.Background(), []byte(`{"d":{"output":"`+cbcReply+`"}}`), "wrong-request-key-wrong-request-k") + if err != nil || string(plain) != cbcReplyPlain { + t.Fatalf("open() = %s, %v; want the reply opened under the key it names", plain, err) + } +} + +func TestCBCOpenClassifiesWhatItCannotUse(t *testing.T) { + c := testCBC(t) + wrongKey, _ := cbcEncrypt("ffffffffffffffffffffffffffffffff", []byte(cbcReplyPlain)) + for _, tc := range []struct { + name, body, code string + status int + }{ + {"not JSON", `down`, "NET_DOWNSTREAM_UNAVAILABLE", http.StatusBadGateway}, + {"no envelope", `{"d":{}}`, "NET_DOWNSTREAM_UNAVAILABLE", http.StatusBadGateway}, + {"plain text where the ciphertext belongs", `{"d":{"output":"Invalid Token"}}`, "NET_DOWNSTREAM_UNAVAILABLE", http.StatusBadGateway}, + {"sealed under another key", `{"d":{"output":"` + wrongKey + `"}}`, "NET_INTERNAL_ERROR", http.StatusInternalServerError}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := c.open(context.Background(), []byte(tc.body), cbcKeyString) + if err == nil { + t.Fatal("open() succeeded, want a refusal") + } + if code, status := codeOf(t, err); code != tc.code || status != tc.status { + t.Errorf("got %s/%d, want %s/%d", code, status, tc.code, tc.status) + } + }) + } +} + +func TestCBCSealFailsClosedWithoutItsToken(t *testing.T) { + t.Setenv(testOTPTokenEnv, "") + if _, _, err := testCBC(t).seal(context.Background(), []byte(`{}`)); err == nil { + t.Fatal("seal() succeeded with the token unset; it must never send without it") + } +} + +func TestNewCBCCodecRefusesIncompleteSettings(t *testing.T) { + for name, tweak := range map[string]func(*realmSettings){ + "unknown kind": func(s *realmSettings) { s.kind = "aesGcm" }, + "no request field": func(s *realmSettings) { s.requestField = "" }, + "no response path": func(s *realmSettings) { s.responsePaths = "" }, + "token with no field": func(s *realmSettings) { s.tokenField = "" }, + } { + t.Run(name, func(t *testing.T) { + s := otpRealm() + tweak(&s) + if _, err := newCBCCodec("pmkisan", s); err == nil { + t.Fatal("newCBCCodec() accepted settings that cannot work") + } + }) + } +} diff --git a/pkg/plugin/implementation/internal/common/envelope/envelope.go b/pkg/plugin/implementation/internal/common/envelope/envelope.go new file mode 100644 index 00000000..dd92fb8e --- /dev/null +++ b/pkg/plugin/implementation/internal/common/envelope/envelope.go @@ -0,0 +1,382 @@ +// Package envelope encrypts a provider's bodies, verifies a challenge before +// the call and makes follow-up reads, so mappings stay plain JSON (ADR-0001). +package envelope + +import ( + "context" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "sort" + "strconv" + "strings" + + "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" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common/util" +) + +// Envelope kinds. See gcm.go and cbc.go. +const ( + envelopeAESGCM = "aesGcm" // fixed key and nonce + envelopeAESCBCKeyInBand = "aesCbcKeyInBand" // fresh key per request, sent as cipher@key +) + +// codeInternal is returned when the adapter itself is misconfigured. +const codeInternal = "NET_INTERNAL_ERROR" + +// envelopeFields maps each provider-block setting to its field. envelope* is the +// default codec, otp* a separate OTP service. +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 }, + "envelopeNonceEnv": func(s *envelopeSettings, v string) { s.nonceEnv = v }, + "envelopeTokenEnv": func(s *envelopeSettings, v string) { s.tokenEnv = v }, + "envelopeTokenField": func(s *envelopeSettings, v string) { s.tokenField = v }, + "envelopeRequestField": func(s *envelopeSettings, v string) { s.requestField = v }, + "envelopeResponsePaths": func(s *envelopeSettings, v string) { s.responsePaths = v }, + + "otpEnvelope": func(s *envelopeSettings, v string) { s.otp.kind = v }, + "otpBindingKey": func(s *envelopeSettings, v string) { s.otp.bindingKey = v }, + "otpTokenEnv": func(s *envelopeSettings, v string) { s.otp.tokenEnv = v }, + "otpTokenField": func(s *envelopeSettings, v string) { s.otp.tokenField = v }, + "otpRequestField": func(s *envelopeSettings, v string) { s.otp.requestField = v }, + "otpResponsePaths": func(s *envelopeSettings, v string) { s.otp.responsePaths = v }, + "otpActions": func(s *envelopeSettings, v string) { s.otpActions = v }, + "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 }, +} + +// envelopeSettings is one provider's envelope as written in config. +type envelopeSettings struct { + kind, keyEnv, nonceEnv, tokenEnv, tokenField, requestField, responsePaths string + + otp realmSettings + otpActions string + verifyPath string + verifyActions string + verifyTimeoutMs string +} + +// realmSettings configures a CBC codec. bindingKey names the registry binding +// whose participant is the OTP service, a host other than the provider's. +type realmSettings struct { + kind, bindingKey, tokenEnv, tokenField, requestField, responsePaths string +} + +// Parsed is each provider's envelope and the actions it sends elsewhere, for +// common.Config's EnvelopeByProvider and HostedElsewhere. +type Parsed struct { + Envelopes map[string]common.Envelope + HostedElsewhere map[string]map[string]string +} + +// Parse reads each provider's "setting-provider" envelope settings and returns +// the rest. +func Parse(config map[string]string, hooks Hooks) (Parsed, map[string]string, error) { + rest := make(map[string]string, len(config)) + settings := map[string]*envelopeSettings{} + for key, value := range config { + field, provider, dashed := strings.Cut(key, "-") + set, isEnvelope := envelopeFields[field] + if !dashed || !isEnvelope { + rest[key] = value + continue + } + if settings[provider] == nil { + settings[provider] = &envelopeSettings{} + } + set(settings[provider], value) + } + if len(settings) == 0 { + return Parsed{}, rest, nil + } + + parsed := Parsed{Envelopes: make(map[string]common.Envelope, len(settings))} + for provider, s := range settings { + e, err := newProviderEnvelope(provider, *s, hooks) + if err != nil { + return Parsed{}, nil, err + } + parsed.Envelopes[provider] = e + if hosted := e.hostedElsewhere(); len(hosted) > 0 { + if parsed.HostedElsewhere == nil { + parsed.HostedElsewhere = map[string]map[string]string{} + } + parsed.HostedElsewhere[provider] = hosted + } + } + return parsed, rest, nil +} + +// codec seals a request and returns the opener for its answer. +type codec interface { + seal(ctx context.Context, mapped []byte) ([]byte, common.Opener, error) +} + +// providerEnvelope picks a codec per action and runs the OTP verify and +// follow-up read where configured. A nil field means that feature is off. +type providerEnvelope struct { + provider string + base codec // nil: plain JSON + otp codec + otpBindingKey string + otpActions map[string]bool + verify *verify +} + +var _ common.Envelope = (*providerEnvelope)(nil) + +// hostedElsewhere sends the OTP actions to the OTP service's participant. +func (e *providerEnvelope) hostedElsewhere() map[string]string { + if e.otpBindingKey == "" { + return nil + } + hosted := make(map[string]string, len(e.otpActions)) + for action := range e.otpActions { + hosted[action] = e.otpBindingKey + } + return hosted +} + +// otpHost is the OTP service's base URL, read from the registry per call. +func (e *providerEnvelope) otpHost(ctx context.Context, exchange common.Exchange) (string, error) { + return common.HostOf(ctx, exchange.Registry, e.otpBindingKey) +} + +// 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) { + if e.verify != nil && e.verify.actions[exchange.Action] { + if err := e.verify.check(ctx, exchange); err != nil { + return nil, nil, err + } + } + c := e.base + if e.otpActions[exchange.Action] { + c = e.otp + } + if c == nil { + return mapped, func(_ context.Context, answer []byte) ([]byte, error) { return answer, nil }, nil + } + return c.seal(ctx, mapped) +} + +// newProviderEnvelope validates the settings. Secrets are read at call time. +func newProviderEnvelope(provider string, s envelopeSettings, hooks Hooks) (*providerEnvelope, error) { + if hooks.Succeeded == nil { + return nil, fmt.Errorf("%s: the envelope needs Hooks.Succeeded", provider) + } + 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. + client := &http.Client{CheckRedirect: util.RefuseOffHostRedirect} + + switch s.kind { + case "": + // Without the kind the body would go out unsealed: refuse at startup. + if s.keyEnv != "" || s.nonceEnv != "" || s.tokenEnv != "" || s.tokenField != "" || + s.requestField != "" || s.responsePaths != "" { + return nil, fmt.Errorf("%s: envelope settings are present but envelope is not set", provider) + } + default: + base, err := newGCMCodec(provider, s) + if err != nil { + return nil, err + } + e.base = base + } + + if s.otp.kind != "" { + otp, err := newCBCCodec(provider, s.otp) + if err != nil { + return nil, err + } + if len(e.otpActions) == 0 { + return nil, fmt.Errorf("%s: otpEnvelope needs otpActions, the actions sent through it", provider) + } + participant, capability, found := strings.Cut(e.otpBindingKey, "|") + if !found || participant == "" || capability == "" { + return nil, fmt.Errorf("%s: otpEnvelope needs otpBindingKey, the binding key (participant|capability) "+ + "whose participant is the OTP service; got %q", provider, e.otpBindingKey) + } + e.otp = otp + } else if len(e.otpActions) > 0 || e.otpBindingKey != "" || s.otp.tokenEnv != "" || s.otp.requestField != "" || s.otp.responsePaths != "" { + return nil, fmt.Errorf("%s: otp settings are present but otpEnvelope is not set", provider) + } + + verifyTimeoutMs, err := timeoutSetting(provider, "otpVerifyTimeoutMs", s.verifyTimeoutMs) + if err != nil { + return nil, err + } + + verifyActions := listSet(s.verifyActions) + switch { + case s.verifyPath == "" && len(verifyActions) == 0: + case s.verifyPath == "" || len(verifyActions) == 0: + 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: + 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}} + } + + if e.base == nil && e.otp == nil { + return nil, fmt.Errorf("%s: an envelope block needs envelope or otpEnvelope", provider) + } + return e, nil +} + + +// --- shared by the codecs ---------------------------------------------------- + +// withToken adds the service token to the plaintext. It is added here, not in +// the mapping, because mapping files are public. +func withToken(ctx context.Context, provider string, mapped []byte, tokenEnv, tokenField string) ([]byte, error) { + fields := map[string]any{} + if len(mapped) > 0 { + if err := json.Unmarshal(mapped, &fields); err != nil { + return nil, fmt.Errorf("the %s request mapping must produce a JSON object: %w", provider, err) + } + } + if tokenField != "" { + token := os.Getenv(tokenEnv) + if token == "" { + return nil, unconfigured(ctx, provider, tokenEnv+" is not set") + } + if _, present := fields[tokenField]; present { + // A public mapping must never carry the token. + return nil, fmt.Errorf("the %s request mapping sets %s, which the envelope supplies; "+ + "remove it from the mapping", provider, tokenField) + } + fields[tokenField] = token + } + plain, err := json.Marshal(fields) + if err != nil { + return nil, fmt.Errorf("the %s request could not be encoded: %w", provider, err) + } + return plain, nil +} + +// checkToken requires tokenEnv and tokenField together or not at all. +func checkToken(tokenEnv, tokenField string, missing []string, prefix string) []string { + if (tokenEnv == "") != (tokenField == "") { + return append(missing, "both "+prefix+"TokenEnv and "+prefix+"TokenField, or neither") + } + return missing +} + +// parsePaths reads a comma-separated list of dotted paths. +func parsePaths(provider, setting, raw string) ([][]string, error) { + var paths [][]string + for _, path := range strings.Split(raw, ",") { + path = strings.TrimSpace(path) + if path == "" { + continue + } + segments := strings.Split(path, ".") + for _, segment := range segments { + if strings.TrimSpace(segment) == "" { + return nil, fmt.Errorf("%s: %s %q has a blank segment", provider, setting, path) + } + } + paths = append(paths, segments) + } + return paths, nil +} + +// sealedAnswer finds the sealed string at the first path that holds one. +func sealedAnswer(wire any, paths [][]string) (string, bool) { + for _, path := range paths { + node := wire + for _, key := range path { + fields, ok := node.(map[string]any) + if !ok { + node = nil + break + } + node = fields[key] + } + if value, ok := node.(string); ok { + return value, true + } + } + return "", false +} + +// unconfigured returns a 500; the detail goes to the log, not the wire. +func unconfigured(ctx context.Context, provider, detail string) error { + err := fmt.Errorf("the envelope for %s is not configured on this adapter", provider) + log.Errorf(ctx, err, "%s", detail) + return model.NewCodedErr(http.StatusInternalServerError, codeInternal, err) +} + +// redactToken removes a service token from text about to be logged. +func redactToken(text, tokenEnv string) string { + if tokenEnv == "" { + return text + } + if token := os.Getenv(tokenEnv); token != "" { + text = strings.ReplaceAll(text, token, util.RedactedMarker) + } + return text +} + +// refused is a 502: the provider's answer is unusable. +func refused(err error) error { + return model.NewCodedErr(http.StatusBadGateway, util.CodeUpstreamUnavailable, err) +} + +// decodeHex decodes hex, ignoring whitespace. +func decodeHex(raw string) ([]byte, error) { + cleaned := strings.Join(strings.Fields(raw), "") + if cleaned == "" { + return nil, errors.New("empty") + } + return hex.DecodeString(cleaned) +} + +// listSet reads a comma-separated list into a set. +func listSet(raw string) map[string]bool { + set := map[string]bool{} + for _, part := range strings.Split(raw, ",") { + if trimmed := strings.TrimSpace(part); trimmed != "" { + set[trimmed] = true + } + } + return set +} + +// missingSettings lists empty settings, sorted. +func missingSettings(settings map[string]string) []string { + var missing []string + for setting, value := range settings { + if strings.TrimSpace(value) == "" { + missing = append(missing, setting) + } + } + sort.Strings(missing) + return missing +} + +// timeoutSetting reads a call's own budget in milliseconds. Unset is 0, which +// util.Budget takes as its default. +func timeoutSetting(provider, name, value string) (int, error) { + if strings.TrimSpace(value) == "" { + return 0, nil + } + ms, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil || ms <= 0 { + return 0, fmt.Errorf("%s: %s must be a positive number of milliseconds, got %q", provider, name, value) + } + return ms, nil +} diff --git a/pkg/plugin/implementation/internal/common/envelope/envelope_test.go b/pkg/plugin/implementation/internal/common/envelope/envelope_test.go new file mode 100644 index 00000000..53e477f3 --- /dev/null +++ b/pkg/plugin/implementation/internal/common/envelope/envelope_test.go @@ -0,0 +1,402 @@ +package envelope + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/beckn-one/beckn-onix/pkg/model" +) + +// Test material only. Made up for these tests; it is not, and must never be, +// the portal's. +const ( + testKeyHex = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" + testNonceHex = "a0a1a2a3a4a5a6a7a8a9aaab" + testToken = "test-service-token" + + testKeyEnv = "TEST_PMKISAN_KEY" + testNonceEnv = "TEST_PMKISAN_NONCE" + testTokenEnv = "TEST_PMKISAN_TOKEN" +) + +// legacyVectors were produced by Node's crypto with the same calls the legacy +// PM-KISAN client makes -- createCipheriv("aes-256-gcm", key, iv), then +// base64(ciphertext || authTag) -- over vectorPlaintext. Matching them proves +// this envelope speaks the portal's format byte for byte, not merely that it +// round-trips with itself. +// +// Two nonce lengths, because the portal's is not documented: 12 bytes is GCM's +// standard path, and anything else takes the hashed-nonce path. +const vectorPlaintext = `{"Responce":"True","message":"Grievance lodged"}` + +var legacyVectors = map[string]string{ + "a0a1a2a3a4a5a6a7a8a9aaab": "nTouSDa7bdEBAKXpJS6yqxWOdTL/0jEf/WlDpEWJMnO7EzGewUE2HTPzYK9sHqGEK+AlFb2oL9FNTItRmM+LMQ==", + "b0b1b2b3b4b5b6b7b8b9babbbcbdbebf": "yi/WF5kjet5MxolK5bGSb88/hP6Daw1FPfZdP2PB+fdYV84BOj0bFh4+sLqc4SkuJLa8xsCECoAGHG2F/cfnRg==", +} + +func setTestSecrets(t *testing.T) { + t.Helper() + t.Setenv(testKeyEnv, testKeyHex) + t.Setenv(testNonceEnv, testNonceHex) + t.Setenv(testTokenEnv, testToken) +} + +// pmkisanSettings is PM-KISAN's envelope as config/provider-adapter.yaml +// declares it. +func pmkisanSettings() envelopeSettings { + return envelopeSettings{ + kind: envelopeAESGCM, keyEnv: testKeyEnv, nonceEnv: testNonceEnv, + tokenEnv: testTokenEnv, tokenField: "TokenNo", + requestField: "EncryptedRequest", responsePaths: "d.output,output", + } +} + +func testEnvelope(t *testing.T) *gcmCodec { + t.Helper() + e, err := newGCMCodec("pmkisan", pmkisanSettings()) + if err != nil { + t.Fatalf("newGCMCodec() = %v", err) + } + return e +} + +// codeOf returns the taxonomy code and status of a coded step error. +func codeOf(t *testing.T, err error) (string, int) { + t.Helper() + var coded *model.CodedErr + if !errors.As(err, &coded) { + t.Fatalf("error %v (%T) is not a coded error", err, err) + } + return coded.BecknError().Code, coded.HTTPStatus() +} + +func TestOpenReadsWhatTheLegacyClientWrites(t *testing.T) { + for nonce, sealed := range legacyVectors { + t.Run(nonce, func(t *testing.T) { + setTestSecrets(t) + t.Setenv(testNonceEnv, nonce) + + plain, err := testEnvelope(t).Open(context.Background(), + []byte(`{"d":{"__type":"x","output":"`+sealed+`"}}`)) + if err != nil { + t.Fatalf("Open() = %v", err) + } + if string(plain) != vectorPlaintext { + t.Errorf("Open() = %s, want %s", plain, vectorPlaintext) + } + }) + } +} + +func TestSealWritesWhatTheLegacyClientWrites(t *testing.T) { + // The fixed nonce makes the output deterministic, so a known plaintext + // must seal to the known ciphertext. TokenNo is added by Seal, so the + // vector is checked through the cipher directly, then Seal's own output is + // checked to decrypt to the mapped body plus the token. + setTestSecrets(t) + e := testEnvelope(t) + + gcm, nonce, err := e.cipher(context.Background()) + if err != nil { + t.Fatalf("cipher() = %v", err) + } + got := base64.StdEncoding.EncodeToString(gcm.Seal(nil, nonce, []byte(vectorPlaintext), nil)) + if want := legacyVectors[testNonceHex]; got != want { + t.Errorf("sealed %s, want the legacy client's %s", got, want) + } + + wire, err := e.Seal(context.Background(), []byte(`{"Type":"Reg_No_Status","IdentityNo":"UP12345678A"}`)) + if err != nil { + t.Fatalf("Seal() = %v", err) + } + var body map[string]string + if err := json.Unmarshal(wire, &body); err != nil || len(body) != 1 || body["EncryptedRequest"] == "" { + t.Fatalf("Seal() = %s, want exactly {\"EncryptedRequest\": ...}", wire) + } + if strings.Contains(string(wire), testToken) || strings.Contains(string(wire), "UP12345678A") { + t.Errorf("sealed body %s carries plaintext", wire) + } + raw, _ := base64.StdEncoding.DecodeString(body["EncryptedRequest"]) + plain, err := gcm.Open(nil, nonce, raw, nil) + if err != nil { + t.Fatalf("Seal()'s output does not decrypt: %v", err) + } + var sent map[string]string + _ = json.Unmarshal(plain, &sent) + if sent["TokenNo"] != testToken || sent["IdentityNo"] != "UP12345678A" || sent["Type"] != "Reg_No_Status" { + t.Errorf("plaintext = %s, want the mapped fields plus TokenNo", plain) + } +} + +func TestSealRefusesAMappingThatSetsTheToken(t *testing.T) { + setTestSecrets(t) + if _, err := testEnvelope(t).Seal(context.Background(), []byte(`{"TokenNo":"x"}`)); err == nil { + t.Fatal("Seal() accepted a mapping carrying TokenNo, want a refusal") + } +} + +func TestSealFailsClosedWithoutItsSecrets(t *testing.T) { + for _, unset := range []string{testKeyEnv, testNonceEnv, testTokenEnv} { + t.Run(unset, func(t *testing.T) { + setTestSecrets(t) + t.Setenv(unset, "") + + _, err := testEnvelope(t).Seal(context.Background(), []byte(`{}`)) + if err == nil { + t.Fatal("Seal() succeeded with a secret unset; it must never send plaintext") + } + code, status := codeOf(t, err) + if code != "NET_INTERNAL_ERROR" || status != http.StatusInternalServerError { + t.Errorf("got %s/%d, want NET_INTERNAL_ERROR/500", code, status) + } + if strings.Contains(err.Error(), unset) { + t.Errorf("error %q names the variable; that belongs in the log, not on the wire", err) + } + }) + } +} + +// The key is hex. Any AES length is taken; anything else is refused. +func TestSealTakesAnyAESKeyLength(t *testing.T) { + for hexChars, ok := range map[int]bool{32: true, 48: true, 64: true, 20: false, 40: false} { + t.Run(fmt.Sprintf("%d hex characters", hexChars), func(t *testing.T) { + setTestSecrets(t) + t.Setenv(testKeyEnv, strings.Repeat("ab", hexChars/2)) + _, err := testEnvelope(t).Seal(context.Background(), []byte(`{}`)) + if ok && err != nil { + t.Fatalf("Seal() = %v, want a %d-byte key accepted", err, hexChars/2) + } + if !ok && err == nil { + t.Fatalf("Seal() accepted a %d-byte key, want it refused", hexChars/2) + } + }) + } +} + +// A 16-byte key seals with AES-128-GCM, readable with the same hex key and +// nonce by any standard implementation. +func TestSealWithASixteenByteKeyIsAES128GCM(t *testing.T) { + setTestSecrets(t) + key := testKeyHex[:32] + t.Setenv(testKeyEnv, key) + wire, err := testEnvelope(t).Seal(context.Background(), []byte(`{"Type":"Reg_No_Status"}`)) + if err != nil { + t.Fatalf("Seal() = %v", err) + } + var body map[string]string + if err := json.Unmarshal(wire, &body); err != nil { + t.Fatalf("sealed body %s: %v", wire, err) + } + sealed, _ := base64.StdEncoding.DecodeString(body["EncryptedRequest"]) + rawKey, _ := hex.DecodeString(key) + nonce, _ := hex.DecodeString(testNonceHex) + block, _ := aes.NewCipher(rawKey) + gcm, _ := cipher.NewGCMWithNonceSize(block, len(nonce)) + plain, err := gcm.Open(nil, nonce, sealed, nil) + if err != nil { + t.Fatalf("AES-128-GCM could not open what Seal() sent: %v", err) + } + if !strings.Contains(string(plain), `"Type":"Reg_No_Status"`) || !strings.Contains(string(plain), testToken) { + t.Errorf("opened %s, want the request with the service token", plain) + } +} + +func TestOpenClassifiesWhatItCannotUse(t *testing.T) { + setTestSecrets(t) + e := testEnvelope(t) + + tampered := []byte(legacyVectors[testNonceHex]) + tampered[3] ^= 0x01 + + for _, tc := range []struct { + name, body, code string + status int + }{ + {"not JSON", `Service Unavailable`, "NET_DOWNSTREAM_UNAVAILABLE", 502}, + {"no envelope", `{"d":{}}`, "NET_DOWNSTREAM_UNAVAILABLE", 502}, + {"plain text where the ciphertext belongs", `{"d":{"output":"Invalid Token No."}}`, "NET_DOWNSTREAM_UNAVAILABLE", 502}, + {"an envelope that will not open", `{"d":{"output":"` + string(tampered) + `"}}`, "NET_INTERNAL_ERROR", 500}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := e.Open(context.Background(), []byte(tc.body)) + if err == nil { + t.Fatal("Open() succeeded, want a refusal") + } + if code, status := codeOf(t, err); code != tc.code || status != tc.status { + t.Errorf("got %s/%d, want %s/%d", code, status, tc.code, tc.status) + } + if strings.Contains(err.Error(), "Invalid Token") { + t.Errorf("error %q passes the portal's message through", err) + } + }) + } +} + +func TestOpenAcceptsABareOutput(t *testing.T) { + setTestSecrets(t) + plain, err := testEnvelope(t).Open(context.Background(), + []byte(`{"output":"`+legacyVectors[testNonceHex]+`"}`)) + if err != nil || string(plain) != vectorPlaintext { + t.Fatalf("Open() = %s, %v; want the plaintext from a bare output", plain, err) + } +} + +func TestNewEnvelopeRefusesIncompleteSettings(t *testing.T) { + for name, tweak := range map[string]func(*envelopeSettings){ + "unknown kind": func(s *envelopeSettings) { s.kind = "aesCbc" }, + "no key variable": func(s *envelopeSettings) { s.keyEnv = "" }, + "no nonce variable": func(s *envelopeSettings) { s.nonceEnv = "" }, + "no request field": func(s *envelopeSettings) { s.requestField = "" }, + "no response path": func(s *envelopeSettings) { s.responsePaths = "" }, + "token with no field": func(s *envelopeSettings) { s.tokenField = "" }, + "field with no token": func(s *envelopeSettings) { s.tokenEnv = "" }, + "blank path segment": func(s *envelopeSettings) { s.responsePaths = "d..output" }, + } { + t.Run(name, func(t *testing.T) { + settings := pmkisanSettings() + tweak(&settings) + if _, err := newGCMCodec("pmkisan", settings); err == nil { + t.Fatal("newGCMCodec() accepted settings that cannot work") + } + }) + } +} + +func TestNewEnvelopeWithoutATokenSendsNone(t *testing.T) { + setTestSecrets(t) + settings := pmkisanSettings() + settings.tokenEnv, settings.tokenField = "", "" + e, err := newGCMCodec("pmkisan", settings) + if err != nil { + t.Fatalf("newGCMCodec() = %v", err) + } + wire, err := e.Seal(context.Background(), []byte(`{"a":1}`)) + if err != nil { + t.Fatalf("Seal() = %v", err) + } + var body map[string]string + _ = json.Unmarshal(wire, &body) + raw, _ := base64.StdEncoding.DecodeString(body["EncryptedRequest"]) + gcm, nonce, _ := e.cipher(context.Background()) + plain, _ := gcm.Open(nil, nonce, raw, nil) + if string(plain) != `{"a":1}` { + t.Errorf("plaintext = %s, want the mapped body with no token added", plain) + } +} + +func TestParseTakesTheEnvelopeOutOfTheProviderBlock(t *testing.T) { + parsed, rest, err := Parse(map[string]string{ + "bindingKeys": "pmkisan|openagrinet:Grievance", + "authScheme-pmkisan": "none", + "envelope-pmkisan": "aesGcm", + "envelopeKeyEnv-pmkisan": testKeyEnv, + "envelopeNonceEnv-pmkisan": testNonceEnv, + "envelopeTokenEnv-pmkisan": testTokenEnv, + "envelopeTokenField-pmkisan": "TokenNo", + "envelopeRequestField-pmkisan": "EncryptedRequest", + "envelopeResponsePaths-pmkisan": "d.output,output", + }, testHooks) + if err != nil { + t.Fatalf("Parse() = %v", err) + } + if _, ok := parsed.Envelopes["pmkisan"]; !ok || len(parsed.Envelopes) != 1 { + t.Errorf("envelopes = %v, want pmkisan's alone", parsed.Envelopes) + } + if parsed.HostedElsewhere != nil { + t.Errorf("HostedElsewhere = %v, want none: pmkisan names no OTP service here", parsed.HostedElsewhere) + } + for key := range rest { + if strings.HasPrefix(key, "envelope") { + t.Errorf("%s was left for the credential parser, which refuses it", key) + } + } + if rest["authScheme-pmkisan"] != "none" || rest["bindingKeys"] == "" { + t.Errorf("rest = %v, want everything but the envelope kept", rest) + } +} + +func TestParseWithNoneDeclaredReturnsNone(t *testing.T) { + parsed, rest, err := Parse(map[string]string{"authScheme-pmfby": "none"}, testHooks) + if err != nil || parsed.Envelopes != nil || parsed.HostedElsewhere != nil || rest["authScheme-pmfby"] != "none" { + t.Fatalf("Parse() = %v, %v, %v; want nothing parsed and the config back", parsed, rest, err) + } +} + +// The OTP actions go to the OTP service's participant, named by binding key. +func TestParseNamesTheOTPServicesParticipantForTheOTPActions(t *testing.T) { + parsed, _, err := Parse(map[string]string{ + "envelope-pmkisan": "aesGcm", + "envelopeKeyEnv-pmkisan": testKeyEnv, + "envelopeNonceEnv-pmkisan": testNonceEnv, + "envelopeRequestField-pmkisan": "EncryptedRequest", + "envelopeResponsePaths-pmkisan": "d.output,output", + "otpEnvelope-pmkisan": "aesCbcKeyInBand", + "otpBindingKey-pmkisan": testOTPBindingKey, + "otpRequestField-pmkisan": "EncryptedRequest", + "otpResponsePaths-pmkisan": "d.output,output", + "otpActions-pmkisan": "init", + }, testHooks) + if err != nil { + t.Fatalf("Parse() = %v", err) + } + if got := parsed.HostedElsewhere["pmkisan"]; len(got) != 1 || got["init"] != testOTPBindingKey { + t.Errorf("HostedElsewhere = %v, want pmkisan's init on %s", parsed.HostedElsewhere, testOTPBindingKey) + } +} + +func TestNewEnvelopeNeedsTheHooksItUses(t *testing.T) { + if _, err := newProviderEnvelope("pmkisan", pmkisanSettings(), Hooks{}); err == nil { + t.Error("newProviderEnvelope() took no Succeeded") + } + s := pmkisanSettings() + s.otp, s.otpActions, s.verifyPath, s.verifyActions = otpRealm(), "init", "/v", "support" + if _, err := newProviderEnvelope("pmkisan", s, Hooks{Succeeded: testHooks.Succeeded}); err == nil { + t.Error("newProviderEnvelope() took a verify with no Challenge") + } +} + +// Envelope settings without the kind would send the body unsealed. +func TestNewEnvelopeRefusesSettingsWithoutAKind(t *testing.T) { + s := pmkisanSettings() + s.kind = "" + _, err := newProviderEnvelope("pmkisan", s, testHooks) + if err == nil || !strings.Contains(err.Error(), "envelope is not set") { + t.Errorf("newProviderEnvelope() = %v, want a refusal naming the missing envelope", err) + } +} + +// A fixed GCM nonce is already a weakness; a short one is refused. +func TestSealRefusesANonceShorterThanGCMs(t *testing.T) { + setTestSecrets(t) + t.Setenv(testNonceEnv, "a0a1a2a3") + _, err := testEnvelope(t).Seal(context.Background(), []byte(`{}`)) + if err == nil { + t.Fatal("Seal() took a 4-byte nonce") + } + if code, _ := codeOf(t, err); code != "NET_INTERNAL_ERROR" { + t.Errorf("got %s, want NET_INTERNAL_ERROR", code) + } +} + +func TestNewEnvelopeRefusesABadTimeout(t *testing.T) { + for name, set := range map[string]func(*envelopeSettings){ + "otpVerifyTimeoutMs": func(s *envelopeSettings) { s.verifyTimeoutMs = "soon" }, + } { + t.Run(name, func(t *testing.T) { + s := pmkisanSettings() + set(&s) + if _, err := newProviderEnvelope("pmkisan", s, testHooks); err == nil || !strings.Contains(err.Error(), name) { + t.Errorf("newProviderEnvelope() = %v, want a refusal naming %s", err, name) + } + }) + } +} diff --git a/pkg/plugin/implementation/internal/common/envelope/gcm.go b/pkg/plugin/implementation/internal/common/envelope/gcm.go new file mode 100644 index 00000000..10d911fc --- /dev/null +++ b/pkg/plugin/implementation/internal/common/envelope/gcm.go @@ -0,0 +1,158 @@ +package envelope + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + + "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" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common/util" +) + +// keySizes are the AES key lengths in bytes, decoded from hex. +var keySizes = map[int]bool{16: true, 24: true, 32: true} + +// minNonceSize is GCM's standard nonce length. A shorter one is refused; it +// only makes the fixed-nonce break below easier. +const minNonceSize = 12 + +// gcmCodec is AES-GCM under a fixed key and nonce: base64(ciphertext||tag). +// A fixed nonce breaks GCM; TLS is the only protection (ADR-0001). +type gcmCodec struct { + provider string + keyEnv, nonceEnv, tokenEnv string + tokenField string + requestField string + responsePaths [][]string +} + +// newGCMCodec validates the settings. +func newGCMCodec(provider string, s envelopeSettings) (*gcmCodec, error) { + if s.kind != envelopeAESGCM { + return nil, fmt.Errorf("%s: envelope %q is not one this plugin knows; want %s", + provider, s.kind, envelopeAESGCM) + } + missing := missingSettings(map[string]string{ + "envelopeKeyEnv": s.keyEnv, + "envelopeNonceEnv": s.nonceEnv, + "envelopeRequestField": s.requestField, + "envelopeResponsePaths": s.responsePaths, + }) + missing = checkToken(s.tokenEnv, s.tokenField, missing, "envelope") + if len(missing) > 0 { + return nil, fmt.Errorf("%s: envelope %s needs %s", provider, envelopeAESGCM, strings.Join(missing, ", ")) + } + paths, err := parsePaths(provider, "envelopeResponsePaths", s.responsePaths) + if err != nil { + return nil, err + } + return &gcmCodec{provider: provider, keyEnv: s.keyEnv, nonceEnv: s.nonceEnv, + tokenEnv: s.tokenEnv, tokenField: s.tokenField, requestField: s.requestField, + responsePaths: paths}, nil +} + +func (c *gcmCodec) seal(ctx context.Context, mapped []byte) ([]byte, common.Opener, error) { + sealed, err := c.Seal(ctx, mapped) + if err != nil { + return nil, nil, err + } + return sealed, c.Open, nil +} + +// Seal adds the service token to the mapped request and encrypts it. +func (c *gcmCodec) Seal(ctx context.Context, mapped []byte) ([]byte, error) { + plain, err := withToken(ctx, c.provider, mapped, c.tokenEnv, c.tokenField) + if err != nil { + return nil, err + } + gcm, nonce, err := c.cipher(ctx) + if err != nil { + return nil, err + } + // Go appends the 16-byte tag, the layout the provider expects. + sealed := base64.StdEncoding.EncodeToString(gcm.Seal(nil, nonce, plain, nil)) + return json.Marshal(map[string]string{c.requestField: sealed}) +} + +// Open decrypts the answer. Missing or plain-text envelope: 502. Will not +// decrypt (our key is wrong): 500. Not JSON: 502. Neither text is logged. +func (c *gcmCodec) Open(ctx context.Context, answer []byte) ([]byte, error) { + var wire any + if err := json.Unmarshal(answer, &wire); err != nil { + log.Warnf(ctx, "%s answered with a body that is not JSON (%d bytes)", c.provider, len(answer)) + return nil, refused(fmt.Errorf("%s answered without an envelope", c.provider)) + } + sealed, found := sealedAnswer(wire, c.responsePaths) + if !found { + log.Warnf(ctx, "%s answered with no envelope at any configured path", c.provider) + return nil, refused(fmt.Errorf("%s answered without an envelope", c.provider)) + } + + raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(sealed)) + if err != nil { + // A plain-text rejection: log it redacted, never return it. + log.Warnf(ctx, "%s answered with plain text where its envelope belongs: %s", + c.provider, redactToken(util.Explain([]byte(sealed)), c.tokenEnv)) + return nil, refused(fmt.Errorf("%s rejected the request", c.provider)) + } + + gcm, nonce, err := c.cipher(ctx) + if err != nil { + return nil, err + } + plain, err := gcm.Open(nil, nonce, raw, nil) + if err != nil { + log.Errorf(ctx, err, "%s's answer will not decrypt; check %s and %s against the values it issued", + c.provider, c.keyEnv, c.nonceEnv) + return nil, model.NewCodedErr(http.StatusInternalServerError, codeInternal, + fmt.Errorf("the answer from %s could not be decrypted", c.provider)) + } + if !json.Valid(plain) { + log.Warnf(ctx, "%s sealed something other than JSON (%d bytes)", c.provider, len(plain)) + return nil, refused(fmt.Errorf("%s answered with something that is not JSON", c.provider)) + } + return plain, nil +} + +// cipher builds the AEAD from the environment. +func (c *gcmCodec) cipher(ctx context.Context) (cipher.AEAD, []byte, error) { + rawKey, rawNonce := os.Getenv(c.keyEnv), os.Getenv(c.nonceEnv) + if rawKey == "" || rawNonce == "" { + return nil, nil, unconfigured(ctx, c.provider, c.keyEnv+" and "+c.nonceEnv+" must both be set") + } + key, err := decodeHex(rawKey) + if err != nil { + return nil, nil, unconfigured(ctx, c.provider, c.keyEnv+" is not hex") + } + if !keySizes[len(key)] { + return nil, nil, unconfigured(ctx, c.provider, fmt.Sprintf("%s decodes to %d bytes; AES needs 16, 24 or 32", + c.keyEnv, len(key))) + } + nonce, err := decodeHex(rawNonce) + if err != nil { + return nil, nil, unconfigured(ctx, c.provider, c.nonceEnv+" is not hex") + } + if len(nonce) < minNonceSize { + return nil, nil, unconfigured(ctx, c.provider, fmt.Sprintf("%s decodes to %d bytes; GCM needs at least %d", + c.nonceEnv, len(nonce), minNonceSize)) + } + + block, err := aes.NewCipher(key) + if err != nil { + return nil, nil, unconfigured(ctx, c.provider, "the AES key was refused: "+err.Error()) + } + // Any nonce length the provider issued, from minNonceSize up. + gcm, err := cipher.NewGCMWithNonceSize(block, len(nonce)) + if err != nil { + return nil, nil, unconfigured(ctx, c.provider, "GCM could not be built: "+err.Error()) + } + return gcm, nonce, nil +} diff --git a/pkg/plugin/implementation/internal/common/envelope/hooks_test.go b/pkg/plugin/implementation/internal/common/envelope/hooks_test.go new file mode 100644 index 00000000..dcddf4eb --- /dev/null +++ b/pkg/plugin/implementation/internal/common/envelope/hooks_test.go @@ -0,0 +1,66 @@ +package envelope + +import ( + "encoding/json" + "errors" + "strings" + + "github.com/beckn-one/beckn-onix/pkg/model" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common" +) + +// testHooks stand in for a capability's: the challenge sits where the +// grievance packs put it, and "yes" is a "True" flag. Enough for this +// package's tests; the real ones are the Grievance package's. +var testHooks = Hooks{ + Challenge: func(exchange common.Exchange) (map[string]any, error) { + holder := testHolder(exchange.Beckn) + kind, id := "", "" + if v, _ := holder["enrolmentId"].(string); v != "" { + kind, id = "Ben_id", v + } else if applicant, ok := holder["applicant"].(map[string]any); ok { + if v, _ := applicant["mobile"].(string); v != "" { + kind, id = "Mobile", v + } + } + challenge, _ := holder["challenge"].(map[string]any) + response, _ := challenge["response"].(map[string]any) + otp, _ := response["value"].(string) + if kind == "" || otp == "" { + return nil, model.NewBadReqErr("SCH_REQUIRED_FIELD_MISSING", errors.New("no identifier or OTP")) + } + return map[string]any{"Types": kind, "Values": id, "OTP": otp}, nil + }, + Succeeded: func(plain []byte) bool { + var answer map[string]any + if json.Unmarshal(plain, &answer) != nil { + return false + } + for _, key := range []string{"Rsponce", "Responce", "Status", "status"} { + if value, ok := answer[key].(string); ok { + return strings.EqualFold(strings.TrimSpace(value), "true") + } + } + return false + }, +} + +// testHolder is support's channel or the commitment's attributes. +func testHolder(beckn any) map[string]any { + payload, _ := beckn.(map[string]any) + message, _ := payload["message"].(map[string]any) + if support, ok := message["support"].(map[string]any); ok { + if channels, ok := support["channels"].([]any); ok && len(channels) > 0 { + holder, _ := channels[0].(map[string]any) + return holder + } + return nil + } + contract, _ := message["contract"].(map[string]any) + if commitments, ok := contract["commitments"].([]any); ok && len(commitments) > 0 { + commitment, _ := commitments[0].(map[string]any) + holder, _ := commitment["commitmentAttributes"].(map[string]any) + return holder + } + return nil +} diff --git a/pkg/plugin/implementation/internal/common/envelope/verify.go b/pkg/plugin/implementation/internal/common/envelope/verify.go new file mode 100644 index 00000000..58b84513 --- /dev/null +++ b/pkg/plugin/implementation/internal/common/envelope/verify.go @@ -0,0 +1,75 @@ +package envelope + +import ( + "context" + "encoding/json" + "errors" + "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" +) + +// Hooks are what the capability knows and this package does not: where its +// payload carries the challenge, and what its provider's "yes" looks like. +type Hooks struct { + // Challenge builds the verify request from the payload, or a coded + // refusal. Needed only by a provider that verifies. + Challenge func(exchange common.Exchange) (map[string]any, error) + + // Succeeded reports whether an opened answer says yes. It must fail + // closed: anything it cannot read is a no. + Succeeded func(plain []byte) bool +} + +// verify checks the caller's challenge before the guarded call. The challenge +// goes only here, never to the call, the log or the reply. +type verify struct { + provider string + path string + actions map[string]bool + codec *cbcCodec + host func(context.Context, common.Exchange) (string, error) + hooks Hooks + client *http.Client + // budget is the verify's own time budget, not the guarded action's. + budget model.ActionPlan +} + +// check verifies the challenge. Unreachable: 502. Not a yes: 400 +// BIZ_GENERIC_ERROR (401 means a bad Beckn signature). +func (v *verify) check(ctx context.Context, exchange common.Exchange) error { + fields, err := v.hooks.Challenge(exchange) + if err != nil { + return err + } + base, err := v.host(ctx, exchange) + if err != nil { + return err + } + body, err := json.Marshal(fields) + if err != nil { + return err + } + sealed, open, err := v.codec.seal(ctx, body) + if err != nil { + return err + } + + answer, err := postSealed(ctx, v.client, v.provider, "OTP verification", base, v.path, v.budget, sealed) + if err != nil { + return err + } + plain, err := open(ctx, answer) + if err != nil { + return err + } + if !v.hooks.Succeeded(plain) { + // The provider's message is never logged or returned. + log.Warnf(ctx, "%s refused the OTP", v.provider) + return model.NewCodedErr(http.StatusBadRequest, "BIZ_GENERIC_ERROR", + errors.New("the OTP is wrong or has expired")) + } + return nil +} diff --git a/pkg/plugin/implementation/internal/common/envelope/verify_test.go b/pkg/plugin/implementation/internal/common/envelope/verify_test.go new file mode 100644 index 00000000..05687f54 --- /dev/null +++ b/pkg/plugin/implementation/internal/common/envelope/verify_test.go @@ -0,0 +1,329 @@ +package envelope + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "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/internal/common" +) + +const validOTP = "4821" + +// otpService stands in for the OTP realm's verify call: it opens the +// key-in-band request, checks the OTP and token, and answers sealed. +type otpService struct { + t *testing.T + calls int + path string + sent map[string]string + status int // non-zero answers with this status and no body +} + +func (s *otpService) ServeHTTP(w http.ResponseWriter, r *http.Request) { + s.calls++ + s.path = r.URL.Path + if s.status != 0 { + w.WriteHeader(s.status) + return + } + raw, _ := io.ReadAll(r.Body) + var envelope map[string]string + _ = json.Unmarshal(raw, &envelope) + sealed, key, _ := strings.Cut(envelope["EncryptedRequest"], "@") + cipherText, _ := base64.StdEncoding.DecodeString(sealed) + plain, err := cbcDecrypt(key, cipherText) + if err != nil { + s.t.Errorf("the OTP service could not open the request: %v", err) + return + } + s.sent = map[string]string{} + _ = json.Unmarshal(plain, &s.sent) + + answer := `{"Rsponce":"False","Message":"Invalid OTP"}` + if s.sent["OTP"] == validOTP && s.sent["Token"] == testOTPToken { + answer = `{"Rsponce":"True","Message":"OTP Verified"}` + } + out, _ := cbcEncrypt(key, []byte(answer)) + fmt.Fprintf(w, `{"d":{"output":%q}}`, out) +} + +// pmkisanEnvelopeSettings is PM-KISAN's block as config/provider-adapter.yaml +// declares it: GCM by default, CBC for init, OTP verified before support and +// status. +func pmkisanEnvelopeSettings() envelopeSettings { + s := pmkisanSettings() + s.otp = otpRealm() + s.otpActions = "init" + s.verifyPath = "/ChatbotOTPVerified" + s.verifyActions = "support,status" + return s +} + +func pmkisanEnvelope(t *testing.T) *providerEnvelope { + t.Helper() + setTestSecrets(t) + t.Setenv(testOTPTokenEnv, testOTPToken) + e, err := newProviderEnvelope("pmkisan", pmkisanEnvelopeSettings(), testHooks) + if err != nil { + t.Fatalf("newProviderEnvelope() = %v", err) + } + return e +} + +// otpAt is a registry publishing the OTP service at url. +func otpAt(url string) registry { + return registry{testOTPBindingKey: {BindingKey: testOTPBindingKey, BaseURL: url}} +} + +// registry answers each binding key with its own record. +type registry map[string]*model.ProviderRecord + +func (r registry) ProviderRecord(_ context.Context, key string) (*model.ProviderRecord, error) { + if record, ok := r[key]; ok { + return record, nil + } + return nil, definition.ErrProviderRecordNotFound +} + +// planOn is the grievance service's call plan, whose participant host is not +// the OTP service's. The OTP service is wherever the exchange's registry says. +func planOn(t *testing.T, url string) *model.ProviderRecord { + t.Helper() + return &model.ProviderRecord{ + BaseURL: "http://grievance.invalid", + Actions: map[string]model.ActionPlan{ + "init": {Method: http.MethodPost, Path: "/ChatbotOTP", TimeoutMs: 5000}, + "support": {Method: http.MethodPost, Path: "/LodgeGrievance"}, + "status": {Method: http.MethodPost, Path: "/GrievanceStatusCheck"}, + }, + } +} + +func supportPayload(otp string) map[string]any { + channel := map[string]any{"provider": map[string]any{"id": "pmkisan"}, "enrolmentId": "UP12345678A"} + if otp != "" { + channel["challenge"] = map[string]any{"response": map[string]any{"method": "SMS_OTP", "value": otp}} + } + return map[string]any{"message": map[string]any{"support": map[string]any{ + "channels": []any{channel}}}} +} + +func statusPayload(otp string) map[string]any { + attributes := map[string]any{"enrolmentId": "UP12345678A"} + if otp != "" { + attributes["challenge"] = map[string]any{"response": map[string]any{"method": "SMS_OTP", "value": otp}} + } + return map[string]any{"message": map[string]any{"contract": map[string]any{ + "commitments": []any{map[string]any{"commitmentAttributes": attributes}}}}} +} + +func TestInitGoesThroughTheOTPRealm(t *testing.T) { + e := pmkisanEnvelope(t) + wire, _, err := e.Seal(context.Background(), common.Exchange{Action: "init", Plan: planOn(t, "http://otp.invalid"), Registry: otpAt("http://otp.invalid")}, + []byte(`{"Types":"Ben_id","Values":"UP12345678A"}`)) + if err != nil { + t.Fatalf("Seal() = %v", err) + } + var body map[string]string + _ = json.Unmarshal(wire, &body) + if !strings.Contains(body["EncryptedRequest"], "@") { + t.Errorf("init sealed %q, want the OTP realm's cipher@key", body["EncryptedRequest"]) + } +} + +func TestInitIsHostedByTheOTPServicesParticipant(t *testing.T) { + e := pmkisanEnvelope(t) + hosted := e.hostedElsewhere() + if len(hosted) != 1 || hosted["init"] != testOTPBindingKey { + t.Errorf("hostedElsewhere() = %v, want init on %s and nothing else", hosted, testOTPBindingKey) + } +} + +func TestAnUnpublishedOTPServiceStopsTheVerify(t *testing.T) { + e := pmkisanEnvelope(t) + _, _, err := e.Seal(context.Background(), common.Exchange{Action: "support", Beckn: supportPayload(validOTP), + Plan: &model.ProviderRecord{BaseURL: "http://grievance.invalid"}, Registry: registry{}}, []byte(`{}`)) + if code, status := codeOf(t, err); code != "NET_DOWNSTREAM_UNAVAILABLE" || status != http.StatusBadGateway { + t.Errorf("verify with the OTP service unpublished: got %s/%d, want NET_DOWNSTREAM_UNAVAILABLE/502", code, status) + } +} + +func TestSupportAndStatusVerifyTheOTPThenGoThroughTheGrievanceRealm(t *testing.T) { + for action, payload := range map[string]map[string]any{ + "support": supportPayload(validOTP), + "status": statusPayload(validOTP), + } { + t.Run(action, func(t *testing.T) { + e := pmkisanEnvelope(t) + otp := &otpService{t: t} + server := httptest.NewServer(otp) + defer server.Close() + + wire, _, err := e.Seal(context.Background(), + common.Exchange{Action: action, Beckn: payload, Plan: planOn(t, server.URL), Registry: otpAt(server.URL)}, []byte(`{"Type":"x"}`)) + if err != nil { + t.Fatalf("Seal() = %v", err) + } + if otp.calls != 1 || otp.path != "/ChatbotOTPVerified" { + t.Fatalf("the OTP service saw %d call(s) at %q, want one at /ChatbotOTPVerified", otp.calls, otp.path) + } + for field, want := range map[string]string{"Types": "Ben_id", "Values": "UP12345678A", "OTP": validOTP, "Token": testOTPToken} { + if otp.sent[field] != want { + t.Errorf("verify sent %s = %q, want %q", field, otp.sent[field], want) + } + } + var body map[string]string + _ = json.Unmarshal(wire, &body) + if body["EncryptedRequest"] == "" || strings.Contains(body["EncryptedRequest"], "@") { + t.Errorf("%s sealed %q, want the grievance realm's GCM payload", action, body["EncryptedRequest"]) + } + }) + } +} + +func TestAWrongOTPStopsTheCallItGuards(t *testing.T) { + e := pmkisanEnvelope(t) + otp := &otpService{t: t} + server := httptest.NewServer(otp) + defer server.Close() + + _, _, err := e.Seal(context.Background(), + common.Exchange{Action: "support", Beckn: supportPayload("0000"), Plan: planOn(t, server.URL), Registry: otpAt(server.URL)}, []byte(`{}`)) + if err == nil { + t.Fatal("Seal() succeeded on a wrong OTP") + } + if code, status := codeOf(t, err); code != "BIZ_GENERIC_ERROR" || status != http.StatusBadRequest { + t.Errorf("got %s/%d, want BIZ_GENERIC_ERROR/400 -- a wrong OTP is the farmer's to fix, and not a 401", code, status) + } + if strings.Contains(err.Error(), "Invalid OTP") { + t.Errorf("error %q passes the portal's text through", err) + } +} + +func TestAMissingChallengeIsRefusedWithoutCallingTheOTPService(t *testing.T) { + e := pmkisanEnvelope(t) + otp := &otpService{t: t} + server := httptest.NewServer(otp) + defer server.Close() + + _, _, err := e.Seal(context.Background(), + common.Exchange{Action: "status", Beckn: statusPayload(""), Plan: planOn(t, server.URL), Registry: otpAt(server.URL)}, []byte(`{}`)) + if code, status := codeOf(t, err); code != "SCH_REQUIRED_FIELD_MISSING" || status != http.StatusBadRequest { + t.Errorf("got %s/%d, want SCH_REQUIRED_FIELD_MISSING/400", code, status) + } + if otp.calls != 0 { + t.Errorf("the OTP service was called %d time(s) without an OTP to verify", otp.calls) + } +} + +func TestAnUnreachableOTPServiceIsTheUpstreamFailing(t *testing.T) { + e := pmkisanEnvelope(t) + otp := &otpService{t: t, status: http.StatusServiceUnavailable} + server := httptest.NewServer(otp) + defer server.Close() + + _, _, err := e.Seal(context.Background(), + common.Exchange{Action: "support", Beckn: supportPayload(validOTP), Plan: planOn(t, server.URL), Registry: otpAt(server.URL)}, []byte(`{}`)) + if code, status := codeOf(t, err); code != "NET_DOWNSTREAM_UNAVAILABLE" || status != http.StatusBadGateway { + t.Errorf("got %s/%d, want NET_DOWNSTREAM_UNAVAILABLE/502", code, status) + } +} + +func TestNewProviderEnvelopeRefusesInconsistentOTPSettings(t *testing.T) { + for name, tweak := range map[string]func(*envelopeSettings){ + "otp envelope with no actions": func(s *envelopeSettings) { s.otpActions = "" }, + "verify path with no actions": func(s *envelopeSettings) { s.verifyActions = "" }, + "verify actions with no path": func(s *envelopeSettings) { s.verifyPath = "" }, + "otp settings with no envelope": func(s *envelopeSettings) { s.otp.kind = "" }, + "otp envelope with no host": func(s *envelopeSettings) { s.otp.bindingKey = "" }, + "a host that is no binding key": func(s *envelopeSettings) { s.otp.bindingKey = "pmkisan-chatbot" }, + "neither envelope nor otp": func(s *envelopeSettings) { *s = envelopeSettings{} }, + } { + t.Run(name, func(t *testing.T) { + s := pmkisanEnvelopeSettings() + tweak(&s) + if _, err := newProviderEnvelope("pmkisan", s, testHooks); err == nil { + t.Fatal("newProviderEnvelope() accepted settings that cannot work") + } + }) + } +} + +func TestAProviderWithOnlyAnOTPRealmSendsItsOtherActionsPlain(t *testing.T) { + t.Setenv(testOTPTokenEnv, testOTPToken) + s := envelopeSettings{otp: otpRealm(), otpActions: "init"} + e, err := newProviderEnvelope("pmkisan", s, testHooks) + if err != nil { + t.Fatalf("newProviderEnvelope() = %v", err) + } + wire, open, err := e.Seal(context.Background(), common.Exchange{Action: "status"}, []byte(`{"a":1}`)) + if err != nil || string(wire) != `{"a":1}` { + t.Fatalf("Seal() = %s, %v; want the body untouched", wire, err) + } + if got, _ := open(context.Background(), []byte(`{"b":2}`)); string(got) != `{"b":2}` { + t.Errorf("open() = %s, want the answer untouched", got) + } +} + +// 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) { + elsewhere := &otpService{t: t} + other := httptest.NewServer(elsewhere) + defer other.Close() + redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, other.URL+r.URL.Path, http.StatusTemporaryRedirect) + })) + defer redirector.Close() + + e := pmkisanEnvelope(t) + _, _, err := e.Seal(context.Background(), + common.Exchange{Action: "support", Beckn: supportPayload(validOTP), Plan: planOn(t, redirector.URL), Registry: otpAt(redirector.URL)}, []byte(`{}`)) + if code, status := codeOf(t, err); code != "NET_DOWNSTREAM_UNAVAILABLE" || status != http.StatusBadGateway { + t.Errorf("got %s/%d, want NET_DOWNSTREAM_UNAVAILABLE/502", code, status) + } + if elsewhere.calls != 0 { + t.Errorf("the redirect was followed: the other host got %d call(s)", elsewhere.calls) + } +} + +// The verify runs on its own budget, not the guarded action's. +func TestTheVerifyKeepsToItsOwnBudget(t *testing.T) { + release := make(chan struct{}) + slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-release + })) + defer slow.Close() + defer close(release) + + setTestSecrets(t) + t.Setenv(testOTPTokenEnv, testOTPToken) + s := pmkisanEnvelopeSettings() + s.verifyTimeoutMs = "50" + e, err := newProviderEnvelope("pmkisan", s, testHooks) + if err != nil { + t.Fatalf("newProviderEnvelope() = %v", err) + } + plan := planOn(t, slow.URL) + plan.Actions["support"] = model.ActionPlan{Method: http.MethodPost, Path: "/LodgeGrievance", TimeoutMs: 30000} + + start := time.Now() + _, _, err = e.Seal(context.Background(), + common.Exchange{Action: "support", Beckn: supportPayload(validOTP), Plan: plan, Registry: otpAt(slow.URL)}, []byte(`{}`)) + if err == nil { + t.Fatal("Seal() succeeded against a service that never answered") + } + if took := time.Since(start); took > 5*time.Second { + t.Errorf("the verify took %v; it should stop at its own 50ms budget, not support's 30s", took) + } +} diff --git a/pkg/plugin/implementation/internal/common/envelope_test.go b/pkg/plugin/implementation/internal/common/envelope_test.go new file mode 100644 index 00000000..a9dd3ec4 --- /dev/null +++ b/pkg/plugin/implementation/internal/common/envelope_test.go @@ -0,0 +1,269 @@ +// The Envelope hook: what a capability needs when its provider's bodies are not +// plain JSON on the wire. +// +// A separate file from common_test.go, which is already past two thousand +// lines. +package common + +import ( + "context" + "errors" + "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/definition" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common/util" +) + +func planWithAction(baseURL, action string) *model.ProviderRecord { + plan := testPlan(baseURL, http.MethodPost) + plan.Actions = map[string]model.ActionPlan{ + action: {Method: http.MethodPost, Path: "/lodge", Mappings: testMappingRef, TimeoutMs: 2000}, + } + return plan +} + +// recordingUpstream answers with answer and remembers the body it was sent. +func recordingUpstream(t *testing.T, answer string, sent *string) string { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + *sent = string(body) + fmt.Fprint(w, answer) + })) + t.Cleanup(server.Close) + return server.URL +} + +// --- the envelope ------------------------------------------------------------- + +// reversingEnvelope stands in for a real codec: reversible, and obviously not +// the plaintext, so a test can tell which side of it a body was seen on. +type reversingEnvelope struct { + sealErr, openErr error + sealed, opened int + exchange Exchange +} + +func reverse(b []byte) []byte { + out := make([]byte, len(b)) + for i := range b { + out[len(b)-1-i] = b[i] + } + return out +} + +func (e *reversingEnvelope) Seal(_ context.Context, exchange Exchange, mapped []byte) ([]byte, Opener, error) { + e.sealed++ + e.exchange = exchange + if e.sealErr != nil { + return nil, nil, e.sealErr + } + return reverse(mapped), func(_ context.Context, answer []byte) ([]byte, error) { + e.opened++ + if e.openErr != nil { + return nil, e.openErr + } + return reverse(answer), nil + }, nil +} + +func envelopedStep(t *testing.T, url string, mapper definition.Mapper, envelope Envelope) *Step { + t.Helper() + return newStep(t, &stubRegistry{plan: planWithAction(url, "select")}, mapper, func(c *Config) { + c.EnvelopeByProvider = map[string]Envelope{testProvider: envelope} + }) +} + +func TestRunSealsTheRequestAndOpensTheAnswer(t *testing.T) { + var sent string + url := recordingUpstream(t, string(reverse([]byte(`{"answer":42}`))), &sent) + mapper := &stubMapper{requestResult: []byte(`{"ask":1}`), responseResult: []byte(`{"ok":true}`)} + envelope := &reversingEnvelope{} + + if _, err := runStep(t, envelopedStep(t, url, mapper, envelope), selectBody); err != nil { + t.Fatalf("Run() = %v", err) + } + if sent != string(reverse([]byte(`{"ask":1}`))) { + t.Errorf("upstream received %q, want the sealed request", sent) + } + response, _ := mapper.responseInput.(map[string]any) + if fmt.Sprint(response["response"]) != "map[answer:42]" { + t.Errorf("the response half saw %v, want the opened answer", response["response"]) + } + if envelope.sealed != 1 || envelope.opened != 1 { + t.Errorf("sealed %d, opened %d; want one of each", envelope.sealed, envelope.opened) + } +} + +func TestRunStopsWhenTheEnvelopeRefuses(t *testing.T) { + refusal := model.NewCodedErr(http.StatusInternalServerError, "NET_INTERNAL_ERROR", errors.New("no key")) + + t.Run("seal", func(t *testing.T) { + var sent string + url := recordingUpstream(t, `{}`, &sent) + step := envelopedStep(t, url, &stubMapper{requestResult: []byte(`{}`)}, + &reversingEnvelope{sealErr: refusal}) + if _, err := runStep(t, step, selectBody); !errors.Is(err, refusal) { + t.Fatalf("Run() = %v, want the envelope's own error", err) + } + if sent != "" { + t.Errorf("upstream was called with %q after the seal failed; it must never see plaintext", sent) + } + }) + + t.Run("open", func(t *testing.T) { + var sent string + url := recordingUpstream(t, `{}`, &sent) + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{}`)} + step := envelopedStep(t, url, mapper, &reversingEnvelope{openErr: refusal}) + if _, err := runStep(t, step, selectBody); !errors.Is(err, refusal) { + t.Fatalf("Run() = %v, want the envelope's own error", err) + } + if mapper.responseInput != nil { + t.Error("the response half ran on an answer the envelope refused") + } + }) +} + +func TestNewRefusesAnEnvelopeForAProviderItDoesNotServe(t *testing.T) { + cfg := &Config{BindingKeys: []string{testBindingKey}} + cfg.setProviderAuth(AuthProfile{Scheme: util.AuthSchemeNone}) + cfg.EnvelopeByProvider = map[string]Envelope{"elsewhere": &reversingEnvelope{}} + _, _, err := New(context.Background(), &stubRegistry{}, &stubMapper{}, nil, cfg) + if err == nil || !strings.Contains(err.Error(), "elsewhere") { + t.Fatalf("New() = %v, want the stray provider refused", err) + } +} + +func TestRunSendsPlainJSONToAProviderWithNoEnvelope(t *testing.T) { + var sent string + url := recordingUpstream(t, `{"ok":true}`, &sent) + mapper := &stubMapper{requestResult: []byte(`{"ask":1}`), responseResult: []byte(`{"a":1}`)} + step := newStep(t, &stubRegistry{plan: planWithAction(url, "select")}, mapper) + + if _, err := runStep(t, step, selectBody); err != nil { + t.Fatalf("Run() = %v", err) + } + if sent != `{"ask":1}` { + t.Errorf("upstream received %q, want the mapped request untouched", sent) + } +} + +func TestRunHandsTheEnvelopeTheExchange(t *testing.T) { + var sent string + url := recordingUpstream(t, string(reverse([]byte(`{}`))), &sent) + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{"ok":true}`)} + envelope := &reversingEnvelope{} + + if _, err := runStep(t, envelopedStep(t, url, mapper, envelope), selectBody); err != nil { + t.Fatalf("Run() = %v", err) + } + if envelope.exchange.Action != "select" || envelope.exchange.Plan == nil || envelope.exchange.Beckn == nil { + t.Errorf("exchange = %+v, want the action, the plan and the inbound payload", envelope.exchange) + } +} + +// keyedRegistry answers each binding key with its own record. +type keyedRegistry map[string]*model.ProviderRecord + +func (r keyedRegistry) ProviderRecord(_ context.Context, key string) (*model.ProviderRecord, error) { + if record, ok := r[key]; ok { + return record, nil + } + return nil, definition.ErrProviderRecordNotFound +} + +const otherBindingKey = "other-host|openagrinet:Other" + +// hostedStep serves testBindingKey on participant, with select hosted by +// otherBindingKey's participant at elsewhere ("" leaves it unpublished). +func hostedStep(t *testing.T, participant, elsewhere string, mapper definition.Mapper) *Step { + t.Helper() + registry := keyedRegistry{testBindingKey: planWithAction(participant, "select")} + if elsewhere != "" { + registry[otherBindingKey] = &model.ProviderRecord{BindingKey: otherBindingKey, BaseURL: elsewhere} + } + return newStep(t, registry, mapper, func(c *Config) { + c.HostedElsewhere = map[string]map[string]string{testProvider: {"select": otherBindingKey}} + }) +} + +func TestRunCallsTheHostOfTheParticipantNamedForTheAction(t *testing.T) { + var onElsewhere, onParticipant string + elsewhere := recordingUpstream(t, `{"ok":true}`, &onElsewhere) + participant := recordingUpstream(t, `{"ok":true}`, &onParticipant) + + mapper := &stubMapper{requestResult: []byte(`{"ask":1}`), responseResult: []byte(`{"a":1}`)} + if _, err := runStep(t, hostedStep(t, participant, elsewhere, mapper), selectBody); err != nil { + t.Fatalf("Run() = %v", err) + } + if onElsewhere == "" || onParticipant != "" { + t.Errorf("the named host got %q, the participant got %q; want the named host called", onElsewhere, onParticipant) + } +} + +func TestRunCallsTheParticipantForAnActionHostedNowhereElse(t *testing.T) { + var sent string + participant := recordingUpstream(t, `{"ok":true}`, &sent) + + mapper := &stubMapper{requestResult: []byte(`{"ask":1}`), responseResult: []byte(`{"a":1}`)} + step := newStep(t, keyedRegistry{testBindingKey: planWithAction(participant, "select")}, mapper, func(c *Config) { + c.HostedElsewhere = map[string]map[string]string{testProvider: {"init": otherBindingKey}} + }) + if _, err := runStep(t, step, selectBody); err != nil { + t.Fatalf("Run() = %v", err) + } + if sent == "" { + t.Error("the participant's host was not called") + } +} + +func TestRunIsAnUpstreamFailureWhenTheNamedHostIsUnpublished(t *testing.T) { + var sent string + participant := recordingUpstream(t, `{}`, &sent) + + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{}`)} + _, err := runStep(t, hostedStep(t, participant, "", mapper), selectBody) + var coded *model.CodedErr + if !errors.As(err, &coded) || coded.HTTPStatus() != http.StatusBadGateway { + t.Fatalf("Run() = %v, want a 502", err) + } + if sent != "" { + t.Errorf("the participant was called with %q instead", sent) + } +} + +func TestNewRefusesAHostForAProviderItDoesNotServeOrABadKey(t *testing.T) { + for name, hosts := range map[string]map[string]map[string]string{ + "an unserved provider": {"someone-else": {"init": otherBindingKey}}, + "a key with no |": {testProvider: {"init": "other-host"}}, + "an empty half": {testProvider: {"init": "|openagrinet:Other"}}, + } { + t.Run(name, func(t *testing.T) { + cfg := &Config{BindingKeys: []string{testBindingKey}, HostedElsewhere: hosts} + cfg.setProviderAuth(AuthProfile{Scheme: util.AuthSchemeNone}) + if _, _, err := New(context.Background(), &stubRegistry{}, &stubMapper{}, nil, cfg); err == nil { + t.Error("New() accepted it") + } + }) + } +} + +func TestRunHandsTheEnvelopeTheRegistry(t *testing.T) { + var sent string + url := recordingUpstream(t, string(reverse([]byte(`{}`))), &sent) + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{"ok":true}`)} + envelope := &reversingEnvelope{} + if _, err := runStep(t, envelopedStep(t, url, mapper, envelope), selectBody); err != nil { + t.Fatalf("Run() = %v", err) + } + if envelope.exchange.Registry == nil { + t.Error("the envelope was not handed the registry") + } +} diff --git a/pkg/plugin/implementation/internal/common/hosts.go b/pkg/plugin/implementation/internal/common/hosts.go new file mode 100644 index 00000000..e83c018f --- /dev/null +++ b/pkg/plugin/implementation/internal/common/hosts.go @@ -0,0 +1,33 @@ +package common + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/beckn-one/beckn-onix/pkg/model" + "github.com/beckn-one/beckn-onix/pkg/plugin/definition" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common/util" +) + +// HostOf returns the registry's base URL for bindingKey's participant; an +// unpublished binding is a 502 (ADR-0002). +func HostOf(ctx context.Context, registry definition.ProviderRecordLookup, bindingKey string) (string, error) { + if registry == nil { + return "", fmt.Errorf("no registry to find %s's host in", bindingKey) + } + record, err := registry.ProviderRecord(ctx, bindingKey) + if err != nil { + if errors.Is(err, definition.ErrProviderRecordNotFound) { + return "", model.NewCodedErr(http.StatusBadGateway, util.CodeUpstreamUnavailable, + fmt.Errorf("the registry publishes no active binding for %s: %w", bindingKey, err)) + } + return "", fmt.Errorf("no host for %s: %w", bindingKey, err) + } + if record == nil || record.BaseURL == "" { + return "", model.NewCodedErr(http.StatusBadGateway, util.CodeUpstreamUnavailable, + fmt.Errorf("the registry has no base URL for %s", bindingKey)) + } + return record.BaseURL, nil +} diff --git a/pkg/plugin/implementation/internal/common/serve.go b/pkg/plugin/implementation/internal/common/serve.go index 1671df2c..2ef7593a 100644 --- a/pkg/plugin/implementation/internal/common/serve.go +++ b/pkg/plugin/implementation/internal/common/serve.go @@ -151,16 +151,42 @@ func (s *Step) serve(ctx *model.StepContext, plan *model.ProviderRecord) error { // Resolved from the binding key, so a step serving several providers // authenticates each as itself. Startup guarantees a profile per served // provider; this guards a record arriving for an undeclared key. - auth, configured := s.auth[providerIDFrom(plan.BindingKey)] + provider := providerIDFrom(plan.BindingKey) + auth, configured := s.auth[provider] if !configured { return fmt.Errorf("no credential is configured for %s", plan.BindingKey) } - upstreamResponse, err := s.call(ctx, auth, plan.BaseURL, call, upstreamRequest) + // The participant's host, unless another participant serves this action. + base := plan.BaseURL + if key := s.config.HostedElsewhere[provider][action]; key != "" { + if base, err = HostOf(ctx, s.registry, key); err != nil { + return err + } + } + + // The provider's envelope, if any: seal once before the retry loop, open + // once after it. + envelope, wrapped := s.config.EnvelopeByProvider[provider] + var open Opener + if wrapped { + exchange := Exchange{Action: action, Beckn: beckn, Plan: plan, Registry: s.registry} + if upstreamRequest, open, err = envelope.Seal(ctx, exchange, upstreamRequest); err != nil { + return err + } + } + + upstreamResponse, err := s.call(ctx, auth, base, call, upstreamRequest) if err != nil { return err } + if wrapped && open != nil { + if upstreamResponse, err = open(ctx, upstreamResponse); err != nil { + return err + } + } + answer, err := decodeBody(upstreamResponse) if err != nil { return fmt.Errorf("provider answered with something that is not JSON: %w", err) From f28ae9bea216eacef51971480b5d6642946cea9b Mon Sep 17 00:00:00 2001 From: KrutikaPhirangi <138781661+KrutikaPhirangi@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:09:05 +0530 Subject: [PATCH 2/3] feat: fetch a pack from where it is served, and keep the caller's value out of the rest [#60] extendedSchema_contextRewrite takes "from|to" prefix pairs: a pack whose published @context does not serve it is fetched from the rewritten URL, while the payload keeps its @context. The allowlist is checked on the rewritten host. Schema errors already carry the reason alone; a non-schema error and the two debug logs that printed the raw error now drop the schema and the offending value too, which a pack can mark no-echo. --- .../schemav2validator/cmd/plugin.go | 14 ++ .../schemav2validator/cmd/plugin_test.go | 39 +++- .../schemav2validator/extended_schema.go | 23 ++- .../schemav2validator/extended_schema_test.go | 172 ++++++++++++++++++ .../schemav2validator/schemav2validator.go | 18 +- 5 files changed, 259 insertions(+), 7 deletions(-) diff --git a/pkg/plugin/implementation/schemav2validator/cmd/plugin.go b/pkg/plugin/implementation/schemav2validator/cmd/plugin.go index 9db7f49d..8a9ffa1a 100644 --- a/pkg/plugin/implementation/schemav2validator/cmd/plugin.go +++ b/pkg/plugin/implementation/schemav2validator/cmd/plugin.go @@ -3,6 +3,7 @@ package main import ( "context" "errors" + "fmt" "strconv" "strings" @@ -104,6 +105,19 @@ func (vp schemav2ValidatorProvider) New(ctx context.Context, config map[string]s if v, ok := config["extendedSchema_localSchemaPath"]; ok && v != "" { cfg.ExtendedSchemaConfig.LocalSchemaPath = v } + // "from|to" pairs, comma-separated: fetch an @context starting with + // from from to instead. "|" cannot appear unescaped in a URL. + if v, ok := config["extendedSchema_contextRewrite"]; ok && strings.TrimSpace(v) != "" { + for _, pair := range strings.Split(v, ",") { + from, to, found := strings.Cut(strings.TrimSpace(pair), "|") + from, to = strings.TrimSpace(from), strings.TrimSpace(to) + if !found || from == "" || to == "" { + return nil, nil, fmt.Errorf("extendedSchema_contextRewrite: %q is not a from|to pair", pair) + } + cfg.ExtendedSchemaConfig.ContextRewrites = append(cfg.ExtendedSchemaConfig.ContextRewrites, + schemav2validator.ContextRewrite{From: from, To: to}) + } + } } diff --git a/pkg/plugin/implementation/schemav2validator/cmd/plugin_test.go b/pkg/plugin/implementation/schemav2validator/cmd/plugin_test.go index 96bb3a56..01d461a0 100644 --- a/pkg/plugin/implementation/schemav2validator/cmd/plugin_test.go +++ b/pkg/plugin/implementation/schemav2validator/cmd/plugin_test.go @@ -180,9 +180,9 @@ func TestProvider_AuxiliaryConfig(t *testing.T) { { name: "auxiliary collides with primary", config: map[string]string{ - "type": "url", - "location": primary.URL, - "auxiliaryTypes": "url", + "type": "url", + "location": primary.URL, + "auxiliaryTypes": "url", "auxiliaryLocations": aux.URL, }, // aux and primary serve the same spec — collision on "test" action. @@ -296,3 +296,36 @@ func contains(s, substr string) bool { } return false } + +func TestProvider_ContextRewriteConfig(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(testSpec)) + })) + defer server.Close() + + for name, tc := range map[string]struct { + rewrite string + wantErr bool + }{ + "one pair": {"https://published.example/|https://raw.example/branch/", false}, + "two pairs, spaced": {" https://a.example/ | https://b.example/ , https://c.example/|https://d.example/ ", false}, + "empty is no rewrite": {"", false}, + "no separator": {"https://published.example/", true}, + "an empty side": {"https://published.example/|", true}, + } { + t.Run(name, func(t *testing.T) { + _, _, err := Provider.New(context.Background(), map[string]string{ + "type": "url", + "location": server.URL, + "extendedSchema_enabled": "true", + "extendedSchema_contextRewrite": tc.rewrite, + }) + if (err != nil) != tc.wantErr { + t.Errorf("New() error = %v, wantErr %v", err, tc.wantErr) + } + if err != nil && !contains(err.Error(), "extendedSchema_contextRewrite") { + t.Errorf("error %q does not name the setting", err) + } + }) + } +} diff --git a/pkg/plugin/implementation/schemav2validator/extended_schema.go b/pkg/plugin/implementation/schemav2validator/extended_schema.go index 1c3d7169..12f05725 100644 --- a/pkg/plugin/implementation/schemav2validator/extended_schema.go +++ b/pkg/plugin/implementation/schemav2validator/extended_schema.go @@ -72,6 +72,26 @@ type ExtendedSchemaConfig struct { DownloadTimeout int // seconds, default 30 AllowedDomains []string // whitelist (empty = all allowed) LocalSchemaPath string // when non-empty, preload schemas from this directory; network is fallback + // ContextRewrites change where a pack is fetched from, never the + // payload's @context; the first matching prefix wins (ADR-0003). + ContextRewrites []ContextRewrite +} + +// ContextRewrite is one @context prefix and the prefix to fetch it from. +type ContextRewrite struct { + From string + To string +} + +// rewriteContext returns where to fetch contextURL from. The allowlist is +// checked on the result, the host actually read. +func rewriteContext(contextURL string, rewrites []ContextRewrite) string { + for _, r := range rewrites { + if r.From != "" && strings.HasPrefix(contextURL, r.From) { + return r.To + strings.TrimPrefix(contextURL, r.From) + } + } + return contextURL } // referencedObject represents a domain-specific object with @context. @@ -209,6 +229,7 @@ func (v *schemav2Validator) validateExtendedSchemas(ctx context.Context, body in // Validate each object for _, obj := range objects { + obj.Context = rewriteContext(obj.Context, refConfig.ContextRewrites) log.Debugf(ctx, "Validating object at path: %s, @context: %s, @type: %s", obj.Path, obj.Context, obj.Type) @@ -797,7 +818,7 @@ func (c *schemaCache) validateReferencedObject( openapi3.EnableFormatValidation(), } if err := schema.Value.VisitJSON(domainData, opts...); err != nil { - log.Debugf(ctx, "Validation failed for @type: %s at path: %s: %v", obj.Type, obj.Path, err) + log.Debugf(ctx, "Validation failed for @type: %s at path: %s: %s", obj.Type, obj.Path, withoutDetails(err.Error())) return err } diff --git a/pkg/plugin/implementation/schemav2validator/extended_schema_test.go b/pkg/plugin/implementation/schemav2validator/extended_schema_test.go index 9facca44..bb77ebec 100644 --- a/pkg/plugin/implementation/schemav2validator/extended_schema_test.go +++ b/pkg/plugin/implementation/schemav2validator/extended_schema_test.go @@ -1987,3 +1987,175 @@ func TestPayloadDirectedReader(t *testing.T) { }) } } + +func TestRewriteContext(t *testing.T) { + rewrites := []ContextRewrite{ + {From: "https://published.example/specs/", To: "https://raw.example/specs/branch/"}, + {From: "https://published.example/", To: "https://other.example/"}, + } + for name, tc := range map[string]struct{ in, want string }{ + "the first matching prefix wins": {"https://published.example/specs/X/v0.1/context.jsonld", "https://raw.example/specs/branch/X/v0.1/context.jsonld"}, + "a later prefix still matches": {"https://published.example/other/context.jsonld", "https://other.example/other/context.jsonld"}, + "no match is left as is": {"https://elsewhere.example/X/context.jsonld", "https://elsewhere.example/X/context.jsonld"}, + } { + t.Run(name, func(t *testing.T) { + if got := rewriteContext(tc.in, rewrites); got != tc.want { + t.Errorf("rewriteContext(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } + if got := rewriteContext("https://a.example/x", nil); got != "https://a.example/x" { + t.Errorf("no rewrites changed the URL to %q", got) + } +} + +// A pack that pins @context to its published URL, served only from the +// rewrite target: the payload keeps the published URL, the fetch goes to the +// target, and the allowlist is checked on the target's host. +func TestValidateExtendedSchemas_ContextRewriteFetchesFromTheTarget(t *testing.T) { + const published = "https://published.invalid/specs/" + schemaContent := `openapi: 3.1.0 +info: + title: Test Schema + version: 1.0.0 +components: + schemas: + TestType: + type: object + x-jsonld: + "@context": ./context.jsonld + "@type": TestType + required: ["@context", field1] + properties: + "@context": + const: ` + published + `TestType/v0.1/context.jsonld + field1: + type: string + pattern: "^[0-9]{4}$"` + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/branch/TestType/v0.1/attributes.yaml" { + http.NotFound(w, r) + return + } + w.Write([]byte(schemaContent)) + })) + defer server.Close() + serverURL, _ := url.Parse(server.URL) + + validator := func(rewrites []ContextRewrite) *schemav2Validator { + return &schemav2Validator{ + config: &Config{ + EnableExtendedSchema: true, + ExtendedSchemaConfig: ExtendedSchemaConfig{ + AllowedDomains: []string{serverURL.Host}, + ContextRewrites: rewrites, + }, + }, + schemaCache: newSchemaCache(10), + } + } + body := func(field1 string) map[string]interface{} { + return map[string]interface{}{"message": map[string]interface{}{"order": map[string]interface{}{ + "@context": published + "TestType/v0.1/context.jsonld", + "@type": "TestType", + "field1": field1, + }}} + } + rewrite := []ContextRewrite{{From: published, To: server.URL + "/branch/"}} + ctx := context.Background() + + if err := validator(rewrite).validateExtendedSchemas(ctx, body("4821")); err != nil { + t.Errorf("a valid object behind a rewritten @context: %v", err) + } + + err := validator(rewrite).validateExtendedSchemas(ctx, body("48210")) + if err == nil { + t.Error("an invalid object behind a rewritten @context passed") + } else if !strings.Contains(err.Error(), "field1") { + t.Errorf("error %v does not name the failing field", err) + } + + // Without the rewrite the published host is fetched, and it is not allowed. + err = validator(nil).validateExtendedSchemas(ctx, body("4821")) + var schemaErr *model.SchemaValidationErr + if !errors.As(err, &schemaErr) || schemaErr.Errors[0].Code != "SCH_INVALID_JSONLD_CONTEXT" { + t.Errorf("without a rewrite: %v, want SCH_INVALID_JSONLD_CONTEXT", err) + } + + // A rewrite to a host off the allowlist is refused, not fetched. + offList := []ContextRewrite{{From: published, To: "https://not-allowed.invalid/"}} + err = validator(offList).validateExtendedSchemas(ctx, body("4821")) + if !errors.As(err, &schemaErr) || schemaErr.Errors[0].Code != "SCH_INVALID_JSONLD_CONTEXT" { + t.Errorf("a rewrite off the allowlist: %v, want SCH_INVALID_JSONLD_CONTEXT", err) + } +} + +// A refusal names the rule and the path, never the value: a pack can mark the +// field no-echo, and kin-openapi appends the value and the schema by default. +func TestValidateExtendedSchemas_ErrorDoesNotEchoTheValue(t *testing.T) { + schemaContent := `openapi: 3.1.0 +info: + title: Test Schema + version: 1.0.0 +components: + schemas: + TestType: + type: object + x-jsonld: + "@context": ./context.jsonld + "@type": TestType + properties: + applicant: + type: object + properties: + mobile: + type: string + pattern: "^[6-9][0-9]{9}$" + x-oan-pii: + class: contact + handling: [no-echo]` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(schemaContent)) + })) + defer server.Close() + + v := &schemav2Validator{ + config: &Config{EnableExtendedSchema: true, ExtendedSchemaConfig: ExtendedSchemaConfig{}}, + schemaCache: newSchemaCache(10), + } + const secret = "5812345670" + body := map[string]interface{}{"message": map[string]interface{}{"order": map[string]interface{}{ + "@context": server.URL + "/TestType/v0.1/context.jsonld", + "@type": "TestType", + "applicant": map[string]interface{}{"mobile": secret}, + }}} + + err := v.validateExtendedSchemas(context.Background(), body) + var schemaErr *model.SchemaValidationErr + if !errors.As(err, &schemaErr) || len(schemaErr.Errors) == 0 { + t.Fatalf("validateExtendedSchemas() = %v, want a schema refusal", err) + } + got := schemaErr.BecknError() + for _, leak := range []string{secret, "Value:", "Schema:", "x-oan-pii"} { + if strings.Contains(got.Message, leak) { + t.Errorf("message %q carries %q", got.Message, leak) + } + } + if !strings.Contains(got.Message, "regular expression") { + t.Errorf("message %q does not say which rule failed", got.Message) + } + if got.Details == nil || !strings.Contains(got.Details.Path, "mobile") { + t.Errorf("details = %+v, want the path to the field", got.Details) + } +} + +func TestWithoutDetails(t *testing.T) { + in := "string doesn't match the regular expression \"^[0-9]{4}$\"\nSchema:\n {\"pattern\": \"^[0-9]{4}$\"}\n\nValue:\n \"48210\"\n" + if got := withoutDetails(in); got != "string doesn't match the regular expression \"^[0-9]{4}$\"" { + t.Errorf("withoutDetails() = %q", got) + } + if got := withoutDetails(" plain reason "); got != "plain reason" { + t.Errorf("withoutDetails() = %q, want the reason trimmed", got) + } +} diff --git a/pkg/plugin/implementation/schemav2validator/schemav2validator.go b/pkg/plugin/implementation/schemav2validator/schemav2validator.go index d66cf23c..c0a6b3f6 100644 --- a/pkg/plugin/implementation/schemav2validator/schemav2validator.go +++ b/pkg/plugin/implementation/schemav2validator/schemav2validator.go @@ -203,8 +203,9 @@ func (v *schemav2Validator) Validate(ctx context.Context, reqURL *url.URL, data openapi3.EnableFormatValidation(), } if err := schema.Value.VisitJSON(jsonData, opts...); err != nil { - log.Debugf(ctx, "Schema validation failed: %v", err) - return v.formatValidationError(err) + formatted := v.formatValidationError(err) + log.Debugf(ctx, "Schema validation failed: %v", formatted) + return formatted } log.Debugf(ctx, "base schema validation passed for action: %s", action) @@ -588,7 +589,7 @@ func (v *schemav2Validator) extractSchemaErrors(err error, schemaErrors *[]model } } else { // Generic error — no schema keyword to classify against. - *schemaErrors = append(*schemaErrors, *model.NewCodedError("SCH_SCHEMA_VALIDATION_FAILED", err.Error())) + *schemaErrors = append(*schemaErrors, *model.NewCodedError("SCH_SCHEMA_VALIDATION_FAILED", withoutDetails(err.Error()))) } } @@ -718,6 +719,17 @@ func schemaErrorLeaves(err error) []*openapi3.SchemaError { return nil } +// withoutDetails keeps the reason and drops the schema and the caller's value +// that kin-openapi appends (ADR-0003). +func withoutDetails(message string) string { + for _, marker := range []string{"\nSchema:", "\nValue:"} { + if i := strings.Index(message, marker); i >= 0 { + message = message[:i] + } + } + return strings.TrimSpace(message) +} + // 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 From 0d77809b5013fbab1e0d3cad53842f6280de23ee Mon Sep 17 00:00:00 2001 From: KrutikaPhirangi <138781661+KrutikaPhirangi@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:37:58 +0530 Subject: [PATCH 3/3] feat: serve PM-KISAN grievances: lodge and read with an OTP [#60] PM-KISAN grievance on the Grievance pack: init sends an OTP through the chatbot service's participant (otpBindingKey), support lodges and status reads, each after the OTP is verified there. Bodies are AES-GCM with the service token inside. The mappings only build bodies and read answers; input rules are the pack's. A refusal with a known reason (an unknown registration number) is a 400 BIZ_GENERIC_ERROR naming the field; any other is a 502; no record found is a 400. The provider's text is logged, never returned. --- config/mappings/pmkisan/grievance.init.yaml | 47 + config/mappings/pmkisan/grievance.status.yaml | 90 ++ .../mappings/pmkisan/grievance.support.yaml | 79 ++ config/provider-adapter.yaml | 27 +- .../implementation/Grievance/cmd/plugin.go | 10 +- .../Grievance/cmd/pmkisan_test.go | 153 +++ pkg/plugin/implementation/Grievance/otp.go | 90 ++ .../implementation/Grievance/otp_test.go | 77 ++ .../Grievance/pmkisan_mappings_test.go | 909 ++++++++++++++++++ .../cmd/shippedconfig_test.go | 4 + 10 files changed, 1484 insertions(+), 2 deletions(-) create mode 100644 config/mappings/pmkisan/grievance.init.yaml create mode 100644 config/mappings/pmkisan/grievance.status.yaml create mode 100644 config/mappings/pmkisan/grievance.support.yaml create mode 100644 pkg/plugin/implementation/Grievance/cmd/pmkisan_test.go create mode 100644 pkg/plugin/implementation/Grievance/otp.go create mode 100644 pkg/plugin/implementation/Grievance/otp_test.go create mode 100644 pkg/plugin/implementation/Grievance/pmkisan_mappings_test.go diff --git a/config/mappings/pmkisan/grievance.init.yaml b/config/mappings/pmkisan/grievance.init.yaml new file mode 100644 index 00000000..6b92d71e --- /dev/null +++ b/config/mappings/pmkisan/grievance.init.yaml @@ -0,0 +1,47 @@ +# PM-KISAN grievance init -> /ChatbotOTP on the OTP service: send an OTP. + +request: | + { + "Types": "Ben_id", + "Values": beckn.message.contract.commitments[0].commitmentAttributes.enrolmentId + } + +# "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/grievance.status.yaml b/config/mappings/pmkisan/grievance.status.yaml new file mode 100644 index 00000000..5a83941f --- /dev/null +++ b/config/mappings/pmkisan/grievance.status.yaml @@ -0,0 +1,90 @@ +# PM-KISAN grievance status -> /GrievanceStatusCheck, matched on case.filedOn. + +request: | + { + "Type": "Reg_No_Status", + "IdentityNo": beckn.message.contract.commitments[0].commitmentAttributes.enrolmentId + } + +# "False": 502; no record on filedOn: 400. Only allow-listed fields are returned. +response: | + $exists(_status) + ? {} + : ( + $false := function($v) { $exists($v) and $lowercase($string($v)) = "false" }; + $refused := $false(response.Status) or $false(response.Responce) + or $false(response.Rsponce) or $false(response.status); + + $c := beckn.message.contract.commitments[0]; + $ca := $c.commitmentAttributes; + $records := $type(response) = "array" ? response + : $type(response.details) = "array" ? response.details + : $exists(response.details) ? [response.details] + : []; + + /* ISO or dd/MM/yyyy (or dd-MM-yyyy) to ISO; time dropped. */ + $iso := function($d) { + ( + $day := $type($d) = "string" ? $split($trim($d), /[ T]/)[0]; + $exists($match($day, /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/)[0]) ? $day + : $exists($match($day, /^[0-9]{2}[\/-][0-9]{2}[\/-][0-9]{4}$/)[0]) + ? $substring($day, 6, 4) & "-" & $substring($day, 3, 2) & "-" & $substring($day, 0, 2) + ) + }; + /* Non-empty trimmed string, else absent. */ + $text := function($v) { + $type($v) = "string" and $length($trim($v)) > 0 ? $trim($v) + }; + /* CaseStatusCode by lowercased, letters-only phrase. */ + $codes := { + "registered": "Registered", "underreview": "UnderReview", + "awaitingapplicant": "AwaitingApplicant", "replied": "Replied", + "disposed": "Replied", + "resolved": "Resolved", "rejected": "Rejected", "closed": "Closed" + }; + + $record := $filter($records, function($r) { $iso($r.GrievanceDate) = $ca.case.filedOn })[0]; + $phrase := $text($record.GrievanceStatus); + $remark := $text($record.OfficerReply); + $complaint := $text($record.GrievanceDescription); + + $refused + ? { "_error": { "status": 502, "code": "NET_DOWNSTREAM_UNAVAILABLE", + "message": "PM-KISAN could not read the grievance" } } + : $not($exists($record)) + ? { "_error": { "status": 400, "code": "BIZ_GENERIC_ERROR", + "message": "No grievance matches this registration number and filing date." } } + : { + "context": $merge([beckn.context, { "action": "on_status", "timestamp": $now() }]), + "message": { + "contract": { + "id": beckn.message.contract.id, + "commitments": [{ + "status": { "descriptor": { "code": "ACTIVE" } }, + "offer": $c.offer, + "resources": $c.resources, + "commitmentAttributes": { + "@context": $ca.`@context`, + "@type": $ca.`@type`, + "informationMode": "Direct", + "scheme": $ca.scheme, + "grievance": $complaint ? { "description": $complaint }, + "case": { + "status": { + "code": $phrase + ? ( + $known := $lookup($codes, $lowercase($replace($phrase, /[^A-Za-z]/, ""))); + $exists($known) ? $known : "UnderReview" + ) + : ($remark ? "Replied" : "UnderReview") + }, + "filedOn": $iso($record.GrievanceDate), + "reply": $remark, + "repliedOn": $remark ? $iso($record.OfficeReplyDate) + } + } + }] + } + } + } + ) diff --git a/config/mappings/pmkisan/grievance.support.yaml b/config/mappings/pmkisan/grievance.support.yaml new file mode 100644 index 00000000..3567163c --- /dev/null +++ b/config/mappings/pmkisan/grievance.support.yaml @@ -0,0 +1,79 @@ +# PM-KISAN grievance support -> /LodgeGrievance: lodge a grievance. + +request: | + ( + $ch := beckn.message.support.channels[0]; + { + "Type": "Reg_No_Details", + "IdentityNo": $ch.enrolmentId, + "GrievanceType": $ch.grievance.category.code, + "GrievanceDescription": $trim($ch.grievance.description) + } + ) + +# Known refusal: 400, else 502 (ADR-0004). Success adds case.ticketNo and filedOn (IST). +response: | + $exists(_status) + ? {} + : ( + $false := function($v) { $exists($v) and $lowercase($string($v)) = "false" }; + $refused := $false(response.Status) or $false(response.Responce) + or $false(response.Rsponce) or $false(response.status); + /* Text or number; empty is none. */ + $handle := function($v) { + ($type($v) = "string" and $v != "") or $type($v) = "number" ? $string($v) + }; + $ticketNo := $exists($handle(response.GrievanceID)) + ? $handle(response.GrievanceID) + : $handle(response.GrievanceNo); + $ch := beckn.message.support.channels[0]; + + /* The portal's reason, lower-cased, to what the caller is told. */ + $KNOWN_REFUSALS := { + "registration number not found": { + "message": "PM-KISAN has no registration for this enrolmentId.", + "path": "$.message.support.channels[0].enrolmentId" + } + }; + $portalMessage := ( + $m := response.message ? response.message : response.Message; + $type($m) = "string" ? $trim($m) + ); + $known := $portalMessage ? $lookup($KNOWN_REFUSALS, $lowercase($portalMessage)); + + $filedOn := $exists(beckn.context.timestamp) + ? $fromMillis($toMillis(beckn.context.timestamp), "[Y0001]-[M01]-[D01]", "+0530") + : $now("[Y0001]-[M01]-[D01]", "+0530"); + $refused and $exists($known) + ? { "_error": { "status": 400, "code": "BIZ_GENERIC_ERROR", + "message": $known.message, "path": $known.path, "cause": $portalMessage } } + : $refused + ? { "_error": { "status": 502, "code": "NET_DOWNSTREAM_UNAVAILABLE", + "message": "PM-KISAN did not accept the grievance", "cause": $portalMessage } } + : { + "context": $merge([beckn.context, { "action": "on_support", "timestamp": $now() }]), + "message": { + "support": { + "channels": [{ + "@context": $ch.`@context`, + "@type": $ch.`@type`, + "informationMode": "Direct", + "provider": $ch.provider, + "scheme": $ch.scheme, + "enrolmentId": $ch.enrolmentId, + /* The code and description sent; a name beside the code is dropped. */ + "grievance": { + "category": { "code": $ch.grievance.category.code }, + "description": $ch.grievance.description + }, + "case": { + "ticketNo": $ticketNo, + "status": { "code": "Registered" }, + /* IST: status matches on this date. */ + "filedOn": $filedOn + } + }] + } + } + } + ) diff --git a/config/provider-adapter.yaml b/config/provider-adapter.yaml index 4c94f991..f6dd62b6 100644 --- a/config/provider-adapter.yaml +++ b/config/provider-adapter.yaml @@ -232,6 +232,9 @@ modules: # one-line change. extendedSchema_allowedDomains: "raw.githubusercontent.com,schema.beckn.io,schema.nfh.global" + # Fetch api-schemas/ from the branch until the published URL serves it (ADR-0003). + extendedSchema_contextRewrite: "https://openagrinet.github.io/network-specs/api-schemas/|https://raw.githubusercontent.com/OpenAgriNet/network-specs/api-schema-packs-v0.1/api-schemas/" + extendedSchema_cacheTTL: "86400" # 24h extendedSchema_maxCacheSize: "100" extendedSchema_downloadTimeout: "30" @@ -507,10 +510,11 @@ modules: # ------------------------------------------------------------------ # Grievance -- support files a grievance, status reads it back. + # PM-KISAN: init (OTP), then support or status with the OTP. # ------------------------------------------------------------------ - id: Grievance config: - bindingKeys: "pmfby|openagrinet:Grievance" + bindingKeys: "pmfby|openagrinet:Grievance,pmkisan|openagrinet:Grievance" # Paired by position, tried in order: status names the binding on # its contract, support on its channel. @@ -531,6 +535,27 @@ modules: tokenExpiryField: responseDynamic.token.validTo headerName: Authorization + # PM-KISAN grievance: encrypted bodies, OTP on the chatbot service (ADR-0001, ADR-0002). + pmkisan: + authScheme: none + envelope: aesGcm + envelopeKeyEnv: PMKISAN_GRIEVANCE_KEY + envelopeNonceEnv: PMKISAN_GRIEVANCE_IV + envelopeTokenEnv: PMKISAN_SERVICE_TOKEN + envelopeTokenField: TokenNo + envelopeRequestField: EncryptedRequest + envelopeResponsePaths: "d.output,output" + otpEnvelope: aesCbcKeyInBand + otpBindingKey: "pmkisan-chatbot|openagrinet:SchemeApplication" + otpTokenEnv: PMKISAN_OTP_TOKEN + otpTokenField: Token + otpRequestField: EncryptedRequest + otpResponsePaths: "d.output,output" + otpActions: init + otpVerifyPath: /ChatbotOTPVerified + otpVerifyActions: "support,status" + otpVerifyTimeoutMs: "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/cmd/plugin.go b/pkg/plugin/implementation/Grievance/cmd/plugin.go index dae3fc93..75a033f4 100644 --- a/pkg/plugin/implementation/Grievance/cmd/plugin.go +++ b/pkg/plugin/implementation/Grievance/cmd/plugin.go @@ -41,8 +41,16 @@ func (p grievanceProvider) parseConfig(config map[string]string) (*Grievance.Con cfg.MaxResponseBytes = value } + // Envelope settings first: the auth parser refuses unknown settings. + envelopes, rest, err := Grievance.ParseEnvelopes(config) + if err != nil { + return nil, err + } + cfg.EnvelopeByProvider = envelopes.Envelopes + cfg.HostedElsewhere = envelopes.HostedElsewhere + // One credential profile per provider. - auth, err := common.ParseProviderAuth(config) + auth, err := common.ParseProviderAuth(rest) if err != nil { return nil, err } diff --git a/pkg/plugin/implementation/Grievance/cmd/pmkisan_test.go b/pkg/plugin/implementation/Grievance/cmd/pmkisan_test.go new file mode 100644 index 00000000..ca111d8b --- /dev/null +++ b/pkg/plugin/implementation/Grievance/cmd/pmkisan_test.go @@ -0,0 +1,153 @@ +package main + +import ( + "context" + "os" + "strings" + "testing" + + yaml "gopkg.in/yaml.v2" + + "github.com/beckn-one/beckn-onix/pkg/plugin" +) + +const pmkisanBindingKey = "pmkisan|openagrinet:Grievance" + +// bothSchemesConfig is PMFBY's block plus PM-KISAN's, flattened as the plugin +// receives them: one step serving both schemes. +func bothSchemesConfig() map[string]string { + config := pmfbyConfig() + config["bindingKeys"] = pmfbyBindingKey + "," + pmkisanBindingKey + for key, value := range map[string]string{ + "fallbackProviderIdAt": "message.support.channels[].provider.id", + "fallbackCapabilityCodeAt": "message.support.channels[].@type", + "authScheme-pmkisan": "none", + "envelope-pmkisan": "aesGcm", + "envelopeKeyEnv-pmkisan": "PMKISAN_GRIEVANCE_KEY", + "envelopeNonceEnv-pmkisan": "PMKISAN_GRIEVANCE_IV", + "envelopeTokenEnv-pmkisan": "PMKISAN_SERVICE_TOKEN", + "envelopeTokenField-pmkisan": "TokenNo", + "envelopeRequestField-pmkisan": "EncryptedRequest", + "envelopeResponsePaths-pmkisan": "d.output,output", + "otpEnvelope-pmkisan": "aesCbcKeyInBand", + "otpBindingKey-pmkisan": "pmkisan-chatbot|openagrinet:SchemeApplication", + "otpTokenEnv-pmkisan": "PMKISAN_OTP_TOKEN", + "otpTokenField-pmkisan": "Token", + "otpRequestField-pmkisan": "EncryptedRequest", + "otpResponsePaths-pmkisan": "d.output,output", + "otpActions-pmkisan": "init", + "otpVerifyPath-pmkisan": "/ChatbotOTPVerified", + "otpVerifyActions-pmkisan": "support,status", + } { + config[key] = value + } + return config +} + +func TestParseConfig_PMKISANBlock_ReadsEnvelope(t *testing.T) { + cfg, err := grievanceProvider{}.parseConfig(bothSchemesConfig()) + if err != nil { + t.Fatalf("parseConfig() = %v", err) + } + if len(cfg.BindingKeys) != 2 { + t.Errorf("BindingKeys = %v, want both schemes", cfg.BindingKeys) + } + if _, ok := cfg.EnvelopeByProvider["pmkisan"]; !ok || len(cfg.EnvelopeByProvider) != 1 { + t.Errorf("EnvelopeByProvider = %v, want pmkisan's alone -- PMFBY speaks plain JSON", cfg.EnvelopeByProvider) + } + if got := cfg.HostedElsewhere["pmkisan"]["init"]; got != "pmkisan-chatbot|openagrinet:SchemeApplication" { + t.Errorf("HostedElsewhere = %v, want pmkisan's init on the chatbot service's participant", cfg.HostedElsewhere) + } + if len(cfg.AuthByProvider) != 2 || cfg.AuthByProvider["pmkisan"].Scheme != "none" { + t.Errorf("AuthByProvider = %v, want pmfby and pmkisan", cfg.AuthByProvider) + } +} + +func TestNew_BothSchemes_BuildsStep(t *testing.T) { + step, closer, err := grievanceProvider{}.New(context.Background(), stubRegistry{}, stubMapper{}, bothSchemesConfig()) + if err != nil { + t.Fatalf("New() = %v", err) + } + if step == nil { + t.Fatal("New() returned no step") + } + _ = closer() +} + +func TestParseConfig_IncompleteOTPRealm_Refused(t *testing.T) { + config := bothSchemesConfig() + delete(config, "otpVerifyActions-pmkisan") + if _, err := (grievanceProvider{}).parseConfig(config); err == nil || + !strings.Contains(err.Error(), "otpVerifyActions") { + t.Fatalf("parseConfig() = %v, want the half-set verify named", err) + } +} + +func TestParseConfig_IncompleteEnvelope_Refused(t *testing.T) { + config := bothSchemesConfig() + delete(config, "envelopeKeyEnv-pmkisan") + if _, err := (grievanceProvider{}).parseConfig(config); err == nil || + !strings.Contains(err.Error(), "envelopeKeyEnv") { + t.Fatalf("parseConfig() = %v, want the missing envelopeKeyEnv named", err) + } +} + +func TestNew_EnvelopeForAnUnservedProvider_Refused(t *testing.T) { + config := bothSchemesConfig() + config["bindingKeys"] = pmfbyBindingKey + delete(config, "authScheme-pmkisan") + if _, _, err := (grievanceProvider{}).New(context.Background(), stubRegistry{}, stubMapper{}, config); err == nil { + t.Fatal("New() accepted an envelope for a provider the step does not serve") + } +} + +// The shipped config and this code move together: the fallback paths and the +// envelope settings are only valid if pkg/plugin flattens them and +// parseConfig reads them back. This reads the real file rather than a copy. +// +// Decoded with yaml.v2, the version cmd/adapter/main.go uses: a test on v3 +// passes while the adapter refuses the same file. +func TestShippedConfig_GrievanceEntry_BuildsStep(t *testing.T) { + raw, err := os.ReadFile("../../../../../config/provider-adapter.yaml") + if err != nil { + t.Skipf("reference config unavailable: %v", err) + } + var file struct { + Modules []struct { + Handler struct { + Plugins struct { + ProviderSteps []plugin.Config `yaml:"providerSteps"` + } `yaml:"plugins"` + } `yaml:"handler"` + } `yaml:"modules"` + } + if err := yaml.Unmarshal(raw, &file); err != nil { + t.Fatalf("the shipped config does not decode: %v", err) + } + + var shipped map[string]string + for _, m := range file.Modules { + for _, step := range m.Handler.Plugins.ProviderSteps { + if step.ID == "Grievance" { + shipped = step.Config + } + } + } + if shipped == nil { + t.Fatal("the shipped config has no Grievance entry") + } + + cfg, err := grievanceProvider{}.parseConfig(shipped) + if err != nil { + t.Fatalf("parseConfig on the shipped entry: %v", err) + } + if !strings.Contains(cfg.ProviderIDAt, "message.support.channels[]") { + t.Error("the shipped entry gives support no binding path; every PM-KISAN lodge would pass through unserved") + } + if _, ok := cfg.EnvelopeByProvider["pmkisan"]; !ok { + t.Error("the shipped entry gives pmkisan no envelope; the portal would be sent plaintext") + } + 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 new file mode 100644 index 00000000..3741ef6e --- /dev/null +++ b/pkg/plugin/implementation/Grievance/otp.go @@ -0,0 +1,90 @@ +package Grievance + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/beckn-one/beckn-onix/pkg/model" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common/envelope" +) + +// challengeMethod is the only accepted challenge. +const challengeMethod = "SMS_OTP" + +// identityType is the OTP service's type for a registration number (enrolmentId). +const identityType = "Ben_id" + +// 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} + +// ParseEnvelopes reads each provider's envelope settings out of the plugin +// config. See envelope.Parse. +func ParseEnvelopes(config map[string]string) (envelope.Parsed, map[string]string, error) { + return envelope.Parse(config, hooks) +} + +// verifyRequest is the OTP service's verify body: identifier, its type and +// the OTP. Missing or a wrong method is a 400. +func verifyRequest(exchange common.Exchange) (map[string]any, error) { + kind, identity, method, otp := challengeIn(exchange) + if kind == "" || identity == "" || otp == "" { + return nil, model.NewBadReqErr("SCH_REQUIRED_FIELD_MISSING", errors.New( + "this call needs the farmer's identifier and challenge.response.value, the OTP the farmer received")) + } + if method != challengeMethod { + return nil, model.NewBadReqErr("SCH_INVALID_FORMAT", fmt.Errorf( + "challenge.response.method must be %s", challengeMethod)) + } + 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. +func challengeIn(exchange common.Exchange) (kind, identity, method, otp string) { + payload, _ := exchange.Beckn.(map[string]any) + message, _ := payload["message"].(map[string]any) + + var holder map[string]any + if support, isSupport := message["support"].(map[string]any); isSupport { + if channels, ok := support["channels"].([]any); ok && len(channels) > 0 { + holder, _ = channels[0].(map[string]any) + } + } else if contract, isContract := message["contract"].(map[string]any); isContract { + if commitments, ok := contract["commitments"].([]any); ok && len(commitments) > 0 { + commitment, _ := commitments[0].(map[string]any) + holder, _ = commitment["commitmentAttributes"].(map[string]any) + } + } + if id := text(holder["enrolmentId"]); id != "" { + kind, identity = identityType, id + } + challenge, _ := holder["challenge"].(map[string]any) + if response, ok := challenge["response"].(map[string]any); ok { + method, otp = text(response["method"]), text(response["value"]) + } + return kind, identity, method, otp +} + +// succeeded is true only for a "True" flag; anything else fails closed. +func succeeded(plain []byte) bool { + var answer map[string]any + if json.Unmarshal(plain, &answer) != nil { + return false + } + for _, key := range []string{"Rsponce", "Responce", "Status", "status"} { + if value, ok := answer[key]; ok { + return strings.EqualFold(text(value), "true") + } + } + return false +} + +// text returns a trimmed string, or "" if the value is not one. +func text(value any) string { + s, _ := value.(string) + return strings.TrimSpace(s) +} diff --git a/pkg/plugin/implementation/Grievance/otp_test.go b/pkg/plugin/implementation/Grievance/otp_test.go new file mode 100644 index 00000000..c2512a7c --- /dev/null +++ b/pkg/plugin/implementation/Grievance/otp_test.go @@ -0,0 +1,77 @@ +package Grievance + +import ( + "errors" + "testing" + + "github.com/beckn-one/beckn-onix/pkg/model" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common" +) + +func TestSucceededFailsClosed(t *testing.T) { + for answer, want := range map[string]bool{ + `{"Rsponce":"True"}`: true, + `{"Rsponce":"true"}`: true, + `{"Responce":"True"}`: true, + `{"Rsponce":"False"}`: false, + `{"Rsponce":""}`: false, + `{"Message":"sent"}`: false, + `not json`: false, + } { + if got := succeeded([]byte(answer)); got != want { + t.Errorf("succeeded(%s) = %v, want %v", answer, got, want) + } + } +} + +// The registration number is the only identifier. +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"}}, "", ""}, + "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{}, "", ""}, + } { + payload := map[string]any{"message": map[string]any{"contract": map[string]any{"commitments": []any{ + map[string]any{"commitmentAttributes": tc.attributes}}}}} + if kind, id, _, _ := challengeIn(common.Exchange{Beckn: payload}); kind != tc.kind || id != tc.id { + t.Errorf("challengeIn(%s) = %q, %q, want %q, %q", name, kind, id, tc.kind, tc.id) + } + } + support := map[string]any{"message": map[string]any{"support": map[string]any{"orderId": "UP12345678A", + "channels": []any{map[string]any{"enrolmentId": "BR295454592"}}}}} + if kind, id, _, _ := challengeIn(common.Exchange{Beckn: support}); kind != "Ben_id" || id != "BR295454592" { + t.Errorf("challengeIn(support) = %q, %q, want the channel's enrolmentId, not orderId", kind, id) + } +} + +func TestVerifyRequestRefusesWhatCannotBeVerified(t *testing.T) { + withOTP := func(method, value string) common.Exchange { + return common.Exchange{Beckn: map[string]any{"message": map[string]any{"contract": map[string]any{"commitments": []any{ + map[string]any{"commitmentAttributes": map[string]any{"enrolmentId": "UP12345678A", + "challenge": map[string]any{"response": map[string]any{"method": method, "value": value}}}}}}}}} + } + for name, tc := range map[string]struct { + exchange common.Exchange + code string + }{ + "no OTP": {withOTP("SMS_OTP", ""), "SCH_REQUIRED_FIELD_MISSING"}, + "another method": {withOTP("EMAIL_OTP", "4821"), "SCH_INVALID_FORMAT"}, + } { + t.Run(name, func(t *testing.T) { + _, err := verifyRequest(tc.exchange) + var be model.BecknErrorer + if !errors.As(err, &be) || be.BecknError().Code != tc.code { + t.Errorf("verifyRequest() = %v, want %s", err, tc.code) + } + }) + } + fields, err := verifyRequest(withOTP("SMS_OTP", "4821")) + if err != nil || fields["Types"] != "Ben_id" || fields["Values"] != "UP12345678A" || fields["OTP"] != "4821" { + t.Errorf("verifyRequest() = %v, %v; want the identifier, its type and the OTP", fields, err) + } +} diff --git a/pkg/plugin/implementation/Grievance/pmkisan_mappings_test.go b/pkg/plugin/implementation/Grievance/pmkisan_mappings_test.go new file mode 100644 index 00000000..45f26055 --- /dev/null +++ b/pkg/plugin/implementation/Grievance/pmkisan_mappings_test.go @@ -0,0 +1,909 @@ +package Grievance_test + +// pmkisan_mappings_test.go runs the shipped PM-KISAN mappings through the real mapper, +// the real provider step and the real envelope, against a stand-in pkPortal that +// decrypts what it is sent and encrypts what it answers -- as the live one +// does. It is the only test that proves the pieces fit: a mapping is JSONata +// inside YAML fetched over HTTP, the plaintext it produces is sealed before it +// leaves, and nothing but running it establishes what reaches the pkPortal and +// what comes back as Beckn. +// +// An external test package on purpose: it uses the plugins exactly as the +// adapter does, through their exported surface. + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "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 pkMappingsDir = "../../../../config/mappings/pmkisan" + +const ( + pkCapability = "openagrinet:Grievance" + pkBindingKey = "pmkisan|" + pkCapability + + // Test material only, never the pkPortal's. + pkKeyHex = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" + pkNonceHex = "a0a1a2a3a4a5a6a7a8a9aaab" + pkToken = "test-service-token" + + pkRegistrationNo = "UP12345678A" +) + +const pkSupportRequest = `{ + "context": { + "version": "2.0.0", "action": "support", "networkId": "openagrinet", + "transactionId": "7f3a0000-0000-4000-8000-000000000001", + "messageId": "4a1e0000-0000-4000-8000-000000000002", + "timestamp": "2026-09-28T11:04:00Z" + }, + "message": { + "support": { + "channels": [{ + "@context": "https://openagrinet.github.io/network-specs/api-schemas/Grievance/v0.1/context.jsonld", + "@type": "openagrinet:Grievance", + "provider": { "id": "pmkisan", "descriptor": { "name": "PM-KISAN Grievance Portal" } }, + "informationMode": "OnDemand", + "scheme": { "code": "PM-KISAN", "name": "Pradhan Mantri Kisan Samman Nidhi" }, + "enrolmentId": "UP12345678A", + "grievance": { + "category": { "code": "G003", "name": "Installment not received" }, + "description": " Third instalment for 2026 has not been credited. " + }, + "challenge": { "response": { "method": "SMS_OTP", "value": "4821" } } + }] + } + } +}` + +const pkStatusRequest = `{ + "context": { + "version": "2.0.0", "action": "status", "networkId": "openagrinet", + "transactionId": "c04b0000-0000-4000-8000-000000000003", + "messageId": "e91f0000-0000-4000-8000-000000000004", + "timestamp": "2026-10-02T09:10:44Z" + }, + "message": { "contract": { + "id": "c9b31a45-0f78-4e2d-9a60-84b7d3e15c02", + "commitments": [{ + "status": { "descriptor": { "code": "ACTIVE" } }, + "offer": { + "id": "off:pmkisan:grievance", + "provider": { "id": "pmkisan", "descriptor": { "name": "PM-KISAN Grievance Portal" } }, + "resourceIds": ["res:pmkisan:grievance"] + }, + "resources": [{ "id": "res:pmkisan:grievance", "quantity": { "count": 1 } }], + "commitmentAttributes": { + "@context": "https://openagrinet.github.io/network-specs/api-schemas/Grievance/v0.1/context.jsonld", + "@type": "openagrinet:Grievance", + "informationMode": "OnDemand", + "scheme": { "code": "PM-KISAN", "name": "Pradhan Mantri Kisan Samman Nidhi" }, + "enrolmentId": "UP12345678A", + "case": { "filedOn": "2026-09-28" }, + "challenge": { "response": { "method": "SMS_OTP", "value": "4821" } } + } + }] + }} +}` + +const pkInitRequest = `{ + "context": { + "version": "2.0.0", "action": "init", "networkId": "openagrinet", + "transactionId": "7f3a0000-0000-4000-8000-000000000005", + "messageId": "1b850000-0000-4000-8000-000000000006", + "timestamp": "2026-09-28T10:58:00Z" + }, + "message": { "contract": { + "id": "5e2704c8-9d31-4f6a-b8c0-1a73e6d2f094", + "commitments": [{ + "status": { "descriptor": { "code": "DRAFT" } }, + "offer": { + "id": "off:pmkisan:grievance", + "provider": { "id": "pmkisan", "descriptor": { "name": "PM-KISAN Grievance Portal" } }, + "resourceIds": ["res:pmkisan:grievance"] + }, + "resources": [{ "id": "res:pmkisan:grievance", "quantity": { "count": 1 } }], + "commitmentAttributes": { + "@context": "https://openagrinet.github.io/network-specs/api-schemas/Grievance/v0.1/context.jsonld", + "@type": "openagrinet:Grievance", + "informationMode": "OnDemand", + "scheme": { "code": "PM-KISAN", "name": "Pradhan Mantri Kisan Samman Nidhi" }, + "enrolmentId": "UP12345678A" + } + }] + }} +}` + +// pkStatusAnswer is what /GrievanceStatusCheck decrypts to: every grievance on +// the registration number, each carrying the farmer's personal data beside +// the grievance. Three records, two dates, two date formats -- the pkPortal's +// is undocumented, so both shapes the mapping reads are exercised. +const pkStatusAnswer = `{ + "Responce": "True", + "details": [ + { + "Reg_No": "UP12345678A", "Farmer_Name": "Ramesh Kumar", "Father_Name": "Suresh Kumar", + "Gender": "Male", "MobileNo": "9876543210", "StateName": "Uttar Pradesh", + "DistrictName": "Lucknow", "BlockName": "Malihabad", "RevenueVillageName": "Rahimabad", + "GrievanceDate": "15/08/2026", "GrievanceStatus": "Closed", + "GrievanceDescription": "An older grievance.", "OfficerReply": "Resolved.", + "OfficeReplyDate": "20/08/2026" + }, + { + "Reg_No": "UP12345678A", "Farmer_Name": "Ramesh Kumar", "Father_Name": "Suresh Kumar", + "Gender": "Male", "MobileNo": "9876543210", "StateName": "Uttar Pradesh", + "DistrictName": "Lucknow", "BlockName": "Malihabad", "RevenueVillageName": "Rahimabad", + "GrievanceDate": "28/09/2026 11:04:12", + "GrievanceDescription": "Third instalment for 2026 has not been credited.", + "OfficerReply": "Instalment released on 2026-10-01, credited to the linked account.", + "OfficeReplyDate": "01/10/2026" + } + ] +}` + +// --- the stand-in pkPortal ---------------------------------------------------- + +type pkPortal struct { + t *testing.T + gcm cipher.AEAD + nonce []byte + answer string // plaintext to seal; ignored when raw is set + raw string // the exact body to answer with, unsealed + calls int + path string + sent map[string]any // the decrypted request +} + +func newPKPortal(t *testing.T, answer string) *pkPortal { + t.Helper() + key, _ := hex.DecodeString(pkKeyHex) + nonce, _ := hex.DecodeString(pkNonceHex) + block, err := aes.NewCipher(key) + if err != nil { + t.Fatal(err) + } + gcm, err := cipher.NewGCMWithNonceSize(block, len(nonce)) + if err != nil { + t.Fatal(err) + } + return &pkPortal{t: t, gcm: gcm, nonce: nonce, answer: answer} +} + +func (p *pkPortal) ServeHTTP(w http.ResponseWriter, r *http.Request) { + p.calls++ + p.path = r.URL.Path + + body, _ := io.ReadAll(r.Body) + var envelope map[string]string + if err := json.Unmarshal(body, &envelope); err != nil || len(envelope) != 1 { + p.t.Errorf("portal received %s, want exactly {\"EncryptedRequest\": ...}", body) + w.WriteHeader(http.StatusBadRequest) + return + } + sealed, _ := base64.StdEncoding.DecodeString(envelope["EncryptedRequest"]) + plain, err := p.gcm.Open(nil, p.nonce, sealed, nil) + if err != nil { + p.t.Errorf("portal could not decrypt the request: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + p.sent = map[string]any{} + _ = json.Unmarshal(plain, &p.sent) + + if p.raw != "" { + fmt.Fprint(w, p.raw) + return + } + out := base64.StdEncoding.EncodeToString(p.gcm.Seal(nil, p.nonce, []byte(p.answer), nil)) + fmt.Fprintf(w, `{"d":{"__type":"Grievance.Output","output":%q}}`, out) +} + +// --- the stand-in OTP service --------------------------------------------------- + +const ( + pkOTPToken = "test-otp-token" + pkValidOTP = "4821" + // A registration the OTP service will not send an OTP for. + pkNoOTPFor = "FAILOTP0000" +) + +// pkOTPService stands in for PM-KISAN's OTP service: AES-128-CBC with the key +// sent beside the ciphertext, /ChatbotOTP to send one and /ChatbotOTPVerified +// to check it. +type pkOTPService struct { + t *testing.T + calls map[string]int + sent map[string]map[string]string // per path, the decrypted request +} + +func newPKOTPService(t *testing.T) *pkOTPService { + return &pkOTPService{t: t, calls: map[string]int{}, sent: map[string]map[string]string{}} +} + +func pkCBCKey(keyString string) []byte { + key := make([]byte, aes.BlockSize) + copy(key, keyString) + return key +} + +func (o *pkOTPService) ServeHTTP(w http.ResponseWriter, r *http.Request) { + o.calls[r.URL.Path]++ + body, _ := io.ReadAll(r.Body) + var envelope map[string]string + _ = json.Unmarshal(body, &envelope) + sealed, keyString, found := strings.Cut(envelope["EncryptedRequest"], "@") + if !found { + o.t.Errorf("the OTP 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) + o.sent[r.URL.Path] = sent + + answer := `{"Rsponce":"False","Message":"Request could not be processed"}` + switch { + case sent["Token"] != pkOTPToken: + answer = `{"Rsponce":"False","Message":"Invalid Token"}` + case r.URL.Path == "/ChatbotOTP" && sent["Values"] != pkNoOTPFor: + answer = `{"Rsponce":"True","Message":"OTP has been sent to the registered mobile number"}` + case r.URL.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)) +} + +// --- harness ---------------------------------------------------------------- + +func pkServeMappings(t *testing.T) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := os.ReadFile(filepath.Join(pkMappingsDir, filepath.Base(r.URL.Path))) + if err != nil { + t.Errorf("could not read the mapping %q: %v", r.URL.Path, err) + w.WriteHeader(http.StatusNotFound) + return + } + fmt.Fprint(w, string(body)) + })) + t.Cleanup(server.Close) + return server +} + +// pkRegistry answers the served binding with plan, and any other binding it +// publishes (the OTP service's) with its own record. +type pkRegistry struct { + plan *model.ProviderRecord + others map[string]*model.ProviderRecord +} + +func (s *pkRegistry) ProviderRecord(_ context.Context, key string) (*model.ProviderRecord, error) { + if record, ok := s.others[key]; ok { + return record, nil + } + return s.plan, nil +} + +// pkOTPBindingKey is the OTP service's participant, as the shipped config names it. +const pkOTPBindingKey = "pmkisan-chatbot|openagrinet:SchemeApplication" + +// runPMKISAN drives the real step over the shipped mappings against p, configured as +// config/provider-adapter.yaml configures it. +func runPMKISAN(t *testing.T, p *pkPortal, request string) ([]byte, error) { + t.Helper() + return runPMKISANWith(t, p, newPKOTPService(t), request) +} + +// runPMKISANWith is runPMKISAN with the OTP service under the test's eye. +func runPMKISANWith(t *testing.T, p *pkPortal, otp *pkOTPService, request string) ([]byte, error) { + t.Helper() + t.Setenv("TEST_PMKISAN_KEY", pkKeyHex) + t.Setenv("TEST_PMKISAN_NONCE", pkNonceHex) + t.Setenv("TEST_PMKISAN_TOKEN", pkToken) + t.Setenv("TEST_PMKISAN_OTP_TOKEN", pkOTPToken) + + mappings := pkServeMappings(t) + upstream := httptest.NewServer(p) + t.Cleanup(upstream.Close) + otpHost := httptest.NewServer(otp) + t.Cleanup(otpHost.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: pkBindingKey, + ParticipantID: "pmkisan", + CapabilityCode: pkCapability, + BaseURL: upstream.URL, + Actions: map[string]model.ActionPlan{ + // The OTP lives on another participant, which the envelope names. + "init": {Method: http.MethodPost, Path: "/ChatbotOTP", + Mappings: mappings.URL + "/grievance.init.yaml", TimeoutMs: 20000}, + "support": {Method: http.MethodPost, Path: "/LodgeGrievance", + Mappings: mappings.URL + "/grievance.support.yaml", TimeoutMs: 30000}, + "status": {Method: http.MethodPost, Path: "/GrievanceStatusCheck", + Mappings: mappings.URL + "/grievance.status.yaml", TimeoutMs: 30000, RetryMax: 2}, + }, + }, others: map[string]*model.ProviderRecord{ + pkOTPBindingKey: {BindingKey: pkOTPBindingKey, ParticipantID: "pmkisan-chatbot", BaseURL: otpHost.URL}, + }} + + // The envelope is built from the provider block exactly as the plugin's + // cmd package builds it from config/provider-adapter.yaml. + envelopes, _, err := Grievance.ParseEnvelopes(map[string]string{ + "envelope-pmkisan": "aesGcm", + "envelopeKeyEnv-pmkisan": "TEST_PMKISAN_KEY", + "envelopeNonceEnv-pmkisan": "TEST_PMKISAN_NONCE", + "envelopeTokenEnv-pmkisan": "TEST_PMKISAN_TOKEN", + "envelopeTokenField-pmkisan": "TokenNo", + "envelopeRequestField-pmkisan": "EncryptedRequest", + "envelopeResponsePaths-pmkisan": "d.output,output", + "otpEnvelope-pmkisan": "aesCbcKeyInBand", + "otpBindingKey-pmkisan": pkOTPBindingKey, + "otpTokenEnv-pmkisan": "TEST_PMKISAN_OTP_TOKEN", + "otpTokenField-pmkisan": "Token", + "otpRequestField-pmkisan": "EncryptedRequest", + "otpResponsePaths-pmkisan": "d.output,output", + "otpActions-pmkisan": "init", + "otpVerifyPath-pmkisan": "/ChatbotOTPVerified", + "otpVerifyActions-pmkisan": "support,status", + }) + if err != nil { + t.Fatalf("ParseEnvelopes() = %v", err) + } + + step, closeStep, err := Grievance.New(context.Background(), registry, mapper, &Grievance.Config{ + BindingKeys: []string{pkBindingKey}, + // 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", + AuthByProvider: map[string]*common.AuthProfile{ + "pmkisan": {Scheme: util.AuthSchemeNone}, + }, + EnvelopeByProvider: envelopes.Envelopes, + HostedElsewhere: envelopes.HostedElsewhere, + }) + 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 pkDecode(t *testing.T, body []byte) map[string]any { + t.Helper() + var answer map[string]any + if err := json.Unmarshal(body, &answer); err != nil { + t.Fatalf("the answer is not JSON: %v\n%s", err, body) + } + return answer +} + +// pkAt walks a decoded document by keys and indices. +func pkAt(t *testing.T, node any, path ...any) any { + t.Helper() + for _, step := range path { + switch key := step.(type) { + case string: + fields, ok := node.(map[string]any) + if !ok { + t.Fatalf("at %v: %T is not an object", path, node) + } + node = fields[key] + case int: + items, ok := node.([]any) + if !ok || key >= len(items) { + t.Fatalf("at %v: no element %d", path, key) + } + node = items[key] + } + } + return node +} + +func pkBecknErr(t *testing.T, err error) (*model.Error, int) { + t.Helper() + var coded *model.CodedErr + if errors.As(err, &coded) { + return coded.BecknError(), coded.HTTPStatus() + } + var ack *model.AckNoCallbackErr + if errors.As(err, &ack) { + return ack.BecknError(), http.StatusAccepted + } + var other model.BecknErrorer + if errors.As(err, &other) { + return other.BecknError(), http.StatusBadRequest + } + t.Fatalf("error %v (%T) is not a Beckn error", err, err) + return nil, 0 +} + +// --- support ---------------------------------------------------------------- + +func TestPMKISANSupportLodgesTheGrievanceInPlaintextTheMappingNeverSees(t *testing.T) { + p := newPKPortal(t, `{"Responce":"True","GrievanceID":"PMK2026091234","message":"Grievance submitted successfully"}`) + body, err := runPMKISAN(t, p, pkSupportRequest) + if err != nil { + t.Fatalf("Run() = %v", err) + } + + // What the pkPortal decrypted: the mapping's fields plus the pkToken the + // envelope added. Description trimmed, as the pkPortal measures it. + if p.path != "/LodgeGrievance" { + t.Errorf("portal was called at %s, want /LodgeGrievance", p.path) + } + for field, want := range map[string]string{ + "Type": "Reg_No_Details", + "IdentityNo": pkRegistrationNo, + "GrievanceType": "G003", + "GrievanceDescription": "Third instalment for 2026 has not been credited.", + "TokenNo": pkToken, + } { + if got := p.sent[field]; got != want { + t.Errorf("portal received %s = %v, want %q", field, got, want) + } + } + + answer := pkDecode(t, body) + if got := pkAt(t, answer, "context", "action"); got != "on_support" { + t.Errorf("context.action = %v, want on_support", got) + } + support := pkAt(t, answer, "message", "support") + if _, present := support.(map[string]any)["orderId"]; present { + t.Error("on_support carries orderId; the registration travels as the channel's enrolmentId") + } + if _, present := support.(map[string]any)["descriptor"]; present { + t.Error("on_support carries a descriptor; the grievance now travels on the channel") + } + channel := pkAt(t, support, "channels", 0) + for field, want := range map[string]any{ + "@type": pkCapability, + "informationMode": "Direct", + } { + if got := pkAt(t, channel, field); got != want { + t.Errorf("channel %s = %v, want %v", field, got, want) + } + } + // The caller's own, echoed: provider and the grievance band. + if got := pkAt(t, channel, "provider", "id"); got != "pmkisan" { + t.Errorf("provider.id = %v, want pmkisan", got) + } + if got := pkAt(t, channel, "grievance", "category", "code"); got != "G003" { + t.Errorf("grievance.category.code = %v, want it echoed", got) + } + if got := pkAt(t, channel, "grievance", "category"); fmt.Sprint(got) != fmt.Sprint(map[string]any{"code": "G003"}) { + t.Errorf("grievance.category = %v, want the code alone: the pack returns no name on a lodge", got) + } + if got := pkAt(t, channel, "enrolmentId"); got != pkRegistrationNo { + t.Errorf("enrolmentId = %v, want the registration number echoed on the channel", got) + } + // The case band: the portal's handle, and the adapter's assertions -- the + // network's code with no name, and today in IST, the date a later status + // matches on. + if got := pkAt(t, channel, "case", "ticketNo"); got != "PMK2026091234" { + t.Errorf("case.ticketNo = %v, want the portal's GrievanceID", got) + } + if got := pkAt(t, channel, "case", "status"); fmt.Sprint(got) != fmt.Sprint(map[string]any{"code": "Registered"}) { + t.Errorf("case.status = %v, want {code: Registered} with no name", got) + } + // The IST date of the request's own timestamp, 2026-09-28T11:04:00Z. + if got := pkAt(t, channel, "case", "filedOn"); got != "2026-09-28" { + t.Errorf("case.filedOn = %v, want the request's date in IST, 2026-09-28", got) + } + + if strings.Contains(string(body), pkToken) { + t.Errorf("the answer carries the service token:\n%s", body) + } + if strings.Contains(string(body), "Grievance submitted successfully") { + t.Errorf("the answer passes the portal's message through:\n%s", body) + } +} + +func TestPMKISANSupportReportsAPortalRefusalInOurWords(t *testing.T) { + enrolment := "$.message.support.channels[0].enrolmentId" + for name, tc := range map[string]struct { + answer string + status int + code string + path string + }{ + // A known reason is the caller's to fix: a 400 naming the field. + "Responce, not found": {`{"Responce":"False","message":"Registration number not found"}`, http.StatusBadRequest, "BIZ_GENERIC_ERROR", enrolment}, + "Rsponce, not found": {`{"Rsponce":"false","message":"registration number not found "}`, http.StatusBadRequest, "BIZ_GENERIC_ERROR", enrolment}, + "status, not found": {`{"status":"False","Message":"Registration number not found"}`, http.StatusBadRequest, "BIZ_GENERIC_ERROR", enrolment}, + // Any other "False" is PM-KISAN failing: a 502. + "an unknown reason": {`{"Responce":"False","message":"Service temporarily unavailable"}`, http.StatusBadGateway, "NET_DOWNSTREAM_UNAVAILABLE", ""}, + "a reason we do not read": {`{"Status":"False","Remark":"Registration number not found"}`, http.StatusBadGateway, "NET_DOWNSTREAM_UNAVAILABLE", ""}, + "no reason": {`{"Responce":"False"}`, http.StatusBadGateway, "NET_DOWNSTREAM_UNAVAILABLE", ""}, + } { + t.Run(name, func(t *testing.T) { + _, err := runPMKISAN(t, newPKPortal(t, tc.answer), pkSupportRequest) + if err == nil { + t.Fatal("Run() succeeded on a portal refusal") + } + beckn, status := pkBecknErr(t, err) + if status != tc.status || beckn.Code != tc.code { + t.Errorf("got %s/%d, want %s/%d", beckn.Code, status, tc.code, tc.status) + } + if tc.path != "" && (beckn.Details == nil || beckn.Details.Path != tc.path) { + t.Errorf("details = %+v, want path %s", beckn.Details, tc.path) + } + for _, leak := range []string{"not found", "temporarily"} { + if strings.Contains(beckn.Message, leak) { + t.Errorf("message %q passes the portal's text through", beckn.Message) + } + } + }) + } +} + +func TestPMKISANSupportTreatsPlainTextFromThePortalAsARefusal(t *testing.T) { + p := newPKPortal(t, "") + p.raw = `{"d":{"__type":"Grievance.Output","output":"Invalid Token No."}}` + _, err := runPMKISAN(t, p, pkSupportRequest) + 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) + } +} + +// --- status ----------------------------------------------------------------- + +func TestPMKISANStatusAnswersTheGrievanceFiledOnThatDate(t *testing.T) { + p := newPKPortal(t, pkStatusAnswer) + body, err := runPMKISAN(t, p, pkStatusRequest) + if err != nil { + t.Fatalf("Run() = %v", err) + } + + if p.path != "/GrievanceStatusCheck" { + t.Errorf("portal was called at %s, want /GrievanceStatusCheck", p.path) + } + if p.sent["Type"] != "Reg_No_Status" || p.sent["IdentityNo"] != pkRegistrationNo || p.sent["TokenNo"] != pkToken { + t.Errorf("portal received %v, want Reg_No_Status for the registration number, with TokenNo", p.sent) + } + + answer := pkDecode(t, body) + if got := pkAt(t, answer, "context", "action"); got != "on_status" { + t.Errorf("context.action = %v, want on_status", got) + } + contract := pkAt(t, answer, "message", "contract") + if got := pkAt(t, contract, "id"); got != "c9b31a45-0f78-4e2d-9a60-84b7d3e15c02" { + t.Errorf("contract.id = %v, want it echoed", got) + } + if _, present := contract.(map[string]any)["descriptor"]; present { + t.Error("on_status carries contract.descriptor; the complaint now travels in the grievance band") + } + attributes := pkAt(t, contract, "commitments", 0, "commitmentAttributes") + if got := pkAt(t, attributes, "informationMode"); got != "Direct" { + t.Errorf("informationMode = %v, want Direct", got) + } + if got := pkAt(t, attributes, "grievance"); fmt.Sprint(got) != + fmt.Sprint(map[string]any{"description": "Third instalment for 2026 has not been credited."}) { + t.Errorf("grievance = %v, want the matching record's description alone", got) + } + for field, want := range map[string]any{ + "filedOn": "2026-09-28", + "repliedOn": "2026-10-01", + "reply": "Instalment released on 2026-10-01, credited to the linked account.", + } { + if got := pkAt(t, attributes, "case", field); got != want { + t.Errorf("case.%s = %v, want %v", field, got, want) + } + } + // No GrievanceStatus on this record, and a remark exists: Replied. + if got := pkAt(t, attributes, "case", "status"); fmt.Sprint(got) != fmt.Sprint(map[string]any{"code": "Replied"}) { + t.Errorf("case.status = %v, want {code: Replied} with no name", got) + } + if _, present := attributes.(map[string]any)["enrolmentId"]; present { + t.Error("on_status echoes enrolmentId; the registration number is consumed, not surfaced") + } + commitmentStatus := pkAt(t, pkDecode(t, body), "message", "contract", "commitments", 0, "status", "descriptor") + if fmt.Sprint(commitmentStatus) != fmt.Sprint(map[string]any{"code": "ACTIVE"}) { + t.Errorf("commitment status = %v, want {code: ACTIVE}", commitmentStatus) + } + + // The allow-list: nothing about the farmer, and not the other grievance. + for _, leaked := range []string{"Ramesh", "Suresh", "9876543210", "Rahimabad", "Malihabad", + pkRegistrationNo, "An older grievance", "Male"} { + if strings.Contains(string(body), leaked) { + t.Errorf("the answer carries %q, which the allow-list must drop:\n%s", leaked, body) + } + } +} + +func TestPMKISANStatusMapsThePortalsPhraseToTheNetworksCode(t *testing.T) { + for phrase, code := range map[string]string{ + // A phrase naming a network state maps to it, case and spacing aside. + "Under Review": "UnderReview", + "REPLIED": "Replied", + "Closed": "Closed", + // The portal's word for a grievance it has answered. + "Disposed": "Replied", + // One it does not recognise is non-terminal: never inferred closed. + "Pending at District": "UnderReview", + } { + t.Run(phrase, func(t *testing.T) { + answer := strings.Replace(pkStatusAnswer, `"GrievanceDate": "28/09/2026 11:04:12",`, + `"GrievanceDate": "2026-09-28", "GrievanceStatus": "`+phrase+`",`, 1) + body, err := runPMKISAN(t, newPKPortal(t, answer), pkStatusRequest) + if err != nil { + t.Fatalf("Run() = %v", err) + } + status := pkAt(t, pkDecode(t, body), "message", "contract", "commitments", 0, "commitmentAttributes", "case", "status") + if fmt.Sprint(status) != fmt.Sprint(map[string]any{"code": code}) { + t.Errorf("case.status = %v, want {code: %s} and no name: the pack refuses it", status, code) + } + }) + } +} + +func TestPMKISANStatusReportsNothingOnFileAsARefusal(t *testing.T) { + request := strings.Replace(pkStatusRequest, `"case": { "filedOn": "2026-09-28" }`, `"case": { "filedOn": "2026-09-29" }`, 1) + _, err := runPMKISAN(t, newPKPortal(t, pkStatusAnswer), request) + if err == nil { + t.Fatal("Run() answered a date with no grievance on it") + } + 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) + } +} + +func TestPMKISANStatusReportsAnAnswerThatWillNotDecryptAsOurs(t *testing.T) { + // The pkPortal answers with an envelope sealed under a nonce this adapter + // does not hold -- what key or nonce drift looks like from our side. + p := newPKPortal(t, "") + other, _ := hex.DecodeString("ffffffffffffffffffffffff") + sealed := base64.StdEncoding.EncodeToString(p.gcm.Seal(nil, other, []byte(pkStatusAnswer), nil)) + p.raw = `{"d":{"output":"` + sealed + `"}}` + + _, err := runPMKISAN(t, p, pkStatusRequest) + if err == nil { + t.Fatal("Run() succeeded on an answer it cannot decrypt") + } + beckn, status := pkBecknErr(t, err) + if status != http.StatusInternalServerError || beckn.Code != "NET_INTERNAL_ERROR" { + t.Errorf("got %s/%d, want NET_INTERNAL_ERROR/500: a portal that answered correctly is not to blame", + beckn.Code, status) + } +} + +func TestPMKISANStatusReportsAnAnswerWithNoRecordsAsNothingOnFile(t *testing.T) { + for name, answer := range map[string]string{ + "no details": `{"Responce":"True"}`, + "empty details": `{"Responce":"True","details":[]}`, + } { + t.Run(name, func(t *testing.T) { + _, err := runPMKISAN(t, newPKPortal(t, answer), pkStatusRequest) + if err == nil { + t.Fatal("Run() answered a reply with no records") + } + 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) + } + }) + } +} + +func TestPMKISANSupportReadsTheTicketFromEitherName(t *testing.T) { + for name, tc := range map[string]struct{ answer, want string }{ + "GrievanceID": {`{"Responce":"True","GrievanceID":"PMK1"}`, "PMK1"}, + "GrievanceNo when ID is absent": {`{"Responce":"True","GrievanceNo":"PMK2"}`, "PMK2"}, + "a numeric GrievanceNo": {`{"Responce":"True","GrievanceNo":20260912}`, "20260912"}, + "neither": {`{"Responce":"True"}`, ""}, + } { + t.Run(name, func(t *testing.T) { + body, err := runPMKISAN(t, newPKPortal(t, tc.answer), pkSupportRequest) + if err != nil { + t.Fatalf("Run() = %v", err) + } + c := pkAt(t, pkDecode(t, body), "message", "support", "channels", 0, "case").(map[string]any) + got, present := c["ticketNo"] + if tc.want == "" { + if present { + t.Errorf("case.ticketNo = %v, want it absent when the portal sends no handle", got) + } + return + } + if got != tc.want { + t.Errorf("case.ticketNo = %v, want %s", got, tc.want) + } + }) + } +} + +// --- init and the OTP ------------------------------------------------------------ + +func TestPMKISANInitAsksTheOTPServiceForAnOTP(t *testing.T) { + p := newPKPortal(t, "") + otp := newPKOTPService(t) + body, err := runPMKISANWith(t, p, otp, pkInitRequest) + if err != nil { + t.Fatalf("Run() = %v", err) + } + + // The OTP service was asked, by registration number, with its own token; + // the grievance service was not called at all. + sent := otp.sent["/ChatbotOTP"] + for field, want := range map[string]string{"Types": "Ben_id", "Values": pkRegistrationNo, "Token": pkOTPToken} { + if sent[field] != want { + t.Errorf("OTP service received %s = %q, want %q", field, sent[field], want) + } + } + if p.calls != 0 { + t.Errorf("the grievance service was called %d time(s) for an OTP", p.calls) + } + + answer := pkDecode(t, body) + if got := pkAt(t, answer, "context", "action"); got != "on_init" { + t.Errorf("context.action = %v, want on_init", got) + } + commitment := pkAt(t, answer, "message", "contract", "commitments", 0) + if got := pkAt(t, commitment, "status", "descriptor", "code"); got != "DRAFT" { + t.Errorf("commitment status = %v, want DRAFT", got) + } + attributes := pkAt(t, commitment, "commitmentAttributes").(map[string]any) + if attributes["informationMode"] != "OnDemand" { + t.Errorf("informationMode = %v, want OnDemand: an acknowledgement carries no case", attributes["informationMode"]) + } + challenge, _ := attributes["challenge"].(map[string]any) + issued, _ := challenge["issued"].(map[string]any) + if issued["method"] != "SMS_OTP" { + t.Errorf("challenge.issued.method = %v, want SMS_OTP", issued["method"]) + } + if issued["length"] != float64(4) || issued["valueType"] != "NUMERIC" { + t.Errorf("challenge.issued length, valueType = %v, %v, want 4, NUMERIC", issued["length"], issued["valueType"]) + } + if expires, _ := issued["expiresAt"].(string); expires == "" { + t.Error("challenge.issued.expiresAt is missing") + } else if at, err := time.Parse(time.RFC3339, expires); err != nil || at.Before(time.Now()) { + t.Errorf("challenge.issued.expiresAt = %q, want a future instant", expires) + } + if _, present := issued["sentTo"]; present { + t.Error("on_init carries sentTo; PM-KISAN does not disclose the number it texted") + } + if _, present := attributes["enrolmentId"]; present { + t.Error("on_init echoes enrolmentId") + } + if strings.Contains(string(body), pkOTPToken) { + t.Errorf("the answer carries the OTP service's token:\n%s", body) + } +} + +func TestPMKISANInitReportsAnOTPServiceRefusal(t *testing.T) { + request := strings.Replace(pkInitRequest, `"enrolmentId": "UP12345678A"`, `"enrolmentId": "`+pkNoOTPFor+`"`, 1) + _, err := runPMKISAN(t, newPKPortal(t, ""), request) + 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 TestPMKISANSupportVerifiesTheOTPAndKeepsItOutOfTheLodge(t *testing.T) { + p := newPKPortal(t, `{"Responce":"True","GrievanceID":"PMK1"}`) + otp := newPKOTPService(t) + body, err := runPMKISANWith(t, p, otp, pkSupportRequest) + if err != nil { + t.Fatalf("Run() = %v", err) + } + verify := otp.sent["/ChatbotOTPVerified"] + for field, want := range map[string]string{"Types": "Ben_id", "Values": pkRegistrationNo, "OTP": pkValidOTP, "Token": pkOTPToken} { + if verify[field] != want { + t.Errorf("verify received %s = %q, want %q", field, verify[field], want) + } + } + for _, field := range []string{"OTP", "challenge", "Token"} { + if _, present := p.sent[field]; present { + t.Errorf("the lodge plaintext carries %s; the OTP goes to the verify and nowhere else", field) + } + } + if strings.Contains(string(body), pkValidOTP) { + t.Errorf("on_support echoes the OTP:\n%s", body) + } +} + +func TestPMKISANAWrongOTPLodgesAndReadsNothing(t *testing.T) { + for name, request := range map[string]string{ + "support": strings.Replace(pkSupportRequest, `"value": "4821"`, `"value": "0000"`, 1), + "status": strings.Replace(pkStatusRequest, `"value": "4821"`, `"value": "0000"`, 1), + } { + t.Run(name, func(t *testing.T) { + p := newPKPortal(t, pkStatusAnswer) + otp := newPKOTPService(t) + _, err := runPMKISANWith(t, p, otp, request) + 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 otp.calls["/ChatbotOTPVerified"] != 1 { + t.Errorf("verify called %d time(s), want 1", otp.calls["/ChatbotOTPVerified"]) + } + if p.calls != 0 { + t.Errorf("the grievance service was called %d time(s) after a failed OTP", p.calls) + } + }) + } +} + +func TestPMKISANStatusRefusesWithoutAnOTP(t *testing.T) { + request := strings.Replace(pkStatusRequest, `, + "challenge": { "response": { "method": "SMS_OTP", "value": "4821" } }`, ``, 1) + if request == pkStatusRequest { + t.Fatal("the edit did not apply to the request") + } + p := newPKPortal(t, pkStatusAnswer) + otp := newPKOTPService(t) + _, err := runPMKISANWith(t, p, otp, request) + beckn, status := pkBecknErr(t, err) + if status != http.StatusBadRequest || beckn.Code != "SCH_REQUIRED_FIELD_MISSING" { + t.Errorf("got %s/%d, want SCH_REQUIRED_FIELD_MISSING/400", beckn.Code, status) + } + if otp.calls["/ChatbotOTPVerified"] != 0 || p.calls != 0 { + t.Errorf("verify %d, read %d; a request refused before the call must reach neither service", + otp.calls["/ChatbotOTPVerified"], p.calls) + } +} + +// filedOn is the request's own date in IST, so a lodge sent at 19:00 UTC is +// filed on the next day -- the day it was in India -- and a later status +// matches on that day. +func TestPMKISANSupportDatesTheGrievanceByTheRequestInIST(t *testing.T) { + request := strings.Replace(pkSupportRequest, `"timestamp": "2026-09-28T11:04:00Z"`, `"timestamp": "2026-09-28T19:00:00Z"`, 1) + if request == pkSupportRequest { + t.Fatal("the edit did not apply to the request") + } + body, err := runPMKISAN(t, newPKPortal(t, `{"Responce":"True"}`), request) + if err != nil { + t.Fatalf("Run() = %v", err) + } + if got := pkAt(t, pkDecode(t, body), "message", "support", "channels", 0, "case", "filedOn"); got != "2026-09-29" { + t.Errorf("case.filedOn = %v, want 2026-09-29: 19:00 UTC is 00:30 the next day in IST", got) + } +} diff --git a/pkg/plugin/implementation/KnowledgeAdvisory/cmd/shippedconfig_test.go b/pkg/plugin/implementation/KnowledgeAdvisory/cmd/shippedconfig_test.go index 958ecccb..e0f21a54 100644 --- a/pkg/plugin/implementation/KnowledgeAdvisory/cmd/shippedconfig_test.go +++ b/pkg/plugin/implementation/KnowledgeAdvisory/cmd/shippedconfig_test.go @@ -48,6 +48,10 @@ func TestShippedConfigParsesIntoAProfilePerProvider(t *testing.T) { for _, step := range steps { t.Run(step.ID, func(t *testing.T) { + if step.ID == "Grievance" { + // Envelope settings need the Grievance plugin's own parser. + t.Skip("checked by Grievance/cmd TestShippedConfig_GrievanceEntry_BuildsStep") + } // Flattened on the way in: a plugin never sees a block, so every // value here is a string. parseConfig failing would mean the // nesting did not survive.