diff --git a/tools/v1/team/email-ownership-tracker/docs/SAFETY_LIMITS.md b/tools/v1/team/email-ownership-tracker/docs/SAFETY_LIMITS.md new file mode 100644 index 000000000..b78e4deeb --- /dev/null +++ b/tools/v1/team/email-ownership-tracker/docs/SAFETY_LIMITS.md @@ -0,0 +1,54 @@ +# Email Ownership Tracker Safety Limits + +## Threat Assumptions + +Email Ownership Tracker will eventually receive shared-inbox events, ownership +updates, attachment metadata, and team member data from the mail product. Until +an integration issue exists, every external value in this tool folder is treated +as untrusted: + +- event ids and message ids can contain path traversal, URL fragments, HTML, or + whitespace that should never become storage keys. +- owner, actor, and team member emails can contain malformed addresses or CRLF + header-injection payloads. +- owner names, reasons, and tags can contain control characters, HTML-like text, + or oversized strings. +- attachment metadata can be malformed, negative, or inflated to force expensive + accounting. +- ownership histories can contain many stale events from shared mailbox imports. + +## Unsafe Input Handling + +`guards/ownership-boundaries.mjs` provides pure, synchronous helpers that future +services or hooks can call before rendering, storage, notifications, or network +work. The guards: + +- reject unsafe ids before they can be used as keys or paths. +- normalize emails to lowercase and reject CRLF or null-byte payloads. +- allow only known ownership actions: `claim`, `assign`, `transfer`, `release`, + and `escalate`. +- sanitize primitive display text by removing control characters and HTML-like + tags, then truncating to field budgets. +- validate attachment metadata only; this tool must not inspect attachment file + bodies in the isolated folder. +- require pagination for oversized histories and team member lists. + +The guard layer intentionally does not HTML-escape for a specific renderer. Any +future UI must still escape output according to the rendering framework. + +## Performance Limits + +Default budgets are deliberately small enough for a V1 launch tool: + +| Budget | Default | Reason | +| ----------------- | ------: | ------------------------------------------------ | +| Ownership history | 500 | Bounds one-pass current-owner derivation. | +| Team members | 250 | Prevents scanning large directories in one view. | +| Attachments | 30 | Keeps metadata checks cheap. | +| Attachment bytes | 50 MB | Caps aggregate accounting for one event. | +| Reason text | 1,200 | Avoids rendering imported email threads. | +| Tags | 20 | Prevents unbounded filters and chips. | + +Future integration should run these guards before any inbox read, ownership +write, notification fanout, background job, or analytics update. Larger +histories should be paginated and summarized outside the request path. diff --git a/tools/v1/team/email-ownership-tracker/fixtures/hostile-ownership-inputs.json b/tools/v1/team/email-ownership-tracker/fixtures/hostile-ownership-inputs.json new file mode 100644 index 000000000..d630d7012 --- /dev/null +++ b/tools/v1/team/email-ownership-tracker/fixtures/hostile-ownership-inputs.json @@ -0,0 +1,111 @@ +{ + "validEvents": [ + { + "eventId": "evt-001", + "messageId": "msg-2026-001@example.test", + "action": "claim", + "actorEmail": "lead@example.test", + "ownerEmail": "owner@example.test", + "ownerDisplayName": "Case Owner", + "createdAt": "2026-07-01T09:00:00.000Z", + "reason": "Initial ownership claim for a shared inbox message.", + "teamId": "support-team-1", + "tags": ["vip", "billing", "vip"], + "attachments": [ + { + "name": "contract.pdf", + "contentType": "application/pdf", + "sizeBytes": 1024 + } + ] + }, + { + "eventId": "evt-002", + "messageId": "msg-2026-001@example.test", + "action": "release", + "actorEmail": "owner@example.test", + "createdAt": "2026-07-01T10:00:00.000Z", + "reason": "Released after triage." + } + ], + "hostileFields": [ + { + "id": "path-like-event-id", + "field": "eventId", + "value": "../../etc/passwd", + "reason": "reject path traversal in event identifiers" + }, + { + "id": "angle-message-id", + "field": "messageId", + "value": "", + "reason": "reject HTML-like message identifiers" + }, + { + "id": "unsupported-action", + "field": "action", + "value": "delete-everything", + "reason": "reject actions outside the ownership allowlist" + }, + { + "id": "header-injection-actor", + "field": "actorEmail", + "value": "actor@example.test\r\nBcc: victim@example.test", + "reason": "reject CRLF header injection in actor email" + }, + { + "id": "missing-owner", + "field": "ownerEmail", + "value": "", + "reason": "require an owner email for ownership-setting actions" + }, + { + "id": "bad-created-at", + "field": "createdAt", + "value": "not-a-date", + "reason": "reject timestamps that cannot be parsed" + }, + { + "id": "unsafe-tag-shape", + "field": "tags", + "value": ["finance", "../private"], + "reason": "reject unsafe tag characters" + }, + { + "id": "bad-attachment-size", + "field": "attachments", + "value": [ + { + "name": "invoice.pdf", + "contentType": "application/pdf", + "sizeBytes": -10 + } + ], + "reason": "reject negative attachment sizes" + } + ], + "textCases": [ + { + "id": "html-display-name", + "input": "Alice\u0000 Smith", + "expected": "Alice Smith" + }, + { + "id": "long-reason", + "inputLength": 1400, + "expectedLength": 1200 + } + ], + "teamMembers": [ + { + "id": "member-1", + "email": "Lead@Example.Test", + "displayName": "Lead Reviewer" + }, + { + "id": "member-2", + "email": "backup@example.test", + "displayName": "Backup Reviewer" + } + ] +} diff --git a/tools/v1/team/email-ownership-tracker/guards/ownership-boundaries.mjs b/tools/v1/team/email-ownership-tracker/guards/ownership-boundaries.mjs new file mode 100644 index 000000000..79e5c1019 --- /dev/null +++ b/tools/v1/team/email-ownership-tracker/guards/ownership-boundaries.mjs @@ -0,0 +1,351 @@ +const ALLOWED_ACTIONS = new Set(["claim", "assign", "transfer", "release", "escalate"]); + +const LIMITS = { + MAX_EVENT_ID_LENGTH: 128, + MAX_MESSAGE_ID_LENGTH: 160, + MAX_OWNER_EMAIL_LENGTH: 254, + MAX_ACTOR_EMAIL_LENGTH: 254, + MAX_DISPLAY_NAME_LENGTH: 120, + MAX_REASON_LENGTH: 1_200, + MAX_TEAM_ID_LENGTH: 96, + MAX_TAG_COUNT: 20, + MAX_TAG_LENGTH: 48, + MAX_ATTACHMENT_COUNT: 30, + MAX_ATTACHMENT_NAME_LENGTH: 180, + MAX_ATTACHMENT_TYPE_LENGTH: 120, + MAX_ATTACHMENT_BYTES: 50 * 1024 * 1024, + MAX_HISTORY_EVENTS: 500, + MAX_TEAM_MEMBERS: 250, +}; + +const ID_PATTERN = /^[A-Za-z0-9._:@-]+$/; +const EMAIL_PATTERN = /^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/; +const ISO_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/; +const CONTROL_CHARS = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]/g; +const HTML_TAGS = /<[^>]*>/g; + +export class OwnershipBoundaryError extends Error { + constructor(message, field) { + super(message); + this.name = "OwnershipBoundaryError"; + this.field = field; + } +} + +function withLimits(overrides = {}) { + return { ...LIMITS, ...overrides }; +} + +function isPlainObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +export function sanitizeText(value, maxLength, field = "text") { + if (value === undefined || value === null) { + return ""; + } + + if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") { + throw new OwnershipBoundaryError(`${field} must be a primitive value`, field); + } + + let text = String(value).replace(CONTROL_CHARS, "").replace(HTML_TAGS, "").trim(); + text = text.replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n"); + + if (text.length > maxLength) { + text = text.slice(0, maxLength); + } + + return text; +} + +export function validateTrackerId(value, field = "id", maxLength = LIMITS.MAX_EVENT_ID_LENGTH) { + if (typeof value !== "string") { + throw new OwnershipBoundaryError(`${field} must be a string`, field); + } + + const id = value.trim(); + if (id.length === 0) { + throw new OwnershipBoundaryError(`${field} must not be empty`, field); + } + + if (id.length > maxLength) { + throw new OwnershipBoundaryError(`${field} exceeds ${maxLength} characters`, field); + } + + if (id.includes("..") || /[\\/<>#?%&\s]/.test(id) || !ID_PATTERN.test(id)) { + throw new OwnershipBoundaryError(`${field} contains unsafe characters`, field); + } + + return id; +} + +export function normalizeEmail(value, field = "email", maxLength = LIMITS.MAX_OWNER_EMAIL_LENGTH) { + if (typeof value !== "string") { + throw new OwnershipBoundaryError(`${field} must be a string`, field); + } + + const email = value.trim().toLowerCase(); + if (email.length === 0) { + throw new OwnershipBoundaryError(`${field} must not be empty`, field); + } + + if (email.length > maxLength) { + throw new OwnershipBoundaryError(`${field} exceeds ${maxLength} characters`, field); + } + + if (/[\r\n\0]/.test(email) || !EMAIL_PATTERN.test(email)) { + throw new OwnershipBoundaryError(`${field} is not a safe email address`, field); + } + + return email; +} + +export function validateOwnershipAction(value) { + if (typeof value !== "string") { + throw new OwnershipBoundaryError("action must be a string", "action"); + } + + const action = value.trim().toLowerCase(); + if (!ALLOWED_ACTIONS.has(action)) { + throw new OwnershipBoundaryError( + `action must be one of: ${[...ALLOWED_ACTIONS].join(", ")}`, + "action", + ); + } + + return action; +} + +export function normalizeTimestamp(value, field = "createdAt") { + if (typeof value !== "string") { + throw new OwnershipBoundaryError(`${field} must be an ISO timestamp string`, field); + } + + const timestamp = value.trim(); + const parsed = Date.parse(timestamp); + if (!ISO_TIMESTAMP_PATTERN.test(timestamp) || !Number.isFinite(parsed)) { + throw new OwnershipBoundaryError(`${field} must be a valid UTC ISO timestamp`, field); + } + + return new Date(parsed).toISOString(); +} + +export function sanitizeTags(value, limits = LIMITS) { + if (value === undefined || value === null) { + return []; + } + + if (!Array.isArray(value)) { + throw new OwnershipBoundaryError("tags must be an array", "tags"); + } + + if (value.length > limits.MAX_TAG_COUNT) { + throw new OwnershipBoundaryError(`tags exceed safe limit of ${limits.MAX_TAG_COUNT}`, "tags"); + } + + const seen = new Set(); + const tags = []; + + for (const tag of value) { + const clean = sanitizeText(tag, limits.MAX_TAG_LENGTH, "tags").toLowerCase(); + if (clean.length === 0) { + continue; + } + if (/[\\/<>#?%&]/.test(clean)) { + throw new OwnershipBoundaryError("tag contains unsafe characters", "tags"); + } + if (!seen.has(clean)) { + seen.add(clean); + tags.push(clean); + } + } + + return tags; +} + +export function sanitizeAttachmentMetadata(value, options = {}) { + const limits = withLimits(options); + + if (value === undefined || value === null) { + return []; + } + + if (!Array.isArray(value)) { + throw new OwnershipBoundaryError("attachments must be an array", "attachments"); + } + + if (value.length > limits.MAX_ATTACHMENT_COUNT) { + throw new OwnershipBoundaryError( + `attachments exceed safe limit of ${limits.MAX_ATTACHMENT_COUNT}`, + "attachments", + ); + } + + let totalBytes = 0; + return value.map((attachment, index) => { + if (!isPlainObject(attachment)) { + throw new OwnershipBoundaryError(`attachment ${index} must be an object`, "attachments"); + } + + const name = sanitizeText( + attachment.name ?? attachment.filename ?? "", + limits.MAX_ATTACHMENT_NAME_LENGTH, + "attachments.name", + ); + const contentType = sanitizeText( + attachment.contentType ?? attachment.type ?? "application/octet-stream", + limits.MAX_ATTACHMENT_TYPE_LENGTH, + "attachments.contentType", + ).toLowerCase(); + const sizeBytes = Number(attachment.sizeBytes ?? attachment.size ?? 0); + + if (!Number.isInteger(sizeBytes) || sizeBytes < 0) { + throw new OwnershipBoundaryError( + `attachment ${index} has invalid size`, + "attachments.sizeBytes", + ); + } + + totalBytes += sizeBytes; + if (totalBytes > limits.MAX_ATTACHMENT_BYTES) { + throw new OwnershipBoundaryError( + `attachments exceed byte budget of ${limits.MAX_ATTACHMENT_BYTES}`, + "attachments.sizeBytes", + ); + } + + return { name, contentType, sizeBytes }; + }); +} + +export function validateTeamMembers(value, options = {}) { + const limits = withLimits(options); + + if (!Array.isArray(value)) { + throw new OwnershipBoundaryError("teamMembers must be an array", "teamMembers"); + } + + if (value.length > limits.MAX_TEAM_MEMBERS) { + throw new OwnershipBoundaryError( + `teamMembers exceed safe limit of ${limits.MAX_TEAM_MEMBERS}`, + "teamMembers", + ); + } + + return value.map((member, index) => { + if (!isPlainObject(member)) { + throw new OwnershipBoundaryError(`team member ${index} must be an object`, "teamMembers"); + } + + return { + id: validateTrackerId(member.id, "teamMembers.id", limits.MAX_TEAM_ID_LENGTH), + email: normalizeEmail(member.email, "teamMembers.email"), + displayName: sanitizeText( + member.displayName ?? member.name ?? "", + limits.MAX_DISPLAY_NAME_LENGTH, + "teamMembers.displayName", + ), + }; + }); +} + +export function validateOwnershipEvent(value, options = {}) { + const limits = withLimits(options); + + if (!isPlainObject(value)) { + throw new OwnershipBoundaryError("ownership event must be an object", "event"); + } + + const action = validateOwnershipAction(value.action); + const normalized = { + eventId: validateTrackerId(value.eventId ?? value.id, "eventId", limits.MAX_EVENT_ID_LENGTH), + messageId: validateTrackerId(value.messageId, "messageId", limits.MAX_MESSAGE_ID_LENGTH), + action, + actorEmail: normalizeEmail(value.actorEmail, "actorEmail", limits.MAX_ACTOR_EMAIL_LENGTH), + createdAt: normalizeTimestamp(value.createdAt), + ownerEmail: null, + ownerDisplayName: sanitizeText( + value.ownerDisplayName ?? "", + limits.MAX_DISPLAY_NAME_LENGTH, + "ownerDisplayName", + ), + reason: sanitizeText(value.reason ?? "", limits.MAX_REASON_LENGTH, "reason"), + teamId: value.teamId + ? validateTrackerId(value.teamId, "teamId", limits.MAX_TEAM_ID_LENGTH) + : null, + tags: sanitizeTags(value.tags, limits), + attachments: sanitizeAttachmentMetadata(value.attachments, limits), + }; + + if (action !== "release") { + normalized.ownerEmail = normalizeEmail( + value.ownerEmail, + "ownerEmail", + limits.MAX_OWNER_EMAIL_LENGTH, + ); + } else if ( + value.ownerEmail !== undefined && + value.ownerEmail !== null && + value.ownerEmail !== "" + ) { + normalized.ownerEmail = normalizeEmail( + value.ownerEmail, + "ownerEmail", + limits.MAX_OWNER_EMAIL_LENGTH, + ); + } + + return normalized; +} + +export function guardOwnershipHistory(value, options = {}) { + const limits = withLimits(options); + + if (!Array.isArray(value)) { + throw new OwnershipBoundaryError("ownership history must be an array", "history"); + } + + if (value.length > limits.MAX_HISTORY_EVENTS) { + throw new OwnershipBoundaryError( + `ownership history exceeds safe limit of ${limits.MAX_HISTORY_EVENTS}`, + "history", + ); + } + + return true; +} + +export function prepareOwnershipHistory(value, options = {}) { + guardOwnershipHistory(value, options); + return value.map((event) => validateOwnershipEvent(event, options)); +} + +export function deriveCurrentOwners(value, options = {}) { + const events = prepareOwnershipHistory(value, options); + const ownersByMessageId = new Map(); + + for (const event of events) { + if (event.action === "release") { + ownersByMessageId.delete(event.messageId); + continue; + } + + ownersByMessageId.set(event.messageId, { + messageId: event.messageId, + ownerEmail: event.ownerEmail, + ownerDisplayName: event.ownerDisplayName, + action: event.action, + actorEmail: event.actorEmail, + createdAt: event.createdAt, + tags: event.tags, + }); + } + + return { + count: ownersByMessageId.size, + owners: [...ownersByMessageId.values()], + }; +} + +export { ALLOWED_ACTIONS, LIMITS }; diff --git a/tools/v1/team/email-ownership-tracker/tests/ownership-boundaries.test.mjs b/tools/v1/team/email-ownership-tracker/tests/ownership-boundaries.test.mjs new file mode 100644 index 000000000..7390da86a --- /dev/null +++ b/tools/v1/team/email-ownership-tracker/tests/ownership-boundaries.test.mjs @@ -0,0 +1,302 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + LIMITS, + OwnershipBoundaryError, + deriveCurrentOwners, + guardOwnershipHistory, + normalizeEmail, + prepareOwnershipHistory, + sanitizeAttachmentMetadata, + sanitizeTags, + sanitizeText, + validateOwnershipAction, + validateOwnershipEvent, + validateTeamMembers, + validateTrackerId, +} from "../guards/ownership-boundaries.mjs"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const fixture = JSON.parse( + readFileSync(join(__dirname, "..", "fixtures", "hostile-ownership-inputs.json"), "utf8"), +); + +function validEvent(overrides = {}) { + return { + eventId: "evt-valid-001", + messageId: "msg-valid-001@example.test", + action: "claim", + actorEmail: "actor@example.test", + ownerEmail: "owner@example.test", + ownerDisplayName: "Owner Name", + createdAt: "2026-07-01T09:00:00.000Z", + reason: "Claimed for team handling.", + teamId: "support-team", + tags: ["billing"], + attachments: [], + ...overrides, + }; +} + +describe("validateTrackerId", () => { + it("accepts compact ids used as event or message keys", () => { + assert.equal( + validateTrackerId("msg-2026.001@example.test", "messageId"), + "msg-2026.001@example.test", + ); + }); + + it("rejects path traversal and path separators", () => { + assert.throws(() => validateTrackerId("../secret", "eventId"), OwnershipBoundaryError); + assert.throws(() => validateTrackerId("team\\secret", "eventId"), OwnershipBoundaryError); + }); + + it("rejects oversized ids before they can become keys", () => { + assert.throws( + () => validateTrackerId("a".repeat(LIMITS.MAX_EVENT_ID_LENGTH + 1), "eventId"), + OwnershipBoundaryError, + ); + }); +}); + +describe("normalizeEmail", () => { + it("normalizes safe emails", () => { + assert.equal(normalizeEmail(" Owner@Example.Test "), "owner@example.test"); + }); + + it("rejects malformed or injected emails", () => { + assert.throws(() => normalizeEmail("owner.example.test"), OwnershipBoundaryError); + assert.throws( + () => normalizeEmail("owner@example.test\r\nBcc: x@example.test"), + OwnershipBoundaryError, + ); + assert.throws(() => normalizeEmail("owner\0@example.test"), OwnershipBoundaryError); + }); +}); + +describe("validateOwnershipAction", () => { + it("accepts known ownership actions case-insensitively", () => { + assert.equal(validateOwnershipAction("TRANSFER"), "transfer"); + }); + + it("rejects unsupported actions", () => { + assert.throws(() => validateOwnershipAction("delete"), OwnershipBoundaryError); + }); +}); + +describe("sanitizeText", () => { + it("removes control characters and HTML-like tags", () => { + assert.equal(sanitizeText("Alice\0 Smith", 120, "ownerDisplayName"), "Alice Smith"); + }); + + it("rejects object text payloads", () => { + assert.throws(() => sanitizeText({ html: "x" }, 50, "reason"), OwnershipBoundaryError); + }); + + it("truncates long primitive text", () => { + assert.equal(sanitizeText("x".repeat(20), 8, "reason").length, 8); + }); +}); + +describe("sanitizeTags", () => { + it("deduplicates and normalizes tags", () => { + assert.deepEqual(sanitizeTags(["VIP", "vip", "Billing"]), ["vip", "billing"]); + }); + + it("rejects unsafe tag characters and oversized tag arrays", () => { + assert.throws(() => sanitizeTags(["../private"]), OwnershipBoundaryError); + assert.throws( + () => + sanitizeTags( + Array.from({ length: LIMITS.MAX_TAG_COUNT + 1 }, (_, index) => `tag-${index}`), + ), + OwnershipBoundaryError, + ); + }); +}); + +describe("sanitizeAttachmentMetadata", () => { + it("keeps metadata only and normalizes content types", () => { + assert.deepEqual( + sanitizeAttachmentMetadata([{ filename: "Invoice.pdf", type: "APPLICATION/PDF", size: 12 }]), + [{ name: "Invoice.pdf", contentType: "application/pdf", sizeBytes: 12 }], + ); + }); + + it("rejects invalid attachment shapes and negative sizes", () => { + assert.throws(() => sanitizeAttachmentMetadata(["invoice.pdf"]), OwnershipBoundaryError); + assert.throws( + () => sanitizeAttachmentMetadata([{ name: "invoice.pdf", sizeBytes: -1 }]), + OwnershipBoundaryError, + ); + }); + + it("rejects attachment lists over count or byte budgets", () => { + assert.throws( + () => + sanitizeAttachmentMetadata( + new Array(LIMITS.MAX_ATTACHMENT_COUNT + 1).fill({ name: "a", sizeBytes: 1 }), + ), + OwnershipBoundaryError, + ); + assert.throws( + () => + sanitizeAttachmentMetadata([ + { name: "big.bin", sizeBytes: LIMITS.MAX_ATTACHMENT_BYTES + 1 }, + ]), + OwnershipBoundaryError, + ); + }); +}); + +describe("validateTeamMembers", () => { + it("normalizes safe team member records", () => { + assert.deepEqual(validateTeamMembers(fixture.teamMembers), [ + { + id: "member-1", + email: "lead@example.test", + displayName: "Lead Reviewer", + }, + { + id: "member-2", + email: "backup@example.test", + displayName: "Backup Reviewer", + }, + ]); + }); + + it("rejects oversized team directories", () => { + assert.throws( + () => + validateTeamMembers( + Array.from({ length: LIMITS.MAX_TEAM_MEMBERS + 1 }, (_, index) => ({ + id: `member-${index}`, + email: `member-${index}@example.test`, + })), + ), + OwnershipBoundaryError, + ); + }); +}); + +describe("validateOwnershipEvent", () => { + it("normalizes a complete ownership event", () => { + const event = validateOwnershipEvent( + validEvent({ ownerEmail: "Owner@Example.Test", tags: ["VIP", "vip"] }), + ); + assert.equal(event.ownerEmail, "owner@example.test"); + assert.deepEqual(event.tags, ["vip"]); + assert.equal(event.createdAt, "2026-07-01T09:00:00.000Z"); + }); + + it("allows release events without ownerEmail", () => { + const event = validateOwnershipEvent(validEvent({ action: "release", ownerEmail: "" })); + assert.equal(event.action, "release"); + assert.equal(event.ownerEmail, null); + }); + + it("requires ownerEmail for ownership-setting actions", () => { + assert.throws( + () => validateOwnershipEvent(validEvent({ action: "assign", ownerEmail: "" })), + OwnershipBoundaryError, + ); + }); + + it("rejects non-object events and invalid timestamps", () => { + assert.throws(() => validateOwnershipEvent(null), OwnershipBoundaryError); + assert.throws( + () => validateOwnershipEvent(validEvent({ createdAt: "not-a-date" })), + OwnershipBoundaryError, + ); + assert.throws( + () => validateOwnershipEvent(validEvent({ createdAt: "2026-07-01" })), + OwnershipBoundaryError, + ); + }); +}); + +describe("history guards", () => { + it("rejects non-array and oversized histories before mapping events", () => { + assert.throws(() => guardOwnershipHistory(null), OwnershipBoundaryError); + assert.throws( + () => guardOwnershipHistory(new Array(LIMITS.MAX_HISTORY_EVENTS + 1).fill(validEvent())), + OwnershipBoundaryError, + ); + }); + + it("prepares fixture events", () => { + const events = prepareOwnershipHistory(fixture.validEvents); + assert.equal(events.length, 2); + assert.equal(events[0].ownerEmail, "owner@example.test"); + }); + + it("derives current owners in one bounded pass", () => { + const summary = deriveCurrentOwners([ + validEvent({ + eventId: "evt-1", + messageId: "msg-1@example.test", + ownerEmail: "one@example.test", + }), + validEvent({ + eventId: "evt-2", + messageId: "msg-2@example.test", + ownerEmail: "two@example.test", + }), + validEvent({ + eventId: "evt-3", + messageId: "msg-1@example.test", + action: "release", + ownerEmail: "", + }), + ]); + + assert.equal(summary.count, 1); + assert.equal(summary.owners[0].messageId, "msg-2@example.test"); + }); +}); + +describe("fixture hostile fields", () => { + const fieldMutators = { + eventId: (value) => validEvent({ eventId: value }), + messageId: (value) => validEvent({ messageId: value }), + action: (value) => validEvent({ action: value }), + actorEmail: (value) => validEvent({ actorEmail: value }), + ownerEmail: (value) => validEvent({ ownerEmail: value }), + createdAt: (value) => validEvent({ createdAt: value }), + tags: (value) => validEvent({ tags: value }), + attachments: (value) => validEvent({ attachments: value }), + }; + + for (const hostile of fixture.hostileFields) { + it(`${hostile.id}: ${hostile.reason}`, () => { + assert.throws( + () => validateOwnershipEvent(fieldMutators[hostile.field](hostile.value)), + OwnershipBoundaryError, + ); + }); + } +}); + +describe("fixture text cases", () => { + it("sanitizes HTML/control display names", () => { + const htmlCase = fixture.textCases.find((entry) => entry.id === "html-display-name"); + assert.equal( + sanitizeText(htmlCase.input, LIMITS.MAX_DISPLAY_NAME_LENGTH, "ownerDisplayName"), + htmlCase.expected, + ); + }); + + it("truncates long reasons to the documented budget", () => { + const longReason = fixture.textCases.find((entry) => entry.id === "long-reason"); + const output = sanitizeText( + "x".repeat(longReason.inputLength), + LIMITS.MAX_REASON_LENGTH, + "reason", + ); + assert.equal(output.length, longReason.expectedLength); + }); +});