mirror of
https://github.com/bitwarden/clients.git
synced 2026-07-04 21:05:54 +08:00
feat(access-intelligence): expand type guard infrastructure with EnhancedGuard pattern and V2 model validators (#19621)
This commit is contained in:
parent
c73f1307a3
commit
e4dd535e4f
@ -0,0 +1,2 @@
|
||||
// Abstractions
|
||||
export * from "./abstractions/access-report-encryption.service";
|
||||
@ -1 +1,3 @@
|
||||
export * from "./risk-insights-data-mappers";
|
||||
export * from "./type-guards/basic-type-guards";
|
||||
export * from "./type-guards/risk-insights-type-guards";
|
||||
|
||||
@ -0,0 +1,199 @@
|
||||
# Type Guards
|
||||
|
||||
**Purpose:** Reference guide for the type guard infrastructure used to validate decrypted Access Intelligence report data.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
All data arriving from decrypted blobs must pass through these guards before use.
|
||||
Two categories of building blocks work together:
|
||||
|
||||
| File | What it contains |
|
||||
| ------------------------------ | ------------------------------------------------------- |
|
||||
| `basic-type-guards.ts` | Primitive guards and factory functions |
|
||||
| `risk-insights-type-guards.ts` | Access Intelligence validators composed from primitives |
|
||||
|
||||
---
|
||||
|
||||
## Factory Functions
|
||||
|
||||
### `createValidator<T>(validators)`
|
||||
|
||||
Creates a type guard for a **fixed-schema plain object** from a map of per-field guards.
|
||||
Use this for every object validator — never write manual object validators.
|
||||
|
||||
```typescript
|
||||
// ✅ DO — compose via createValidator
|
||||
const isMyModel = createValidator<MyModel>({
|
||||
id: isBoundedString, // required — must be present and non-empty
|
||||
label: isBoundedStringOrNull, // required — must be present, may be null
|
||||
reportedAt: isDateStringOrUndefined, // optional — key may be absent from JSON
|
||||
});
|
||||
|
||||
// ❌ DON'T — write manual field-by-field checks
|
||||
function isMyModel(value: unknown): value is MyModel {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const obj = value as Record<string, unknown>;
|
||||
return isBoundedString(obj["id"]) && ...;
|
||||
}
|
||||
```
|
||||
|
||||
**Built-in protections:**
|
||||
|
||||
- Rejects non-plain-objects (arrays, class instances, `null`)
|
||||
- Blocks prototype pollution (`__proto__`, `constructor`, `prototype`)
|
||||
- Absent keys pass `undefined` to the guard — required guards reject it naturally;
|
||||
optional guards (e.g. `isBoundedStringOrUndefined`) accept it
|
||||
|
||||
#### `.explain()` — Diagnostic Mode
|
||||
|
||||
Every validator returned by `createValidator` exposes an `.explain(value)` method.
|
||||
It returns `string[]` — empty if validation passes, otherwise one message per failing field.
|
||||
|
||||
Use `.explain()` only in error messages and logging. Never use it in hot data paths —
|
||||
it iterates all fields even after the boolean guard has already returned `false`.
|
||||
|
||||
```typescript
|
||||
const errors = isMyModel.explain(suspectValue);
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Validation failed:\n ${errors.join("\n ")}`);
|
||||
}
|
||||
```
|
||||
|
||||
**Error message format:**
|
||||
|
||||
| Situation | Example message |
|
||||
| ---------------------------- | ---------------------------------------------------------------------- |
|
||||
| Not a plain object | `"expected plain object, got Array(3)"` |
|
||||
| Class instance or null-proto | `"rejected: non-plain object (class instance or Object.create(null))"` |
|
||||
| Prototype pollution attempt | `"rejected: prototype pollution attempt via key \"__proto__\""` |
|
||||
| Field fails its guard | `"field 'email': guard isBoundedString failed — got null"` |
|
||||
| Required field absent | `"field 'id': guard isBoundedString failed — got undefined"` |
|
||||
|
||||
**Security note:** Messages describe types only (`"null"`, `"string"`, `"Array(3)"`) —
|
||||
never actual values. Safe for production logs (no PII or vault data exposed).
|
||||
|
||||
**Constraint:** `.explain()` reports top-level field failures only. It does not recurse into
|
||||
why a nested guard (e.g. `isMemberDetailsArray`) failed element-by-element.
|
||||
|
||||
---
|
||||
|
||||
### `createBoundedArrayGuard<T>(isType)`
|
||||
|
||||
Creates a type guard for an array of `T`, bounded to `BOUNDED_ARRAY_MAX_LENGTH` (50 000 items).
|
||||
|
||||
```typescript
|
||||
const isMyModelArray = createBoundedArrayGuard(isMyModel);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Primitive Guards
|
||||
|
||||
Primitives are `(value: unknown) => value is T` functions that test a single value.
|
||||
They are the building blocks passed to `createValidator`.
|
||||
|
||||
### Choosing the Right Guard for a Field
|
||||
|
||||
| TypeScript field type | Guard to use |
|
||||
| ------------------------------------------ | ---------------------------- |
|
||||
| `string` (required) | `isBoundedString` |
|
||||
| `string \| null` | `isBoundedStringOrNull` |
|
||||
| `string \| undefined` (optional field) | `isBoundedStringOrUndefined` |
|
||||
| `number` (count, non-negative) | `isBoundedPositiveNumber` |
|
||||
| `boolean` | `isBoolean` |
|
||||
| `Record<string, boolean>` | `isBooleanRecord` |
|
||||
| `Date` | `isDate` |
|
||||
| `Date \| null` | `isDateOrNull` |
|
||||
| `string` (ISO date, required) | `isDateString` |
|
||||
| `string \| null` (ISO date, nullable) | `isDateStringOrNull` |
|
||||
| `string \| undefined` (ISO date, optional) | `isDateStringOrUndefined` |
|
||||
|
||||
> ⚠️ **Optional vs. nullable:** Use `*OrUndefined` guards for fields that may be absent from
|
||||
> JSON-parsed objects (`JSON.stringify` drops `undefined` values, so the key won't exist after
|
||||
> `JSON.parse`). Use `*OrNull` guards for fields that are always present but may be `null`.
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Validator
|
||||
|
||||
### Step 1 — Do you need a new primitive?
|
||||
|
||||
If the field type is not covered by the table above, add it to `basic-type-guards.ts`.
|
||||
Keep each primitive focused on one type.
|
||||
|
||||
```typescript
|
||||
// basic-type-guards.ts
|
||||
export function isMyNewType(value: unknown): value is MyNewType {
|
||||
// single-type check only
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2 — Compose the object validator
|
||||
|
||||
```typescript
|
||||
// risk-insights-type-guards.ts
|
||||
const isMyModel = createValidator<MyModel>({
|
||||
id: isBoundedString,
|
||||
count: isBoundedPositiveNumber,
|
||||
reportedAt: isDateStringOrUndefined,
|
||||
});
|
||||
const isMyModelArray = createBoundedArrayGuard(isMyModel);
|
||||
```
|
||||
|
||||
### Step 3 — Add a validate function for blob entry points
|
||||
|
||||
Validate functions are called at the decryption boundary and throw on invalid data.
|
||||
|
||||
```typescript
|
||||
export function validateMyModelArray(data: unknown): MyModel[] {
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error("Invalid data: expected array, received non-array");
|
||||
}
|
||||
const invalidItems = data
|
||||
.map((item, index) => ({ item, index }))
|
||||
.filter(({ item }) => !isMyModel(item));
|
||||
if (invalidItems.length > 0) {
|
||||
const elementMessages = invalidItems.map(({ item, index }) => {
|
||||
const fieldErrors = isMyModel.explain(item).join("; ");
|
||||
return ` element[${index}]: ${fieldErrors}`;
|
||||
});
|
||||
throw new Error(
|
||||
`Invalid data: ${invalidItems.length} invalid element(s)\n` + elementMessages.join("\n"),
|
||||
);
|
||||
}
|
||||
if (!isMyModelArray(data)) {
|
||||
throw new Error("Invalid data");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## When Manual Validation Is Acceptable
|
||||
|
||||
`createValidator` handles fixed-schema objects. Two cases require hand-written logic:
|
||||
|
||||
1. **Dynamic-key dictionaries** (e.g. `Record<string, SomeType>`) — use a manual loop:
|
||||
|
||||
```typescript
|
||||
// validateAccessReportPayload does this for memberRegistry
|
||||
for (const [key, entry] of Object.entries(registry)) {
|
||||
if (!isBoundedString(key) || !isMemberRegistryEntryData(entry)) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
2. **Custom primitive guards** for field types not in `basic-type-guards.ts` (e.g. `isCipherId`,
|
||||
`isValidDateOrNull`) — these are single-value primitives, not object validators.
|
||||
|
||||
> **Rule:** Never write a manual multi-field object validator. If `createValidator` can't
|
||||
> express the validation, add the missing primitive to `basic-type-guards.ts` and compose.
|
||||
> If the shape is truly dynamic, use a manual loop with existing primitives.
|
||||
|
||||
---
|
||||
|
||||
**Document Version:** 1.0
|
||||
**Last Updated:** 2026-03-06
|
||||
**Maintainer:** DIRT Team
|
||||
@ -0,0 +1,337 @@
|
||||
import {
|
||||
createBoundedArrayGuard,
|
||||
createValidator,
|
||||
isBoolean,
|
||||
isBooleanRecord,
|
||||
isBoundedArray,
|
||||
isBoundedPositiveNumber,
|
||||
isBoundedString,
|
||||
isBoundedStringOrNull,
|
||||
isBoundedStringOrUndefined,
|
||||
isDate,
|
||||
isDateOrNull,
|
||||
isDateString,
|
||||
isDateStringOrNull,
|
||||
isDateStringOrUndefined,
|
||||
BOUNDED_ARRAY_MAX_LENGTH,
|
||||
BOUNDED_STRING_MAX_LENGTH,
|
||||
} from "./basic-type-guards";
|
||||
|
||||
describe("basic-type-guards", () => {
|
||||
describe("isBoolean", () => {
|
||||
it("should return true for true", () => expect(isBoolean(true)).toBe(true));
|
||||
it("should return true for false", () => expect(isBoolean(false)).toBe(true));
|
||||
it("should return false for 1", () => expect(isBoolean(1)).toBe(false));
|
||||
it("should return false for string", () => expect(isBoolean("true")).toBe(false));
|
||||
it("should return false for null", () => expect(isBoolean(null)).toBe(false));
|
||||
});
|
||||
|
||||
describe("isBoundedPositiveNumber", () => {
|
||||
it("should return true for 0", () => expect(isBoundedPositiveNumber(0)).toBe(true));
|
||||
it("should return true for positive integer", () =>
|
||||
expect(isBoundedPositiveNumber(42)).toBe(true));
|
||||
it("should return false for negative number", () =>
|
||||
expect(isBoundedPositiveNumber(-1)).toBe(false));
|
||||
it("should return false for NaN", () => expect(isBoundedPositiveNumber(NaN)).toBe(false));
|
||||
it("should return false for Infinity", () =>
|
||||
expect(isBoundedPositiveNumber(Infinity)).toBe(false));
|
||||
it("should return false for float", () => expect(isBoundedPositiveNumber(1.5)).toBe(false));
|
||||
it("should return false for string", () => expect(isBoundedPositiveNumber("5")).toBe(false));
|
||||
it("should return false for number exceeding max", () => {
|
||||
expect(isBoundedPositiveNumber(10_000_001)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isBoundedString", () => {
|
||||
it("should return true for a normal string", () => expect(isBoundedString("hello")).toBe(true));
|
||||
it("should return false for empty string", () => expect(isBoundedString("")).toBe(false));
|
||||
it("should return false for null", () => expect(isBoundedString(null)).toBe(false));
|
||||
it("should return false for undefined", () => expect(isBoundedString(undefined)).toBe(false));
|
||||
it("should return false for number", () => expect(isBoundedString(42)).toBe(false));
|
||||
it("should return false for string exceeding max length", () => {
|
||||
expect(isBoundedString("a".repeat(BOUNDED_STRING_MAX_LENGTH + 1))).toBe(false);
|
||||
});
|
||||
it("should return true for string at max length", () => {
|
||||
expect(isBoundedString("a".repeat(BOUNDED_STRING_MAX_LENGTH))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isBoundedStringOrNull", () => {
|
||||
it("should return true for valid string", () =>
|
||||
expect(isBoundedStringOrNull("hello")).toBe(true));
|
||||
it("should return true for null", () => expect(isBoundedStringOrNull(null)).toBe(true));
|
||||
it("should return true for undefined (null-ish)", () =>
|
||||
expect(isBoundedStringOrNull(undefined)).toBe(true));
|
||||
it("should return false for empty string", () => expect(isBoundedStringOrNull("")).toBe(false));
|
||||
it("should return false for number", () => expect(isBoundedStringOrNull(42)).toBe(false));
|
||||
});
|
||||
|
||||
describe("isBoundedStringOrUndefined", () => {
|
||||
it("should return true for valid string", () =>
|
||||
expect(isBoundedStringOrUndefined("hello")).toBe(true));
|
||||
it("should return true for undefined", () =>
|
||||
expect(isBoundedStringOrUndefined(undefined)).toBe(true));
|
||||
it("should return false for null", () => expect(isBoundedStringOrUndefined(null)).toBe(false));
|
||||
it("should return false for empty string", () =>
|
||||
expect(isBoundedStringOrUndefined("")).toBe(false));
|
||||
it("should return false for number", () => expect(isBoundedStringOrUndefined(42)).toBe(false));
|
||||
});
|
||||
|
||||
describe("isBooleanRecord", () => {
|
||||
it("should return true for valid Record<string, boolean>", () => {
|
||||
expect(isBooleanRecord({ a: true, b: false })).toBe(true);
|
||||
});
|
||||
it("should return true for empty object", () => expect(isBooleanRecord({})).toBe(true));
|
||||
it("should return false for object with non-boolean values", () => {
|
||||
expect(isBooleanRecord({ a: "true" })).toBe(false);
|
||||
});
|
||||
it("should return false for array", () => expect(isBooleanRecord([true])).toBe(false));
|
||||
it("should return false for null", () => expect(isBooleanRecord(null)).toBe(false));
|
||||
});
|
||||
|
||||
describe("isDate", () => {
|
||||
it("should return true for a valid Date", () => expect(isDate(new Date())).toBe(true));
|
||||
it("should return false for an invalid Date", () =>
|
||||
expect(isDate(new Date("invalid"))).toBe(false));
|
||||
it("should return false for a date string", () => expect(isDate("2024-01-01")).toBe(false));
|
||||
it("should return false for null", () => expect(isDate(null)).toBe(false));
|
||||
});
|
||||
|
||||
describe("isDateOrNull", () => {
|
||||
it("should return true for a valid Date", () => expect(isDateOrNull(new Date())).toBe(true));
|
||||
it("should return true for null", () => expect(isDateOrNull(null)).toBe(true));
|
||||
it("should return false for undefined", () => expect(isDateOrNull(undefined)).toBe(false));
|
||||
it("should return false for a date string", () =>
|
||||
expect(isDateOrNull("2024-01-01")).toBe(false));
|
||||
});
|
||||
|
||||
describe("isDateString", () => {
|
||||
it("should return true for ISO date string", () => {
|
||||
expect(isDateString("2024-01-15T10:30:00.000Z")).toBe(true);
|
||||
});
|
||||
it("should return true for simple date string", () => {
|
||||
expect(isDateString("2024-01-01")).toBe(true);
|
||||
});
|
||||
it("should return false for invalid date string", () => {
|
||||
expect(isDateString("not-a-date")).toBe(false);
|
||||
});
|
||||
it("should return false for null", () => expect(isDateString(null)).toBe(false));
|
||||
it("should return false for Date object", () => expect(isDateString(new Date())).toBe(false));
|
||||
});
|
||||
|
||||
describe("isDateStringOrNull", () => {
|
||||
it("should return true for valid date string", () => {
|
||||
expect(isDateStringOrNull("2024-01-01")).toBe(true);
|
||||
});
|
||||
it("should return true for null", () => expect(isDateStringOrNull(null)).toBe(true));
|
||||
it("should return false for undefined", () =>
|
||||
expect(isDateStringOrNull(undefined)).toBe(false));
|
||||
it("should return false for invalid string", () => {
|
||||
expect(isDateStringOrNull("not-a-date")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDateStringOrUndefined", () => {
|
||||
it("should return true for valid date string", () => {
|
||||
expect(isDateStringOrUndefined("2024-01-15T10:30:00.000Z")).toBe(true);
|
||||
});
|
||||
it("should return true for undefined", () => {
|
||||
expect(isDateStringOrUndefined(undefined)).toBe(true);
|
||||
});
|
||||
it("should return false for null", () => expect(isDateStringOrUndefined(null)).toBe(false));
|
||||
it("should return false for invalid string", () => {
|
||||
expect(isDateStringOrUndefined("not-a-date")).toBe(false);
|
||||
});
|
||||
it("should return false for Date object", () => {
|
||||
expect(isDateStringOrUndefined(new Date())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isBoundedArray", () => {
|
||||
it("should return true for an empty array", () => expect(isBoundedArray([])).toBe(true));
|
||||
it("should return true for a small array", () => expect(isBoundedArray([1, 2, 3])).toBe(true));
|
||||
it("should return false for a non-array", () => expect(isBoundedArray("array")).toBe(false));
|
||||
it("should return false for array exceeding max length", () => {
|
||||
const oversized = new Array(BOUNDED_ARRAY_MAX_LENGTH + 1);
|
||||
expect(isBoundedArray(oversized)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createBoundedArrayGuard", () => {
|
||||
const isNumberArray = createBoundedArrayGuard(
|
||||
(v: unknown): v is number => typeof v === "number",
|
||||
);
|
||||
|
||||
it("should return true for an array of valid items", () => {
|
||||
expect(isNumberArray([1, 2, 3])).toBe(true);
|
||||
});
|
||||
it("should return true for an empty array", () => expect(isNumberArray([])).toBe(true));
|
||||
it("should return false if any item fails the guard", () => {
|
||||
expect(isNumberArray([1, "two", 3])).toBe(false);
|
||||
});
|
||||
it("should return false for a non-array", () => expect(isNumberArray("not-array")).toBe(false));
|
||||
});
|
||||
|
||||
describe("createValidator", () => {
|
||||
interface TestModel {
|
||||
id: string;
|
||||
label: string | null;
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
const isTestModel = createValidator<TestModel>({
|
||||
id: isBoundedString,
|
||||
label: isBoundedStringOrNull,
|
||||
tag: isBoundedStringOrUndefined,
|
||||
});
|
||||
|
||||
it("should return true for a valid object with all fields present", () => {
|
||||
expect(isTestModel({ id: "abc", label: "name", tag: "optional" })).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when optional field key is absent from object", () => {
|
||||
expect(isTestModel({ id: "abc", label: null })).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when required field is missing", () => {
|
||||
expect(isTestModel({ label: "name" })).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when required field fails its guard", () => {
|
||||
expect(isTestModel({ id: "", label: "name" })).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for null", () => expect(isTestModel(null)).toBe(false));
|
||||
it("should return false for array", () => expect(isTestModel([])).toBe(false));
|
||||
it("should return false for string", () => expect(isTestModel("string")).toBe(false));
|
||||
|
||||
it("should return false for class instances (non-plain-object)", () => {
|
||||
class Foo {
|
||||
id = "abc";
|
||||
label = "name";
|
||||
}
|
||||
expect(isTestModel(new Foo())).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for prototype pollution via __proto__", () => {
|
||||
const obj = { id: "abc", label: "name", __proto__: { polluted: true } };
|
||||
expect(isTestModel(obj)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for prototype pollution via constructor property", () => {
|
||||
const obj = Object.create(null) as Record<string, unknown>;
|
||||
obj["id"] = "abc";
|
||||
obj["label"] = "name";
|
||||
// Object.create(null) has no prototype — getPrototypeOf returns null ≠ Object.prototype
|
||||
expect(isTestModel(obj)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createValidator — EnhancedGuard (.explain())", () => {
|
||||
interface TestModel {
|
||||
id: string;
|
||||
label: string | null;
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
const isTestModel = createValidator<TestModel>({
|
||||
id: isBoundedString,
|
||||
label: isBoundedStringOrNull,
|
||||
tag: isBoundedStringOrUndefined,
|
||||
});
|
||||
|
||||
describe("structural failures", () => {
|
||||
it("should return a structural error for null", () => {
|
||||
const result = isTestModel.explain(null);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toContain("expected plain object");
|
||||
expect(result[0]).toContain("null");
|
||||
});
|
||||
|
||||
it("should return a structural error for an array", () => {
|
||||
const result = isTestModel.explain([]);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toContain("expected plain object");
|
||||
expect(result[0]).toContain("Array(0)");
|
||||
});
|
||||
|
||||
it("should return a structural error for a string", () => {
|
||||
const result = isTestModel.explain("hello");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toContain("expected plain object");
|
||||
expect(result[0]).toContain("string");
|
||||
});
|
||||
|
||||
it("should return a prototype pollution error for a class instance", () => {
|
||||
class Foo {
|
||||
id = "abc";
|
||||
label = "name";
|
||||
}
|
||||
const result = isTestModel.explain(new Foo());
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toContain("non-plain object");
|
||||
});
|
||||
|
||||
it("should return a prototype pollution error for Object.create(null)", () => {
|
||||
const obj = Object.create(null) as Record<string, unknown>;
|
||||
obj["id"] = "abc";
|
||||
obj["label"] = "name";
|
||||
const result = isTestModel.explain(obj);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toContain("non-plain object");
|
||||
});
|
||||
|
||||
it("should return a prototype pollution error for __proto__ key", () => {
|
||||
const obj = JSON.parse(
|
||||
'{"id":"abc","label":"name","__proto__":{"polluted":true}}',
|
||||
) as unknown;
|
||||
const result = isTestModel.explain(obj);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toContain("prototype pollution");
|
||||
});
|
||||
});
|
||||
|
||||
describe("field-level failures", () => {
|
||||
it("should return empty array for a valid object", () => {
|
||||
expect(isTestModel.explain({ id: "abc", label: "name", tag: "opt" })).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should return empty array when optional field is absent", () => {
|
||||
expect(isTestModel.explain({ id: "abc", label: null })).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should identify a single failing required field", () => {
|
||||
const result = isTestModel.explain({ id: "", label: "name" });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toContain("field 'id'");
|
||||
expect(result[0]).toContain("isBoundedString");
|
||||
});
|
||||
|
||||
it("should identify multiple failing fields", () => {
|
||||
const result = isTestModel.explain({ id: "", label: 42 });
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.some((m) => m.includes("field 'id'"))).toBe(true);
|
||||
expect(result.some((m) => m.includes("field 'label'"))).toBe(true);
|
||||
});
|
||||
|
||||
it("should report undefined for a missing required field", () => {
|
||||
const result = isTestModel.explain({ label: "name" });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toContain("field 'id'");
|
||||
expect(result[0]).toContain("undefined");
|
||||
});
|
||||
|
||||
it("should include the guard function name in the message", () => {
|
||||
const result = isTestModel.explain({ id: null, label: "name" });
|
||||
expect(result[0]).toContain("isBoundedString");
|
||||
});
|
||||
|
||||
it("should describe the received type, not the actual value", () => {
|
||||
const result = isTestModel.explain({ id: 42, label: "name" });
|
||||
expect(result[0]).toContain("number");
|
||||
// Should NOT include the actual numeric value as a data point
|
||||
expect(result[0]).not.toContain("42");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -7,11 +7,13 @@
|
||||
// Basic types
|
||||
export type BoundedString = string;
|
||||
export type BoundedStringOrNull = BoundedString | null;
|
||||
export type BoundedStringOrUndefined = BoundedString | undefined;
|
||||
export type PositiveSafeNumber = number;
|
||||
export type BoundedArray<T> = T[];
|
||||
export type DateOrNull = Date | null;
|
||||
export type DateString = string;
|
||||
export type DateStringOrNull = DateString | null;
|
||||
export type DateStringOrUndefined = DateString | undefined;
|
||||
|
||||
// Constants
|
||||
/**
|
||||
@ -44,12 +46,32 @@ export function isBoundedStringOrNull(value: unknown): value is BoundedStringOrN
|
||||
return value == null || isBoundedString(value);
|
||||
}
|
||||
|
||||
export function isBoundedStringOrUndefined(value: unknown): value is BoundedStringOrUndefined {
|
||||
return value === undefined || isBoundedString(value);
|
||||
}
|
||||
|
||||
export const isBoundedStringArray = createBoundedArrayGuard(isBoundedString);
|
||||
|
||||
export function isBoundedArray<T>(arr: unknown): arr is BoundedArray<T> {
|
||||
return Array.isArray(arr) && arr.length < BOUNDED_ARRAY_MAX_LENGTH;
|
||||
}
|
||||
|
||||
/**
|
||||
* A type guard to check if a value is a plain object with string keys and boolean values
|
||||
* @param value The value to check
|
||||
* @returns True if the value is a Record<string, boolean>, false otherwise
|
||||
*/
|
||||
export function isBooleanRecord(value: unknown): value is Record<string, boolean> {
|
||||
if (value == null || typeof value !== "object" || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
const entries = Object.entries(value as object);
|
||||
if (entries.length > BOUNDED_ARRAY_MAX_LENGTH) {
|
||||
return false;
|
||||
}
|
||||
return entries.every(([k, v]) => isBoundedString(k) && isBoolean(v));
|
||||
}
|
||||
|
||||
/**
|
||||
* A type guard to check if a value is a valid Date object
|
||||
* @param value The value to check
|
||||
@ -97,6 +119,16 @@ export function isDateStringOrNull(value: unknown): value is DateStringOrNull {
|
||||
return value === null || isDateString(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* A type guard to check if a value is a valid date string or undefined (absent field).
|
||||
* Use this for optional date fields where the key may be absent from JSON-parsed objects.
|
||||
* @param value The value to check
|
||||
* @returns True if the value is undefined or a valid date string, false otherwise
|
||||
*/
|
||||
export function isDateStringOrUndefined(value: unknown): value is DateStringOrUndefined {
|
||||
return value === undefined || isDateString(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* A higher-order function that takes a type guard for T and returns a
|
||||
* new type guard for an array of T.
|
||||
@ -110,33 +142,102 @@ export function createBoundedArrayGuard<T>(isType: (item: unknown) => item is T)
|
||||
type TempObject = Record<PropertyKey, unknown>;
|
||||
|
||||
/**
|
||||
* Describes the type/shape of a value without exposing actual content.
|
||||
* Safe to include in logs — never reveals PII or vault data.
|
||||
*/
|
||||
function describeType(value: unknown): string {
|
||||
if (value === null) {
|
||||
return "null";
|
||||
}
|
||||
if (value === undefined) {
|
||||
return "undefined";
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return `Array(${value.length})`;
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
return "object";
|
||||
}
|
||||
return typeof value; // "string", "number", "boolean"
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks that obj is a plain JSON-parsed object (not null, not a class instance,
|
||||
* not a prototype-polluted object).
|
||||
* Returns null if the structure is valid, or a diagnostic string if not.
|
||||
*/
|
||||
function checkStructure(obj: unknown): string | null {
|
||||
if (typeof obj !== "object" || obj === null || Array.isArray(obj)) {
|
||||
return `expected plain object, got ${describeType(obj)}`;
|
||||
}
|
||||
|
||||
if (Object.getPrototypeOf(obj) !== Object.prototype) {
|
||||
return "rejected: non-plain object (class instance or Object.create(null))";
|
||||
}
|
||||
|
||||
// Prevent dangerous properties that could be used for prototype pollution
|
||||
const dangerousKeys = ["__proto__", "constructor", "prototype"];
|
||||
for (const key of dangerousKeys) {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||
return `rejected: prototype pollution attempt via key "${key}"`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A type guard function with an attached `.explain()` method.
|
||||
* The guard itself behaves identically to a standard type predicate.
|
||||
* `.explain()` returns an array of diagnostic messages describing which fields
|
||||
* failed validation — safe to include in logs (never exposes actual values, only types).
|
||||
*/
|
||||
export type EnhancedGuard<T> = ((obj: unknown) => obj is T) & {
|
||||
explain: (obj: unknown) => string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Factory that creates a type-safe object validator from a map of per-field guards.
|
||||
*
|
||||
* @param validators
|
||||
* @returns
|
||||
* Pass a `{ fieldName: typeGuardFn }` map and receive a single function that validates
|
||||
* a plain JSON-parsed object against all listed fields. The returned validator:
|
||||
* - Rejects non-plain-objects (arrays, class instances, null)
|
||||
* - Blocks prototype pollution via `__proto__` / `constructor` / `prototype`
|
||||
* - For each field in the map: if the field is **absent** from the object, passes
|
||||
* `undefined` to the guard — required fields fail naturally (e.g. `isBoundedString(undefined)
|
||||
* → false`), optional fields pass if their guard accepts `undefined`
|
||||
* (e.g. `isDateStringOrUndefined(undefined) → true`)
|
||||
*
|
||||
* The returned `EnhancedGuard<T>` also exposes an `.explain(value)` method that returns
|
||||
* field-level diagnostic messages (which fields failed and what type was received).
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // All required fields — key must be present and pass its guard
|
||||
* const isMemberEntry = createValidator<MemberRegistryEntryData>({
|
||||
* id: isBoundedString,
|
||||
* userName: isBoundedStringOrNull, // present, but may be null
|
||||
* email: isBoundedString,
|
||||
* });
|
||||
*
|
||||
* // Mixed required and optional fields — optional fields use an *OrUndefined guard
|
||||
* const isReportEntry = createValidator<ApplicationHealthData>({
|
||||
* applicationName: isBoundedString, // required
|
||||
* memberRefs: isBooleanRecord, // required
|
||||
* iconUri: isBoundedStringOrUndefined, // optional — key may be absent
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createValidator<T>(validators: {
|
||||
[K in keyof T]: (value: unknown) => value is T[K];
|
||||
}): (obj: unknown) => obj is T {
|
||||
}): EnhancedGuard<T> {
|
||||
const keys = Object.keys(validators) as (keyof T)[];
|
||||
|
||||
return function (obj: unknown): obj is T {
|
||||
if (typeof obj !== "object" || obj === null) {
|
||||
const guard = function (obj: unknown): obj is T {
|
||||
if (checkStructure(obj) !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Object.getPrototypeOf(obj) !== Object.prototype) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Prevent dangerous properties that could be used for prototype pollution
|
||||
// Check for __proto__, constructor, and prototype as own properties
|
||||
const dangerousKeys = ["__proto__", "constructor", "prototype"];
|
||||
for (const key of dangerousKeys) {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Type cast to TempObject for key checks
|
||||
const tempObj = obj as TempObject;
|
||||
|
||||
@ -149,14 +250,109 @@ export function createValidator<T>(validators: {
|
||||
// return false;
|
||||
// }
|
||||
|
||||
// Check for each property's existence and type
|
||||
// For each field in the validator map, pass the value (or `undefined` if absent) to
|
||||
// its guard. Required guards (e.g. isBoundedString) reject undefined naturally;
|
||||
// optional guards (e.g. isBoundedStringOrUndefined) accept it.
|
||||
return keys.every((key) => {
|
||||
// Use 'in' to check for property existence before accessing it
|
||||
if (!(key in tempObj)) {
|
||||
return false;
|
||||
}
|
||||
// Pass the value to its specific validator
|
||||
return validators[key](tempObj[key]);
|
||||
const value = key in tempObj ? tempObj[key] : undefined;
|
||||
return validators[key](value);
|
||||
});
|
||||
};
|
||||
|
||||
guard.explain = function (obj: unknown): string[] {
|
||||
const structuralError = checkStructure(obj);
|
||||
if (structuralError !== null) {
|
||||
return [structuralError];
|
||||
}
|
||||
|
||||
const tempObj = obj as TempObject;
|
||||
const failures: string[] = [];
|
||||
|
||||
for (const key of keys) {
|
||||
const value = key in tempObj ? tempObj[key] : undefined;
|
||||
if (!validators[key](value)) {
|
||||
const nestedExplain = (validators[key] as EnhancedGuard<unknown>).explain;
|
||||
if (typeof nestedExplain === "function") {
|
||||
const nested = nestedExplain(value);
|
||||
failures.push(...nested.map((e) => `field '${String(key)}': ${e}`));
|
||||
} else {
|
||||
failures.push(
|
||||
`field '${String(key)}': guard ${validators[key].name || "(anonymous)"} failed — got ${describeType(value)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return failures;
|
||||
};
|
||||
|
||||
return guard;
|
||||
}
|
||||
|
||||
/**
|
||||
* A higher-order function that takes an EnhancedGuard for T and returns an
|
||||
* EnhancedGuard for T[], with element-level diagnostics in `.explain()`.
|
||||
* Use this instead of createBoundedArrayGuard when you need recursive diagnostics.
|
||||
*/
|
||||
export function createEnhancedBoundedArrayGuard<T>(isType: EnhancedGuard<T>): EnhancedGuard<T[]> {
|
||||
const guard = function (value: unknown): value is T[] {
|
||||
return Array.isArray(value) && value.length <= BOUNDED_ARRAY_MAX_LENGTH && value.every(isType);
|
||||
};
|
||||
guard.explain = function (value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [`expected array, got ${describeType(value)}`];
|
||||
}
|
||||
if (value.length > BOUNDED_ARRAY_MAX_LENGTH) {
|
||||
return [`array length ${value.length} exceeds max ${BOUNDED_ARRAY_MAX_LENGTH}`];
|
||||
}
|
||||
const errors: string[] = [];
|
||||
value.forEach((item, index) => {
|
||||
if (!isType(item)) {
|
||||
const fieldErrors = isType.explain(item);
|
||||
errors.push(...fieldErrors.map((e) => `[${index}]: ${e}`));
|
||||
}
|
||||
});
|
||||
return errors;
|
||||
};
|
||||
return guard;
|
||||
}
|
||||
|
||||
/**
|
||||
* A higher-order function that takes an EnhancedGuard for T and returns an
|
||||
* EnhancedGuard for Record<string, T>, with entry-level diagnostics in `.explain()`.
|
||||
* Use this for dynamic-key dictionaries (e.g. member registries keyed by user GUID).
|
||||
*/
|
||||
export function createBoundedRecordGuard<T>(
|
||||
isValue: EnhancedGuard<T>,
|
||||
): EnhancedGuard<Record<string, T>> {
|
||||
const guard = function (value: unknown): value is Record<string, T> {
|
||||
if (value == null || typeof value !== "object" || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
const entries = Object.entries(value as object);
|
||||
if (entries.length > BOUNDED_ARRAY_MAX_LENGTH) {
|
||||
return false;
|
||||
}
|
||||
return entries.every(([k, v]) => isBoundedString(k) && isValue(v));
|
||||
};
|
||||
guard.explain = function (value: unknown): string[] {
|
||||
if (value == null || typeof value !== "object" || Array.isArray(value)) {
|
||||
return [`expected plain object, got ${describeType(value)}`];
|
||||
}
|
||||
const entries = Object.entries(value as object);
|
||||
if (entries.length > BOUNDED_ARRAY_MAX_LENGTH) {
|
||||
return [`record length ${entries.length} exceeds max ${BOUNDED_ARRAY_MAX_LENGTH}`];
|
||||
}
|
||||
const errors: string[] = [];
|
||||
for (const [k, v] of entries) {
|
||||
if (!isBoundedString(k)) {
|
||||
errors.push(`key: invalid — got ${describeType(k)}`);
|
||||
} else if (!isValue(v)) {
|
||||
const fieldErrors = isValue.explain(v);
|
||||
errors.push(...fieldErrors.map((e) => `["${k}"]: ${e}`));
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
};
|
||||
return guard;
|
||||
}
|
||||
|
||||
@ -3,11 +3,15 @@ import { MemberDetails } from "../../models";
|
||||
import {
|
||||
isApplicationHealthReportDetail,
|
||||
isMemberDetails,
|
||||
isMemberRegistryEntryData,
|
||||
isOrganizationReportApplication,
|
||||
isOrganizationReportSummary,
|
||||
validateApplicationHealthReportDetailArray,
|
||||
validateOrganizationReportApplicationArray,
|
||||
validateOrganizationReportSummary,
|
||||
validateAccessReportSettingsDataArray,
|
||||
validateAccessReportSummaryData,
|
||||
validateAccessReportPayload,
|
||||
} from "./risk-insights-type-guards";
|
||||
|
||||
describe("Risk Insights Type Guards", () => {
|
||||
@ -60,7 +64,7 @@ describe("Risk Insights Type Guards", () => {
|
||||
];
|
||||
|
||||
expect(() => validateApplicationHealthReportDetailArray(invalidData)).toThrow(
|
||||
/Invalid report data: array contains 1 invalid ApplicationHealthReportDetail element\(s\) at indices: 0/,
|
||||
/Invalid report data: array contains 1 invalid ApplicationHealthReportDetail element\(s\)/,
|
||||
);
|
||||
});
|
||||
|
||||
@ -82,7 +86,7 @@ describe("Risk Insights Type Guards", () => {
|
||||
];
|
||||
|
||||
expect(() => validateApplicationHealthReportDetailArray(invalidData)).toThrow(
|
||||
/Invalid report data: array contains 2 invalid ApplicationHealthReportDetail element\(s\) at indices: 0, 2/,
|
||||
/Invalid report data: array contains 2 invalid ApplicationHealthReportDetail element\(s\)/,
|
||||
);
|
||||
});
|
||||
|
||||
@ -227,7 +231,7 @@ describe("Risk Insights Type Guards", () => {
|
||||
];
|
||||
|
||||
expect(() => validateOrganizationReportApplicationArray(invalidData)).toThrow(
|
||||
"Invalid application data: array contains 1 invalid OrganizationReportApplication element(s) at indices: 0",
|
||||
/Invalid application data: array contains 1 invalid OrganizationReportApplication element\(s\)/,
|
||||
);
|
||||
});
|
||||
|
||||
@ -247,7 +251,7 @@ describe("Risk Insights Type Guards", () => {
|
||||
];
|
||||
|
||||
expect(() => validateOrganizationReportApplicationArray(invalidData)).toThrow(
|
||||
/Invalid application data: array contains 1 invalid OrganizationReportApplication element\(s\) at indices: 0/,
|
||||
/Invalid application data: array contains 1 invalid OrganizationReportApplication element\(s\)/,
|
||||
);
|
||||
});
|
||||
|
||||
@ -551,4 +555,324 @@ describe("Risk Insights Type Guards", () => {
|
||||
expect(isOrganizationReportApplication(invalidData)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isMemberRegistryEntryData", () => {
|
||||
it("should return true for valid MemberRegistryEntryData", () => {
|
||||
const validData = { id: "u1", userName: "Alice", email: "alice@example.com" };
|
||||
expect(isMemberRegistryEntryData(validData)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for empty id", () => {
|
||||
const invalidData = { id: "", userName: "Alice", email: "alice@example.com" };
|
||||
expect(isMemberRegistryEntryData(invalidData)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for empty userName (empty string is not a valid non-empty string)", () => {
|
||||
const invalidData = { id: "u1", userName: "", email: "alice@example.com" };
|
||||
expect(isMemberRegistryEntryData(invalidData)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true when userName is absent (userName is optional)", () => {
|
||||
const dataWithoutUserName = { id: "u1", email: "alice@example.com" };
|
||||
expect(isMemberRegistryEntryData(dataWithoutUserName)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when userName is undefined (userName is optional)", () => {
|
||||
const dataWithUndefinedUserName: unknown = {
|
||||
id: "u1",
|
||||
userName: undefined,
|
||||
email: "alice@example.com",
|
||||
};
|
||||
expect(isMemberRegistryEntryData(dataWithUndefinedUserName)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for empty email", () => {
|
||||
const invalidData = { id: "u1", userName: "Alice", email: "" };
|
||||
expect(isMemberRegistryEntryData(invalidData)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for missing field", () => {
|
||||
const invalidData = { id: "u1", userName: "Alice" };
|
||||
expect(isMemberRegistryEntryData(invalidData)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for non-object", () => {
|
||||
expect(isMemberRegistryEntryData("not an object")).toBe(false);
|
||||
expect(isMemberRegistryEntryData(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for prototype pollution attempts", () => {
|
||||
const invalidData = {
|
||||
id: "u1",
|
||||
userName: "Alice",
|
||||
email: "alice@example.com",
|
||||
__proto__: { malicious: true },
|
||||
};
|
||||
expect(isMemberRegistryEntryData(invalidData)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateAccessReportPayload", () => {
|
||||
const validV2Data = {
|
||||
version: 2,
|
||||
reports: [
|
||||
{
|
||||
applicationName: "github.com",
|
||||
passwordCount: 2,
|
||||
atRiskPasswordCount: 1,
|
||||
memberRefs: { u1: true, u2: false },
|
||||
cipherRefs: { c1: true, c2: false },
|
||||
memberCount: 2,
|
||||
atRiskMemberCount: 1,
|
||||
},
|
||||
],
|
||||
memberRegistry: {
|
||||
u1: { id: "u1", userName: "Alice", email: "alice@example.com" },
|
||||
u2: { id: "u2", userName: "Bob", email: "bob@example.com" },
|
||||
},
|
||||
};
|
||||
|
||||
it("should validate valid V2 report data", () => {
|
||||
expect(() => validateAccessReportPayload(validV2Data)).not.toThrow();
|
||||
const result = validateAccessReportPayload(validV2Data);
|
||||
expect(result.reports).toHaveLength(1);
|
||||
expect(Object.keys(result.memberRegistry)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should validate V2 data with empty reports and registry", () => {
|
||||
const emptyV2Data: unknown = { version: 2, reports: [], memberRegistry: {} };
|
||||
expect(() => validateAccessReportPayload(emptyV2Data)).not.toThrow();
|
||||
});
|
||||
|
||||
it("should throw for non-object input", () => {
|
||||
expect(() => validateAccessReportPayload("not an object")).toThrow(
|
||||
/expected object, received non-object/,
|
||||
);
|
||||
expect(() => validateAccessReportPayload(null)).toThrow(
|
||||
/expected object, received non-object/,
|
||||
);
|
||||
expect(() => validateAccessReportPayload([1, 2, 3])).toThrow(
|
||||
/expected object, received non-object/,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw for invalid reports array", () => {
|
||||
const invalidReports = { ...validV2Data, reports: "not an array" };
|
||||
expect(() => validateAccessReportPayload(invalidReports)).toThrow(
|
||||
/reports array failed validation/,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw for missing memberRegistry", () => {
|
||||
const noRegistry: unknown = { version: 2, reports: [] };
|
||||
expect(() => validateAccessReportPayload(noRegistry)).toThrow(
|
||||
/memberRegistry failed validation/,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw for invalid memberRegistry entry", () => {
|
||||
const invalidEntry = {
|
||||
...validV2Data,
|
||||
memberRegistry: {
|
||||
u1: { id: "u1", userName: "Alice" }, // missing email
|
||||
},
|
||||
};
|
||||
expect(() => validateAccessReportPayload(invalidEntry)).toThrow(
|
||||
/memberRegistry failed validation/,
|
||||
);
|
||||
});
|
||||
|
||||
it("should normalize empty userName to undefined for backwards compatibility", () => {
|
||||
const dataWithEmptyUserName: unknown = {
|
||||
version: 2,
|
||||
reports: [],
|
||||
memberRegistry: {
|
||||
u1: { id: "u1", userName: "", email: "alice@example.com" },
|
||||
},
|
||||
};
|
||||
const result = validateAccessReportPayload(dataWithEmptyUserName);
|
||||
expect(result.memberRegistry["u1"].userName).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateAccessReportSummaryData", () => {
|
||||
const validSummary = {
|
||||
totalMemberCount: 10,
|
||||
totalApplicationCount: 5,
|
||||
totalAtRiskMemberCount: 2,
|
||||
totalAtRiskApplicationCount: 1,
|
||||
totalCriticalApplicationCount: 3,
|
||||
totalCriticalMemberCount: 4,
|
||||
totalCriticalAtRiskMemberCount: 1,
|
||||
totalCriticalAtRiskApplicationCount: 1,
|
||||
};
|
||||
|
||||
it("should validate valid summary data", () => {
|
||||
expect(() => validateAccessReportSummaryData(validSummary)).not.toThrow();
|
||||
const result = validateAccessReportSummaryData(validSummary);
|
||||
expect(result.totalMemberCount).toBe(10);
|
||||
expect(result.totalApplicationCount).toBe(5);
|
||||
});
|
||||
|
||||
it("should throw for invalid field types", () => {
|
||||
const invalid = { ...validSummary, totalMemberCount: "10" };
|
||||
expect(() => validateAccessReportSummaryData(invalid)).toThrow(/Invalid report summary/);
|
||||
});
|
||||
|
||||
it("should throw for non-object input", () => {
|
||||
expect(() => validateAccessReportSummaryData(null)).toThrow(/Invalid report summary/);
|
||||
expect(() => validateAccessReportSummaryData("string")).toThrow(/Invalid report summary/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateAccessReportSettingsDataArray", () => {
|
||||
it("should validate valid V2 application data array", () => {
|
||||
const validData = [
|
||||
{ applicationName: "app.com", isCritical: true, reviewedDate: "2024-01-15T10:30:00.000Z" },
|
||||
{ applicationName: "other.com", isCritical: false },
|
||||
];
|
||||
|
||||
expect(() => validateAccessReportSettingsDataArray(validData)).not.toThrow();
|
||||
const result = validateAccessReportSettingsDataArray(validData);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].reviewedDate).toBe("2024-01-15T10:30:00.000Z");
|
||||
expect(result[1].reviewedDate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should accept missing reviewedDate (undefined)", () => {
|
||||
const validData = [{ applicationName: "app.com", isCritical: false }];
|
||||
const result = validateAccessReportSettingsDataArray(validData);
|
||||
expect(result[0].reviewedDate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should throw for non-array input", () => {
|
||||
expect(() => validateAccessReportSettingsDataArray("not an array")).toThrow(
|
||||
"Invalid application data: expected array of AccessReportSettingsData, received non-array",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw for array with invalid elements", () => {
|
||||
const invalidData = [{ applicationName: "app.com" }]; // missing isCritical
|
||||
expect(() => validateAccessReportSettingsDataArray(invalidData)).toThrow(
|
||||
/Invalid application data: array contains 1 invalid AccessReportSettingsData element\(s\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw for null reviewedDate", () => {
|
||||
const invalidData: unknown = [
|
||||
{ applicationName: "app.com", isCritical: true, reviewedDate: null },
|
||||
];
|
||||
expect(() => validateAccessReportSettingsDataArray(invalidData)).toThrow(
|
||||
/Invalid application data/,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw for invalid date string in reviewedDate", () => {
|
||||
const invalidData = [
|
||||
{ applicationName: "app.com", isCritical: true, reviewedDate: "not-a-date" },
|
||||
];
|
||||
expect(() => validateAccessReportSettingsDataArray(invalidData)).toThrow(
|
||||
/Invalid application data/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("field-level diagnostics", () => {
|
||||
describe("validateApplicationHealthReportDetailArray", () => {
|
||||
it("should include element index and field name in error message", () => {
|
||||
const invalidData = [{ applicationName: "Test App" }]; // missing many required fields
|
||||
|
||||
let caught: Error | null = null;
|
||||
try {
|
||||
validateApplicationHealthReportDetailArray(invalidData);
|
||||
} catch (e) {
|
||||
caught = e as Error;
|
||||
}
|
||||
|
||||
expect(caught).not.toBeNull();
|
||||
expect(caught!.message).toMatch(/element\[0\]/);
|
||||
expect(caught!.message).toMatch(/field '/);
|
||||
});
|
||||
|
||||
it("should identify memberDetails field failure when memberDetails has wrong shape", () => {
|
||||
const invalidData = [
|
||||
{
|
||||
applicationName: "Test App",
|
||||
passwordCount: 10,
|
||||
atRiskPasswordCount: 2,
|
||||
atRiskCipherIds: ["cipher-1"],
|
||||
memberCount: 5,
|
||||
atRiskMemberCount: 1,
|
||||
memberDetails: [{ userGuid: "user-1" }] as any, // missing required fields
|
||||
atRiskMemberDetails: [] as MemberDetails[],
|
||||
cipherIds: ["cipher-1"],
|
||||
},
|
||||
];
|
||||
|
||||
let caught: Error | null = null;
|
||||
try {
|
||||
validateApplicationHealthReportDetailArray(invalidData);
|
||||
} catch (e) {
|
||||
caught = e as Error;
|
||||
}
|
||||
|
||||
expect(caught).not.toBeNull();
|
||||
expect(caught!.message).toContain("element[0]");
|
||||
expect(caught!.message).toContain("memberDetails");
|
||||
});
|
||||
|
||||
it("should list each failing element by index, skipping valid ones", () => {
|
||||
const invalidData = [
|
||||
{ applicationName: "App 1" }, // invalid
|
||||
{
|
||||
applicationName: "App 2",
|
||||
passwordCount: 10,
|
||||
atRiskPasswordCount: 2,
|
||||
atRiskCipherIds: ["cipher-1"],
|
||||
memberCount: 5,
|
||||
atRiskMemberCount: 1,
|
||||
memberDetails: [] as MemberDetails[],
|
||||
atRiskMemberDetails: [] as MemberDetails[],
|
||||
cipherIds: ["cipher-1"],
|
||||
}, // valid
|
||||
{ applicationName: "App 3" }, // invalid
|
||||
];
|
||||
|
||||
let caught: Error | null = null;
|
||||
try {
|
||||
validateApplicationHealthReportDetailArray(invalidData);
|
||||
} catch (e) {
|
||||
caught = e as Error;
|
||||
}
|
||||
|
||||
expect(caught).not.toBeNull();
|
||||
expect(caught!.message).toContain("element[0]");
|
||||
expect(caught!.message).toContain("element[2]");
|
||||
expect(caught!.message).not.toContain("element[1]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateAccessReportPayload — memberRegistry diagnostics", () => {
|
||||
it("should include the registry key and failing field name when a registry entry is invalid", () => {
|
||||
const invalidPayload = {
|
||||
version: 2,
|
||||
reports: [] as unknown[],
|
||||
memberRegistry: {
|
||||
u1: { id: "u1", userName: "Alice" }, // missing email
|
||||
},
|
||||
};
|
||||
|
||||
let caught: Error | null = null;
|
||||
try {
|
||||
validateAccessReportPayload(invalidPayload);
|
||||
} catch (e) {
|
||||
caught = e as Error;
|
||||
}
|
||||
|
||||
expect(caught).not.toBeNull();
|
||||
expect(caught!.message).toContain('"u1"');
|
||||
expect(caught!.message).toContain("email");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,5 +1,12 @@
|
||||
import { CipherId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import {
|
||||
MemberRegistryEntryData,
|
||||
AccessReportSettingsData,
|
||||
ApplicationHealthData,
|
||||
AccessReportSummaryData,
|
||||
} from "../../../../access-intelligence/models";
|
||||
import { AccessReportPayload } from "../../../../access-intelligence/services";
|
||||
import {
|
||||
ApplicationHealthReportDetail,
|
||||
MemberDetails,
|
||||
@ -9,17 +16,22 @@ import {
|
||||
|
||||
import {
|
||||
createBoundedArrayGuard,
|
||||
createBoundedRecordGuard,
|
||||
createEnhancedBoundedArrayGuard,
|
||||
createValidator,
|
||||
isBoolean,
|
||||
isBooleanRecord,
|
||||
isBoundedString,
|
||||
isBoundedStringOrNull,
|
||||
isBoundedStringOrUndefined,
|
||||
isBoundedPositiveNumber,
|
||||
BOUNDED_ARRAY_MAX_LENGTH,
|
||||
isDate,
|
||||
isDateString,
|
||||
isDateStringOrUndefined,
|
||||
} from "./basic-type-guards";
|
||||
|
||||
// Risk Insights specific type guards
|
||||
// === Type Guards for Access Intelligence ===
|
||||
|
||||
/**
|
||||
* Type guard to validate MemberDetails structure
|
||||
@ -30,14 +42,27 @@ export const isMemberDetails = createValidator<MemberDetails>({
|
||||
userGuid: isBoundedString,
|
||||
userName: isBoundedStringOrNull,
|
||||
email: isBoundedString,
|
||||
cipherId: isBoundedString,
|
||||
cipherId: isBoundedString, // TODO is isBoundedStringOrNull for backwards compatibility
|
||||
});
|
||||
export const isMemberDetailsArray = createBoundedArrayGuard(isMemberDetails);
|
||||
export const isMemberDetailsArray = createEnhancedBoundedArrayGuard(isMemberDetails);
|
||||
|
||||
/**
|
||||
* Type guard to validate MemberRegistryEntryData structure
|
||||
* Exported for testability
|
||||
* Strict validation: rejects objects with unexpected properties and prototype pollution
|
||||
*/
|
||||
export const isMemberRegistryEntryData = createValidator<MemberRegistryEntryData>({
|
||||
id: isBoundedString,
|
||||
userName: isBoundedStringOrUndefined,
|
||||
email: isBoundedString,
|
||||
});
|
||||
const isMemberRegistry = createBoundedRecordGuard(isMemberRegistryEntryData);
|
||||
|
||||
export function isCipherId(value: unknown): value is CipherId {
|
||||
return value == null || isBoundedString(value);
|
||||
}
|
||||
export const isCipherIdArray = createBoundedArrayGuard(isCipherId);
|
||||
|
||||
/**
|
||||
* Type guard to validate ApplicationHealthReportDetail structure
|
||||
* Exported for testability
|
||||
@ -58,6 +83,19 @@ export const isApplicationHealthReportDetailArray = createBoundedArrayGuard(
|
||||
isApplicationHealthReportDetail,
|
||||
);
|
||||
|
||||
const isApplicationHealthData = createValidator<ApplicationHealthData>({
|
||||
applicationName: isBoundedString,
|
||||
passwordCount: isBoundedPositiveNumber,
|
||||
atRiskPasswordCount: isBoundedPositiveNumber,
|
||||
memberRefs: isBooleanRecord,
|
||||
cipherRefs: isBooleanRecord,
|
||||
memberCount: isBoundedPositiveNumber,
|
||||
atRiskMemberCount: isBoundedPositiveNumber,
|
||||
iconUri: isBoundedStringOrUndefined,
|
||||
iconCipherId: isBoundedStringOrUndefined,
|
||||
});
|
||||
const isApplicationHealthDataArray = createBoundedArrayGuard(isApplicationHealthData);
|
||||
|
||||
/**
|
||||
* Type guard to validate OrganizationReportSummary structure
|
||||
* Exported for testability
|
||||
@ -98,6 +136,8 @@ export const isOrganizationReportApplicationArray = createBoundedArrayGuard(
|
||||
isOrganizationReportApplication,
|
||||
);
|
||||
|
||||
// === Validate Functions ===
|
||||
|
||||
/**
|
||||
* Validates and returns an array of ApplicationHealthReportDetail
|
||||
* @throws Error if validation fails
|
||||
@ -122,9 +162,13 @@ export function validateApplicationHealthReportDetailArray(
|
||||
.filter(({ item }) => !isApplicationHealthReportDetail(item));
|
||||
|
||||
if (invalidItems.length > 0) {
|
||||
const invalidIndices = invalidItems.map(({ index }) => index).join(", ");
|
||||
const elementMessages = invalidItems.map(({ item, index }) => {
|
||||
const fieldErrors = isApplicationHealthReportDetail.explain(item).join("; ");
|
||||
return ` element[${index}]: ${fieldErrors}`;
|
||||
});
|
||||
throw new Error(
|
||||
`Invalid report data: array contains ${invalidItems.length} invalid ApplicationHealthReportDetail element(s) at indices: ${invalidIndices}`,
|
||||
`Invalid report data: array contains ${invalidItems.length} invalid ApplicationHealthReportDetail element(s)\n` +
|
||||
elementMessages.join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
@ -149,6 +193,28 @@ export function validateOrganizationReportSummary(data: unknown): OrganizationRe
|
||||
return data;
|
||||
}
|
||||
|
||||
export const isAccessReportSummaryData = createValidator<AccessReportSummaryData>({
|
||||
totalMemberCount: isBoundedPositiveNumber,
|
||||
totalApplicationCount: isBoundedPositiveNumber,
|
||||
totalAtRiskMemberCount: isBoundedPositiveNumber,
|
||||
totalAtRiskApplicationCount: isBoundedPositiveNumber,
|
||||
totalCriticalApplicationCount: isBoundedPositiveNumber,
|
||||
totalCriticalMemberCount: isBoundedPositiveNumber,
|
||||
totalCriticalAtRiskMemberCount: isBoundedPositiveNumber,
|
||||
totalCriticalAtRiskApplicationCount: isBoundedPositiveNumber,
|
||||
});
|
||||
|
||||
/**
|
||||
* Validates and returns AccessReportSummaryData
|
||||
* @throws Error if validation fails
|
||||
*/
|
||||
export function validateAccessReportSummaryData(data: unknown): AccessReportSummaryData {
|
||||
if (!isAccessReportSummaryData(data)) {
|
||||
throw new Error("Invalid report summary");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and returns an array of OrganizationReportApplication
|
||||
* @throws Error if validation fails
|
||||
@ -173,9 +239,13 @@ export function validateOrganizationReportApplicationArray(
|
||||
.filter(({ item }) => !isOrganizationReportApplication(item));
|
||||
|
||||
if (invalidItems.length > 0) {
|
||||
const invalidIndices = invalidItems.map(({ index }) => index).join(", ");
|
||||
const elementMessages = invalidItems.map(({ item, index }) => {
|
||||
const fieldErrors = isOrganizationReportApplication.explain(item).join("; ");
|
||||
return ` element[${index}]: ${fieldErrors}`;
|
||||
});
|
||||
throw new Error(
|
||||
`Invalid application data: array contains ${invalidItems.length} invalid OrganizationReportApplication element(s) at indices: ${invalidIndices}`,
|
||||
`Invalid application data: array contains ${invalidItems.length} invalid OrganizationReportApplication element(s)\n` +
|
||||
elementMessages.join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
@ -203,3 +273,89 @@ export function validateOrganizationReportApplicationArray(
|
||||
// Convert string dates to Date objects for reviewedDate
|
||||
return mappedData;
|
||||
}
|
||||
|
||||
const isAccessReportSettingsData = createValidator<AccessReportSettingsData>({
|
||||
applicationName: isBoundedString,
|
||||
isCritical: isBoolean,
|
||||
reviewedDate: isDateStringOrUndefined,
|
||||
});
|
||||
export const isAccessReportSettingsDataArray = createBoundedArrayGuard(isAccessReportSettingsData);
|
||||
|
||||
/**
|
||||
* Validates and returns an array of AccessReportSettingsData
|
||||
* @throws Error if validation fails
|
||||
*/
|
||||
export function validateAccessReportSettingsDataArray(data: unknown): AccessReportSettingsData[] {
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error(
|
||||
"Invalid application data: expected array of AccessReportSettingsData, received non-array",
|
||||
);
|
||||
}
|
||||
|
||||
if (data.length > BOUNDED_ARRAY_MAX_LENGTH) {
|
||||
throw new Error(
|
||||
`Invalid application data: array length ${data.length} exceeds maximum allowed length ${BOUNDED_ARRAY_MAX_LENGTH}`,
|
||||
);
|
||||
}
|
||||
|
||||
const invalidItems = data
|
||||
.map((item, index) => ({ item, index }))
|
||||
.filter(({ item }) => !isAccessReportSettingsData(item));
|
||||
|
||||
if (invalidItems.length > 0) {
|
||||
const elementMessages = invalidItems.map(({ item, index }) => {
|
||||
const fieldErrors = isAccessReportSettingsData.explain(item).join("; ");
|
||||
return ` element[${index}]: ${fieldErrors}`;
|
||||
});
|
||||
throw new Error(
|
||||
`Invalid application data: array contains ${invalidItems.length} invalid AccessReportSettingsData element(s)\n` +
|
||||
elementMessages.join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAccessReportSettingsDataArray(data)) {
|
||||
// Throw for type casting return
|
||||
// Should never get here
|
||||
throw new Error("Invalid application data");
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and returns AccessReportPayload
|
||||
* @throws Error if validation fails
|
||||
*/
|
||||
export function validateAccessReportPayload(data: unknown): AccessReportPayload {
|
||||
if (data == null || typeof data !== "object" || Array.isArray(data)) {
|
||||
throw new Error("Invalid report payload: expected object, received non-object");
|
||||
}
|
||||
|
||||
const obj = data as Record<string, unknown>;
|
||||
|
||||
if (!isApplicationHealthDataArray(obj["reports"])) {
|
||||
throw new Error("Invalid report payload: reports array failed validation");
|
||||
}
|
||||
|
||||
// Pre-normalize "" → undefined before validation for backwards compatibility with
|
||||
// blobs that stored empty string. The guard uses isBoundedStringOrUndefined which
|
||||
// rejects "", so normalization must happen before the guard runs.
|
||||
if (typeof obj["memberRegistry"] === "object" && obj["memberRegistry"] !== null) {
|
||||
for (const entry of Object.values(obj["memberRegistry"] as Record<string, unknown>)) {
|
||||
if (
|
||||
typeof entry === "object" &&
|
||||
entry !== null &&
|
||||
(entry as Record<string, unknown>)["userName"] === ""
|
||||
) {
|
||||
(entry as Record<string, unknown>)["userName"] = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isMemberRegistry(obj["memberRegistry"])) {
|
||||
const errors = isMemberRegistry.explain(obj["memberRegistry"]).join("; ");
|
||||
throw new Error(`Invalid report payload: memberRegistry failed validation: ${errors}`);
|
||||
}
|
||||
|
||||
return data as AccessReportPayload;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user