feat: add timezone-aware TOTP time travel

This commit is contained in:
2026-08-19 13:00:06 +02:00
parent f1cb2d7151
commit 603559c540
9 changed files with 720 additions and 19 deletions
+49 -1
View File
@@ -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(<Workbench />);
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",
),
);
});
});
+81
View File
@@ -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,
);
});
});