Fix signup registration receipts and activation worker lifetime (#57)
This commit is contained in:
1 parent
bd675357bb
commit
abca15f047
6 files changed
+148
-13
No files matched your search
@@ -45,8 +45,6 @@ func Run(ctx context.Context, c Config, logger mlogger.Logger) error {
|
||||
defer cancel()
|
||||
_ = client.Disconnect(cleanup)
|
||||
}()
|
||||
startup, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
repo, err := store.New(logger, client.Database())
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -57,7 +55,7 @@ func Run(ctx context.Context, c Config, logger mlogger.Logger) error {
|
||||
}
|
||||
defer rt.Close()
|
||||
svc := service.New(repo, catalog, sender, string(c.Email.Driver), logger)
|
||||
broker, err := bus.New(startup, rt.Durable, rt.DurableAdmin, repo, svc, logger, c.Queue)
|
||||
broker, err := bus.New(ctx, rt.Durable, rt.DurableAdmin, repo, svc, logger, c.Queue)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -35,9 +35,13 @@ func New(ctx context.Context, transport reliable.Transport, admin reliable.Admin
|
||||
}
|
||||
settings := reliable.DurableSettings{Enabled: true, Stream: config.Stream, Provisioning: config.Provisioning}
|
||||
consumer := reliable.DurableConsumerSettings{Durable: "email", Subject: subject, AckWaitSeconds: 60, MaxDeliver: -1, DeliverPolicy: reliable.DurableDeliverAll}
|
||||
if err := admin.EnsureConsumerResources(ctx, settings, consumer); err != nil {
|
||||
startup, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
if err := admin.EnsureConsumerResources(startup, settings, consumer); err != nil {
|
||||
return nil, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "configure notification messaging")
|
||||
}
|
||||
// NATS retains this context and unsubscribes when it ends. The worker must
|
||||
// live until service shutdown, independently of the provisioning deadline.
|
||||
subscription, err := transport.OpenConsumer(ctx, settings, consumer)
|
||||
if err != nil {
|
||||
return nil, merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "subscribe notification worker")
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/messaging/reliable"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type outbox struct{ jobs []model.Notification }
|
||||
@@ -23,6 +24,50 @@ type publisher struct {
|
||||
records []reliable.Record
|
||||
}
|
||||
|
||||
type consumerTransport struct {
|
||||
reliable.Transport
|
||||
reliable.Subscription
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (t *consumerTransport) OpenConsumer(ctx context.Context, _ reliable.DurableSettings, _ reliable.DurableConsumerSettings) (reliable.Subscription, error) {
|
||||
t.ctx = ctx
|
||||
return t.Subscription, nil
|
||||
}
|
||||
|
||||
type consumerAdmin struct {
|
||||
reliable.Admin
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (a *consumerAdmin) EnsureConsumerResources(ctx context.Context, _ reliable.DurableSettings, _ reliable.DurableConsumerSettings) error {
|
||||
a.ctx = ctx
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestSubscriptionOutlivesProvisioning(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
transport := &consumerTransport{}
|
||||
admin := &consumerAdmin{}
|
||||
if _, err := New(ctx, transport, admin, &outbox{}, nil, zap.NewNop(), DefaultConfig()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if admin.ctx.Err() != context.Canceled {
|
||||
t.Fatal("provisioning context was not released")
|
||||
}
|
||||
if _, ok := admin.ctx.Deadline(); !ok {
|
||||
t.Fatal("provisioning has no deadline")
|
||||
}
|
||||
if err := transport.ctx.Err(); err != nil {
|
||||
t.Fatalf("subscription ended with provisioning: %v", err)
|
||||
}
|
||||
cancel()
|
||||
if transport.ctx.Err() != context.Canceled {
|
||||
t.Fatal("subscription does not follow service shutdown")
|
||||
}
|
||||
}
|
||||
|
||||
func (p *publisher) Publish(_ context.Context, r reliable.Record) error {
|
||||
if p.fail {
|
||||
return merrors.ServiceUnavailable("offline")
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func (c *Client) post(ctx context.Context, resource, requestRef string, payload any) (receiptDTO, error) {
|
||||
func (c *Client) post(ctx context.Context, resource, requestRef string, payload any, expectedStatus int) (receiptDTO, error) {
|
||||
var receipt receiptDTO
|
||||
token, err := c.token()
|
||||
if err != nil {
|
||||
@@ -38,7 +38,7 @@ func (c *Client) post(ctx context.Context, resource, requestRef string, payload
|
||||
}
|
||||
defer func() { _ = response.Body.Close() }()
|
||||
// Never decode or expose upstream error bodies, which can contain assertions.
|
||||
if response.StatusCode != http.StatusOK {
|
||||
if response.StatusCode != expectedStatus {
|
||||
return receipt, merrors.ServiceUnavailable("Sendico request was not accepted")
|
||||
}
|
||||
decoder := json.NewDecoder(io.LimitReader(response.Body, 16385))
|
||||
@@ -54,14 +54,14 @@ func (c *Client) post(ctx context.Context, resource, requestRef string, payload
|
||||
}
|
||||
|
||||
func (c *Client) Register(ctx context.Context, requestRef string) (string, error) {
|
||||
dto, err := c.post(ctx, "individuals", requestRef, nil)
|
||||
dto, err := c.post(ctx, "individuals", requestRef, nil, http.StatusCreated)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return mapSubject(dto)
|
||||
}
|
||||
func (c *Client) Submit(ctx context.Context, subject, source string, facts identity.Facts) (identity.Receipt, error) {
|
||||
dto, err := c.post(ctx, "identity_facts", bson.NewObjectID().Hex(), mapFacts(subject, source, facts))
|
||||
dto, err := c.post(ctx, "identity_facts", bson.NewObjectID().Hex(), mapFacts(subject, source, facts), http.StatusOK)
|
||||
if err != nil {
|
||||
return identity.Receipt{}, err
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ func TestSignupWireContractAndCredentialRotation(t *testing.T) {
|
||||
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))
|
||||
@@ -95,6 +96,36 @@ func TestRejectInvalidConfiguration(t *testing.T) {
|
||||
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
|
||||
@@ -113,7 +144,11 @@ func TestUpstreamFailuresNeverProduceReceiptsOrLeakBodies(t *testing.T) {
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(tc.status)
|
||||
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()
|
||||
|
||||
Reference in new issue
Block a user