-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add login and logout command #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
2597eea
0771e7f
5d4863f
ad8003c
8e586be
72aa7d1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,115 @@ | ||
| import { describe, it, expect, vi, beforeEach } from "vitest"; | ||
| import { Command } from "commander"; | ||
| import { registerLoginCommand } from "./login"; | ||
| import { AccountService } from "../api/client/services/AccountService"; | ||
|
|
||
| vi.mock("../api/client/services/AccountService"); | ||
| vi.mock("../utils/credentials", () => ({ | ||
| saveCredentials: vi.fn(), | ||
| getCredentialsPath: vi.fn(() => "/home/test/.codacy/credentials"), | ||
| promptForToken: vi.fn(), | ||
| })); | ||
|
|
||
| vi.spyOn(console, "log").mockImplementation(() => {}); | ||
| vi.spyOn(console, "error").mockImplementation(() => {}); | ||
|
|
||
| import { saveCredentials, promptForToken } from "../utils/credentials"; | ||
|
|
||
| function createProgram(): Command { | ||
| const program = new Command(); | ||
| program.exitOverride(); | ||
| registerLoginCommand(program); | ||
| return program; | ||
| } | ||
|
|
||
| const mockUser = { | ||
| id: 1, | ||
| name: "Test User", | ||
| mainEmail: "test@example.com", | ||
| otherEmails: [], | ||
| isAdmin: false, | ||
| isActive: true, | ||
| created: "2024-01-01", | ||
| }; | ||
|
|
||
| describe("login command", () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| }); | ||
|
|
||
| it("should validate and store token via --token flag", async () => { | ||
| vi.mocked(AccountService.getUser).mockResolvedValue({ data: mockUser }); | ||
|
|
||
| const program = createProgram(); | ||
| await program.parseAsync(["node", "test", "login", "--token", "my-token"]); | ||
|
|
||
| expect(AccountService.getUser).toHaveBeenCalledOnce(); | ||
| expect(saveCredentials).toHaveBeenCalledWith("my-token"); | ||
| expect(console.log).toHaveBeenCalledWith( | ||
| expect.stringContaining("Token stored at"), | ||
| ); | ||
| }); | ||
|
|
||
| it("should show error on invalid token (401)", async () => { | ||
| const apiError = new Error("Unauthorized"); | ||
| (apiError as any).status = 401; | ||
| vi.mocked(AccountService.getUser).mockRejectedValue(apiError); | ||
|
|
||
| const mockExit = vi.spyOn(process, "exit").mockImplementation(() => { | ||
| throw new Error("process.exit called"); | ||
| }); | ||
|
|
||
| const program = createProgram(); | ||
| await expect( | ||
| program.parseAsync(["node", "test", "login", "--token", "bad-token"]), | ||
| ).rejects.toThrow("process.exit called"); | ||
|
|
||
| expect(saveCredentials).not.toHaveBeenCalled(); | ||
| mockExit.mockRestore(); | ||
| }); | ||
|
|
||
| it("should show network error when API is unreachable", async () => { | ||
| vi.mocked(AccountService.getUser).mockRejectedValue( | ||
| new Error("fetch failed"), | ||
| ); | ||
|
|
||
| const mockExit = vi.spyOn(process, "exit").mockImplementation(() => { | ||
| throw new Error("process.exit called"); | ||
| }); | ||
|
|
||
| const program = createProgram(); | ||
| await expect( | ||
| program.parseAsync(["node", "test", "login", "--token", "some-token"]), | ||
| ).rejects.toThrow("process.exit called"); | ||
|
|
||
| expect(saveCredentials).not.toHaveBeenCalled(); | ||
| mockExit.mockRestore(); | ||
| }); | ||
|
|
||
| it("should use interactive prompt when no --token flag", async () => { | ||
| vi.mocked(promptForToken).mockResolvedValue("prompted-token"); | ||
| vi.mocked(AccountService.getUser).mockResolvedValue({ data: mockUser }); | ||
|
|
||
| const program = createProgram(); | ||
| await program.parseAsync(["node", "test", "login"]); | ||
|
|
||
| expect(promptForToken).toHaveBeenCalledWith("API Token: "); | ||
| expect(saveCredentials).toHaveBeenCalledWith("prompted-token"); | ||
| }); | ||
|
|
||
| it("should reject empty token from interactive prompt", async () => { | ||
| vi.mocked(promptForToken).mockResolvedValue(" "); | ||
|
|
||
| const mockExit = vi.spyOn(process, "exit").mockImplementation(() => { | ||
| throw new Error("process.exit called"); | ||
| }); | ||
|
|
||
| const program = createProgram(); | ||
| await expect( | ||
| program.parseAsync(["node", "test", "login"]), | ||
| ).rejects.toThrow("process.exit called"); | ||
|
|
||
| expect(saveCredentials).not.toHaveBeenCalled(); | ||
| mockExit.mockRestore(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| import { Command } from "commander"; | ||
| import ansis from "ansis"; | ||
| import ora from "ora"; | ||
| import { OpenAPI } from "../api/client/core/OpenAPI"; | ||
| import { AccountService } from "../api/client/services/AccountService"; | ||
| import { handleError } from "../utils/error"; | ||
| import { | ||
| saveCredentials, | ||
| getCredentialsPath, | ||
| promptForToken, | ||
| } from "../utils/credentials"; | ||
|
|
||
| export function registerLoginCommand(program: Command) { | ||
| program | ||
| .command("login") | ||
|
|
||
| .description("Authenticate with Codacy by storing your API token") | ||
| .option("-t, --token <token>", "API token (skips interactive prompt)") | ||
| .addHelpText( | ||
| "after", | ||
| ` | ||
| Examples: | ||
| $ codacy login | ||
| $ codacy login --token <your-api-token> | ||
|
|
||
| Get your token at: https://app.codacy.com/account/access-management | ||
| My Account > Access Management > API Tokens`, | ||
| ) | ||
| .action(async (options) => { | ||
| try { | ||
| let token: string; | ||
|
|
||
| if (options.token) { | ||
| token = String(options.token).trim(); | ||
|
|
||
| if (!token) { | ||
| console.error(ansis.red("Error: Token cannot be empty.")); | ||
| process.exit(1); | ||
| } | ||
| } else { | ||
| console.log(ansis.bold("\nCodacy Login\n")); | ||
| console.log("You need an Account API Token to authenticate."); | ||
| console.log( | ||
| `Get one at: ${ansis.cyan("https://app.codacy.com/account/access-management")}`, | ||
| ); | ||
| console.log( | ||
| ansis.dim(" My Account > Access Management > API Tokens\n"), | ||
| ); | ||
|
|
||
| token = await promptForToken("API Token: "); | ||
|
|
||
| if (!token.trim()) { | ||
| console.error(ansis.red("Error: Token cannot be empty.")); | ||
| process.exit(1); | ||
| } | ||
|
Comment on lines
+32
to
+52
|
||
|
|
||
| token = token.trim(); | ||
| } | ||
|
|
||
| const spinner = ora("Validating token...").start(); | ||
|
|
||
| OpenAPI.HEADERS = { | ||
| "api-token": token, | ||
| "X-Codacy-Origin": "cli-cloud-tool", | ||
| }; | ||
|
||
|
|
||
| let userName: string; | ||
| let userEmail: string; | ||
| try { | ||
| const response = await AccountService.getUser(); | ||
| userName = response.data.name || "Unknown"; | ||
| userEmail = response.data.mainEmail; | ||
| } catch (apiErr: any) { | ||
| spinner.fail("Authentication failed."); | ||
| if (apiErr?.status === 401) { | ||
| throw new Error( | ||
| "Invalid API token. Check that it is correct and not expired.", | ||
| ); | ||
| } | ||
|
alerizzo marked this conversation as resolved.
|
||
| if (typeof apiErr?.status === "number") { | ||
| throw new Error( | ||
| `Codacy API returned an error (status ${apiErr.status}). Please try again or check your permissions.`, | ||
| ); | ||
| } | ||
| throw new Error( | ||
| "Could not reach the Codacy API. Check your network connection.", | ||
|
alerizzo marked this conversation as resolved.
|
||
| ); | ||
| } | ||
|
|
||
| saveCredentials(token); | ||
| spinner.succeed(`Logged in as ${ansis.bold(userName)} (${userEmail})`); | ||
| console.log(ansis.dim(` Token stored at ${getCredentialsPath()}`)); | ||
| } catch (err) { | ||
| handleError(err); | ||
| } | ||
| }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| import { describe, it, expect, vi, beforeEach } from "vitest"; | ||
| import { Command } from "commander"; | ||
| import { registerLogoutCommand } from "./logout"; | ||
|
|
||
| vi.mock("../utils/credentials", () => ({ | ||
| deleteCredentials: vi.fn(), | ||
| getCredentialsPath: vi.fn(() => "/home/test/.codacy/credentials"), | ||
| })); | ||
|
|
||
| vi.spyOn(console, "log").mockImplementation(() => {}); | ||
|
|
||
| import { deleteCredentials } from "../utils/credentials"; | ||
|
|
||
| function createProgram(): Command { | ||
| const program = new Command(); | ||
| registerLogoutCommand(program); | ||
| return program; | ||
| } | ||
|
|
||
| describe("logout command", () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| }); | ||
|
|
||
| it("should delete credentials and show confirmation", async () => { | ||
| vi.mocked(deleteCredentials).mockReturnValue(true); | ||
|
|
||
| const program = createProgram(); | ||
| await program.parseAsync(["node", "test", "logout"]); | ||
|
|
||
| expect(deleteCredentials).toHaveBeenCalledOnce(); | ||
| expect(console.log).toHaveBeenCalledWith( | ||
| expect.stringContaining("Logged out"), | ||
| ); | ||
| }); | ||
|
|
||
| it("should show message when no credentials exist", async () => { | ||
| vi.mocked(deleteCredentials).mockReturnValue(false); | ||
|
|
||
| const program = createProgram(); | ||
| await program.parseAsync(["node", "test", "logout"]); | ||
|
|
||
| expect(deleteCredentials).toHaveBeenCalledOnce(); | ||
| expect(console.log).toHaveBeenCalledWith("No stored credentials found."); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| import { Command } from "commander"; | ||
| import ansis from "ansis"; | ||
| import { | ||
| deleteCredentials, | ||
| getCredentialsPath, | ||
| } from "../utils/credentials"; | ||
| import { handleError } from "../utils/error"; | ||
|
|
||
| export function registerLogoutCommand(program: Command) { | ||
| program | ||
| .command("logout") | ||
|
|
||
| .description("Remove stored Codacy API token") | ||
| .addHelpText( | ||
| "after", | ||
| ` | ||
| Examples: | ||
| $ codacy logout`, | ||
| ) | ||
| .action(() => { | ||
| try { | ||
| const deleted = deleteCredentials(); | ||
| if (deleted) { | ||
| console.log( | ||
| ansis.green("Logged out.") + | ||
| ansis.dim(` Removed ${getCredentialsPath()}`), | ||
| ); | ||
| } else { | ||
| console.log("No stored credentials found."); | ||
| } | ||
| } catch (err) { | ||
| handleError(err); | ||
| } | ||
| }); | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.