From b5772656cfcff0cd6e419e2791b1135840b5392a Mon Sep 17 00:00:00 2001 From: Wayn_Liu Date: Fri, 4 Sep 2026 22:48:43 +0800 Subject: [PATCH] fix(auth): clear .olauth on logout, and stop persisting the password by default Three related problems in credential handling, reported in #50. logout cleared the global config and printed "Credentials cleared", while the .olauth file in the current directory survived. That file is consulted ahead of the global config by both getSessionCookie() and the MCP server, so the user stayed authenticated in that directory. logout now clears both and reports each source it removed. Environment variables cannot be unset by a child process, so OVERLEAF_SESSION and OVERLEAF_EMAIL/OVERLEAF_PASSWORD are reported rather than ignored - staying silent about them would repeat the original mistake somewhere else. The account password was persisted in plaintext unless the user opted out. It is now written only under --save-password. The session cookie stored alongside it is what every later command uses; the password only bought an automatic re-login after that cookie expired, and unlike a cookie it is reusable outside olcli and cannot be revoked without changing it. --no-save-password still parses and still means what it says, so existing scripts keep working. --password is now optional. Omitting it prompts on the terminal without echoing, which keeps the password out of shell history; with no terminal available the error names OVERLEAF_EMAIL/OVERLEAF_PASSWORD, which every command already reads. Keystroke handling is a pure reducer so it can be tested without a pty - driving the real prompt over one is what surfaced the bug it now guards against, where filtering only the ESC of an arrow key left the printable '[' and 'A' behind and appended them to the password. Also: auth said "Password login saved." unconditionally, including under --no-save-password, and check omitted the stored password entirely - the same class of bug as the logout message, in two more places. --- CHANGELOG.md | 15 ++++ README.md | 28 +++++++- SKILL.md | 13 +++- src/cli.ts | 126 +++++++++++++++++++++++++++++--- src/config.ts | 61 +++++++++++++++- src/prompt.ts | 171 ++++++++++++++++++++++++++++++++++++++++++++ test/config.test.ts | 85 ++++++++++++++++++++++ test/prompt.test.ts | 123 +++++++++++++++++++++++++++++++ 8 files changed, 606 insertions(+), 16 deletions(-) create mode 100644 src/prompt.ts create mode 100644 test/config.test.ts create mode 100644 test/prompt.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 73fab4b..de3fe91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ All notable changes to this project will be documented in this file. +## [Unreleased] + +### Fixed +- **`olcli logout` left `.olauth` behind and reported success anyway** ([#50](https://github.com/aloth/olcli/issues/50)) - it cleared the global config and printed `Credentials cleared`, while the `.olauth` file in the current directory survived. That file is consulted *ahead* of the global config, so the user stayed authenticated in that directory - and in `olcli-mcp`, which reads it too. `logout` now clears both and lists what it actually removed + - Environment variables cannot be unset by a child process, so `OVERLEAF_SESSION` and `OVERLEAF_EMAIL`/`OVERLEAF_PASSWORD` are now reported instead of ignored. They outrank everything on disk, and a logout that stays silent about them repeats the original mistake in a different place +- **`olcli auth` claimed `Password login saved.` even under `--no-save-password`** - the same class of bug: a message stating an outcome that did not happen. It now reports what was actually stored + +### Changed +- **The account password is no longer persisted by default** ([#50](https://github.com/aloth/olcli/issues/50)) - it is written only when you ask for it with `--save-password`. The session cookie is stored either way and is what every later command uses; the password only bought an automatic re-login after that cookie expired. A cookie is scoped to olcli and rotates, a password is reusable everywhere and cannot be revoked without changing it + - `--no-save-password` still parses and still means "do not save", so existing scripts keep working - it is simply the default now + - **Behaviour change for self-hosted users:** an expired session no longer re-logs in silently. Re-run `olcli auth`, pass `--save-password` to keep the old behaviour, or set `OVERLEAF_EMAIL`/`OVERLEAF_PASSWORD` +- **`olcli auth --password` is now optional and prompts instead** ([#50](https://github.com/aloth/olcli/issues/50)) - passing it puts the password in shell history, so `olcli auth --email you@example.com` now reads it from the terminal without echoing. The flag still works and warns; with no terminal available, the error names `OVERLEAF_EMAIL`/`OVERLEAF_PASSWORD`, which every command already reads + - Keystroke handling is a pure reducer so it can be tested without a pty. Driving the real prompt over one is what surfaced the bug it now guards: filtering only the ESC of an arrow key left the printable `[` and `A` behind and silently appended them to the password +- **`olcli check` now reports whether a password is stored** and whether a `.olauth` file is present, never the values. Answering "is my password on disk?" previously meant opening the config file + ## [0.11.0] - 2026-09-04 ### Added diff --git a/README.md b/README.md index ee1d180..415ccc4 100644 --- a/README.md +++ b/README.md @@ -83,9 +83,16 @@ olcli auth --cookie "your_session_cookie_value" **Email/password** (self-hosted without reCAPTCHA): ```bash -olcli auth --email "you@example.com" --password "your_password" +olcli auth --email "you@example.com" +# prompts for the password, so it stays out of your shell history ``` +The password is **not stored** unless you pass `--save-password`. A session +cookie is saved either way and is what later commands use; the password only +buys an automatic re-login once that cookie expires. For scripts, set +`OVERLEAF_EMAIL` and `OVERLEAF_PASSWORD` — every command reads them, so a +scripted run never needs `olcli auth` at all. + ### 2. List Projects ```bash @@ -128,7 +135,7 @@ All commands auto-detect the project when run from a synced directory (contains |---------|-------------| | `olcli auth` | Set session cookie or login with email/password | | `olcli whoami` | Check authentication status | -| `olcli logout` | Clear stored credentials | +| `olcli logout` | Clear the global config and the local `.olauth`, reporting each | | `olcli list` | List all projects | | `olcli info [project]` | Show project details and file list | | `olcli pull [project] [dir]` | Download project files to local directory | @@ -284,6 +291,23 @@ hardcoded here: on macOS it lands under `~/Library/Preferences/`, on Linux under which is usually your LaTeX project. Add it to that project's `.gitignore` before committing. +### What is stored, and how to clear it + +Everything is stored in plaintext, so it is worth knowing what is on disk: + +| Credential | Stored by default | Where | +|---|---|---| +| Session cookie | yes | global config, or `.olauth` with `--save-local` | +| Email + password | **no** — only with `--save-password` | global config | + +`olcli check` reports what exists without printing any secret. + +`olcli logout` clears the global config **and** the `.olauth` file in the +current directory, then lists what it removed. It cannot unset environment +variables, so if `OVERLEAF_SESSION` or `OVERLEAF_EMAIL`/`OVERLEAF_PASSWORD` are +set, it says so instead of implying you are logged out — those take precedence +over anything on disk. + ### Self-hosted Overleaf ```bash diff --git a/SKILL.md b/SKILL.md index 4794a1e..16f7b93 100644 --- a/SKILL.md +++ b/SKILL.md @@ -60,6 +60,15 @@ Clear stored credentials: olcli logout ``` +Clears the global config and the `.olauth` file in the current directory, and +reports each. Environment variables cannot be unset by a child process, so +`OVERLEAF_SESSION` and `OVERLEAF_EMAIL`/`OVERLEAF_PASSWORD` are reported rather +than silently ignored — they outrank anything on disk. + +For unattended use, prefer `OVERLEAF_EMAIL`/`OVERLEAF_PASSWORD` over +`olcli auth --password`: every command reads them, and nothing is written to +disk or to shell history. + ### Self-hosted Overleaf ```bash @@ -255,9 +264,9 @@ zip arxiv.zip *.tex main.bbl figures/*.pdf | Command | Description | |---------|-------------| | `olcli auth --cookie ` | Authenticate with session cookie | -| `olcli auth --email --password

