Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(ui): broken playwright authentication for cleanup #1016

Merged
merged 20 commits into from
Sep 16, 2024
Merged
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
7 changes: 4 additions & 3 deletions .github/workflows/e2e-playwright.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -116,14 +116,15 @@ jobs:
rm packages/ui/zarf-package-leapfrogai-ui-amd64-e2e-test.tar.zst

# Run the playwright UI tests using the deployed Supabase endpoint and upload report as an artifact
# Note - workflow doesn't need teardown and causes import issues
- name: UI/API/Supabase E2E Playwright Tests
run: |
cp src/leapfrogai_ui/.env.example src/leapfrogai_ui/.env
mkdir -p playwright/auth
touch playwright/auth.user.json
rm src/leapfrogai_ui/tests/global.teardown.ts
mkdir -p src/leapfrogai_ui/playwright/.auth
SERVICE_ROLE_KEY=$(uds zarf tools kubectl get secret -n leapfrogai supabase-bootstrap-jwt -o jsonpath={.data.service-key} | base64 -d)
echo "::add-mask::$SERVICE_ROLE_KEY"
SERVICE_ROLE_KEY=$SERVICE_ROLE_KEY TEST_ENV=CI USERNAME=doug PASSWORD=$FAKE_E2E_USER_PASSWORD PUBLIC_SUPABASE_ANON_KEY=$ANON_KEY npm --prefix src/leapfrogai_ui run test:integration:ci
SERVICE_ROLE_KEY=$SERVICE_ROLE_KEY TEST_ENV=CI USERNAME=doug PASSWORD=$FAKE_E2E_USER_PASSWORD PUBLIC_SUPABASE_ANON_KEY=$ANON_KEY DEFAULT_MODEL=llama-cpp-python npm --prefix src/leapfrogai_ui run test:integration:ci

# Upload the Playwright report as an artifact
- name: Archive Playwright Report
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/e2e-registry1-weekly.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ jobs:
mkdir -p playwright/auth
touch playwright/auth.user.json

SERVICE_ROLE_KEY=$SERVICE_ROLE_KEY TEST_ENV=CI USERNAME=doug PASSWORD=$FAKE_E2E_USER_PASSWORD PUBLIC_SUPABASE_ANON_KEY=$ANON_KEY npm --prefix src/leapfrogai_ui run test:integration:ci
SERVICE_ROLE_KEY=$SERVICE_ROLE_KEY TEST_ENV=CI USERNAME=doug PASSWORD=$FAKE_E2E_USER_PASSWORD PUBLIC_SUPABASE_ANON_KEY=$ANON_KEY DEFAULT_MODEL=llama-cpp-python npm --prefix src/leapfrogai_ui run test:integration:ci

- name: Archive Playwright Report
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
Expand Down
6 changes: 4 additions & 2 deletions src/leapfrogai_ui/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@ SUPABASE_AUTH_KEYCLOAK_SECRET=<secret>
#ORIGIN=http://localhost:3000 # set if running in Docker locally (variable is also used in deployment)

#If specified, app will use OpenAI instead of LeapfrogAI
OPENAI_API_KEY=
#OPENAI_API_KEY=

