-
Notifications
You must be signed in to change notification settings - Fork 9
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
4 changed files
with
396 additions
and
0 deletions.
There are no files selected for viewing
137 changes: 137 additions & 0 deletions
137
src/__tests__/unit/components/guards/has-not-completed-action.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,137 @@ | ||
import { hasNotCompletedAction } from '@/components/guards/has-not-completed-action'; | ||
import { render, screen, cleanup } from '@testing-library/react'; | ||
import '@testing-library/jest-dom'; | ||
import navigation from 'next/navigation'; | ||
import { Builder } from 'builder-pattern'; | ||
import { UserContext, type UserContextType } from '@/contexts/user-context'; | ||
import { Actions } from '@/model/enums/actions'; | ||
import type { User } from '@/model/types/user'; | ||
import type { AppRouterInstance } from 'next/dist/shared/lib/app-router-context.shared-runtime'; | ||
|
||
jest.mock('next/navigation', () => ({ | ||
useRouter: jest.fn(), | ||
})); | ||
|
||
describe('hasNotCompletedAction', () => { | ||
let router: AppRouterInstance; | ||
|
||
beforeEach(() => { | ||
router = Builder<AppRouterInstance>().push(jest.fn()).build(); | ||
jest.spyOn(navigation, 'useRouter').mockImplementation(() => router); | ||
}); | ||
|
||
afterEach(cleanup); | ||
|
||
it('returns a component that can be rendered.', () => { | ||
const user = Builder<User>() | ||
.completedActions({ | ||
electionReminders: false, | ||
registerToVote: false, | ||
sharedChallenge: false, | ||
}) | ||
.build(); | ||
|
||
const userContextValue = Builder<UserContextType>().user(user).build(); | ||
|
||
const TestComponent = hasNotCompletedAction( | ||
function () { | ||
return <div data-testid="test"></div>; | ||
}, | ||
{ action: Actions.ElectionReminders, redirectTo: '/' }, | ||
); | ||
|
||
render( | ||
<UserContext.Provider value={userContextValue}> | ||
<TestComponent /> | ||
</UserContext.Provider>, | ||
); | ||
expect(screen.queryByTestId('test')).toBeInTheDocument(); | ||
}); | ||
|
||
it('returns a component that can accept props.', () => { | ||
const user = Builder<User>() | ||
.completedActions({ | ||
electionReminders: false, | ||
registerToVote: false, | ||
sharedChallenge: false, | ||
}) | ||
.build(); | ||
|
||
const userContextValue = Builder<UserContextType>().user(user).build(); | ||
|
||
interface TestComponentProps { | ||
message: string; | ||
} | ||
|
||
const TestComponent = hasNotCompletedAction( | ||
function ({ message }: TestComponentProps) { | ||
return <div>{message}</div>; | ||
}, | ||
{ action: Actions.ElectionReminders, redirectTo: '/' }, | ||
); | ||
|
||
const message = 'test'; | ||
|
||
render( | ||
<UserContext.Provider value={userContextValue}> | ||
<TestComponent message={message} /> | ||
</UserContext.Provider>, | ||
); | ||
expect(screen.queryByText(message)).toBeInTheDocument(); | ||
}); | ||
|
||
it('blocks access to a page if the user has completed the provided action.', () => { | ||
const user = Builder<User>() | ||
.completedActions({ | ||
electionReminders: true, | ||
registerToVote: false, | ||
sharedChallenge: false, | ||
}) | ||
.build(); | ||
|
||
const userContextValue = Builder<UserContextType>().user(user).build(); | ||
|
||
const redirectTo = '/'; | ||
|
||
const TestComponent = hasNotCompletedAction( | ||
function () { | ||
return null; | ||
}, | ||
{ action: Actions.ElectionReminders, redirectTo }, | ||
); | ||
|
||
render( | ||
<UserContext.Provider value={userContextValue}> | ||
<TestComponent /> | ||
</UserContext.Provider>, | ||
); | ||
|
||
expect(router.push).toHaveBeenCalledWith(redirectTo); | ||
}); | ||
|
||
it('allows access to a page if the user has not completed the provided action.', () => { | ||
const user = Builder<User>() | ||
.completedActions({ | ||
electionReminders: true, | ||
registerToVote: true, | ||
sharedChallenge: false, | ||
}) | ||
.build(); | ||
|
||
const userContextValue = Builder<UserContextType>().user(user).build(); | ||
|
||
const TestComponent = hasNotCompletedAction( | ||
function () { | ||
return null; | ||
}, | ||
{ action: Actions.SharedChallenge, redirectTo: '/' }, | ||
); | ||
|
||
render( | ||
<UserContext.Provider value={userContextValue}> | ||
<TestComponent /> | ||
</UserContext.Provider>, | ||
); | ||
expect(router.push).not.toHaveBeenCalled(); | ||
}); | ||
}); |
98 changes: 98 additions & 0 deletions
98
...s/server/redirect-if-completed-action/redirect-if-supabase-user-completed-action.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,98 @@ | ||
import { redirectIfSupabaseUserCompletedAction } from '@/services/server/redirect-if-completed-action/redirect-if-supabase-user-completed-action'; | ||
import { willBeRedirected } from '@/utils/shared/will-be-redirected'; | ||
import { resetAuthAndDatabase } from '@/utils/test/reset-auth-and-database'; | ||
import { NextRequest } from 'next/server'; | ||
import { getSignedInRequestWithUser } from '@/utils/test/get-signed-in-request-with-user'; | ||
import { SupabaseUserRecordBuilder } from '@/utils/test/supabase-user-record-builder'; | ||
import { Actions } from '@/model/enums/actions'; | ||
import { serverContainer } from '@/services/server/container'; | ||
import { SERVER_SERVICE_KEYS } from '@/services/server/keys'; | ||
|
||
describe('redirectIfSupabaseUserCompletedAction', () => { | ||
afterEach(() => { | ||
return resetAuthAndDatabase(); | ||
}); | ||
|
||
it(`returns a response that redirects a user if they have completed the | ||
specified action.`, async () => { | ||
const user = await new SupabaseUserRecordBuilder('[email protected]') | ||
.completedActions({ | ||
electionReminders: true, | ||
registerToVote: false, | ||
sharedChallenge: false, | ||
}) | ||
.build(); | ||
|
||
const request = await getSignedInRequestWithUser( | ||
user, | ||
'https://challenge.8by8.us/reminders', | ||
{ | ||
method: 'GET', | ||
}, | ||
); | ||
|
||
const userRepo = serverContainer.get(SERVER_SERVICE_KEYS.UserRepository); | ||
|
||
const response = await redirectIfSupabaseUserCompletedAction( | ||
userRepo, | ||
request, | ||
{ | ||
action: Actions.ElectionReminders, | ||
redirectTo: '/', | ||
}, | ||
); | ||
|
||
expect(willBeRedirected(response)).toBe(true); | ||
}); | ||
|
||
it(`returns a response that does not redirect the user if they have not | ||
completed the specified action.`, async () => { | ||
const user = await new SupabaseUserRecordBuilder('[email protected]') | ||
.completedActions({ | ||
electionReminders: false, | ||
registerToVote: false, | ||
sharedChallenge: false, | ||
}) | ||
.build(); | ||
|
||
const request = await getSignedInRequestWithUser( | ||
user, | ||
'https://challenge.8by8.us/reminders', | ||
{ | ||
method: 'GET', | ||
}, | ||
); | ||
|
||
const userRepo = serverContainer.get(SERVER_SERVICE_KEYS.UserRepository); | ||
|
||
const response = await redirectIfSupabaseUserCompletedAction( | ||
userRepo, | ||
request, | ||
{ | ||
action: Actions.ElectionReminders, | ||
redirectTo: '/', | ||
}, | ||
); | ||
|
||
expect(willBeRedirected(response)).toBe(false); | ||
}); | ||
|
||
it(`returns a response that does not redirect the user if they are signed out.`, async () => { | ||
const request = new NextRequest('https://challenge.8by8.us/reminders', { | ||
method: 'GET', | ||
}); | ||
|
||
const userRepo = serverContainer.get(SERVER_SERVICE_KEYS.UserRepository); | ||
|
||
const response = await redirectIfSupabaseUserCompletedAction( | ||
userRepo, | ||
request, | ||
{ | ||
action: Actions.ElectionReminders, | ||
redirectTo: '/', | ||
}, | ||
); | ||
|
||
expect(willBeRedirected(response)).toBe(false); | ||
}); | ||
}); |
109 changes: 109 additions & 0 deletions
109
src/__tests__/unit/utils/test/get-signed-in-request-with-user.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,109 @@ | ||
import { getSignedInRequestWithUser } from '@/utils/test/get-signed-in-request-with-user'; | ||
import { SupabaseUserRecordBuilder } from '@/utils/test/supabase-user-record-builder'; | ||
import { resetAuthAndDatabase } from '@/utils/test/reset-auth-and-database'; | ||
import * as supabaseSSR from '@supabase/ssr'; | ||
import { AuthError } from '@supabase/supabase-js'; | ||
|
||
jest.mock('@supabase/ssr', () => ({ | ||
__esModule: true, | ||
...jest.requireActual('@supabase/ssr'), | ||
})); | ||
|
||
describe('getSignedInRequest', () => { | ||
afterEach(() => { | ||
return resetAuthAndDatabase(); | ||
}); | ||
|
||
afterAll(() => { | ||
jest.unmock('@supabase/ssr'); | ||
}); | ||
|
||
it('returns a request with an authentication cookie.', async () => { | ||
const user = await new SupabaseUserRecordBuilder( | ||
'[email protected]', | ||
).build(); | ||
|
||
const request = await getSignedInRequestWithUser( | ||
user, | ||
'https://challenge.8by8.org/progress', | ||
{ | ||
method: 'GET', | ||
}, | ||
); | ||
|
||
const authCookieNamePattern = /sb-[^-]+-auth-token/; | ||
|
||
expect( | ||
request.cookies | ||
.getAll() | ||
.find(cookie => authCookieNamePattern.test(cookie.name)), | ||
).toBeDefined(); | ||
}); | ||
|
||
it('throws an error if supabase.auth.admin.generateLink() returns an error.', async () => { | ||
const user = await new SupabaseUserRecordBuilder( | ||
'[email protected]', | ||
).build(); | ||
|
||
const supabaseSpy = jest | ||
.spyOn(supabaseSSR, 'createServerClient') | ||
.mockImplementationOnce(() => ({ | ||
auth: { | ||
admin: { | ||
generateLink: () => { | ||
return Promise.resolve({ | ||
data: null, | ||
error: new AuthError('Magic link not supported.', 502), | ||
}); | ||
}, | ||
}, | ||
}, | ||
})); | ||
|
||
await expect( | ||
getSignedInRequestWithUser(user, 'https://challenge.8by8.org/progress', { | ||
method: 'GET', | ||
}), | ||
).rejects.toThrow(new Error('Magic link not supported.')); | ||
|
||
supabaseSpy.mockRestore(); | ||
}); | ||
|
||
it('throws an error if supabase.auth.verifyOtp throws an error.', async () => { | ||
const user = await new SupabaseUserRecordBuilder( | ||
'[email protected]', | ||
).build(); | ||
|
||
const supabaseSpy = jest | ||
.spyOn(supabaseSSR, 'createServerClient') | ||
.mockImplementationOnce(() => ({ | ||
auth: { | ||
admin: { | ||
generateLink: () => { | ||
return Promise.resolve({ | ||
data: { | ||
properties: { | ||
email_otp: '123456', | ||
}, | ||
}, | ||
error: null, | ||
}); | ||
}, | ||
}, | ||
verifyOtp: () => { | ||
return Promise.resolve({ | ||
error: new AuthError('Failed to verify otp.'), | ||
}); | ||
}, | ||
}, | ||
})); | ||
|
||
await expect( | ||
getSignedInRequestWithUser(user, 'https://challenge.8by8.org/progress', { | ||
method: 'GET', | ||
}), | ||
).rejects.toThrow(new Error('Failed to verify otp.')); | ||
|
||
supabaseSpy.mockRestore(); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,52 @@ | ||
import { PRIVATE_ENVIRONMENT_VARIABLES } from '@/constants/private-environment-variables'; | ||
import { PUBLIC_ENVIRONMENT_VARIABLES } from '@/constants/public-environment-variables'; | ||
import { createServerClient } from '@supabase/ssr'; | ||
import { NextRequest } from 'next/server'; | ||
import { MockNextCookies } from './mock-next-cookies'; | ||
import type { User } from '@/model/types/user'; | ||
|
||
export async function getSignedInRequestWithUser( | ||
user: User, | ||
...args: ConstructorParameters<typeof NextRequest> | ||
) { | ||
const mockCookies = new MockNextCookies(); | ||
|
||
const supabase = createServerClient( | ||
PUBLIC_ENVIRONMENT_VARIABLES.NEXT_PUBLIC_SUPABASE_URL, | ||
PRIVATE_ENVIRONMENT_VARIABLES.SUPABASE_SERVICE_ROLE_KEY, | ||
{ | ||
cookies: { | ||
getAll() { | ||
return mockCookies.cookies().getAll(); | ||
}, | ||
setAll(cookiesToSet) { | ||
cookiesToSet.forEach(({ name, value, options }) => | ||
mockCookies.cookies().set(name, value, options), | ||
); | ||
}, | ||
}, | ||
}, | ||
); | ||
|
||
const { data, error: generateLinkError } = | ||
await supabase.auth.admin.generateLink({ | ||
type: 'magiclink', | ||
email: user.email, | ||
}); | ||
|
||
if (generateLinkError) throw new Error(generateLinkError.message); | ||
|
||
const { error: verifyOtpError } = await supabase.auth.verifyOtp({ | ||
email: user.email, | ||
type: 'email', | ||
token: data.properties.email_otp, | ||
}); | ||
|
||
if (verifyOtpError) throw new Error(verifyOtpError.message); | ||
|
||
const authCookie = mockCookies.cookies().getAll()[0]; | ||
|
||
const request = new NextRequest(...args); | ||
request.cookies.set(authCookie.name, authCookie.value); | ||
return request; | ||
} |