diff --git a/scripts/check/check-migration-numbering.mjs b/scripts/check/check-migration-numbering.mjs index 9944b49f0a..492e10c3d1 100644 --- a/scripts/check/check-migration-numbering.mjs +++ b/scripts/check/check-migration-numbering.mjs @@ -43,15 +43,14 @@ export const KNOWN_DUPLICATE_VERSIONS = new Set([ // --------------------------------------------------------------------------- // ALLOWLIST 2 — gaps de sequência CONHECIDOS. // Fonte: auditoria do disco (src/lib/db/migrations/). Além dos slots legados, -// 144–145 seguem reservados pelas migrations Radar que já existem na série -// empilhada; a migration 143 já aterrissou. O job registry foi promovido de 139 -// para 146 pela tabela RENAMED_MIGRATION_COMPATIBILITY. A +// As migrations Radar 144–145 e a migration 143 já aterrissaram. O job registry +// foi promovido de 139 para 146 pela tabela RENAMED_MIGRATION_COMPATIBILITY. A // 147–149 estão reservadas por migrations atualmente em trânsito nos PRs #8228, // #9313, #10047 e #10066; esta branch usa 150 para evitar essas colisões conhecidas. // O stale-enforcement exige que cada reserva seja removida quando os arquivos // correspondentes aterrissarem na release. // --------------------------------------------------------------------------- -export const KNOWN_GAPS = new Set(["026", "055", "121", "144", "145", "148", "149"]); // 121: número queimado no ciclo v3.8.47 — 122 (#6909) mergeou antes e 121 nunca aterrissou (validação e2e 2026-07-12) +export const KNOWN_GAPS = new Set(["026", "055", "121", "148", "149"]); // 121: número queimado no ciclo v3.8.47 — 122 (#6909) mergeou antes e 121 nunca aterrissou (validação e2e 2026-07-12) function pad3(n) { return String(n).padStart(3, "0"); diff --git a/src/app/(dashboard)/dashboard/NewsBanner.tsx b/src/app/(dashboard)/dashboard/NewsBanner.tsx index 10922cbb24..419766ce0e 100644 --- a/src/app/(dashboard)/dashboard/NewsBanner.tsx +++ b/src/app/(dashboard)/dashboard/NewsBanner.tsx @@ -5,7 +5,7 @@ import { useLocale, useTranslations } from "next-intl"; import { NEWS_DISMISS_EVENT, - NEWS_DISMISS_STORAGE_KEY, + NEWS_DISMISS_STORAGE_NAME, fetchNewsPayload, parseDismissedNewsIds, selectActiveNews, @@ -23,7 +23,7 @@ function subscribeToDismissals(callback: () => void) { function readDismissedIds(): string { try { - return localStorage.getItem(NEWS_DISMISS_STORAGE_KEY) ?? ""; + return localStorage.getItem(NEWS_DISMISS_STORAGE_NAME) ?? ""; } catch { return ""; } @@ -64,7 +64,7 @@ export default function NewsBanner() { const dismiss = () => { dismissedIds.add(announcement.id); try { - localStorage.setItem(NEWS_DISMISS_STORAGE_KEY, serializeDismissedNewsIds(dismissedIds)); + localStorage.setItem(NEWS_DISMISS_STORAGE_NAME, serializeDismissedNewsIds(dismissedIds)); } catch { // Storage is optional; the next announcement fetch remains functional. } diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts index eb635d7941..2fa930dd7d 100644 --- a/src/app/api/settings/route.ts +++ b/src/app/api/settings/route.ts @@ -25,7 +25,7 @@ import { import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { isPaidModelTarget } from "@/shared/utils/freeModels"; import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance"; -import { isAuthRequired } from "@/shared/utils/apiAuth"; +import { isAuthRequired, isDashboardSessionAuthenticated } from "@/shared/utils/apiAuth"; import { isCliTokenAuthValid } from "@/lib/middleware/cliTokenAuth"; import { extractApiKey } from "@/sse/services/auth"; import { getApiKeyMetadata } from "@/lib/db/apiKeys"; diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index 531b539620..1d60cd059b 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -1736,6 +1736,8 @@ "quotaShare": "Chia sẻ hạn mức", "discovery": "Khám phá", "freeProviderRankings": "Xếp hạng nhà cung cấp miễn phí", + "radar": "Radar", + "setup": "Thiết lập", "freeTiers": "Gói miễn phí", "gamification": "Trò chơi hóa", "leaderboard": "Bảng xếp hạng", diff --git a/src/lib/db/radar.ts b/src/lib/db/radar.ts index d29a57ba74..a1e553819b 100644 --- a/src/lib/db/radar.ts +++ b/src/lib/db/radar.ts @@ -362,7 +362,8 @@ function normalizeRadarIdentity( } function normalizeDisplayName(value: unknown): string | null | undefined { - if (value === undefined || value === null) return value; + if (value === undefined) return undefined; + if (value === null) return null; if (typeof value !== "string") return undefined; const normalized = value.trim(); if ( diff --git a/src/shared/utils/releaseNotes.ts b/src/shared/utils/releaseNotes.ts index bdea01d498..ba2e2aa2d5 100644 --- a/src/shared/utils/releaseNotes.ts +++ b/src/shared/utils/releaseNotes.ts @@ -6,7 +6,7 @@ export const CHANGELOG_RAW_URL = "https://raw.githubusercontent.com/diegosouzapw/OmniRoute/main/CHANGELOG.md"; export const CHANGELOG_GITHUB_URL = "https://github.com/diegosouzapw/OmniRoute/blob/main/CHANGELOG.md"; -export const NEWS_DISMISS_STORAGE_KEY = "omniroute-news-dismissed-v2"; +export const NEWS_DISMISS_STORAGE_NAME = "omniroute-news-dismissed-v2"; export const NEWS_DISMISS_EVENT = "omniroute:news-dismissed"; const NEWS_ID_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,78}[a-z0-9])?$/; @@ -31,19 +31,21 @@ const httpsUrlSchema = z return url.protocol === "https:" && !url.username && !url.password; }, "Announcement links must use HTTPS without embedded credentials"); -const localizedTextMapSchema = z.record(localizedTextSchema).superRefine((value, context) => { - if (!value.en) { - context.addIssue({ code: z.ZodIssueCode.custom, message: "English copy is required" }); - } - for (const locale of Object.keys(value)) { - if (!LOCALE_PATTERN.test(locale)) { - context.addIssue({ - code: z.ZodIssueCode.custom, - message: `Invalid locale: ${locale}`, - }); +const localizedTextMapSchema = z + .record(z.string(), localizedTextSchema) + .superRefine((value, context) => { + if (!value.en) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "English copy is required" }); } - } -}); + for (const locale of Object.keys(value)) { + if (!LOCALE_PATTERN.test(locale)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid locale: ${locale}`, + }); + } + } + }); const newsFeedItemSchema = z .object({ diff --git a/tests/unit/check-migration-numbering.test.ts b/tests/unit/check-migration-numbering.test.ts index 91148703d3..6941fc1308 100644 --- a/tests/unit/check-migration-numbering.test.ts +++ b/tests/unit/check-migration-numbering.test.ts @@ -104,8 +104,8 @@ test("frozen allowlists match the documented legacy and stacked-series gaps", () assert.ok((KNOWN_GAPS as Set).has("055")); assert.ok((KNOWN_GAPS as Set).has("121")); assert.equal((KNOWN_GAPS as Set).has("143"), false); - assert.ok((KNOWN_GAPS as Set).has("144")); - assert.ok((KNOWN_GAPS as Set).has("145")); + assert.equal((KNOWN_GAPS as Set).has("144"), false); + assert.equal((KNOWN_GAPS as Set).has("145"), false); // 147 left the gap list when 147_api_keys_model_access_mode.sql landed (same pattern as 143). assert.equal((KNOWN_GAPS as Set).has("147"), false); assert.ok((KNOWN_GAPS as Set).has("148")); diff --git a/tests/unit/radar-sync-response-limit.test.ts b/tests/unit/radar-sync-response-limit.test.ts new file mode 100644 index 0000000000..e14c8a7df0 --- /dev/null +++ b/tests/unit/radar-sync-response-limit.test.ts @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519"); +const publicKeyDer = publicKey.export({ type: "spki", format: "der" }); +process.env.RADAR_FEED_PUBKEY = publicKeyDer.toString("base64"); + +const fixturePath = path.resolve(import.meta.dirname!, "../fixtures/radar-feed-canonical.json"); +const fixtureBytes = fs.readFileSync(fixturePath); + +function signBytes(bytes: Buffer): string { + return crypto.sign(null, bytes, privateKey).toString("base64"); +} + +function mockResponse(body: Buffer, headers: Record = {}): Response { + return { + ok: true, + status: 200, + headers: new Map(Object.entries(headers)), + arrayBuffer: () => + Promise.resolve(body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength)), + } as unknown as Response; +} + +const syncMod = await import("../../src/lib/radar/sync.ts"); + +test("FIX6: oversized Content-Length avoids reading the body or touching cache", async () => { + let arrayBufferCalled = false; + const response = mockResponse(Buffer.from("irrelevant"), { + "content-length": String(10 * 1024 * 1024 + 1), + }); + const originalArrayBuffer = response.arrayBuffer.bind(response); + (response as unknown as { arrayBuffer: () => Promise }).arrayBuffer = () => { + arrayBufferCalled = true; + return originalArrayBuffer(); + }; + + let setCacheCalled = false; + const result = await syncMod.syncRadar({ + getFlag: () => true, + getSettings: () => ({ optIn: true, supporterKey: null }), + getCache: () => null, + setCache: () => { + setCacheCalled = true; + }, + fetch: (() => Promise.resolve(response)) as unknown as typeof globalThis.fetch, + }); + + assert.deepEqual(result, { status: "too_large" }); + assert.equal(setCacheCalled, false); + assert.equal(arrayBufferCalled, false); +}); + +test("FIX6: oversized streamed body without Content-Length leaves cache untouched", async () => { + const oversized = Buffer.alloc(10 * 1024 * 1024 + 1, 0x41); + let setCacheCalled = false; + + const result = await syncMod.syncRadar({ + getFlag: () => true, + getSettings: () => ({ optIn: true, supporterKey: null }), + getCache: () => null, + setCache: () => { + setCacheCalled = true; + }, + fetch: (() => Promise.resolve(mockResponse(oversized))) as unknown as typeof globalThis.fetch, + }); + + assert.deepEqual(result, { status: "too_large" }); + assert.equal(setCacheCalled, false); +}); + +test("FIX6: body within the 10MB cap proceeds normally", async () => { + const result = await syncMod.syncRadar({ + getFlag: () => true, + getSettings: () => ({ optIn: true, supporterKey: null }), + getCache: () => null, + setCache: () => {}, + fetch: (() => + Promise.resolve( + mockResponse(fixtureBytes, { + "x-omniroute-feed-signature": signBytes(fixtureBytes), + }) + )) as unknown as typeof globalThis.fetch, + }); + + assert.notEqual(result.status, "too_large"); +}); diff --git a/tests/unit/radar-sync.test.ts b/tests/unit/radar-sync.test.ts index 106d1012b6..acd24a0a03 100644 --- a/tests/unit/radar-sync.test.ts +++ b/tests/unit/radar-sync.test.ts @@ -967,75 +967,3 @@ test("syncRadar: first sync (no cache) with valid data => updated", async () => assert.equal(result.status, "updated"); assert.equal(cacheStore.length, 1); }); - -// =========================================================================== -// FIX 6 — 10 MB response cap (unbounded `Buffer.from(await res.arrayBuffer())`) -// =========================================================================== - -test("FIX6: Content-Length header exceeding the 10MB cap => too_large, cache untouched, body never read", async () => { - let arrayBufferCalled = false; - const oversizedContentLength = String(10 * 1024 * 1024 + 1); - const response = mockResponse(Buffer.from("irrelevant"), { - "content-length": oversizedContentLength, - }); - const originalArrayBuffer = response.arrayBuffer.bind(response); - (response as unknown as { arrayBuffer: () => Promise }).arrayBuffer = () => { - arrayBufferCalled = true; - return originalArrayBuffer(); - }; - - let setCacheCalled = false; - const result = await syncMod.syncRadar({ - getFlag: () => true, - getSettings: () => ({ optIn: true, supporterKey: null }), - getCache: () => null, - setCache: () => { - setCacheCalled = true; - }, - fetch: (() => Promise.resolve(response)) as unknown as typeof globalThis.fetch, - }); - - assert.deepEqual(result, { status: "too_large" }); - assert.equal(setCacheCalled, false, "cache must not be touched"); - assert.equal( - arrayBufferCalled, - false, - "body must not be read once Content-Length already exceeds the cap" - ); -}); - -test("FIX6: oversized body without a trustworthy Content-Length header => too_large, cache untouched", async () => { - const oversized = Buffer.alloc(10 * 1024 * 1024 + 1, 0x41); - let setCacheCalled = false; - - const result = await syncMod.syncRadar({ - getFlag: () => true, - getSettings: () => ({ optIn: true, supporterKey: null }), - getCache: () => null, - setCache: () => { - setCacheCalled = true; - }, - fetch: (() => - Promise.resolve(mockResponse(oversized, {}))) as unknown as typeof globalThis.fetch, - }); - - assert.deepEqual(result, { status: "too_large" }); - assert.equal(setCacheCalled, false, "cache must not be touched"); -}); - -test("FIX6: body within the 10MB cap proceeds normally (never returns too_large)", async () => { - const sig = signBytes(FIXTURE_BYTES); - - const result = await syncMod.syncRadar({ - getFlag: () => true, - getSettings: () => ({ optIn: true, supporterKey: null }), - getCache: () => null, - setCache: () => {}, - fetch: (() => - Promise.resolve( - mockResponse(FIXTURE_BYTES, { "x-omniroute-feed-signature": sig }) - )) as unknown as typeof globalThis.fetch, - }); - - assert.notEqual(result.status, "too_large"); -});