diff --git a/src/Frontend/src/components/configuration/EmailSettings.ts b/src/Frontend/src/components/configuration/EmailSettings.ts deleted file mode 100644 index 725b07323..000000000 --- a/src/Frontend/src/components/configuration/EmailSettings.ts +++ /dev/null @@ -1,10 +0,0 @@ -export default interface EmailSettings { - enabled: boolean | null; - enable_tls: boolean | null; - smtp_server: string; - smtp_port: number | null; - authentication_account: string; - authentication_password: string; - from: string; - to: string; -} diff --git a/src/Frontend/src/components/configuration/HealthCheckNotifications.spec.ts b/src/Frontend/src/components/configuration/HealthCheckNotifications.spec.ts index 494970b37..b4c4448f0 100644 --- a/src/Frontend/src/components/configuration/HealthCheckNotifications.spec.ts +++ b/src/Frontend/src/components/configuration/HealthCheckNotifications.spec.ts @@ -1,9 +1,12 @@ -import { render, describe, test, screen, expect, userEvent } from "@component-test-utils"; +import { render, describe, test, screen, within, expect, userEvent, mockEndpoint, mockEndpointDynamic } from "@component-test-utils"; +import { waitFor } from "@testing-library/vue"; import { vi, beforeEach, afterEach } from "vitest"; import sut from "@/components/configuration/HealthCheckNotifications.vue"; import { createTestingPinia } from "@pinia/testing"; -import { useHealthChecksStore } from "@/stores/HealthChecksStore"; -import type EmailSettings from "@/components/configuration/EmailSettings"; +import { useHealthChecksStore, type HealthChecksStore } from "@/stores/HealthChecksStore"; +import type EmailNotifications from "@/resources/EmailNotifications"; +import type UpdateEmailNotificationsSettingsRequest from "@/resources/UpdateEmailNotificationsSettingsRequest"; +import { mockServer } from "../../../test/mock-server"; // ─── DSL ───────────────────────────────────────────────────────────────────── @@ -15,7 +18,10 @@ interface ComponentDSL { discardEmailConfigurationChanges(): Promise; requestTestNotification(): Promise; provideValidEmailConfiguration(): Promise; - settingsChangeInServiceControl(changes: Partial): void; + enterPassword(password: string): Promise; + removeAccount(): Promise; + settingsChangeInServiceControl(changes: Partial): void; + serviceControlBecomesUnreachable(): void; }; verify: { emailNotificationsAreEnabled(): void; @@ -30,12 +36,22 @@ interface ComponentDSL { testNotificationWasRequested(): void; emailConfigurationWasSaved(): void; emailConfigurationWasNotSaved(): void; - emailConfigurationMatches(config: Partial): void; + savedSettingsInclude(settings: Partial): void; + emailConfigurationMatches(config: Partial): void; storedPasswordIsNotShownAndCanBeKept(): void; limitingTheSmtpAccountIsRecommended(): void; + emailSettingsCouldNotBeLoaded(): void; }; } +// The store state a test can start from. Only page-level state lives in the store; the dialog reads the settings it +// edits from ServiceControl, given with settingsInServiceControl. +interface InitialState { + HealthChecksStore?: Partial>; +} + +const storedPassword = "stored-secret"; + // ─── Test suite ────────────────────────────────────────────────────────────── // The email configuration modal is rendered via . @@ -52,11 +68,12 @@ beforeEach(() => { afterEach(() => { const modalDisplay = document.getElementById("modalDisplay"); if (modalDisplay) document.body.removeChild(modalDisplay); + mockServer.resetHandlers(); }); describe("FEATURE: Health check notifications", () => { - describe("RULE: Email notifications can be enabled and disabled", () => { - test("EXAMPLE: Email notifications are currently disabled", async () => { + describe("RULE: Must let email notifications be turned on and off", () => { + test("EXAMPLE: The one where email notifications are off and get switched on", async () => { /* * Given Email notifications are OFF * When the toggle button is clicked @@ -65,7 +82,7 @@ describe("FEATURE: Health check notifications", () => { const componentDriver = renderComponent({ initialState: { HealthChecksStore: { - emailNotifications: emailSettingsWithEnabled(false), + emailNotificationsEnabled: false, }, }, }); @@ -75,7 +92,7 @@ describe("FEATURE: Health check notifications", () => { componentDriver.verify.emailNotificationsWereToggled(); }); - test("EXAMPLE: Email notifications are currently enabled", async () => { + test("EXAMPLE: The one where email notifications are on and get switched off", async () => { /* * Given Email notifications are ON * When the toggle button is clicked @@ -84,7 +101,7 @@ describe("FEATURE: Health check notifications", () => { const componentDriver = renderComponent({ initialState: { HealthChecksStore: { - emailNotifications: emailSettingsWithEnabled(true), + emailNotificationsEnabled: true, }, }, }); @@ -95,8 +112,8 @@ describe("FEATURE: Health check notifications", () => { }); }); - describe("RULE: The email configuration dialog can be opened and closed", () => { - test("EXAMPLE: The configuration dialog is not currently visible", async () => { + describe("RULE: Must let the email configuration dialog be opened and closed without saving", () => { + test("EXAMPLE: The one where Configure is clicked, and the dialog opens", async () => { /* * Given the Email configuration popup is not visible * When the "Configure" button is clicked @@ -109,7 +126,7 @@ describe("FEATURE: Health check notifications", () => { componentDriver.verify.emailConfigurationIsAccessible(); }); - test("EXAMPLE: Edits have been made in the configuration dialog", async () => { + test("EXAMPLE: The one where edits are made and then cancelled, and nothing is saved", async () => { /* * Given the Email configuration popup is visible * And edits have been made to the email configuration @@ -128,49 +145,37 @@ describe("FEATURE: Health check notifications", () => { }); }); - describe("RULE: Email configuration can only be saved when the form is valid", () => { - test("EXAMPLE: Required fields are missing or contain invalid values", async () => { + describe("RULE: Must allow saving the email configuration only when the form is valid", () => { + test("EXAMPLE: The one where every required field is valid, and the configuration can be saved", async () => { /* * Given the Email configuration popup is visible - * When invalid or incomplete data is entered into the form - * Then the Save button is not enabled + * When valid data is entered for all required fields + * Then the Save button is enabled */ - const componentDriver = renderComponent({ - initialState: { - HealthChecksStore: { - // All fields empty – form is invalid by default - emailNotifications: emptyEmailSettings(), - }, - }, - }); + const componentDriver = renderComponent({ settingsInServiceControl: emptyEmailSettings() }); await componentDriver.actions.openEmailConfiguration(); componentDriver.verify.userCannotSaveConfiguration(); + await componentDriver.actions.provideValidEmailConfiguration(); + componentDriver.verify.userCanSaveConfiguration(); }); - test("EXAMPLE: All required fields are present and valid", async () => { + test("COUNTER-EXAMPLE: The one where required fields are missing or invalid, and the configuration cannot be saved", async () => { /* * Given the Email configuration popup is visible - * When valid data is entered for all required fields - * Then the Save button is enabled + * When invalid or incomplete data is entered into the form + * Then the Save button is not enabled */ - const componentDriver = renderComponent({ - initialState: { - HealthChecksStore: { - emailNotifications: emptyEmailSettings(), - }, - }, - }); + // All fields empty – form is invalid by default + const componentDriver = renderComponent({ settingsInServiceControl: emptyEmailSettings() }); await componentDriver.actions.openEmailConfiguration(); componentDriver.verify.userCannotSaveConfiguration(); - await componentDriver.actions.provideValidEmailConfiguration(); - componentDriver.verify.userCanSaveConfiguration(); }); }); - describe("RULE: Saving email configuration updates the stored settings and closes the dialog", () => { - test("EXAMPLE: A valid configuration has been entered", async () => { + describe("RULE: Must close the dialog once a valid email configuration is saved", () => { + test("EXAMPLE: The one where a valid configuration is saved, and the dialog closes", async () => { /* * Given the Email configuration popup is visible * And a valid configuration has been entered @@ -178,13 +183,7 @@ describe("FEATURE: Health check notifications", () => { * Then the email configuration is updated * And the Email configuration popup is closed */ - const componentDriver = renderComponent({ - initialState: { - HealthChecksStore: { - emailNotifications: emptyEmailSettings(), - }, - }, - }); + const componentDriver = renderComponent({ settingsInServiceControl: emptyEmailSettings() }); await componentDriver.actions.openEmailConfiguration(); await componentDriver.actions.provideValidEmailConfiguration(); @@ -195,40 +194,33 @@ describe("FEATURE: Health check notifications", () => { }); }); - describe("RULE: Health check notification settings persist after a page refresh", () => { - test("EXAMPLE: Email configuration has been updated", async () => { + describe("RULE: Must show the saved settings after a page refresh", () => { + test("EXAMPLE: The one where the configuration was changed before the refresh, and the dialog shows what was last saved", async () => { /* * When the email configuration has been changed * And the screen is refreshed * Then the email notification configuration matches what was last saved * - * The store's refresh() action is stubbed, so the initial state we set - * represents what the store holds after loading previously-saved settings. + * ServiceControl's response is mocked, so the settings we provide represent + * what ServiceControl returns for previously-saved settings. */ - const savedConfig: EmailSettings = { + const savedConfig: EmailNotifications = { enabled: true, enable_tls: true, smtp_server: "mail.example.com", smtp_port: 465, authentication_account: "user@example.com", - authentication_password: "secret", from: "noreply@example.com", to: "admin@example.com", }; - const componentDriver = renderComponent({ - initialState: { - HealthChecksStore: { - emailNotifications: savedConfig, - }, - }, - }); + const componentDriver = renderComponent({ settingsInServiceControl: savedConfig }); await componentDriver.actions.openEmailConfiguration(); componentDriver.verify.emailConfigurationMatches(savedConfig); }); - test("EXAMPLE: Email notifications were enabled before refresh", () => { + test("EXAMPLE: The one where email notifications were on before the refresh, and they are still shown as on", () => { /* * Given Email notifications are ON * When the page is refreshed @@ -237,7 +229,7 @@ describe("FEATURE: Health check notifications", () => { const componentDriver = renderComponent({ initialState: { HealthChecksStore: { - emailNotifications: emailSettingsWithEnabled(true), + emailNotificationsEnabled: true, }, }, }); @@ -245,7 +237,7 @@ describe("FEATURE: Health check notifications", () => { componentDriver.verify.emailNotificationsAreEnabled(); }); - test("EXAMPLE: Email notifications were disabled before refresh", () => { + test("EXAMPLE: The one where email notifications were off before the refresh, and they are still shown as off", () => { /* * Given Email notifications are OFF * When the page is refreshed @@ -254,7 +246,7 @@ describe("FEATURE: Health check notifications", () => { const componentDriver = renderComponent({ initialState: { HealthChecksStore: { - emailNotifications: emailSettingsWithEnabled(false), + emailNotificationsEnabled: false, }, }, }); @@ -263,72 +255,136 @@ describe("FEATURE: Health check notifications", () => { }); }); - describe("RULE: The email configuration dialog shows the settings that ServiceControl has now", () => { - test("EXAMPLE: Another user changed the settings after the page was loaded", async () => { + describe("RULE: Must show the settings ServiceControl has now when the dialog opens", () => { + test("EXAMPLE: The one where another user changed the settings after the page loaded, and the dialog shows their change", async () => { /* * Given the page was loaded with the To address old@example.com * And another user changed the To address in ServiceControl to new@example.com * When the Email configuration popup is opened * Then the To address is new@example.com */ + const componentDriver = renderComponent({ settingsInServiceControl: { ...emptyEmailSettings(), to: "old@example.com" } }); + + componentDriver.actions.settingsChangeInServiceControl({ to: "new@example.com" }); + await componentDriver.actions.openEmailConfiguration(); + componentDriver.verify.emailConfigurationMatches({ to: "new@example.com" }); + }); + + test("COUNTER-EXAMPLE: The one where ServiceControl cannot be reached when the dialog opens, and it says the settings could not be loaded", async () => { + /* + * Given Email notifications are ON + * And ServiceControl cannot be reached + * When the Email configuration popup is opened + * Then it says that the settings could not be loaded + * And the configuration cannot be saved + * And Email notifications are still shown as ON + */ const componentDriver = renderComponent({ initialState: { HealthChecksStore: { - emailNotifications: { ...emptyEmailSettings(), to: "old@example.com" }, + emailNotificationsEnabled: true, }, }, }); - componentDriver.actions.settingsChangeInServiceControl({ to: "new@example.com" }); + componentDriver.actions.serviceControlBecomesUnreachable(); await componentDriver.actions.openEmailConfiguration(); - componentDriver.verify.emailConfigurationMatches({ to: "new@example.com" }); + componentDriver.verify.emailSettingsCouldNotBeLoaded(); + componentDriver.verify.userCannotSaveConfiguration(); + componentDriver.verify.emailNotificationsAreEnabled(); }); }); - describe("RULE: The stored SMTP password is never shown", () => { - test("EXAMPLE: ServiceControl has a stored password for the SMTP account", async () => { + describe("RULE: Must never show the stored SMTP password", () => { + test("EXAMPLE: The one where ServiceControl returns the stored password, and the password field stays empty", async () => { /* - * Given ServiceControl has a stored SMTP password - * And ServiceControl does not return it + * Given ServiceControl older than 6.21.2, which returns the stored SMTP password * When the Email configuration popup is opened * Then the password field is empty * And the field explains that an empty password keeps the stored password */ - const componentDriver = renderComponent({ - initialState: { - HealthChecksStore: { - emailNotifications: { ...emptyEmailSettings(), authentication_account: "user@example.com" }, - }, - }, - }); + const componentDriver = renderComponent({ settingsInServiceControl: settingsWithStoredPassword() }); await componentDriver.actions.openEmailConfiguration(); componentDriver.verify.storedPasswordIsNotShownAndCanBeKept(); }); }); - describe("RULE: The email configuration recommends limiting what the SMTP account can send", () => { - test("EXAMPLE: The Email configuration popup is opened", async () => { + describe("RULE: Must keep the stored password unless a new one is entered or the account is removed", () => { + test("EXAMPLE: The one where ServiceControl 6.21.2 or later does not return the stored password and it is left empty, and the empty password is sent", async () => { + /* + * Given ServiceControl 6.21.2 or later, which does not return the stored password + * and keeps it when an empty one is sent + * When the settings are saved with the password left empty + * Then the empty password is sent + */ + const componentDriver = renderComponent({ settingsInServiceControl: settingsWithoutPassword() }); + + await componentDriver.actions.openEmailConfiguration(); + await componentDriver.actions.confirmEmailConfigurationChanges(); + componentDriver.verify.savedSettingsInclude({ authorization_account: "user@example.com", authorization_password: "" }); + }); + + test("EXAMPLE: The one where ServiceControl older than 6.21.2 returns the stored password and it is left empty, and the stored password is sent back", async () => { + /* + * Given ServiceControl older than 6.21.2, which returns the stored password + * and replaces it with whatever a save sends + * When the settings are saved with the password left empty + * Then the stored password is sent back with the save + * (sending the empty password would erase it) + */ + const componentDriver = renderComponent({ settingsInServiceControl: settingsWithStoredPassword() }); + + await componentDriver.actions.openEmailConfiguration(); + await componentDriver.actions.confirmEmailConfigurationChanges(); + componentDriver.verify.savedSettingsInclude({ authorization_account: "user@example.com", authorization_password: storedPassword }); + }); + + test("EXAMPLE: The one where a new password is entered, and it replaces the stored one", async () => { + /* + * Given ServiceControl returns the stored password + * When a new password is entered and the settings are saved + * Then the new password is sent + */ + const componentDriver = renderComponent({ settingsInServiceControl: settingsWithStoredPassword() }); + + await componentDriver.actions.openEmailConfiguration(); + await componentDriver.actions.enterPassword("new-secret"); + await componentDriver.actions.confirmEmailConfigurationChanges(); + componentDriver.verify.savedSettingsInclude({ authorization_password: "new-secret" }); + }); + + test("EXAMPLE: The one where the account is removed, and the password is removed with it", async () => { + /* + * Given ServiceControl returns the stored password + * When the account is removed and the settings are saved with the password left empty + * Then the empty password is sent, because without an account the password is never used + */ + const componentDriver = renderComponent({ settingsInServiceControl: settingsWithStoredPassword() }); + + await componentDriver.actions.openEmailConfiguration(); + await componentDriver.actions.removeAccount(); + await componentDriver.actions.confirmEmailConfigurationChanges(); + componentDriver.verify.savedSettingsInclude({ authorization_account: "", authorization_password: "" }); + }); + }); + + describe("RULE: Should recommend limiting what the SMTP account can send", () => { + test("EXAMPLE: The one where the dialog opens, and it recommends a dedicated SMTP account with limited send permissions", async () => { /* * When the Email configuration popup is opened * Then it recommends a dedicated SMTP account with limited send permissions * And it links to the documentation */ - const componentDriver = renderComponent({ - initialState: { - HealthChecksStore: { - emailNotifications: emptyEmailSettings(), - }, - }, - }); + const componentDriver = renderComponent({ settingsInServiceControl: emptyEmailSettings() }); await componentDriver.actions.openEmailConfiguration(); componentDriver.verify.limitingTheSmtpAccountIsRecommended(); }); }); - describe("RULE: Sending a test notification indicates whether delivery succeeded or failed", () => { - test("EXAMPLE: The email configuration is invalid", async () => { + describe("RULE: Must show whether a test notification was delivered", () => { + test("EXAMPLE: The one where the configuration is invalid, and the test notification is shown as failed", async () => { /* * Given an invalid configuration * When "Send test notification" is clicked @@ -345,7 +401,7 @@ describe("FEATURE: Health check notifications", () => { await componentDriver.verify.testNotificationFailed(); }); - test("EXAMPLE: The email configuration is valid", async () => { + test("EXAMPLE: The one where the configuration is valid, and the test notification is shown as sent", async () => { /* * Given a valid configuration * When "Send test notification" is clicked @@ -366,9 +422,18 @@ describe("FEATURE: Health check notifications", () => { // ─── Render helper ─────────────────────────────────────────────────────────── -function renderComponent({ initialState = {} }: { initialState?: Record } = {}): ComponentDSL { +function renderComponent({ initialState = {}, settingsInServiceControl = emptyEmailSettings() }: { initialState?: InitialState; settingsInServiceControl?: EmailNotifications } = {}): ComponentDSL { const user = userEvent.setup(); + // The dialog reads and saves the settings it edits through ServiceControl's API, answered here by the mock server. + const emailSettingsUrl = `${window.defaultConfig.service_control_url}notifications/email`; + mockEndpoint(emailSettingsUrl, { body: settingsInServiceControl }); + const savedSettings: UpdateEmailNotificationsSettingsRequest[] = []; + mockEndpointDynamic(emailSettingsUrl, "post", async (_url, _params, request) => { + savedSettings.push((await request.json()) as UpdateEmailNotificationsSettingsRequest); + return { body: {} }; + }); + render(sut, { global: { plugins: [ @@ -399,9 +464,12 @@ function renderComponent({ initialState = {} }: { initialState?: Record expect(within(dialog).queryByRole("status")).not.toBeInTheDocument()); }, async confirmEmailConfigurationChanges() { await user.click(screen.getByRole("button", { name: /save/i })); + await waitFor(() => expect(screen.queryByRole("dialog", { name: /email configuration/i })).not.toBeInTheDocument()); }, async discardEmailConfigurationChanges() { await user.click(screen.getByRole("button", { name: /cancel/i })); @@ -409,11 +477,17 @@ function renderComponent({ initialState = {} }: { initialState?: Record) { - vi.mocked(store.refresh).mockImplementation(() => { - store.emailNotifications = { ...store.emailNotifications, ...changes }; - return Promise.resolve(); - }); + async enterPassword(password: string) { + await user.type(screen.getByLabelText(/authentication password/i), password); + }, + async removeAccount() { + await user.clear(screen.getByLabelText(/authentication account/i)); + }, + settingsChangeInServiceControl(changes: Partial) { + mockEndpoint(emailSettingsUrl, { body: { ...settingsInServiceControl, ...changes } }); + }, + serviceControlBecomesUnreachable() { + mockEndpoint(emailSettingsUrl, { body: null, networkError: true }); }, async provideValidEmailConfiguration() { await user.type(screen.getByLabelText(/smtp server address/i), "smtp.example.com"); @@ -454,10 +528,14 @@ function renderComponent({ initialState = {} }: { initialState?: Record) { + expect(savedSettings).toHaveLength(1); + expect(savedSettings[0]).toMatchObject(settings); }, storedPasswordIsNotShownAndCanBeKept() { const password = screen.getByLabelText(/authentication password/i); @@ -465,11 +543,15 @@ function renderComponent({ initialState = {} }: { initialState?: Record) { + emailConfigurationMatches(config: Partial) { if (config.smtp_server !== undefined) { expect(screen.getByLabelText(/smtp server address/i)).toHaveValue(config.smtp_server); } @@ -497,22 +579,27 @@ function renderComponent({ initialState = {} }: { initialState?: Record {
- +
@@ -139,8 +134,8 @@ onMounted(async () => { - - + + diff --git a/src/Frontend/src/components/configuration/HealthCheckNotifications_ConfigureEmail.vue b/src/Frontend/src/components/configuration/HealthCheckNotifications_ConfigureEmail.vue index d98fb8315..e58bd0273 100644 --- a/src/Frontend/src/components/configuration/HealthCheckNotifications_ConfigureEmail.vue +++ b/src/Frontend/src/components/configuration/HealthCheckNotifications_ConfigureEmail.vue @@ -1,25 +1,57 @@