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