fix(connect): a Continue to Slack link, a landing page that exists and a plain refusal
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 (authorizeUrl) but no component, and a frontend step shows only the components it declares. The screen is now an HTML component: one sentence about what happens, a Continue to Slack link to the consent page (same tab, so Slack can return the browser) and the lifetime of the link, read from SlackOAuthState.LIFETIME_SECONDS (10 minutes). After Slack, the callback redirected to /preferences?section=slack, which the host UI does not serve. It returns to /app/preferences?section=slack with &result=connected or &error=<code>. 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 refused before any state is issued with a user-facing message that says 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 Preferences action still answers with the authorize address as the whole message: core has no redirect field on an action result. qqq-frontend-next opens a message that is only an https address. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
1 parent
2508c6b93c
commit
80bea0f931
10 files changed
+364
-21
No files matched your search
@@ -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=<code>`.
|
||||
- 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
|
||||
|
||||
+16
-5
@@ -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=<code>` (`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:<id>`) directly.
|
||||
|
||||
|
||||
@@ -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<String> 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());
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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<String, Long> 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));
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = "<p style=\"margin:0 0 12px\">";
|
||||
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.</p>"
|
||||
+ paragraph + "<a href=\"" + escapeHtml(authorizeUrl) + "\" style=\"display:inline-block;padding:8px 16px;border-radius:6px;"
|
||||
+ "background:#4A154B;color:#ffffff;font-weight:600;text-decoration:none\">Continue to Slack</a></p>"
|
||||
+ "<p style=\"margin:0\">This link expires in " + SlackOAuthState.LIFETIME_SECONDS / 60 + " minutes. If it has expired, run this action again.</p>");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** 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.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -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<QFrontendStepMetaData> 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("<a href=\"" + url.replace("&", "&") + "\"").contains(">Continue to Slack</a>");
|
||||
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</a>", "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("<a href=\"https://slack.com/oauth/v2/authorize?", ">Continue to Slack</a>", "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<String, String> noSecrets = name -> null;
|
||||
Function<String, String> onlyClientId = name -> "SLACK_CLIENT_ID".equals(name) ? CLIENT_ID_CANARY : null;
|
||||
Function<String, String> 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<String, String> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user