Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 28 additions & 7 deletions pkg/education/submit.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,10 @@
)

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 {
Expand Down Expand Up @@ -91,13 +88,13 @@
}
defer func() {
if closeErr := resp.Body.Close(); closeErr != nil {
log.WarnContext(ctx, errCloseNSCSubmitResponseBody.Error(), slog.Any("error", closeErr))

Check failure on line 91 in pkg/education/submit.go

View workflow job for this annotation

GitHub Actions / lint

undefined: errCloseNSCSubmitResponseBody
}
}()

respBytes, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return Response{}, fmt.Errorf("%w: %w", errReadNSCSubmitResponseBody, readErr)

Check failure on line 97 in pkg/education/submit.go

View workflow job for this annotation

GitHub Actions / lint

undefined: errReadNSCSubmitResponseBody
}
snippet := bodySnippet(respBytes)

Expand Down Expand Up @@ -125,7 +122,7 @@
slog.String("body_snippet", snippet),
)

return Response{}, fmt.Errorf("%w: status=%d", errNSCSubmitFailed, resp.StatusCode)

Check failure on line 125 in pkg/education/submit.go

View workflow job for this annotation

GitHub Actions / lint

undefined: errNSCSubmitFailed
}

var out nscResponse
Expand Down Expand Up @@ -155,7 +152,7 @@
slog.Int("enrollment_records", enrollmentRecordCount(out)),
)

return translateNSCResponse(out, rawBody)

Check failure on line 155 in pkg/education/submit.go

View workflow job for this annotation

GitHub Actions / lint

too many arguments in call to translateNSCResponse
}

//nolint:gocritic // Request is treated as an immutable API payload value.
Expand Down Expand Up @@ -187,15 +184,15 @@
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
}

status, ok := resolveEnrollmentStatus(resp)
if !ok {
return Response{}, errNSCMissingEnrollmentStatus

Check failure on line 195 in pkg/education/submit.go

View workflow job for this annotation

GitHub Actions / lint

undefined: errNSCMissingEnrollmentStatus
}

details := []EnrollmentDetail{}
Expand All @@ -217,7 +214,7 @@
return Response{
EnrollmentStatus: status,
EnrollmentDetails: details,
RawData: rawBody,

Check failure on line 217 in pkg/education/submit.go

View workflow job for this annotation

GitHub Actions / lint

undefined: rawBody
DataSource: core.DataSourceNSC,
}, nil
}
Expand Down Expand Up @@ -393,3 +390,27 @@

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

Check failure on line 410 in pkg/education/submit.go

View workflow job for this annotation

GitHub Actions / lint

undefined: EnrollmentStatusEnrolled
case "":
return "", errLegacyEnrollmentStatusRequired
default:
return "", fmt.Errorf("%w %q", errUnsupportedLegacyNSCStatusCode, legacy.Status.Code)
}
}
24 changes: 24 additions & 0 deletions pkg/education/submit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -369,3 +369,27 @@
})
}
}

func TestMapLegacyEnrollmentStatus_CodeZeroReturnsEnrolled(t *testing.T) {
status, err := mapLegacyEnrollmentStatus([]byte(`{"status":{"code":"0"}}`))
require.NoError(t, err)
require.Equal(t, EnrollmentStatusEnrolled, status)

Check failure on line 376 in pkg/education/submit_test.go

View workflow job for this annotation

GitHub Actions / lint

undefined: EnrollmentStatusEnrolled (typecheck)
}

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")
}
42 changes: 29 additions & 13 deletions pkg/veteran/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@ package veteran

import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
Expand All @@ -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"
)

Expand Down Expand Up @@ -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) {
Expand Down
9 changes: 2 additions & 7 deletions pkg/veteran/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
7 changes: 7 additions & 0 deletions swagger-ui/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading