| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358 |
- import { afterAll, beforeEach, describe, expect, test, vi } from "vitest";
- import { inArray } from "drizzle-orm";
- import { db } from "@/drizzle/db";
- import { keys, users } from "@/drizzle/schema";
- import { callActionsRoute } from "../test-utils";
- /**
- * Issue #687: allowReadOnlyAccess endpoints test
- *
- * Test that endpoints with allowReadOnlyAccess: true can be accessed
- * by API keys with canLoginWebUi=false (readonly keys).
- *
- * These endpoints have business logic that already supports regular users
- * (returning only their own data), so they should allow readonly access.
- */
- let currentAuthToken: string | undefined;
- let currentAuthorization: string | undefined;
- vi.mock("next/headers", () => ({
- cookies: () => ({
- get: (name: string) => {
- if (name !== "auth-token") return undefined;
- return currentAuthToken ? { value: currentAuthToken } : undefined;
- },
- set: vi.fn(),
- delete: vi.fn(),
- has: (name: string) => name === "auth-token" && Boolean(currentAuthToken),
- }),
- headers: () => ({
- get: (name: string) => {
- if (name.toLowerCase() !== "authorization") return null;
- return currentAuthorization ?? null;
- },
- }),
- }));
- type TestKey = { id: number; userId: number; key: string; name: string };
- type TestUser = { id: number; name: string };
- async function createTestUser(name: string): Promise<TestUser> {
- const [row] = await db
- .insert(users)
- .values({ name })
- .returning({ id: users.id, name: users.name });
- if (!row) {
- throw new Error("Failed to create test user");
- }
- return row;
- }
- async function createTestKey(params: {
- userId: number;
- key: string;
- name: string;
- canLoginWebUi: boolean;
- }): Promise<TestKey> {
- const [row] = await db
- .insert(keys)
- .values({
- userId: params.userId,
- key: params.key,
- name: params.name,
- canLoginWebUi: params.canLoginWebUi,
- dailyResetMode: "rolling",
- dailyResetTime: "00:00",
- })
- .returning({ id: keys.id, userId: keys.userId, key: keys.key, name: keys.name });
- if (!row) {
- throw new Error("Failed to create test key");
- }
- return row;
- }
- describe("allowReadOnlyAccess endpoints (Issue #687)", () => {
- const createdUserIds: number[] = [];
- const createdKeyIds: number[] = [];
- afterAll(async () => {
- const now = new Date();
- if (createdKeyIds.length > 0) {
- await db
- .update(keys)
- .set({ deletedAt: now, updatedAt: now })
- .where(inArray(keys.id, createdKeyIds));
- }
- if (createdUserIds.length > 0) {
- await db
- .update(users)
- .set({ deletedAt: now, updatedAt: now })
- .where(inArray(users.id, createdUserIds));
- }
- });
- beforeEach(() => {
- currentAuthToken = undefined;
- currentAuthorization = undefined;
- });
- test("readonly key (canLoginWebUi=false) can access getUsers endpoint", async () => {
- const unique = `readonly-getusers-${Date.now()}-${Math.random().toString(16).slice(2)}`;
- const user = await createTestUser(`Test ${unique}`);
- createdUserIds.push(user.id);
- const readonlyKey = await createTestKey({
- userId: user.id,
- key: `test-readonly-key-${unique}`,
- name: `readonly-${unique}`,
- canLoginWebUi: false,
- });
- createdKeyIds.push(readonlyKey.id);
- currentAuthToken = readonlyKey.key;
- const { response, json } = await callActionsRoute({
- method: "POST",
- pathname: "/api/actions/users/getUsers",
- authToken: readonlyKey.key,
- body: {},
- });
- expect(response.status).toBe(200);
- expect(json).toMatchObject({ ok: true });
- // Regular user should only see their own data
- const data = (json as { ok: boolean; data: Array<{ id: number }> }).data;
- expect(data.length).toBe(1);
- expect(data[0].id).toBe(user.id);
- });
- test("readonly key can access getUserLimitUsage for own user", async () => {
- const unique = `readonly-userlimit-${Date.now()}-${Math.random().toString(16).slice(2)}`;
- const user = await createTestUser(`Test ${unique}`);
- createdUserIds.push(user.id);
- const readonlyKey = await createTestKey({
- userId: user.id,
- key: `test-readonly-key-${unique}`,
- name: `readonly-${unique}`,
- canLoginWebUi: false,
- });
- createdKeyIds.push(readonlyKey.id);
- currentAuthToken = readonlyKey.key;
- const { response, json } = await callActionsRoute({
- method: "POST",
- pathname: "/api/actions/users/getUserLimitUsage",
- authToken: readonlyKey.key,
- body: { userId: user.id },
- });
- expect(response.status).toBe(200);
- expect(json).toMatchObject({ ok: true });
- });
- // Note: getKeys and getKeyLimitUsage are intentionally NOT allowReadOnlyAccess
- // because a readonly key should not be able to see other keys under the same user
- test("readonly key can access getUserStatistics", async () => {
- const unique = `readonly-stats-${Date.now()}-${Math.random().toString(16).slice(2)}`;
- const user = await createTestUser(`Test ${unique}`);
- createdUserIds.push(user.id);
- const readonlyKey = await createTestKey({
- userId: user.id,
- key: `test-readonly-key-${unique}`,
- name: `readonly-${unique}`,
- canLoginWebUi: false,
- });
- createdKeyIds.push(readonlyKey.id);
- currentAuthToken = readonlyKey.key;
- const { response, json } = await callActionsRoute({
- method: "POST",
- pathname: "/api/actions/statistics/getUserStatistics",
- authToken: readonlyKey.key,
- body: { timeRange: "today" },
- });
- expect(response.status).toBe(200);
- expect(json).toMatchObject({ ok: true });
- });
- test("readonly key can access getUsageLogs", async () => {
- const unique = `readonly-logs-${Date.now()}-${Math.random().toString(16).slice(2)}`;
- const user = await createTestUser(`Test ${unique}`);
- createdUserIds.push(user.id);
- const readonlyKey = await createTestKey({
- userId: user.id,
- key: `test-readonly-key-${unique}`,
- name: `readonly-${unique}`,
- canLoginWebUi: false,
- });
- createdKeyIds.push(readonlyKey.id);
- currentAuthToken = readonlyKey.key;
- const { response, json } = await callActionsRoute({
- method: "POST",
- pathname: "/api/actions/usage-logs/getUsageLogs",
- authToken: readonlyKey.key,
- body: {},
- });
- expect(response.status).toBe(200);
- expect(json).toMatchObject({ ok: true });
- });
- test("readonly key can access getOverviewData", async () => {
- const unique = `readonly-overview-${Date.now()}-${Math.random().toString(16).slice(2)}`;
- const user = await createTestUser(`Test ${unique}`);
- createdUserIds.push(user.id);
- const readonlyKey = await createTestKey({
- userId: user.id,
- key: `test-readonly-key-${unique}`,
- name: `readonly-${unique}`,
- canLoginWebUi: false,
- });
- createdKeyIds.push(readonlyKey.id);
- currentAuthToken = readonlyKey.key;
- const { response, json } = await callActionsRoute({
- method: "POST",
- pathname: "/api/actions/overview/getOverviewData",
- authToken: readonlyKey.key,
- body: {},
- });
- expect(response.status).toBe(200);
- expect(json).toMatchObject({ ok: true });
- });
- test("readonly key can access getActiveSessions", async () => {
- const unique = `readonly-sessions-${Date.now()}-${Math.random().toString(16).slice(2)}`;
- const user = await createTestUser(`Test ${unique}`);
- createdUserIds.push(user.id);
- const readonlyKey = await createTestKey({
- userId: user.id,
- key: `test-readonly-key-${unique}`,
- name: `readonly-${unique}`,
- canLoginWebUi: false,
- });
- createdKeyIds.push(readonlyKey.id);
- currentAuthToken = readonlyKey.key;
- const { response, json } = await callActionsRoute({
- method: "POST",
- pathname: "/api/actions/active-sessions/getActiveSessions",
- authToken: readonlyKey.key,
- body: {},
- });
- expect(response.status).toBe(200);
- expect(json).toMatchObject({ ok: true });
- });
- test("readonly key cannot access other user's data", async () => {
- const unique = `readonly-isolation-${Date.now()}-${Math.random().toString(16).slice(2)}`;
- // Create user A with readonly key
- const userA = await createTestUser(`Test ${unique}-A`);
- createdUserIds.push(userA.id);
- const keyA = await createTestKey({
- userId: userA.id,
- key: `test-readonly-key-A-${unique}`,
- name: `readonly-A-${unique}`,
- canLoginWebUi: false,
- });
- createdKeyIds.push(keyA.id);
- // Create user B
- const userB = await createTestUser(`Test ${unique}-B`);
- createdUserIds.push(userB.id);
- const keyB = await createTestKey({
- userId: userB.id,
- key: `test-readonly-key-B-${unique}`,
- name: `readonly-B-${unique}`,
- canLoginWebUi: false,
- });
- createdKeyIds.push(keyB.id);
- currentAuthToken = keyA.key;
- // User A trying to access User B's limit usage should fail
- const { response, json } = await callActionsRoute({
- method: "POST",
- pathname: "/api/actions/users/getUserLimitUsage",
- authToken: keyA.key,
- body: { userId: userB.id },
- });
- expect(response.status).toBe(200);
- expect(json).toMatchObject({ ok: false });
- });
- test("readonly key cannot access admin-only endpoints", async () => {
- const unique = `readonly-admin-${Date.now()}-${Math.random().toString(16).slice(2)}`;
- const user = await createTestUser(`Test ${unique}`);
- createdUserIds.push(user.id);
- const readonlyKey = await createTestKey({
- userId: user.id,
- key: `test-readonly-key-${unique}`,
- name: `readonly-${unique}`,
- canLoginWebUi: false,
- });
- createdKeyIds.push(readonlyKey.id);
- currentAuthToken = readonlyKey.key;
- // Sensitive words management is admin-only
- const { response, json } = await callActionsRoute({
- method: "POST",
- pathname: "/api/actions/sensitive-words/listSensitiveWords",
- authToken: readonlyKey.key,
- body: {},
- });
- // Should be rejected (either 401 or 403)
- expect([401, 403]).toContain(response.status);
- expect(json).toMatchObject({ ok: false });
- });
- test("Bearer token authentication works for readonly endpoints", async () => {
- const unique = `readonly-bearer-${Date.now()}-${Math.random().toString(16).slice(2)}`;
- const user = await createTestUser(`Test ${unique}`);
- createdUserIds.push(user.id);
- const readonlyKey = await createTestKey({
- userId: user.id,
- key: `test-readonly-key-${unique}`,
- name: `readonly-${unique}`,
- canLoginWebUi: false,
- });
- createdKeyIds.push(readonlyKey.id);
- currentAuthorization = `Bearer ${readonlyKey.key}`;
- const { response, json } = await callActionsRoute({
- method: "POST",
- pathname: "/api/actions/users/getUsers",
- headers: { Authorization: currentAuthorization },
- body: {},
- });
- expect(response.status).toBe(200);
- expect(json).toMatchObject({ ok: true });
- });
- });
|