From 1a3972d19e686ab92d7b23c6177b38933d5313bd Mon Sep 17 00:00:00 2001 From: Benjamin Evans Chodroff <4411206+benjaminchodroff@users.noreply.github.com> Date: Sat, 16 May 2026 10:55:17 +0400 Subject: [PATCH 1/5] Allow SSL certificate on the default site (#422) Adds the ability to select an SSL certificate and force HTTPS for the "Default Site" used when nginx is hit with an unknown host. The default site now listens on 443 with the chosen certificate (custom or Let's Encrypt) when configured, and optionally redirects HTTP to HTTPS when ssl_forced is enabled. The "congratulations" option keeps its existing behavior and ignores SSL fields. - backend/templates/default.conf: add SSL listener, certificates include, and optional force-SSL block - backend/internal/setting.js: hydrate the default-site row with its certificate before rendering nginx config - backend/schema/paths/settings/settingID/put.json: accept certificate_id and ssl_forced in meta - frontend/src/pages/Settings/DefaultSite.tsx: certificate picker and force-SSL toggle in the Default Site settings page - test/cypress/e2e/api/Settings.cy.js: cover new meta fields --- backend/internal/setting.js | 38 +++++++++++++- .../schema/paths/settings/settingID/put.json | 9 ++++ backend/templates/default.conf | 17 +++++++ frontend/src/pages/Settings/DefaultSite.tsx | 51 ++++++++++++++++++- test/cypress/e2e/api/Settings.cy.js | 21 ++++++++ 5 files changed, 133 insertions(+), 3 deletions(-) diff --git a/backend/internal/setting.js b/backend/internal/setting.js index f8fc711454..630d1a0ddc 100644 --- a/backend/internal/setting.js +++ b/backend/internal/setting.js @@ -1,8 +1,42 @@ import fs from "node:fs"; import errs from "../lib/error.js"; +import certificateModel from "../models/certificate.js"; import settingModel from "../models/setting.js"; import internalNginx from "./nginx.js"; +/** + * For the default-site setting, fetch the related SSL certificate + * (if any) and attach it so the nginx template can render an HTTPS + * server block. + * + * @param {Object} row The default-site setting row + * @returns {Promise} + */ +const expandDefaultSiteCertificate = async (row) => { + if (row?.id !== "default-site") { + return row; + } + const certificateId = Number.parseInt(row?.meta?.certificate_id, 10) || 0; + row.certificate_id = certificateId; + row.ssl_forced = !!row?.meta?.ssl_forced; + row.certificate = null; + if (certificateId > 0) { + const cert = await certificateModel + .query() + .where("id", certificateId) + .andWhere("is_deleted", 0) + .first(); + if (cert) { + row.certificate = cert; + } else { + // Certificate doesn't exist anymore, reset to no SSL + row.certificate_id = 0; + row.ssl_forced = false; + } + } + return row; +}; + const internalSetting = { /** * @param {Access} access @@ -31,13 +65,15 @@ const internalSetting = { id: data.id, }); }) - .then((row) => { + .then(async (row) => { if (row.id === "default-site") { // write the html if we need to if (row.value === "html") { fs.writeFileSync("/data/nginx/default_www/index.html", row.meta.html, { encoding: "utf8" }); } + await expandDefaultSiteCertificate(row); + // Configure nginx return internalNginx .deleteConfig("default") diff --git a/backend/schema/paths/settings/settingID/put.json b/backend/schema/paths/settings/settingID/put.json index 050ad44125..4eda4368c6 100644 --- a/backend/schema/paths/settings/settingID/put.json +++ b/backend/schema/paths/settings/settingID/put.json @@ -46,6 +46,15 @@ }, "html": { "type": "string" + }, + "certificate_id": { + "type": "integer", + "minimum": 0, + "description": "Certificate ID to use for SSL on the default site, or 0 for none" + }, + "ssl_forced": { + "type": "boolean", + "description": "Whether to redirect HTTP to HTTPS on the default site" } }, "example": { diff --git a/backend/templates/default.conf b/backend/templates/default.conf index cc590f9d85..5f66b1eebd 100644 --- a/backend/templates/default.conf +++ b/backend/templates/default.conf @@ -10,6 +10,14 @@ server { listen [::]:80 default; {% else -%} #listen [::]:80 default; +{% endif %} +{% if certificate and certificate_id > 0 -%} + listen 443 ssl default; +{% if ipv6 -%} + listen [::]:443 ssl default; +{% else -%} + #listen [::]:443 ssl default; +{% endif %} {% endif %} server_name default-host.localhost; access_log /data/logs/default-host_access.log combined; @@ -18,6 +26,15 @@ server { include conf.d/include/letsencrypt-acme-challenge.conf; +{% include "_certificates.conf" %} +{% if certificate and certificate_id > 0 -%} +{% if ssl_forced == 1 or ssl_forced == true %} + # Force SSL + set $trust_forwarded_proto "F"; + include conf.d/include/force-ssl.conf; +{% endif %} +{% endif %} + {%- if value == "404" %} location / { return 404; diff --git a/frontend/src/pages/Settings/DefaultSite.tsx b/frontend/src/pages/Settings/DefaultSite.tsx index 1ee1cb5ef5..b6db537730 100644 --- a/frontend/src/pages/Settings/DefaultSite.tsx +++ b/frontend/src/pages/Settings/DefaultSite.tsx @@ -1,13 +1,43 @@ import CodeEditor from "@uiw/react-textarea-code-editor"; -import { Field, Form, Formik } from "formik"; +import cn from "classnames"; +import { Field, Form, Formik, useFormikContext } from "formik"; import { type ReactNode, useState } from "react"; import { Alert } from "react-bootstrap"; -import { Button, Loading } from "src/components"; +import { Button, Loading, SSLCertificateField } from "src/components"; import { useSetSetting, useSetting } from "src/hooks"; import { intl, T } from "src/locale"; import { validateString } from "src/modules/Validations"; import { showObjectSuccess } from "src/notifications"; +function ForceSSLField() { + const { values, setFieldValue } = useFormikContext(); + const v: any = values || {}; + const hasCertificate = v?.certificateId && v?.certificateId > 0; + const sslForced = !!v?.sslForced; + + const toggleClasses = "form-check-input"; + const toggleEnabled = cn(toggleClasses, "bg-teal"); + + return ( + + {({ field }: any) => ( + + )} + + ); +} + export default function DefaultSite() { const { data, isLoading, error } = useSetting("default-site"); const { mutate: setSetting } = useSetSetting(); @@ -19,12 +49,17 @@ export default function DefaultSite() { setIsSubmitting(true); setErrorMsg(null); + const certificateId = + typeof values.certificateId === "number" && values.certificateId > 0 ? values.certificateId : 0; + const payload = { id: "default-site", value: values.value, meta: { redirect: values.redirect, html: values.html, + certificate_id: certificateId, + ssl_forced: certificateId > 0 ? !!values.sslForced : false, }, }; @@ -69,6 +104,8 @@ export default function DefaultSite() { value: data?.value || "congratulations", redirect: data?.meta?.redirect || "", html: data?.meta?.html || "", + certificateId: data?.meta?.certificate_id || 0, + sslForced: !!data?.meta?.ssl_forced, } as any } onSubmit={onSubmit} @@ -247,6 +284,16 @@ export default function DefaultSite() { )} )} + {values.value !== "congratulations" && ( +
+ + +
+ )}
diff --git a/test/cypress/e2e/api/Settings.cy.js b/test/cypress/e2e/api/Settings.cy.js index a925b2d374..26c7d642b0 100644 --- a/test/cypress/e2e/api/Settings.cy.js +++ b/test/cypress/e2e/api/Settings.cy.js @@ -122,4 +122,25 @@ describe('Settings endpoints', () => { expect(data.meta.html).to.be.equal('

hello world

'); }); }); + + it('Default Site with SSL disabled (certificate_id 0)', () => { + cy.task('backendApiPut', { + token: token, + path: '/api/settings/default-site', + data: { + value: '404', + meta: { + certificate_id: 0, + ssl_forced: false, + }, + }, + }).then((data) => { + cy.validateSwaggerSchema('put', 200, '/settings/{settingID}', data); + expect(data).to.have.property('id'); + expect(data.id).to.be.equal('default-site'); + expect(data).to.have.property('meta'); + expect(data.meta.certificate_id).to.be.equal(0); + expect(data.meta.ssl_forced).to.be.equal(false); + }); + }); }); From 56164e12aac680979f3ae6b594047be0c088282c Mon Sep 17 00:00:00 2001 From: Benjamin Evans Chodroff <4411206+benjaminchodroff@users.noreply.github.com> Date: Sat, 16 May 2026 16:03:39 +0400 Subject: [PATCH 2/5] Fix default-site setting response to match schema (#422) --- backend/internal/setting.js | 37 +++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/backend/internal/setting.js b/backend/internal/setting.js index 630d1a0ddc..b493afd207 100644 --- a/backend/internal/setting.js +++ b/backend/internal/setting.js @@ -5,21 +5,18 @@ import settingModel from "../models/setting.js"; import internalNginx from "./nginx.js"; /** - * For the default-site setting, fetch the related SSL certificate - * (if any) and attach it so the nginx template can render an HTTPS - * server block. + * Build the nginx render context for the default-site setting by + * resolving its SSL certificate (if any). Returns a NEW object so the + * underlying row sent back to the API client is not mutated with + * fields that aren't part of the setting-object schema. * * @param {Object} row The default-site setting row - * @returns {Promise} + * @returns {Promise} A row-like object with certificate/ssl fields */ -const expandDefaultSiteCertificate = async (row) => { - if (row?.id !== "default-site") { - return row; - } +const buildDefaultSiteRenderContext = async (row) => { const certificateId = Number.parseInt(row?.meta?.certificate_id, 10) || 0; - row.certificate_id = certificateId; - row.ssl_forced = !!row?.meta?.ssl_forced; - row.certificate = null; + let certificate = null; + let ssl_forced = !!row?.meta?.ssl_forced; if (certificateId > 0) { const cert = await certificateModel .query() @@ -27,14 +24,18 @@ const expandDefaultSiteCertificate = async (row) => { .andWhere("is_deleted", 0) .first(); if (cert) { - row.certificate = cert; + certificate = cert; } else { - // Certificate doesn't exist anymore, reset to no SSL - row.certificate_id = 0; - row.ssl_forced = false; + // Certificate doesn't exist anymore, render without SSL + ssl_forced = false; } } - return row; + return { + ...row, + certificate_id: certificate ? certificateId : 0, + ssl_forced, + certificate, + }; }; const internalSetting = { @@ -72,13 +73,13 @@ const internalSetting = { fs.writeFileSync("/data/nginx/default_www/index.html", row.meta.html, { encoding: "utf8" }); } - await expandDefaultSiteCertificate(row); + const renderContext = await buildDefaultSiteRenderContext(row); // Configure nginx return internalNginx .deleteConfig("default") .then(() => { - return internalNginx.generateConfig("default", row); + return internalNginx.generateConfig("default", renderContext); }) .then(() => { return internalNginx.test(); From 3aef29c9de00c49dae31e9af8911c85e40ff77c8 Mon Sep 17 00:00:00 2001 From: Benjamin Evans Chodroff <4411206+benjaminchodroff@users.noreply.github.com> Date: Sat, 16 May 2026 16:39:51 +0400 Subject: [PATCH 3/5] Harden default-site SSL: don't log private key; regen on cert delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-up fixes on top of the SSL-on-default-site feature (#422): 1. Strip cert PEM contents from the nginx render context. The cert row pulled from the DB carries the certificate and certificate_key PEMs in `meta`, and `internalNginx.generateConfig` debug-logs the full render context via JSON.stringify — so under DEBUG the private key ended up in stdout / /data/logs. The nginx template only reads `certificate.provider` and renders disk paths from `certificate_id`, so meta is unnecessary. 2. Regenerate the default-site nginx config when the cert it references is deleted. Other host types are covered by the existing disableInUseHosts / enableInUseHosts flow, which is keyed off cert domain_names and doesn't see the default-site setting. Without this, deleting a cert in use by the default-site leaves a stale ssl_certificate path on disk and the next nginx reload fails. --- backend/internal/certificate.js | 16 ++++++++++++++++ backend/internal/setting.js | 26 ++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/backend/internal/certificate.js b/backend/internal/certificate.js index 59c13bf694..bf59c0119d 100644 --- a/backend/internal/certificate.js +++ b/backend/internal/certificate.js @@ -13,11 +13,13 @@ import error from "../lib/error.js"; import utils from "../lib/utils.js"; import { debug, ssl as logger } from "../logger.js"; import certificateModel from "../models/certificate.js"; +import settingModel from "../models/setting.js"; import tokenModel from "../models/token.js"; import userModel from "../models/user.js"; import internalAuditLog from "./audit-log.js"; import internalHost from "./host.js"; import internalNginx from "./nginx.js"; +import { regenerateDefaultSiteConfig } from "./setting.js"; const letsencryptConfig = "/etc/letsencrypt.ini"; const certbotCommand = "certbot"; @@ -419,6 +421,20 @@ const internalCertificate = { // Revoke the cert await internalCertificate.revokeLetsEncryptSsl(row); } + + // If the default-site setting was using this cert, regenerate its + // nginx config so it falls back to no-SSL. Without this, the next + // nginx reload would fail because the cert PEM files are gone from + // disk while the rendered default_host/site.conf still references them. + try { + const defaultSite = await settingModel.query().where("id", "default-site").first(); + if (Number.parseInt(defaultSite?.meta?.certificate_id, 10) === row.id) { + await regenerateDefaultSiteConfig(); + } + } catch (err) { + logger.warn(`Failed to regenerate default-site config after cert ${row.id} delete: ${err.message}`); + } + return true; }, diff --git a/backend/internal/setting.js b/backend/internal/setting.js index b493afd207..e4b9eede6e 100644 --- a/backend/internal/setting.js +++ b/backend/internal/setting.js @@ -24,6 +24,12 @@ const buildDefaultSiteRenderContext = async (row) => { .andWhere("is_deleted", 0) .first(); if (cert) { + // Drop PEM cert / private-key contents from `meta` before passing + // to the nginx template. The template renders paths based on + // `certificate_id` and only reads `certificate.provider`; the PEM + // material is on disk. Keeping `meta` here would leak the private + // key into `internalNginx.generateConfig`'s debug log. + cert.meta = {}; certificate = cert; } else { // Certificate doesn't exist anymore, render without SSL @@ -159,4 +165,24 @@ const internalSetting = { }, }; +/** + * Regenerate the nginx config for the default-site setting based on + * its current DB state. Intended for callers outside the settings + * update flow (e.g. certificate.delete) that invalidate the rendered + * config without touching the setting row. + * + * @returns {Promise} + */ +const regenerateDefaultSiteConfig = async () => { + const row = await settingModel.query().where("id", "default-site").first(); + if (!row) { + return; + } + const renderContext = await buildDefaultSiteRenderContext(row); + await internalNginx.deleteConfig("default"); + await internalNginx.generateConfig("default", renderContext); + await internalNginx.reload(); +}; + +export { regenerateDefaultSiteConfig }; export default internalSetting; From 46e46a642d5765f64fa5fafdc31befe4a6c1b652 Mon Sep 17 00:00:00 2001 From: Benjamin Evans Chodroff <4411206+benjaminchodroff@users.noreply.github.com> Date: Mon, 18 May 2026 16:49:32 +0400 Subject: [PATCH 4/5] Address review feedback for default-site SSL (#422) 1. regenerateDefaultSiteConfig: run nginx -t before reload, fall back to an empty config on failure. 2. On certificate delete, also clear stale certificate_id / ssl_forced from default-site meta in the DB. 3. Move _certificates.conf include inside the certificate guard in default.conf. 4. ForceSSLField: drop redundant wrapper, use useFormikContext directly. 5. Simplify ssl_forced check in default.conf to {% if ssl_forced %}. 6. Cypress: cover SSL-enabled PUT and the cert-deletion fallback. 7. Number(...) instead of Number.parseInt(..., 10). 8. Destructure cert.meta out instead of mutating the ORM row. --- backend/internal/certificate.js | 18 ++- backend/internal/setting.js | 29 +++- backend/templates/default.conf | 4 +- frontend/src/pages/Settings/DefaultSite.tsx | 47 +++--- test/cypress/e2e/api/Settings.cy.js | 161 ++++++++++++++++++++ 5 files changed, 219 insertions(+), 40 deletions(-) diff --git a/backend/internal/certificate.js b/backend/internal/certificate.js index bf59c0119d..3c124765e0 100644 --- a/backend/internal/certificate.js +++ b/backend/internal/certificate.js @@ -422,17 +422,23 @@ const internalCertificate = { await internalCertificate.revokeLetsEncryptSsl(row); } - // If the default-site setting was using this cert, regenerate its - // nginx config so it falls back to no-SSL. Without this, the next - // nginx reload would fail because the cert PEM files are gone from - // disk while the rendered default_host/site.conf still references them. + // If the default-site setting was using this cert, clear the stale + // reference from its meta and regenerate the nginx config so it + // falls back to no-SSL. Without the meta cleanup the Settings UI + // would still show a now-dangling certificate_id; without the + // regen the next nginx reload would fail because the cert PEM + // files are gone from disk while the rendered default_host/site.conf + // still references them. try { const defaultSite = await settingModel.query().where("id", "default-site").first(); - if (Number.parseInt(defaultSite?.meta?.certificate_id, 10) === row.id) { + if (defaultSite && Number(defaultSite.meta?.certificate_id) === row.id) { + await settingModel.query().where("id", "default-site").patch({ + meta: { ...defaultSite.meta, certificate_id: 0, ssl_forced: false }, + }); await regenerateDefaultSiteConfig(); } } catch (err) { - logger.warn(`Failed to regenerate default-site config after cert ${row.id} delete: ${err.message}`); + logger.warn(`Failed to clean up default-site after cert ${row.id} delete: ${err.message}`); } return true; diff --git a/backend/internal/setting.js b/backend/internal/setting.js index e4b9eede6e..9fd38f2e7e 100644 --- a/backend/internal/setting.js +++ b/backend/internal/setting.js @@ -14,7 +14,7 @@ import internalNginx from "./nginx.js"; * @returns {Promise} A row-like object with certificate/ssl fields */ const buildDefaultSiteRenderContext = async (row) => { - const certificateId = Number.parseInt(row?.meta?.certificate_id, 10) || 0; + const certificateId = Number(row?.meta?.certificate_id) || 0; let certificate = null; let ssl_forced = !!row?.meta?.ssl_forced; if (certificateId > 0) { @@ -28,9 +28,10 @@ const buildDefaultSiteRenderContext = async (row) => { // to the nginx template. The template renders paths based on // `certificate_id` and only reads `certificate.provider`; the PEM // material is on disk. Keeping `meta` here would leak the private - // key into `internalNginx.generateConfig`'s debug log. - cert.meta = {}; - certificate = cert; + // key into `internalNginx.generateConfig`'s debug log. Destructure + // to avoid mutating the ORM result. + const { meta: _meta, ...safeCert } = cert; + certificate = safeCert; } else { // Certificate doesn't exist anymore, render without SSL ssl_forced = false; @@ -169,7 +170,9 @@ const internalSetting = { * Regenerate the nginx config for the default-site setting based on * its current DB state. Intended for callers outside the settings * update flow (e.g. certificate.delete) that invalidate the rendered - * config without touching the setting row. + * config without touching the setting row. Mirrors the test-before- + * reload pattern of the main update flow and falls back to a clean + * config if the regenerated one is invalid. * * @returns {Promise} */ @@ -179,9 +182,19 @@ const regenerateDefaultSiteConfig = async () => { return; } const renderContext = await buildDefaultSiteRenderContext(row); - await internalNginx.deleteConfig("default"); - await internalNginx.generateConfig("default", renderContext); - await internalNginx.reload(); + try { + await internalNginx.deleteConfig("default"); + await internalNginx.generateConfig("default", renderContext); + await internalNginx.test(); + await internalNginx.reload(); + } catch (err) { + // Generated config is invalid — strip it and reload so nginx + // stays healthy even if the default-site is rendered empty. + await internalNginx.deleteConfig("default"); + await internalNginx.test(); + await internalNginx.reload(); + throw err; + } }; export { regenerateDefaultSiteConfig }; diff --git a/backend/templates/default.conf b/backend/templates/default.conf index 5f66b1eebd..0557a19dd8 100644 --- a/backend/templates/default.conf +++ b/backend/templates/default.conf @@ -26,9 +26,9 @@ server { include conf.d/include/letsencrypt-acme-challenge.conf; -{% include "_certificates.conf" %} {% if certificate and certificate_id > 0 -%} -{% if ssl_forced == 1 or ssl_forced == true %} +{% include "_certificates.conf" %} +{% if ssl_forced %} # Force SSL set $trust_forwarded_proto "F"; include conf.d/include/force-ssl.conf; diff --git a/frontend/src/pages/Settings/DefaultSite.tsx b/frontend/src/pages/Settings/DefaultSite.tsx index b6db537730..e5cff5bb1c 100644 --- a/frontend/src/pages/Settings/DefaultSite.tsx +++ b/frontend/src/pages/Settings/DefaultSite.tsx @@ -9,32 +9,27 @@ import { intl, T } from "src/locale"; import { validateString } from "src/modules/Validations"; import { showObjectSuccess } from "src/notifications"; -function ForceSSLField() { +function SSLToggle({ name, label, disabled }: { name: string; label: string; disabled?: boolean }) { const { values, setFieldValue } = useFormikContext(); const v: any = values || {}; - const hasCertificate = v?.certificateId && v?.certificateId > 0; - const sslForced = !!v?.sslForced; + const checked = !!v?.[name]; const toggleClasses = "form-check-input"; const toggleEnabled = cn(toggleClasses, "bg-teal"); return ( - - {({ field }: any) => ( - - )} - + ); } @@ -58,8 +53,8 @@ export default function DefaultSite() { meta: { redirect: values.redirect, html: values.html, - certificate_id: certificateId, - ssl_forced: certificateId > 0 ? !!values.sslForced : false, + certificateId: certificateId, + sslForced: certificateId > 0 ? !!values.sslForced : false, }, }; @@ -104,8 +99,8 @@ export default function DefaultSite() { value: data?.value || "congratulations", redirect: data?.meta?.redirect || "", html: data?.meta?.html || "", - certificateId: data?.meta?.certificate_id || 0, - sslForced: !!data?.meta?.ssl_forced, + certificateId: data?.meta?.certificateId || 0, + sslForced: !!data?.meta?.sslForced, } as any } onSubmit={onSubmit} @@ -291,7 +286,11 @@ export default function DefaultSite() { label="ssl-certificate" forHttp={true} /> - + )} diff --git a/test/cypress/e2e/api/Settings.cy.js b/test/cypress/e2e/api/Settings.cy.js index 26c7d642b0..2bb3a143fe 100644 --- a/test/cypress/e2e/api/Settings.cy.js +++ b/test/cypress/e2e/api/Settings.cy.js @@ -143,4 +143,165 @@ describe('Settings endpoints', () => { expect(data.meta.ssl_forced).to.be.equal(false); }); }); + + it('Default Site with SSL enabled (certificate_id > 0, ssl_forced true)', () => { + // Create a custom cert and upload PEM material so the rendered + // nginx config can actually point at on-disk files. + cy.task('backendApiPost', { + token: token, + path: '/api/nginx/certificates', + data: { + provider: 'other', + nice_name: 'Default Site Cert', + }, + }).then((cert) => { + expect(cert).to.have.property('id'); + const certID = cert.id; + + cy.task('backendApiPostFiles', { + token: token, + path: `/api/nginx/certificates/${certID}/upload`, + files: { + certificate: 'test.example.com.pem', + certificate_key: 'test.example.com-key.pem', + }, + }).then(() => { + // Attach the cert + force SSL to the default site + cy.task('backendApiPut', { + token: token, + path: '/api/settings/default-site', + data: { + value: '404', + meta: { + certificate_id: certID, + ssl_forced: true, + }, + }, + }).then((data) => { + cy.validateSwaggerSchema('put', 200, '/settings/{settingID}', data); + expect(data.meta.certificate_id).to.be.equal(certID); + expect(data.meta.ssl_forced).to.be.equal(true); + // Response must NOT leak the joined certificate row or + // the resolved certificate_id / ssl_forced at the top + // level — those are template-only fields. + expect(data).to.not.have.property('certificate'); + expect(data).to.not.have.property('certificate_id'); + expect(data).to.not.have.property('ssl_forced'); + }); + + // Clean up so the next test starts fresh + cy.task('backendApiDelete', { + token: token, + path: `/api/nginx/certificates/${certID}`, + }); + }); + }); + }); + + it('Default Site is unaffected when an unrelated certificate is deleted', () => { + // Two custom certs: A is attached to the default-site, B is unrelated. + // Deleting B must not touch the default-site's meta. + const createCert = (name) => cy.task('backendApiPost', { + token: token, + path: '/api/nginx/certificates', + data: { provider: 'other', nice_name: name }, + }).then((cert) => { + return cy.task('backendApiPostFiles', { + token: token, + path: `/api/nginx/certificates/${cert.id}/upload`, + files: { + certificate: 'test.example.com.pem', + certificate_key: 'test.example.com-key.pem', + }, + }).then(() => cert.id); + }); + + createCert('Default-Site Cert A').then((aID) => { + createCert('Unrelated Cert B').then((bID) => { + cy.task('backendApiPut', { + token: token, + path: '/api/settings/default-site', + data: { + value: '404', + meta: { certificate_id: aID, ssl_forced: true }, + }, + }); + + // Delete B (the unrelated one) + cy.task('backendApiDelete', { + token: token, + path: `/api/nginx/certificates/${bID}`, + }).then(() => { + cy.task('backendApiGet', { + token: token, + path: '/api/settings/default-site', + }).then((data) => { + cy.validateSwaggerSchema('get', 200, '/settings/{settingID}', data); + // Default-site should still reference A unchanged + expect(data.meta.certificate_id).to.be.equal(aID); + expect(data.meta.ssl_forced).to.be.equal(true); + }); + + // Cleanup + cy.task('backendApiDelete', { + token: token, + path: `/api/nginx/certificates/${aID}`, + }); + }); + }); + }); + }); + + it('Default Site falls back to no-SSL when its certificate is deleted', () => { + cy.task('backendApiPost', { + token: token, + path: '/api/nginx/certificates', + data: { + provider: 'other', + nice_name: 'Disposable Cert', + }, + }).then((cert) => { + const certID = cert.id; + + cy.task('backendApiPostFiles', { + token: token, + path: `/api/nginx/certificates/${certID}/upload`, + files: { + certificate: 'test.example.com.pem', + certificate_key: 'test.example.com-key.pem', + }, + }).then(() => { + cy.task('backendApiPut', { + token: token, + path: '/api/settings/default-site', + data: { + value: '404', + meta: { + certificate_id: certID, + ssl_forced: true, + }, + }, + }).then((data) => { + expect(data.meta.certificate_id).to.be.equal(certID); + expect(data.meta.ssl_forced).to.be.equal(true); + }); + + // Delete the cert that the default-site references + cy.task('backendApiDelete', { + token: token, + path: `/api/nginx/certificates/${certID}`, + }).then(() => { + // The default-site row should have been cleaned up + cy.task('backendApiGet', { + token: token, + path: '/api/settings/default-site', + }).then((data) => { + cy.validateSwaggerSchema('get', 200, '/settings/{settingID}', data); + expect(data.meta.certificate_id).to.be.equal(0); + expect(data.meta.ssl_forced).to.be.equal(false); + }); + }); + }); + }); + }); }); From 77c5655edf9cf913e71889cdce38dbf7d02f284a Mon Sep 17 00:00:00 2001 From: Benjamin Evans Chodroff <4411206+benjaminchodroff@users.noreply.github.com> Date: Mon, 18 May 2026 18:01:26 +0400 Subject: [PATCH 5/5] retrigger ci