diff --git a/Makefile b/Makefile index 3dababca..920e7bab 100644 --- a/Makefile +++ b/Makefile @@ -42,6 +42,12 @@ test-api: # attorney sign cat ./docs/attorney-sign.json | ./api-test/tester -expectedStatus=201 REQUEST POST $(URL)/lpas/$(LPA_UID)/updates "`xargs -0`" + # donor id check complete + cat ./docs/donor-confirm-identity.json | ./api-test/tester -expectedStatus=201 REQUEST POST $(URL)/lpas/$(LPA_UID)/updates "`xargs -0`" + + # certificate provider id check complete + cat ./docs/certificate-provider-confirm-identity.json | ./api-test/tester -expectedStatus=201 REQUEST POST $(URL)/lpas/$(LPA_UID)/updates "`xargs -0`" + # trust corporation sign cat ./docs/trust-corporation-sign.json | ./api-test/tester -expectedStatus=201 REQUEST POST $(URL)/lpas/$(LPA_UID)/updates "`xargs -0`" diff --git a/docs/certificate-provider-confirm-identity.json b/docs/certificate-provider-confirm-identity.json new file mode 100644 index 00000000..0aa70088 --- /dev/null +++ b/docs/certificate-provider-confirm-identity.json @@ -0,0 +1,20 @@ +{ + "type": "CERTIFICATE_PROVIDER_CONFIRM_IDENTITY", + "changes": [ + { + "key": "/certificateProvider/identityCheck/checkedAt", + "new": "2024-01-13T22:00:00Z", + "old": null + }, + { + "key": "/certificateProvider/identityCheck/type", + "new": "one-login", + "old": null + }, + { + "key": "/certificateProvider/identityCheck/reference", + "new": "1234567890", + "old": null + } + ] +} diff --git a/docs/donor-confirm-identity.json b/docs/donor-confirm-identity.json new file mode 100644 index 00000000..0eb48e0e --- /dev/null +++ b/docs/donor-confirm-identity.json @@ -0,0 +1,20 @@ +{ + "type": "DONOR_CONFIRM_IDENTITY", + "changes": [ + { + "key": "/donor/identityCheck/checkedAt", + "new": "2024-01-13T22:00:00Z", + "old": null + }, + { + "key": "/donor/identityCheck/type", + "new": "one-login", + "old": null + }, + { + "key": "/donor/identityCheck/reference", + "new": "1234567890", + "old": null + } + ] +} diff --git a/docs/openapi/openapi.yaml b/docs/openapi/openapi.yaml index 07c04219..c08dda43 100644 --- a/docs/openapi/openapi.yaml +++ b/docs/openapi/openapi.yaml @@ -268,6 +268,8 @@ components: - PERFECT - REGISTER - CERTIFICATE_PROVIDER_OPT_OUT + - DONOR_CONFIRM_IDENTITY + - CERTIFICATE_PROVIDER_CONFIRM_IDENTITY changes: type: array items: diff --git a/internal/shared/person.go b/internal/shared/person.go index 3a51b591..829ad69d 100644 --- a/internal/shared/person.go +++ b/internal/shared/person.go @@ -24,19 +24,21 @@ type Person struct { type Donor struct { Person - DateOfBirth Date `json:"dateOfBirth"` - Email string `json:"email"` - OtherNamesKnownBy string `json:"otherNamesKnownBy,omitempty"` - ContactLanguagePreference Lang `json:"contactLanguagePreference"` + DateOfBirth Date `json:"dateOfBirth"` + Email string `json:"email"` + OtherNamesKnownBy string `json:"otherNamesKnownBy,omitempty"` + ContactLanguagePreference Lang `json:"contactLanguagePreference"` + IdentityCheck *IdentityCheck `json:"identityCheck,omitempty"` } type CertificateProvider struct { Person - Email string `json:"email"` - Phone string `json:"phone"` - Channel Channel `json:"channel"` - SignedAt *time.Time `json:"signedAt,omitempty"` - ContactLanguagePreference Lang `json:"contactLanguagePreference,omitempty"` + Email string `json:"email"` + Phone string `json:"phone"` + Channel Channel `json:"channel"` + SignedAt *time.Time `json:"signedAt,omitempty"` + ContactLanguagePreference Lang `json:"contactLanguagePreference,omitempty"` + IdentityCheck *IdentityCheck `json:"identityCheck,omitempty"` } type Channel string @@ -101,6 +103,23 @@ type PersonToNotify struct { Person } +type IdentityCheck struct { + CheckedAt time.Time `json:"checkedAt"` + Reference string `json:"reference"` + Type IdentityCheckType `json:"type"` +} + +type IdentityCheckType string + +const ( + IdentityCheckTypeOneLogin = IdentityCheckType("one-login") + IdentityCheckTypeOpgPaperId = IdentityCheckType("opg-paper-id") +) + +func (e IdentityCheckType) IsValid() bool { + return e == IdentityCheckTypeOneLogin || e == IdentityCheckTypeOpgPaperId +} + type HowMakeDecisions string const ( diff --git a/lambda/update/confirm_identity.go b/lambda/update/confirm_identity.go new file mode 100644 index 00000000..17c1cfee --- /dev/null +++ b/lambda/update/confirm_identity.go @@ -0,0 +1,63 @@ +package main + +import ( + "github.com/ministryofjustice/opg-data-lpa-store/internal/shared" + "github.com/ministryofjustice/opg-data-lpa-store/internal/validate" + "github.com/ministryofjustice/opg-data-lpa-store/lambda/update/parse" +) + +type IdCheckComplete struct { + Actor idccActor + IdentityCheck *shared.IdentityCheck +} + +type idccActor string + +var ( + donor = idccActor("Donor") + certificateProvider = idccActor("CertificateProvider") +) + +func (idcc IdCheckComplete) Apply(lpa *shared.Lpa) []shared.FieldError { + if idcc.Actor == donor { + lpa.Donor.IdentityCheck = idcc.IdentityCheck + } else { + lpa.CertificateProvider.IdentityCheck = idcc.IdentityCheck + } + return nil +} + +func validateConfirmIdentity(prefix string, actor idccActor, ic *shared.IdentityCheck, changes []shared.Change) (IdCheckComplete, []shared.FieldError) { + var idcc IdCheckComplete + + errors := parse.Changes(changes). + Prefix(prefix, func(p *parse.Parser) []shared.FieldError { + idcc.Actor = actor + + if ic == nil { + ic = &shared.IdentityCheck{} + } + idcc.IdentityCheck = ic + + return p. + Field("/type", &ic.Type, parse.Validate(func() []shared.FieldError { + return validate.IsValid("", ic.Type) + })). + Field("/checkedAt", &ic.CheckedAt, parse.Validate(func() []shared.FieldError { + return validate.Time("", ic.CheckedAt) + })). + Field("/reference", &ic.Reference, parse.Optional()). + Consumed() + }). + Consumed() + + return idcc, errors +} + +func validateDonorConfirmIdentity(changes []shared.Change, lpa *shared.Lpa) (IdCheckComplete, []shared.FieldError) { + return validateConfirmIdentity("/donor/identityCheck", donor, lpa.Donor.IdentityCheck, changes) +} + +func validateCertificateProviderConfirmIdentity(changes []shared.Change, lpa *shared.Lpa) (IdCheckComplete, []shared.FieldError) { + return validateConfirmIdentity("/certificateProvider/identityCheck", certificateProvider, lpa.CertificateProvider.IdentityCheck, changes) +} diff --git a/lambda/update/confirm_identity_test.go b/lambda/update/confirm_identity_test.go new file mode 100644 index 00000000..d1d2ec88 --- /dev/null +++ b/lambda/update/confirm_identity_test.go @@ -0,0 +1,209 @@ +package main + +import ( + "encoding/json" + "github.com/stretchr/testify/assert" + "testing" + "time" + + "github.com/ministryofjustice/opg-data-lpa-store/internal/shared" +) + +func TestConfirmIdentityDonor(t *testing.T) { + today := time.Now() + + changes := []shared.Change{ + { + Key: "/donor/identityCheck/checkedAt", + Old: json.RawMessage("null"), + New: json.RawMessage(`"` + today.Format(time.RFC3339Nano) + `"`), + }, + { + Key: "/donor/identityCheck/reference", + Old: json.RawMessage("null"), + New: json.RawMessage(`"xyz"`), + }, + { + Key: "/donor/identityCheck/type", + Old: json.RawMessage("null"), + New: json.RawMessage(`"one-login"`), + }, + } + + idCheckComplete, errors := validateDonorConfirmIdentity(changes, &shared.Lpa{}) + + assert.Len(t, errors, 0) + assert.Equal(t, "xyz", idCheckComplete.IdentityCheck.Reference) + assert.Equal(t, shared.IdentityCheckTypeOneLogin, idCheckComplete.IdentityCheck.Type) + assert.Equal(t, today.Format(time.RFC3339Nano), idCheckComplete.IdentityCheck.CheckedAt.Format(time.RFC3339Nano)) + assert.Equal(t, donor, idCheckComplete.Actor) +} + +func TestConfirmIdentityDonorBadFieldsFails(t *testing.T) { + changes := []shared.Change{ + // irrelevant field with no prefix + { + Key: "/irrelevant", + Old: json.RawMessage("null"), + New: json.RawMessage(`"` + time.Now().Format(time.RFC3339Nano) + `"`), + }, + // irrelevant field with prefix + { + Key: "/donor/identityCheck/irrelevant", + Old: json.RawMessage("null"), + New: json.RawMessage(`"` + time.Now().Format(time.RFC3339Nano) + `"`), + }, + // empty optional field - does not cause an error message + { + Key: "/donor/identityCheck/reference", + Old: json.RawMessage("null"), + New: json.RawMessage(`""`), + }, + // invalid value for field + { + Key: "/donor/identityCheck/type", + Old: json.RawMessage("null"), + New: json.RawMessage(`"rinky-dink-login-system"`), + }, + } + + idCheckComplete, errors := validateDonorConfirmIdentity(changes, &shared.Lpa{}) + + assert.Len(t, errors, 4) + assert.Contains(t, errors, shared.FieldError{Source: "/changes", Detail: "missing /donor/identityCheck/checkedAt"}) + assert.Contains(t, errors, shared.FieldError{Source: "/changes/0", Detail: "unexpected change provided"}) + assert.Contains(t, errors, shared.FieldError{Source: "/changes/1", Detail: "unexpected change provided"}) + assert.Contains(t, errors, shared.FieldError{Source: "/changes/3/new", Detail: "invalid value"}) + assert.Equal(t, &shared.IdentityCheck{Type: "rinky-dink-login-system"}, idCheckComplete.IdentityCheck) + assert.Equal(t, donor, idCheckComplete.Actor) +} + +func TestConfirmIdentityDonorANDCertificateProviderFails(t *testing.T) { + changes := []shared.Change{ + { + Key: "/certificateProvider/identityCheck/checkedAt", + Old: json.RawMessage("null"), + New: json.RawMessage(`"` + time.Now().Format(time.RFC3339Nano) + `"`), + }, + { + Key: "/donor/identityCheck/reference", + Old: json.RawMessage("null"), + New: json.RawMessage(`"xyz"`), + }, + { + Key: "/donor/identityCheck/type", + Old: json.RawMessage("null"), + New: json.RawMessage(`"one-login"`), + }, + } + + idCheckComplete, errors := validateDonorConfirmIdentity(changes, &shared.Lpa{}) + expectedIdCheckComplete := &shared.IdentityCheck{ + Type: shared.IdentityCheckTypeOneLogin, + Reference: "xyz", + } + + assert.Len(t, errors, 2) + assert.Contains(t, errors, shared.FieldError{Source: "/changes", Detail: "missing /donor/identityCheck/checkedAt"}) + assert.Contains(t, errors, shared.FieldError{Source: "/changes/0", Detail: "unexpected change provided"}) + + assert.Equal(t, expectedIdCheckComplete, idCheckComplete.IdentityCheck) + assert.Equal(t, donor, idCheckComplete.Actor) +} + +func TestConfirmIdentityDonorMismatchWithExistingLpaFails(t *testing.T) { + changes := []shared.Change{ + { + Key: "/donor/identityCheck/checkedAt", + Old: json.RawMessage("null"), + New: json.RawMessage(`"` + time.Now().Format(time.RFC3339Nano) + `"`), + }, + { + Key: "/donor/identityCheck/reference", + Old: json.RawMessage("null"), + New: json.RawMessage(`"xyz"`), + }, + { + Key: "/donor/identityCheck/type", + Old: json.RawMessage("null"), + New: json.RawMessage(`"one-login"`), + }, + } + + existingLpa := &shared.Lpa{ + LpaInit: shared.LpaInit{ + Donor: shared.Donor{ + IdentityCheck: &shared.IdentityCheck{ + CheckedAt: time.Now().AddDate(-1, 0, 0), + Reference: "notxyz", + Type: "not-one-login", + }, + }, + }, + } + + idCheckComplete, errors := validateDonorConfirmIdentity(changes, existingLpa) + + assert.Len(t, errors, 3) + assert.Contains(t, errors, shared.FieldError{Source: "/changes/0/old", Detail: "does not match existing value"}) + assert.Contains(t, errors, shared.FieldError{Source: "/changes/1/old", Detail: "does not match existing value"}) + assert.Contains(t, errors, shared.FieldError{Source: "/changes/2/old", Detail: "does not match existing value"}) + assert.Equal(t, existingLpa.LpaInit.Donor.IdentityCheck, idCheckComplete.IdentityCheck) + assert.Equal(t, donor, idCheckComplete.Actor) +} + +func TestConfirmIdentityCertificateProvider(t *testing.T) { + today := time.Now() + + changes := []shared.Change{ + { + Key: "/certificateProvider/identityCheck/checkedAt", + Old: json.RawMessage("null"), + New: json.RawMessage(`"` + today.Format(time.RFC3339Nano) + `"`), + }, + { + Key: "/certificateProvider/identityCheck/reference", + Old: json.RawMessage("null"), + New: json.RawMessage(`"abn"`), + }, + { + Key: "/certificateProvider/identityCheck/type", + Old: json.RawMessage("null"), + New: json.RawMessage(`"opg-paper-id"`), + }, + } + + idCheckComplete, errors := validateCertificateProviderConfirmIdentity(changes, &shared.Lpa{}) + + assert.Len(t, errors, 0) + assert.Equal(t, "abn", idCheckComplete.IdentityCheck.Reference) + assert.Equal(t, shared.IdentityCheckTypeOpgPaperId, idCheckComplete.IdentityCheck.Type) + assert.Equal(t, today.Format(time.RFC3339Nano), idCheckComplete.IdentityCheck.CheckedAt.Format(time.RFC3339Nano)) + assert.Equal(t, certificateProvider, idCheckComplete.Actor) +} + +func TestConfirmIdentityApplyDonor(t *testing.T) { + check := IdCheckComplete{ + Actor: donor, + IdentityCheck: &shared.IdentityCheck{}, + } + + lpa := shared.Lpa{} + + check.Apply(&lpa) + + assert.Equal(t, check.IdentityCheck, lpa.Donor.IdentityCheck) +} + +func TestConfirmIdentityApplyCertificateProvider(t *testing.T) { + check := IdCheckComplete{ + Actor: certificateProvider, + IdentityCheck: &shared.IdentityCheck{}, + } + + lpa := shared.Lpa{} + + check.Apply(&lpa) + + assert.Equal(t, check.IdentityCheck, lpa.CertificateProvider.IdentityCheck) +} diff --git a/lambda/update/parse/changes.go b/lambda/update/parse/changes.go index bfa9fc78..69ccc197 100644 --- a/lambda/update/parse/changes.go +++ b/lambda/update/parse/changes.go @@ -156,6 +156,13 @@ func oldEqualsExisting(old any, existing any) bool { return shared.Channel(old.(string)) == *v + case *shared.IdentityCheckType: + if old == nil { + return *v == "" + } + + return shared.IdentityCheckType(old.(string)) == *v + case *string: if old == nil { return *v == "" diff --git a/lambda/update/validate.go b/lambda/update/validate.go index 5cf2f2e7..c3e436c8 100644 --- a/lambda/update/validate.go +++ b/lambda/update/validate.go @@ -22,6 +22,10 @@ func validateUpdate(update shared.Update, lpa *shared.Lpa) (Applyable, []shared. return validateRegister(update.Changes) case "TRUST_CORPORATION_SIGN": return validateTrustCorporationSign(update.Changes, lpa) + case "DONOR_CONFIRM_IDENTITY": + return validateDonorConfirmIdentity(update.Changes, lpa) + case "CERTIFICATE_PROVIDER_CONFIRM_IDENTITY": + return validateCertificateProviderConfirmIdentity(update.Changes, lpa) default: return nil, []shared.FieldError{{Source: "/type", Detail: "invalid value"}} }