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

[Themes] Theme Console - ensureReplEnv 1/2 - Storefront Password Prompting #4191

Merged
merged 8 commits into from
Jul 18, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ export interface themeconsole {
*/
'-s, --store <value>'?: string

/**
* The password for the storefront.
* @environment SHOPIFY_FLAG_STORE_PASSWORD
*/
'--store-password <value>'?: string

/**
* The url to be used as context
* @environment SHOPIFY_FLAG_URL
Expand Down
11 changes: 10 additions & 1 deletion docs-shopify.dev/generated/generated_docs_data.json
Original file line number Diff line number Diff line change
Expand Up @@ -4255,6 +4255,15 @@
"isOptional": true,
"environmentValue": "SHOPIFY_FLAG_PORT"
},
{
"filePath": "docs-shopify.dev/commands/interfaces/theme-console.interface.ts",
"syntaxKind": "PropertySignature",
"name": "--store-password <value>",
"value": "string",
"description": "The password for the storefront.",
"isOptional": true,
"environmentValue": "SHOPIFY_FLAG_STORE_PASSWORD"
},
{
"filePath": "docs-shopify.dev/commands/interfaces/theme-console.interface.ts",
"syntaxKind": "PropertySignature",
Expand Down Expand Up @@ -4292,7 +4301,7 @@
"environmentValue": "SHOPIFY_FLAG_STORE"
}
],
"value": "export interface themeconsole {\n /**\n * The environment to apply to the current command.\n * @environment SHOPIFY_FLAG_ENVIRONMENT\n */\n '-e, --environment <value>'?: string\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Password generated from the Theme Access app.\n * @environment SHOPIFY_CLI_THEME_TOKEN\n */\n '--password <value>'?: string\n\n /**\n * Local port to serve authentication service.\n * @environment SHOPIFY_FLAG_PORT\n */\n '--port <value>'?: string\n\n /**\n * Store URL. It can be the store prefix (johns-apparel) or the full myshopify.com URL (johns-apparel.myshopify.com, https://johns-apparel.myshopify.com).\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store <value>'?: string\n\n /**\n * The url to be used as context\n * @environment SHOPIFY_FLAG_URL\n */\n '--url <value>'?: string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}"
"value": "export interface themeconsole {\n /**\n * The environment to apply to the current command.\n * @environment SHOPIFY_FLAG_ENVIRONMENT\n */\n '-e, --environment <value>'?: string\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Password generated from the Theme Access app.\n * @environment SHOPIFY_CLI_THEME_TOKEN\n */\n '--password <value>'?: string\n\n /**\n * Local port to serve authentication service.\n * @environment SHOPIFY_FLAG_PORT\n */\n '--port <value>'?: string\n\n /**\n * Store URL. It can be the store prefix (johns-apparel) or the full myshopify.com URL (johns-apparel.myshopify.com, https://johns-apparel.myshopify.com).\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store <value>'?: string\n\n /**\n * The password for the storefront.\n * @environment SHOPIFY_FLAG_STORE_PASSWORD\n */\n '--store-password <value>'?: string\n\n /**\n * The url to be used as context\n * @environment SHOPIFY_FLAG_URL\n */\n '--url <value>'?: string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}"
}
}
}
Expand Down
17 changes: 9 additions & 8 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1611,14 +1611,15 @@ USAGE
$ shopify theme console --url /products/classic-leather-jacket

