api-endpoints.test.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. /**
  2. * API 端点 HTTP 集成测试
  3. *
  4. * 目的:
  5. * - 测试 OpenAPI 端点的 HTTP 请求/响应
  6. * - 验证认证、权限、参数验证
  7. * - 测试错误处理和边界条件
  8. *
  9. * 用法:
  10. * bun run test:api
  11. *
  12. * 默认模式(推荐):
  13. * 进程内调用 Next Route Handler,无需启动开发服务器
  14. *
  15. * E2E 模式(可选):
  16. * 设置 API_E2E_BASE_URL 后,将改为真实 HTTP 访问(需要先启动服务与依赖)
  17. * 例如:API_E2E_BASE_URL=http://localhost:13500/api/actions
  18. */
  19. import { describe, expect, test } from "vitest";
  20. import { callActionsRoute } from "../test-utils";
  21. const E2E_API_BASE_URL = process.env.API_E2E_BASE_URL || "";
  22. const API_BASE_URL = E2E_API_BASE_URL || "http://localhost:13500/api/actions";
  23. // 辅助函数:发送 API 请求
  24. async function callApi(
  25. module: string,
  26. action: string,
  27. body: Record<string, unknown> = {},
  28. options: { authToken?: string } = {}
  29. ) {
  30. // 默认走进程内调用(稳定、无需启动服务器),仅当设置 API_E2E_BASE_URL 时才走真实 HTTP
  31. if (!E2E_API_BASE_URL) {
  32. const { response, json } = await callActionsRoute({
  33. method: "POST",
  34. pathname: `/api/actions/${module}/${action}`,
  35. authToken: options.authToken,
  36. body,
  37. });
  38. return { response, data: json as any };
  39. }
  40. const response = await fetch(`${API_BASE_URL}/${module}/${action}`, {
  41. method: "POST",
  42. headers: {
  43. "Content-Type": "application/json",
  44. ...(options.authToken && { Cookie: `auth-token=${options.authToken}` }),
  45. },
  46. body: JSON.stringify(body),
  47. });
  48. const data = await response.json();
  49. return { response, data };
  50. }
  51. describe("API 认证测试", () => {
  52. test("缺少 auth-token 应该返回 401", async () => {
  53. const { response, data } = await callApi("users", "getUsers");
  54. expect(response.status).toBe(401);
  55. expect(data.ok).toBe(false);
  56. expect(data.error).toContain("未认证");
  57. });
  58. test("无效的 auth-token 应该返回 401", async () => {
  59. // 该断言依赖数据库可用(validateKey 会查询 keys/users),因此仅在 E2E 模式运行
  60. if (!E2E_API_BASE_URL) {
  61. console.log("⚠️ 跳过无效 token 测试(需要 API_E2E_BASE_URL + 可用数据库)");
  62. return;
  63. }
  64. const { response, data } = await callApi(
  65. "users",
  66. "getUsers",
  67. {},
  68. { authToken: "invalid-token" }
  69. );
  70. expect(response.status).toBe(401);
  71. expect(data.ok).toBe(false);
  72. expect(data.error).toContain("认证无效");
  73. });
  74. });
  75. describe("API 参数验证测试", () => {
  76. test("缺少必需参数应该返回 400 或 500", async () => {
  77. if (!E2E_API_BASE_URL) {
  78. console.log("⚠️ 跳过参数验证测试(需要 API_E2E_BASE_URL + 可用数据库/认证)");
  79. return;
  80. }
  81. // 模拟登录后的 token(实际使用时需要真实 token)
  82. const mockToken = "test-token";
  83. const { response } = await callApi(
  84. "users",
  85. "editUser",
  86. {
  87. // 缺少 userId 参数
  88. name: "Test User",
  89. },
  90. { authToken: mockToken }
  91. );
  92. // 参数验证失败应该返回错误
  93. expect([400, 401, 500]).toContain(response.status);
  94. });
  95. test("无效参数类型应该返回 400 或 500", async () => {
  96. if (!E2E_API_BASE_URL) {
  97. console.log("⚠️ 跳过参数验证测试(需要 API_E2E_BASE_URL + 可用数据库/认证)");
  98. return;
  99. }
  100. const mockToken = "test-token";
  101. const { response } = await callApi(
  102. "keys",
  103. "getKeys",
  104. {
  105. userId: "not-a-number", // 应该是 number
  106. },
  107. { authToken: mockToken }
  108. );
  109. expect([400, 401, 500]).toContain(response.status);
  110. });
  111. });
  112. describe("API 响应格式测试", () => {
  113. test("所有成功响应应该符合 {ok: true, data: ...} 格式", async () => {
  114. // 这个测试需要真实的认证 token
  115. // 此处仅作示例,实际运行需要有效 session
  116. const mockToken = process.env.TEST_AUTH_TOKEN || "skip";
  117. if (mockToken === "skip") {
  118. console.log("⚠️ 跳过响应格式测试(需要设置 TEST_AUTH_TOKEN 环境变量)");
  119. return;
  120. }
  121. const { response, data } = await callApi(
  122. "overview",
  123. "getOverviewData",
  124. {},
  125. { authToken: mockToken }
  126. );
  127. if (response.ok) {
  128. expect(data).toHaveProperty("ok");
  129. expect(data.ok).toBe(true);
  130. expect(data).toHaveProperty("data");
  131. }
  132. });
  133. test("所有错误响应应该符合 {ok: false, error: ...} 格式", async () => {
  134. const { data } = await callApi("users", "getUsers"); // 无 auth
  135. expect(data).toHaveProperty("ok");
  136. expect(data.ok).toBe(false);
  137. expect(data).toHaveProperty("error");
  138. expect(typeof data.error).toBe("string");
  139. });
  140. });
  141. describe("API 端点可达性测试", () => {
  142. const criticalEndpoints = [
  143. // 用户管理
  144. { module: "users", action: "getUsers" },
  145. { module: "users", action: "addUser" },
  146. { module: "users", action: "editUser" },
  147. { module: "users", action: "removeUser" },
  148. // 密钥管理
  149. { module: "keys", action: "getKeys" },
  150. { module: "keys", action: "addKey" },
  151. // 供应商管理
  152. { module: "providers", action: "getProviders" },
  153. { module: "providers", action: "addProvider" },
  154. { module: "providers", action: "getProvidersHealthStatus" },
  155. // 统计与日志
  156. { module: "statistics", action: "getUserStatistics" },
  157. { module: "usage-logs", action: "getUsageLogs" },
  158. { module: "overview", action: "getOverviewData" },
  159. // Session 管理
  160. { module: "active-sessions", action: "getActiveSessions" },
  161. ];
  162. test("所有关键端点应该可访问(即使认证失败)", async () => {
  163. const results = await Promise.all(
  164. criticalEndpoints.map(async ({ module, action }) => {
  165. try {
  166. const response = !E2E_API_BASE_URL
  167. ? (
  168. await callActionsRoute({
  169. method: "POST",
  170. pathname: `/api/actions/${module}/${action}`,
  171. body: {},
  172. })
  173. ).response
  174. : await fetch(`${API_BASE_URL}/${module}/${action}`, {
  175. method: "POST",
  176. headers: { "Content-Type": "application/json" },
  177. body: JSON.stringify({}),
  178. });
  179. return {
  180. endpoint: `${module}/${action}`,
  181. status: response.status,
  182. reachable: response.status !== 404,
  183. };
  184. } catch (error) {
  185. return {
  186. endpoint: `${module}/${action}`,
  187. status: 0,
  188. reachable: false,
  189. error: error instanceof Error ? error.message : String(error),
  190. };
  191. }
  192. })
  193. );
  194. // 所有端点都应该返回非 404 状态(401 或其他都可以)
  195. const unreachable = results.filter((r) => !r.reachable);
  196. expect(unreachable).toEqual([]);
  197. });
  198. });
  199. describe("API 文档 UI 可访问性", () => {
  200. test("Scalar UI 应该可访问", async () => {
  201. const response = !E2E_API_BASE_URL
  202. ? (await callActionsRoute({ method: "GET", pathname: "/api/actions/scalar" })).response
  203. : await fetch(`${API_BASE_URL}/scalar`);
  204. expect(response.ok).toBe(true);
  205. expect(response.headers.get("content-type")).toContain("text/html");
  206. });
  207. test("Swagger UI 应该可访问", async () => {
  208. const response = !E2E_API_BASE_URL
  209. ? (await callActionsRoute({ method: "GET", pathname: "/api/actions/docs" })).response
  210. : await fetch(`${API_BASE_URL}/docs`);
  211. expect(response.ok).toBe(true);
  212. expect(response.headers.get("content-type")).toContain("text/html");
  213. });
  214. test("健康检查端点应该正常", async () => {
  215. if (!E2E_API_BASE_URL) {
  216. // 进程内调用模式
  217. const { response, json } = await callActionsRoute({
  218. method: "GET",
  219. pathname: "/api/actions/health",
  220. });
  221. expect(response.ok).toBe(true);
  222. expect(json).toBeDefined();
  223. expect((json as any).status).toBe("ok");
  224. expect((json as any).timestamp).toBeDefined();
  225. expect((json as any).version).toBeDefined();
  226. } else {
  227. // E2E HTTP 调用模式
  228. const response = await fetch(`${API_BASE_URL}/health`);
  229. expect(response.ok).toBe(true);
  230. const data = await response.json();
  231. expect(data.status).toBe("ok");
  232. expect(data.timestamp).toBeDefined();
  233. expect(data.version).toBeDefined();
  234. }
  235. });
  236. });