• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

gitify-app / gitify / 13088951820

01 Feb 2025 01:11PM CUT coverage: 87.618% (-0.1%) from 87.75%
13088951820

Pull #1801

github

web-flow
Merge 844edc863 into db1ba7308
Pull Request #1801: feat: gitify dev protocol

639 of 716 branches covered (89.25%)

Branch coverage included in aggregate %.

0 of 2 new or added lines in 1 file covered. (0.0%)

1689 of 1941 relevant lines covered (87.02%)

23.3 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

92.59
/src/renderer/utils/auth/utils.ts
1
import { format } from 'date-fns';
62✔
2
import semver from 'semver';
62✔
3

4
import { ipcRenderer } from 'electron';
62✔
5
import { APPLICATION } from '../../../shared/constants';
62✔
6
import { namespacedEvent } from '../../../shared/events';
62✔
7
import { logError, logWarn } from '../../../shared/logger';
62✔
8
import type {
9
  Account,
10
  AuthCode,
11
  AuthState,
12
  ClientID,
13
  GitifyUser,
14
  Hostname,
15
  Link,
16
  Token,
17
} from '../../types';
18
import type { UserDetails } from '../../typesGitHub';
19
import { getAuthenticatedUser } from '../api/client';
62✔
20
import { apiRequest } from '../api/request';
62✔
21
import { openExternalLink } from '../comms';
62✔
22
import { Constants } from '../constants';
62✔
23
import { getPlatformFromHostname } from '../helpers';
62✔
24
import type { AuthMethod, AuthResponse, AuthTokenResponse } from './types';
25

26
export function authGitHub(
62✔
27
  authOptions = Constants.DEFAULT_AUTH_OPTIONS,
2✔
28
): Promise<AuthResponse> {
29
  return new Promise((resolve, reject) => {
6✔
30
    const authUrl = new URL(`https://${authOptions.hostname}`);
6✔
31
    authUrl.pathname = '/login/oauth/authorize';
6✔
32
    authUrl.searchParams.append('client_id', authOptions.clientId);
6✔
33
    authUrl.searchParams.append('scope', Constants.AUTH_SCOPE.toString());
6✔
34

35
    openExternalLink(authUrl.toString() as Link);
6✔
36

37
    const handleCallback = (callbackUrl: string) => {
6✔
38
      const url = new URL(callbackUrl);
6✔
39

40
      const type = url.hostname;
6✔
41
      const code = url.searchParams.get('code');
6✔
42
      const error = url.searchParams.get('error');
6✔
43
      const errorDescription = url.searchParams.get('error_description');
6✔
44
      const errorUri = url.searchParams.get('error_uri');
6✔
45

46
      if (code && (type === 'auth' || type === 'oauth')) {
6✔
47
        const authMethod: AuthMethod =
48
          type === 'auth' ? 'GitHub App' : 'OAuth App';
4✔
49

50
        resolve({
4✔
51
          authMethod: authMethod,
52
          authCode: code as AuthCode,
53
          authOptions: authOptions,
54
        });
55
      } else if (error) {
2✔
56
        reject(
2✔
57
          `Oops! Something went wrong and we couldn't log you in using GitHub. Please try again. Reason: ${errorDescription} Docs: ${errorUri}`,
58
        );
59
      }
60
    };
61

62
    ipcRenderer.on(
6✔
63
      namespacedEvent('auth-callback'),
64
      (_, callbackUrl: string) => {
65
        handleCallback(callbackUrl);
6✔
66
      },
67
    );
68
  });
69
}
70

71
export async function getUserData(
62✔
72
  token: Token,
73
  hostname: Hostname,
74
): Promise<GitifyUser> {
75
  const response: UserDetails = (await getAuthenticatedUser(hostname, token))
×
76
    .data;
77

78
  return {
×
79
    id: response.id,
80
    login: response.login,
81
    name: response.name,
82
    avatar: response.avatar_url,
83
  };
84
}
85

86
export async function getToken(
62✔
87
  authCode: AuthCode,
88
  authOptions = Constants.DEFAULT_AUTH_OPTIONS,
2✔
89
): Promise<AuthTokenResponse> {
90
  const url =
91
    `https://${authOptions.hostname}/login/oauth/access_token` as Link;
4✔
92
  const data = {
4✔
93
    client_id: authOptions.clientId,
94
    client_secret: authOptions.clientSecret,
95
    code: authCode,
96
  };
97

98
  const response = await apiRequest(url, 'POST', data);
4✔
99
  return {
2✔
100
    hostname: authOptions.hostname,
101
    token: response.data.access_token,
102
  };
103
}
104

105
export async function addAccount(
62✔
106
  auth: AuthState,
107
  method: AuthMethod,
108
  token: Token,
109
  hostname: Hostname,
110
): Promise<AuthState> {
111
  const accountList = auth.accounts;
10✔
112

113
  let newAccount = {
10✔
114
    hostname: hostname,
115
    method: method,
116
    platform: getPlatformFromHostname(hostname),
117
    token: token,
118
  } as Account;
119

120
  newAccount = await refreshAccount(newAccount);
10✔
121
  const newAccountUUID = getAccountUUID(newAccount);
8✔
122

123
  const accountAlreadyExists = accountList.some(
8✔
124
    (a) => getAccountUUID(a) === newAccountUUID,
×
125
  );
126

127
  if (accountAlreadyExists) {
8!
128
    logWarn(
×
129
      'addAccount',
130
      `account for user ${newAccount.user.login} already exists`,
131
    );
132
  } else {
133
    accountList.push(newAccount);
8✔
134
  }
135

136
  return {
8✔
137
    accounts: accountList,
138
  };
139
}
140

