diff --git a/package.json b/package.json index 08cfcf66d..7439bbee6 100644 --- a/package.json +++ b/package.json @@ -459,6 +459,12 @@ "title": "Coder: Export Telemetry", "icon": "$(save)" }, + { + "command": "coder.viewAnnouncements", + "title": "View Announcements", + "category": "Coder", + "icon": "$(megaphone)" + }, { "command": "coder.openAppStatus", "title": "Open App Status", @@ -587,6 +593,10 @@ "command": "coder.exportTelemetry", "when": "true" }, + { + "command": "coder.viewAnnouncements", + "when": "coder.authenticated" + }, { "command": "coder.openAppStatus", "when": "false" diff --git a/src/announcements/banners.ts b/src/announcements/banners.ts new file mode 100644 index 000000000..745f89f1e --- /dev/null +++ b/src/announcements/banners.ts @@ -0,0 +1,86 @@ +import { createHash } from "node:crypto"; + +import type { + AppearanceConfig, + BannerConfig, +} from "coder/site/src/api/typesGenerated"; + +const POPUP_MESSAGE_MAX_LENGTH = 120; + +export type AnnouncementSource = "announcement" | "service"; + +export interface Announcement { + readonly source: AnnouncementSource; + readonly message: string; + readonly backgroundColor?: string; + readonly key: string; +} + +export function normalizeBanners( + appearance: AppearanceConfig, +): readonly Announcement[] { + return [ + toAnnouncement("service", appearance.service_banner), + ...appearance.announcement_banners.map((banner) => + toAnnouncement("announcement", banner), + ), + ].filter((banner): banner is Announcement => banner !== undefined); +} + +export function bannerKey( + banner: Pick, +): string { + return createHash("sha256") + .update( + JSON.stringify({ + source: banner.source, + message: banner.message, + backgroundColor: banner.backgroundColor ?? "", + }), + ) + .digest("hex") + .slice(0, 16); +} + +export function statusText(count: number): string { + return count === 1 ? "$(megaphone) Coder" : `$(megaphone) Coder ${count}`; +} + +export function statusTooltip(banners: readonly Announcement[]): string { + return [ + banners.length === 1 + ? "Coder deployment announcement" + : "Coder deployment announcements", + "", + ...banners.map((banner, index) => `${index + 1}. ${banner.message}`), + ].join("\n"); +} + +export function popupMessage(banners: readonly Announcement[]): string { + return banners.length === 1 + ? `Coder announcement: ${truncate(banners[0].message)}` + : `Coder has ${banners.length} new deployment announcements.`; +} + +function toAnnouncement( + source: AnnouncementSource, + banner: BannerConfig, +): Announcement | undefined { + const message = banner.message?.trim(); + const backgroundColor = banner.background_color?.trim() || undefined; + if (!banner.enabled || !message) { + return undefined; + } + return { + source, + message, + backgroundColor, + key: bannerKey({ source, message, backgroundColor }), + }; +} + +function truncate(message: string): string { + return message.length <= POPUP_MESSAGE_MAX_LENGTH + ? message + : `${message.slice(0, POPUP_MESSAGE_MAX_LENGTH - 1)}…`; +} diff --git a/src/announcements/manager.ts b/src/announcements/manager.ts new file mode 100644 index 000000000..5c81997a0 --- /dev/null +++ b/src/announcements/manager.ts @@ -0,0 +1,196 @@ +import * as vscode from "vscode"; + +import { type CoderApi } from "../api/coderApi"; +import { type SecretsManager } from "../core/secretsManager"; +import { type SessionState } from "../deployment/sessionStore"; +import { type Logger } from "../logging/logger"; +import { areNotificationsDisabled } from "../settings/notifications"; + +import { + type Announcement, + normalizeBanners, + popupMessage, + statusText, + statusTooltip, +} from "./banners"; + +const REFRESH_INTERVAL_MS = 30 * 60 * 1000; +const VIEW_ACTION = "View"; + +interface RefreshOptions { + readonly notify?: boolean; + readonly showErrors?: boolean; +} + +export class AnnouncementManager implements vscode.Disposable { + private readonly statusBarItem: vscode.StatusBarItem; + private readonly sessionChangeDisposable: vscode.Disposable; + private banners: readonly Announcement[] = []; + private refreshTimeout: NodeJS.Timeout | undefined; + private disposed = false; + + public constructor( + private readonly client: Pick, + private readonly sessionState: SessionState, + private readonly secretsManager: SecretsManager, + private readonly logger: Logger, + ) { + this.statusBarItem = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Left, + 998, + ); + this.statusBarItem.name = "Coder Announcements"; + this.statusBarItem.command = "coder.viewAnnouncements"; + this.sessionChangeDisposable = this.sessionState.onDidChange(() => { + this.onSessionChange(); + }); + this.onSessionChange(); + } + + public dispose(): void { + this.disposed = true; + this.cancelRefresh(); + this.sessionChangeDisposable.dispose(); + this.statusBarItem.dispose(); + } + + public async refresh( + options: RefreshOptions = {}, + ): Promise { + if (this.disposed) { + return undefined; + } + this.cancelRefresh(); + try { + return await this.fetch(options); + } catch (error) { + this.logger.warn("Failed to refresh Coder announcements", error); + if (options.showErrors) { + void vscode.window.showErrorMessage( + `Failed to refresh Coder announcements: ${errorMessage(error)}`, + ); + } + return undefined; + } finally { + this.scheduleRefresh(); + } + } + + public async showAnnouncements(): Promise { + const banners = + (await this.refresh({ notify: false, showErrors: true })) ?? this.banners; + if (banners.length === 0) { + void vscode.window.showInformationMessage( + "No active Coder announcements.", + ); + return; + } + + const selected = await vscode.window.showQuickPick( + banners.map((banner, index) => ({ + label: `${banner.source === "service" ? "$(info) Service banner" : "$(megaphone) Announcement"} ${index + 1}`, + detail: banner.message, + description: banner.backgroundColor, + banner, + })), + { + title: "Coder Announcements", + placeHolder: "Select an announcement to view the full message", + }, + ); + if (selected) { + void vscode.window.showInformationMessage(selected.banner.message); + } + } + + private onSessionChange(): void { + this.cancelRefresh(); + this.setBanners([]); + if (this.sessionState.current.kind === "signedIn") { + void this.refresh({ notify: true }); + } + } + + private async fetch( + options: RefreshOptions, + ): Promise { + const session = this.sessionState.current; + if (session.kind !== "signedIn") { + this.setBanners([]); + return []; + } + + const banners = normalizeBanners(await this.client.getAppearance()); + if (this.disposed || this.sessionState.current !== session) { + return undefined; + } + this.setBanners(banners); + + const seen = new Set( + this.secretsManager.getSeenBanners(session.deployment.safeHostname), + ); + const unseen = banners.filter((banner) => !seen.has(banner.key)); + if ( + options.notify && + unseen.length > 0 && + !areNotificationsDisabled(vscode.workspace.getConfiguration()) + ) { + void this.showPopup(unseen); + } + await this.secretsManager.setSeenBanners( + session.deployment.safeHostname, + banners.map((banner) => banner.key), + ); + return banners; + } + + private setBanners(banners: readonly Announcement[]): void { + this.banners = banners; + if (banners.length === 0) { + this.statusBarItem.hide(); + return; + } + this.statusBarItem.text = statusText(banners.length); + this.statusBarItem.tooltip = statusTooltip(banners); + this.statusBarItem.show(); + } + + private async showPopup(banners: readonly Announcement[]): Promise { + try { + const action = await vscode.window.showInformationMessage( + popupMessage(banners), + VIEW_ACTION, + ); + if (action === VIEW_ACTION) { + void this.showAnnouncements(); + } + } catch (error) { + this.logger.warn("Failed to show Coder announcement popup", error); + } + } + + private scheduleRefresh(): void { + if ( + this.disposed || + this.refreshTimeout || + this.sessionState.current.kind !== "signedIn" + ) { + return; + } + this.refreshTimeout = setTimeout(() => { + this.refreshTimeout = undefined; + void this.refresh({ notify: true }); + }, REFRESH_INTERVAL_MS); + } + + private cancelRefresh(): void { + if (this.refreshTimeout) { + clearTimeout(this.refreshTimeout); + this.refreshTimeout = undefined; + } + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/core/commandManager.ts b/src/core/commandManager.ts index 723a4fe21..c9f721c3c 100644 --- a/src/core/commandManager.ts +++ b/src/core/commandManager.ts @@ -21,6 +21,7 @@ export const CODER_COMMAND_IDS = [ "coder.refreshWorkspaces", "coder.viewLogs", "coder.exportTelemetry", + "coder.viewAnnouncements", "coder.searchMyWorkspaces", "coder.searchSharedWorkspaces", "coder.searchAllWorkspaces", diff --git a/src/core/secretsManager.ts b/src/core/secretsManager.ts index 5255c122c..a8004abd1 100644 --- a/src/core/secretsManager.ts +++ b/src/core/secretsManager.ts @@ -17,6 +17,7 @@ const DEPLOYMENT_ACCESS_PREFIX = "coder.access."; type SecretKeyPrefix = typeof SESSION_KEY_PREFIX | typeof OAUTH_CLIENT_PREFIX; const CURRENT_DEPLOYMENT_KEY = "coder.currentDeployment"; +const SEEN_BANNERS_KEY = "seenBanners"; const DEFAULT_MAX_DEPLOYMENTS = 10; const LEGACY_SESSION_TOKEN_KEY = "sessionToken"; @@ -29,6 +30,9 @@ export type CurrentDeploymentState = z.infer< typeof CurrentDeploymentStateSchema >; +const SeenBannersSchema = z.record(z.string(), z.array(z.string())); +type SeenBanners = z.infer; + /** * OAuth token data stored alongside session auth. * When present, indicates the session is authenticated via OAuth. @@ -235,6 +239,7 @@ export class SecretsManager { await Promise.all([ this.clearSessionAuth(safeHostname), this.clearOAuthClientRegistration(safeHostname), + this.clearSeenBanners(safeHostname), this.memento.update( `${DEPLOYMENT_ACCESS_PREFIX}${safeHostname}`, undefined, @@ -242,6 +247,34 @@ export class SecretsManager { ]); } + public getSeenBanners(safeHostname: string): string[] { + return this.getSeenBannersState()[safeHostname] ?? []; + } + + public async setSeenBanners( + safeHostname: string, + bannerKeys: readonly string[], + ): Promise { + const seenBanners = this.getSeenBannersState(); + seenBanners[safeHostname] = [...bannerKeys]; + await this.memento.update(SEEN_BANNERS_KEY, seenBanners); + } + + private async clearSeenBanners(safeHostname: string): Promise { + const seenBanners = this.getSeenBannersState(); + delete seenBanners[safeHostname]; + await this.memento.update( + SEEN_BANNERS_KEY, + Object.keys(seenBanners).length > 0 ? seenBanners : undefined, + ); + } + + private getSeenBannersState(): SeenBanners { + const raw = this.memento.get(SEEN_BANNERS_KEY); + const result = SeenBannersSchema.safeParse(raw); + return result.success ? result.data : {}; + } + /** * Get all known hostnames, ordered by most recently accessed. * Derives the list from actual session secrets stored. diff --git a/src/extension.ts b/src/extension.ts index e097daa87..541331562 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -6,6 +6,7 @@ import { createRequire } from "node:module"; import * as path from "node:path"; import * as vscode from "vscode"; +import { AnnouncementManager } from "./announcements/manager"; import { errToStr } from "./api/api-helper"; import { AuthInterceptor } from "./api/authInterceptor"; import { CoderApi } from "./api/coderApi"; @@ -162,6 +163,14 @@ async function doActivate( ); ctx.subscriptions.push(deploymentManager); + const announcementManager = new AnnouncementManager( + client, + deploymentManager.session, + secretsManager, + output, + ); + ctx.subscriptions.push(announcementManager); + const myWorkspacesProvider = new WorkspaceProvider( WorkspaceQuery.Mine, client, @@ -328,6 +337,10 @@ async function doActivate( "coder.exportTelemetry", commands.exportTelemetry.bind(commands), ); + commandManager.register( + "coder.viewAnnouncements", + announcementManager.showAnnouncements.bind(announcementManager), + ); commandManager.register("coder.searchMyWorkspaces", async () => showTreeViewSearch(MY_WORKSPACES_TREE_ID), ); diff --git a/test/unit/announcements/banners.test.ts b/test/unit/announcements/banners.test.ts new file mode 100644 index 000000000..67848e9ac --- /dev/null +++ b/test/unit/announcements/banners.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "vitest"; + +import { + bannerKey, + normalizeBanners, + popupMessage, + statusText, + statusTooltip, +} from "@/announcements/banners"; + +import type { + AppearanceConfig, + BannerConfig, +} from "coder/site/src/api/typesGenerated"; + +function banner(overrides: Partial = {}): BannerConfig { + return { + enabled: true, + message: "Maintenance tonight", + background_color: "#004852", + ...overrides, + }; +} + +function appearance( + overrides: Partial = {}, +): AppearanceConfig { + return { + application_name: "Coder", + logo_url: "", + docs_url: "", + service_banner: { enabled: false }, + announcement_banners: [], + ...overrides, + }; +} + +function announcements(...messages: string[]) { + return normalizeBanners( + appearance({ + announcement_banners: messages.map((message) => banner({ message })), + }), + ); +} + +describe("normalizeBanners", () => { + it("returns active service and announcement banners", () => { + const banners = normalizeBanners( + appearance({ + service_banner: banner({ + message: " Service banner ", + background_color: " #123456 ", + }), + announcement_banners: [ + banner({ message: " Announcement " }), + banner({ enabled: false, message: "Disabled" }), + banner({ message: " " }), + ], + }), + ); + + expect(banners).toMatchObject([ + { + source: "service", + message: "Service banner", + backgroundColor: "#123456", + }, + { + source: "announcement", + message: "Announcement", + backgroundColor: "#004852", + }, + ]); + expect(banners).toHaveLength(2); + expect(banners[0].key).toBe( + bannerKey({ + source: "service", + message: "Service banner", + backgroundColor: "#123456", + }), + ); + }); + + it("keeps keys stable when banners reorder", () => { + const original = announcements("First", "Second"); + const reordered = announcements("Second", "First"); + const keyFor = (message: string, banners = original) => + banners.find((banner) => banner.message === message)?.key; + + expect(keyFor("First", reordered)).toBe(keyFor("First")); + expect(keyFor("Second", reordered)).toBe(keyFor("Second")); + }); + + it("changes keys when fingerprint fields change", () => { + const key = bannerKey({ + source: "announcement", + message: "Maintenance tonight", + backgroundColor: "#004852", + }); + + for (const changed of [ + { source: "service" as const }, + { message: "Maintenance tomorrow" }, + { backgroundColor: "#111111" }, + ]) { + expect( + bannerKey({ + source: "announcement", + message: "Maintenance tonight", + backgroundColor: "#004852", + ...changed, + }), + ).not.toBe(key); + } + }); +}); + +describe("banner copy", () => { + it("formats status bar text and tooltip", () => { + const banners = announcements("First", "Second"); + + expect(statusText(1)).toBe("$(megaphone) Coder"); + expect(statusText(2)).toBe("$(megaphone) Coder 2"); + expect(statusTooltip(banners)).toBe( + "Coder deployment announcements\n\n1. First\n2. Second", + ); + }); + + it("formats popup messages", () => { + const longMessage = "a".repeat(121); + + expect(popupMessage(announcements("Maintenance tonight"))).toBe( + "Coder announcement: Maintenance tonight", + ); + expect(popupMessage(announcements("First", "Second"))).toBe( + "Coder has 2 new deployment announcements.", + ); + expect(popupMessage(announcements(longMessage))).toBe( + `Coder announcement: ${"a".repeat(119)}…`, + ); + }); +}); diff --git a/test/unit/announcements/manager.test.ts b/test/unit/announcements/manager.test.ts new file mode 100644 index 000000000..5e568001d --- /dev/null +++ b/test/unit/announcements/manager.test.ts @@ -0,0 +1,277 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as vscode from "vscode"; + +import { AnnouncementManager } from "@/announcements/manager"; +import { SecretsManager } from "@/core/secretsManager"; +import { SessionStore } from "@/deployment/sessionStore"; + +import { + createMockLogger, + createMockUser, + flushPromises, + InMemoryMemento, + InMemorySecretStorage, + MockConfigurationProvider, + MockStatusBarItem, +} from "../../mocks/testHelpers"; + +import type { AppearanceConfig } from "coder/site/src/api/typesGenerated"; + +const DEPLOYMENT = { + url: "https://coder.example.com", + safeHostname: "coder.example.com", +}; + +function createClient() { + return { getAppearance: vi.fn<() => Promise>() }; +} + +type MockAppearanceClient = ReturnType; + +let manager: AnnouncementManager | undefined; + +function appearance(messages: readonly string[] = []): AppearanceConfig { + return { + application_name: "Coder", + logo_url: "", + docs_url: "", + service_banner: { enabled: false }, + announcement_banners: messages.map((message) => ({ + enabled: true, + message, + background_color: "#004852", + })), + }; +} + +function setup() { + const config = new MockConfigurationProvider(); + const client = createClient(); + client.getAppearance.mockResolvedValue(appearance()); + const session = new SessionStore(); + const secretsManager = new SecretsManager( + new InMemorySecretStorage(), + new InMemoryMemento(), + createMockLogger(), + ); + const statusBar = new MockStatusBarItem(); + const logger = createMockLogger(); + const announcementManager = new AnnouncementManager( + client, + session, + secretsManager, + logger, + ); + manager = announcementManager; + return { + client, + config, + logger, + manager: announcementManager, + secretsManager, + session, + statusBar, + }; +} + +async function signIn(session: SessionStore): Promise { + session.signIn(DEPLOYMENT, createMockUser()); + await flushPromises(); +} + +function nextAppearance( + client: MockAppearanceClient, + messages: readonly string[], +): void { + client.getAppearance.mockResolvedValueOnce(appearance(messages)); +} + +function expectInfo(message: string, ...items: string[]): void { + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + message, + ...items, + ); +} + +describe("AnnouncementManager", () => { + beforeEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + afterEach(() => { + manager?.dispose(); + manager = undefined; + }); + + it("shows all active banners in the status bar", async () => { + const { client, session, statusBar } = setup(); + nextAppearance(client, ["First", "Second"]); + + await signIn(session); + + expect(statusBar.text).toBe("$(megaphone) Coder 2"); + expect(statusBar.tooltip).toContain("1. First"); + expect(statusBar.tooltip).toContain("2. Second"); + expect(statusBar.show).toHaveBeenCalled(); + }); + + it("notifies only newly seen banners", async () => { + const { client, manager, secretsManager, session } = setup(); + nextAppearance(client, ["First", "Second"]); + await signIn(session); + expectInfo("Coder has 2 new deployment announcements.", "View"); + vi.mocked(vscode.window.showInformationMessage).mockClear(); + + nextAppearance(client, ["First", "Second", "Third"]); + await manager.refresh({ notify: true }); + + expectInfo("Coder announcement: Third", "View"); + expect(secretsManager.getSeenBanners(DEPLOYMENT.safeHostname)).toHaveLength( + 3, + ); + }); + + it("does not notify for banners already seen on the same deployment", async () => { + const { client, manager, session } = setup(); + client.getAppearance.mockResolvedValue(appearance(["Maintenance tonight"])); + await signIn(session); + vi.mocked(vscode.window.showInformationMessage).mockClear(); + + await manager.refresh({ notify: true }); + + expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); + }); + + it("suppresses popups when notifications are disabled but keeps status bar", async () => { + const { client, config, session, statusBar } = setup(); + config.set("coder.disableNotifications", true); + nextAppearance(client, ["Maintenance tonight"]); + + await signIn(session); + + expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); + expect(statusBar.show).toHaveBeenCalled(); + expect(statusBar.text).toBe("$(megaphone) Coder"); + }); + + it("shows newly seen banners on a different deployment", async () => { + const { client, session } = setup(); + client.getAppearance.mockResolvedValue(appearance(["Maintenance tonight"])); + await signIn(session); + vi.mocked(vscode.window.showInformationMessage).mockClear(); + + session.signIn( + { url: "https://other.example.com", safeHostname: "other.example.com" }, + createMockUser(), + ); + await flushPromises(); + + expectInfo("Coder announcement: Maintenance tonight", "View"); + }); + + it("refreshes before showing announcements from the command", async () => { + const { client, manager, session } = setup(); + client.getAppearance.mockResolvedValue(appearance(["Full details"])); + vi.mocked(vscode.window.showQuickPick).mockResolvedValueOnce({ + label: "$(megaphone) Announcement 1", + detail: "Full details", + banner: { + source: "announcement", + message: "Full details", + backgroundColor: "#004852", + key: "key", + }, + } as never); + await signIn(session); + vi.mocked(vscode.window.showInformationMessage).mockClear(); + + await manager.showAnnouncements(); + + expect(vscode.window.showQuickPick).toHaveBeenCalledWith( + [ + expect.objectContaining({ + label: "$(megaphone) Announcement 1", + detail: "Full details", + }), + ], + expect.objectContaining({ title: "Coder Announcements" }), + ); + expectInfo("Full details"); + }); + + it("shows an empty message when there are no active announcements", async () => { + const { manager, session } = setup(); + await signIn(session); + vi.mocked(vscode.window.showInformationMessage).mockClear(); + + await manager.showAnnouncements(); + + expectInfo("No active Coder announcements."); + }); + + it("shows refresh errors from the command", async () => { + const { client, logger, manager, session } = setup(); + await signIn(session); + vi.mocked(vscode.window.showInformationMessage).mockClear(); + client.getAppearance.mockRejectedValueOnce(new Error("boom")); + + await manager.showAnnouncements(); + + expect(logger.warn).toHaveBeenCalledWith( + "Failed to refresh Coder announcements", + expect.any(Error), + ); + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Failed to refresh Coder announcements: boom", + ); + expectInfo("No active Coder announcements."); + }); + + it("opens the announcements command from the popup action", async () => { + const { client, manager, session } = setup(); + nextAppearance(client, ["Maintenance tonight"]); + const showAnnouncements = vi + .spyOn(manager, "showAnnouncements") + .mockResolvedValue(); + vi.mocked(vscode.window.showInformationMessage).mockResolvedValueOnce( + "View" as never, + ); + + await signIn(session); + await flushPromises(); + + expect(showAnnouncements).toHaveBeenCalledOnce(); + }); + + it("ignores stale refreshes after the session changes", async () => { + const { client, session, statusBar } = setup(); + const stale = Promise.withResolvers(); + client.getAppearance + .mockReturnValueOnce(stale.promise) + .mockResolvedValueOnce(appearance(["Current"])); + + session.signIn(DEPLOYMENT, createMockUser()); + await Promise.resolve(); + session.signIn( + { url: "https://other.example.com", safeHostname: "other.example.com" }, + createMockUser(), + ); + stale.resolve(appearance(["Stale"])); + await flushPromises(); + + expect(statusBar.tooltip).toContain("Current"); + expect(statusBar.tooltip).not.toContain("Stale"); + }); + + it("clears status bar when signed out", async () => { + const { client, session, statusBar } = setup(); + nextAppearance(client, ["Maintenance tonight"]); + await signIn(session); + statusBar.hide.mockClear(); + + session.signOut(null); + + expect(statusBar.hide).toHaveBeenCalledOnce(); + }); +}); diff --git a/test/unit/core/secretsManager.test.ts b/test/unit/core/secretsManager.test.ts index 9997b8951..3cee2dac0 100644 --- a/test/unit/core/secretsManager.test.ts +++ b/test/unit/core/secretsManager.test.ts @@ -170,6 +170,34 @@ describe("SecretsManager", () => { vi.useRealTimers(); }); + describe("seen banners", () => { + it("stores seen banner keys by safe hostname", async () => { + await secretsManager.setSeenBanners("example.com", ["one", "two"]); + await secretsManager.setSeenBanners("other.com", ["three"]); + + expect(secretsManager.getSeenBanners("example.com")).toEqual([ + "one", + "two", + ]); + expect(secretsManager.getSeenBanners("other.com")).toEqual(["three"]); + }); + + it("clears seen banner keys with auth data", async () => { + await secretsManager.setSeenBanners("example.com", ["one"]); + await secretsManager.setSeenBanners("other.com", ["two"]); + + await secretsManager.clearAllAuthData("example.com"); + + expect(secretsManager.getSeenBanners("example.com")).toEqual([]); + expect(secretsManager.getSeenBanners("other.com")).toEqual(["two"]); + }); + + it("ignores corrupted seen banner storage", async () => { + await memento.update("seenBanners", { "example.com": "bad" }); + + expect(secretsManager.getSeenBanners("example.com")).toEqual([]); + }); + }); }); describe("current deployment", () => {