Path: blob/master/src/packages/util/auth-sso-account-restrictions.test.ts
6020 views
/*1* This file is part of CoCalc: Copyright © 2025 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45/**6* Unit tests for SSO account editing restrictions.7*8* This tests the validation logic that prevents users from editing their9* email address and name when their account is controlled by an exclusive10* SSO strategy with updateOnLogin enabled.11*12* The actual implementation is in:13* - packages/util/db-schema/accounts.ts (user_query check_hook)14* - packages/server/auth/sso/passport-login.ts (SSO update logic)15*/1617import { checkRequiredSSO } from "./auth-check-required-sso";18import { Strategy } from "./types/sso";1920const BASE_STRATEGY: Readonly<21Omit<Strategy, "name" | "exclusiveDomains" | "updateOnLogin">22> = {23display: "Test SSO",24backgroundColor: "#000",25public: false,26doNotHide: true,27} as const;2829describe("SSO Account Editing Restrictions", () => {30describe("Email Address Protection (Always Enforced)", () => {31test("user with exclusive SSO domain cannot change email", () => {32const strategy: Strategy = {33...BASE_STRATEGY,34name: "university",35exclusiveDomains: ["university.edu"],36updateOnLogin: false, // Doesn't matter for email37};3839const result = checkRequiredSSO({40email: "[email protected]",41strategies: [strategy],42});4344// If strategy is found, email changes should be blocked45expect(result).toBeDefined();46expect(result?.name).toBe("university");47});4849test("user without exclusive SSO can change email", () => {50const strategy: Strategy = {51...BASE_STRATEGY,52name: "university",53exclusiveDomains: ["university.edu"],54updateOnLogin: false,55};5657const result = checkRequiredSSO({58email: "[email protected]", // Different domain59strategies: [strategy],60});6162// No strategy matched, email changes allowed63expect(result).toBeUndefined();64});6566test("subdomain users also cannot change email", () => {67const strategy: Strategy = {68...BASE_STRATEGY,69name: "university",70exclusiveDomains: ["university.edu"],71updateOnLogin: false,72};7374const result = checkRequiredSSO({75email: "[email protected]", // Subdomain76strategies: [strategy],77});7879expect(result).toBeDefined();80expect(result?.name).toBe("university");81});82});8384describe("Name Protection (When updateOnLogin: true)", () => {85test("updateOnLogin: true blocks name changes", () => {86const strategy: Strategy = {87...BASE_STRATEGY,88name: "university",89exclusiveDomains: ["university.edu"],90updateOnLogin: true, // KEY: blocks name changes91};9293const result = checkRequiredSSO({94email: "[email protected]",95strategies: [strategy],96});9798expect(result).toBeDefined();99expect(result?.updateOnLogin).toBe(true);100// In actual implementation (accounts.ts:750-759), this blocks first_name and last_name edits101});102103test("updateOnLogin: false allows name changes", () => {104const strategy: Strategy = {105...BASE_STRATEGY,106name: "university",107exclusiveDomains: ["university.edu"],108updateOnLogin: false, // KEY: allows name changes109};110111const result = checkRequiredSSO({112email: "[email protected]",113strategies: [strategy],114});115116expect(result).toBeDefined();117expect(result?.updateOnLogin).toBe(false);118// In actual implementation, email is still blocked but names are allowed119});120121test("validation logic type checks", () => {122// This tests the fix for the typeof bug in accounts.ts:753123const strategy: Strategy = {124...BASE_STRATEGY,125name: "university",126exclusiveDomains: ["university.edu"],127updateOnLogin: true,128};129130const result = checkRequiredSSO({131email: "[email protected]",132strategies: [strategy],133});134135// The validation should check:136// typeof obj.first_name === "string" || typeof obj.last_name === "string"137// NOT: obj.last_name === "string" (literal string comparison)138139expect(result?.updateOnLogin).toBe(true);140141// Simulate validation logic142const obj = { first_name: "John", last_name: "Doe" };143const shouldBlock =144result?.updateOnLogin &&145(typeof obj.first_name === "string" ||146typeof obj.last_name === "string");147148expect(shouldBlock).toBe(true);149150// Test the bug: if we use literal comparison, it fails151const buggyCheck =152result?.updateOnLogin &&153(typeof obj.first_name === "string" || obj.last_name === "string");154155expect(buggyCheck).toBe(true); // Still true because first_name check passes156157// But if only last_name is set, the bug becomes obvious158const obj2 = { last_name: "Doe" };159const correctCheck =160result?.updateOnLogin && typeof obj2.last_name === "string";161const buggyCheck2 = result?.updateOnLogin && obj2.last_name === "string";162163expect(correctCheck).toBe(true); // Correct: blocks any string164expect(buggyCheck2).toBe(false); // Bug: only blocks if last_name === "string" literally165});166});167168describe("Wildcard Domain Handling", () => {169test("wildcard matches all domains", () => {170const wildcardStrategy: Strategy = {171...BASE_STRATEGY,172name: "corporate",173exclusiveDomains: ["*"],174updateOnLogin: true,175};176177const result = checkRequiredSSO({178email: "[email protected]",179strategies: [wildcardStrategy],180});181182expect(result).toBeDefined();183expect(result?.name).toBe("corporate");184expect(result?.updateOnLogin).toBe(true);185});186187test("specific domain takes precedence over wildcard", () => {188const specificStrategy: Strategy = {189...BASE_STRATEGY,190name: "university",191exclusiveDomains: ["university.edu"],192updateOnLogin: true,193};194195const wildcardStrategy: Strategy = {196...BASE_STRATEGY,197name: "corporate",198exclusiveDomains: ["*"],199updateOnLogin: false,200};201202const result = checkRequiredSSO({203email: "[email protected]",204strategies: [specificStrategy, wildcardStrategy],205});206207// Specific strategy should match first208expect(result?.name).toBe("university");209expect(result?.updateOnLogin).toBe(true);210});211});212213describe("Edge Cases", () => {214test("empty email returns no strategy", () => {215const strategy: Strategy = {216...BASE_STRATEGY,217name: "university",218exclusiveDomains: ["university.edu"],219updateOnLogin: true,220};221222const result = checkRequiredSSO({223email: "",224strategies: [strategy],225});226227expect(result).toBeUndefined();228});229230test("invalid email returns no strategy", () => {231const strategy: Strategy = {232...BASE_STRATEGY,233name: "university",234exclusiveDomains: ["university.edu"],235updateOnLogin: true,236};237238const result = checkRequiredSSO({239email: "not-an-email",240strategies: [strategy],241});242243expect(result).toBeUndefined();244});245246test("case insensitive matching", () => {247const strategy: Strategy = {248...BASE_STRATEGY,249name: "university",250exclusiveDomains: ["university.edu"], // lowercase (normalized from DB)251updateOnLogin: true,252};253254const result = checkRequiredSSO({255email: "[email protected]", // uppercase256strategies: [strategy],257});258259expect(result).toBeDefined();260expect(result?.name).toBe("university");261});262263test("multiple domains in single strategy", () => {264const strategy: Strategy = {265...BASE_STRATEGY,266name: "enterprise",267exclusiveDomains: ["company.com", "company.net", "company.org"],268updateOnLogin: true,269};270271const result1 = checkRequiredSSO({272email: "[email protected]",273strategies: [strategy],274});275const result2 = checkRequiredSSO({276email: "[email protected]",277strategies: [strategy],278});279const result3 = checkRequiredSSO({280email: "[email protected]",281strategies: [strategy],282});283284expect(result1?.name).toBe("enterprise");285expect(result2?.name).toBe("enterprise");286expect(result3?.name).toBe("enterprise");287});288});289290describe("Integration Scenarios (Mock)", () => {291/**292* These tests simulate the check_hook logic from accounts.ts:732-762293*/294295test("user_query set operation: email change blocked", () => {296const currentEmail = "[email protected]";297const strategy: Strategy = {298...BASE_STRATEGY,299name: "university",300exclusiveDomains: ["university.edu"],301updateOnLogin: false,302};303304const strategies = [strategy];305const attemptedChange = { email_address: "[email protected]" };306307// Simulate check_hook logic308const matchedStrategy = checkRequiredSSO({309email: currentEmail,310strategies,311});312313if (314matchedStrategy != null &&315typeof attemptedChange.email_address === "string"316) {317// Should trigger error: "You are not allowed to change your email address."318expect(matchedStrategy).toBeDefined();319expect(typeof attemptedChange.email_address).toBe("string");320}321});322323test("user_query set operation: name change blocked when updateOnLogin true", () => {324const currentEmail = "[email protected]";325const strategy: Strategy = {326...BASE_STRATEGY,327name: "university",328exclusiveDomains: ["university.edu"],329updateOnLogin: true, // Blocks name changes330};331332const strategies = [strategy];333const attemptedChange = { first_name: "Jane", last_name: "Smith" };334335// Simulate check_hook logic (accounts.ts:750-759)336const matchedStrategy = checkRequiredSSO({337email: currentEmail,338strategies,339});340341if (342matchedStrategy != null &&343matchedStrategy.updateOnLogin &&344(typeof attemptedChange.first_name === "string" ||345typeof attemptedChange.last_name === "string")346) {347// Should trigger error: "You are not allowed to change your first or last name..."348expect(matchedStrategy.updateOnLogin).toBe(true);349expect(350typeof attemptedChange.first_name === "string" ||351typeof attemptedChange.last_name === "string",352).toBe(true);353}354});355356test("user_query set operation: name change allowed when updateOnLogin false", () => {357const currentEmail = "[email protected]";358const strategy: Strategy = {359...BASE_STRATEGY,360name: "university",361exclusiveDomains: ["university.edu"],362updateOnLogin: false, // Allows name changes363};364365const strategies = [strategy];366const attemptedChange = { first_name: "Jane", last_name: "Smith" };367368// Simulate check_hook logic369const matchedStrategy = checkRequiredSSO({370email: currentEmail,371strategies,372});373374const shouldBlock =375matchedStrategy != null &&376matchedStrategy.updateOnLogin &&377(typeof attemptedChange.first_name === "string" ||378typeof attemptedChange.last_name === "string");379380expect(shouldBlock).toBe(false); // updateOnLogin is false, so name changes allowed381});382});383});384385386