diff --git a/changelog.d/64.md b/changelog.d/64.md new file mode 100644 index 0000000..362a291 --- /dev/null +++ b/changelog.d/64.md @@ -0,0 +1 @@ +- Report supplied tokens that remain active after logout and make unauthenticated status machine-detectable. (#64). diff --git a/src/cli.js b/src/cli.js index 8691029..d3f8eb1 100755 --- a/src/cli.js +++ b/src/cli.js @@ -295,6 +295,8 @@ auth } else { console.log(`Skipping remote revocation (no password provided). Token ${config.tokenId} stays active on ${config.url}; revoke it from the web UI under Settings > Applications.`); } + } else if (!config.tokenId && !options.localOnly) { + console.log(`Removing local credentials. Stoke did not create this token and cannot revoke it. The token is still valid on ${config.url}; revoke it from the web UI under Settings > Applications.`); } clearConfig(); @@ -313,8 +315,12 @@ auth try { const config = loadConfig(); if (!config || !config.token) { - console.log('Not authenticated.'); - return; + if (options.json) { + console.log('{"authenticated": false}'); + } else { + console.log('Not authenticated.'); + } + process.exit(1); } const client = ForgejoClient.fromConfig(config); diff --git a/test/cli.test.js b/test/cli.test.js index 817cc06..16a1327 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -40,10 +40,59 @@ test('global --config flag overrides the config location', () => { // "Not authenticated" instead of silently using the default config. const missing = path.join(os.tmpdir(), `stoke-missing-${process.pid}.json`); const res = run(['--config', missing, 'auth', 'status']); - assert.equal(res.status, 0); + assert.equal(res.status, 1); assert.match(res.stdout, /Not authenticated/); }); +test('auth status reports an absent session in text and JSON with a failing status', () => { + const missing = path.join(os.tmpdir(), `stoke-missing-${process.pid}-auth-status.json`); + + const text = run(['auth', 'status'], { STOKE_CONFIG_FILE: missing }); + assert.equal(text.status, 1); + assert.equal(text.stdout, 'Not authenticated.\n'); + assert.equal(text.stderr, ''); + + const json = run(['auth', 'status', '--json'], { STOKE_CONFIG_FILE: missing }); + assert.equal(json.status, 1); + assert.equal(json.stdout, '{"authenticated": false}\n'); + assert.equal(json.stderr, ''); +}); + +test('auth logout identifies a supplied token that remains active without changing local-only output', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-auth-logout-')); + const cfg = path.join(dir, 'config.json'); + const config = { + url: 'https://forge.test', + login: 'bot', + username: 'bot', + token: 'token-that-must-not-be-printed', + tokenId: null, + }; + + try { + fs.writeFileSync(cfg, JSON.stringify(config)); + const logout = run(['auth', 'logout'], { STOKE_CONFIG_FILE: cfg }); + assert.equal(logout.status, 0, logout.stderr); + assert.match(logout.stdout, /local credentials/i); + assert.match(logout.stdout, /did not create this token/i); + assert.match(logout.stdout, /cannot revoke it/i); + assert.match(logout.stdout, /still valid on https:\/\/forge\.test/i); + assert.match(logout.stdout, /Settings > Applications/); + assert.doesNotMatch(logout.stdout, /Revoked token/); + assert.doesNotMatch(logout.stdout, /Password for/); + assert.doesNotMatch(logout.stdout, /token-that-must-not-be-printed/); + assert.equal(fs.existsSync(cfg), false); + + fs.writeFileSync(cfg, JSON.stringify(config)); + const localOnly = run(['auth', 'logout', '--local-only'], { STOKE_CONFIG_FILE: cfg }); + assert.equal(localOnly.status, 0, localOnly.stderr); + assert.equal(localOnly.stdout, 'Local credentials removed.\n'); + assert.equal(fs.existsSync(cfg), false); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + test('invalid --limit is rejected before any network call', () => { const res = run(['repo', 'list', '-l', 'abc']); assert.equal(res.status, 1);