141
export function removeAccount(auth: AuthState, account: Account): AuthState {
62✔
142
  const updatedAccounts = auth.accounts.filter(
4✔
143
    (a) => a.token !== account.token,
8✔
144
  );
145

146
  return {
4✔
147
    accounts: updatedAccounts,
148
  };
149
}
150

151
export async function refreshAccount(account: Account): Promise<Account> {
62✔
152
  try {
12✔
153
    const res = await getAuthenticatedUser(account.hostname, account.token);
12✔
154

155
    // Refresh user data
156
    account.user = {
8✔
157
      id: res.data.id,
158
      login: res.data.login,
159
      name: res.data.name,
160
      avatar: res.data.avatar_url,
161
    };
162

163
    account.version = extractHostVersion(
8✔
164
      res.headers['x-github-enterprise-version'],
165
    );
166

167
    const accountScopes = res.headers['x-oauth-scopes']
8✔
168
      ?.split(',')
169
      .map((scope: string) => scope.trim());
×
170

171
    account.hasRequiredScopes = Constants.AUTH_SCOPE.every((scope) =>
8✔
172
      accountScopes.includes(scope),
8✔
173
    );
174

175
    if (!account.hasRequiredScopes) {
×
176
      logWarn(
×
177
        'refreshAccount',
178
        `account for user ${account.user.login} is missing required scopes`,
179
      );
180
    }
181
  } catch (err) {
182
    logError(
10✔
183
      'refreshAccount',
184
      `failed to refresh account for user ${account.user.login}`,
185
      err,
186
    );
187
  }
188

189
  return account;
10✔
190
}
191

192
export function extractHostVersion(version: string | null): string {
62✔
193
  if (version) {
28✔
194
    return semver.valid(semver.coerce(version));
22✔
195
  }
196

197
  return 'latest';
6✔
198
}
199

200
export function getDeveloperSettingsURL(account: Account): Link {
62✔
201
  const settingsURL = new URL(`https://${account.hostname}`);
12✔
202

203
  switch (account.method) {
12✔
204
    case 'GitHub App':
205
      settingsURL.pathname =
2✔
206
        '/settings/connections/applications/27a352516d3341cee376';
207
      break;
2✔
208
    case 'OAuth App':
209
      settingsURL.pathname = '/settings/developers';
2✔
210
      break;
2✔
211
    case 'Personal Access Token':
212
      settingsURL.pathname = '/settings/tokens';
6✔
213
      break;
6✔
214
    default:
215
      settingsURL.pathname = '/settings';
2✔
216
      break;
2✔
217
  }
218
  return settingsURL.toString() as Link;
12✔
219
}
220

221
export function getNewTokenURL(hostname: Hostname): Link {
62✔
222
  const date = format(new Date(), 'PP p');
6✔
223
  const newTokenURL = new URL(`https://${hostname}/settings/tokens/new`);
6✔
224
  newTokenURL.searchParams.append(
6✔
225
    'description',
226
    `${APPLICATION.NAME} (Created on ${date})`,
227
  );
228
  newTokenURL.searchParams.append('scopes', Constants.AUTH_SCOPE.join(','));
6✔
229

230
  return newTokenURL.toString() as Link;
6✔
231
}
232

233
export function getNewOAuthAppURL(hostname: Hostname): Link {
62✔
234
  const date = format(new Date(), 'PP p');
6✔
235
  const newOAuthAppURL = new URL(
6✔
236
    `https://${hostname}/settings/applications/new`,
237
  );
238
  newOAuthAppURL.searchParams.append(
6✔
239
    'oauth_application[name]',
240
    `${APPLICATION.NAME} (Created on ${date})`,
241
  );
242
  newOAuthAppURL.searchParams.append(
6✔
243
    'oauth_application[url]',
244
    'https://gitify.io',
245
  );
246
  newOAuthAppURL.searchParams.append(
6✔
247
    'oauth_application[callback_url]',
248
    'gitify://oauth',
249
  );
250

251
  return newOAuthAppURL.toString() as Link;
6✔
252
}
253

254
export function isValidHostname(hostname: Hostname) {
62✔
255
  return /^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,6}$/i.test(
28✔
256
    hostname,
257
  );
258
}
259

260
export function isValidClientId(clientId: ClientID) {
62✔
261
  return /^[A-Z0-9_]{20}$/i.test(clientId);
16✔
262
}
263

264
export function isValidToken(token: Token) {
62✔
265
  return /^[A-Z0-9_]{40}$/i.test(token);
26✔
266
}
267

268
export function getAccountUUID(account: Account): string {
62✔
269
  return btoa(`${account.hostname}-${account.user.id}-${account.method}`);
64✔
270
}
271

272
export function hasAccounts(auth: AuthState) {
62✔
273
  return auth.accounts.length > 0;
24✔
274
}
275

276
export function hasMultipleAccounts(auth: AuthState) {
62✔
277
  return auth.accounts.length > 1;
28✔
278
}
279

280
export function formatRequiredScopes() {
62✔
281
  return Constants.AUTH_SCOPE.join(', ');
74✔
282
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc