From 4312189d42656964d1d16fad88b5cdc33ab7cb5a Mon Sep 17 00:00:00 2001 From: Ashish Reddy Podduturi Date: Thu, 20 Aug 2026 13:44:44 -0700 Subject: [PATCH] feat: distinct sign-in error when Q Developer access is blocked 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, because every Q request from that identity is refused and the refusal surfaces as if it were a chat reply. No explanation, and nothing the user can act on. Only the language server 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 and there is no client-side signal to classify. The server reports it over the existing notification channel, so this change teaches the plugin to listen. - Declare aws/window/showNotification on the client and act on it. - Advertise window.notifications. Without it the runtime builds no notification router and drops the notification silently -- no error and no log, which is the failure mode that cost the most time on the VS Code and JetBrains ports. - Identify the notification by id, never by title. The runtime's router rewrites the declared id into base64 of {"serverName":...,"id":...}, so the raw id never arrives, and a title match would sign out a working user the first time an unrelated error reused the title "Amazon Q Developer". - Add QDevAccessBlockedState and resolve it in ViewRouter ahead of the logged-out state. Reacting to the refusal signs the user out, so the two states are always true together; checking logged out first would show the ordinary login view and lose the explanation. - Add QDevAccessBlockedView, following ReauthenticateView, explaining what happened, pointing to Kiro, and offering a route back to sign-in for pre-cutoff Builder IDs. The screen carries its own copy rather than displaying the service's message: that message is one sentence written for an API consumer and does not say what to do next. Consequently the dates on the screen are product copy from the public announcement, not values reported by the service. Ports the behaviour already shipped in VS Code (aws/amazon-q-vscode#159) and JetBrains (aws/amazon-q-jetbrains#120). Verified: mvn -B package on Corretto 17 -- BUILD SUCCESS, 508 tests, 0 failures, including 7 new classifier tests and ViewRouterTest at 14/14 with 2 new routing tests. --- .../broker/events/AmazonQViewType.java | 2 +- .../broker/events/QDevAccessBlockedState.java | 15 +++ .../broker/events/ViewRouterPluginState.java | 2 +- .../eclipse/amazonq/lsp/AmazonQLspClient.java | 4 + .../amazonq/lsp/AmazonQLspClientImpl.java | 33 +++++++ .../amazonq/lsp/AmazonQLspServerBuilder.java | 3 + .../lsp/QDevAccessBlockedNotification.java | 73 ++++++++++++++ .../lsp/model/ShowNotificationParams.java | 24 +++++ .../amazonq/views/AmazonQViewContainer.java | 3 +- .../amazonq/views/QDevAccessBlockedView.java | 99 +++++++++++++++++++ .../amazonq/views/router/ViewRouter.java | 22 ++++- .../QDevAccessBlockedNotificationTest.java | 80 +++++++++++++++ .../amazonq/views/router/ViewRouterTest.java | 46 ++++++++- 13 files changed, 400 insertions(+), 6 deletions(-) create mode 100644 plugin/src/software/aws/toolkits/eclipse/amazonq/broker/events/QDevAccessBlockedState.java create mode 100644 plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/QDevAccessBlockedNotification.java create mode 100644 plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/model/ShowNotificationParams.java create mode 100644 plugin/src/software/aws/toolkits/eclipse/amazonq/views/QDevAccessBlockedView.java create mode 100644 plugin/tst/software/aws/toolkits/eclipse/amazonq/lsp/QDevAccessBlockedNotificationTest.java 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);