diff --git a/source/ulid.ts b/source/ulid.ts index a270724..7492783 100644 --- a/source/ulid.ts +++ b/source/ulid.ts @@ -1,6 +1,6 @@ import crypto from "node:crypto"; import { incrementBase32 } from "./crockford.js"; -import { ENCODING, ENCODING_LEN, RANDOM_LEN, TIME_LEN, TIME_MAX } from "./constants.js"; +import { ENCODING, ENCODING_LEN, RANDOM_LEN, TIME_LEN, TIME_MAX, ULID_REGEX } from "./constants.js"; import { ULIDError, ULIDErrorCode } from "./error.js"; import { PRNG, ULID, ULIDFactory } from "./types.js"; import { randomChar } from "./utils.js"; @@ -133,14 +133,9 @@ function inWebWorker(): boolean { * isValid(""); // false */ export function isValid(id: string): boolean { - return ( - typeof id === "string" && - id.length === TIME_LEN + RANDOM_LEN && - id - .toUpperCase() - .split("") - .every(char => ENCODING.indexOf(char) !== -1) - ); + // ULID_REGEX also constrains the leading character to 0-7, so a string whose + // timestamp exceeds the 48-bit maximum (which decodeTime rejects) is invalid. + return typeof id === "string" && ULID_REGEX.test(id); } /** diff --git a/test/node/ulid.spec.ts b/test/node/ulid.spec.ts index 8896506..5d4df71 100644 --- a/test/node/ulid.spec.ts +++ b/test/node/ulid.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { decodeTime, encodeTime, monotonicFactory, ulid, ULIDFactory } from "../../"; +import { decodeTime, encodeTime, isValid, monotonicFactory, ulid, ULIDFactory } from "../../"; const ULID_REXP = /^[0-7][0-9a-hjkmnp-tv-zA-HJKMNP-TV-Z]{25}$/; @@ -85,3 +85,37 @@ describe("ulid", () => { expect(id).toMatch(ULID_REXP); }); }); + +describe("isValid", () => { + it("accepts a generated ULID, in either case", () => { + const id = ulid(); + expect(isValid(id)).toBe(true); + expect(isValid(id.toLowerCase())).toBe(true); + }); + + it("rejects non-strings, the wrong length and out-of-alphabet characters", () => { + expect(isValid("")).toBe(false); + expect(isValid("0".repeat(25))).toBe(false); + expect(isValid("0I" + "0".repeat(24))).toBe(false); // I is not in Crockford base32 + }); + + it("rejects a timestamp larger than the 48-bit maximum", () => { + // The largest valid ULID is 7ZZ…; a leading character above 7 overflows + // the 48-bit timestamp, which decodeTime rejects — so isValid must too. + expect(isValid("8" + "0".repeat(25))).toBe(false); + expect(isValid("Z".repeat(26))).toBe(false); + }); + + it("agrees with decodeTime", () => { + const ids = ["7" + "Z".repeat(25), "8" + "0".repeat(25), "Z".repeat(26)]; + for (const id of ids) { + let decodes = true; + try { + decodeTime(id); + } catch { + decodes = false; + } + expect(isValid(id)).toBe(decodes); + } + }); +});