Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ interface ComponentDSL {
discardEmailConfigurationChanges(): Promise<void>;
requestTestNotification(): Promise<void>;
provideValidEmailConfiguration(): Promise<void>;
settingsChangeInServiceControl(changes: Partial<EmailSettings>): void;
};
verify: {
emailNotificationsAreEnabled(): void;
Expand All @@ -30,6 +31,8 @@ interface ComponentDSL {
emailConfigurationWasSaved(): void;
emailConfigurationWasNotSaved(): void;
emailConfigurationMatches(config: Partial<EmailSettings>): void;
storedPasswordIsNotShownAndCanBeKept(): void;
limitingTheSmtpAccountIsRecommended(): void;
};
}

Expand Down Expand Up @@ -260,6 +263,70 @@ 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 () => {
/*
* 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({
initialState: {
HealthChecksStore: {
emailNotifications: { ...emptyEmailSettings(), to: "old@example.com" },
},
},
});

componentDriver.actions.settingsChangeInServiceControl({ to: "new@example.com" });
await componentDriver.actions.openEmailConfiguration();
componentDriver.verify.emailConfigurationMatches({ to: "new@example.com" });
});
});

describe("RULE: The stored SMTP password is never shown", () => {
test("EXAMPLE: ServiceControl has a stored password for the SMTP account", async () => {
/*
* Given ServiceControl has a stored SMTP password
* And ServiceControl does not return it
* 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" },
},
},
});

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 () => {
/*
* 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(),
},
},
});

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 () => {
/*
Expand Down Expand Up @@ -342,6 +409,12 @@ function renderComponent({ initialState = {} }: { initialState?: Record<string,
async requestTestNotification() {
await user.click(screen.getByRole("button", { name: /send test notification/i }));
},
settingsChangeInServiceControl(changes: Partial<EmailSettings>) {
vi.mocked(store.refresh).mockImplementation(() => {
store.emailNotifications = { ...store.emailNotifications, ...changes };
return Promise.resolve();
});
},
async provideValidEmailConfiguration() {
await user.type(screen.getByLabelText(/smtp server address/i), "smtp.example.com");
await user.type(screen.getByLabelText(/smtp server port/i), "587");
Expand Down Expand Up @@ -386,6 +459,16 @@ function renderComponent({ initialState = {} }: { initialState?: Record<string,
emailConfigurationWasNotSaved() {
expect(store.saveEmailNotifications).not.toHaveBeenCalled();
},
storedPasswordIsNotShownAndCanBeKept() {
const password = screen.getByLabelText(/authentication password/i);
expect(password).toHaveValue("");
expect(password).toHaveAttribute("placeholder", expect.stringMatching(/leave empty to keep the stored password/i));
expect(password).toHaveAccessibleDescription(/stored password is never shown/i);
},
limitingTheSmtpAccountIsRecommended() {
expect(screen.getByText(/use a dedicated smtp account/i)).toBeInTheDocument();
expect(screen.getByRole("link", { name: /securing the smtp account/i })).toHaveAttribute("href", "https://docs.particular.net/servicecontrol/security/smtp-account");
},
emailConfigurationMatches(config: Partial<EmailSettings>) {
if (config.smtp_server !== undefined) {
expect(screen.getByLabelText(/smtp server address/i)).toHaveValue(config.smtp_server);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,12 @@ async function toggleEmailNotifications() {
emailToggleSuccessful.value = await healthChecksStore.toggleEmailNotifications();
}

function editEmailNotifications() {
async function editEmailNotifications() {
emailToggleSuccessful.value = null;
emailTestSuccessful.value = null;
emailUpdateSuccessful.value = null;
// Start the form from what ServiceControl has now, not from what was loaded with the page.
await healthChecksStore.refresh();
showEmailConfiguration.value = true;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { computed, ref } from "vue";
import type EmailSettings from "@/components/configuration/EmailSettings";
import type UpdateEmailNotificationsSettingsRequest from "@/resources/UpdateEmailNotificationsSettingsRequest";
import ExternalLink from "@/components/ExternalLink.vue";

const emit = defineEmits<{
save: [settings: UpdateEmailNotificationsSettingsRequest];
Expand Down Expand Up @@ -87,7 +88,13 @@ function close() {
<div class="row"></div>
<div class="form-group">
<label for="password">Authentication password</label>
<input type="password" id="password" name="password" v-model="authentication_password" class="form-control" />
<input type="password" id="password" name="password" v-model="authentication_password" class="form-control" placeholder="Leave empty to keep the stored password" autocomplete="new-password" aria-describedby="passwordHelp" />
<div id="passwordHelp" class="form-text">The stored password is never shown.</div>
</div>
<div class="row"></div>
<div class="alert alert-info py-2">
Use a dedicated SMTP account with limited send permissions.
<ExternalLink href="https://docs.particular.net/servicecontrol/security/smtp-account">Securing the SMTP account</ExternalLink>
</div>
<div class="row"></div>
<div class="form-group">
Expand Down
28 changes: 28 additions & 0 deletions src/Frontend/src/stores/HealthChecksStore.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import serviceControlClient from "@/components/serviceControlClient";
import { useHealthChecksStore } from "@/stores/HealthChecksStore";

describe("HealthChecksStore", () => {
beforeEach(() => {
setActivePinia(createPinia());
});

test("does not keep the SMTP password in memory after the settings are saved", async () => {
vi.spyOn(serviceControlClient, "postToServiceControl").mockResolvedValue(new Response(null, { status: 200 }));
const store = useHealthChecksStore();

const saved = await store.saveEmailNotifications({
smtp_server: "smtp.example.com",
smtp_port: 587,
authorization_account: "user@example.com",
authorization_password: "secret",
enable_tls: true,
from: "from@example.com",
to: "to@example.com",
});

expect(saved).toBe(true);
expect(store.emailNotifications.authentication_password).toBe("");
});
});
12 changes: 2 additions & 10 deletions src/Frontend/src/stores/HealthChecksStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,16 +83,8 @@ export const useHealthChecksStore = defineStore("HealthChecksStore", () => {
async function saveEmailNotifications(newSettings: UpdateEmailNotificationsSettingsRequest) {
const result = await getResponseOrError(() => serviceControlClient.postToServiceControl("notifications/email", newSettings));
if (result.message === "success") {
emailNotifications.value = {
enabled: emailNotifications.value.enabled,
enable_tls: newSettings.enable_tls,
smtp_server: newSettings.smtp_server,
smtp_port: newSettings.smtp_port,
authentication_account: newSettings.authorization_account,
authentication_password: newSettings.authorization_password,
from: newSettings.from,
to: newSettings.to,
};
// The saved settings are not copied into the store. The configuration dialog loads them from
// ServiceControl when it opens, and the password must not stay in memory.
return true;
} else {
logger.error(result.message);
Expand Down
Loading