auth.test.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525
  1. import { describe, expect, it, vi, beforeEach } from "vitest";
  2. import { appRouter } from "./routers";
  3. import { COOKIE_NAME } from "../shared/const";
  4. import type { TrpcContext } from "./_core/context";
  5. type AuthenticatedUser = NonNullable<TrpcContext["user"]>;
  6. /* ─── Context helpers ─── */
  7. function createPublicContext() {
  8. const cookies: { name: string; value: string; options: Record<string, unknown> }[] = [];
  9. const clearedCookies: { name: string; options: Record<string, unknown> }[] = [];
  10. const ctx: TrpcContext = {
  11. user: null,
  12. req: { protocol: "https", headers: {} } as TrpcContext["req"],
  13. res: {
  14. cookie: (name: string, value: string, options: Record<string, unknown>) => {
  15. cookies.push({ name, value, options });
  16. },
  17. clearCookie: (name: string, options: Record<string, unknown>) => {
  18. clearedCookies.push({ name, options });
  19. },
  20. } as unknown as TrpcContext["res"],
  21. };
  22. return { ctx, cookies, clearedCookies };
  23. }
  24. function createAuthContext(role: "admin" | "agent" | "user") {
  25. const user: AuthenticatedUser = {
  26. id: 1,
  27. openId: "test-openid",
  28. email: "test@homelegance.com",
  29. name: "Test User",
  30. loginMethod: "password",
  31. role,
  32. createdAt: new Date(),
  33. updatedAt: new Date(),
  34. lastSignedIn: new Date(),
  35. };
  36. return {
  37. user,
  38. req: { protocol: "https", headers: {} } as TrpcContext["req"],
  39. res: {
  40. cookie: vi.fn(),
  41. clearCookie: vi.fn(),
  42. } as unknown as TrpcContext["res"],
  43. };
  44. }
  45. /* ─── Mock database ─── */
  46. const mockUsers: any[] = [];
  47. const mockResetTokens: any[] = [];
  48. let userIdCounter = 1;
  49. let tokenIdCounter = 1;
  50. vi.mock("./db", () => ({
  51. getUserByEmailWithPassword: vi.fn(async (email: string) => {
  52. return mockUsers.find(u => u.email === email) || null;
  53. }),
  54. createUserWithPassword: vi.fn(async (data: any) => {
  55. const user = {
  56. id: userIdCounter++,
  57. openId: `email-${data.email}`,
  58. email: data.email,
  59. name: data.name,
  60. passwordHash: data.passwordHash,
  61. role: "user",
  62. loginMethod: "password",
  63. createdAt: new Date(),
  64. updatedAt: new Date(),
  65. lastSignedIn: new Date(),
  66. };
  67. mockUsers.push(user);
  68. return user;
  69. }),
  70. updateUserPassword: vi.fn(async (userId: number, hash: string) => {
  71. const user = mockUsers.find(u => u.id === userId);
  72. if (user) user.passwordHash = hash;
  73. }),
  74. createPasswordResetToken: vi.fn(async (data: any) => {
  75. const token = { id: tokenIdCounter++, ...data, usedAt: null, createdAt: new Date() };
  76. mockResetTokens.push(token);
  77. return token;
  78. }),
  79. getPasswordResetToken: vi.fn(async (token: string) => {
  80. return mockResetTokens.find(t => t.token === token) || null;
  81. }),
  82. markPasswordResetTokenUsed: vi.fn(async (id: number) => {
  83. const token = mockResetTokens.find(t => t.id === id);
  84. if (token) token.usedAt = new Date();
  85. }),
  86. getUserById: vi.fn(async (id: number) => {
  87. return mockUsers.find(u => u.id === id) || null;
  88. }),
  89. // Other db exports needed by routers
  90. createConversation: vi.fn(async (data: any) => ({ id: 1, ...data })),
  91. getConversations: vi.fn(async () => []),
  92. getConversationById: vi.fn(async () => null),
  93. getConversationBySessionId: vi.fn(async () => null),
  94. updateConversationStatus: vi.fn(async () => null),
  95. getConversationStats: vi.fn(async () => ({ total: 0, active: 0, escalated: 0, resolved: 0, closed: 0 })),
  96. addMessage: vi.fn(async (data: any) => ({ id: 1, ...data })),
  97. getMessagesByConversation: vi.fn(async () => []),
  98. saveWorkflow: vi.fn(async () => ({})),
  99. getWorkflow: vi.fn(async () => ({ nodes: [], edges: [] })),
  100. getAllUsers: vi.fn(async () => []),
  101. updateUserRole: vi.fn(async () => null),
  102. getUserByEmail: vi.fn(async () => null),
  103. deleteUser: vi.fn(async () => null),
  104. createInvitation: vi.fn(async () => ({})),
  105. getAllInvitations: vi.fn(async () => []),
  106. getInvitationByToken: vi.fn(async () => null),
  107. getInvitationByEmail: vi.fn(async () => []),
  108. updateInvitationStatus: vi.fn(async () => null),
  109. expireOldInvitations: vi.fn(async () => {}),
  110. createAuditLog: vi.fn(async () => ({})),
  111. getAuditLogs: vi.fn(async () => []),
  112. upsertUser: vi.fn(),
  113. getUserByOpenId: vi.fn(),
  114. }));
  115. vi.mock("./_core/llm", () => ({
  116. invokeLLM: vi.fn(async () => ({
  117. id: "test",
  118. created: Date.now(),
  119. model: "test",
  120. choices: [{ index: 0, message: { role: "assistant", content: "Test" }, finish_reason: "stop" }],
  121. })),
  122. }));
  123. vi.mock("./_core/notification", () => ({
  124. notifyOwner: vi.fn(async () => true),
  125. }));
  126. vi.mock("./_core/sdk", () => ({
  127. sdk: {
  128. createSessionToken: vi.fn(async () => "mock-session-token"),
  129. },
  130. }));
  131. vi.mock("bcryptjs", () => ({
  132. default: {
  133. hash: vi.fn(async (password: string) => `hashed-${password}`),
  134. compare: vi.fn(async (password: string, hash: string) => hash === `hashed-${password}`),
  135. },
  136. }));
  137. beforeEach(() => {
  138. mockUsers.length = 0;
  139. mockResetTokens.length = 0;
  140. userIdCounter = 1;
  141. tokenIdCounter = 1;
  142. });
  143. /* ═══════════════════════════════════════════════════════════════
  144. REGISTRATION
  145. ═══════════════════════════════════════════════════════════════ */
  146. describe("auth.register", () => {
  147. it("creates a new user and sets session cookie", async () => {
  148. const { ctx, cookies } = createPublicContext();
  149. const caller = appRouter.createCaller(ctx);
  150. const result = await caller.auth.register({
  151. email: "new@homelegance.com",
  152. password: "StrongPass1",
  153. name: "New User",
  154. });
  155. expect(result.success).toBe(true);
  156. expect(result.user.email).toBe("new@homelegance.com");
  157. expect(result.user.name).toBe("New User");
  158. expect(result.user.role).toBe("user");
  159. expect(cookies.length).toBe(1);
  160. expect(cookies[0].name).toBe(COOKIE_NAME);
  161. expect(cookies[0].value).toBe("mock-session-token");
  162. });
  163. it("rejects duplicate email registration", async () => {
  164. const { ctx: ctx1 } = createPublicContext();
  165. const caller1 = appRouter.createCaller(ctx1);
  166. await caller1.auth.register({
  167. email: "dup@homelegance.com",
  168. password: "StrongPass1",
  169. name: "First User",
  170. });
  171. const { ctx: ctx2 } = createPublicContext();
  172. const caller2 = appRouter.createCaller(ctx2);
  173. await expect(
  174. caller2.auth.register({
  175. email: "dup@homelegance.com",
  176. password: "AnotherPass1",
  177. name: "Second User",
  178. })
  179. ).rejects.toThrow("An account with this email already exists");
  180. });
  181. it("rejects password shorter than 8 characters", async () => {
  182. const { ctx } = createPublicContext();
  183. const caller = appRouter.createCaller(ctx);
  184. await expect(
  185. caller.auth.register({
  186. email: "short@homelegance.com",
  187. password: "Short1",
  188. name: "Short Pass",
  189. })
  190. ).rejects.toThrow();
  191. });
  192. });
  193. /* ═══════════════════════════════════════════════════════════════
  194. LOGIN
  195. ═══════════════════════════════════════════════════════════════ */
  196. describe("auth.login", () => {
  197. it("logs in with correct credentials and sets session cookie", async () => {
  198. // First register a user
  199. const { ctx: regCtx } = createPublicContext();
  200. await appRouter.createCaller(regCtx).auth.register({
  201. email: "login@homelegance.com",
  202. password: "CorrectPass1",
  203. name: "Login User",
  204. });
  205. // Now login
  206. const { ctx, cookies } = createPublicContext();
  207. const caller = appRouter.createCaller(ctx);
  208. const result = await caller.auth.login({
  209. email: "login@homelegance.com",
  210. password: "CorrectPass1",
  211. });
  212. expect(result.success).toBe(true);
  213. expect(result.user.email).toBe("login@homelegance.com");
  214. expect(cookies.length).toBe(1);
  215. expect(cookies[0].name).toBe(COOKIE_NAME);
  216. });
  217. it("rejects login with wrong password", async () => {
  218. const { ctx: regCtx } = createPublicContext();
  219. await appRouter.createCaller(regCtx).auth.register({
  220. email: "wrong@homelegance.com",
  221. password: "CorrectPass1",
  222. name: "Wrong Pass User",
  223. });
  224. const { ctx } = createPublicContext();
  225. const caller = appRouter.createCaller(ctx);
  226. await expect(
  227. caller.auth.login({
  228. email: "wrong@homelegance.com",
  229. password: "WrongPassword1",
  230. })
  231. ).rejects.toThrow("Invalid email or password");
  232. });
  233. it("rejects login with non-existent email", async () => {
  234. const { ctx } = createPublicContext();
  235. const caller = appRouter.createCaller(ctx);
  236. await expect(
  237. caller.auth.login({
  238. email: "nonexistent@homelegance.com",
  239. password: "SomePass1",
  240. })
  241. ).rejects.toThrow("Invalid email or password");
  242. });
  243. });
  244. /* ═══════════════════════════════════════════════════════════════
  245. FORGOT PASSWORD
  246. ═══════════════════════════════════════════════════════════════ */
  247. describe("auth.forgotPassword", () => {
  248. it("returns success and token for existing user", async () => {
  249. // Register a user first
  250. const { ctx: regCtx } = createPublicContext();
  251. await appRouter.createCaller(regCtx).auth.register({
  252. email: "forgot@homelegance.com",
  253. password: "OldPass123",
  254. name: "Forgot User",
  255. });
  256. const { ctx } = createPublicContext();
  257. const caller = appRouter.createCaller(ctx);
  258. const result = await caller.auth.forgotPassword({
  259. email: "forgot@homelegance.com",
  260. });
  261. expect(result.success).toBe(true);
  262. expect(result.resetToken).toBeDefined();
  263. expect(typeof result.resetToken).toBe("string");
  264. });
  265. it("returns success even for non-existent email (prevents enumeration)", async () => {
  266. const { ctx } = createPublicContext();
  267. const caller = appRouter.createCaller(ctx);
  268. const result = await caller.auth.forgotPassword({
  269. email: "nobody@homelegance.com",
  270. });
  271. expect(result.success).toBe(true);
  272. expect(result.resetToken).toBeUndefined();
  273. });
  274. });
  275. /* ═══════════════════════════════════════════════════════════════
  276. VALIDATE RESET TOKEN
  277. ═══════════════════════════════════════════════════════════════ */
  278. describe("auth.validateResetToken", () => {
  279. it("validates a valid token", async () => {
  280. // Register and request reset
  281. const { ctx: regCtx } = createPublicContext();
  282. await appRouter.createCaller(regCtx).auth.register({
  283. email: "validate@homelegance.com",
  284. password: "OldPass123",
  285. name: "Validate User",
  286. });
  287. const { ctx: forgotCtx } = createPublicContext();
  288. const forgotResult = await appRouter.createCaller(forgotCtx).auth.forgotPassword({
  289. email: "validate@homelegance.com",
  290. });
  291. const { ctx } = createPublicContext();
  292. const caller = appRouter.createCaller(ctx);
  293. const result = await caller.auth.validateResetToken({
  294. token: forgotResult.resetToken!,
  295. });
  296. expect(result.valid).toBe(true);
  297. if (result.valid) {
  298. expect(result.email).toBe("validate@homelegance.com");
  299. }
  300. });
  301. it("rejects an invalid token", async () => {
  302. const { ctx } = createPublicContext();
  303. const caller = appRouter.createCaller(ctx);
  304. const result = await caller.auth.validateResetToken({
  305. token: "invalid-token-xyz",
  306. });
  307. expect(result.valid).toBe(false);
  308. });
  309. it("rejects an expired token", async () => {
  310. // Register and create an expired token manually
  311. const { ctx: regCtx } = createPublicContext();
  312. await appRouter.createCaller(regCtx).auth.register({
  313. email: "expired@homelegance.com",
  314. password: "OldPass123",
  315. name: "Expired User",
  316. });
  317. // Manually add an expired token
  318. mockResetTokens.push({
  319. id: tokenIdCounter++,
  320. userId: 1,
  321. token: "expired-token",
  322. expiresAt: new Date(Date.now() - 1000), // expired 1 second ago
  323. usedAt: null,
  324. createdAt: new Date(),
  325. });
  326. const { ctx } = createPublicContext();
  327. const caller = appRouter.createCaller(ctx);
  328. const result = await caller.auth.validateResetToken({
  329. token: "expired-token",
  330. });
  331. expect(result.valid).toBe(false);
  332. if (!result.valid) {
  333. expect(result.reason).toContain("expired");
  334. }
  335. });
  336. it("rejects an already-used token", async () => {
  337. mockResetTokens.push({
  338. id: tokenIdCounter++,
  339. userId: 1,
  340. token: "used-token",
  341. expiresAt: new Date(Date.now() + 3600000),
  342. usedAt: new Date(),
  343. createdAt: new Date(),
  344. });
  345. const { ctx } = createPublicContext();
  346. const caller = appRouter.createCaller(ctx);
  347. const result = await caller.auth.validateResetToken({
  348. token: "used-token",
  349. });
  350. expect(result.valid).toBe(false);
  351. if (!result.valid) {
  352. expect(result.reason).toContain("already been used");
  353. }
  354. });
  355. });
  356. /* ═══════════════════════════════════════════════════════════════
  357. RESET PASSWORD
  358. ═══════════════════════════════════════════════════════════════ */
  359. describe("auth.resetPassword", () => {
  360. it("resets password with a valid token", async () => {
  361. // Register and request reset
  362. const { ctx: regCtx } = createPublicContext();
  363. await appRouter.createCaller(regCtx).auth.register({
  364. email: "reset@homelegance.com",
  365. password: "OldPass123",
  366. name: "Reset User",
  367. });
  368. const { ctx: forgotCtx } = createPublicContext();
  369. const forgotResult = await appRouter.createCaller(forgotCtx).auth.forgotPassword({
  370. email: "reset@homelegance.com",
  371. });
  372. const { ctx } = createPublicContext();
  373. const caller = appRouter.createCaller(ctx);
  374. const result = await caller.auth.resetPassword({
  375. token: forgotResult.resetToken!,
  376. newPassword: "NewStrongPass1",
  377. });
  378. expect(result.success).toBe(true);
  379. });
  380. it("rejects reset with invalid token", async () => {
  381. const { ctx } = createPublicContext();
  382. const caller = appRouter.createCaller(ctx);
  383. await expect(
  384. caller.auth.resetPassword({
  385. token: "invalid-token",
  386. newPassword: "NewPass123",
  387. })
  388. ).rejects.toThrow("Invalid reset link");
  389. });
  390. it("rejects reset with expired token", async () => {
  391. mockResetTokens.push({
  392. id: tokenIdCounter++,
  393. userId: 1,
  394. token: "expired-reset",
  395. expiresAt: new Date(Date.now() - 1000),
  396. usedAt: null,
  397. createdAt: new Date(),
  398. });
  399. const { ctx } = createPublicContext();
  400. const caller = appRouter.createCaller(ctx);
  401. await expect(
  402. caller.auth.resetPassword({
  403. token: "expired-reset",
  404. newPassword: "NewPass123",
  405. })
  406. ).rejects.toThrow("expired");
  407. });
  408. it("rejects reset with already-used token", async () => {
  409. mockResetTokens.push({
  410. id: tokenIdCounter++,
  411. userId: 1,
  412. token: "used-reset",
  413. expiresAt: new Date(Date.now() + 3600000),
  414. usedAt: new Date(),
  415. createdAt: new Date(),
  416. });
  417. const { ctx } = createPublicContext();
  418. const caller = appRouter.createCaller(ctx);
  419. await expect(
  420. caller.auth.resetPassword({
  421. token: "used-reset",
  422. newPassword: "NewPass123",
  423. })
  424. ).rejects.toThrow("already been used");
  425. });
  426. it("rejects new password shorter than 8 characters", async () => {
  427. // Register and request reset
  428. const { ctx: regCtx } = createPublicContext();
  429. await appRouter.createCaller(regCtx).auth.register({
  430. email: "shortpw@homelegance.com",
  431. password: "OldPass123",
  432. name: "Short PW User",
  433. });
  434. const { ctx: forgotCtx } = createPublicContext();
  435. const forgotResult = await appRouter.createCaller(forgotCtx).auth.forgotPassword({
  436. email: "shortpw@homelegance.com",
  437. });
  438. const { ctx } = createPublicContext();
  439. const caller = appRouter.createCaller(ctx);
  440. await expect(
  441. caller.auth.resetPassword({
  442. token: forgotResult.resetToken!,
  443. newPassword: "Short1",
  444. })
  445. ).rejects.toThrow();
  446. });
  447. });
  448. /* ═══════════════════════════════════════════════════════════════
  449. LOGOUT
  450. ═══════════════════════════════════════════════════════════════ */
  451. describe("auth.logout", () => {
  452. it("clears the session cookie", async () => {
  453. const ctx = createAuthContext("admin");
  454. const caller = appRouter.createCaller(ctx);
  455. const result = await caller.auth.logout();
  456. expect(result.success).toBe(true);
  457. expect(ctx.res.clearCookie).toHaveBeenCalledWith(
  458. COOKIE_NAME,
  459. expect.objectContaining({ maxAge: -1 })
  460. );
  461. });
  462. });