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) }