diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1102e45..c75d55d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -79,3 +79,132 @@ Before submitting your contribution, ensure the following: - [ ] Documentation updated (if applicable) - [ ] Code formatted and linted - [ ] Changes thoroughly explained in the PR description + + + +# AuthProvider Component Documentation + +## **Overview** +The `AuthProvider` component is a React context provider designed to manage authentication, authorization, and API key handling for CLI-based workflows. It handles multiple flows, such as login, API key validation, token management, and organization/project/environment selection. + +This component is the backbone of authentication for the CLI app. It ensures a smooth and secure authentication process by abstracting away the complexities of API key creation, validation, and token management. + +--- + +## **What It Does** +### **Authentication Flows** +1. **Token Validation:** + - It attempts to load an existing authentication token using `loadAuthToken()`. + - If a token exists but has the wrong scope or is invalid, the user is redirected to reauthenticate. + +2. **API Key Validation:** + - The component validates the provided `--key` (if supplied) against the required scope (e.g., organization, project, or environment). + - If invalid, it throws an error. + +3. **Token Creation and Management:** + - If no key is provided or no stored key is found, we take the user through appropriate selection flow, and get a token of that scope. + - If while fetching the token, no valid key with name (`CLI_API_Key`) is found, the component handles the creation of a new API key (`CLI_API_Key`), ensuring it is scoped appropriately. + +### **User Prompts** +- Prompts users to select an organization or project if required and dynamically handles state transitions based on the user's input. + +### **Error Handling** +- Any error in the authentication flow (e.g., invalid token, API failure) is captured and displayed to the user. If an error is critical, the CLI exits with a non-zero status. + +--- + +## **Key Features** +1. **`--key<-->permitKey` Functionality:** + - Users can pass a `--key` flag to provide an API key directly to the `permitKey` prop of `AuthProvider`. The component validates the key and uses it if valid + has a valid scope. + - If not passed, the component tries to load a previously stored token or guides the user through a scope based selection and key creation flow. + +2. **Scope Handling:** + - The `scope` prop defines the required level of access (e.g., `organization`, `project`, or `environment`). + - Based on the scope, the component dynamically fetches or validates the key. + +3. **Key Creation:** + - If on an organization/project scope level, we fetch the key, and we don't find a `CLI_API_Key`, we create one for the user and notify them that it's a secure token and not to edit it in any way. + +4. **Error and Loading Indicators:** + - Displays appropriate messages during loading or error states to ensure users are informed about what’s happening. + +--- + +## **How to Use It** + +- Any component that is wrapped with AuthProvider can use `useAuth()` to access: +```tsx +type AuthContextType = { + authToken: string; + loading: boolean; + error?: string | null; + scope: ApiKeyScope; +}; +``` + +1. **Wrap Your Application:** + - The `AuthProvider` should wrap the root of your CLI app. It ensures that authentication is initialized before the app runs. + ```tsx + import { AuthProvider } from './context/AuthProvider'; + + const App = () => ( + // The scope here is optional and defaults to environment + + + ); + ``` + +2. **Access Authentication Context:** + - Use the `useAuth` hook to access the `authToken` and other authentication states in your components. + ```tsx + import { useAuth } from './context/AuthProvider'; + + const MyCommand = () => { + const { authToken, loading, error, scope } = useAuth(); + + if (loading) { + return Loading...; + } + + if (error) { + return Error: {error}; + } + + return Authenticated with token: {authToken}; + }; + ``` + +3. **Customizing Behavior:** + - Pass the `permit_key` or `scope` prop to customize the authentication flow. + - Example: + ```tsx + + + + ``` + +--- + +## **What Happens Inside** +### **Step-by-Step Breakdown** +1. **Initialization:** + - Checks if a `permit_key` or token is already available. + - Validates the token against the required `scope`. + +2. **Token Validation:** + - If invalid or missing, it guides the user through organization/project/environment selection. + +3. **API Key Handling:** + - Searches for an existing `CLI_API_Key` scoped to the organization/project. + - Creates one if it doesn’t exist and retrieves its secret. + +4. **State Transition:** + - Handles transitions between `loading`, `login`, `organization`, `project`, and `done` states based on user input and validation results. + +5. **Error Handling:** + - Displays errors and exits the process if authentication fails irrecoverably. + +--- + + + diff --git a/eslint.config.js b/eslint.config.js index f92e870..a062163 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -7,6 +7,7 @@ import reactHooksPlugin from 'eslint-plugin-react-hooks'; import prettierPlugin from 'eslint-plugin-prettier'; import js from '@eslint/js'; import prettierConfig from 'eslint-config-prettier'; +import globals from "globals"; const compat = new FlatCompat({ baseDirectory: import.meta.url, @@ -31,6 +32,7 @@ export default [ }, }, globals: { + ...globals.browser, Headers: 'readonly', RequestInit: 'readonly', fetch: 'readonly', diff --git a/package-lock.json b/package-lock.json index b77be75..c80c96c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "MIT", "dependencies": { + "@scaleway/random-name": "^5.1.1", "clipboardy": "^4.0.0", "delay": "^6.0.0", "fuse.js": "^7.0.0", @@ -58,7 +59,7 @@ "vitest": "^2.1.8" }, "engines": { - "node": ">=16" + "node": ">=22" } }, "node_modules/@alcalzone/ansi-tokenize": { @@ -3363,6 +3364,14 @@ "dev": true, "license": "MIT" }, + "node_modules/@scaleway/random-name": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@scaleway/random-name/-/random-name-5.1.1.tgz", + "integrity": "sha512-DelRK+56UBUEoRr8pQGwTE7oyC019pExis6iG6zZPcjuz4nLbMB5LIosmclnsvajfIBEQiD1Dv9JI/MgnPwygQ==", + "engines": { + "node": ">=20.x" + } + }, "node_modules/@scure/base": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.1.tgz", diff --git a/package.json b/package.json index 1785fec..5fc8174 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "dist" ], "dependencies": { + "@scaleway/random-name": "^5.1.1", "clipboardy": "^4.0.0", "delay": "^6.0.0", "fuse.js": "^7.0.0", diff --git a/source/commands/env/copy.tsx b/source/commands/env/copy.tsx index cea0d63..37e88ac 100644 --- a/source/commands/env/copy.tsx +++ b/source/commands/env/copy.tsx @@ -1,23 +1,20 @@ -import React, { useCallback, useEffect, useState } from 'react'; -import { Text } from 'ink'; +import React from 'react'; import { option } from 'pastel'; -import { TextInput } from '@inkjs/ui'; import zod from 'zod'; import { type infer as zInfer } from 'zod'; -import { useApiKeyApi } from '../../hooks/useApiKeyApi.js'; -import { useEnvironmentApi } from '../../hooks/useEnvironmentApi.js'; -import EnvironmentSelection, { - ActiveState, -} from '../../components/EnvironmentSelection.js'; -import { cleanKey } from '../../lib/env/copy/utils.js'; +import { AuthProvider } from '../../components/AuthProvider.js'; +import CopyComponent from '../../components/env/CopyComponent.js'; export const options = zod.object({ - key: zod.string().describe( - option({ - description: - 'API Key to be used for the environment copying (should be at least a project level key)', - }), - ), + key: zod + .string() + .optional() + .describe( + option({ + description: + 'Optional: API Key to be used for the environment copying (should be at least a project level key). In case not set, CLI lets you select one', + }), + ), from: zod .string() .optional() @@ -70,192 +67,20 @@ type Props = { readonly options: zInfer; }; -interface EnvCopyBody { - existingEnvId?: string | null; - newEnvKey?: string | null; - newEnvName?: string | null; - newEnvDescription?: string | null; - conflictStrategy?: string | null; -} - export default function Copy({ - options: { - key: apiKey, - from, - to: envToId, - name, - description, - conflictStrategy, - }, + options: { key, from, to, name, description, conflictStrategy }, }: Props) { - const [error, setError] = React.useState(null); - const [authToken, setAuthToken] = React.useState(null); - const [state, setState] = useState< - | 'loading' - | 'selecting-env' - | 'selecting-name' - | 'selecting-description' - | 'copying' - | 'done' - >('loading'); - const [projectFrom, setProjectFrom] = useState(null); - const [envToName, setEnvToName] = useState(name); - const [envFrom, setEnvFrom] = useState(from); - const [envToDescription, setEnvToDescription] = useState( - description, - ); - - const { validateApiKeyScope } = useApiKeyApi(); - const { copyEnvironment } = useEnvironmentApi(); - - useEffect(() => { - if (error || state === 'done') { - process.exit(1); - } - }, [error, state]); - - useEffect(() => { - const handleEnvCopy = async (envCopyBody: EnvCopyBody) => { - let body = {}; - if (envCopyBody.existingEnvId) { - body = { - target_env: { existing: envCopyBody.existingEnvId }, - }; - } else if (envCopyBody.newEnvKey && envCopyBody.newEnvName) { - body = { - target_env: { - new: { - key: cleanKey(envCopyBody.newEnvKey), - name: envCopyBody.newEnvName, - description: envCopyBody.newEnvDescription ?? '', - }, - }, - }; - } - if (conflictStrategy) { - body = { - ...body, - conflict_strategy: envCopyBody.conflictStrategy ?? 'fail', - }; - } - const { error } = await copyEnvironment( - projectFrom ?? '', - envFrom ?? '', - apiKey, - null, - body, - ); - if (error) { - setError(`Error while copying Environment: ${error}`); - return; - } - setState('done'); - }; - - if ( - ((envToName && envToDescription && conflictStrategy) || envToId) && - envFrom && - projectFrom - ) { - setState('copying'); - handleEnvCopy({ - newEnvKey: envToName, - newEnvName: envToName, - newEnvDescription: envToDescription, - existingEnvId: envToId, - conflictStrategy: conflictStrategy, - }); - } - }, [ - apiKey, - conflictStrategy, - copyEnvironment, - envFrom, - envToDescription, - envToId, - envToName, - projectFrom, - ]); - - useEffect(() => { - // Step 1, we use the API Key provided by the user & - // checks if the api_key scope >= project_level & - // sets the apiKey and sets the projectFrom - - (async () => { - const { valid, scope, error } = await validateApiKeyScope( - apiKey, - 'project', - ); - if (!valid || error) { - setError(error); - return; - } else if (scope && valid) { - setProjectFrom(scope.project_id); - setAuthToken(apiKey); - } - })(); - }, [apiKey, validateApiKeyScope]); - - const handleEnvFromSelection = useCallback( - ( - _organisation_id: ActiveState, - _project_id: ActiveState, - environment_id: ActiveState, - ) => { - setEnvFrom(environment_id.value); - }, - [], - ); - - useEffect(() => { - if (!envFrom) { - setState('selecting-env'); - } else if (!envToName && !envToId) { - setState('selecting-name'); - } else if (!envToDescription && !envToId) { - setState('selecting-description'); - } - }, [envFrom, envToDescription, envToId, envToName]); - return ( <> - {state === 'selecting-env' && authToken && ( - <> - Select an existing Environment to copy from. - - - )} - {authToken && state === 'selecting-name' && ( - <> - Input the new Environment name to copy to. - { - setEnvToName(name); - }} - placeholder={'Enter name here...'} - /> - - )} - {authToken && state === 'selecting-description' && ( - <> - Input the new Environment Description. - { - setEnvToDescription(description); - }} - placeholder={'Enter description here...'} - /> - - )} - - {state === 'done' && Environment copied successfully} - {error && {error}} + + + ); } diff --git a/source/commands/env/member.tsx b/source/commands/env/member.tsx index 0cfbd6e..5bcc849 100644 --- a/source/commands/env/member.tsx +++ b/source/commands/env/member.tsx @@ -1,34 +1,21 @@ -import React, { useCallback, useEffect, useState } from 'react'; -import { Text } from 'ink'; -import Spinner from 'ink-spinner'; +import React from 'react'; import { option } from 'pastel'; -import { TextInput } from '@inkjs/ui'; import zod from 'zod'; import { type infer as zInfer } from 'zod'; -import { ApiKeyScope, useApiKeyApi } from '../../hooks/useApiKeyApi.js'; -import SelectInput from 'ink-select-input'; -import { useMemberApi } from '../../hooks/useMemberApi.js'; -import EnvironmentSelection, { - ActiveState, -} from '../../components/EnvironmentSelection.js'; - -const rolesOptions = [ - { label: 'Owner', value: 'admin' }, - { label: 'Editor', value: 'write' }, - { - label: 'Viewer', - value: 'read', - }, -]; +import { AuthProvider } from '../../components/AuthProvider.js'; +import MemberComponent from '../../components/env/MemberComponent.js'; export const options = zod.object({ - key: zod.string().describe( - option({ - description: - 'An API key to perform the invite. A project or organization level API key is required to invite members to the account.', - }), - ), + key: zod + .string() + .optional() + .describe( + option({ + description: + 'Optional: An API key to perform the invite. A project or organization level API key is required to invite members to the account. In case not set, CLI lets you select one', + }), + ), environment: zod .string() .optional() @@ -56,11 +43,27 @@ export const options = zod.object({ }), ), role: zod - .enum(rolesOptions.map(role => role.value) as [string, ...string[]]) + .enum(['admin', 'write', 'read']) .optional() .describe( option({ - description: 'Optional: Role of the user', + description: 'Optional: Environment role for the user', + }), + ), + inviterEmail: zod + .string() + .optional() + .describe( + option({ + description: 'Optional: Inviter email address', + }), + ), + inviterName: zod + .string() + .optional() + .describe( + option({ + description: 'Optional: Inviter name', }), ), }); @@ -69,164 +72,29 @@ type Props = { readonly options: zInfer; }; -interface MemberInviteResult { - memberEmail: string; - memberRole: string; -} - export default function Member({ - options: { key, environment, project, email: emailP, role: roleP }, + options: { + key, + environment, + project, + email, + role, + inviterName, + inviterEmail, + }, }: Props) { - const [error, setError] = React.useState(null); - const [state, setState] = useState< - 'loading' | 'selecting' | 'input-email' | 'input-role' | 'inviting' | 'done' - >('loading'); - const [keyScope, setKeyScope] = useState({ - environment_id: environment ?? null, - organization_id: '', - project_id: project ?? null, - }); - const [email, setEmail] = useState(emailP); - const [role, setRole] = useState(roleP); - const [apiKey, setApiKey] = useState(null); - - const { validateApiKeyScope } = useApiKeyApi(); - const { inviteNewMember } = useMemberApi(); - - useEffect(() => { - // console.log(error, state); - if (error || state === 'done') { - process.exit(1); - } - }, [error, state]); - - useEffect(() => { - (async () => { - const { valid, scope, error } = await validateApiKeyScope(key, 'project'); - // console.log({ valid, scope, error }); - if (error || !valid) { - setError(error); - return; - } else if (valid && scope) { - setApiKey(key); - } - - if (valid && scope && environment) { - if (!scope.project_id && !project) { - setError( - 'Please pass the project key, or use a project level Api Key', - ); - } - setKeyScope(prev => ({ - ...prev, - organization_id: scope.organization_id, - project_id: scope.project_id ?? project ?? null, - })); - } - })(); - }, [environment, key, project, validateApiKeyScope]); - - const handleMemberInvite = useCallback( - async (memberInvite: MemberInviteResult) => { - const requestBody = { - email: memberInvite.memberEmail, - permissions: [ - { - ...keyScope, - object_type: 'env', - access_level: memberInvite.memberRole, - }, - ], - }; - - const { error } = await inviteNewMember(apiKey ?? '', requestBody); - if (error) { - setError(error); - return; - } - setState('done'); - }, - [apiKey, inviteNewMember, keyScope], - ); - - const onEnvironmentSelectSuccess = useCallback( - ( - organisation: ActiveState, - project: ActiveState, - environment: ActiveState, - ) => { - // console.log(environment); - if (keyScope && keyScope.environment_id !== environment.value) { - setKeyScope({ - organization_id: organisation.value, - project_id: project.value, - environment_id: environment.value, - }); - } - }, - [keyScope], - ); - - useEffect(() => { - // console.log({ email, environment, handleMemberInvite, keyScope, role }); - if (!apiKey) return; - if (!environment && !keyScope?.environment_id) { - setState('selecting'); - } else if (!email) { - setState('input-email'); - } else if (!role) { - setState('input-role'); - } else if (keyScope && keyScope.environment_id && email && role) { - setState('inviting'); - handleMemberInvite({ - memberEmail: email, - memberRole: role, - }); - } - }, [apiKey, email, environment, handleMemberInvite, keyScope, role]); - return ( <> - {state === 'loading' && ( - - - Loading your environment - - )} - {apiKey && state === 'selecting' && ( - <> - Select Environment to add member to - - - )} - {apiKey && state === 'input-email' && ( - <> - User email: - { - setEmail(email_input); - }} - /> - - )} - {apiKey && state === 'input-role' && ( - <> - Select a scope - { - setRole(role.value); - }} - /> - - )} - {state === 'done' && User Invited Successfully !} - {error && {error}} + + + ); } diff --git a/source/commands/env/select.tsx b/source/commands/env/select.tsx index e3685b4..7a29878 100644 --- a/source/commands/env/select.tsx +++ b/source/commands/env/select.tsx @@ -1,16 +1,11 @@ -import React, { useCallback, useEffect, useState } from 'react'; -import { Text } from 'ink'; -import Spinner from 'ink-spinner'; +import React from 'react'; + import { option } from 'pastel'; -import { saveAuthToken } from '../../lib/auth.js'; -import EnvironmentSelection, { - ActiveState, -} from '../../components/EnvironmentSelection.js'; import zod from 'zod'; import { type infer as zInfer } from 'zod'; -import Login from '../login.js'; -import { useApiKeyApi } from '../../hooks/useApiKeyApi.js'; +import { AuthProvider } from '../../components/AuthProvider.js'; +import SelectComponent from '../../components/env/SelectComponent.js'; export const options = zod.object({ key: zod @@ -24,89 +19,14 @@ export const options = zod.object({ ), }); -type Props = { +export type EnvSelectProps = { readonly options: zInfer; }; -export default function Select({ options: { key: authToken } }: Props) { - const [error, setError] = React.useState(null); - // const [authToken, setAuthToken] = React.useState(apiKey); - const [state, setState] = useState< - 'loading' | 'login' | 'selecting' | 'done' - >('loading'); - const [environment, setEnvironment] = useState(null); - - const { validateApiKey } = useApiKeyApi(); - - useEffect(() => { - if (error || (state === 'done' && environment)) { - process.exit(1); - } - }, [error, state, environment]); - - useEffect(() => { - if (!authToken) { - setState('login'); - } else if (!validateApiKey(authToken)) { - setError('Invalid API Key. Please provide a valid API Key.'); - return; - } else { - setState('selecting'); - } - }, [authToken, validateApiKey]); - - const onEnvironmentSelectSuccess = async ( - _organisation: ActiveState, - _project: ActiveState, - environment: ActiveState, - secret: string, - ) => { - try { - await saveAuthToken(secret); - } catch (error: unknown) { - setError(error as string); - } - setEnvironment(environment.label); - setState('done'); - }; - - const loginSuccess = useCallback( - ( - _organisation: ActiveState, - _project: ActiveState, - environment: ActiveState, - ) => { - setEnvironment(environment.label); - setState('done'); - }, - [], - ); - +export default function Select({ options }: EnvSelectProps) { return ( - <> - {state === 'loading' && ( - - - Loading your environment - - )} - {state === 'login' && ( - <> - No Key provided, Redirecting to Login... - - - )} - {authToken && state === 'selecting' && ( - - )} - {state === 'done' && environment && ( - Environment: {environment} selected successfully - )} - {error && {error}} - + + + ); } diff --git a/source/commands/gitops/create/github.tsx b/source/commands/gitops/create/github.tsx index 58e4098..f720cfc 100644 --- a/source/commands/gitops/create/github.tsx +++ b/source/commands/gitops/create/github.tsx @@ -35,7 +35,7 @@ type Props = { export default function GitHub({ options }: Props) { return ( - + ('login'); + const [state, setState] = useState<'login' | 'signup' | 'env' | 'done'>( + 'login', + ); const [accessToken, setAccessToken] = useState(''); const [cookie, setCookie] = useState(''); const [error, setError] = useState(null); @@ -68,9 +71,18 @@ export default function Login({ [loginSuccess], ); + const onSignupSuccess = useCallback(() => { + setState('env'); + }, []); + useEffect(() => { - if (error || state === 'done') { - process.exit(1); + if (error === 'NO_ORGANIZATIONS') { + setState('signup'); + setError(null); + } else if (error || state === 'done') { + setTimeout(() => { + process.exit(1); + }, 100); } }, [error, state]); @@ -94,12 +106,21 @@ export default function Login({ workspace={workspace} /> )} + {state === 'signup' && ( + <> + + + )} {state === 'done' && ( Logged in to {organization} with selected environment as {environment} )} - {error && {error}} + {error && state !== 'signup' && {error}} ); } diff --git a/source/commands/opa/policy.tsx b/source/commands/opa/policy.tsx index 61f710a..e53cb79 100644 --- a/source/commands/opa/policy.tsx +++ b/source/commands/opa/policy.tsx @@ -1,13 +1,8 @@ import React from 'react'; -import { Box, Newline, Text } from 'ink'; import zod from 'zod'; import { option } from 'pastel'; -import Spinner from 'ink-spinner'; -import { keyAccountOption } from '../../options/keychain.js'; -import { inspect } from 'util'; -import { loadAuthToken } from '../../lib/auth.js'; -import { TextInput, Select } from '@inkjs/ui'; -import Fuse from 'fuse.js'; +import { AuthProvider } from '../../components/AuthProvider.js'; +import OPAPolicyComponent from '../../components/opa/OPAPolicyComponent.js'; export const options = zod.object({ serverUrl: zod @@ -28,133 +23,18 @@ export const options = zod.object({ 'The API key for the OPA Server and Permit env, project or Workspace', }), ), - keyAccount: keyAccountOption, }); -type Props = { +export type OpaPolicyProps = { options: zod.infer; }; -interface PolicyItem { - id: string; - name?: string; -} - -interface Option { - label: string; - value: string; -} - -interface QueryResult { - result: { result: PolicyItem[] }; - status: number; -} - -export default function Policy({ options }: Props) { - const [error, setError] = React.useState(null); - const [res, setRes] = React.useState({ - result: { result: [] }, - status: 0, - }); - const [selection, setSelection] = React.useState( - undefined, - ); - const [selectionFilter, setSelectionFilter] = React.useState(''); - - const queryOPA = async (apiKey: string, path?: string) => { - const document = path ? `/${path}` : ''; - const response = await fetch( - `${options.serverUrl}/v1/policies${document}`, - { headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {} }, - ); - const data = await response.json(); - setRes({ result: data, status: response.status }); - }; - - React.useEffect(() => { - const performQuery = async () => { - const apiKey = options.apiKey || (await loadAuthToken()); - await queryOPA(apiKey); - }; - performQuery().catch(err => setError(err)); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [options.apiKey, options.serverUrl]); - - const policyItems: Option[] = res.result.result.map(i => ({ - label: i.id, - value: i.id, - })); - - const fuse = new Fuse(policyItems, { - keys: ['label', 'id'], - minMatchCharLength: 0, - }); - const filtered = fuse.search(selectionFilter).map(i => i.item); - const view = filtered.length === 0 ? policyItems : filtered; - - const handleSelection = (selectedValue: string) => { - const selectedPolicy = res.result.result.find(p => p.id === selectedValue); - setSelection(selectedPolicy); - }; - +export default function Policy({ options }: OpaPolicyProps) { return ( <> - - Listing Policies on Opa Server={options.serverUrl} - - {res.status === 0 && error === null && } - {res.status === 200 && ( - <> - {!selection && ( - <> - - Showing {view.length} of {policyItems.length} policies: - - - - { - const selectedPolicy = res.result.result.find( - p => p.id === value, - ); - setSelection(selectedPolicy); - }} - onChange={setSelectionFilter} - suggestions={policyItems.map(i => i.label)} - /> - - - + + + )} + {!!selection && ( + + + {inspect(selection, { + colors: true, + depth: null, + maxArrayLength: Infinity, + })} + + + )} + + )} + {error && ( + + Request failed: {JSON.stringify(error)} + + + {inspect(res, { + colors: true, + depth: null, + maxArrayLength: Infinity, + })} + + + )} + + ); +} diff --git a/source/components/pdp/PDPCheckComponent.tsx b/source/components/pdp/PDPCheckComponent.tsx new file mode 100644 index 0000000..06671be --- /dev/null +++ b/source/components/pdp/PDPCheckComponent.tsx @@ -0,0 +1,120 @@ +import React, { useEffect, useState } from 'react'; +import { Box, Newline, Text } from 'ink'; +import { CLOUD_PDP_URL } from '../../config.js'; +import Spinner from 'ink-spinner'; +import { inspect } from 'util'; +import { parseAttributes } from '../../utils/attributes.js'; +import { PDPCheckProps } from '../../commands/pdp/check.js'; +import { useAuth } from '../AuthProvider.js'; + +interface AllowedResult { + allow?: boolean; +} + +export default function PDPCheckComponent({ options }: PDPCheckProps) { + const [error, setError] = useState(''); + const [res, setRes] = useState({ allow: undefined }); + + const auth = useAuth(); + + useEffect(() => { + const queryPDP = async (apiKey: string) => { + try { + const userAttrs = options.userAttributes + ? parseAttributes(options.userAttributes) + : {}; + const resourceAttrs = options.resourceAttributes + ? parseAttributes(options.resourceAttributes) + : {}; + + const response = await fetch( + `${options.pdpurl || CLOUD_PDP_URL}/allowed`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), + }, + body: JSON.stringify({ + user: { + key: options.user, + ...userAttrs, + }, + resource: { + type: options.resource.includes(':') + ? options.resource.split(':')[0] + : options.resource, + key: options.resource.includes(':') + ? options.resource.split(':')[1] + : '', + tenant: options.tenant, + ...resourceAttrs, + }, + action: options.action, + }), + }, + ); + + if (!response.ok) { + const errorText = await response.text(); + setError(errorText); + return; + } + + setRes(await response.json()); + } catch (err) { + if (err instanceof Error) { + setError(err.message); + } else { + setError(String(err)); + } + } + }; + + if (auth.error) { + setError(auth.error); + } + if (!auth.loading) { + queryPDP(auth.authToken); + } + }, [auth, options]); + + return ( + <> + {/*The following text adheres to react/no-unescaped-entities rule */} + + Checking user="{options.user}"{' '} + {options.userAttributes && + ` with attributes=${options.userAttributes && ' '}`} + action="{options.action}" resource="{options.resource} + "{' '} + {options.resourceAttributes && + ` with attributes=${options.resourceAttributes} && ' '`} + at tenant="{options.tenant}" + + {res.allow === true && ( + <> + ALLOWED + + + {inspect(res, { + colors: true, + depth: null, + maxArrayLength: Infinity, + })} + + + + )} + {res.allow === false && DENIED} + {res.allow === undefined && error === '' && } + {error && ( + + Request failed: {error} + + {JSON.stringify(res)} + + )} + + ); +} diff --git a/source/components/PDPCommand.tsx b/source/components/pdp/PDPRunComponent.tsx similarity index 83% rename from source/components/PDPCommand.tsx rename to source/components/pdp/PDPRunComponent.tsx index 2604c80..7f91e1e 100644 --- a/source/components/PDPCommand.tsx +++ b/source/components/pdp/PDPRunComponent.tsx @@ -1,13 +1,13 @@ import React from 'react'; import { Text } from 'ink'; import Spinner from 'ink-spinner'; -import { useAuth } from './AuthProvider.js'; +import { useAuth } from '../AuthProvider.js'; type Props = { opa?: number; }; -export default function PDPCommand({ opa }: Props) { +export default function PDPRunComponent({ opa }: Props) { const { authToken } = useAuth(); return authToken ? ( <> diff --git a/source/components/signup/SignupComponent.tsx b/source/components/signup/SignupComponent.tsx new file mode 100644 index 0000000..92006a3 --- /dev/null +++ b/source/components/signup/SignupComponent.tsx @@ -0,0 +1,121 @@ +import React, { useState, FC, useEffect } from 'react'; +import { TextInput, ConfirmInput } from '@inkjs/ui'; +import { Text, Newline } from 'ink'; +import Spinner from 'ink-spinner'; +import randomName from '@scaleway/random-name'; +import { useOrganisationApi } from '../../hooks/useOrganisationApi.js'; +import { cleanKey } from '../../lib/env/copy/utils.js'; + +type SignupProp = { + accessToken: string; + cookie?: string | null; + onSuccess: () => void; +}; + +const SignupComponent: FC = ({ + accessToken, + cookie, + onSuccess, +}: SignupProp) => { + const [organizationName, setOrganizationName] = useState(randomName()); + const [error, setError] = useState(null); + const [state, setState] = useState< + 'confirming' | 'selecting' | 'creating' | 'done' + >('confirming'); + + useEffect(() => { + if (state === 'done') { + onSuccess(); + } + if (error) { + setTimeout(() => { + process.exit(1); + }, 1000); + } + }); + + const { createOrg } = useOrganisationApi(); + + const handleWorkspaceCreation = async (workspace: string) => { + const cleanOrgName = cleanKey(workspace); + setOrganizationName(cleanOrgName); + const { error } = await createOrg( + { + key: cleanOrgName, + name: cleanOrgName, + }, + accessToken, + cookie, + ); + if (error) { + setError(error); + return; + } + setState('done'); + }; + + const handleConfirm = async () => { + setState('creating'); + await handleWorkspaceCreation(organizationName); + }; + + const handleCancel = () => { + setState('selecting'); + }; + + return ( + <> + Welcome! Create your Workspace + {/**/} + {state === 'confirming' && ( + <> + + Use the default organization name:{' '} + {organizationName}?{' '} + {' '} + + + )} + {state === 'selecting' && ( + <> + + Enter your organization name (Default:{' '} + {organizationName}): + + { + setState('creating'); + handleWorkspaceCreation(input || organizationName); + }} + /> + + )} + + {state === 'creating' && ( + <> + + Creating your organization + + + )} + + {state === 'done' && ( + <> + + Organization: {organizationName} created successfully & Signup + successful + + + )} + {error && ( + <> + + {error} + + )} + + ); +}; + +export default SignupComponent; diff --git a/source/config.tsx b/source/config.tsx index e86f518..5e90bdb 100644 --- a/source/config.tsx +++ b/source/config.tsx @@ -5,3 +5,11 @@ export const CLOUD_PDP_URL = 'https://cloudpdp.api.permit.io'; export const PERMIT_API_URL = 'https://api.permit.io'; export const API_URL = 'https://api.permit.io/v2/'; export const API_PDPS_CONFIG_URL = `${API_URL}pdps/me/config`; +export const PERMIT_ORIGIN_URL = 'https://app.permit.io'; + +export const AUTH_REDIRECT_HOST = 'localhost'; +export const AUTH_REDIRECT_PORT = 62419; +export const AUTH_REDIRECT_URI = `http://${AUTH_REDIRECT_HOST}:${AUTH_REDIRECT_PORT}`; +export const AUTH_PERMIT_DOMAIN = 'app.permit.io'; +export const AUTH_API_URL = 'https://api.permit.io/v1/'; +export const AUTH_PERMIT_URL = 'https://auth.permit.io'; diff --git a/source/hooks/useApiKeyApi.ts b/source/hooks/useApiKeyApi.ts index 4a69dcb..5f4ed72 100644 --- a/source/hooks/useApiKeyApi.ts +++ b/source/hooks/useApiKeyApi.ts @@ -8,6 +8,30 @@ export interface ApiKeyScope { environment_id: string | null; } +type MemberAccessObj = 'org' | 'project' | 'env'; +type MemberAccessLevel = 'admin' | 'write' | 'read' | 'no_access'; +type APIKeyOwnerType = 'pdp_config' | 'member' | 'elements'; + +interface ApiKeyResponse { + organization_id: string; // UUID + project_id?: string; // UUID (optional) + environment_id?: string; // UUID (optional) + object_type?: MemberAccessObj; // Default: "env" + access_level?: MemberAccessLevel; // Default: "admin" + owner_type: APIKeyOwnerType; + name?: string; + id: string; // UUID + secret?: string; + created_at: string; + last_used_at?: string; // date-time +} + +interface PaginatedApiKeyResponse { + data: ApiKeyResponse[]; + total_count: number; // >= 0 + page_count?: number; // Default: 0 +} + export const useApiKeyApi = () => { const getProjectEnvironmentApiKey = async ( projectId: string, @@ -26,6 +50,31 @@ export const useApiKeyApi = () => { return await apiCall(`v2/api-key/scope`, accessToken); }; + const getApiKeyList = async ( + objectType: MemberAccessObj, + accessToken: string, + cookie?: string | null, + projectId?: string | null, + ) => { + return await apiCall( + `v2/api-key?object_type=${objectType}${projectId ? '&proj_id=' + projectId : ''}`, + accessToken, + cookie ?? '', + ); + }; + + const getApiKeyById = async ( + apiKeyId: string, + accessToken: string, + cookie?: string | null, + ) => { + return await apiCall( + `v2/api-key/${apiKeyId}`, + accessToken, + cookie ?? '', + ); + }; + const validateApiKey = useCallback((apiKey: string) => { return apiKey && tokenType(apiKey) === TokenType.APIToken; }, []); @@ -76,10 +125,27 @@ export const useApiKeyApi = () => { [validateApiKey], ); + const createApiKey = async ( + token: string, + body: string, + cookie?: string | null, + ) => { + return await apiCall( + 'v2/api-key', + token, + cookie, + 'POST', + body, + ); + }; + return useMemo( () => ({ getProjectEnvironmentApiKey, getApiKeyScope, + getApiKeyList, + getApiKeyById, + createApiKey, validateApiKeyScope, validateApiKey, }), diff --git a/source/hooks/useMemberApi.ts b/source/hooks/useMemberApi.ts index 2a1f5a9..1358b45 100644 --- a/source/hooks/useMemberApi.ts +++ b/source/hooks/useMemberApi.ts @@ -2,9 +2,14 @@ import { apiCall } from '../lib/api.js'; import { useMemo } from 'react'; export const useMemberApi = () => { - const inviteNewMember = async (authToken: string, body: object) => { + const inviteNewMember = async ( + authToken: string, + body: object, + inviter_name: string, + inviter_email: string, + ) => { return await apiCall( - `v2/members`, + `v2/members?inviter_name=${inviter_name}&inviter_email=${inviter_email}`, authToken, null, 'POST', diff --git a/source/hooks/useOrganisationApi.ts b/source/hooks/useOrganisationApi.ts index 45f54d9..cd63022 100644 --- a/source/hooks/useOrganisationApi.ts +++ b/source/hooks/useOrganisationApi.ts @@ -37,10 +37,25 @@ export const useOrganisationApi = () => { ); }; + const createOrg = async ( + body: object, + accessToken: string, + cookie?: string | null, + ) => { + return await apiCall( + `v2/orgs`, + accessToken, + cookie ?? '', + 'POST', + JSON.stringify(body), + ); + }; + return useMemo( () => ({ getOrgs, getOrg, + createOrg, }), [], ); diff --git a/source/lib/api.ts b/source/lib/api.ts index 5e105fd..9f4759f 100644 --- a/source/lib/api.ts +++ b/source/lib/api.ts @@ -1,4 +1,4 @@ -import { PERMIT_API_URL } from '../config.js'; +import { PERMIT_API_URL, PERMIT_ORIGIN_URL } from '../config.js'; type ApiResponse = { headers: Headers; @@ -26,7 +26,7 @@ export const apiCall = async ( method, headers: { Accept: '*/*', - Origin: 'https://app.permit.io', + Origin: PERMIT_ORIGIN_URL, Authorization: `Bearer ${token}`, Cookie: cookie ?? '', 'Content-Type': 'application/json', diff --git a/source/lib/auth.ts b/source/lib/auth.ts index 6540c7b..f1d352e 100644 --- a/source/lib/auth.ts +++ b/source/lib/auth.ts @@ -3,8 +3,14 @@ import * as http from 'node:http'; import open from 'open'; import * as pkg from 'keytar'; import { + AUTH_API_URL, + AUTH_PERMIT_DOMAIN, + AUTH_REDIRECT_HOST, + AUTH_REDIRECT_PORT, + AUTH_REDIRECT_URI, DEFAULT_PERMIT_KEYSTORE_ACCOUNT, KEYSTORE_PERMIT_SERVICE_NAME, + AUTH_PERMIT_URL, } from '../config.js'; import { URL, URLSearchParams } from 'url'; import { setTimeout } from 'timers'; @@ -86,7 +92,7 @@ export const authCallbackServer = async (verifier: string): Promise => { const code = url.searchParams.get('code'); // Send the response - const data = await fetch('https://auth.permit.io/oauth/token', { + const data = await fetch(`${AUTH_PERMIT_URL}/oauth/token`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -96,7 +102,7 @@ export const authCallbackServer = async (verifier: string): Promise => { client_id: 'Pt7rWJ4BYlpELNIdLg6Ciz7KQ2C068C1', code_verifier: verifier, code, - redirect_uri: 'http://localhost:62419', + redirect_uri: AUTH_REDIRECT_URI, }), }).then(async response => response.json()); res.statusCode = 200; // Set the response status code @@ -107,11 +113,8 @@ export const authCallbackServer = async (verifier: string): Promise => { }); // Specify the port and host - const port = 62_419; - const host = 'localhost'; - // Start the server and listen on the specified port - server.listen(port, host); + server.listen(AUTH_REDIRECT_PORT, AUTH_REDIRECT_HOST); setTimeout(() => { server.close(); @@ -137,20 +140,20 @@ export const browserAuth = async (): Promise => { const challenge = base64UrlEncode(sha256(verifier)); const parameters = new URLSearchParams({ - audience: 'https://api.permit.io/v1/', - screen_hint: 'app.permit.io', - domain: 'app.permit.io', + audience: AUTH_API_URL, + screen_hint: AUTH_PERMIT_DOMAIN, + domain: AUTH_PERMIT_DOMAIN, auth0Client: 'eyJuYW1lIjoiYXV0aDAtcmVhY3QiLCJ2ZXJzaW9uIjoiMS4xMC4yIn0=', isEAP: 'false', response_type: 'code', - fragment: 'domain=app.permit.io', + fragment: `domain=${AUTH_PERMIT_DOMAIN}`, code_challenge: challenge, code_challenge_method: 'S256', client_id: 'Pt7rWJ4BYlpELNIdLg6Ciz7KQ2C068C1', - redirect_uri: 'http://localhost:62419', + redirect_uri: AUTH_REDIRECT_URI, scope: 'openid profile email', state: 'bFR2dn5idUhBVDNZYlhlSEFHZnJaSjRFdUhuczdaSlhCSHFDSGtlYXpqbQ==', }); - await open(`https://auth.permit.io/authorize?${parameters.toString()}`); + await open(`${AUTH_PERMIT_URL}/authorize?${parameters.toString()}`); return verifier; }; diff --git a/tests/EnvCopy.test.tsx b/tests/EnvCopy.test.tsx index b392952..4e28594 100644 --- a/tests/EnvCopy.test.tsx +++ b/tests/EnvCopy.test.tsx @@ -6,6 +6,7 @@ import { useApiKeyApi } from '../source/hooks/useApiKeyApi.js'; import { useEnvironmentApi } from '../source/hooks/useEnvironmentApi.js'; import EnvironmentSelection from '../source/components/EnvironmentSelection.js'; import delay from 'delay'; +import * as keytar from 'keytar'; vi.mock('../source/hooks/useApiKeyApi.js', () => ({ useApiKeyApi: vi.fn(() => ({ @@ -31,6 +32,19 @@ beforeEach(() => { }); }); + +vi.mock('keytar', () => { + const demoPermitKey = 'permit_key_'.concat('a'.repeat(97)); + const keytar = { + setPassword: vi.fn().mockResolvedValue(demoPermitKey), + getPassword: vi.fn().mockResolvedValue(demoPermitKey), + deletePassword: vi.fn().mockResolvedValue(demoPermitKey), + + }; + return { ...keytar, default: keytar }; +}); + + afterEach(() => { vi.restoreAllMocks(); }); diff --git a/tests/EnvMember.test.tsx b/tests/EnvMember.test.tsx index 742deb8..d505007 100644 --- a/tests/EnvMember.test.tsx +++ b/tests/EnvMember.test.tsx @@ -6,6 +6,7 @@ import { useApiKeyApi } from '../source/hooks/useApiKeyApi.js'; import { useMemberApi } from '../source/hooks/useMemberApi.js'; import EnvironmentSelection from '../source/components/EnvironmentSelection.js'; import delay from 'delay'; +import * as keytar from "keytar" vi.mock('../source/hooks/useApiKeyApi.js', () => ({ useApiKeyApi: vi.fn(() => ({ @@ -24,6 +25,18 @@ vi.mock('../source/components/EnvironmentSelection.js', () => ({ default: vi.fn(), })); +vi.mock('keytar', () => { + const demoPermitKey = 'permit_key_'.concat('a'.repeat(97)); + const keytar = { + setPassword: vi.fn().mockResolvedValue(demoPermitKey), + getPassword: vi.fn().mockResolvedValue(demoPermitKey), + deletePassword: vi.fn().mockResolvedValue(demoPermitKey), + + }; + return { ...keytar, default: keytar }; +}); + + beforeEach(() => { vi.restoreAllMocks(); vi.spyOn(process, 'exit').mockImplementation(code => { @@ -73,14 +86,61 @@ describe('Member Component', () => { , ); - await delay(50); // Allow environment selection + await delay(100); // Allow environment selection stdin.write('user@example.com\n'); await delay(50); stdin.write(enter); await delay(50); stdin.write(enter); - await delay(100); // Allow role selection + await delay(50) + stdin.write("dummy_name") + await delay(50); + stdin.write(enter); + await delay(50) + stdin.write("dummy_email") + await delay(50); + stdin.write(enter); + await delay(100); + + expect(lastFrame()).toMatch(/User Invited Successfully/); + }); + it('should handle successful member invite flow with all flags passed', async () => { + vi.mocked(useApiKeyApi).mockReturnValue({ + validateApiKeyScope: vi.fn(() => + Promise.resolve({ + valid: true, + scope: { + organization_id: 'org1', + project_id: 'proj1', + }, + error: null, + }), + ), + }); + + vi.mocked(useMemberApi).mockReturnValue({ + inviteNewMember: vi.fn(() => + Promise.resolve({ + error: null, + }), + ), + }); + + vi.mocked(EnvironmentSelection).mockImplementation(({ onComplete }) => { + onComplete( + { label: 'Org1', value: 'org1' }, + { label: 'Proj1', value: 'proj1' }, + { label: 'Env1', value: 'env1' }, + ); + return null; + }); + + const { lastFrame, stdin } = render( + , + ); + + await delay(100); expect(lastFrame()).toMatch(/User Invited Successfully/); }); diff --git a/tests/EnvSelect.test.tsx b/tests/EnvSelect.test.tsx index 51c0704..5fc9ee6 100644 --- a/tests/EnvSelect.test.tsx +++ b/tests/EnvSelect.test.tsx @@ -8,12 +8,60 @@ import Login from '../source/commands/login.js'; import EnvironmentSelection from '../source/components/EnvironmentSelection.js'; import { saveAuthToken } from '../source/lib/auth.js'; import delay from 'delay'; +import SelectComponent from '../source/components/env/SelectComponent'; + + +const demoPermitKey = 'permit_key_'.concat('a'.repeat(97)); + + +// vi.mock('../source/hooks/useApiKeyApi.js', () => ({ +// useApiKeyApi: vi.fn(() => ({ +// validateApiKey: vi.fn(), +// validateApiKeyScope: vi.fn(), +// })), +// })); + +vi.mock('../source/components/AuthProvider.tsx', async() => { + const original = await vi.importActual('../source/components/AuthProvider.tsx'); + return { + ...original, + useAuth: () => ({ + authToken: demoPermitKey, + }) + } +}) + +vi.mock('../source/hooks/useApiKeyApi', async() => { + const original = await vi.importActual('../source/hooks/useApiKeyApi'); + return { + ...original, + useApiKeyApi: () => ({ + getApiKeyScope: vi.fn().mockResolvedValue({ + response: { + environment_id: 'env1', + project_id: 'proj1', + organization_id: 'org1', + }, + error: null, + status: 200, + }), + getProjectEnvironmentApiKey: vi.fn().mockResolvedValue({ + response: { secret: 'test-secret' }, + error: null, + }), + validateApiKeyScope: vi.fn().mockResolvedValue({ + valid: true, + scope: { + environment_id: 'env1', + project_id: 'proj1', + organization_id: 'org1', + }, + error: null + }) + }), + } +}); -vi.mock('../source/hooks/useApiKeyApi.js', () => ({ - useApiKeyApi: vi.fn(() => ({ - validateApiKey: vi.fn(), - })), -})); vi.mock('../source/commands/login.js', () => ({ __esModule: true, @@ -42,48 +90,12 @@ afterEach(() => { describe('Select Component', () => { it('should display loading state initially', async () => { - const { lastFrame } = render(); - - await delay(50); // Allow state transitions to occur - - expect(lastFrame()).toMatch(/No Key provided, Redirecting to Login/); - expect(lastFrame()).toMatch(/Mocked Login Component/); - }); - - it('should display error for invalid API key', async () => { - // Mock validateApiKey to return false - vi.mocked(useApiKeyApi).mockReturnValue({ - validateApiKey: vi.fn(() => false), - }); - - const { lastFrame } = render( - ); + const { lastFrame } = render(); await delay(100); // Allow async operations to complete expect(lastFrame()).toMatch(/Failed to save token/); expect(process.exit).toHaveBeenCalledWith(1); }); - it('handle login successs', async () => { - vi.mocked(useApiKeyApi).mockReturnValue({ - validateApiKey: vi.fn(() => true), - }); - vi.mocked(EnvironmentSelection).mockImplementation(({ onComplete }) => { - onComplete( - { label: 'Org1', value: 'org1' }, - { label: 'Proj1', value: 'proj1' }, - { label: 'Env1', value: 'env1' }, - 'secret_token', - ); - return null; - }); - const { lastFrame } = render(); + const { lastFrame } = render(); await delay(100); // Allow async operations to complete expect(lastFrame()).toMatch(/Environment: Env1 selected successfully/); }); diff --git a/tests/OPAPolicy.test.tsx b/tests/OPAPolicy.test.tsx index 20ac38f..c92f2ce 100644 --- a/tests/OPAPolicy.test.tsx +++ b/tests/OPAPolicy.test.tsx @@ -3,18 +3,59 @@ import { describe, it, expect, vi } from 'vitest'; import { render } from 'ink-testing-library'; import Policy from '../source/commands/opa/policy'; import delay from 'delay'; -import * as keytar from 'keytar'; +import * as keytar from "keytar" global.fetch = vi.fn(); const enter = '\r'; - vi.mock('keytar', () => { + const demoPermitKey = 'permit_key_'.concat('a'.repeat(97)); const keytar = { - setPassword: vi.fn(), - getPassword: vi.fn(), // Mocked return value - deletePassword: vi.fn(), + setPassword: vi.fn().mockResolvedValue(demoPermitKey), + getPassword: vi.fn().mockResolvedValue(demoPermitKey), + deletePassword: vi.fn().mockResolvedValue(demoPermitKey), + }; return { ...keytar, default: keytar }; }); +const demoPermitKey = 'permit_key_'.concat('a'.repeat(97)); + +vi.mock('../source/lib/auth.js', async () => { + const original = await vi.importActual('../source/lib/auth.js'); + return { + ...original, + loadAuthToken: vi.fn(() => demoPermitKey), + }; +}); +vi.mock('../source/hooks/useApiKeyApi', async() => { + const original = await vi.importActual('../source/hooks/useApiKeyApi'); + return { + ...original, + useApiKeyApi: () => ({ + getApiKeyScope: vi.fn().mockResolvedValue({ + response: { + environment_id: 'env1', + project_id: 'proj1', + organization_id: 'org1', + }, + error: null, + status: 200, + }), + getProjectEnvironmentApiKey: vi.fn().mockResolvedValue({ + response: { secret: 'test-secret' }, + error: null, + }), + validateApiKeyScope: vi.fn().mockResolvedValue({ + valid: true, + scope: { + environment_id: 'env1', + project_id: 'proj1', + organization_id: 'org1', + }, + error: null + }) + }), + } +}); + describe('OPA Policy Command', () => { it('should render the policy command', async () => { @@ -34,6 +75,9 @@ describe('OPA Policy Command', () => { status: 200, }); const { stdin, lastFrame } = render(); + const frameString = lastFrame()?.toString() ?? ''; + expect(frameString).toMatch(/Loading Token/); + await delay(100); expect(lastFrame()?.toString()).toMatch( 'Listing Policies on Opa Server=http://localhost:8181', ); @@ -50,6 +94,9 @@ describe('OPA Policy Command', () => { }; (fetch as any).mockRejectedValueOnce(new Error('Error')); const { lastFrame } = render(); + const frameString = lastFrame()?.toString() ?? ''; + expect(frameString).toMatch(/Loading Token/); + await delay(100); expect(lastFrame()?.toString()).toMatch( 'Listing Policies on Opa Server=http://localhost:8181', ); diff --git a/tests/PDPCheck.test.tsx b/tests/PDPCheck.test.tsx index 05b6ab1..5acda0a 100644 --- a/tests/PDPCheck.test.tsx +++ b/tests/PDPCheck.test.tsx @@ -1,101 +1,211 @@ import React from 'react'; import { render } from 'ink-testing-library'; -import { describe, vi, it, expect, afterEach } from 'vitest'; +import { describe, vi, it, expect, afterEach, beforeEach } from 'vitest'; import delay from 'delay'; import Check from '../source/commands/pdp/check'; import * as keytar from 'keytar'; +import { useApiKeyApi } from '../source/hooks/useApiKeyApi'; + +const demoPermitKey = 'permit_key_'.concat('a'.repeat(97)); global.fetch = vi.fn(); vi.mock('keytar', () => { + const demoPermitKey = 'permit_key_'.concat('a'.repeat(97)); const keytar = { - getPassword: vi.fn().mockResolvedValue('permit_key_a'.concat('a').repeat(97)), + setPassword: vi.fn(), + getPassword: vi.fn(), + deletePassword: vi.fn(), + }; return { ...keytar, default: keytar }; }); + +vi.mock('../source/hooks/useApiKeyApi.js', () => ({ + useApiKeyApi: vi.fn(() => ({ + validateApiKeyScope: vi.fn(), + })), +})); + + +vi.mock('../source/lib/auth.js', async () => { + const original = await vi.importActual('../source/lib/auth.js'); + return { + ...original, + loadAuthToken: vi.fn(() => demoPermitKey), + }; +}); + describe('PDP Check Component', () => { + beforeEach(() => { + vi.clearAllMocks(); // Ensure all mocks are cleared before each test + }); + afterEach(() => { - // Clear mock calls after each test - vi.clearAllMocks(); + vi.restoreAllMocks(); // Restore all mocked modules after each test }); - it('should render with the given options', async () => { - (fetch as any).mockResolvedValueOnce({ + + it('should render with the given options and allow access', async () => { + (fetch as any).mockResolvedValue({ ok: true, json: async () => ({ allow: true }), }); + + vi.mocked(useApiKeyApi).mockReturnValue({ + validateApiKeyScope: vi.fn(() => + Promise.resolve({ + valid: true, + scope: { + project_id: 'proj1', + environment_id: 'proj1', + }, + error: null, + }), + ), + }); + const options = { user: 'testUser', resource: 'testResource', action: 'testAction', tenant: 'testTenant', keyAccount: 'testKeyAccount', + demoPermitKey: demoPermitKey }; const { lastFrame } = render(); + const frameString = lastFrame()?.toString() ?? ''; + expect(frameString).toMatch(/Loading Token/); + await delay(100); expect(lastFrame()).toContain( - `Checking user="testUser"action=testAction resource=testResourceat tenant=testTenant`, + `Checking user="testUser" action="testAction" resource="testResource" at tenant="testTenant"`, ); + await delay(50); - console.log(lastFrame()); + expect(lastFrame()?.toString()).toContain('ALLOWED'); }); - it('should render with the given options', async () => { - (fetch as any).mockResolvedValueOnce({ + + it('should render with the given options and deny access', async () => { + (fetch as any).mockResolvedValue({ ok: true, json: async () => ({ allow: false }), }); + + vi.mocked(useApiKeyApi).mockReturnValue({ + validateApiKeyScope: vi.fn(() => + Promise.resolve({ + valid: true, + scope: { + project_id: 'proj1', + environment_id: 'proj1', + }, + error: null, + }), + ), + }); + const options = { user: 'testUser', resource: 'testResource', action: 'testAction', tenant: 'testTenant', + demoPermitKey: demoPermitKey, keyAccount: 'testKeyAccount', + }; const { lastFrame } = render(); + const frameString = lastFrame()?.toString() ?? ''; + expect(frameString).toMatch(/Loading Token/); + await delay(100); expect(lastFrame()).toContain( - `Checking user="testUser"action=testAction resource=testResourceat tenant=testTenant`, + `Checking user="testUser" action="testAction" resource="testResource" at tenant="testTenant"`, ); + await delay(50); + expect(lastFrame()?.toString()).toContain('DENIED'); }); - it('should render with the given options', async () => { + + it('should render an error when fetch fails', async () => { (fetch as any).mockResolvedValueOnce({ ok: false, text: async () => 'Error', }); + + vi.mocked(useApiKeyApi).mockReturnValue({ + validateApiKeyScope: vi.fn(() => + Promise.resolve({ + valid: true, + scope: { + project_id: 'proj1', + environment_id: 'proj1', + }, + error: null, + }), + ), + }); + const options = { user: 'testUser', resource: 'testResource', action: 'testAction', tenant: 'testTenant', keyAccount: 'testKeyAccount', + demoPermitKey: demoPermitKey }; const { lastFrame } = render(); + const frameString = lastFrame()?.toString() ?? ''; + expect(frameString).toMatch(/Loading Token/); + await delay(100); expect(lastFrame()).toContain( - `Checking user="testUser"action=testAction resource=testResourceat tenant=testTenant`, + `Checking user="testUser" action="testAction" resource="testResource" at tenant="testTenant"`, ); - await delay(50); + + await delay(100); + expect(lastFrame()?.toString()).toContain('Error'); }); - it('should render with the given options with multiple resource', async () => { - (fetch as any).mockResolvedValueOnce({ + + it('should render with multiple resources and allow access', async () => { + (fetch as any).mockResolvedValue({ ok: true, json: async () => ({ allow: true }), }); + + vi.mocked(useApiKeyApi).mockReturnValue({ + validateApiKeyScope: vi.fn(() => + Promise.resolve({ + valid: true, + scope: { + project_id: 'proj1', + environment_id: 'proj1', + }, + error: null, + }), + ), + }); + const options = { user: 'testUser', - resource: 'testResourceType: testRecsourceKey', + resource: 'testResourceType: testResourceKey', action: 'testAction', tenant: 'testTenant', keyAccount: 'testKeyAccount', + demoPermitKey: demoPermitKey }; const { lastFrame } = render(); - expect(lastFrame()) - .toContain(`Checking user="testUser"action=testAction resource=testResourceType: testRecsourceKeyat -tenant=testTenant`); + const frameString = lastFrame()?.toString() ?? ''; + expect(frameString).toMatch(/Loading Token/); + await delay(100); + expect(lastFrame()).toContain( + `Checking user="testUser" action="testAction" resource="testResourceType: testResourceKey" at\ntenant="testTenant"`, + ); + await delay(50); + expect(lastFrame()?.toString()).toContain('ALLOWED'); }); }); diff --git a/tests/components/AuthProvider.test.tsx b/tests/components/AuthProvider.test.tsx index bd6e285..08fe63d 100644 --- a/tests/components/AuthProvider.test.tsx +++ b/tests/components/AuthProvider.test.tsx @@ -1,14 +1,39 @@ import React from 'react'; import { render } from 'ink-testing-library'; import { AuthProvider, useAuth } from '../../source/components/AuthProvider.js'; -import { loadAuthToken } from '../../source/lib/auth.js'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { authCallbackServer, browserAuth, loadAuthToken } from '../../source/lib/auth.js'; +import { describe, it, expect, vi } from 'vitest'; import { Text } from 'ink'; import delay from 'delay'; +import * as keytar from "keytar" -vi.mock('../../source/lib/auth.js', () => ({ - loadAuthToken: vi.fn(), -})); + +const demoPermitKey = 'permit_key_'.concat('a'.repeat(97)); + +global.fetch = vi.fn(); + +vi.mock('../../source/lib/auth.js', async () => { + const original = await vi.importActual('../../source/lib/auth.js'); + return { + ...original, + loadAuthToken: vi.fn(), + browserAuth: vi.fn(), + authCallbackServer: vi.fn(), + }; +}); + +vi.mock('keytar', () => { + const demoPermitKey = 'permit_key_'.concat('a'.repeat(97)); + + const keytar = { + setPassword: vi.fn().mockResolvedValue(demoPermitKey), + getPassword: vi.fn().mockResolvedValue(demoPermitKey), + deletePassword: vi.fn().mockResolvedValue(demoPermitKey), + + }; + return { ...keytar, default: keytar }; +}); +const enter = '\r'; describe('AuthProvider', () => { it('should display loading text while loading token', async () => { @@ -22,23 +47,18 @@ describe('AuthProvider', () => { expect(lastFrame()).toContain('Loading Token'); }); - it('should display error message if loading token fails', async () => { - (loadAuthToken as any).mockRejectedValueOnce( - new Error('Failed to load token'), - ); - - const { lastFrame } = render( - - Child Component - , - ); - - await delay(50); - expect(lastFrame()).toContain('Failed to load token'); - }); it('should display children when token is loaded successfully', async () => { - (loadAuthToken as any).mockResolvedValueOnce('mocked-token'); + (loadAuthToken as any).mockResolvedValue(demoPermitKey); + (fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + environment_id: 'env1', + project_id: 'proj1', + organization_id: 'org1', + }), + status: 200 + }); const { lastFrame } = render( @@ -46,26 +66,9 @@ describe('AuthProvider', () => { , ); - await delay(50); + await delay(500); expect(lastFrame()).toContain('Child Component'); }); - it('should use the auth context successfully', async () => { - const ChildComponent = () => { - const { authToken } = useAuth(); - return {authToken || 'No token'}; - }; - - (loadAuthToken as any).mockResolvedValueOnce('mocked-token'); - - const { lastFrame } = render( - - - , - ); - - await delay(100); - expect(lastFrame()).toContain('mocked-token'); - }); it('should throw an error when useAuth is called outside of AuthProvider', () => { const ChildComponent = () => { @@ -83,4 +86,196 @@ describe('AuthProvider', () => { 'useAuth must be used within an AuthProvider', ); }); + + it('should display project if scope is project or greater', async () => { + (loadAuthToken as any).mockRejectedValue( + new Error('Failed to load token'), + ); + vi.mocked(browserAuth).mockResolvedValue('verifier'); + vi.mocked(authCallbackServer).mockResolvedValue('browser_token'); + (fetch as any).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: { + getSetCookie: () => ['cookie_value'], + }, + json: async () => ({}), + error: null, + }).mockResolvedValueOnce({ + ok: true, + status: 200, + error: null, + json: async () => ([ + { id: 'org1', name: 'Organization 1' }, + { id: 'org2', name: 'Organization 2' }, + ] + ) + }).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: { + getSetCookie: () => ['cookie_value'], + }, + json: async () => ({}), + error: null, + }).mockResolvedValueOnce({ + ok: true, + status: 200, + error: null, + json: async () => ([ + { id: 'proj1', name: 'Project 1' }, + { id: 'proj2', name: 'Project 2' }, + ] + ) + }).mockResolvedValueOnce({ + ok: true, + status: 200, + error: null, + json: async () => ({ + data: [ + {id: 'mock-id'} + ] + } + ) + }).mockResolvedValueOnce({ + ok: true, + status: 200, + error: null, + json: async () => ( + {id: 'mock-id'} + + ) + }).mockResolvedValueOnce({ + ok: true, + status: 200, + error: null, + json: async () => ({ + secret: "secret", + project_id: "proj_id", + organization_id: "organization_id", + } + ) + }) + const {stdin, lastFrame } = render( + + Child Component + , + ); + await delay(500); + expect(lastFrame()).toContain('Select an organization'); + await delay(50); + stdin.write(enter); + await delay(50); + expect(lastFrame()).toContain('Select a project'); + await delay(50); + stdin.write(enter); + await delay(50); + expect(lastFrame()).toContain('Child Component'); + }); + + it('should display project if scope is environment', async () => { + (loadAuthToken as any).mockRejectedValue( + new Error('Failed to load token'), + ); + vi.mocked(browserAuth).mockResolvedValue('verifier'); + vi.mocked(authCallbackServer).mockResolvedValue('browser_token'); + (fetch as any).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: { + getSetCookie: () => ['cookie_value'], + }, + json: async () => ({}), + error: null, + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + error: null, + json: async () => ([ + { id: 'org1', name: 'Organization 1' }, + { id: 'org2', name: 'Organization 2' }, + ] + ) + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + headers: { + getSetCookie: () => ['cookie_value'], + }, + json: async () => ({}), + error: null, + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + error: null, + json: async () => ([ + { id: 'proj1', name: 'Project 1' }, + { id: 'proj2', name: 'Project 2' }, + ] + ) + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + error: null, + json: async () => ([ + { id: 'env1', name: 'Env 1' }, + { id: 'env2', name: 'Env 2' }, + ] + ) + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + error: null, + json: async () => ({ + secret: 'super-secret' + } + ) + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + error: null, + json: async () => ({ + data: [ + {id: 'mock-id'} + ] + } + ) + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + error: null, + json: async () => ({ + secret: "secret", + project_id: "proj_id", + organization_id: "organization_id_", + } + ) + }) + const {stdin, lastFrame } = render( + + Child Component + , + ); + await delay(500); + expect(lastFrame()).toContain('Select an organization'); + await delay(50); + stdin.write(enter); + await delay(50); + expect(lastFrame()).toContain('Select a project'); + await delay(50); + stdin.write(enter); + await delay(50); + expect(lastFrame()).toContain('Select an environment'); + await delay(50); + stdin.write(enter); + await delay(50); + expect(lastFrame()).toContain('Child Component'); + }); }); diff --git a/tests/components/PDPCommand.test.tsx b/tests/components/PDPCommand.test.tsx index 81deba9..7b292e6 100644 --- a/tests/components/PDPCommand.test.tsx +++ b/tests/components/PDPCommand.test.tsx @@ -1,22 +1,53 @@ import React from 'react'; import { describe, it, expect, vi } from 'vitest'; import { render } from 'ink-testing-library'; -import PDPCommand from '../../source/components/PDPCommand'; +import PDPRunComponent from '../../source/components/pdp/PDPRunComponent'; import { AuthProvider } from '../../source/components/AuthProvider'; import delay from 'delay'; import { loadAuthToken } from '../../source/lib/auth'; +import Run from '../../source/commands/pdp/run'; vi.mock('../../source/lib/auth', () => ({ loadAuthToken: vi.fn(), })); + +vi.mock('../../source/hooks/useApiKeyApi', async() => { + const original = await vi.importActual('../../source/hooks/useApiKeyApi'); + return { + ...original, + useApiKeyApi: () => ({ + getApiKeyScope: vi.fn().mockResolvedValue({ + response: { + environment_id: 'env1', + project_id: 'proj1', + organization_id: 'org1', + }, + error: null, + status: 200, + }), + getProjectEnvironmentApiKey: vi.fn().mockResolvedValue({ + response: { secret: 'test-secret' }, + error: null, + }), + validateApiKeyScope: vi.fn().mockResolvedValue({ + valid: true, + scope: { + environment_id: 'env1', + project_id: 'proj1', + organization_id: 'org1', + }, + error: null + }) + }), + } +}); + describe('PDP Component', () => { it('should render the PDP component with auth token', async () => { (loadAuthToken as any).mockResolvedValueOnce( 'permit_key_'.concat('a'.repeat(97)), ); const { lastFrame } = render( - - - , + ); expect(lastFrame()?.toString()).toMatch('Loading Token'); await delay(50); @@ -24,14 +55,4 @@ describe('PDP Component', () => { 'Run the following command from your terminal:', ); }); - it('should render the Spinner', async () => { - const { lastFrame } = render( - - - , - ); - expect(lastFrame()?.toString()).toMatch('Loading Token'); - await delay(50); - expect(lastFrame()?.toString()).toMatch('Loading command'); - }); }); diff --git a/tests/components/SignupComponent.test.tsx b/tests/components/SignupComponent.test.tsx new file mode 100644 index 0000000..8e69f7b --- /dev/null +++ b/tests/components/SignupComponent.test.tsx @@ -0,0 +1,126 @@ +import React from 'react'; +import { render } from 'ink-testing-library'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import SignupComponent from '../../source/components/signup/SignupComponent.js'; +import { useOrganisationApi } from '../../source/hooks/useOrganisationApi.js'; +import delay from 'delay'; + +vi.mock('../../source/hooks/useOrganisationApi.js', () => ({ + useOrganisationApi: vi.fn(() => ({ + createOrg: vi.fn(), + })), +})); + +const enter = '\r'; + +describe('SignupComponent', () => { + let mockOnSuccess: () => void; + let mockCreateOrg: ReturnType; + + beforeEach(() => { + mockOnSuccess = vi.fn(); + mockCreateOrg = vi.fn(); + (useOrganisationApi as jest.Mock).mockReturnValue({ createOrg: mockCreateOrg }); + }); + + it('should display the default organization name and allow confirmation', async () => { + const { lastFrame, stdin } = render( + + ); + + expect(lastFrame()).toMatch(/Welcome! Create your Workspace/); + expect(lastFrame()).toMatch(/Use the default organization name:/); + + // Confirm the default organization name + stdin.write('y'); + stdin.write(enter); + await delay(50); + + }); + + it('should allow the user to enter a custom organization name', async () => { + + mockCreateOrg.mockResolvedValue({ error: null }); + + const { lastFrame, stdin } = render( + + ); + + expect(lastFrame()).toMatch(/Welcome! Create your Workspace/); + await delay(50); + // Cancel the default name confirmation + stdin.write('n'); + // stdin.write(enter); + await delay(50); + + expect(lastFrame()).toMatch(/Enter your organization name/); + + // Submit a custom name + const customName = 'custom-org'; + stdin.write(customName); + stdin.write(enter); + await delay(50); + + }); + + it('should display a spinner while creating an organization', async () => { + mockCreateOrg.mockImplementation(() => new Promise(() => {})); // Never resolve + const { lastFrame, stdin } = render( + + ); + + // Confirm the default organization name + await delay(50); + stdin.write('y'); + // stdin.write(enter); + await delay(50); + + expect(lastFrame()).toMatch(/Creating your organization/); + }); + + it('should handle errors and display an error message', async () => { + mockCreateOrg.mockResolvedValue({ error: 'Mock error' }); + + const { lastFrame, stdin } = render( + + ); + + // Confirm the default organization name + await delay(50); + stdin.write('y'); + // stdin.write(enter); + await delay(50); + + expect(lastFrame()).toMatch(/Mock error/); + }); + + it('should call onSuccess after successful organization creation', async () => { + mockCreateOrg.mockResolvedValue({ error: null }); + + const { stdin } = render( + + ); + + // Confirm the default organization name + await delay(50); + stdin.write('y'); + await delay(50); + + expect(mockOnSuccess).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/github.test.tsx b/tests/github.test.tsx index cbb20cc..d4898a5 100644 --- a/tests/github.test.tsx +++ b/tests/github.test.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { render } from 'ink-testing-library'; -import SelectProject from '../source/components/gitops/SelectProject.js'; import RepositoryKey from '../source/components/gitops/RepositoryKey.js'; import SSHKey from '../source/components/gitops/SSHKey.js'; import BranchName from '../source/components/gitops/BranchName.js'; @@ -13,7 +12,13 @@ import { getProjectList, getRepoList, } from '../source/lib/gitops/utils.js'; -import { loadAuthToken } from '../source/lib/auth.js'; +import { loadAuthToken, TokenType } from '../source/lib/auth.js'; +import { useApiKeyApi } from '../../source/hooks/useApiKeyApi'; +import * as keytar from "keytar" +import SelectProject from '../source/components/SelectProject'; +import { useProjectAPI } from '../source/hooks/useProjectAPI'; + + const demoPermitKey = 'permit_key_'.concat('a'.repeat(97)); vi.mock('clipboardy', () => ({ @@ -21,9 +26,18 @@ vi.mock('clipboardy', () => ({ writeSync: vi.fn(), }, })); -vi.mock('../source/lib/auth.js', () => ({ - loadAuthToken: vi.fn(() => demoPermitKey), +vi.mock('../source/lib/auth.js', async () => { + const original = await vi.importActual('../source/lib/auth.js'); + return { + ...original, + loadAuthToken: vi.fn(() => demoPermitKey), + }; +}); + +vi.mock('../source/hooks/useProjectAPI.js', () => ({ + useProjectAPI: vi.fn(), })); + vi.mock('../source/lib/gitops/utils.js', () => ({ getProjectList: vi.fn(() => Promise.resolve([ @@ -48,81 +62,51 @@ vi.mock('../source/lib/gitops/utils.js', () => ({ Promise.resolve({ id: '1', status: 'active', key: 'repo1' }), ), })); +vi.mock('../source/hooks/useApiKeyApi', async() => { + const original = await vi.importActual('../source/hooks/useApiKeyApi'); + return { + ...original, + useApiKeyApi: () => ({ + getApiKeyScope: vi.fn().mockResolvedValue({ + response: { + environment_id: 'env1', + project_id: 'proj1', + organization_id: 'org1', + }, + error: null, + status: 200, + }), + getProjectEnvironmentApiKey: vi.fn().mockResolvedValue({ + response: { secret: 'test-secret' }, + error: null, + }), + validateApiKeyScope: vi.fn().mockResolvedValue({ + valid: true, + scope: { + environment_id: 'env1', + project_id: 'proj1', + organization_id: 'org1', + }, + error: null + }) + }), + } +}); + +vi.mock('keytar', () => { + const demoPermitKey = 'permit_key_'.concat('a'.repeat(97)); + const keytar = { + setPassword: vi.fn().mockResolvedValue(demoPermitKey), + getPassword: vi.fn().mockResolvedValue(demoPermitKey), + deletePassword: vi.fn().mockResolvedValue(demoPermitKey), + + }; + return { ...keytar, default: keytar }; +}); const enter = '\r'; const arrowUp = '\u001B[A'; const arrowDown = '\u001B[B'; -describe('Select Project Component', () => { - it('should display loading message when projects are being loaded', async () => { - const onProjectSubmit = vi.fn(); - const onError = vi.fn(); - const accessKey = 'permit_key_'.concat('a'.repeat(97)); - const { stdin, lastFrame } = render( - , - ); - - // Assertion - expect(lastFrame()?.toString() ?? '').toMatch(/Loading projects.../); - }); - - it('Should display project after loading', async () => { - const onProjectSubmit = vi.fn(); - const onError = vi.fn(); - const accessKey = 'permit_key_'.concat('a'.repeat(97)); - const { stdin, lastFrame } = render( - , - ); - - // Check that the loading message is displayed - expect(lastFrame()?.toString() ?? '').toMatch(/Loading projects.../); - - // Wait for the mocked getProjectList to resolve and display the projects - await delay(50); // Adjust time depending on the delay for fetching projects - - // Optionally: Check that the project names are displayed - expect(lastFrame()?.toString()).toMatch(/Project 1/); - expect(lastFrame()?.toString()).toMatch(/Project 2/); - stdin.write(arrowDown); - await delay(50); - stdin.write(enter); - await delay(50); - expect(onProjectSubmit).toHaveBeenCalledOnce(); - expect(onProjectSubmit).toHaveBeenCalledWith('proj2'); - }); - it('should display an error message when fetching projects fails', async () => { - const onProjectSubmit = vi.fn(); - const onError = vi.fn(); - const accessKey = 'permit_key_'.concat('a'.repeat(97)); - - // Mock error response - (getProjectList as any).mockRejectedValueOnce( - new Error('Failed to fetch projects'), - ); - - const { stdin, lastFrame } = render( - , - ); - - // Initially, check for loading message - expect(lastFrame()?.toString()).toMatch(/Loading projects.../); - - // Wait for the error to be handled - await delay(50); // Adjust delay as needed - expect(onError).toHaveBeenCalledWith('Failed to fetch projects'); - }); -}); describe('RepositoryKey Component', () => { it('should call onRepoKeySubmit with the correct value', async () => { @@ -130,6 +114,18 @@ describe('RepositoryKey Component', () => { const onError = vi.fn(); const projectName = 'project1'; const accessToken = 'permit_key_'.concat('a'.repeat(97)); + const mockGetProjects = vi.fn(() => + Promise.resolve({ + response: [ + { id: 'proj1', name: 'Project 1' }, + { id: 'proj2', name: 'Project 2' }, + ], + error: null, + }), + ); + (useProjectAPI as ReturnType).mockReturnValue({ + getProjects: mockGetProjects, + }); const { stdin, lastFrame } = render( { const onError = vi.fn(); const projectName = 'project1'; const accessToken = 'permit_key_'.concat('a'.repeat(97)); + const mockGetProjects = vi.fn(() => + Promise.resolve({ + response: [ + { id: 'proj1', name: 'Project 1' }, + { id: 'proj2', name: 'Project 2' }, + ], + error: null, + }), + ); + (useProjectAPI as ReturnType).mockReturnValue({ + getProjects: mockGetProjects, + }); const { stdin, lastFrame } = render( { const onError = vi.fn(); const projectName = 'project1'; const accessToken = 'permit_key_'.concat('a'.repeat(97)); + const mockGetProjects = vi.fn(() => + Promise.resolve({ + response: [ + { id: 'proj1', name: 'Project 1' }, + { id: 'proj2', name: 'Project 2' }, + ], + error: null, + }), + ); + (useProjectAPI as ReturnType).mockReturnValue({ + getProjects: mockGetProjects, + }); const { stdin, lastFrame } = render( { const onError = vi.fn(); const projectName = 'project1'; const accessToken = 'permit_key_'.concat('a'.repeat(97)); + const mockGetProjects = vi.fn(() => + Promise.resolve({ + response: [ + { id: 'proj1', name: 'Project 1' }, + { id: 'proj2', name: 'Project 2' }, + ], + error: null, + }), + ); + (useProjectAPI as ReturnType).mockReturnValue({ + getProjects: mockGetProjects, + }); const { stdin, lastFrame } = render( { describe('SSHKey Component', () => { it('should call onSSHKeySubmit with the correct value', async () => { const onSSHKeySubmit = vi.fn(); + const mockGetProjects = vi.fn(() => + Promise.resolve({ + response: [ + { id: 'proj1', name: 'Project 1' }, + { id: 'proj2', name: 'Project 2' }, + ], + error: null, + }), + ); + (useProjectAPI as ReturnType).mockReturnValue({ + getProjects: mockGetProjects, + }); const onError = vi.fn(); const { stdin, lastFrame } = render( , @@ -265,6 +309,18 @@ describe('SSHKey Component', () => { it("should call onError with 'Please enter a valid SSH URL' for empty value", async () => { const onSSHKeySubmit = vi.fn(); const onError = vi.fn(); + const mockGetProjects = vi.fn(() => + Promise.resolve({ + response: [ + { id: 'proj1', name: 'Project 1' }, + { id: 'proj2', name: 'Project 2' }, + ], + error: null, + }), + ); + (useProjectAPI as ReturnType).mockReturnValue({ + getProjects: mockGetProjects, + }); const { stdin, lastFrame } = render( , ); @@ -287,6 +343,18 @@ describe('SSHKey Component', () => { it("should call onError with 'Please enter a valid SSH URL' for invalid value", async () => { const onSSHKeySubmit = vi.fn(); const onError = vi.fn(); + const mockGetProjects = vi.fn(() => + Promise.resolve({ + response: [ + { id: 'proj1', name: 'Project 1' }, + { id: 'proj2', name: 'Project 2' }, + ], + error: null, + }), + ); + (useProjectAPI as ReturnType).mockReturnValue({ + getProjects: mockGetProjects, + }); const { stdin, lastFrame } = render( , ); @@ -345,7 +413,7 @@ describe('Branch Name component', () => { }); }); -describe('GiHub Complete Flow', () => { +describe('GitHub Complete Flow', () => { it('should complete the flow', async () => { const { stdin, lastFrame } = render( , diff --git a/tests/hooks/useMemberAPI.test.tsx b/tests/hooks/useMemberAPI.test.tsx index 10add98..71c4c7f 100644 --- a/tests/hooks/useMemberAPI.test.tsx +++ b/tests/hooks/useMemberAPI.test.tsx @@ -28,7 +28,7 @@ describe('useMemberApi', () => { apiCall.mockResolvedValue({ success: true }); const inviteMember = async () => { - const result = await inviteNewMember(authToken, body); + const result = await inviteNewMember(authToken, body, 'dummy_name', 'dummy_email'); return result.success ? 'Member invited' : 'Failed to invite member'; }; const [result, setResult] = React.useState(null); @@ -56,7 +56,7 @@ describe('useMemberApi', () => { apiCall.mockResolvedValue({ success: false }); const inviteMember = async () => { - const result = await inviteNewMember(authToken, body); + const result = await inviteNewMember(authToken, body, 'dummy_name', 'dummy_email'); return result.success ? 'Member invited' : 'Failed to invite member'; }; const [result, setResult] = React.useState(null);