diff --git a/web/packages/design/src/utils/testing.tsx b/web/packages/design/src/utils/testing.tsx index 7312f4eb1a5af..b68eecff79cfe 100644 --- a/web/packages/design/src/utils/testing.tsx +++ b/web/packages/design/src/utils/testing.tsx @@ -25,6 +25,7 @@ import { render as testingRender, waitFor, waitForElementToBeRemoved, + within, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { ReactNode } from 'react'; @@ -78,8 +79,8 @@ screen.debug = () => { }; type RenderOptions = { - wrapper: React.FC; - container: HTMLElement; + wrapper?: React.FC; + container?: HTMLElement; }; export { @@ -95,4 +96,5 @@ export { Router, userEvent, waitForElementToBeRemoved, + within, }; diff --git a/web/packages/shared/utils/collectKeys.test.ts b/web/packages/shared/utils/collectKeys.test.ts new file mode 100644 index 0000000000000..9b7b736af7842 --- /dev/null +++ b/web/packages/shared/utils/collectKeys.test.ts @@ -0,0 +1,97 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { collectKeys } from './collectKeys'; + +describe('collectKeys', () => { + it.each` + value + ${undefined} + ${null} + ${1} + ${true} + ${() => {}} + `('supports non object values ($value)', ({ value }) => { + const actual = collectKeys(value); + expect(actual).toBeNull(); + }); + + it('supports empty object values', () => { + const actual = collectKeys({}); + expect(actual).toEqual([]); + }); + + it('supports empty array values', () => { + const actual = collectKeys([]); + expect(actual).toEqual([]); + }); + + it('supports simple object values', () => { + const actual = collectKeys({ + number: 1, + boolean: true, + string: 'string', + function: () => {}, + null: null, + undefined: undefined, + }); + expect(actual).toEqual([ + '.number', + '.boolean', + '.string', + '.function', + '.null', + '.undefined', + ]); + }); + + it('supports simple array values', () => { + const actual = collectKeys([ + { alpha: true }, + { alpha: true }, + { beta: true }, + ]); + expect(actual).toEqual(['.alpha', '.alpha', '.beta']); + }); + + it('supports nested object values', () => { + const actual = collectKeys([ + { + inner: { + foo: 'bar', + }, + }, + ]); + expect(actual).toEqual(['.inner.foo']); + }); + + it('supports nested array values', () => { + const actual = collectKeys([[{ foo: 'bar' }], { bar: 'foo' }]); + expect(actual).toEqual(['.foo', '.bar']); + }); + + it('allows a custom key prefix', () => { + const actual = collectKeys( + { + foo: 1, + }, + 'root' + ); + expect(actual).toEqual(['root.foo']); + }); +}); diff --git a/web/packages/shared/utils/collectKeys.ts b/web/packages/shared/utils/collectKeys.ts new file mode 100644 index 0000000000000..cb96b4358dba7 --- /dev/null +++ b/web/packages/shared/utils/collectKeys.ts @@ -0,0 +1,42 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/** + * `collectKeys` gathers object keys recursively and returns them. Arrays are + * traversed, but are transparent. Returns null if the value is not an object + * or array of objects, and an empty array of no keys are present. + * + * @param value Value from which keys will be collected + * @param prefix An optional value to be prepended to all keys returned + * @returns An array of the keys collected (if any) or null + */ +export const collectKeys = (value: unknown, prefix: string = '') => { + if (typeof value !== 'object' || value === null) { + return prefix ? [prefix] : null; + } + + if (Array.isArray(value)) { + return value.flatMap(val => { + return collectKeys(val, prefix); + }); + } + + return Object.entries(value).flatMap(([k, v]) => { + return collectKeys(v, `${prefix}.${k}`); + }); +}; diff --git a/web/packages/teleport/src/JoinTokens/JoinTokenForms.tsx b/web/packages/teleport/src/JoinTokens/JoinTokenForms.tsx index 5c9b7a4f5a806..dc6604e8db544 100644 --- a/web/packages/teleport/src/JoinTokens/JoinTokenForms.tsx +++ b/web/packages/teleport/src/JoinTokens/JoinTokenForms.tsx @@ -31,9 +31,11 @@ import { export const JoinTokenIAMForm = ({ tokenState, onUpdateState, + readonly, }: { tokenState: NewJoinTokenState; onUpdateState: (newToken: NewJoinTokenState) => void; + readonly: boolean; }) => { const rules = tokenState.iam; @@ -104,6 +106,7 @@ export const JoinTokenIAMForm = ({ onChange={e => setTokenRulesField(index, 'aws_account', e.target.value) } + readonly={readonly} /> setTokenRulesField(index, 'aws_arn', e.target.value)} + readonly={readonly} /> ))} @@ -125,9 +129,11 @@ export const JoinTokenIAMForm = ({ export const JoinTokenGCPForm = ({ tokenState, onUpdateState, + readonly, }: { tokenState: NewJoinTokenState; onUpdateState: (newToken: NewJoinTokenState) => void; + readonly: boolean; }) => { const rules = tokenState.gcp; function removeRule(index: number) { @@ -198,6 +204,7 @@ export const JoinTokenGCPForm = ({ value={rule.project_ids} label="Add Project ID(s)" rule={requiredField('At least 1 Project ID required')} + isDisabled={readonly} /> ))} @@ -235,9 +244,11 @@ export const JoinTokenGCPForm = ({ export const JoinTokenOracleForm = ({ tokenState, onUpdateState, + readonly, }: { tokenState: NewJoinTokenState; onUpdateState: (newToken: NewJoinTokenState) => void; + readonly: boolean; }) => { const rules = tokenState.oracle; function removeRule(index: number) { @@ -301,6 +312,7 @@ export const JoinTokenOracleForm = ({ placeholder="ocid1.tenancy.oc1.." value={rule.tenancy} onChange={e => updateRuleField(index, 'tenancy', e.target.value)} + readonly={readonly} /> ))} diff --git a/web/packages/teleport/src/JoinTokens/JoinTokenGithubForm.test.tsx b/web/packages/teleport/src/JoinTokens/JoinTokenGithubForm.test.tsx new file mode 100644 index 0000000000000..b9c9e817a35a3 --- /dev/null +++ b/web/packages/teleport/src/JoinTokens/JoinTokenGithubForm.test.tsx @@ -0,0 +1,390 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import userEvent from '@testing-library/user-event'; +import { PropsWithChildren } from 'react'; + +import { fireEvent, render, screen, within } from 'design/utils/testing'; +import Validation, { useValidation } from 'shared/components/Validation'; + +import { ThemeProvider } from 'teleport/ThemeProvider'; + +import { JoinTokenGithubForm } from './JoinTokenGithubForm'; +import { NewJoinTokenState } from './UpsertJoinTokenDialog'; + +const populateRuleFieldTest = + ( + field: keyof NewJoinTokenState['github']['rules'][number], + placeholer: string, + value: string + ) => + async () => { + const state = baseState(); + const onUpdate = jest.fn(); + + render( + , + { wrapper: Wrapper } + ); + + fireEvent.change(screen.getByPlaceholderText(placeholer), { + target: { value }, + }); + + expect(onUpdate).toHaveBeenCalledTimes(1); + expect(onUpdate).toHaveBeenLastCalledWith( + baseState({ + rules: [ + { + ...state.github.rules[0], + [field]: value, + }, + ], + }) + ); + }; + +const populateFieldTest = + ({ + field, + placeholer, + value, + }: { + field: keyof NewJoinTokenState['github']; + placeholer: string; + value: string; + }) => + async () => { + const state = baseState(); + const onUpdate = jest.fn(); + + render( + , + { wrapper: Wrapper } + ); + + fireEvent.change(screen.getByPlaceholderText(placeholer), { + target: { value }, + }); + + expect(onUpdate).toHaveBeenCalledTimes(1); + expect(onUpdate).toHaveBeenLastCalledWith( + baseState({ + [field]: value, + }) + ); + }; + +describe('GithubJoinTokenForm', () => { + it('a rule can be added', async () => { + const state = baseState(); + const onUpdate = jest.fn(); + + render( + , + { wrapper: Wrapper } + ); + + await userEvent.click( + screen.getByRole('button', { name: /Add another GitHub rule/i }) + ); + + expect(onUpdate).toHaveBeenCalledTimes(1); + expect(onUpdate).toHaveBeenLastCalledWith( + baseState({ + rules: [ + ...state.github.rules, + { + ref_type: 'any', + }, + ], + }) + ); + }); + + it('delete button is hidden when only one rule exists', async () => { + const state = baseState(); + + render( + , + { wrapper: Wrapper } + ); + + expect(screen.queryByTestId('delete_rule')).not.toBeInTheDocument(); + }); + + it('delete button is visible when more than one rule exists', async () => { + const state = baseState({ + rules: [{}, {}], + }); + + render( + , + { wrapper: Wrapper } + ); + + expect(screen.queryAllByTestId('delete_rule').length).toBe(2); + }); + + it('a rule can be deleted', async () => { + const state = baseState({ + rules: [{}, {}], + }); + const onUpdate = jest.fn(); + + render( + , + { wrapper: Wrapper } + ); + + const rule0 = screen.getByTestId('rule_0'); + const deleteButton0 = within(rule0).getByTestId('delete_rule'); + + await userEvent.click(deleteButton0); + + expect(onUpdate).toHaveBeenCalledTimes(1); + expect(onUpdate).toHaveBeenLastCalledWith( + baseState({ + rules: [state.github.rules[0]], + }) + ); + }); + + // eslint-disable-next-line jest/expect-expect + it( + 'repository field can be populated', + populateRuleFieldTest( + 'repository', + 'gravitational/teleport', + 'gravitational/teleport' + ) + ); + + it('repository field shows a validation message', async () => { + const state = baseState(); + + render( + , + { wrapper: Wrapper } + ); + + await userEvent.click(screen.getByTestId('submit')); + + expect( + screen.getByText('Either repository name or owner is required') + ).toBeInTheDocument(); + }); + + // eslint-disable-next-line jest/expect-expect + it( + 'repository owner field can be populated', + populateRuleFieldTest('repository_owner', 'gravitational', 'gravitational') + ); + + it('repository owner field shows a validation message', async () => { + const state = baseState(); + + render( + , + { wrapper: Wrapper } + ); + + await userEvent.click(screen.getByTestId('submit')); + + expect( + screen.getByText('Either repository owner or name is required') + ).toBeInTheDocument(); + }); + + // eslint-disable-next-line jest/expect-expect + it( + 'workflow field can be populated', + populateRuleFieldTest('workflow', 'my-workflow', 'my-workflow') + ); + + // eslint-disable-next-line jest/expect-expect + it( + 'environment field can be populated', + populateRuleFieldTest('environment', 'production', 'production') + ); + + // eslint-disable-next-line jest/expect-expect + it( + 'ref field can be populated', + populateRuleFieldTest('ref', 'ref/heads/main', 'ref/heads/main') + ); + + it('ref type is disabled when ref is not populated', async () => { + const state = baseState(); + + render( + , + { wrapper: Wrapper } + ); + + expect(screen.getByLabelText('Ref type')).toBeDisabled(); + }); + + it('ref type can be selected', async () => { + const state = baseState({ + rules: [ + { + ref: 'ref/heads/main', + ref_type: 'any', + }, + ], + }); + const onUpdate = jest.fn(); + + render( + , + { wrapper: Wrapper } + ); + + const selectElement = screen.getByLabelText('Ref type'); + expect(selectElement).toBeEnabled(); + + // Seems to be the only way to interact with react-select component + fireEvent.keyDown(selectElement, { key: 'ArrowDown' }); + const existingItem = await screen.findByText('Branch'); + fireEvent.click(existingItem); + + expect(onUpdate).toHaveBeenCalledTimes(1); + expect(onUpdate).toHaveBeenLastCalledWith( + baseState({ + rules: [ + { + ...state.github.rules[0], + ref_type: 'branch', + }, + ], + }) + ); + }); + + // eslint-disable-next-line jest/expect-expect + it( + 'server host field can be populated', + populateFieldTest({ + field: 'server_host', + placeholer: 'github.example.com', + value: 'github.example.com', + }) + ); + + // eslint-disable-next-line jest/expect-expect + it( + 'slug field can be populated', + populateFieldTest({ + field: 'enterprise_slug', + placeholer: 'octo-enterprise', + value: 'octo-enterprise', + }) + ); + + // eslint-disable-next-line jest/expect-expect + it( + 'jwks field can be populated', + populateFieldTest({ + field: 'static_jwks', + placeholer: '{"keys":[--snip--]}', + value: '{"keys":[]}', + }) + ); +}); + +const Wrapper = ({ children }: PropsWithChildren) => { + return ( + + + {children} + + + ); +}; + +const SubmitWrapper = ({ children }: PropsWithChildren) => { + const validation = useValidation(); + + return ( + <> + {children} +