]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-15918 managing the connections of SonarLint push endpoint
authorlukasz-jarocki-sonarsource <77498856+lukasz-jarocki-sonarsource@users.noreply.github.com>
Wed, 2 Feb 2022 14:31:44 +0000 (15:31 +0100)
committersonartech <sonartech@sonarsource.com>
Fri, 18 Feb 2022 15:48:03 +0000 (15:48 +0000)
server/sonar-webserver-pushapi/src/main/java/org/sonar/server/pushapi/HeartbeatTask.java [new file with mode: 0644]
server/sonar-webserver-pushapi/src/main/java/org/sonar/server/pushapi/ServerPushClient.java [new file with mode: 0644]
server/sonar-webserver-pushapi/src/main/java/org/sonar/server/pushapi/ServerPushWsModule.java
server/sonar-webserver-pushapi/src/main/java/org/sonar/server/pushapi/sonarlint/SonarLintClient.java [new file with mode: 0644]
server/sonar-webserver-pushapi/src/main/java/org/sonar/server/pushapi/sonarlint/SonarLintClientsRegistry.java [new file with mode: 0644]
server/sonar-webserver-pushapi/src/main/java/org/sonar/server/pushapi/sonarlint/SonarLintPushAction.java
server/sonar-webserver-pushapi/src/test/java/org/sonar/server/pushapi/HeartbeatTaskTest.java [new file with mode: 0644]
server/sonar-webserver-pushapi/src/test/java/org/sonar/server/pushapi/ServerPushClientTest.java [new file with mode: 0644]
server/sonar-webserver-pushapi/src/test/java/org/sonar/server/pushapi/sonarlint/SonarLintClientTest.java [new file with mode: 0644]
server/sonar-webserver-pushapi/src/test/java/org/sonar/server/pushapi/sonarlint/SonarLintClientsRegistryTest.java [new file with mode: 0644]
server/sonar-webserver-pushapi/src/test/java/org/sonar/server/pushapi/sonarlint/SonarLintPushActionTest.java

diff --git a/server/sonar-webserver-pushapi/src/main/java/org/sonar/server/pushapi/HeartbeatTask.java b/server/sonar-webserver-pushapi/src/main/java/org/sonar/server/pushapi/HeartbeatTask.java
new file mode 100644 (file)
index 0000000..5bfd8ad
--- /dev/null
@@ -0,0 +1,38 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser 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
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.server.pushapi;
+
+class HeartbeatTask implements Runnable {
+
+  private final ServerPushClient serverPushClient;
+
+  public HeartbeatTask(ServerPushClient serverPushClient) {
+    this.serverPushClient = serverPushClient;
+  }
+
+  @Override
+  public void run() {
+    synchronized (this) {
+      serverPushClient.writeAndFlush('\r');
+      serverPushClient.writeAndFlush('\n');
+    }
+    serverPushClient.scheduleHeartbeat();
+  }
+}
diff --git a/server/sonar-webserver-pushapi/src/main/java/org/sonar/server/pushapi/ServerPushClient.java b/server/sonar-webserver-pushapi/src/main/java/org/sonar/server/pushapi/ServerPushClient.java
new file mode 100644 (file)
index 0000000..5f43331
--- /dev/null
@@ -0,0 +1,93 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser 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
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.server.pushapi;
+
+import java.io.IOException;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import javax.servlet.AsyncContext;
+import javax.servlet.AsyncListener;
+import javax.servlet.ServletOutputStream;
+import org.sonar.api.utils.log.Logger;
+import org.sonar.api.utils.log.Loggers;
+
+public abstract class ServerPushClient {
+
+  private static final Logger LOG = Loggers.get(ServerPushClient.class);
+  private static final int DEFAULT_HEARTBEAT_PERIOD = 60;
+
+  protected final AsyncContext asyncContext;
+
+  private final ScheduledExecutorService executorService;
+  private final HeartbeatTask heartbeatTask;
+  private ScheduledFuture<?> startedHeartbeat;
+
+  protected ServerPushClient(ScheduledExecutorService executorService, AsyncContext asyncContext) {
+    this.executorService = executorService;
+    this.asyncContext = asyncContext;
+    this.heartbeatTask = new HeartbeatTask(this);
+  }
+
+  public void scheduleHeartbeat() {
+    startedHeartbeat = executorService.schedule(heartbeatTask, DEFAULT_HEARTBEAT_PERIOD, TimeUnit.SECONDS);
+  }
+
+  public void writeAndFlush(char character) {
+    write(character);
+    flush();
+  }
+
+  public synchronized void write(char character) {
+    try {
+      output().write(character);
+    } catch (IOException e) {
+      handleIOException(e);
+    }
+  }
+
+  public synchronized void flush() {
+    try {
+      output().flush();
+    } catch (IOException e) {
+      handleIOException(e);
+    }
+  }
+
+  private void handleIOException(IOException e) {
+    String remoteAddr = asyncContext.getRequest().getRemoteAddr();
+    LOG.info(String.format("The server push client %s gone without notice, closing the connection (%s)", remoteAddr, e.getMessage()));
+    close();
+  }
+
+  private synchronized void close() {
+    startedHeartbeat.cancel(false);
+    asyncContext.complete();
+  }
+
+  private ServletOutputStream output() throws IOException {
+    return asyncContext.getResponse().getOutputStream();
+  }
+
+  public void addListener(AsyncListener asyncListener) {
+    asyncContext.addListener(asyncListener);
+  }
+
+}
index f82da6a534d5587314ecb63eb666f8b60165a5c4..5aff7050e36d36e20ff576406d5462c7d534a7bc 100644 (file)
@@ -20,6 +20,7 @@
 package org.sonar.server.pushapi;
 
 import org.sonar.core.platform.Module;
