2
0

auth.test.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. import { afterAll, beforeEach, describe, expect, test, vi } from "vitest";
  2. import { inArray } from "drizzle-orm";
  3. import { db } from "@/drizzle/db";
  4. import { keys, users } from "@/drizzle/schema";
  5. import {
  6. clearAuthCookie,
  7. getAuthCookie,
  8. getLoginRedirectTarget,
  9. getSession,
  10. setAuthCookie,
  11. validateKey,
  12. } from "@/lib/auth";
  13. /**
  14. * 说明:
  15. * - 本文件用于覆盖 auth.ts 的权限边界与 Cookie 行为
  16. * - 重点验证:allowReadOnlyAccess 白名单语义
  17. * - 以及 getSession/cookie 的读写一致性
  18. */
  19. let currentCookieValue: string | undefined;
  20. let currentAuthorizationValue: string | undefined;
  21. const cookieSet = vi.fn((name: string, value: string) => {
  22. if (name === "auth-token") currentCookieValue = value;
  23. });
  24. const cookieDelete = vi.fn((name: string) => {
  25. if (name === "auth-token") currentCookieValue = undefined;
  26. });
  27. vi.mock("next/headers", () => ({
  28. cookies: () => ({
  29. get: (name: string) => {
  30. if (name !== "auth-token") return undefined;
  31. return currentCookieValue ? { value: currentCookieValue } : undefined;
  32. },
  33. set: cookieSet,
  34. delete: cookieDelete,
  35. has: (name: string) => name === "auth-token" && Boolean(currentCookieValue),
  36. }),
  37. headers: () => ({
  38. get: (name: string) => {
  39. if (name.toLowerCase() !== "authorization") return null;
  40. return currentAuthorizationValue ?? null;
  41. },
  42. }),
  43. }));
  44. type TestUser = { id: number; name: string };
  45. type TestKey = { id: number; userId: number; key: string; canLoginWebUi: boolean };
  46. async function createTestUser(name: string): Promise<TestUser> {
  47. const [row] = await db
  48. .insert(users)
  49. .values({ name })
  50. .returning({ id: users.id, name: users.name });
  51. if (!row) throw new Error("创建测试用户失败:未返回插入结果");
  52. return row;
  53. }
  54. async function createTestKey(params: {
  55. userId: number;
  56. key: string;
  57. canLoginWebUi: boolean;
  58. }): Promise<TestKey> {
  59. const [row] = await db
  60. .insert(keys)
  61. .values({
  62. userId: params.userId,
  63. key: params.key,
  64. name: `key-${params.key}`,
  65. canLoginWebUi: params.canLoginWebUi,
  66. dailyResetMode: "rolling",
  67. dailyResetTime: "00:00",
  68. })
  69. .returning({
  70. id: keys.id,
  71. userId: keys.userId,
  72. key: keys.key,
  73. canLoginWebUi: keys.canLoginWebUi,
  74. });
  75. if (!row) throw new Error("创建测试 Key 失败:未返回插入结果");
  76. return row;
  77. }
  78. describe("auth.ts:validateKey / getSession(安全边界)", () => {
  79. const createdUserIds: number[] = [];
  80. const createdKeyIds: number[] = [];
  81. afterAll(async () => {
  82. const now = new Date();
  83. if (createdKeyIds.length > 0) {
  84. await db
  85. .update(keys)
  86. .set({ deletedAt: now, updatedAt: now })
  87. .where(inArray(keys.id, createdKeyIds));
  88. }
  89. if (createdUserIds.length > 0) {
  90. await db
  91. .update(users)
  92. .set({ deletedAt: now, updatedAt: now })
  93. .where(inArray(users.id, createdUserIds));
  94. }
  95. });
  96. beforeEach(() => {
  97. currentCookieValue = undefined;
  98. currentAuthorizationValue = undefined;
  99. cookieSet.mockClear();
  100. cookieDelete.mockClear();
  101. });
  102. test("admin token:应返回 admin session(无需 DB)", async () => {
  103. const adminToken = process.env.ADMIN_TOKEN;
  104. expect(adminToken).toBeTruthy();
  105. const session = await validateKey(adminToken as string);
  106. expect(session?.user.role).toBe("admin");
  107. expect(session?.key.canLoginWebUi).toBe(true);
  108. });
  109. test("不存在的 key:validateKey 应返回 null", async () => {
  110. const session = await validateKey(`non-existent-${Date.now()}`);
  111. expect(session).toBeNull();
  112. });
  113. test("canLoginWebUi=false 且 allowReadOnlyAccess=false:应拒绝", async () => {
  114. const unique = `auth-${Date.now()}-${Math.random().toString(16).slice(2)}`;
  115. const user = await createTestUser(`Test ${unique}`);
  116. createdUserIds.push(user.id);
  117. const key = await createTestKey({
  118. userId: user.id,
  119. key: `test-key-${unique}`,
  120. canLoginWebUi: false,
  121. });
  122. createdKeyIds.push(key.id);
  123. const session = await validateKey(key.key, { allowReadOnlyAccess: false });
  124. expect(session).toBeNull();
  125. });
  126. test("allowReadOnlyAccess=true:应允许只读 key 查询自己的数据", async () => {
  127. const unique = `auth-ro-${Date.now()}-${Math.random().toString(16).slice(2)}`;
  128. const user = await createTestUser(`Test ${unique}`);
  129. createdUserIds.push(user.id);
  130. const key = await createTestKey({
  131. userId: user.id,
  132. key: `test-ro-key-${unique}`,
  133. canLoginWebUi: false,
  134. });
  135. createdKeyIds.push(key.id);
  136. const session = await validateKey(key.key, { allowReadOnlyAccess: true });
  137. expect(session?.key.key).toBe(key.key);
  138. expect(session?.key.canLoginWebUi).toBe(false);
  139. });
  140. test("用户被软删除:validateKey 应返回 null", async () => {
  141. const unique = `auth-del-${Date.now()}-${Math.random().toString(16).slice(2)}`;
  142. const user = await createTestUser(`Test ${unique}`);
  143. createdUserIds.push(user.id);
  144. const key = await createTestKey({
  145. userId: user.id,
  146. key: `test-key-${unique}`,
  147. canLoginWebUi: true,
  148. });
  149. createdKeyIds.push(key.id);
  150. const now = new Date();
  151. await db
  152. .update(users)
  153. .set({ deletedAt: now, updatedAt: now })
  154. .where(inArray(users.id, [user.id]));
  155. const session = await validateKey(key.key, { allowReadOnlyAccess: true });
  156. expect(session).toBeNull();
  157. });
  158. test("getSession:无 Cookie 时返回 null;有 Cookie 时返回 session", async () => {
  159. const noCookie = await getSession({ allowReadOnlyAccess: true });
  160. expect(noCookie).toBeNull();
  161. const unique = `auth-sess-${Date.now()}-${Math.random().toString(16).slice(2)}`;
  162. const user = await createTestUser(`Test ${unique}`);
  163. createdUserIds.push(user.id);
  164. const key = await createTestKey({
  165. userId: user.id,
  166. key: `test-key-${unique}`,
  167. canLoginWebUi: false,
  168. });
  169. createdKeyIds.push(key.id);
  170. currentCookieValue = key.key;
  171. const session = await getSession({ allowReadOnlyAccess: true });
  172. expect(session?.key.key).toBe(key.key);
  173. });
  174. test("getSession:仅 Authorization: Bearer 时也应返回 session", async () => {
  175. const unique = `auth-bearer-${Date.now()}-${Math.random().toString(16).slice(2)}`;
  176. const user = await createTestUser(`Test ${unique}`);
  177. createdUserIds.push(user.id);
  178. const key = await createTestKey({
  179. userId: user.id,
  180. key: `test-key-${unique}`,
  181. canLoginWebUi: false,
  182. });
  183. createdKeyIds.push(key.id);
  184. currentAuthorizationValue = `Bearer ${key.key}`;
  185. const session = await getSession({ allowReadOnlyAccess: true });
  186. expect(session?.key.key).toBe(key.key);
  187. });
  188. });
  189. describe("auth.ts:Cookie 工具函数与跳转目标", () => {
  190. beforeEach(() => {
  191. currentCookieValue = undefined;
  192. currentAuthorizationValue = undefined;
  193. cookieSet.mockClear();
  194. cookieDelete.mockClear();
  195. });
  196. test("set/get/clear auth cookie:应读写一致", async () => {
  197. await setAuthCookie("abc");
  198. expect(cookieSet).toHaveBeenCalled();
  199. const value = await getAuthCookie();
  200. expect(value).toBe("abc");
  201. await clearAuthCookie();
  202. expect(cookieDelete).toHaveBeenCalledWith("auth-token");
  203. expect(await getAuthCookie()).toBeUndefined();
  204. });
  205. test("getLoginRedirectTarget:应根据 role 与 canLoginWebUi 决定跳转", () => {
  206. const adminTarget = getLoginRedirectTarget({
  207. user: { role: "admin" } as any,
  208. key: { canLoginWebUi: false } as any,
  209. });
  210. expect(adminTarget).toBe("/dashboard");
  211. const webUiTarget = getLoginRedirectTarget({
  212. user: { role: "user" } as any,
  213. key: { canLoginWebUi: true } as any,
  214. });
  215. expect(webUiTarget).toBe("/dashboard");
  216. const readonlyTarget = getLoginRedirectTarget({
  217. user: { role: "user" } as any,
  218. key: { canLoginWebUi: false } as any,
  219. });
  220. expect(readonlyTarget).toBe("/my-usage");
  221. });
  222. });