diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index a8e8d55..c19f7f5 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -30,3 +30,15 @@ updates:
slack:
patterns:
- "com.slack.api:*"
+ - package-ecosystem: "maven"
+ directory: "standardized-auth"
+ schedule:
+ interval: "daily"
+ cooldown:
+ default-days: 7
+ exclude:
+ - "com.slack.api:*"
+ groups:
+ slack:
+ patterns:
+ - "com.slack.api:*"
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index f196005..da79df1 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -16,6 +16,7 @@ jobs:
showcase:
- "block-kit"
- "methods"
+ - "standardized-auth"
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
diff --git a/README.md b/README.md
index bc305e3..74b2219 100644
--- a/README.md
+++ b/README.md
@@ -6,3 +6,4 @@ This collections of examples highlights features of a Slack app in the language
- **[Block Kit](./block-kit)**: The framework of visual components arranged to create app layouts.
- **[Methods](./methods)**: An interface for querying information from and enacting change in a Slack workspace.
+- **[Standardized Auth](./standardized-auth)**: Connect user accounts with the Slack-owned Connect and Manage connection controls.
diff --git a/standardized-auth/.gitignore b/standardized-auth/.gitignore
new file mode 100644
index 0000000..de7ac38
--- /dev/null
+++ b/standardized-auth/.gitignore
@@ -0,0 +1,6 @@
+.classpath
+.project
+.settings
+target
+.env*
+acme.properties
diff --git a/standardized-auth/.slack/.gitignore b/standardized-auth/.slack/.gitignore
new file mode 100644
index 0000000..973ba60
--- /dev/null
+++ b/standardized-auth/.slack/.gitignore
@@ -0,0 +1,2 @@
+apps.dev.json
+cache/
diff --git a/standardized-auth/.slack/config.json b/standardized-auth/.slack/config.json
new file mode 100644
index 0000000..909afbe
--- /dev/null
+++ b/standardized-auth/.slack/config.json
@@ -0,0 +1,6 @@
+{
+ "manifest": {
+ "source": "local"
+ },
+ "project_id": "00000000-0000-0000-0000-000000000000"
+}
diff --git a/standardized-auth/.slack/hooks.json b/standardized-auth/.slack/hooks.json
new file mode 100644
index 0000000..9146667
--- /dev/null
+++ b/standardized-auth/.slack/hooks.json
@@ -0,0 +1,8 @@
+{
+ "hooks": {
+ "start": "mvn --quiet compile exec:java"
+ },
+ "config": {
+ "sdk-managed-connection-enabled": true
+ }
+}
diff --git a/standardized-auth/README.md b/standardized-auth/README.md
new file mode 100644
index 0000000..5f6fd87
--- /dev/null
+++ b/standardized-auth/README.md
@@ -0,0 +1,29 @@
+# Standardized Auth
+
+Connect your Acme account to Slack with the Slack-owned **Connect** and **Manage connection** controls. Rocket skates, anvils, and giant magnets not included.
+
+Read the docs: https://docs.slack.dev/authentication/standardized-auth
+
+## How it works
+
+Acme runs over Socket Mode and keeps every connection in a local properties file ([`Connections.java`](./src/main/java/acme/Connections.java)): a user is connected when their Slack user ID is in it. The provider object and the App Home view live in [`Acme.java`](./src/main/java/acme/Acme.java), and every listener and Slack API call lives in [`Main.java`](./src/main/java/acme/Main.java).
+
+- **Connect** connects the Slack user who clicked it, with their user ID as the connected identity. It reports the connection with `apps.user.connection.requestAuth` and refreshes App Home.
+- **Connect** or **Manage** on the app's profile sends the `user_connection` event, and Acme answers within 3 seconds by opening the connection modal with `apps.user.connection.requestAuth` and the event's `trigger_id`.
+- **Disconnect** forgets the connection and reports the user as disconnected.
+- **`user_connection_status_requested`** answers with `apps.user.connection.update` whenever Slack asks about a user it doesn't know yet.
+- **App Home** shows an `auth_provider` block with the user's current connection.
+
+Connecting in one click stands in for your service's real login. Slack requires at least one host in the manifest's `features.user_connections.auth_hosts`, so `example.com` holds the place until you have one. To sign users in to your own service, set `auth_url` on the provider to your OAuth or SSO page, replace `example.com` with its host, and carry the Slack user through the browser in a signed `state` so a connection always lands on the user who clicked **Connect**.
+
+## Setup
+
+Everything arrives over Socket Mode, so the app needs no public URL. The manifest subscribes to the `user_connection` and `user_connection_status_requested` events, which every app with `features.user_connections` needs. Use a Slack CLI newer than v4.10.0: older versions don't send `features.user_connections` with the manifest.
+
+Acme runs on Java 17 with Maven. `slack run` starts it with `mvn compile exec:java`, the `start` hook in [`.slack/hooks.json`](./.slack/hooks.json), and every Slack API call and its response is logged at debug ([`logback.xml`](./src/main/resources/logback.xml)).
+
+```sh
+$ slack run
+```
+
+Then open the app's **Home** tab and click **Connect**.
diff --git a/standardized-auth/manifest.json b/standardized-auth/manifest.json
new file mode 100644
index 0000000..6b30ae7
--- /dev/null
+++ b/standardized-auth/manifest.json
@@ -0,0 +1,39 @@
+{
+ "display_information": {
+ "name": "Acme",
+ "description": "Rocket skates, anvils, and giant magnets for every plan"
+ },
+ "features": {
+ "app_home": {
+ "home_tab_enabled": true
+ },
+ "bot_user": {
+ "display_name": "Acme",
+ "always_online": true
+ },
+ "user_connections": {
+ "is_enabled": true,
+ "auth_hosts": ["example.com"]
+ }
+ },
+ "oauth_config": {
+ "scopes": {
+ "bot": ["users:read"]
+ }
+ },
+ "settings": {
+ "event_subscriptions": {
+ "bot_events": [
+ "app_home_opened",
+ "user_connection",
+ "user_connection_status_requested"
+ ]
+ },
+ "interactivity": {
+ "is_enabled": true
+ },
+ "org_deploy_enabled": true,
+ "socket_mode_enabled": true,
+ "token_rotation_enabled": false
+ }
+}
diff --git a/standardized-auth/pom.xml b/standardized-auth/pom.xml
new file mode 100644
index 0000000..61621dd
--- /dev/null
+++ b/standardized-auth/pom.xml
@@ -0,0 +1,110 @@
+
+ 4.0.0
+ com.slack.api
+ standardized-auth-example
+ 0.1-SNAPSHOT
+ jar
+
+ UTF-8
+ acme.Main
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.16.0
+
+ 17
+
+
+
+ org.codehaus.mojo
+ exec-maven-plugin
+ 3.6.4
+
+ false
+
+
+
+ com.diffplug.spotless
+ spotless-maven-plugin
+ 3.10.4
+
+
+
+
+ .gitignore
+
+
+
+
+ true
+ 4
+
+
+
+
+
+
+
+
+
+ **/*.md
+
+
+
+
+
+
+
+ apply
+
+ compile
+
+
+
+
+
+
+
+ com.slack.api
+ bolt
+ 1.52.1-SNAPSHOT
+
+
+ com.slack.api
+ bolt-socket-mode
+ 1.52.1-SNAPSHOT
+
+
+ javax.websocket
+ javax.websocket-api
+ 1.1
+
+
+ org.glassfish.tyrus.bundles
+ tyrus-standalone-client
+ 1.21
+
+
+ ch.qos.logback
+ logback-classic
+ 1.6.5
+
+
+ org.junit.jupiter
+ junit-jupiter
+ 6.1.3
+ test
+
+
+ org.mockito
+ mockito-junit-jupiter
+ 5.23.0
+ test
+
+
+
diff --git a/standardized-auth/src/main/java/acme/Acme.java b/standardized-auth/src/main/java/acme/Acme.java
new file mode 100644
index 0000000..ef1849c
--- /dev/null
+++ b/standardized-auth/src/main/java/acme/Acme.java
@@ -0,0 +1,48 @@
+package acme;
+
+import static com.slack.api.model.block.Blocks.asBlocks;
+import static com.slack.api.model.block.Blocks.authProvider;
+import static com.slack.api.model.block.Blocks.section;
+import static com.slack.api.model.block.composition.BlockCompositions.markdownText;
+import static com.slack.api.model.block.composition.BlockCompositions.plainText;
+import static com.slack.api.model.view.Views.view;
+
+import com.slack.api.methods.request.apps.user.connection.AppsUserConnectionRequestAuthRequest.PrimaryProvider;
+import com.slack.api.model.view.View;
+
+public final class Acme {
+ public static final String PROVIDER_VALUE = "acme";
+
+ private Acme() {}
+
+ public static PrimaryProvider provider(String userId) {
+ PrimaryProvider acme = PrimaryProvider.builder()
+ .value(PROVIDER_VALUE)
+ .name(plainText("Acme account"))
+ .description(plainText("Order rocket skates, anvils, and giant magnets without leaving Slack."))
+ .isConnected(Connections.isConnected(userId))
+ .build();
+ if (acme.getIsConnected()) {
+ acme.setConnectedIdentity(plainText(userId));
+ }
+ return acme;
+ }
+
+ public static View homeView(PrimaryProvider acme) {
+ return view(view -> view.type("home")
+ .blocks(asBlocks(
+ section(section -> section.text(markdownText("*Welcome to Acme!* :rocket:\n"
+ + "Anvils ship same day, gravity permitting. "
+ + "Please keep giant magnets away from your laptop."))),
+ authProvider(block -> block.value(acme.getValue())
+ .name(acme.getName())
+ .description(acme.getDescription())
+ .iconUrl(acme.getIconUrl())
+ .authUrl(acme.getAuthUrl())
+ .disconnectUrl(acme.getDisconnectUrl())
+ .isConnected(acme.getIsConnected())
+ .connectedIdentity(acme.getConnectedIdentity())
+ .connectedContext(acme.getConnectedContext())
+ .accessory(acme.getAccessory())))));
+ }
+}
diff --git a/standardized-auth/src/main/java/acme/Connections.java b/standardized-auth/src/main/java/acme/Connections.java
new file mode 100644
index 0000000..8469172
--- /dev/null
+++ b/standardized-auth/src/main/java/acme/Connections.java
@@ -0,0 +1,52 @@
+package acme;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.io.UncheckedIOException;
+import java.io.Writer;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Properties;
+
+public final class Connections {
+ static Path filename = Path.of("acme.properties");
+
+ private Connections() {}
+
+ public static void connect(String userId) {
+ Properties db = read();
+ db.setProperty(userId, "true");
+ write(db);
+ }
+
+ public static void disconnect(String userId) {
+ Properties db = read();
+ db.remove(userId);
+ write(db);
+ }
+
+ public static boolean isConnected(String userId) {
+ return read().containsKey(userId);
+ }
+
+ private static Properties read() {
+ Properties db = new Properties();
+ if (Files.notExists(filename)) {
+ return db;
+ }
+ try (Reader reader = Files.newBufferedReader(filename)) {
+ db.load(reader);
+ } catch (IOException error) {
+ throw new UncheckedIOException(error);
+ }
+ return db;
+ }
+
+ private static void write(Properties db) {
+ try (Writer writer = Files.newBufferedWriter(filename)) {
+ db.store(writer, null);
+ } catch (IOException error) {
+ throw new UncheckedIOException(error);
+ }
+ }
+}
diff --git a/standardized-auth/src/main/java/acme/Main.java b/standardized-auth/src/main/java/acme/Main.java
new file mode 100644
index 0000000..31e22b6
--- /dev/null
+++ b/standardized-auth/src/main/java/acme/Main.java
@@ -0,0 +1,89 @@
+package acme;
+
+import static acme.Acme.PROVIDER_VALUE;
+import static acme.Acme.homeView;
+import static acme.Acme.provider;
+
+import com.slack.api.app_backend.events.payload.EventsApiPayload;
+import com.slack.api.app_backend.interactive_components.payload.BlockActionPayload;
+import com.slack.api.bolt.App;
+import com.slack.api.bolt.context.builtin.ActionContext;
+import com.slack.api.bolt.context.builtin.EventContext;
+import com.slack.api.bolt.request.builtin.BlockActionRequest;
+import com.slack.api.bolt.response.Response;
+import com.slack.api.bolt.socket_mode.SocketModeApp;
+import com.slack.api.methods.SlackApiException;
+import com.slack.api.methods.request.apps.user.connection.AppsUserConnectionRequestAuthRequest.PrimaryProvider;
+import com.slack.api.model.event.AppHomeOpenedEvent;
+import com.slack.api.model.event.UserConnectionEvent;
+import com.slack.api.model.event.UserConnectionStatusRequestedEvent;
+import java.io.IOException;
+
+public class Main {
+ public static void main(String[] args) throws Exception {
+ App app = new App();
+ app.event(AppHomeOpenedEvent.class, Main::appHomeOpenedCallback);
+ app.blockAction("auth_provider_connect", Main::connectCallback);
+ app.blockAction("auth_provider_disconnect", Main::disconnectCallback);
+ app.event(UserConnectionEvent.class, Main::userConnectionCallback);
+ app.event(UserConnectionStatusRequestedEvent.class, Main::userConnectionStatusRequestedCallback);
+ new SocketModeApp(app).start();
+ }
+
+ public static Response appHomeOpenedCallback(EventsApiPayload payload, EventContext ctx)
+ throws IOException, SlackApiException {
+ String userId = payload.getEvent().getUser();
+ ctx.client().viewsPublish(request -> request.userId(userId).view(homeView(provider(userId))));
+ return ctx.ack();
+ }
+
+ public static Response connectCallback(BlockActionRequest req, ActionContext ctx)
+ throws IOException, SlackApiException {
+ BlockActionPayload body = req.getPayload();
+ if (!PROVIDER_VALUE.equals(body.getActions().get(0).getValue())) {
+ return ctx.ack();
+ }
+ String userId = body.getUser().getId();
+ Connections.connect(userId);
+ PrimaryProvider acme = provider(userId);
+ ctx.client()
+ .appsUserConnectionRequestAuth(request -> request.userId(userId).primaryProvider(acme));
+ ctx.client().viewsPublish(request -> request.userId(userId).view(homeView(acme)));
+ return ctx.ack();
+ }
+
+ public static Response disconnectCallback(BlockActionRequest req, ActionContext ctx)
+ throws IOException, SlackApiException {
+ BlockActionPayload body = req.getPayload();
+ if (!PROVIDER_VALUE.equals(body.getActions().get(0).getValue())) {
+ return ctx.ack();
+ }
+ String userId = body.getUser().getId();
+ Connections.disconnect(userId);
+ PrimaryProvider acme = provider(userId);
+ ctx.client()
+ .appsUserConnectionRequestAuth(request -> request.userId(userId).primaryProvider(acme));
+ ctx.client().viewsPublish(request -> request.userId(userId).view(homeView(acme)));
+ return ctx.ack();
+ }
+
+ public static Response userConnectionCallback(EventsApiPayload payload, EventContext ctx)
+ throws IOException, SlackApiException {
+ UserConnectionEvent event = payload.getEvent();
+ if ("connect".equals(event.getSubtype())) {
+ ctx.client()
+ .appsUserConnectionRequestAuth(request ->
+ request.triggerId(event.getTriggerId()).primaryProvider(provider(event.getUser())));
+ }
+ return ctx.ack();
+ }
+
+ public static Response userConnectionStatusRequestedCallback(
+ EventsApiPayload payload, EventContext ctx)
+ throws IOException, SlackApiException {
+ String userId = payload.getEvent().getUser();
+ String status = Connections.isConnected(userId) ? "connected" : "disconnected";
+ ctx.client().appsUserConnectionUpdate(request -> request.userId(userId).status(status));
+ return ctx.ack();
+ }
+}
diff --git a/standardized-auth/src/main/resources/logback.xml b/standardized-auth/src/main/resources/logback.xml
new file mode 100644
index 0000000..9b1b639
--- /dev/null
+++ b/standardized-auth/src/main/resources/logback.xml
@@ -0,0 +1,16 @@
+
+
+
+
+ %d{HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/standardized-auth/src/test/java/acme/AcmeTest.java b/standardized-auth/src/test/java/acme/AcmeTest.java
new file mode 100644
index 0000000..9b6547f
--- /dev/null
+++ b/standardized-auth/src/test/java/acme/AcmeTest.java
@@ -0,0 +1,76 @@
+package acme;
+
+import static acme.Acme.homeView;
+import static acme.Acme.provider;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import com.slack.api.methods.request.apps.user.connection.AppsUserConnectionRequestAuthRequest.PrimaryProvider;
+import com.slack.api.model.view.View;
+import com.slack.api.util.json.GsonFactory;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+public class AcmeTest {
+ private TempConnections connections;
+
+ @BeforeEach
+ public void setUp() throws Exception {
+ connections = new TempConnections();
+ }
+
+ @AfterEach
+ public void tearDown() throws Exception {
+ connections.close();
+ }
+
+ @Test
+ public void testDisconnectedProviderOffersToConnect() {
+ String expected = """
+ {
+ "value": "acme",
+ "name": { "type": "plain_text", "text": "Acme account" },
+ "description": {
+ "type": "plain_text",
+ "text": "Order rocket skates, anvils, and giant magnets without leaving Slack."
+ },
+ "is_connected": false
+ }
+ """;
+
+ assertEquals(JsonParser.parseString(expected), json(provider("U0ADA")));
+ }
+
+ @Test
+ public void testConnectedProviderNamesTheSlackUser() {
+ Connections.connect("U0ADA");
+
+ PrimaryProvider acme = provider("U0ADA");
+
+ assertTrue(acme.getIsConnected());
+ assertEquals(
+ JsonParser.parseString("{\"type\":\"plain_text\",\"text\":\"U0ADA\"}"),
+ json(acme.getConnectedIdentity()));
+ }
+
+ @Test
+ public void testHomeViewRendersTheProviderAsAnAuthProviderBlock() {
+ PrimaryProvider acme = provider("U0ADA");
+
+ View view = homeView(acme);
+
+ JsonObject block =
+ json(view.getBlocks().get(view.getBlocks().size() - 1)).getAsJsonObject();
+ assertEquals("home", view.getType());
+ assertEquals("auth_provider", block.remove("type").getAsString());
+ assertEquals(json(acme), block);
+ }
+
+ private static JsonElement json(Object value) {
+ return JsonParser.parseString(GsonFactory.createSnakeCase().toJson(value));
+ }
+}
diff --git a/standardized-auth/src/test/java/acme/ConnectionsTest.java b/standardized-auth/src/test/java/acme/ConnectionsTest.java
new file mode 100644
index 0000000..7a8caf7
--- /dev/null
+++ b/standardized-auth/src/test/java/acme/ConnectionsTest.java
@@ -0,0 +1,55 @@
+package acme;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+public class ConnectionsTest {
+ private TempConnections connections;
+
+ @BeforeEach
+ public void setUp() throws Exception {
+ connections = new TempConnections();
+ }
+
+ @AfterEach
+ public void tearDown() throws Exception {
+ connections.close();
+ }
+
+ @Test
+ public void testRemembersAConnectedUser() {
+ Connections.connect("U0ADA");
+
+ assertTrue(Connections.isConnected("U0ADA"));
+ }
+
+ @Test
+ public void testDisconnectForgetsTheUser() {
+ Connections.connect("U0ADA");
+
+ Connections.disconnect("U0ADA");
+
+ assertFalse(Connections.isConnected("U0ADA"));
+ }
+
+ @Test
+ public void testDisconnectKeepsOtherUsersConnected() {
+ Connections.connect("U0ADA");
+ Connections.connect("U0BOB");
+
+ Connections.disconnect("U0ADA");
+
+ assertTrue(Connections.isConnected("U0BOB"));
+ }
+
+ @Test
+ public void testDisconnectingAnUnknownUserChangesNothing() {
+ Connections.disconnect("U0ADA");
+
+ assertFalse(Connections.isConnected("U0ADA"));
+ }
+}
diff --git a/standardized-auth/src/test/java/acme/MainTest.java b/standardized-auth/src/test/java/acme/MainTest.java
new file mode 100644
index 0000000..1adb75f
--- /dev/null
+++ b/standardized-auth/src/test/java/acme/MainTest.java
@@ -0,0 +1,232 @@
+package acme;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import com.slack.api.RequestConfigurator;
+import com.slack.api.app_backend.events.payload.EventsApiPayload;
+import com.slack.api.bolt.context.builtin.ActionContext;
+import com.slack.api.bolt.context.builtin.EventContext;
+import com.slack.api.bolt.request.RequestHeaders;
+import com.slack.api.bolt.request.builtin.BlockActionRequest;
+import com.slack.api.bolt.response.Response;
+import com.slack.api.bolt.util.EventsApiPayloadParser;
+import com.slack.api.methods.MethodsClient;
+import com.slack.api.methods.request.apps.user.connection.AppsUserConnectionRequestAuthRequest;
+import com.slack.api.methods.request.apps.user.connection.AppsUserConnectionRequestAuthRequest.PrimaryProvider;
+import com.slack.api.methods.request.apps.user.connection.AppsUserConnectionUpdateRequest;
+import com.slack.api.methods.request.views.ViewsPublishRequest;
+import com.slack.api.model.block.AuthProviderBlock;
+import com.slack.api.model.block.LayoutBlock;
+import com.slack.api.model.event.AppHomeOpenedEvent;
+import com.slack.api.model.event.Event;
+import com.slack.api.model.event.UserConnectionEvent;
+import com.slack.api.model.event.UserConnectionStatusRequestedEvent;
+import com.slack.api.util.json.GsonFactory;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+public class MainTest {
+ private TempConnections connections;
+
+ @Mock
+ private MethodsClient client;
+
+ @Captor
+ private ArgumentCaptor<
+ RequestConfigurator<
+ AppsUserConnectionRequestAuthRequest.AppsUserConnectionRequestAuthRequestBuilder>>
+ requestAuth;
+
+ @Captor
+ private ArgumentCaptor>
+ update;
+
+ @Captor
+ private ArgumentCaptor> publish;
+
+ private ActionContext actionContext;
+ private EventContext eventContext;
+
+ @BeforeEach
+ public void setUp() throws Exception {
+ connections = new TempConnections();
+ actionContext = spy(new ActionContext());
+ eventContext = spy(new EventContext());
+ lenient().doReturn(client).when(actionContext).client();
+ lenient().doReturn(client).when(eventContext).client();
+ }
+
+ @AfterEach
+ public void tearDown() throws Exception {
+ connections.close();
+ }
+
+ @Test
+ public void testAppHomeOpenedShowsTheConnection() throws Exception {
+ Connections.connect("U0ADA");
+ AppHomeOpenedEvent event = new AppHomeOpenedEvent();
+ event.setUser("U0ADA");
+
+ Main.appHomeOpenedCallback(payload(event), eventContext);
+
+ assertEquals("U0ADA", published().getUserId());
+ assertTrue(homeBlock().getIsConnected());
+ }
+
+ @Test
+ public void testConnectSavesAndShowsTheConnection() throws Exception {
+ Response response = Main.connectCallback(buttonClick("acme"), actionContext);
+
+ assertEquals(200, response.getStatusCode());
+ assertTrue(Connections.isConnected("U0ADA"));
+ assertTrue(sentProvider().getIsConnected());
+ assertTrue(homeBlock().getIsConnected());
+ }
+
+ @Test
+ public void testDisconnectForgetsAndShowsTheConnection() throws Exception {
+ Connections.connect("U0ADA");
+
+ Response response = Main.disconnectCallback(buttonClick("acme"), actionContext);
+
+ assertEquals(200, response.getStatusCode());
+ assertFalse(Connections.isConnected("U0ADA"));
+ assertFalse(sentProvider().getIsConnected());
+ assertFalse(homeBlock().getIsConnected());
+ }
+
+ @Test
+ public void testConnectIgnoresAnotherProvider() throws Exception {
+ Response response = Main.connectCallback(buttonClick("wile-e-coyote"), actionContext);
+
+ assertEquals(200, response.getStatusCode());
+ assertFalse(Connections.isConnected("U0ADA"));
+ verifyNoInteractions(client);
+ }
+
+ @Test
+ public void testDisconnectIgnoresAnotherProvider() throws Exception {
+ Connections.connect("U0ADA");
+
+ Response response = Main.disconnectCallback(buttonClick("wile-e-coyote"), actionContext);
+
+ assertEquals(200, response.getStatusCode());
+ assertTrue(Connections.isConnected("U0ADA"));
+ verifyNoInteractions(client);
+ }
+
+ @Test
+ public void testUserConnectionConnectOpensTheModal() throws Exception {
+ UserConnectionEvent event =
+ userConnection("{\"subtype\":\"connect\",\"user\":\"U0ADA\",\"trigger_id\":\"123.456.abc\"}");
+
+ Main.userConnectionCallback(payload(event), eventContext);
+
+ AppsUserConnectionRequestAuthRequest request = sentRequestAuth();
+ assertEquals("123.456.abc", request.getTriggerId());
+ assertFalse(request.getPrimaryProvider().getIsConnected());
+ }
+
+ @Test
+ public void testUserConnectionDisconnectIsIgnored() throws Exception {
+ UserConnectionEvent event =
+ userConnection("{\"subtype\":\"disconnect\",\"user\":\"U0ADA\",\"trigger_id\":\"123.456.abc\"}");
+
+ Main.userConnectionCallback(payload(event), eventContext);
+
+ verifyNoInteractions(client);
+ }
+
+ @Test
+ public void testUserConnectionRoutesBothSubtypesToOneListener() {
+ assertEquals("user_connection", EventsApiPayloadParser.getEventTypeAndSubtype(UserConnectionEvent.class));
+ }
+
+ @Test
+ public void testStatusRequestedReportsConnectedUsers() throws Exception {
+ Connections.connect("U0ADA");
+
+ Main.userConnectionStatusRequestedCallback(payload(statusRequested("U0ADA")), eventContext);
+
+ AppsUserConnectionUpdateRequest request = sentUpdate();
+ assertEquals("U0ADA", request.getUserId());
+ assertEquals("connected", request.getStatus());
+ }
+
+ @Test
+ public void testStatusRequestedReportsUnknownUsersAsDisconnected() throws Exception {
+ Main.userConnectionStatusRequestedCallback(payload(statusRequested("U0ADA")), eventContext);
+
+ AppsUserConnectionUpdateRequest request = sentUpdate();
+ assertEquals("U0ADA", request.getUserId());
+ assertEquals("disconnected", request.getStatus());
+ }
+
+ private AppsUserConnectionRequestAuthRequest sentRequestAuth() throws Exception {
+ verify(client).appsUserConnectionRequestAuth(requestAuth.capture());
+ return requestAuth
+ .getValue()
+ .configure(AppsUserConnectionRequestAuthRequest.builder())
+ .build();
+ }
+
+ private PrimaryProvider sentProvider() throws Exception {
+ return sentRequestAuth().getPrimaryProvider();
+ }
+
+ private AppsUserConnectionUpdateRequest sentUpdate() throws Exception {
+ verify(client).appsUserConnectionUpdate(update.capture());
+ return update.getValue()
+ .configure(AppsUserConnectionUpdateRequest.builder())
+ .build();
+ }
+
+ private ViewsPublishRequest published() throws Exception {
+ verify(client).viewsPublish(publish.capture());
+ return publish.getValue().configure(ViewsPublishRequest.builder()).build();
+ }
+
+ private AuthProviderBlock homeBlock() throws Exception {
+ List blocks = published().getView().getBlocks();
+ return (AuthProviderBlock) blocks.get(blocks.size() - 1);
+ }
+
+ private static BlockActionRequest buttonClick(String value) {
+ String payload = "{\"user\":{\"id\":\"U0ADA\"},\"actions\":[{\"value\":\"%s\"}]}".formatted(value);
+ return new BlockActionRequest(null, payload, new RequestHeaders(Map.of()));
+ }
+
+ private static UserConnectionEvent userConnection(String json) {
+ return GsonFactory.createSnakeCase().fromJson(json, UserConnectionEvent.class);
+ }
+
+ private static UserConnectionStatusRequestedEvent statusRequested(String userId) {
+ UserConnectionStatusRequestedEvent event = new UserConnectionStatusRequestedEvent();
+ event.setUser(userId);
+ return event;
+ }
+
+ @SuppressWarnings("unchecked")
+ private static EventsApiPayload payload(E event) {
+ EventsApiPayload payload = mock(EventsApiPayload.class);
+ when(payload.getEvent()).thenReturn(event);
+ return payload;
+ }
+}
diff --git a/standardized-auth/src/test/java/acme/TempConnections.java b/standardized-auth/src/test/java/acme/TempConnections.java
new file mode 100644
index 0000000..7777db7
--- /dev/null
+++ b/standardized-auth/src/test/java/acme/TempConnections.java
@@ -0,0 +1,22 @@
+package acme;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+public final class TempConnections {
+ private final Path original = Connections.filename;
+ private final Path dir;
+
+ public TempConnections() throws IOException {
+ dir = Files.createTempDirectory("acme-");
+ Connections.filename = dir.resolve("acme.properties");
+ }
+
+ public void close() throws IOException {
+ Path file = Connections.filename;
+ Connections.filename = original;
+ Files.deleteIfExists(file);
+ Files.delete(dir);
+ }
+}