diff --git a/Makefile b/Makefile index 1d76d21c..e91005df 100644 --- a/Makefile +++ b/Makefile @@ -17,7 +17,7 @@ down: ## Stop application docker compose down test: ## Unit tests - go test ./lambda/get/... ./lambda/create/... ./lambda/update/... ./internal/shared/... -race -covermode=atomic -coverprofile=coverage.out + go test ./... -race -covermode=atomic -coverprofile=coverage.out test-api: URL ?= http://localhost:9000 test-api: diff --git a/docs/openapi/openapi.yaml b/docs/openapi/openapi.yaml index 3665a7d1..3fba6bea 100644 --- a/docs/openapi/openapi.yaml +++ b/docs/openapi/openapi.yaml @@ -119,7 +119,6 @@ paths: httpMethod: "POST" type: "aws_proxy" contentHandling: "CONVERT_TO_TEXT" - /health-check: get: operationId: healthCheck @@ -445,9 +444,7 @@ components: properties: type: enum: - - DONOR_ADDRESS_UPDATE - - ATTORNEY_ADDRESS_UPDATE - - SCANNING_CORRECTION + - CERTIFICATE_PROVIDER_SIGN changes: type: array items: diff --git a/go.mod b/go.mod index b7a49784..d0902eda 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,6 @@ require ( github.com/aws/aws-lambda-go v1.43.0 github.com/aws/aws-sdk-go v1.49.13 github.com/aws/aws-xray-sdk-go v1.8.3 - github.com/go-openapi/jsonpointer v0.20.2 github.com/golang-jwt/jwt/v5 v5.2.0 github.com/google/uuid v1.5.0 github.com/ministryofjustice/opg-go-common v0.0.0-20231128145056-24628fba649c @@ -16,15 +15,12 @@ require ( require ( github.com/andybalholm/brotli v1.0.6 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/go-openapi/swag v0.22.5 // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect - github.com/josharian/intern v1.0.0 // indirect github.com/klauspost/compress v1.17.2 // indirect - github.com/mailru/easyjson v0.7.7 // indirect + github.com/kr/pretty v0.3.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/rogpeppe/go-internal v1.9.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.50.0 // indirect golang.org/x/net v0.18.0 // indirect @@ -33,5 +29,6 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17 // indirect google.golang.org/grpc v1.59.0 // indirect google.golang.org/protobuf v1.31.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 7bdca8db..90905d11 100644 --- a/go.sum +++ b/go.sum @@ -1,37 +1,16 @@ github.com/DATA-DOG/go-sqlmock v1.4.1 h1:ThlnYciV1iM/V0OSF/dtkqWb6xo5qITT1TJBG1MRDJM= github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= -github.com/aws/aws-lambda-go v1.41.0 h1:l/5fyVb6Ud9uYd411xdHZzSf2n86TakxzpvIoz7l+3Y= -github.com/aws/aws-lambda-go v1.41.0/go.mod h1:jwFe2KmMsHmffA1X2R09hH6lFzJQxzI8qK17ewzbQMM= -github.com/aws/aws-lambda-go v1.42.0 h1:U4QKkxLp/il15RJGAANxiT9VumQzimsUER7gokqA0+c= -github.com/aws/aws-lambda-go v1.42.0/go.mod h1:dpMpZgvWx5vuQJfBt0zqBha60q7Dd7RfgJv23DymV8A= github.com/aws/aws-lambda-go v1.43.0 h1:Tdu7SnMB5bD+CbdnSq1Dg4sM68vEuGIDcQFZ+IjUfx0= github.com/aws/aws-lambda-go v1.43.0/go.mod h1:dpMpZgvWx5vuQJfBt0zqBha60q7Dd7RfgJv23DymV8A= -github.com/aws/aws-sdk-go v1.48.11 h1:9YbiSbaF/jWi+qLRl+J5dEhr2mcbDYHmKg2V7RBcD5M= -github.com/aws/aws-sdk-go v1.48.11/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= -github.com/aws/aws-sdk-go v1.48.16 h1:mcj2/9J/MJ55Dov+ocMevhR8Jv6jW/fAxbrn4a1JFc8= -github.com/aws/aws-sdk-go v1.48.16/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= -github.com/aws/aws-sdk-go v1.49.0 h1:g9BkW1fo9GqKfwg2+zCD+TW/D36Ux+vtfJ8guF4AYmY= -github.com/aws/aws-sdk-go v1.49.0/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= -github.com/aws/aws-sdk-go v1.49.4 h1:qiXsqEeLLhdLgUIyfr5ot+N/dGPWALmtM1SetRmbUlY= -github.com/aws/aws-sdk-go v1.49.4/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= -github.com/aws/aws-sdk-go v1.49.9 h1:4xoyi707rsifB1yMsd5vGbAH21aBzwpL3gNRMSmjIyc= -github.com/aws/aws-sdk-go v1.49.9/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= github.com/aws/aws-sdk-go v1.49.13 h1:f4mGztsgnx2dR9r8FQYa9YW/RsKb+N7bgef4UGrOW1Y= github.com/aws/aws-sdk-go v1.49.13/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= github.com/aws/aws-xray-sdk-go v1.8.3 h1:S8GdgVncBRhzbNnNUgTPwhEqhwt2alES/9rLASyhxjU= github.com/aws/aws-xray-sdk-go v1.8.3/go.mod h1:tv8uLMOSCABolrIF8YCcp3ghyswArsan8dfLCA1ZATk= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-openapi/jsonpointer v0.20.0 h1:ESKJdU9ASRfaPNOPRx12IUyA1vn3R9GiE3KYD14BXdQ= -github.com/go-openapi/jsonpointer v0.20.0/go.mod h1:6PGzBjjIIumbLYysB73Klnms1mwnU4G3YHOECG3CedA= -github.com/go-openapi/jsonpointer v0.20.2 h1:mQc3nmndL8ZBzStEo3JYF8wzmeWffDH4VbXz58sAx6Q= -github.com/go-openapi/jsonpointer v0.20.2/go.mod h1:bHen+N0u1KEO3YlmqOjTT9Adn1RfD91Ar825/PuiRVs= -github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU= -github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= -github.com/go-openapi/swag v0.22.5 h1:fVS63IE3M0lsuWRzuom3RLwUMVI2peDH01s6M70ugys= -github.com/go-openapi/swag v0.22.5/go.mod h1:Gl91UqO+btAM0plGGxHqJcQZ1ZTy6jbmridBTsDy8A0= github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= @@ -39,10 +18,6 @@ github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= -github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= -github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 h1:+9834+KizmvFV7pXQGSXQTsaWhq2GjuNUt0aUU0YBYw= @@ -50,16 +25,18 @@ github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9Y github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/klauspost/compress v1.17.2 h1:RlWWUY/Dr4fL8qk9YG7DTZ7PDgME2V4csBXA8L/ixi4= github.com/klauspost/compress v1.17.2/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/ministryofjustice/opg-go-common v0.0.0-20231128145056-24628fba649c h1:598i3upKVEHRLW+eSkGmCaV7+yIaFTV6lMiHOC3tXDY= github.com/ministryofjustice/opg-go-common v0.0.0-20231128145056-24628fba649c/go.mod h1:qktwZb46YkojkLVHU2QNnVK6yVktXkNpBuJ+TyobvuY= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -90,6 +67,7 @@ google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/ddb/client.go b/internal/ddb/client.go new file mode 100644 index 00000000..0f539291 --- /dev/null +++ b/internal/ddb/client.go @@ -0,0 +1,69 @@ +package ddb + +import ( + "context" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/dynamodb" + "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute" + "github.com/aws/aws-xray-sdk-go/xray" + "github.com/ministryofjustice/opg-data-lpa-store/internal/shared" +) + +type Client struct { + ddb *dynamodb.DynamoDB + tableName string +} + +func (c *Client) Put(ctx context.Context, data any) error { + item, err := dynamodbattribute.MarshalMap(data) + if err != nil { + return err + } + + _, err = c.ddb.PutItemWithContext(ctx, &dynamodb.PutItemInput{ + TableName: aws.String(c.tableName), + Item: item, + }) + + return err +} + +func (c *Client) Get(ctx context.Context, uid string) (shared.Lpa, error) { + lpa := shared.Lpa{} + + marshalledUid, err := dynamodbattribute.Marshal(uid) + if err != nil { + return lpa, err + } + + getItemOutput, err := c.ddb.GetItemWithContext(ctx, &dynamodb.GetItemInput{ + TableName: aws.String(c.tableName), + Key: map[string]*dynamodb.AttributeValue{ + "uid": marshalledUid, + }, + }) + + if err != nil { + return lpa, err + } + + err = dynamodbattribute.UnmarshalMap(getItemOutput.Item, &lpa) + + return lpa, err +} + +func New(endpoint, tableName string) *Client { + sess := session.Must(session.NewSession()) + sess.Config.Endpoint = &endpoint + + c := &Client{ + ddb: dynamodb.New(sess), + tableName: tableName, + } + + xray.AWS(c.ddb.Client) + + return c +} diff --git a/internal/shared/client.go b/internal/shared/client.go deleted file mode 100644 index abff0de6..00000000 --- a/internal/shared/client.go +++ /dev/null @@ -1,10 +0,0 @@ -package shared - -import ( - "context" -) - -type Client interface { - Put(ctx context.Context, data Lpa) error - Get(ctx context.Context, uid string) (Lpa, error) -} diff --git a/internal/shared/ddb.go b/internal/shared/ddb.go index 908beb39..a29b5e40 100644 --- a/internal/shared/ddb.go +++ b/internal/shared/ddb.go @@ -1,71 +1 @@ package shared - -import ( - "context" - "os" - - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/aws/session" - "github.com/aws/aws-sdk-go/service/dynamodb" - "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute" - "github.com/aws/aws-xray-sdk-go/xray" -) - -type DynamoDBClient struct { - ddb *dynamodb.DynamoDB - tableName string -} - -func (c DynamoDBClient) Put(ctx context.Context, data Lpa) error { - item, err := dynamodbattribute.MarshalMap(data) - if err != nil { - return err - } - - _, err = c.ddb.PutItemWithContext(ctx, &dynamodb.PutItemInput{ - TableName: aws.String(c.tableName), - Item: item, - }) - - return err -} - -func (c DynamoDBClient) Get(ctx context.Context, uid string) (Lpa, error) { - lpa := Lpa{} - - marshalledUid, err := dynamodbattribute.Marshal(uid) - if err != nil { - return lpa, err - } - - getItemOutput, err := c.ddb.GetItemWithContext(ctx, &dynamodb.GetItemInput{ - TableName: aws.String(c.tableName), - Key: map[string]*dynamodb.AttributeValue{ - "uid": marshalledUid, - }, - }) - - if err != nil { - return lpa, err - } - - err = dynamodbattribute.UnmarshalMap(getItemOutput.Item, &lpa) - - return lpa, err -} - -func NewDynamoDB(tableName string) DynamoDBClient { - sess := session.Must(session.NewSession()) - - endpoint := os.Getenv("AWS_DYNAMODB_ENDPOINT") - sess.Config.Endpoint = &endpoint - - c := DynamoDBClient{ - ddb: dynamodb.New(sess), - tableName: tableName, - } - - xray.AWS(c.ddb.Client) - - return c -} diff --git a/internal/shared/jwt.go b/internal/shared/jwt.go index f201512c..38d28d85 100644 --- a/internal/shared/jwt.go +++ b/internal/shared/jwt.go @@ -88,25 +88,6 @@ func NewJWTVerifier() JWTVerifier { } } -// tokenStr is the JWT token, minus any "Bearer: " prefix -func (v JWTVerifier) VerifyToken(tokenStr string) error { - lsc := lpaStoreClaims{} - - parsedToken, err := jwt.ParseWithClaims(tokenStr, &lsc, func(token *jwt.Token) (interface{}, error) { - return v.secretKey, nil - }) - - if err != nil { - return err - } - - if !parsedToken.Valid { - return fmt.Errorf("Invalid JWT") - } - - return nil -} - var bearerRegexp = regexp.MustCompile("^Bearer[ ]+") // verify JWT from event header @@ -119,9 +100,28 @@ func (v JWTVerifier) VerifyHeader(event events.APIGatewayProxyRequest) bool { } tokenStr := bearerRegexp.ReplaceAllString(jwtHeaders[0], "") - if v.VerifyToken(tokenStr) != nil { + if v.verifyToken(tokenStr) != nil { return false } return true } + +// tokenStr is the JWT token, minus any "Bearer: " prefix +func (v JWTVerifier) verifyToken(tokenStr string) error { + lsc := lpaStoreClaims{} + + parsedToken, err := jwt.ParseWithClaims(tokenStr, &lsc, func(token *jwt.Token) (interface{}, error) { + return v.secretKey, nil + }) + + if err != nil { + return err + } + + if !parsedToken.Valid { + return fmt.Errorf("Invalid JWT") + } + + return nil +} diff --git a/internal/shared/jwt_test.go b/internal/shared/jwt_test.go index b92e3122..8077da3d 100644 --- a/internal/shared/jwt_test.go +++ b/internal/shared/jwt_test.go @@ -26,7 +26,7 @@ func createToken(claims jwt.MapClaims) string { } func TestVerifyEmptyJwt(t *testing.T) { - err := verifier.VerifyToken("") + err := verifier.verifyToken("") assert.NotNil(t, err) } @@ -38,7 +38,7 @@ func TestVerifyExpInPast(t *testing.T) { "sub": "M-3467-89QW-ERTY", }) - err := verifier.VerifyToken(token) + err := verifier.verifyToken(token) assert.NotNil(t, err) if err != nil { @@ -54,7 +54,7 @@ func TestVerifyIatInFuture(t *testing.T) { "sub": "someone@someplace.somewhere.com", }) - err := verifier.VerifyToken(token) + err := verifier.verifyToken(token) assert.NotNil(t, err) if err != nil { @@ -70,7 +70,7 @@ func TestVerifyIssuer(t *testing.T) { "sub": "someone@someplace.somewhere.com", }) - err := verifier.VerifyToken(token) + err := verifier.verifyToken(token) assert.NotNil(t, err) if err != nil { @@ -86,7 +86,7 @@ func TestVerifyBadEmailForSiriusIssuer(t *testing.T) { "sub": "", }) - err := verifier.VerifyToken(token) + err := verifier.verifyToken(token) assert.NotNil(t, err) if err != nil { @@ -102,7 +102,7 @@ func TestVerifyBadUIDForMRLPAIssuer(t *testing.T) { "sub": "", }) - err := verifier.VerifyToken(token) + err := verifier.verifyToken(token) assert.NotNil(t, err) if err != nil { @@ -118,7 +118,7 @@ func TestVerifyGoodJwt(t *testing.T) { "sub": "someone@someplace.somewhere.com", }) - err := verifier.VerifyToken(token) + err := verifier.verifyToken(token) assert.Nil(t, err) } @@ -134,7 +134,7 @@ func TestNewJWTVerifier(t *testing.T) { newVerifier := NewJWTVerifier() os.Unsetenv("JWT_SECRET_KEY") - err := newVerifier.VerifyToken(token) + err := newVerifier.verifyToken(token) assert.Nil(t, err) } diff --git a/internal/shared/lang.go b/internal/shared/lang.go new file mode 100644 index 00000000..2fed3758 --- /dev/null +++ b/internal/shared/lang.go @@ -0,0 +1,12 @@ +package shared + +type Lang string + +var ( + LangCy = Lang("cy") + LangEn = Lang("en") +) + +func (l Lang) IsValid() bool { + return l == LangCy || l == LangEn +} diff --git a/internal/shared/person.go b/internal/shared/person.go index febd607c..4a809cbd 100644 --- a/internal/shared/person.go +++ b/internal/shared/person.go @@ -1,5 +1,7 @@ package shared +import "time" + type Address struct { Line1 string `json:"line1"` Line2 string `json:"line2"` @@ -9,6 +11,10 @@ type Address struct { Country string `json:"country"` } +func (a Address) IsSet() bool { + return a.Line1 != "" || a.Line2 != "" || a.Line3 != "" || a.Town != "" || a.Postcode != "" || a.Country != "" +} + type Person struct { FirstNames string `json:"firstNames"` LastName string `json:"lastName"` @@ -24,8 +30,10 @@ type Donor struct { type CertificateProvider struct { Person - Email string `json:"email"` - Channel Channel `json:"channel"` + Email string `json:"email"` + Channel Channel `json:"channel"` + SignedAt time.Time `json:"signedAt,omitempty"` + ContactLanguagePreference Lang `json:"contactLanguagePreference,omitempty"` } type Channel string diff --git a/internal/shared/update.go b/internal/shared/update.go index b1e4727f..536028bf 100644 --- a/internal/shared/update.go +++ b/internal/shared/update.go @@ -1,9 +1,11 @@ package shared +import "encoding/json" + type Change struct { - Key string `json:"key"` - Old interface{} `json:"old"` - New interface{} `json:"new"` + Key string `json:"key"` + Old json.RawMessage `json:"old"` + New json.RawMessage `json:"new"` } type Update struct { diff --git a/internal/validate/validate.go b/internal/validate/validate.go new file mode 100644 index 00000000..8882f7c6 --- /dev/null +++ b/internal/validate/validate.go @@ -0,0 +1,93 @@ +package validate + +import ( + "fmt" + "regexp" + "time" + + "github.com/ministryofjustice/opg-data-lpa-store/internal/shared" +) + +var countryCodeRe = regexp.MustCompile("^[A-Z]{2}$") + +func All(fieldErrors ...[]shared.FieldError) []shared.FieldError { + var errors []shared.FieldError + + for _, e := range fieldErrors { + if e != nil { + errors = append(errors, e...) + } + } + + return errors +} + +func IfElse(ok bool, eIf []shared.FieldError, eElse []shared.FieldError) []shared.FieldError { + if ok { + return eIf + } + + return eElse +} + +func If(ok bool, e []shared.FieldError) []shared.FieldError { + return IfElse(ok, e, nil) +} + +func Required(source string, value string) []shared.FieldError { + return If(value == "", []shared.FieldError{{Source: source, Detail: "field is required"}}) +} + +func Empty(source string, value string) []shared.FieldError { + return If(value != "", []shared.FieldError{{Source: source, Detail: "field must not be provided"}}) +} + +func Date(source string, date shared.Date) []shared.FieldError { + if date.IsMalformed { + return []shared.FieldError{{Source: source, Detail: "invalid format"}} + } + + if date.IsZero() { + return []shared.FieldError{{Source: source, Detail: "field is required"}} + } + + return nil +} + +func Time(source string, t time.Time) []shared.FieldError { + return If(t.IsZero(), []shared.FieldError{{Source: source, Detail: "field is required"}}) +} + +func Address(prefix string, address shared.Address) []shared.FieldError { + return All( + Required(fmt.Sprintf("%s/line1", prefix), address.Line1), + Required(fmt.Sprintf("%s/town", prefix), address.Town), + Required(fmt.Sprintf("%s/country", prefix), address.Country), + Country(fmt.Sprintf("%s/country", prefix), address.Country), + ) +} + +func Country(source string, country string) []shared.FieldError { + return If(!countryCodeRe.MatchString(country), []shared.FieldError{{Source: source, Detail: "must be a valid ISO-3166-1 country code"}}) +} + +type isValid interface { + ~string + IsValid() bool +} + +func IsValid[V isValid](source string, v V) []shared.FieldError { + if e := Required(source, string(v)); e != nil { + return e + } + + if !v.IsValid() { + return []shared.FieldError{{Source: source, Detail: "invalid value"}} + } + + return nil +} + +func Unset(source string, v interface{ Unset() bool }) []shared.FieldError { + return If(!v.Unset(), []shared.FieldError{{Source: source, Detail: "field must not be provided"}}) +} diff --git a/internal/validate/validate_test.go b/internal/validate/validate_test.go new file mode 100644 index 00000000..a74e0289 --- /dev/null +++ b/internal/validate/validate_test.go @@ -0,0 +1,112 @@ +package validate + +import ( + "testing" + "time" + + "github.com/ministryofjustice/opg-data-lpa-store/internal/shared" + "github.com/stretchr/testify/assert" +) + +var validAddress = shared.Address{ + Line1: "123 Main St", + Town: "Homeland", + Country: "GB", +} + +func newDate(date string, isMalformed bool) shared.Date { + t, _ := time.Parse("2006-01-02", date) + + return shared.Date{ + Time: t, + IsMalformed: isMalformed, + } +} + +func TestAll(t *testing.T) { + errA := shared.FieldError{Source: "a", Detail: "a"} + errB := shared.FieldError{Source: "b", Detail: "b"} + errC := shared.FieldError{Source: "c", Detail: "c"} + + assert.Nil(t, All()) + assert.Nil(t, All([]shared.FieldError{}, []shared.FieldError{})) + assert.Equal(t, []shared.FieldError{errA, errB, errC}, All([]shared.FieldError{errA, errB}, []shared.FieldError{errC})) + assert.Equal(t, []shared.FieldError{errA, errB, errC}, All([]shared.FieldError{errA}, []shared.FieldError{errB, errC})) + assert.Equal(t, []shared.FieldError{errA, errB, errC}, All([]shared.FieldError{errA}, []shared.FieldError{errB}, []shared.FieldError{errC})) +} + +func TestIf(t *testing.T) { + errs := []shared.FieldError{{Source: "a", Detail: "a"}} + + assert.Equal(t, errs, If(true, errs)) + assert.Nil(t, If(false, errs)) +} + +func TestIfElse(t *testing.T) { + errsA := []shared.FieldError{{Source: "a", Detail: "a"}} + errsB := []shared.FieldError{{Source: "b", Detail: "b"}} + + assert.Equal(t, errsA, IfElse(true, errsA, errsB)) + assert.Equal(t, errsB, IfElse(false, errsA, errsB)) +} + +func TestRequired(t *testing.T) { + assert.Nil(t, Required("a", "a")) + assert.Equal(t, []shared.FieldError{{Source: "a", Detail: "field is required"}}, Required("a", "")) +} + +func TestEmpty(t *testing.T) { + assert.Nil(t, Empty("a", "")) + assert.Equal(t, []shared.FieldError{{Source: "a", Detail: "field must not be provided"}}, Empty("a", "a")) +} + +func TestDate(t *testing.T) { + assert.Nil(t, Date("a", shared.Date{Time: time.Now()})) + assert.Equal(t, []shared.FieldError{{Source: "a", Detail: "invalid format"}}, Date("a", shared.Date{IsMalformed: true})) + assert.Equal(t, []shared.FieldError{{Source: "a", Detail: "field is required"}}, Date("a", shared.Date{})) +} + +func TestAddressEmpty(t *testing.T) { + address := shared.Address{} + errors := Address("/test", address) + + assert.Contains(t, errors, shared.FieldError{Source: "/test/line1", Detail: "field is required"}) + assert.Contains(t, errors, shared.FieldError{Source: "/test/town", Detail: "field is required"}) + assert.Contains(t, errors, shared.FieldError{Source: "/test/country", Detail: "field is required"}) +} + +func TestAddressValid(t *testing.T) { + errors := Address("/test", validAddress) + + assert.Empty(t, errors) +} + +func TestAddressInvalidCountry(t *testing.T) { + invalidAddress := shared.Address{ + Line1: "123 Main St", + Town: "Homeland", + Country: "United Kingdom", + } + errors := Address("/test", invalidAddress) + + assert.Contains(t, errors, shared.FieldError{Source: "/test/country", Detail: "must be a valid ISO-3166-1 country code"}) +} + +type testIsValid string + +func (t testIsValid) IsValid() bool { return string(t) == "ok" } + +func TestIsValid(t *testing.T) { + assert.Nil(t, IsValid("a", testIsValid("ok"))) + assert.Equal(t, []shared.FieldError{{Source: "a", Detail: "field is required"}}, IsValid("a", testIsValid(""))) + assert.Equal(t, []shared.FieldError{{Source: "a", Detail: "invalid value"}}, IsValid("a", testIsValid("x"))) +} + +type testUnset bool + +func (t testUnset) Unset() bool { return bool(t) } + +func TestUnset(t *testing.T) { + assert.Nil(t, Unset("a", testUnset(true))) + assert.Equal(t, []shared.FieldError{{Source: "a", Detail: "field must not be provided"}}, Unset("a", testUnset(false))) +} diff --git a/lambda/Dockerfile b/lambda/Dockerfile index e84e3bc3..acbee20d 100644 --- a/lambda/Dockerfile +++ b/lambda/Dockerfile @@ -6,7 +6,7 @@ COPY ./go.sum /app/go.sum RUN go mod download -COPY ./internal/shared /app/internal/shared +COPY ./internal /app/internal ARG DIR COPY ./lambda/$DIR /app/lambda/$DIR diff --git a/lambda/create/main.go b/lambda/create/main.go index 197a4ac6..ceb66965 100644 --- a/lambda/create/main.go +++ b/lambda/create/main.go @@ -8,19 +8,22 @@ import ( "github.com/aws/aws-lambda-go/events" "github.com/aws/aws-lambda-go/lambda" + "github.com/ministryofjustice/opg-data-lpa-store/internal/ddb" "github.com/ministryofjustice/opg-data-lpa-store/internal/shared" "github.com/ministryofjustice/opg-go-common/logging" ) -type Response struct { -} - type Logger interface { Print(...interface{}) } +type Store interface { + Put(ctx context.Context, data any) error + Get(ctx context.Context, uid string) (shared.Lpa, error) +} + type Lambda struct { - store shared.Client + store Store verifier shared.JWTVerifier logger Logger } @@ -84,22 +87,15 @@ func (l *Lambda) HandleEvent(ctx context.Context, event events.APIGatewayProxyRe } // respond - body, err := json.Marshal(Response{}) - - if err != nil { - l.logger.Print(err) - return shared.ProblemInternalServerError.Respond() - } - response.StatusCode = 201 - response.Body = string(body) + response.Body = `{}` return response, nil } func main() { l := &Lambda{ - store: shared.NewDynamoDB(os.Getenv("DDB_TABLE_NAME_DEEDS")), + store: ddb.New(os.Getenv("AWS_DYNAMODB_ENDPOINT"), os.Getenv("DDB_TABLE_NAME_DEEDS")), verifier: shared.NewJWTVerifier(), logger: logging.New(os.Stdout, "opg-data-lpa-store"), } diff --git a/lambda/create/validate.go b/lambda/create/validate.go index 29ab8215..74707a24 100644 --- a/lambda/create/validate.go +++ b/lambda/create/validate.go @@ -2,56 +2,53 @@ package main import ( "fmt" - "regexp" - "time" "github.com/ministryofjustice/opg-data-lpa-store/internal/shared" + "github.com/ministryofjustice/opg-data-lpa-store/internal/validate" ) -var countryCodeRe = regexp.MustCompile("^[A-Z]{2}$") - func Validate(lpa shared.LpaInit) []shared.FieldError { activeAttorneyCount, replacementAttorneyCount := countAttorneys(lpa.Attorneys, lpa.TrustCorporations) - return flatten( - validateIsValid("/lpaType", lpa.LpaType), - required("/donor/firstNames", lpa.Donor.FirstNames), - required("/donor/lastName", lpa.Donor.LastName), - validateDate("/donor/dateOfBirth", lpa.Donor.DateOfBirth), - validateAddress("/donor/address", lpa.Donor.Address), - required("/certificateProvider/firstNames", lpa.CertificateProvider.FirstNames), - required("/certificateProvider/lastName", lpa.CertificateProvider.LastName), - validateAddress("/certificateProvider/address", lpa.CertificateProvider.Address), - validateIsValid("/certificateProvider/channel", lpa.CertificateProvider.Channel), - validateIfElse(lpa.CertificateProvider.Channel == shared.ChannelOnline, - required("/certificateProvider/email", lpa.CertificateProvider.Email), - empty("/certificateProvider/email", lpa.CertificateProvider.Email)), + return validate.All( + validate.IsValid("/lpaType", lpa.LpaType), + validate.Required("/donor/firstNames", lpa.Donor.FirstNames), + validate.Required("/donor/lastName", lpa.Donor.LastName), + validate.Date("/donor/dateOfBirth", lpa.Donor.DateOfBirth), + validate.Address("/donor/address", lpa.Donor.Address), + validate.Required("/certificateProvider/firstNames", lpa.CertificateProvider.FirstNames), + validate.Required("/certificateProvider/lastName", lpa.CertificateProvider.LastName), + validate.Address("/certificateProvider/address", lpa.CertificateProvider.Address), + validate.IsValid("/certificateProvider/channel", lpa.CertificateProvider.Channel), + validate.IfElse(lpa.CertificateProvider.Channel == shared.ChannelOnline, + validate.Required("/certificateProvider/email", lpa.CertificateProvider.Email), + validate.Empty("/certificateProvider/email", lpa.CertificateProvider.Email)), validateAttorneys("/attorneys", lpa.Attorneys), validateTrustCorporations("/trustCorporations", lpa.TrustCorporations), - validateIfElse(activeAttorneyCount > 1, - validateIsValid("/howAttorneysMakeDecisions", lpa.HowAttorneysMakeDecisions), - validateUnset("/howAttorneysMakeDecisions", lpa.HowAttorneysMakeDecisions)), - validateIfElse(lpa.HowAttorneysMakeDecisions == shared.HowMakeDecisionsJointlyForSomeSeverallyForOthers, - required("/howAttorneysMakeDecisionsDetails", lpa.HowAttorneysMakeDecisionsDetails), - empty("/howAttorneysMakeDecisionsDetails", lpa.HowAttorneysMakeDecisionsDetails)), - validateIf(replacementAttorneyCount > 0 && lpa.HowAttorneysMakeDecisions == shared.HowMakeDecisionsJointlyAndSeverally, - validateIsValid("/howReplacementAttorneysStepIn", lpa.HowReplacementAttorneysStepIn)), - validateIfElse(lpa.HowReplacementAttorneysStepIn == shared.HowStepInAnotherWay, - required("/howReplacementAttorneysStepInDetails", lpa.HowReplacementAttorneysStepInDetails), - empty("/howReplacementAttorneysStepInDetails", lpa.HowReplacementAttorneysStepInDetails)), - validateIfElse(replacementAttorneyCount > 1 && (lpa.HowReplacementAttorneysStepIn == shared.HowStepInAllCanNoLongerAct || lpa.HowAttorneysMakeDecisions != shared.HowMakeDecisionsJointlyAndSeverally), - validateIsValid("/howReplacementAttorneysMakeDecisions", lpa.HowReplacementAttorneysMakeDecisions), - validateUnset("/howReplacementAttorneysMakeDecisions", lpa.HowReplacementAttorneysMakeDecisions)), - validateIfElse(lpa.HowReplacementAttorneysMakeDecisions == shared.HowMakeDecisionsJointlyForSomeSeverallyForOthers, - required("/howReplacementAttorneysMakeDecisionsDetails", lpa.HowReplacementAttorneysMakeDecisionsDetails), - empty("/howReplacementAttorneysMakeDecisionsDetails", lpa.HowReplacementAttorneysMakeDecisionsDetails)), - validateIf(lpa.LpaType == shared.LpaTypePersonalWelfare, flatten( - validateIsValid("/lifeSustainingTreatmentOption", lpa.LifeSustainingTreatmentOption), - validateUnset("/whenTheLpaCanBeUsed", lpa.WhenTheLpaCanBeUsed))), - validateIf(lpa.LpaType == shared.LpaTypePropertyAndAffairs, flatten( - validateIsValid("/whenTheLpaCanBeUsed", lpa.WhenTheLpaCanBeUsed), - validateUnset("/lifeSustainingTreatmentOption", lpa.LifeSustainingTreatmentOption))), - validateTime("/signedAt", lpa.SignedAt), + validate.IfElse(activeAttorneyCount > 1, + validate.IsValid("/howAttorneysMakeDecisions", lpa.HowAttorneysMakeDecisions), + validate.Unset("/howAttorneysMakeDecisions", lpa.HowAttorneysMakeDecisions)), + validate.IfElse(lpa.HowAttorneysMakeDecisions == shared.HowMakeDecisionsJointlyForSomeSeverallyForOthers, + validate.Required("/howAttorneysMakeDecisionsDetails", lpa.HowAttorneysMakeDecisionsDetails), + validate.Empty("/howAttorneysMakeDecisionsDetails", lpa.HowAttorneysMakeDecisionsDetails)), + validate.If(replacementAttorneyCount > 0 && lpa.HowAttorneysMakeDecisions == shared.HowMakeDecisionsJointlyAndSeverally, + validate.IsValid("/howReplacementAttorneysStepIn", lpa.HowReplacementAttorneysStepIn)), + validate.IfElse(lpa.HowReplacementAttorneysStepIn == shared.HowStepInAnotherWay, + validate.Required("/howReplacementAttorneysStepInDetails", lpa.HowReplacementAttorneysStepInDetails), + validate.Empty("/howReplacementAttorneysStepInDetails", lpa.HowReplacementAttorneysStepInDetails)), + validate.IfElse(replacementAttorneyCount > 1 && (lpa.HowReplacementAttorneysStepIn == shared.HowStepInAllCanNoLongerAct || lpa.HowAttorneysMakeDecisions != shared.HowMakeDecisionsJointlyAndSeverally), + validate.IsValid("/howReplacementAttorneysMakeDecisions", lpa.HowReplacementAttorneysMakeDecisions), + validate.Unset("/howReplacementAttorneysMakeDecisions", lpa.HowReplacementAttorneysMakeDecisions)), + validate.IfElse(lpa.HowReplacementAttorneysMakeDecisions == shared.HowMakeDecisionsJointlyForSomeSeverallyForOthers, + validate.Required("/howReplacementAttorneysMakeDecisionsDetails", lpa.HowReplacementAttorneysMakeDecisionsDetails), + validate.Empty("/howReplacementAttorneysMakeDecisionsDetails", lpa.HowReplacementAttorneysMakeDecisionsDetails)), + validate.If(lpa.LpaType == shared.LpaTypePersonalWelfare, validate.All( + validate.IsValid("/lifeSustainingTreatmentOption", lpa.LifeSustainingTreatmentOption), + validate.Unset("/whenTheLpaCanBeUsed", lpa.WhenTheLpaCanBeUsed))), + validate.If(lpa.LpaType == shared.LpaTypePropertyAndAffairs, validate.All( + validate.IsValid("/whenTheLpaCanBeUsed", lpa.WhenTheLpaCanBeUsed), + validate.Unset("/lifeSustainingTreatmentOption", lpa.LifeSustainingTreatmentOption))), + validate.Time("/signedAt", lpa.SignedAt), ) } @@ -77,84 +74,6 @@ func countAttorneys(as []shared.Attorney, ts []shared.TrustCorporation) (actives return actives, replacements } -func flatten(fieldErrors ...[]shared.FieldError) []shared.FieldError { - var errors []shared.FieldError - - for _, e := range fieldErrors { - if e != nil { - errors = append(errors, e...) - } - } - - return errors -} - -func validateIfElse(ok bool, eIf []shared.FieldError, eElse []shared.FieldError) []shared.FieldError { - if ok { - return eIf - } - - return eElse -} - -func validateIf(ok bool, e []shared.FieldError) []shared.FieldError { - return validateIfElse(ok, e, nil) -} - -func required(source string, value string) []shared.FieldError { - return validateIf(value == "", []shared.FieldError{{Source: source, Detail: "field is required"}}) -} - -func empty(source string, value string) []shared.FieldError { - return validateIf(value != "", []shared.FieldError{{Source: source, Detail: "field must not be provided"}}) -} - -func validateDate(source string, date shared.Date) []shared.FieldError { - if date.IsMalformed { - return []shared.FieldError{{Source: source, Detail: "invalid format"}} - } - - if date.IsZero() { - return []shared.FieldError{{Source: source, Detail: "field is required"}} - } - - return nil -} - -func validateTime(source string, t time.Time) []shared.FieldError { - return validateIf(t.IsZero(), []shared.FieldError{{Source: source, Detail: "field is required"}}) -} - -func validateAddress(prefix string, address shared.Address) []shared.FieldError { - return flatten( - required(fmt.Sprintf("%s/line1", prefix), address.Line1), - required(fmt.Sprintf("%s/town", prefix), address.Town), - required(fmt.Sprintf("%s/country", prefix), address.Country), - validateIf(!countryCodeRe.MatchString(address.Country), []shared.FieldError{{Source: fmt.Sprintf("%s/country", prefix), Detail: "must be a valid ISO-3166-1 country code"}}), - ) -} - -type isValid interface { - ~string - IsValid() bool -} - -func validateIsValid[V isValid](source string, v V) []shared.FieldError { - if e := required(source, string(v)); e != nil { - return e - } - - if !v.IsValid() { - return []shared.FieldError{{Source: source, Detail: "invalid value"}} - } - - return nil -} - -func validateUnset(source string, v interface{ Unset() bool }) []shared.FieldError { - return validateIf(!v.Unset(), []shared.FieldError{{Source: source, Detail: "field must not be provided"}}) -} - func validateAttorneys(prefix string, attorneys []shared.Attorney) []shared.FieldError { var errors []shared.FieldError @@ -172,13 +91,13 @@ func validateAttorneys(prefix string, attorneys []shared.Attorney) []shared.Fiel } func validateAttorney(prefix string, attorney shared.Attorney) []shared.FieldError { - return flatten( - required(fmt.Sprintf("%s/firstNames", prefix), attorney.FirstNames), - required(fmt.Sprintf("%s/lastName", prefix), attorney.LastName), - required(fmt.Sprintf("%s/status", prefix), string(attorney.Status)), - validateDate(fmt.Sprintf("%s/dateOfBirth", prefix), attorney.DateOfBirth), - validateAddress(fmt.Sprintf("%s/address", prefix), attorney.Address), - validateIsValid(fmt.Sprintf("%s/status", prefix), attorney.Status), + return validate.All( + validate.Required(fmt.Sprintf("%s/firstNames", prefix), attorney.FirstNames), + validate.Required(fmt.Sprintf("%s/lastName", prefix), attorney.LastName), + validate.Required(fmt.Sprintf("%s/status", prefix), string(attorney.Status)), + validate.Date(fmt.Sprintf("%s/dateOfBirth", prefix), attorney.DateOfBirth), + validate.Address(fmt.Sprintf("%s/address", prefix), attorney.Address), + validate.IsValid(fmt.Sprintf("%s/status", prefix), attorney.Status), ) } @@ -195,11 +114,11 @@ func validateTrustCorporations(prefix string, trustCorporations []shared.TrustCo } func validateTrustCorporation(prefix string, trustCorporation shared.TrustCorporation) []shared.FieldError { - return flatten( - required(fmt.Sprintf("%s/name", prefix), trustCorporation.Name), - required(fmt.Sprintf("%s/companyNumber", prefix), trustCorporation.CompanyNumber), - required(fmt.Sprintf("%s/email", prefix), trustCorporation.Email), - validateAddress(fmt.Sprintf("%s/address", prefix), trustCorporation.Address), - validateIsValid(fmt.Sprintf("%s/status", prefix), trustCorporation.Status), + return validate.All( + validate.Required(fmt.Sprintf("%s/name", prefix), trustCorporation.Name), + validate.Required(fmt.Sprintf("%s/companyNumber", prefix), trustCorporation.CompanyNumber), + validate.Required(fmt.Sprintf("%s/email", prefix), trustCorporation.Email), + validate.Address(fmt.Sprintf("%s/address", prefix), trustCorporation.Address), + validate.IsValid(fmt.Sprintf("%s/status", prefix), trustCorporation.Status), ) } diff --git a/lambda/create/validate_test.go b/lambda/create/validate_test.go index 2482fdc6..69999243 100644 --- a/lambda/create/validate_test.go +++ b/lambda/create/validate_test.go @@ -40,94 +40,6 @@ func TestCountAttorneys(t *testing.T) { assert.Equal(t, 3, replacements) } -func TestFlatten(t *testing.T) { - errA := shared.FieldError{Source: "a", Detail: "a"} - errB := shared.FieldError{Source: "b", Detail: "b"} - errC := shared.FieldError{Source: "c", Detail: "c"} - - assert.Nil(t, flatten()) - assert.Nil(t, flatten([]shared.FieldError{}, []shared.FieldError{})) - assert.Equal(t, []shared.FieldError{errA, errB, errC}, flatten([]shared.FieldError{errA, errB}, []shared.FieldError{errC})) - assert.Equal(t, []shared.FieldError{errA, errB, errC}, flatten([]shared.FieldError{errA}, []shared.FieldError{errB, errC})) - assert.Equal(t, []shared.FieldError{errA, errB, errC}, flatten([]shared.FieldError{errA}, []shared.FieldError{errB}, []shared.FieldError{errC})) -} - -func TestValidateIf(t *testing.T) { - errs := []shared.FieldError{{Source: "a", Detail: "a"}} - - assert.Equal(t, errs, validateIf(true, errs)) - assert.Nil(t, validateIf(false, errs)) -} - -func TestValidateIfElse(t *testing.T) { - errsA := []shared.FieldError{{Source: "a", Detail: "a"}} - errsB := []shared.FieldError{{Source: "b", Detail: "b"}} - - assert.Equal(t, errsA, validateIfElse(true, errsA, errsB)) - assert.Equal(t, errsB, validateIfElse(false, errsA, errsB)) -} - -func TestRequired(t *testing.T) { - assert.Nil(t, required("a", "a")) - assert.Equal(t, []shared.FieldError{{Source: "a", Detail: "field is required"}}, required("a", "")) -} - -func TestEmpty(t *testing.T) { - assert.Nil(t, empty("a", "")) - assert.Equal(t, []shared.FieldError{{Source: "a", Detail: "field must not be provided"}}, empty("a", "a")) -} - -func TestValidateDate(t *testing.T) { - assert.Nil(t, validateDate("a", shared.Date{Time: time.Now()})) - assert.Equal(t, []shared.FieldError{{Source: "a", Detail: "invalid format"}}, validateDate("a", shared.Date{IsMalformed: true})) - assert.Equal(t, []shared.FieldError{{Source: "a", Detail: "field is required"}}, validateDate("a", shared.Date{})) -} - -func TestValidateAddressEmpty(t *testing.T) { - address := shared.Address{} - errors := validateAddress("/test", address) - - assert.Contains(t, errors, shared.FieldError{Source: "/test/line1", Detail: "field is required"}) - assert.Contains(t, errors, shared.FieldError{Source: "/test/town", Detail: "field is required"}) - assert.Contains(t, errors, shared.FieldError{Source: "/test/country", Detail: "field is required"}) -} - -func TestValidateAddressValid(t *testing.T) { - errors := validateAddress("/test", validAddress) - - assert.Empty(t, errors) -} - -func TestValidateAddressInvalidCountry(t *testing.T) { - invalidAddress := shared.Address{ - Line1: "123 Main St", - Town: "Homeland", - Country: "United Kingdom", - } - errors := validateAddress("/test", invalidAddress) - - assert.Contains(t, errors, shared.FieldError{Source: "/test/country", Detail: "must be a valid ISO-3166-1 country code"}) -} - -type testIsValid string - -func (t testIsValid) IsValid() bool { return string(t) == "ok" } - -func TestValidateIsValid(t *testing.T) { - assert.Nil(t, validateIsValid("a", testIsValid("ok"))) - assert.Equal(t, []shared.FieldError{{Source: "a", Detail: "field is required"}}, validateIsValid("a", testIsValid(""))) - assert.Equal(t, []shared.FieldError{{Source: "a", Detail: "invalid value"}}, validateIsValid("a", testIsValid("x"))) -} - -type testUnset bool - -func (t testUnset) Unset() bool { return bool(t) } - -func TestValidateUnset(t *testing.T) { - assert.Nil(t, validateUnset("a", testUnset(true))) - assert.Equal(t, []shared.FieldError{{Source: "a", Detail: "field must not be provided"}}, validateUnset("a", testUnset(false))) -} - func TestValidateAttorneyEmpty(t *testing.T) { attorney := shared.Attorney{} errors := validateAttorney("/test", attorney) diff --git a/lambda/get/main.go b/lambda/get/main.go index c0d7a1e9..d5b8c1bd 100644 --- a/lambda/get/main.go +++ b/lambda/get/main.go @@ -7,6 +7,7 @@ import ( "github.com/aws/aws-lambda-go/events" "github.com/aws/aws-lambda-go/lambda" + "github.com/ministryofjustice/opg-data-lpa-store/internal/ddb" "github.com/ministryofjustice/opg-data-lpa-store/internal/shared" "github.com/ministryofjustice/opg-go-common/logging" ) @@ -15,8 +16,12 @@ type Logger interface { Print(...interface{}) } +type Store interface { + Get(ctx context.Context, uid string) (shared.Lpa, error) +} + type Lambda struct { - store shared.Client + store Store verifier shared.JWTVerifier logger Logger } @@ -36,8 +41,8 @@ func (l *Lambda) HandleEvent(ctx context.Context, event events.APIGatewayProxyRe lpa, err := l.store.Get(ctx, event.PathParameters["uid"]) - // If item can't be found in DynamoDB then it returns empty object hence 404 error returned if - // empty object returned + // If item can't be found in DynamoDB then it returns empty object hence 404 error returned if + // empty object returned if lpa.Uid == "" { l.logger.Print("Uid not found") return shared.ProblemNotFoundRequest.Respond() @@ -63,7 +68,7 @@ func (l *Lambda) HandleEvent(ctx context.Context, event events.APIGatewayProxyRe func main() { l := &Lambda{ - store: shared.NewDynamoDB(os.Getenv("DDB_TABLE_NAME_DEEDS")), + store: ddb.New(os.Getenv("AWS_DYNAMODB_ENDPOINT"), os.Getenv("DDB_TABLE_NAME_DEEDS")), verifier: shared.NewJWTVerifier(), logger: logging.New(os.Stdout, "opg-data-lpa-store"), } diff --git a/lambda/update/main.go b/lambda/update/main.go index 0a2def0d..c71179ef 100644 --- a/lambda/update/main.go +++ b/lambda/update/main.go @@ -3,12 +3,11 @@ package main import ( "context" "encoding/json" - "fmt" "os" "github.com/aws/aws-lambda-go/events" "github.com/aws/aws-lambda-go/lambda" - "github.com/go-openapi/jsonpointer" + "github.com/ministryofjustice/opg-data-lpa-store/internal/ddb" "github.com/ministryofjustice/opg-data-lpa-store/internal/shared" "github.com/ministryofjustice/opg-go-common/logging" ) @@ -17,10 +16,17 @@ type Logger interface { Print(...interface{}) } +type Store interface { + Put(ctx context.Context, data any) error + Get(ctx context.Context, uid string) (shared.Lpa, error) +} + type Lambda struct { - store shared.Client - verifier shared.JWTVerifier - logger Logger + store Store + verifier interface { + VerifyHeader(events.APIGatewayProxyRequest) bool + } + logger Logger } func (l *Lambda) HandleEvent(ctx context.Context, event events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { @@ -48,29 +54,32 @@ func (l *Lambda) HandleEvent(ctx context.Context, event events.APIGatewayProxyRe l.logger.Print(err) return shared.ProblemInternalServerError.Respond() } + if lpa.Uid == "" { + l.logger.Print("Uid not found") + return shared.ProblemNotFoundRequest.Respond() + } - validationErrs, err := applyUpdate(&lpa, update) + applyable, errors := validateUpdate(update) + if len(errors) > 0 { + problem := shared.ProblemInvalidRequest + problem.Errors = errors - if err != nil { - l.logger.Print(err) - return shared.ProblemInternalServerError.Respond() + return problem.Respond() } - if len(validationErrs) > 0 { + if errors := applyable.Apply(&lpa); len(errors) > 0 { problem := shared.ProblemInvalidRequest - problem.Errors = validationErrs + problem.Errors = errors return problem.Respond() } - err = l.store.Put(ctx, lpa) - if err != nil { + if err := l.store.Put(ctx, lpa); err != nil { l.logger.Print(err) return shared.ProblemInternalServerError.Respond() } body, err := json.Marshal(lpa) - if err != nil { l.logger.Print(err) return shared.ProblemInternalServerError.Respond() @@ -82,39 +91,9 @@ func (l *Lambda) HandleEvent(ctx context.Context, event events.APIGatewayProxyRe return response, nil } -func applyUpdate(lpa *shared.Lpa, update shared.Update) ([]shared.FieldError, error) { - validationErrs := []shared.FieldError{} - - for index, change := range update.Changes { - pointer, err := jsonpointer.New(change.Key) - if err != nil { - return validationErrs, err - } - - current, _, err := pointer.Get(*lpa) - if err != nil { - return validationErrs, err - } - - if current != change.Old { - validationErrs = append(validationErrs, shared.FieldError{ - Source: fmt.Sprintf("/changes/%d/old", index), - Detail: "does not match existing value", - }) - } - - _, err = pointer.Set(lpa, change.New) - if err != nil { - return validationErrs, err - } - } - - return validationErrs, nil -} - func main() { l := &Lambda{ - store: shared.NewDynamoDB(os.Getenv("DDB_TABLE_NAME_DEEDS")), + store: ddb.New(os.Getenv("AWS_DYNAMODB_ENDPOINT"), os.Getenv("DDB_TABLE_NAME_DEEDS")), verifier: shared.NewJWTVerifier(), logger: logging.New(os.Stdout, "opg-data-lpa-store"), } diff --git a/lambda/update/main_test.go b/lambda/update/main_test.go new file mode 100644 index 00000000..3416a3eb --- /dev/null +++ b/lambda/update/main_test.go @@ -0,0 +1,143 @@ +package main + +import ( + "context" + "errors" + "io" + "testing" + "time" + + "github.com/aws/aws-lambda-go/events" + "github.com/ministryofjustice/opg-data-lpa-store/internal/shared" + "github.com/ministryofjustice/opg-go-common/logging" + "github.com/stretchr/testify/assert" +) + +var expectedError = errors.New("expected") + +type mockStore struct { + get shared.Lpa + getErr error + put any + putErr error +} + +func (m *mockStore) Get(context.Context, string) (shared.Lpa, error) { return m.get, m.getErr } +func (m *mockStore) Put(ctx context.Context, data any) error { + m.put = data + return m.putErr +} + +type mockVerifier struct{ ok bool } + +func (m *mockVerifier) VerifyHeader(events.APIGatewayProxyRequest) bool { return m.ok } + +func TestHandleEvent(t *testing.T) { + store := &mockStore{get: shared.Lpa{Uid: "1"}} + l := Lambda{ + store: store, + verifier: &mockVerifier{ok: true}, + logger: logging.New(io.Discard, ""), + } + + resp, err := l.HandleEvent(context.Background(), events.APIGatewayProxyRequest{ + Body: `{"type":"CERTIFICATE_PROVIDER_SIGN","changes":[{"key":"/certificateProvider/signedAt","new":"2022-01-02T12:13:14.000000006Z"},{"key":"/certificateProvider/contactLanguagePreference","new":"en"}]}`, + }) + assert.Nil(t, err) + assert.Equal(t, 201, resp.StatusCode) + assert.Contains(t, resp.Body, `"2022-01-02T12:13:14.000000006Z"`) + assert.Contains(t, resp.Body, `"en"`) + assert.Equal(t, shared.Lpa{ + Uid: "1", + LpaInit: shared.LpaInit{ + CertificateProvider: shared.CertificateProvider{ + SignedAt: time.Date(2022, time.January, 2, 12, 13, 14, 6, time.UTC), + ContactLanguagePreference: shared.LangEn, + }, + }, + }, store.put) +} + +func TestHandleEventWhenUnknownType(t *testing.T) { + l := Lambda{ + store: &mockStore{get: shared.Lpa{Uid: "1"}}, + verifier: &mockVerifier{ok: true}, + logger: logging.New(io.Discard, ""), + } + + resp, err := l.HandleEvent(context.Background(), events.APIGatewayProxyRequest{ + Body: `{"type":"SCANNING_CORRECTION","changes":[{"key":"/donor/firstNames","old":"Johm","new":"John"}]}`, + }) + assert.Nil(t, err) + assert.Equal(t, 400, resp.StatusCode) + assert.JSONEq(t, `{"code":"INVALID_REQUEST","detail":"Invalid request","errors":[{"source":"/type","detail":"invalid value"}]}`, resp.Body) +} + +func TestHandleEventWhenUpdateInvalid(t *testing.T) { + l := Lambda{ + store: &mockStore{get: shared.Lpa{Uid: "1"}}, + verifier: &mockVerifier{ok: true}, + logger: logging.New(io.Discard, ""), + } + + resp, err := l.HandleEvent(context.Background(), events.APIGatewayProxyRequest{ + Body: `{"type":"CERTIFICATE_PROVIDER_SIGN","changes":[]}`, + }) + assert.Nil(t, err) + assert.Equal(t, 400, resp.StatusCode) + assert.JSONEq(t, `{"code":"INVALID_REQUEST","detail":"Invalid request","errors":[{"source":"/changes","detail":"missing /certificateProvider/signedAt"},{"source":"/changes","detail":"missing /certificateProvider/contactLanguagePreference"}]}`, resp.Body) +} + +func TestHandleEventWhenLpaNotFound(t *testing.T) { + l := Lambda{ + store: &mockStore{}, + verifier: &mockVerifier{ok: true}, + logger: logging.New(io.Discard, ""), + } + + resp, err := l.HandleEvent(context.Background(), events.APIGatewayProxyRequest{ + Body: `{}`, + }) + assert.Nil(t, err) + assert.Equal(t, 404, resp.StatusCode) + assert.JSONEq(t, `{"code":"NOT_FOUND","detail":"Record not found"}`, resp.Body) +} + +func TestHandleEventWhenStoreGetError(t *testing.T) { + l := Lambda{ + store: &mockStore{getErr: expectedError}, + verifier: &mockVerifier{ok: true}, + logger: logging.New(io.Discard, ""), + } + + resp, err := l.HandleEvent(context.Background(), events.APIGatewayProxyRequest{ + Body: `{}`, + }) + assert.Nil(t, err) + assert.Equal(t, 500, resp.StatusCode) + assert.JSONEq(t, `{"code":"INTERNAL_SERVER_ERROR","detail":"Internal server error"}`, resp.Body) +} + +func TestHandleEventWhenRequestBodyNotJSON(t *testing.T) { + l := Lambda{ + verifier: &mockVerifier{ok: true}, + logger: logging.New(io.Discard, ""), + } + + resp, err := l.HandleEvent(context.Background(), events.APIGatewayProxyRequest{}) + assert.Nil(t, err) + assert.Equal(t, 500, resp.StatusCode) + assert.JSONEq(t, `{"code":"INTERNAL_SERVER_ERROR","detail":"Internal server error"}`, resp.Body) +} + +func TestHandleEventWhenHeaderNotVerified(t *testing.T) { + l := Lambda{ + verifier: &mockVerifier{ok: false}, + logger: logging.New(io.Discard, ""), + } + + resp, err := l.HandleEvent(context.Background(), events.APIGatewayProxyRequest{}) + assert.Nil(t, err) + assert.Equal(t, 401, resp.StatusCode) + assert.JSONEq(t, `{"code":"UNAUTHORISED","detail":"Invalid JWT"}`, resp.Body) +} diff --git a/lambda/update/validate.go b/lambda/update/validate.go new file mode 100644 index 00000000..6151660e --- /dev/null +++ b/lambda/update/validate.go @@ -0,0 +1,129 @@ +package main + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/ministryofjustice/opg-data-lpa-store/internal/shared" + "github.com/ministryofjustice/opg-data-lpa-store/internal/validate" +) + +type Applyable interface { + Apply(*shared.Lpa) []shared.FieldError +} + +type CertificateProviderSign struct { + Address shared.Address + SignedAt time.Time + ContactLanguagePreference shared.Lang +} + +func (c CertificateProviderSign) Apply(lpa *shared.Lpa) []shared.FieldError { + if !lpa.CertificateProvider.SignedAt.IsZero() { + return []shared.FieldError{{Source: "/type", Detail: "certificate provider cannot sign again"}} + } + + lpa.CertificateProvider.Address = c.Address + lpa.CertificateProvider.SignedAt = c.SignedAt + lpa.CertificateProvider.ContactLanguagePreference = c.ContactLanguagePreference + + return nil +} + +func validateUpdate(update shared.Update) (Applyable, []shared.FieldError) { + switch update.Type { + case "CERTIFICATE_PROVIDER_SIGN": + var ( + data CertificateProviderSign + errors []shared.FieldError + ) + + for i, change := range update.Changes { + if len(change.Old) != 0 { + errors = append(errors, shared.FieldError{Source: fmt.Sprintf("/changes/%d/old", i), Detail: "field must not be provided"}) + } + + newKey := fmt.Sprintf("/changes/%d/new", i) + switch change.Key { + case "/certificateProvider/address/line1": + if err := json.Unmarshal(change.New, &data.Address.Line1); err != nil { + errors = errorMustBeString(errors, newKey) + } + case "/certificateProvider/address/line2": + if err := json.Unmarshal(change.New, &data.Address.Line2); err != nil { + errors = errorMustBeString(errors, newKey) + } + case "/certificateProvider/address/line3": + if err := json.Unmarshal(change.New, &data.Address.Line3); err != nil { + errors = errorMustBeString(errors, newKey) + } + case "/certificateProvider/address/town": + if err := json.Unmarshal(change.New, &data.Address.Town); err != nil { + errors = errorMustBeString(errors, newKey) + } + case "/certificateProvider/address/postcode": + if err := json.Unmarshal(change.New, &data.Address.Postcode); err != nil { + errors = errorMustBeString(errors, newKey) + } + case "/certificateProvider/address/country": + if err := json.Unmarshal(change.New, &data.Address.Country); err != nil { + errors = errorMustBeString(errors, newKey) + } else { + errors = append(errors, validate.Country(newKey, data.Address.Country)...) + } + case "/certificateProvider/signedAt": + if err := json.Unmarshal(change.New, &data.SignedAt); err != nil { + errors = errorMustBeDateTime(errors, newKey) + } + case "/certificateProvider/contactLanguagePreference": + if err := json.Unmarshal(change.New, &data.ContactLanguagePreference); err != nil { + errors = errorMustBeString(errors, newKey) + } else { + errors = append(errors, validate.IsValid(newKey, data.ContactLanguagePreference)...) + } + default: + errors = append(errors, shared.FieldError{Source: fmt.Sprintf("/changes/%d", i), Detail: "change not allowed for type"}) + } + } + + if data.Address.IsSet() { + if data.Address.Line1 == "" { + errors = errorMissing(errors, "/certificateProvider/address/line1") + } + + if data.Address.Town == "" { + errors = errorMissing(errors, "/certificateProvider/address/town") + } + + if data.Address.Country == "" { + errors = errorMissing(errors, "/certificateProvider/address/country") + } + } + + if data.SignedAt.IsZero() { + errors = errorMissing(errors, "/certificateProvider/signedAt") + } + + if data.ContactLanguagePreference == shared.Lang("") { + errors = errorMissing(errors, "/certificateProvider/contactLanguagePreference") + } + + return data, errors + + default: + return nil, []shared.FieldError{{Source: "/type", Detail: "invalid value"}} + } +} + +func errorMustBeString(errors []shared.FieldError, source string) []shared.FieldError { + return append(errors, shared.FieldError{Source: source, Detail: "must be a string"}) +} + +func errorMustBeDateTime(errors []shared.FieldError, source string) []shared.FieldError { + return append(errors, shared.FieldError{Source: source, Detail: "must be a datetime"}) +} + +func errorMissing(errors []shared.FieldError, key string) []shared.FieldError { + return append(errors, shared.FieldError{Source: "/changes", Detail: "missing " + key}) +} diff --git a/lambda/update/validate_test.go b/lambda/update/validate_test.go new file mode 100644 index 00000000..782eb49a --- /dev/null +++ b/lambda/update/validate_test.go @@ -0,0 +1,147 @@ +package main + +import ( + "encoding/json" + "testing" + "time" + + "github.com/ministryofjustice/opg-data-lpa-store/internal/shared" + "github.com/stretchr/testify/assert" +) + +func TestCertificateProviderSignApply(t *testing.T) { + lpa := &shared.Lpa{} + c := CertificateProviderSign{ + Address: shared.Address{Line1: "line 1"}, + SignedAt: time.Now(), + ContactLanguagePreference: shared.LangCy, + } + + errors := c.Apply(lpa) + assert.Empty(t, errors) + assert.Equal(t, c.Address, lpa.CertificateProvider.Address) + assert.Equal(t, c.SignedAt, lpa.CertificateProvider.SignedAt) + assert.Equal(t, c.ContactLanguagePreference, lpa.CertificateProvider.ContactLanguagePreference) +} + +func TestCertificateProviderSignApplyWhenAlreadySigned(t *testing.T) { + lpa := &shared.Lpa{LpaInit: shared.LpaInit{CertificateProvider: shared.CertificateProvider{SignedAt: time.Now()}}} + c := CertificateProviderSign{} + + errors := c.Apply(lpa) + assert.Equal(t, errors, []shared.FieldError{{Source: "/type", Detail: "certificate provider cannot sign again"}}) +} + +func TestValidateUpdate(t *testing.T) { + testcases := map[string]struct { + update shared.Update + errors []shared.FieldError + }{ + "CERTIFICATE_PROVIDER_SIGN/valid": { + update: shared.Update{ + Type: "CERTIFICATE_PROVIDER_SIGN", + Changes: []shared.Change{ + { + Key: "/certificateProvider/address/line1", + New: json.RawMessage(`"123 Main St"`), + }, + { + Key: "/certificateProvider/address/town", + New: json.RawMessage(`"Homeland"`), + }, + { + Key: "/certificateProvider/address/country", + New: json.RawMessage(`"GB"`), + }, + { + Key: "/certificateProvider/signedAt", + New: json.RawMessage(`"` + time.Now().Format(time.RFC3339) + `"`), + }, + { + Key: "/certificateProvider/contactLanguagePreference", + New: json.RawMessage(`"cy"`), + }, + }, + }, + }, + "CERTIFICATE_PROVIDER_SIGN/missing all": { + update: shared.Update{Type: "CERTIFICATE_PROVIDER_SIGN"}, + errors: []shared.FieldError{ + {Source: "/changes", Detail: "missing /certificateProvider/signedAt"}, + {Source: "/changes", Detail: "missing /certificateProvider/contactLanguagePreference"}, + }, + }, + "CERTIFICATE_PROVIDER_SIGN/bad address": { + update: shared.Update{ + Type: "CERTIFICATE_PROVIDER_SIGN", + Changes: []shared.Change{ + { + Key: "/certificateProvider/address/line3", + New: json.RawMessage("1"), + }, + { + Key: "/certificateProvider/address/country", + New: json.RawMessage(`"x"`), + }, + }, + }, + errors: []shared.FieldError{ + {Source: "/changes/0/new", Detail: "must be a string"}, + {Source: "/changes", Detail: "missing /certificateProvider/address/line1"}, + {Source: "/changes", Detail: "missing /certificateProvider/address/town"}, + {Source: "/changes/1/new", Detail: "must be a valid ISO-3166-1 country code"}, + {Source: "/changes", Detail: "missing /certificateProvider/signedAt"}, + {Source: "/changes", Detail: "missing /certificateProvider/contactLanguagePreference"}, + }, + }, + "CERTIFICATE_PROVIDER_SIGN/extra fields": { + update: shared.Update{ + Type: "CERTIFICATE_PROVIDER_SIGN", + Changes: []shared.Change{ + { + Key: "/certificateProvider/signedAt", + New: json.RawMessage(`"` + time.Now().Format(time.RFC3339) + `"`), + }, + { + Key: "/certificateProvider/contactLanguagePreference", + Old: json.RawMessage(`"` + shared.LangEn + `"`), + New: json.RawMessage(`"` + shared.LangCy + `"`), + }, + { + Key: "/donor/firstNames", + New: json.RawMessage(`"John"`), + }, + }, + }, + errors: []shared.FieldError{ + {Source: "/changes/1/old", Detail: "field must not be provided"}, + {Source: "/changes/2", Detail: "change not allowed for type"}, + }, + }, + "CERTIFICATE_PROVIDER_SIGN/invalid contact language": { + update: shared.Update{ + Type: "CERTIFICATE_PROVIDER_SIGN", + Changes: []shared.Change{ + { + Key: "/certificateProvider/signedAt", + New: json.RawMessage(`"` + time.Now().Format(time.RFC3339) + `"`), + }, + { + Key: "/certificateProvider/contactLanguagePreference", + New: json.RawMessage(`"xy"`), + }, + }, + }, + errors: []shared.FieldError{ + {Source: "/changes/1/new", Detail: "invalid value"}, + }, + }, + } + + for name, tc := range testcases { + t.Run(name, func(t *testing.T) { + _, errors := validateUpdate(tc.update) + assert.ElementsMatch(t, tc.errors, errors) + }) + } +}