Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions server/platform/services/remotecluster/recv.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ func (rcs *Service) ReceiveInviteConfirmation(confirm model.RemoteClusterInvite)

// If the accepting cluster sent a RefreshedToken (its RemoteToken), set it as our Token
if confirm.Version >= 2 && confirm.RefreshedToken != "" {
if confirm.RefreshedToken == rc.Token {
return nil, fmt.Errorf("cannot accept invite confirmation for remote %s: RefreshedToken must be different from the original invite token", confirm.RemoteId)
}
rc.Token = confirm.RefreshedToken
} else {
// For older versions or if no RefreshedToken was provided, generate a new token
Expand Down
41 changes: 41 additions & 0 deletions server/platform/services/remotecluster/recv_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,47 @@ func TestReceiveInviteConfirmation_TokenInvalidation(t *testing.T) {

remoteClusterStoreMock.AssertExpectations(t)
})

t.Run("Protocol v2+ with RefreshedToken equal to original token - rejected (MM-67098)", func(t *testing.T) {
// Security: RefreshedToken must be different from the original invite token.
// If the remote sends back the same token, they did not actually refresh; reject.
originalToken := model.NewId()
remoteId := model.NewId()

originalRC := &model.RemoteCluster{
RemoteId: remoteId,
Token: originalToken,
SiteURL: model.SiteURLPending + model.NewId(),
CreateAt: model.GetMillis(),
}

remoteClusterStoreMock := &mocks.RemoteClusterStore{}
remoteClusterStoreMock.On("Get", remoteId, false).Return(originalRC, nil)
// Update must NOT be called when RefreshedToken == rc.Token

storeMock := &mocks.Store{}
storeMock.On("RemoteCluster").Return(remoteClusterStoreMock)

mockServer := newMockServerWithStore(t, storeMock)
mockApp := newMockApp(t, nil)
service, err := NewRemoteClusterService(mockServer, mockApp)
require.NoError(t, err)

confirm := model.RemoteClusterInvite{
RemoteId: remoteId,
SiteURL: "http://example.com",
Token: model.NewId(),
RefreshedToken: originalToken, // Same as rc.Token - invalid, must be different
Version: 3,
}

rcUpdated, err := service.ReceiveInviteConfirmation(confirm)

require.Error(t, err)
assert.Nil(t, rcUpdated)
assert.Contains(t, err.Error(), "RefreshedToken must be different from the original invite token")
remoteClusterStoreMock.AssertNotCalled(t, "Update", mock.Anything)
})
}

// TestReceiveInviteConfirmation_EdgeCases tests various edge cases
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@
.create-recap-modal-body {
max-height: 500px;
padding: 0px 0px;
overflow-y: auto;
}

.create-recap-modal-header {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
margin-top: -9px;

.create-recap-modal-header-actions {
display: flex;
Expand Down Expand Up @@ -136,13 +136,15 @@
.input-container {
position: relative;
display: flex;
align-items: center;
flex-direction: column;

.icon {
position: absolute;
top: 50%;
left: 16px;
color: rgba(var(--center-channel-color-rgb), 0.64);
font-size: 16px;
transform: translateY(-50%);
}

.form-control {
Expand All @@ -153,6 +155,36 @@
&:focus {
border-color: var(--button-bg);
}

&.input-error {
border-color: var(--error-text);

&:focus {
border-color: var(--error-text);
box-shadow: 0 0 0 1px var(--error-text);
}
}
}

&.has-error {
.icon {
color: var(--error-text);
}
}

.input-error-message {
display: flex;
align-items: center;
margin-top: 6px;
color: var(--error-text);
font-size: 12px;
gap: 4px;

.icon {
position: static;
font-size: 14px;
transform: none;
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,27 @@ describe('CreateRecapModal', () => {
});
});

test('should not advance to next step when name is empty and show validation error', async () => {
renderWithContext(<CreateRecapModal {...defaultProps}/>, initialState);

await waitFor(() => {
const dropdownButton = screen.getByLabelText('Agent selector');
expect(dropdownButton).toHaveTextContent('Copilot');
});

// Select a recap type but leave the name empty
const allUnreadsButton = screen.getByText('Recap all my unreads');
await userEvent.click(allUnreadsButton);

// Next button should be disabled since name is empty
const nextButton = screen.getByRole('button', {name: /next/i});
expect(nextButton).toBeDisabled();

// We should still be on step 1
expect(screen.getByText('Give your recap a name')).toBeInTheDocument();
expect(screen.getByText('What type of recap would you like?')).toBeInTheDocument();
});

test('should enable Next on channel selection step when a checkbox is clicked', async () => {
renderWithContext(<CreateRecapModal {...defaultProps}/>, initialState);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,68 @@ describe('RecapConfiguration', () => {
});
});

describe('Auto-focus', () => {
it('should auto-focus the name input on mount', () => {
renderWithContext(<RecapConfiguration {...defaultProps}/>);

const input = screen.getByPlaceholderText('Give your recap a name');
expect(input).toHaveFocus();
});
});

describe('Name Validation', () => {
it('should not show error before input is blurred', () => {
renderWithContext(<RecapConfiguration {...defaultProps}/>);

expect(screen.queryByText('This field is required')).not.toBeInTheDocument();
});

it('should show error message when input is blurred with empty name', async () => {
renderWithContext(<RecapConfiguration {...defaultProps}/>);

const input = screen.getByPlaceholderText('Give your recap a name');
await userEvent.click(input);
await userEvent.tab();

expect(screen.getByText('This field is required')).toBeInTheDocument();
});

it('should not show error when blurred with a valid name', async () => {
renderWithContext(
<RecapConfiguration
{...defaultProps}
recapName='My Recap'
/>,
);

const input = screen.getByPlaceholderText('Give your recap a name');
await userEvent.click(input);
await userEvent.tab();

expect(screen.queryByText('This field is required')).not.toBeInTheDocument();
});

it('should add input-error class when error is shown', async () => {
renderWithContext(<RecapConfiguration {...defaultProps}/>);

const input = screen.getByPlaceholderText('Give your recap a name');
await userEvent.click(input);
await userEvent.tab();

expect(input).toHaveClass('input-error');
});

it('should set aria-invalid when error is shown', async () => {
renderWithContext(<RecapConfiguration {...defaultProps}/>);

const input = screen.getByPlaceholderText('Give your recap a name');
await userEvent.click(input);
await userEvent.tab();

expect(input).toHaveAttribute('aria-invalid', 'true');
});
});

describe('Form Labels', () => {
it('should display name label', () => {
renderWithContext(<RecapConfiguration {...defaultProps}/>);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

import React from 'react';
import React, {useCallback, useState} from 'react';
import {useIntl, FormattedMessage} from 'react-intl';

import {ProductChannelsIcon, LightningBoltOutlineIcon, CheckCircleIcon} from '@mattermost/compass-icons/components';
Expand All @@ -21,8 +21,15 @@ type Props = {

const RecapConfiguration = ({recapName, setRecapName, recapType, setRecapType, unreadChannels}: Props) => {
const {formatMessage} = useIntl();
const [touched, setTouched] = useState(false);
const hasUnreadChannels = unreadChannels.length > 0;

const showError = touched && recapName.trim().length === 0;

const handleBlur = useCallback(() => {
setTouched(true);
}, []);

const allUnreadsButton = (
<button
type='button'
Expand Down Expand Up @@ -63,16 +70,28 @@ const RecapConfiguration = ({recapName, setRecapName, recapType, setRecapType, u
defaultMessage='Give your recap a name'
/>
</label>
<div className='input-container'>
<div className={`input-container${showError ? ' has-error' : ''}`}>
<input
id='recap-name-input'
type='text'
className='form-control'
autoFocus={true}
className={`form-control${showError ? ' input-error' : ''}`}
placeholder={formatMessage({id: 'recaps.modal.namePlaceholder', defaultMessage: 'Give your recap a name'})}
value={recapName}
onChange={(e) => setRecapName(e.target.value)}
onBlur={handleBlur}
maxLength={RECAP_NAME_MAX_LENGTH}
aria-invalid={showError}
/>
{showError && (
<div className='input-error-message'>
<i className='icon icon-alert-circle-outline'/>
<FormattedMessage
id='recaps.modal.nameRequired'
defaultMessage='This field is required'
/>
</div>
)}
</div>
</div>

Expand Down
Loading
Loading