]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-14214 - Unassign Security Hotspot
authorBelen Pruvost <belen.pruvost@sonarsource.com>
Wed, 28 Apr 2021 13:04:45 +0000 (15:04 +0200)
committersonartech <sonartech@sonarsource.com>
Thu, 29 Apr 2021 20:03:32 +0000 (20:03 +0000)
13 files changed:
server/sonar-web/src/main/js/apps/security-hotspots/components/assignee/Assignee.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/assignee/AssigneeRenderer.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/assignee/AssigneeSelection.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/assignee/AssigneeSelectionRenderer.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/assignee/__tests__/Assignee-test.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/assignee/__tests__/AssigneeSelection-test.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/assignee/__tests__/AssigneeSelectionRenderer-test.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/assignee/__tests__/__snapshots__/AssigneeSelection-test.tsx.snap
server/sonar-web/src/main/js/apps/security-hotspots/components/assignee/__tests__/__snapshots__/AssigneeSelectionRenderer-test.tsx.snap
server/sonar-web/src/main/js/types/security-hotspots.ts
server/sonar-webserver-webapi/src/main/java/org/sonar/server/hotspot/ws/AssignAction.java
server/sonar-webserver-webapi/src/test/java/org/sonar/server/hotspot/ws/AssignActionTest.java
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 902532259ba7cbaf223507bb092adb56594b2488..19ba3ae0ef893bbee2367e6e9846577b07707ce2 100644 (file)
@@ -18,7 +18,7 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
-import { translateWithParameters } from 'sonar-ui-common/helpers/l10n';
+import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n';
 import { assignSecurityHotspot } from '../../../../api/security-hotspots';
 import addGlobalSuccessMessage from '../../../../app/utils/addGlobalSuccessMessage';
 import { withCurrentUser } from '../../../../components/hoc/withCurrentUser';
@@ -61,25 +61,25 @@ export class Assignee extends React.PureComponent<Props, State> {
     this.setState({ editing: false });
   };
 
