Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

sso.test.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

sso.test.tsBlame226 lines · 1 contributor
edf7c36Claude1/**
2 * Block I10 — Enterprise SSO (OIDC) tests.
3 *
4 * Covers pure helpers (URL building, domain gating, username normalization)
5 * and route authorization smokes. The full OIDC dance against a real IdP is
6 * exercised by live integration — we don't mock fetch here.
7 */
8
9import { describe, it, expect } from "bun:test";
10import app from "../app";
11import {
12 buildAuthorizeUrl,
13 emailDomainAllowed,
14 randomToken,
15 ssoRedirectUri,
16 __internal,
17} from "../lib/sso";
18
19const { emptyToNull, normalizeUsername } = __internal;
20
21describe("sso — buildAuthorizeUrl", () => {
22 const cfg = {
23 authorizationEndpoint: "https://idp.example.com/authorize",
24 clientId: "abc123",
25 scopes: "openid profile email",
26 };
27
28 it("includes all OIDC required params", () => {
29 const url = buildAuthorizeUrl(
30 cfg,
31 "state-xyz",
32 "nonce-abc",
33 "https://app.example.com/login/sso/callback"
34 );
35 const u = new URL(url);
36 expect(u.origin + u.pathname).toBe("https://idp.example.com/authorize");
37 expect(u.searchParams.get("client_id")).toBe("abc123");
38 expect(u.searchParams.get("response_type")).toBe("code");
39 expect(u.searchParams.get("scope")).toBe("openid profile email");
40 expect(u.searchParams.get("state")).toBe("state-xyz");
41 expect(u.searchParams.get("nonce")).toBe("nonce-abc");
42 expect(u.searchParams.get("redirect_uri")).toBe(
43 "https://app.example.com/login/sso/callback"
44 );
45 });
46
47 it("preserves existing query params on the endpoint", () => {
48 const url = buildAuthorizeUrl(
49 {
50 authorizationEndpoint: "https://idp.example.com/authorize?ext=1",
51 clientId: "abc",
52 scopes: "openid",
53 },
54 "s",
55 "n",
56 "https://app/cb"
57 );
58 const u = new URL(url);
59 expect(u.searchParams.get("ext")).toBe("1");
60 expect(u.searchParams.get("client_id")).toBe("abc");
61 });
62
63 it("throws when endpoint or client_id is missing", () => {
64 expect(() =>
65 buildAuthorizeUrl(
66 { authorizationEndpoint: null, clientId: "x", scopes: "openid" } as any,
67 "s",
68 "n",
69 "https://app/cb"
70 )
71 ).toThrow();
72 expect(() =>
73 buildAuthorizeUrl(
74 {
75 authorizationEndpoint: "https://i/a",
76 clientId: null,
77 scopes: "openid",
78 } as any,
79 "s",
80 "n",
81 "https://app/cb"
82 )
83 ).toThrow();
84 });
85
86 it("falls back to default scopes when empty", () => {
87 const url = buildAuthorizeUrl(
88 {
89 authorizationEndpoint: "https://idp/a",
90 clientId: "c",
91 scopes: "" as any,
92 },
93 "s",
94 "n",
95 "https://app/cb"
96 );
97 expect(new URL(url).searchParams.get("scope")).toBe(
98 "openid profile email"
99 );
100 });
101});
102
103describe("sso — emailDomainAllowed", () => {
104 it("allows any when domains list is null", () => {
105 expect(emailDomainAllowed("a@example.com", null)).toBe(true);
106 });
107
108 it("allows any when list is empty", () => {
109 expect(emailDomainAllowed("a@example.com", "")).toBe(true);
110 expect(emailDomainAllowed("a@example.com", " ")).toBe(true);
111 });
112
113 it("accepts matching domain (case-insensitive)", () => {
114 expect(emailDomainAllowed("a@EXAMPLE.COM", "example.com")).toBe(true);
115 expect(emailDomainAllowed("a@example.com", "acme.io, example.com")).toBe(
116 true
117 );
118 });
119
120 it("rejects unmatched domain", () => {
121 expect(emailDomainAllowed("a@evil.com", "example.com")).toBe(false);
122 });
123
124 it("rejects missing email when list is set", () => {
125 expect(emailDomainAllowed(null, "example.com")).toBe(false);
126 expect(emailDomainAllowed("", "example.com")).toBe(false);
127 });
128
129 it("rejects malformed email", () => {
130 expect(emailDomainAllowed("not-an-email", "example.com")).toBe(false);
131 });
132});
133
134describe("sso — normalizeUsername", () => {
135 it("lowercases and slugifies", () => {
136 expect(normalizeUsername("Alice Smith")).toBe("alice-smith");
137 expect(normalizeUsername("BOB@acme.IO")).toBe("bob-acme-io");
138 });
139
140 it("strips leading/trailing dashes", () => {
141 expect(normalizeUsername("---foo---")).toBe("foo");
142 });
143
144 it("falls back to 'user' for empty input", () => {
145 expect(normalizeUsername("")).toBe("user");
146 expect(normalizeUsername("@@@")).toBe("user");
147 });
148
149 it("caps at 32 chars", () => {
150 const out = normalizeUsername("a".repeat(80));
151 expect(out.length).toBeLessThanOrEqual(32);
152 });
153});
154
155describe("sso — emptyToNull", () => {
156 it("returns null for empty or whitespace-only", () => {
157 expect(emptyToNull("")).toBeNull();
158 expect(emptyToNull(" ")).toBeNull();
159 expect(emptyToNull(null)).toBeNull();
160 expect(emptyToNull(undefined)).toBeNull();
161 });
162
163 it("trims and returns non-empty strings", () => {
164 expect(emptyToNull(" hello ")).toBe("hello");
165 expect(emptyToNull("x")).toBe("x");
166 });
167});
168
169describe("sso — randomToken", () => {
170 it("returns hex of expected length", () => {
171 expect(randomToken(8)).toMatch(/^[0-9a-f]{16}$/);
172 expect(randomToken(16)).toMatch(/^[0-9a-f]{32}$/);
173 });
174
175 it("returns distinct values across calls", () => {
176 expect(randomToken(16)).not.toBe(randomToken(16));
177 });
178});
179
180describe("sso — ssoRedirectUri", () => {
181 it("ends with /login/sso/callback", () => {
182 expect(ssoRedirectUri().endsWith("/login/sso/callback")).toBe(true);
183 });
184});
185
186describe("sso — route auth", () => {
187 it("GET /admin/sso without auth → 302 /login", async () => {
188 const res = await app.request("/admin/sso");
189 expect(res.status).toBe(302);
190 expect(res.headers.get("location") || "").toContain("/login");
191 });
192
193 it("POST /admin/sso without auth → 302 /login", async () => {
194 const res = await app.request("/admin/sso", {
195 method: "POST",
196 body: new URLSearchParams({ provider_name: "x" }),
197 headers: { "content-type": "application/x-www-form-urlencoded" },
198 });
199 expect(res.status).toBe(302);
200 expect(res.headers.get("location") || "").toContain("/login");
201 });
202
203 it("POST /settings/sso/unlink without auth → 302 /login", async () => {
204 const res = await app.request("/settings/sso/unlink", { method: "POST" });
205 expect(res.status).toBe(302);
206 expect(res.headers.get("location") || "").toContain("/login");
207 });
208
209 it("GET /login/sso when SSO not configured → 302 /login with error", async () => {
210 const res = await app.request("/login/sso");
211 expect(res.status).toBe(302);
212 const loc = res.headers.get("location") || "";
213 expect(loc).toContain("/login");
214 // Either "not enabled" or "not fully configured" — either proves the
215 // route is mounted and SSO guard fires.
216 expect(loc).toContain("error=");
217 });
218
219 it("GET /login/sso/callback without state cookie → 302 /login", async () => {
220 const res = await app.request(
221 "/login/sso/callback?code=abc&state=xyz"
222 );
223 expect(res.status).toBe(302);
224 expect(res.headers.get("location") || "").toContain("/login");
225 });
226});