Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 39 additions & 2 deletions cmd/lfx-v1-sync-helper/handlers_users.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
73 changes: 73 additions & 0 deletions cmd/lfx-v1-sync-helper/handlers_users_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading