Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* Teleport
* Copyright (C) 2024 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 <http://www.gnu.org/licenses/>.
*/

import { useEffect } from 'react';
import { renderHook, act } from '@testing-library/react';

import { MockAppContextProvider } from 'teleterm/ui/fixtures/MockAppContextProvider';
import { MockAppContext } from 'teleterm/ui/fixtures/mocks';
import { makeRootCluster } from 'teleterm/services/tshd/testHelpers';
import {
ResourcesContextProvider,
useResourcesContext,
} from 'teleterm/ui/DocumentCluster/resourcesContext';
import { RootClusterUri } from 'teleterm/ui/uri';

import { useAssumedRolesBar } from './useAssumedRolesBar';

test('dropping a request refreshes resources', async () => {
const appContext = new MockAppContext();
const cluster = makeRootCluster();
appContext.workspacesService.setState(draftState => {
draftState.rootClusterUri = cluster.uri;
draftState.workspaces[cluster.uri] = {
localClusterUri: cluster.uri,
documents: [],
location: undefined,
accessRequests: undefined,
};
});
jest.spyOn(appContext.clustersService, 'dropRoles');
const refreshListener = jest.fn();

const wrapper = ({ children }) => (
<MockAppContextProvider appContext={appContext}>
<ResourcesContextProvider>
<RequestRefreshSubscriber
rootClusterUri={cluster.uri}
onResourcesRefreshRequest={refreshListener}
/>
{children}
</ResourcesContextProvider>
</MockAppContextProvider>
);

const { result } = renderHook(
() =>
useAssumedRolesBar({
roles: [],
id: 'mocked-request-id',
expires: new Date(),
}),
{ wrapper }
);

await act(() => result.current.dropRequest());
expect(appContext.clustersService.dropRoles).toHaveBeenCalledTimes(1);
expect(appContext.clustersService.dropRoles).toHaveBeenCalledWith(
cluster.uri,
['mocked-request-id']
);
expect(refreshListener).toHaveBeenCalledTimes(1);
});

