Merge branch 'master' into fix/issue-6025-webhook-up-notification

This commit is contained in:
Saber 2026-01-14 16:02:49 -08:00 committed by GitHub
commit 22306fd410
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 340 additions and 69 deletions

View File

@ -41,7 +41,10 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
run: |
gh pr ready "${{ github.event.pull_request.number }}" --undo || true
gh pr ready "${{ github.event.pull_request.number }}" \
--repo "${{ github.repository }}" \
--undo || true
# || true to ignore the case where the pr is already a draft
ready-for-review:
runs-on: ubuntu-latest

View File

@ -159,20 +159,19 @@ class DomainExpiry extends BeanModel {
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 });
}
if (!tld.isIcann) {
throw new TranslatableError("domain_expiry_unsupported_is_icann", {
domain: tld.domain,
publicSuffix: tld.publicSuffix,
});
}
const rdap = await getRdapServer(tld.publicSuffix);
if (!rdap) {

View File

@ -3,26 +3,61 @@ const { log, UP } = require("../../src/util");
const dayjs = require("dayjs");
const postgresConParse = require("pg-connection-string").parse;
const { Client } = require("pg");
const { ConditionVariable } = require("../monitor-conditions/variables");
const { defaultStringOperators } = require("../monitor-conditions/operators");
const { ConditionExpressionGroup } = require("../monitor-conditions/expression");
const { evaluateExpressionGroup } = require("../monitor-conditions/evaluator");
class PostgresMonitorType extends MonitorType {
name = "postgres";
supportsConditions = true;
conditionVariables = [new ConditionVariable("result", defaultStringOperators)];
/**
* @inheritdoc
*/
async check(monitor, heartbeat, _server) {
let startTime = dayjs().valueOf();
let query = monitor.databaseQuery;
// No query provided by user, use SELECT 1
if (!query || (typeof query === "string" && query.trim() === "")) {
query = "SELECT 1";
}
await this.postgresQuery(monitor.databaseConnectionString, query);
heartbeat.msg = "";
heartbeat.status = UP;
const conditions = monitor.conditions ? ConditionExpressionGroup.fromMonitor(monitor) : null;
const hasConditions = conditions && conditions.children && conditions.children.length > 0;
const startTime = dayjs().valueOf();
try {
if (hasConditions) {
// When conditions are enabled, expect a single value result
const result = await this.postgresQuerySingleValue(monitor.databaseConnectionString, query);
heartbeat.ping = dayjs().valueOf() - startTime;
const conditionsResult = evaluateExpressionGroup(conditions, { result: String(result) });
if (!conditionsResult) {
throw new Error(`Query result did not meet the specified conditions (${result})`);
}
heartbeat.status = UP;
heartbeat.msg = "Query did meet specified conditions";
} else {
// Backwards compatible: just check connection and return row count
const result = await this.postgresQuery(monitor.databaseConnectionString, query);
heartbeat.ping = dayjs().valueOf() - startTime;
heartbeat.status = UP;
heartbeat.msg = result;
}
} catch (error) {
heartbeat.ping = dayjs().valueOf() - startTime;
// Re-throw condition errors as-is, wrap database errors
if (error.message.includes("did not meet the specified conditions")) {
throw error;
}
throw new Error(`Database connection/query failed: ${error.message}`);
}
}
/**
@ -76,6 +111,75 @@ class PostgresMonitorType extends MonitorType {
});
});
}
/**
* Run a query on Postgres
* @param {string} connectionString The database connection string
* @param {string} query The query to validate the database with
* @returns {Promise<(string[] | object[] | object)>} Response from
* server
*/
async postgresQuerySingleValue(connectionString, query) {
return new Promise((resolve, reject) => {
const config = postgresConParse(connectionString);
// Fix #3868, which true/false is not parsed to boolean
if (typeof config.ssl === "string") {
config.ssl = config.ssl === "true";
}
if (config.password === "") {
// See https://github.com/brianc/node-postgres/issues/1927
reject(new Error("Password is undefined."));
return;
}
const client = new Client(config);
client.on("error", (error) => {
log.debug(this.name, "Error caught in the error event handler.");
reject(error);
});
client.connect((err) => {
if (err) {
reject(err);
client.end();
} else {
// Connected here
try {
client.query(query, (err, res) => {
if (err) {
reject(err);
} else {
// Check if we have results
if (!res.rows || res.rows.length === 0) {
reject(new Error("Query returned no results"));
return;
}
// Check if we have multiple rows
if (res.rows.length > 1) {
reject(new Error("Multiple values were found, expected only one value"));
return;
}
const firstRow = res.rows[0];
const columnNames = Object.keys(firstRow);
// Check if we have multiple columns
if (columnNames.length > 1) {
reject(new Error("Multiple columns were found, expected only one value"));
return;
}
resolve(firstRow[columnNames[0]]);
}
client.end();
});
} catch (e) {
reject(e);
client.end();
}
}
});
});
}
}
module.exports = {

View File

@ -1297,9 +1297,8 @@
"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_icann": "The domain \"{domain}\" is not a candidate for domain expiry monitoring, because its public suffix \".{publicSuffix}\" is not ICAN",
"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.",

View File

@ -49,5 +49,203 @@ describe(
await assert.rejects(postgresMonitor.check(monitor, heartbeat, {}), regex);
});
test("check() sets status to UP when custom query returns single value", async () => {
// The default timeout of 30 seconds might not be enough for the container to start
const postgresContainer = await new PostgreSqlContainer("postgres:latest")
.withStartupTimeout(60000)
.start();
const postgresMonitor = new PostgresMonitorType();
const monitor = {
databaseConnectionString: postgresContainer.getConnectionUri(),
databaseQuery: "SELECT 42",
conditions: "[]",
};
const heartbeat = {
msg: "",
status: PENDING,
};
try {
await postgresMonitor.check(monitor, heartbeat, {});
assert.strictEqual(heartbeat.status, UP, `Expected status ${UP} but got ${heartbeat.status}`);
} finally {
await postgresContainer.stop();
}
});
test("check() sets status to UP when custom query result meets condition", async () => {
const postgresContainer = await new PostgreSqlContainer("postgres:latest")
.withStartupTimeout(60000)
.start();
const postgresMonitor = new PostgresMonitorType();
const monitor = {
databaseConnectionString: postgresContainer.getConnectionUri(),
databaseQuery: "SELECT 42 AS value",
conditions: JSON.stringify([
{
type: "expression",
andOr: "and",
variable: "result",
operator: "equals",
value: "42",
},
]),
};
const heartbeat = {
msg: "",
status: PENDING,
};
try {
await postgresMonitor.check(monitor, heartbeat, {});
assert.strictEqual(heartbeat.status, UP, `Expected status ${UP} but got ${heartbeat.status}`);
} finally {
await postgresContainer.stop();
}
});
test("check() rejects when custom query result does not meet condition", async () => {
const postgresContainer = await new PostgreSqlContainer("postgres:latest")
.withStartupTimeout(60000)
.start();
const postgresMonitor = new PostgresMonitorType();
const monitor = {
databaseConnectionString: postgresContainer.getConnectionUri(),
databaseQuery: "SELECT 99 AS value",
conditions: JSON.stringify([
{
type: "expression",
andOr: "and",
variable: "result",
operator: "equals",
value: "42",
},
]),
};
const heartbeat = {
msg: "",
status: PENDING,
};
try {
await assert.rejects(
postgresMonitor.check(monitor, heartbeat, {}),
new Error("Query result did not meet the specified conditions (99)")
);
assert.strictEqual(heartbeat.status, PENDING, `Expected status should not be ${heartbeat.status}`);
} finally {
await postgresContainer.stop();
}
});
test("check() rejects when query returns no results with conditions", async () => {
const postgresContainer = await new PostgreSqlContainer("postgres:latest")
.withStartupTimeout(60000)
.start();
const postgresMonitor = new PostgresMonitorType();
const monitor = {
databaseConnectionString: postgresContainer.getConnectionUri(),
databaseQuery: "SELECT 1 WHERE 1 = 0",
conditions: JSON.stringify([
{
type: "expression",
andOr: "and",
variable: "result",
operator: "equals",
value: "1",
},
]),
};
const heartbeat = {
msg: "",
status: PENDING,
};
try {
await assert.rejects(
postgresMonitor.check(monitor, heartbeat, {}),
new Error("Database connection/query failed: Query returned no results")
);
assert.strictEqual(heartbeat.status, PENDING, `Expected status should not be ${heartbeat.status}`);
} finally {
await postgresContainer.stop();
}
});
test("check() rejects when query returns multiple rows with conditions", async () => {
const postgresContainer = await new PostgreSqlContainer("postgres:latest")
.withStartupTimeout(60000)
.start();
const postgresMonitor = new PostgresMonitorType();
const monitor = {
databaseConnectionString: postgresContainer.getConnectionUri(),
databaseQuery: "SELECT 1 UNION ALL SELECT 2",
conditions: JSON.stringify([
{
type: "expression",
andOr: "and",
variable: "result",
operator: "equals",
value: "1",
},
]),
};
const heartbeat = {
msg: "",
status: PENDING,
};
try {
await assert.rejects(
postgresMonitor.check(monitor, heartbeat, {}),
new Error("Database connection/query failed: Multiple values were found, expected only one value")
);
assert.strictEqual(heartbeat.status, PENDING, `Expected status should not be ${heartbeat.status}`);
} finally {
await postgresContainer.stop();
}
});
test("check() rejects when query returns multiple columns with conditions", async () => {
const postgresContainer = await new PostgreSqlContainer("postgres:latest")
.withStartupTimeout(60000)
.start();
const postgresMonitor = new PostgresMonitorType();
const monitor = {
databaseConnectionString: postgresContainer.getConnectionUri(),
databaseQuery: "SELECT 1 AS col1, 2 AS col2",
conditions: JSON.stringify([
{
type: "expression",
andOr: "and",
variable: "result",
operator: "equals",
value: "1",
},
]),
};
const heartbeat = {
msg: "",
status: PENDING,
};
try {
await assert.rejects(
postgresMonitor.check(monitor, heartbeat, {}),
new Error("Database connection/query failed: Multiple columns were found, expected only one value")
);
assert.strictEqual(heartbeat.status, PENDING, `Expected status should not be ${heartbeat.status}`);
} finally {
await postgresContainer.stop();
}
});
}
);

