From 5f4f55e36fdb07167f55efb9cdb0b73ec3e8ed17 Mon Sep 17 00:00:00 2001 From: zereight Date: Mon, 12 Oct 2026 22:23:17 +0900 Subject: [PATCH 1/2] feat: add GITLAB_PERSONAL_ACCESS_TOKEN_FILE (#833) --- README.ko.md | 2 +- README.md | 2 +- README.pt-BR.md | 2 +- README.zh-CN.md | 2 +- config.ts | 20 +- docs/configuration/environment-variables.md | 16 +- docs/getting-started/cli-without-mcp.md | 2 + docs/getting-started/index.md | 14 +- index.ts | 3 +- personal-access-token.ts | 93 ++++++ scripts/run-mock-tests.sh | 2 + server.json | 9 +- skills/gl-cli/SKILL.md | 2 + test/personal-access-token-file.test.ts | 311 ++++++++++++++++++++ 14 files changed, 465 insertions(+), 15 deletions(-) create mode 100644 personal-access-token.ts create mode 100644 test/personal-access-token-file.test.ts diff --git a/README.ko.md b/README.ko.md index 9eec884f2..04675b3df 100644 --- a/README.ko.md +++ b/README.ko.md @@ -84,7 +84,7 @@ GitLab은 [공식 MCP 서버](https://docs.gitlab.com/user/model_context_protoco **로컬/데스크톱 사용**(가장 일반적): -1. **Personal Access Token** (`GITLAB_PERSONAL_ACCESS_TOKEN`) — 가장 단순한 설정 +1. **Personal Access Token** (`GITLAB_PERSONAL_ACCESS_TOKEN` 또는 `GITLAB_PERSONAL_ACCESS_TOKEN_FILE`) — 가장 단순한 설정 2. **OAuth2 — 로컬 브라우저** (`GITLAB_USE_OAUTH`) — 더 나은 보안을 위해 권장 **서버/원격 배포**: diff --git a/README.md b/README.md index 095a2d8df..fe3084a02 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ The server supports four authentication methods: **For local/desktop use** (most common): -1. **Personal Access Token** (`GITLAB_PERSONAL_ACCESS_TOKEN`) — simplest setup +1. **Personal Access Token** (`GITLAB_PERSONAL_ACCESS_TOKEN` or `GITLAB_PERSONAL_ACCESS_TOKEN_FILE`) — simplest setup 2. **OAuth2 — Local Browser** (`GITLAB_USE_OAUTH`) — recommended for better security **For server/remote deployments**: diff --git a/README.pt-BR.md b/README.pt-BR.md index 1dc68f917..3903a671f 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -84,7 +84,7 @@ O servidor suporta quatro métodos de autenticação. **Para uso local/desktop** (mais comum): -1. **Personal Access Token** (`GITLAB_PERSONAL_ACCESS_TOKEN`) — configuração mais simples +1. **Personal Access Token** (`GITLAB_PERSONAL_ACCESS_TOKEN` ou `GITLAB_PERSONAL_ACCESS_TOKEN_FILE`) — configuração mais simples 2. **OAuth2 — navegador local** (`GITLAB_USE_OAUTH`) — recomendado para maior segurança **Para implantações em servidor/remotas**: diff --git a/README.zh-CN.md b/README.zh-CN.md index 527b893d8..baa020b17 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -84,7 +84,7 @@ GitLab 还提供[官方 MCP 服务器](https://docs.gitlab.com/user/model_contex **本地/桌面使用**(最常见): -1. **Personal Access Token** (`GITLAB_PERSONAL_ACCESS_TOKEN`) — 最简单的设置 +1. **Personal Access Token** (`GITLAB_PERSONAL_ACCESS_TOKEN` 或 `GITLAB_PERSONAL_ACCESS_TOKEN_FILE`) — 最简单的设置 2. **OAuth2 — 本地浏览器** (`GITLAB_USE_OAUTH`) — 推荐用于更好的安全性 **服务器/远程部署**: diff --git a/config.ts b/config.ts index 959eae39c..52bad3135 100644 --- a/config.ts +++ b/config.ts @@ -1,7 +1,12 @@ import { parseCliArgs } from "./cli-boolean-flags.js"; import { deprecatedEnvInputFromSources, type DeprecatedEnvInput } from "./deprecated-env.js"; +import { + readPersonalAccessTokenFile, + resolvePersonalAccessToken, +} from "./personal-access-token.js"; import { parseToolProfile } from "./tools/tool-profile.js"; import { parseCompactToolAllowlist } from "./utils/compact-tool-allowlist.js"; +import { createLogger, flushStartupLogs } from "./utils/logger.js"; // Parse CLI arguments before anything else export const cliArgs: Record = parseCliArgs(process.argv.slice(2)); @@ -21,7 +26,20 @@ export function getConfig( // Authentication // --------------------------------------------------------------------------- -export const GITLAB_PERSONAL_ACCESS_TOKEN = getConfig("token", "GITLAB_PERSONAL_ACCESS_TOKEN"); +function warnIgnoredPersonalAccessTokenFile(message: string): void { + const startupLogger = createLogger(); + startupLogger.warn(message); + flushStartupLogs(startupLogger); +} + +// Read once at module load. Later auth checks use this constant and do not reopen the file. +export const GITLAB_PERSONAL_ACCESS_TOKEN = resolvePersonalAccessToken({ + cliToken: cliArgs.token, + envToken: process.env.GITLAB_PERSONAL_ACCESS_TOKEN, + filePath: process.env.GITLAB_PERSONAL_ACCESS_TOKEN_FILE, + readFile: readPersonalAccessTokenFile, + warn: warnIgnoredPersonalAccessTokenFile, +}); export const GITLAB_JOB_TOKEN = getConfig("job-token", "GITLAB_JOB_TOKEN"); export const GITLAB_AUTH_COOKIE_PATH = getConfig("cookie-path", "GITLAB_AUTH_COOKIE_PATH"); export const USE_OAUTH = getConfig("use-oauth", "GITLAB_USE_OAUTH") === "true"; diff --git a/docs/configuration/environment-variables.md b/docs/configuration/environment-variables.md index 8fd1526f3..344b0a693 100644 --- a/docs/configuration/environment-variables.md +++ b/docs/configuration/environment-variables.md @@ -123,6 +123,20 @@ as outside the masking boundary. Default static GitLab Personal Access Token used in standard mode. +A non-empty value overrides `GITLAB_PERSONAL_ACCESS_TOKEN_FILE`. + +### `GITLAB_PERSONAL_ACCESS_TOKEN_FILE` + +Optional path to a file that holds the personal access token. + +The server reads the file once during startup. It trims leading and trailing whitespace, including newlines. It does not read the file again on later requests. + +If `GITLAB_PERSONAL_ACCESS_TOKEN` or `--token` is non-empty, that inline value wins. The server ignores this file and writes a warning to stderr. It does not open the file. + +If the inline value is empty and this variable is set, startup stops when the file is missing, cannot be read, or is empty after trim. The error includes the path and a cause code such as `ENOENT`, `EACCES`, or `EMPTY`. The error does not include the file contents. + +This variable only fills the personal access token. It does not change the precedence of `GITLAB_JOB_TOKEN`, OAuth, `GITLAB_AUTH_COOKIE_PATH`, or remote authorization. + ### `GITLAB_IS_OLD` Optional. Set to `true` (or `--is-old=true`) to send Personal Access Tokens as @@ -962,7 +976,7 @@ Maximum GitLab client pool size. ## Simple local PAT setup -- `GITLAB_PERSONAL_ACCESS_TOKEN` +- `GITLAB_PERSONAL_ACCESS_TOKEN` or `GITLAB_PERSONAL_ACCESS_TOKEN_FILE` - `GITLAB_API_URL` ## Local OAuth setup diff --git a/docs/getting-started/cli-without-mcp.md b/docs/getting-started/cli-without-mcp.md index b72248f36..c2f27d66c 100644 --- a/docs/getting-started/cli-without-mcp.md +++ b/docs/getting-started/cli-without-mcp.md @@ -34,6 +34,8 @@ read -rs GITLAB_PERSONAL_ACCESS_TOKEN && export GITLAB_PERSONAL_ACCESS_TOKEN export GITLAB_API_URL=https://gitlab.example.com/api/v4 # omit for gitlab.com ``` +`GITLAB_PERSONAL_ACCESS_TOKEN_FILE` can point at a token file instead. The process reads that file once at startup and trims surrounding whitespace. If `GITLAB_PERSONAL_ACCESS_TOKEN` is already non-empty, the file is ignored and a warning goes to stderr. + 3. Verify: ```bash diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index a014e0992..90d69e0bc 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -7,12 +7,12 @@ This page helps you pick the right **authentication method** and the right The server supports four ways to authenticate against GitLab. -| Method | When to use it | Config | -| -------------------------- | ------------------------------------------------------------------ | ------------------------------ | -| **Personal Access Token** | Simplest setup for local/desktop use. | `GITLAB_PERSONAL_ACCESS_TOKEN` | -| **OAuth2 — Local Browser** | Better security for local clients (Claude Desktop, VS Code). | `GITLAB_USE_OAUTH=true` | -| **OAuth2 — MCP Proxy** | Remote MCP clients such as Claude.ai. | `GITLAB_MCP_OAUTH=true` | -| **Remote Authorization** | Multi-user deployments where each caller provides their own token. | `REMOTE_AUTHORIZATION=true` | +| Method | When to use it | Config | +| -------------------------- | ------------------------------------------------------------------ | --------------------------------------------------------------------- | +| **Personal Access Token** | Simplest setup for local/desktop use. | `GITLAB_PERSONAL_ACCESS_TOKEN` or `GITLAB_PERSONAL_ACCESS_TOKEN_FILE` | +| **OAuth2 — Local Browser** | Better security for local clients (Claude Desktop, VS Code). | `GITLAB_USE_OAUTH=true` | +| **OAuth2 — MCP Proxy** | Remote MCP clients such as Claude.ai. | `GITLAB_MCP_OAUTH=true` | +| **Remote Authorization** | Multi-user deployments where each caller provides their own token. | `REMOTE_AUTHORIZATION=true` | ### Decision flow @@ -21,7 +21,7 @@ The server supports four ways to authenticate against GitLab. - **Local desktop, single user** → Personal Access Token or OAuth Local. - **Remote single tenant (e.g., Claude.ai)** → OAuth MCP Proxy. - **Remote multi-tenant (each user has their own GitLab token)** → Remote Authorization. - - **Docker / containers** → Personal Access Token (OAuth needs a browser). + - **Docker / containers** → Personal Access Token, or `GITLAB_PERSONAL_ACCESS_TOKEN_FILE` when the token is mounted as a file (OAuth needs a browser). Full guides: diff --git a/index.ts b/index.ts index 275f18a6a..6eab8c7ee 100644 --- a/index.ts +++ b/index.ts @@ -1439,7 +1439,8 @@ function validateConfiguration(): void { // Validate auth configuration const remoteAuth = getConfig("remote-auth", "REMOTE_AUTHORIZATION") === "true"; const useOAuth = getConfig("use-oauth", "GITLAB_USE_OAUTH") === "true"; - const hasToken = !!getConfig("token", "GITLAB_PERSONAL_ACCESS_TOKEN"); + // getConfig() would ignore GITLAB_PERSONAL_ACCESS_TOKEN_FILE. + const hasToken = !!GITLAB_PERSONAL_ACCESS_TOKEN; const hasJobToken = !!getConfig("job-token", "GITLAB_JOB_TOKEN"); const hasCookie = !!getConfig("cookie-path", "GITLAB_AUTH_COOKIE_PATH"); const mcpOAuth = getConfig("mcp-oauth", "GITLAB_MCP_OAUTH") === "true"; diff --git a/personal-access-token.ts b/personal-access-token.ts new file mode 100644 index 000000000..6e250acc8 --- /dev/null +++ b/personal-access-token.ts @@ -0,0 +1,93 @@ +import { readFileSync } from "node:fs"; + +const EMPTY_FILE_CODE = "EMPTY"; + +export class PersonalAccessTokenFileError extends Error { + readonly code: string; + readonly filePath: string; + + constructor(filePath: string, code: string) { + super(`Cannot load GITLAB_PERSONAL_ACCESS_TOKEN_FILE at ${filePath} (${code})`); + this.name = "PersonalAccessTokenFileError"; + this.filePath = filePath; + this.code = code; + } +} + +export interface PersonalAccessTokenSource { + readonly cliToken: string | undefined; + readonly envToken: string | undefined; + readonly filePath: string | undefined; + readonly readFile: (filePath: string) => string; + readonly warn: (message: string) => void; +} + +function presentInlineToken(value: string | undefined): string | undefined { + if (value === undefined || value.length === 0) { + return undefined; + } + return value; +} + +function configuredFilePath(filePath: string | undefined): string | undefined { + if (filePath === undefined) { + return undefined; + } + const trimmed = filePath.trim(); + if (trimmed.length === 0) { + return undefined; + } + return trimmed; +} + +function errnoCode(error: unknown): string { + if (typeof error !== "object" || error === null || !("code" in error)) { + return "EIO"; + } + const code = error.code; + if (typeof code !== "string" || code.length === 0) { + return "EIO"; + } + return code; +} + +export function readPersonalAccessTokenFile(filePath: string): string { + return readFileSync(filePath, { encoding: "utf8" }); +} + +function ignoredFileWarning(filePath: string): string { + return `Ignoring GITLAB_PERSONAL_ACCESS_TOKEN_FILE at ${filePath} because an inline personal access token is set`; +} + +function readTokenFile(source: PersonalAccessTokenSource, filePath: string): string { + try { + return source.readFile(filePath); + } catch (error: unknown) { + throw new PersonalAccessTokenFileError(filePath, errnoCode(error)); + } +} + +/** + * Resolve the personal access token once. + * `--token` wins, then a non-empty GITLAB_PERSONAL_ACCESS_TOKEN, then the file. + * Job tokens, OAuth, cookies, and remote authorization are not inputs. + */ +export function resolvePersonalAccessToken(source: PersonalAccessTokenSource): string | undefined { + const inline = presentInlineToken(source.cliToken) ?? presentInlineToken(source.envToken); + const filePath = configuredFilePath(source.filePath); + if (inline !== undefined) { + if (filePath !== undefined) { + source.warn(ignoredFileWarning(filePath)); + } + return inline; + } + if (filePath === undefined) { + return undefined; + } + + const token = readTokenFile(source, filePath).trim(); + if (token.length === 0) { + throw new PersonalAccessTokenFileError(filePath, EMPTY_FILE_CODE); + } + return token; +} diff --git a/scripts/run-mock-tests.sh b/scripts/run-mock-tests.sh index a9cdde701..dba789635 100755 --- a/scripts/run-mock-tests.sh +++ b/scripts/run-mock-tests.sh @@ -39,6 +39,7 @@ run_mock_tests 4 \ -o -path 'test/deprecated-env.test.ts' \ -o -path 'test/oauth-scopes.test.ts' \ -o -path 'test/oauth-device-flow-tests.ts' \ + -o -path 'test/personal-access-token-file.test.ts' \ -o -path 'test/cli/*.test.ts' \) # Co-located unit tests for scripts/ helper modules — no MCP/mock server processes @@ -61,6 +62,7 @@ run_mock_tests 1 \ ! -path 'test/deprecated-env.test.ts' \ ! -path 'test/oauth-scopes.test.ts' \ ! -path 'test/oauth-device-flow-tests.ts' \ + ! -path 'test/personal-access-token-file.test.ts' \ ! -path 'test/cli/*' tsx test/oauth-tests.ts diff --git a/server.json b/server.json index 7a304ac9c..cfd8638df 100644 --- a/server.json +++ b/server.json @@ -18,11 +18,18 @@ "environmentVariables": [ { "name": "GITLAB_PERSONAL_ACCESS_TOKEN", - "description": "GitLab personal access token for local stdio use. One of this token, GITLAB_JOB_TOKEN, GITLAB_AUTH_COOKIE_PATH, or GITLAB_USE_OAUTH=true must be set. Create a token with the GitLab scopes the tools need, such as api or read_api.", + "description": "GitLab personal access token for local stdio use. A non-empty value overrides GITLAB_PERSONAL_ACCESS_TOKEN_FILE. One of this token, GITLAB_PERSONAL_ACCESS_TOKEN_FILE, GITLAB_JOB_TOKEN, GITLAB_AUTH_COOKIE_PATH, or GITLAB_USE_OAUTH=true must be set. Create a token with the GitLab scopes the tools need, such as api or read_api.", "isRequired": false, "isSecret": true, "format": "string" }, + { + "name": "GITLAB_PERSONAL_ACCESS_TOKEN_FILE", + "description": "Path to a file that contains the personal access token. Read once at startup and trimmed. Ignored when GITLAB_PERSONAL_ACCESS_TOKEN or --token is non-empty. A missing, unreadable, or empty file stops startup. Does not replace job token, OAuth, cookie, or remote authorization.", + "isRequired": false, + "isSecret": false, + "format": "filepath" + }, { "name": "GITLAB_JOB_TOKEN", "description": "Optional GitLab CI job token to use instead of a personal access token when running inside GitLab CI.", diff --git a/skills/gl-cli/SKILL.md b/skills/gl-cli/SKILL.md index 13caef32e..79b4a40eb 100644 --- a/skills/gl-cli/SKILL.md +++ b/skills/gl-cli/SKILL.md @@ -33,6 +33,8 @@ read -rs GITLAB_PERSONAL_ACCESS_TOKEN && export GITLAB_PERSONAL_ACCESS_TOKEN export GITLAB_API_URL=https://gitlab.example.com/api/v4 # omit for gitlab.com ``` +`GITLAB_PERSONAL_ACCESS_TOKEN_FILE` can point at a token file instead. The process reads it once at startup and trims surrounding whitespace. If `GITLAB_PERSONAL_ACCESS_TOKEN` is already non-empty, the file is ignored and a warning is written to stderr. + **B. OAuth device flow** (needs a GitLab OAuth application ID) ```bash diff --git a/test/personal-access-token-file.test.ts b/test/personal-access-token-file.test.ts new file mode 100644 index 000000000..097396b6e --- /dev/null +++ b/test/personal-access-token-file.test.ts @@ -0,0 +1,311 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + PersonalAccessTokenFileError, + readPersonalAccessTokenFile, + resolvePersonalAccessToken, + type PersonalAccessTokenSource, +} from "../personal-access-token.js"; + +const SERVER_ENTRY = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../build/index.js" +); +const INLINE_TOKEN = "glpat-inline-value"; +const FILE_TOKEN = "glpat-file-value"; +const CLI_TOKEN = "glpat-cli-value"; + +interface ResolvedToken { + readonly token: string | undefined; + readonly warnings: readonly string[]; +} + +interface ServerRun { + readonly code: number | null; + readonly stdout: string; + readonly stderr: string; +} + +function isRootUser(): boolean { + return typeof process.getuid === "function" && process.getuid() === 0; +} + +function writeTokenFile(contents: string): string { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "gitlab-pat-file-")); + const filePath = path.join(directory, "token"); + fs.writeFileSync(filePath, contents, { encoding: "utf8" }); + return filePath; +} + +function removeTokenFile(filePath: string): void { + fs.chmodSync(filePath, 0o600); + fs.rmSync(path.dirname(filePath), { recursive: true, force: true }); +} + +function resolveFrom(source: Omit): ResolvedToken { + const warnings: string[] = []; + const token = resolvePersonalAccessToken({ + ...source, + readFile: readPersonalAccessTokenFile, + warn(message: string): void { + warnings.push(message); + }, + }); + return { token, warnings }; +} + +function loadFailure( + source: Omit +): PersonalAccessTokenFileError { + try { + resolveFrom(source); + } catch (error: unknown) { + if (error instanceof PersonalAccessTokenFileError) { + return error; + } + throw error; + } + throw new Error("expected PersonalAccessTokenFileError"); +} + +function childEnv(extra: Readonly>): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(process.env)) { + if (value === undefined || key.startsWith("GITLAB_") || key === "PORT") { + continue; + } + if ( + key === "SSE" || + key === "STREAMABLE_HTTP" || + key === "REMOTE_AUTHORIZATION" || + key === "LOG_LEVEL" || + key === "LOG_FORMAT" + ) { + continue; + } + env[key] = value; + } + env.LOG_LEVEL = "info"; + for (const [key, value] of Object.entries(extra)) { + env[key] = value; + } + return env; +} + +function runServerAsync(extra: Readonly>): Promise { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [SERVER_ENTRY], { + env: childEnv(extra), + stdio: ["ignore", "pipe", "pipe"], + timeout: 15_000, + killSignal: "SIGKILL", + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", chunk => { + stdout += String(chunk); + }); + child.stderr.on("data", chunk => { + stderr += String(chunk); + }); + child.on("error", reject); + child.on("close", code => resolve({ code, stdout, stderr })); + }); +} + +describe("When GITLAB_PERSONAL_ACCESS_TOKEN_FILE is set", () => { + describe("with a token file", () => { + it("should return the file contents", () => { + const filePath = writeTokenFile(`${FILE_TOKEN}\n`); + try { + const resolved = resolveFrom({ + cliToken: undefined, + envToken: undefined, + filePath, + }); + assert.equal(resolved.token, FILE_TOKEN); + } finally { + removeTokenFile(filePath); + } + }); + }); + + describe("with surrounding whitespace", () => { + it("should trim the token", () => { + const filePath = writeTokenFile(`\n ${FILE_TOKEN} \t\r\n`); + try { + const resolved = resolveFrom({ + cliToken: undefined, + envToken: undefined, + filePath, + }); + assert.equal(resolved.token, FILE_TOKEN); + } finally { + removeTokenFile(filePath); + } + }); + }); + + describe("with a non-empty inline token", () => { + it("should return the inline token", () => { + const resolved = resolveFrom({ + cliToken: undefined, + envToken: INLINE_TOKEN, + filePath: path.join(os.tmpdir(), "gitlab-pat-file-missing"), + }); + assert.equal(resolved.token, INLINE_TOKEN); + }); + + it("should warn that the file is ignored", () => { + const filePath = path.join(os.tmpdir(), "gitlab-pat-file-missing"); + const resolved = resolveFrom({ + cliToken: undefined, + envToken: INLINE_TOKEN, + filePath, + }); + assert.deepEqual(resolved.warnings, [ + `Ignoring GITLAB_PERSONAL_ACCESS_TOKEN_FILE at ${filePath} because an inline personal access token is set`, + ]); + }); + }); + + describe("with a missing file", () => { + it("should fail with ENOENT", () => { + const filePath = path.join(os.tmpdir(), "gitlab-pat-file-does-not-exist"); + const error = loadFailure({ + cliToken: undefined, + envToken: undefined, + filePath, + }); + assert.equal(error.code, "ENOENT"); + assert.equal(error.filePath, filePath); + }); + }); + + describe("with a file the process cannot read", () => { + it("should fail with EACCES", { skip: isRootUser() }, () => { + const filePath = writeTokenFile(FILE_TOKEN); + fs.chmodSync(filePath, 0o000); + try { + const error = loadFailure({ + cliToken: undefined, + envToken: undefined, + filePath, + }); + assert.equal(error.code, "EACCES"); + assert.equal(error.message.includes(FILE_TOKEN), false); + } finally { + removeTokenFile(filePath); + } + }); + }); + + describe("with an empty file", () => { + it("should fail with EMPTY", () => { + const filePath = writeTokenFile(""); + try { + const error = loadFailure({ + cliToken: undefined, + envToken: undefined, + filePath, + }); + assert.equal(error.code, "EMPTY"); + assert.equal(error.filePath, filePath); + } finally { + removeTokenFile(filePath); + } + }); + }); + + describe("with a whitespace-only file", () => { + it("should fail with EMPTY", () => { + const filePath = writeTokenFile(" \n\t"); + try { + const error = loadFailure({ + cliToken: undefined, + envToken: "", + filePath, + }); + assert.equal(error.code, "EMPTY"); + } finally { + removeTokenFile(filePath); + } + }); + }); + + describe("with --token and an inline env token", () => { + it("should return the CLI token", () => { + const resolved = resolveFrom({ + cliToken: CLI_TOKEN, + envToken: INLINE_TOKEN, + filePath: path.join(os.tmpdir(), "gitlab-pat-file-missing"), + }); + assert.equal(resolved.token, CLI_TOKEN); + }); + }); +}); + +describe("When the server starts", () => { + describe("with only a token file and an invalid PORT", () => { + it("should treat the file as the personal access token", async () => { + const filePath = writeTokenFile(`${FILE_TOKEN}\n`); + try { + const run = await runServerAsync({ + GITLAB_PERSONAL_ACCESS_TOKEN_FILE: filePath, + PORT: "70000", + }); + assert.equal(run.code, 1); + assert.match(run.stderr, /PORT must be between 1 and 65535/); + assert.equal( + run.stderr.includes("GITLAB_PERSONAL_ACCESS_TOKEN environment variable is not set"), + false + ); + assert.equal(run.stderr.includes("Either --token"), false); + assert.equal(run.stderr.includes(FILE_TOKEN), false); + } finally { + removeTokenFile(filePath); + } + }); + }); + + describe("with an inline token and a token file", () => { + it("should warn on stderr without the token", async () => { + const filePath = writeTokenFile(`${FILE_TOKEN}\n`); + try { + const run = await runServerAsync({ + GITLAB_PERSONAL_ACCESS_TOKEN: INLINE_TOKEN, + GITLAB_PERSONAL_ACCESS_TOKEN_FILE: filePath, + PORT: "70000", + }); + assert.match( + run.stderr, + /Ignoring GITLAB_PERSONAL_ACCESS_TOKEN_FILE at .+ because an inline personal access token is set/ + ); + assert.equal(run.stderr.includes(INLINE_TOKEN), false); + assert.equal(run.stderr.includes(FILE_TOKEN), false); + } finally { + removeTokenFile(filePath); + } + }); + }); + + describe("with a missing token file", () => { + it("should exit before serving", async () => { + const filePath = path.join(os.tmpdir(), "gitlab-pat-file-does-not-exist"); + const run = await runServerAsync({ + GITLAB_PERSONAL_ACCESS_TOKEN_FILE: filePath, + }); + assert.equal(run.code, 1); + assert.match( + run.stderr, + new RegExp(`Cannot load GITLAB_PERSONAL_ACCESS_TOKEN_FILE at ${filePath} \\(ENOENT\\)`) + ); + }); + }); +}); From 3646cdf8ae3920713012673dcfd4e467acf97889 Mon Sep 17 00:00:00 2001 From: zereight Date: Mon, 12 Oct 2026 23:09:43 +0900 Subject: [PATCH 2/2] test: use includes for PAT_FILE ENOENT assert (CodeRabbit) Co-authored-by: Cursor --- test/personal-access-token-file.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/test/personal-access-token-file.test.ts b/test/personal-access-token-file.test.ts index 097396b6e..21e539628 100644 --- a/test/personal-access-token-file.test.ts +++ b/test/personal-access-token-file.test.ts @@ -302,9 +302,8 @@ describe("When the server starts", () => { GITLAB_PERSONAL_ACCESS_TOKEN_FILE: filePath, }); assert.equal(run.code, 1); - assert.match( - run.stderr, - new RegExp(`Cannot load GITLAB_PERSONAL_ACCESS_TOKEN_FILE at ${filePath} \\(ENOENT\\)`) + assert.ok( + run.stderr.includes(`Cannot load GITLAB_PERSONAL_ACCESS_TOKEN_FILE at ${filePath} (ENOENT)`) ); }); });