Skip to content

Commit a3b78ae

Browse files
authored
E2E Test for Token Authentication (opendatahub-io#4572)
* add E2E test to validate token authentication * copy tokens from UI * remove comment
1 parent 13bbb39 commit a3b78ae

File tree

5 files changed

+252
-1
lines changed

5 files changed

+252
-1
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# testModelTokenAuth.cy.ts Test Data #
2+
projectResourceName: 'test-model-token-auth'
3+
projectDisplayName: 'Test Model Token Auth'
4+
singleModelName: 'test-model'
5+
modelOpenVinoExamplePath: 'kserve-openvino-test/openvino-example-model/'
6+

frontend/src/__tests__/cypress/cypress/pages/modelServing.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,13 @@ class ModelServingGlobal {
108108
findServingRuntime(name: string) {
109109
return this.findModelsTable().find(`[data-label=Serving Runtime]`).contains(name);
110110
}
111+
112+
findTokenCopyButton(index: number) {
113+
if (index === 0) {
114+
return cy.findAllByTestId('token-secret').findAllByRole('button').eq(0);
115+
}
116+
return cy.findAllByTestId('token-secret').eq(index).findAllByRole('button').eq(0);
117+
}
111118
}
112119

113120
class ServingRuntimeGroup extends Contextual<HTMLElement> {}
@@ -243,6 +250,14 @@ class InferenceServiceModal extends ServingModal {
243250
return this.find().findByTestId('service-account-form-name');
244251
}
245252

253+
findServiceAccountIndex(index: number) {
254+
return this.find().findAllByTestId('service-account-form-name').eq(index);
255+
}
256+
257+
findAddServiceAccountButton() {
258+
return this.find().findByTestId('add-service-account-button');
259+
}
260+
246261
findExistingConnectionSelect() {
247262
return this.find().findByTestId('typeahead-menu-toggle');
248263
}
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { projectListPage, projectDetails } from '#~/__tests__/cypress/cypress/pages/projects';
2+
import {
3+
modelServingGlobal,
4+
inferenceServiceModal,
5+
modelServingSection,
6+
kserveModalEdit,
7+
} from '#~/__tests__/cypress/cypress/pages/modelServing';
8+
import type { DataScienceProjectData } from '#~/__tests__/cypress/cypress/types';
9+
import { loadDSPFixture } from '#~/__tests__/cypress/cypress/utils/dataLoader';
10+
import { retryableBefore } from '#~/__tests__/cypress/cypress/utils/retryableHooks';
11+
import { generateTestUUID } from '#~/__tests__/cypress/cypress/utils/uuidGenerator';
12+
import {
13+
checkInferenceServiceState,
14+
modelExternalTester,
15+
provisionProjectForModelServing,
16+
verifyModelExternalToken,
17+
} from '#~/__tests__/cypress/cypress/utils/oc_commands/modelServing';
18+
import { deleteOpenShiftProject } from '#~/__tests__/cypress/cypress/utils/oc_commands/project';
19+
import { HTPASSWD_CLUSTER_ADMIN_USER } from '#~/__tests__/cypress/cypress/utils/e2eUsers';
20+
21+
let testData: DataScienceProjectData;
22+
let projectName: string;
23+
let modelName: string;
24+
let modelFilePath: string;
25+
const awsBucket = 'BUCKET_1' as const;
26+
const uuid = generateTestUUID();
27+
28+
describe('A model can be deployed with token auth', () => {
29+
retryableBefore(() => {
30+
cy.log('Loading test data');
31+
return loadDSPFixture('e2e/dataScienceProjects/testModelTokenAuth.yaml').then(
32+
(fixtureData: DataScienceProjectData) => {
33+
testData = fixtureData;
34+
projectName = `${testData.projectResourceName}-${uuid}`;
35+
modelName = testData.singleModelName;
36+
modelFilePath = testData.modelOpenVinoExamplePath;
37+
38+
if (!projectName) {
39+
throw new Error('Project name is undefined or empty in the loaded fixture');
40+
}
41+
cy.log(`Loaded project name: ${projectName}`);
42+
// Create a Project
43+
provisionProjectForModelServing(
44+
projectName,
45+
awsBucket,
46+
'resources/yaml/data_connection_model_serving.yaml',
47+
);
48+
},
49+
);
50+
});
51+
after(() => {
52+
// Delete provisioned Project - wait for completion due to RHOAIENG-19969 to support test retries, 5 minute timeout
53+
// TODO: Review this timeout once RHOAIENG-19969 is resolved
54+
deleteOpenShiftProject(projectName, { wait: true, ignoreNotFound: true, timeout: 300000 });
55+
});
56+
57+
it(
58+
'Verify that a model can be deployed with token auth',
59+
{ tags: ['@Smoke', '@SmokeSet3', '@Dashboard', '@ModelServing'] },
60+
() => {
61+
cy.log('Model Name:', modelName);
62+
cy.step(`Log into the application with ${HTPASSWD_CLUSTER_ADMIN_USER.USERNAME}`);
63+
cy.visitWithLogin('/', HTPASSWD_CLUSTER_ADMIN_USER);
64+
65+
// Project navigation
66+
cy.step(`Navigate to the Project list tab and search for ${projectName}`);
67+
projectListPage.navigate();
68+
projectListPage.filterProjectByName(projectName);
69+
projectListPage.findProjectLink(projectName).click();
70+
71+
// Navigate to Model Serving tab and Deploy a model
72+
cy.step('Navigate to Model Serving and click to Deploy a model');
73+
projectDetails.findSectionTab('model-server').click();
74+
modelServingGlobal.findSingleServingModelButton().click();
75+
modelServingGlobal.findDeployModelButton().click();
76+
77+
// Launch a model
78+
cy.step('Launch a Single Serving Model using Openvino');
79+
inferenceServiceModal.findModelNameInput().type(testData.singleModelName);
80+
inferenceServiceModal.findServingRuntimeTemplateSearchSelector().click();
81+
inferenceServiceModal.findGlobalScopedTemplateOption('OpenVINO Model Server').click();
82+
inferenceServiceModal.findModelFrameworkSelect().click();
83+
inferenceServiceModal.findOpenVinoIROpSet13().click();
84+
85+
// Enable Model access through an external route
86+
cy.step('Enable Model access through an external route');
87+
inferenceServiceModal.findDeployedModelRouteCheckbox().click();
88+
inferenceServiceModal.findDeployedModelRouteCheckbox().should('be.checked');
89+
inferenceServiceModal.findServiceAccountIndex(0).clear();
90+
inferenceServiceModal.findServiceAccountIndex(0).type('secret');
91+
inferenceServiceModal.findAddServiceAccountButton().click();
92+
inferenceServiceModal.findServiceAccountIndex(1).clear();
93+
inferenceServiceModal.findServiceAccountIndex(1).type('secret2');
94+
inferenceServiceModal.findLocationPathInput().type(modelFilePath);
95+
inferenceServiceModal.findSubmitButton().click();
96+
inferenceServiceModal.shouldBeOpen(false);
97+
98+
// Verify the model created
99+
cy.step('Verify that the Model is running');
100+
checkInferenceServiceState(testData.singleModelName, projectName, {
101+
checkReady: true,
102+
checkLatestDeploymentReady: true,
103+
});
104+
105+
// Verify the model is not accessible without a token
106+
cy.step('Verify the model is not accessible without a token');
107+
modelExternalTester(modelName, projectName).then(({ response }) => {
108+
expect(response.status).to.equal(401);
109+
});
110+
111+
// Get the tokens from the UI
112+
cy.step('Get the tokens from the UI');
113+
const kserveRow = modelServingSection.getKServeRow(testData.singleModelName);
114+
kserveRow.findToggleButton().click();
115+
116+
cy.window().then((win) => {
117+
const copied: string[] = [];
118+
cy.wrap(copied).as('copiedTokens');
119+
120+
cy.stub(win.navigator.clipboard, 'writeText').callsFake((text: string) => {
121+
copied.push(text);
122+
return Promise.resolve();
123+
});
124+
});
125+
126+
// Click the two copy buttons
127+
modelServingGlobal.findTokenCopyButton(0).click();
128+
modelServingGlobal.findTokenCopyButton(1).click();
129+
130+
// Use the copied tokens
131+
cy.get<string[]>('@copiedTokens')
132+
.should('have.length.at.least', 2)
133+
.then((tokens) => {
134+
const [token1, token2] = tokens;
135+
verifyModelExternalToken(modelName, projectName, token1).then((r) =>
136+
expect(r.status).to.equal(200),
137+
);
138+
verifyModelExternalToken(modelName, projectName, token2).then((r) =>
139+
expect(r.status).to.equal(200),
140+
);
141+
});
142+
143+
// Remove the token
144+
cy.step('Remove the token');
145+
modelServingSection
146+
.getKServeRow(testData.singleModelName)
147+
.find()
148+
.findKebabAction('Edit')
149+
.click();
150+
// Check the service accounts are showing up in the UI
151+
kserveModalEdit.findServiceAccountIndex(0).should('have.value', 'secret');
152+
kserveModalEdit.findServiceAccountIndex(1).should('have.value', 'secret2');
153+
kserveModalEdit.findTokenAuthenticationCheckbox().click();
154+
kserveModalEdit.findTokenAuthenticationCheckbox().should('not.be.checked');
155+
kserveModalEdit.findSubmitButton().click();
156+
kserveModalEdit.shouldBeOpen(false);
157+
158+
// Verify the model is accessible without a token
159+
cy.step('Verify the model is accessible without a token');
160+
verifyModelExternalToken(modelName, projectName).then((response) => {
161+
expect(response.status).to.equal(200);
162+
});
163+
},
164+
);
165+
});

frontend/src/__tests__/cypress/cypress/utils/oc_commands/modelServing.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,7 @@ export const checkInferenceServiceState = (
277277
export const modelExternalTester = (
278278
modelName: string,
279279
namespace: string,
280+
token?: string,
280281
): Cypress.Chainable<{ url: string; response: Cypress.Response<unknown> }> => {
281282
return cy.exec(`oc get inferenceService ${modelName} -n ${namespace} -o json`).then((result) => {
282283
const inferenceService = JSON.parse(result.stdout);
@@ -294,7 +295,12 @@ export const modelExternalTester = (
294295
cy.log(`Request attempt ${attemptNumber} of ${maxAttempts}`);
295296
cy.log(`Request URL: ${url}/v2/models/${modelName}/infer`);
296297
cy.log(`Request method: POST`);
297-
cy.log(`Request headers: ${JSON.stringify({ 'Content-Type': 'application/json' })}`);
298+
cy.log(
299+
`Request headers: ${JSON.stringify({
300+
'Content-Type': 'application/json',
301+
...(token && { Authorization: `Bearer ${token}` }),
302+
})}`,
303+
);
298304
cy.log(
299305
`Request body: ${JSON.stringify({
300306
inputs: [
@@ -314,6 +320,7 @@ export const modelExternalTester = (
314320
url: `${url}/v2/models/${modelName}/infer`,
315321
headers: {
316322
'Content-Type': 'application/json',
323+
...(token && { Authorization: `Bearer ${token}` }),
317324
},
318325
body: {
319326
inputs: [
@@ -452,3 +459,60 @@ export const verifyS3CopyCompleted = (
452459
}
453460
});
454461
};
462+
/**
463+
* Retrieve the token for a given service account and model
464+
*
465+
* @param namespace The namespace where the InferenceService is deployed.
466+
* @param serviceAccountName The name of the service account to get the token for.
467+
* @param modelName The name of the model to get the token for.
468+
* @returns Cypress.Chainable<string> that resolves after validation.
469+
*/
470+
export const getModelExternalToken = (
471+
namespace: string,
472+
serviceAccountName: string,
473+
modelName: string,
474+
): Cypress.Chainable<string> => {
475+
return cy
476+
.exec(
477+
`oc get secret ${serviceAccountName}-${modelName}-sa -n ${namespace} -o jsonpath='{.data.token}' | base64 -d`,
478+
)
479+
.then((result) => {
480+
return result.stdout;
481+
});
482+
};
483+
484+
/**
485+
* Verify the model is accessible with a token
486+
*
487+
* @param modelName The name of the model to test.
488+
* @param namespace The namespace where the model is deployed.
489+
* @param token The (optional) token to use for the request.
490+
* @returns Cypress.Chainable<Cypress.Response<unknown>> that resolves after validation.
491+
*/
492+
export const verifyModelExternalToken = (
493+
modelName: string,
494+
namespace: string,
495+
token?: string,
496+
): Cypress.Chainable<Cypress.Response<unknown>> => {
497+
return cy.exec(`oc get inferenceService ${modelName} -n ${namespace} -o json`).then((result) => {
498+
const inferenceService = JSON.parse(result.stdout);
499+
const { url } = inferenceService.status;
500+
501+
if (!url) {
502+
throw new Error('External URL not found in InferenceService');
503+
}
504+
505+
return cy
506+
.request({
507+
method: 'GET',
508+
url: `${url}/v2/models/${modelName}`,
509+
headers: {
510+
...(token && { Authorization: `Bearer ${token}` }),
511+
},
512+
})
513+
.then((response) => {
514+
cy.log('Model metadata:', JSON.stringify(response.body));
515+
return cy.wrap(response);
516+
});
517+
});
518+
};

frontend/src/pages/modelServing/screens/projects/ServingRuntimeModal/ServingRuntimeTokenSection.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ const ServingRuntimeTokenSection = <D extends CreatingModelServingObjectCommon>(
7474
variant="link"
7575
icon={<PlusCircleIcon />}
7676
isDisabled={!allowCreate}
77+
data-testid="add-service-account-button"
7778
>
7879
Add a service account
7980
</Button>

0 commit comments

Comments
 (0)