Skip to content

Commit f2478cb

Browse files
authored
Reset Github state on auth mismatch (#5179)
* reset the github auth state on mismatch * toast inside else * promise.resolve * unit tests * no user details actions
1 parent 3304d32 commit f2478cb

File tree

2 files changed

+270
-23
lines changed

2 files changed

+270
-23
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import type { ProjectContentTreeRoot } from '../../../components/assets'
2+
import type { EditorDispatch } from '../../../components/editor/action-types'
3+
import { emptyGithubData } from '../../../components/editor/store/editor-state'
4+
import {
5+
getRefreshGithubActions,
6+
getUserDetailsFromServer,
7+
updateUserDetailsWhenAuthenticated,
8+
} from './helpers'
9+
import type { GithubOperationContext } from './operations/github-operation-context'
10+
11+
describe('github helpers', () => {
12+
let mockFetch = jest.spyOn(global, 'fetch')
13+
14+
beforeEach(() => {
15+
mockFetch = jest.spyOn(global, 'fetch')
16+
})
17+
afterEach(() => {
18+
mockFetch.mockReset()
19+
})
20+
21+
let mockDispatch: EditorDispatch = () => {}
22+
23+
describe('getUserDetailsFromServer', () => {
24+
it('returns null if the response is not ok', async () => {
25+
mockFetch.mockResolvedValue(new Response(null, { status: 418 }))
26+
const got = await getUserDetailsFromServer()
27+
expect(got).toBe(null)
28+
})
29+
30+
it('returns null if the request returns a failure', async () => {
31+
mockFetch.mockResolvedValue(new Response(JSON.stringify({ type: 'FAILURE' })))
32+
const got = await getUserDetailsFromServer()
33+
expect(got).toBe(null)
34+
})
35+
36+
it('returns the user details if the request succeeds', async () => {
37+
mockFetch.mockResolvedValue(
38+
new Response(JSON.stringify({ type: 'SUCCESS', user: { id: 'that-guy' } })),
39+
)
40+
const got = await getUserDetailsFromServer()
41+
expect(got).toEqual({ id: 'that-guy' })
42+
})
43+
})
44+
45+
describe('updateUserDetailsWhenAuthenticated', () => {
46+
it('returns false if the auth check fails', async () => {
47+
const got = await updateUserDetailsWhenAuthenticated(mockDispatch, Promise.resolve(false))
48+
expect(got).toBe(false)
49+
})
50+
51+
it('returns false if there are no user details', async () => {
52+
mockFetch.mockResolvedValue(new Response(null, { status: 418 }))
53+
const got = await updateUserDetailsWhenAuthenticated(mockDispatch, Promise.resolve(true))
54+
expect(got).toBe(false)
55+
})
56+
57+
it('returns true if the user details are there', async () => {
58+
mockFetch.mockResolvedValue(
59+
new Response(JSON.stringify({ type: 'SUCCESS', user: { id: 'that-guy' } })),
60+
)
61+
const got = await updateUserDetailsWhenAuthenticated(mockDispatch, Promise.resolve(true))
62+
expect(got).toBe(true)
63+
})
64+
})
65+
66+
describe('getRefreshGithubActions', () => {
67+
function makeMockContext(
68+
fetch?: (url: string, options?: RequestInit) => Promise<Response>,
69+
): GithubOperationContext {
70+
return {
71+
fetch: (url, options) => (fetch ?? global.fetch)?.(url, options),
72+
updateProjectContentsWithParseResults: async (): Promise<ProjectContentTreeRoot> => {
73+
return {}
74+
},
75+
}
76+
}
77+
78+
it('returns actions to reset the GH data if GH is not authenticated', async () => {
79+
const got = await getRefreshGithubActions(
80+
mockDispatch,
81+
false,
82+
null,
83+
null,
84+
null,
85+
null,
86+
null,
87+
null,
88+
makeMockContext(),
89+
)
90+
expect(got.length).toBe(1)
91+
const promises = await got[0]
92+
expect(promises.length).toBe(1)
93+
expect(promises[0].action).toBe('UPDATE_GITHUB_DATA')
94+
if (promises[0].action === 'UPDATE_GITHUB_DATA') {
95+
expect(promises[0].data).toEqual(emptyGithubData())
96+
}
97+
})
98+
99+
it("returns actions to reset the GH data if GH is authenticated but there's no user on the server", async () => {
100+
mockFetch.mockResolvedValue(new Response(null, { status: 418 }))
101+
const got = await getRefreshGithubActions(
102+
mockDispatch,
103+
true,
104+
null,
105+
null,
106+
null,
107+
null, // <- user details
108+
null,
109+
null,
110+
makeMockContext(),
111+
)
112+
expect(got.length).toBe(1)
113+
const promises = await got[0]
114+
expect(promises.length).toBe(2)
115+
expect(promises[0].action).toBe('SET_GITHUB_STATE')
116+
if (promises[0].action === 'SET_GITHUB_STATE') {
117+
expect(promises[0].githubState.authenticated).toBe(false)
118+
}
119+
expect(promises[1].action).toBe('UPDATE_GITHUB_DATA')
120+
if (promises[1].action === 'UPDATE_GITHUB_DATA') {
121+
expect(promises[1].data).toEqual(emptyGithubData())
122+
}
123+
})
124+
125+
it("returns actions to update the GH data if GH is authenticated and there's a user on the server", async () => {
126+
mockFetch.mockResolvedValue(
127+
new Response(JSON.stringify({ type: 'SUCCESS', user: { id: 'that-guy' } })),
128+
)
129+
const got = await getRefreshGithubActions(
130+
mockDispatch,
131+
true,
132+
null,
133+
null,
134+
null,
135+
null, // <- user details
136+
null,
137+
null,
138+
makeMockContext(),
139+
)
140+
expect(got.length).toBe(1)
141+
const promises = await got[0]
142+
expect(promises.length).toBe(1)
143+
expect(promises[0].action).toBe('UPDATE_GITHUB_DATA')
144+
if (promises[0].action === 'UPDATE_GITHUB_DATA') {
145+
expect(promises[0].data).toEqual({ githubUserDetails: { id: 'that-guy' } })
146+
}
147+
})
148+
149+
it('proceeds with the refresh if the GH data is authenticated and the user is there', async () => {
150+
mockFetch.mockImplementation(async () => {
151+
return new Response(
152+
JSON.stringify({
153+
type: 'SUCCESS',
154+
user: { id: 'that-guy' },
155+
repositories: [],
156+
}),
157+
)
158+
})
159+
160+
const got = await getRefreshGithubActions(
161+
mockDispatch,
162+
true,
163+
null,
164+
null,
165+
null,
166+
{ login: 'the-login', avatarURL: 'the-avatar', htmlURL: 'the-html-url', name: 'the-name' }, // <- user details
167+
null,
168+
null,
169+
makeMockContext(mockFetch.getMockImplementation()),
170+
)
171+
expect(got.length).toBe(2)
172+
173+
let promises = await got[0]
174+
expect(promises.length).toBe(1)
175+
expect(promises[0].action).toBe('UPDATE_GITHUB_DATA')
176+
if (promises[0].action === 'UPDATE_GITHUB_DATA') {
177+
expect(promises[0].data).toEqual({
178+
publicRepositories: [],
179+
})
180+
}
181+
182+
promises = await got[1]
183+
expect(promises.length).toBe(1)
184+
expect(promises[0].action).toBe('UPDATE_GITHUB_DATA')
185+
if (promises[0].action === 'UPDATE_GITHUB_DATA') {
186+
expect(promises[0].data).toEqual({
187+
branches: null,
188+
})
189+
}
190+
})
191+
})
192+
})

editor/src/core/shared/github/helpers.ts

+78-23
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type { EditorAction, EditorDispatch } from '../../../components/editor/ac
1919
import {
2020
deleteFile,
2121
removeFileConflict,
22+
setGithubState,
2223
showToast,
2324
updateBranchContents,
2425
updateFile,
@@ -38,7 +39,6 @@ import type {
3839
import {
3940
emptyGithubData,
4041
emptyGithubSettings,
41-
FileChecksums,
4242
githubOperationPrettyName,
4343
projectGithubSettings,
4444
} from '../../../components/editor/store/editor-state'
@@ -184,10 +184,18 @@ export async function runGithubOperation(
184184
dispatch([updateGithubOperations(operation, 'add')], 'everyone')
185185
result = await logic(operation)
186186
} catch (error: any) {
187-
dispatch(
188-
[showToast(notice(`${opName} failed. See the console for more information.`, 'ERROR'))],
189-
'everyone',
190-
)
187+
let actions: EditorAction[] = []
188+
189+
const userDetails = await getUserDetailsFromServer()
190+
if (userDetails == null) {
191+
actions.push(...resetGithubStateAndDataActions())
192+
} else {
193+
actions.push(
194+
showToast(notice(`${opName} failed. See the console for more information.`, 'ERROR')),
195+
)
196+
}
197+
198+
dispatch(actions, 'everyone')
191199
console.error(`[GitHub] operation "${opName}" failed:`, error)
192200
throw error
193201
} finally {
@@ -287,7 +295,15 @@ export async function getBranchContentFromServer(
287295
})
288296
}
289297

290-
export async function getUserDetailsFromServer(): Promise<Array<EditorAction>> {
298+
function resetGithubStateAndDataActions(): EditorAction[] {
299+
return [setGithubState({ authenticated: false }), updateGithubData(emptyGithubData())]
300+
}
301+
302+
function updateUserDetailsFromServerActions(userDetails: GithubUser): EditorAction[] {
303+
return [updateGithubData({ githubUserDetails: userDetails })]
304+
}
305+
306+
export async function getUserDetailsFromServer(): Promise<GithubUser | null> {
291307
const url = GithubEndpoints.userDetails()
292308

293309
const response = await fetch(url, {
@@ -301,19 +317,21 @@ export async function getUserDetailsFromServer(): Promise<Array<EditorAction>> {
301317
const responseBody: GetGithubUserResponse = await response.json()
302318
switch (responseBody.type) {
303319
case 'FAILURE':
304-
throw new Error(
320+
console.error(
305321
`Error when attempting to retrieve the user details: ${responseBody.failureReason}`,
306322
)
323+
return null
307324
case 'SUCCESS':
308-
return [updateGithubData({ githubUserDetails: responseBody.user })]
325+
return responseBody.user
309326
default:
310327
const _exhaustiveCheck: never = responseBody
311328
throw new Error(`Unhandled response body ${JSON.stringify(responseBody)}`)
312329
}
313330
} else {
314-
throw new Error(
331+
console.error(
315332
`Github: Unexpected status returned from user details endpoint: ${response.status}`,
316333
)
334+
return null
317335
}
318336
}
319337

@@ -323,12 +341,20 @@ export async function updateUserDetailsWhenAuthenticated(
323341
authenticationCheck: Promise<boolean>,
324342
): Promise<boolean> {
325343
const authenticationResult = await authenticationCheck
326-
if (authenticationResult) {
327-
await dispatchPromiseActions(dispatch, getUserDetailsFromServer()).catch((error) => {
328-
console.error(`Error while attempting to retrieve Github user details: ${error}`)
329-
})
344+
if (!authenticationResult) {
345+
return false
346+
}
347+
348+
const userDetails = await getUserDetailsFromServer()
349+
if (userDetails == null) {
350+
// if the user details are not available while the auth is successful, it means that
351+
// there a mismatch between the cookies and the actual auth, so return false overall.
352+
dispatch(resetGithubStateAndDataActions())
353+
return false
330354
}
331-
return authenticationResult
355+
356+
dispatch(updateUserDetailsFromServerActions(userDetails))
357+
return true
332358
}
333359

334360
const githubFileChangesSelector = createSelector(
@@ -840,7 +866,7 @@ export const startGithubPolling =
840866
githubPollingTimeoutId = window.setTimeout(pollGithub, 0)
841867
}
842868

843-
export async function refreshGithubData(
869+
export function getRefreshGithubActions(
844870
dispatch: EditorDispatch,
845871
githubAuthenticated: boolean,
846872
githubRepo: GithubRepo | null,
@@ -850,14 +876,20 @@ export async function refreshGithubData(
850876
previousCommitSha: string | null,
851877
originCommitSha: string | null,
852878
operationContext: GithubOperationContext,
853-
): Promise<void> {
879+
): Array<Promise<Array<EditorAction>>> {
854880
// Collect actions which are the results of the various requests,
855881
// but not those that show which Github operations are running.
856-
const promises: Array<Promise<Array<EditorAction>>> = []
857-
if (githubAuthenticated) {
858-
if (githubUserDetails === null) {
859-
promises.push(getUserDetailsFromServer())
860-
}
882+
let promises: Array<Promise<Array<EditorAction>>> = []
883+
if (!githubAuthenticated) {
884+
promises.push(Promise.resolve([updateGithubData(emptyGithubData())]))
885+
} else if (githubUserDetails === null) {
886+
const noUserDetailsActions = getUserDetailsFromServer().then((userDetails) => {
887+
return userDetails == null
888+
? resetGithubStateAndDataActions()
889+
: updateUserDetailsFromServerActions(userDetails)
890+
})
891+
promises.push(noUserDetailsActions)
892+
} else {
861893
promises.push(getUsersPublicGithubRepositories(operationContext)(dispatch))
862894
if (githubRepo != null) {
863895
promises.push(getBranchesForGithubRepository(operationContext)(dispatch, githubRepo))
@@ -886,10 +918,33 @@ export async function refreshGithubData(
886918
} else {
887919
promises.push(Promise.resolve([updateGithubData({ branches: null })]))
888920
}
889-
} else {
890-
promises.push(Promise.resolve([updateGithubData(emptyGithubData())]))
891921
}
892922

923+
return promises
924+
}
925+
926+
export async function refreshGithubData(
927+
dispatch: EditorDispatch,
928+
githubAuthenticated: boolean,
929+
githubRepo: GithubRepo | null,
930+
branchName: string | null,
931+
branchOriginChecksums: FileChecksumsWithFile | null,
932+
githubUserDetails: GithubUser | null,
933+
previousCommitSha: string | null,
934+
originCommitSha: string | null,
935+
operationContext: GithubOperationContext,
936+
): Promise<void> {
937+
const promises = await getRefreshGithubActions(
938+
dispatch,
939+
githubAuthenticated,
940+
githubRepo,
941+
branchName,
942+
branchOriginChecksums,
943+
githubUserDetails,
944+
previousCommitSha,
945+
originCommitSha,
946+
operationContext,
947+
)
893948
// Resolve all the promises.
894949
await Promise.allSettled(promises).then((results) => {
895950
let actions: Array<EditorAction> = []

0 commit comments

Comments
 (0)