Skip to content

Commit 32d6d7b

Browse files
authored
feat(gitea): allow explicit HTTP instance origins (#3345)
1 parent f7763e7 commit 32d6d7b

23 files changed

Lines changed: 454 additions & 77 deletions

‎.github/workflows/test.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ jobs:
8383
with:
8484
name: visual-regression-diffs
8585
path: .vitest-attachments/
86+
include-hidden-files: true
8687
if-no-files-found: ignore
8788

8889
sonarqube:

‎src/renderer/components/login/LoginWithPersonalAccessTokenForm.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ export const LoginWithPersonalAccessTokenForm: FC<LoginWithPersonalAccessTokenFo
257257
<Stack align="center" direction="horizontal" gap="condensed">
258258
<Button
259259
data-testid="login-create-token"
260-
disabled={!formData.hostname}
260+
disabled={!adapter.validateHostname(formData.hostname)}
261261
leadingVisual={KeyIcon}
262262
onClick={() =>
263263
openExternalLink(adapter.getPersonalAccessTokenSettingsUrl(formData.hostname))

‎src/renderer/routes/Accounts.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ export const AccountsRoute: FC = () => {
181181
data-testid="account-host"
182182
direction="horizontal"
183183
gap="condensed"
184-
onClick={() => openHost(account.hostname)}
184+
onClick={() => openHost(account)}
185185
title="Open host ↗"
186186
>
187187
{PlatformIcon && <PlatformIcon />}
3.82 KB
Loading
3.96 KB
Loading

‎src/renderer/routes/gitea/LoginWithPersonalAccessToken.test.tsx‎

Lines changed: 43 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -24,22 +24,18 @@ describe('renderer/routes/gitea/LoginWithPersonalAccessToken.tsx', () => {
2424
expect(tree.container).toMatchSnapshot();
2525
});
2626

27-
describe('form validation', () => {
28-
it.each(['gitea.example.com', 'gitea.example.com:3000', 'forgejo.example.com:443'])(
29-
'accepts %s',
30-
(hostname) => {
31-
expect(
32-
validateForm(
33-
{
34-
hostname: hostname as Hostname,
35-
token: 'abcdef1234567890abcdef1234567890abcdef12' as Token,
36-
},
37-
'gitea',
38-
),
39-
).toEqual({});
40-
},
27+
it('disables token settings and rejects credentials or paths in the origin', async () => {
28+
renderWithProviders(<GiteaLoginWithPersonalAccessTokenRoute />);
29+
await userEvent.type(
30+
screen.getByTestId('login-hostname'),
31+
'http://user:secret@git.internal/path',
4132
);
33+
expect(screen.getByTestId('login-create-token')).toBeDisabled();
34+
await userEvent.click(screen.getByTestId('login-submit'));
35+
expect(screen.getByText('Hostname format is invalid')).toBeInTheDocument();
36+
});
4237

38+
describe('form validation', () => {
4339
it.each(['gitea.example.com:0', 'gitea.example.com:65536', 'gitea.example.com:https'])(
4440
'rejects invalid port in %s',
4541
(hostname) => {
@@ -67,6 +63,24 @@ describe('renderer/routes/gitea/LoginWithPersonalAccessToken.tsx', () => {
6763
).toBe('Hostname format is invalid');
6864
});
6965

66+
it.each(['http://git.internal', 'http://git.internal:3000', 'gitea.example.com:3000'])(
67+
'accepts Gitea origin %s',
68+
(hostname) => {
69+
expect(
70+
validateForm({ hostname: hostname as Hostname, token: 'a'.repeat(40) as Token }, 'gitea'),
71+
).toEqual({});
72+
},
73+
);
74+
75+
it.each<Forge>(['github', 'gitlab', 'bitbucket'])('keeps %s hostnames HTTPS-only', (forge) => {
76+
expect(
77+
validateForm(
78+
{ hostname: 'http://git.internal:3000' as Hostname, token: '' as Token },
79+
forge,
80+
).hostname,
81+
).toBe('Hostname format is invalid');
82+
});
83+
7084
it('should validate the token matches the Gitea format', () => {
7185
const values: IFormData = {
7286
hostname: 'gitea.example.com' as Hostname,
@@ -86,26 +100,25 @@ describe('renderer/routes/gitea/LoginWithPersonalAccessToken.tsx', () => {
86100
});
87101
});
88102

89-
it.each(['gitea.example.com', 'gitea.example.com:3000'])(
90-
'should open the token settings for %s',
91-
async (hostname) => {
92-
renderWithProviders(<GiteaLoginWithPersonalAccessTokenRoute />, {
93-
loginWithPersonalAccessToken: loginWithPersonalAccessTokenMock,
94-
});
103+
it.each([
104+
['gitea.example.com', 'https://gitea.example.com'],
105+
['gitea.example.com:3000', 'https://gitea.example.com:3000'],
106+
['http://git.internal:3000', 'http://git.internal:3000'],
107+
])('opens token settings for %s', async (hostname, origin) => {
108+
renderWithProviders(<GiteaLoginWithPersonalAccessTokenRoute />, {
109+
loginWithPersonalAccessToken: loginWithPersonalAccessTokenMock,
110+
});
95111

96-
await userEvent.type(screen.getByTestId('login-hostname'), hostname);
112+
await userEvent.type(screen.getByTestId('login-hostname'), hostname);
97113

98-
await userEvent.click(screen.getByTestId('login-create-token'));
114+
await userEvent.click(screen.getByTestId('login-create-token'));
99115

100-
expect(openExternalLinkSpy).toHaveBeenCalledTimes(1);
101-
expect(openExternalLinkSpy).toHaveBeenCalledWith(
102-
`https://${hostname}/user/settings/applications`,
103-
);
104-
},
105-
);
116+
expect(openExternalLinkSpy).toHaveBeenCalledTimes(1);
117+
expect(openExternalLinkSpy).toHaveBeenCalledWith(`${origin}/user/settings/applications`);
118+
});
106119

107-
it.each(['gitea.example.com', 'gitea.example.com:3000'])(
108-
'should login using a token on %s',
120+
it.each(['gitea.example.com', 'gitea.example.com:3000', 'http://git.internal:3000'])(
121+
'logs in with %s',
109122
async (hostname) => {
110123
loginWithPersonalAccessTokenMock.mockResolvedValueOnce(null);
111124

‎src/renderer/routes/gitea/LoginWithPersonalAccessToken.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export const GiteaLoginWithPersonalAccessTokenRoute: FC = () => (
66
<LoginWithPersonalAccessTokenForm
77
docsTooltip="Gitea API documentation"
88
forge="gitea"
9-
hostnameCaption="Your Gitea instance hostname (for example gitea.example.com)"
9+
hostnameCaption="Your instance hostname or origin, e.g. gitea.example.com or http://git.internal:3000. Defaults to HTTPS."
1010
hostnamePlaceholder="gitea.example.com"
1111
title="Login to Gitea with Personal Access Token"
1212
tokenPlaceholder="Your Gitea personal access token"
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { page } from 'vite-plus/test/browser';
2+
3+
import { setLoginsOverrides } from '../../__helpers__/hook-mocks';
4+
import { renderRoute } from '../../__helpers__/visual-utils';
5+
6+
import { useAccountsStore } from '../../stores';
7+
8+
import { DesignLanguage, Theme } from '../../types';
9+
10+
import { GiteaLoginWithPersonalAccessTokenRoute } from './LoginWithPersonalAccessToken';
11+
12+
describe('Gitea HTTP login', () => {
13+
beforeEach(() => {
14+
setLoginsOverrides({
15+
loginWithPersonalAccessToken: ({ token, hostname }) =>
16+
useAccountsStore
17+
.getState()
18+
.createAccount('Personal Access Token', token, hostname, 'gitea'),
19+
});
20+
});
21+
it('logs in over HTTP and retains the account when persisted state is reloaded', async () => {
22+
const origin = 'http://git.internal:3000';
23+
vi.mocked(fetch).mockImplementation(
24+
async () =>
25+
new Response(JSON.stringify({ id: 7, login: 'lan-user', full_name: 'LAN User' }), {
26+
headers: { 'content-type': 'application/json' },
27+
}),
28+
);
29+
await renderRoute(<GiteaLoginWithPersonalAccessTokenRoute />, {
30+
theme: Theme.LIGHT,
31+
designLanguage: DesignLanguage.CLASSIC,
32+
});
33+
34+
await page.getByTestId('login-hostname').fill(origin);
35+
await page.getByTestId('login-create-token').click();
36+
expect(window.gitify.openExternalLink).toHaveBeenCalledWith(
37+
`${origin}/user/settings/applications`,
38+
true,
39+
);
40+
await page.getByTestId('login-token').fill('a'.repeat(40));
41+
await page.getByTestId('login-submit').click();
42+
43+
await expect.poll(() => useAccountsStore.getState().accounts).toHaveLength(1);
44+
expect(fetch).toHaveBeenCalledWith(
45+
`${origin}/api/v1/user`,
46+
expect.objectContaining({
47+
headers: expect.objectContaining({ Authorization: 'token decrypted' }),
48+
}),
49+
);
50+
await useAccountsStore.persist.rehydrate();
51+
expect(useAccountsStore.getState().accounts[0]).toMatchObject({
52+
forge: 'gitea',
53+
hostname: origin,
54+
user: { login: 'lan-user' },
55+
});
56+
});
57+
58+
it('shows validation and API errors', async () => {
59+
await renderRoute(<GiteaLoginWithPersonalAccessTokenRoute />, {
60+
theme: Theme.LIGHT,
61+
designLanguage: DesignLanguage.CLASSIC,
62+
});
63+
await page.getByTestId('login-submit').click();
64+
await expect.element(page.getByText('Hostname is required')).toBeVisible();
65+
await page.getByTestId('login-hostname').fill('http://user:secret@git.internal/path');
66+
await expect.element(page.getByTestId('login-create-token')).toBeDisabled();
67+
await page.getByTestId('login-submit').click();
68+
await expect.element(page.getByText('Hostname format is invalid')).toBeVisible();
69+
expect(fetch).not.toHaveBeenCalled();
70+
71+
vi.mocked(fetch).mockImplementation(
72+
async () => new Response(null, { status: 401, statusText: 'Unauthorized' }),
73+
);
74+
await page.getByTestId('login-hostname').fill('http://git.internal:3000');
75+
await page.getByTestId('login-token').fill('a'.repeat(40));
76+
await page.getByTestId('login-submit').click();
77+
await expect
78+
.element(page.getByTestId('login-errors'))
79+
.toHaveTextContent('Gitea API 401 Unauthorized');
80+
expect(useAccountsStore.getState().accounts).toHaveLength(0);
81+
});
82+
});

‎src/renderer/routes/gitea/__snapshots__/LoginWithPersonalAccessToken.test.tsx.snap‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src/renderer/stores/useAccountsStore.test.ts‎

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type { Account, Forge, Hostname, Link, Token } from '../types';
1212
import type { GetAuthenticatedUserResponse } from '../utils/forges/github/types';
1313

1414
import { getRecommendedScopeNames } from '../utils/auth/scopes';
15+
import { getAccountUUID } from '../utils/auth/utils';
1516
import * as logger from '../utils/core/logger';
1617
import * as apiClient from '../utils/forges/github/client';
1718
import { getAdapter } from '../utils/forges/registry';
@@ -61,6 +62,98 @@ describe('renderer/stores/useAccountsStore.ts', () => {
6162
).toEqual([]);
6263
},
6364
);
65+
66+
it.each(['gitea.example.com', 'http://git.internal:3000', 'gitea.example.com:3000'])(
67+
'retains the Gitea account at %s on reload',
68+
(hostname) => {
69+
const account = { ...mockGiteaAccount, hostname: hostname as Hostname };
70+
expect(sanitizeAccounts([account])).toEqual([account]);
71+
},
72+
);
73+
74+
it.each<Forge>(['github', 'gitlab', 'bitbucket'])(
75+
'rejects HTTP origins for persisted %s accounts',
76+
(forge) => {
77+
expect(
78+
sanitizeAccounts([
79+
{ ...mockGitHubCloudAccount, forge, hostname: 'http://git.internal:3000' as Hostname },
80+
]),
81+
).toEqual([]);
82+
},
83+
);
84+
85+
it.each(['http://git.internal/path', 'http://user:pass@git.internal', 'javascript:alert(1)'])(
86+
'drops invalid Gitea origin %s',
87+
(hostname) => {
88+
expect(sanitizeAccounts([{ ...mockGiteaAccount, hostname: hostname as Hostname }])).toEqual(
89+
[],
90+
);
91+
},
92+
);
93+
});
94+
95+
it.each([
96+
['http://git.internal:3000', 'http://git.internal:3000/'],
97+
['Gitea.Example.com', 'https://gitea.example.com:443/'],
98+
])(
99+
'replaces the token for equivalent origins %s and %s without changing the account UUID',
100+
async (hostname, alias) => {
101+
vi.mocked(fetch).mockImplementation(
102+
async () => new Response(JSON.stringify({ id: 7, login: 'lan-user' })),
103+
);
104+
vi.mocked(window.gitify.encryptValue)
105+
.mockResolvedValueOnce('first-encrypted')
106+
.mockResolvedValueOnce('replacement-encrypted');
107+
await useAccountsStore
108+
.getState()
109+
.createAccount(
110+
'Personal Access Token',
111+
'a'.repeat(40) as Token,
112+
hostname as Hostname,
113+
'gitea',
114+
);
115+
const originalUUID = getAccountUUID(useAccountsStore.getState().accounts[0]);
116+
await useAccountsStore
117+
.getState()
118+
.createAccount(
119+
'Personal Access Token',
120+
'b'.repeat(40) as Token,
121+
alias as Hostname,
122+
'gitea',
123+
);
124+
const accounts = useAccountsStore.getState().accounts;
125+
expect(accounts).toHaveLength(1);
126+
expect(accounts[0].hostname).toBe(hostname);
127+
expect(accounts[0].token).toBe('replacement-encrypted');
128+
expect(getAccountUUID(accounts[0])).toBe(originalUUID);
129+
},
130+
);
131+
132+
it.each([
133+
['https://git.internal:3000', 7],
134+
['http://git.internal:3001', 7],
135+
['http://git.internal:3000', 8],
136+
])('keeps a separate Gitea account for %s with user %s', async (hostname, userId) => {
137+
vi.mocked(fetch)
138+
.mockResolvedValueOnce(new Response(JSON.stringify({ id: 7, login: 'first-user' })))
139+
.mockResolvedValueOnce(new Response(JSON.stringify({ id: userId, login: 'second-user' })));
140+
await useAccountsStore
141+
.getState()
142+
.createAccount(
143+
'Personal Access Token',
144+
'a'.repeat(40) as Token,
145+
'http://git.internal:3000' as Hostname,
146+
'gitea',
147+
);
148+
await useAccountsStore
149+
.getState()
150+
.createAccount(
151+
'Personal Access Token',
152+
'b'.repeat(40) as Token,
153+
hostname as Hostname,
154+
'gitea',
155+
);
156+
expect(useAccountsStore.getState().accounts).toHaveLength(2);
64157
});
65158

66159
describe('createAccount', () => {

0 commit comments

Comments
 (0)