Every user-visible date now goes through web/src/lib/datetime.ts, driven by three settings (Settings > General > Locale): - Language & region: automatic, or any of the 618 locales CLDR has data for, each named in its own language and script (web/src/lib/locales.ts, generated by probing Intl over the subtag space). - Date format: automatic (locale order), 22.11.2025, 22/11/2025, 11/22/2025, or ISO 8601 2025-11-22. - Time format: automatic (locale), 24-hour, or 12-hour. Automatic takes the locale Stalwart has for the account, read best-effort at login via x:Account/get (urn:stalwart:jmap) and passed to the client in the session; servers without the capability, or that deny sysAccountGet to a regular user, fall back to the browser locale. POSIX forms are normalised (de_DE.UTF-8 -> de-DE) and script modifiers kept (sr_RS@latin -> sr-Latn-RS, uz_UZ@cyrillic -> uz-Cyrl-UZ), while dialect/variant/currency modifiers are dropped and a script the locale already implies is not appended. Numerals follow the locale (22.11.2025 renders as Arabic-Indic digits under ar-EG); ISO 8601 is the exception and pins date and clock to Latin digits so one line never mixes digit systems. Rewired: message list and headers, quoted reply headers, calendar (titles, weekday and hour gutters, mini calendar, agenda, popovers, invite cards, free/busy), contacts, files, sessions. No raw toLocale*String date calls are left in web/src. Native <input type="datetime-local"> pickers always follow the browser locale and cannot be restyled by a page, so the out-of-office fields echo the entered instant in the chosen format underneath. Also: month-grid day labels no longer wrap when they hold a date, and the mock server serves x:Account/get (MOCK_LOCALE, default en_US). Closes #1
66 lines
2.8 KiB
TypeScript
66 lines
2.8 KiB
TypeScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { SessionStore } from "./sessions.js";
|
|
import { normalizeLocale } from "./upstream.js";
|
|
import { deriveKey, open, seal, sha256 } from "./crypto.js";
|
|
import { RateLimiter } from "./ratelimit.js";
|
|
import { randomBytes } from "node:crypto";
|
|
|
|
test("seal/open round-trips and rejects wrong key", () => {
|
|
const salt = randomBytes(16);
|
|
const k1 = deriveKey("cookie-secret", "app-secret", salt);
|
|
const k2 = deriveKey("other", "app-secret", salt);
|
|
const ct = seal("hello", k1);
|
|
assert.equal(open(ct, k1), "hello");
|
|
assert.equal(open(ct, k2), null);
|
|
assert.equal(sha256("a"), sha256("a"));
|
|
});
|
|
|
|
test("session store creates, resolves, and refuses tampered cookies", () => {
|
|
const store = new SessionStore("");
|
|
const { cookie, session } = store.create({ username: "[email protected]", password: "p4ss", remember: false, userAgent: "ua", ip: "127.0.0.1" });
|
|
assert.equal(session.username, "[email protected]");
|
|
const live = store.resolve(cookie);
|
|
assert.ok(live);
|
|
assert.equal(live!.authorization, `Basic ${Buffer.from("[email protected]:p4ss").toString("base64")}`);
|
|
assert.equal(store.resolve(cookie + "x"), null);
|
|
assert.equal(store.resolve("nope"), null);
|
|
assert.equal(store.listForUser("[email protected]").length, 1);
|
|
store.destroy(live!.id);
|
|
assert.equal(store.resolve(cookie), null);
|
|
});
|
|
|
|
test("persisted session data does not contain the password", () => {
|
|
const store = new SessionStore("");
|
|
store.create({ username: "u", password: "super-secret-pw", remember: true, userAgent: "", ip: "" });
|
|
const json = JSON.stringify(store.listForUser("u"));
|
|
assert.ok(!json.includes("super-secret-pw"));
|
|
});
|
|
|
|
test("rate limiter blocks after max hits in window", () => {
|
|
const rl = new RateLimiter(3, 60_000);
|
|
assert.equal(rl.check("k"), true);
|
|
assert.equal(rl.check("k"), true);
|
|
assert.equal(rl.check("k"), true);
|
|
assert.equal(rl.check("k"), false);
|
|
assert.ok(rl.retryAfterSeconds("k") > 0);
|
|
rl.reset("k");
|
|
assert.equal(rl.check("k"), true);
|
|
});
|
|
|
|
test("normalizes Stalwart account locales to BCP-47 tags", () => {
|
|
assert.equal(normalizeLocale("de_DE"), "de-DE");
|
|
assert.equal(normalizeLocale("de_DE.UTF-8"), "de-DE");
|
|
assert.equal(normalizeLocale("ca_ES@valencia"), "ca-ES");
|
|
assert.equal(normalizeLocale("sr_RS@latin"), "sr-Latn-RS");
|
|
assert.equal(normalizeLocale("uz_UZ@cyrillic"), "uz-Cyrl-UZ");
|
|
assert.equal(normalizeLocale("ru_RU@cyrillic"), "ru-RU");
|
|
assert.equal(normalizeLocale("en"), "en");
|
|
assert.equal(normalizeLocale("POSIX"), null);
|
|
assert.equal(normalizeLocale("C"), null);
|
|
assert.equal(normalizeLocale(""), null);
|
|
assert.equal(normalizeLocale(undefined), null);
|
|
assert.equal(normalizeLocale({ locale: "de_DE" }), null);
|
|
assert.equal(normalizeLocale("../etc/passwd"), null);
|
|
});
|