fix: noisy domain expiry checks in monitor editor and missing debuggability (#6637)

Co-authored-by: Frank Elsinga <frank@elsinga.de>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Joseph Adams 2026-01-08 06:22:08 +01:00 committed by GitHub
parent e31ef63b01
commit 0eca301181
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 373 additions and 95 deletions

View File

@ -5,8 +5,10 @@ const { parse: parseTld } = require("tldts");
const { getDaysRemaining, getDaysBetween, setting, setSetting } = require("../util-server"); const { getDaysRemaining, getDaysBetween, setting, setSetting } = require("../util-server");
const { Notification } = require("../notification"); const { Notification } = require("../notification");
const { default: NodeFetchCache, MemoryCache } = require("node-fetch-cache"); const { default: NodeFetchCache, MemoryCache } = require("node-fetch-cache");
const TranslatableError = require("../translatable-error");
const TABLE = "domain_expiry"; const TABLE = "domain_expiry";
// NOTE: Keep these type filters in sync with `showDomainExpiryNotification` in `src/pages/EditMonitor.vue`.
const urlTypes = [ "websocket-upgrade", "http", "keyword", "json-query", "real-browser" ]; const urlTypes = [ "websocket-upgrade", "http", "keyword", "json-query", "real-browser" ];
const excludeTypes = [ "docker", "group", "push", "manual", "rabbitmq", "redis" ]; const excludeTypes = [ "docker", "group", "push", "manual", "rabbitmq", "redis" ];
@ -36,6 +38,7 @@ async function getRdapServer(tld) {
return urls[0]; return urls[0];
} }
} }
log.debug("rdap", `No RDAP server found for TLD ${tld}`);
return null; return null;
} }
@ -126,7 +129,8 @@ class DomainExpiry extends BeanModel {
static parseTld = parseTld; static parseTld = parseTld;
/** /**
* @returns {(object)} parsed domain components * @typedef {import("tldts-core").IResult} DomainComponents
* @returns {DomainComponents} parsed domain components
*/ */
parseName() { parseName() {
return parseTld(this.domain); return parseTld(this.domain);
@ -141,26 +145,71 @@ class DomainExpiry extends BeanModel {
/** /**
* @param {Monitor} monitor Monitor object * @param {Monitor} monitor Monitor object
* @returns {Promise<DomainExpiry>} Domain expiry bean * @throws {TranslatableError} Throws an error if the monitor type is unsupported or missing target.
* @returns {Promise<{ domain: string, tld: string }>} Domain expiry support info
*/ */
static async forMonitor(monitor) { static async checkSupport(monitor) {
const m = monitor; if (excludeTypes.includes(monitor.type) || monitor.type?.match(/sql$/)) {
if (excludeTypes.includes(m.type) || m.type?.match(/sql$/)) { throw new TranslatableError("domain_expiry_unsupported_monitor_type");
return false;
} }
const tld = parseTld(urlTypes.includes(m.type) ? m.url : m.type === "grpc-keyword" ? m.grpcUrl : m.hostname);
let target;
if (urlTypes.includes(monitor.type)) {
target = monitor.url;
} else if (monitor.type === "grpc-keyword") {
target = monitor.grpcUrl;
} else {
target = monitor.hostname;
}
if (typeof target !== "string" || target.length === 0) {
throw new TranslatableError("domain_expiry_unsupported_missing_target");
}
const tld = parseTld(target);
// Avoid logging for incomplete/invalid input while editing monitors.
if (!tld.domain) {
throw new TranslatableError("domain_expiry_unsupported_invalid_domain", { hostname: tld.hostname });
}
if ( !tld.publicSuffix) {
throw new TranslatableError("domain_expiry_unsupported_public_suffix", { publicSuffix: tld.publicSuffix });
}
if (tld.isIp) {
throw new TranslatableError("domain_expiry_unsupported_is_ip", { hostname: tld.hostname });
}
// No one-letter public suffix exists; treat this as an incomplete/invalid input while typing.
if (tld.publicSuffix.length < 2) {
throw new TranslatableError("domain_expiry_public_suffix_too_short", { publicSuffix: tld.publicSuffix });
}
const rdap = await getRdapServer(tld.publicSuffix); const rdap = await getRdapServer(tld.publicSuffix);
if (!rdap) { if (!rdap) {
log.warn("domain_expiry", `Domain expiry unsupported for '.${tld.publicSuffix}' because its RDAP endpoint is not listed in the IANA database.`); // Only warn when the monitor actually has domain expiry notifications enabled.
return false; // The edit monitor page calls this method frequently while the user is typing.
if (Boolean(monitor.domainExpiryNotification)) {
log.warn("domain_expiry", `Domain expiry unsupported for '.${tld.publicSuffix}' because its RDAP endpoint is not listed in the IANA database.`);
}
throw new TranslatableError("domain_expiry_unsupported_unsupported_tld_no_rdap_endpoint", { publicSuffix: tld.publicSuffix });
} }
const existing = await DomainExpiry.findByName(tld.domain);
return {
domain: tld.domain,
tld: tld.publicSuffix,
};
}
/**
* @param {string} domainName Domain name
* @returns {Promise<DomainExpiry>} Domain expiry bean
*/
static async findByDomainNameOrCreate(domainName) {
const existing = await DomainExpiry.findByName(domainName);
if (existing) { if (existing) {
return existing; return existing;
} }
if (tld.domain) { return DomainExpiry.createByName(domainName);
return await DomainExpiry.createByName(tld.domain);
}
} }
/** /**
@ -178,12 +227,12 @@ class DomainExpiry extends BeanModel {
} }
/** /**
* @param {(Monitor)} monitor Monitor object * @param {string} domainName Monitor object
* @returns {Promise<void>} * @throws {TranslatableError} If the domain is not supported
* @returns {Promise<Date | undefined>} the expiry date
*/ */
static async checkExpiry(monitor) { static async checkExpiry(domainName) {
let bean = await DomainExpiry.findByDomainNameOrCreate(domainName);
let bean = await DomainExpiry.forMonitor(monitor);
let expiryDate; let expiryDate;
if (bean?.lastCheck && getDaysBetween(new Date(bean.lastCheck), new Date()) < 1) { if (bean?.lastCheck && getDaysBetween(new Date(bean.lastCheck), new Date()) < 1) {
@ -209,14 +258,12 @@ class DomainExpiry extends BeanModel {
} }
/** /**
* @param {Monitor} monitor Monitor instance * @param {string} domainName the domain name to send notifications for
* @param {LooseObject<any>[]} notificationList notification List * @param {LooseObject<any>[]} notificationList notification List
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
static async sendNotifications(monitor, notificationList) { static async sendNotifications(domainName, notificationList) {
const domain = await DomainExpiry.forMonitor(monitor); const domain = await DomainExpiry.findByDomainNameOrCreate(domainName);
const name = domain.domain;
if (!notificationList.length > 0) { if (!notificationList.length > 0) {
// fail fast. If no notification is set, all the following checks can be skipped. // fail fast. If no notification is set, all the following checks can be skipped.
log.debug("domain_expiry", "No notification, no need to send domain notification"); log.debug("domain_expiry", "No notification, no need to send domain notification");
@ -224,13 +271,13 @@ class DomainExpiry extends BeanModel {
} }
// sanity check if expiry date is valid before calculating days remaining. Should not happen and likely indicates a bug in the code. // sanity check if expiry date is valid before calculating days remaining. Should not happen and likely indicates a bug in the code.
if (!domain.expiry || isNaN(new Date(domain.expiry).getTime())) { if (!domain.expiry || isNaN(new Date(domain.expiry).getTime())) {
log.warn("domain_expiry", `No valid expiry date passed to sendNotifications for ${name} (expiry: ${domain.expiry}), skipping notification`); log.warn("domain_expiry", `No valid expiry date passed to sendNotifications for ${domainName} (expiry: ${domain.expiry}), skipping notification`);
return; return;
} }
const daysRemaining = getDaysRemaining(new Date(), domain.expiry); const daysRemaining = getDaysRemaining(new Date(), domain.expiry);
const lastSent = domain.lastExpiryNotificationSent; const lastSent = domain.lastExpiryNotificationSent;
log.debug("domain_expiry", `${name} expires in ${daysRemaining} days`); log.debug("domain_expiry", `${domainName} expires in ${daysRemaining} days`);
let notifyDays = await setting("domainExpiryNotifyDays"); let notifyDays = await setting("domainExpiryNotifyDays");
if (notifyDays == null || !Array.isArray(notifyDays)) { if (notifyDays == null || !Array.isArray(notifyDays)) {
@ -244,19 +291,19 @@ class DomainExpiry extends BeanModel {
for (const targetDays of notifyDays) { for (const targetDays of notifyDays) {
if (daysRemaining > targetDays) { if (daysRemaining > targetDays) {
log.debug( log.debug(
"domain", "domain_expiry",
`No need to send domain notification for ${name} (${daysRemaining} days valid) on ${targetDays} deadline.` `No need to send domain notification for ${domainName} (${daysRemaining} days valid) on ${targetDays} deadline.`
); );
continue; continue;
} else if (lastSent && lastSent <= targetDays) { } else if (lastSent && lastSent <= targetDays) {
log.debug( log.debug(
"domain", "domain_expiry",
`Notification for ${name} on ${targetDays} deadline sent already, no need to send again.` `Notification for ${domainName} on ${targetDays} deadline sent already, no need to send again.`
); );
continue; continue;
} }
const sent = await sendDomainNotificationByTargetDays( const sent = await sendDomainNotificationByTargetDays(
name, domainName,
daysRemaining, daysRemaining,
targetDays, targetDays,
notificationList notificationList

View File

@ -920,14 +920,15 @@ class Monitor extends BeanModel {
if (bean.status !== MAINTENANCE && Boolean(this.domainExpiryNotification)) { if (bean.status !== MAINTENANCE && Boolean(this.domainExpiryNotification)) {
try { try {
const domainExpiryDate = await DomainExpiry.checkExpiry(this); const supportInfo = await DomainExpiry.checkSupport(this);
const domainExpiryDate = await DomainExpiry.checkExpiry(supportInfo.domain);
if (domainExpiryDate) { if (domainExpiryDate) {
DomainExpiry.sendNotifications(this, await Monitor.getNotificationList(this) || []); DomainExpiry.sendNotifications(supportInfo.domain, await Monitor.getNotificationList(this) || []);
} else { } else {
log.debug("monitor", `Failed getting expiration date for domain ${this.name}`); log.debug("monitor", `Failed getting expiration date for domain ${supportInfo.domain}`);
} }
} catch (error) { } catch (error) {
log.warn("monitor", `Failed to get domain expiry for ${this.name} : ${error.message}`); // purposely not logged due to noise. Is accessible via checkMointor
} }
} }
@ -1232,10 +1233,13 @@ class Monitor extends BeanModel {
static async sendDomainInfo(io, monitorID, userID) { static async sendDomainInfo(io, monitorID, userID) {
const monitor = await R.findOne("monitor", "id = ?", [ monitorID ]); const monitor = await R.findOne("monitor", "id = ?", [ monitorID ]);
const domain = await DomainExpiry.forMonitor(monitor); try {
if (domain?.expiry) { const supportInfo = await DomainExpiry.checkSupport(monitor);
io.to(userID).emit("domainInfo", monitorID, domain.daysRemaining, new Date(domain.expiry)); const domain = await DomainExpiry.findByDomainNameOrCreate(supportInfo.domain);
} if (domain?.expiry) {
io.to(userID).emit("domainInfo", monitorID, domain.daysRemaining, new Date(domain.expiry));
}
} catch (e) {}
} }
/** /**
@ -1306,7 +1310,7 @@ class Monitor extends BeanModel {
* @param {boolean} isFirstBeat Is this beat the first of this monitor? * @param {boolean} isFirstBeat Is this beat the first of this monitor?
* @param {Monitor} monitor The monitor to send a notification about * @param {Monitor} monitor The monitor to send a notification about
* @param {Bean} bean Status information about monitor * @param {Bean} bean Status information about monitor
* @returns {void} * @returns {Promise<void>}
*/ */
static async sendNotification(isFirstBeat, monitor, bean) { static async sendNotification(isFirstBeat, monitor, bean) {
if (!isFirstBeat || bean.status === DOWN) { if (!isFirstBeat || bean.status === DOWN) {
@ -1363,7 +1367,7 @@ class Monitor extends BeanModel {
/** /**
* checks certificate chain for expiring certificates * checks certificate chain for expiring certificates
* @param {object} tlsInfoObject Information about certificate * @param {object} tlsInfoObject Information about certificate
* @returns {void} * @returns {Promise<void>}
*/ */
async checkCertExpiryNotifications(tlsInfoObject) { async checkCertExpiryNotifications(tlsInfoObject) {
if (tlsInfoObject && tlsInfoObject.certInfo && tlsInfoObject.certInfo.daysRemaining) { if (tlsInfoObject && tlsInfoObject.certInfo && tlsInfoObject.certInfo.daysRemaining) {

View File

@ -698,7 +698,7 @@ let needSetup = false;
callback({ callback({
ok: false, ok: false,
msg: e.message, msg: e.message,
msgi18n: !!e.msgi18n, msgi18n: !!e.msgi18n
}); });
} }
}); });
@ -990,14 +990,18 @@ let needSetup = false;
try { try {
checkLogin(socket); checkLogin(socket);
const DomainExpiry = require("./model/domain_expiry"); const DomainExpiry = require("./model/domain_expiry");
const supportInfo = await DomainExpiry.checkSupport(partial);
callback({ callback({
ok: true, ok: true,
domain: (await DomainExpiry.forMonitor(partial))?.domain || null domain: supportInfo.domain,
tld: supportInfo.tld
}); });
} catch (e) { } catch (e) {
callback({ callback({
ok: false, ok: false,
msg: e.message, msg: e.message,
msgi18n: !!e.msgi18n,
meta: e.meta ?? {}
}); });
} }
}); });

View File

@ -1,16 +1,21 @@
/**
* Error whose message is a translation key.
* @augments Error
*/
class TranslatableError extends Error { class TranslatableError extends Error {
/** /**
* Error whose message is a translation key. * Indicates that the error message is a translation key.
* @augments Error
*/ */
msgi18n = true;
/** /**
* Create a TranslatableError. * Create a TranslatableError.
* @param {string} key - Translation key present in src/lang/en.json * @param {string} key - Translation key present in src/lang/en.json
* @param {object} meta Arbitrary metadata
*/ */
constructor(key) { constructor(key, meta = {}) {
super(key); super(key);
this.msgi18n = true; this.meta = meta;
this.key = key;
Error.captureStackTrace(this, this.constructor); Error.captureStackTrace(this, this.constructor);
} }
} }

View File

@ -487,7 +487,7 @@ const parseCertificateInfo = function (info) {
/** /**
* Check if certificate is valid * Check if certificate is valid
* @param {tls.TLSSocket} socket TLSSocket, which may or may not be connected * @param {tls.TLSSocket} socket TLSSocket, which may or may not be connected
* @returns {object} Object containing certificate information * @returns {null | {valid: boolean, certInfo: object}} Object containing certificate information
*/ */
exports.checkCertificate = function (socket) { exports.checkCertificate = function (socket) {
let certInfoStartTime = dayjs().valueOf(); let certInfoStartTime = dayjs().valueOf();

View File

@ -1261,6 +1261,13 @@
"labelDomainExpiry": "Domain Exp.", "labelDomainExpiry": "Domain Exp.",
"labelDomainNameExpiryNotification": "Domain Name Expiry Notification", "labelDomainNameExpiryNotification": "Domain Name Expiry Notification",
"domainExpiryDescription": "Trigger notification when domain names expires in:", "domainExpiryDescription": "Trigger notification when domain names expires in:",
"domain_expiry_unsupported_monitor_type": "Domain expiry monitoring is not supported for this monitor type",
"domain_expiry_unsupported_missing_target": "No valid domain or hostname is configured for this monitor",
"domain_expiry_unsupported_invalid_domain": "The configured value \"{hostname}\" is not a valid domain name",
"domain_expiry_public_suffix_too_short": "\".{publicSuffix}\" is too short for a top level domain",
"domain_expiry_unsupported_public_suffix": "The domain \"{domain}\" does not have a valid public suffix",
"domain_expiry_unsupported_is_ip": "\"{hostname}\" is an IP address. Domain expiry monitoring requires a domain name",
"domain_expiry_unsupported_unsupported_tld_no_rdap_endpoint": "Domain expiry monitoring is not available for \".{publicSuffix}\" because no RDAP service is listed by IANA",
"minimumIntervalWarning": "Intervals below 20 seconds may result in poor performance.", "minimumIntervalWarning": "Intervals below 20 seconds may result in poor performance.",
"lowIntervalWarning": "Are you sure want to set the interval value below 20 seconds? Performance may be degraded, particularly if there are a large number of monitors.", "lowIntervalWarning": "Are you sure want to set the interval value below 20 seconds? Performance may be degraded, particularly if there are a large number of monitors.",
"imageResetConfirmation": "Image reset to default", "imageResetConfirmation": "Image reset to default",

View File

@ -346,25 +346,33 @@ export default {
return socket; return socket;
}, },
/**
* Apply translation to a message if possible
* @param {string | {key: string, values: object}} msg Message to translate
* @returns {string} Translated message
*/
applyTranslation(msg) {
if (msg != null && typeof msg === "object") {
return this.$t(msg.key, msg.values);
} else {
return this.$t(msg);
}
},
/** /**
* Show success or error toast dependent on response status code * Show success or error toast dependent on response status code
* @param {object} res Response object * @param {{ok:boolean, msg: string, msgi18n: false} | {ok:boolean, msg: string|{key: string, values: object}, msgi18n: true}} res Response object
* @returns {void} * @returns {void}
*/ */
toastRes(res) { toastRes(res) {
let msg = res.msg;
if (res.msgi18n) { if (res.msgi18n) {
if (msg != null && typeof msg === "object") { res.msg = this.applyTranslation(res.msg);
msg = this.$t(msg.key, msg.values);
} else {
msg = this.$t(msg);
}
} }
if (res.ok) { if (res.ok) {
toast.success(msg); toast.success(res.msg);
} else { } else {
toast.error(msg); toast.error(res.msg);
} }
}, },

View File

@ -817,12 +817,13 @@
</div> </div>
</div> </div>
<div v-if="hasDomain" class="my-3 form-check"> <div v-if="showDomainExpiryNotification" class="my-3 form-check">
<input id="domain-expiry-notification" v-model="monitor.domainExpiryNotification" class="form-check-input" type="checkbox"> <input id="domain-expiry-notification" v-model="monitor.domainExpiryNotification" class="form-check-input" type="checkbox" :disabled="!hasDomain">
<label class="form-check-label" for="domain-expiry-notification"> <label class="form-check-label" for="domain-expiry-notification">
{{ $t("labelDomainNameExpiryNotification") }} {{ $t("labelDomainNameExpiryNotification") }}
</label> </label>
<div class="form-text"> <div v-if="!hasDomain && domainExpiryUnsupportedReason" class="form-text">
{{ domainExpiryUnsupportedReason }}
</div> </div>
</div> </div>
<div v-if="monitor.type === 'websocket-upgrade' " class="my-3 form-check"> <div v-if="monitor.type === 'websocket-upgrade' " class="my-3 form-check">
@ -1442,6 +1443,8 @@ export default {
// Do not add default value here, please check init() method // Do not add default value here, please check init() method
}, },
hasDomain: false, hasDomain: false,
domainExpiryUnsupportedReason: null,
checkMonitorDebounce: null,
acceptedStatusCodeOptions: [], acceptedStatusCodeOptions: [],
acceptedWebsocketCodeOptions: [], acceptedWebsocketCodeOptions: [],
dnsresolvetypeOptions: [], dnsresolvetypeOptions: [],
@ -1517,6 +1520,18 @@ export default {
}; };
}, },
showDomainExpiryNotification() {
// NOTE: Keep this list in sync with `excludeTypes` in `server/model/domain_expiry.js`.
const excludedTypes = [ "docker", "group", "push", "manual", "rabbitmq", "redis" ];
const type = this.monitor.type;
if (!type) {
return false;
}
return !excludedTypes.includes(type) && !type.match(/sql$/);
},
pageName() { pageName() {
let name = "Add New Monitor"; let name = "Add New Monitor";
if (this.isClone) { if (this.isClone) {
@ -1795,12 +1810,22 @@ message HealthCheckResponse {
}, },
"monitorTypeUrlHost"(data) { "monitorTypeUrlHost"(data) {
this.$root.getSocket().emit("checkMointor", data, (res) => { if (this.checkMonitorDebounce != null) {
this.hasDomain = !!res?.domain; clearTimeout(this.checkMonitorDebounce);
if (!res?.domain) { }
this.monitor.domainExpiryNotification = false;
} if (!this.showDomainExpiryNotification) {
}); this.hasDomain = false;
this.domainExpiryUnsupportedReason = null;
return;
}
this.checkMonitorDebounce = setTimeout(() => {
this.$root.getSocket().emit("checkMointor", data, (res) => {
this.hasDomain = !!res?.ok;
this.domainExpiryUnsupportedReason = res.msgi18n ? this.$t(res.msg, res.meta) : res.msg;
});
}, 500);
}, },
"monitor.type"(newType, oldType) { "monitor.type"(newType, oldType) {

View File

@ -1,6 +1,6 @@
process.env.UPTIME_KUMA_HIDE_LOG = [ "info_db", "info_server" ].join(","); process.env.UPTIME_KUMA_HIDE_LOG = [ "info_db", "info_server" ].join(",");
const { describe, test, mock } = require("node:test"); const { describe, test, mock, before, after } = require("node:test");
const assert = require("node:assert"); const assert = require("node:assert");
const DomainExpiry = require("../../server/model/domain_expiry"); const DomainExpiry = require("../../server/model/domain_expiry");
const mockWebhook = require("./notification-providers/mock-webhook"); const mockWebhook = require("./notification-providers/mock-webhook");
@ -19,22 +19,217 @@ describe("Domain Expiry", () => {
domainExpiryNotification: true domainExpiryNotification: true
}; };
test("getExpiryDate() returns correct expiry date for .wiki domain with no A record", async () => { before(async () => {
await testDb.create(); await testDb.create();
Notification.init(); Notification.init();
});
after(async () => {
Settings.stopCacheCleaner();
await testDb.destroy();
});
test("getExpiryDate() returns correct expiry date for .wiki domain with no A record", async () => {
const d = DomainExpiry.createByName("google.wiki"); const d = DomainExpiry.createByName("google.wiki");
assert.deepEqual(await d.getExpiryDate(), new Date("2026-11-26T23:59:59.000Z")); assert.deepEqual(await d.getExpiryDate(), new Date("2026-11-26T23:59:59.000Z"));
}); });
test("forMonitor() retrieves expiration date for .com domain from RDAP", async () => { describe("checkSupport()", () => {
const domain = await DomainExpiry.forMonitor(monHttpCom); test("allows and correctly parses http monitor with valid domain", async () => {
const supportInfo = await DomainExpiry.checkSupport(monHttpCom);
let expected = {
domain: "google.com",
tld: "com"
};
assert.deepStrictEqual(supportInfo, expected);
});
describe("Target Validation", () => {
test("throws error for empty string target", async () => {
const monitor = {
type: "http",
url: "",
domainExpiryNotification: true
};
await assert.rejects(
async () => await DomainExpiry.checkSupport(monitor),
(error) => {
assert.strictEqual(error.constructor.name, "TranslatableError");
assert.strictEqual(error.message, "domain_expiry_unsupported_missing_target");
return true;
}
);
});
test("throws error for undefined target", async () => {
const monitor = {
type: "http",
domainExpiryNotification: true
};
await assert.rejects(
async () => await DomainExpiry.checkSupport(monitor),
(error) => {
assert.strictEqual(error.constructor.name, "TranslatableError");
assert.strictEqual(error.message, "domain_expiry_unsupported_missing_target");
return true;
}
);
});
test("throws error for null target", async () => {
const monitor = {
type: "http",
url: null,
domainExpiryNotification: true
};
await assert.rejects(
async () => await DomainExpiry.checkSupport(monitor),
(error) => {
assert.strictEqual(error.constructor.name, "TranslatableError");
assert.strictEqual(error.message, "domain_expiry_unsupported_missing_target");
return true;
}
);
});
});
describe("Domain Parsing", () => {
test("throws error for invalid domain (no domain part)", async () => {
const monitor = {
type: "http",
url: "https://",
domainExpiryNotification: true
};
await assert.rejects(
async () => await DomainExpiry.checkSupport(monitor),
(error) => {
assert.strictEqual(error.constructor.name, "TranslatableError");
assert.strictEqual(error.message, "domain_expiry_unsupported_invalid_domain");
return true;
}
);
});
test("throws error for IPv4 address instead of domain", async () => {
const monitor = {
type: "http",
url: "https://192.168.1.1",
domainExpiryNotification: true
};
await assert.rejects(
async () => await DomainExpiry.checkSupport(monitor),
(error) => {
assert.strictEqual(error.constructor.name, "TranslatableError");
assert.strictEqual(error.message, "domain_expiry_unsupported_invalid_domain");
return true;
}
);
});
test("throws error for IPv6 address", async () => {
const monitor = {
type: "http",
url: "https://[2001:db8::1]",
domainExpiryNotification: true
};
await assert.rejects(
async () => await DomainExpiry.checkSupport(monitor),
(error) => {
assert.strictEqual(error.constructor.name, "TranslatableError");
assert.strictEqual(error.message, "domain_expiry_unsupported_invalid_domain");
return true;
}
);
});
test("throws error for single-letter TLD", async () => {
const monitor = {
type: "http",
url: "https://example.x",
domainExpiryNotification: true
};
await assert.rejects(
async () => await DomainExpiry.checkSupport(monitor),
(error) => {
assert.strictEqual(error.constructor.name, "TranslatableError");
assert.strictEqual(error.message, "domain_expiry_public_suffix_too_short");
return true;
}
);
});
});
describe("Edge Cases & RDAP Support", () => {
test("handles subdomain correctly", async () => {
const monitor = {
type: "http",
url: "https://api.staging.example.com/v1/users",
domainExpiryNotification: true
};
const supportInfo = await DomainExpiry.checkSupport(monitor);
assert.strictEqual(supportInfo.domain, "example.com");
assert.strictEqual(supportInfo.tld, "com");
});
test("handles complex subdomain correctly", async () => {
const monitor = {
type: "http",
url: "https://mail.subdomain.example.org",
domainExpiryNotification: true
};
const supportInfo = await DomainExpiry.checkSupport(monitor);
assert.strictEqual(supportInfo.domain, "example.org");
assert.strictEqual(supportInfo.tld, "org");
});
test("handles URL with port correctly", async () => {
const monitor = {
type: "http",
url: "https://example.com:8080/api",
domainExpiryNotification: true
};
const supportInfo = await DomainExpiry.checkSupport(monitor);
assert.strictEqual(supportInfo.domain, "example.com");
assert.strictEqual(supportInfo.tld, "com");
});
test("handles URL with query parameters correctly", async () => {
const monitor = {
type: "http",
url: "https://example.com/search?q=test&page=1",
domainExpiryNotification: true
};
const supportInfo = await DomainExpiry.checkSupport(monitor);
assert.strictEqual(supportInfo.domain, "example.com");
assert.strictEqual(supportInfo.tld, "com");
});
test("throws error for unsupported TLD without RDAP endpoint", async () => {
const monitor = {
type: "http",
url: "https://example.localhost",
domainExpiryNotification: true
};
await assert.rejects(
async () => await DomainExpiry.checkSupport(monitor),
(error) => {
assert.strictEqual(error.constructor.name, "TranslatableError");
assert.strictEqual(error.message, "domain_expiry_unsupported_unsupported_tld_no_rdap_endpoint");
return true;
}
);
});
});
});
test("findByDomainNameOrCreate() retrieves expiration date for .com domain from RDAP", async () => {
const domain = await DomainExpiry.findByDomainNameOrCreate("google.com");
const expiryFromRdap = await domain.getExpiryDate(); // from RDAP const expiryFromRdap = await domain.getExpiryDate(); // from RDAP
assert.deepEqual(expiryFromRdap, new Date("2028-09-14T04:00:00.000Z")); assert.deepEqual(expiryFromRdap, new Date("2028-09-14T04:00:00.000Z"));
}); });
test("checkExpiry() caches expiration date in database", async () => { test("checkExpiry() caches expiration date in database", async () => {
await DomainExpiry.checkExpiry(monHttpCom); // RDAP -> Cache await DomainExpiry.checkExpiry("google.com"); // RDAP -> Cache
const domain = await DomainExpiry.findByName("google.com"); const domain = await DomainExpiry.findByName("google.com");
assert(Date.now() - domain.lastCheck < 5 * 1000); assert(Date.now() - domain.lastCheck < 5 * 1000);
}); });
@ -60,27 +255,22 @@ describe("Domain Expiry", () => {
const manyDays = 3650; const manyDays = 3650;
setSetting("domainExpiryNotifyDays", [ manyDays ], "general"); setSetting("domainExpiryNotifyDays", [ manyDays ], "general");
const [ , data ] = await Promise.all([ const [ , data ] = await Promise.all([
DomainExpiry.sendNotifications(monHttpCom, [ notif ]), DomainExpiry.sendNotifications("google.com", [ notif ]),
mockWebhook(hook.port, hook.url) mockWebhook(hook.port, hook.url)
]); ]);
assert.match(data.msg, /will expire in/); assert.match(data.msg, /will expire in/);
setTimeout(async () => {
Settings.stopCacheCleaner();
await testDb.destroy();
}, 200);
}); });
test("sendNotifications() handles domain with null expiry without sending NaN", async () => { test("sendNotifications() handles domain with null expiry without sending NaN", async () => {
// Regression test for bug: "Domain name will expire in NaN days" // Regression test for bug: "Domain name will expire in NaN days"
// Mock forMonitor to return a bean with null expiry // Mock findByDomainNameOrCreate to return a bean with null expiry
const mockDomain = { const mockDomain = {
domain: "test-null.com", domain: "test-null.com",
expiry: null, expiry: null,
lastExpiryNotificationSent: null lastExpiryNotificationSent: null
}; };
mock.method(DomainExpiry, "forMonitor", async () => mockDomain); mock.method(DomainExpiry, "findByDomainNameOrCreate", async () => mockDomain);
try { try {
const hook = { const hook = {
@ -88,12 +278,6 @@ describe("Domain Expiry", () => {
"url": "should-not-be-called-null" "url": "should-not-be-called-null"
}; };
const monTest = {
type: "http",
url: "https://test-null.com",
domainExpiryNotification: true
};
const notif = { const notif = {
name: "TestNullExpiry", name: "TestNullExpiry",
config: JSON.stringify({ config: JSON.stringify({
@ -107,7 +291,7 @@ describe("Domain Expiry", () => {
// Race between sendNotifications and mockWebhook timeout // Race between sendNotifications and mockWebhook timeout
// If webhook is called, we fail. If it times out, we pass. // If webhook is called, we fail. If it times out, we pass.
const result = await Promise.race([ const result = await Promise.race([
DomainExpiry.sendNotifications(monTest, [ notif ]), DomainExpiry.sendNotifications("test-null.com", [ notif ]),
mockWebhook(hook.port, hook.url, 500).then(() => { mockWebhook(hook.port, hook.url, 500).then(() => {
throw new Error("Webhook was called but should not have been for null expiry"); throw new Error("Webhook was called but should not have been for null expiry");
}).catch((e) => { }).catch((e) => {
@ -126,26 +310,20 @@ describe("Domain Expiry", () => {
test("sendNotifications() handles domain with undefined expiry without sending NaN", async () => { test("sendNotifications() handles domain with undefined expiry without sending NaN", async () => {
try { try {
// Mock forMonitor to return a bean with undefined expiry (newly created bean scenario) // Mock findByDomainNameOrCreate to return a bean with undefined expiry (newly created bean scenario)
const mockDomain = { const mockDomain = {
domain: "test-undefined.com", domain: "test-undefined.com",
expiry: undefined, expiry: undefined,
lastExpiryNotificationSent: null lastExpiryNotificationSent: null
}; };
mock.method(DomainExpiry, "forMonitor", async () => mockDomain); mock.method(DomainExpiry, "findByDomainNameOrCreate", async () => mockDomain);
const hook = { const hook = {
"port": 3013, "port": 3013,
"url": "should-not-be-called-undefined" "url": "should-not-be-called-undefined"
}; };
const monTest = {
type: "http",
url: "https://test-undefined.com",
domainExpiryNotification: true
};
const notif = { const notif = {
name: "TestUndefinedExpiry", name: "TestUndefinedExpiry",
config: JSON.stringify({ config: JSON.stringify({
@ -159,7 +337,7 @@ describe("Domain Expiry", () => {
// Race between sendNotifications and mockWebhook timeout // Race between sendNotifications and mockWebhook timeout
// If webhook is called, we fail. If it times out, we pass. // If webhook is called, we fail. If it times out, we pass.
const result = await Promise.race([ const result = await Promise.race([
DomainExpiry.sendNotifications(monTest, [ notif ]), DomainExpiry.sendNotifications("test-undefined.com", [ notif ]),
mockWebhook(hook.port, hook.url, 500).then(() => { mockWebhook(hook.port, hook.url, 500).then(() => {
throw new Error("Webhook was called but should not have been for undefined expiry"); throw new Error("Webhook was called but should not have been for undefined expiry");
}).catch((e) => { }).catch((e) => {