diff --git a/pkg/education/submit.go b/pkg/education/submit.go index c789890..b5113db 100644 --- a/pkg/education/submit.go +++ b/pkg/education/submit.go @@ -16,13 +16,10 @@ import ( ) var ( - errNSCSubmitFailed = errors.New("nsc submit failed") - errNSCMissingEnrollmentStatus = errors.New("nsc response missing enrollment status") - errReadNSCSubmitResponseBody = errors.New("read nsc submit response body") - errCloseNSCSubmitResponseBody = errors.New("close nsc submit response body") + errLegacyEnrollmentStatusRequired = errors.New("enrollmentStatus is required") + errUnsupportedLegacyNSCStatusCode = errors.New("unsupported legacy nsc status code") ) -//nolint:gocritic // Request is treated as an immutable API payload value. func (s *service) LookupEnrollmentStatus(ctx context.Context, reqBody Request) (Response, error) { if s.opts.Timeout > 0 { if _, hasDeadline := ctx.Deadline(); !hasDeadline { @@ -187,8 +184,8 @@ func toNSCRequest(cfg *core.NSCConfig, reqBody Request) nscRequest { return out } -//nolint:gocritic // Response is treated as an immutable API payload value. -func translateNSCResponse(resp nscResponse, rawBody any) (Response, error) { +//nolint:gocritic // Keeping value semantics is acceptable for this internal translation helper. +func translateNSCResponse(resp nscResponse) (Response, error) { if isNSCNoHit(resp) || isNSCNotCurrentlyEnrolled(resp) { return Response{}, ErrNotFound } @@ -393,3 +390,27 @@ func enrollmentRecordCount(resp nscResponse) int { return count } + +type legacySubmitResponse struct { + Status legacySubmitStatus `json:"status"` +} + +type legacySubmitStatus struct { + Code string `json:"code"` +} + +func mapLegacyEnrollmentStatus(respBytes []byte) (EnrollmentStatus, error) { + var legacy legacySubmitResponse + if err := json.Unmarshal(respBytes, &legacy); err != nil { + return "", fmt.Errorf("decode legacy nsc response: %w", err) + } + + switch legacy.Status.Code { + case "0": + return EnrollmentStatusEnrolled, nil + case "": + return "", errLegacyEnrollmentStatusRequired + default: + return "", fmt.Errorf("%w %q", errUnsupportedLegacyNSCStatusCode, legacy.Status.Code) + } +} diff --git a/pkg/education/submit_test.go b/pkg/education/submit_test.go index 98b4c91..de94b12 100644 --- a/pkg/education/submit_test.go +++ b/pkg/education/submit_test.go @@ -369,3 +369,27 @@ func TestResolveEnrollmentStatus_PrioritizesStatus(t *testing.T) { }) } } + +func TestMapLegacyEnrollmentStatus_CodeZeroReturnsEnrolled(t *testing.T) { + status, err := mapLegacyEnrollmentStatus([]byte(`{"status":{"code":"0"}}`)) + require.NoError(t, err) + require.Equal(t, EnrollmentStatusEnrolled, status) +} + +func TestMapLegacyEnrollmentStatus_MissingCodeReturnsError(t *testing.T) { + status, err := mapLegacyEnrollmentStatus([]byte(`{"status":{}}`)) + require.Equal(t, EnrollmentStatus(""), status) + require.ErrorContains(t, err, "enrollmentStatus is required") +} + +func TestMapLegacyEnrollmentStatus_UnsupportedCodeReturnsError(t *testing.T) { + status, err := mapLegacyEnrollmentStatus([]byte(`{"status":{"code":"99"}}`)) + require.Equal(t, EnrollmentStatus(""), status) + require.ErrorContains(t, err, `unsupported legacy nsc status code "99"`) +} + +func TestMapLegacyEnrollmentStatus_InvalidJSONReturnsError(t *testing.T) { + status, err := mapLegacyEnrollmentStatus([]byte(`{`)) + require.Equal(t, EnrollmentStatus(""), status) + require.ErrorContains(t, err, "decode legacy nsc response") +} diff --git a/pkg/veteran/oauth.go b/pkg/veteran/oauth.go index 368a46a..ceb695b 100644 --- a/pkg/veteran/oauth.go +++ b/pkg/veteran/oauth.go @@ -2,8 +2,12 @@ package veteran import ( "context" + "crypto" + "crypto/rand" "crypto/rsa" + "crypto/sha256" "crypto/x509" + "encoding/base64" "encoding/json" "encoding/pem" "errors" @@ -18,8 +22,6 @@ import ( "github.com/cmsgov/emmy-api/pkg/core" "github.com/google/uuid" - "github.com/lestrrat-go/jwx/v2/jwa" - "github.com/lestrrat-go/jwx/v2/jwt" "golang.org/x/oauth2" ) @@ -175,24 +177,38 @@ func signedClientAssertion(cfg *core.VAConfig, now time.Time) (string, error) { return "", err } - token, err := jwt.NewBuilder(). - Audience([]string{cfg.TokenAudience}). - Issuer(cfg.ClientID). - Subject(cfg.ClientID). - JwtID(strings.ToLower(uuid.NewString())). - IssuedAt(now). - Expiration(now.Add(clientAssertionLifetime)). - Build() + headerJSON, err := json.Marshal(map[string]string{ + "alg": "RS256", + "typ": "JWT", + }) + if err != nil { + return "", fmt.Errorf("marshal jwt header: %w", err) + } + + payloadJSON, err := json.Marshal(map[string]any{ + "iss": cfg.ClientID, + "sub": cfg.ClientID, + "aud": cfg.TokenAudience, + "jti": strings.ToLower(uuid.NewString()), + "iat": now.Unix(), + "exp": now.Add(clientAssertionLifetime).Unix(), + }) if err != nil { - return "", fmt.Errorf("build jwt claims: %w", err) + return "", fmt.Errorf("marshal jwt claims: %w", err) } - signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256, key)) + encodedHeader := base64.RawURLEncoding.EncodeToString(headerJSON) + encodedPayload := base64.RawURLEncoding.EncodeToString(payloadJSON) + signingInput := encodedHeader + "." + encodedPayload + + hash := sha256.Sum256([]byte(signingInput)) + signature, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, hash[:]) if err != nil { return "", fmt.Errorf("sign jwt: %w", err) } - return string(signed), nil + encodedSignature := base64.RawURLEncoding.EncodeToString(signature) + return signingInput + "." + encodedSignature, nil } func loadRSAPrivateKey(path string) (*rsa.PrivateKey, error) { diff --git a/pkg/veteran/service.go b/pkg/veteran/service.go index 9d6582e..e14981b 100644 --- a/pkg/veteran/service.go +++ b/pkg/veteran/service.go @@ -65,13 +65,8 @@ type Address struct { } type Response struct { - RawData any `json:"rawData"` - LegalEffectiveDate *string `json:"legalEffectiveDate"` - CombinedEffectiveDate *string `json:"combinedEffectiveDate"` - EarliestRatingEndDate *string `json:"earliestRatingEndDate"` - DataSource core.DataSource `json:"dataSource"` - Metadata education.Metadata `json:"metadata"` - CombinedDisabilityRating int `json:"combinedDisabilityRating"` + //nolint:tagliatelle // External API contract uses camelCase. + CombinedDisabilityRating int `json:"combinedDisabilityRating"` } type service struct { diff --git a/swagger-ui/index.css b/swagger-ui/index.css index f2376fd..48dc78d 100644 --- a/swagger-ui/index.css +++ b/swagger-ui/index.css @@ -14,3 +14,10 @@ body { margin: 0; background: #fafafa; } + +/* Keep button growth anchored on the left edge so hover expansion moves right only. */ +.swagger-ui .btn, +.swagger-ui .grow, +.swagger-ui .grow-large { + transform-origin: left center; +}