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");
}