From 04bbbe907e2d5c9e1f4a397f4f1c8581aa65332c Mon Sep 17 00:00:00 2001 From: Brent G Date: Thu, 3 Sep 2026 06:53:51 +0000 Subject: [PATCH] =?UTF-8?q?fix(geoip):=20create=20GEOIP=5FDB=5FDIR=20befor?= =?UTF-8?q?e=20writing=20=E2=80=94=20first=20refresh=20on=20a=20fresh=20co?= =?UTF-8?q?ntainer=20ENOENTed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT --- server/geoip-refresh.ts | 7 +++++++ tests/geoip-refresh.test.ts | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/server/geoip-refresh.ts b/server/geoip-refresh.ts index fc2ba57..5c86561 100644 --- a/server/geoip-refresh.ts +++ b/server/geoip-refresh.ts @@ -144,6 +144,7 @@ export interface RefreshDeps { dir: string; now: Date; getMaxmindKey: () => Promise<{ key: string | null; source: MaxmindKeySource }>; + mkdir: (dir: string) => Promise; download: (url: string) => Promise; gunzip: (data: Buffer) => Promise; extractTarGz: (data: Buffer, editionId: string) => Promise; @@ -228,6 +229,11 @@ function defaultDeps(): RefreshDeps { dir: GEOIP_DIR, now: new Date(), getMaxmindKey, + // recursive:true is a no-op (not an error) when the dir already exists — + // no separate existence check needed. Fresh containers (Coolify's + // ephemeral filesystem) have no GEOIP_DB_DIR yet, and the very first + // refresh used to ENOENT trying to write its .tmp-*.mmdb file there. + mkdir: (dir) => fsp.mkdir(dir, { recursive: true }).then(() => {}), download: downloadDefault, gunzip: gunzipDefault, extractTarGz: extractTarGzDefault, @@ -262,6 +268,7 @@ export async function refreshGeoipDatabases(overrides: Partial = {} const at = deps.now.toISOString(); let source: GeoipSource = "dbip"; try { + await deps.mkdir(deps.dir); const { key: maxmindKey } = await deps.getMaxmindKey(); source = resolveGeoipSource(maxmindKey); const urls = buildDownloadUrls(source, deps.now); diff --git a/tests/geoip-refresh.test.ts b/tests/geoip-refresh.test.ts index cd2d49f..b1da63d 100644 --- a/tests/geoip-refresh.test.ts +++ b/tests/geoip-refresh.test.ts @@ -1,4 +1,8 @@ import { describe, it, expect, vi, afterEach } from "vitest"; +import { promises as fsp } from "fs"; +import { rm } from "fs/promises"; +import path from "path"; +import { tmpdir } from "os"; import { resolveGeoipSource, buildDownloadUrls, @@ -200,6 +204,7 @@ describe("refreshGeoipDatabases", () => { dir: "/fake/geoip", now: new Date("2026-09-02T00:00:00Z"), getMaxmindKey: vi.fn(async () => ({ key: null, source: null as const })), // dbip path by default + mkdir: vi.fn(async () => {}), download: vi.fn(async () => Buffer.from("raw-bytes")), gunzip: vi.fn(async (data: Buffer) => Buffer.concat([Buffer.from("decompressed:"), data])), extractTarGz: vi.fn(async () => Buffer.from("extracted-mmdb-bytes")), @@ -353,4 +358,35 @@ describe("refreshGeoipDatabases", () => { expect(rename).not.toHaveBeenCalled(); expect(deps.reload).not.toHaveBeenCalled(); }); + + it("a fresh container (GEOIP_DB_DIR does not exist yet) still succeeds — the dir is created before the first temp-file write", async () => { + // Reproduces prod: a brand-new container has no GEOIP_DB_DIR, and the very + // first refresh used to ENOENT trying to write the .tmp-*.mmdb file into a + // directory that was never created. Uses the REAL fs for writeFile/rename/ + // unlink (not the jest-mock versions makeDeps() gives every other test in + // this file) so this test exercises the actual filesystem write path, not + // a mock's assumption that the directory is already there. + const dir = path.join(tmpdir(), `vox-geoip-mkdir-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + await expect(fsp.access(dir)).rejects.toThrow(); // sanity: must not pre-exist + + try { + const deps = makeDeps({ + dir, + mkdir: (d) => fsp.mkdir(d, { recursive: true }).then(() => {}), + writeFile: (p, data) => fsp.writeFile(p, data), + rename: (from, to) => fsp.rename(from, to), + unlink: (p) => fsp.unlink(p).then(() => {}, () => {}), + }); + + const result = await refreshGeoipDatabases(deps); + + expect(result.ok).toBe(true); + const stat = await fsp.stat(dir); + expect(stat.isDirectory()).toBe(true); + const entries = (await fsp.readdir(dir)).sort(); + expect(entries).toEqual(["ASN.mmdb", "City.mmdb", "geoip-meta.json"]); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); });