View File

@ -96,58 +96,26 @@ describe("Domain Expiry", () => {
});
describe("Domain Parsing", () => {
test("throws error for invalid domain (no domain part)", async () => {
test("throws error for IP address (isIp check)", async () => {
const monitor = {
type: "http",
url: "https://",
url: "https://127.0.0.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");
assert.strictEqual(error.message, "domain_expiry_unsupported_is_ip");
return true;
}
);
});
test("throws error for IPv4 address instead of domain", async () => {
test("throws error for too short suffix(example.a)", 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",
url: "https://example.a",
domainExpiryNotification: true,
};
await assert.rejects(
@ -159,6 +127,22 @@ describe("Domain Expiry", () => {
}
);
});
test("throws error for non-ICANN TLD (e.g. .local)", async () => {
const monitor = {
type: "http",
url: "https://example.local",
domainExpiryNotification: true,
};
await assert.rejects(
async () => await DomainExpiry.checkSupport(monitor),
(error) => {
assert.strictEqual(error.constructor.name, "TranslatableError");
assert.strictEqual(error.message, "domain_expiry_unsupported_is_icann");
return true;
}
);
});
});
describe("Edge Cases & RDAP Support", () => {
@ -205,22 +189,6 @@ describe("Domain Expiry", () => {
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;
}
);
});
});
});