Skip to content
Merged
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
65 changes: 65 additions & 0 deletions server/internal/handlers/checks/email_auth_patch_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package checks_test

import (
"testing"

"github.com/stretchr/testify/require"

"github.com/fclairamb/solidping/server/internal/handlers/checks"
)

// TestPatchingEmailAuthOptionsKeepsTheToken covers the dashboard's edit of an
// email check's sender-authentication options (spec 2026-11-12-03): the PATCH
// carries only the options, the inbound address token must survive it, and an
// empty config clears the options again.
func TestPatchingEmailAuthOptionsKeepsTheToken(t *testing.T) {
t.Parallel()

r := require.New(t)
ctx := t.Context()
svc, dbSvc, _, org := setupPlaintextChecksService(t)

created, err := svc.CreateCheck(ctx, org.Slug, checks.CreateCheckRequest{
Name: "email-auth-patch",
Slug: "email-auth-patch",
Type: "email",
Config: map[string]any{},
})
r.NoError(err)

token, ok := created.Config["token"].(string)
r.True(ok)
r.NotEmpty(token)

patch := map[string]any{
"require_spf": "pass",
"require_dkim": true,
"require_dmarc": "pass",
"check_blocklists": true,
"blocklists": []any{"zen.spamhaus.org"},
}
_, err = svc.UpdateCheck(ctx, org.Slug, created.UID, &checks.UpdateCheckRequest{Config: &patch})
r.NoError(err)

row, err := dbSvc.GetCheck(ctx, org.UID, created.UID)
r.NoError(err)
r.Equal("pass", row.Config["require_spf"])
r.Equal(true, row.Config["require_dkim"])
r.Equal("pass", row.Config["require_dmarc"])
r.Equal(true, row.Config["check_blocklists"])
r.Equal([]any{"zen.spamhaus.org"}, row.Config["blocklists"])
r.Equal(token, row.Config["token"], "the inbound token must survive a PATCH that never mentions it")

empty := map[string]any{}
_, err = svc.UpdateCheck(ctx, org.Slug, created.UID, &checks.UpdateCheckRequest{Config: &empty})
r.NoError(err)

row, err = dbSvc.GetCheck(ctx, org.UID, created.UID)
r.NoError(err)

for _, key := range []string{"require_spf", "require_dkim", "require_dmarc", "check_blocklists", "blocklists"} {
r.NotContains(row.Config, key, "an empty config clears %s", key)
}

r.Equal(token, row.Config["token"])
}
68 changes: 68 additions & 0 deletions web/dash0/e2e/email-check-sender-auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,72 @@ test.describe("Email check sender authentication", () => {
"zen.spamhaus.org\nbl.spamcop.net",
);
});
// The edit route used to drop `config` from a passive check's PATCH, so these
// options showed on edit but were never saved.
test("edits the authentication options of an existing check", async ({ authenticatedPage }) => {
const page = authenticatedPage;
const token = await getAuthToken(page);
await ensureEmailInboxConfigured(page, token);
const headers = { Authorization: `Bearer ${token}` };

const created = await page.request.post(`${API_BASE}/api/v1/orgs/test/checks`, {
headers,
data: { type: "email", name: `E2E Email Auth Edit ${Date.now()}`, config: {} },
});
expect(created.status()).toBe(201);
const { uid, config: createdConfig } = await created.json();
const inboxToken = createdConfig.token;
expect(typeof inboxToken).toBe("string");

const readConfig = async () => {
const resp = await page.request.get(`${API_BASE}/api/v1/orgs/test/checks/${uid}`, { headers });
return (await resp.json()).config;
};
const save = async () => {
const patched = page.waitForResponse(
(r) => r.request().method() === "PATCH" && r.url().includes(`/checks/${uid}`),
);
await page.getByTestId("check-submit-button").click();
expect((await patched).status()).toBe(200);
};

// Turn every option on.
await page.goto(`orgs/test/checks/${uid}/edit`);
await page.getByTestId("email-auth-section").click();
await page.getByTestId("email-require-spf-select").click();
await page.getByRole("option", { name: "Pass", exact: true }).click();
await page.getByTestId("email-require-dkim-switch").click();
await page.getByTestId("email-require-dmarc-select").click();
await page.getByRole("option", { name: "Pass", exact: true }).click();
await page.getByTestId("email-check-blocklists-switch").click();
await page.getByTestId("email-blocklists-input").fill("zen.spamhaus.org");
await save();

let config = await readConfig();
expect(config.require_spf).toBe("pass");
expect(config.require_dkim).toBe(true);
expect(config.require_dmarc).toBe("pass");
expect(config.check_blocklists).toBe(true);
expect(config.blocklists).toEqual(["zen.spamhaus.org"]);
expect(config.token).toBe(inboxToken);

// A reload of the edit form shows them, and turning them off clears them.
await page.goto(`orgs/test/checks/${uid}/edit`);
await expect(page.getByTestId("email-require-dkim-switch")).toBeChecked();
await page.getByTestId("email-require-spf-select").click();
await page.getByRole("option", { name: "Off" }).click();
await page.getByTestId("email-require-dkim-switch").click();
await page.getByTestId("email-require-dmarc-select").click();
await page.getByRole("option", { name: "Off" }).click();
await page.getByTestId("email-check-blocklists-switch").click();
await save();

config = await readConfig();
expect(config.require_spf).toBeUndefined();
expect(config.require_dkim).toBeUndefined();
expect(config.require_dmarc).toBeUndefined();
expect(config.check_blocklists).toBeUndefined();
expect(config.blocklists).toBeUndefined();
expect(config.token).toBe(inboxToken);
});
});
9 changes: 7 additions & 2 deletions web/dash0/src/components/shared/check-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1480,8 +1480,13 @@ export function CheckForm({
escalationPolicyUid:
escalationPolicyUid || (mode === "edit" ? "" : undefined),
period: isPassiveCheckType(type) ? formatPeriod(periodValue, periodUnit) : period,
// Don't send config for passive edits — the token is managed by the backend
...(isPassiveCheckType(type) && mode === "edit" ? {} : { config: submittedConfig }),
// Passive edits omit config: heartbeat has no editable key and a
// private location's region is read-only. Email is the exception, its
// sender-authentication options live in config. The server keeps the
// stored token when a PATCH leaves it out.
...(isPassiveCheckType(type) && type !== "email" && mode === "edit"
? {}
: { config: submittedConfig }),
// Automatic placement sends the count and lets the server choose the
// regions; pinned sends the explicit list (spec 2026-09-25-06). When
// the picker is hidden (fewer than two eligible regions), a NEW check
Expand Down
Loading