Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/renderer/__mocks__/state-mocks.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Constants } from '../constants';

import {
type Account,
type AppearanceSettingsState,
type AuthState,
FetchType,
GroupBy,
type KeyboardAcceleratorShortcut,
Expand All @@ -18,7 +18,7 @@ import {

import { mockGitHubCloudAccount, mockGitHubEnterpriseServerAccount } from './account-mocks';

export const mockAuth: AuthState = {
export const mockAuth: { accounts: Account[] } = {
accounts: [mockGitHubCloudAccount, mockGitHubEnterpriseServerAccount],
};

Expand Down
32 changes: 13 additions & 19 deletions src/renderer/hooks/useLogins.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,9 @@ import { Constants } from '../constants';

import { useAccountsStore } from '../stores';

import type { AuthState, ClientID, ClientSecret, Token } from '../types';
import type { ClientID, ClientSecret, Token } from '../types';
import type { DeviceFlowSession } from '../utils/auth/types';

import * as authUtils from '../utils/auth/utils';
import { getAdapter } from '../utils/forges/registry';
import { useLogins } from './useLogins';

Expand All @@ -36,19 +35,20 @@ const renderLoginsHook = () => renderHook(() => useLogins(), { wrapper: createWr

describe('renderer/hooks/useLogins.ts', () => {
const removeAccountNotificationsMock = vi.fn();
let createAccountSpy: ReturnType<typeof vi.spyOn>;
let removeAccountSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
removeAccountNotificationsMock.mockReset();
setNotificationsOverrides({ removeAccountNotifications: removeAccountNotificationsMock });
});

const addAccountSpy = vi
.spyOn(authUtils, 'addAccount')
.mockImplementation(vi.fn())
.mockResolvedValueOnce({
accounts: [mockGitHubCloudAccount],
} as unknown as AuthState);
const removeAccountSpy = vi.spyOn(authUtils, 'removeAccount');
createAccountSpy = vi
.spyOn(useAccountsStore.getState(), 'createAccount')
.mockResolvedValue(undefined);
createAccountSpy.mockClear();
removeAccountSpy = vi.spyOn(useAccountsStore.getState(), 'removeAccount');
removeAccountSpy.mockClear();
});

it('loginWithDeviceFlowStart delegates to the forge adapter', async () => {
const adapter = getAdapter('github');
Expand Down Expand Up @@ -76,7 +76,7 @@ describe('renderer/hooks/useLogins.ts', () => {
expect(pollSpy).toHaveBeenCalledWith('session');
});

it('loginWithDeviceFlowComplete calls addAccount', async () => {
it('loginWithDeviceFlowComplete creates the account', async () => {
const { result } = renderLoginsHook();

await act(async () => {
Expand All @@ -87,13 +87,7 @@ describe('renderer/hooks/useLogins.ts', () => {
);
});

expect(addAccountSpy).toHaveBeenCalledWith(
expect.anything(),
'GitHub App',
'token',
'github.com',
'github',
);
expect(createAccountSpy).toHaveBeenCalledWith('GitHub App', 'token', 'github.com', 'github');
});

it('loginWithOAuthApp delegates to the forge adapter', async () => {
Expand Down Expand Up @@ -163,6 +157,6 @@ describe('renderer/hooks/useLogins.ts', () => {
});

expect(removeAccountNotificationsMock).toHaveBeenCalledWith(mockGitHubCloudAccount);
expect(removeAccountSpy).toHaveBeenCalledWith(expect.anything(), mockGitHubCloudAccount);
expect(removeAccountSpy).toHaveBeenCalledWith(mockGitHubCloudAccount);
});
});
160 changes: 160 additions & 0 deletions src/renderer/stores/useAccountsStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,16 @@ import {
mockGitHubCloudAccount,
mockGitHubEnterpriseServerAccount,
} from '../__mocks__/account-mocks';
import { mockRawUser } from '../utils/forges/github/__mocks__/response-mocks';

import { Constants } from '../constants';

import type { Account, Hostname, Link, Token } from '../types';
import type { GetAuthenticatedUserResponse } from '../utils/forges/github/types';

import { getRecommendedScopeNames } from '../utils/auth/scopes';
import * as logger from '../utils/core/logger';
import * as apiClient from '../utils/forges/github/client';
import { DEFAULT_ACCOUNTS_STATE } from './defaults';
import useAccountsStore from './useAccountsStore';

Expand All @@ -19,6 +28,139 @@ describe('renderer/stores/useAccountsStore.ts', () => {
expect(result.current).toMatchObject(DEFAULT_ACCOUNTS_STATE);
});

describe('createAccount', () => {
vi.spyOn(logger, 'rendererLogInfo').mockImplementation(vi.fn());

const mockAuthenticatedResponse = mockRawUser('authenticated-user');

const fetchAuthenticatedUserDetailsSpy = vi.spyOn(apiClient, 'fetchAuthenticatedUserDetails');

const expectedUser = () => ({
id: String(mockAuthenticatedResponse.id),
name: mockAuthenticatedResponse.name ?? null,
login: mockAuthenticatedResponse.login,
avatar: mockAuthenticatedResponse.avatar_url as Link,
});

describe('GitHub Cloud accounts', () => {
beforeEach(() => {
fetchAuthenticatedUserDetailsSpy.mockResolvedValue({
status: 200,
url: 'https://api.github.com/user',
data: mockAuthenticatedResponse as GetAuthenticatedUserResponse,
headers: {
'x-oauth-scopes': getRecommendedScopeNames().join(', '),
},
});
});

test('should add personal access token account', async () => {
await useAccountsStore
.getState()
.createAccount('Personal Access Token', '123-456' as Token, 'github.com' as Hostname);

expect(useAccountsStore.getState().accounts).toEqual([
{
forge: 'github',
hostname: 'github.com' as Hostname,
method: 'Personal Access Token',
platform: 'GitHub Cloud',
scopes: getRecommendedScopeNames(),
token: 'encrypted' as Token,
user: expectedUser(),
version: 'latest',
} satisfies Account,
]);
});

test('should add oauth app account', async () => {
await useAccountsStore
.getState()
.createAccount('OAuth App', '123-456' as Token, 'github.com' as Hostname);

expect(useAccountsStore.getState().accounts).toEqual([
{
forge: 'github',
hostname: 'github.com' as Hostname,
method: 'OAuth App',
platform: 'GitHub Cloud',
scopes: getRecommendedScopeNames(),
token: 'encrypted' as Token,
user: expectedUser(),
version: 'latest',
} satisfies Account,
]);
});

test('should replace an existing account on re-authentication', async () => {
await useAccountsStore
.getState()
.createAccount('Personal Access Token', '123-456' as Token, 'github.com' as Hostname);
await useAccountsStore
.getState()
.createAccount('Personal Access Token', '789-000' as Token, 'github.com' as Hostname);

expect(useAccountsStore.getState().accounts).toHaveLength(1);
});
});

describe('GitHub Enterprise Server accounts', () => {
beforeEach(() => {
fetchAuthenticatedUserDetailsSpy.mockResolvedValue({
status: 200,
url: 'https://github.gitify.io/api/v3/user',
data: mockAuthenticatedResponse as GetAuthenticatedUserResponse,
headers: {
'x-github-enterprise-version': '3.0.0',
'x-oauth-scopes': getRecommendedScopeNames().join(', '),
},
});
});

test('should add personal access token account', async () => {
await useAccountsStore
.getState()
.createAccount(
'Personal Access Token',
'123-456' as Token,
'github.gitify.io' as Hostname,
);

expect(useAccountsStore.getState().accounts).toEqual([
{
forge: 'github',
hostname: 'github.gitify.io' as Hostname,
method: 'Personal Access Token',
platform: 'GitHub Enterprise Server',
scopes: getRecommendedScopeNames(),
token: 'encrypted' as Token,
user: expectedUser(),
version: '3.0.0',
} satisfies Account,
]);
});

test('should add oauth app account', async () => {
await useAccountsStore
.getState()
.createAccount('OAuth App', '123-456' as Token, 'github.gitify.io' as Hostname);

expect(useAccountsStore.getState().accounts).toEqual([
{
forge: 'github',
hostname: 'github.gitify.io' as Hostname,
method: 'OAuth App',
platform: 'GitHub Enterprise Server',
scopes: getRecommendedScopeNames(),
token: 'encrypted' as Token,
user: expectedUser(),
version: '3.0.0',
} satisfies Account,
]);
});
});
});

describe('removeAccount', () => {
test('should remove an account', () => {
useAccountsStore.setState({
Expand Down Expand Up @@ -89,6 +231,24 @@ describe('renderer/stores/useAccountsStore.ts', () => {
});
});

describe('primaryAccountHostname', () => {
test('should return first (primary) account hostname when multiple', () => {
useAccountsStore.setState({
accounts: [mockGitHubCloudAccount, mockGitHubEnterpriseServerAccount],
});

const { result } = renderHook(() => useAccountsStore());

expect(result.current.primaryAccountHostname()).toBe(mockGitHubCloudAccount.hostname);
});

test('should use default hostname if no accounts', () => {
const { result } = renderHook(() => useAccountsStore());

expect(result.current.primaryAccountHostname()).toBe(Constants.GITHUB_HOSTNAME);
});
});

describe('reset', () => {
test('should reset accounts to default', () => {
useAccountsStore.setState({
Expand Down
66 changes: 43 additions & 23 deletions src/renderer/stores/useAccountsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,9 @@ import type { Account, Forge, Hostname, Token } from '../types';
import type { AuthMethod } from '../utils/auth/types';
import type { AccountsState, AccountsStore } from './types';

import {
addAccount,
getAccountUUID,
getPrimaryAccountHostname,
hasAccounts,
hasMultipleAccounts,
isValidHostname,
refreshAccount,
removeAccount,
} from '../utils/auth/utils';
import { rendererLogWarn } from '../utils/core/logger';
import { resolvePlatform } from '../utils/auth/platform';
import { getAccountUUID, isValidHostname, refreshAccount } from '../utils/auth/utils';
import { rendererLogInfo, rendererLogWarn } from '../utils/core/logger';
import { getAdapter, isKnownForge } from '../utils/forges/registry';
import { decryptValue, encryptValue } from '../utils/system/comms';
import { DEFAULT_ACCOUNTS_STATE } from './defaults';
Expand Down Expand Up @@ -64,15 +56,43 @@ const useAccountsStore = create<AccountsStore>()(
hostname: Hostname,
forge: Forge = 'github',
) => {
const updated = await addAccount(
{ accounts: [...get().accounts] },
method,
token,
hostname,
const encryptedToken = (await encryptValue(token)) as Token;

let newAccount: Account = {
forge,
hostname: hostname,
method: method,
platform: resolvePlatform(forge, hostname),
token: encryptedToken,
user: null, // Will be updated during the refresh call below
};

newAccount = await refreshAccount(newAccount);
const newAccountUUID = getAccountUUID(newAccount);

const accounts = get().accounts;
const existingAccount = accounts.find(
(account) => getAccountUUID(account) === newAccountUUID,
);

set({ accounts: updated.accounts });
if (existingAccount) {
// Drop any forge-specific HTTP client cache so the new token is used.
getAdapter(existingAccount).onAccountTokenChange?.(existingAccount);

// Replace the existing account (e.g. re-authentication with a new token)
rendererLogInfo(
'createAccount',
`updating existing account for user ${newAccount.user?.login}`,
);

set({
accounts: accounts.map((account) =>
getAccountUUID(account) === newAccountUUID ? newAccount : account,
),
});
} else {
set({ accounts: [...accounts, newAccount] });
}
},

refreshAccount: async (account: Account): Promise<Account> => {
Expand All @@ -90,12 +110,12 @@ const useAccountsStore = create<AccountsStore>()(
},

removeAccount: (account) => {
const updated = removeAccount({ accounts: get().accounts }, account);

// Drop any forge-specific HTTP client state for the removed account.
getAdapter(account).onAccountTokenChange?.(account);

set({ accounts: updated.accounts });
set((state) => ({
accounts: state.accounts.filter((a) => a.token !== account.token),
}));
},

migrateAccountTokens: async () => {
Expand Down Expand Up @@ -134,15 +154,15 @@ const useAccountsStore = create<AccountsStore>()(
},

isLoggedIn: () => {
return hasAccounts({ accounts: get().accounts });
return get().accounts.length > 0;
},

hasMultipleAccounts: () => {
return hasMultipleAccounts({ accounts: get().accounts });
return get().accounts.length > 1;
},

primaryAccountHostname: () => {
return getPrimaryAccountHostname({ accounts: get().accounts });
return get().accounts[0]?.hostname ?? Constants.GITHUB_HOSTNAME;
},

reset: () => {
Expand Down
4 changes: 0 additions & 4 deletions src/renderer/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,10 +133,6 @@ export interface SystemSettingsState {
keepWindowOnBlur: boolean;
}

export interface AuthState {
accounts: Account[];
}

export enum Theme {
SYSTEM = 'SYSTEM',
LIGHT = 'LIGHT',
Expand Down
Loading