Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/util/auth-sso-account-restrictions.test.ts
6020 views
1
/*
2
* This file is part of CoCalc: Copyright © 2025 Sagemath, Inc.
3
* License: MS-RSL – see LICENSE.md for details
4
*/
5
6
/**
7
* Unit tests for SSO account editing restrictions.
8
*
9
* This tests the validation logic that prevents users from editing their
10
* email address and name when their account is controlled by an exclusive
11
* SSO strategy with updateOnLogin enabled.
12
*
13
* The actual implementation is in:
14
* - packages/util/db-schema/accounts.ts (user_query check_hook)
15
* - packages/server/auth/sso/passport-login.ts (SSO update logic)
16
*/
17
18
import { checkRequiredSSO } from "./auth-check-required-sso";
19
import { Strategy } from "./types/sso";
20
21
const BASE_STRATEGY: Readonly<
22
Omit<Strategy, "name" | "exclusiveDomains" | "updateOnLogin">
23
> = {
24
display: "Test SSO",
25
backgroundColor: "#000",
26
public: false,
27
doNotHide: true,
28
} as const;
29
30
describe("SSO Account Editing Restrictions", () => {
31
describe("Email Address Protection (Always Enforced)", () => {
32
test("user with exclusive SSO domain cannot change email", () => {
33
const strategy: Strategy = {
34
...BASE_STRATEGY,
35
name: "university",
36
exclusiveDomains: ["university.edu"],
37
updateOnLogin: false, // Doesn't matter for email
38
};
39
40
const result = checkRequiredSSO({
41
email: "[email protected]",
42
strategies: [strategy],
43
});
44
45
// If strategy is found, email changes should be blocked
46
expect(result).toBeDefined();
47
expect(result?.name).toBe("university");
48
});
49
50
test("user without exclusive SSO can change email", () => {
51
const strategy: Strategy = {
52
...BASE_STRATEGY,
53
name: "university",
54
exclusiveDomains: ["university.edu"],
55
updateOnLogin: false,
56
};
57
58
const result = checkRequiredSSO({
59
email: "[email protected]", // Different domain
60
strategies: [strategy],
61
});
62
63
// No strategy matched, email changes allowed
64
expect(result).toBeUndefined();
65
});
66
67
test("subdomain users also cannot change email", () => {
68
const strategy: Strategy = {
69
...BASE_STRATEGY,
70
name: "university",
71
exclusiveDomains: ["university.edu"],
72
updateOnLogin: false,
73
};
74
75
const result = checkRequiredSSO({
76
email: "[email protected]", // Subdomain
77
strategies: [strategy],
78
});
79
80
expect(result).toBeDefined();
81
expect(result?.name).toBe("university");
82
});
83
});
84
85
describe("Name Protection (When updateOnLogin: true)", () => {
86
test("updateOnLogin: true blocks name changes", () => {
87
const strategy: Strategy = {
88
...BASE_STRATEGY,
89
name: "university",
90
exclusiveDomains: ["university.edu"],
91
updateOnLogin: true, // KEY: blocks name changes
92
};
93
94
const result = checkRequiredSSO({
95
email: "[email protected]",
96
strategies: [strategy],
97
});
98
99
expect(result).toBeDefined();
100
expect(result?.updateOnLogin).toBe(true);
101
// In actual implementation (accounts.ts:750-759), this blocks first_name and last_name edits
102
});
103
104
test("updateOnLogin: false allows name changes", () => {
105
const strategy: Strategy = {
106
...BASE_STRATEGY,
107
name: "university",
108
exclusiveDomains: ["university.edu"],
109
updateOnLogin: false, // KEY: allows name changes
110
};
111
112
const result = checkRequiredSSO({
113
email: "[email protected]",
114
strategies: [strategy],
115
});
116
117
expect(result).toBeDefined();
118
expect(result?.updateOnLogin).toBe(false);
119
// In actual implementation, email is still blocked but names are allowed
120
});
121
122
test("validation logic type checks", () => {
123
// This tests the fix for the typeof bug in accounts.ts:753
124
const strategy: Strategy = {
125
...BASE_STRATEGY,
126
name: "university",
127
exclusiveDomains: ["university.edu"],
128
updateOnLogin: true,
129
};
130
131
const result = checkRequiredSSO({
132
email: "[email protected]",
133
strategies: [strategy],
134
});
135
136
// The validation should check:
137
// typeof obj.first_name === "string" || typeof obj.last_name === "string"
138
// NOT: obj.last_name === "string" (literal string comparison)
139
140
expect(result?.updateOnLogin).toBe(true);
141
142
// Simulate validation logic
143
const obj = { first_name: "John", last_name: "Doe" };
144
const shouldBlock =
145
result?.updateOnLogin &&
146
(typeof obj.first_name === "string" ||
147
typeof obj.last_name === "string");
148
149
expect(shouldBlock).toBe(true);
150
151
// Test the bug: if we use literal comparison, it fails
152
const buggyCheck =
153
result?.updateOnLogin &&
154
(typeof obj.first_name === "string" || obj.last_name === "string");
155
156
expect(buggyCheck).toBe(true); // Still true because first_name check passes
157
158
// But if only last_name is set, the bug becomes obvious
159
const obj2 = { last_name: "Doe" };
160
const correctCheck =
161
result?.updateOnLogin && typeof obj2.last_name === "string";
162
const buggyCheck2 = result?.updateOnLogin && obj2.last_name === "string";
163
164
expect(correctCheck).toBe(true); // Correct: blocks any string
165
expect(buggyCheck2).toBe(false); // Bug: only blocks if last_name === "string" literally
166
});
167
});
168
169
describe("Wildcard Domain Handling", () => {
170
test("wildcard matches all domains", () => {
171
const wildcardStrategy: Strategy = {
172
...BASE_STRATEGY,
173
name: "corporate",
174
exclusiveDomains: ["*"],
175
updateOnLogin: true,
176
};
177
178
const result = checkRequiredSSO({
179
email: "[email protected]",
180
strategies: [wildcardStrategy],
181
});
182
183
expect(result).toBeDefined();
184
expect(result?.name).toBe("corporate");
185
expect(result?.updateOnLogin).toBe(true);
186
});
187
188
test("specific domain takes precedence over wildcard", () => {
189
const specificStrategy: Strategy = {
190
...BASE_STRATEGY,
191
name: "university",
192
exclusiveDomains: ["university.edu"],
193
updateOnLogin: true,
194
};
195
196
const wildcardStrategy: Strategy = {
197
...BASE_STRATEGY,
198
name: "corporate",
199
exclusiveDomains: ["*"],
200
updateOnLogin: false,
201
};
202
203
const result = checkRequiredSSO({
204
email: "[email protected]",
205
strategies: [specificStrategy, wildcardStrategy],
206
});
207
208
// Specific strategy should match first
209
expect(result?.name).toBe("university");
210
expect(result?.updateOnLogin).toBe(true);
211
});
212
});
213
214
describe("Edge Cases", () => {
215
test("empty email returns no strategy", () => {
216
const strategy: Strategy = {
217
...BASE_STRATEGY,
218
name: "university",
219
exclusiveDomains: ["university.edu"],
220
updateOnLogin: true,
221
};
222
223
const result = checkRequiredSSO({
224
email: "",
225
strategies: [strategy],
226
});
227
228
expect(result).toBeUndefined();
229
});
230
231
test("invalid email returns no strategy", () => {
232
const strategy: Strategy = {
233
...BASE_STRATEGY,
234
name: "university",
235
exclusiveDomains: ["university.edu"],
236
updateOnLogin: true,
237
};
238
239
const result = checkRequiredSSO({
240
email: "not-an-email",
241
strategies: [strategy],
242
});
243
244
expect(result).toBeUndefined();
245
});
246
247
test("case insensitive matching", () => {
248
const strategy: Strategy = {
249
...BASE_STRATEGY,
250
name: "university",
251
exclusiveDomains: ["university.edu"], // lowercase (normalized from DB)
252
updateOnLogin: true,
253
};
254
255
const result = checkRequiredSSO({
256
email: "[email protected]", // uppercase
257
strategies: [strategy],
258
});
259
260
expect(result).toBeDefined();
261
expect(result?.name).toBe("university");
262
});
263
264
test("multiple domains in single strategy", () => {
265
const strategy: Strategy = {
266
...BASE_STRATEGY,
267
name: "enterprise",
268
exclusiveDomains: ["company.com", "company.net", "company.org"],
269
updateOnLogin: true,
270
};
271
272
const result1 = checkRequiredSSO({
273
email: "[email protected]",
274
strategies: [strategy],
275
});
276
const result2 = checkRequiredSSO({
277
email: "[email protected]",
278
strategies: [strategy],
279
});
280
const result3 = checkRequiredSSO({
281
email: "[email protected]",
282
strategies: [strategy],
283
});
284
285
expect(result1?.name).toBe("enterprise");
286
expect(result2?.name).toBe("enterprise");
287
expect(result3?.name).toBe("enterprise");
288
});
289
});
290
291
describe("Integration Scenarios (Mock)", () => {
292
/**
293
* These tests simulate the check_hook logic from accounts.ts:732-762
294
*/
295
296
test("user_query set operation: email change blocked", () => {
297
const currentEmail = "[email protected]";
298
const strategy: Strategy = {
299
...BASE_STRATEGY,
300
name: "university",
301
exclusiveDomains: ["university.edu"],
302
updateOnLogin: false,
303
};
304
305
const strategies = [strategy];
306
const attemptedChange = { email_address: "[email protected]" };
307
308
// Simulate check_hook logic
309
const matchedStrategy = checkRequiredSSO({
310
email: currentEmail,
311
strategies,
312
});
313
314
if (
315
matchedStrategy != null &&
316
typeof attemptedChange.email_address === "string"
317
) {
318
// Should trigger error: "You are not allowed to change your email address."
319
expect(matchedStrategy).toBeDefined();
320
expect(typeof attemptedChange.email_address).toBe("string");
321
}
322
});
323
324
test("user_query set operation: name change blocked when updateOnLogin true", () => {
325
const currentEmail = "[email protected]";
326
const strategy: Strategy = {
327
...BASE_STRATEGY,
328
name: "university",
329
exclusiveDomains: ["university.edu"],
330
updateOnLogin: true, // Blocks name changes
331
};
332
333
const strategies = [strategy];
334
const attemptedChange = { first_name: "Jane", last_name: "Smith" };
335
336
// Simulate check_hook logic (accounts.ts:750-759)
337
const matchedStrategy = checkRequiredSSO({
338
email: currentEmail,
339
strategies,
340
});
341
342
if (
343
matchedStrategy != null &&
344
matchedStrategy.updateOnLogin &&
345
(typeof attemptedChange.first_name === "string" ||
346
typeof attemptedChange.last_name === "string")
347
) {
348
// Should trigger error: "You are not allowed to change your first or last name..."
349
expect(matchedStrategy.updateOnLogin).toBe(true);
350
expect(
351
typeof attemptedChange.first_name === "string" ||
352
typeof attemptedChange.last_name === "string",
353
).toBe(true);
354
}
355
});
356
357
test("user_query set operation: name change allowed when updateOnLogin false", () => {
358
const currentEmail = "[email protected]";
359
const strategy: Strategy = {
360
...BASE_STRATEGY,
361
name: "university",
362
exclusiveDomains: ["university.edu"],
363
updateOnLogin: false, // Allows name changes
364
};
365
366
const strategies = [strategy];
367
const attemptedChange = { first_name: "Jane", last_name: "Smith" };
368
369
// Simulate check_hook logic
370
const matchedStrategy = checkRequiredSSO({
371
email: currentEmail,
372
strategies,
373
});
374
375
const shouldBlock =
376
matchedStrategy != null &&
377
matchedStrategy.updateOnLogin &&
378
(typeof attemptedChange.first_name === "string" ||
379
typeof attemptedChange.last_name === "string");
380
381
expect(shouldBlock).toBe(false); // updateOnLogin is false, so name changes allowed
382
});
383
});
384
});
385
386