From 1b51d2af01cb3b4c37ed0fc38791c6360651824d Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 1 Oct 2026 21:31:36 +0700 Subject: [PATCH] =?UTF-8?q?fix(geoip):=20=D0=BE=D1=82=D0=BA=D0=BB=D0=BE?= =?UTF-8?q?=D0=BD=D1=8F=D1=82=D1=8C=20=D0=BD=D0=B5=D0=BF=D0=BE=D0=BB=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D1=8B=20=D0=B8=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D1=8F=D1=82=D1=8C=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D1=82=D0=B2=D0=B5=D1=80=D0=B6=D0=B4=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=BE=D0=BF=D0=B5=D1=80=D0=B0=D1=86=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ux-ui-plan.md | 2 +- docs/ux-ui-progress.md | 10 ++++- docs/ux-ui-stage-11b.md | 9 +++++ shared/api/geoip.test.ts | 36 ++++++++++++++++++ shared/api/geoip.ts | 44 +++++++++++----------- tests/e2e/data-collection-geoip.spec.ts | 19 ++++++++++ tests/e2e/data-collection-snapshot.spec.ts | 3 +- tests/e2e/fixtures/geoip.ts | 1 + 8 files changed, 98 insertions(+), 26 deletions(-) create mode 100644 shared/api/geoip.test.ts create mode 100644 tests/e2e/data-collection-geoip.spec.ts create mode 100644 tests/e2e/fixtures/geoip.ts diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 7d749397..c447c583 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -226,7 +226,7 @@ REST запрос → ожидание → подтверждённый output / | Пользователи | [Профиль](ux-ui/screens/users-profile-desktop.png), [ошибка](ux-ui/screens/users-error-desktop.png) | [390](ux-ui/screens/users-profile-mobile.png), [320](ux-ui/screens/users-profile-narrow.png) | | Контейнеры | [Обзор](ux-ui/screens/containers-overview-desktop.png), [снимок](ux-ui/screens/containers-export-desktop.png) | [390](ux-ui/screens/containers-export-mobile.png), [320](ux-ui/screens/containers-export-narrow.png) | -Актуальная точка продолжения: 11Б.3.2 — строгий GeoIP API; затем подключение scheduler клиента/состояния сбора. Scheduler API основа проверена в 11Б.3.1, пока не подключена к странице. Совместимость JSONB/legacy snapshot и foreign guard проверены в 11Б.2; исходные lint/context ограничения страницы описаны в журнале. Общий runner исправлен в 11Б.1; карта оставшегося этапа — [11Б](ux-ui-stage-11b.md). Этап 11А принят локально в 11А.33: backups 71 основных + 28 source E2E, 27 frontend unit, backend профиль и production build. Backup scheduler cleanup проверен в 11А.32; общий dispatcher/journal status исправлен в 11Б.1, реальные интеграции остаются внешней приёмкой. S3/partial job/initial errors приняты в 11А.31.2.2. Диалоги ошибок приняты в 11А.31.2.1. Frame/первые 9 экранов приняты в 11А.31.1. URL/Filters контекст принят в 11А.30. Клиентский checkpoint restore принят в 11А.29.5. Backend outcome неизвестного импорта принят в 11А.29.4. Идентичность receipt принята в 11А.29.3. Backend cleanup удаления принят в 11А.29.2. Frontend delete/restore/download принят в 11А.29.1, затем URL/Frame/визуальная приёмка и scheduler cleanup. Frontend checkpoint/создание/serial polling приняты локально в 11А.28.2. API/основа наблюдения реализованы в 11А.28.1. Настройки расписания/S3 реализованы в 11А.27.1–11А.27.2. Каталоги/source/права реализованы в 11А.25, backend результат ручных операций исправлен в 11А.26. Сертификаты приняты локально в 11А.24, backups API стал строгим. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Резервные копии и весь 11А приняты локально в 11А.33; внешние ограничения — журнал. +Актуальная точка продолжения: 11Б.3.3.1 — контекст и исходные lint ошибки страницы сбора; затем strict readers/dirty states. GeoIP API принят в 11Б.3.2. Scheduler API основа проверена в 11Б.3.1, пока не подключена к странице. Совместимость JSONB/legacy snapshot и foreign guard проверены в 11Б.2; исходные lint/context ограничения страницы описаны в журнале. Общий runner исправлен в 11Б.1; карта оставшегося этапа — [11Б](ux-ui-stage-11b.md). Этап 11А принят локально в 11А.33: backups 71 основных + 28 source E2E, 27 frontend unit, backend профиль и production build. Backup scheduler cleanup проверен в 11А.32; общий dispatcher/journal status исправлен в 11Б.1, реальные интеграции остаются внешней приёмкой. S3/partial job/initial errors приняты в 11А.31.2.2. Диалоги ошибок приняты в 11А.31.2.1. Frame/первые 9 экранов приняты в 11А.31.1. URL/Filters контекст принят в 11А.30. Клиентский checkpoint restore принят в 11А.29.5. Backend outcome неизвестного импорта принят в 11А.29.4. Идентичность receipt принята в 11А.29.3. Backend cleanup удаления принят в 11А.29.2. Frontend delete/restore/download принят в 11А.29.1, затем URL/Frame/визуальная приёмка и scheduler cleanup. Frontend checkpoint/создание/serial polling приняты локально в 11А.28.2. API/основа наблюдения реализованы в 11А.28.1. Настройки расписания/S3 реализованы в 11А.27.1–11А.27.2. Каталоги/source/права реализованы в 11А.25, backend результат ручных операций исправлен в 11А.26. Сертификаты приняты локально в 11А.24, backups API стал строгим. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Резервные копии и весь 11А приняты локально в 11А.33; внешние ограничения — журнал. | Экран | ПК | Телефон | | --- | --- | --- | diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 754e3ac6..58658f0e 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -28,7 +28,7 @@ | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 | -| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.1; runner/JSONB и strict scheduler API проверены; далее GeoIP/API и состояния сбора | +| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.2; runner/JSONB/scheduler API/GeoIP проверены; далее контекст и состояния сбора | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,14 @@ +## Блок 11Б.3.2 — строгий GeoIP API против malformed crash (2026-10-01) + +- Существующие GeoIP schemas подключены к GET/PUT/update; неполный status не попадает в UI state. Ответ проверяет ready/loaded и время, PUT подтверждает отправленные поля, update сверяет ok с partial/fatal/errors и счётчиками. False/null/zero не заменяются defaults, partial download остаётся фактом. +- Выходная ошибка понятна пользователю, без сырого Zod JSON. Запросы имеют caller cancellation/30s срок; неверный/потерянный POST не повторяется автоматически. Timeout не доказывает отмену backend. +- 4 GeoIP API unit + 5 scheduler API unit, 6 E2E GeoIP/snapshot, профильный ESLint и production frontend/contracts build прошли. E2E fixture GeoIP приведена к полному существующему DTO. Первую проверку toast уточнили для двух dev StrictMode вызовов; повтор прошёл без pageerror. +- UI GeoIP/NetFlow пока сохраняет прежние defaults/dirty/context/mutation ограничения и требует отдельной приёмки; strict reader исправляет конкретное падение, не доказывает реальные загрузки mmdb или готовность интеграции. +- **Следующий блок 11Б.3.3.1:** изоляция контекста страницы сбора и исходные lint ошибки; затем подключение strict scheduler/settings readers, независимые состояния и черновики. + ## Блок 11Б.3.1 — строгий API-клиент планировщика (2026-10-01) - Добавлен shared/api/scheduler.ts: status/runs/run-now/refresh проверяют DTO, известные ключи, дубли, обязательные false/zero/null, время PostgreSQL и согласованность receipt. Фильтр журнала отклоняет записи чужой задачи. Snapshot остаётся unknown в каталоге до отдельного декодирования. diff --git a/docs/ux-ui-stage-11b.md b/docs/ux-ui-stage-11b.md index 2461518d..10e3a1d5 100644 --- a/docs/ux-ui-stage-11b.md +++ b/docs/ux-ui-stage-11b.md @@ -54,3 +54,12 @@ ReUI PRO/base-nova; существующие Frame, Data Grid v9, KpiStatGrid, S - 5 новых API unit + 4 snapshot unit, полный TypeScript и ESLint новых файлов прошли. Frontend build прошёл в 11Б.2; этот блок добавляет пока не подключённый API-модуль, UI/backend не менялись. - Клиент ещё не подключён к странице: её старые settings/receipts/locks/context/lint ограничения сохраняются. Это основа миграции, не приёмка операций сборщика. Вложенные коллекции остальных snapshot ещё требуют строгого decoder. - **Следующий блок 11Б.3.2:** GeoIP strict API/receipt против выявленного malformed crash; затем 11Б.3.3 подключение scheduler и независимые reader/context/dirty states. + + +## Блок 11Б.3.2 — строгий GeoIP API против malformed crash (2026-10-01) + +- Существующие GeoIP schemas подключены к GET/PUT/update; неполный status не попадает в UI state. Ответ проверяет ready/loaded и время, PUT подтверждает отправленные поля, update сверяет ok с partial/fatal/errors и счётчиками. False/null/zero не заменяются defaults, partial download остаётся фактом. +- Выходная ошибка понятна пользователю, без сырого Zod JSON. Запросы имеют caller cancellation/30s срок; неверный/потерянный POST не повторяется автоматически. Timeout не доказывает отмену backend. +- 4 GeoIP API unit + 5 scheduler API unit, 6 E2E GeoIP/snapshot, профильный ESLint и production frontend/contracts build прошли. E2E fixture GeoIP приведена к полному существующему DTO. Первую проверку toast уточнили для двух dev StrictMode вызовов; повтор прошёл без pageerror. +- UI GeoIP/NetFlow пока сохраняет прежние defaults/dirty/context/mutation ограничения и требует отдельной приёмки; strict reader исправляет конкретное падение, не доказывает реальные загрузки mmdb или готовность интеграции. +- **Следующий блок 11Б.3.3.1:** изоляция контекста страницы сбора и исходные lint ошибки; затем подключение strict scheduler/settings readers, независимые состояния и черновики. diff --git a/shared/api/geoip.test.ts b/shared/api/geoip.test.ts new file mode 100644 index 00000000..19ac4f1b --- /dev/null +++ b/shared/api/geoip.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { getGeoipStatus, putGeoipSettings, runGeoipUpdateNow } from "./geoip" +const base = "https://api.example.test" +const status = { ready: false, countryLoaded: false, asnLoaded: false, countryFile: "country.mmdb", asnFile: "asn.mmdb", dir: "geoip", running: false, settings: { enabled: false, updateIntervalSec: 604800, lastCheckAt: null, lastSuccessAt: null, lastError: null, countryBuildAt: null, asnBuildAt: null, updatedAt: "2026-10-01T00:00:00Z" } } +const snapshot = { v: 1, job: "geoip_update", sampledAt: "2026-10-01T00:00:00Z", checked: 2, downloaded: 1, skippedUnchanged: 0, bytes: 0, errors: ["ASN unavailable"] } +const json = (body: unknown) => new Response(JSON.stringify(body)) +test("malformed GeoIP status is rejected before UI state; false and nullable metadata stay explicit", async () => { + const original = globalThis.fetch + try { + for (const raw of [{}, null, { ...status, settings: undefined }, { ...status, settings: {} }, { ...status, ready: true }, { ...status, settings: { ...status.settings, updatedAt: "bad" } }]) { globalThis.fetch = async () => json(raw); await assert.rejects(() => getGeoipStatus(base), /Данные GeoIP не подтверждены/) } + globalThis.fetch = async () => json(status); const result = await getGeoipStatus(base); assert.equal(result.ready, false); assert.equal(result.settings.enabled, false); assert.equal(result.settings.lastSuccessAt, null) + } finally { globalThis.fetch = original } +}) +test("PUT validates input and confirms sent settings rather than trusting ok", async () => { + const original = globalThis.fetch + try { + let calls = 0; globalThis.fetch = async () => { calls++; return json({}) }; await assert.rejects(() => putGeoipSettings(base, { updateIntervalSec: 0 })); assert.equal(calls, 0) + for (const raw of [{}, { ok: false, status }, { ok: true, status: { ...status, settings: { ...status.settings, enabled: true } } }]) { globalThis.fetch = async () => json(raw); await assert.rejects(() => putGeoipSettings(base, { enabled: false })) } + globalThis.fetch = async () => json({ ok: true, status }); assert.equal((await putGeoipSettings(base, { enabled: false })).status.settings.enabled, false) + } finally { globalThis.fetch = original } +}) +test("partial update retains downloaded data and errors; contradictory, negative or empty success is rejected", async () => { + const original = globalThis.fetch + try { + globalThis.fetch = async () => json({ ok: false, snapshot }); const result = await runGeoipUpdateNow(base); assert.equal(result.ok, false); assert.equal(result.snapshot.downloaded, 1); assert.equal(result.snapshot.bytes, 0) + for (const raw of [{}, { ok: true, snapshot }, { ok: false, snapshot: { ...snapshot, checked: -1 } }, { ok: false, snapshot: { ...snapshot, downloaded: 3 } }, { ok: false, snapshot: { ...snapshot, v: 2 } }, { ok: false, snapshot: { ...snapshot, sampledAt: "bad" } }]) { globalThis.fetch = async () => json(raw); await assert.rejects(() => runGeoipUpdateNow(base)) } + } finally { globalThis.fetch = original } +}) +test("mutation has one attempt on lost acknowledgment and carries caller cancellation", async () => { + const original = globalThis.fetch + try { + let calls = 0; globalThis.fetch = async () => { calls++; throw Error("lost receipt") }; await assert.rejects(() => runGeoipUpdateNow(base), /lost receipt/); assert.equal(calls, 1) + const controller = new AbortController(); controller.abort(); globalThis.fetch = async (_url, init) => { assert.equal(init?.signal?.aborted, true); throw controller.signal.reason }; await assert.rejects(() => getGeoipStatus(base, { signal: controller.signal })) + } finally { globalThis.fetch = original } +}) diff --git a/shared/api/geoip.ts b/shared/api/geoip.ts index 913c0bcf..2fe9ce71 100644 --- a/shared/api/geoip.ts +++ b/shared/api/geoip.ts @@ -1,27 +1,25 @@ -import type { - GeoipSettingsPatch, - GeoipStatusDto, - GeoipUpdateSnapshotDto, -} from "@mmapp/contracts/geoip" +import { z } from "zod" +import { geoipSettingsPatchSchema, geoipStatusDtoSchema, geoipUpdateSnapshotDtoSchema, type GeoipSettingsPatch } from "@mmapp/contracts/geoip" import { requestJson } from "@/shared/api/http-client" - -export async function getGeoipStatus(baseUrl: string): Promise { - return requestJson(baseUrl, "/api/geoip") +const statusSchema = geoipStatusDtoSchema.refine(status => status.ready === (status.countryLoaded && status.asnLoaded) && [status.settings.updatedAt, status.settings.lastCheckAt, status.settings.lastSuccessAt, status.settings.countryBuildAt, status.settings.asnBuildAt].every(value => value === null || Number.isFinite(Date.parse(value))), "Несогласованная готовность или время GeoIP") +const updateSchema = z.object({ ok: z.boolean(), snapshot: geoipUpdateSnapshotDtoSchema }).refine(response => response.snapshot.v === 1 && Number.isFinite(Date.parse(response.snapshot.sampledAt)) && response.snapshot.checked >= 0 && response.snapshot.downloaded >= 0 && response.snapshot.skippedUnchanged >= 0 && response.snapshot.downloaded + response.snapshot.skippedUnchanged <= response.snapshot.checked && response.ok === (!response.snapshot.fatalError && response.snapshot.errors.length === 0), "Несогласованный результат GeoIP") +function confirmed(schema: z.ZodType, raw: unknown): T { + const parsed = schema.safeParse(raw) + if (!parsed.success) throw new Error("Данные GeoIP не подтверждены: получен некорректный ответ сервера") + return parsed.data } - -export async function putGeoipSettings( - baseUrl: string, - patch: GeoipSettingsPatch, -): Promise<{ ok: boolean; status: GeoipStatusDto }> { - return requestJson(baseUrl, "/api/geoip", { - method: "PUT", - body: JSON.stringify(patch), - }) +async function json(baseUrl: string, path: string, init?: RequestInit) { + return requestJson(baseUrl, path, { ...init, signal: AbortSignal.any([...(init?.signal ? [init.signal] : []), AbortSignal.timeout(30_000)]) }) } - -export async function runGeoipUpdateNow(baseUrl: string): Promise<{ - ok: boolean - snapshot: GeoipUpdateSnapshotDto -}> { - return requestJson(baseUrl, "/api/geoip/update", { method: "POST" }) +export async function getGeoipStatus(baseUrl: string, init?: RequestInit) { + return confirmed(statusSchema, await json(baseUrl, "/api/geoip", { ...init, method: "GET" })) +} +export async function putGeoipSettings(baseUrl: string, patch: GeoipSettingsPatch, init?: RequestInit) { + const body = geoipSettingsPatchSchema.strict().parse(patch) + const response = confirmed(z.object({ ok: z.literal(true), status: statusSchema }), await json(baseUrl, "/api/geoip", { ...init, method: "PUT", body: JSON.stringify(body) })) + for (const field of ["enabled", "updateIntervalSec"] as const) if (body[field] !== undefined && body[field] !== response.status.settings[field]) throw new Error("Сохранение настроек GeoIP не подтверждено: ответ отличается от отправленных значений") + return response +} +export async function runGeoipUpdateNow(baseUrl: string, init?: RequestInit) { + return confirmed(updateSchema, await json(baseUrl, "/api/geoip/update", { ...init, method: "POST" })) } diff --git a/tests/e2e/data-collection-geoip.spec.ts b/tests/e2e/data-collection-geoip.spec.ts new file mode 100644 index 00000000..1ec9e2f1 --- /dev/null +++ b/tests/e2e/data-collection-geoip.spec.ts @@ -0,0 +1,19 @@ +import { test, expect } from "@playwright/test" +import { geoip } from "./fixtures/geoip" +for (const invalid of [true, false]) test("GeoIP: malformed reader не обрушает сбор данных/" + invalid, async ({ page }) => { + const errors: string[] = []; page.on("pageerror", error => errors.push(error.message)) + await page.route("**/health", r => r.fulfill({ json: { ok: true } })) + await page.route("**/api/**", r => { + const path = new URL(r.request().url()).pathname + if (path === "/api/auth/config") return r.fulfill({ json: { required: false } }) + if (path === "/api/geoip") return r.fulfill({ json: invalid ? {} : geoip }) + if (path === "/api/traffic/flow/settings") return r.fulfill({ status: 503, json: { error: "NetFlow outside fixture" } }) + if (path === "/api/scheduler/runs") return r.fulfill({ json: { runs: [] } }) + return r.fulfill({ json: { enabled: false, intervalSec: 300, retentionDays: 14, lastCollectedAt: null, lastDurationMs: null, lastError: null, scheduler: { jobs: [] } } }) + }) + await page.goto("/data-collection") + await expect(page.getByText("Журнал прогонов", { exact: true })).toBeVisible() + if (invalid) await expect(page.getByText("Данные GeoIP не подтверждены: получен некорректный ответ сервера", { exact: true }).first()).toBeVisible() + else await expect(page.getByRole("button", { name: "Сохранить GeoIP", exact: true })).toBeVisible() + expect(errors).toEqual([]) +}) diff --git a/tests/e2e/data-collection-snapshot.spec.ts b/tests/e2e/data-collection-snapshot.spec.ts index 72d1fcdf..63d008d2 100644 --- a/tests/e2e/data-collection-snapshot.spec.ts +++ b/tests/e2e/data-collection-snapshot.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from "@playwright/test" +import { geoip } from "./fixtures/geoip" const snapshot = { v: 1, job: "backups", sampledAt: "2026-10-01T00:00:00Z", due: true, created: 2, failures: 0, pruned: 1, errors: ["S3 upload unavailable"] } for (const representation of ["jsonb", "legacy", "invalid", "foreign"] as const) test("Сбор данных: snapshot/" + representation, async ({ page }) => { const pageErrors: string[] = []; page.on("pageerror", error => pageErrors.push(error.message)) @@ -7,7 +8,7 @@ for (const representation of ["jsonb", "legacy", "invalid", "foreign"] as const) const path = new URL(r.request().url()).pathname if (path === "/api/traffic/flow/settings") return r.fulfill({ status: 503, json: { error: "NetFlow is outside this snapshot fixture" } }) const settings = { enabled: false, intervalSec: 300, retentionDays: 14, lastCollectedAt: null, lastDurationMs: null, lastError: null, scheduler: { jobs: [] } } - return r.fulfill({ json: path === "/api/auth/config" ? { required: false } : path === "/api/geoip" ? { settings: { ...settings, updateIntervalSec: 604800 }, countryLoaded: false, asnLoaded: false, dir: "test" } : path === "/api/scheduler/runs" ? { runs: [{ id: "run-jsonb", jobKey: "backups", startedAt: snapshot.sampledAt, finishedAt: snapshot.sampledAt, status: "error", error: "Завершено с ограничениями", durationMs: 100, resultJson: representation === "legacy" ? JSON.stringify(snapshot) : representation === "invalid" ? { ...snapshot, created: undefined } : representation === "foreign" ? { v: 1, job: "traffic", sampledAt: snapshot.sampledAt, servers: [] } : snapshot }] } : settings }) + return r.fulfill({ json: path === "/api/auth/config" ? { required: false } : path === "/api/geoip" ? geoip : path === "/api/scheduler/runs" ? { runs: [{ id: "run-jsonb", jobKey: "backups", startedAt: snapshot.sampledAt, finishedAt: snapshot.sampledAt, status: "error", error: "Завершено с ограничениями", durationMs: 100, resultJson: representation === "legacy" ? JSON.stringify(snapshot) : representation === "invalid" ? { ...snapshot, created: undefined } : representation === "foreign" ? { v: 1, job: "traffic", sampledAt: snapshot.sampledAt, servers: [] } : snapshot }] } : settings }) }) await page.goto("/data-collection") await page.getByRole("button", { name: /Бэкапы.*100 мс/ }).click() diff --git a/tests/e2e/fixtures/geoip.ts b/tests/e2e/fixtures/geoip.ts new file mode 100644 index 00000000..08304269 --- /dev/null +++ b/tests/e2e/fixtures/geoip.ts @@ -0,0 +1 @@ +export const geoip = { ready: false, countryLoaded: false, asnLoaded: false, countryFile: "country.mmdb", asnFile: "asn.mmdb", dir: "geoip", running: false, settings: { enabled: false, updateIntervalSec: 604800, lastCheckAt: null, lastSuccessAt: null, lastError: null, countryBuildAt: null, asnBuildAt: null, updatedAt: "2026-10-01T00:00:00Z" } }