diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/broker/events/AmazonQViewType.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/broker/events/AmazonQViewType.java index 7ccfb74a5..3622d9b22 100644 --- a/plugin/src/software/aws/toolkits/eclipse/amazonq/broker/events/AmazonQViewType.java +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/broker/events/AmazonQViewType.java @@ -6,6 +6,6 @@ public enum AmazonQViewType { TOOLKIT_LOGIN_VIEW, CHAT_VIEW, DEPENDENCY_MISSING_VIEW, RE_AUTHENTICATE_VIEW, CHAT_ASSET_MISSING_VIEW, - LSP_STARTUP_FAILED_VIEW + LSP_STARTUP_FAILED_VIEW, Q_DEV_ACCESS_BLOCKED_VIEW } diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/broker/events/QDevAccessBlockedState.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/broker/events/QDevAccessBlockedState.java new file mode 100644 index 000000000..f144d1697 --- /dev/null +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/broker/events/QDevAccessBlockedState.java @@ -0,0 +1,15 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.broker.events; + +/** + * Whether Amazon Q Developer has refused this identity at sign-in. + * + *

Reacting to the refusal signs the user out, which on its own would route to the ordinary login + * view and lose the explanation. This state is therefore resolved ahead of the logged-out state by + * {@code ViewRouter}, so the user lands on a screen that says what happened. + */ +public enum QDevAccessBlockedState { + NOT_BLOCKED, BLOCKED +} diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/broker/events/ViewRouterPluginState.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/broker/events/ViewRouterPluginState.java index 9a5e077fe..f20230eba 100644 --- a/plugin/src/software/aws/toolkits/eclipse/amazonq/broker/events/ViewRouterPluginState.java +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/broker/events/ViewRouterPluginState.java @@ -7,5 +7,5 @@ public record ViewRouterPluginState(AuthState authState, AmazonQLspState lspState, BrowserCompatibilityState browserCompatibilityState, ChatWebViewAssetState chatWebViewAssetState, ToolkitLoginWebViewAssetState toolkitLoginWebViewAssetState, - QDeveloperProfileState qDeveloperProfileState) { + QDeveloperProfileState qDeveloperProfileState, QDevAccessBlockedState qDevAccessBlockedState) { } diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspClient.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspClient.java index 54ae81373..3770567af 100644 --- a/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspClient.java +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspClient.java @@ -16,6 +16,7 @@ import software.aws.toolkits.eclipse.amazonq.lsp.auth.model.SsoTokenChangedParams; import software.aws.toolkits.eclipse.amazonq.lsp.model.ConnectionMetadata; import software.aws.toolkits.eclipse.amazonq.lsp.model.OpenFileDiffParams; +import software.aws.toolkits.eclipse.amazonq.lsp.model.ShowNotificationParams; public interface AmazonQLspClient extends LanguageClient { @@ -61,6 +62,9 @@ public interface AmazonQLspClient extends LanguageClient { @JsonNotification("aws/didCreateDirectory") void didCreateDirectory(Object params); + @JsonNotification("aws/window/showNotification") + void showNotification(ShowNotificationParams params); + @JsonNotification("aws/chat/sendPinnedContext") void sendPinnedContext(Object params); } diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspClientImpl.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspClientImpl.java index dbeda359d..70dec828d 100644 --- a/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspClientImpl.java +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspClientImpl.java @@ -82,6 +82,8 @@ import software.aws.toolkits.eclipse.amazonq.lsp.auth.model.SsoTokenChangedParams; import software.aws.toolkits.eclipse.amazonq.lsp.model.ConnectionMetadata; import software.aws.toolkits.eclipse.amazonq.lsp.model.OpenFileDiffParams; +import software.aws.toolkits.eclipse.amazonq.lsp.model.ShowNotificationParams; +import software.aws.toolkits.eclipse.amazonq.broker.events.QDevAccessBlockedState; import software.aws.toolkits.eclipse.amazonq.lsp.model.OpenTabUiResponse; import software.aws.toolkits.eclipse.amazonq.lsp.model.SsoProfileData; import software.aws.toolkits.eclipse.amazonq.lsp.model.TelemetryEvent; @@ -257,6 +259,37 @@ public final void ssoTokenChanged(final SsoTokenChangedParams params) { } } + /** + * Handles the language server's generic notification channel. Today the only notification this + * plugin acts on is the report that Amazon Q Developer has refused this identity at sign-in. + * + *

Reacting means signing the user out and routing to an explanation. Sign-out happens here + * rather than in the view because the session is already useless: every Q request from this + * identity is refused, so leaving the user signed in would show a working-looking IDE that + * silently does nothing. + * + *

Never throws. This runs on the LSP message thread and is shared by every future + * notification, so a failure to classify one must not take the channel down. + */ + @Override + public final void showNotification(final ShowNotificationParams params) { + try { + if (!QDevAccessBlockedNotification.isAccessBlocked(params)) { + return; + } + + String message = params.content() == null ? null : params.content().text(); + Activator.getLogger().info("Amazon Q Developer access is blocked for this identity: " + message); + + // Publish before signing out. Sign-out makes the router re-evaluate, and the blocked + // state has to be in place by then or the router resolves the ordinary login view. + Activator.getEventBroker().post(QDevAccessBlockedState.class, QDevAccessBlockedState.BLOCKED); + Activator.getLoginService().logout(); + } catch (Exception e) { + Activator.getLogger().error("Failed to handle showNotification", e); + } + } + @Override public final void sendContextCommands(final Object params) { var command = ChatUIInboundCommand.createCommand("aws/chat/sendContextCommands", params); diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspServerBuilder.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspServerBuilder.java index 6b657c856..d43fdced6 100644 --- a/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspServerBuilder.java +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspServerBuilder.java @@ -63,6 +63,9 @@ private Map getInitializationOptions(final ClientMetadata metada awsClientCapabilities.put("q", qOptions); Map window = new HashMap<>(); window.put("showSaveFileDialog", true); + // Required. The runtime builds no notification router unless the client asks for one, and + // then drops aws/window/showNotification silently -- no error, no log. + window.put("notifications", true); awsClientCapabilities.put("window", window); awsInitOptions.put("awsClientCapabilities", awsClientCapabilities); initOptions.put("aws", awsInitOptions); diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/QDevAccessBlockedNotification.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/QDevAccessBlockedNotification.java new file mode 100644 index 000000000..150f9b8e5 --- /dev/null +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/QDevAccessBlockedNotification.java @@ -0,0 +1,73 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.lsp; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import software.aws.toolkits.eclipse.amazonq.lsp.model.ShowNotificationParams; +import software.aws.toolkits.eclipse.amazonq.util.ObjectMapperFactory; + +/** + * Recognises the notification the language server raises when Amazon Q Developer refuses an identity + * at sign-in. + * + *

Only the language server ever observes the refusal. The service gates on the User-Agent of the + * shared language server, so the plugin's own SDK calls are allowed unconditionally -- there is no + * client-side signal to classify. The server reports it over the existing notification channel and + * this class decides whether a given notification is that report. + * + *

Identification is by id, never by title. The runtime's router rewrites the declared id into + * base64 of {@code {"serverName":...,"id":...}}, so the raw id never reaches us and a title match + * would sign out a working user the first time an unrelated error reused the same title. + */ +public final class QDevAccessBlockedNotification { + + /** Id declared by the server for this notification, found inside the routed envelope. */ + private static final String BLOCKED_NOTIFICATION_ID = "qDevPluginAccessBlocked"; + + private static final ObjectMapper OBJECT_MAPPER = ObjectMapperFactory.getInstance(); + + private QDevAccessBlockedNotification() { + // utility class + } + + /** + * @return true when the given notification reports that this identity is blocked from Amazon Q + * Developer. Never throws: an unrecognised or malformed notification is simply not a + * match, because failing to classify one must not break the notification channel for + * every other message that uses it. + */ + public static boolean isAccessBlocked(final ShowNotificationParams params) { + if (params == null) { + return false; + } + return BLOCKED_NOTIFICATION_ID.equals(resolveId(params.id())); + } + + /** + * Resolves the id the server declared. Accepts the routed form, base64 of + * {@code {"serverName":...,"id":...}}, and falls back to the raw value so that a server or + * runtime that does not wrap the id still matches. + */ + private static String resolveId(final String id) { + if (id == null || id.isBlank()) { + return null; + } + try { + String decoded = new String(Base64.getDecoder().decode(id), StandardCharsets.UTF_8); + JsonNode node = OBJECT_MAPPER.readTree(decoded); + JsonNode inner = node.get("id"); + if (inner != null && inner.isTextual()) { + return inner.asText(); + } + } catch (Exception e) { + // Not a routed envelope. Fall through and treat the value as a plain id. + } + return id; + } +} diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/model/ShowNotificationParams.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/model/ShowNotificationParams.java new file mode 100644 index 000000000..8b914de95 --- /dev/null +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/model/ShowNotificationParams.java @@ -0,0 +1,24 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.lsp.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** + * Parameters of the {@code aws/window/showNotification} notification, the language server's generic + * channel for surfacing a message to the user. + * + *

The {@code id} does not arrive as the server declared it. The runtime's router rewrites it into + * base64 of {@code {"serverName":...,"id":...}} so that a follow-up action can be routed back to the + * server that raised it. Callers must therefore decode the envelope and compare the inner id rather + * than this field, and must never key behaviour off {@code content.title} -- titles are shared + * between unrelated notifications, so matching on one would fire this handler for the wrong message. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record ShowNotificationParams(String id, String type, NotificationContent content) { + + @JsonIgnoreProperties(ignoreUnknown = true) + public record NotificationContent(String title, String text) { + } +} diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/views/AmazonQViewContainer.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/views/AmazonQViewContainer.java index 427402201..22d5bbfbd 100644 --- a/plugin/src/software/aws/toolkits/eclipse/amazonq/views/AmazonQViewContainer.java +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/views/AmazonQViewContainer.java @@ -35,7 +35,8 @@ AmazonQViewType.DEPENDENCY_MISSING_VIEW, new DependencyMissingView(), AmazonQViewType.RE_AUTHENTICATE_VIEW, new ReauthenticateView(), AmazonQViewType.LSP_STARTUP_FAILED_VIEW, new LspStartUpFailedView(), AmazonQViewType.CHAT_VIEW, new AmazonQChatWebview(), - AmazonQViewType.TOOLKIT_LOGIN_VIEW, new ToolkitLoginWebview()); + AmazonQViewType.TOOLKIT_LOGIN_VIEW, new ToolkitLoginWebview(), + AmazonQViewType.Q_DEV_ACCESS_BLOCKED_VIEW, new QDevAccessBlockedView()); } public AmazonQViewContainer() { diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/views/QDevAccessBlockedView.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/views/QDevAccessBlockedView.java new file mode 100644 index 000000000..60d5ff552 --- /dev/null +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/views/QDevAccessBlockedView.java @@ -0,0 +1,99 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.views; + +import org.eclipse.swt.SWT; +import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.events.SelectionListener; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Link; + +import software.aws.toolkits.eclipse.amazonq.broker.events.QDevAccessBlockedState; +import software.aws.toolkits.eclipse.amazonq.plugin.Activator; +import software.aws.toolkits.eclipse.amazonq.util.PluginUtils; + +/** + * Shown when Amazon Q Developer refuses this identity at sign-in. + * + *

Amazon Q Developer stopped accepting new Builder ID accounts. Such an account signs in + * successfully -- sign-in is OIDC and is never gated -- and then finds Q silently non-functional, + * with the service's refusal arriving as if it were a chat reply. This view replaces that dead end + * with an explanation, a pointer to Kiro, and a route back to sign-in for anyone whose Builder ID + * predates the cutoff. + * + *

The dates and URLs below are product copy taken from the public announcement, not values + * reported by the service. The service's own message is deliberately not displayed: it is a single + * sentence written for an API consumer, and it does not say what the user should do next. + */ +public final class QDevAccessBlockedView extends CallToActionView { + + public static final String ID = "software.aws.toolkits.eclipse.amazonq.views.QDevAccessBlockedView"; + + private static final String ICON_PATH = "icons/AmazonQ64.png"; + private static final String HEADER_LABEL = "New sign-ups are no longer available"; + private static final String SIGNUP_CUTOFF_DATE = "May 15, 2026"; + private static final String END_OF_SUPPORT_DATE = "April 30, 2027"; + private static final String DETAIL_MESSAGE = "Amazon Q Developer stopped accepting new accounts as of " + + SIGNUP_CUTOFF_DATE + ". Amazon Q Developer IDE plugins are reaching end of support on " + + END_OF_SUPPORT_DATE + "." + + System.lineSeparator() + System.lineSeparator() + + "Kiro includes all the AI coding features from Q Developer, plus spec-driven development and more." + + System.lineSeparator() + System.lineSeparator() + + "If your Builder ID was created before " + SIGNUP_CUTOFF_DATE + + ", you can still sign in -- only newly created accounts are blocked."; + private static final String BUTTON_LABEL = "Get started with Kiro"; + private static final String LINK_LABEL = "Try a different login method"; + + private static final String KIRO_URL = "https://kiro.dev"; + + @Override + protected String getIconPath() { + return ICON_PATH; + } + + @Override + protected String getHeaderLabel() { + return HEADER_LABEL; + } + + @Override + protected String getDetailMessage() { + return DETAIL_MESSAGE; + } + + @Override + protected String getButtonLabel() { + return BUTTON_LABEL; + } + + @Override + protected SelectionListener getButtonHandler() { + return new SelectionAdapter() { + @Override + public void widgetSelected(final SelectionEvent e) { + PluginUtils.openWebpage(KIRO_URL); + } + }; + } + + @Override + protected void setupButtonFooterContent(final Composite composite) { + Link hyperlink = new Link(composite, SWT.NONE); + hyperlink.setText("" + LINK_LABEL + ""); + hyperlink.setLayoutData(new GridData(SWT.CENTER, SWT.CENTER, true, false)); + hyperlink.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(final SelectionEvent e) { + /* + * Clearing the state is what returns the user to sign-in: reacting to the refusal + * already signed them out, so the router resolves the logged-out state as soon as + * this view stops taking priority. Signing out again here would be a no-op. + */ + Activator.getEventBroker().post(QDevAccessBlockedState.class, QDevAccessBlockedState.NOT_BLOCKED); + } + }); + } +} diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/views/router/ViewRouter.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/views/router/ViewRouter.java index 372f6a8c8..23221e277 100644 --- a/plugin/src/software/aws/toolkits/eclipse/amazonq/views/router/ViewRouter.java +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/views/router/ViewRouter.java @@ -10,6 +10,7 @@ import software.aws.toolkits.eclipse.amazonq.broker.events.AmazonQViewType; import software.aws.toolkits.eclipse.amazonq.broker.events.BrowserCompatibilityState; import software.aws.toolkits.eclipse.amazonq.broker.events.ChatWebViewAssetState; +import software.aws.toolkits.eclipse.amazonq.broker.events.QDevAccessBlockedState; import software.aws.toolkits.eclipse.amazonq.broker.events.QDeveloperProfileState; import software.aws.toolkits.eclipse.amazonq.broker.events.ToolkitLoginWebViewAssetState; import software.aws.toolkits.eclipse.amazonq.broker.events.ViewRouterPluginState; @@ -69,6 +70,11 @@ private ViewRouter(final Builder builder) { builder.qDeveloperProfileStateObservable = Activator.getEventBroker() .ofObservable(QDeveloperProfileState.class); } + + if (builder.qDevAccessBlockedStateObservable == null) { + builder.qDevAccessBlockedStateObservable = Activator.getEventBroker() + .ofObservable(QDevAccessBlockedState.class); + } /** * Combines all state observables into a single stream that emits a new PluginState * whenever any individual state changes. The combined stream: @@ -78,7 +84,7 @@ private ViewRouter(final Builder builder) { Observable.combineLatest(builder.authStateObservable, builder.lspStateObservable, builder.browserCompatibilityStateObservable, builder.chatWebViewAssetStateObservable, builder.toolkitLoginWebViewAssetStateObservable, builder.qDeveloperProfileStateObservable, - ViewRouterPluginState::new).observeOn(Schedulers.computation()).subscribe(this::onEvent); + builder.qDevAccessBlockedStateObservable, ViewRouterPluginState::new).observeOn(Schedulers.computation()).subscribe(this::onEvent); } public static Builder builder() { @@ -117,6 +123,13 @@ private void refreshActiveView(final ViewRouterPluginState pluginState) { } else if (pluginState.chatWebViewAssetState() == ChatWebViewAssetState.DEPENDENCY_MISSING || pluginState.toolkitLoginWebViewAssetState() == ToolkitLoginWebViewAssetState.DEPENDENCY_MISSING) { newActiveView = AmazonQViewType.CHAT_ASSET_MISSING_VIEW; + } else if (pluginState.qDevAccessBlockedState() == QDevAccessBlockedState.BLOCKED) { + /* + * Resolved ahead of the logged-out state on purpose. Reacting to the refusal signs the + * user out, so this state and the logged-out state are always true together; checking + * logged out first would show the ordinary login view and lose the explanation. + */ + newActiveView = AmazonQViewType.Q_DEV_ACCESS_BLOCKED_VIEW; } else if (pluginState.authState().isLoggedOut()) { newActiveView = AmazonQViewType.TOOLKIT_LOGIN_VIEW; } else if (pluginState.authState().isExpired()) { @@ -160,6 +173,7 @@ public static final class Builder { private Observable chatWebViewAssetStateObservable; private Observable toolkitLoginWebViewAssetStateObservable; private Observable qDeveloperProfileStateObservable; + private Observable qDevAccessBlockedStateObservable; public Builder withAuthStateObservable(final Observable authStateObservable) { this.authStateObservable = authStateObservable; @@ -195,6 +209,12 @@ public Builder withQDeveloperProfileStateObservable( return this; } + public Builder withQDevAccessBlockedStateObservable( + final Observable qDevAccessBlockedStateObservable) { + this.qDevAccessBlockedStateObservable = qDevAccessBlockedStateObservable; + return this; + } + public ViewRouter build() { return new ViewRouter(this); } diff --git a/plugin/tst/software/aws/toolkits/eclipse/amazonq/lsp/QDevAccessBlockedNotificationTest.java b/plugin/tst/software/aws/toolkits/eclipse/amazonq/lsp/QDevAccessBlockedNotificationTest.java new file mode 100644 index 000000000..45a45dab9 --- /dev/null +++ b/plugin/tst/software/aws/toolkits/eclipse/amazonq/lsp/QDevAccessBlockedNotificationTest.java @@ -0,0 +1,80 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.lsp; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import org.junit.jupiter.api.Test; + +import software.aws.toolkits.eclipse.amazonq.lsp.model.ShowNotificationParams; +import software.aws.toolkits.eclipse.amazonq.lsp.model.ShowNotificationParams.NotificationContent; + +public final class QDevAccessBlockedNotificationTest { + + private static final String BLOCKED_ID = "qDevPluginAccessBlocked"; + private static final String SHARED_TITLE = "Amazon Q Developer"; + + private static String routed(final String id) { + String envelope = "{\"serverName\":\"AmazonQ-For-Eclipse\",\"id\":\"" + id + "\"}"; + return Base64.getEncoder().encodeToString(envelope.getBytes(StandardCharsets.UTF_8)); + } + + private static ShowNotificationParams params(final String id, final String title) { + return new ShowNotificationParams(id, "error", new NotificationContent(title, "blocked message")); + } + + @Test + void matchesTheRoutedEnvelopeTheRuntimeActuallySends() { + assertTrue(QDevAccessBlockedNotification.isAccessBlocked(params(routed(BLOCKED_ID), SHARED_TITLE))); + } + + @Test + void matchesAPlainIdSoAnUnroutedServerStillWorks() { + assertTrue(QDevAccessBlockedNotification.isAccessBlocked(params(BLOCKED_ID, SHARED_TITLE))); + } + + /** + * The whole point of matching on the id: an unrelated error that happens to share the title must + * not sign the user out. Matching on title instead would fire here. + */ + @Test + void ignoresADifferentNotificationThatSharesTheTitle() { + assertFalse(QDevAccessBlockedNotification.isAccessBlocked(params(routed("someOtherError"), SHARED_TITLE))); + } + + @Test + void ignoresANotificationWithNoId() { + assertFalse(QDevAccessBlockedNotification.isAccessBlocked(params(null, SHARED_TITLE))); + assertFalse(QDevAccessBlockedNotification.isAccessBlocked(params("", SHARED_TITLE))); + } + + @Test + void ignoresNullParams() { + assertFalse(QDevAccessBlockedNotification.isAccessBlocked(null)); + } + + /** + * Malformed input must be a non-match rather than an exception: this classifier sits on the + * shared notification channel, so throwing would break every other notification. + */ + @Test + void treatsMalformedInputAsNotBlocked() { + assertFalse(QDevAccessBlockedNotification.isAccessBlocked(params("!!!not-base64!!!", SHARED_TITLE))); + String base64OfNonJson = Base64.getEncoder().encodeToString("not json".getBytes(StandardCharsets.UTF_8)); + assertFalse(QDevAccessBlockedNotification.isAccessBlocked(params(base64OfNonJson, SHARED_TITLE))); + String envelopeWithoutId = Base64.getEncoder() + .encodeToString("{\"serverName\":\"x\"}".getBytes(StandardCharsets.UTF_8)); + assertFalse(QDevAccessBlockedNotification.isAccessBlocked(params(envelopeWithoutId, SHARED_TITLE))); + } + + @Test + void doesNotRequireContentToBePresent() { + assertTrue(QDevAccessBlockedNotification + .isAccessBlocked(new ShowNotificationParams(routed(BLOCKED_ID), "error", null))); + } +} diff --git a/plugin/tst/software/aws/toolkits/eclipse/amazonq/views/router/ViewRouterTest.java b/plugin/tst/software/aws/toolkits/eclipse/amazonq/views/router/ViewRouterTest.java index 7e7b88698..29642300a 100644 --- a/plugin/tst/software/aws/toolkits/eclipse/amazonq/views/router/ViewRouterTest.java +++ b/plugin/tst/software/aws/toolkits/eclipse/amazonq/views/router/ViewRouterTest.java @@ -25,6 +25,7 @@ import software.aws.toolkits.eclipse.amazonq.broker.events.AmazonQViewType; import software.aws.toolkits.eclipse.amazonq.broker.events.BrowserCompatibilityState; import software.aws.toolkits.eclipse.amazonq.broker.events.ChatWebViewAssetState; +import software.aws.toolkits.eclipse.amazonq.broker.events.QDevAccessBlockedState; import software.aws.toolkits.eclipse.amazonq.broker.events.QDeveloperProfileState; import software.aws.toolkits.eclipse.amazonq.broker.events.ToolkitLoginWebViewAssetState; import software.aws.toolkits.eclipse.amazonq.extensions.implementation.ActivatorStaticMockExtension; @@ -41,6 +42,7 @@ public final class ViewRouterTest { private Observable chatWebViewAssetStateObservable; private Observable toolkitLoginWebViewAssetStateObservable; private Observable qDeveloperProfileStateObservable; + private Observable qDevAccessBlockedStateObservable; private ViewRouter viewRouter; private EventBroker eventBrokerMock; @@ -61,6 +63,7 @@ void setupBeforeEach() { chatWebViewAssetStateObservable = publishSubject.ofType(ChatWebViewAssetState.class); toolkitLoginWebViewAssetStateObservable = publishSubject.ofType(ToolkitLoginWebViewAssetState.class); qDeveloperProfileStateObservable = publishSubject.ofType(QDeveloperProfileState.class); + qDevAccessBlockedStateObservable = publishSubject.ofType(QDevAccessBlockedState.class); eventBrokerMock = activatorStaticMockExtension.getMock(EventBroker.class); @@ -69,7 +72,8 @@ void setupBeforeEach() { .withBrowserCompatibilityStateObservable(browserCompatibilityStateObservable) .withChatWebViewAssetStateObservable(chatWebViewAssetStateObservable) .withToolkitLoginWebViewAssetStateObservable(toolkitLoginWebViewAssetStateObservable) - .withQDeveloperProfileStateObservable(qDeveloperProfileStateObservable).build(); + .withQDeveloperProfileStateObservable(qDeveloperProfileStateObservable) + .withQDevAccessBlockedStateObservable(qDevAccessBlockedStateObservable).build(); } @AfterEach @@ -89,11 +93,48 @@ void testActiveViewResolutionBasedOnPluginState(final AmazonQLspState lspState, publishSubject.onNext(browserCompatibilityState); publishSubject.onNext(chatWebViewAssetState); publishSubject.onNext(toolkitLoginWebViewAssetState); - publishSubject.onNext(QDeveloperProfileState.AVAILABLE); // does not affect view selection + publishSubject.onNext(QDeveloperProfileState.AVAILABLE); + publishSubject.onNext(QDevAccessBlockedState.NOT_BLOCKED); // does not affect view selection verify(eventBrokerMock).post(AmazonQViewType.class, expectedActiveViewType); } + /** + * Reacting to the refusal signs the user out, so BLOCKED and logged-out are always true + * together. The router must resolve BLOCKED first, otherwise the user lands on the ordinary + * login view and the explanation is lost. + */ + @Test + void testAccessBlockedTakesPriorityOverLoggedOut() { + publishSubject.onNext(getAuthStateObject(AuthStateType.LOGGED_OUT)); + publishSubject.onNext(AmazonQLspState.ACTIVE); + publishSubject.onNext(BrowserCompatibilityState.COMPATIBLE); + publishSubject.onNext(ChatWebViewAssetState.RESOLVED); + publishSubject.onNext(ToolkitLoginWebViewAssetState.RESOLVED); + publishSubject.onNext(QDeveloperProfileState.AVAILABLE); + publishSubject.onNext(QDevAccessBlockedState.BLOCKED); + + verify(eventBrokerMock).post(AmazonQViewType.class, AmazonQViewType.Q_DEV_ACCESS_BLOCKED_VIEW); + } + + /** + * Clearing the state is what returns the user to sign-in: they are already signed out, so the + * router should fall through to the login view. + */ + @Test + void testClearingAccessBlockedReturnsToLoginView() { + publishSubject.onNext(getAuthStateObject(AuthStateType.LOGGED_OUT)); + publishSubject.onNext(AmazonQLspState.ACTIVE); + publishSubject.onNext(BrowserCompatibilityState.COMPATIBLE); + publishSubject.onNext(ChatWebViewAssetState.RESOLVED); + publishSubject.onNext(ToolkitLoginWebViewAssetState.RESOLVED); + publishSubject.onNext(QDeveloperProfileState.AVAILABLE); + publishSubject.onNext(QDevAccessBlockedState.BLOCKED); + publishSubject.onNext(QDevAccessBlockedState.NOT_BLOCKED); + + verify(eventBrokerMock).post(AmazonQViewType.class, AmazonQViewType.TOOLKIT_LOGIN_VIEW); + } + @Test void testDuplicateViewIdPublishedWhenDeveloperProfileSelected() { publishSubject.onNext(getAuthStateObject(AuthStateType.LOGGED_IN)); @@ -102,6 +143,7 @@ void testDuplicateViewIdPublishedWhenDeveloperProfileSelected() { publishSubject.onNext(ChatWebViewAssetState.RESOLVED); publishSubject.onNext(ToolkitLoginWebViewAssetState.RESOLVED); publishSubject.onNext(QDeveloperProfileState.AVAILABLE); + publishSubject.onNext(QDevAccessBlockedState.NOT_BLOCKED); publishSubject.onNext(getAuthStateObject(AuthStateType.LOGGED_IN)); publishSubject.onNext(AmazonQLspState.ACTIVE);