function RequestRefreshSubscriber(props: {
rootClusterUri: RootClusterUri;
onResourcesRefreshRequest: () => void;
}) {
const { onResourcesRefreshRequest } = useResourcesContext(
props.rootClusterUri
);
useEffect(() => {
onResourcesRefreshRequest(props.onResourcesRefreshRequest);
}, [onResourcesRefreshRequest, props.onResourcesRefreshRequest]);

return null;
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,12 @@ import { useInterval } from 'shared/hooks';
import { useAppContext } from 'teleterm/ui/appContextProvider';
import { retryWithRelogin } from 'teleterm/ui/utils';
import { AssumedRequest } from 'teleterm/services/tshd/types';
import { useResourcesContext } from 'teleterm/ui/DocumentCluster/resourcesContext';

export function useAssumedRolesBar(assumedRequest: AssumedRequest) {
const ctx = useAppContext();
const rootClusterUri = ctx.workspacesService?.getRootClusterUri();
const { requestResourcesRefresh } = useResourcesContext(rootClusterUri);

const [duration, setDuration] = useState<Duration>(() =>
getDurationFromNow({
Expand All @@ -46,21 +48,18 @@ export function useAssumedRolesBar(assumedRequest: AssumedRequest) {
getRefreshInterval(duration)
);

const [dropRequestAttempt, dropRequest] = useAsync(() => {
return retryWithRelogin(
ctx,
rootClusterUri,
() => ctx.clustersService.dropRoles(rootClusterUri, [assumedRequest.id])
// TODO(gzdunek): We should refresh the resources,
// the same as after assuming a role in `useAssumeAccess`.
// Unfortunately, we can't do this because we don't have access to `ResourcesContext`.
// Consider moving it into `ResourcesService`.
).catch(err => {
const [dropRequestAttempt, dropRequest] = useAsync(async () => {
try {
await retryWithRelogin(ctx, rootClusterUri, () =>
ctx.clustersService.dropRoles(rootClusterUri, [assumedRequest.id])
);
requestResourcesRefresh();
} catch (err) {
ctx.notificationsService.notifyError({
title: 'Could not switch back the role',
title: 'Could not drop role',
description: err.message,
});
});
}
});

const updateDurationAndInterval = useCallback(() => {
Expand Down
17 changes: 10 additions & 7 deletions web/packages/teleterm/src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,20 +29,23 @@ import AppContext from './appContext';
import { ThemeProvider } from './ThemeProvider';
import { VnetContextProvider } from './Vnet/vnetContext';
import { ConnectionsContextProvider } from './TopBar/Connections/connectionsContext';
import { ResourcesContextProvider } from './DocumentCluster/resourcesContext';

export const App: React.FC<{ ctx: AppContext }> = ({ ctx }) => {
return (
<CatchError>
<StyledApp>
<DndProvider backend={HTML5Backend}>
<AppContextProvider value={ctx}>
<ConnectionsContextProvider>
<VnetContextProvider>
<ThemeProvider>
<AppInitializer />
</ThemeProvider>
</VnetContextProvider>
</ConnectionsContextProvider>
<ResourcesContextProvider>
<ConnectionsContextProvider>
<VnetContextProvider>
<ThemeProvider>
<AppInitializer />
</ThemeProvider>
</VnetContextProvider>
</ConnectionsContextProvider>
</ResourcesContextProvider>
</AppContextProvider>
</DndProvider>
</StyledApp>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ function AgentSetup() {
setDownloadAgentAttempt,
agentProcessState,
} = useConnectMyComputerContext();
const { requestResourcesRefresh } = useResourcesContext();
const { requestResourcesRefresh } = useResourcesContext(rootClusterUri);
const rootCluster = ctx.clustersService.findCluster(rootClusterUri);

// The verify agent step checks if we can execute the binary. This triggers OS-level checks, such
Expand Down Expand Up @@ -279,8 +279,8 @@ function AgentSetup() {
throw error;
}

// Now that the node has joined the server, let's refresh all open DocumentCluster instances
// to show the new node.
// Now that the node has joined the server, let's refresh open DocumentCluster
// instances in the workspace to show the new node.
requestResourcesRefresh();
}, [startAgent, requestResourcesRefresh])
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ export const ConnectMyComputerContextProvider: FC<
workspacesService,
usageService,
} = ctx;
const { requestResourcesRefresh } = useResourcesContext();
const { requestResourcesRefresh } = useResourcesContext(rootClusterUri);
clustersService.useState();

const [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ const renderActionCell = (
onClick={() => assumeRole(request)}
width="108px"
>
{flags.isAssumed ? 'assumed' : 'assume roles'}
{flags.isAssumed ? 'Assumed' : 'Assume Roles'}
</ButtonPrimary>
);
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,12 @@ export function useAssumeAccess() {
rootClusterUri,
documentsService,
} = useWorkspaceContext();
const { requestResourcesRefresh } = useResourcesContext();
const { requestResourcesRefresh } = useResourcesContext(rootClusterUri);

const [assumeRoleAttempt, runAssumeRole] = useAsync((requestId: string) =>
retryWithRelogin(ctx, clusterUri, async () => {
await ctx.clustersService.assumeRoles(rootClusterUri, [requestId]);
// refresh the current resource tabs
// Refresh the current resource tabs in the workspace.
requestResourcesRefresh();
})
);
Expand All @@ -58,7 +58,7 @@ export function useAssumeAccess() {
return;
}

// refresh the current resource tabs
// Refresh the current resource tabs in the workspace.
requestResourcesRefresh();

// open new cluster tab
Expand Down
131 changes: 125 additions & 6 deletions web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import { useImperativeHandle, forwardRef, createRef } from 'react';
import { render, screen } from 'design/utils/testing';
import { mockIntersectionObserver } from 'jsdom-testing-mocks';
import { act } from '@testing-library/react';
Expand All @@ -30,22 +31,26 @@ import { ShowResources } from 'gen-proto-ts/teleport/lib/teleterm/v1/cluster_pb'

import { UnifiedResources } from 'teleterm/ui/DocumentCluster/UnifiedResources';
import { MockAppContextProvider } from 'teleterm/ui/fixtures/MockAppContextProvider';
import { ResourcesContextProvider } from 'teleterm/ui/DocumentCluster/resourcesContext';
import {
ResourcesContextProvider,
useResourcesContext,
} from 'teleterm/ui/DocumentCluster/resourcesContext';
import { ConnectMyComputerContextProvider } from 'teleterm/ui/ConnectMyComputer';
import { MockWorkspaceContextProvider } from 'teleterm/ui/fixtures/MockWorkspaceContextProvider';
import { makeDocumentCluster } from 'teleterm/ui/services/workspacesService/documentsService/testHelpers';
import { MockAppContext } from 'teleterm/ui/fixtures/mocks';
import {
makeRootCluster,
rootClusterUri,
makeServer,
} from 'teleterm/services/tshd/testHelpers';
import { getEmptyPendingAccessRequest } from 'teleterm/ui/services/workspacesService/accessRequestsService';

import { MockedUnaryCall } from 'teleterm/services/tshd/cloneableClient';
import * as uri from 'teleterm/ui/uri';

const mio = mockIntersectionObserver();

const tests = [
test.each([
{
name: 'fetches only available resources if cluster does not support access requests',
conditions: {
Expand Down Expand Up @@ -166,9 +171,7 @@ const tests = [
includeRequestable: false,
},
},
];

test.each(tests)('$name', async testCase => {
])('$name', async testCase => {
const doc = makeDocumentCluster();

const appContext = new MockAppContext({ platform: 'darwin' });
Expand Down Expand Up @@ -270,3 +273,119 @@ test.each(tests)('$name', async testCase => {
new AbortController().signal
);
});

test.each([
{
name: 'refreshes resources when the document cluster URI matches the requested cluster URI',
conditions: {
documentClusterUri: '/clusters/teleport-local',
},
expect: {
resourcesRefreshed: true,
},
},
{
name: 'refreshes resources when the document cluster URI is a leaf of the requested cluster URI',
conditions: {
documentClusterUri: '/clusters/teleport-local/leaves/leaf',
},
expect: {
resourcesRefreshed: true,
},
},
])('$name', async testCase => {
const doc = makeDocumentCluster({
clusterUri: testCase.conditions.documentClusterUri,
});
const rootCluster = makeRootCluster({
uri: uri.routing.ensureRootClusterUri(doc.clusterUri),
});
const serverResource = makeServer();
const appContext = new MockAppContext();
appContext.clustersService.setState(draft => {
draft.clusters.set(rootCluster.uri, rootCluster);
});

appContext.workspacesService.setState(draftState => {
draftState.rootClusterUri = rootCluster.uri;
draftState.workspaces[rootCluster.uri] = {
localClusterUri: rootCluster.uri,
documents: [doc],
location: doc.uri,
accessRequests: {
pending: getEmptyPendingAccessRequest(),
isBarCollapsed: true,
},
};
});

jest
.spyOn(appContext.resourcesService, 'listUnifiedResources')
.mockResolvedValue({
resources: [
{
kind: 'server',
resource: serverResource,
requiresRequest: false,
},
],
nextKey: '',
});

const ref = createRef<{
requestResourcesRefresh: () => void;
}>();

render(
<MockAppContextProvider appContext={appContext}>
<MockWorkspaceContextProvider>
<ResourcesContextProvider>
<ConnectMyComputerContextProvider rootClusterUri={rootCluster.uri}>
<Refresher ref={ref} rootClusterUri={rootCluster.uri} />
<UnifiedResources
clusterUri={doc.clusterUri}
docUri={doc.uri}
queryParams={doc.queryParams}
/>
</ConnectMyComputerContextProvider>
</ResourcesContextProvider>
</MockWorkspaceContextProvider>
</MockAppContextProvider>
);

act(mio.enterAll);

// Wait for resources to render.
await expect(
screen.findByText(serverResource.hostname)
).resolves.toBeInTheDocument();
expect(
appContext.resourcesService.listUnifiedResources
).toHaveBeenCalledTimes(1);

act(() => ref.current.requestResourcesRefresh());

// Wait for resources to (potentially) re-render.
await expect(
screen.findByText(serverResource.hostname)
).resolves.toBeInTheDocument();
expect(
appContext.resourcesService.listUnifiedResources
// When resources are refreshed, we have two calls to the API.
).toHaveBeenCalledTimes(testCase.expect.resourcesRefreshed ? 2 : 1);
});

const Refresher = forwardRef<
{
requestResourcesRefresh: () => void;
},
{
rootClusterUri: uri.RootClusterUri;
}
>((props, ref) => {
const resourcesContext = useResourcesContext(props.rootClusterUri);
useImperativeHandle(ref, () => ({
requestResourcesRefresh: resourcesContext.requestResourcesRefresh,
}));
return null;
});
Loading