From 510a553984d989845a8869c122e41635ac898a01 Mon Sep 17 00:00:00 2001 From: Amin Ehterami Date: Sat, 24 Oct 2026 16:40:37 +0330 Subject: [PATCH 1/2] fix: authentication bug --- src/commands/account/add.ts | 9 ++- src/commands/login.ts | 65 ++++++++++++++++---- test/auth/account-add.unit.test.ts | 92 +++++++++++++++++++++++++++++ test/auth/login.unit.test.ts | 95 ++++++++++++++++++++++++++++-- 4 files changed, 240 insertions(+), 21 deletions(-) create mode 100644 test/auth/account-add.unit.test.ts diff --git a/src/commands/account/add.ts b/src/commands/account/add.ts index 9ddfd92..79a667a 100644 --- a/src/commands/account/add.ts +++ b/src/commands/account/add.ts @@ -55,7 +55,10 @@ export default class AccountAdd extends Command { 'api token is not creditable, please get your api token from https://console.liara.ir/API.', ); } - const name = flags.account || (await this.promptName(user.email)); + // Accounts without an email (such as phone sign-ups) use their phone or id. + const name = + flags.account || + (await this.promptName(user.email || user.phone || user._id)); this.addNewAccountToConfig(currentAccounts, { name, ...user, @@ -263,10 +266,10 @@ Error: ${error.response.statusMessage} (${error.response.statusCode}) return password; } - async getMe(flags: any): Promise { + async getMe(flags: any): Promise { const { user } = await this.got('v1/me', { headers: { Authorization: `Bearer ${flags['api-token']}` }, - }).json<{ user: IAccount }>(); + }).json<{ user: IAccount & { _id: string; phone?: string } }>(); return user; } diff --git a/src/commands/login.ts b/src/commands/login.ts index 55332de..cd7b52b 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -1,14 +1,19 @@ import fs from 'fs-extra'; import chalk from 'chalk'; import open, { apps, AppName } from 'open'; +import got from 'got'; import { Flags } from '@oclif/core'; -import Command from '../base.js'; +import Command, { IAccount } from '../base.js'; import AccountAdd from './account/add.js'; import AccountUse from './account/use.js'; import IBrowserLogin from '../types/browser-login.js'; import { createDebugLogger } from '../utils/output.js'; -import { GLOBAL_CONF_PATH, GLOBAL_CONF_VERSION } from '../constants.js'; +import { + GLOBAL_CONF_PATH, + GLOBAL_CONF_VERSION, + REGIONS_API_URL, +} from '../constants.js'; import ora from 'ora'; const CONSOLE_LOGIN_URL = 'https://console.liara.ir/login'; @@ -65,33 +70,69 @@ export default class Login extends Command { let currentAccount; + // No interceptor hooks here: a rejected token must throw, not exit. + const api = got.extend({ prefixUrl: REGIONS_API_URL['iran'] }); + const saved: string[] = []; + for (const account of accounts) { - if (!account.email) { - this.spinner.stop(); + const skippedAccount = account?.email || account?.fullname; - await this.openConsoleLogin(flags.browser).catch((error) => - debug(`${error.message}\n`), + if (!account?.token) { + this.spinner.stop(); + this.warn( + `Skipped the account ${skippedAccount} because the console did not send a token for it.`, ); + continue; + } + + let user; + + try { + ({ user } = await api('v1/me', { + headers: { Authorization: `Bearer ${account.token}` }, + }).json<{ user: IAccount & { _id: string; phone?: string } }>()); + } catch (error) { + if (error.response?.statusCode !== 401) { + throw error; + } - this.error( - `Your account${account.fullname ? ` (${account.fullname})` : ''} has no email address, which is required to log in with the CLI. -Please add an email to your account at ${CONSOLE_LOGIN_URL} and then run ${chalk.green('liara login')} again.`, + this.spinner.stop(); + this.warn( + `Skipped the account ${skippedAccount} because its token was rejected. Log out of it at ${CONSOLE_LOGIN_URL}, log in again and then run ${chalk.green('liara login')} again.`, ); + continue; } - const name = `${account.email.split('@')[0]}`; + // Accounts without an email, such as phone sign-ups, use their phone or id. + const name = user.email + ? user.email.split('@')[0] + : user.phone || user._id; if (account.current) { currentAccount = name; } currentAccounts[name] = { - email: account.email, + email: user.email, avatar: account.avatar, api_token: account.token, fullname: account.fullname, current: false, }; + saved.push(name); + } + + if (!saved.length) { + this.spinner.stop(); + + await this.openConsoleLogin(flags.browser).catch((error) => + debug(`${error.message}\n`), + ); + + this.error( + `No valid account was received from the console. +Please log out and log in again at ${CONSOLE_LOGIN_URL} and then run ${chalk.green('liara login')} again.`, + ); } fs.writeFileSync( @@ -104,7 +145,7 @@ Please add an email to your account at ${CONSOLE_LOGIN_URL} and then run ${chalk this.spinner.succeed('You have logged in successfully.'); - currentAccount && (await AccountUse.run(['--account', currentAccount])); + await AccountUse.run(['--account', currentAccount || saved[0]]); const { accountName } = await this.getCurrentAccount(); diff --git a/test/auth/account-add.unit.test.ts b/test/auth/account-add.unit.test.ts new file mode 100644 index 0000000..a2ee712 --- /dev/null +++ b/test/auth/account-add.unit.test.ts @@ -0,0 +1,92 @@ +import { runCommand } from '@oclif/test'; +import { expect } from 'chai'; +import nock from 'nock'; +import sinon from 'sinon'; +import fs from 'fs-extra'; +import inquirer from 'inquirer'; +import path from 'path'; +import os from 'node:os'; +import AccountAdd from '../../src/commands/account/add.ts'; + +describe('account:add --api-token', async () => { + let fsStub: sinon.SinonStub; + let promptStub: sinon.SinonStub; + + const mockMe = (user: object) => + nock('https://api.liara.ir') + .get('/v1/me') + .matchHeader('authorization', 'Bearer token') + .reply(200, { user }); + + const expectedFile = (name: string, email: string | null) => + JSON.stringify({ + accounts: { + [name]: { + email, + api_token: 'token', + fullname: 'No Email', + avatar: '', + current: false, + }, + }, + version: '1', + }); + + beforeEach(() => { + nock.disableNetConnect(); + fsStub = sinon.stub(fs, 'writeFileSync'); + sinon + .stub(AccountAdd.prototype, 'readGlobalConfig') + .resolves({ version: '1', accounts: {} }); + promptStub = sinon + .stub(inquirer, 'prompt') + .callsFake(async (q: any) => ({ name: q.default })); + }); + + afterEach(() => { + nock.cleanAll(); + nock.enableNetConnect(); + sinon.restore(); + }); + + it('should name an api-token account without an email by its phone number', async () => { + mockMe({ + _id: 'u1', + email: null, + phone: '09120000000', + fullname: 'No Email', + avatar: '', + }); + + const { error } = await runCommand(['account:add', '--api-token', 'token']); + + expect(error).to.be.undefined; + expect(promptStub.firstCall.args[0].default).to.equal('09120000000'); + expect( + fsStub.calledWithExactly( + path.join(os.homedir(), '.liara-auth.json'), + expectedFile('09120000000', null), + ), + ).to.be.true; + }); + + it('should default the api-token account name to the email local part', async () => { + mockMe({ + _id: 'u1', + email: 'jane@example.com', + fullname: 'No Email', + avatar: '', + }); + + const { error } = await runCommand(['account:add', '--api-token', 'token']); + + expect(error).to.be.undefined; + expect(promptStub.firstCall.args[0].default).to.equal('jane'); + expect( + fsStub.calledWithExactly( + path.join(os.homedir(), '.liara-auth.json'), + expectedFile('jane', 'jane@example.com'), + ), + ).to.be.true; + }); +}); diff --git a/test/auth/login.unit.test.ts b/test/auth/login.unit.test.ts index 4945cdb..6af25c2 100644 --- a/test/auth/login.unit.test.ts +++ b/test/auth/login.unit.test.ts @@ -1,5 +1,6 @@ import { runCommand } from '@oclif/test'; import { expect } from 'chai'; +import nock from 'nock'; import sinon from 'sinon'; import { accounts, currentAccounts } from '../fixtures/accounts/fixture.ts'; import Login from '../../src/commands/login.ts'; @@ -15,7 +16,23 @@ describe('login', async () => { let AccountUseStub: sinon.SinonStub; let AccountAddStub: sinon.SinonStub; + const mockMe = (token: string, user: object) => + nock('https://api.liara.ir') + .get('/v1/me') + .matchHeader('authorization', `Bearer ${token}`) + .reply(200, { user }); + + const meOf = (account: (typeof accounts)[number]) => ({ + _id: 'id', + email: account.email, + fullname: account.fullname, + avatar: account.avatar, + }); + beforeEach(() => { + nock.disableNetConnect(); + mockMe(accounts[0].token, meOf(accounts[0])); + mockMe(accounts[1].token, meOf(accounts[1])); AccountUseStub = sinon.stub(AccountUse.prototype, 'run'); AccountAddStub = sinon.stub(AccountAdd.prototype, 'run'); browserStub = sinon @@ -25,6 +42,8 @@ describe('login', async () => { }); afterEach(() => { + nock.cleanAll(); + nock.enableNetConnect(); sinon.restore(); }); @@ -99,19 +118,84 @@ describe('login', async () => { ).to.be.true; }); it('should pass the current account to account use command', async () => { + browserStub.resolves([accounts[0], accounts[2]]); + mockMe(accounts[2].token, meOf(accounts[2])); sinon .stub(Login.prototype, 'readGlobalConfig') .resolves({ version: '1', accounts: {} }); await runCommand(['login']); - const currentAccount = accounts.find((data) => data.current); - const currentAccountName = `${currentAccount!.email.split('@')[0]}_${currentAccount!.region}`; - - expect(AccountUseStub.calledWithExactly(currentAccountName)); + expect(AccountUseStub.firstCall.thisValue.argv).to.deep.equal([ + '--account', + 'test3', + ]); }); - it('should redirect to console login and not save accounts when an account has no email', async () => { + it('should log in an account without an email under its phone number', async () => { + nock.cleanAll(); + mockMe(accounts[0].token, meOf(accounts[0])); + mockMe(accounts[1].token, { + _id: 'u2', + email: null, + phone: '09120000000', + fullname: accounts[1].fullname, + avatar: accounts[1].avatar, + }); browserStub.resolves([accounts[0], { ...accounts[1], email: null }]); + sinon + .stub(Login.prototype, 'readGlobalConfig') + .resolves({ version: '1', accounts: {} }); + + const { error } = await runCommand(['login']); + + expect(error).to.be.undefined; + const written = JSON.parse(fsStub.firstCall.args[1]); + expect(written.accounts['09120000000']).to.deep.equal({ + email: null, + avatar: accounts[1].avatar, + api_token: accounts[1].token, + fullname: accounts[1].fullname, + current: false, + }); + }); + it('should take the email from v1/me when the console sent an empty one', async () => { + browserStub.resolves([{ ...accounts[1], email: '' }]); + sinon + .stub(Login.prototype, 'readGlobalConfig') + .resolves({ version: '1', accounts: {} }); + + const { error } = await runCommand(['login']); + + expect(error).to.be.undefined; + const written = JSON.parse(fsStub.firstCall.args[1]); + expect(Object.keys(written.accounts)).to.deep.equal(['test2']); + expect(written.accounts.test2.email).to.equal('test2@gmail.com'); + }); + it('should skip an account whose token is rejected', async () => { + nock.cleanAll(); + nock('https://api.liara.ir') + .get('/v1/me') + .matchHeader('authorization', `Bearer ${accounts[0].token}`) + .reply(401, {}); + mockMe(accounts[1].token, meOf(accounts[1])); + browserStub.resolves([{ ...accounts[0], current: true }, accounts[1]]); + sinon + .stub(Login.prototype, 'readGlobalConfig') + .resolves({ version: '1', accounts: {} }); + + const { error, stderr } = await runCommand(['login']); + + expect(error).to.be.undefined; + expect(stderr).to.contain(accounts[0].email); + const written = JSON.parse(fsStub.firstCall.args[1]); + expect(Object.keys(written.accounts)).to.deep.equal(['test2']); + expect(AccountUseStub.firstCall.thisValue.argv).to.deep.equal([ + '--account', + 'test2', + ]); + }); + it('should fail without saving when no account has a valid token', async () => { + browserStub.resolves([{ ...accounts[0], token: '' }]); sinon .stub(Login.prototype, 'readGlobalConfig') .resolves({ version: '1', accounts: {} }); @@ -121,7 +205,6 @@ describe('login', async () => { const { error } = await runCommand(['login']); - expect(error?.message).to.contain('has no email address'); expect(error?.message).to.contain('https://console.liara.ir/login'); expect(openConsoleLoginStub.calledOnce).to.be.true; expect(fsStub.called).to.be.false; From 5748c7e70aaccdb0027e06ee54d2817a505aea81 Mon Sep 17 00:00:00 2001 From: Amin Ehterami Date: Sun, 25 Oct 2026 16:13:37 +0330 Subject: [PATCH 2/2] change error handling --- src/commands/account/add.ts | 39 ++++++++++--------- test/auth/account-add.unit.test.ts | 60 +++++++++++++++++++++++++++++- 2 files changed, 81 insertions(+), 18 deletions(-) diff --git a/src/commands/account/add.ts b/src/commands/account/add.ts index 79a667a..771456e 100644 --- a/src/commands/account/add.ts +++ b/src/commands/account/add.ts @@ -59,6 +59,7 @@ export default class AccountAdd extends Command { const name = flags.account || (await this.promptName(user.email || user.phone || user._id)); + this.addNewAccountToConfig(currentAccounts, { name, ...user, @@ -104,8 +105,10 @@ export default class AccountAdd extends Command { accountName && this.log(`> Current account is: ${accountName}`); } async checkIfExists(body: { email: string }, debug: DebugLogger) { + let data; + try { - const data = await this.got + data = await this.got .post('v1/login/check-if-exists', { json: body, headers: { Authorization: undefined }, @@ -116,26 +119,28 @@ export default class AccountAdd extends Command { socialCompleted: boolean; twoFAEnabled: boolean; }>(); - - if (!data.exists) { - this.log(); - this.error( - `The email you entered isn’t registered. -To continue, please sign up at: https://console.liara.ir`, - ); - } - if (!data.socialCompleted) { - this.log(); - this.error(`No password is set for this account. -Set one here: https://console.liara.ir/settings/security -Then run 'liara login' or 'liara account:add' again.`); - } - return data; } catch (error) { debug(error); this.error(`Please check your internet connection and try again. If the issue persists, please submit a ticket at https://console.liara.ir/tickets for further assistance.`); } + + // Outside the try, which would replace these messages with the one above. + if (!data.exists) { + this.log(); + this.error( + `The email you entered isn’t registered. +To continue, please sign up at: https://console.liara.ir`, + ); + } + + if (!data.socialCompleted) { + this.log(); + this.error(`No password is set for this account. +Set one here: https://console.liara.ir/settings/security +Then run 'liara login' or 'liara account:add' again.`); + } + return data; } async login(body: { @@ -154,7 +159,7 @@ If the issue persists, please submit a ticket at https://console.liara.ir/ticket .json(); return { - email: data.email, + email: body.email, api_token: data.api_token!, fullname: data.fullname, current: data.current, diff --git a/test/auth/account-add.unit.test.ts b/test/auth/account-add.unit.test.ts index a2ee712..9574291 100644 --- a/test/auth/account-add.unit.test.ts +++ b/test/auth/account-add.unit.test.ts @@ -8,7 +8,7 @@ import path from 'path'; import os from 'node:os'; import AccountAdd from '../../src/commands/account/add.ts'; -describe('account:add --api-token', async () => { +describe('account:add', async () => { let fsStub: sinon.SinonStub; let promptStub: sinon.SinonStub; @@ -18,6 +18,26 @@ describe('account:add --api-token', async () => { .matchHeader('authorization', 'Bearer token') .reply(200, { user }); + const mockCheckIfExists = (status: object = {}) => + nock('https://api.liara.ir') + .post('/v1/login/check-if-exists', { email: 'jane@example.com' }) + .reply(200, { + status: 'success', + exists: true, + socialCompleted: true, + twoFAEnabled: false, + ...status, + }); + + const passwordLogin = () => + runCommand([ + 'account:add', + '--email', + 'jane@example.com', + '--password', + 'secret', + ]); + const expectedFile = (name: string, email: string | null) => JSON.stringify({ accounts: { @@ -89,4 +109,42 @@ describe('account:add --api-token', async () => { ), ).to.be.true; }); + + it('should save the email of a password login, which the login response leaves out', async () => { + mockCheckIfExists(); + nock('https://api.liara.ir') + .post('/v1/login', { email: 'jane@example.com', password: 'secret' }) + .query({ expiresIn: '1y' }) + .reply(200, { api_token: 'token', fullname: 'No Email', avatar: '' }); + + const { error } = await passwordLogin(); + + expect(error).to.be.undefined; + expect( + fsStub.calledWithExactly( + path.join(os.homedir(), '.liara-auth.json'), + expectedFile('jane', 'jane@example.com'), + ), + ).to.be.true; + }); + + it("should say when the email isn't registered", async () => { + mockCheckIfExists({ exists: false, socialCompleted: false }); + + const { error } = await passwordLogin(); + + expect(error?.message).to.contain( + 'The email you entered isn’t registered.', + ); + expect(fsStub.called).to.be.false; + }); + + it('should say when the account has no password, such as an OAuth sign-up', async () => { + mockCheckIfExists({ socialCompleted: false }); + + const { error } = await passwordLogin(); + + expect(error?.message).to.contain('No password is set for this account.'); + expect(fsStub.called).to.be.false; + }); });