FLAGS
-e, --environment=<value> The environment to apply to the current command.
-s, --store=<value> Store URL. It can be the store prefix (johns-apparel) or the full myshopify.com URL
(johns-apparel.myshopify.com, https://johns-apparel.myshopify.com).
--no-color Disable color output.
--password=<value> Password generated from the Theme Access app.
--port=<value> [default: 9293] Local port to serve authentication service.
--url=<value> [default: /] The url to be used as context
--verbose Increase the verbosity of the output.
-e, --environment=<value> The environment to apply to the current command.
-s, --store=<value> Store URL. It can be the store prefix (johns-apparel) or the full myshopify.com URL
(johns-apparel.myshopify.com, https://johns-apparel.myshopify.com).
--no-color Disable color output.
--password=<value> Password generated from the Theme Access app.
--port=<value> [default: 9293] Local port to serve authentication service.
--store-password=<value> The password for the storefront.
--url=<value> [default: /] The url to be used as context
--verbose Increase the verbosity of the output.

DESCRIPTION
Shopify Liquid REPL (read-eval-print loop) tool
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -4688,6 +4688,14 @@
"name": "store",
"type": "option"
},
"store-password": {
"description": "The password for the storefront.",
"env": "SHOPIFY_FLAG_STORE_PASSWORD",
"hasDynamicHelp": false,
"multiple": false,
"name": "store-password",
"type": "option"
},
"url": {
"default": "/",
"description": "The url to be used as context",
Expand Down
15 changes: 10 additions & 5 deletions packages/theme/src/cli/commands/theme/console.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {themeFlags} from '../../flags.js'
import ThemeCommand from '../../utilities/theme-command.js'
import {ensureThemeStore} from '../../utilities/theme-store.js'
import {repl} from '../../services/console.js'
import {ensureReplEnv, repl} from '../../services/console.js'
import {globalFlags} from '@shopify/cli-kit/node/cli'
import {ensureAuthenticatedStorefront, ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session'
import {execCLI2} from '@shopify/cli-kit/node/ruby'
Expand Down Expand Up @@ -36,6 +36,10 @@ export default class Console extends ThemeCommand {
env: 'SHOPIFY_FLAG_PORT',
default: '9293',
}),
'store-password': Flags.string({
description: 'The password for the storefront.',
env: 'SHOPIFY_FLAG_STORE_PASSWORD',
}),
'dev-preview': Flags.boolean({
hidden: true,
description: 'Enables the developer preview for the upcoming `theme console` implementation.',
Expand All @@ -46,17 +50,18 @@ export default class Console extends ThemeCommand {
async run() {
const {flags} = await this.parse(Console)
const store = ensureThemeStore(flags)
const {password, url, port} = flags
const {url, port, password: themeAccessPassword} = flags
const cliVersion = CLI_KIT_VERSION
const theme = `liquid-console-repl-${cliVersion}`

const adminSession = await ensureAuthenticatedThemes(store, password, [], true)
const storefrontToken = await ensureAuthenticatedStorefront([], password)
const adminSession = await ensureAuthenticatedThemes(store, themeAccessPassword, [], true)
const storefrontToken = await ensureAuthenticatedStorefront([], themeAccessPassword)
const authUrl = `http://localhost:${port}/password`

if (flags['dev-preview']) {
outputInfo('This feature is currently in development and is not ready for use or testing yet.')
await repl(adminSession, storefrontToken, flags.password)
const {themeId, storePassword} = await ensureReplEnv(store, flags['store-password'])
await repl(adminSession, storefrontToken, themeId, storePassword)
return
}

Expand Down
42 changes: 42 additions & 0 deletions packages/theme/src/cli/services/console.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {ensureReplEnv} from './console.js'
import {
isStorefrontPasswordCorrect,
isStorefrontPasswordProtected,
} from '../utilities/theme-environment/storefront-session.js'
import {ensureValidPassword} from '../utilities/prompts.js'
import {beforeEach, describe, expect, test, vi} from 'vitest'

vi.mock('../utilities/theme-environment/storefront-session.js')
vi.mock('../utilities/prompts.js')

describe('ensureReplEnv', () => {
beforeEach(() => {
vi.mocked(ensureValidPassword).mockResolvedValue('testPassword')
})

test('should prompt for password when storefront is password protected', async () => {
// Given
vi.mocked(isStorefrontPasswordProtected).mockResolvedValue(true)
vi.mocked(isStorefrontPasswordCorrect).mockResolvedValue(true)

// When
const {storePassword} = await ensureReplEnv('test-store')

// Then
expect(ensureValidPassword).toHaveBeenCalled()
expect(storePassword).toBe('testPassword')
})

test('should skip prompt for password when storefront is not password protected', async () => {
// Given
vi.mocked(isStorefrontPasswordProtected).mockResolvedValue(false)
vi.mocked(isStorefrontPasswordCorrect).mockResolvedValue(true)

// When
const {storePassword} = await ensureReplEnv('test-store')

// Then
expect(ensureValidPassword).not.toHaveBeenCalled()
expect(storePassword).toBeUndefined()
})
})
26 changes: 25 additions & 1 deletion packages/theme/src/cli/services/console.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,27 @@
import {ensureValidPassword} from '../utilities/prompts.js'
import {isStorefrontPasswordProtected} from '../utilities/theme-environment/storefront-session.js'
import {AdminSession} from '@shopify/cli-kit/node/session'

export async function repl(_adminSession: AdminSession, _storefrontToken: string, _password?: string) {}
export async function ensureReplEnv(store: string, storePasswordFlag?: string) {
const themeId = await findOrCreateReplTheme()

const storePassword = (await isStorefrontPasswordProtected(store))
? await ensureValidPassword(storePasswordFlag, store)
: undefined

return {
themeId,
storePassword,
}
}

async function findOrCreateReplTheme(): Promise<string> {
return ''
}

export async function repl(
_adminSession: AdminSession,
_storefrontToken: string,
_themeId: string,
_password: string | undefined,
) {}
54 changes: 54 additions & 0 deletions packages/theme/src/cli/utilities/prompts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import {isStorefrontPasswordProtected, isStorefrontPasswordCorrect} from './theme-environment/storefront-session.js'
import {ensureValidPassword} from './prompts.js'
import {AdminSession} from '@shopify/cli-kit/node/session'
import {renderTextPrompt} from '@shopify/cli-kit/node/ui'
import {describe, beforeEach, vi, test, expect} from 'vitest'

vi.mock('../utilities/theme-environment/storefront-session.js')
vi.mock('@shopify/cli-kit/node/ui')
vi.mock('../utilities/repl-theme-manager.js', () => {
const REPLThemeManager = vi.fn()
REPLThemeManager.prototype.findOrCreate = () => ({
id: 1,
name: 'theme',
role: 'development',
createdAtRuntime: true,
processing: true,
})
return {REPLThemeManager}
})

describe('ensureValidPassword', () => {
beforeEach(() => {
vi.mocked(renderTextPrompt).mockResolvedValue('testPassword')
})

const adminSession: AdminSession = {storeFqdn: 'test-store.myshopify.com', token: 'token'}

test('should skip prompt for password when correct storefront password is provided', async () => {
// Given
vi.mocked(isStorefrontPasswordProtected).mockResolvedValue(true)
vi.mocked(isStorefrontPasswordCorrect).mockResolvedValue(true)

// When
await ensureValidPassword('correctPassword', 'test-store')

// Then
expect(renderTextPrompt).not.toHaveBeenCalled()
})

test('should prompt for correct password when incorrect password is provided', async () => {
// Given
vi.mocked(isStorefrontPasswordProtected).mockResolvedValue(true)
vi.mocked(isStorefrontPasswordCorrect)
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(false)
.mockResolvedValue(true)

// When
await ensureValidPassword('incorrectPassword', 'test-store')

// Then
expect(renderTextPrompt).toHaveBeenCalledTimes(2)
})
})
20 changes: 20 additions & 0 deletions packages/theme/src/cli/utilities/prompts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import {isStorefrontPasswordCorrect} from './theme-environment/storefront-session.js'
import {renderTextPrompt} from '@shopify/cli-kit/node/ui'

export async function ensureValidPassword(password: string | undefined, store: string) {
let finalPassword = password || (await promptPassword('Enter your theme password'))

// eslint-disable-next-line no-await-in-loop
while (!(await isStorefrontPasswordCorrect(finalPassword, store))) {
// eslint-disable-next-line no-await-in-loop
finalPassword = await promptPassword('Incorrect password provided. Please try again')
}
return finalPassword
}

async function promptPassword(prompt: string): Promise<string> {
return renderTextPrompt({
message: prompt,
password: true,
})
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import {getStorefrontSessionCookies, isStorefrontPasswordProtected} from './storefront-session.js'
import {
getStorefrontSessionCookies,
isStorefrontPasswordCorrect,
isStorefrontPasswordProtected,
} from './storefront-session.js'
import {describe, expect, test, vi} from 'vitest'
import {fetch} from '@shopify/cli-kit/node/http'

Expand Down Expand Up @@ -126,7 +130,79 @@ describe('Storefront API', () => {
headers: {
...mock.headers,
raw: vi.fn().mockReturnValue({'set-cookie': setCookieArray}),
get: vi.fn().mockImplementation((key) => mock.headers?.[key]),
},
} as any
}

describe('storefrontPasswordIsCorrect', () => {
test('returns true when the password is correct', async () => {
// Given
vi.mocked(fetch).mockResolvedValueOnce(
response({
status: 302,
headers: {
location: 'https://store.myshopify.com/',
},
}),
)

// When
const result = await isStorefrontPasswordCorrect('correct-password', 'store.myshopify.com')

// Then
expect(result).toBe(true)
})

test('returns false when the password is incorrect', async () => {
// Given
vi.mocked(fetch).mockResolvedValueOnce(
response({
status: 401,
}),
)

// When
const result = await isStorefrontPasswordCorrect('wrong-password', 'store.myshopify.com')

// Then
expect(result).toBe(false)
})

test('returns false when the redirect location is incorrect', async () => {
// Given
vi.mocked(fetch).mockResolvedValueOnce(
response({
status: 302,
headers: {
location: 'https://random-location.com/',
},
}),
)

// When
const result = await isStorefrontPasswordCorrect('correct-password', 'store.myshopify.com')

// Then
expect(result).toBe(false)
})

test('throws an error when the server responds with "Too Many Requests"', async () => {
// Given
vi.mocked(fetch).mockResolvedValueOnce(
response({
status: 429,
headers: {
'retry-after': '60',
},
}),
)

// When
const result = isStorefrontPasswordCorrect('wrong-password', 'store.myshopify.com')

// Then
await expect(result).rejects.toThrow('Too many incorrect password attempts. Please try again after 60 seconds.')
})
})
})
Loading