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)); +});