-  handleAssign = (newAssignee?: T.UserActive) => {
-    if (newAssignee && newAssignee.login) {
-      this.setState({ loading: true });
-      assignSecurityHotspot(this.props.hotspot.key, {
-        assignee: newAssignee.login
+  handleAssign = (newAssignee: T.UserActive) => {
+    this.setState({ loading: true });
+    assignSecurityHotspot(this.props.hotspot.key, {
+      assignee: newAssignee?.login
+    })
+      .then(() => {
+        if (this.mounted) {
+          this.setState({ editing: false, loading: false });
+          this.props.onAssigneeChange();
+        }
       })
-        .then(() => {
-          if (this.mounted) {
-            this.setState({ editing: false, loading: false });
-            this.props.onAssigneeChange();
-          }
-        })
-        .then(() =>
-          addGlobalSuccessMessage(
-            translateWithParameters('hotspots.assign.success', newAssignee.name)
-          )
+      .then(() =>
+        addGlobalSuccessMessage(
+          newAssignee.login
+            ? translateWithParameters('hotspots.assign.success', newAssignee.name)
+            : translate('hotspots.assign.unassign.success')
         )
-        .catch(() => this.setState({ loading: false }));
-    }
+      )
+      .catch(() => this.setState({ loading: false }));
   };
 
   render() {
index b9b179c0cfd8da6b2d2a852593bd144370410746..e0da8d9dcbf0ab08cf1128b3487bc1ad26dbb8d2 100644 (file)
@@ -33,7 +33,7 @@ export interface AssigneeRendererProps {
   assignee?: T.UserBase;
   loggedInUser?: T.LoggedInUser;
 
-  onAssign: (user?: T.UserActive) => void;
+  onAssign: (user: T.UserActive) => void;
   onEnterEditionMode: () => void;
   onExitEditionMode: () => void;
 }
index bb51746a7c551d38c7d817989047d708e39c7773..efb0ebe2caf52d72b1c8b57a348d3f649fe44e7c 100644 (file)
@@ -20,6 +20,7 @@
 import { debounce } from 'lodash';
 import * as React from 'react';
 import { KeyCodes } from 'sonar-ui-common/helpers/keycodes';
+import { translate } from 'sonar-ui-common/helpers/l10n';
 import { searchUsers } from '../../../../api/users';
 import { isUserActive } from '../../../../helpers/users';
 import AssigneeSelectionRenderer from './AssigneeSelectionRenderer';
@@ -27,17 +28,18 @@ import AssigneeSelectionRenderer from './AssigneeSelectionRenderer';
 interface Props {
   allowCurrentUserSelection: boolean;
   loggedInUser: T.LoggedInUser;
-  onSelect: (user?: T.UserActive) => void;
+  onSelect: (user: T.UserActive) => void;
 }
 
 interface State {
   highlighted?: T.UserActive;
   loading: boolean;
-  open: boolean;
   query?: string;
   suggestedUsers: T.UserActive[];
 }
 
+const UNASSIGNED: T.UserActive = { login: '', name: translate('unassigned') };
+
 export default class AssigneeSelection extends React.PureComponent<Props, State> {
   mounted = false;
 
@@ -46,8 +48,9 @@ export default class AssigneeSelection extends React.PureComponent<Props, State>
 
     this.state = {
       loading: false,
-      open: props.allowCurrentUserSelection,
-      suggestedUsers: props.allowCurrentUserSelection ? [props.loggedInUser] : []
+      suggestedUsers: props.allowCurrentUserSelection
+        ? [props.loggedInUser, UNASSIGNED]
+        : [UNASSIGNED]
     };
 
     this.handleSearch = debounce(this.handleSearch, 250);
@@ -76,9 +79,8 @@ export default class AssigneeSelection extends React.PureComponent<Props, State>
 
     this.setState({
       loading: false,
-      open: allowCurrentUserSelection,
       query,
-      suggestedUsers: allowCurrentUserSelection ? [loggedInUser] : []
+      suggestedUsers: allowCurrentUserSelection ? [loggedInUser, UNASSIGNED] : [UNASSIGNED]
     });
   };
 
@@ -90,8 +92,7 @@ export default class AssigneeSelection extends React.PureComponent<Props, State>
           this.setState({
             loading: false,
             query,
-            open: true,
-            suggestedUsers: result.users.filter(isUserActive) as T.UserActive[]
+            suggestedUsers: (result.users.filter(isUserActive) as T.UserActive[]).concat(UNASSIGNED)
           });
         }
       })
@@ -158,7 +159,7 @@ export default class AssigneeSelection extends React.PureComponent<Props, State>
   };
 
   render() {
-    const { highlighted, loading, open, query, suggestedUsers } = this.state;
+    const { highlighted, loading, query, suggestedUsers } = this.state;
 
     return (
       <AssigneeSelectionRenderer
@@ -167,7 +168,6 @@ export default class AssigneeSelection extends React.PureComponent<Props, State>
         onKeyDown={this.handleKeyDown}
         onSearch={this.handleSearch}
         onSelect={this.props.onSelect}
-        open={open}
         query={query}
         suggestedUsers={suggestedUsers}
       />
index 6ff3a5a40e5a2422fcd0cdd790aa94ebf69b4bcd..252c54cfedf23a73223666f228643e89e749bfc1 100644 (file)
@@ -32,14 +32,14 @@ export interface HotspotAssigneeSelectRendererProps {
   loading: boolean;
   onKeyDown: (event: React.KeyboardEvent) => void;
   onSearch: (query: string) => void;
-  onSelect: (user: T.UserActive) => void;
-  open: boolean;
+  onSelect: (user?: T.UserActive) => void;
   query?: string;
   suggestedUsers?: T.UserActive[];
 }
 
 export default function AssigneeSelectionRenderer(props: HotspotAssigneeSelectRendererProps) {
-  const { highlighted, loading, open, query, suggestedUsers } = props;
+  const { highlighted, loading, query, suggestedUsers } = props;
+
   return (
     <>
       <div className="display-flex-center">
@@ -50,35 +50,33 @@ export default function AssigneeSelectionRenderer(props: HotspotAssigneeSelectRe
           placeholder={translate('hotspots.assignee.select_user')}
           value={query}
         />
-
         {loading && <DeferredSpinner className="spacer-left" />}
       </div>
 
-      {!loading && open && (
+      {!loading && (
         <div className="position-relative">
           <DropdownOverlay noPadding={true} placement={PopupPlacement.BottomLeft}>
-            {suggestedUsers && suggestedUsers.length > 0 ? (
-              <ul className="hotspot-assignee-search-results">
-                {suggestedUsers.map(suggestion => (
+            <ul className="hotspot-assignee-search-results">
+              {suggestedUsers &&
+                suggestedUsers.map(suggestion => (
                   <li
                     className={classNames('padded', {
                       active: highlighted && highlighted.login === suggestion.login
                     })}
                     key={suggestion.login}
                     onClick={() => props.onSelect(suggestion)}>
-                    <Avatar
-                      className="spacer-right"
-                      hash={suggestion.avatar}
-                      name={suggestion.name}
-                      size={16}
-                    />
+                    {suggestion.login && (
+                      <Avatar
+                        className="spacer-right"
+                        hash={suggestion.avatar}
+                        name={suggestion.name}
+                        size={16}
+                      />
+                    )}
                     {suggestion.name}
                   </li>
                 ))}
-              </ul>
-            ) : (
-              <div className="padded">{translate('no_results')}</div>
-            )}
+            </ul>
           </DropdownOverlay>
         </div>
       )}
index 7441b83cd2a5f04b58e229c15b1c6d37417fbed2..60d34a60ec0e11f52fd71dab88283ceaab6f57d0 100644 (file)
@@ -59,10 +59,12 @@ it('should handle edition event correctly', () => {
   expect(wrapper.state().editing).toBe(false);
 });
 
-it('should handle assign event correctly', async () => {
+it.each([
+  ['assign to user', mockUser() as T.UserActive],
+  ['unassign', { login: '', name: 'unassigned' } as T.UserActive]
+])('should handle %s event', async (_, user: T.UserActive) => {
   const hotspot = mockHotspot();
   const onAssigneeChange = jest.fn();
-  const user = mockUser() as T.UserActive;
 
   const wrapper = shallowRender({ hotspot, onAssigneeChange });
 
@@ -73,7 +75,7 @@ it('should handle assign event correctly', async () => {
     .onAssign(user);
 
   expect(wrapper.state().loading).toBe(true);
-  expect(assignSecurityHotspot).toHaveBeenCalledWith(hotspot.key, { assignee: user.login });
+  expect(assignSecurityHotspot).toHaveBeenCalledWith(hotspot.key, { assignee: user?.login });
 
   await waitAndUpdate(wrapper);
 
index 27d15238e1f790fde6c2a4157210c82673747925..e4eaf7a2315fbf00569870f1ed8d618d63017c82 100644 (file)
@@ -45,7 +45,7 @@ it('should handle keydown', () => {
   const wrapper = shallowRender({ onSelect });
 
   wrapper.instance().handleKeyDown(mockEvent(KeyCodes.UpArrow) as any);
-  expect(wrapper.state().highlighted).toBeUndefined();
+  expect(wrapper.state().highlighted).toEqual({ login: '', name: 'unassigned' });
 
   wrapper.setState({ suggestedUsers });
 
@@ -77,11 +77,10 @@ it('should handle search', async () => {
   const onSelect = jest.fn();
 
   const wrapper = shallowRender({ onSelect });
-  expect(wrapper.state().suggestedUsers.length).toBe(0);
+  expect(wrapper.state().suggestedUsers.length).toBe(1);
   wrapper.instance().handleSearch('j');
 
   expect(searchUsers).not.toBeCalled();
-  expect(wrapper.state().open).toBe(false);
 
   wrapper.instance().handleSearch('jo');
   expect(wrapper.state().loading).toBe(true);
@@ -90,14 +89,13 @@ it('should handle search', async () => {
   await waitAndUpdate(wrapper);
 
   expect(wrapper.state().loading).toBe(false);
-  expect(wrapper.state().open).toBe(true);
-  expect(wrapper.state().suggestedUsers).toHaveLength(3);
+  expect(wrapper.state().suggestedUsers).toHaveLength(4);
 
   jest.clearAllMocks();
 
   wrapper.instance().handleSearch('');
   expect(searchUsers).not.toBeCalled();
-  expect(wrapper.state().suggestedUsers.length).toBe(0);
+  expect(wrapper.state().suggestedUsers.length).toBe(1);
 });
 
 it('should allow current user selection', async () => {
@@ -110,7 +108,7 @@ it('should allow current user selection', async () => {
 
   wrapper.instance().handleSearch('jo');
   await waitAndUpdate(wrapper);
-  expect(wrapper.state().suggestedUsers).toHaveLength(3);
+  expect(wrapper.state().suggestedUsers).toHaveLength(4);
 
   wrapper.instance().handleSearch('');
   expect(wrapper.state().suggestedUsers[0]).toBe(loggedInUser);
index b931e176e43f9793c92b730bf25a079fe147e657..e0694d8ef2b9d0eace126a43feffdbd333372317 100644 (file)
@@ -27,13 +27,11 @@ import AssigneeSelectionRenderer, {
 it('should render correctly', () => {
   expect(shallowRender()).toMatchSnapshot();
   expect(shallowRender({ loading: true })).toMatchSnapshot('loading');
-  expect(shallowRender({ open: true })).toMatchSnapshot('open');
 
   const highlightedUser = mockUser({ login: 'highlighted' }) as T.UserActive;
   expect(
     shallowRender({
       highlighted: highlightedUser,
-      open: true,
       suggestedUsers: [mockUser() as T.UserActive, highlightedUser]
     })
   ).toMatchSnapshot('open with results');
@@ -43,7 +41,6 @@ it('should call onSelect when clicked', () => {
   const user = mockUser() as T.UserActive;
   const onSelect = jest.fn();
   const wrapper = shallowRender({
-    open: true,
     onSelect,
     suggestedUsers: [user]
   });
@@ -63,7 +60,6 @@ function shallowRender(props?: Partial<HotspotAssigneeSelectRendererProps>) {
       onKeyDown={jest.fn()}
       onSearch={jest.fn()}
       onSelect={jest.fn()}
-      open={false}
       {...props}
     />
   );
index a0b29106d14cfa2459f542301cf25e4aecaec577..c814ca7fdce6395744b67d4b6157bc48902ef19c 100644 (file)
@@ -6,7 +6,13 @@ exports[`should render correctly 1`] = `
   onKeyDown={[Function]}
   onSearch={[Function]}
   onSelect={[MockFunction]}
-  open={false}
-  suggestedUsers={Array []}
+  suggestedUsers={
+    Array [
+      Object {
+        "login": "",
+        "name": "unassigned",
+      },
+    ]
+  }
 />
 `;
index d8731bbac9ed6884512b5e344ed439f1940fcd9f..ee9b3ab78e3eaa34bd4e0c571a8b890093122bac 100644 (file)
@@ -12,6 +12,18 @@ exports[`should render correctly 1`] = `
       placeholder="hotspots.assignee.select_user"
     />
   </div>
+  <div
+    className="position-relative"
+  >
+    <DropdownOverlay
+      noPadding={true}
+      placement="bottom-left"
+    >
+      <ul
+        className="hotspot-assignee-search-results"
+      />
+    </DropdownOverlay>
+  </div>
 </Fragment>
 `;
 
@@ -33,35 +45,6 @@ exports[`should render correctly: loading 1`] = `
 </Fragment>
 `;
 
-exports[`should render correctly: open 1`] = `
-<Fragment>
-  <div
-    className="display-flex-center"
-  >
-    <SearchBox
-      autoFocus={true}
-      onChange={[MockFunction]}
-      onKeyDown={[MockFunction]}
-      placeholder="hotspots.assignee.select_user"
-    />
-  </div>
-  <div
-    className="position-relative"
-  >
-    <DropdownOverlay
-      noPadding={true}
-      placement="bottom-left"
-    >
-      <div
-        className="padded"
-      >
-        no_results
-      </div>
-    </DropdownOverlay>
-  </div>
-</Fragment>
-`;
-
 exports[`should render correctly: open with results 1`] = `
 <Fragment>
   <div
index 34dcc490359807e04cd519d18c52ea1e52469abd..ba043f8f34433afc994fd78b4e257c4fcb02f81c 100644 (file)
@@ -150,6 +150,6 @@ export interface HotspotSetStatusRequest {
 }
 
 export interface HotspotAssignRequest {
-  assignee: string;
+  assignee?: string;
   comment?: string;
 }
index 8eaeb88200fada9d1f1b0ba565d02cc8bf7a577a..cefdf68ff61b1777b391c69556ce141247611244 100644 (file)
@@ -20,6 +20,7 @@
 package org.sonar.server.hotspot.ws;
 
 import javax.annotation.Nullable;
+import org.sonar.api.server.ws.Change;
 import org.sonar.api.server.ws.Request;
 import org.sonar.api.server.ws.Response;
 import org.sonar.api.server.ws.WebService;
@@ -35,6 +36,7 @@ import org.sonar.db.user.UserDto;
 import org.sonar.server.issue.IssueFieldsSetter;
 import org.sonar.server.issue.ws.IssueUpdater;
 
+import static com.google.common.base.Strings.isNullOrEmpty;
 import static org.sonar.api.issue.Issue.STATUS_TO_REVIEW;
 import static org.sonar.server.exceptions.NotFoundException.checkFound;
 import static org.sonar.server.exceptions.NotFoundException.checkFoundWithOptional;
@@ -66,7 +68,9 @@ public class AssignAction implements HotspotsWsAction {
       .setSince("8.2")
       .setHandler(this)
       .setInternal(true)
-      .setPost(true);
+      .setPost(true)
+      .setChangelog(
+        new Change("8.9", "Parameter 'assignee' is no longer mandatory"));
 
     action.createParam(PARAM_HOTSPOT_KEY)
       .setDescription("Hotspot key")
@@ -75,7 +79,6 @@ public class AssignAction implements HotspotsWsAction {
 
     action.createParam(PARAM_ASSIGNEE)
       .setDescription("Login of the assignee with 'Browse' project permission")
-      .setRequired(true)
       .setExampleValue("admin");
 
     action.createParam(PARAM_COMMENT)
@@ -85,7 +88,7 @@ public class AssignAction implements HotspotsWsAction {
 
   @Override
   public void handle(Request request, Response response) throws Exception {
-    String assignee = request.mandatoryParam(PARAM_ASSIGNEE);
+    String assignee = request.param(PARAM_ASSIGNEE);
     String key = request.mandatoryParam(PARAM_HOTSPOT_KEY);
     String comment = request.param(PARAM_COMMENT);
 
@@ -101,7 +104,7 @@ public class AssignAction implements HotspotsWsAction {
 
       checkIfHotspotToReview(hotspotDto);
       hotspotWsSupport.loadAndCheckProject(dbSession, hotspotDto, UserRole.USER);
-      UserDto assignee = getAssignee(dbSession, login);
+      UserDto assignee = isNullOrEmpty(login) ? null :getAssignee(dbSession, login);
 
       IssueChangeContext context = hotspotWsSupport.newIssueChangeContext();
 
@@ -111,7 +114,9 @@ public class AssignAction implements HotspotsWsAction {
         issueFieldsSetter.addComment(defaultIssue, comment, context);
       }
 
-      checkAssigneeProjectPermission(dbSession, assignee, hotspotDto.getProjectUuid());
+      if (assignee != null) {
+        checkAssigneeProjectPermission(dbSession, assignee, hotspotDto.getProjectUuid());
+      }
 
       if (issueFieldsSetter.assign(defaultIssue, assignee, context)) {
         issueUpdater.saveIssueAndPreloadSearchResponseData(dbSession, defaultIssue, context, false);
index 0f749d11e0ba61ba738ed5dbd1e430ebcfaf7ee4..a952e555bb6997ef4112506c08541cba80eb96da 100644 (file)
@@ -34,6 +34,7 @@ import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.mockito.ArgumentCaptor;
 import org.sonar.api.rules.RuleType;
+import org.sonar.api.server.ws.Change;
 import org.sonar.api.server.ws.WebService;
 import org.sonar.api.utils.System2;
 import org.sonar.api.web.UserRole;
@@ -58,9 +59,11 @@ import org.sonar.server.ws.WsActionTester;
 import static org.apache.commons.lang.RandomStringUtils.randomAlphanumeric;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.tuple;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.argThat;
 import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoMoreInteractions;
@@ -100,11 +103,14 @@ public class AssignActionTest {
     assertThat(hotspotParam.isRequired()).isTrue();
     WebService.Param assigneeParam = wsDefinition.param("assignee");
     assertThat(assigneeParam).isNotNull();
-    assertThat(assigneeParam.isRequired()).isTrue();
+    assertThat(assigneeParam.isRequired()).isFalse();
     WebService.Param commentParam = wsDefinition.param("comment");
     assertThat(commentParam).isNotNull();
     assertThat(commentParam.isRequired()).isFalse();
     assertThat(wsDefinition.since()).isEqualTo("8.2");
+    assertThat(wsDefinition.changelog())
+      .extracting(Change::getVersion, Change::getDescription)
+      .contains(tuple("8.9", "Parameter 'assignee' is no longer mandatory"));
   }
 
   @Test
@@ -124,6 +130,23 @@ public class AssignActionTest {
     verifyFieldSetters(assignee, null);
   }
 
+  @Test
+  public void unassign_hotspot_for_public_project() {
+    ComponentDto project = dbTester.components().insertPublicProject();
+    ComponentDto file = dbTester.components().insertComponent(newFileDto(project));
+    UserDto assignee = insertUser(randomAlphanumeric(15));
+
+    IssueDto hotspot = dbTester.issues().insertHotspot(project, file, h -> h.setAssigneeUuid(assignee.getUuid()));
+
+    UserDto userDto = insertUser(randomAlphanumeric(10));
+    userSessionRule.logIn(userDto).registerComponents(project);
+    when(issueFieldsSetter.assign(eq(hotspot.toDefaultIssue()), isNull(), any(IssueChangeContext.class))).thenReturn(true);
+
+    executeRequest(hotspot, null, null);
+
+    verifyFieldSetters(null, null);
+  }
+
   @Test
   public void assign_hotspot_to_me_for_public_project() {
     ComponentDto project = dbTester.components().insertPublicProject();
@@ -140,6 +163,21 @@ public class AssignActionTest {
     verifyFieldSetters(me, null);
   }
 
+  @Test
+  public void unassign_hotspot_myself_for_public_project() {
+    ComponentDto project = dbTester.components().insertPublicProject();
+    ComponentDto file = dbTester.components().insertComponent(newFileDto(project));
+    UserDto me = insertUser(randomAlphanumeric(10));
+    userSessionRule.logIn(me).registerComponents(project);
+    IssueDto hotspot = dbTester.issues().insertHotspot(project, file, h -> h.setAssigneeUuid(me.getUuid()));
+
+    when(issueFieldsSetter.assign(eq(hotspot.toDefaultIssue()), isNull(), any(IssueChangeContext.class))).thenReturn(true);
+
+    executeRequest(hotspot, null, null);
+
+    verifyFieldSetters(null, null);
+  }
+
   @Test
   public void assign_hotspot_to_someone_for_private_project() {
     ComponentDto project = dbTester.components().insertPrivateProject();
@@ -156,6 +194,22 @@ public class AssignActionTest {
     verifyFieldSetters(assignee, null);
   }
 
+  @Test
+  public void unassign_hotspot_for_private_project() {
+    ComponentDto project = dbTester.components().insertPrivateProject();
+    ComponentDto file = dbTester.components().insertComponent(newFileDto(project));
+    UserDto assignee = insertUser(randomAlphanumeric(15));
+    IssueDto hotspot = dbTester.issues().insertHotspot(project, file, h -> h.setAssigneeUuid(assignee.getUuid()));
+
+    insertAndLoginAsUserWithProjectUserPermission(randomAlphanumeric(10), project, UserRole.USER);
+
+    when(issueFieldsSetter.assign(eq(hotspot.toDefaultIssue()), isNull(), any(IssueChangeContext.class))).thenReturn(true);
+
+    executeRequest(hotspot, null, null);
+
+    verifyFieldSetters(null, null);
+  }
+
   @Test
   public void assign_hotspot_to_someone_for_private_project_branch() {
     ComponentDto project = dbTester.components().insertPrivateProject();
@@ -494,8 +548,12 @@ public class AssignActionTest {
   }
 
   private static UserDto userMatcher(UserDto user) {
-    return argThat(argument -> argument.getLogin().equals(user.getLogin()) &&
-      argument.getUuid().equals(user.getUuid()));
+    if (user == null) {
+      return isNull();
+    } else {
+      return argThat(argument -> argument.getLogin().equals(user.getLogin()) &&
+        argument.getUuid().equals(user.getUuid()));
+    }
   }
 
 }
index fd904c2248330b15db5df36636affae3f240b3a5..8c0bf019fd204261b775d58e9d214d6afb68a0a0 100644 (file)
@@ -754,6 +754,7 @@ hotspots.reviewed.tooltip=Percentage of Security Hotspots reviewed (fixed or saf
 hotspots.review_hotspot=Review Hotspot
 
 hotspots.assign.success=Security Hotspot was successfully assigned to {0}
+hotspots.assign.unassign.success=Security Hotspot was successfully unassigned
 hotspots.update.success=Security Hotspot status was successfully changed to {0}
 
 #------------------------------------------------------------------------------