Submit customer identity proofs and show reviewed limits
This commit is contained in:
1 parent
7bf8b8087a
commit
1354551ae8
50 files changed
+1777
-67
No files matched your search
@@ -0,0 +1,184 @@
|
||||
package sendico
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/identity"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
)
|
||||
|
||||
type profileDTO struct {
|
||||
FullName string `json:"full_name"`
|
||||
DateOfBirth string `json:"date_of_birth"`
|
||||
ResidenceCountry string `json:"residence_country"`
|
||||
Email string `json:"email"`
|
||||
CustomerRef string `json:"external_customer_ref"`
|
||||
}
|
||||
type verificationDTO struct {
|
||||
SubjectRef string `json:"subject_ref"`
|
||||
SubmissionRef string `json:"submission_ref"`
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason"`
|
||||
Profile profileDTO `json:"profile"`
|
||||
UpgradePending bool `json:"upgrade_pending"`
|
||||
}
|
||||
type policyDTO struct {
|
||||
Tier string `json:"tier"`
|
||||
ProfileRef string `json:"profile_ref"`
|
||||
Entries []limitDTO `json:"entries"`
|
||||
}
|
||||
type limitDTO struct {
|
||||
Operation string `json:"operation"`
|
||||
Period string `json:"period"`
|
||||
Currency string `json:"currency"`
|
||||
Cap string `json:"cap"`
|
||||
Remaining string `json:"remaining"`
|
||||
Used string `json:"used"`
|
||||
Reserved string `json:"reserved"`
|
||||
}
|
||||
|
||||
func (c *Client) identityRequest(ctx context.Context, subject, suffix, method, contentType string, body []byte, dst any) error {
|
||||
if !validRef(subject) {
|
||||
return merrors.InvalidArgument("invalid bound subject")
|
||||
}
|
||||
token, err := c.token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.base+"/identity_facts/"+c.config.OrganizationID+"/proofs/"+subject+suffix, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "create identity request")
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
// Uploads need a longer bound than small sign-up receipts. The base client is
|
||||
// copied so concurrent sign-up operations keep their own four-second timeout.
|
||||
client := *c.http
|
||||
client.Timeout = 60 * time.Second
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "identity provider request failed")
|
||||
}
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
switch res.StatusCode {
|
||||
case http.StatusOK:
|
||||
case http.StatusBadRequest, http.StatusRequestEntityTooLarge:
|
||||
return merrors.InvalidArgument("identity profile or photos were not accepted")
|
||||
case http.StatusConflict:
|
||||
return merrors.DataConflict("identity submission changed; refresh")
|
||||
case http.StatusForbidden:
|
||||
return merrors.PermissionDenied("identity resource unavailable")
|
||||
case http.StatusTooManyRequests:
|
||||
retry := 60
|
||||
if seconds, parseErr := strconv.Atoi(res.Header.Get("Retry-After")); parseErr == nil && seconds > 0 && seconds <= 86400 {
|
||||
retry = seconds
|
||||
}
|
||||
return merrors.WithThrottle(merrors.ErrServiceUnavailable, "identity", time.Duration(retry)*time.Second, "identity submission temporarily limited")
|
||||
default:
|
||||
return merrors.ServiceUnavailable("identity provider unavailable")
|
||||
}
|
||||
var envelope struct {
|
||||
Status string `json:"status"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
decoder := json.NewDecoder(io.LimitReader(res.Body, 256*1024+1))
|
||||
if err := decoder.Decode(&envelope); err != nil {
|
||||
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "decode identity response")
|
||||
}
|
||||
var extra any
|
||||
if decoder.Decode(&extra) != io.EOF || envelope.Status != "success" {
|
||||
return merrors.ServiceUnavailable("invalid identity response")
|
||||
}
|
||||
if err := json.Unmarshal(envelope.Data, dst); err != nil {
|
||||
return merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "decode identity payload")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func mapVerification(dto verificationDTO, subject string) (identity.Verification, error) {
|
||||
if dto.SubjectRef != subject {
|
||||
return identity.Verification{}, merrors.ServiceUnavailable("identity response scope mismatch")
|
||||
}
|
||||
switch dto.Status {
|
||||
case "unverified", "pending", "approved", "changesRequested", "denied":
|
||||
default:
|
||||
return identity.Verification{}, merrors.ServiceUnavailable("invalid identity status")
|
||||
}
|
||||
if dto.Status == "approved" && dto.UpgradePending {
|
||||
dto.Status = "pending"
|
||||
}
|
||||
if dto.Status != "unverified" && !validRef(dto.SubmissionRef) {
|
||||
return identity.Verification{}, merrors.ServiceUnavailable("invalid identity receipt")
|
||||
}
|
||||
profile := identity.Profile{FullName: dto.Profile.FullName, DateOfBirth: dto.Profile.DateOfBirth, ResidenceCountry: dto.Profile.ResidenceCountry}
|
||||
if dto.Status != "unverified" {
|
||||
if err := profile.Validate(); err != nil {
|
||||
return identity.Verification{}, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "invalid identity profile response")
|
||||
}
|
||||
}
|
||||
return identity.Verification{Status: dto.Status, UpgradePending: dto.UpgradePending, Reason: dto.Reason, SubmissionID: dto.SubmissionRef, Profile: profile}, nil
|
||||
}
|
||||
func (c *Client) Verification(ctx context.Context, subject string) (identity.Verification, error) {
|
||||
var dto verificationDTO
|
||||
if err := c.identityRequest(ctx, subject, "", http.MethodGet, "", nil, &dto); err != nil {
|
||||
return identity.Verification{}, err
|
||||
}
|
||||
return mapVerification(dto, subject)
|
||||
}
|
||||
func (c *Client) SubmitVerification(ctx context.Context, subject, key string, p identity.Profile, f identity.Facts, document, selfie []byte) (identity.Verification, error) {
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
profile, err := json.Marshal(profileDTO{FullName: p.FullName, DateOfBirth: p.DateOfBirth, ResidenceCountry: p.ResidenceCountry, Email: f.Email, CustomerRef: f.CustomerRef})
|
||||
if err != nil {
|
||||
return identity.Verification{}, merrors.OpaqueWrap(merrors.ErrInternal, err, "encode identity profile")
|
||||
}
|
||||
if err = writer.WriteField("profile", string(profile)); err != nil {
|
||||
return identity.Verification{}, err
|
||||
}
|
||||
if err = writer.WriteField("source_submission_ref", key); err != nil {
|
||||
return identity.Verification{}, err
|
||||
}
|
||||
for i, data := range [][]byte{document, selfie} {
|
||||
name := "document"
|
||||
if i == 1 {
|
||||
name = "selfie"
|
||||
}
|
||||
part, writeErr := writer.CreateFormFile(name, name)
|
||||
if writeErr != nil {
|
||||
return identity.Verification{}, writeErr
|
||||
}
|
||||
if _, writeErr = part.Write(data); writeErr != nil {
|
||||
return identity.Verification{}, writeErr
|
||||
}
|
||||
}
|
||||
if err = writer.Close(); err != nil {
|
||||
return identity.Verification{}, err
|
||||
}
|
||||
var dto verificationDTO
|
||||
if err = c.identityRequest(ctx, subject, "", http.MethodPost, writer.FormDataContentType(), body.Bytes(), &dto); err != nil {
|
||||
return identity.Verification{}, err
|
||||
}
|
||||
return mapVerification(dto, subject)
|
||||
}
|
||||
func (c *Client) Limits(ctx context.Context, subject string) (identity.Limits, error) {
|
||||
var dto policyDTO
|
||||
if err := c.identityRequest(ctx, subject, "/limits", http.MethodGet, "", nil, &dto); err != nil {
|
||||
return identity.Limits{}, err
|
||||
}
|
||||
if (dto.Tier != "basic" && dto.Tier != "verified") || dto.ProfileRef == "" || dto.Entries == nil {
|
||||
return identity.Limits{}, merrors.ServiceUnavailable("invalid limits response")
|
||||
}
|
||||
entries := make([]identity.Limit, 0, len(dto.Entries))
|
||||
for _, v := range dto.Entries {
|
||||
entries = append(entries, identity.Limit{Operation: v.Operation, Period: v.Period, Currency: v.Currency, Cap: v.Cap, Remaining: v.Remaining, Used: v.Used, Reserved: v.Reserved})
|
||||
}
|
||||
return identity.Limits{Tier: dto.Tier, ProfileRef: dto.ProfileRef, Entries: entries}, nil
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package sendico
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/identity"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestProofWireContractAndLimitsProjection(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Contains(t, r.Header.Get("Authorization"), "Bearer ")
|
||||
expected := "/api/v1/identity_facts/" + org + "/proofs/" + subject
|
||||
if r.URL.Path == expected+"/limits" {
|
||||
require.Equal(t, http.MethodGet, r.Method)
|
||||
_, _ = io.WriteString(w, `{"status":"success","data":{"tier":"verified","profile_ref":"profile_personal_verified","entries":[{"operation":"Transfer","period":"day","currency":"BTC","cap":"0.12345678","remaining":"0.10000001","used":"0.02","reserved":"0.00345677"}]}}`)
|
||||
return
|
||||
}
|
||||
require.Equal(t, expected, r.URL.Path)
|
||||
if r.Method == http.MethodPost {
|
||||
reader, err := r.MultipartReader()
|
||||
require.NoError(t, err)
|
||||
fields := map[string]string{}
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
require.NoError(t, err)
|
||||
data, err := io.ReadAll(part)
|
||||
require.NoError(t, err)
|
||||
fields[part.FormName()] = string(data)
|
||||
}
|
||||
require.Equal(t, "document bytes", fields["document"])
|
||||
require.Equal(t, "selfie bytes", fields["selfie"])
|
||||
require.Equal(t, "stable-source", fields["source_submission_ref"])
|
||||
var profile map[string]string
|
||||
require.NoError(t, json.Unmarshal([]byte(fields["profile"]), &profile))
|
||||
require.Equal(t, "Legal Name", profile["full_name"])
|
||||
require.Equal(t, "local-customer", profile["external_customer_ref"])
|
||||
require.Equal(t, "verified@example.test", profile["email"])
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"status":"success","data":{"subject_ref":"`+subject+`","submission_ref":"`+submission+`","status":"approved","upgrade_pending":true,"profile":{"full_name":"Legal Name","date_of_birth":"1990-04-23","residence_country":"PL"}}}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := Open(config(t, server.URL))
|
||||
require.NoError(t, err)
|
||||
result, err := client.SubmitVerification(t.Context(), subject, "stable-source", identity.Profile{FullName: "Legal Name", DateOfBirth: "1990-04-23", ResidenceCountry: "PL"}, identity.Facts{Email: "verified@example.test", CustomerRef: "local-customer"}, []byte("document bytes"), []byte("selfie bytes"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "pending", result.Status)
|
||||
require.True(t, result.UpgradePending)
|
||||
policy, err := client.Limits(t.Context(), subject)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "0.12345678", policy.Entries[0].Cap)
|
||||
require.Equal(t, "verified", policy.Tier)
|
||||
require.Equal(t, 4*time.Second, client.http.Timeout)
|
||||
}
|
||||
func TestProofRateLimitPreservesTypedRecoveryWithoutUpstreamBody(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Retry-After", "120")
|
||||
w.WriteHeader(429)
|
||||
_, _ = io.WriteString(w, "private identity assertions")
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := Open(config(t, server.URL))
|
||||
require.NoError(t, err)
|
||||
_, err = client.Verification(t.Context(), subject)
|
||||
require.Error(t, err)
|
||||
require.NotContains(t, err.Error(), "private identity assertions")
|
||||
throttle, ok := merrors.ThrottleDetailsFrom(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, 120*time.Second, throttle.RetryAfter)
|
||||
}
|
||||
Reference in new issue
Block a user