diff --git a/.env.example b/.env.example index 2e38229c..622833f2 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ DB_FILE_NAME=file:local.db ENCRYPTION_KEY="ABCD1234=" # openssl rand -base64 32 -# COOKIE_SECURE=true # set when the dashboard is served over HTTPS \ No newline at end of file +# COOKIE_SECURE=true # set when the dashboard is served over HTTPS +# PROVAL_USER_PROMPT_MAX_LENGTH=2000 # character limit for the custom instructions of a repository \ No newline at end of file diff --git a/apps/api/src/api/auth/auth.service.test.ts b/apps/api/src/api/auth/auth.service.test.ts new file mode 100644 index 00000000..d2a0fedd --- /dev/null +++ b/apps/api/src/api/auth/auth.service.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, mock } from "bun:test"; +import { USER_PROMPT_MAX_LENGTH } from "@proval/types"; + +let selectResultQueue: unknown[][] = []; + +function createFromChain(result: unknown[]) { + return { + where: mock(() => ({ + limit: mock(() => Promise.resolve(result)), + })), + then(resolve: (value: unknown) => void) { + resolve(result); + }, + }; +} + +const insertMock = mock(() => ({ + values: mock(() => ({ + onConflictDoNothing: mock(() => ({ + returning: mock(() => Promise.resolve([])), + })), + })), +})); + +const selectMock = mock(() => ({ + from: mock(() => createFromChain(selectResultQueue.shift() ?? [])), +})); + +mock.module("../../db/index.js", () => ({ + default: { + insert: insertMock, + select: selectMock, + }, +})); + +const { AuthService } = await import("./auth.service.js"); +const authService = new AuthService(); + +const instanceSetting = { + id: 1, + authEnabled: false, + registrationEnabled: false, +}; + +function restoreUserPromptMaxLengthEnv(previous: string | undefined) { + if (previous === undefined) { + delete process.env.PROVAL_USER_PROMPT_MAX_LENGTH; + } else { + process.env.PROVAL_USER_PROMPT_MAX_LENGTH = previous; + } +} + +describe("Get current user", () => { + it("announces the default userPromptMaxLength", async () => { + const previousMaxLength = process.env.PROVAL_USER_PROMPT_MAX_LENGTH; + delete process.env.PROVAL_USER_PROMPT_MAX_LENGTH; + try { + selectResultQueue = [[instanceSetting], [{ value: 0 }]]; + const me = await authService.getMe(null); + expect(me.userPromptMaxLength).toBe(USER_PROMPT_MAX_LENGTH); + } finally { + restoreUserPromptMaxLengthEnv(previousMaxLength); + } + }); + + it("announces the userPromptMaxLength from PROVAL_USER_PROMPT_MAX_LENGTH", async () => { + const previousMaxLength = process.env.PROVAL_USER_PROMPT_MAX_LENGTH; + process.env.PROVAL_USER_PROMPT_MAX_LENGTH = "9000"; + try { + selectResultQueue = [[instanceSetting], [{ value: 0 }]]; + const me = await authService.getMe(null); + expect(me.userPromptMaxLength).toBe(9000); + } finally { + restoreUserPromptMaxLengthEnv(previousMaxLength); + } + }); + + it("announces the default userPromptMaxLength when the env value is invalid", async () => { + const previousMaxLength = process.env.PROVAL_USER_PROMPT_MAX_LENGTH; + process.env.PROVAL_USER_PROMPT_MAX_LENGTH = "abc"; + try { + selectResultQueue = [[instanceSetting], [{ value: 0 }]]; + const me = await authService.getMe(null); + expect(me.userPromptMaxLength).toBe(USER_PROMPT_MAX_LENGTH); + } finally { + restoreUserPromptMaxLengthEnv(previousMaxLength); + } + }); +}); diff --git a/apps/api/src/api/auth/auth.service.ts b/apps/api/src/api/auth/auth.service.ts index c4c09237..1e41b8b5 100644 --- a/apps/api/src/api/auth/auth.service.ts +++ b/apps/api/src/api/auth/auth.service.ts @@ -7,6 +7,7 @@ import type { InstanceSettingUpdateInput, UserResponse, } from "@proval/types"; +import { resolveUserPromptMaxLength } from "@proval/types"; import db from "../../db/index.js"; const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; @@ -72,6 +73,7 @@ export class AuthService { isAuthEnabled: setting.authEnabled, isRegistrationEnabled: setting.registrationEnabled, isSetupRequired, + userPromptMaxLength: resolveUserPromptMaxLength(process.env.PROVAL_USER_PROMPT_MAX_LENGTH), }; } diff --git a/apps/api/src/api/repository/repository.service.test.ts b/apps/api/src/api/repository/repository.service.test.ts index 3e50975e..f66023a9 100644 --- a/apps/api/src/api/repository/repository.service.test.ts +++ b/apps/api/src/api/repository/repository.service.test.ts @@ -1,8 +1,17 @@ import { beforeEach, describe, expect, it, mock } from "bun:test"; import type { Repository } from "@proval/types"; +import { USER_PROMPT_MAX_LENGTH } from "@proval/types"; process.env.ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64"); +function restoreUserPromptMaxLengthEnv(previous: string | undefined) { + if (previous === undefined) { + delete process.env.PROVAL_USER_PROMPT_MAX_LENGTH; + } else { + process.env.PROVAL_USER_PROMPT_MAX_LENGTH = previous; + } +} + const insertReturningMock = mock<() => Promise>(() => Promise.resolve([])); const insertValuesMock = mock((_value: unknown) => ({ returning: insertReturningMock, @@ -306,9 +315,60 @@ describe("Update GitLab Repository", () => { }); it("rejects userPrompt longer than the max length", async () => { - await expect(repositoryService.update(1, { userPrompt: "a".repeat(2001) })).rejects.toThrow( - "Custom instructions must be at most 2000 characters", - ); + const previousMaxLength = process.env.PROVAL_USER_PROMPT_MAX_LENGTH; + delete process.env.PROVAL_USER_PROMPT_MAX_LENGTH; + try { + await expect( + repositoryService.update(1, { userPrompt: "a".repeat(USER_PROMPT_MAX_LENGTH + 1) }), + ).rejects.toThrow(`Custom instructions must be at most ${USER_PROMPT_MAX_LENGTH} characters`); + } finally { + restoreUserPromptMaxLengthEnv(previousMaxLength); + } + }); + + it("accepts a userPrompt of exactly the effective limit", async () => { + const previousMaxLength = process.env.PROVAL_USER_PROMPT_MAX_LENGTH; + try { + delete process.env.PROVAL_USER_PROMPT_MAX_LENGTH; + const atDefaultLimit = "a".repeat(USER_PROMPT_MAX_LENGTH); + updateReturningMock.mockResolvedValueOnce([makeRepositoryRow({ userPrompt: atDefaultLimit })]); + const updatedAtDefault = await repositoryService.update(1, { userPrompt: atDefaultLimit }); + expect(updatedAtDefault.userPrompt).toBe(atDefaultLimit); + + process.env.PROVAL_USER_PROMPT_MAX_LENGTH = "50"; + const atCustomLimit = "b".repeat(50); + updateReturningMock.mockResolvedValueOnce([makeRepositoryRow({ userPrompt: atCustomLimit })]); + const updatedAtCustom = await repositoryService.update(1, { userPrompt: atCustomLimit }); + expect(updatedAtCustom.userPrompt).toBe(atCustomLimit); + } finally { + restoreUserPromptMaxLengthEnv(previousMaxLength); + } + }); + + it("uses PROVAL_USER_PROMPT_MAX_LENGTH when the deployment sets it", async () => { + const previousMaxLength = process.env.PROVAL_USER_PROMPT_MAX_LENGTH; + process.env.PROVAL_USER_PROMPT_MAX_LENGTH = "50"; + try { + await expect(repositoryService.update(1, { userPrompt: "a".repeat(51) })).rejects.toThrow( + "Custom instructions must be at most 50 characters", + ); + } finally { + restoreUserPromptMaxLengthEnv(previousMaxLength); + } + }); + + it("ignores a PROVAL_USER_PROMPT_MAX_LENGTH that is not a positive integer", async () => { + const previousMaxLength = process.env.PROVAL_USER_PROMPT_MAX_LENGTH; + try { + for (const invalid of ["abc", "0", "-5", "1.5", ""]) { + process.env.PROVAL_USER_PROMPT_MAX_LENGTH = invalid; + await expect( + repositoryService.update(1, { userPrompt: "a".repeat(USER_PROMPT_MAX_LENGTH + 1) }), + ).rejects.toThrow(`Custom instructions must be at most ${USER_PROMPT_MAX_LENGTH} characters`); + } + } finally { + restoreUserPromptMaxLengthEnv(previousMaxLength); + } }); it("rejects userPrompt when it is not a string", async () => { @@ -318,9 +378,7 @@ describe("Update GitLab Repository", () => { }); it("rejects invalid reasoning effort", async () => { - await expect( - repositoryService.update(1, { reasoningEffort: "bogus" as "medium" }), - ).rejects.toThrow( + await expect(repositoryService.update(1, { reasoningEffort: "bogus" as "medium" })).rejects.toThrow( "Invalid reasoning effort", ); }); diff --git a/apps/api/src/api/repository/repository.service.ts b/apps/api/src/api/repository/repository.service.ts index 49accffc..dfb775b0 100644 --- a/apps/api/src/api/repository/repository.service.ts +++ b/apps/api/src/api/repository/repository.service.ts @@ -15,7 +15,7 @@ import { GitLabProvider } from "../../git-provider/gitlab.js"; import type { GitProvider } from "../../git-provider/types.js"; import { logError } from "../../util/log.js"; import { decrypt, encrypt } from "../../util/encrypt.js"; -import { reasoningEffortValueList, USER_PROMPT_MAX_LENGTH } from "@proval/types"; +import { reasoningEffortValueList, resolveUserPromptMaxLength } from "@proval/types"; import type { ReasoningEffort, Repository, @@ -458,8 +458,9 @@ export class RepositoryService { if (!trimmed) { return null; } - if (trimmed.length > USER_PROMPT_MAX_LENGTH) { - throw new Error(`Custom instructions must be at most ${USER_PROMPT_MAX_LENGTH} characters`); + const maxLength = resolveUserPromptMaxLength(process.env.PROVAL_USER_PROMPT_MAX_LENGTH); + if (trimmed.length > maxLength) { + throw new Error(`Custom instructions must be at most ${maxLength} characters`); } return trimmed; } diff --git a/apps/client/src/lib/components/organism/RepositoryForm.svelte b/apps/client/src/lib/components/organism/RepositoryForm.svelte index 5880a19c..63fc5018 100644 --- a/apps/client/src/lib/components/organism/RepositoryForm.svelte +++ b/apps/client/src/lib/components/organism/RepositoryForm.svelte @@ -1,7 +1,8 @@