Skip to content

Commit b9dba40

Browse files
author
Rajat
committed
Added tests
1 parent 8b002c7 commit b9dba40

File tree

4 files changed

+377
-1
lines changed

4 files changed

+377
-1
lines changed

apps/docs/src/pages/en/schools/sso.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,20 @@ When the SSO login provider is configured and enabled, the customer will see a `
9494

9595
### 1. Email login is disabled and now I am locked out
9696

97+
#### a. Cloud-hosted (courselit.app)
98+
9799
You can re-enable the email provider from the [CourseLit](https://app.courselit.app) dashboard.
98100

99101
![Re-enable email login provider](/assets/schools/reenable-email-login-provider.png)
100102

103+
#### b. Self-hosted
104+
105+
You need to log in to your school's MongoDB instance and run the following query to re-enable the email provider:
106+
107+
```javascript
108+
db.domains.updateMany({}, { $addToSet: { "settings.logins": "email" } });
109+
```
110+
101111
## Stuck somewhere?
102112

103113
We are always here for you. Come chat with us in our <a href="https://discord.com/invite/GR4bQsN" target="_blank">Discord</a> channel or send a tweet at <a href="https://twitter.com/courselit" target="_blank">@CourseLit</a>.
Lines changed: 365 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,365 @@
1+
import {
2+
updateSSOProvider,
3+
getSSOProviderSettings,
4+
getSSOProvider,
5+
removeSSOProvider,
6+
getFeatures,
7+
toggleLoginProvider,
8+
} from "../logic";
9+
import DomainModel from "@models/Domain";
10+
import UserModel from "@models/User";
11+
import SSOProviderModel from "@models/SSOProvider";
12+
import constants from "@/config/constants";
13+
import { Constants } from "@courselit/common-models";
14+
15+
const SUITE_PREFIX = `sso-tests-${Date.now()}`;
16+
const id = (suffix: string) => `${SUITE_PREFIX}-${suffix}`;
17+
const email = (suffix: string) => `${suffix}-${SUITE_PREFIX}@example.com`;
18+
19+
describe("SSO Logic Tests", () => {
20+
let testDomain: any;
21+
let adminUser: any;
22+
let regularUser: any;
23+
let mockCtx: any;
24+
25+
beforeAll(async () => {
26+
// Create test domain with SSO feature enabled
27+
testDomain = await DomainModel.create({
28+
name: id("domain"),
29+
email: email("domain"),
30+
features: [Constants.Features.SSO],
31+
settings: {
32+
logins: [Constants.LoginProvider.EMAIL],
33+
},
34+
});
35+
36+
// Create admin user with manageSettings permission
37+
adminUser = await UserModel.create({
38+
domain: testDomain._id,
39+
userId: id("admin"),
40+
email: email("admin"),
41+
name: "Admin User",
42+
permissions: [constants.permissions.manageSettings],
43+
active: true,
44+
unsubscribeToken: id("unsubscribe-admin"),
45+
purchases: [],
46+
});
47+
48+
// Create regular user without permissions
49+
regularUser = await UserModel.create({
50+
domain: testDomain._id,
51+
userId: id("regular"),
52+
email: email("regular"),
53+
name: "Regular User",
54+
permissions: [],
55+
active: true,
56+
unsubscribeToken: id("unsubscribe-regular"),
57+
purchases: [],
58+
});
59+
60+
mockCtx = {
61+
user: adminUser,
62+
subdomain: testDomain,
63+
} as any;
64+
});
65+
66+
afterEach(async () => {
67+
await SSOProviderModel.deleteMany({ domain: testDomain._id });
68+
// Reset domain settings
69+
await DomainModel.updateOne(
70+
{ _id: testDomain._id },
71+
{
72+
$set: {
73+
"settings.ssoTrustedDomain": undefined,
74+
"settings.logins": [Constants.LoginProvider.EMAIL],
75+
},
76+
},
77+
);
78+
// Refresh local domain object
79+
const updatedDomain = await DomainModel.findById(testDomain._id);
80+
mockCtx.subdomain = updatedDomain;
81+
});
82+
83+
afterAll(async () => {
84+
await UserModel.deleteMany({ domain: testDomain._id });
85+
await DomainModel.deleteOne({ _id: testDomain._id });
86+
});
87+
88+
describe("updateSSOProvider", () => {
89+
const validConfig = {
90+
idpMetadata: "xml-metadata",
91+
entryPoint: "https://idp.example.com",
92+
cert: "cert-string",
93+
backend: "https://backend.example.com",
94+
};
95+
96+
it("should throw if user is not authenticated", async () => {
97+
await expect(
98+
updateSSOProvider({ ...validConfig, context: {} as any }),
99+
).rejects.toThrow();
100+
});
101+
102+
it("should throw if user does not have manageSettings permission", async () => {
103+
const ctx = { ...mockCtx, user: regularUser };
104+
await expect(
105+
updateSSOProvider({ ...validConfig, context: ctx }),
106+
).rejects.toThrow();
107+
});
108+
109+
it("should throw if domain does not have SSO feature", async () => {
110+
// Temporarily remove SSO feature
111+
const noSSODomain = { ...mockCtx.subdomain, features: [] };
112+
const ctx = { ...mockCtx, subdomain: noSSODomain };
113+
await expect(
114+
updateSSOProvider({ ...validConfig, context: ctx }),
115+
).rejects.toThrow();
116+
});
117+
118+
it("should throw if configuration is invalid", async () => {
119+
await expect(
120+
updateSSOProvider({
121+
...validConfig,
122+
idpMetadata: "",
123+
context: mockCtx,
124+
}),
125+
).rejects.toThrow();
126+
});
127+
128+
it("should create SSO provider and update domain settings", async () => {
129+
const result = await updateSSOProvider({
130+
...validConfig,
131+
context: mockCtx,
132+
});
133+
134+
expect(result).toBeDefined();
135+
expect(result.providerId).toBe("sso");
136+
137+
const savedProvider = await SSOProviderModel.findOne({
138+
domain: testDomain._id,
139+
});
140+
expect(savedProvider).toBeDefined();
141+
const samlConfig = JSON.parse(savedProvider!.samlConfig);
142+
expect(samlConfig.entryPoint).toBe(validConfig.entryPoint);
143+
144+
// Check if domain settings updated (refresh context first or check DB)
145+
const domain = await DomainModel.findById(testDomain._id);
146+
expect(domain!.settings.ssoTrustedDomain).toBe(
147+
new URL(validConfig.entryPoint).origin,
148+
);
149+
});
150+
});
151+
152+
describe("getSSOProviderSettings", () => {
153+
it("should return null if no provider exists", async () => {
154+
const result = await getSSOProviderSettings(mockCtx);
155+
expect(result).toBeNull();
156+
});
157+
158+
it("should return settings if provider exists", async () => {
159+
// Setup provider
160+
const config = {
161+
entryPoint: "https://test-idp.com",
162+
cert: "test-cert",
163+
idpMetadata: { metadata: "test-metadata" },
164+
};
165+
166+
await SSOProviderModel.create({
167+
id: id("sso-1"),
168+
domain: testDomain._id,
169+
providerId: "sso",
170+
samlConfig: JSON.stringify(config),
171+
domain_string: "backend.com",
172+
});
173+
174+
const result = await getSSOProviderSettings(mockCtx);
175+
expect(result).toEqual({
176+
entryPoint: config.entryPoint,
177+
cert: config.cert,
178+
idpMetadata: config.idpMetadata.metadata,
179+
});
180+
});
181+
});
182+
183+
describe("getSSOProvider", () => {
184+
it("should return null if feature disabled", async () => {
185+
const noSSODomain = { ...mockCtx.subdomain, features: [] };
186+
const ctx = { ...mockCtx, subdomain: noSSODomain };
187+
const result = await getSSOProvider(ctx);
188+
expect(result).toBeNull();
189+
});
190+
191+
it("should return null if no provider configured", async () => {
192+
const result = await getSSOProvider(mockCtx);
193+
expect(result).toBeNull();
194+
});
195+
196+
it("should return provider info if configured", async () => {
197+
await SSOProviderModel.create({
198+
id: id("sso-2"),
199+
domain: testDomain._id,
200+
providerId: "sso",
201+
samlConfig: "{}",
202+
domain_string: "test-domain",
203+
});
204+
205+
const result = await getSSOProvider(mockCtx);
206+
expect(result).toEqual({
207+
providerId: "sso",
208+
domain: "test-domain",
209+
});
210+
});
211+
});
212+
213+
describe("removeSSOProvider", () => {
214+
it("should remove provider and disable SSO login", async () => {
215+
// Setup
216+
await SSOProviderModel.create({
217+
id: id("sso-3"),
218+
domain: testDomain._id,
219+
providerId: "sso",
220+
samlConfig: "{}",
221+
domain_string: "test",
222+
});
223+
224+
// Enable SSO login first
225+
await toggleLoginProvider({
226+
provider: Constants.LoginProvider.SSO,
227+
value: true,
228+
ctx: mockCtx,
229+
});
230+
231+
const result = await removeSSOProvider(mockCtx);
232+
expect(result).toBe(true);
233+
234+
// Verify removal
235+
const provider = await SSOProviderModel.findOne({
236+
domain: testDomain._id,
237+
});
238+
expect(provider).toBeNull();
239+
240+
// Verify login disabled
241+
const domain = await DomainModel.findById(testDomain._id);
242+
expect(domain!.settings.logins).not.toContain(
243+
Constants.LoginProvider.SSO,
244+
);
245+
expect(domain!.settings.ssoTrustedDomain).toBeUndefined();
246+
});
247+
});
248+
249+
describe("toggleLoginProvider", () => {
250+
it("should enable SSO login if provider configured", async () => {
251+
// Must have provider first
252+
await SSOProviderModel.create({
253+
id: id("sso-4"),
254+
domain: testDomain._id,
255+
providerId: "sso",
256+
samlConfig: "{}",
257+
domain_string: "test",
258+
});
259+
260+
const result = await toggleLoginProvider({
261+
provider: Constants.LoginProvider.SSO,
262+
value: true,
263+
ctx: mockCtx,
264+
});
265+
266+
expect(result).toContain(Constants.LoginProvider.SSO);
267+
});
268+
269+
it("should throw if enabling SSO without provider", async () => {
270+
await expect(
271+
toggleLoginProvider({
272+
provider: Constants.LoginProvider.SSO,
273+
value: true,
274+
ctx: mockCtx,
275+
}),
276+
).rejects.toThrow();
277+
});
278+
279+
it("should toggle email login", async () => {
280+
// Ensure we have another provider so we can disable email (though logic.ts might allow disabling if it's not the ONLY one, or logic prevents disabling the last one)
281+
// logic.ts: if !value and logins.length <= 1 and contains EMAIL -> throw.
282+
// So we cannot disable email if it is the only one.
283+
284+
await expect(
285+
toggleLoginProvider({
286+
provider: Constants.LoginProvider.EMAIL,
287+
value: false,
288+
ctx: mockCtx,
289+
}),
290+
).rejects.toThrow();
291+
292+
// Add SSO then disable email
293+
await SSOProviderModel.create({
294+
id: id("sso-5"),
295+
domain: testDomain._id,
296+
providerId: "sso",
297+
samlConfig: "{}",
298+
domain_string: "test",
299+
});
300+
await toggleLoginProvider({
301+
provider: Constants.LoginProvider.SSO,
302+
value: true,
303+
ctx: mockCtx,
304+
});
305+
306+
// Now disable email
307+
const result = await toggleLoginProvider({
308+
provider: Constants.LoginProvider.EMAIL,
309+
value: false,
310+
ctx: mockCtx,
311+
});
312+
expect(result).not.toContain(Constants.LoginProvider.EMAIL);
313+
});
314+
315+
it("should automatically re-enable email if SSO is disabled and it was the only provider", async () => {
316+
// Setup: Create provider and enable SSO
317+
await SSOProviderModel.create({
318+
id: id("sso-auto-enable"),
319+
domain: testDomain._id,
320+
providerId: "sso",
321+
samlConfig: "{}",
322+
domain_string: "test",
323+
});
324+
325+
await toggleLoginProvider({
326+
provider: Constants.LoginProvider.SSO,
327+
value: true,
328+
ctx: mockCtx,
329+
});
330+
331+
// Disable Email (allowed because SSO is enabled)
332+
await toggleLoginProvider({
333+
provider: Constants.LoginProvider.EMAIL,
334+
value: false,
335+
ctx: mockCtx,
336+
});
337+
338+
expect(mockCtx.subdomain.settings.logins).not.toContain(
339+
Constants.LoginProvider.EMAIL,
340+
);
341+
expect(mockCtx.subdomain.settings.logins).toContain(
342+
Constants.LoginProvider.SSO,
343+
);
344+
345+
// Disable SSO - should fallback to Email
346+
const result = await toggleLoginProvider({
347+
provider: Constants.LoginProvider.SSO,
348+
value: false,
349+
ctx: mockCtx,
350+
});
351+
352+
expect(result).toContain(Constants.LoginProvider.EMAIL);
353+
expect(mockCtx.subdomain.settings.logins).toContain(
354+
Constants.LoginProvider.EMAIL,
355+
);
356+
});
357+
});
358+
359+
describe("getFeatures", () => {
360+
it("should return domain features", async () => {
361+
const features = await getFeatures(mockCtx);
362+
expect(features).toContain(Constants.Features.SSO);
363+
});
364+
});
365+
});

apps/web/jest.server.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const config: Config = {
1818
"@/payments-new": "<rootDir>/payments-new",
1919
"@/graphql/(.*)": "<rootDir>/graphql/$1",
2020
"@/config/(.*)": "<rootDir>/config/$1",
21+
"@/data/(.*)": "<rootDir>/data/$1",
2122
"@/lib/(.*)": "<rootDir>/lib/$1",
2223
"@/services/(.*)": "<rootDir>/services/$1",
2324
"@/templates/(.*)": "<rootDir>/templates/$1",

0 commit comments

Comments
 (0)