Skip to content

Commit 1be77a9

Browse files
committed
fix(bb-auth-cognito): admin createUser attr-prefix + getUser groups (AWS parity)
Live sandbox e2e for the new admin runtime paths surfaced two mock-vs-AWS parity bugs the unit tests couldn't: - AWS admin.createUser passed custom attributes unprefixed, so Cognito rejected them ('attribute department is not defined in schema'). Now runs them through prefixCustomAttrs like signUp and the mock do. - AWS admin.getUser never populated AdminUser.groups (AdminGetUser doesn't return memberships), so the typed groups field was always empty on AWS. Now fetches them via AdminListGroupsForUser, matching the mock. Adds sandbox e2e coverage for the new surface: getUser attribute+group round-trip (Gap 1) and scan() with a Cognito ListUsers Filter (Gap 4), plus the backend routes they drive. Verified live (us-west-2): all 7 admin e2e tests pass.
1 parent 8d2027b commit 1be77a9

3 files changed

Lines changed: 81 additions & 1 deletion

File tree

packages/bb-auth-cognito/src/index.aws.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -774,7 +774,10 @@ export class AuthCognito<const O extends AuthCognitoOptions = AuthCognitoOptions
774774
createUser: async (username, init) => {
775775
this.assertAdminAction('lifecycle');
776776
try {
777-
const attrs: AttributeType[] = Object.entries(init?.attributes ?? {}).map(
777+
// Prefix declared custom attributes with `custom:` (matching signUp
778+
// and the mock) — Cognito rejects an unprefixed custom attr name as
779+
// "not defined in schema".
780+
const attrs: AttributeType[] = Object.entries(this.prefixCustomAttrs(init?.attributes ?? {})).map(
778781
([Name, Value]) => ({ Name, Value }),
779782
);
780783
const resp = await this.client.send(new AdminCreateUserCommand({
@@ -837,11 +840,24 @@ export class AuthCognito<const O extends AuthCognitoOptions = AuthCognitoOptions
837840
for (const a of (resp.UserAttributes ?? []) as AttributeType[]) {
838841
if (a.Name) attributes[a.Name] = a.Value ?? '';
839842
}
843+
// AdminGetUser does not return group memberships, so fetch them
844+
// separately to populate AdminUser.groups (matches the mock,
845+
// which reads groups from its in-memory state).
846+
const groups: string[] = [];
847+
let nextToken: string | undefined;
848+
do {
849+
const g = await this.client.send(new AdminListGroupsForUserCommand({
850+
UserPoolId: this.adminUserPoolId(), Username: username, NextToken: nextToken,
851+
}));
852+
for (const grp of g.Groups ?? []) if (grp.GroupName) groups.push(grp.GroupName);
853+
nextToken = g.NextToken;
854+
} while (nextToken);
840855
return {
841856
username: resp.Username ?? username,
842857
userSub: attributes['sub'] ?? '',
843858
enabled: resp.Enabled ?? true,
844859
attributes,
860+
groups: groups as GroupOf<AuthCognitoOptions>[],
845861
};
846862
} catch (e) {
847863
if (e instanceof Error && e.name === AuthCognitoErrors.UserNotFound) return null;

test-apps/comprehensive/aws-blocks/index.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -969,6 +969,31 @@ export const api = new ApiNamespace(scope, 'api', (context) => ({
969969
const u = await authC.admin.createUser(username, { temporaryPassword });
970970
return { username: u.username, enabled: u.enabled };
971971
},
972+
async authCAdminCreateUserWithDept(username: string, temporaryPassword: string, department: string) {
973+
// Seeds a declared custom attribute so authCAdminGetUser can round-trip it.
974+
const u = await authC.admin.createUser(username, {
975+
temporaryPassword,
976+
attributes: { department, email: `${username}@example.com` },
977+
});
978+
return { username: u.username, enabled: u.enabled };
979+
},
980+
async authCAdminGetUser(username: string) {
981+
const u = await authC.admin.getUser(username);
982+
if (!u) return null;
983+
// Return the typed reads so the e2e can assert the attribute/group round-trip.
984+
return {
985+
username: u.username,
986+
userSub: u.userSub,
987+
enabled: u.enabled,
988+
department: u.attributes['custom:department'] ?? null,
989+
groups: u.groups ?? [],
990+
};
991+
},
992+
async authCAdminScan(filter?: { attribute: string; match: 'startsWith' | 'equals'; value: string }) {
993+
const usernames: string[] = [];
994+
for await (const u of authC.admin.scan(filter)) usernames.push(u.username);
995+
return usernames;
996+
},
972997
async authCAdminSetPassword(username: string, password: string) {
973998
await authC.admin.setUserPassword(username, password, { permanent: true });
974999
return { success: true };

test-apps/comprehensive/test/auth-cognito-admin-sandbox.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,5 +110,44 @@ export function authCognitoAdminTests(getApi: () => typeof apiType) {
110110

111111
await assert.rejects(() => api.authCSignIn(u, PW));
112112
});
113+
114+
// ── Gap 1: typed reads round-trip real Cognito data ──────────────────
115+
test('admin.getUser round-trips custom attribute + group membership', async () => {
116+
const api = getApi();
117+
const u = uniqueUser();
118+
await api.authCAdminCreateUserWithDept(u, PW, 'engineering');
119+
await api.authCAdminAddToGroup(u, 'admins');
120+
121+
const got = await api.authCAdminGetUser(u);
122+
assert.ok(got, 'expected a user');
123+
assert.strictEqual(got.username, u);
124+
assert.strictEqual(got.department, 'engineering');
125+
assert.ok(got.groups.includes('admins'), `expected admins in ${JSON.stringify(got.groups)}`);
126+
assert.strictEqual(await api.authCAdminGetUser(`${u}-missing`), null);
127+
128+
await api.authCAdminDeleteUser(u);
129+
});
130+
131+
// ── Gap 4: scan filter is executed by Cognito (ListUsers Filter) ─────
132+
test('admin.scan with a startsWith filter narrows to matching users', async () => {
133+
const api = getApi();
134+
const prefix = `scanflt-${uniqueUser()}`;
135+
const a = `${prefix}-alpha`;
136+
const b = `${prefix}-beta`;
137+
await api.authCAdminCreateUser(a, PW);
138+
await api.authCAdminCreateUser(b, PW);
139+
140+
// Cognito validates this Filter expression server-side — the whole point
141+
// of exercising it live rather than in the in-memory mock.
142+
const matched = await api.authCAdminScan({ attribute: 'username', match: 'startsWith', value: prefix });
143+
assert.ok(matched.includes(a) && matched.includes(b), `expected both seeded users, got ${JSON.stringify(matched)}`);
144+
145+
// A prefix that matches neither returns an empty (or non-matching) set.
146+
const none = await api.authCAdminScan({ attribute: 'username', match: 'startsWith', value: `${prefix}-zzz` });
147+
assert.ok(!none.includes(a) && !none.includes(b), 'non-matching filter should exclude seeded users');
148+
149+
await api.authCAdminDeleteUser(a);
150+
await api.authCAdminDeleteUser(b);
151+
});
113152
});
114153
}

0 commit comments

Comments
 (0)