From 603559c540afdec72814544474682a2052378a53 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 19 Aug 2026 13:00:06 +0200 Subject: [PATCH] feat: add timezone-aware TOTP time travel --- CHANGELOG.md | 5 + README.md | 3 +- public/CHANGELOG.md | 5 + public/README.md | 3 +- src/components/otp/OtpWorkspace.tsx | 307 ++++++++++++++++++++++++++-- src/otp/time.ts | 205 +++++++++++++++++++ src/styles.css | 80 ++++++++ tests/components/workbench.test.tsx | 50 ++++- tests/otp/time.test.ts | 81 ++++++++ 9 files changed, 720 insertions(+), 19 deletions(-) create mode 100644 src/otp/time.ts create mode 100644 tests/otp/time.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c754c90..433205c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes are documented here. +## Unreleased + +- Added auditable TOTP time travel using live time, bounded positive or negative period offsets, or a frozen local date/time interpreted in an explicit IANA timezone. +- Added DST gap rejection and earlier/later disambiguation for repeated wall-clock times, with effective local time, UTC instant, Unix seconds and TOTP counter diagnostics. + ## 0.1.0 - 2026-08-19 - Initial HOTP, TOTP and OCRA credential laboratory with official RFC-vector coverage. diff --git a/README.md b/README.md index bb4a473..b59f18c 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,11 @@ A production-oriented, local-first browser workbench for OTP credentials and Web Authentication material stays in the active tab. The application has no backend, telemetry, automatic network lookup, service worker, cookie, local-storage credential store, or IndexedDB database. -## Included in 0.1.0 +## Current source capabilities - RFC 4226 HOTP and RFC 6238 TOTP generation and bounded diagnostic verification using SHA-1, SHA-256 or SHA-512, with exact 64-bit counters and preserved leading zeroes. - Strict `otpauth://` parsing/serialization, random secret generation, masked values, interoperability findings, live period display and a project-owned QR encoder. +- Auditable TOTP time travel using positive/negative period offsets or a frozen local date/time in an explicit IANA timezone, including DST-gap rejection and repeated-time disambiguation. - RFC 6287 OCRA-1 suite parsing and computation for counter, numeric/alphanumeric/hex challenge, PIN/password hash, session and timestamp inputs. Official RFC interoperability vectors cover SHA-1, SHA-256 and SHA-512 paths. - Import of line-delimited provisioning URIs, Google Authenticator migration QR payloads, the documented CSV shape and RFC 6030 PSKC files containing plain secrets. Encrypted PSKC is rejected rather than guessed. - Explicit URI-list and CSV export with an unencrypted-secret warning. diff --git a/public/CHANGELOG.md b/public/CHANGELOG.md index c754c90..433205c 100644 --- a/public/CHANGELOG.md +++ b/public/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes are documented here. +## Unreleased + +- Added auditable TOTP time travel using live time, bounded positive or negative period offsets, or a frozen local date/time interpreted in an explicit IANA timezone. +- Added DST gap rejection and earlier/later disambiguation for repeated wall-clock times, with effective local time, UTC instant, Unix seconds and TOTP counter diagnostics. + ## 0.1.0 - 2026-08-19 - Initial HOTP, TOTP and OCRA credential laboratory with official RFC-vector coverage. diff --git a/public/README.md b/public/README.md index bb4a473..b59f18c 100644 --- a/public/README.md +++ b/public/README.md @@ -4,10 +4,11 @@ A production-oriented, local-first browser workbench for OTP credentials and Web Authentication material stays in the active tab. The application has no backend, telemetry, automatic network lookup, service worker, cookie, local-storage credential store, or IndexedDB database. -## Included in 0.1.0 +## Current source capabilities - RFC 4226 HOTP and RFC 6238 TOTP generation and bounded diagnostic verification using SHA-1, SHA-256 or SHA-512, with exact 64-bit counters and preserved leading zeroes. - Strict `otpauth://` parsing/serialization, random secret generation, masked values, interoperability findings, live period display and a project-owned QR encoder. +- Auditable TOTP time travel using positive/negative period offsets or a frozen local date/time in an explicit IANA timezone, including DST-gap rejection and repeated-time disambiguation. - RFC 6287 OCRA-1 suite parsing and computation for counter, numeric/alphanumeric/hex challenge, PIN/password hash, session and timestamp inputs. Official RFC interoperability vectors cover SHA-1, SHA-256 and SHA-512 paths. - Import of line-delimited provisioning URIs, Google Authenticator migration QR payloads, the documented CSV shape and RFC 6030 PSKC files containing plain secrets. Encrypted PSKC is rejected rather than guessed. - Explicit URI-list and CSV export with an unencrypted-secret warning. diff --git a/src/components/otp/OtpWorkspace.tsx b/src/components/otp/OtpWorkspace.tsx index 295e560..2a3a2d9 100644 --- a/src/components/otp/OtpWorkspace.tsx +++ b/src/components/otp/OtpWorkspace.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { base32ToBytes, bytesToBase32, @@ -16,6 +16,7 @@ import { import { hotp, totp, + totpCounter, verifyHotp, verifyTotp, type OtpHashAlgorithm, @@ -27,8 +28,26 @@ import { serializeOtpAuth, type OtpProfile, } from "../../otp/profile"; +import { + formatDateTimeLocal, + MAX_TOTP_PERIOD_OFFSET, + resolveZonedDateTime, + shiftTimestampByPeriods, + supportedTimeZones, + type TimeDisambiguation, +} from "../../otp/time"; import { qrSvg } from "../../qr/encoder"; +type TotpTimeMode = "current" | "relative" | "absolute"; + +function browserTimeZone(): string { + return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; +} + +function timeError(reason: unknown): string { + return reason instanceof Error ? reason.message : "Invalid TOTP time."; +} + function downloadText( name: string, content: string, @@ -82,7 +101,16 @@ export function OtpWorkspace() { ); const [secretVisible, setSecretVisible] = useState(false); const [now, setNow] = useState(() => Date.now()); + const [totpTimeMode, setTotpTimeMode] = useState("current"); + const [periodOffset, setPeriodOffset] = useState("0"); + const [timeZone, setTimeZone] = useState(browserTimeZone); + const [absoluteTime, setAbsoluteTime] = useState(() => + formatDateTimeLocal(Date.now(), browserTimeZone()), + ); + const [timeDisambiguation, setTimeDisambiguation] = + useState("earlier"); const [code, setCode] = useState("······"); + const generationId = useRef(0); const [verifyCode, setVerifyCode] = useState(""); const [verification, setVerification] = useState(""); const [error, setError] = useState(""); @@ -93,12 +121,52 @@ export function OtpWorkspace() { const [imported, setImported] = useState([]); const [warnings, setWarnings] = useState([]); + const timeZones = useMemo(() => supportedTimeZones(), []); + const resolvedTime = useMemo(() => { + try { + if (totpTimeMode === "current") + return { timestampMs: now, candidates: [now], error: "" }; + if (totpTimeMode === "relative") { + if (!/^-?[0-9]+$/u.test(periodOffset)) + throw new RangeError("Enter a whole-number period offset."); + const offset = Number(periodOffset); + return { + timestampMs: shiftTimestampByPeriods(now, profile.period, offset), + candidates: [] as number[], + error: "", + }; + } + const resolved = resolveZonedDateTime( + absoluteTime, + timeZone, + timeDisambiguation, + ); + return { ...resolved, error: "" }; + } catch (reason) { + return { + timestampMs: null, + candidates: [] as number[], + error: timeError(reason), + }; + } + }, [ + absoluteTime, + now, + periodOffset, + profile.period, + timeDisambiguation, + timeZone, + totpTimeMode, + ]); + useEffect(() => { const timer = window.setInterval(() => setNow(Date.now()), 500); return () => window.clearInterval(timer); }, []); useEffect(() => { - let active = true; + const currentGeneration = ++generationId.current; + if (profile.kind === "totp" && resolvedTime.timestampMs === null) + return undefined; const generate = profile.kind === "totp" ? totp({ @@ -106,7 +174,7 @@ export function OtpWorkspace() { algorithm: profile.algorithm, digits: profile.digits, period: profile.period, - timestamp: now / 1000, + timestamp: resolvedTime.timestampMs! / 1000, }) : hotp({ secret: profile.secret, @@ -116,18 +184,18 @@ export function OtpWorkspace() { }); void generate .then((value) => { - if (active) setCode(value); + if (generationId.current === currentGeneration) setCode(value); }) .catch((reason: unknown) => { - if (active) + if (generationId.current === currentGeneration) setError( reason instanceof Error ? reason.message : "OTP generation failed.", ); }); return () => { - active = false; + if (generationId.current === currentGeneration) generationId.current += 1; }; - }, [profile, now]); + }, [profile, resolvedTime.timestampMs]); const uri = useMemo(() => { try { @@ -144,10 +212,31 @@ export function OtpWorkspace() { } }, [uri]); const strength = useMemo(() => profileStrength(profile), [profile]); - const remaining = - profile.kind === "totp" - ? profile.period - (Math.floor(now / 1000) % profile.period) - : null; + const timeDetails = useMemo(() => { + if ( + profile.kind !== "totp" || + resolvedTime.timestampMs === null || + !Number.isInteger(profile.period) || + profile.period < 1 + ) + return null; + const seconds = Math.floor(resolvedTime.timestampMs / 1000); + try { + return { + local: new Intl.DateTimeFormat(undefined, { + timeZone, + dateStyle: "medium", + timeStyle: "long", + }).format(resolvedTime.timestampMs), + utc: new Date(resolvedTime.timestampMs).toISOString(), + unixSeconds: seconds, + counter: totpCounter(seconds, profile.period).toString(), + remaining: profile.period - (seconds % profile.period), + }; + } catch { + return null; + } + }, [profile.kind, profile.period, resolvedTime.timestampMs, timeZone]); const update = ( key: Key, @@ -183,6 +272,8 @@ export function OtpWorkspace() { }; const verify = async () => { try { + if (profile.kind === "totp" && resolvedTime.timestampMs === null) + throw new Error(resolvedTime.error); const match = profile.kind === "totp" ? await verifyTotp(verifyCode, { @@ -190,7 +281,7 @@ export function OtpWorkspace() { algorithm: profile.algorithm, digits: profile.digits, period: profile.period, - timestamp: now / 1000, + timestamp: resolvedTime.timestampMs! / 1000, window: 2, }) : await verifyHotp(verifyCode, { @@ -211,6 +302,33 @@ export function OtpWorkspace() { ); } }; + const adjustPeriodOffset = (delta: number) => { + const current = /^-?[0-9]+$/u.test(periodOffset) ? Number(periodOffset) : 0; + const next = Math.max( + -MAX_TOTP_PERIOD_OFFSET, + Math.min(MAX_TOTP_PERIOD_OFFSET, current + delta), + ); + setPeriodOffset(next.toString()); + }; + const selectAbsoluteTime = () => { + try { + setAbsoluteTime( + formatDateTimeLocal(resolvedTime.timestampMs ?? now, timeZone), + ); + } catch { + setAbsoluteTime(formatDateTimeLocal(now, "UTC")); + setTimeZone("UTC"); + } + setTotpTimeMode("absolute"); + }; + const setAbsoluteToNow = () => { + try { + setAbsoluteTime(formatDateTimeLocal(now, timeZone)); + } catch { + setTimeZone("UTC"); + setAbsoluteTime(formatDateTimeLocal(now, "UTC")); + } + }; const performImport = (text = migrationText) => { try { const result = detectImport(text); @@ -439,20 +557,177 @@ export function OtpWorkspace() {

