action-adapter-auth-session.unit.test.ts 3.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127
  1. import { describe, expect, test, vi } from "vitest";
  2. import "@/lib/auth-session-storage.node";
  3. /**
  4. * 回归用例:/api/actions adapter 鉴权通过后,action 内部调用 getSession() 仍应拿到会话
  5. *
  6. * 背景:
  7. * - adapter 层使用 hono 读取 Cookie/Authorization 并 validateKey
  8. * - action 层传统依赖 next/headers 读取请求上下文
  9. * - 某些运行时下 action 读取不到上下文,导致返回 ok 但 data 为空
  10. *
  11. * 期望:
  12. * - adapter 在调用 action 时注入 session(AsyncLocalStorage)
  13. * - action 内 getSession() 优先读取注入会话,不触发 next/headers
  14. */
  15. vi.mock("next/headers", () => ({
  16. cookies: () => {
  17. throw new Error("不应在该用例中调用 next/headers.cookies()");
  18. },
  19. headers: () => ({
  20. get: () => null,
  21. }),
  22. }));
  23. describe("Action Adapter:会话透传", () => {
  24. test("requiresAuth=true:action 内 getSession() 应返回注入的 session", async () => {
  25. vi.resetModules();
  26. const mockSession = {
  27. user: {
  28. id: 123,
  29. name: "u1",
  30. description: "",
  31. role: "user" as const,
  32. rpm: null,
  33. dailyQuota: null,
  34. providerGroup: null,
  35. tags: [],
  36. createdAt: new Date(),
  37. updatedAt: new Date(),
  38. deletedAt: undefined,
  39. dailyResetMode: "fixed" as const,
  40. dailyResetTime: "00:00",
  41. isEnabled: true,
  42. expiresAt: null,
  43. allowedClients: [],
  44. allowedModels: [],
  45. },
  46. key: {
  47. id: 1,
  48. userId: 123,
  49. name: "k1",
  50. key: "token-1",
  51. isEnabled: true,
  52. expiresAt: undefined,
  53. canLoginWebUi: false,
  54. limit5hUsd: null,
  55. limitDailyUsd: null,
  56. dailyResetMode: "fixed" as const,
  57. dailyResetTime: "00:00",
  58. limitWeeklyUsd: null,
  59. limitMonthlyUsd: null,
  60. limitTotalUsd: null,
  61. limitConcurrentSessions: 0,
  62. providerGroup: null,
  63. cacheTtlPreference: null,
  64. createdAt: new Date(),
  65. updatedAt: new Date(),
  66. deletedAt: undefined,
  67. },
  68. };
  69. vi.doMock("@/lib/auth", async (importActual) => {
  70. const actual = (await importActual()) as typeof import("@/lib/auth");
  71. return {
  72. ...actual,
  73. validateKey: vi.fn(async () => mockSession),
  74. validateAuthToken: vi.fn(async () => mockSession),
  75. };
  76. });
  77. const { createActionRoute } = await import("@/lib/api/action-adapter-openapi");
  78. const { getSession, validateAuthToken } = await import("@/lib/auth");
  79. const action = vi.fn(async () => {
  80. const session = await getSession();
  81. // 显式降权校验:当 key 为只读(canLoginWebUi=false)时,strict session 应返回 null
  82. const strictSession = await getSession({ allowReadOnlyAccess: false });
  83. return {
  84. ok: true,
  85. data: { userId: session?.user.id ?? null, strictUserId: strictSession?.user.id ?? null },
  86. };
  87. });
  88. const { handler } = createActionRoute("users", "getUsers", action as any, {
  89. requiresAuth: true,
  90. allowReadOnlyAccess: true,
  91. });
  92. const response = (await handler({
  93. req: {
  94. raw: new Request("http://localhost/api/actions/users/getUsers", {
  95. headers: new Headers(),
  96. }),
  97. json: async () => ({}),
  98. header: (name: string) => {
  99. if (name.toLowerCase() === "authorization") return "Bearer token-1";
  100. return undefined;
  101. },
  102. },
  103. json: (payload: unknown, status = 200) =>
  104. new Response(JSON.stringify(payload), {
  105. status,
  106. headers: { "content-type": "application/json" },
  107. }),
  108. } as any)) as Response;
  109. expect(validateAuthToken).toHaveBeenCalledTimes(1);
  110. expect(action).toHaveBeenCalledTimes(1);
  111. expect(response.status).toBe(200);
  112. await expect(response.json()).resolves.toEqual({
  113. ok: true,
  114. data: { userId: 123, strictUserId: null },
  115. });
  116. });
  117. });