diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 877104a21..8af83e658 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -41,7 +41,6 @@ jobs: run: pnpm build env: OAUTH_CLIENT_ID: ${{ secrets.OAUTH_CLIENT_ID }} - OAUTH_CLIENT_SECRET: ${{ secrets.OAUTH_CLIENT_SECRET }} - name: Package and publish for ${{ matrix.platform }} run: ${{ matrix.package-cmd }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9f5c7b97a..70202a52c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,7 @@ pnpm install > [!TIP] > _Optional: If you prefer to use your own OAuth credentials, you can do so by passing them as environment variables when bundling the app. This is optional as the app has some default "development" keys (use at your own discretion)._ > ```shell -> OAUTH_CLIENT_ID="123" OAUTH_CLIENT_SECRET="456789" pnpm build +> OAUTH_CLIENT_ID="123" pnpm build > ``` Copy the `.env.template` to `.env` and add update `GITHUB_TOKEN` with a GitHub Personal Access Token. This is used for fetching the latest GitHub GraphQL API schema for `graphql-codegen`. diff --git a/config/webpack.config.renderer.base.ts b/config/webpack.config.renderer.base.ts index 3e016d01c..a7e8d9c20 100644 --- a/config/webpack.config.renderer.base.ts +++ b/config/webpack.config.renderer.base.ts @@ -58,7 +58,6 @@ const configuration: webpack.Configuration = { // Development Keys - See CONTRIBUTING.md new webpack.EnvironmentPlugin({ OAUTH_CLIENT_ID: 'Ov23liQIkFs5ehQLNzHF', - OAUTH_CLIENT_SECRET: '404b80632292e18419dbd2a6ed25976856e95255', }), // Extract CSS into a separate file diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index d652819ae..541344f66 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -12,6 +12,7 @@ import { AppProvider } from './context/App'; import { AccountsRoute } from './routes/Accounts'; import { FiltersRoute } from './routes/Filters'; import { LoginRoute } from './routes/Login'; +import { LoginWithDeviceFlowRoute } from './routes/LoginWithDeviceFlow'; import { LoginWithOAuthAppRoute } from './routes/LoginWithOAuthApp'; import { LoginWithPersonalAccessTokenRoute } from './routes/LoginWithPersonalAccessToken'; import { NotificationsRoute } from './routes/Notifications'; @@ -78,6 +79,10 @@ export const App = () => { path="/accounts" /> } path="/login" /> + } + path="/login-device-flow" + /> } path="/login-personal-access-token" diff --git a/src/renderer/__helpers__/jest.setup.env.ts b/src/renderer/__helpers__/jest.setup.env.ts index f70f10b5c..fa815fa2a 100644 --- a/src/renderer/__helpers__/jest.setup.env.ts +++ b/src/renderer/__helpers__/jest.setup.env.ts @@ -8,5 +8,4 @@ export default () => { // Mock OAuth client ID and secret process.env.OAUTH_CLIENT_ID = 'FAKE_CLIENT_ID_123'; - process.env.OAUTH_CLIENT_SECRET = 'FAKE_CLIENT_SECRET_123'; }; diff --git a/src/renderer/__helpers__/jest.setup.ts b/src/renderer/__helpers__/jest.setup.ts index c4ccc10e3..c98149063 100644 --- a/src/renderer/__helpers__/jest.setup.ts +++ b/src/renderer/__helpers__/jest.setup.ts @@ -70,3 +70,11 @@ globalThis.matchMedia = (query: string): MediaQueryList => ({ removeEventListener: () => {}, dispatchEvent: () => false, }); + +Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: jest.fn().mockResolvedValue(undefined), + readText: jest.fn().mockResolvedValue(''), + }, + configurable: true, +}); diff --git a/src/renderer/__helpers__/test-utils.tsx b/src/renderer/__helpers__/test-utils.tsx index baf8ea05c..7bcc2684b 100644 --- a/src/renderer/__helpers__/test-utils.tsx +++ b/src/renderer/__helpers__/test-utils.tsx @@ -38,7 +38,9 @@ function AppContextProvider({ children, value = {} }: AppContextProviderProps) { globalError: null, // Default mock implementations for all required methods - loginWithGitHubApp: jest.fn(), + loginWithDeviceFlowStart: jest.fn(), + loginWithDeviceFlowPoll: jest.fn(), + loginWithDeviceFlowComplete: jest.fn(), loginWithOAuthApp: jest.fn(), loginWithPersonalAccessToken: jest.fn(), logoutFromAccount: jest.fn(), diff --git a/src/renderer/__mocks__/account-mocks.ts b/src/renderer/__mocks__/account-mocks.ts index 16787ffa4..348cb748b 100644 --- a/src/renderer/__mocks__/account-mocks.ts +++ b/src/renderer/__mocks__/account-mocks.ts @@ -14,7 +14,7 @@ export const mockGitHubAppAccount: Account = { platform: 'GitHub Cloud', method: 'GitHub App', token: 'token-987654321' as Token, - hostname: Constants.DEFAULT_AUTH_OPTIONS.hostname, + hostname: Constants.GITHUB_HOSTNAME, user: mockGitifyUser, hasRequiredScopes: true, }; @@ -23,7 +23,7 @@ export const mockPersonalAccessTokenAccount: Account = { platform: 'GitHub Cloud', method: 'Personal Access Token', token: 'token-123-456' as Token, - hostname: Constants.DEFAULT_AUTH_OPTIONS.hostname, + hostname: Constants.GITHUB_HOSTNAME, user: mockGitifyUser, hasRequiredScopes: true, }; @@ -41,7 +41,7 @@ export const mockGitHubCloudAccount: Account = { platform: 'GitHub Cloud', method: 'Personal Access Token', token: 'token-123-456' as Token, - hostname: Constants.DEFAULT_AUTH_OPTIONS.hostname, + hostname: Constants.GITHUB_HOSTNAME, user: mockGitifyUser, version: 'latest', hasRequiredScopes: true, diff --git a/src/renderer/constants.ts b/src/renderer/constants.ts index 2961490c7..6250ea0ba 100644 --- a/src/renderer/constants.ts +++ b/src/renderer/constants.ts @@ -1,5 +1,4 @@ -import type { ClientID, ClientSecret, Hostname, Link } from './types'; -import type { LoginOAuthAppOptions } from './utils/auth/types'; +import type { ClientID, Hostname, Link } from './types'; export const Constants = { STORAGE_KEY: 'gitify-storage', @@ -10,11 +9,9 @@ export const Constants = { ALTERNATE: ['read:user', 'notifications', 'public_repo'], }, - DEFAULT_AUTH_OPTIONS: { - hostname: 'github.com' as Hostname, - clientId: process.env.OAUTH_CLIENT_ID as ClientID, - clientSecret: process.env.OAUTH_CLIENT_SECRET as ClientSecret, - } satisfies LoginOAuthAppOptions, + GITHUB_HOSTNAME: 'github.com' as Hostname, + + OAUTH_DEVICE_FLOW_CLIENT_ID: process.env.OAUTH_CLIENT_ID as ClientID, // GitHub Enterprise Cloud with Data Residency uses *.ghe.com domains GITHUB_ENTERPRISE_CLOUD_DATA_RESIDENCY_HOSTNAME: 'ghe.com', diff --git a/src/renderer/context/App.test.tsx b/src/renderer/context/App.test.tsx index 106beded3..ed5989d6c 100644 --- a/src/renderer/context/App.test.tsx +++ b/src/renderer/context/App.test.tsx @@ -1,6 +1,7 @@ -import { act, fireEvent, waitFor } from '@testing-library/react'; +import { act, waitFor } from '@testing-library/react'; import { renderWithAppContext } from '../__helpers__/test-utils'; +import { mockGitHubCloudAccount } from '../__mocks__/account-mocks'; import { mockGitifyNotification } from '../__mocks__/notifications-mocks'; import { mockSettings } from '../__mocks__/state-mocks'; @@ -9,9 +10,16 @@ import { Constants } from '../constants'; import { useAppContext } from '../hooks/useAppContext'; import { useNotifications } from '../hooks/useNotifications'; -import type { AuthState, SettingsState } from '../types'; +import type { + AuthState, + ClientID, + ClientSecret, + SettingsState, + Token, +} from '../types'; +import type { DeviceFlowSession } from '../utils/auth/types'; -// import * as apiRequests from '../utils/api/request'; +import * as authUtils from '../utils/auth/utils'; import * as notifications from '../utils/notifications/notifications'; import * as storage from '../utils/storage'; import * as tray from '../utils/tray'; @@ -20,45 +28,30 @@ import { defaultSettings } from './defaults'; jest.mock('../hooks/useNotifications'); -// Helper to render a button that calls a context method when clicked -const renderContextButton = ( - contextMethodName: keyof AppContextState, - ...args: unknown[] -) => { - const TestComponent = () => { - const context = useAppContext(); - - const method = context[contextMethodName]; - return ( - - ); +// Helper to render the context +const renderWithContext = () => { + let context!: AppContextState; + + const CaptureContext = () => { + context = useAppContext(); + return null; }; - const result = renderWithAppContext( + renderWithAppContext( - + , ); - const button = result.getByTestId('context-method-button'); - return { ...result, button }; + return () => context; }; describe('renderer/context/App.tsx', () => { - const mockFetchNotifications = jest.fn(); + const fetchNotificationsMock = jest.fn(); const markNotificationsAsReadMock = jest.fn(); const markNotificationsAsDoneMock = jest.fn(); const unsubscribeNotificationMock = jest.fn(); + const removeAccountNotificationsMock = jest.fn(); const saveStateSpy = jest .spyOn(storage, 'saveState') @@ -67,10 +60,11 @@ describe('renderer/context/App.tsx', () => { beforeEach(() => { jest.useFakeTimers(); (useNotifications as jest.Mock).mockReturnValue({ - fetchNotifications: mockFetchNotifications, + fetchNotifications: fetchNotificationsMock, markNotificationsAsRead: markNotificationsAsReadMock, markNotificationsAsDone: markNotificationsAsDoneMock, unsubscribeNotification: unsubscribeNotificationMock, + removeAccountNotifications: removeAccountNotificationsMock, }); }); @@ -101,7 +95,7 @@ describe('renderer/context/App.tsx', () => { renderWithAppContext({null}); await waitFor(() => - expect(mockFetchNotifications).toHaveBeenCalledTimes(1), + expect(fetchNotificationsMock).toHaveBeenCalledTimes(1), ); act(() => { @@ -109,39 +103,40 @@ describe('renderer/context/App.tsx', () => { Constants.DEFAULT_FETCH_NOTIFICATIONS_INTERVAL_MS, ); }); - expect(mockFetchNotifications).toHaveBeenCalledTimes(2); + expect(fetchNotificationsMock).toHaveBeenCalledTimes(2); act(() => { jest.advanceTimersByTime( Constants.DEFAULT_FETCH_NOTIFICATIONS_INTERVAL_MS, ); }); - expect(mockFetchNotifications).toHaveBeenCalledTimes(3); + expect(fetchNotificationsMock).toHaveBeenCalledTimes(3); act(() => { jest.advanceTimersByTime( Constants.DEFAULT_FETCH_NOTIFICATIONS_INTERVAL_MS, ); }); - expect(mockFetchNotifications).toHaveBeenCalledTimes(4); + expect(fetchNotificationsMock).toHaveBeenCalledTimes(4); }); it('should call fetchNotifications', async () => { - const { button } = renderContextButton('fetchNotifications'); - - mockFetchNotifications.mockReset(); + const getContext = renderWithContext(); + fetchNotificationsMock.mockReset(); - fireEvent.click(button); + act(() => { + getContext().fetchNotifications(); + }); - expect(mockFetchNotifications).toHaveBeenCalledTimes(1); + expect(fetchNotificationsMock).toHaveBeenCalledTimes(1); }); it('should call markNotificationsAsRead', async () => { - const { button } = renderContextButton('markNotificationsAsRead', [ - mockGitifyNotification, - ]); + const getContext = renderWithContext(); - fireEvent.click(button); + act(() => { + getContext().markNotificationsAsRead([mockGitifyNotification]); + }); expect(markNotificationsAsReadMock).toHaveBeenCalledTimes(1); expect(markNotificationsAsReadMock).toHaveBeenCalledWith( @@ -152,11 +147,11 @@ describe('renderer/context/App.tsx', () => { }); it('should call markNotificationsAsDone', async () => { - const { button } = renderContextButton('markNotificationsAsDone', [ - mockGitifyNotification, - ]); + const getContext = renderWithContext(); - fireEvent.click(button); + act(() => { + getContext().markNotificationsAsDone([mockGitifyNotification]); + }); expect(markNotificationsAsDoneMock).toHaveBeenCalledTimes(1); expect(markNotificationsAsDoneMock).toHaveBeenCalledWith( @@ -167,12 +162,11 @@ describe('renderer/context/App.tsx', () => { }); it('should call unsubscribeNotification', async () => { - const { button } = renderContextButton( - 'unsubscribeNotification', - mockGitifyNotification, - ); + const getContext = renderWithContext(); - fireEvent.click(button); + act(() => { + getContext().unsubscribeNotification(mockGitifyNotification); + }); expect(unsubscribeNotificationMock).toHaveBeenCalledTimes(1); expect(unsubscribeNotificationMock).toHaveBeenCalledWith( @@ -183,65 +177,17 @@ describe('renderer/context/App.tsx', () => { }); }); - describe('authentication methods', () => { - it('should call loginWithGitHubApp', async () => { - const { button } = renderContextButton('loginWithGitHubApp'); - - fireEvent.click(button); - - await waitFor(() => - expect(mockFetchNotifications).toHaveBeenCalledTimes(1), - ); - }); - - it('should call loginWithOAuthApp', async () => { - const { button } = renderContextButton('loginWithOAuthApp'); - - fireEvent.click(button); - - await waitFor(() => - expect(mockFetchNotifications).toHaveBeenCalledTimes(1), - ); - }); - - // it('should call loginWithPersonalAccessToken', async () => { - // const performAuthenticatedRESTRequestSpy = jest - // .spyOn(apiRequests, 'performAuthenticatedRESTRequest') - // .mockResolvedValueOnce(null); - - // const { button } = renderContextButton('loginWithPersonalAccessToken', { - // hostname: 'github.com' as Hostname, - // token: '123-456' as Token, - // }); - - // fireEvent.click(button); - - // await waitFor(() => - // expect(mockFetchNotifications).toHaveBeenCalledTimes(1), - // ); - - // expect(performAuthenticatedRESTRequestSpy).toHaveBeenCalledTimes(1); - // expect(performAuthenticatedRESTRequestSpy).toHaveBeenCalledWith( - // 'HEAD', - // 'https://api.github.com/notifications', - // 'encrypted', - // ); - // }); - }); - describe('settings methods', () => { const saveStateSpy = jest .spyOn(storage, 'saveState') .mockImplementation(jest.fn()); it('should call updateSetting', async () => { - const { button } = renderContextButton( - 'updateSetting', - 'participating', - true, - ); + const getContext = renderWithContext(); - fireEvent.click(button); + act(() => { + getContext().updateSetting('participating', true); + }); expect(saveStateSpy).toHaveBeenCalledWith({ auth: { @@ -255,9 +201,11 @@ describe('renderer/context/App.tsx', () => { }); it('should call resetSettings', async () => { - const { button } = renderContextButton('resetSettings'); + const getContext = renderWithContext(); - fireEvent.click(button); + act(() => { + getContext().resetSettings(); + }); expect(saveStateSpy).toHaveBeenCalledWith({ auth: { @@ -270,14 +218,11 @@ describe('renderer/context/App.tsx', () => { describe('filter methods', () => { it('should call updateFilter - checked', async () => { - const { button } = renderContextButton( - 'updateFilter', - 'filterReasons', - 'assign', - true, - ); + const getContext = renderWithContext(); - fireEvent.click(button); + act(() => { + getContext().updateFilter('filterReasons', 'assign', true); + }); expect(saveStateSpy).toHaveBeenCalledWith({ auth: { @@ -291,14 +236,11 @@ describe('renderer/context/App.tsx', () => { }); it('should call updateFilter - unchecked', async () => { - const { button } = renderContextButton( - 'updateFilter', - 'filterReasons', - 'assign', - false, - ); + const getContext = renderWithContext(); - fireEvent.click(button); + act(() => { + getContext().updateFilter('filterReasons', 'assign', false); + }); expect(saveStateSpy).toHaveBeenCalledWith({ auth: { @@ -312,9 +254,11 @@ describe('renderer/context/App.tsx', () => { }); it('should clear filters back to default', async () => { - const { button } = renderContextButton('clearFilters'); + const getContext = renderWithContext(); - fireEvent.click(button); + act(() => { + getContext().clearFilters(); + }); expect(saveStateSpy).toHaveBeenCalledWith({ auth: { @@ -332,4 +276,96 @@ describe('renderer/context/App.tsx', () => { }); }); }); + + describe('authentication functions', () => { + const addAccountSpy = jest + .spyOn(authUtils, 'addAccount') + .mockImplementation(jest.fn()); + const removeAccountSpy = jest.spyOn(authUtils, 'removeAccount'); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('loginWithDeviceFlowStart calls startGitHubDeviceFlow', async () => { + const startGitHubDeviceFlowSpy = jest + .spyOn(authUtils, 'startGitHubDeviceFlow') + .mockImplementation(jest.fn()); + + const getContext = renderWithContext(); + + act(() => { + getContext().loginWithDeviceFlowStart(); + }); + + expect(startGitHubDeviceFlowSpy).toHaveBeenCalled(); + }); + + it('loginWithDeviceFlowPoll calls pollGitHubDeviceFlow', async () => { + const pollGitHubDeviceFlowSpy = jest + .spyOn(authUtils, 'pollGitHubDeviceFlow') + .mockImplementation(jest.fn()); + + const getContext = renderWithContext(); + + act(() => { + getContext().loginWithDeviceFlowPoll( + 'session' as unknown as DeviceFlowSession, + ); + }); + + expect(pollGitHubDeviceFlowSpy).toHaveBeenCalledWith('session'); + }); + + it('loginWithDeviceFlowComplete calls addAccount', async () => { + const getContext = renderWithContext(); + + act(() => { + getContext().loginWithDeviceFlowComplete( + 'token' as Token, + Constants.GITHUB_HOSTNAME, + ); + }); + + expect(addAccountSpy).toHaveBeenCalledWith( + expect.anything(), + 'GitHub App', + 'token', + 'github.com', + ); + }); + + it('loginWithOAuthApp calls performGitHubWebOAuth', async () => { + const performGitHubWebOAuthSpy = jest.spyOn( + authUtils, + 'performGitHubWebOAuth', + ); + + const getContext = renderWithContext(); + + act(() => { + getContext().loginWithOAuthApp({ + clientId: 'id' as ClientID, + clientSecret: 'secret' as ClientSecret, + hostname: Constants.GITHUB_HOSTNAME, + }); + }); + + expect(performGitHubWebOAuthSpy).toHaveBeenCalled(); + }); + + it('logoutFromAccount calls removeAccountNotifications, removeAccount', async () => { + const getContext = renderWithContext(); + + getContext().logoutFromAccount(mockGitHubCloudAccount); + + expect(removeAccountNotificationsMock).toHaveBeenCalledWith( + mockGitHubCloudAccount, + ); + expect(removeAccountSpy).toHaveBeenCalledWith( + expect.anything(), + mockGitHubCloudAccount, + ); + }); + }); }); diff --git a/src/renderer/context/App.tsx b/src/renderer/context/App.tsx index d1b7f2cef..05daa5e42 100644 --- a/src/renderer/context/App.tsx +++ b/src/renderer/context/App.tsx @@ -25,6 +25,7 @@ import type { FilterSettingsValue, GitifyError, GitifyNotification, + Hostname, SettingsState, SettingsValue, Status, @@ -32,7 +33,8 @@ import type { } from '../types'; import { FetchType } from '../types'; import type { - LoginOAuthAppOptions, + DeviceFlowSession, + LoginOAuthWebOptions, LoginPersonalAccessTokenOptions, } from '../utils/auth/types'; @@ -43,9 +45,11 @@ import { exchangeAuthCodeForAccessToken, getAccountUUID, hasAccounts, - performGitHubOAuth, + performGitHubWebOAuth, + pollGitHubDeviceFlow, refreshAccount, removeAccount, + startGitHubDeviceFlow, } from '../utils/auth/utils'; import { decryptValue, @@ -75,8 +79,15 @@ import { export interface AppContextState { auth: AuthState; isLoggedIn: boolean; - loginWithGitHubApp: () => Promise; - loginWithOAuthApp: (data: LoginOAuthAppOptions) => Promise; + loginWithDeviceFlowStart: () => Promise; + loginWithDeviceFlowPoll: ( + session: DeviceFlowSession, + ) => Promise; + loginWithDeviceFlowComplete: ( + token: Token, + hostname: Hostname, + ) => Promise; + loginWithOAuthApp: (data: LoginOAuthWebOptions) => Promise; loginWithPersonalAccessToken: ( data: LoginPersonalAccessTokenOptions, ) => Promise; @@ -397,27 +408,45 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { }, [auth]); /** - * Login with GitHub App. + * Login to GitHub Gitify OAuth App. * - * Note: although we call this "Login with GitHub App", this function actually - * authenticates via a predefined "Gitify" GitHub OAuth App. + * Initiate device flow session. */ - const loginWithGitHubApp = useCallback(async () => { - const { authCode } = await performGitHubOAuth(); - const token = await exchangeAuthCodeForAccessToken(authCode); - const hostname = Constants.DEFAULT_AUTH_OPTIONS.hostname; + const loginWithDeviceFlowStart = useCallback( + async () => await startGitHubDeviceFlow(), + [], + ); - const updatedAuth = await addAccount(auth, 'GitHub App', token, hostname); + /** + * Login to GitHub Gitify OAuth App. + * + * Poll for device flow session. + */ + const loginWithDeviceFlowPoll = useCallback( + async (session: DeviceFlowSession) => await pollGitHubDeviceFlow(session), + [], + ); - persistAuth(updatedAuth); - }, [auth, persistAuth]); + /** + * Login to GitHub Gitify OAuth App. + * + * Finalize device flow session. + */ + const loginWithDeviceFlowComplete = useCallback( + async (token: Token, hostname: Hostname) => { + const updatedAuth = await addAccount(auth, 'GitHub App', token, hostname); + + persistAuth(updatedAuth); + }, + [auth, persistAuth], + ); /** * Login with custom GitHub OAuth App. */ const loginWithOAuthApp = useCallback( - async (data: LoginOAuthAppOptions) => { - const { authOptions, authCode } = await performGitHubOAuth(data); + async (data: LoginOAuthWebOptions) => { + const { authOptions, authCode } = await performGitHubWebOAuth(data); const token = await exchangeAuthCodeForAccessToken(authCode, authOptions); const updatedAuth = await addAccount( @@ -496,7 +525,9 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { () => ({ auth, isLoggedIn, - loginWithGitHubApp, + loginWithDeviceFlowStart, + loginWithDeviceFlowPoll, + loginWithDeviceFlowComplete, loginWithOAuthApp, loginWithPersonalAccessToken, logoutFromAccount, @@ -526,7 +557,9 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { [ auth, isLoggedIn, - loginWithGitHubApp, + loginWithDeviceFlowStart, + loginWithDeviceFlowPoll, + loginWithDeviceFlowComplete, loginWithOAuthApp, loginWithPersonalAccessToken, logoutFromAccount, diff --git a/src/renderer/routes/Accounts.test.tsx b/src/renderer/routes/Accounts.test.tsx index 96c5f9702..b34dd3a91 100644 --- a/src/renderer/routes/Accounts.test.tsx +++ b/src/renderer/routes/Accounts.test.tsx @@ -198,19 +198,19 @@ describe('renderer/routes/Accounts.tsx', () => { describe('Add new accounts', () => { it('should show login with github app', async () => { - const loginWithGitHubAppMock = jest.fn(); - await act(async () => { renderWithAppContext(, { auth: { accounts: [mockOAuthAccount] }, - loginWithGitHubApp: loginWithGitHubAppMock, }); }); await userEvent.click(screen.getByTestId('account-add-new')); await userEvent.click(screen.getByTestId('account-add-github')); - expect(loginWithGitHubAppMock).toHaveBeenCalled(); + expect(navigateMock).toHaveBeenCalledTimes(1); + expect(navigateMock).toHaveBeenCalledWith('/login-device-flow', { + replace: true, + }); }); it('should show login with personal access token', async () => { diff --git a/src/renderer/routes/Accounts.tsx b/src/renderer/routes/Accounts.tsx index 73277cc10..8d9514ce6 100644 --- a/src/renderer/routes/Accounts.tsx +++ b/src/renderer/routes/Accounts.tsx @@ -43,14 +43,12 @@ import { openDeveloperSettings, openHost, } from '../utils/links'; -import { rendererLogError } from '../utils/logger'; import { saveState } from '../utils/storage'; export const AccountsRoute: FC = () => { const navigate = useNavigate(); - const { auth, settings, loginWithGitHubApp, logoutFromAccount } = - useAppContext(); + const { auth, settings, logoutFromAccount } = useAppContext(); const [loadingStates, setLoadingStates] = useState>( {}, @@ -64,13 +62,13 @@ export const AccountsRoute: FC = () => { [logoutFromAccount], ); - const setAsPrimaryAccount = useCallback((account: Account) => { + const setAsPrimaryAccount = (account: Account) => { auth.accounts = [account, ...auth.accounts.filter((a) => a !== account)]; saveState({ auth, settings }); navigate('/accounts', { replace: true }); - }, []); + }; - const handleRefresh = useCallback(async (account: Account) => { + const handleRefresh = async (account: Account) => { const accountUUID = getAccountUUID(account); setLoadingStates((prev) => ({ @@ -91,23 +89,19 @@ export const AccountsRoute: FC = () => { [accountUUID]: false, })); }, 500); - }, []); + }; - const loginWithGitHub = useCallback(async () => { - try { - await loginWithGitHubApp(); - } catch (err) { - rendererLogError('loginWithGitHub', 'failed to login with GitHub', err); - } - }, []); + const loginWithGitHub = async () => { + return navigate('/login-device-flow', { replace: true }); + }; - const loginWithPersonalAccessToken = useCallback(() => { + const loginWithPersonalAccessToken = () => { return navigate('/login-personal-access-token', { replace: true }); - }, []); + }; - const loginWithOAuthApp = useCallback(() => { + const loginWithOAuthApp = () => { return navigate('/login-oauth-app', { replace: true }); - }, []); + }; return ( diff --git a/src/renderer/routes/Login.test.tsx b/src/renderer/routes/Login.test.tsx index 3f1ac0f74..23e82af01 100644 --- a/src/renderer/routes/Login.test.tsx +++ b/src/renderer/routes/Login.test.tsx @@ -38,16 +38,14 @@ describe('renderer/routes/Login.tsx', () => { }); it('should login with github', async () => { - const loginWithGitHubAppMock = jest.fn(); - renderWithAppContext(, { isLoggedIn: false, - loginWithGitHubApp: loginWithGitHubAppMock, }); await userEvent.click(screen.getByTestId('login-github')); - expect(loginWithGitHubAppMock).toHaveBeenCalled(); + expect(navigateMock).toHaveBeenCalledTimes(1); + expect(navigateMock).toHaveBeenCalledWith('/login-device-flow'); }); it('should navigate to login with personal access token', async () => { diff --git a/src/renderer/routes/Login.tsx b/src/renderer/routes/Login.tsx index c8dbffda6..e690b18b9 100644 --- a/src/renderer/routes/Login.tsx +++ b/src/renderer/routes/Login.tsx @@ -1,4 +1,4 @@ -import { type FC, useCallback, useEffect } from 'react'; +import { type FC, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { KeyIcon, MarkGithubIcon, PersonIcon } from '@primer/octicons-react'; @@ -12,12 +12,11 @@ import { Centered } from '../components/layout/Centered'; import { Size } from '../types'; import { showWindow } from '../utils/comms'; -import { rendererLogError } from '../utils/logger'; export const LoginRoute: FC = () => { const navigate = useNavigate(); - const { loginWithGitHubApp, isLoggedIn } = useAppContext(); + const { isLoggedIn } = useAppContext(); useEffect(() => { if (isLoggedIn) { @@ -26,18 +25,6 @@ export const LoginRoute: FC = () => { } }, [isLoggedIn]); - const loginUser = useCallback(async () => { - try { - await loginWithGitHubApp(); - } catch (err) { - rendererLogError( - 'loginWithGitHubApp', - 'failed to login with GitHub', - err, - ); - } - }, [loginWithGitHubApp]); - return ( @@ -54,7 +41,7 @@ export const LoginRoute: FC = () => { + + {/* {session && ( + + )} */} + + + ); +}; diff --git a/src/renderer/routes/LoginWithOAuthApp.tsx b/src/renderer/routes/LoginWithOAuthApp.tsx index 6ba9b2cc0..2393ff2ae 100644 --- a/src/renderer/routes/LoginWithOAuthApp.tsx +++ b/src/renderer/routes/LoginWithOAuthApp.tsx @@ -28,7 +28,7 @@ import { Footer } from '../components/primitives/Footer'; import { Header } from '../components/primitives/Header'; import type { ClientID, ClientSecret, Hostname, Token } from '../types'; -import type { LoginOAuthAppOptions } from '../utils/auth/types'; +import type { LoginOAuthWebOptions } from '../utils/auth/types'; import { getNewOAuthAppURL, @@ -85,7 +85,7 @@ export const LoginWithOAuthAppRoute: FC = () => { const [isVerifyingCredentials, setIsVerifyingCredentials] = useState(false); const [formData, setFormData] = useState({ - hostname: 'github.com' as Hostname, + hostname: Constants.GITHUB_HOSTNAME, clientId: '' as ClientID, clientSecret: '' as ClientSecret, } as IFormData); @@ -116,7 +116,7 @@ export const LoginWithOAuthAppRoute: FC = () => { const verifyLoginCredentials = useCallback( async (data: IFormData) => { try { - await loginWithOAuthApp(data as LoginOAuthAppOptions); + await loginWithOAuthApp(data as LoginOAuthWebOptions); navigate(-1); } catch (err) { rendererLogError( diff --git a/src/renderer/routes/LoginWithPersonalAccessToken.tsx b/src/renderer/routes/LoginWithPersonalAccessToken.tsx index 3c1c43d9f..91a300545 100644 --- a/src/renderer/routes/LoginWithPersonalAccessToken.tsx +++ b/src/renderer/routes/LoginWithPersonalAccessToken.tsx @@ -78,7 +78,7 @@ export const LoginWithPersonalAccessTokenRoute: FC = () => { const [isVerifyingCredentials, setIsVerifyingCredentials] = useState(false); const [formData, setFormData] = useState({ - hostname: 'github.com' as Hostname, + hostname: Constants.GITHUB_HOSTNAME, token: '' as Token, } as IFormData); diff --git a/src/renderer/utils/api/__mocks__/response-mocks.ts b/src/renderer/utils/api/__mocks__/response-mocks.ts index a12521776..e81dc9c1c 100644 --- a/src/renderer/utils/api/__mocks__/response-mocks.ts +++ b/src/renderer/utils/api/__mocks__/response-mocks.ts @@ -20,7 +20,7 @@ export function mockRawUser(login: string): RawUser { login, id: 1, node_id: 'MDQ6VXNlcjE=', - avatar_url: 'https://avatars.githubusercontent.com/u/1?v=4' as Link, + avatar_url: 'https://avatars.githubusercontent.com/u/583231?v=4' as Link, gravatar_id: '', url: `https://api.github.com/users/${login}` as Link, html_url: `https://github.com/${login}` as Link, diff --git a/src/renderer/utils/api/utils.test.ts b/src/renderer/utils/api/utils.test.ts index a35dab86a..3cf2960d9 100644 --- a/src/renderer/utils/api/utils.test.ts +++ b/src/renderer/utils/api/utils.test.ts @@ -1,3 +1,5 @@ +import { Constants } from '../../constants'; + import type { Hostname } from '../../types'; import { getGitHubAPIBaseUrl } from './utils'; @@ -5,7 +7,7 @@ import { getGitHubAPIBaseUrl } from './utils'; describe('renderer/utils/api/utils.ts', () => { describe('getGitHubAPIBaseUrl', () => { it('should generate a GitHub REST API url - non enterprise', () => { - const result = getGitHubAPIBaseUrl('github.com' as Hostname, 'rest'); + const result = getGitHubAPIBaseUrl(Constants.GITHUB_HOSTNAME, 'rest'); expect(result.toString()).toBe('https://api.github.com/'); }); @@ -27,7 +29,7 @@ describe('renderer/utils/api/utils.ts', () => { }); it('should generate a GitHub GraphQL url - non enterprise', () => { - const result = getGitHubAPIBaseUrl('github.com' as Hostname, 'graphql'); + const result = getGitHubAPIBaseUrl(Constants.GITHUB_HOSTNAME, 'graphql'); expect(result.toString()).toBe('https://api.github.com/'); }); diff --git a/src/renderer/utils/auth/types.ts b/src/renderer/utils/auth/types.ts index 94e99908f..1b7be6db5 100644 --- a/src/renderer/utils/auth/types.ts +++ b/src/renderer/utils/auth/types.ts @@ -13,12 +13,28 @@ export type PlatformType = | 'GitHub Enterprise Server' | 'GitHub Enterprise Cloud with Data Residency'; -export interface LoginOAuthAppOptions { +export interface LoginOAuthWebOptions { hostname: Hostname; clientId: ClientID; clientSecret: ClientSecret; } +export interface DeviceFlowSession { + hostname: Hostname; + clientId: ClientID; + deviceCode: string; + userCode: string; + verificationUri: string; + intervalSeconds: number; + expiresAt: number; +} + +export type DeviceFlowErrorResponse = { + error: string; + error_description: string; + error_uri: string; +}; + export interface LoginPersonalAccessTokenOptions { hostname: Hostname; token: Token; @@ -27,5 +43,5 @@ export interface LoginPersonalAccessTokenOptions { export interface AuthResponse { authMethod: AuthMethod; authCode: AuthCode; - authOptions: LoginOAuthAppOptions; + authOptions: LoginOAuthWebOptions; } diff --git a/src/renderer/utils/auth/utils.test.ts b/src/renderer/utils/auth/utils.test.ts index dfc6c4c34..6b3a19e4a 100644 --- a/src/renderer/utils/auth/utils.test.ts +++ b/src/renderer/utils/auth/utils.test.ts @@ -14,7 +14,7 @@ import type { Link, Token, } from '../../types'; -import type { AuthMethod } from './types'; +import type { AuthMethod, LoginOAuthWebOptions } from './types'; import * as comms from '../../utils/comms'; import * as apiClient from '../api/client'; @@ -28,61 +28,92 @@ import { jest.mock('@octokit/oauth-methods', () => ({ ...jest.requireActual('@octokit/oauth-methods'), + createDeviceCode: jest.fn(), + exchangeDeviceCode: jest.fn(), exchangeWebFlowCode: jest.fn(), })); -import { exchangeWebFlowCode } from '@octokit/oauth-methods'; +import { + createDeviceCode, + exchangeDeviceCode, + exchangeWebFlowCode, +} from '@octokit/oauth-methods'; import type { GetAuthenticatedUserResponse } from '../api/types'; +const createDeviceCodeMock = createDeviceCode as jest.MockedFunction< + typeof createDeviceCode +>; +const exchangeDeviceCodeMock = exchangeDeviceCode as jest.MockedFunction< + typeof exchangeDeviceCode +>; const exchangeWebFlowCodeMock = exchangeWebFlowCode as jest.MockedFunction< typeof exchangeWebFlowCode >; describe('renderer/utils/auth/utils.ts', () => { - describe('authGitHub', () => { - jest.spyOn(logger, 'rendererLogInfo').mockImplementation(); - const openExternalLinkSpy = jest - .spyOn(comms, 'openExternalLink') - .mockImplementation(); + jest.spyOn(logger, 'rendererLogInfo').mockImplementation(); + const openExternalLinkSpy = jest + .spyOn(comms, 'openExternalLink') + .mockImplementation(); - afterEach(() => { - jest.clearAllMocks(); - }); + afterEach(() => { + jest.clearAllMocks(); + }); - it('should call performGitHubOAuth using gitify oauth app - success auth flow', async () => { - window.gitify.onAuthCallback = jest - .fn() - .mockImplementation((callback) => { - callback('gitify://auth?code=123-456'); - }); + describe('performGitHubDeviceOAuth', () => { + it('should authenticate using device flow for GitHub app', async () => { + createDeviceCodeMock.mockResolvedValueOnce({ + data: { + device_code: 'device-code', + user_code: 'user-code', + verification_uri: 'https://github.com/login/device', + expires_in: 900, + interval: 5, + }, + } as unknown as Awaited>); - const res = await authUtils.performGitHubOAuth(); + exchangeDeviceCodeMock.mockResolvedValueOnce({ + authentication: { + token: 'device-token', + }, + } as unknown as Awaited>); - expect(openExternalLinkSpy).toHaveBeenCalledTimes(1); - expect(openExternalLinkSpy).toHaveBeenCalledWith( - expect.stringContaining( - 'https://github.com/login/oauth/authorize?allow_signup=false&client_id=FAKE_CLIENT_ID_123&scope=read%3Auser%2Cnotifications%2Crepo', - ), - ); + const token = await authUtils.performGitHubDeviceOAuth(); - expect(window.gitify.onAuthCallback).toHaveBeenCalledTimes(1); - expect(window.gitify.onAuthCallback).toHaveBeenCalledWith( - expect.any(Function), - ); + expect(createDeviceCodeMock).toHaveBeenCalledWith({ + clientType: 'oauth-app', + clientId: 'FAKE_CLIENT_ID_123', + scopes: Constants.OAUTH_SCOPES.RECOMMENDED, + request: expect.any(Function), + }); - expect(res.authMethod).toBe('GitHub App'); - expect(res.authCode).toBe('123-456'); + expect(exchangeDeviceCodeMock).toHaveBeenCalledWith({ + clientType: 'oauth-app', + clientId: 'FAKE_CLIENT_ID_123', + code: 'device-code', + request: expect.any(Function), + }); + + expect(token).toBe('device-token'); }); + }); + + describe('performGitHubWebOAuth', () => { + const webAuthOptions: LoginOAuthWebOptions = { + hostname: 'github.com' as Hostname, + clientId: 'FAKE_CLIENT_ID_123' as ClientID, + clientSecret: 'FAKE_CLIENT_SECRET_123' as ClientSecret, + }; - it('should call performGitHubOAuth using custom oauth app - success oauth flow', async () => { + it('should call performGitHubWebOAuth using custom oauth app - success oauth flow', async () => { window.gitify.onAuthCallback = jest .fn() .mockImplementation((callback) => { callback('gitify://oauth?code=123-456'); }); - const res = await authUtils.performGitHubOAuth({ + const res = await authUtils.performGitHubWebOAuth({ clientId: 'BYO_CLIENT_ID' as ClientID, clientSecret: 'BYO_CLIENT_SECRET' as ClientSecret, hostname: 'my.git.com' as Hostname, @@ -104,7 +135,7 @@ describe('renderer/utils/auth/utils.ts', () => { expect(res.authCode).toBe('123-456'); }); - it('should call performGitHubOAuth - failure', async () => { + it('should call performGitHubWebOAuth - failure', async () => { window.gitify.onAuthCallback = jest .fn() .mockImplementation((callback) => { @@ -114,7 +145,7 @@ describe('renderer/utils/auth/utils.ts', () => { }); await expect( - async () => await authUtils.performGitHubOAuth(), + async () => await authUtils.performGitHubWebOAuth(webAuthOptions), ).rejects.toEqual( new Error( "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", @@ -133,31 +164,40 @@ describe('renderer/utils/auth/utils.ts', () => { expect.any(Function), ); }); - }); - describe('exchangeAuthCodeForAccessToken', () => { - const authCode = '123-456' as AuthCode; + describe('exchangeAuthCodeForAccessToken', () => { + const authCode = '123-456' as AuthCode; - it('should exchange auth code for access token', async () => { - exchangeWebFlowCodeMock.mockResolvedValueOnce({ - authentication: { - token: 'this-is-a-token', - }, - } as any); + it('should exchange auth code for access token', async () => { + exchangeWebFlowCodeMock.mockResolvedValueOnce({ + authentication: { + token: 'this-is-a-token', + }, + } as unknown as Awaited>); - const res = await authUtils.exchangeAuthCodeForAccessToken( - authCode, - Constants.DEFAULT_AUTH_OPTIONS, - ); + const res = await authUtils.exchangeAuthCodeForAccessToken(authCode, { + ...webAuthOptions, + }); - expect(exchangeWebFlowCodeMock).toHaveBeenCalledWith({ - clientType: 'oauth-app', - clientId: 'FAKE_CLIENT_ID_123', - clientSecret: 'FAKE_CLIENT_SECRET_123', - code: '123-456', - request: expect.any(Function), + expect(exchangeWebFlowCodeMock).toHaveBeenCalledWith({ + clientType: 'oauth-app', + clientId: 'FAKE_CLIENT_ID_123', + clientSecret: 'FAKE_CLIENT_SECRET_123', + code: '123-456', + request: expect.any(Function), + }); + expect(res).toBe('this-is-a-token'); + }); + + it('should throw when client secret is missing', async () => { + await expect( + async () => + await authUtils.exchangeAuthCodeForAccessToken(authCode, { + ...webAuthOptions, + clientSecret: undefined as unknown as ClientSecret, + }), + ).rejects.toThrow('clientSecret is required to exchange an auth code'); }); - expect(res).toBe('this-is-a-token'); }); }); @@ -517,7 +557,7 @@ describe('renderer/utils/auth/utils.ts', () => { it('should use default hostname if no accounts', () => { expect(authUtils.getPrimaryAccountHostname({ accounts: [] })).toBe( - Constants.DEFAULT_AUTH_OPTIONS.hostname, + Constants.GITHUB_HOSTNAME, ); }); }); diff --git a/src/renderer/utils/auth/utils.ts b/src/renderer/utils/auth/utils.ts index 65254b33e..0ab0b6718 100644 --- a/src/renderer/utils/auth/utils.ts +++ b/src/renderer/utils/auth/utils.ts @@ -1,8 +1,11 @@ import { + createDeviceCode, + exchangeDeviceCode, exchangeWebFlowCode, getWebFlowAuthorizationUrl, } from '@octokit/oauth-methods'; import { request } from '@octokit/request'; +import { RequestError } from '@octokit/request-error'; import { format } from 'date-fns'; import semver from 'semver'; @@ -21,15 +24,21 @@ import type { Link, Token, } from '../../types'; -import type { AuthMethod, AuthResponse, LoginOAuthAppOptions } from './types'; +import type { + AuthMethod, + AuthResponse, + DeviceFlowErrorResponse, + DeviceFlowSession, + LoginOAuthWebOptions, +} from './types'; import { fetchAuthenticatedUserDetails } from '../api/client'; import { encryptValue, openExternalLink } from '../comms'; import { getPlatformFromHostname } from '../helpers'; import { rendererLogError, rendererLogInfo, rendererLogWarn } from '../logger'; -export function performGitHubOAuth( - authOptions: LoginOAuthAppOptions = Constants.DEFAULT_AUTH_OPTIONS, +export function performGitHubWebOAuth( + authOptions: LoginOAuthWebOptions, ): Promise { return new Promise((resolve, reject) => { const { url } = getWebFlowAuthorizationUrl({ @@ -53,12 +62,9 @@ export function performGitHubOAuth( const errorDescription = url.searchParams.get('error_description'); const errorUri = url.searchParams.get('error_uri'); - if (code && (type === 'auth' || type === 'oauth')) { - const authMethod: AuthMethod = - type === 'auth' ? 'GitHub App' : 'OAuth App'; - + if (code && type === 'oauth') { resolve({ - authMethod: authMethod, + authMethod: 'OAuth App', authCode: code as AuthCode, authOptions: authOptions, }); @@ -81,10 +87,87 @@ export function performGitHubOAuth( }); } +export async function startGitHubDeviceFlow(): Promise { + const deviceCode = await createDeviceCode({ + clientType: 'oauth-app' as const, + clientId: Constants.OAUTH_DEVICE_FLOW_CLIENT_ID, + scopes: Constants.OAUTH_SCOPES.RECOMMENDED, + request: request.defaults({ + baseUrl: getGitHubAuthBaseUrl(Constants.GITHUB_HOSTNAME).toString(), + }), + }); + + return { + hostname: Constants.GITHUB_HOSTNAME, + clientId: Constants.OAUTH_DEVICE_FLOW_CLIENT_ID, + deviceCode: deviceCode.data.device_code, + userCode: deviceCode.data.user_code, + verificationUri: deviceCode.data.verification_uri, + intervalSeconds: deviceCode.data.interval, + expiresAt: Date.now() + deviceCode.data.expires_in * 1000, + } as DeviceFlowSession; +} + +export async function pollGitHubDeviceFlow( + session: DeviceFlowSession, +): Promise { + try { + const { authentication } = await exchangeDeviceCode({ + clientType: 'oauth-app' as const, + clientId: session.clientId, + code: session.deviceCode, + request: request.defaults({ + baseUrl: getGitHubAuthBaseUrl(session.hostname).toString(), + }), + }); + + return authentication.token as Token; + } catch (err) { + if (err instanceof RequestError) { + const response = err.response.data as DeviceFlowErrorResponse; + const errorCode = response.error; + + if (errorCode === 'authorization_pending' || errorCode === 'slow_down') { + return null; + } + } + + rendererLogError( + 'pollGitHubDeviceFlow', + 'Error exchanging device code', + err, + ); + + throw err; + } +} + +export async function performGitHubDeviceOAuth(): Promise { + const session = await startGitHubDeviceFlow(); + + const intervalMs = Math.max(5000, session.intervalSeconds * 1000); + + while (Date.now() < session.expiresAt) { + const token = await pollGitHubDeviceFlow(session); + + if (token) { + return token; + } + + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + + throw new Error('Device code expired before authorization completed'); +} + export async function exchangeAuthCodeForAccessToken( authCode: AuthCode, - authOptions: LoginOAuthAppOptions = Constants.DEFAULT_AUTH_OPTIONS, + authOptions: LoginOAuthWebOptions, ): Promise { + if (!authOptions.clientSecret) { + throw new Error('clientSecret is required to exchange an auth code'); + } + const { authentication } = await exchangeWebFlowCode({ clientType: 'oauth-app', clientId: authOptions.clientId, @@ -305,7 +388,7 @@ export function getAccountUUID(account: Account): AccountUUID { * Return the primary (first) account hostname */ export function getPrimaryAccountHostname(auth: AuthState) { - return auth.accounts[0]?.hostname ?? Constants.DEFAULT_AUTH_OPTIONS.hostname; + return auth.accounts[0]?.hostname ?? Constants.GITHUB_HOSTNAME; } export function hasAccounts(auth: AuthState) { diff --git a/src/renderer/utils/comms.test.ts b/src/renderer/utils/comms.test.ts index 080020c98..b6f96d9b6 100644 --- a/src/renderer/utils/comms.test.ts +++ b/src/renderer/utils/comms.test.ts @@ -3,6 +3,7 @@ import { mockSettings } from '../__mocks__/state-mocks'; import { type Link, OpenPreference } from '../types'; import { + copyToClipboard, decryptValue, encryptValue, getAppVersion, @@ -156,4 +157,11 @@ describe('renderer/utils/comms.ts', () => { expect(window.gitify.tray.updateTitle).toHaveBeenCalledWith('gitify'); }); }); + + it('copy to clipboard', async () => { + copyToClipboard('some-value'); + + expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(1); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('some-value'); + }); }); diff --git a/src/renderer/utils/comms.ts b/src/renderer/utils/comms.ts index 5ffa3b64c..836294a06 100644 --- a/src/renderer/utils/comms.ts +++ b/src/renderer/utils/comms.ts @@ -78,3 +78,7 @@ export function updateTrayColor(notificationsLength: number): void { export function updateTrayTitle(title: string): void { window.gitify.tray.updateTitle(title); } + +export async function copyToClipboard(text: string): Promise { + await navigator.clipboard.writeText(text); +} diff --git a/src/renderer/utils/helpers.ts b/src/renderer/utils/helpers.ts index 3c9113f77..d00f42d3c 100644 --- a/src/renderer/utils/helpers.ts +++ b/src/renderer/utils/helpers.ts @@ -20,7 +20,7 @@ export interface ParsedCodePart { } export function getPlatformFromHostname(hostname: string): PlatformType { - if (hostname.endsWith(Constants.DEFAULT_AUTH_OPTIONS.hostname)) { + if (hostname.endsWith(Constants.GITHUB_HOSTNAME)) { return 'GitHub Cloud'; } diff --git a/src/renderer/utils/links.test.ts b/src/renderer/utils/links.test.ts index 8c50feb20..e910265ec 100644 --- a/src/renderer/utils/links.test.ts +++ b/src/renderer/utils/links.test.ts @@ -4,7 +4,7 @@ import { mockGitifyNotificationUser } from '../__mocks__/user-mocks'; import { Constants } from '../constants'; -import type { GitifyRepository, Hostname, Link } from '../types'; +import type { GitifyRepository, Link } from '../types'; import * as authUtils from './auth/utils'; import * as comms from './comms'; @@ -83,7 +83,7 @@ describe('renderer/utils/links.ts', () => { }); it('openHost', () => { - openHost('github.com' as Hostname); + openHost(Constants.GITHUB_HOSTNAME); expect(openExternalLinkSpy).toHaveBeenCalledWith('https://github.com'); }); diff --git a/src/renderer/utils/storage.test.ts b/src/renderer/utils/storage.test.ts index f4e69377b..4069caafb 100644 --- a/src/renderer/utils/storage.test.ts +++ b/src/renderer/utils/storage.test.ts @@ -1,9 +1,6 @@ +import { mockGitHubCloudAccount } from '../__mocks__/account-mocks'; import { mockSettings } from '../__mocks__/state-mocks'; -import { Constants } from '../constants'; - -import type { Token } from '../types'; - import { clearState, loadState, saveState } from './storage'; describe('renderer/utils/storage.ts', () => { @@ -11,30 +8,14 @@ describe('renderer/utils/storage.ts', () => { jest.spyOn(Storage.prototype, 'getItem').mockReturnValueOnce( JSON.stringify({ auth: { - accounts: [ - { - hostname: Constants.DEFAULT_AUTH_OPTIONS.hostname, - platform: 'GitHub Cloud', - method: 'Personal Access Token', - token: '123-456' as Token, - user: null, - }, - ], + accounts: [mockGitHubCloudAccount], }, settings: { theme: 'DARK_DEFAULT' }, }), ); const result = loadState(); - expect(result.auth.accounts).toEqual([ - { - hostname: Constants.DEFAULT_AUTH_OPTIONS.hostname, - platform: 'GitHub Cloud', - method: 'Personal Access Token', - token: '123-456' as Token, - user: null, - }, - ]); + expect(result.auth.accounts).toEqual([mockGitHubCloudAccount]); expect(result.settings.theme).toBe('DARK_DEFAULT'); }); @@ -53,15 +34,7 @@ describe('renderer/utils/storage.ts', () => { saveState({ auth: { - accounts: [ - { - hostname: Constants.DEFAULT_AUTH_OPTIONS.hostname, - platform: 'GitHub Cloud', - method: 'Personal Access Token', - token: '123-456' as Token, - user: null, - }, - ], + accounts: [mockGitHubCloudAccount], }, settings: mockSettings, });