` | Authenticate with password (self-hosted) | +| `olcli auth --email ` | Authenticate with password, prompted (self-hosted) | | `olcli whoami` | Check authentication status | -| `olcli logout` | Clear stored credentials | +| `olcli logout` | Clear the global config and the local `.olauth` | | `olcli check` | Show config paths and credential sources | | `olcli list` | List all projects | | `olcli project create ` | Create a blank or example project | diff --git a/src/cli.ts b/src/cli.ts index 3a3bc8e..70a59e1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -38,8 +38,11 @@ import { setTimeout, getPasswordCredentials, setPasswordCredentials, + clearOlAuth, + inspectStoredCredentials, type PasswordCredentials } from './config.js'; +import { promptHidden, PromptCancelled, NotATerminal } from './prompt.js'; const program = new Command(); @@ -158,9 +161,18 @@ program .description('Authenticate with Overleaf using a session cookie or email/password') .option('--cookie ', 'Session cookie (overleaf_session2 value)') .option('--email ', 'Account email for password login') - .option('--password ', 'Account password for password login') - .option('--no-save-password', 'Do not persist email/password credentials') + .option('--password ', 'Account password (omit to be prompted; see warning below)') + .option('--save-password', 'Persist the password in the config file, in plaintext') + .option('--no-save-password', 'Do not persist the password (the default; kept for existing scripts)') .option('--save-local', 'Save to .olauth in current directory') + .addHelpText('after', ` +The password is not stored unless you ask for it with --save-password. A +session cookie is stored either way, and that is what later commands use; the +password only buys an automatic re-login once the cookie expires. + +Passing --password puts the password in your shell history. Omit it to be +prompted instead, or set OVERLEAF_EMAIL and OVERLEAF_PASSWORD, which every +command reads without needing 'auth' at all.`) .action(async (options) => { if (!options.cookie && !options.email && !options.password) { console.log(chalk.yellow('To authenticate, provide a session cookie:')); @@ -173,7 +185,8 @@ program console.log(chalk.cyan(' olcli auth --cookie "your_session_cookie_value"')); console.log(); console.log('Or log in with email/password:'); - console.log(chalk.cyan(' olcli auth --email "you@example.com" --password "your_password"')); + console.log(chalk.cyan(' olcli auth --email "you@example.com"')); + console.log(chalk.dim(' (prompts for the password, so it stays out of your shell history)')); console.log(); console.log('Or set OVERLEAF_SESSION environment variable'); return; @@ -184,11 +197,41 @@ program process.exit(1); } - if (!options.cookie && (!options.email || !options.password)) { - console.error(chalk.red('Both --email and --password are required for password login.')); + if (!options.cookie && !options.email) { + console.error(chalk.red('--email is required for password login.')); process.exit(1); } + // Resolve the password before the spinner starts: a prompt and a spinner + // both own the terminal, and ora would redraw over the prompt line. + let password: string | undefined = options.password; + if (!options.cookie) { + if (password) { + console.log(chalk.yellow('⚠ --password is now in your shell history.')); + console.log(chalk.dim(' Omit it to be prompted, or set OVERLEAF_EMAIL/OVERLEAF_PASSWORD.')); + } else { + try { + password = await promptHidden(`Password for ${options.email}: `); + } catch (error: any) { + if (error instanceof NotATerminal) { + console.error(chalk.red('No terminal available to prompt for a password.')); + console.error('Set OVERLEAF_EMAIL and OVERLEAF_PASSWORD instead — every command reads them,'); + console.error("so a scripted run does not need 'olcli auth' at all."); + process.exit(1); + } + if (error instanceof PromptCancelled) { + console.error(chalk.red('Cancelled.')); + process.exit(1); + } + throw error; + } + if (!password) { + console.error(chalk.red('Password must not be empty.')); + process.exit(1); + } + } + } + const spinner = ora('Verifying session...').start(); try { const baseUrl = (program.opts().baseUrl as string | undefined) || getBaseUrl(); @@ -208,15 +251,30 @@ program } } else { spinner.text = 'Logging in with email/password...'; - const client = await OverleafClient.fromPasswordLogin(options.email, options.password, baseUrl); + const client = await OverleafClient.fromPasswordLogin(options.email, password!, baseUrl); const projects = await client.listProjects(); persistClientSession(client, cookieName); setBaseUrl(baseUrl); - if (options.savePassword !== false) { - setPasswordCredentials(options.email, options.password); + + // Opt-in, not opt-out. The session cookie persisted just above is what + // later commands actually use; the password only buys an automatic + // re-login after that cookie expires, and it is stored in plaintext. + // A cookie is scoped to olcli and rotates; a password is reusable + // everywhere and cannot be revoked without changing it. See issue #50. + const savePassword = options.savePassword === true; + if (savePassword) { + setPasswordCredentials(options.email, password!); } - spinner.succeed(`Authenticated! Found ${projects.length} projects. Password login saved.`); + // The old message said "Password login saved." unconditionally - even + // under --no-save-password, which had just prevented exactly that. + spinner.succeed(`Authenticated! Found ${projects.length} projects.`); + if (savePassword) { + console.log(chalk.yellow('Password stored in plaintext in the config file.')); + } else { + console.log(chalk.dim('Session cookie stored. The password was not saved; re-run')); + console.log(chalk.dim('olcli auth when the session expires, or use --save-password.')); + } } console.log(chalk.dim(`Config saved to: ${getConfigPath()}`)); @@ -251,9 +309,41 @@ program program .command('logout') .description('Clear stored credentials') + .addHelpText('after', ` +Clears the global config and the .olauth file in the current directory, and +reports each one separately. Environment variables cannot be cleared by a +child process, so OVERLEAF_SESSION and OVERLEAF_EMAIL/OVERLEAF_PASSWORD are +reported instead of silently ignored - both take precedence over anything on +disk.`) .action(() => { + // Read before clearing: afterwards there is nothing left to report on. + const before = inspectStoredCredentials(); + clearConfig(); - console.log(chalk.green('Credentials cleared')); + const removedOlAuth = clearOlAuth(); + + const cleared: string[] = []; + if (before.sessionCookie) cleared.push('session cookie (global config)'); + if (before.password) cleared.push('saved password (global config)'); + if (removedOlAuth) cleared.push(removedOlAuth); + + if (cleared.length === 0) { + console.log('Nothing stored to clear.'); + } else { + console.log(chalk.green('Cleared:')); + for (const item of cleared) console.log(` ${item}`); + } + + // The reason this command was wrong before: it announced success while a + // higher-precedence source kept the user authenticated. Anything olcli + // cannot clear has to be said out loud, or the message is a lie again. + if (before.envSession || before.envPassword) { + console.log(); + console.log(chalk.yellow('Still authenticated in this shell:')); + if (before.envSession) console.log(' OVERLEAF_SESSION is set'); + if (before.envPassword) console.log(' OVERLEAF_EMAIL and OVERLEAF_PASSWORD are set'); + console.log(chalk.dim(' These outrank anything on disk. Unset them to finish logging out.')); + } }); // ───────────────────────────────────────────────────────────────────────────── @@ -1961,6 +2051,22 @@ program } else { console.log(chalk.yellow('✗ No session cookie found')); } + + // A stored password is a credential source this command used to omit, + // which made it impossible to answer "is my password on disk?" without + // opening the config file. Never print the value - only whether it exists + // and which source it came from. + const stored = inspectStoredCredentials(); + if (stored.envPassword) { + console.log(chalk.yellow('⚠ Password set via OVERLEAF_EMAIL/OVERLEAF_PASSWORD')); + } else if (stored.password) { + console.log(chalk.yellow('⚠ Password stored in plaintext in the config file')); + console.log(chalk.dim(" Remove it with 'olcli logout', then re-auth without --save-password.")); + } + + if (stored.olAuthPath) { + console.log(chalk.dim(` .olauth present: ${stored.olAuthPath}`)); + } }); program.parse(process.argv); diff --git a/src/config.ts b/src/config.ts index 5930662..0a32e95 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,7 +3,7 @@ */ import Conf from 'conf'; -import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; interface OlcliConfig { @@ -135,10 +135,67 @@ export function getConfigPath(): string { return config.path; } +/** + * Where a `.olauth` file would live for a given directory. + * + * Defaults to the current working directory, which is what `saveOlAuth` and + * `getSessionCookie` both use. + */ +export function getOlAuthPath(dir?: string): string { + return join(dir || process.cwd(), '.olauth'); +} + /** * Save session cookie in .olauth format for compatibility */ export function saveOlAuth(cookie: string, path?: string): void { - const authPath = path || join(process.cwd(), '.olauth'); + const authPath = path || getOlAuthPath(); writeFileSync(authPath, `${getSessionCookieName()}=${cookie}`, 'utf-8'); } + +/** + * Delete the `.olauth` file for a directory, if there is one. + * + * Returns the path that was removed, or null when there was nothing to + * remove. `logout` needs the distinction to report what it actually did. + */ +export function clearOlAuth(dir?: string): string | null { + const authPath = getOlAuthPath(dir); + if (!existsSync(authPath)) return null; + rmSync(authPath); + return authPath; +} + +/** + * What credentials exist right now, and where. + * + * Deliberately reports every source `getSessionCookie` and + * `getPasswordCredentials` consult, including the two that no command can + * clear. `logout` used to clear the global config and announce success while + * a `.olauth` file - which takes precedence over it - stayed on disk and kept + * the user authenticated. Reporting per source is what stops that message + * from being wrong again. See issue #50. + */ +export interface StoredCredentials { + /** Session cookie in the global config file. */ + sessionCookie: boolean; + /** Email/password pair in the global config file. */ + password: boolean; + /** Path of the `.olauth` file, when one exists. Takes precedence over the config. */ + olAuthPath: string | null; + /** `OVERLEAF_SESSION` is set. Takes precedence over everything, and logout cannot unset it. */ + envSession: boolean; + /** `OVERLEAF_EMAIL`/`OVERLEAF_PASSWORD` are set. Same caveat. */ + envPassword: boolean; +} + +export function inspectStoredCredentials(dir?: string): StoredCredentials { + const authPath = getOlAuthPath(dir); + return { + sessionCookie: Boolean(config.get('sessionCookie')), + password: Boolean(config.get('loginEmail') || config.get('loginPassword')), + olAuthPath: existsSync(authPath) ? authPath : null, + envSession: Boolean(process.env.OVERLEAF_SESSION), + envPassword: Boolean(process.env.OVERLEAF_EMAIL && process.env.OVERLEAF_PASSWORD) + }; +} diff --git a/src/prompt.ts b/src/prompt.ts new file mode 100644 index 0000000..2c1cc82 --- /dev/null +++ b/src/prompt.ts @@ -0,0 +1,171 @@ +/** + * Interactive terminal prompts. + * + * Exists for one reason: reading a password without it appearing on the + * command line, and therefore in shell history. See issue #50. + * + * No dependency for this. A masked read is a raw-mode loop over stdin, and + * pulling in a prompt library to avoid writing it would cost more than it + * saves - the bar in this repo is low dependency count, not zero. + * + * The keystroke handling is a pure reducer (`applyChunk`) with the terminal + * wiring wrapped around it, for the same reason `diff.ts` is pure: a raw-mode + * loop cannot be exercised without a pty, and the part that can silently + * corrupt a password is the character handling, not the plumbing. + */ + +import { stdin, stdout } from 'node:process'; + +const CTRL_C = '\u0003'; +const CTRL_D = '\u0004'; +const DEL = '\u007f'; +const ESC = '\u001b'; + +/** Raised when the user aborts the prompt (Ctrl+C / Ctrl+D). */ +export class PromptCancelled extends Error { + constructor() { + super('Cancelled'); + this.name = 'PromptCancelled'; + } +} + +/** Raised when there is no terminal to prompt on. */ +export class NotATerminal extends Error { + constructor(message: string) { + super(message); + this.name = 'NotATerminal'; + } +} + +export interface KeyState { + /** What the user has typed so far. */ + value: string; + /** + * Where we are in an ANSI escape sequence. + * + * 'none' - ordinary input + * 'esc' - saw ESC, waiting to see whether a sequence follows + * 'csi' - inside `ESC [` or `ESC O`, swallowing until the final byte + */ + escape: 'none' | 'esc' | 'csi'; + outcome: 'pending' | 'submit' | 'cancel'; +} + +export function initialKeyState(): KeyState { + return { value: '', escape: 'none', outcome: 'pending' }; +} + +/** + * Fold one chunk of terminal input into the state. + * + * A chunk is not one keystroke: a paste arrives whole, and a single arrow key + * arrives as the three characters `ESC [ A`. Both have to be handled here. + * + * Escape sequences are swallowed rather than filtered character by character. + * Dropping only the ESC leaves `[` and `A` behind, and both are printable - so + * an arrow key pressed mid-entry would append "[A" to the password and the + * user would never see it. That is the failure this state machine exists to + * prevent; it was found by driving the prompt over a pty. + */ +export function applyChunk(state: KeyState, chunk: string): KeyState { + let { value, escape, outcome } = state; + + for (const ch of chunk) { + if (outcome !== 'pending') break; + + if (escape === 'csi') { + // Parameter and intermediate bytes continue the sequence; a final byte + // in @..~ ends it. `ESC [ 3 ~` (Delete) needs the parameter handling. + if (ch >= '@' && ch <= '~') escape = 'none'; + continue; + } + + if (escape === 'esc') { + // `ESC [` is CSI, `ESC O` is the alternate cursor mode some terminals + // use for arrows. Anything else was a lone ESC plus a real character. + escape = ch === '[' || ch === 'O' ? 'csi' : 'none'; + continue; + } + + switch (ch) { + case '\r': + case '\n': + outcome = 'submit'; + break; + case CTRL_C: + case CTRL_D: + outcome = 'cancel'; + break; + case DEL: + case '\b': + value = value.slice(0, -1); + break; + case ESC: + escape = 'esc'; + break; + default: + // Remaining control characters are not password material. Appending + // them would corrupt the value invisibly. + if (ch >= ' ') value += ch; + } + } + + return { value, escape, outcome }; +} + +/** + * Read a line from stdin without echoing it. + * + * Nothing is written back as the user types - not even asterisks. `sudo` + * behaves the same way, and echoing one character per keystroke leaks the + * length of the secret to anyone looking at the screen. + * + * Throws `NotATerminal` when stdin is piped or redirected. A masked read is + * impossible there, and silently falling back to an unmasked one would defeat + * the point; the caller is expected to name the scripted alternative instead. + */ +export function promptHidden(label: string): Promise { + return new Promise((resolve, reject) => { + if (!stdin.isTTY) { + reject(new NotATerminal('stdin is not a terminal, so a password cannot be prompted for')); + return; + } + + // Restore whatever mode the caller was in rather than assuming cooked: + // olcli may be driven from a wrapper that set raw mode itself. + const wasRaw = stdin.isRaw; + let state = initialKeyState(); + let settled = false; + + const cleanup = (): void => { + if (settled) return; + settled = true; + stdin.removeListener('data', onData); + stdin.setRawMode(wasRaw); + stdin.pause(); + stdout.write('\n'); + }; + + const onData = (chunk: string): void => { + state = applyChunk(state, chunk); + if (state.outcome === 'submit') { + cleanup(); + resolve(state.value); + } else if (state.outcome === 'cancel') { + cleanup(); + reject(new PromptCancelled()); + } + }; + + // Raw mode goes on before the label is written. The terminal's line + // discipline echoes whatever arrives while ECHO is still set, so a caller + // that answers the instant the prompt appears - anything automated - would + // otherwise have its first keystrokes echoed to the screen. + stdin.setRawMode(true); + stdin.resume(); + stdin.setEncoding('utf8'); + stdin.on('data', onData); + + stdout.write(label); + }); +} diff --git a/test/config.test.ts b/test/config.test.ts new file mode 100644 index 0000000..6e70bfa --- /dev/null +++ b/test/config.test.ts @@ -0,0 +1,85 @@ +import { test, before, after, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync, existsSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { getOlAuthPath, clearOlAuth, inspectStoredCredentials } from '../src/config.js'; + +// Every assertion here is scoped to a temp directory or to process.env. +// +// `config.ts` builds its Conf store against the real user config path at +// import time, so a test must never call clearConfig() or any setter: it would +// delete the credentials of whoever is running the suite. The functions under +// test all take an explicit directory for exactly that reason, and the two +// config-backed fields of inspectStoredCredentials() are left unasserted - +// their value depends on whether the machine happens to be logged in. + +let root: string; +const savedEnv = { + session: process.env.OVERLEAF_SESSION, + email: process.env.OVERLEAF_EMAIL, + password: process.env.OVERLEAF_PASSWORD +}; + +before(() => { + root = mkdtempSync(join(tmpdir(), 'olcli-config-')); +}); + +after(() => { + rmSync(root, { recursive: true, force: true }); + process.env.OVERLEAF_SESSION = savedEnv.session; + process.env.OVERLEAF_EMAIL = savedEnv.email; + process.env.OVERLEAF_PASSWORD = savedEnv.password; +}); + +beforeEach(() => { + delete process.env.OVERLEAF_SESSION; + delete process.env.OVERLEAF_EMAIL; + delete process.env.OVERLEAF_PASSWORD; +}); + +test('getOlAuthPath: resolves .olauth inside the given directory', () => { + assert.equal(getOlAuthPath(root), join(root, '.olauth')); +}); + +test('clearOlAuth: returns the removed path and deletes the file', () => { + const authPath = join(root, '.olauth'); + writeFileSync(authPath, 'overleaf_session2=abc\n'); + + assert.equal(clearOlAuth(root), authPath); + assert.equal(existsSync(authPath), false, 'the file must actually be gone'); +}); + +test('clearOlAuth: returns null when there is nothing to remove', () => { + // logout distinguishes "cleared a file" from "there was no file" so it can + // report what it actually did; a bare boolean would not carry the path. + assert.equal(clearOlAuth(root), null); +}); + +test('inspectStoredCredentials: reports .olauth only while it exists', () => { + const authPath = join(root, '.olauth'); + assert.equal(inspectStoredCredentials(root).olAuthPath, null); + + writeFileSync(authPath, 'overleaf_session2=abc\n'); + assert.equal(inspectStoredCredentials(root).olAuthPath, authPath); + + clearOlAuth(root); + assert.equal(inspectStoredCredentials(root).olAuthPath, null); +}); + +test('inspectStoredCredentials: reports OVERLEAF_SESSION, which logout cannot unset', () => { + assert.equal(inspectStoredCredentials(root).envSession, false); + + process.env.OVERLEAF_SESSION = 'from-the-environment'; + assert.equal(inspectStoredCredentials(root).envSession, true); +}); + +test('inspectStoredCredentials: needs both env password variables before reporting one', () => { + // getPasswordCredentials() returns undefined unless both are set, so + // reporting on either alone would announce a credential that cannot be used. + process.env.OVERLEAF_EMAIL = 'someone@example.com'; + assert.equal(inspectStoredCredentials(root).envPassword, false); + + process.env.OVERLEAF_PASSWORD = 'secret'; + assert.equal(inspectStoredCredentials(root).envPassword, true); +}); diff --git a/test/prompt.test.ts b/test/prompt.test.ts new file mode 100644 index 0000000..6b532c2 --- /dev/null +++ b/test/prompt.test.ts @@ -0,0 +1,123 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + promptHidden, + applyChunk, + initialKeyState, + NotATerminal, + PromptCancelled +} from '../src/prompt.js'; + +const CTRL_C = '\u0003'; +const CTRL_D = '\u0004'; +const DEL = '\u007f'; +const ESC = '\u001b'; + +/** Feed chunks in order, as the terminal would deliver them. */ +const type = (...chunks: string[]) => + chunks.reduce((state, chunk) => applyChunk(state, chunk), initialKeyState()); + +test('applyChunk: collects printable characters and submits on Enter', () => { + const state = type('hunter2', '\r'); + assert.equal(state.value, 'hunter2'); + assert.equal(state.outcome, 'submit'); +}); + +test('applyChunk: a paste arrives as one chunk and is kept whole', () => { + assert.equal(type('a-long-pasted-secret', '\r').value, 'a-long-pasted-secret'); +}); + +test('applyChunk: backspace and DEL remove the last character', () => { + assert.equal(type('abcXX', DEL, DEL, '\r').value, 'abc'); + assert.equal(type('abcX', '\b', '\r').value, 'abc'); +}); + +test('applyChunk: backspace on an empty value is a no-op, not an error', () => { + assert.equal(type(DEL, DEL, 'a', '\r').value, 'a'); +}); + +test('applyChunk: Ctrl+C and Ctrl+D cancel', () => { + assert.equal(type('abc', CTRL_C).outcome, 'cancel'); + assert.equal(type('abc', CTRL_D).outcome, 'cancel'); +}); + +test('applyChunk: an arrow key mid-entry contributes nothing', () => { + // The regression this reducer exists for. Filtering only the ESC leaves the + // printable '[' and 'A' behind, which silently appended "[A" to the + // password. Found by driving the real prompt over a pty. + assert.equal(type('pa', `${ESC}[A`, 'ss', '\r').value, 'pass'); +}); + +test('applyChunk: multi-byte CSI sequences are swallowed whole', () => { + // Delete is ESC [ 3 ~ - the parameter byte has to be consumed too, or the + // '3' lands in the password. + assert.equal(type('pa', `${ESC}[3~`, 'ss', '\r').value, 'pass'); + // Home in application cursor mode is ESC O H. + assert.equal(type('pa', `${ESC}OH`, 'ss', '\r').value, 'pass'); +}); + +test('applyChunk: an escape sequence split across chunks is still swallowed', () => { + // A terminal is free to deliver ESC, '[' and 'A' in separate reads, which is + // why the escape state lives in KeyState rather than in a local variable. + assert.equal(type('pa', ESC, '[', 'A', 'ss', '\r').value, 'pass'); +}); + +test('applyChunk: input after Enter is ignored', () => { + const state = type('abc\rdef'); + assert.equal(state.value, 'abc'); + assert.equal(state.outcome, 'submit'); +}); + +test('applyChunk: an empty password is a valid submit, distinct from a cancel', () => { + // `auth` rejects the empty value with its own message; the reducer must not + // conflate "typed nothing" with "pressed Ctrl+C". + const state = type('\r'); + assert.equal(state.value, ''); + assert.equal(state.outcome, 'submit'); +}); + +test('applyChunk: non-ASCII characters survive', () => { + assert.equal(type('pásswörd✓', '\r').value, 'pásswörd✓'); +}); + +// The masked read itself needs a real TTY and cannot be exercised here - the +// test runner's stdin is not one. That is precisely the branch worth pinning: +// `auth` relies on this rejection to tell a scripted caller to use +// OVERLEAF_EMAIL/OVERLEAF_PASSWORD rather than silently reading the password +// unmasked from a pipe. + +test('promptHidden: rejects with NotATerminal when stdin is not a TTY', async () => { + await assert.rejects( + () => promptHidden('Password: '), + (error: unknown) => { + assert.ok(error instanceof NotATerminal, 'callers switch on the error type'); + return true; + } + ); +}); + +test('promptHidden: does not write the label when it cannot prompt', async () => { + // Writing a prompt and then failing would leave a stray "Password:" in the + // output of a scripted run, ahead of the error explaining what to do. + const written: string[] = []; + const original = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: string | Uint8Array) => { + written.push(String(chunk)); + return true; + }) as typeof process.stdout.write; + + try { + await promptHidden('Password: ').catch(() => { /* asserted above */ }); + } finally { + process.stdout.write = original; + } + + assert.deepEqual(written, []); +}); + +test('PromptCancelled and NotATerminal are distinguishable', () => { + // `auth` maps one to "Cancelled." and the other to a message naming the + // scripted alternative; collapsing them would give the wrong advice. + assert.ok(!(new PromptCancelled() instanceof NotATerminal)); + assert.ok(!(new NotATerminal('x') instanceof PromptCancelled)); +});