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:
parent
e31ef63b01
commit
0eca301181
@ -5,8 +5,10 @@ const { parse: parseTld } = require("tldts");
|
||||
const { getDaysRemaining, getDaysBetween, setting, setSetting } = require("../util-server");
|
||||
const { Notification } = require("../notification");
|
||||
const { default: NodeFetchCache, MemoryCache } = require("node-fetch-cache");
|
||||
const TranslatableError = require("../translatable-error");
|
||||
|
||||
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 excludeTypes = [ "docker", "group", "push", "manual", "rabbitmq", "redis" ];
|
||||
|
||||
@ -36,6 +38,7 @@ async function getRdapServer(tld) {
|
||||
return urls[0];
|
||||
}
|
||||
}
|
||||
log.debug("rdap", `No RDAP server found for TLD ${tld}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -126,7 +129,8 @@ class DomainExpiry extends BeanModel {
|
||||
static parseTld = parseTld;
|
||||
|
||||
/**
|
||||
* @returns {(object)} parsed domain components
|
||||
* @typedef {import("tldts-core").IResult} DomainComponents
|
||||
* @returns {DomainComponents} parsed domain components
|
||||
*/
|
||||
parseName() {
|
||||
return parseTld(this.domain);
|
||||
@ -141,26 +145,71 @@ class DomainExpiry extends BeanModel {
|
||||
|
||||
/**
|
||||
* @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) {
|
||||
const m = monitor;
|
||||
if (excludeTypes.includes(m.type) || m.type?.match(/sql$/)) {
|
||||
return false;
|
||||
static async checkSupport(monitor) {
|
||||
if (excludeTypes.includes(monitor.type) || monitor.type?.match(/sql$/)) {
|
||||
throw new TranslatableError("domain_expiry_unsupported_monitor_type");
|
||||
}
|
||||
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);
|
||||
if (!rdap) {
|
||||
log.warn("domain_expiry", `Domain expiry unsupported for '.${tld.publicSuffix}' because its RDAP endpoint is not listed in the IANA database.`);
|
||||
return false;
|
||||
// Only warn when the monitor actually has domain expiry notifications enabled.
|
||||
// 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) {
|
||||
return existing;
|
||||
}
|
||||
if (tld.domain) {
|
||||
return await DomainExpiry.createByName(tld.domain);
|
||||
}
|
||||
return DomainExpiry.createByName(domainName);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -178,12 +227,12 @@ class DomainExpiry extends BeanModel {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {(Monitor)} monitor Monitor object
|
||||
* @returns {Promise<void>}
|
||||
* @param {string} domainName Monitor object
|
||||
* @throws {TranslatableError} If the domain is not supported
|
||||
* @returns {Promise<Date | undefined>} the expiry date
|
||||
*/
|
||||
static async checkExpiry(monitor) {
|
||||
|
||||
let bean = await DomainExpiry.forMonitor(monitor);
|
||||
static async checkExpiry(domainName) {
|
||||
let bean = await DomainExpiry.findByDomainNameOrCreate(domainName);
|
||||
|
||||
let expiryDate;
|
||||
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
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
static async sendNotifications(monitor, notificationList) {
|
||||
const domain = await DomainExpiry.forMonitor(monitor);
|
||||
const name = domain.domain;
|
||||
|
||||
static async sendNotifications(domainName, notificationList) {
|
||||
const domain = await DomainExpiry.findByDomainNameOrCreate(domainName);
|
||||
if (!notificationList.length > 0) {
|
||||
// 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");
|
||||
@ -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.
|
||||
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;
|
||||
}
|
||||
|
||||
const daysRemaining = getDaysRemaining(new Date(), domain.expiry);
|
||||
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");
|
||||
if (notifyDays == null || !Array.isArray(notifyDays)) {
|
||||
@ -244,19 +291,19 @@ class DomainExpiry extends BeanModel {
|
||||
for (const targetDays of notifyDays) {
|
||||
if (daysRemaining > targetDays) {
|
||||
log.debug(
|
||||
"domain",
|
||||
`No need to send domain notification for ${name} (${daysRemaining} days valid) on ${targetDays} deadline.`
|
||||
"domain_expiry",
|
||||
`No need to send domain notification for ${domainName} (${daysRemaining} days valid) on ${targetDays} deadline.`
|
||||
);
|
||||
continue;
|
||||
} else if (lastSent && lastSent <= targetDays) {
|
||||
log.debug(
|
||||
"domain",
|
||||
`Notification for ${name} on ${targetDays} deadline sent already, no need to send again.`
|
||||
"domain_expiry",
|
||||
`Notification for ${domainName} on ${targetDays} deadline sent already, no need to send again.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const sent = await sendDomainNotificationByTargetDays(
|
||||
name,
|
||||
domainName,
|
||||
daysRemaining,
|
||||
targetDays,
|
||||
notificationList
|
||||
|
||||
@ -920,14 +920,15 @@ class Monitor extends BeanModel {
|
||||
|
||||
if (bean.status !== MAINTENANCE && Boolean(this.domainExpiryNotification)) {
|
||||
try {
|
||||
const domainExpiryDate = await DomainExpiry.checkExpiry(this);
|
||||
const supportInfo = await DomainExpiry.checkSupport(this);
|
||||
const domainExpiryDate = await DomainExpiry.checkExpiry(supportInfo.domain);
|
||||
if (domainExpiryDate) {
|
||||
DomainExpiry.sendNotifications(this, await Monitor.getNotificationList(this) || []);
|
||||
DomainExpiry.sendNotifications(supportInfo.domain, await Monitor.getNotificationList(this) || []);
|
||||
} 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) {
|
||||
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) {
|
||||
const monitor = await R.findOne("monitor", "id = ?", [ monitorID ]);
|
||||
|
||||
const domain = await DomainExpiry.forMonitor(monitor);
|
||||
if (domain?.expiry) {
|
||||
io.to(userID).emit("domainInfo", monitorID, domain.daysRemaining, new Date(domain.expiry));
|
||||
}
|
||||
try {
|
||||
const supportInfo = await DomainExpiry.checkSupport(monitor);
|
||||
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 {Monitor} monitor The monitor to send a notification about
|
||||
* @param {Bean} bean Status information about monitor
|
||||
* @returns {void}
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
static async sendNotification(isFirstBeat, monitor, bean) {
|
||||
if (!isFirstBeat || bean.status === DOWN) {
|
||||
@ -1363,7 +1367,7 @@ class Monitor extends BeanModel {
|
||||
/**
|
||||
* checks certificate chain for expiring certificates
|
||||
* @param {object} tlsInfoObject Information about certificate
|
||||
* @returns {void}
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async checkCertExpiryNotifications(tlsInfoObject) {
|
||||
if (tlsInfoObject && tlsInfoObject.certInfo && tlsInfoObject.certInfo.daysRemaining) {
|
||||
|
||||
@ -698,7 +698,7 @@ let needSetup = false;
|
||||
callback({
|
||||
ok: false,
|
||||
msg: e.message,
|
||||
msgi18n: !!e.msgi18n,
|
||||
msgi18n: !!e.msgi18n
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -990,14 +990,18 @@ let needSetup = false;
|
||||
try {
|
||||
checkLogin(socket);
|
||||
const DomainExpiry = require("./model/domain_expiry");
|
||||
const supportInfo = await DomainExpiry.checkSupport(partial);
|
||||
callback({
|
||||
ok: true,
|
||||
domain: (await DomainExpiry.forMonitor(partial))?.domain || null
|
||||
domain: supportInfo.domain,
|
||||
tld: supportInfo.tld
|
||||
});
|
||||
} catch (e) {
|
||||
callback({
|
||||
ok: false,
|
||||
msg: e.message,
|
||||
msgi18n: !!e.msgi18n,
|
||||
meta: e.meta ?? {}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,16 +1,21 @@
|
||||
/**
|
||||
* Error whose message is a translation key.
|
||||
* @augments Error
|
||||
*/
|
||||
class TranslatableError extends Error {
|
||||
/**
|
||||
* Error whose message is a translation key.
|
||||
* @augments Error
|
||||
* Indicates that the error message is a translation key.
|
||||
*/
|
||||
msgi18n = true;
|
||||
|
||||
/**
|
||||
* Create a TranslatableError.
|
||||
* @param {string} key - Translation key present in src/lang/en.json
|
||||
* @param {object} meta Arbitrary metadata
|
||||
*/
|
||||
constructor(key) {
|
||||
constructor(key, meta = {}) {
|
||||
super(key);
|
||||
this.msgi18n = true;
|
||||
this.key = key;
|
||||
this.meta = meta;
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
}
|
||||
|
||||
@ -487,7 +487,7 @@ const parseCertificateInfo = function (info) {
|
||||
/**
|
||||
* Check if certificate is valid
|
||||
* @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) {
|
||||
let certInfoStartTime = dayjs().valueOf();
|
||||
|
||||
@ -1261,6 +1261,13 @@
|
||||
"labelDomainExpiry": "Domain Exp.",
|
||||
"labelDomainNameExpiryNotification": "Domain Name Expiry Notification",
|
||||
"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.",
|
||||
"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",
|
||||
|
||||
@ -346,25 +346,33 @@ export default {
|
||||
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
|
||||
* @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}
|
||||
*/
|
||||
toastRes(res) {
|
||||
let msg = res.msg;
|
||||
if (res.msgi18n) {
|
||||
if (msg != null && typeof msg === "object") {
|
||||
msg = this.$t(msg.key, msg.values);
|
||||
} else {
|
||||
msg = this.$t(msg);
|
||||
}
|
||||
res.msg = this.applyTranslation(res.msg);
|
||||
}
|
||||
|
||||
if (res.ok) {
|
||||
toast.success(msg);
|
||||
toast.success(res.msg);
|
||||
} else {
|
||||
toast.error(msg);
|
||||
toast.error(res.msg);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@ -817,12 +817,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasDomain" class="my-3 form-check">
|
||||
<input id="domain-expiry-notification" v-model="monitor.domainExpiryNotification" class="form-check-input" type="checkbox">
|
||||
<div v-if="showDomainExpiryNotification" class="my-3 form-check">
|
||||
<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">
|
||||
{{ $t("labelDomainNameExpiryNotification") }}
|
||||
</label>
|
||||
<div class="form-text">
|
||||
<div v-if="!hasDomain && domainExpiryUnsupportedReason" class="form-text">
|
||||
{{ domainExpiryUnsupportedReason }}
|
||||
</div>
|
||||
</div>
|
||||
<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
|
||||
},
|
||||
hasDomain: false,
|
||||
domainExpiryUnsupportedReason: null,
|
||||
checkMonitorDebounce: null,
|
||||
acceptedStatusCodeOptions: [],
|
||||
acceptedWebsocketCodeOptions: [],
|
||||
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() {
|
||||
let name = "Add New Monitor";
|
||||
if (this.isClone) {
|
||||
@ -1795,12 +1810,22 @@ message HealthCheckResponse {
|
||||
},
|
||||
|
||||
"monitorTypeUrlHost"(data) {
|
||||
this.$root.getSocket().emit("checkMointor", data, (res) => {
|
||||
this.hasDomain = !!res?.domain;
|
||||
if (!res?.domain) {
|
||||
this.monitor.domainExpiryNotification = false;
|
||||
}
|
||||
});
|
||||
if (this.checkMonitorDebounce != null) {
|
||||
clearTimeout(this.checkMonitorDebounce);
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
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 DomainExpiry = require("../../server/model/domain_expiry");
|
||||
const mockWebhook = require("./notification-providers/mock-webhook");
|
||||
@ -19,22 +19,217 @@ describe("Domain Expiry", () => {
|
||||
domainExpiryNotification: true
|
||||
};
|
||||
|
||||
test("getExpiryDate() returns correct expiry date for .wiki domain with no A record", async () => {
|
||||
before(async () => {
|
||||
await testDb.create();
|
||||
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");
|
||||
assert.deepEqual(await d.getExpiryDate(), new Date("2026-11-26T23:59:59.000Z"));
|
||||
});
|
||||
|
||||
test("forMonitor() retrieves expiration date for .com domain from RDAP", async () => {
|
||||
const domain = await DomainExpiry.forMonitor(monHttpCom);
|
||||
describe("checkSupport()", () => {
|
||||
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
|
||||
assert.deepEqual(expiryFromRdap, new Date("2028-09-14T04:00:00.000Z"));
|
||||
});
|
||||
|
||||
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");
|
||||
assert(Date.now() - domain.lastCheck < 5 * 1000);
|
||||
});
|
||||
@ -60,27 +255,22 @@ describe("Domain Expiry", () => {
|
||||
const manyDays = 3650;
|
||||
setSetting("domainExpiryNotifyDays", [ manyDays ], "general");
|
||||
const [ , data ] = await Promise.all([
|
||||
DomainExpiry.sendNotifications(monHttpCom, [ notif ]),
|
||||
DomainExpiry.sendNotifications("google.com", [ notif ]),
|
||||
mockWebhook(hook.port, hook.url)
|
||||
]);
|
||||
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 () => {
|
||||
// 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 = {
|
||||
domain: "test-null.com",
|
||||
expiry: null,
|
||||
lastExpiryNotificationSent: null
|
||||
};
|
||||
|
||||
mock.method(DomainExpiry, "forMonitor", async () => mockDomain);
|
||||
mock.method(DomainExpiry, "findByDomainNameOrCreate", async () => mockDomain);
|
||||
|
||||
try {
|
||||
const hook = {
|
||||
@ -88,12 +278,6 @@ describe("Domain Expiry", () => {
|
||||
"url": "should-not-be-called-null"
|
||||
};
|
||||
|
||||
const monTest = {
|
||||
type: "http",
|
||||
url: "https://test-null.com",
|
||||
domainExpiryNotification: true
|
||||
};
|
||||
|
||||
const notif = {
|
||||
name: "TestNullExpiry",
|
||||
config: JSON.stringify({
|
||||
@ -107,7 +291,7 @@ describe("Domain Expiry", () => {
|
||||
// Race between sendNotifications and mockWebhook timeout
|
||||
// If webhook is called, we fail. If it times out, we pass.
|
||||
const result = await Promise.race([
|
||||
DomainExpiry.sendNotifications(monTest, [ notif ]),
|
||||
DomainExpiry.sendNotifications("test-null.com", [ notif ]),
|
||||
mockWebhook(hook.port, hook.url, 500).then(() => {
|
||||
throw new Error("Webhook was called but should not have been for null expiry");
|
||||
}).catch((e) => {
|
||||
@ -126,26 +310,20 @@ describe("Domain Expiry", () => {
|
||||
|
||||
test("sendNotifications() handles domain with undefined expiry without sending NaN", async () => {
|
||||
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 = {
|
||||
domain: "test-undefined.com",
|
||||
expiry: undefined,
|
||||
lastExpiryNotificationSent: null
|
||||
};
|
||||
|
||||
mock.method(DomainExpiry, "forMonitor", async () => mockDomain);
|
||||
mock.method(DomainExpiry, "findByDomainNameOrCreate", async () => mockDomain);
|
||||
|
||||
const hook = {
|
||||
"port": 3013,
|
||||
"url": "should-not-be-called-undefined"
|
||||
};
|
||||
|
||||
const monTest = {
|
||||
type: "http",
|
||||
url: "https://test-undefined.com",
|
||||
domainExpiryNotification: true
|
||||
};
|
||||
|
||||
const notif = {
|
||||
name: "TestUndefinedExpiry",
|
||||
config: JSON.stringify({
|
||||
@ -159,7 +337,7 @@ describe("Domain Expiry", () => {
|
||||
// Race between sendNotifications and mockWebhook timeout
|
||||
// If webhook is called, we fail. If it times out, we pass.
|
||||
const result = await Promise.race([
|
||||
DomainExpiry.sendNotifications(monTest, [ notif ]),
|
||||
DomainExpiry.sendNotifications("test-undefined.com", [ notif ]),
|
||||
mockWebhook(hook.port, hook.url, 500).then(() => {
|
||||
throw new Error("Webhook was called but should not have been for undefined expiry");
|
||||
}).catch((e) => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user