Live value

{profile.kind.toUpperCase()} code

- {remaining !== null && ( - {remaining}s + {timeDetails !== null && ( + + {totpTimeMode === "absolute" + ? "Frozen" + : `${timeDetails.remaining}s`} + )}
{code.match(/.{1,3}/gu)?.join(" ")}
+ {profile.kind === "totp" && ( +
+
+ + + +
+ {totpTimeMode === "relative" && ( +
+ + + +
+ )} + {totpTimeMode === "absolute" && ( +
+ + + + {resolvedTime.candidates.length > 1 && ( + + )} +
+ )} + {resolvedTime.error ? ( +

+ {resolvedTime.error} The last valid code remains visible. +

+ ) : ( + timeDetails && ( +
+
+
Effective time
+
{timeDetails.local}
+
+
+
UTC instant
+
{timeDetails.utc}
+
+
+
Unix seconds
+
{timeDetails.unixSeconds}
+
+
+
TOTP counter
+
{timeDetails.counter}
+
+
+ ) + )} +

+ One epoch is the configured {profile.period}-second period. + Timezones interpret wall-clock input; the TOTP calculation + always uses the resulting UTC instant. +

+
+ )}
(); + +function formatter(timeZone: string): Intl.DateTimeFormat { + const normalized = timeZone.trim(); + if (!normalized || normalized.length > 100) + throw new RangeError("Enter a valid IANA timezone."); + const existing = formatterCache.get(normalized); + if (existing) return existing; + let created: Intl.DateTimeFormat; + try { + created = new Intl.DateTimeFormat("en-GB-u-ca-gregory-nu-latn", { + timeZone: normalized, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + fractionalSecondDigits: 3, + hourCycle: "h23", + }); + } catch { + throw new RangeError(`Unknown IANA timezone: ${normalized}`); + } + formatterCache.set(normalized, created); + return created; +} + +function utcTimestamp(fields: LocalDateTimeFields): number { + const date = new Date(0); + date.setUTCFullYear(fields.year, fields.month - 1, fields.day); + date.setUTCHours( + fields.hour, + fields.minute, + fields.second, + fields.millisecond, + ); + return date.getTime(); +} + +function fieldsAt(timestampMs: number, timeZone: string): LocalDateTimeFields { + const values = new Map( + formatter(timeZone) + .formatToParts(new Date(timestampMs)) + .map((part) => [part.type, part.value]), + ); + return { + year: Number(values.get("year")), + month: Number(values.get("month")), + day: Number(values.get("day")), + hour: Number(values.get("hour")), + minute: Number(values.get("minute")), + second: Number(values.get("second")), + millisecond: Number(values.get("fractionalSecond") ?? "0"), + }; +} + +function sameFields( + left: LocalDateTimeFields, + right: LocalDateTimeFields, +): boolean { + return ( + left.year === right.year && + left.month === right.month && + left.day === right.day && + left.hour === right.hour && + left.minute === right.minute && + left.second === right.second && + left.millisecond === right.millisecond + ); +} + +export function parseLocalDateTime(value: string): LocalDateTimeFields { + const match = + /^(?[0-9]{4})-(?[0-9]{2})-(?[0-9]{2})T(?[0-9]{2}):(?[0-9]{2})(?::(?[0-9]{2})(?:\.(?[0-9]{1,3}))?)?$/u.exec( + value, + ); + if (!match?.groups) + throw new RangeError("Enter a complete local date and time."); + const fields = { + year: Number(match.groups.year), + month: Number(match.groups.month), + day: Number(match.groups.day), + hour: Number(match.groups.hour), + minute: Number(match.groups.minute), + second: Number(match.groups.second ?? "0"), + millisecond: Number((match.groups.fraction ?? "0").padEnd(3, "0")), + }; + if (fields.year < 1970 || fields.year > 9999) + throw new RangeError("TOTP time must be between 1970 and 9999."); + const timestamp = utcTimestamp(fields); + if ( + !Number.isFinite(timestamp) || + !sameFields(fieldsAt(timestamp, "UTC"), fields) + ) + throw new RangeError("Enter a valid calendar date and time."); + return fields; +} + +export function zonedDateTimeCandidates( + value: string, + timeZone: string, +): number[] { + const desired = parseLocalDateTime(value); + const wallTimestamp = utcTimestamp(desired); + const offsets = new Set(); + for (let hours = -48; hours <= 48; hours += 6) { + const sample = wallTimestamp + hours * 60 * 60 * 1000; + const roundedSample = Math.floor(sample / 1000) * 1000; + offsets.add( + utcTimestamp(fieldsAt(roundedSample, timeZone)) - roundedSample, + ); + } + const candidates = [...offsets] + .map((offset) => wallTimestamp - offset) + .filter( + (candidate) => + candidate >= 0 && sameFields(fieldsAt(candidate, timeZone), desired), + ); + return [...new Set(candidates)].sort((left, right) => left - right); +} + +export function resolveZonedDateTime( + value: string, + timeZone: string, + disambiguation: TimeDisambiguation = "earlier", +): { timestampMs: number; candidates: number[] } { + const candidates = zonedDateTimeCandidates(value, timeZone); + if (!candidates.length) { + throw new RangeError( + "That local time does not exist in this timezone because of a clock change.", + ); + } + return { + timestampMs: + disambiguation === "later" ? candidates.at(-1)! : candidates.at(0)!, + candidates, + }; +} + +function pad(value: number, length = 2): string { + return value.toString().padStart(length, "0"); +} + +export function formatDateTimeLocal( + timestampMs: number, + timeZone: string, +): string { + if (!Number.isFinite(timestampMs) || timestampMs < 0) + throw new RangeError("TOTP timestamp must be non-negative."); + const fields = fieldsAt(timestampMs, timeZone); + return `${pad(fields.year, 4)}-${pad(fields.month)}-${pad(fields.day)}T${pad(fields.hour)}:${pad(fields.minute)}:${pad(fields.second)}`; +} + +export function shiftTimestampByPeriods( + timestampMs: number, + periodSeconds: number, + periodOffset: number, +): number { + if (!Number.isFinite(timestampMs) || timestampMs < 0) + throw new RangeError("TOTP timestamp must be non-negative."); + if ( + !Number.isInteger(periodSeconds) || + periodSeconds < 1 || + periodSeconds > 86_400 + ) + throw new RangeError("TOTP period must be between 1 and 86400 seconds."); + if ( + !Number.isSafeInteger(periodOffset) || + Math.abs(periodOffset) > MAX_TOTP_PERIOD_OFFSET + ) + throw new RangeError( + `Period offset must be between -${MAX_TOTP_PERIOD_OFFSET} and ${MAX_TOTP_PERIOD_OFFSET}.`, + ); + const shifted = timestampMs + periodOffset * periodSeconds * 1000; + if (!Number.isSafeInteger(shifted) || shifted < 0) + throw new RangeError( + "The selected period offset is outside supported time.", + ); + return shifted; +} + +export function supportedTimeZones(): string[] { + const implementation = Intl as typeof Intl & { + supportedValuesOf?: (key: "timeZone") => string[]; + }; + const zones = implementation.supportedValuesOf?.("timeZone") ?? []; + return [...new Set(["UTC", ...zones])].sort((left, right) => + left.localeCompare(right), + ); +} diff --git a/src/styles.css b/src/styles.css index a7ddfd1..f9093b8 100644 --- a/src/styles.css +++ b/src/styles.css @@ -386,6 +386,81 @@ button:disabled { background: var(--auth-blue); transition: width 0.4s linear; } +.time-travel { + display: grid; + gap: 0.75rem; + margin: 0 1rem 1rem; + padding: 0.8rem; + border: 1px solid var(--toolbox-border); + border-radius: calc(var(--toolbox-radius) * 0.75); + background: var(--toolbox-surface-soft); +} +.time-mode { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.25rem; + padding: 0.2rem; + border: 1px solid var(--toolbox-border); + border-radius: calc(var(--toolbox-radius) * 0.6); + background: var(--toolbox-background); +} +.time-mode button { + min-width: 0; + border: 0; + border-radius: calc(var(--toolbox-radius) * 0.45); + padding: 0.45rem 0.35rem; + background: transparent; + color: var(--toolbox-muted); + font-size: 0.72rem; + font-weight: 750; +} +.time-mode button.active { + background: var(--toolbox-surface); + color: var(--toolbox-text); + box-shadow: 0 1px 5px rgb(20 30 60 / 10%); +} +.time-offset-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 0.45rem; + align-items: end; +} +.absolute-time-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.6rem; + align-items: end; +} +.time-readout { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.45rem; + margin: 0; +} +.time-readout div { + min-width: 0; + padding: 0.5rem; + border: 1px solid var(--toolbox-border); + border-radius: calc(var(--toolbox-radius) * 0.5); + background: var(--toolbox-surface); +} +.time-readout dt { + color: var(--toolbox-muted); + font-size: 0.65rem; + font-weight: 750; + letter-spacing: 0.05em; + text-transform: uppercase; +} +.time-readout dd { + margin: 0.18rem 0 0; + overflow-wrap: anywhere; + color: var(--toolbox-text); + font: + 650 0.72rem/1.35 ui-monospace, + SFMono-Regular, + Consolas, + monospace; +} .verify-row { padding: 0 1rem; } @@ -757,6 +832,11 @@ button:disabled { .input-actions input { flex-basis: 100%; } + .time-mode, + .absolute-time-grid, + .time-readout { + grid-template-columns: 1fr; + } .workspace-tabs small { display: none; } diff --git a/tests/components/workbench.test.tsx b/tests/components/workbench.test.tsx index b16b549..ba09828 100644 --- a/tests/components/workbench.test.tsx +++ b/tests/components/workbench.test.tsx @@ -1,4 +1,10 @@ -import { render, screen } from "@testing-library/react"; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it } from "vitest"; import { Workbench } from "../../src/components/Workbench"; @@ -38,4 +44,46 @@ describe("authentication workbench", () => { screen.getByRole("button", { name: "Create test credential" }), ).toBeDisabled(); }); + + it("generates a TOTP at an absolute wall-clock time in an explicit timezone", async () => { + const user = userEvent.setup(); + render(); + + fireEvent.change(screen.getByLabelText("Base32 secret"), { + target: { value: "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" }, + }); + fireEvent.change(screen.getByLabelText("Account"), { + target: { value: "rfc-vector" }, + }); + await user.click(screen.getByRole("button", { name: "Show secret" })); + await waitFor(() => + expect( + (screen.getByLabelText("Provisioning URI") as HTMLTextAreaElement) + .value, + ).toContain("secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"), + ); + await user.selectOptions(screen.getByLabelText("Digits"), "8"); + await user.click(screen.getByRole("button", { name: "Point in time" })); + fireEvent.change(screen.getByLabelText("Local date and time"), { + target: { value: "1970-01-01T00:00:59" }, + }); + fireEvent.change(screen.getByLabelText("IANA timezone"), { + target: { value: "UTC" }, + }); + + expect(screen.getByLabelText("Local date and time")).toHaveValue( + "1970-01-01T00:00:59.000", + ); + expect(screen.getByLabelText("IANA timezone")).toHaveValue("UTC"); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(screen.getByText("1970-01-01T00:00:59.000Z")).toBeInTheDocument(); + expect( + within(screen.getByText("TOTP counter").parentElement!).getByText("1"), + ).toBeInTheDocument(); + await waitFor(() => + expect(document.querySelector(".code-display")).toHaveTextContent( + "942 870 82", + ), + ); + }); }); diff --git a/tests/otp/time.test.ts b/tests/otp/time.test.ts new file mode 100644 index 0000000..a86a08b --- /dev/null +++ b/tests/otp/time.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { + formatDateTimeLocal, + parseLocalDateTime, + resolveZonedDateTime, + shiftTimestampByPeriods, + zonedDateTimeCandidates, +} from "../../src/otp/time"; + +describe("TOTP time travel", () => { + it("parses exact UTC wall-clock values", () => { + const resolved = resolveZonedDateTime("2025-08-19T12:34:56", "UTC"); + expect(new Date(resolved.timestampMs).toISOString()).toBe( + "2025-08-19T12:34:56.000Z", + ); + expect(formatDateTimeLocal(resolved.timestampMs, "UTC")).toBe( + "2025-08-19T12:34:56", + ); + expect( + resolveZonedDateTime("2025-08-19T12:34:56.125", "UTC").timestampMs, + ).toBe(Date.UTC(2025, 7, 19, 12, 34, 56, 125)); + }); + + it("respects seasonal IANA timezone offsets", () => { + expect( + new Date( + resolveZonedDateTime("2024-01-15T12:00:00", "America/New_York") + .timestampMs, + ).toISOString(), + ).toBe("2024-01-15T17:00:00.000Z"); + expect( + new Date( + resolveZonedDateTime("2024-07-15T12:00:00", "America/New_York") + .timestampMs, + ).toISOString(), + ).toBe("2024-07-15T16:00:00.000Z"); + }); + + it("rejects nonexistent DST times and exposes both repeated times", () => { + expect(() => + resolveZonedDateTime("2024-03-10T02:30:00", "America/New_York"), + ).toThrow(/does not exist/iu); + + const candidates = zonedDateTimeCandidates( + "2024-11-03T01:30:00", + "America/New_York", + ); + expect(candidates.map((value) => new Date(value).toISOString())).toEqual([ + "2024-11-03T05:30:00.000Z", + "2024-11-03T06:30:00.000Z", + ]); + expect( + new Date( + resolveZonedDateTime("2024-11-03T01:30:00", "America/New_York", "later") + .timestampMs, + ).toISOString(), + ).toBe("2024-11-03T06:30:00.000Z"); + }); + + it("handles non-hour timezone offsets", () => { + expect( + new Date( + resolveZonedDateTime("2025-08-19T12:00:00", "Asia/Kathmandu") + .timestampMs, + ).toISOString(), + ).toBe("2025-08-19T06:15:00.000Z"); + }); + + it("validates calendar values, timezone names and bounded period shifts", () => { + expect(() => parseLocalDateTime("2025-02-29T12:00:00")).toThrow( + /valid calendar/iu, + ); + expect(() => + resolveZonedDateTime("2025-01-01T00:00:00", "Mars/Base"), + ).toThrow(/timezone/iu); + expect(shiftTimestampByPeriods(90_000, 30, -2)).toBe(30_000); + expect(() => shiftTimestampByPeriods(1_000, 30, -1)).toThrow( + /outside supported time/iu, + ); + }); +});