diff --git a/cmd/lfx-v1-sync-helper/handlers_users.go b/cmd/lfx-v1-sync-helper/handlers_users.go index 034fcee..dba3384 100644 --- a/cmd/lfx-v1-sync-helper/handlers_users.go +++ b/cmd/lfx-v1-sync-helper/handlers_users.go @@ -54,6 +54,10 @@ var ( deleteIndexKeyFn = deleteIndexKey ) +// handleMergedUserDelete dependencies, split out so tests can inject fakes +// without needing a live NATS connection. +var publishUserDeletedEventFn = publishUserDeletedEvent + // toKVKey normalizes a user-provided string and encodes it as a URL-safe base64 // key segment safe for NATS KV. Order: TrimSpace → ToLower → NFC → RawURLEncoding. // NFC unifies decomposed/precomposed Unicode (e.g. n\u0303 ≡ ñ) without semantic @@ -117,8 +121,9 @@ func handleMergedUserUpdate(ctx context.Context, key string, v1Data map[string]a // handleMergedUserDelete processes deletion of a merged user record: deletes // the username -> SFID secondary index so future lookups do not resolve a -// deleted user. Soft deletes and hard KV deletes both arrive here; v1Data is -// nil for a hard KV delete. +// deleted user, then publishes a NATS event so downstream v2 services can +// scrub the username from project and committee settings. +// Soft deletes and hard KV deletes both arrive here; v1Data is nil for a hard KV delete. // Returns true if the operation should be retried, false otherwise. func handleMergedUserDelete(ctx context.Context, key, userSfid string, v1Data map[string]any) bool { if v1Data == nil { @@ -146,9 +151,41 @@ func handleMergedUserDelete(ctx context.Context, key, userSfid string, v1Data ma // In practice the alternate email rows are deleted before or alongside the user row, so // handleAlternateEmailDelete cleans those up individually — but if the user is deleted // without its alternate emails being deleted first, those entries will be orphaned. + + if username != "" { + publishUserDeletedEventFn(ctx, key, username) + } + return false } +// userDeletedEvent is the payload published to "lfx.v1-sync-helper.user.deleted" when a +// merged user is soft-deleted. The committee and project services subscribe to this +// subject and scrub the username from settings writers/auditors (and committee members). +type userDeletedEvent struct { + Username string `json:"username"` +} + +const v1SyncHelperUserDeletedSubject = "lfx.v1-sync-helper.user.deleted" + +// publishUserDeletedEvent publishes a user-deleted NATS event. Best-effort: publish +// errors are logged and do not affect the delete handler's return value. +func publishUserDeletedEvent(ctx context.Context, key, username string) { + payload, err := json.Marshal(userDeletedEvent{Username: username}) + if err != nil { + logger.With(errKey, err, "key", key). + ErrorContext(ctx, "failed to marshal user-deleted event; username scrub skipped") + return + } + if err := natsConn.Publish(v1SyncHelperUserDeletedSubject, payload); err != nil { + logger.With(errKey, err, "key", key). + ErrorContext(ctx, "failed to publish user-deleted event; username scrub skipped") + return + } + logger.With("key", key, "username", username). + InfoContext(ctx, "published user-deleted event for v2 username scrub") +} + // syncMergedUserProfile calls syncProfileToAuth0Fn synchronously and returns // true if the error is retryable so the caller can NACK the JetStream message. func syncMergedUserProfile(ctx context.Context, key, auth0UserID string, v1Data map[string]any) bool { diff --git a/cmd/lfx-v1-sync-helper/handlers_users_test.go b/cmd/lfx-v1-sync-helper/handlers_users_test.go index 99ba06f..6e941d7 100644 --- a/cmd/lfx-v1-sync-helper/handlers_users_test.go +++ b/cmd/lfx-v1-sync-helper/handlers_users_test.go @@ -320,6 +320,79 @@ func TestExtractUsernameIndex(t *testing.T) { } } +// TestHandleMergedUserDeleteScrub verifies that handleMergedUserDelete triggers the +// username scrub (NATS publish) when a username is present in the payload. +func TestHandleMergedUserDeleteScrub(t *testing.T) { + origLogger := logger + origDeleteIndex := deleteIndexKeyFn + origPublish := publishUserDeletedEventFn + t.Cleanup(func() { + logger = origLogger + deleteIndexKeyFn = origDeleteIndex + publishUserDeletedEventFn = origPublish + }) + logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + deleteIndexKeyFn = func(_ context.Context, _ string) error { return nil } + + const ( + userSfid = "003ABC" + username = "alice" + ) + + tests := []struct { + name string + v1Data map[string]any + wantPublished bool + wantUsername string + }{ + { + name: "username present → publish event", + v1Data: map[string]any{ + "sfid": userSfid, + "username__c": username, + }, + wantPublished: true, + wantUsername: username, + }, + { + name: "no username → no publish", + v1Data: map[string]any{ + "sfid": userSfid, + }, + wantPublished: false, + }, + { + name: "nil v1Data (hard KV delete) → no publish", + v1Data: nil, + wantPublished: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var publishedUsername string + var publishCalled bool + + publishUserDeletedEventFn = func(_ context.Context, _, u string) { + publishCalled = true + publishedUsername = u + } + + got := handleMergedUserDelete(context.Background(), "test-key", userSfid, tc.v1Data) + + if got { + t.Errorf("handleMergedUserDelete() = true, want false") + } + if publishCalled != tc.wantPublished { + t.Errorf("publishCalled = %v, want %v", publishCalled, tc.wantPublished) + } + if tc.wantPublished && publishedUsername != tc.wantUsername { + t.Errorf("published username = %q, want %q", publishedUsername, tc.wantUsername) + } + }) + } +} + // TestExtractEmailIndex covers the field extraction for the alternate_email reindex phase. func TestExtractEmailIndex(t *testing.T) { tests := []struct {