diff --git a/.kokoro/tests/run_tests.sh b/.kokoro/tests/run_tests.sh index c1b9336427d..24f8a6d1335 100755 --- a/.kokoro/tests/run_tests.sh +++ b/.kokoro/tests/run_tests.sh @@ -65,12 +65,14 @@ if [[ "$SCRIPT_DEBUG" != "true" ]]; then source "${KOKORO_GFILE_DIR}/aws-secrets.sh" # shellcheck source=src/storage-hmac-credentials.sh source "${KOKORO_GFILE_DIR}/storage-hmac-credentials.sh" - # shellcheck source=src/dlp_secrets.sh + # shellcheck source=src/dlp_secrets.txt source "${KOKORO_GFILE_DIR}/dlp_secrets.txt" - # shellcheck source=src/bigtable_secrets.sh + # shellcheck source=src/bigtable_secrets.txt source "${KOKORO_GFILE_DIR}/bigtable_secrets.txt" - # shellcheck source=src/automl_secrets.sh + # shellcheck source=src/automl_secrets.txt source "${KOKORO_GFILE_DIR}/automl_secrets.txt" + # shellcheck source=src/functions_secrets.txt + source "${KOKORO_GFILE_DIR}/functions_secrets.txt" # Activate service account gcloud auth activate-service-account \ --key-file="$GOOGLE_APPLICATION_CREDENTIALS" \ diff --git a/functions/snippets/.gcloudignore b/functions/snippets/.gcloudignore deleted file mode 100644 index 0ac51e7dd4e..00000000000 --- a/functions/snippets/.gcloudignore +++ /dev/null @@ -1,8 +0,0 @@ -# Don't run tests when deploying a function using gcloud -# Comment out (with a leading '#') or remove the line below -# if you want to run tests upon deployment with 'gcloud' -src/test/ - -# If you want your code to be automatically tested and -# deployed to Cloud Functions, we recommend using -# a continuous integration pipeline to enable this. \ No newline at end of file diff --git a/functions/snippets/pom.xml b/functions/snippets/pom.xml index d9097416e54..42f8895c54c 100644 --- a/functions/snippets/pom.xml +++ b/functions/snippets/pom.xml @@ -26,20 +26,32 @@ - + com.google.code.gson gson 2.8.6 - + com.google.cloud google-cloud-logging 1.100.0 + + + com.google.apis + google-api-services-kgsearch + v1-rev253-1.25.0 + + + com.github.seratch + jslack + 3.4.1 + + junit @@ -102,10 +114,36 @@ - + + + + + + + + + env.NEW_BUILD + + + + true + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M3 + + ${skipTests} + sponge_log + false + + + org.apache.maven.plugins maven-compiler-plugin diff --git a/functions/snippets/src/main/java/com/example/functions/SlackSlashCommand.java b/functions/snippets/src/main/java/com/example/functions/SlackSlashCommand.java new file mode 100644 index 00000000000..e4991da499c --- /dev/null +++ b/functions/snippets/src/main/java/com/example/functions/SlackSlashCommand.java @@ -0,0 +1,163 @@ +/* + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.functions; + +import com.github.seratch.jslack.app_backend.SlackSignature; +import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport; +import com.google.api.client.json.jackson2.JacksonFactory; +import com.google.api.services.kgsearch.v1.Kgsearch; +import com.google.cloud.functions.HttpFunction; +import com.google.cloud.functions.HttpRequest; +import com.google.cloud.functions.HttpResponse; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import java.io.BufferedWriter; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.security.GeneralSecurityException; +import java.util.HashMap; +import java.util.List; +import java.util.logging.Logger; +import java.util.stream.Collectors; + +public class SlackSlashCommand implements HttpFunction { + + private Kgsearch kgClient; + private static final String API_KEY = System.getenv("KG_API_KEY"); + private static final String SLACK_SECRET = System.getenv("SLACK_SECRET"); + private static final Logger LOGGER = Logger.getLogger(SlackSlashCommand.class.getName()); + private SlackSignature.Verifier verifier; + private Gson gson = new Gson(); + + public SlackSlashCommand() throws IOException, GeneralSecurityException { + kgClient = new Kgsearch.Builder( + GoogleNetHttpTransport.newTrustedTransport(), new JacksonFactory(), null).build(); + + verifier = new SlackSignature.Verifier(new SlackSignature.Generator(SLACK_SECRET)); + } + + boolean isValidSlackWebhook(HttpRequest request, String requestBody) throws IOException { + + // Check for headers + HashMap> headers = new HashMap(request.getHeaders()); + if (!headers.containsKey("X-Slack-Request-Timestamp") + || !headers.containsKey("X-Slack-Signature")) { + return false; + } + return verifier.isValid( + headers.get("X-Slack-Request-Timestamp").get(0), + requestBody, + headers.get("X-Slack-Signature").get(0), + 1L); + } + + void addPropertyIfPresent( + JsonObject target, String targetName, JsonObject source, String sourceName) { + if (source.has(sourceName)) { + target.addProperty(targetName, source.get(sourceName).getAsString()); + } + } + + String formatSlackMessage(JsonObject kgResponse, String query) { + JsonObject attachmentJson = new JsonObject(); + JsonArray attachments = new JsonArray(); + + JsonObject responseJson = new JsonObject(); + responseJson.addProperty("response_type", "in_channel"); + responseJson.addProperty("text", String.format("Query: %s", query)); + + JsonArray entityList = kgResponse.getAsJsonArray("itemListElement"); + + // Extract the first entity from the result list, if any + if (entityList.size() == 0) { + attachmentJson.addProperty("text","No results match your query..."); + + attachments.add(attachmentJson); + responseJson.add("attachments", attachmentJson); + + return gson.toJson(responseJson); + } + + JsonObject entity = entityList.get(0).getAsJsonObject().getAsJsonObject("result"); + + // Construct Knowledge Graph response attachment + String title = entity.get("name").getAsString(); + if (entity.has("description")) { + title = String.format("%s: %s", title, entity.get("description").getAsString()); + } + attachmentJson.addProperty("title", title); + + if (entity.has("detailedDescription")) { + JsonObject detailedDescJson = entity.getAsJsonObject("detailedDescription"); + addPropertyIfPresent(attachmentJson, "title_link", detailedDescJson, "url"); + addPropertyIfPresent(attachmentJson, "text", detailedDescJson, "articleBody"); + } + + if (entity.has("image")) { + JsonObject imageJson = entity.getAsJsonObject("image"); + addPropertyIfPresent(attachmentJson, "image_url", imageJson, "contentUrl"); + } + + // Construct top level response + attachments.add(attachmentJson); + responseJson.add("attachments", attachmentJson); + + return gson.toJson(responseJson); + } + + JsonObject searchKnowledgeGraph(String query) throws IOException { + Kgsearch.Entities.Search kgRequest = kgClient.entities().search(); + kgRequest.setQuery(query); + kgRequest.setKey(API_KEY); + + return gson.fromJson(kgRequest.execute().toString(), JsonObject.class); + } + + @Override + public void service(HttpRequest request, HttpResponse response) throws IOException { + + // Validate request + if (request.getMethod() != "POST") { + response.setStatusCode(HttpURLConnection.HTTP_BAD_METHOD); + return; + } + + // reader can only be read once per request, so we preserve its contents + String bodyString = request.getReader().lines().collect(Collectors.joining()); + JsonObject body = (new Gson()).fromJson(bodyString, JsonObject.class); + + if (body == null || !body.has("text")) { + response.setStatusCode(HttpURLConnection.HTTP_BAD_REQUEST); + return; + } + + if (!isValidSlackWebhook(request, bodyString)) { + response.setStatusCode(HttpURLConnection.HTTP_UNAUTHORIZED); + return; + } + + String query = body.get("text").getAsString(); + + // Call knowledge graph API + JsonObject kgResponse = searchKnowledgeGraph(query); + + // Format response to Slack + BufferedWriter writer = response.getWriter(); + writer.write(formatSlackMessage(kgResponse, query)); + } +} diff --git a/functions/snippets/src/test/java/com/example/functions/SlackSlashCommandTest.java b/functions/snippets/src/test/java/com/example/functions/SlackSlashCommandTest.java new file mode 100644 index 00000000000..9b4eaad6fb0 --- /dev/null +++ b/functions/snippets/src/test/java/com/example/functions/SlackSlashCommandTest.java @@ -0,0 +1,171 @@ +/* + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.functions; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.powermock.api.mockito.PowerMockito.mock; +import static org.powermock.api.mockito.PowerMockito.when; + +import com.github.seratch.jslack.app_backend.SlackSignature; +import com.google.api.client.googleapis.json.GoogleJsonResponseException; +import com.google.cloud.functions.HttpRequest; +import com.google.cloud.functions.HttpResponse; +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.StringReader; +import java.io.StringWriter; +import java.net.HttpURLConnection; +import java.security.GeneralSecurityException; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentMatchers; +import org.mockito.Mock; +import org.powermock.reflect.Whitebox; + +public class SlackSlashCommandTest { + + private BufferedWriter writerOut; + private StringWriter responseOut; + + @Mock private HttpRequest request; + @Mock private HttpResponse response; + + @Mock private SlackSignature.Verifier alwaysValidVerifier; + + @Before + public void beforeTest() throws IOException { + request = mock(HttpRequest.class); + when(request.getReader()).thenReturn(new BufferedReader(new StringReader(""))); + + response = mock(HttpResponse.class); + + responseOut = new StringWriter(); + + writerOut = new BufferedWriter(responseOut); + when(response.getWriter()).thenReturn(writerOut); + + alwaysValidVerifier = mock(SlackSignature.Verifier.class); + when(alwaysValidVerifier.isValid( + ArgumentMatchers.any(), + ArgumentMatchers.any(), + ArgumentMatchers.any(), + ArgumentMatchers.anyLong()) + ).thenReturn(true); + + // Construct valid header list + HashMap> validHeaders = new HashMap>(); + String validSlackSignature = System.getenv("SLACK_TEST_SIGNATURE"); + String timestamp = "0"; // start of Unix epoch + + validHeaders.put("X-Slack-Signature", Arrays.asList(validSlackSignature)); + validHeaders.put("X-Slack-Request-Timestamp", Arrays.asList(timestamp)); + + when(request.getHeaders()).thenReturn(validHeaders); + + // Reset knowledge graph API key + Whitebox.setInternalState(SlackSlashCommand.class, "API_KEY", System.getenv("KG_API_KEY")); + } + + @Test + public void onlyAcceptsPostRequestsTest() throws IOException, GeneralSecurityException { + when(request.getMethod()).thenReturn("GET"); + new SlackSlashCommand().service(request, response); + + writerOut.flush(); + verify(response, times(1)).setStatusCode(HttpURLConnection.HTTP_BAD_METHOD); + } + + @Test + public void requiresSlackAuthHeadersTest() throws IOException, GeneralSecurityException { + StringReader requestReadable = new StringReader("{ \"text\": \"foo\" }\n"); + + when(request.getMethod()).thenReturn("POST"); + when(request.getReader()).thenReturn(new BufferedReader(requestReadable)); + + new SlackSlashCommand().service(request, response); + + // Do NOT look for HTTP_BAD_REQUEST here (that means the request WAS authorized)! + verify(response, times(1)).setStatusCode(HttpURLConnection.HTTP_UNAUTHORIZED); + } + + @Test + public void recognizesValidSlackTokenTest() throws IOException, GeneralSecurityException { + StringReader requestReadable = new StringReader("{}"); + + when(request.getReader()).thenReturn(new BufferedReader(requestReadable)); + when(request.getMethod()).thenReturn("POST"); + + new SlackSlashCommand().service(request, response); + + verify(response, times(1)).setStatusCode(HttpURLConnection.HTTP_BAD_REQUEST); + } + + @Test(expected = GoogleJsonResponseException.class) + public void handlesSearchErrorTest() throws IOException, GeneralSecurityException { + StringReader requestReadable = new StringReader("{ \"text\": \"foo\" }\n"); + + when(request.getReader()).thenReturn(new BufferedReader(requestReadable)); + when(request.getMethod()).thenReturn("POST"); + + SlackSlashCommand functionInstance = new SlackSlashCommand(); + Whitebox.setInternalState(functionInstance, "verifier", alwaysValidVerifier); + Whitebox.setInternalState(SlackSlashCommand.class, "API_KEY", "gibberish"); + + // Should throw a GoogleJsonResponseException (due to invalid API key) + functionInstance.service(request, response); + } + + @Test + public void handlesEmptyKgResultsTest() throws IOException, GeneralSecurityException { + StringReader requestReadable = new StringReader("{ \"text\": \"asdfjkl13579\" }\n"); + + when(request.getReader()).thenReturn(new BufferedReader(requestReadable)); + when(request.getMethod()).thenReturn("POST"); + + SlackSlashCommand functionInstance = new SlackSlashCommand(); + Whitebox.setInternalState(functionInstance, "verifier", alwaysValidVerifier); + + + functionInstance.service(request, response); + + writerOut.flush(); + assertThat(responseOut.toString()).contains("No results match your query..."); + } + + @Test + public void handlesPopulatedKgResultsTest() throws IOException, GeneralSecurityException { + StringReader requestReadable = new StringReader("{ \"text\": \"lion\" }\n"); + + when(request.getReader()).thenReturn(new BufferedReader(requestReadable)); + when(request.getMethod()).thenReturn("POST"); + + SlackSlashCommand functionInstance = new SlackSlashCommand(); + Whitebox.setInternalState(functionInstance, "verifier", alwaysValidVerifier); + + + functionInstance.service(request, response); + + writerOut.flush(); + assertThat(responseOut.toString()).contains("https://en.wikipedia.org/wiki/Lion"); + } +} diff --git a/functions/snippets/src/test/java/com/example/functions/SnippetsTests.java b/functions/snippets/src/test/java/com/example/functions/SnippetsTests.java index ecdf7af7a7f..c13a6aa3b9e 100644 --- a/functions/snippets/src/test/java/com/example/functions/SnippetsTests.java +++ b/functions/snippets/src/test/java/com/example/functions/SnippetsTests.java @@ -20,24 +20,6 @@ import static org.powermock.api.mockito.PowerMockito.mock; import static org.powermock.api.mockito.PowerMockito.when; -import com.example.functions.CorsEnabled; -import com.example.functions.EnvVars; -import com.example.functions.ExecutionCount; -import com.example.functions.FileSystem; -import com.example.functions.FirebaseAuth; -import com.example.functions.HelloBackground; -import com.example.functions.HelloGcs; -import com.example.functions.HelloHttp; -import com.example.functions.HelloPubSub; -import com.example.functions.HelloWorld; -import com.example.functions.LazyFields; -import com.example.functions.LogHelloWorld; -import com.example.functions.ParseContentType; -import com.example.functions.PubSubMessage; -import com.example.functions.RetrieveLogs; -import com.example.functions.Scopes; -import com.example.functions.SendHttpRequest; -import com.example.functions.StackdriverLogging; import com.google.cloud.functions.HttpRequest; import com.google.cloud.functions.HttpResponse; import com.google.common.testing.TestLogHandler;