# PLAYWRIGHT
[email protected]
PASSWORD=<password>
# MFA secret is only needed when running playwright tests locally. In the workflow, the keycloak user
# is created without MFA requirements
MFA_SECRET=<secret>
# Service Role key comes from Supabase and allows Playwright to bypass row level security for test setup/cleanup. This is only needed for tests.
SERVICE_ROLE_KEY=<key>
SERVICE_ROLE_KEY=<key>
2 changes: 1 addition & 1 deletion src/leapfrogai_ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"format": "prettier --write . && eslint . --fix",
"test:integration": "playwright test",
"test:integration:ui": "playwright test --ui",
"test:integration:ci": "playwright test tests/global.setup.ts tests/api.test.ts tests/api-keys.test.ts tests/header.test.ts",
"test:integration:ci": "playwright test tests/global.setup.ts tests/api.test.ts tests/api-keys.test.ts",
"test:unit": "vitest run",
"test:unit:watch": "vitest",
"supabase:start": "supabase start",
Expand Down
17 changes: 16 additions & 1 deletion src/leapfrogai_ui/tests/chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ test('it saves in progress responses when interrupted by a page reload', async (
page,
openAIClient
}) => {
if (process.env.DEFAULT_MODEL === 'llama-cpp-python') {
test.skip();
}
const uniqueLongMessagePrompt = `${LONG_RESPONSE_PROMPT} ${new Date().toISOString()}`;
await loadChatPage(page);
const messages = page.getByTestId('message');
Expand All @@ -43,6 +46,9 @@ test('it saves in progress responses when interrupted by changing threads', asyn
page,
openAIClient
}) => {
if (process.env.DEFAULT_MODEL === 'llama-cpp-python') {
test.skip();
}
const uniqueLongMessagePrompt = `${LONG_RESPONSE_PROMPT} ${new Date().toISOString()}`;
await loadChatPage(page);
const messages = page.getByTestId('message');
Expand All @@ -64,6 +70,9 @@ function countWords(str: string) {
}

test('it cancels responses', async ({ page, openAIClient }) => {
if (process.env.DEFAULT_MODEL === 'llama-cpp-python') {
test.skip();
}
await loadChatPage(page);
const messages = page.getByTestId('message');
await sendMessage(page, LONG_RESPONSE_PROMPT);
Expand All @@ -83,6 +92,9 @@ test('it cancels responses when clicking enter instead of pause button and does
page,
openAIClient
}) => {
if (process.env.DEFAULT_MODEL === 'llama-cpp-python') {
test.skip();
}
await loadChatPage(page);
const messages = page.getByTestId('message');
await sendMessage(page, LONG_RESPONSE_PROMPT); // response must take a long time for this test to work
Expand Down Expand Up @@ -166,7 +178,10 @@ test('it formats code in a code block and can copy the code', async ({ page }) =

// The skeleton only shows for assistant messages
// TODO -this test can be flaky if the backend is really fast and the loading-msg skeleton barely has time to be shown
test('it shows a loading skeleton when a response is pending', async ({ page, openAIClient }) => {
test.skip('it shows a loading skeleton when a response is pending', async ({
page,
openAIClient
}) => {
const assistant = await createAssistantWithApi({ openAIClient });

await loadChatPage(page);
Expand Down
2 changes: 2 additions & 0 deletions src/leapfrogai_ui/tests/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const supabaseUsername = '[email protected]';
export const supabasePassword = 'fakepass';
62 changes: 36 additions & 26 deletions src/leapfrogai_ui/tests/fixtures.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,51 @@
import { test as base } from '@playwright/test';
import OpenAI from 'openai';
import fs from 'node:fs';

type MyFixtures = {
openAIClient: OpenAI;
};

export async function getAccessToken() {
const supabaseUrl = process.env.PUBLIC_SUPABASE_URL;
const serviceRoleKey = process.env.SERVICE_ROLE_KEY;

const response = await fetch(`${supabaseUrl}/auth/v1/token?grant_type=password`, {
method: 'POST',
// @ts-expect-error: apikey is a required header for this request
headers: {
'Content-Type': 'application/json',
apikey: serviceRoleKey,
Authorization: `Bearer ${serviceRoleKey}`
},
body: JSON.stringify({
email: process.env.USERNAME,
password: process.env.PASSWORD
})
});

const data = await response.json();
type Cookie = {
name: string;
value: string;
domain: string;
path: string;
expires: number;
httpOnly: boolean;
secure: boolean;
sameSite: string;
};

if (response.ok) {
return data.access_token;
} else {
console.error('Error fetching access token:', data);
throw new Error(data.error_description || 'Failed to fetch access token');
// Gets an access token from cookie
export const getAccessToken = async () => {
try {
const authData = JSON.parse(
fs.readFileSync(`${process.cwd()}/playwright/.auth/user.json`, 'utf-8')
);
const cookie = authData.cookies.find(
(cookie: Cookie) =>
cookie.name === 'sb-supabase-kong-auth-token' ||
cookie.name === 'sb-supabase-kong-auth-token.0' ||
cookie.name === 'sb-supabase-kong-auth-token.1'
);
const cookieStripped = cookie.value.replace('base64-', '');
// Decode the base64 string
const convertedCookie = Buffer.from(cookieStripped, 'base64').toString('utf-8');
const accessTokenMatch = convertedCookie.match(/"access_token":"(.*?)"/);
if (!accessTokenMatch) {
console.log('Access token not found in cookie');
return '';
}
return accessTokenMatch[1];
} catch (e) {
console.error('Error getting access token', e);
return '';
}
}
};

export const getOpenAIClient = async () => {
const token = await getAccessToken();

return new OpenAI({
apiKey: process.env.OPENAI_API_KEY || token,
baseURL: process.env.OPENAI_API_KEY
Expand Down
19 changes: 7 additions & 12 deletions src/leapfrogai_ui/tests/global.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,21 @@ import { expect, test as setup } from './fixtures';
import * as OTPAuth from 'otpauth';
import { delay } from 'msw';
import type { Page } from '@playwright/test';

const authFile = 'playwright/.auth/user.json';
import { supabasePassword, supabaseUsername } from './constants';

const doSupabaseLogin = async (page: Page) => {
// The fake keycloak user does not have a password stored in supabase, so login with that user will not work
// we create a separate user when keycloak is disabled (or use existing one)
await page.goto('/'); // go to the home page
await delay(2000); // allow page to fully hydrate
// when running in Github CI, create a new account because we don't have seed migrations
const emailField = page.getByTestId('email-input');
const passwordField = page.getByTestId('password-input');

await emailField.click();
await emailField.fill(process.env.USERNAME!);
await emailField.fill(supabaseUsername);
await passwordField.click();
await passwordField.fill(process.env.PASSWORD!);

const emailText = await emailField.innerText();
const passwordText = await passwordField.innerText();
if (emailText !== process.env.USERNAME!) await emailField.fill(process.env.USERNAME!);
if (passwordText !== process.env.PASSWORD!) await passwordField.fill(process.env.PASSWORD!);
await passwordField.fill(supabasePassword);

await page.getByTestId('submit-btn').click();

Expand All @@ -30,7 +26,6 @@ const doSupabaseLogin = async (page: Page) => {
await page.getByTestId('toggle-submit-btn').click();
await page.getByTestId('submit-btn').click();
}
// }
};

const doKeycloakLogin = async (page: Page) => {
Expand Down Expand Up @@ -107,7 +102,7 @@ setup('authenticate', async ({ page }) => {
// will invalidate the session and cause other tests to fail
await logout(page);

if (process.env.PUBLIC_DISABLE_KEYCLOAK === 'false') await delay(31000); // prevent logging back in too quickly and getting denied
if (process.env.PUBLIC_DISABLE_KEYCLOAK !== 'true') await delay(31000); // prevent logging back in too quickly and getting denied
// Log back in to begin rest of tests
await login(page);

Expand All @@ -119,5 +114,5 @@ setup('authenticate', async ({ page }) => {

// End of authentication steps.

await page.context().storageState({ path: authFile });
await page.context().storageState({ path: 'playwright/.auth/user.json' });
});
17 changes: 8 additions & 9 deletions src/leapfrogai_ui/tests/global.teardown.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { test as teardown } from './fixtures';
import { test } from '@playwright/test';
import { cleanup } from './helpers/cleanup';
import { getOpenAIClient } from './fixtures';

// teardown not necessary in CI testing envs
if (process.env.TEST_ENV !== 'CI') {
teardown('teardown', async ({ openAIClient }) => {
console.log('cleaning up...');
await cleanup(openAIClient);
console.log('clean up complete');
});
}
test('teardown', async () => {
const openAIClient = await getOpenAIClient();
console.log('cleaning up...');
await cleanup(openAIClient);
console.log('clean up complete');
});
2 changes: 1 addition & 1 deletion src/leapfrogai_ui/tests/helpers/apiHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export const deleteAllTestAPIKeys = async () => {
fetch(`${process.env.LEAPFROGAI_API_BASE_URL}/leapfrogai/v1/auth/api-keys/${key.id}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${process.env.SERVICE_ROLE_KEY}`,
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
}
})
Expand Down
24 changes: 24 additions & 0 deletions src/leapfrogai_ui/tests/helpers/cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { deleteAllAssistants, deleteAssistantAvatars } from './assistantHelpers'
import { deleteAllTestThreadsWithApi } from './threadHelpers';
import type OpenAI from 'openai';
import { deleteAllTestAPIKeys } from './apiHelpers';
import { createClient } from '@supabase/supabase-js';
import { supabaseUsername } from '../constants';

export const cleanup = async (openAIClient: OpenAI) => {
deleteAllGeneratedFixtureFiles();
Expand All @@ -11,4 +13,26 @@ export const cleanup = async (openAIClient: OpenAI) => {
await deleteAllTestThreadsWithApi(openAIClient);
await deleteAssistantAvatars();
await deleteAllTestAPIKeys();
if (process.env.PUBLIC_DISABLE_KEYCLOAK === 'true') {
const supabase = createClient(process.env.PUBLIC_SUPABASE_URL!, process.env.SERVICE_ROLE_KEY!, {
auth: {
autoRefreshToken: false,
persistSession: false
}
});
const {
data: { users }
} = await supabase.auth.admin.listUsers();
let userId = '';
for (const user of users) {
if (user.email === supabaseUsername) {
userId = user.id;
}
}
if (userId) {
await supabase.from('profiles').delete().eq('id', userId);
const { error } = await supabase.auth.admin.deleteUser(userId);
if (error) console.error('Error deleting test user', error);
}
}
};
8 changes: 7 additions & 1 deletion src/leapfrogai_ui/tests/helpers/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ import { createClient } from '@supabase/supabase-js';

export const supabase = createClient(
process.env.PUBLIC_SUPABASE_URL!,
process.env.SERVICE_ROLE_KEY!
process.env.SERVICE_ROLE_KEY!,
{
auth: {
autoRefreshToken: false,
persistSession: false
}
}
);

export const SHORT_RESPONSE_PROMPT = 'respond with no more than one sentence';
Expand Down