Skip to content

Commit fec3477

Browse files
committed
auth-splits
Signed-off-by: Adam Setch <adam.setch@outlook.com>
1 parent 6de32cb commit fec3477

File tree

14 files changed

+661
-525
lines changed

14 files changed

+661
-525
lines changed

src/renderer/__mocks__/account-mocks.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type {
88
Token,
99
} from '../types';
1010

11-
import { getRecommendedScopeNames } from '../utils/auth/utils';
11+
import { getRecommendedScopeNames } from '../utils/auth/scopes';
1212
import { mockGitifyUser } from './user-mocks';
1313

1414
export const mockGitHubAppAccount: Account = {

src/renderer/components/settings/NotificationSettings.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import { FetchType, GroupBy, Size } from '../../types';
3636
import {
3737
hasAlternateScopes,
3838
hasRecommendedScopes,
39-
} from '../../utils/auth/utils';
39+
} from '../../utils/auth/scopes';
4040
import { openGitHubParticipatingDocs } from '../../utils/system/links';
4141

4242
export const NotificationSettings: FC = () => {

src/renderer/context/App.test.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type {
1919
} from '../types';
2020
import type { DeviceFlowSession } from '../utils/auth/types';
2121

22+
import * as authFlows from '../utils/auth/flows';
2223
import * as authUtils from '../utils/auth/utils';
2324
import * as storage from '../utils/core/storage';
2425
import * as notifications from '../utils/notifications/notifications';
@@ -230,7 +231,7 @@ describe('renderer/context/App.tsx', () => {
230231

231232
it('loginWithDeviceFlowStart calls startGitHubDeviceFlow', async () => {
232233
const startGitHubDeviceFlowSpy = vi
233-
.spyOn(authUtils, 'startGitHubDeviceFlow')
234+
.spyOn(authFlows, 'startGitHubDeviceFlow')
234235
.mockImplementation(vi.fn());
235236

236237
const getContext = renderWithContext();
@@ -244,7 +245,7 @@ describe('renderer/context/App.tsx', () => {
244245

245246
it('loginWithDeviceFlowPoll calls pollGitHubDeviceFlow', async () => {
246247
const pollGitHubDeviceFlowSpy = vi
247-
.spyOn(authUtils, 'pollGitHubDeviceFlow')
248+
.spyOn(authFlows, 'pollGitHubDeviceFlow')
248249
.mockImplementation(vi.fn());
249250

250251
const getContext = renderWithContext();
@@ -278,7 +279,7 @@ describe('renderer/context/App.tsx', () => {
278279

279280
it('loginWithOAuthApp calls performGitHubWebOAuth', async () => {
280281
const performGitHubWebOAuthSpy = vi.spyOn(
281-
authUtils,
282+
authFlows,
282283
'performGitHubWebOAuth',
283284
);
284285

src/renderer/context/App.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,17 @@ import type {
3939
import { fetchAuthenticatedUserDetails } from '../utils/api/client';
4040
import { clearOctokitClientCache } from '../utils/api/octokit';
4141
import {
42-
addAccount,
4342
exchangeAuthCodeForAccessToken,
44-
getAccountUUID,
45-
hasAccounts,
4643
performGitHubWebOAuth,
4744
pollGitHubDeviceFlow,
45+
startGitHubDeviceFlow,
46+
} from '../utils/auth/flows';
47+
import {
48+
addAccount,
49+
getAccountUUID,
50+
hasAccounts,
4851
refreshAccount,
4952
removeAccount,
50-
startGitHubDeviceFlow,
5153
} from '../utils/auth/utils';
5254
import { clearState, loadState, saveState } from '../utils/core/storage';
5355
import {

src/renderer/routes/AccountScopes.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {
2323
getRecommendedScopeNames,
2424
getRequiredScopeNames,
2525
hasRequiredScopes,
26-
} from '../utils/auth/utils';
26+
} from '../utils/auth/scopes';
2727
import { openDeveloperSettings } from '../utils/system/links';
2828

2929
interface LocationState {

src/renderer/routes/Accounts.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,8 @@ import { Header } from '../components/primitives/Header';
3333
import { type Account, type GitifyError, IconColor, Size } from '../types';
3434

3535
import { determineFailureType } from '../utils/api/errors';
36-
import {
37-
getAccountUUID,
38-
hasAlternateScopes,
39-
hasRecommendedScopes,
40-
refreshAccount,
41-
} from '../utils/auth/utils';
36+
import { hasAlternateScopes, hasRecommendedScopes } from '../utils/auth/scopes';
37+
import { getAccountUUID, refreshAccount } from '../utils/auth/utils';
4238
import { Errors } from '../utils/core/errors';
4339
import { saveState } from '../utils/core/storage';
4440
import {

src/renderer/routes/LoginWithDeviceFlow.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import type { DeviceFlowSession } from '../utils/auth/types';
3232
import {
3333
getAlternateScopeNames,
3434
getRecommendedScopeNames,
35-
} from '../utils/auth/utils';
35+
} from '../utils/auth/scopes';
3636
import { rendererLogError } from '../utils/core/logger';
3737
import { copyToClipboard, openExternalLink } from '../utils/system/comms';
3838
import { openDeveloperSettings } from '../utils/system/links';

src/renderer/routes/LoginWithPersonalAccessToken.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ import { Header } from '../components/primitives/Header';
3030
import type { Account, Hostname, Token } from '../types';
3131
import type { LoginPersonalAccessTokenOptions } from '../utils/auth/types';
3232

33+
import { formatRecommendedOAuthScopes } from '../utils/auth/scopes';
3334
import {
34-
formatRecommendedOAuthScopes,
3535
getNewTokenURL,
3636
isValidHostname,
3737
isValidToken,
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
// Use a hoist-safe mock factory for '@octokit/oauth-methods'
2+
vi.mock('@octokit/oauth-methods', async () => {
3+
const actual = await vi.importActual<typeof import('@octokit/oauth-methods')>(
4+
'@octokit/oauth-methods',
5+
);
6+
return {
7+
...actual,
8+
createDeviceCode: vi.fn(),
9+
exchangeDeviceCode: vi.fn(),
10+
exchangeWebFlowCode: vi.fn(),
11+
};
12+
});
13+
14+
import {
15+
createDeviceCode,
16+
exchangeDeviceCode,
17+
exchangeWebFlowCode,
18+
} from '@octokit/oauth-methods';
19+
import { RequestError } from '@octokit/request-error';
20+
21+
import type { MockedFunction } from 'vitest';
22+
23+
import { Constants } from '../../constants';
24+
25+
import type { AuthCode, ClientID, ClientSecret, Hostname } from '../../types';
26+
import type { DeviceFlowSession, LoginOAuthWebOptions } from './types';
27+
28+
import * as comms from '../../utils/system/comms';
29+
import * as logger from '../core/logger';
30+
import * as authUtils from './flows';
31+
import { getRecommendedScopeNames } from './scopes';
32+
33+
const createDeviceCodeMock = createDeviceCode as unknown as MockedFunction<
34+
typeof createDeviceCode
35+
>;
36+
37+
const exchangeDeviceCodeMock = exchangeDeviceCode as unknown as MockedFunction<
38+
typeof exchangeDeviceCode
39+
>;
40+
41+
const exchangeWebFlowCodeMock =
42+
exchangeWebFlowCode as unknown as MockedFunction<typeof exchangeWebFlowCode>;
43+
44+
describe('renderer/utils/auth/flows.ts', () => {
45+
vi.spyOn(logger, 'rendererLogInfo').mockImplementation(vi.fn());
46+
const openExternalLinkSpy = vi
47+
.spyOn(comms, 'openExternalLink')
48+
.mockImplementation(vi.fn());
49+
50+
beforeEach(() => {
51+
// Mock OAUTH_DEVICE_FLOW_CLIENT_ID value
52+
Constants.OAUTH_DEVICE_FLOW_CLIENT_ID = 'FAKE_CLIENT_ID_123' as ClientID;
53+
});
54+
55+
describe('Gitify GitHub OAuth - Device Code Flow', () => {
56+
describe('startGitHubDeviceFlow', () => {
57+
it('should request a device code and return a session', async () => {
58+
createDeviceCodeMock.mockResolvedValueOnce({
59+
data: {
60+
device_code: 'device-code-xyz',
61+
user_code: 'user-code-xyz',
62+
verification_uri: 'https://github.com/login/device',
63+
expires_in: 600,
64+
interval: 5,
65+
},
66+
} as unknown as Awaited<ReturnType<typeof createDeviceCode>>);
67+
68+
const session = await authUtils.startGitHubDeviceFlow();
69+
70+
expect(createDeviceCodeMock).toHaveBeenCalledWith({
71+
clientType: 'oauth-app',
72+
clientId: 'FAKE_CLIENT_ID_123',
73+
scopes: getRecommendedScopeNames(),
74+
request: expect.any(Function),
75+
});
76+
77+
expect(session.deviceCode).toBe('device-code-xyz');
78+
expect(session.userCode).toBe('user-code-xyz');
79+
expect(session.verificationUri).toBe('https://github.com/login/device');
80+
expect(session.intervalSeconds).toBe(5);
81+
expect(session.expiresAt).toBeGreaterThan(Date.now());
82+
});
83+
});
84+
85+
describe('pollGitHubDeviceFlow', () => {
86+
const baseSession = {
87+
hostname: 'github.com',
88+
clientId: 'FAKE_CLIENT_ID_123',
89+
deviceCode: 'device-code',
90+
userCode: 'user-code',
91+
verificationUri: 'https://github.com/login/device',
92+
intervalSeconds: 5,
93+
expiresAt: Date.now() + 10000,
94+
} as const;
95+
96+
it('returns token on successful exchange', async () => {
97+
exchangeDeviceCodeMock.mockResolvedValueOnce({
98+
authentication: { token: 'device-token-xyz' },
99+
} as unknown as Awaited<ReturnType<typeof exchangeDeviceCode>>);
100+
101+
const token = await authUtils.pollGitHubDeviceFlow(
102+
baseSession as DeviceFlowSession,
103+
);
104+
105+
expect(exchangeDeviceCodeMock).toHaveBeenCalledWith({
106+
clientType: 'oauth-app',
107+
clientId: 'FAKE_CLIENT_ID_123',
108+
code: 'device-code',
109+
request: expect.any(Function),
110+
});
111+
112+
expect(token).toBe('device-token-xyz');
113+
});
114+
115+
it('returns null when authorization is pending or slow_down', async () => {
116+
const pendingErr = Object.create(RequestError.prototype);
117+
pendingErr.response = { data: { error: 'authorization_pending' } };
118+
119+
exchangeDeviceCodeMock.mockRejectedValueOnce(pendingErr);
120+
121+
const token = await authUtils.pollGitHubDeviceFlow(
122+
baseSession as DeviceFlowSession,
123+
);
124+
125+
expect(token).toBeNull();
126+
});
127+
128+
it('throws on other errors', async () => {
129+
const otherErr = new Error('boom');
130+
131+
exchangeDeviceCodeMock.mockRejectedValueOnce(otherErr);
132+
133+
await expect(
134+
async () =>
135+
await authUtils.pollGitHubDeviceFlow(
136+
baseSession as DeviceFlowSession,
137+
),
138+
).rejects.toThrow('boom');
139+
});
140+
});
141+
});
142+
143+
describe('performGitHubWebOAuth', () => {
144+
const webAuthOptions: LoginOAuthWebOptions = {
145+
hostname: 'github.com' as Hostname,
146+
clientId: 'FAKE_CLIENT_ID_123' as ClientID,
147+
clientSecret: 'FAKE_CLIENT_SECRET_123' as ClientSecret,
148+
};
149+
150+
it('should call performGitHubWebOAuth using custom oauth app - success oauth flow', async () => {
151+
window.gitify.onAuthCallback = vi.fn().mockImplementation((callback) => {
152+
callback('gitify://oauth?code=123-456');
153+
});
154+
155+
const res = await authUtils.performGitHubWebOAuth({
156+
clientId: 'BYO_CLIENT_ID' as ClientID,
157+
clientSecret: 'BYO_CLIENT_SECRET' as ClientSecret,
158+
hostname: 'my.git.com' as Hostname,
159+
});
160+
161+
expect(openExternalLinkSpy).toHaveBeenCalledTimes(1);
162+
expect(openExternalLinkSpy).toHaveBeenCalledWith(
163+
expect.stringContaining(
164+
'https://my.git.com/login/oauth/authorize?allow_signup=false&client_id=BYO_CLIENT_ID&scope=notifications%2Cread%3Auser%2Crepo',
165+
),
166+
);
167+
168+
expect(window.gitify.onAuthCallback).toHaveBeenCalledTimes(1);
169+
expect(window.gitify.onAuthCallback).toHaveBeenCalledWith(
170+
expect.any(Function),
171+
);
172+
173+
expect(res.authMethod).toBe('OAuth App');
174+
expect(res.authCode).toBe('123-456');
175+
});
176+
177+
it('should call performGitHubWebOAuth - failure', async () => {
178+
window.gitify.onAuthCallback = vi.fn().mockImplementation((callback) => {
179+
callback(
180+
'gitify://auth?error=invalid_request&error_description=The+redirect_uri+is+missing+or+invalid.&error_uri=https://docs.github.com/en/developers/apps/troubleshooting-oauth-errors',
181+
);
182+
});
183+
184+
await expect(
185+
async () => await authUtils.performGitHubWebOAuth(webAuthOptions),
186+
).rejects.toEqual(
187+
new Error(
188+
"Oops! Something went wrong and we couldn't log you in using GitHub. Please try again. Reason: The redirect_uri is missing or invalid. Docs: https://docs.github.com/en/developers/apps/troubleshooting-oauth-errors",
189+
),
190+
);
191+
192+
expect(openExternalLinkSpy).toHaveBeenCalledTimes(1);
193+
expect(openExternalLinkSpy).toHaveBeenCalledWith(
194+
expect.stringContaining(
195+
'https://github.com/login/oauth/authorize?allow_signup=false&client_id=FAKE_CLIENT_ID_123&scope=notifications%2Cread%3Auser%2Crepo',
196+
),
197+
);
198+
199+
expect(window.gitify.onAuthCallback).toHaveBeenCalledTimes(1);
200+
expect(window.gitify.onAuthCallback).toHaveBeenCalledWith(
201+
expect.any(Function),
202+
);
203+
});
204+
205+
describe('exchangeAuthCodeForAccessToken', () => {
206+
const authCode = '123-456' as AuthCode;
207+
208+
it('should exchange auth code for access token', async () => {
209+
exchangeWebFlowCodeMock.mockResolvedValueOnce({
210+
authentication: {
211+
token: 'this-is-a-token',
212+
},
213+
} as unknown as Awaited<ReturnType<typeof exchangeWebFlowCode>>);
214+
215+
const res = await authUtils.exchangeAuthCodeForAccessToken(authCode, {
216+
...webAuthOptions,
217+
});
218+
219+
expect(exchangeWebFlowCodeMock).toHaveBeenCalledWith({
220+
clientType: 'oauth-app',
221+
clientId: 'FAKE_CLIENT_ID_123',
222+
clientSecret: 'FAKE_CLIENT_SECRET_123',
223+
code: '123-456',
224+
request: expect.any(Function),
225+
});
226+
expect(res).toBe('this-is-a-token');
227+
});
228+
229+
it('should throw when client secret is missing', async () => {
230+
await expect(
231+
async () =>
232+
await authUtils.exchangeAuthCodeForAccessToken(authCode, {
233+
...webAuthOptions,
234+
clientSecret: undefined as unknown as ClientSecret,
235+
}),
236+
).rejects.toThrow('clientSecret is required to exchange an auth code');
237+
});
238+
});
239+
});
240+
});

0 commit comments

Comments
 (0)