Skip to content

Commit 5998bd8

Browse files
committed
merge(main): resolve conflict in handlers_users.go
Keep our primary-email cache write (LFXV2-2645) alongside main's new isSoleQualifyingAlternateEmail logic (LFXV2-2662). The isPrimary block now caches the email and returns false (matching main's return change); the sole-qualifying-email block follows unchanged from main. Generated with [Claude Code](https://claude.ai/code) Signed-off-by: Andres Tobon <andrest2455@gmail.com>
2 parents 0caa31c + da4ab4b commit 5998bd8

13 files changed

Lines changed: 1224 additions & 71 deletions

CODEOWNERS

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
# Platform engineering group within LFX engineering. @emsearcy represents the LFX architecture team.
2-
* @linuxfoundation/lfx-platform @emsearcy
2+
* @linuxfoundation/lfx-v1-sync-helper

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,24 @@ lfx-v1-sync-helper --backfill-acs-project [--dry-run]
176176
lfx-v1-sync-helper --backfill-acs-org [--dry-run]
177177
```
178178

179+
**Committee-member reverse-mapping repair (`--backfill-committee-member-mappings`):**
180+
181+
Repairs committee-member reverse mappings (`committee_member.uid.*` in `v1-mappings`) that store the `platform-community__c` record sfid instead of the contact SFID (v1 API "MemberID") — the root cause of v1 committee-member deletes 404ing and leaving members on the committee / meeting invites (LFXV2-2673). Resolves the contact SFID from the `v1-objects` KV bucket; makes no calls to any other service. Writes are optimistic-concurrency guarded against the live sync-helper, idempotent, and safe to re-run.
182+
183+
Run once after deploying the fix, to repair already-affected mappings. A dry-run pass is recommended first:
184+
185+
```sh
186+
kubectl --context lfx-v2-prod -n v1-sync-helper apply -f manifests/backfill-committee-member-mappings-job.yaml
187+
```
188+
189+
Add `--dry-run` to the manifest args, apply, inspect logs (`inspected`/`poisoned`/`fixed`/`already_ok`/`unresolved`/`malformed`/`tombstoned`/`conflicted` counts), then re-apply without it for the live run.
190+
191+
Locally (with NATS port-forwarded):
192+
193+
```sh
194+
lfx-v1-sync-helper --backfill-committee-member-mappings [--dry-run]
195+
```
196+
179197
## Architecture Diagrams
180198

181199
Regarding the following sequence diagrams:

charts/lfx-v1-sync-helper/templates/app-deployment.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ spec:
2020
component: app
2121
template:
2222
metadata:
23+
{{- with .Values.podAnnotations }}
24+
annotations:
25+
{{- toYaml . | nindent 8 }}
26+
{{- end }}
2327
labels:
2428
app: {{ .Chart.Name }}
2529
app.kubernetes.io/name: {{ .Chart.Name }}

cmd/lfx-v1-sync-helper/backfill_committee_member_mappings.go

Lines changed: 396 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
// Copyright The Linux Foundation and each contributor to LFX.
2+
// SPDX-License-Identifier: MIT
3+
4+
// The lfx-v1-sync-helper service.
5+
package main
6+
7+
import "testing"
8+
9+
// Sample SFIDs/UUIDs used across the classification and parsing tests below.
10+
const (
11+
testProjectSFID = "a0941000002wBz9AAE"
12+
testCommitteeSFID = "a0941000002wCz9AAE"
13+
testRecordSFID = "51fde723-67df-4e0e-91c6-936d01d59559" // UUID: platform-community__c record sfid
14+
testContactSFID = "0031000001AbCdeAAB" // Salesforce ID: contact_name__c
15+
)
16+
17+
func TestClassifyReverseMapping(t *testing.T) {
18+
tests := []struct {
19+
name string
20+
data []byte
21+
want reverseMappingOutcome
22+
wantRec string // expected recordSFID, when applicable
23+
}{
24+
{
25+
name: "empty value is malformed, not tombstoned",
26+
data: []byte(""),
27+
want: reverseMappingMalformed,
28+
},
29+
{
30+
name: "explicit tombstone marker",
31+
data: []byte(tombstoneMarker),
32+
want: reverseMappingTombstoned,
33+
},
34+
{
35+
name: "legacy 3-field mapping with UUID third field needs a fix",
36+
data: []byte(testProjectSFID + ":" + testCommitteeSFID + ":" + testRecordSFID),
37+
want: reverseMappingNeedsFix,
38+
},
39+
{
40+
name: "legacy 3-field mapping with contact SFID third field is already OK",
41+
data: []byte(testProjectSFID + ":" + testCommitteeSFID + ":" + testContactSFID),
42+
want: reverseMappingAlreadyOK,
43+
},
44+
{
45+
name: "3-field mapping with empty third field is malformed",
46+
data: []byte(testProjectSFID + ":" + testCommitteeSFID + ":"),
47+
want: reverseMappingMalformed,
48+
},
49+
{
50+
name: "value with no colons is malformed",
51+
data: []byte("not-a-mapping"),
52+
want: reverseMappingMalformed,
53+
},
54+
{
55+
name: "value with one colon is malformed",
56+
data: []byte(testProjectSFID + ":" + testCommitteeSFID),
57+
want: reverseMappingMalformed,
58+
},
59+
}
60+
61+
for _, tt := range tests {
62+
t.Run(tt.name, func(t *testing.T) {
63+
got := classifyReverseMapping(tt.data)
64+
if got.outcome != tt.want {
65+
t.Fatalf("classifyReverseMapping(%q).outcome = %v, want %v", tt.data, got.outcome, tt.want)
66+
}
67+
})
68+
}
69+
}
70+
71+
func TestClassifyReverseMapping_NeedsFixCarriesFields(t *testing.T) {
72+
data := []byte(testProjectSFID + ":" + testCommitteeSFID + ":" + testRecordSFID)
73+
got := classifyReverseMapping(data)
74+
75+
if got.outcome != reverseMappingNeedsFix {
76+
t.Fatalf("outcome = %v, want %v", got.outcome, reverseMappingNeedsFix)
77+
}
78+
if got.projectSFID != testProjectSFID {
79+
t.Errorf("projectSFID = %q, want %q", got.projectSFID, testProjectSFID)
80+
}
81+
if got.committeeSFID != testCommitteeSFID {
82+
t.Errorf("committeeSFID = %q, want %q", got.committeeSFID, testCommitteeSFID)
83+
}
84+
if got.recordSFID != testRecordSFID {
85+
t.Errorf("recordSFID = %q, want %q", got.recordSFID, testRecordSFID)
86+
}
87+
}
88+
89+
func TestBuildRepairedReverseMappingValue(t *testing.T) {
90+
got := buildRepairedReverseMappingValue(testProjectSFID, testCommitteeSFID, testContactSFID)
91+
want := testProjectSFID + ":" + testCommitteeSFID + ":" + testContactSFID
92+
if got != want {
93+
t.Fatalf("buildRepairedReverseMappingValue() = %q, want %q", got, want)
94+
}
95+
96+
// The repaired value must itself classify as already OK and re-parse to the same
97+
// contact SFID, so a repaired mapping is stable under a second backfill pass.
98+
class := classifyReverseMapping([]byte(got))
99+
if class.outcome != reverseMappingAlreadyOK {
100+
t.Fatalf("classifyReverseMapping(repaired value).outcome = %v, want %v", class.outcome, reverseMappingAlreadyOK)
101+
}
102+
103+
gotProject, gotCommittee, gotRecord, gotContact, ok := parseCommitteeMemberReverseMapping(got)
104+
if !ok {
105+
t.Fatalf("parseCommitteeMemberReverseMapping(%q) failed to parse", got)
106+
}
107+
if gotProject != testProjectSFID || gotCommittee != testCommitteeSFID || gotRecord != "" || gotContact != testContactSFID {
108+
t.Fatalf("parseCommitteeMemberReverseMapping(%q) = (%q, %q, %q, %q), want (%q, %q, %q, %q)",
109+
got, gotProject, gotCommittee, gotRecord, gotContact,
110+
testProjectSFID, testCommitteeSFID, "", testContactSFID)
111+
}
112+
}
113+
114+
func TestIsUUID(t *testing.T) {
115+
tests := []struct {
116+
name string
117+
s string
118+
want bool
119+
}{
120+
{"canonical lowercase UUID", "51fde723-67df-4e0e-91c6-936d01d59559", true},
121+
{"canonical uppercase UUID", "51FDE723-67DF-4E0E-91C6-936D01D59559", true},
122+
{"salesforce 18-char id", testContactSFID, false},
123+
{"salesforce 15-char id", "a094100000TFAKE", false},
124+
{"empty string", "", false},
125+
{"wrong length", "51fde723-67df-4e0e-91c6-936d01d5955", false},
126+
{"missing hyphens", "51fde72367df4e0e91c6936d01d59559", false},
127+
{"hyphens in wrong place", "51fde7236-7df-4e0e-91c6-936d01d59559", false},
128+
{"non-hex character", "51fde723-67df-4e0e-91c6-936d01d5955g", false},
129+
}
130+
131+
for _, tt := range tests {
132+
t.Run(tt.name, func(t *testing.T) {
133+
if got := isUUID(tt.s); got != tt.want {
134+
t.Errorf("isUUID(%q) = %v, want %v", tt.s, got, tt.want)
135+
}
136+
})
137+
}
138+
}
139+
140+
func TestParseCommitteeMemberReverseMapping(t *testing.T) {
141+
tests := []struct {
142+
name string
143+
s string
144+
wantOK bool
145+
wantProject string
146+
wantCommittee string
147+
wantRecord string
148+
wantContact string
149+
}{
150+
{
151+
name: "legacy 3-field mapping with record sfid",
152+
s: testProjectSFID + ":" + testCommitteeSFID + ":" + testRecordSFID,
153+
wantOK: true,
154+
wantProject: testProjectSFID,
155+
wantCommittee: testCommitteeSFID,
156+
wantRecord: testRecordSFID,
157+
wantContact: "",
158+
},
159+
{
160+
name: "legacy 3-field mapping with contact sfid",
161+
s: testProjectSFID + ":" + testCommitteeSFID + ":" + testContactSFID,
162+
wantOK: true,
163+
wantProject: testProjectSFID,
164+
wantCommittee: testCommitteeSFID,
165+
wantRecord: "",
166+
wantContact: testContactSFID,
167+
},
168+
{
169+
name: "too few fields",
170+
s: testProjectSFID + ":" + testCommitteeSFID,
171+
wantOK: false,
172+
},
173+
{
174+
// splitThreeParts only splits on the first two colons, so everything after
175+
// the second colon (including further colons) folds into the third field;
176+
// the result is neither a UUID nor a valid SFID, so it must be rejected as
177+
// malformed rather than misclassified as a usable contact SFID.
178+
name: "extra colons fold into an invalid third field",
179+
s: testProjectSFID + ":" + testCommitteeSFID + ":" + testContactSFID + ":extra",
180+
wantOK: false,
181+
},
182+
{
183+
name: "third field is neither a UUID nor a valid SFID",
184+
s: testProjectSFID + ":" + testCommitteeSFID + ":not-a-sfid",
185+
wantOK: false,
186+
},
187+
{
188+
name: "no colons",
189+
s: "not-a-mapping",
190+
wantOK: false,
191+
},
192+
}
193+
194+
for _, tt := range tests {
195+
t.Run(tt.name, func(t *testing.T) {
196+
gotProject, gotCommittee, gotRecord, gotContact, gotOK := parseCommitteeMemberReverseMapping(tt.s)
197+
if gotOK != tt.wantOK {
198+
t.Fatalf("parseCommitteeMemberReverseMapping(%q) ok = %v, want %v", tt.s, gotOK, tt.wantOK)
199+
}
200+
if !tt.wantOK {
201+
return
202+
}
203+
if gotProject != tt.wantProject || gotCommittee != tt.wantCommittee || gotRecord != tt.wantRecord || gotContact != tt.wantContact {
204+
t.Fatalf("parseCommitteeMemberReverseMapping(%q) = (%q, %q, %q, %q), want (%q, %q, %q, %q)",
205+
tt.s, gotProject, gotCommittee, gotRecord, gotContact,
206+
tt.wantProject, tt.wantCommittee, tt.wantRecord, tt.wantContact)
207+
}
208+
})
209+
}
210+
}

0 commit comments

Comments
 (0)