From 94737f58242b032b57373f83d429fb3c62dbf520 Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Wed, 14 Oct 2026 07:27:04 -0400 Subject: [PATCH] test(renderer): add unit tests for the login validation utils utils/auth/loginValidation.ts was the only utils module without a colocated test; its pure functions were only exercised from route and component test files. - Add utils/auth/loginValidation.test.ts covering validatePersonalAccessTokenForm (incl. per-forge hostname rules and Bitbucket/GitLab/Gitea cases), validateOAuthAppForm, and summarizeLoginError - Move the pure validator/summary cases out of the route and form tests, so those files keep only their rendering/integration coverage - Delete the PAT form test file, whose only content (summarizeLoginError) moved to the new module test --- .../LoginWithPersonalAccessTokenForm.test.tsx | 28 --- .../LoginWithPersonalAccessToken.test.tsx | 30 --- .../LoginWithPersonalAccessToken.test.tsx | 76 +------ .../routes/github/LoginWithOAuthApp.test.tsx | 29 --- .../LoginWithPersonalAccessToken.test.tsx | 27 +-- .../LoginWithPersonalAccessToken.test.tsx | 35 --- .../utils/auth/loginValidation.test.ts | 206 ++++++++++++++++++ 7 files changed, 208 insertions(+), 223 deletions(-) delete mode 100644 src/renderer/components/login/LoginWithPersonalAccessTokenForm.test.tsx create mode 100644 src/renderer/utils/auth/loginValidation.test.ts diff --git a/src/renderer/components/login/LoginWithPersonalAccessTokenForm.test.tsx b/src/renderer/components/login/LoginWithPersonalAccessTokenForm.test.tsx deleted file mode 100644 index 12b787598..000000000 --- a/src/renderer/components/login/LoginWithPersonalAccessTokenForm.test.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { summarizeLoginError } from '../../utils/auth/loginValidation'; - -describe('renderer/components/login/LoginWithPersonalAccessTokenForm.tsx', () => { - describe('summarizeLoginError', () => { - it('returns the error message', () => { - expect(summarizeLoginError(new Error('GitLab API 403'))).toBe('GitLab API 403'); - }); - - it('keeps only the first line so an echoed request body never reaches the banner', () => { - expect(summarizeLoginError(new Error('GitLab API 403\nPRIVATE-TOKEN: leaked-pat'))).toBe( - 'GitLab API 403', - ); - }); - - it('collapses whitespace and caps the length', () => { - const summary = summarizeLoginError(new Error(`Bad ${'x'.repeat(300)}`)); - - expect(summary).toHaveLength(150); - expect(summary?.startsWith('Bad x')).toBe(true); - }); - - it('returns undefined for non-errors and empty messages', () => { - expect(summarizeLoginError(null)).toBeUndefined(); - expect(summarizeLoginError('bad credentials')).toBeUndefined(); - expect(summarizeLoginError(new Error(' '))).toBeUndefined(); - }); - }); -}); diff --git a/src/renderer/routes/bitbucket/LoginWithPersonalAccessToken.test.tsx b/src/renderer/routes/bitbucket/LoginWithPersonalAccessToken.test.tsx index 354c7cc3b..a41156c67 100644 --- a/src/renderer/routes/bitbucket/LoginWithPersonalAccessToken.test.tsx +++ b/src/renderer/routes/bitbucket/LoginWithPersonalAccessToken.test.tsx @@ -4,12 +4,6 @@ import userEvent from '@testing-library/user-event'; import { navigateMock, renderWithProviders } from '../../__helpers__/test-utils'; import { mockBitbucketAccount } from '../../__mocks__/account-mocks'; -import type { Hostname, Token } from '../../types'; - -import { - type PersonalAccessTokenFormData, - validatePersonalAccessTokenForm, -} from '../../utils/auth/loginValidation'; import * as comms from '../../utils/system/comms'; import { BitbucketLoginWithPersonalAccessTokenRoute } from './LoginWithPersonalAccessToken'; @@ -23,30 +17,6 @@ describe('renderer/routes/bitbucket/LoginWithPersonalAccessToken.tsx', () => { expect(tree.container).toMatchSnapshot(); }); - describe('form validation', () => { - it('requires username (Atlassian email) for bitbucket', () => { - const values: PersonalAccessTokenFormData = { - hostname: 'bitbucket.org' as Hostname, - token: 'my-api-token' as Token, - username: '', - }; - - expect(validatePersonalAccessTokenForm(values, 'bitbucket').username).toBe( - 'Atlassian email is required', - ); - }); - - it('passes validation with username and token', () => { - const values: PersonalAccessTokenFormData = { - hostname: 'bitbucket.org' as Hostname, - token: 'my-api-token' as Token, - username: 'user@example.com', - }; - - expect(validatePersonalAccessTokenForm(values, 'bitbucket')).toEqual({}); - }); - }); - it('renders the username field', () => { renderWithProviders(); expect(screen.getByTestId('login-username')).toBeInTheDocument(); diff --git a/src/renderer/routes/gitea/LoginWithPersonalAccessToken.test.tsx b/src/renderer/routes/gitea/LoginWithPersonalAccessToken.test.tsx index 053957c1c..5d0983bec 100644 --- a/src/renderer/routes/gitea/LoginWithPersonalAccessToken.test.tsx +++ b/src/renderer/routes/gitea/LoginWithPersonalAccessToken.test.tsx @@ -4,12 +4,8 @@ import userEvent from '@testing-library/user-event'; import { navigateMock, renderWithProviders } from '../../__helpers__/test-utils'; import { mockGiteaAccount } from '../../__mocks__/account-mocks'; -import type { Forge, Hostname, Token } from '../../types'; +import type { Hostname } from '../../types'; -import { - type PersonalAccessTokenFormData, - validatePersonalAccessTokenForm, -} from '../../utils/auth/loginValidation'; import * as comms from '../../utils/system/comms'; import { GiteaLoginWithPersonalAccessTokenRoute } from './LoginWithPersonalAccessToken'; @@ -34,76 +30,6 @@ describe('renderer/routes/gitea/LoginWithPersonalAccessToken.tsx', () => { expect(screen.getByText('Hostname format is invalid')).toBeInTheDocument(); }); - describe('form validation', () => { - it.each(['gitea.example.com:0', 'gitea.example.com:65536', 'gitea.example.com:https'])( - 'rejects invalid port in %s', - (hostname) => { - expect( - validatePersonalAccessTokenForm( - { - hostname: hostname as Hostname, - token: 'abcdef1234567890abcdef1234567890abcdef12' as Token, - }, - 'gitea', - ).hostname, - ).toBe('Hostname format is invalid'); - }, - ); - - it.each(['github', 'gitlab', 'bitbucket'])('keeps ports invalid for %s', (forge) => { - expect( - validatePersonalAccessTokenForm( - { - hostname: 'git.example.com:3000' as Hostname, - token: 'abcdef1234567890abcdef1234567890abcdef12' as Token, - }, - forge, - ).hostname, - ).toBe('Hostname format is invalid'); - }); - - it.each(['http://git.internal', 'http://git.internal:3000', 'gitea.example.com:3000'])( - 'accepts Gitea origin %s', - (hostname) => { - expect( - validatePersonalAccessTokenForm( - { hostname: hostname as Hostname, token: 'a'.repeat(40) as Token }, - 'gitea', - ), - ).toEqual({}); - }, - ); - - it.each(['github', 'gitlab', 'bitbucket'])('keeps %s hostnames HTTPS-only', (forge) => { - expect( - validatePersonalAccessTokenForm( - { hostname: 'http://git.internal:3000' as Hostname, token: '' as Token }, - forge, - ).hostname, - ).toBe('Hostname format is invalid'); - }); - - it('should validate the token matches the Gitea format', () => { - const values: PersonalAccessTokenFormData = { - hostname: 'gitea.example.com' as Hostname, - token: 'abcdef1234567890abcdef1234567890abcdef12' as Token, - }; - - expect(validatePersonalAccessTokenForm(values, 'gitea')).toEqual({}); - }); - - it('should reject tokens that are not 40-character lowercase hex', () => { - const values: PersonalAccessTokenFormData = { - hostname: 'gitea.example.com' as Hostname, - token: 'GHT_INVALID_TOKEN' as Token, - }; - - expect(validatePersonalAccessTokenForm(values, 'gitea').token).toBe( - 'Token format is invalid', - ); - }); - }); - it.each([ ['gitea.example.com', 'https://gitea.example.com'], ['gitea.example.com:3000', 'https://gitea.example.com:3000'], diff --git a/src/renderer/routes/github/LoginWithOAuthApp.test.tsx b/src/renderer/routes/github/LoginWithOAuthApp.test.tsx index 40dc76642..c6aa28793 100644 --- a/src/renderer/routes/github/LoginWithOAuthApp.test.tsx +++ b/src/renderer/routes/github/LoginWithOAuthApp.test.tsx @@ -3,9 +3,6 @@ import userEvent from '@testing-library/user-event'; import { navigateMock, renderWithProviders } from '../../__helpers__/test-utils'; -import type { ClientID, ClientSecret, Hostname } from '../../types'; - -import { type OAuthAppFormData, validateOAuthAppForm } from '../../utils/auth/loginValidation'; import * as logger from '../../utils/core/logger'; import * as comms from '../../utils/system/comms'; import { GitHubLoginWithOAuthAppRoute } from './LoginWithOAuthApp'; @@ -30,32 +27,6 @@ describe('renderer/routes/github/LoginWithOAuthApp.tsx', () => { expect(navigateMock).toHaveBeenCalledWith(-1); }); - describe('form validation', () => { - it('should validate the form values are not empty', () => { - const values: OAuthAppFormData = { - hostname: null as unknown as Hostname, - clientId: null as unknown as ClientID, - clientSecret: null as unknown as ClientSecret, - }; - - expect(validateOAuthAppForm(values).hostname).toBe('Hostname is required'); - expect(validateOAuthAppForm(values).clientId).toBe('Client ID is required'); - expect(validateOAuthAppForm(values).clientSecret).toBe('Client Secret is required'); - }); - - it('should validate the form values are correct format', () => { - const values: OAuthAppFormData = { - hostname: 'hello' as Hostname, - clientId: '!@£INVALID-.1' as ClientID, - clientSecret: '!@£INVALID-.1' as ClientSecret, - }; - - expect(validateOAuthAppForm(values).hostname).toBe('Hostname format is invalid'); - expect(validateOAuthAppForm(values).clientId).toBe('Client ID format is invalid'); - expect(validateOAuthAppForm(values).clientSecret).toBe('Client Secret format is invalid'); - }); - }); - describe("'Create new OAuth App' button", () => { it('should be disabled if no hostname configured', async () => { renderWithProviders(); diff --git a/src/renderer/routes/github/LoginWithPersonalAccessToken.test.tsx b/src/renderer/routes/github/LoginWithPersonalAccessToken.test.tsx index d43db74e9..fbad45ffb 100644 --- a/src/renderer/routes/github/LoginWithPersonalAccessToken.test.tsx +++ b/src/renderer/routes/github/LoginWithPersonalAccessToken.test.tsx @@ -4,12 +4,8 @@ import userEvent from '@testing-library/user-event'; import { navigateMock, renderWithProviders } from '../../__helpers__/test-utils'; import { mockPersonalAccessTokenAccount } from '../../__mocks__/account-mocks'; -import type { Hostname, Token } from '../../types'; +import type { Hostname } from '../../types'; -import { - type PersonalAccessTokenFormData, - validatePersonalAccessTokenForm, -} from '../../utils/auth/loginValidation'; import * as logger from '../../utils/core/logger'; import * as comms from '../../utils/system/comms'; import { GitHubLoginWithPersonalAccessTokenRoute } from './LoginWithPersonalAccessToken'; @@ -33,27 +29,6 @@ describe('renderer/routes/github/LoginWithPersonalAccessToken.tsx', () => { expect(navigateMock).toHaveBeenCalledWith(-1); }); - describe('form validation', () => { - it('should validate the form values are not empty', () => { - const values: PersonalAccessTokenFormData = { - hostname: null as unknown as Hostname, - token: null as unknown as Token, - }; - expect(validatePersonalAccessTokenForm(values).hostname).toBe('Hostname is required'); - expect(validatePersonalAccessTokenForm(values).token).toBe('Token is required'); - }); - - it('should validate the form values are correct format', () => { - const values: PersonalAccessTokenFormData = { - hostname: 'hello' as Hostname, - token: '!@£INVALID-.1' as Token, - }; - - expect(validatePersonalAccessTokenForm(values).hostname).toBe('Hostname format is invalid'); - expect(validatePersonalAccessTokenForm(values).token).toBe('Token format is invalid'); - }); - }); - describe("'Generate a PAT' button", () => { it('should be disabled if no hostname configured', async () => { renderWithProviders(, { diff --git a/src/renderer/routes/gitlab/LoginWithPersonalAccessToken.test.tsx b/src/renderer/routes/gitlab/LoginWithPersonalAccessToken.test.tsx index c52329ae2..bb446afd4 100644 --- a/src/renderer/routes/gitlab/LoginWithPersonalAccessToken.test.tsx +++ b/src/renderer/routes/gitlab/LoginWithPersonalAccessToken.test.tsx @@ -6,10 +6,6 @@ import { mockGitLabAccount } from '../../__mocks__/account-mocks'; import type { Hostname, Token } from '../../types'; -import { - type PersonalAccessTokenFormData, - validatePersonalAccessTokenForm, -} from '../../utils/auth/loginValidation'; import * as comms from '../../utils/system/comms'; import { GitLabLoginWithPersonalAccessTokenRoute } from './LoginWithPersonalAccessToken'; @@ -31,37 +27,6 @@ describe('renderer/routes/gitlab/LoginWithPersonalAccessToken.tsx', () => { expect(screen.getByTestId('login-hostname')).toHaveValue('gitlab.com'); }); - describe('form validation', () => { - it('accepts the modern routing-prefixed token format', () => { - const values: PersonalAccessTokenFormData = { - hostname: 'gitlab.com' as Hostname, - token: MODERN_TOKEN, - }; - - expect(validatePersonalAccessTokenForm(values, 'gitlab')).toEqual({}); - }); - - it('rejects an empty token', () => { - const values: PersonalAccessTokenFormData = { - hostname: 'gitlab.com' as Hostname, - token: '' as Token, - }; - - expect(validatePersonalAccessTokenForm(values, 'gitlab').token).toBe('Token is required'); - }); - - it('rejects an invalid hostname', () => { - const values: PersonalAccessTokenFormData = { - hostname: 'not a hostname' as Hostname, - token: MODERN_TOKEN, - }; - - expect(validatePersonalAccessTokenForm(values, 'gitlab').hostname).toBe( - 'Hostname format is invalid', - ); - }); - }); - it('should open the token settings for the configured hostname', async () => { renderWithProviders(, { loginWithPersonalAccessToken: loginWithPersonalAccessTokenMock, diff --git a/src/renderer/utils/auth/loginValidation.test.ts b/src/renderer/utils/auth/loginValidation.test.ts new file mode 100644 index 000000000..6e4e136cc --- /dev/null +++ b/src/renderer/utils/auth/loginValidation.test.ts @@ -0,0 +1,206 @@ +import type { ClientID, ClientSecret, Forge, Hostname, Token } from '../../types'; + +import { + type OAuthAppFormData, + type PersonalAccessTokenFormData, + summarizeLoginError, + validateOAuthAppForm, + validatePersonalAccessTokenForm, +} from './loginValidation'; + +const GITLAB_MODERN_TOKEN = + 'glpat-cEj977iancWuTGoHQbbpGWM6MQpvOjEKdToxcDBrNw8.01.1711l2dhw' as Token; +const GITEA_TOKEN = 'abcdef1234567890abcdef1234567890abcdef12' as Token; + +describe('renderer/utils/auth/loginValidation.ts', () => { + describe('validatePersonalAccessTokenForm', () => { + it('reports missing hostname and token', () => { + const values: PersonalAccessTokenFormData = { + hostname: null as unknown as Hostname, + token: null as unknown as Token, + }; + + expect(validatePersonalAccessTokenForm(values).hostname).toBe('Hostname is required'); + expect(validatePersonalAccessTokenForm(values).token).toBe('Token is required'); + }); + + it('reports invalid hostname and token formats', () => { + const values: PersonalAccessTokenFormData = { + hostname: 'hello' as Hostname, + token: '!@£INVALID-.1' as Token, + }; + + expect(validatePersonalAccessTokenForm(values).hostname).toBe('Hostname format is invalid'); + expect(validatePersonalAccessTokenForm(values).token).toBe('Token format is invalid'); + }); + + it('requires a username (Atlassian email) for Bitbucket', () => { + const values: PersonalAccessTokenFormData = { + hostname: 'bitbucket.org' as Hostname, + token: 'my-api-token' as Token, + username: '', + }; + + expect(validatePersonalAccessTokenForm(values, 'bitbucket').username).toBe( + 'Atlassian email is required', + ); + }); + + it('passes for Bitbucket with a username and token', () => { + const values: PersonalAccessTokenFormData = { + hostname: 'bitbucket.org' as Hostname, + token: 'my-api-token' as Token, + username: 'user@example.com', + }; + + expect(validatePersonalAccessTokenForm(values, 'bitbucket')).toEqual({}); + }); + + it('accepts the modern GitLab routing-prefixed token format', () => { + const values: PersonalAccessTokenFormData = { + hostname: 'gitlab.com' as Hostname, + token: GITLAB_MODERN_TOKEN, + }; + + expect(validatePersonalAccessTokenForm(values, 'gitlab')).toEqual({}); + }); + + it('rejects an empty GitLab token', () => { + const values: PersonalAccessTokenFormData = { + hostname: 'gitlab.com' as Hostname, + token: '' as Token, + }; + + expect(validatePersonalAccessTokenForm(values, 'gitlab').token).toBe('Token is required'); + }); + + it('rejects an invalid GitLab hostname', () => { + const values: PersonalAccessTokenFormData = { + hostname: 'not a hostname' as Hostname, + token: GITLAB_MODERN_TOKEN, + }; + + expect(validatePersonalAccessTokenForm(values, 'gitlab').hostname).toBe( + 'Hostname format is invalid', + ); + }); + + it('accepts a Gitea 40-character lowercase-hex token', () => { + const values: PersonalAccessTokenFormData = { + hostname: 'gitea.example.com' as Hostname, + token: GITEA_TOKEN, + }; + + expect(validatePersonalAccessTokenForm(values, 'gitea')).toEqual({}); + }); + + it('rejects a Gitea token that is not 40-character lowercase hex', () => { + const values: PersonalAccessTokenFormData = { + hostname: 'gitea.example.com' as Hostname, + token: 'GHT_INVALID_TOKEN' as Token, + }; + + expect(validatePersonalAccessTokenForm(values, 'gitea').token).toBe( + 'Token format is invalid', + ); + }); + + describe('hostname rules per forge', () => { + it.each(['gitea.example.com:0', 'gitea.example.com:65536', 'gitea.example.com:https'])( + 'rejects an invalid Gitea port in %s', + (hostname) => { + expect( + validatePersonalAccessTokenForm( + { hostname: hostname as Hostname, token: GITEA_TOKEN }, + 'gitea', + ).hostname, + ).toBe('Hostname format is invalid'); + }, + ); + + it.each(['github', 'gitlab', 'bitbucket'])('keeps ports invalid for %s', (forge) => { + expect( + validatePersonalAccessTokenForm( + { hostname: 'git.example.com:3000' as Hostname, token: GITEA_TOKEN }, + forge, + ).hostname, + ).toBe('Hostname format is invalid'); + }); + + it.each(['http://git.internal', 'http://git.internal:3000', 'gitea.example.com:3000'])( + 'accepts a Gitea origin %s', + (hostname) => { + expect( + validatePersonalAccessTokenForm( + { hostname: hostname as Hostname, token: GITEA_TOKEN }, + 'gitea', + ), + ).toEqual({}); + }, + ); + + it.each(['github', 'gitlab', 'bitbucket'])( + 'keeps %s hostnames HTTPS-only', + (forge) => { + expect( + validatePersonalAccessTokenForm( + { hostname: 'http://git.internal:3000' as Hostname, token: '' as Token }, + forge, + ).hostname, + ).toBe('Hostname format is invalid'); + }, + ); + }); + }); + + describe('validateOAuthAppForm', () => { + it('reports missing hostname, client ID, and client secret', () => { + const values: OAuthAppFormData = { + hostname: null as unknown as Hostname, + clientId: null as unknown as ClientID, + clientSecret: null as unknown as ClientSecret, + }; + + expect(validateOAuthAppForm(values).hostname).toBe('Hostname is required'); + expect(validateOAuthAppForm(values).clientId).toBe('Client ID is required'); + expect(validateOAuthAppForm(values).clientSecret).toBe('Client Secret is required'); + }); + + it('reports invalid hostname, client ID, and client secret formats', () => { + const values: OAuthAppFormData = { + hostname: 'hello' as Hostname, + clientId: '!@£INVALID-.1' as ClientID, + clientSecret: '!@£INVALID-.1' as ClientSecret, + }; + + expect(validateOAuthAppForm(values).hostname).toBe('Hostname format is invalid'); + expect(validateOAuthAppForm(values).clientId).toBe('Client ID format is invalid'); + expect(validateOAuthAppForm(values).clientSecret).toBe('Client Secret format is invalid'); + }); + }); + + describe('summarizeLoginError', () => { + it('returns the error message', () => { + expect(summarizeLoginError(new Error('GitLab API 403'))).toBe('GitLab API 403'); + }); + + it('keeps only the first line so an echoed request body never reaches the banner', () => { + expect(summarizeLoginError(new Error('GitLab API 403\nPRIVATE-TOKEN: leaked-pat'))).toBe( + 'GitLab API 403', + ); + }); + + it('collapses whitespace and caps the length', () => { + const summary = summarizeLoginError(new Error(`Bad ${'x'.repeat(300)}`)); + + expect(summary).toHaveLength(150); + expect(summary?.startsWith('Bad x')).toBe(true); + }); + + it('returns undefined for non-errors and empty messages', () => { + expect(summarizeLoginError(null)).toBeUndefined(); + expect(summarizeLoginError('bad credentials')).toBeUndefined(); + expect(summarizeLoginError(new Error(' '))).toBeUndefined(); + }); + }); +});