fix(geoip): отклонять неполные ответы и проверять подтверждения операций
This commit is contained in:
1 parent
638c17caef
commit
1b51d2af01
8 files changed
+98
-26
No files matched your search
@@ -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 }
|
||||
})
|
||||
+21
-23
@@ -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<GeoipStatusDto> {
|
||||
return requestJson<GeoipStatusDto>(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<T>(schema: z.ZodType<T>, 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<unknown>(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" }))
|
||||
}
|
||||
Reference in new issue
Block a user