Skip to content

Commit

Permalink
tests(repo): Integration tests for Protect (#2319)
Browse files Browse the repository at this point in the history
* tests(repo): Integration tests for `Protect` in components

* tests(repo): Integration tests for `Protect` in components

* tests(repo): Integration tests for `Protect` with 2 roles

* tests(repo): Integration test utils for organization switcher

* chore(nextjs): Add sample json key

* chore(integration): Introduce E2E_APP_STAGING_CLERK_API_URL

* chore(integration): Introduce next.appRouter.withCustomRoles long running app

* chore(integration): Replace E2E_APP_STAGING_CLERK_API_URL with hardcoded value

As it's going to be removed soon(ish)

* chore(integration): Use next.appRouter.withCustomRoles long running app

---------

Co-authored-by: Nikos Douvlis <[email protected]>
  • Loading branch information
panteliselef and nikosdouvlis authored Dec 13, 2023
1 parent 6728b24 commit 71fb9f6
Show file tree
Hide file tree
Showing 19 changed files with 257 additions and 12 deletions.
4 changes: 4 additions & 0 deletions integration/.keys.json.sample
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,9 @@
"with-email-links": {
"pk": "",
"sk": ""
},
"with-custom-roles": {
"pk": "",
"sk": ""
}
}
4 changes: 2 additions & 2 deletions integration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ If you need to run a test suite inside a different environment (e.g. a different
```ts
const yourConciseName = environmentConfig()
.setId('yourConciseName')
.setEnvVariable('private', 'CLERK_API_URL', process.env.E2E_APP_CLERK_API_URL)
.setEnvVariable('private', 'CLERK_API_URL', process.env.E2E_APP_STAGING_CLERK_API_URL)
.setEnvVariable('private', 'CLERK_SECRET_KEY', envKeys['your-concise-name'].sk)
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', envKeys['your-concise-name'].pk)
.setEnvVariable('public', 'CLERK_SIGN_IN_URL', '/sign-in')
Expand Down Expand Up @@ -458,7 +458,7 @@ Inside [`presets/envs.ts`](../integration/presets/envs.ts) you can also create a
```ts
const withCustomRoles = environmentConfig()
.setId('withCustomRoles')
.setEnvVariable('private', 'CLERK_API_URL', process.env.E2E_APP_CLERK_API_URL)
.setEnvVariable('private', 'CLERK_API_URL', process.env.E2E_APP_STAGING_CLERK_API_URL)
.setEnvVariable('private', 'CLERK_SECRET_KEY', envKeys['with-custom-roles'].sk)
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', envKeys['with-custom-roles'].pk)
.setEnvVariable('public', 'CLERK_SIGN_IN_URL', '/sign-in')
Expand Down
4 changes: 4 additions & 0 deletions integration/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,8 @@ export const constants = {
* The version of the dependency to use, controlled programmatically.
*/
E2E_CLERK_VERSION: process.env.E2E_CLERK_VERSION,
/**
* PK and SK pairs from the env to use for integration tests.
*/
INTEGRATION_INSTANCE_KEYS: process.env.INTEGRATION_INSTANCE_KEYS,
} as const;
21 changes: 16 additions & 5 deletions integration/presets/envs.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
/* eslint-disable turbo/no-undeclared-env-vars */
import { resolve } from 'node:path';

import fs from 'fs-extra';

import { constants } from '../constants';
import { environmentConfig } from '../models/environment.js';

const getInstanceKeys = () => {
let keys: Record<string, { pk: string; sk: string }>;
try {
keys = process.env.INTEGRATION_INSTANCE_KEYS
? JSON.parse(process.env.INTEGRATION_INSTANCE_KEYS)
keys = constants.INTEGRATION_INSTANCE_KEYS
? JSON.parse(constants.INTEGRATION_INSTANCE_KEYS)
: fs.readJSONSync(resolve(__dirname, '..', '.keys.json')) || null;
} catch (e) {
console.log('Could not find .keys.json file', e);
Expand All @@ -28,17 +28,28 @@ const withEmailCodes = environmentConfig()
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', envKeys['with-email-codes'].pk)
.setEnvVariable('public', 'CLERK_SIGN_IN_URL', '/sign-in')
.setEnvVariable('public', 'CLERK_SIGN_UP_URL', '/sign-up')
.setEnvVariable('public', 'CLERK_JS', process.env.E2E_APP_CLERK_JS || 'http://localhost:18211/clerk.browser.js');
.setEnvVariable('public', 'CLERK_JS', constants.E2E_APP_CLERK_JS || 'http://localhost:18211/clerk.browser.js');

const withEmailLinks = environmentConfig()
.setId('withEmailLinks')
.setEnvVariable('private', 'CLERK_SECRET_KEY', envKeys['with-email-links'].sk)
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', envKeys['with-email-links'].pk)
.setEnvVariable('public', 'CLERK_SIGN_IN_URL', '/sign-in')
.setEnvVariable('public', 'CLERK_SIGN_UP_URL', '/sign-up')
.setEnvVariable('public', 'CLERK_JS', process.env.E2E_APP_CLERK_JS || 'http://localhost:18211/clerk.browser.js');
.setEnvVariable('public', 'CLERK_JS', constants.E2E_APP_CLERK_JS || 'http://localhost:18211/clerk.browser.js');

const withCustomRoles = environmentConfig()
.setId('withCustomRoles')
// Temporarily use the stage api until the custom roles feature is released to prod
.setEnvVariable('private', 'CLERK_API_URL', 'https://api.clerkstage.dev')
.setEnvVariable('private', 'CLERK_SECRET_KEY', envKeys['with-custom-roles'].sk)
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', envKeys['with-custom-roles'].pk)
.setEnvVariable('public', 'CLERK_SIGN_IN_URL', '/sign-in')
.setEnvVariable('public', 'CLERK_SIGN_UP_URL', '/sign-up')
.setEnvVariable('public', 'CLERK_JS', constants.E2E_APP_CLERK_JS || 'http://localhost:18211/clerk.browser.js');

export const envs = {
withEmailCodes,
withEmailLinks,
withCustomRoles,
} as const;
1 change: 1 addition & 0 deletions integration/presets/longRunningApps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const createLongRunningApps = () => {
{ id: 'react.vite.withEmailLinks', config: react.vite, env: envs.withEmailLinks },
{ id: 'remix.node.withEmailCodes', config: remix.remixNode, env: envs.withEmailCodes },
{ id: 'next.appRouter.withEmailCodes', config: next.appRouter, env: envs.withEmailCodes },
{ id: 'next.appRouter.withCustomRoles', config: next.appRouter, env: envs.withCustomRoles },
] as const;

const apps = configs.map(longRunningApplication);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { auth } from '@clerk/nextjs/server';

export function GET() {
const { userId } = auth().protect(has => has({ role: 'admin' }) || has({ role: 'org:editor' }));
return new Response(JSON.stringify({ userId }));
}
3 changes: 2 additions & 1 deletion integration/templates/next-app-router/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { SignedIn, SignedOut, SignIn, UserButton } from '@clerk/nextjs';
import { SignedIn, SignedOut, SignIn, UserButton, Protect } from '@clerk/nextjs';

export default function Home() {
return (
<main>
<UserButton />
<SignedIn>SignedIn</SignedIn>
<SignedOut>SignedOut</SignedOut>
<Protect fallback={'SignedOut from protect'}>SignedIn from protect</Protect>
<SignIn
path={'/'}
signUpUrl={'/sign-up'}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { auth } from '@clerk/nextjs/server';

export default function Page() {
const { userId, has } = auth();
if (!userId || !has({ permission: 'org:posts:manage' })) {
return <p>User is missing permissions</p>;
}
return <p>User has access</p>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { auth } from '@clerk/nextjs/server';

export default function Page() {
auth().protect({ role: 'admin' });
return <p>User has access</p>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
'use client';
import { Protect } from '@clerk/nextjs';
export default function Page() {
return (
<Protect
permission='org:posts:manage'
fallback={<p>User is missing permissions</p>}
>
<p>User has access</p>
</Protect>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Protect } from '@clerk/nextjs';

export default function Page() {
return (
<Protect
role='admin'
fallback={<p>User is not admin</p>}
>
<p>User has access</p>
</Protect>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
'use client';
import { useAuth } from '@clerk/nextjs';

export default function Page() {
const { has, isLoaded } = useAuth();
if (!isLoaded) {
return <p>Loading</p>;
}
if (!has({ role: 'admin' })) {
return <p>User is not admin</p>;
}
return <p>User has access</p>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { OrganizationSwitcher } from '@clerk/nextjs';

export default function Page() {
return <OrganizationSwitcher hidePersonal={true} />;
}
2 changes: 1 addition & 1 deletion integration/templates/next-app-router/src/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { authMiddleware } from '@clerk/nextjs/server';

export default authMiddleware({
publicRoutes: ['/', '/hash/sign-in', '/hash/sign-up'],
publicRoutes: ['/', '/hash/sign-in', '/hash/sign-up', /^(\/(settings)\/*).*$/],
});

export const config = {
Expand Down
7 changes: 5 additions & 2 deletions integration/testUtils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,18 @@ import type { Browser, BrowserContext, Page } from '@playwright/test';
import type { Application } from '../models/application';
import { createAppPageObject } from './appPageObject';
import { createEmailService } from './emailService';
import { createOrganizationSwitcherComponentPageObject } from './organizationSwitcherPageObject';
import type { EnchancedPage, TestArgs } from './signInPageObject';
import { createSignInComponentPageObject } from './signInPageObject';
import { createSignUpComponentPageObject } from './signUpPageObject';
import { createUserProfileComponentPageObject } from './userProfilePageObject';
import type { FakeUser } from './usersService';
import type { FakeOrganization, FakeUser } from './usersService';
import { createUserService } from './usersService';

export type { FakeUser };
export type { FakeUser, FakeOrganization };
const createClerkClient = (app: Application) => {
return backendCreateClerkClient({
apiUrl: app.env.privateVariables.get('CLERK_API_URL'),
secretKey: app.env.privateVariables.get('CLERK_SECRET_KEY'),
publishableKey: app.env.publicVariables.get('CLERK_PUBLISHABLE_KEY'),
});
Expand Down Expand Up @@ -66,6 +68,7 @@ export const createTestUtils = <
signUp: createSignUpComponentPageObject(testArgs),
signIn: createSignInComponentPageObject(testArgs),
userProfile: createUserProfileComponentPageObject(testArgs),
organizationSwitcher: createOrganizationSwitcherComponentPageObject(testArgs),
expect: createExpectPageObject(testArgs),
};

Expand Down
30 changes: 30 additions & 0 deletions integration/testUtils/organizationSwitcherPageObject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { expect } from '@playwright/test';

import { common } from './commonPageObject';
import type { TestArgs } from './signInPageObject';

export const createOrganizationSwitcherComponentPageObject = (testArgs: TestArgs) => {
const { page } = testArgs;

const self = {
...common(testArgs),
goTo: async (relativePath = '/switcher') => {
await page.goToRelative(relativePath);
return self.waitForMounted();
},
waitForMounted: () => {
return page.waitForSelector('.cl-organizationSwitcher-root', { state: 'attached' });
},
expectNoOrganizationSelected: () => {
return expect(page.getByText(/No organization selected/i)).toBeVisible();
},
toggleTrigger: () => {
return page.locator('.cl-organizationSwitcherTrigger').click();
},
waitForAnOrganizationToSelected: () => {
return page.waitForSelector('.cl-userPreviewMainIdentifier__personalWorkspace', { state: 'detached' });
},
};

return self;
};
13 changes: 13 additions & 0 deletions integration/testUtils/usersService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { faker } from '@faker-js/faker';
import { hash } from '../models/helpers';

export type FakeUser = ReturnType<ReturnType<typeof createUserService>['createFakeUser']>;
export type FakeOrganization = ReturnType<ReturnType<Awaited<typeof createUserService>>['createFakeOrganization']>;

export const createUserService = (clerkClient: ReturnType<typeof Clerk>) => {
const self = {
Expand Down Expand Up @@ -36,6 +37,18 @@ export const createUserService = (clerkClient: ReturnType<typeof Clerk>) => {
await clerkClient.users.deleteUser(id);
}
},
createFakeOrganization: async (userId: string) => {
const name = faker.animal.dog();
const { data: organization } = await clerkClient.organizations.createOrganization({
name: faker.animal.dog(),
createdBy: userId,
});
return {
name,
organization,
delete: () => clerkClient.organizations.deleteOrganization(organization.id),
};
},
};
return self;
};
115 changes: 115 additions & 0 deletions integration/tests/protect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import type { OrganizationMembershipRole } from '@clerk/backend';
import { expect, test } from '@playwright/test';

import { appConfigs } from '../presets';
import type { FakeOrganization, FakeUser } from '../testUtils';
import { createTestUtils, testAgainstRunningApps } from '../testUtils';

testAgainstRunningApps({ withEnv: [appConfigs.envs.withCustomRoles] })('authorization @nextjs', ({ app }) => {
test.describe.configure({ mode: 'serial' });

let fakeAdmin: FakeUser;
let fakeViewer: FakeUser;
let fakeOrganization: FakeOrganization;

test.beforeAll(async () => {
const m = createTestUtils({ app });
fakeAdmin = m.services.users.createFakeUser();
const { data: admin } = await m.services.users.createBapiUser(fakeAdmin);
fakeOrganization = await m.services.users.createFakeOrganization(admin.id);
fakeViewer = m.services.users.createFakeUser();
const { data: viewer } = await m.services.users.createBapiUser(fakeViewer);

await m.services.clerk.organizations.createOrganizationMembership({
organizationId: fakeOrganization.organization.id,
role: 'org:viewer' as OrganizationMembershipRole,
userId: viewer.id,
});
});

test.afterAll(async () => {
await fakeOrganization.delete();
await fakeViewer.deleteIfExists();
await fakeAdmin.deleteIfExists();
await app.teardown();
});

test('Protect in RSCs and RCCs as `admin`', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });

await u.po.signIn.goTo();
await u.po.signIn.waitForMounted();
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeAdmin.email, password: fakeAdmin.password });
await u.po.expect.toBeSignedIn();

await u.po.organizationSwitcher.goTo();
await u.po.organizationSwitcher.waitForMounted();
await u.po.organizationSwitcher.expectNoOrganizationSelected();
await u.po.organizationSwitcher.toggleTrigger();
await u.page.locator('.cl-organizationSwitcherPreviewButton').click();
await u.po.organizationSwitcher.waitForAnOrganizationToSelected();

await u.page.goToRelative('/settings/rsc-protect');
await expect(u.page.getByText(/User has access/i)).toBeVisible();
await u.page.goToRelative('/settings/rcc-protect');
await expect(u.page.getByText(/User has access/i)).toBeVisible();
await u.page.goToRelative('/settings/useAuth-has');
await expect(u.page.getByText(/User has access/i)).toBeVisible();
await u.page.goToRelative('/settings/auth-has');
await expect(u.page.getByText(/User has access/i)).toBeVisible();
await u.page.goToRelative('/settings/auth-protect');
await expect(u.page.getByText(/User has access/i)).toBeVisible();

// route handler
await u.page.goToRelative('/api/settings/');
await expect(u.page.getByText(/userId/i)).toBeVisible();
});

test('Protect in RSCs and RCCs as `signed-out user`', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });

await u.page.goToRelative('/settings/rsc-protect');
await expect(u.page.getByText(/User is not admin/i)).toBeVisible();
await u.page.goToRelative('/settings/rcc-protect');
await expect(u.page.getByText(/User is missing permissions/i)).toBeVisible();
await u.page.goToRelative('/settings/useAuth-has');
await expect(u.page.getByText(/User is not admin/i)).toBeVisible();
await u.page.goToRelative('/settings/auth-has');
await expect(u.page.getByText(/User is missing permissions/i)).toBeVisible();
await u.page.goToRelative('/settings/auth-protect');
await expect(u.page.getByText(/this page could not be found/i)).toBeVisible();
});

test('Protect in RSCs and RCCs as `viewer`', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });

await u.po.signIn.goTo();
await u.po.signIn.waitForMounted();
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeViewer.email, password: fakeViewer.password });
await u.po.expect.toBeSignedIn();

await u.po.organizationSwitcher.goTo();
await u.po.organizationSwitcher.waitForMounted();
await u.po.organizationSwitcher.expectNoOrganizationSelected();
await u.po.organizationSwitcher.toggleTrigger();
await u.page.locator('.cl-organizationSwitcherPreviewButton').click();
await u.po.organizationSwitcher.waitForAnOrganizationToSelected();

await u.page.goToRelative('/settings/rsc-protect');
await expect(u.page.getByText(/User is not admin/i)).toBeVisible();
await u.page.goToRelative('/settings/rcc-protect');
await expect(u.page.getByText(/User is missing permissions/i)).toBeVisible();
await u.page.goToRelative('/settings/useAuth-has');
await expect(u.page.getByText(/User is not admin/i)).toBeVisible();
await u.page.goToRelative('/settings/auth-has');
await expect(u.page.getByText(/User is missing permissions/i)).toBeVisible();
await u.page.goToRelative('/settings/auth-protect');
await expect(u.page.getByText(/this page could not be found/i)).toBeVisible();

// route handler
await u.page.goToRelative('/api/settings/');

// Result of 404 response with empty body
expect(await u.page.content()).toEqual('<html><head></head><body></body></html>');
});
});
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"test:integration:deployment:nextjs": "DEBUG=1 npx playwright test --config integration/playwright.deployments.config.ts",
"test:integration:express": "E2E_APP_ID=express.* npm run test:integration:base -- --grep @express",
"test:integration:generic": "E2E_APP_ID=react.vite.* npm run test:integration:base -- --grep @generic",
"test:integration:nextjs": "E2E_APP_ID=next.appRouter.withEmailCodes npm run test:integration:base -- --grep \"@generic|@nextjs\"",
"test:integration:nextjs": "E2E_APP_ID=next.appRouter.* npm run test:integration:base -- --grep \"@generic|@nextjs\"",
"test:integration:remix": "echo 'placeholder'",
"turbo:clean": "turbo daemon clean",
"update:lockfile": "npm run nuke && npm install -D --arch=x64 --platform=linux turbo && npm install -D --arch=arm64 --platform=darwin turbo",
Expand Down

0 comments on commit 71fb9f6

Please sign in to comment.