191 lines
7.5 KiB
Go
191 lines
7.5 KiB
Go
package sendico
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.sendico.io/tech/sxchnge/product/identity"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
const org = "507f1f77bcf86cd799439011"
|
|
const source = "507f1f77bcf86cd799439012"
|
|
const subject = "507f1f77bcf86cd799439013"
|
|
const submission = "507f1f77bcf86cd799439014"
|
|
|
|
func credential(t *testing.T, id string, expiry int64) string {
|
|
t.Helper()
|
|
raw, err := json.Marshal(map[string]any{"id": id, "exp": expiry})
|
|
require.NoError(t, err)
|
|
return "header." + base64.RawURLEncoding.EncodeToString(raw) + ".signature"
|
|
}
|
|
func config(t *testing.T, endpoint string) Config {
|
|
t.Helper()
|
|
file := filepath.Join(t.TempDir(), "token")
|
|
require.NoError(t, os.WriteFile(file, []byte(credential(t, source, time.Now().Add(time.Hour).Unix())), 0600))
|
|
return Config{APIURL: endpoint + "/api/v1", OrganizationID: org, SourceID: source, TokenFile: file}
|
|
}
|
|
|
|
func TestSignupWireContractAndCredentialRotation(t *testing.T) {
|
|
paths := []string{}
|
|
var token string
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
require.Equal(t, http.MethodPost, r.Method)
|
|
require.Equal(t, "Bearer "+token, r.Header.Get("Authorization"))
|
|
paths = append(paths, r.URL.Path)
|
|
body, err := io.ReadAll(r.Body)
|
|
require.NoError(t, err)
|
|
if strings.Contains(r.URL.Path, "/individuals/") {
|
|
require.Equal(t, "registration-request", r.Header.Get("X-Request-ID"))
|
|
require.Empty(t, body)
|
|
w.WriteHeader(http.StatusCreated)
|
|
_, _ = fmt.Fprintf(w, `{"status":"success","data":{"subject_ref":%q,"registered_at":"2026-10-07T10:30:00.123Z"}}`, subject)
|
|
} else {
|
|
require.JSONEq(t, `{"subject_ref":"`+subject+`","source_submission_ref":"signup-1","facts":{"full_name":"Ada Lovelace","email":"ada@example.com","external_customer_ref":"local-user"}}`, string(body))
|
|
_, _ = fmt.Fprintf(w, `{"status":"success","data":{"subject_ref":%q,"submission_ref":%q,"accepted_at":"2026-10-07T10:30:00.123Z"}}`, subject, submission)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
cfg := config(t, server.URL)
|
|
token = credential(t, source, time.Now().Add(time.Hour).Unix())
|
|
client, err := Open(cfg)
|
|
require.NoError(t, err)
|
|
ref, err := client.Register(context.Background(), "registration-request")
|
|
require.NoError(t, err)
|
|
require.Equal(t, subject, ref)
|
|
// File rotation takes effect without restarting; account identity stays fixed.
|
|
token = credential(t, source, time.Now().Add(2*time.Hour).Unix())
|
|
require.NoError(t, os.WriteFile(cfg.TokenFile, []byte(token), 0600))
|
|
receipt, err := client.Submit(context.Background(), subject, "signup-1", identity.Facts{FullName: "Ada Lovelace", Email: "ada@example.com", CustomerRef: "local-user"})
|
|
require.NoError(t, err)
|
|
require.Equal(t, submission, receipt.SubmissionRef)
|
|
require.Equal(t, subject, receipt.SubjectRef)
|
|
require.Equal(t, []string{"/api/v1/individuals/" + org, "/api/v1/identity_facts/" + org}, paths)
|
|
require.NoError(t, os.WriteFile(cfg.TokenFile, []byte(credential(t, org, time.Now().Add(time.Hour).Unix())), 0600))
|
|
_, err = client.Register(context.Background(), "registration-request")
|
|
require.Error(t, err)
|
|
require.Len(t, paths, 2)
|
|
}
|
|
|
|
func TestRejectInvalidConfiguration(t *testing.T) {
|
|
for _, endpoint := range []string{"http://remote.example", "https://user:pass@api.example", "https://api.example?secret=x", "https://api.example/#fragment", "//api.example", "ftp://localhost"} {
|
|
_, err := Open(config(t, endpoint))
|
|
require.Error(t, err, endpoint)
|
|
}
|
|
cfg := config(t, "https://api.example")
|
|
cfg.OrganizationID = "../other"
|
|
_, err := Open(cfg)
|
|
require.Error(t, err)
|
|
cfg = config(t, "https://api.example")
|
|
cfg.SourceID = ""
|
|
_, err = Open(cfg)
|
|
require.Error(t, err)
|
|
cfg = config(t, "https://api.example")
|
|
require.NoError(t, os.WriteFile(cfg.TokenFile, []byte(credential(t, source, 1)), 0600))
|
|
_, err = Open(cfg)
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestSignupReceiptsRequireOperationStatus(t *testing.T) {
|
|
for _, status := range []int{200, 201, 202, 204, 206, 300, 403, 409, 503} {
|
|
t.Run(http.StatusText(status), func(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(status)
|
|
_, _ = fmt.Fprintf(w, `{"status":"success","data":{"subject_ref":%q,"registered_at":"2026-10-07T10:30:00Z","submission_ref":%q,"accepted_at":"2026-10-07T10:30:00Z"}}`, subject, submission)
|
|
}))
|
|
defer server.Close()
|
|
client, err := Open(config(t, server.URL))
|
|
require.NoError(t, err)
|
|
ref, err := client.Register(context.Background(), "registration-request")
|
|
if status == http.StatusCreated {
|
|
require.NoError(t, err)
|
|
require.Equal(t, subject, ref)
|
|
} else {
|
|
require.Error(t, err)
|
|
require.Empty(t, ref)
|
|
}
|
|
receipt, err := client.Submit(context.Background(), subject, "key", identity.Facts{})
|
|
if status == http.StatusOK {
|
|
require.NoError(t, err)
|
|
require.Equal(t, submission, receipt.SubmissionRef)
|
|
} else {
|
|
require.Error(t, err)
|
|
require.Empty(t, receipt)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUpstreamFailuresNeverProduceReceiptsOrLeakBodies(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
status int
|
|
body string
|
|
}{
|
|
{"denied", 403, `{"secret":"private-assertion"}`},
|
|
{"conflict", 409, `{"secret":"private-assertion"}`},
|
|
{"unavailable", 503, `{"secret":"private-assertion"}`},
|
|
{"malformed", 200, `{"private-assertion":`},
|
|
{"empty", 200, `{"status":"success","data":{}}`},
|
|
{"wrong-status", 200, `{"status":"error","data":{}}`},
|
|
{"oversized", 200, `{"padding":"` + strings.Repeat("x", 17000) + `"}`},
|
|
{"wrong-subject", 200, `{"status":"success","data":{"subject_ref":"` + org + `","submission_ref":"` + submission + `","accepted_at":"2026-10-07T10:30:00Z"}}`},
|
|
{"trailing", 200, `{"status":"success","data":{}} {}`},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
status := tc.status
|
|
if status == http.StatusOK && strings.Contains(r.URL.Path, "/individuals/") {
|
|
status = http.StatusCreated
|
|
}
|
|
w.WriteHeader(status)
|
|
_, _ = io.WriteString(w, tc.body)
|
|
}))
|
|
defer server.Close()
|
|
client, err := Open(config(t, server.URL))
|
|
require.NoError(t, err)
|
|
_, err = client.Submit(context.Background(), subject, "key", identity.Facts{})
|
|
require.Error(t, err)
|
|
require.NotContains(t, err.Error(), "private-assertion")
|
|
_, err = client.Register(context.Background(), "registration-request")
|
|
require.Error(t, err)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRedirectAndTimeoutDoNotRepeatRegistration(t *testing.T) {
|
|
var calls int
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
calls++
|
|
http.Redirect(w, r, "/redirected", http.StatusTemporaryRedirect)
|
|
}))
|
|
defer server.Close()
|
|
client, err := Open(config(t, server.URL))
|
|
require.NoError(t, err)
|
|
_, err = client.Register(context.Background(), "registration-request")
|
|
require.Error(t, err)
|
|
require.Equal(t, 1, calls)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
_, err = client.Register(ctx, "registration-request")
|
|
require.Error(t, err)
|
|
require.Equal(t, 1, calls)
|
|
timeoutServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-r.Context().Done() }))
|
|
defer timeoutServer.Close()
|
|
client, err = Open(config(t, timeoutServer.URL))
|
|
require.NoError(t, err)
|
|
client.http.Timeout = 20 * time.Millisecond
|
|
_, err = client.Register(context.Background(), "timeout-request")
|
|
require.Error(t, err)
|
|
}
|