diff --git a/CHANGELOG.md b/CHANGELOG.md index f8ebb5e..d22571e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `SlackPayloadGuard` uses core `ClassifiedValues` for field classification and `ClassifiedValueTokens.changed()` for tokenized restricted values (qqq#1142), replacing its stand-in logic. ### Fixed +- **Install for my organization** and **Connect Slack** on the Connections table ended on an empty screen with only a Return button: the screen declared a view field but no component, and a frontend step shows only its components. The screen is now an HTML component with one sentence about what happens, a **Continue to Slack** link to the consent page (same tab) and the link's lifetime (10 minutes, `SlackOAuthState.LIFETIME_SECONDS`). +- After Slack, the callback redirected to `/preferences?section=slack`, which the host UI does not serve. It now returns to `/app/preferences?section=slack` with `&result=connected` or `&error=`. +- Connecting in an environment without the public URL or the Slack app secrets failed with `IllegalArgumentException (Slack public URL is required)` or `Slack secret unavailable`. It is now refused with a user-facing message: "Slack is not set up in this environment", which of the two is missing, and the names of the missing environment variables, never a value. - QBit metadata now reports the Maven artifact version, including release versions, from a filtered properties resource. ### Added diff --git a/docs/DESIGN.md b/docs/DESIGN.md index ecddb19..688691a 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -121,7 +121,12 @@ There is one row per QQQ user and workspace row, unique (`userId`, `workspaceId` 1. **Start** (UI API only). It's a preference-section action, **Connect Slack**, in the user's Slack section (core #1162), or the same action on the Connections table. - - It returns the Slack authorize URL. + - It returns the Slack authorize URL. Nobody copies it: the preference action answers with the address + alone, which the UI opens (see Preferences below), and the Connections table action ends on a screen + with one sentence, a **Continue to Slack** link and the link's lifetime. + - An environment without the public URL or without the Slack app secrets is refused before any state is + issued: "Slack is not set up in this environment", which of the two is missing, and the names of the + missing environment variables (never a value). - The `state` is a server-signed, single-use token over the user id, tenant key, nonce, mode (`user` or `user+bot`) and a 10-minute expiry. - Users who hold `slack.workspace.install` in their tenant may also request the bot scopes. @@ -132,8 +137,11 @@ There is one row per QQQ user and workspace row, unique (`userId`, `workspaceId` environment variables. - It upserts the tenant's `slack_workspace` (bot token, when granted) and the user's `slack_connection` (user token and Slack user id). - - Then it redirects to the Preferences page with a result notice. - - Errors redirect with a plain message, never token text. + - Then it redirects to the Slack section of the Preferences page, + `/app/preferences?section=slack&result=connected`, which says the connection succeeded. + - Errors redirect there with a fixed code, `&error=` (`sign_in_required`, `not_permitted`, + `authorization_denied`, `exchange_failed`, `connect_failed`), never provider or token text. The page + shows the code as words. 3. **Disconnect** calls `auth.revoke` on the user token, then marks the row REVOKED and clears the token. **Uninstall** (admin) calls `apps.uninstall` or revokes the bot token, then marks the workspace UNINSTALLED. @@ -540,8 +548,11 @@ qbit-slack's own Preferences section (core #1162) has: The section requires `slack.connect`, appears in the user menu, and rejects PATCH requests. Actions are filtered per session; test messages also require `slack.send`. RC.7 has no action-level permission property, so the section returns a filtered metadata view without mutating its shared -registrations. Connect and Install return their authorize URL in `UserPreferenceActionResult.message`; -RC.7 has no external-link or redirect result field, so this does not automatically navigate the client. +registrations. Connect and Install return their authorize URL as the whole of +`UserPreferenceActionResult.message`: core has no external-link or redirect result field (checked through +QQQ 4.1.0-RC.31). qqq-frontend-next sends the browser to a message that is only an `https:` address +instead of showing it, so the message must stay the address and nothing else. A typed redirect field in +core would replace this convention. Without qbit-alerts, callers use `SendMessageAction` (`slack`, `me` or `user:`) directly. diff --git a/src/main/java/com/kingsrook/qbits/slack/oauth/SlackConnect.java b/src/main/java/com/kingsrook/qbits/slack/oauth/SlackConnect.java index 9a85d92..cd1b275 100644 --- a/src/main/java/com/kingsrook/qbits/slack/oauth/SlackConnect.java +++ b/src/main/java/com/kingsrook/qbits/slack/oauth/SlackConnect.java @@ -25,6 +25,7 @@ import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; +import java.util.stream.Stream; import com.kingsrook.qbits.slack.SlackQBitConfig; import com.kingsrook.qbits.slack.metadata.SlackTables; import com.kingsrook.qbits.slack.security.SlackSecrets; @@ -35,6 +36,7 @@ import com.kingsrook.qbits.slack.support.SlackStore; import com.kingsrook.qbits.slack.transport.SlackResponse; import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock; import com.kingsrook.qqq.backend.core.model.session.QSession; @@ -59,8 +61,10 @@ public final class SlackConnect /******************************************************************************* ** Start only for the signed-in user, never an automated or system session. + ** An environment without a public URL or without the Slack app secrets is + ** refused in words the user can act on, before any state is issued. *******************************************************************************/ - public static String startUrl(String userId, String mode) + public static String startUrl(String userId, String mode) throws QException { QSession session = session(); if(!session.getUser().getIdReference().equals(userId) || !session.hasPermission("slack.connect") @@ -69,6 +73,11 @@ public final class SlackConnect throw (new IllegalArgumentException("Slack connect is not permitted")); } SlackQBitConfig config = SlackRuntime.config(); + String notSetUp = notSetUp(config); + if(notSetUp != null) + { + throw (new QUserFacingException(notSetUp)); + } String redirect = redirectUri(config); String state = SlackOAuthState.issue(config, session, userId, tenant(config, session), mode); return ("https://slack.com/oauth/v2/authorize?client_id=" + encode(SlackSecrets.get(config, config.getClientIdEnvName())) @@ -274,14 +283,52 @@ public final class SlackConnect return (session); } + /******************************************************************************* + ** Why Slack cannot be connected in this environment, or null when it can. + ** Connecting needs two things from the host: the public URL Slack sends the + ** browser back to, and the Slack app secrets (client id, client secret and + ** token key). The sentence says which is missing and names the environment + ** variables, never a value. + *******************************************************************************/ + public static String notSetUp(SlackQBitConfig config) + { + boolean noUrl = config.getPublicBaseUrl() == null || config.getPublicBaseUrl().isBlank(); + List secrets = Stream.of(config.getClientIdEnvName(), config.getClientSecretEnvName(), config.getTokenKeyEnvName()) + .filter(name -> !present(config, name)).toList(); + if(!noUrl && secrets.isEmpty()) + { + return (null); + } + String missing = noUrl && !secrets.isEmpty() ? "the public URL and the Slack app secrets are not configured" + : noUrl ? "the public URL is not configured" : "the Slack app secrets are not configured"; + return ("Slack is not set up in this environment: " + missing + + (secrets.isEmpty() ? "." : " (missing " + String.join(", ", secrets) + ").")); + } + + /******************************************************************************* + ** True when a named secret resolves to a value. The value is not kept. + *******************************************************************************/ + private static boolean present(SlackQBitConfig config, String name) + { + try + { + SlackSecrets.get(config, name); + return (true); + } + catch(IllegalArgumentException e) + { + return (false); + } + } + /******************************************************************************* ** The exact redirect URI shared by start and exchange. *******************************************************************************/ private static String redirectUri(SlackQBitConfig config) { - if(config.getPublicBaseUrl() == null) + if(config.getPublicBaseUrl() == null || config.getPublicBaseUrl().isBlank()) { - throw (new IllegalArgumentException("Slack public URL is required")); + throw (new IllegalArgumentException("Slack is not set up in this environment: the public URL is not configured.")); } return (config.getPublicBaseUrl().replaceAll("/+$", "") + config.getOauthCallbackPath()); } diff --git a/src/main/java/com/kingsrook/qbits/slack/oauth/SlackOAuthRouteProvider.java b/src/main/java/com/kingsrook/qbits/slack/oauth/SlackOAuthRouteProvider.java index 9b97196..2c47cde 100644 --- a/src/main/java/com/kingsrook/qbits/slack/oauth/SlackOAuthRouteProvider.java +++ b/src/main/java/com/kingsrook/qbits/slack/oauth/SlackOAuthRouteProvider.java @@ -37,6 +37,9 @@ import io.javalin.http.Context; *******************************************************************************/ public class SlackOAuthRouteProvider implements QJavalinRouteProviderInterface { + /** The Slack section of the host UI's Preferences page. */ + public static final String RETURN_PATH = "/app/preferences?section=slack"; + private QInstance instance; /******************************************************************************* @@ -137,7 +140,18 @@ public class SlackOAuthRouteProvider implements QJavalinRouteProviderInterface QContext.init(previous); } context.header("Cache-Control", "no-store").header("Referrer-Policy", "no-referrer"); - context.redirect(SlackRuntime.config(instance).getPublicBaseUrl().replaceAll("/+$", "") - + "/preferences?section=slack" + (error == null ? "&result=connected" : "&error=" + error)); + context.redirect(returnUrl(SlackRuntime.config(instance).getPublicBaseUrl(), error)); + } + + /******************************************************************************* + ** Where the browser lands after Slack: the user's Slack preferences in the + ** host UI (/app/preferences, like every other /app path this qbit links to), + ** with a fixed outcome code the page turns into words. A host without a + ** public URL gets a path on the same origin. + *******************************************************************************/ + public static String returnUrl(String publicBaseUrl, String error) + { + return ((publicBaseUrl == null ? "" : publicBaseUrl.replaceAll("/+$", "")) + + RETURN_PATH + (error == null ? "&result=connected" : "&error=" + error)); } } diff --git a/src/main/java/com/kingsrook/qbits/slack/oauth/SlackOAuthState.java b/src/main/java/com/kingsrook/qbits/slack/oauth/SlackOAuthState.java index 35b2073..9fe77e7 100644 --- a/src/main/java/com/kingsrook/qbits/slack/oauth/SlackOAuthState.java +++ b/src/main/java/com/kingsrook/qbits/slack/oauth/SlackOAuthState.java @@ -43,6 +43,9 @@ import org.json.JSONObject; *******************************************************************************/ public final class SlackOAuthState { + /** How long an issued state, and so the authorize address that carries it, stays valid. */ + public static final int LIFETIME_SECONDS = 600; + private static final Map NONCES = new HashMap<>(); private static final int CAPACITY = 10_000; @@ -72,7 +75,7 @@ public final class SlackOAuthState throw (new IllegalArgumentException("Slack connect unavailable")); } String nonce = UUID.randomUUID().toString(); - long expiry = now + 600; + long expiry = now + LIFETIME_SECONDS; String payload = encode(new JSONObject().put("user", user).put("tenant", Objects.toString(tenant, "")) .put("session", session.getUuid()).put("nonce", nonce).put("mode", mode).put("expiry", expiry).toString().getBytes(StandardCharsets.UTF_8)); String signed = payload + "." + encode(sign(config, payload)); diff --git a/src/main/java/com/kingsrook/qbits/slack/preferences/SlackPreferences.java b/src/main/java/com/kingsrook/qbits/slack/preferences/SlackPreferences.java index 133719e..f484de4 100644 --- a/src/main/java/com/kingsrook/qbits/slack/preferences/SlackPreferences.java +++ b/src/main/java/com/kingsrook/qbits/slack/preferences/SlackPreferences.java @@ -92,7 +92,7 @@ public class SlackPreferences /******************************************************************************* ** Start signed, single-use OAuth for the current user only. *******************************************************************************/ - public static String connect(boolean install) + public static String connect(boolean install) throws QException { return (SlackConnect.startUrl(owner(), install ? "user+bot" : "user")); } diff --git a/src/main/java/com/kingsrook/qbits/slack/preferences/SlackPreferencesProvider.java b/src/main/java/com/kingsrook/qbits/slack/preferences/SlackPreferencesProvider.java index 6c146c0..479f944 100644 --- a/src/main/java/com/kingsrook/qbits/slack/preferences/SlackPreferencesProvider.java +++ b/src/main/java/com/kingsrook/qbits/slack/preferences/SlackPreferencesProvider.java @@ -162,7 +162,11 @@ public class SlackPreferencesProvider implements UserPreferenceProviderInterface } /******************************************************************************* - ** RC.7 has no redirect result; return the authorize URL in its message field. + ** Core has no redirect field on an action result (checked through QQQ + ** 4.1.0-RC.31), so Connect and Install answer with the authorize address as + ** the whole message and nothing else. qqq-frontend-next sends the browser to + ** a message that is only an https address instead of showing it; any other + ** word in the message would turn it back into text to read. ** Row services retain ownership checks even after core has checked the row. *******************************************************************************/ @Override diff --git a/src/main/java/com/kingsrook/qbits/slack/processes/SlackProcesses.java b/src/main/java/com/kingsrook/qbits/slack/processes/SlackProcesses.java index 2f8f7b7..18789d5 100644 --- a/src/main/java/com/kingsrook/qbits/slack/processes/SlackProcesses.java +++ b/src/main/java/com/kingsrook/qbits/slack/processes/SlackProcesses.java @@ -30,6 +30,7 @@ import com.kingsrook.qbits.slack.SlackQBitConfig; import com.kingsrook.qbits.slack.delivery.SlackDispatcher; import com.kingsrook.qbits.slack.metadata.SlackTables; import com.kingsrook.qbits.slack.oauth.SlackConnect; +import com.kingsrook.qbits.slack.oauth.SlackOAuthState; import com.kingsrook.qbits.slack.readiness.SlackReadiness; import com.kingsrook.qbits.slack.template.SlackTemplates; import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; @@ -42,6 +43,9 @@ import com.kingsrook.qqq.backend.core.model.actions.processes.Status; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData; import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData; import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep; @@ -67,6 +71,9 @@ public final class SlackProcesses public static final String MAINTAIN = "slack_readiness"; public static final String DISPATCH = "slack_dispatch"; + /** The one screen of Connect Slack and Install: its HTML is the process value "continue.html". */ + public static final String CONTINUE_STEP = "continue"; + /******************************************************************************* @@ -110,31 +117,63 @@ public final class SlackProcesses /******************************************************************************* ** OAuth starts without requiring an existing connection or selected record. + ** The one screen is an HTML component: a frontend step shows only the + ** components it declares, so a view field alone renders an empty screen. *******************************************************************************/ private static QProcessMetaData connectAction(String name, String label) { return (new QProcessMetaData().withName(name).withLabel(label).withTableName(SlackTables.CONNECTION) .withMinInputRecords(0).withPermissionRules(SlackTables.permissionRules()) .withStep(new QBackendStepMetaData().withName("authorize").withCode(new QCodeReference(ConnectStep.class))) - .withStep(new com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData().withName("result").withLabel("Connect Slack") - .withViewField(new com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData("authorizeUrl", com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType.STRING).withLabel("Open this Slack authorization URL")))); + .withStep(new QFrontendStepMetaData().withName(CONTINUE_STEP).withLabel("Continue to Slack") + .withComponent(new QFrontendComponentMetaData().withType(QComponentType.HTML)))); } /******************************************************************************* - ** Return the authorize URL through the standard process UI API. + ** Put the way to Slack on the process screen through the standard UI API. *******************************************************************************/ public static class ConnectStep implements BackendStep { /******************************************************************************* - ** No remote mutation happens until the user approves at Slack. + ** No remote mutation happens until the user approves at Slack. The screen + ** gets a link, not an address to copy; the address itself stays available + ** to API clients in the authorizeUrl value. *******************************************************************************/ @Override - public void run(RunBackendStepInput input, RunBackendStepOutput output) + public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException { - output.addValue("authorizeUrl", com.kingsrook.qbits.slack.preferences.SlackPreferences.connect(INSTALL.equals(input.getProcessName()))); + boolean install = INSTALL.equals(input.getProcessName()); + String url = com.kingsrook.qbits.slack.preferences.SlackPreferences.connect(install); + output.addValue("authorizeUrl", url); + output.addValue(CONTINUE_STEP + ".html", continueHtml(url, install)); } } + /******************************************************************************* + ** What the screen says: one sentence about what happens next, the link to + ** Slack's consent page (same tab, so Slack can return the browser here), and + ** when the link stops working. The link carries its own style because a host + ** UI may render links in process HTML like the text around them. + *******************************************************************************/ + static String continueHtml(String authorizeUrl, boolean install) + { + String paragraph = "

"; + String what = install ? "Slack will ask you to approve installing this application for your organization and connecting your own Slack account." + : "Slack will ask you to approve connecting your Slack account to this application."; + return (paragraph + what + " Afterwards you return to your Slack preferences, which show the result.

" + + paragraph + "Continue to Slack

" + + "

This link expires in " + SlackOAuthState.LIFETIME_SECONDS / 60 + " minutes. If it has expired, run this action again.

"); + } + + /******************************************************************************* + ** Escape text for an HTML attribute or element. + *******************************************************************************/ + private static String escapeHtml(String text) + { + return (text.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """).replace("'", "'")); + } + /******************************************************************************* ** The scheduler's entry point. *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/slack/SlackConnectScreenTest.java b/src/test/java/com/kingsrook/qbits/slack/SlackConnectScreenTest.java new file mode 100644 index 0000000..cb49461 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/slack/SlackConnectScreenTest.java @@ -0,0 +1,222 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * 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 + * + * https://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.kingsrook.qbits.slack; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import com.kingsrook.qbits.slack.oauth.SlackConnect; +import com.kingsrook.qbits.slack.oauth.SlackOAuthRouteProvider; +import com.kingsrook.qbits.slack.oauth.SlackOAuthState; +import com.kingsrook.qbits.slack.permissions.SlackPermissions; +import com.kingsrook.qbits.slack.processes.SlackProcesses; +import com.kingsrook.qqq.backend.core.actions.preferences.UserPreferencesAction; +import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; +import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/******************************************************************************* + ** What a person sees when they start a Slack connection: a link to follow on + ** the process screen, an address-only answer the Preferences panel can send + ** the browser to, a landing page that exists, and a plain refusal when Slack + ** is not set up in the environment. + *******************************************************************************/ +class SlackConnectScreenTest +{ + private static final String CLIENT_ID_CANARY = "client-id-canary-value"; + + /******************************************************************************* + ** A configured environment: a public URL and every Slack app secret. + *******************************************************************************/ + private static SlackTestEnv configured() throws Exception + { + return (SlackTestEnv.create(false, c -> c.withPublicBaseUrl("https://example.test") + .withSecretResolver(name -> "SLACK_TOKEN_KEY".equals(name) ? SlackTokenCipherTest.KEY_ONE : "fake-application-secret"))); + } + + /******************************************************************************* + ** Connect and Install each end on a screen with content. A frontend step + ** shows only its declared components, so a step without one is an empty + ** screen with a Return button. + *******************************************************************************/ + @Test + void connectAndInstallScreensDeclareWhatTheyShow() throws Exception + { + try(SlackTestEnv env = configured()) + { + for(String name : List.of(SlackProcesses.CONNECT, SlackProcesses.INSTALL)) + { + List screens = env.instance().getProcess(name).getStepList().stream() + .filter(QFrontendStepMetaData.class::isInstance).map(QFrontendStepMetaData.class::cast).toList(); + assertThat(screens).as(name).hasSize(1); + assertThat(screens.getFirst().getName()).isEqualTo(SlackProcesses.CONTINUE_STEP); + assertThat(screens.getFirst().getLabel()).isEqualTo("Continue to Slack"); + assertThat(screens.getFirst().getComponents()).as(name + " screen components").isNotEmpty() + .extracting(QFrontendComponentMetaData::getType).containsExactly(QComponentType.HTML); + } + } + } + + /******************************************************************************* + ** The screen's HTML is a sentence, a link named Continue to Slack whose + ** target is the authorize address, and the lifetime of the link read from + ** the state. The address is in the link target only, never text to copy. + *******************************************************************************/ + @Test + void theScreenLinksToSlackAndSaysWhenTheLinkExpires() throws Exception + { + try(SlackTestEnv env = configured()) + { + env.asOwner("alice").withPermissions(SlackPermissions.CONNECT, SlackPermissions.INSTALL); + RunBackendStepOutput connect = new RunBackendStepOutput(); + new SlackProcesses.ConnectStep().run(new RunBackendStepInput().withProcessName(SlackProcesses.CONNECT), connect); + String url = connect.getValueString("authorizeUrl"); + String html = connect.getValueString(SlackProcesses.CONTINUE_STEP + ".html"); + assertThat(url).startsWith("https://slack.com/oauth/v2/authorize?").contains("&state="); + assertThat(html).contains("Continue to Slack"); + assertThat(html).doesNotContain("target="); + assertThat(html).contains("approve connecting your Slack account"); + assertThat(SlackOAuthState.LIFETIME_SECONDS).isEqualTo(600); + assertThat(html).contains("This link expires in 10 minutes."); + assertThat(html.replaceAll("<[^>]*>", " ")).as("visible text").doesNotContain("https://", "slack.com", "state="); + + RunBackendStepOutput install = new RunBackendStepOutput(); + new SlackProcesses.ConnectStep().run(new RunBackendStepInput().withProcessName(SlackProcesses.INSTALL), install); + assertThat(install.getValueString(SlackProcesses.CONTINUE_STEP + ".html")) + .contains("installing this application for your organization", ">Continue to Slack", "scope=chat"); + } + } + + /******************************************************************************* + ** Run as the UI runs it: the process stops on its one screen and the + ** screen's HTML is among the values the UI API returns for it. + *******************************************************************************/ + @Test + void installRunStopsOnTheContinueScreenWithItsHtml() throws Exception + { + try(SlackTestEnv env = configured()) + { + env.asOwner("alice").setPermissions(Set.of(SlackPermissions.CONNECT, SlackPermissions.INSTALL)); + RunProcessInput input = new RunProcessInput(); + input.setProcessName(SlackProcesses.INSTALL); + input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.BREAK); + RunProcessOutput output = new RunProcessAction().execute(input); + assertThat(output.getException()).isEmpty(); + assertThat(output.getProcessState().getNextStepName()).hasValue(SlackProcesses.CONTINUE_STEP); + assertThat(String.valueOf(output.getValues().get(SlackProcesses.CONTINUE_STEP + ".html"))) + .contains("Continue to Slack", "This link expires in 10 minutes."); + } + } + + /******************************************************************************* + ** The Preferences action answers with the address and nothing else: the UI + ** follows a message that is only an https address, and shows any other. + *******************************************************************************/ + @Test + void thePreferencesActionAnswersWithTheAddressAlone() throws Exception + { + try(SlackTestEnv env = configured()) + { + env.asOwner("alice").setPermissions(Set.of(SlackPermissions.CONNECT, SlackPermissions.INSTALL)); + for(String name : List.of(SlackProcesses.CONNECT, SlackProcesses.INSTALL)) + { + String message = new UserPreferencesAction().runAction("slack", name, Map.of()).getMessage(); + assertThat(message).as(name).startsWith("https://slack.com/oauth/v2/authorize?").doesNotContainAnyWhitespaces(); + } + } + } + + /******************************************************************************* + ** After Slack, the browser lands on the Slack section of the host's + ** Preferences page, which is under /app, with a fixed outcome code. + *******************************************************************************/ + @Test + void theCallbackReturnsToTheSlackPreferencesPage() + { + assertThat(SlackOAuthRouteProvider.RETURN_PATH).isEqualTo("/app/preferences?section=slack"); + assertThat(SlackOAuthRouteProvider.returnUrl("https://example.test/", null)).isEqualTo("https://example.test/app/preferences?section=slack&result=connected"); + assertThat(SlackOAuthRouteProvider.returnUrl("https://example.test", "authorization_denied")) + .isEqualTo("https://example.test/app/preferences?section=slack&error=authorization_denied"); + assertThat(SlackOAuthRouteProvider.returnUrl(null, "connect_failed")).isEqualTo("/app/preferences?section=slack&error=connect_failed"); + } + + /******************************************************************************* + ** An environment that lacks the public URL, the Slack app secrets or both + ** says so, says which, and names the missing variables, never a value. + *******************************************************************************/ + @Test + void anEnvironmentWithoutSlackSaysWhatIsMissing() throws Exception + { + Function noSecrets = name -> null; + Function onlyClientId = name -> "SLACK_CLIENT_ID".equals(name) ? CLIENT_ID_CANARY : null; + Function allSecrets = name -> "SLACK_TOKEN_KEY".equals(name) ? SlackTokenCipherTest.KEY_ONE : "fake-application-secret"; + + assertThat(refusal(null, allSecrets)).isEqualTo("Slack is not set up in this environment: the public URL is not configured."); + assertThat(refusal("https://example.test", noSecrets)).isEqualTo("Slack is not set up in this environment: " + + "the Slack app secrets are not configured (missing SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_TOKEN_KEY)."); + assertThat(refusal(null, onlyClientId)).isEqualTo("Slack is not set up in this environment: " + + "the public URL and the Slack app secrets are not configured (missing SLACK_CLIENT_SECRET, SLACK_TOKEN_KEY).") + .doesNotContain(CLIENT_ID_CANARY); + } + + /******************************************************************************* + ** Start a connection every way a person can, in an environment built with + ** the given public URL and secrets, and return the one refusal they all give. + ** A user-facing exception is what the UI API shows as written. + *******************************************************************************/ + private static String refusal(String publicBaseUrl, Function secrets) throws Exception + { + try(SlackTestEnv env = SlackTestEnv.create(false, c -> c.withPublicBaseUrl(publicBaseUrl).withSecretResolver(secrets))) + { + env.asOwner("alice").setPermissions(Set.of(SlackPermissions.CONNECT)); + String expected = SlackConnect.notSetUp(env.config()); + assertThat(expected).isNotNull(); + assertThatThrownBy(() -> SlackConnect.startUrl("alice", "user")).isInstanceOf(QUserFacingException.class).hasMessage(expected); + assertThatThrownBy(() -> new UserPreferencesAction().runAction("slack", SlackProcesses.CONNECT, Map.of())) + .isInstanceOf(QUserFacingException.class).hasMessage(expected); + assertThatThrownBy(() -> new SlackProcesses.ConnectStep().run(new RunBackendStepInput().withProcessName(SlackProcesses.CONNECT), new RunBackendStepOutput())) + .isInstanceOf(QUserFacingException.class).hasMessage(expected); + return (expected); + } + } + + /******************************************************************************* + ** A configured environment has nothing to refuse. + *******************************************************************************/ + @Test + void aConfiguredEnvironmentIsNotRefused() throws Exception + { + try(SlackTestEnv env = configured()) + { + assertThat(SlackConnect.notSetUp(env.config())).isNull(); + } + } +} diff --git a/src/test/java/com/kingsrook/qbits/slack/SlackOAuthRouteTest.java b/src/test/java/com/kingsrook/qbits/slack/SlackOAuthRouteTest.java index ab92f25..95f31e7 100644 --- a/src/test/java/com/kingsrook/qbits/slack/SlackOAuthRouteTest.java +++ b/src/test/java/com/kingsrook/qbits/slack/SlackOAuthRouteTest.java @@ -98,12 +98,12 @@ class SlackOAuthRouteTest URI uri = URI.create("http://127.0.0.1:" + app.port() + env.config().getOauthCallbackPath() + "?code=code&state=" + URLEncoder.encode(signed, StandardCharsets.UTF_8)); var unsigned = client.send(HttpRequest.newBuilder(uri).GET().build(), HttpResponse.BodyHandlers.ofString()); assertThat(unsigned.statusCode()).isEqualTo(302); - assertThat(unsigned.headers().firstValue("location").orElseThrow()).endsWith("&error=sign_in_required"); + assertThat(unsigned.headers().firstValue("location").orElseThrow()).isEqualTo("https://example.test/app/preferences?section=slack&error=sign_in_required"); env.slack().script("oauth.v2.access", FakeSlackServer.Reply.of(200, "{\"ok\":true,\"team\":{\"id\":\"T1\"},\"authed_user\":{\"id\":\"UA\",\"access_token\":\"xoxp-route-token\"}}")); var request = HttpRequest.newBuilder(uri).header("Cookie", "sessionUUID=" + session.getUuid()).GET().build(); var response = client.send(request, HttpResponse.BodyHandlers.ofString()); - assertThat(response.headers().firstValue("location").orElseThrow()).isEqualTo("https://example.test/preferences?section=slack&result=connected"); + assertThat(response.headers().firstValue("location").orElseThrow()).isEqualTo("https://example.test/app/preferences?section=slack&result=connected"); assertThat(response.headers().map().toString()).doesNotContain("xoxp-route-token", "fake-secret"); assertThat(client.send(request, HttpResponse.BodyHandlers.ofString()).headers().firstValue("location").orElseThrow()).endsWith("&error=connect_failed"); }