2
0

readonly-access-endpoints.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  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 { callActionsRoute } from "../test-utils";
  6. /**
  7. * Issue #687: allowReadOnlyAccess endpoints test
  8. *
  9. * Test that endpoints with allowReadOnlyAccess: true can be accessed
  10. * by API keys with canLoginWebUi=false (readonly keys).
  11. *
  12. * These endpoints have business logic that already supports regular users
  13. * (returning only their own data), so they should allow readonly access.
  14. */
  15. let currentAuthToken: string | undefined;
  16. let currentAuthorization: string | undefined;
  17. vi.mock("next/headers", () => ({
  18. cookies: () => ({
  19. get: (name: string) => {
  20. if (name !== "auth-token") return undefined;
  21. return currentAuthToken ? { value: currentAuthToken } : undefined;
  22. },
  23. set: vi.fn(),
  24. delete: vi.fn(),
  25. has: (name: string) => name === "auth-token" && Boolean(currentAuthToken),
  26. }),
  27. headers: () => ({
  28. get: (name: string) => {
  29. if (name.toLowerCase() !== "authorization") return null;
  30. return currentAuthorization ?? null;
  31. },
  32. }),
  33. }));
  34. type TestKey = { id: number; userId: number; key: string; name: string };
  35. type TestUser = { id: number; name: string };
  36. async function createTestUser(name: string): Promise<TestUser> {
  37. const [row] = await db
  38. .insert(users)
  39. .values({ name })
  40. .returning({ id: users.id, name: users.name });
  41. if (!row) {
  42. throw new Error("Failed to create test user");
  43. }
  44. return row;
  45. }
  46. async function createTestKey(params: {
  47. userId: number;
  48. key: string;
  49. name: string;
  50. canLoginWebUi: boolean;
  51. }): Promise<TestKey> {
  52. const [row] = await db
  53. .insert(keys)
  54. .values({
  55. userId: params.userId,
  56. key: params.key,
  57. name: params.name,
  58. canLoginWebUi: params.canLoginWebUi,
  59. dailyResetMode: "rolling",
  60. dailyResetTime: "00:00",
  61. })
  62. .returning({ id: keys.id, userId: keys.userId, key: keys.key, name: keys.name });
  63. if (!row) {
  64. throw new Error("Failed to create test key");
  65. }
  66. return row;
  67. }
  68. describe("allowReadOnlyAccess endpoints (Issue #687)", () => {
  69. const createdUserIds: number[] = [];
  70. const createdKeyIds: number[] = [];
  71. afterAll(async () => {
  72. const now = new Date();
  73. if (createdKeyIds.length > 0) {
  74. await db
  75. .update(keys)
  76. .set({ deletedAt: now, updatedAt: now })
  77. .where(inArray(keys.id, createdKeyIds));
  78. }
  79. if (createdUserIds.length > 0) {
  80. await db
  81. .update(users)
  82. .set({ deletedAt: now, updatedAt: now })
  83. .where(inArray(users.id, createdUserIds));
  84. }
  85. });
  86. beforeEach(() => {
  87. currentAuthToken = undefined;
  88. currentAuthorization = undefined;
  89. });
  90. test("readonly key (canLoginWebUi=false) can access getUsers endpoint", async () => {
  91. const unique = `readonly-getusers-${Date.now()}-${Math.random().toString(16).slice(2)}`;
  92. const user = await createTestUser(`Test ${unique}`);
  93. createdUserIds.push(user.id);
  94. const readonlyKey = await createTestKey({
  95. userId: user.id,
  96. key: `test-readonly-key-${unique}`,
  97. name: `readonly-${unique}`,
  98. canLoginWebUi: false,
  99. });
  100. createdKeyIds.push(readonlyKey.id);
  101. currentAuthToken = readonlyKey.key;
  102. const { response, json } = await callActionsRoute({
  103. method: "POST",
  104. pathname: "/api/actions/users/getUsers",
  105. authToken: readonlyKey.key,
  106. body: {},
  107. });
  108. expect(response.status).toBe(200);
  109. expect(json).toMatchObject({ ok: true });
  110. // Regular user should only see their own data
  111. const data = (json as { ok: boolean; data: Array<{ id: number }> }).data;
  112. expect(data.length).toBe(1);
  113. expect(data[0].id).toBe(user.id);
  114. });
  115. test("readonly key can access getUserLimitUsage for own user", async () => {
  116. const unique = `readonly-userlimit-${Date.now()}-${Math.random().toString(16).slice(2)}`;
  117. const user = await createTestUser(`Test ${unique}`);
  118. createdUserIds.push(user.id);
  119. const readonlyKey = await createTestKey({
  120. userId: user.id,
  121. key: `test-readonly-key-${unique}`,
  122. name: `readonly-${unique}`,
  123. canLoginWebUi: false,
  124. });
  125. createdKeyIds.push(readonlyKey.id);
  126. currentAuthToken = readonlyKey.key;
  127. const { response, json } = await callActionsRoute({
  128. method: "POST",
  129. pathname: "/api/actions/users/getUserLimitUsage",
  130. authToken: readonlyKey.key,
  131. body: { userId: user.id },
  132. });
  133. expect(response.status).toBe(200);
  134. expect(json).toMatchObject({ ok: true });
  135. });
  136. // Note: getKeys and getKeyLimitUsage are intentionally NOT allowReadOnlyAccess
  137. // because a readonly key should not be able to see other keys under the same user
  138. test("readonly key can access getUserStatistics", async () => {
  139. const unique = `readonly-stats-${Date.now()}-${Math.random().toString(16).slice(2)}`;
  140. const user = await createTestUser(`Test ${unique}`);
  141. createdUserIds.push(user.id);
  142. const readonlyKey = await createTestKey({
  143. userId: user.id,
  144. key: `test-readonly-key-${unique}`,
  145. name: `readonly-${unique}`,
  146. canLoginWebUi: false,
  147. });
  148. createdKeyIds.push(readonlyKey.id);
  149. currentAuthToken = readonlyKey.key;
  150. const { response, json } = await callActionsRoute({
  151. method: "POST",
  152. pathname: "/api/actions/statistics/getUserStatistics",
  153. authToken: readonlyKey.key,
  154. body: { timeRange: "today" },
  155. });
  156. expect(response.status).toBe(200);
  157. expect(json).toMatchObject({ ok: true });
  158. });
  159. test("readonly key can access getUsageLogs", async () => {
  160. const unique = `readonly-logs-${Date.now()}-${Math.random().toString(16).slice(2)}`;
  161. const user = await createTestUser(`Test ${unique}`);
  162. createdUserIds.push(user.id);
  163. const readonlyKey = await createTestKey({
  164. userId: user.id,
  165. key: `test-readonly-key-${unique}`,
  166. name: `readonly-${unique}`,
  167. canLoginWebUi: false,
  168. });
  169. createdKeyIds.push(readonlyKey.id);
  170. currentAuthToken = readonlyKey.key;
  171. const { response, json } = await callActionsRoute({
  172. method: "POST",
  173. pathname: "/api/actions/usage-logs/getUsageLogs",
  174. authToken: readonlyKey.key,
  175. body: {},
  176. });
  177. expect(response.status).toBe(200);
  178. expect(json).toMatchObject({ ok: true });
  179. });
  180. test("readonly key can access getOverviewData", async () => {
  181. const unique = `readonly-overview-${Date.now()}-${Math.random().toString(16).slice(2)}`;
  182. const user = await createTestUser(`Test ${unique}`);
  183. createdUserIds.push(user.id);
  184. const readonlyKey = await createTestKey({
  185. userId: user.id,
  186. key: `test-readonly-key-${unique}`,
  187. name: `readonly-${unique}`,
  188. canLoginWebUi: false,
  189. });
  190. createdKeyIds.push(readonlyKey.id);
  191. currentAuthToken = readonlyKey.key;
  192. const { response, json } = await callActionsRoute({
  193. method: "POST",
  194. pathname: "/api/actions/overview/getOverviewData",
  195. authToken: readonlyKey.key,
  196. body: {},
  197. });
  198. expect(response.status).toBe(200);
  199. expect(json).toMatchObject({ ok: true });
  200. });
  201. test("readonly key can access getActiveSessions", async () => {
  202. const unique = `readonly-sessions-${Date.now()}-${Math.random().toString(16).slice(2)}`;
  203. const user = await createTestUser(`Test ${unique}`);
  204. createdUserIds.push(user.id);
  205. const readonlyKey = await createTestKey({
  206. userId: user.id,
  207. key: `test-readonly-key-${unique}`,
  208. name: `readonly-${unique}`,
  209. canLoginWebUi: false,
  210. });
  211. createdKeyIds.push(readonlyKey.id);
  212. currentAuthToken = readonlyKey.key;
  213. const { response, json } = await callActionsRoute({
  214. method: "POST",
  215. pathname: "/api/actions/active-sessions/getActiveSessions",
  216. authToken: readonlyKey.key,
  217. body: {},
  218. });
  219. expect(response.status).toBe(200);
  220. expect(json).toMatchObject({ ok: true });
  221. });
  222. test("readonly key cannot access other user's data", async () => {
  223. const unique = `readonly-isolation-${Date.now()}-${Math.random().toString(16).slice(2)}`;
  224. // Create user A with readonly key
  225. const userA = await createTestUser(`Test ${unique}-A`);
  226. createdUserIds.push(userA.id);
  227. const keyA = await createTestKey({
  228. userId: userA.id,
  229. key: `test-readonly-key-A-${unique}`,
  230. name: `readonly-A-${unique}`,
  231. canLoginWebUi: false,
  232. });
  233. createdKeyIds.push(keyA.id);
  234. // Create user B
  235. const userB = await createTestUser(`Test ${unique}-B`);
  236. createdUserIds.push(userB.id);
  237. const keyB = await createTestKey({
  238. userId: userB.id,
  239. key: `test-readonly-key-B-${unique}`,
  240. name: `readonly-B-${unique}`,
  241. canLoginWebUi: false,
  242. });
  243. createdKeyIds.push(keyB.id);
  244. currentAuthToken = keyA.key;
  245. // User A trying to access User B's limit usage should fail
  246. const { response, json } = await callActionsRoute({
  247. method: "POST",
  248. pathname: "/api/actions/users/getUserLimitUsage",
  249. authToken: keyA.key,
  250. body: { userId: userB.id },
  251. });
  252. expect(response.status).toBe(200);
  253. expect(json).toMatchObject({ ok: false });
  254. });
  255. test("readonly key cannot access admin-only endpoints", async () => {
  256. const unique = `readonly-admin-${Date.now()}-${Math.random().toString(16).slice(2)}`;
  257. const user = await createTestUser(`Test ${unique}`);
  258. createdUserIds.push(user.id);
  259. const readonlyKey = await createTestKey({
  260. userId: user.id,
  261. key: `test-readonly-key-${unique}`,
  262. name: `readonly-${unique}`,
  263. canLoginWebUi: false,
  264. });
  265. createdKeyIds.push(readonlyKey.id);
  266. currentAuthToken = readonlyKey.key;
  267. // Sensitive words management is admin-only
  268. const { response, json } = await callActionsRoute({
  269. method: "POST",
  270. pathname: "/api/actions/sensitive-words/listSensitiveWords",
  271. authToken: readonlyKey.key,
  272. body: {},
  273. });
  274. // Should be rejected (either 401 or 403)
  275. expect([401, 403]).toContain(response.status);
  276. expect(json).toMatchObject({ ok: false });
  277. });
  278. test("Bearer token authentication works for readonly endpoints", async () => {
  279. const unique = `readonly-bearer-${Date.now()}-${Math.random().toString(16).slice(2)}`;
  280. const user = await createTestUser(`Test ${unique}`);
  281. createdUserIds.push(user.id);
  282. const readonlyKey = await createTestKey({
  283. userId: user.id,
  284. key: `test-readonly-key-${unique}`,
  285. name: `readonly-${unique}`,
  286. canLoginWebUi: false,
  287. });
  288. createdKeyIds.push(readonlyKey.id);
  289. currentAuthorization = `Bearer ${readonlyKey.key}`;
  290. const { response, json } = await callActionsRoute({
  291. method: "POST",
  292. pathname: "/api/actions/users/getUsers",
  293. headers: { Authorization: currentAuthorization },
  294. body: {},
  295. });
  296. expect(response.status).toBe(200);
  297. expect(json).toMatchObject({ ok: true });
  298. });
  299. });