| @@ -0,0 +1,309 @@ |
| | 1 | import { describe, it, expect } from "@effect/vitest"; |
| | 2 | import { Effect, Layer } from "effect"; |
| | 3 | |
| | 4 | import { AuthContext } from "../auth/middleware"; |
| | 5 | import { WorkOSAuth } from "../auth/workos"; |
| | 6 | import { Forbidden } from "./api"; |
| | 7 | |
| | 8 | // --------------------------------------------------------------------------- |
| | 9 | // Stub factory — only implement what each test calls |
| | 10 | // --------------------------------------------------------------------------- |
| | 11 | |
| | 12 | // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test stub needs wide function types |
| | 13 | type StubFn = (...args: never[]) => Effect.Effect<any>; |
| | 14 | |
| | 15 | type StubOverrides = { |
| | 16 | listOrgMembers?: StubFn; |
| | 17 | getUser?: StubFn; |
| | 18 | sendInvitation?: StubFn; |
| | 19 | deleteOrgMembership?: StubFn; |
| | 20 | updateOrgMembershipRole?: StubFn; |
| | 21 | listOrgRoles?: StubFn; |
| | 22 | }; |
| | 23 | |
| | 24 | const stubWorkOS = (overrides: StubOverrides = {}) => |
| | 25 | Layer.succeed( |
| | 26 | WorkOSAuth, |
| | 27 | new Proxy({} as WorkOSAuth["Type"], { |
| | 28 | get: (_target, prop) => { |
| | 29 | if (prop in overrides) return (overrides as Record<string, unknown>)[prop as string]; |
| | 30 | return () => { |
| | 31 | throw new Error(`WorkOSAuth.${String(prop)} not stubbed`); |
| | 32 | }; |
| | 33 | }, |
| | 34 | }), |
| | 35 | ); |
| | 36 | |
| | 37 | // --------------------------------------------------------------------------- |
| | 38 | // Fixtures |
| | 39 | // --------------------------------------------------------------------------- |
| | 40 | |
| | 41 | const adminAuth = { |
| | 42 | accountId: "user_admin", |
| | 43 | organizationId: "org_1", |
| | 44 | email: "admin@test.com", |
| | 45 | name: "Admin", |
| | 46 | avatarUrl: null, |
| | 47 | }; |
| | 48 | |
| | 49 | const memberAuth = { |
| | 50 | accountId: "user_member", |
| | 51 | organizationId: "org_1", |
| | 52 | email: "member@test.com", |
| | 53 | name: "Member", |
| | 54 | avatarUrl: null, |
| | 55 | }; |
| | 56 | |
| | 57 | type FakeMembership = { |
| | 58 | id: string; |
| | 59 | userId: string; |
| | 60 | status: string; |
| | 61 | role: { slug: string }; |
| | 62 | }; |
| | 63 | type FakeUser = { |
| | 64 | email: string; |
| | 65 | firstName: string | null; |
| | 66 | lastName: string | null; |
| | 67 | profilePictureUrl: string | null; |
| | 68 | lastSignInAt: string | null; |
| | 69 | }; |
| | 70 | type FakeRole = { slug: string; name: string }; |
| | 71 | |
| | 72 | const fakeMemberships: FakeMembership[] = [ |
| | 73 | { |
| | 74 | id: "mem_admin", |
| | 75 | userId: "user_admin", |
| | 76 | status: "active", |
| | 77 | role: { slug: "admin" }, |
| | 78 | }, |
| | 79 | { |
| | 80 | id: "mem_member", |
| | 81 | userId: "user_member", |
| | 82 | status: "active", |
| | 83 | role: { slug: "member" }, |
| | 84 | }, |
| | 85 | ]; |
| | 86 | |
| | 87 | const fakeUsers: Record<string, FakeUser> = { |
| | 88 | user_admin: { |
| | 89 | email: "admin@test.com", |
| | 90 | firstName: "Admin", |
| | 91 | lastName: null, |
| | 92 | profilePictureUrl: null, |
| | 93 | lastSignInAt: "2026-04-09T00:00:00Z", |
| | 94 | }, |
| | 95 | user_member: { |
| | 96 | email: "member@test.com", |
| | 97 | firstName: "Member", |
| | 98 | lastName: null, |
| | 99 | profilePictureUrl: null, |
| | 100 | lastSignInAt: null, |
| | 101 | }, |
| | 102 | }; |
| | 103 | |
| | 104 | const fakeRoles: FakeRole[] = [ |
| | 105 | { slug: "admin", name: "Admin" }, |
| | 106 | { slug: "member", name: "Member" }, |
| | 107 | ]; |
| | 108 | |
| | 109 | // --------------------------------------------------------------------------- |
| | 110 | // The admin guard — mirrors handlers.ts |
| | 111 | // --------------------------------------------------------------------------- |
| | 112 | |
| | 113 | const requireAdmin = Effect.gen(function* () { |
| | 114 | const auth = yield* AuthContext; |
| | 115 | const workos = yield* WorkOSAuth; |
| | 116 | const memberships = yield* workos.listOrgMembers(auth.organizationId); |
| | 117 | const current = memberships.data.find((m: FakeMembership) => m.userId === auth.accountId); |
| | 118 | if (!current || current.role?.slug !== "admin") { |
| | 119 | return yield* new Forbidden(); |
| | 120 | } |
| | 121 | }); |
| | 122 | |
| | 123 | const provide = (auth: typeof adminAuth, workosOverrides: StubOverrides = {}) => |
| | 124 | Layer.mergeAll(Layer.succeed(AuthContext, auth), stubWorkOS(workosOverrides)); |
| | 125 | |
| | 126 | const withMembers: StubOverrides = { |
| | 127 | listOrgMembers: () => Effect.succeed({ data: fakeMemberships }), |
| | 128 | }; |
| | 129 | |
| | 130 | // --------------------------------------------------------------------------- |
| | 131 | // Tests |
| | 132 | // --------------------------------------------------------------------------- |
| | 133 | |
| | 134 | describe("Team handlers", () => { |
| | 135 | describe("listMembers", () => { |
| | 136 | it.effect("returns members with isCurrentUser set correctly", () => |
| | 137 | Effect.gen(function* () { |
| | 138 | const auth = yield* AuthContext; |
| | 139 | const workos = yield* WorkOSAuth; |
| | 140 | const result = yield* workos.listOrgMembers(auth.organizationId); |
| | 141 | const members = yield* Effect.all( |
| | 142 | result.data.map((m: FakeMembership) => |
| | 143 | Effect.gen(function* () { |
| | 144 | const user = yield* workos.getUser(m.userId); |
| | 145 | return { |
| | 146 | id: m.id, |
| | 147 | email: user.email, |
| | 148 | role: m.role?.slug ?? "member", |
| | 149 | isCurrentUser: m.userId === auth.accountId, |
| | 150 | }; |
| | 151 | }), |
| | 152 | ), |
| | 153 | ); |
| | 154 | |
| | 155 | expect(members).toHaveLength(2); |
| | 156 | expect(members[0]).toMatchObject({ |
| | 157 | email: "admin@test.com", |
| | 158 | isCurrentUser: true, |
| | 159 | }); |
| | 160 | expect(members[1]).toMatchObject({ |
| | 161 | email: "member@test.com", |
| | 162 | isCurrentUser: false, |
| | 163 | }); |
| | 164 | }).pipe( |
| | 165 | Effect.provide( |
| | 166 | provide(adminAuth, { |
| | 167 | ...withMembers, |
| | 168 | getUser: (id: string) => Effect.succeed(fakeUsers[id]), |
| | 169 | }), |
| | 170 | ), |
| | 171 | ), |
| | 172 | ); |
| | 173 | }); |
| | 174 | |
| | 175 | describe("listRoles", () => { |
| | 176 | it.effect("returns available roles", () => |
| | 177 | Effect.gen(function* () { |
| | 178 | const auth = yield* AuthContext; |
| | 179 | const workos = yield* WorkOSAuth; |
| | 180 | const result = yield* workos.listOrgRoles(auth.organizationId); |
| | 181 | const roles = result.data.map((r: FakeRole) => ({ |
| | 182 | slug: r.slug, |
| | 183 | name: r.name, |
| | 184 | })); |
| | 185 | |
| | 186 | expect(roles).toEqual(fakeRoles); |
| | 187 | }).pipe( |
| | 188 | Effect.provide( |
| | 189 | provide(adminAuth, { |
| | 190 | listOrgRoles: () => Effect.succeed({ data: fakeRoles }), |
| | 191 | }), |
| | 192 | ), |
| | 193 | ), |
| | 194 | ); |
| | 195 | }); |
| | 196 | |
| | 197 | describe("requireAdmin", () => { |
| | 198 | it.effect("passes for admin user", () => |
| | 199 | requireAdmin.pipe(Effect.provide(provide(adminAuth, withMembers))), |
| | 200 | ); |
| | 201 | |
| | 202 | it.effect("rejects non-admin with Forbidden", () => |
| | 203 | Effect.gen(function* () { |
| | 204 | const error = yield* Effect.flip(requireAdmin); |
| | 205 | expect(error).toBeInstanceOf(Forbidden); |
| | 206 | }).pipe(Effect.provide(provide(memberAuth, withMembers))), |
| | 207 | ); |
| | 208 | }); |
| | 209 | |
| | 210 | describe("invite (admin-gated)", () => { |
| | 211 | it.effect("admin can invite", () => |
| | 212 | Effect.gen(function* () { |
| | 213 | yield* requireAdmin; |
| | 214 | const auth = yield* AuthContext; |
| | 215 | const workos = yield* WorkOSAuth; |
| | 216 | const result = yield* workos.sendInvitation({ |
| | 217 | email: "new@test.com", |
| | 218 | organizationId: auth.organizationId, |
| | 219 | }); |
| | 220 | |
| | 221 | expect(result.email).toBe("new@test.com"); |
| | 222 | }).pipe( |
| | 223 | Effect.provide( |
| | 224 | provide(adminAuth, { |
| | 225 | ...withMembers, |
| | 226 | sendInvitation: (p: { email: string }) => |
| | 227 | Effect.succeed({ id: "inv_1", email: p.email }), |
| | 228 | }), |
| | 229 | ), |
| | 230 | ), |
| | 231 | ); |
| | 232 | |
| | 233 | it.effect("member cannot invite", () => |
| | 234 | Effect.gen(function* () { |
| | 235 | const error = yield* Effect.flip( |
| | 236 | Effect.gen(function* () { |
| | 237 | yield* requireAdmin; |
| | 238 | const workos = yield* WorkOSAuth; |
| | 239 | yield* workos.sendInvitation({ |
| | 240 | email: "x", |
| | 241 | organizationId: "org_1", |
| | 242 | }); |
| | 243 | }), |
| | 244 | ); |
| | 245 | expect(error).toBeInstanceOf(Forbidden); |
| | 246 | }).pipe(Effect.provide(provide(memberAuth, withMembers))), |
| | 247 | ); |
| | 248 | }); |
| | 249 | |
| | 250 | describe("removeMember (admin-gated)", () => { |
| | 251 | it.effect("admin can remove", () => |
| | 252 | Effect.gen(function* () { |
| | 253 | yield* requireAdmin; |
| | 254 | const workos = yield* WorkOSAuth; |
| | 255 | yield* workos.deleteOrgMembership("mem_member"); |
| | 256 | }).pipe( |
| | 257 | Effect.provide( |
| | 258 | provide(adminAuth, { |
| | 259 | ...withMembers, |
| | 260 | deleteOrgMembership: () => Effect.void, |
| | 261 | }), |
| | 262 | ), |
| | 263 | ), |
| | 264 | ); |
| | 265 | |
| | 266 | it.effect("member cannot remove", () => |
| | 267 | Effect.gen(function* () { |
| | 268 | const error = yield* Effect.flip( |
| | 269 | Effect.gen(function* () { |
| | 270 | yield* requireAdmin; |
| | 271 | const workos = yield* WorkOSAuth; |
| | 272 | yield* workos.deleteOrgMembership("mem_admin"); |
| | 273 | }), |
| | 274 | ); |
| | 275 | expect(error).toBeInstanceOf(Forbidden); |
| | 276 | }).pipe(Effect.provide(provide(memberAuth, withMembers))), |
| | 277 | ); |
| | 278 | }); |
| | 279 | |
| | 280 | describe("updateMemberRole (admin-gated)", () => { |
| | 281 | it.effect("admin can change role", () => |
| | 282 | Effect.gen(function* () { |
| | 283 | yield* requireAdmin; |
| | 284 | const workos = yield* WorkOSAuth; |
| | 285 | yield* workos.updateOrgMembershipRole("mem_member", "admin"); |
| | 286 | }).pipe( |
| | 287 | Effect.provide( |
| | 288 | provide(adminAuth, { |
| | 289 | ...withMembers, |
| | 290 | updateOrgMembershipRole: () => Effect.void, |
| | 291 | }), |
| | 292 | ), |
| | 293 | ), |
| | 294 | ); |
| | 295 | |
| | 296 | it.effect("member cannot change role", () => |
| | 297 | Effect.gen(function* () { |
| | 298 | const error = yield* Effect.flip( |
| | 299 | Effect.gen(function* () { |
| | 300 | yield* requireAdmin; |
| | 301 | const workos = yield* WorkOSAuth; |
| | 302 | yield* workos.updateOrgMembershipRole("mem_admin", "member"); |
| | 303 | }), |
| | 304 | ); |
| | 305 | expect(error).toBeInstanceOf(Forbidden); |
| | 306 | }).pipe(Effect.provide(provide(memberAuth, withMembers))), |
| | 307 | ); |
| | 308 | }); |
| | 309 | }); |