fix: test server registration gaps, Redis subscriber cleanup, atomic settings upsert

This commit is contained in:
SnapOtter
2026-06-14 14:30:35 +08:00
parent 29dd675f42
commit 954cfb01a6
6 changed files with 66 additions and 42 deletions
+2 -14
View File
@@ -27,6 +27,7 @@ import { eq, lt } from "drizzle-orm";
import type { FastifyBaseLogger } from "fastify";
import { env } from "../config.js";
import { db, schema } from "../db/index.js";
import { upsertSetting } from "../lib/settings-helpers.js";
type ArchivalState = "PENDING" | "EXPORTING" | "EXPORTED" | "PURGING" | "COMPLETE";
@@ -40,7 +41,7 @@ interface ArchivalRun {
checksum: string;
}
// -- Settings helpers (same pattern as siem-forward.ts) ------------------------
// -- Settings helpers ----------------------------------------------------------
async function readSettingValue(key: string): Promise<string | null> {
const [row] = await db
@@ -50,19 +51,6 @@ async function readSettingValue(key: string): Promise<string | null> {
return row?.value ?? null;
}
async function upsertSetting(key: string, value: string): Promise<void> {
const [existing] = await db.select().from(schema.settings).where(eq(schema.settings.key, key));
if (existing) {
await db
.update(schema.settings)
.set({ value, updatedAt: new Date() })
.where(eq(schema.settings.key, key));
} else {
await db.insert(schema.settings).values({ key, value });
}
}
async function deleteSetting(key: string): Promise<void> {
await db.delete(schema.settings).where(eq(schema.settings.key, key));
}
+1 -13
View File
@@ -18,6 +18,7 @@ import { asc, eq, gte } from "drizzle-orm";
import { env } from "../config.js";
import { db, schema } from "../db/index.js";
import { decrypt, isEncrypted } from "../lib/encryption.js";
import { upsertSetting } from "../lib/settings-helpers.js";
import { deliverWebhook } from "../lib/webhook-delivery.js";
import { readSiemConfig } from "../routes/enterprise/siem.js";
@@ -34,19 +35,6 @@ async function readSettingValue(key: string): Promise<string | null> {
return row?.value ?? null;
}
async function upsertSetting(key: string, value: string): Promise<void> {
const [existing] = await db.select().from(schema.settings).where(eq(schema.settings.key, key));
if (existing) {
await db
.update(schema.settings)
.set({ value, updatedAt: new Date() })
.where(eq(schema.settings.key, key));
} else {
await db.insert(schema.settings).values({ key, value });
}
}
export async function runSiemForward(): Promise<{ forwarded: number } | void> {
// 1. Read SIEM config
const config = await readSiemConfig();
+14
View File
@@ -1,6 +1,20 @@
import { eq } from "drizzle-orm";
import { db, schema } from "../db/index.js";
/**
* Atomically insert or update a setting using Postgres ON CONFLICT DO UPDATE.
* Eliminates the TOCTOU race in the old SELECT-then-INSERT/UPDATE pattern.
*/
export async function upsertSetting(key: string, value: string): Promise<void> {
await db
.insert(schema.settings)
.values({ key, value })
.onConflictDoUpdate({
target: schema.settings.key,
set: { value, updatedAt: new Date() },
});
}
/**
* Read a numeric setting from the DB `settings` table.
* Returns `defaultValue` when the key is missing, non-numeric, or on DB error.
+11 -1
View File
@@ -140,7 +140,17 @@ export async function registerIpAllowlist(app: FastifyInstance): Promise<void> {
const sub = redis.duplicate();
await sub.subscribe(ALLOWLIST_CHANNEL);
sub.on("message", async () => {
await refreshAllowlist();
refreshAllowlist().catch(() => {});
});
// Clean up subscriber on shutdown
app.addHook("onClose", async () => {
try {
await sub.unsubscribe();
await sub.quit();
} catch {
// Best-effort cleanup
}
});
// Hook -- runs before auth, before routes
+1 -13
View File
@@ -4,7 +4,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { db, schema } from "../../db/index.js";
import { sharedRedis } from "../../jobs/connection.js";
import { auditLog } from "../../lib/audit.js";
import { getSettingString } from "../../lib/settings-helpers.js";
import { getSettingString, upsertSetting } from "../../lib/settings-helpers.js";
import { requirePermission } from "../../permissions.js";
import { hashPassword, verifyPassword } from "../../plugins/auth.js";
@@ -179,18 +179,6 @@ function scimListResponse(
// ── Route Registration ───────────────────────────────────────────
async function upsertSetting(key: string, value: string): Promise<void> {
const [existing] = await db.select().from(schema.settings).where(eq(schema.settings.key, key));
if (existing) {
await db
.update(schema.settings)
.set({ value, updatedAt: new Date() })
.where(eq(schema.settings.key, key));
} else {
await db.insert(schema.settings).values({ key, value });
}
}
export async function registerScimRoutes(app: FastifyInstance): Promise<void> {
// ── Token Management Endpoints ────────────────────────────────
+37 -1
View File
@@ -46,7 +46,11 @@ import {
ensureDefaultAdmin,
requireAuth,
} from "../../apps/api/src/plugins/auth.js";
import { registerIpAllowlist } from "../../apps/api/src/plugins/ip-allowlist.js";
import { registerMfa } from "../../apps/api/src/plugins/mfa.js";
import { oidcRoutes } from "../../apps/api/src/plugins/oidc.js";
import { registerPerUserRateLimit } from "../../apps/api/src/plugins/per-user-rate-limit.js";
import { registerSaml } from "../../apps/api/src/plugins/saml.js";
import { registerUpload } from "../../apps/api/src/plugins/upload.js";
import { adminOpsRoutes } from "../../apps/api/src/routes/admin-ops.js";
import { analyticsRoutes } from "../../apps/api/src/routes/analytics.js";
@@ -54,6 +58,7 @@ import { apiKeyRoutes } from "../../apps/api/src/routes/api-keys.js";
import { auditLogRoutes } from "../../apps/api/src/routes/audit-log.js";
import { registerBatchRoutes } from "../../apps/api/src/routes/batch.js";
import { docsRoutes } from "../../apps/api/src/routes/docs.js";
import { registerEnterpriseRoutes } from "../../apps/api/src/routes/enterprise/index.js";
import { registerFetchUrlsRoute } from "../../apps/api/src/routes/fetch-urls.js";
import { fileRoutes } from "../../apps/api/src/routes/files.js";
import { registerMemeTemplates } from "../../apps/api/src/routes/meme-templates.js";
@@ -64,7 +69,6 @@ import { settingsRoutes } from "../../apps/api/src/routes/settings.js";
import { teamsRoutes } from "../../apps/api/src/routes/teams.js";
import { registerToolRoutes } from "../../apps/api/src/routes/tools/index.js";
import { userFileRoutes } from "../../apps/api/src/routes/user-files.js";
import { registerEnterpriseRoutes } from "../../apps/api/src/routes/enterprise/index.js";
// Run migrations (idempotent -- template already has the schema, but this
// ensures the __drizzle_migrations journal is consistent in each fork).
@@ -135,15 +139,43 @@ export async function buildTestApp(): Promise<TestApp> {
// Cookie support
await app.register(cookie, { secret: "test-cookie-secret", hook: "onRequest" });
// IP allowlist (enterprise -- guards internally, returns early if not licensed)
try {
await registerIpAllowlist(app);
} catch {
// Enterprise package not available in test env
}
// Auth middleware (must be registered before routes)
await authMiddleware(app);
// Per-user rate limiting (after auth so request.user is populated)
try {
await registerPerUserRateLimit(app);
} catch {
// Redis may not be fully available in all test scenarios
}
// Auth routes
await authRoutes(app);
// OIDC routes
await oidcRoutes(app);
// SAML routes (enterprise -- guards internally, returns early if not licensed)
try {
await registerSaml(app);
} catch {
// Enterprise package not available in test env
}
// MFA routes (TOTP enrollment, verification, disable)
try {
await registerMfa(app);
} catch {
// MFA dependencies may not be available in test env
}
// File upload/download routes
await fileRoutes(app);
@@ -232,6 +264,10 @@ export async function buildTestApp(): Promise<TestApp> {
config.oidcProviderName = env.OIDC_PROVIDER_NAME || null;
config.oidcLoginUrl = "/api/auth/oidc/login";
}
config.samlEnabled = false;
config.samlProviderName = "";
config.samlLoginUrl = "";
config.ssoEnforced = false;
return config;
});