// Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. import 'reflect-metadata'; import { field } from '@cratis/fundamentals'; import { describe, expect, it, vi } from 'vitest'; import type { ChronicleConnection } from '../connection/index.js'; import { pii } from '../compliance/pii.js'; import { subject } from '../compliance/subject.js'; import { MaterializedReadModels } from './MaterializedReadModels.js'; // Decorators are applied as plain function calls (rather than `@decorator` syntax) so these // fixtures don't depend on the test runner's decorator-syntax support. function createMaterializedReadModels(instancesJson: string[], releaseResponse: Record = { HasError: false, Payload: '{}' }) { const release = vi.fn().mockResolvedValue(releaseResponse); const getInstances = vi.fn().mockResolvedValue({ Instances: instancesJson }); const connection = { compliance: { release }, materializedReadModels: { getInstances } } as unknown as ChronicleConnection; const readModels = new MaterializedReadModels('test-store', 'test-namespace', connection); return { readModels, release }; } describe('MaterializedReadModels', () => { describe('when materialized models have undecorated members', () => { class Model { name = ''; count = 0; occurred = new Date(0); } const json = JSON.stringify({ name: 'stored', count: 3, occurred: '2024-02-01T00:00:00.000Z' }); it('should restore members in paged reads', async () => { const { readModels } = createMaterializedReadModels([json]); const [instance] = await readModels.getInstances(Model); expect(instance).toMatchObject({ name: 'stored', count: 3 }); expect(instance.occurred).toEqual(new Date('2024-02-01T00:00:00.000Z')); }); it('should restore members in observed pages', async () => { const connection = { materializedReadModels: { observeInstances: async function* () { yield { Instances: [json] }; } } } as unknown as ChronicleConnection; const readModels = new MaterializedReadModels('store', 'tenant', connection); const response = await readModels.observeInstances(Model)[Symbol.asyncIterator]().next(); expect(response.value?.[0].name).toBe('stored'); expect(response.value?.[0].occurred).toBeInstanceOf(Date); }); }); describe('when a read model has a property decorated with @subject()', () => { class Employee { id = ''; personId = ''; ssn = ''; } field(String)(Employee.prototype, 'id'); field(String)(Employee.prototype, 'personId'); field(String)(Employee.prototype, 'ssn'); pii()(Employee.prototype, 'ssn'); subject()(Employee.prototype, 'personId'); it('should release using the decorated property as the subject', async () => { const json = JSON.stringify({ id: 'employee-1', personId: 'person-42', ssn: '123-45-6789' }); const { readModels, release } = createMaterializedReadModels([json]); await readModels.getInstances(Employee); expect(release).toHaveBeenCalledWith(expect.objectContaining({ Subject: 'person-42' })); }); }); describe('when a read model has no property decorated with @subject() but has an id property', () => { class Customer { id = ''; ssn = ''; } field(String)(Customer.prototype, 'id'); field(String)(Customer.prototype, 'ssn'); pii()(Customer.prototype, 'ssn'); it('should fall back to the id property as the subject, unchanged from today', async () => { const json = JSON.stringify({ id: 'customer-7', ssn: '987-65-4321' }); const { readModels, release } = createMaterializedReadModels([json]); await readModels.getInstances(Customer); expect(release).toHaveBeenCalledWith(expect.objectContaining({ Subject: 'customer-7' })); }); }); describe('when a read model has neither a decorated property nor an id property', () => { class Anonymous { ssn = ''; } field(String)(Anonymous.prototype, 'ssn'); pii()(Anonymous.prototype, 'ssn'); it('should fail closed without a subject', async () => { const json = JSON.stringify({ ssn: '000-00-0000' }); const { readModels, release } = createMaterializedReadModels([json]); await expect(readModels.getInstances(Anonymous)).rejects.toThrow(/subject/); expect(release).not.toHaveBeenCalled(); }); it('should not return the unreleased instance', async () => { const json = JSON.stringify({ ssn: '000-00-0000' }); const { readModels } = createMaterializedReadModels([json]); await expect(readModels.getInstances(Anonymous)).rejects.toThrow(/subject/); }); }); describe('when the compliance service rejects release', () => { class PrivateCustomer { id = ''; ssn = ''; } field(String)(PrivateCustomer.prototype, 'id'); field(String)(PrivateCustomer.prototype, 'ssn'); pii()(PrivateCustomer.prototype, 'ssn'); it('should reject instead of returning encrypted content', async () => { const { readModels } = createMaterializedReadModels( ['{"id":"a","ssn":"ciphertext"}'], { HasError: true, Error: 'denied' }); await expect(readModels.getInstances(PrivateCustomer)).rejects.toThrow('Failed to release PII: denied'); }); }); });