Skip to content

Commit

Permalink
fix: [M3-7083 & M3-7175] MarketPlace regex fix + end to end coverage (#…
Browse files Browse the repository at this point in the history
…9704)

* fix: [fix-M3-7083] initial coomit: save work

* Make utils and introduce new e2e

* Asserting apps

* Refactor hard coded new apps

* Assert matching drawers

* Assert drawer content

* Save work

* Assert filtering

* Cleanup

* Create linode from OCA

* Cleanup

* Moar Cleanup

* Moar Cleanup

* Fix unit test

* Added changeset: Overly permissive regex in One Click Apps flow

* Fix import
  • Loading branch information
abailly-akamai authored Sep 28, 2023
1 parent d642c62 commit 77db948
Show file tree
Hide file tree
Showing 19 changed files with 616 additions and 100 deletions.
5 changes: 5 additions & 0 deletions packages/manager/.changeset/pr-9704-fixed-1695752085559.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Fixed
---

Overly permissive regex in One Click Apps flow ([#9704](https://github.com/linode/manager/pull/9704))
249 changes: 249 additions & 0 deletions packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
import { containsClick, containsVisible } from 'support/helpers';
import { ui } from 'support/ui';
import { authenticate } from 'support/api/authentication';
import { cleanUp } from 'support/util/cleanup';
import {
interceptGetStackScripts,
mockGetStackScripts,
} from 'support/intercepts/stackscripts';
import { interceptCreateLinode } from 'support/intercepts/linodes';
import {
filterOneClickApps,
handleAppLabel,
} from 'src/features/Linodes/LinodesCreate/utilities';
import { randomLabel, randomString } from 'support/util/random';
import { chooseRegion } from 'support/util/regions';
import {
mockAppendFeatureFlags,
mockGetFeatureFlagClientstream,
} from 'support/intercepts/feature-flags';
import { makeFeatureFlagData } from 'support/util/feature-flags';
import { mapStackScriptLabelToOCA } from 'src/features/OneClickApps/utils';
import { baseApps } from 'src/features/StackScripts/stackScriptUtils';
import { stackScriptFactory } from 'src/factories/stackscripts';
import { oneClickApps } from 'src/features/OneClickApps/oneClickApps';

import type { StackScript } from '@linode/api-v4';
import type { OCA } from '@src/features/OneClickApps/types';

authenticate();

describe('OneClick Apps (OCA)', () => {
before(() => {
cleanUp(['linodes']);
});

it('Lists all the OneClick Apps', () => {
interceptGetStackScripts().as('getStackScripts');

cy.visitWithLogin(`/linodes/create?type=One-Click`);

cy.wait('@getStackScripts').then((xhr) => {
const stackScripts: StackScript[] = xhr.response?.body.data ?? [];

const trimmedApps: StackScript[] = filterOneClickApps({
baseApps,
newApps: {},
queryResults: stackScripts,
});

// Check the content of the OCA listing
cy.findByTestId('one-click-apps-container').within(() => {
// Check that all sections are present (note: New apps can be empty so not asserting its presence)
cy.findByTestId('Popular apps').should('exist');
cy.findByTestId('All apps').should('exist');

trimmedApps.forEach((stackScript) => {
const { decodedLabel, label } = handleAppLabel(stackScript);

// Check that every OCA is listed with the correct label
cy.get(`[data-qa-select-card-heading="${label}"]`).should('exist');

// Check that every OCA has a drawer match
// This validates the regex in `mapStackScriptLabelToOCA`
// and ensures every app listed has a corresponding populated drawer
// This is only true for the apps defined in `oneClickApps.ts`
expect(
mapStackScriptLabelToOCA({
oneClickApps,
stackScriptLabel: decodedLabel,
})
).to.not.be.undefined;
});
});

// Check drawer content for one OCA candidate
const candidate = trimmedApps[0].label;
const stackScriptCandidate = cy
.get(`[data-qa-selection-card-info="${candidate}"]`)
.first();
stackScriptCandidate.should('exist').click();

const app: OCA | undefined = mapStackScriptLabelToOCA({
oneClickApps,
stackScriptLabel: candidate,
});

ui.drawer
.findByTitle(trimmedApps[0].label)
.should('be.visible')
.within(() => {
containsVisible(app?.description);
containsVisible(app?.summary);
containsVisible(app?.website);
});
ui.drawerCloseButton.find().click();
ui.drawer.find().should('not.exist');

// Check the filtering of the apps
cy.scrollTo(0, 0);
const initialNumberOfApps = trimmedApps.length;
cy.findByPlaceholderText('Search for app name')
.should('exist')
.type(candidate);
cy.findByTestId('one-click-apps-container').within(() => {
cy.get('[data-qa-selection-card="true"]').should(
'have.length.below',
initialNumberOfApps
);
cy.get(`[data-qa-selection-card-info="${candidate}"]`).should(
'be.visible'
);
});
});
});

it('Deploys a Linode from a One Click App', () => {
const stackscriptId = 401709;
const stackScripts = stackScriptFactory.build({
id: stackscriptId,
username: 'linode',
user_gravatar_id: '9d4d301385af69ceb7ad658aad09c142',
label: 'E2E Test App',
description: 'Minecraft OCA',
ordinal: 10,
logo_url: 'assets/Minecraft.svg',
images: ['linode/debian11', 'linode/ubuntu20.04'],
deployments_total: 18854,
deployments_active: 412,
is_public: true,
mine: false,
created: '2019-03-08T21:13:32',
updated: '2023-09-26T15:00:45',
rev_note: 'remove maxplayers hard coded options [oca-707]',
script: '#!/usr/bin/env bash\n',
user_defined_fields: [
{
name: 'username',
label:
"The username for the Linode's non-root admin/SSH user(must be lowercase)",
example: 'lgsmuser',
},
{
name: 'password',
label: "The password for the Linode's non-root admin/SSH user",
example: 'S3cuReP@s$w0rd',
},
{
name: 'levelname',
label: 'World Name',
},
],
});

const firstName = randomLabel();
const password = randomString(16);
const image = 'linode/ubuntu20.04';
const rootPassword = randomString(16);
const region = chooseRegion();
const linodeLabel = randomLabel();
const levelName = 'Get the enderman!';

mockGetStackScripts(stackScripts).as('getStackScripts');
mockAppendFeatureFlags({
oneClickApps: makeFeatureFlagData({
401709: 'E2E Test App',
}),
}).as('getFeatureFlags');
mockGetFeatureFlagClientstream().as('getClientStream');

cy.visitWithLogin(`/linodes/create?type=One-Click`);

cy.wait('@getFeatureFlags');
cy.wait('@getStackScripts');

cy.findByTestId('one-click-apps-container').within(() => {
// Since it is mock data we can assert the New App section is present
cy.findByTestId('New apps').should('exist');

// Check that the app is listed and select it
cy.get('[data-qa-selection-card="true"]').should('have.length', 3);
cy.get(`[id=app-${stackscriptId}]`).first().should('be.visible').click();
});

// Input the user defined fields
const userFieldId =
"the-username-for-the-linode's-non-root-admin/ssh-user(must-be-lowercase)";
const passwordFieldId =
"the-password-for-the-linode's-non-root-admin/ssh-user";
const levelNameFieldId = 'world-name';

cy.findByTestId('user-defined-fields-panel').within(() => {
cy.get(`[id="${userFieldId}"]`)
.should('be.visible')
.click()
.type(`${firstName}{enter}`);
cy.get(`[id="${passwordFieldId}"]`)
.should('be.visible')
.click()
.type(`${password}{enter}`);
cy.get(`[id="${levelNameFieldId}"]`)
.should('be.visible')
.click()
.type(`${levelName}{enter}`);

// Check each field should persist when moving onto another field
cy.get(`[id="${userFieldId}"]`).should('have.value', firstName);
cy.get(`[id="${passwordFieldId}"]`).should('have.value', password);
cy.get(`[id="${levelNameFieldId}"]`).should('have.value', levelName);
});

// Choose an image
cy.get('[data-qa-enhanced-select="Choose an image"]').within(() => {
containsClick('Choose an image').type(`${image}{enter}`);
});

// Choose a region
cy.get(`[data-qa-enhanced-select="Select a Region"]`).within(() => {
containsClick('Select a Region').type(`${region.id}{enter}`);
});

// Choose a Linode plan
cy.get('[data-qa-plan-row="Dedicated 8 GB"]')
.closest('tr')
.within(() => {
cy.get('[data-qa-radio]').click();
});

// Enter a label.
cy.findByText('Linode Label')
.should('be.visible')
.click()
.type('{selectAll}{backspace}')
.type(linodeLabel);

// Choose a Root Password
cy.get('[id="root-password"]').type(rootPassword);

// Create the Linode
interceptCreateLinode().as('createLinode');
ui.button
.findByTitle('Create Linode')
.should('be.visible')
.should('be.enabled')
.click();

cy.wait('@createLinode');
ui.toast.assertMessage(`Your Linode ${linodeLabel} is being created.`);
});
});
1 change: 1 addition & 0 deletions packages/manager/cypress/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"moduleResolution": "node",
"lib": ["es6", "dom"],
"baseUrl": "..",
"jsx": "react",
"paths": {
"@src/*": ["src/*"],
"support/*": ["cypress/support/*"],
Expand Down
21 changes: 19 additions & 2 deletions packages/manager/src/factories/stackscripts.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import {
import * as Factory from 'factory.ts';

import type {
StackScript,
UserDefinedField,
} from '@linode/api-v4/lib/stackscripts/types';
import * as Factory from 'factory.ts';
import type { OCA } from 'src/features/OneClickApps/types';

export const stackScriptFactory = Factory.Sync.makeFactory<StackScript>({
created: '2010-12-31T23:59:58',
Expand All @@ -24,6 +26,21 @@ export const stackScriptFactory = Factory.Sync.makeFactory<StackScript>({
username: 'Linode',
});

export const oneClickAppFactory = Factory.Sync.makeFactory<OCA>({
alt_description: 'A test app',
alt_name: 'Test App',
categories: ['App Creators'],
colors: {
end: '#000000',
start: '#000000',
},
description: 'A test app',
logo_url: 'nodejs.svg',
name: 'Test App',
summary: 'A test app',
website: 'https://www.linode.com',
});

export const userDefinedFieldFactory = Factory.Sync.makeFactory<UserDefinedField>(
{
label: Factory.each((i) => `Field${i}`),
Expand Down
8 changes: 3 additions & 5 deletions packages/manager/src/featureFlags.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { TPAProvider } from '@linode/api-v4/lib/profile';

import { NoticeVariant } from 'src/components/Notice/Notice';

import { Doc } from './features/OneClickApps/oneClickApps';
import type { Doc } from './features/OneClickApps/types';
import type { TPAProvider } from '@linode/api-v4/lib/profile';
import type { NoticeVariant } from 'src/components/Notice/Notice';

// These flags should correspond with active features flags in LD

Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { StackScript, UserDefinedField } from '@linode/api-v4/lib/stackscripts';
import Grid from '@mui/material/Unstable_Grid2';
import { styled } from '@mui/material/styles';
import { decode } from 'he';
import * as React from 'react';

import { Box } from 'src/components/Box';
import { Chip } from 'src/components/Chip';
import { Divider } from 'src/components/Divider';
import { Typography } from 'src/components/Typography';
import { SelectionCardWrapper } from 'src/features/Linodes/LinodesCreate/SelectionCardWrapper';

import { handleAppLabel } from './utilities';

interface Props {
apps: StackScript[];
disabled: boolean;
Expand Down Expand Up @@ -41,24 +43,15 @@ export const AppPanelSection = (props: Props) => {
}

return (
<>
<Box data-testid={heading}>
<Typography variant="h2">{heading}</Typography>
{heading && heading.length > 0 ? (
<Divider spacingBottom={16} spacingTop={16} />
) : null}
{apps.length > 0 ? (
<AppPanelGrid container spacing={2}>
{apps.map((eachApp) => {
const decodedLabel = decode(eachApp.label);
const isCluster =
decodedLabel.endsWith('Cluster ') &&
eachApp.user_defined_fields.some(
(field) => field.name === 'cluster_size'
);

const label = isCluster
? decodedLabel.split(' Cluster')[0]
: decodedLabel;
const { decodedLabel, isCluster, label } = handleAppLabel(eachApp);

return (
<SelectionCardWrapper
Expand Down Expand Up @@ -86,7 +79,7 @@ export const AppPanelSection = (props: Props) => {
{`Sorry, no results matching "${searchValue}" were found.`}
</Typography>
)}
</>
</Box>
);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ export class LinodeCreate extends React.PureComponent<
const {
accountBackupsEnabled,
errors,
flags,
formIsSubmitting,
handleAgreementChange,
handleShowApiAwarenessModal,
Expand Down Expand Up @@ -440,6 +441,7 @@ export class LinodeCreate extends React.PureComponent<
// error={hasErrorFor.image}
accountBackupsEnabled={accountBackupsEnabled}
errors={errors}
flags={flags}
imagesData={imagesData!}
regionsData={regionsData!}
typesData={typesData!}
Expand Down
Loading

0 comments on commit 77db948

Please sign in to comment.