+import org.sonar.server.pushapi.sonarlint.SonarLintClientsRegistry;
 import org.sonar.server.pushapi.sonarlint.SonarLintPushAction;
 
 public class ServerPushWsModule extends Module {
@@ -29,6 +30,7 @@ public class ServerPushWsModule extends Module {
     add(
       ServerPushWs.class,
 
+      SonarLintClientsRegistry.class,
       SonarLintPushAction.class);
   }
 }
diff --git a/server/sonar-webserver-pushapi/src/main/java/org/sonar/server/pushapi/sonarlint/SonarLintClient.java b/server/sonar-webserver-pushapi/src/main/java/org/sonar/server/pushapi/sonarlint/SonarLintClient.java
new file mode 100644 (file)
index 0000000..62d1372
--- /dev/null
@@ -0,0 +1,60 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser 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
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.server.pushapi.sonarlint;
+
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import javax.servlet.AsyncContext;
+import org.sonar.server.pushapi.ServerPushClient;
+
+public class SonarLintClient extends ServerPushClient {
+
+  private static final ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor();
+
+  private final Set<String> languages;
+  private final Set<String> projectKeys;
+
+  public SonarLintClient(AsyncContext asyncContext, Set<String> projectKeys, Set<String> languages) {
+    super(scheduledExecutorService, asyncContext);
+    this.projectKeys = projectKeys;
+    this.languages = languages;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (o == null || getClass() != o.getClass()) {
+      return false;
+    }
+    SonarLintClient that = (SonarLintClient) o;
+    return languages.equals(that.languages)
+      && projectKeys.equals(that.projectKeys)
+      && asyncContext.equals(that.asyncContext);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(languages, projectKeys);
+  }
+}
diff --git a/server/sonar-webserver-pushapi/src/main/java/org/sonar/server/pushapi/sonarlint/SonarLintClientsRegistry.java b/server/sonar-webserver-pushapi/src/main/java/org/sonar/server/pushapi/sonarlint/SonarLintClientsRegistry.java
new file mode 100644 (file)
index 0000000..44d9fa4
--- /dev/null
@@ -0,0 +1,83 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser 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
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.server.pushapi.sonarlint;
+
+import com.google.common.annotations.VisibleForTesting;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import javax.servlet.AsyncEvent;
+import javax.servlet.AsyncListener;
+import org.sonar.api.server.ServerSide;
+import org.sonar.api.utils.log.Logger;
+import org.sonar.api.utils.log.Loggers;
+
+@ServerSide
+public class SonarLintClientsRegistry {
+
+  private static final Logger LOG = Loggers.get(SonarLintClientsRegistry.class);
+
+  private final List<SonarLintClient> clients = new CopyOnWriteArrayList<>();
+
+  public void registerClient(SonarLintClient sonarLintClient) {
+    clients.add(sonarLintClient);
+    sonarLintClient.scheduleHeartbeat();
+    sonarLintClient.addListener(new SonarLintClientEventsListener(sonarLintClient));
+    LOG.debug("Registering new SonarLint client");
+  }
+
+  public void unregisterClient(SonarLintClient client) {
+    clients.remove(client);
+    LOG.debug("Removing SonarLint client");
+  }
+
+  @VisibleForTesting
+  List<SonarLintClient> getAllClients() {
+    return clients;
+  }
+
+  class SonarLintClientEventsListener implements AsyncListener {
+    private final SonarLintClient client;
+
+    public SonarLintClientEventsListener(SonarLintClient sonarLintClient) {
+      this.client = sonarLintClient;
+    }
+
+    @Override
+    public void onComplete(AsyncEvent event) {
+      unregisterClient(client);
+    }
+
+    @Override
+    public void onError(AsyncEvent event) {
+      unregisterClient(client);
+    }
+
+    @Override
+    public void onStartAsync(AsyncEvent event) {
+      //nothing to do on start
+    }
+
+    @Override
+    public void onTimeout(AsyncEvent event) {
+      unregisterClient(client);
+    }
+  }
+
+}
index eb72c1c4e81df4b3f6df05a6fd26903fce780500..72419be534f5ab4c363c213e2cb7ec03acb0bc4e 100644 (file)
 package org.sonar.server.pushapi.sonarlint;
 
 import java.io.IOException;
