dev/vendor/MANIFEST.toml pins bcda-app@7decdcc (v3 handler + tests, request validation middleware, OperationOutcome writer, v3 Postman collection, gzip fixtures, sandbox config, LICENSE) and the bcda.cms.gov v3 data dictionary, v2 dictionary and official v3 sample ndjson. vendor_bcda_app.py --verify | --check | --sync (stdlib only); the bcda-vendor workflow runs --check weekly and files an issue on drift — nothing auto-commits, a payload change upstream is something a human reads before the flattener is re-pointed. gen_bcda_v3_crosswalk.py writes dev/seeds/bcda_v3_data_dictionary.csv (fhirPath, IDR sourceColumn, ccwMapping, cclfMapping per v3 field), bcda_v3_cclf_crosswalk.csv (293 CCLF elements -> v3 field) and bcda_v3_cclf_absent.csv (52 CCLF fields v3 does not carry); the CCLF sheet has label/name swapped on data rows and is normalised. Tests assert the committed seeds match the vendored workbook. refs #1139
672 lines
22 KiB
Go
672 lines
22 KiB
Go
package middleware
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/CMSgov/bcda-app/bcda/auth"
|
|
"github.com/CMSgov/bcda-app/bcda/constants"
|
|
"github.com/CMSgov/bcda-app/bcda/fhir"
|
|
"github.com/CMSgov/bcda-app/bcda/responseutils"
|
|
"github.com/CMSgov/bcda-app/log"
|
|
"github.com/sirupsen/logrus"
|
|
"github.com/stretchr/testify/assert"
|
|
)
|
|
|
|
var noop http.HandlerFunc = func(rw http.ResponseWriter, r *http.Request) { rw.WriteHeader(http.StatusOK) }
|
|
|
|
func TestValidRequestURL(t *testing.T) {
|
|
// Allow us to retrieve the RequestParameters by grabbing the updated context.
|
|
// When we call *http.Request.WithContext(ctx), a new request is created.
|
|
// So we cannot leverage the context associated with the original request
|
|
var ctx context.Context
|
|
handler := http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
|
ctx = r.Context()
|
|
})
|
|
|
|
now := time.Now().Add(-24 * time.Hour).Round(time.Millisecond)
|
|
req, err := http.NewRequest("GET",
|
|
fmt.Sprintf("/api/v1/Patient/$export?_type=Patient&_since=%s&_outputFormat=ndjson&_typeFilter=ExplanationOfBenefit%%3Fservice-date%%3Dgt2001-04-01",
|
|
now.Format(time.RFC3339Nano)),
|
|
nil)
|
|
assert.NoError(t, err)
|
|
rr := httptest.NewRecorder()
|
|
ValidateRequestURL(handler).ServeHTTP(rr, req)
|
|
assert.Equal(t, http.StatusOK, rr.Code)
|
|
|
|
// Verify we have the context as expected
|
|
rp, ok := GetRequestParamsFromCtx(ctx)
|
|
assert.True(t, ok)
|
|
// assert.True(t, now.Equal(rp.Since), "Since parameter does not match")
|
|
assert.Equal(t, rp.ResourceTypes, []string{"Patient"})
|
|
assert.Equal(t, rp.Version, "v1")
|
|
}
|
|
|
|
func TestInvalidRequestURL(t *testing.T) {
|
|
base := "/api/v1/Patient/$export?"
|
|
baseV3 := constants.V3Path + "Patient/$export?"
|
|
tests := []struct {
|
|
name string
|
|
url string
|
|
errMsg string
|
|
}{
|
|
{
|
|
"invalidOutputFormat",
|
|
fmt.Sprintf("%s_outputFormat=invalid", base),
|
|
"_outputFormat parameter must be one of",
|
|
},
|
|
{
|
|
"elementsNotSupported",
|
|
fmt.Sprintf("%s_elements=invalid", base),
|
|
"does not support the _elements parameter",
|
|
},
|
|
{
|
|
"contains?",
|
|
fmt.Sprintf("%s?_type=Patient", base),
|
|
"query parameters cannot start with ?",
|
|
},
|
|
{
|
|
"invalidSince",
|
|
fmt.Sprintf("%s_since=05-25-1977", base),
|
|
"Date must be in FHIR Instant format",
|
|
},
|
|
{
|
|
"futureSince",
|
|
fmt.Sprintf("%s_since=%s", base, time.Now().Add(24*time.Hour).Format(time.RFC3339Nano)),
|
|
"Date must be a date that has already passed",
|
|
},
|
|
{
|
|
"repeatedType",
|
|
fmt.Sprintf("%s_type=Patient,Patient", base),
|
|
"Repeated resource type Patient",
|
|
},
|
|
{
|
|
"noVersion",
|
|
"/api/Patient$export",
|
|
"cannot retrieve version",
|
|
},
|
|
{
|
|
"invalidTypeFilterResourceType",
|
|
fmt.Sprintf("%s_typeFilter=MedicationRequest%%3Fstatus%%3Dactive", baseV3),
|
|
"invalid _typeFilter Resource Type (Only EOBs valid): MedicationRequest",
|
|
},
|
|
{
|
|
"invalidTypeFilterSubquery",
|
|
fmt.Sprintf("%s_typeFilter=ExplanationOfBenefit%%3Fservice-dateactive", baseV3),
|
|
"_typeFilter value missing equals sign",
|
|
},
|
|
{
|
|
"invalidTypeFilterSubqueryParam",
|
|
fmt.Sprintf("%s_typeFilter=ExplanationOfBenefit%%3Fstatus%%3Dactive", baseV3),
|
|
"invalid _typeFilter subquery parameter: status",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
ctx := context.Background()
|
|
ctx = log.NewStructuredLoggerEntry(logrus.New(), ctx)
|
|
req, err := http.NewRequest("GET", tt.url, nil)
|
|
assert.NoError(t, err)
|
|
|
|
req = req.WithContext(ctx)
|
|
|
|
rr := httptest.NewRecorder()
|
|
ValidateRequestURL(noop).ServeHTTP(rr, req)
|
|
assert.Equal(t, http.StatusBadRequest, rr.Code)
|
|
assert.Contains(t, rr.Body.String(), tt.errMsg)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseHeaderValues(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
headers http.Header
|
|
headerName string
|
|
expected []string
|
|
}{
|
|
{
|
|
name: "singleValue",
|
|
headers: http.Header{"Prefer": []string{constants.TestRespondAsync}},
|
|
headerName: "Prefer",
|
|
expected: []string{constants.TestRespondAsync},
|
|
},
|
|
{
|
|
name: "commaSeparatedValues",
|
|
headers: http.Header{"Prefer": []string{constants.TestRespondAsync + ",handling=lenient"}},
|
|
headerName: "Prefer",
|
|
expected: []string{constants.TestRespondAsync, "handling=lenient"},
|
|
},
|
|
{
|
|
name: "whitespaceTrimmingAndEmptyTokens",
|
|
headers: http.Header{"Prefer": []string{" " + constants.TestRespondAsync + " , , handling=lenient "}},
|
|
headerName: "Prefer",
|
|
expected: []string{constants.TestRespondAsync, "handling=lenient"},
|
|
},
|
|
{
|
|
name: "multiLineHeaders",
|
|
headers: http.Header{"Prefer": []string{constants.TestRespondAsync, "handling=lenient"}},
|
|
headerName: "Prefer",
|
|
expected: []string{constants.TestRespondAsync, "handling=lenient"},
|
|
},
|
|
{
|
|
name: "missingHeader",
|
|
headers: http.Header{},
|
|
headerName: "Prefer",
|
|
expected: nil,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
results := parseHeaderValues(tt.headers, tt.headerName)
|
|
assert.Equal(t, tt.expected, results)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHasHeaderToken(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
values []string
|
|
targetToken string
|
|
expected bool
|
|
}{
|
|
{
|
|
name: "exactMatch",
|
|
values: []string{"respond-async"},
|
|
targetToken: "respond-async",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "caseInsensitiveMatch",
|
|
values: []string{"RESPOND-ASYNC"},
|
|
targetToken: "respond-async",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "withParameters",
|
|
values: []string{"respond-async; wait=10"},
|
|
targetToken: "respond-async",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "mediaTypeWithParameters",
|
|
values: []string{"application/fhir+json; charset=utf-8"},
|
|
targetToken: "application/fhir+json",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "multipleValuesMatch",
|
|
values: []string{"handling=lenient", "respond-async"},
|
|
targetToken: "respond-async",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "noMatch",
|
|
values: []string{"handling=lenient"},
|
|
targetToken: "respond-async",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "emptySlice",
|
|
values: nil,
|
|
targetToken: "respond-async",
|
|
expected: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
assert.Equal(t, tt.expected, hasHeaderToken(tt.values, tt.targetToken))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidRequestHeaders(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
setupReq func() *http.Request
|
|
}{
|
|
{
|
|
name: "StandardHeaders",
|
|
setupReq: func() *http.Request {
|
|
req, _ := http.NewRequest("GET", "/api/v1/Patient/$export", nil)
|
|
req.Header.Set("Accept", "application/fhir+json")
|
|
req.Header.Set("Prefer", constants.TestRespondAsync)
|
|
return req
|
|
},
|
|
},
|
|
{
|
|
name: "PreferCommaSeparatedDirectives",
|
|
setupReq: func() *http.Request {
|
|
req, _ := http.NewRequest("GET", "/api/v1/Patient/$export", nil)
|
|
req.Header.Set("Accept", "application/fhir+json")
|
|
req.Header.Set("Prefer", constants.TestRespondAsync+",handling=lenient")
|
|
return req
|
|
},
|
|
},
|
|
{
|
|
name: "PreferWithParameters",
|
|
setupReq: func() *http.Request {
|
|
req, _ := http.NewRequest("GET", "/api/v1/Patient/$export", nil)
|
|
req.Header.Set("Accept", "application/fhir+json")
|
|
req.Header.Set("Prefer", constants.TestRespondAsync+"; wait=10")
|
|
return req
|
|
},
|
|
},
|
|
{
|
|
name: "CaseInsensitiveHeaders",
|
|
setupReq: func() *http.Request {
|
|
req, _ := http.NewRequest("GET", "/api/v1/Patient/$export", nil)
|
|
req.Header.Set("Accept", "APPLICATION/FHIR+JSON")
|
|
req.Header.Set("Prefer", "RESPOND-ASYNC")
|
|
return req
|
|
},
|
|
},
|
|
{
|
|
name: "PreferReversedDirectives",
|
|
setupReq: func() *http.Request {
|
|
req, _ := http.NewRequest("GET", "/api/v1/Patient/$export", nil)
|
|
req.Header.Set("Accept", "application/fhir+json")
|
|
req.Header.Set("Prefer", "handling=lenient, "+constants.TestRespondAsync)
|
|
return req
|
|
},
|
|
},
|
|
{
|
|
name: "PreferWhitespaceTolerance",
|
|
setupReq: func() *http.Request {
|
|
req, _ := http.NewRequest("GET", "/api/v1/Patient/$export", nil)
|
|
req.Header.Set("Accept", "application/fhir+json")
|
|
req.Header.Set("Prefer", constants.TestRespondAsync+" , handling=lenient")
|
|
return req
|
|
},
|
|
},
|
|
{
|
|
name: "PreferMultiLineHeaders",
|
|
setupReq: func() *http.Request {
|
|
req, _ := http.NewRequest("GET", "/api/v1/Patient/$export", nil)
|
|
req.Header.Set("Accept", "application/fhir+json")
|
|
req.Header.Add("Prefer", "handling=lenient")
|
|
req.Header.Add("Prefer", constants.TestRespondAsync)
|
|
return req
|
|
},
|
|
},
|
|
{
|
|
name: "AcceptCommaSeparatedMediaTypes",
|
|
setupReq: func() *http.Request {
|
|
req, _ := http.NewRequest("GET", "/api/v1/Patient/$export", nil)
|
|
req.Header.Set("Accept", "application/fhir+json, application/json")
|
|
req.Header.Set("Prefer", constants.TestRespondAsync)
|
|
return req
|
|
},
|
|
},
|
|
{
|
|
name: "AcceptWithParameters",
|
|
setupReq: func() *http.Request {
|
|
req, _ := http.NewRequest("GET", "/api/v1/Patient/$export", nil)
|
|
req.Header.Set("Accept", "application/fhir+json; charset=utf-8")
|
|
req.Header.Set("Prefer", constants.TestRespondAsync)
|
|
return req
|
|
},
|
|
},
|
|
{
|
|
name: "AcceptMultiLineHeaders",
|
|
setupReq: func() *http.Request {
|
|
req, _ := http.NewRequest("GET", "/api/v1/Patient/$export", nil)
|
|
req.Header.Add("Accept", "application/json")
|
|
req.Header.Add("Accept", "application/fhir+json")
|
|
req.Header.Set("Prefer", constants.TestRespondAsync)
|
|
return req
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
ctx := context.Background()
|
|
ctx = log.NewStructuredLoggerEntry(logrus.New(), ctx)
|
|
req := tt.setupReq().WithContext(ctx)
|
|
|
|
rr := httptest.NewRecorder()
|
|
ValidateRequestHeaders(noop).ServeHTTP(rr, req)
|
|
assert.Equal(t, http.StatusOK, rr.Code)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInvalidRequestHeaders(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
acceptHeader string
|
|
preferHeader string
|
|
errMsg string
|
|
}{
|
|
{"NoAcceptHeader", "", constants.TestRespondAsync, "Accept header is required"},
|
|
{"InvalidAcceptHeader", "invalid", constants.TestRespondAsync, "application/fhir+json is the only supported response format"},
|
|
{"UnsupportedAcceptHeader", "application/xml", constants.TestRespondAsync, "application/fhir+json is the only supported response format"},
|
|
{"NoPreferHeader", "application/fhir+json", "", "Prefer header is required"},
|
|
{"InvalidPreferHeader", "application/fhir+json", "invalid", "Only asynchronous responses are supported"},
|
|
{"PreferMissingRespondAsync", "application/fhir+json", "handling=lenient", "Only asynchronous responses are supported"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
ctx := context.Background()
|
|
ctx = log.NewStructuredLoggerEntry(logrus.New(), ctx)
|
|
req, err := http.NewRequest("GET", "/api/v1/Patient/$export", nil)
|
|
assert.NoError(t, err)
|
|
|
|
req = req.WithContext(ctx)
|
|
if tt.acceptHeader != "" {
|
|
req.Header.Set("Accept", tt.acceptHeader)
|
|
}
|
|
if tt.preferHeader != "" {
|
|
req.Header.Set("Prefer", tt.preferHeader)
|
|
}
|
|
|
|
rr := httptest.NewRecorder()
|
|
ValidateRequestHeaders(noop).ServeHTTP(rr, req)
|
|
assert.Equal(t, http.StatusBadRequest, rr.Code)
|
|
assert.Contains(t, rr.Body.String(), tt.errMsg)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateTypeFilterTagCodes(t *testing.T) {
|
|
baseV3 := constants.V3Path + "Patient/$export?"
|
|
ctx := context.Background()
|
|
ctx = log.NewStructuredLoggerEntry(logrus.New(), ctx)
|
|
|
|
tests := []struct {
|
|
name string
|
|
url string
|
|
shouldFail bool
|
|
expectedErr error
|
|
description string
|
|
expectedTypeFilter fhir.TypeFilterSubquery // when non-nil, we assert the parsed TypeFilter in context equals this making sure params are not dropped.
|
|
}{
|
|
{
|
|
name: "validTagSharedSystem",
|
|
url: fmt.Sprintf("%s_typeFilter=ExplanationOfBenefit%%3F_tag%%3Dhttps%%3A%%2F%%2Fbluebutton.cms.gov%%2Ffhir%%2FCodeSystem%%2FSystem-Type%%7CSharedSystem", baseV3),
|
|
shouldFail: false,
|
|
description: "Valid tag in URL format should pass",
|
|
expectedTypeFilter: fhir.TypeFilterSubquery{
|
|
ResourceType: "ExplanationOfBenefit",
|
|
QueryParameters: []fhir.TypeFilterSubqueryParam{
|
|
{
|
|
Name: "_tag",
|
|
Value: constants.BFDSystemTypeURL + "|SharedSystem",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "validTagNationalClaimsHistory",
|
|
url: fmt.Sprintf("%s_typeFilter=ExplanationOfBenefit%%3F_tag%%3Dhttps%%3A%%2F%%2Fbluebutton.cms.gov%%2Ffhir%%2FCodeSystem%%2FSystem-Type%%7CNationalClaimsHistory", baseV3),
|
|
shouldFail: false,
|
|
description: "Valid NCH tag should pass",
|
|
expectedTypeFilter: fhir.TypeFilterSubquery{
|
|
ResourceType: "ExplanationOfBenefit",
|
|
QueryParameters: []fhir.TypeFilterSubqueryParam{
|
|
{
|
|
Name: "_tag",
|
|
Value: constants.BFDSystemTypeURL + "|NationalClaimsHistory",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "validTagSharedSystemComma",
|
|
url: fmt.Sprintf("%s_typeFilter=ExplanationOfBenefit%%3F_tag%%3Dhttps%%3A%%2F%%2Fbluebutton.cms.gov%%2Ffhir%%2FCodeSystem%%2FSystem-Type%%7CSharedSystem,https%%3A%%2F%%2Fbluebutton.cms.gov%%2Ffhir%%2FCodeSystem%%2FFinal-Action%%7CFinalAction", baseV3),
|
|
shouldFail: false,
|
|
description: "Valid comma-separated tags should pass",
|
|
expectedTypeFilter: fhir.TypeFilterSubquery{
|
|
ResourceType: "ExplanationOfBenefit",
|
|
QueryParameters: []fhir.TypeFilterSubqueryParam{
|
|
{
|
|
Name: "_tag",
|
|
Value: constants.BFDSystemTypeURL + "|SharedSystem," + constants.BFDFinalActionURL + "|FinalAction",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "validOutcomeComma",
|
|
url: fmt.Sprintf("%s_typeFilter=ExplanationOfBenefit%%3Foutcome%%3Dpartial,complete", baseV3),
|
|
shouldFail: false,
|
|
description: "Valid comma-separated outcome should pass",
|
|
expectedTypeFilter: fhir.TypeFilterSubquery{
|
|
ResourceType: "ExplanationOfBenefit",
|
|
QueryParameters: []fhir.TypeFilterSubqueryParam{
|
|
{
|
|
Name: "outcome",
|
|
Value: "partial,complete",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "invalidOutcome",
|
|
url: fmt.Sprintf("%s_typeFilter=ExplanationOfBenefit%%3Foutcome%%3Dinvalid_status", baseV3),
|
|
shouldFail: true,
|
|
expectedErr: fhir.ParameterValidationError{},
|
|
description: "Outcome must be complete or partial",
|
|
},
|
|
{
|
|
name: "validTagDDPS",
|
|
url: fmt.Sprintf("%s_typeFilter=ExplanationOfBenefit%%3F_tag%%3Dhttps%%3A%%2F%%2Fbluebutton.cms.gov%%2Ffhir%%2FCodeSystem%%2FSystem-Type%%7CDDPS", baseV3),
|
|
shouldFail: false,
|
|
description: "Valid DDPS tag should pass",
|
|
expectedTypeFilter: fhir.TypeFilterSubquery{
|
|
ResourceType: "ExplanationOfBenefit",
|
|
QueryParameters: []fhir.TypeFilterSubqueryParam{
|
|
{
|
|
Name: "_tag",
|
|
Value: constants.BFDSystemTypeURL + "|DDPS",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "validTagFinalAction",
|
|
url: fmt.Sprintf("%s_typeFilter=ExplanationOfBenefit%%3F_tag%%3Dhttps%%3A%%2F%%2Fbluebutton.cms.gov%%2Ffhir%%2FCodeSystem%%2FFinal-Action%%7CFinalAction", baseV3),
|
|
shouldFail: false,
|
|
description: "Valid FinalAction tag should pass",
|
|
expectedTypeFilter: fhir.TypeFilterSubquery{
|
|
ResourceType: "ExplanationOfBenefit",
|
|
QueryParameters: []fhir.TypeFilterSubqueryParam{
|
|
{
|
|
Name: "_tag",
|
|
Value: constants.BFDFinalActionURL + "|FinalAction",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "validTagNotFinalAction",
|
|
url: fmt.Sprintf("%s_typeFilter=ExplanationOfBenefit%%3F_tag%%3Dhttps%%3A%%2F%%2Fbluebutton.cms.gov%%2Ffhir%%2FCodeSystem%%2FFinal-Action%%7CNotFinalAction", baseV3),
|
|
shouldFail: false,
|
|
description: "Valid NotFinalAction tag should pass",
|
|
expectedTypeFilter: fhir.TypeFilterSubquery{
|
|
ResourceType: "ExplanationOfBenefit",
|
|
QueryParameters: []fhir.TypeFilterSubqueryParam{
|
|
{
|
|
Name: "_tag",
|
|
Value: constants.BFDFinalActionURL + "|NotFinalAction",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "invalidTagPartiallyAdjudicated",
|
|
url: fmt.Sprintf("%s_typeFilter=ExplanationOfBenefit%%3F_tag%%3DPartiallyAdjudicated", baseV3),
|
|
shouldFail: true,
|
|
expectedErr: fhir.ParameterValidationError{},
|
|
description: "Old PartiallyAdjudicated tag should be rejected",
|
|
},
|
|
{
|
|
name: "invalidTagSharedSystem",
|
|
url: fmt.Sprintf("%s_typeFilter=ExplanationOfBenefit%%3F_tag%%3DSharedSystem", baseV3),
|
|
shouldFail: true,
|
|
expectedErr: fhir.ParameterValidationError{},
|
|
description: "Only code, no system should be rejected. even with valid code",
|
|
},
|
|
{
|
|
name: "invalidTagRandomValue",
|
|
url: fmt.Sprintf("%s_typeFilter=ExplanationOfBenefit%%3F_tag%%3DInvalidTag", baseV3),
|
|
shouldFail: true,
|
|
expectedErr: fhir.ParameterValidationError{},
|
|
description: "Random invalid tag should be rejected",
|
|
},
|
|
{
|
|
name: "multipleValidTags",
|
|
url: fmt.Sprintf("%s_typeFilter=ExplanationOfBenefit%%3F_tag%%3Dhttps%%3A%%2F%%2Fbluebutton.cms.gov%%2Ffhir%%2FCodeSystem%%2FFinal-Action%%7CNotFinalAction%%26_tag%%3Dhttps%%3A%%2F%%2Fbluebutton.cms.gov%%2Ffhir%%2FCodeSystem%%2FSystem-Type%%7CSharedSystem", baseV3),
|
|
shouldFail: false,
|
|
description: "Multiple valid tags should pass",
|
|
expectedTypeFilter: fhir.TypeFilterSubquery{
|
|
ResourceType: "ExplanationOfBenefit",
|
|
QueryParameters: []fhir.TypeFilterSubqueryParam{
|
|
{
|
|
Name: "_tag",
|
|
Value: constants.BFDFinalActionURL + "|NotFinalAction",
|
|
},
|
|
{
|
|
Name: "_tag",
|
|
Value: constants.BFDSystemTypeURL + "|SharedSystem",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "validTypeFilterServiceDateAndTag",
|
|
url: fmt.Sprintf("%s_typeFilter=ExplanationOfBenefit%%3Fservice-date%%3Dlt2021-02-15%%26_tag%%3Dhttps%%3A%%2F%%2Fbluebutton.cms.gov%%2Ffhir%%2FCodeSystem%%2FFinal-Action%%7CFinalAction", baseV3),
|
|
shouldFail: false,
|
|
description: "Subquery with service-date and _tag (FinalAction) should pass",
|
|
expectedTypeFilter: fhir.TypeFilterSubquery{
|
|
ResourceType: "ExplanationOfBenefit",
|
|
QueryParameters: []fhir.TypeFilterSubqueryParam{
|
|
{
|
|
Name: "service-date",
|
|
Value: "lt2021-02-15",
|
|
},
|
|
{
|
|
Name: "_tag",
|
|
Value: constants.BFDFinalActionURL + "|FinalAction",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "validTypeFilterServiceDateAndTagAlternate",
|
|
url: fmt.Sprintf("%s_typeFilter=ExplanationOfBenefit%%3Fservice-date%%3Dgt2001-04-01%%26_tag%%3Dhttps%%3A%%2F%%2Fbluebutton.cms.gov%%2Ffhir%%2FCodeSystem%%2FSystem-Type%%7CNationalClaimsHistory", baseV3),
|
|
shouldFail: false,
|
|
description: "Subquery with service-date and _tag (NationalClaimsHistory) should pass",
|
|
expectedTypeFilter: fhir.TypeFilterSubquery{
|
|
ResourceType: "ExplanationOfBenefit",
|
|
QueryParameters: []fhir.TypeFilterSubqueryParam{
|
|
{
|
|
Name: "service-date",
|
|
Value: "gt2001-04-01",
|
|
},
|
|
{
|
|
Name: "_tag",
|
|
Value: constants.BFDSystemTypeURL + "|NationalClaimsHistory",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "multipleTagsOneInvalid",
|
|
url: fmt.Sprintf("%s_typeFilter=ExplanationOfBenefit%%3F_tag%%3Dhttps%%3A%%2F%%2Fbluebutton.cms.gov%%2Ffhir%%2FCodeSystem%%2FFinal-Action%%7CNotFinalAction%%26_tag%%3DPartiallyAdjudicated", baseV3),
|
|
shouldFail: true,
|
|
expectedErr: fhir.ParameterValidationError{},
|
|
description: "Multiple tags with one invalid should fail",
|
|
},
|
|
{
|
|
name: "v2ShouldIgnoreTypeFilter",
|
|
url: fmt.Sprintf("/api/v2/Patient/$export?_typeFilter=ExplanationOfBenefit%%3F_tag%%3DPartiallyAdjudicated"),
|
|
shouldFail: false,
|
|
description: "v2 should ignore _typeFilter validation (old behavior preserved)",
|
|
},
|
|
{
|
|
name: "v1ShouldIgnoreTypeFilter",
|
|
url: fmt.Sprintf("/api/v1/Patient/$export?_typeFilter=ExplanationOfBenefit%%3F_tag%%3DPartiallyAdjudicated"),
|
|
shouldFail: false,
|
|
description: "v1 should ignore _typeFilter validation (old behavior preserved)",
|
|
},
|
|
|
|
{
|
|
name: "multipleTypeFilterSubqueries",
|
|
url: fmt.Sprintf("%s_type=ExplanationOfBenefit&_typeFilter=ExplanationOfBenefit%%3Fservice-date%%3Dlt2021-02-15%%26_tag%%3Dhttps%%3A%%2F%%2Fbluebutton.cms.gov%%2Ffhir%%2FCodeSystem%%2FFinal-Action%%7CFinalAction&_typeFilter=ExplanationOfBenefit%%3F_tag%%3Dhttps%%3A%%2F%%2Fbluebutton.cms.gov%%2Ffhir%%2FCodeSystem%%2FFinal-Action%%7CNotFinalAction", baseV3),
|
|
shouldFail: true,
|
|
expectedErr: fhir.ParameterValidationError{},
|
|
description: "Multiple _typeFilter params. Bulk IG supports this but we do not",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
req, err := http.NewRequest("GET", tt.url, nil)
|
|
assert.NoError(t, err)
|
|
req = req.WithContext(ctx)
|
|
req.Header.Set("Accept", "application/fhir+json")
|
|
req.Header.Set("Prefer", constants.TestRespondAsync)
|
|
|
|
var capturedCtx context.Context
|
|
handler := http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
|
capturedCtx = r.Context()
|
|
rw.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
rr := httptest.NewRecorder()
|
|
ValidateRequestURL(handler).ServeHTTP(rr, req)
|
|
|
|
if tt.shouldFail {
|
|
assert.Equal(t, http.StatusBadRequest, rr.Code, tt.description)
|
|
assert.Contains(t, rr.Body.String(), tt.expectedErr.Error(), tt.description)
|
|
} else {
|
|
assert.Equal(t, http.StatusOK, rr.Code, tt.description)
|
|
if tt.expectedTypeFilter.QueryParameters != nil {
|
|
rp, ok := GetRequestParamsFromCtx(capturedCtx)
|
|
assert.True(t, ok, "request params should be in context")
|
|
assert.Equal(t, tt.expectedTypeFilter, rp.TypeFilter, "parsed _typeFilter params should match request")
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetRespWriter(t *testing.T) {
|
|
ctx := context.Background()
|
|
tests := []struct {
|
|
name string
|
|
path string
|
|
}{
|
|
{"v1", constants.V1Path},
|
|
{"v2", constants.V2Path},
|
|
{"v3", constants.V3Path},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
rr := httptest.NewRecorder()
|
|
rw := auth.GetRespWriter(tt.path)
|
|
rw.OpOutcome(ctx, rr, http.StatusUnauthorized, responseutils.TokenErr, responseutils.TokenErr)
|
|
resp := rr.Body.String()
|
|
switch tt.name {
|
|
case "v1":
|
|
assert.NotContains(t, resp, "coding")
|
|
case "v2":
|
|
assert.Contains(t, resp, "coding")
|
|
case "v3":
|
|
assert.NotContains(t, resp, "coding")
|
|
}
|
|
})
|
|
}
|
|
}
|