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