-import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Set;
 import javax.servlet.AsyncContext;
 import javax.servlet.http.HttpServletResponse;
 import org.sonar.api.server.ws.Request;
 import org.sonar.api.server.ws.Response;
 import org.sonar.api.server.ws.WebService;
-import org.sonar.api.utils.log.Logger;
-import org.sonar.api.utils.log.Loggers;
+import org.sonar.api.web.UserRole;
+import org.sonar.db.DbClient;
+import org.sonar.db.DbSession;
+import org.sonar.db.project.ProjectDto;
 import org.sonar.server.pushapi.ServerPushAction;
+import org.sonar.server.user.UserSession;
 import org.sonar.server.ws.ServletRequest;
 import org.sonar.server.ws.ServletResponse;
 
 public class SonarLintPushAction extends ServerPushAction {
 
-  private static final Logger LOGGER = Loggers.get(SonarLintPushAction.class);
-
   private static final String PROJECT_PARAM_KEY = "projectKeys";
   private static final String LANGUAGE_PARAM_KEY = "languages";
+  private final SonarLintClientsRegistry clientsRegistry;
+  private final UserSession userSession;
+  private final DbClient dbClient;
+
+  public SonarLintPushAction(SonarLintClientsRegistry sonarLintClientRegistry, UserSession userSession, DbClient dbClient) {
+    this.clientsRegistry = sonarLintClientRegistry;
+    this.userSession = userSession;
+    this.dbClient = dbClient;
+  }
 
   @Override
   public void define(WebService.NewController controller) {
@@ -63,18 +74,13 @@ public class SonarLintPushAction extends ServerPushAction {
 
   @Override
   public void handle(Request request, Response response) throws IOException {
+    userSession.checkLoggedIn();
+
     ServletRequest servletRequest = (ServletRequest) request;
     ServletResponse servletResponse = (ServletResponse) response;
 
-    String projectKeys = request.getParam(PROJECT_PARAM_KEY).getValue();
-    String languages = request.getParam(LANGUAGE_PARAM_KEY).getValue();
-
-    // to remove later
-    LOGGER.debug(projectKeys != null ? projectKeys : "");
-    LOGGER.debug(languages != null ? languages : "");
-
-    AsyncContext asyncContext = servletRequest.startAsync();
-    asyncContext.setTimeout(0);
+    var params = new SonarLintPushActionParamsValidator(request);
+    params.validateProjectsPermissions();
 
     if (!isServerSideEventsRequest(servletRequest)) {
       servletResponse.stream().setStatus(HttpServletResponse.SC_NOT_ACCEPTABLE);
@@ -83,8 +89,57 @@ public class SonarLintPushAction extends ServerPushAction {
 
     setHeadersForResponse(servletResponse);
 
-    //test response to remove later
-    response.stream().output().write("Hello world".getBytes(StandardCharsets.UTF_8));
-    response.stream().output().flush();
+    AsyncContext asyncContext = servletRequest.startAsync();
+    asyncContext.setTimeout(0);
+
+    var sonarLintClient = new SonarLintClient(asyncContext, params.getProjectKeys(), params.getLanguages());
+
+    clientsRegistry.registerClient(sonarLintClient);
   }
+
+  class SonarLintPushActionParamsValidator {
+
+    private final Request request;
+    private final Set<String> projectKeys;
+    private final Set<String> languages;
+
+    private List<ProjectDto> projectDtos;
+
+    SonarLintPushActionParamsValidator(Request request) {
+      this.request = request;
+      this.projectKeys = parseParam(PROJECT_PARAM_KEY);
+      this.languages = parseParam(LANGUAGE_PARAM_KEY);
+    }
+
+    Set<String> getProjectKeys() {
+      return projectKeys;
+    }
+
+    Set<String> getLanguages() {
+      return languages;
+    }
+
+    private Set<String> parseParam(String paramKey) {
+      String paramProjectKeys = request.getParam(paramKey).getValue();
+      if (paramProjectKeys == null) {
+        throw new IllegalArgumentException("Param " + paramKey + " was not provided.");
+      }
+      return Set.of(paramProjectKeys.trim().split(","));
+    }
+
+    public void validateProjectsPermissions() {
+      if (projectDtos == null) {
+        try (DbSession dbSession = dbClient.openSession(false)) {
+          projectDtos = dbClient.projectDao().selectProjectsByKeys(dbSession, projectKeys);
+        }
+      }
+      if (projectDtos.size() < projectKeys.size() || projectDtos.isEmpty()) {
+        throw new IllegalArgumentException("Param " + PROJECT_PARAM_KEY + " is invalid.");
+      }
+      for (ProjectDto projectDto : projectDtos) {
+        userSession.checkProjectPermission(UserRole.USER, projectDto);
+      }
+    }
+  }
+
 }
diff --git a/server/sonar-webserver-pushapi/src/test/java/org/sonar/server/pushapi/HeartbeatTaskTest.java b/server/sonar-webserver-pushapi/src/test/java/org/sonar/server/pushapi/HeartbeatTaskTest.java
new file mode 100644 (file)
index 0000000..05154b3
--- /dev/null
@@ -0,0 +1,42 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser 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
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.server.pushapi;
+
+import org.junit.Test;
+
+import static org.mockito.ArgumentMatchers.anyChar;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+public class HeartbeatTaskTest {
+
+  private final ServerPushClient serverPushClient = mock(ServerPushClient.class);
+
+  private final HeartbeatTask underTest = new HeartbeatTask(serverPushClient);
+
+  @Test
+  public void run_reSchedulesHeartBeat() {
+    underTest.run();
+
+    verify(serverPushClient, times(2)).writeAndFlush(anyChar());
+    verify(serverPushClient).scheduleHeartbeat();
+  }
+}
diff --git a/server/sonar-webserver-pushapi/src/test/java/org/sonar/server/pushapi/ServerPushClientTest.java b/server/sonar-webserver-pushapi/src/test/java/org/sonar/server/pushapi/ServerPushClientTest.java
new file mode 100644 (file)
index 0000000..ec1c440
--- /dev/null
@@ -0,0 +1,124 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser 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
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.server.pushapi;
+
+import java.io.IOException;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import javax.servlet.AsyncContext;
+import javax.servlet.AsyncListener;
+import javax.servlet.ServletOutputStream;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mockito;
+
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class ServerPushClientTest {
+
+  private final ScheduledExecutorService executorService = mock(ScheduledExecutorService.class);
+  private final AsyncContext asyncContext = mock(AsyncContext.class);
+
+  private final ServerPushClient underTest = new ServerPushClient(executorService, asyncContext) {};
+
+  private final ServletOutputStream outputStream = mock(ServletOutputStream.class);
+  private ServletResponse servletResponse;
+
+  @Before
+  public void before() throws IOException {
+    servletResponse = mock(ServletResponse.class);
+
+    when(servletResponse.getOutputStream()).thenReturn(outputStream);
+    when(asyncContext.getResponse()).thenReturn(servletResponse);
+    when(asyncContext.getRequest()).thenReturn(mock(ServletRequest.class));
+  }
+
+  @Test
+  public void scheduleHeartbeat_oneTaskIsScheduled() {
+    underTest.scheduleHeartbeat();
+
+    verify(executorService, Mockito.times(1))
+      .schedule(any(HeartbeatTask.class), anyLong(), any());
+  }
+
+  @Test
+  public void writeAndFlush_writeIsCalledOnceAndFlushIsCalledOnce() throws IOException {
+    underTest.writeAndFlush('a');
+
+    verify(outputStream, Mockito.times(1)).flush();
+    verify(outputStream, Mockito.times(1)).write('a');
+  }
+
+  @Test
+  public void write_writeIsCalledOnceAndDoesntFlush() throws IOException {
+    underTest.write('a');
+
+    verify(outputStream, Mockito.never()).flush();
+    verify(outputStream, Mockito.times(1)).write('a');
+  }
+
+  @Test
+  public void flush_streamIsFlushed() throws IOException {
+    underTest.flush();
+
+    verify(outputStream, Mockito.only()).flush();
+  }
+
+  @Test
+  public void addListener_addsListener() {
+    AsyncListener mock = mock(AsyncListener.class);
+
+    underTest.addListener(mock);
+
+    verify(asyncContext).addListener(mock);
+  }
+
+  @Test
+  public void write_exceptionCausesConnectionToClose() throws IOException {
+    when(servletResponse.getOutputStream()).thenThrow(new IOException("mock exception"));
+    ScheduledFuture task = mock(ScheduledFuture.class);
+    when(executorService.schedule(any(HeartbeatTask.class), anyLong(), any(TimeUnit.class))).thenReturn(task);
+    underTest.scheduleHeartbeat();
+
+    underTest.write('a');
+
+    verify(asyncContext).complete();
+  }
+
+  @Test
+  public void flush_exceptionCausesConnectionToClose() throws IOException {
+    when(servletResponse.getOutputStream()).thenThrow(new IOException("mock exception"));
+    ScheduledFuture task = mock(ScheduledFuture.class);
+    when(executorService.schedule(any(HeartbeatTask.class), anyLong(), any(TimeUnit.class))).thenReturn(task);
+    underTest.scheduleHeartbeat();
+
+    underTest.flush();
+
+    verify(asyncContext).complete();
+  }
+}
diff --git a/server/sonar-webserver-pushapi/src/test/java/org/sonar/server/pushapi/sonarlint/SonarLintClientTest.java b/server/sonar-webserver-pushapi/src/test/java/org/sonar/server/pushapi/sonarlint/SonarLintClientTest.java
new file mode 100644 (file)
index 0000000..5fffbd1
--- /dev/null
@@ -0,0 +1,80 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser 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
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.server.pushapi.sonarlint;
+
+import java.util.Set;
+import javax.servlet.AsyncContext;
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+
+public class SonarLintClientTest {
+
+  private final AsyncContext firstContext = mock(AsyncContext.class);
+  private final AsyncContext secondContext = mock(AsyncContext.class);
+
+  @Test
+  public void equals_twoClientsWithSameArgumentsAreEqual() {
+    SonarLintClient first = new SonarLintClient(firstContext, Set.of(), Set.of());
+    SonarLintClient second = new SonarLintClient(firstContext, Set.of(), Set.of());
+
+    assertThat(first).isEqualTo(second);
+  }
+
+  @Test
+  public void equals_twoClientsWithDifferentAsyncObjects() {
+    SonarLintClient first = new SonarLintClient(firstContext, Set.of(), Set.of());
+    SonarLintClient second = new SonarLintClient(secondContext, Set.of(), Set.of());
+
+    assertThat(first).isNotEqualTo(second);
+  }
+
+  @Test
+  public void equals_twoClientsWithDifferentLanguages() {
+    SonarLintClient first = new SonarLintClient(firstContext, Set.of(), Set.of("java"));
+    SonarLintClient second = new SonarLintClient(firstContext, Set.of(), Set.of("cobol"));
+
+    assertThat(first).isNotEqualTo(second);
+  }
+
+  @Test
+  public void equals_twoClientsWithDifferentProjectKeys() {
+    SonarLintClient first = new SonarLintClient(firstContext, Set.of("project1", "project2"), Set.of());
+    SonarLintClient second = new SonarLintClient(firstContext, Set.of("project1"), Set.of());
+
+    assertThat(first).isNotEqualTo(second);
+  }
+
+  @Test
+  public void equals_secondClientIsNull() {
+    SonarLintClient first = new SonarLintClient(firstContext, Set.of("project1", "project2"), Set.of());
+
+    assertThat(first).isNotEqualTo(null);
+  }
+
+  @Test
+  public void hashCode_producesSameHashesForEqualObjects() {
+    SonarLintClient first = new SonarLintClient(firstContext, Set.of(), Set.of());
+    SonarLintClient second = new SonarLintClient(firstContext, Set.of(), Set.of());
+
+    assertThat(first).hasSameHashCodeAs(second);
+  }
+}
diff --git a/server/sonar-webserver-pushapi/src/test/java/org/sonar/server/pushapi/sonarlint/SonarLintClientsRegistryTest.java b/server/sonar-webserver-pushapi/src/test/java/org/sonar/server/pushapi/sonarlint/SonarLintClientsRegistryTest.java
new file mode 100644 (file)
index 0000000..f6f0ccd
--- /dev/null
@@ -0,0 +1,69 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser 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
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.server.pushapi.sonarlint;
+
+import java.util.Set;
+import javax.servlet.AsyncContext;
+import org.junit.Before;
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+
+public class SonarLintClientsRegistryTest {
+
+  private final AsyncContext defaultAsyncContext = mock(AsyncContext.class);
+
+  private final Set<String> exampleKeys = Set.of("project1", "project2", "project3");
+
+  private final Set<String> languageKeys = Set.of("language1", "language2", "language3");
+
+  private SonarLintClientsRegistry underTest;
+
+  @Before
+  public void before() {
+    underTest = new SonarLintClientsRegistry();
+  }
+
+  @Test
+  public void registerClientAndUnregister_changesNumberOfClients() {
+    SonarLintClient sonarLintClient = new SonarLintClient(defaultAsyncContext, exampleKeys, languageKeys);
+
+    underTest.registerClient(sonarLintClient);
+
+    assertThat(underTest.getAllClients()).hasSize(1);
+
+    underTest.unregisterClient(sonarLintClient);
+
+    assertThat(underTest.getAllClients()).isEmpty();
+  }
+
+  @Test
+  public void registering10Clients_10ClientsAreRegistered() {
+    for (int i = 0; i < 10; i++) {
+      AsyncContext newAsyncContext = mock(AsyncContext.class);
+      SonarLintClient sonarLintClient = new SonarLintClient(newAsyncContext, exampleKeys, languageKeys);
+      underTest.registerClient(sonarLintClient);
+    }
+
+    assertThat(underTest.getAllClients()).hasSize(10);
+  }
+
+}
index f6e9f5c7187ca99273dc953a8c83b1c69c2e60e8..624b4173fd90bb39662d341890318fbca2198cdd 100644 (file)
  */
 package org.sonar.server.pushapi.sonarlint;
 
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import org.junit.Before;
 import org.junit.Test;
 import org.sonar.api.server.ws.WebService;
+import org.sonar.db.DbClient;
+import org.sonar.db.project.ProjectDao;
+import org.sonar.db.project.ProjectDto;
 import org.sonar.server.pushapi.TestPushRequest;
 import org.sonar.server.pushapi.WsPushActionTester;
+import org.sonar.server.user.UserSession;
 import org.sonar.server.ws.TestResponse;
 
 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.Mockito.mock;
+import static org.mockito.Mockito.when;
 
 public class SonarLintPushActionTest {
 
-  private final WsPushActionTester ws = new WsPushActionTester(new SonarLintPushAction());
+  private final SonarLintClientsRegistry registry = mock(SonarLintClientsRegistry.class);
+  private final UserSession userSession = mock(UserSession.class);
+  private final DbClient dbClient = mock(DbClient.class);
+  private final ProjectDao projectDao = mock(ProjectDao.class);
+
+  private final WsPushActionTester ws = new WsPushActionTester(new SonarLintPushAction(registry, userSession, dbClient));
+
+  @Before
+  public void before() {
+    List<ProjectDto> projectDtos = generateProjectDtos(2);
+    when(projectDao.selectProjectsByKeys(any(), any())).thenReturn(projectDtos);
+    when(dbClient.projectDao()).thenReturn(projectDao);
+  }
+
+  public List<ProjectDto> generateProjectDtos(int howMany) {
+    return IntStream.rangeClosed(1, howMany).mapToObj(i -> {
+      ProjectDto dto = new ProjectDto();
+      dto.setKee("project" + i);
+      return dto;
+    }).collect(Collectors.toList());
+  }
 
   @Test
   public void defineTest() {
@@ -52,13 +83,12 @@ public class SonarLintPushActionTest {
       .setHeader("accept", "text/event-stream")
       .execute();
 
-    assertThat(response.getInput()).isEqualTo("Hello world");
+    assertThat(response.getInput()).isEmpty();
   }
 
   @Test
   public void handle_whenAcceptHeaderNotProvided_statusCode406() {
-    TestResponse testResponse = ws.newPushRequest().
-       setParam("projectKeys", "project1,project2")
+    TestResponse testResponse = ws.newPushRequest().setParam("projectKeys", "project1,project2")
       .setParam("languages", "java")
       .execute();
 
@@ -67,11 +97,24 @@ public class SonarLintPushActionTest {
 
   @Test
   public void handle_whenParamsNotProvided_throwException() {
-    TestPushRequest testRequest =  ws.newPushRequest()
+    TestPushRequest testRequest = ws.newPushRequest()
       .setHeader("accept", "text/event-stream");
 
     assertThatThrownBy(testRequest::execute)
       .isInstanceOf(IllegalArgumentException.class)
       .hasMessage("The 'projectKeys' parameter is missing");
   }
+
+  @Test
+  public void handle_whenParamProjectKeyNotValid_throwException() {
+    TestPushRequest testRequest = ws.newPushRequest()
+      .setParam("projectKeys", "not-valid-key")
+      .setParam("languages", "java")
+      .setHeader("accept", "text/event-stream");
+    when(projectDao.selectProjectsByKeys(any(), any())).thenReturn(List.of());
+
+    assertThatThrownBy(testRequest::execute)
+      .isInstanceOf(IllegalArgumentException.class)
+      .hasMessage("Param projectKeys is invalid.");
+  }
 }