1
10import { beforeEach, describe, expect, it, vi } from "vitest";
11import { ApiError } from "../../../common/errors.js";
12import { SsoProvisioningService, type SsoProvisioningInput } from "../provisioning.service.js";
13
14interface PrismaMock {
15 organization: { findUnique: ReturnType<typeof vi.fn> };
16 user: { findUnique: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> };
17 ssoIdentity: { findUnique: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn> };
18 organizationMember: { findUnique: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn> };
19 workspace: { findMany: ReturnType<typeof vi.fn> };
20 workspaceMember: { findMany: ReturnType<typeof vi.fn>; createMany: ReturnType<typeof vi.fn> };
21}
22
23function mockPrisma(): PrismaMock {
24 return {
25 organization: { findUnique: vi.fn() },
26 user: { findUnique: vi.fn(), create: vi.fn(), update: vi.fn() },
27 ssoIdentity: { findUnique: vi.fn(), create: vi.fn() },
28 organizationMember: { findUnique: vi.fn(), create: vi.fn() },
29 workspace: { findMany: vi.fn() },
30 workspaceMember: { findMany: vi.fn(), createMany: vi.fn() }
31 };
32}
33
34const ORG_ID = "org_1";
35const PROVIDER = { id: "sso_1", type: "SAML" as const, name: "Acme Okta", config: {} };
36const TOKENS = {
37 accessToken: "at",
38 refreshToken: "rt",
39 expiresIn: 900,
40 session: { id: "sess_1", expiresAt: new Date("2026-12-31T00:00:00Z") }
41};
42
43function makeService(prisma: PrismaMock) {
44 const audit = { audit: vi.fn().mockResolvedValue(undefined) };
45 const auth = { issueTokens: vi.fn().mockResolvedValue(TOKENS) };
46 const secrets = { getSecrets: vi.fn(), saveSecrets: vi.fn() };
47 const svc = new SsoProvisioningService(prisma as never, audit as never, auth as never, secrets as never);
48 return { svc, audit, auth };
49}
50
51function input(over: Partial<SsoProvisioningInput> = {}): SsoProvisioningInput {
52 return {
53 req: { ip: "1.2.3.4", headers: { "user-agent": "test" } } as never,
54 res: {} as never,
55 organizationId: ORG_ID,
56 provider: PROVIDER,
57 externalId: "ext-42",
58 email: "alice@acme.test",
59 ...over
60 };
61}
62
63beforeEach(() => vi.clearAllMocks());
64
65describe("TASK-0131 SSO provisioning matrix", () => {
66 it("links to an EXISTING SsoIdentity (provisioned=existing) and mints an SSO session", async () => {
67 const prisma = mockPrisma();
68 prisma.organization.findUnique.mockResolvedValue({ policy: {} });
69 prisma.ssoIdentity.findUnique.mockResolvedValue({ userId: "u_1" });
70 prisma.user.findUnique.mockResolvedValue({
71 id: "u_1",
72 email: "alice@acme.test",
73 name: "Alice",
74 displayName: "Alice",
75 status: "ACTIVE"
76 });
77 prisma.organizationMember.findUnique.mockResolvedValue({ role: "MEMBER" });
78 prisma.workspace.findMany.mockResolvedValue([{ id: "w_1" }]);
79 prisma.workspaceMember.findMany.mockResolvedValue([{ workspaceId: "w_1" }]);
80 prisma.user.update.mockResolvedValue({});
81
82 const { svc, auth, audit } = makeService(prisma);
83 const res = await svc.provision(input());
84
85 expect(res.provisioned).toBe("existing");
86 expect(auth.issueTokens).toHaveBeenCalledWith(expect.objectContaining({ id: "u_1" }), {
87 req: expect.anything(),
88 res: expect.anything(),
89 ip: "1.2.3.4",
90 userAgent: "test",
91 authMethod: "sso"
92 });
93
94 const actions = audit.audit.mock.calls.map((c) => c[2].action);
95 expect(actions).toContain("sso.login.saml");
96 const login = audit.audit.mock.calls.find((c) => c[2].action === "sso.login.saml")!;
97 expect(login[2].metadata.provisioned).toBe("existing");
98
99 expect(prisma.organizationMember.create).not.toHaveBeenCalled();
100 });
101
102 it("LINKS a known email that has no SsoIdentity (provisioned=linked)", async () => {
103 const prisma = mockPrisma();
104 prisma.organization.findUnique.mockResolvedValue({ policy: {} });
105 prisma.ssoIdentity.findUnique.mockResolvedValue(null);
106 prisma.user.findUnique.mockResolvedValue({
107 id: "u_2",
108 email: "alice@acme.test",
109 name: "Alice",
110 displayName: null,
111 status: "ACTIVE"
112 });
113 prisma.ssoIdentity.create.mockResolvedValue({});
114 prisma.organizationMember.findUnique.mockResolvedValue(null);
115 prisma.organizationMember.create.mockResolvedValue({});
116 prisma.workspace.findMany.mockResolvedValue([]);
117 prisma.workspaceMember.findMany.mockResolvedValue([]);
118 prisma.user.update.mockResolvedValue({});
119
120 const { svc } = makeService(prisma);
121 const res = await svc.provision(input());
122 expect(res.provisioned).toBe("linked");
123 expect(prisma.ssoIdentity.create).toHaveBeenCalledWith({
124 data: { providerId: "sso_1", externalId: "ext-42", userId: "u_2" }
125 });
126 });
127
128 it("JIT-CREATES a passwordless verified user (provisioned=created)", async () => {
129 const prisma = mockPrisma();
130 prisma.organization.findUnique.mockResolvedValue({ policy: {} });
131 prisma.ssoIdentity.findUnique.mockResolvedValue(null);
132 prisma.user.findUnique.mockResolvedValue(null);
133 prisma.user.create.mockResolvedValue({
134 id: "u_3",
135 email: "alice@acme.test",
136 name: "alice",
137 displayName: "Alice",
138 status: "ACTIVE"
139 });
140 prisma.ssoIdentity.create.mockResolvedValue({});
141 prisma.organizationMember.findUnique.mockResolvedValue(null);
142 prisma.organizationMember.create.mockResolvedValue({});
143 prisma.workspace.findMany.mockResolvedValue([{ id: "w_1" }, { id: "w_2" }]);
144 prisma.workspaceMember.findMany.mockResolvedValue([{ workspaceId: "w_1" }]);
145 prisma.workspaceMember.createMany.mockResolvedValue({ count: 1 });
146 prisma.user.update.mockResolvedValue({});
147
148 const { svc } = makeService(prisma);
149 const res = await svc.provision(input({ displayName: "Alice" }));
150 expect(res.provisioned).toBe("created");
151
152 expect(prisma.user.create).toHaveBeenCalledWith(
153 expect.objectContaining({
154 data: expect.objectContaining({
155 email: "alice@acme.test",
156 passwordHash: null,
157 emailVerification: "VERIFIED"
158 })
159 })
160 );
161
162 expect(prisma.workspaceMember.createMany).toHaveBeenCalledWith({
163 data: [expect.objectContaining({ workspaceId: "w_2", role: "MEMBER" })],
164 skipDuplicates: true
165 });
166
167 expect(prisma.organizationMember.create).toHaveBeenCalledWith(
168 expect.objectContaining({ data: expect.objectContaining({ role: "MEMBER" }) })
169 );
170 });
171
172 it("honors ssoDefaultRole=GUEST (never demotes: no create when a membership exists)", async () => {
173 const prisma = mockPrisma();
174 prisma.organization.findUnique.mockResolvedValue({ policy: { ssoDefaultRole: "GUEST" } });
175 prisma.ssoIdentity.findUnique.mockResolvedValue({ userId: "u_1" });
176 prisma.user.findUnique.mockResolvedValue({ id: "u_1", email: "a@b.test", name: "A", displayName: null, status: "ACTIVE" });
177 prisma.organizationMember.findUnique.mockResolvedValue(null);
178 prisma.organizationMember.create.mockResolvedValue({});
179 prisma.workspace.findMany.mockResolvedValue([]);
180 prisma.workspaceMember.findMany.mockResolvedValue([]);
181 prisma.user.update.mockResolvedValue({});
182
183 const { svc } = makeService(prisma);
184 await svc.provision(input());
185 expect(prisma.organizationMember.create).toHaveBeenCalledWith(
186 expect.objectContaining({ data: expect.objectContaining({ role: "GUEST" }) })
187 );
188 });
189
190 it("rejects a SUSPENDED user (audit sso.login_failed reason=suspended, 403)", async () => {
191 const prisma = mockPrisma();
192 prisma.organization.findUnique.mockResolvedValue({ policy: {} });
193 prisma.ssoIdentity.findUnique.mockResolvedValue({ userId: "u_1" });
194 prisma.user.findUnique.mockResolvedValue({ id: "u_1", email: "a@b.test", name: "A", displayName: null, status: "SUSPENDED" });
195
196 const { svc, audit } = makeService(prisma);
197 await expect(svc.provision(input())).rejects.toMatchObject({ code: "FORBIDDEN", httpStatus: 403 });
198 const failed = audit.audit.mock.calls.find((c) => c[2].action === "sso.login_failed");
199 expect(failed?.[2].metadata.reason).toBe("suspended");
200 });
201
202 it("rejects a foreign email when JIT is disabled (reason=jit_disabled)", async () => {
203 const prisma = mockPrisma();
204 prisma.organization.findUnique.mockResolvedValue({ policy: { ssoJitProvisioning: false } });
205 prisma.ssoIdentity.findUnique.mockResolvedValue(null);
206 prisma.user.findUnique.mockResolvedValue(null);
207
208 const { svc, audit } = makeService(prisma);
209 await expect(svc.provision(input())).rejects.toBeInstanceOf(ApiError);
210 const failed = audit.audit.mock.calls.find((c) => c[2].action === "sso.login_failed");
211 expect(failed?.[2].metadata.reason).toBe("jit_disabled");
212 });
213
214 it("audits sso.login.oidc for OIDC providers (protocol in the action)", async () => {
215 const prisma = mockPrisma();
216 prisma.organization.findUnique.mockResolvedValue({ policy: {} });
217 prisma.ssoIdentity.findUnique.mockResolvedValue({ userId: "u_1" });
218 prisma.user.findUnique.mockResolvedValue({ id: "u_1", email: "a@b.test", name: "A", displayName: null, status: "ACTIVE" });
219 prisma.organizationMember.findUnique.mockResolvedValue({});
220 prisma.workspace.findMany.mockResolvedValue([]);
221 prisma.workspaceMember.findMany.mockResolvedValue([]);
222 prisma.user.update.mockResolvedValue({});
223
224 const { svc, audit } = makeService(prisma);
225 await svc.provision(input({ provider: { ...PROVIDER, type: "OIDC" } }));
226 expect(audit.audit.mock.calls.some((c) => c[2].action === "sso.login.oidc")).toBe(true);
227 });
228
229 it("lowercases the email for the account lookup (linked branch)", async () => {
230 const prisma = mockPrisma();
231 prisma.organization.findUnique.mockResolvedValue({ policy: {} });
232 prisma.ssoIdentity.findUnique.mockResolvedValue(null);
233 prisma.user.findUnique.mockResolvedValue({ id: "u_1", email: "alice@acme.test", name: "A", displayName: null, status: "ACTIVE" });
234 prisma.ssoIdentity.create.mockResolvedValue({});
235 prisma.organizationMember.findUnique.mockResolvedValue({});
236 prisma.workspace.findMany.mockResolvedValue([]);
237 prisma.workspaceMember.findMany.mockResolvedValue([]);
238 prisma.user.update.mockResolvedValue({});
239
240 const { svc } = makeService(prisma);
241 await svc.provision(input({ email: "ALICE@acme.test" }));
242
243 expect(prisma.user.findUnique).toHaveBeenCalledWith({ where: { email: "alice@acme.test" } });
244 });
245});
246