From 2edc7af7e20c362688ff2ee674fa2245205d6de4 Mon Sep 17 00:00:00 2001 From: Will Schurman Date: Tue, 11 Jun 2024 20:50:49 -0700 Subject: [PATCH] BREAKING CHANGE: make using non-enforcing entity loader explicit (#238) --- .../GenericLocalMemoryCacher-full-test.ts | 19 +- ...tchedRedisCacheAdapter-integration-test.ts | 14 +- ...enericRedisCacher-full-integration-test.ts | 14 +- .../PostgresEntityIntegration-test.ts | 9 +- .../entity-example/src/routers/notesRouter.ts | 12 +- .../AuthorizationResultBasedEntityLoader.ts | 379 ++++++++++++++++++ packages/entity/src/EnforcingEntityLoader.ts | 9 +- .../entity/src/EntityAssociationLoader.ts | 25 +- packages/entity/src/EntityLoader.ts | 344 ++-------------- packages/entity/src/EntityMutator.ts | 29 +- .../entity/src/EntitySecondaryCacheLoader.ts | 8 +- .../__tests__/EnforcingEntityLoader-test.ts | 310 ++++++++------ .../__tests__/EntityCommonUseCases-test.ts | 16 +- .../EntityLoader-constructor-test.ts | 6 +- .../entity/src/__tests__/EntityLoader-test.ts | 124 ++++-- .../src/__tests__/EntityMutator-test.ts | 45 ++- .../TwoEntitySameTableDisjointRows-test.ts | 23 +- .../entity/src/utils/EntityPrivacyUtils.ts | 14 +- 18 files changed, 841 insertions(+), 559 deletions(-) create mode 100644 packages/entity/src/AuthorizationResultBasedEntityLoader.ts diff --git a/packages/entity-cache-adapter-local-memory/src/__tests__/GenericLocalMemoryCacher-full-test.ts b/packages/entity-cache-adapter-local-memory/src/__tests__/GenericLocalMemoryCacher-full-test.ts index dd9ec9b46..d79fb8cd3 100644 --- a/packages/entity-cache-adapter-local-memory/src/__tests__/GenericLocalMemoryCacher-full-test.ts +++ b/packages/entity-cache-adapter-local-memory/src/__tests__/GenericLocalMemoryCacher-full-test.ts @@ -63,8 +63,9 @@ describe(GenericLocalMemoryCacher, () => { // simulate non existent db fetch, should write negative result ('') to cache const nonExistentId = uuidv4(); - const entityNonExistentResult = - await LocalMemoryTestEntity.loader(viewerContext).loadByIDAsync(nonExistentId); + const entityNonExistentResult = await LocalMemoryTestEntity.loader(viewerContext) + .withAuthorizationResults() + .loadByIDAsync(nonExistentId); expect(entityNonExistentResult.ok).toBe(false); const nonExistentCachedResult = await entitySpecificGenericCacher.loadManyAsync([ @@ -75,12 +76,15 @@ describe(GenericLocalMemoryCacher, () => { }); // load again through entities framework to ensure it reads negative result - const entityNonExistentResult2 = - await LocalMemoryTestEntity.loader(viewerContext).loadByIDAsync(nonExistentId); + const entityNonExistentResult2 = await LocalMemoryTestEntity.loader(viewerContext) + .withAuthorizationResults() + .loadByIDAsync(nonExistentId); expect(entityNonExistentResult2.ok).toBe(false); // invalidate from cache to ensure it invalidates correctly - await LocalMemoryTestEntity.loader(viewerContext).invalidateFieldsAsync(entity1.getAllFields()); + await LocalMemoryTestEntity.loader(viewerContext) + .withAuthorizationResults() + .invalidateFieldsAsync(entity1.getAllFields()); const cachedResultMiss = await entitySpecificGenericCacher.loadManyAsync([ cacheKeyMaker('id', entity1.getID()), ]); @@ -131,8 +135,9 @@ describe(GenericLocalMemoryCacher, () => { // a non existent db fetch should try to write negative result ('') but it's a noop cache, so it should be a miss const nonExistentId = uuidv4(); - const entityNonExistentResult = - await LocalMemoryTestEntity.loader(viewerContext).loadByIDAsync(nonExistentId); + const entityNonExistentResult = await LocalMemoryTestEntity.loader(viewerContext) + .withAuthorizationResults() + .loadByIDAsync(nonExistentId); expect(entityNonExistentResult.ok).toBe(false); const nonExistentCachedResult = await entitySpecificGenericCacher.loadManyAsync([ diff --git a/packages/entity-cache-adapter-redis/src/__integration-tests__/BatchedRedisCacheAdapter-integration-test.ts b/packages/entity-cache-adapter-redis/src/__integration-tests__/BatchedRedisCacheAdapter-integration-test.ts index 0af2ef4fb..e9d68446e 100644 --- a/packages/entity-cache-adapter-redis/src/__integration-tests__/BatchedRedisCacheAdapter-integration-test.ts +++ b/packages/entity-cache-adapter-redis/src/__integration-tests__/BatchedRedisCacheAdapter-integration-test.ts @@ -145,19 +145,23 @@ describe(GenericRedisCacher, () => { // simulate non existent db fetch, should write negative result ('') to cache const nonExistentId = uuidv4(); - const entityNonExistentResult = - await RedisTestEntity.loader(viewerContext).loadByIDAsync(nonExistentId); + const entityNonExistentResult = await RedisTestEntity.loader(viewerContext) + .withAuthorizationResults() + .loadByIDAsync(nonExistentId); expect(entityNonExistentResult.ok).toBe(false); const cacheKeyNonExistent = cacheKeyMaker('id', nonExistentId); const nonExistentCachedValue = await redis.get(cacheKeyNonExistent); expect(nonExistentCachedValue).toEqual(''); // load again through entities framework to ensure it reads negative result - const entityNonExistentResult2 = - await RedisTestEntity.loader(viewerContext).loadByIDAsync(nonExistentId); + const entityNonExistentResult2 = await RedisTestEntity.loader(viewerContext) + .withAuthorizationResults() + .loadByIDAsync(nonExistentId); expect(entityNonExistentResult2.ok).toBe(false); // invalidate from cache to ensure it invalidates correctly in both caches - await RedisTestEntity.loader(viewerContext).invalidateFieldsAsync(entity1.getAllFields()); + await RedisTestEntity.loader(viewerContext) + .withAuthorizationResults() + .invalidateFieldsAsync(entity1.getAllFields()); await expect(redis.get(cacheKeyEntity1)).resolves.toBeNull(); await expect(redis.get(cacheKeyEntity1NameField)).resolves.toBeNull(); }); diff --git a/packages/entity-cache-adapter-redis/src/__integration-tests__/GenericRedisCacher-full-integration-test.ts b/packages/entity-cache-adapter-redis/src/__integration-tests__/GenericRedisCacher-full-integration-test.ts index 886c6b8a0..24b24e4ce 100644 --- a/packages/entity-cache-adapter-redis/src/__integration-tests__/GenericRedisCacher-full-integration-test.ts +++ b/packages/entity-cache-adapter-redis/src/__integration-tests__/GenericRedisCacher-full-integration-test.ts @@ -67,8 +67,9 @@ describe(GenericRedisCacher, () => { // simulate non existent db fetch, should write negative result ('') to cache const nonExistentId = uuidv4(); - const entityNonExistentResult = - await RedisTestEntity.loader(viewerContext).loadByIDAsync(nonExistentId); + const entityNonExistentResult = await RedisTestEntity.loader(viewerContext) + .withAuthorizationResults() + .loadByIDAsync(nonExistentId); expect(entityNonExistentResult.ok).toBe(false); const nonExistentCachedValue = await (genericRedisCacheContext.redisClient as Redis).get( @@ -77,12 +78,15 @@ describe(GenericRedisCacher, () => { expect(nonExistentCachedValue).toEqual(''); // load again through entities framework to ensure it reads negative result - const entityNonExistentResult2 = - await RedisTestEntity.loader(viewerContext).loadByIDAsync(nonExistentId); + const entityNonExistentResult2 = await RedisTestEntity.loader(viewerContext) + .withAuthorizationResults() + .loadByIDAsync(nonExistentId); expect(entityNonExistentResult2.ok).toBe(false); // invalidate from cache to ensure it invalidates correctly - await RedisTestEntity.loader(viewerContext).invalidateFieldsAsync(entity1.getAllFields()); + await RedisTestEntity.loader(viewerContext) + .withAuthorizationResults() + .invalidateFieldsAsync(entity1.getAllFields()); const cachedValueNull = await (genericRedisCacheContext.redisClient as Redis).get( cacheKeyMaker('id', entity1.getID()), ); diff --git a/packages/entity-database-adapter-knex/src/__integration-tests__/PostgresEntityIntegration-test.ts b/packages/entity-database-adapter-knex/src/__integration-tests__/PostgresEntityIntegration-test.ts index 61c63e0b5..91938f976 100644 --- a/packages/entity-database-adapter-knex/src/__integration-tests__/PostgresEntityIntegration-test.ts +++ b/packages/entity-database-adapter-knex/src/__integration-tests__/PostgresEntityIntegration-test.ts @@ -1,7 +1,6 @@ import { OrderByOrdering, createUnitTestEntityCompanionProvider, - enforceResultsAsync, ViewerContext, TransactionIsolationLevel, } from '@expo/entity'; @@ -92,7 +91,7 @@ describe('postgres entity integration', () => { PostgresTestEntity.creator(vc1).setField('name', 'hello').createAsync(), ); - await enforceAsyncResult(PostgresTestEntity.loader(vc1).loadByIDAsync(firstEntity.getID())); + await PostgresTestEntity.loader(vc1).enforcing().loadByIDAsync(firstEntity.getID()); const errorToThrow = new Error('Intentional error'); @@ -111,9 +110,9 @@ describe('postgres entity integration', () => { ), ).rejects.toEqual(errorToThrow); - const entities = await enforceResultsAsync( - PostgresTestEntity.loader(vc1).loadManyByFieldEqualingAsync('name', 'hello'), - ); + const entities = await PostgresTestEntity.loader(vc1) + .enforcing() + .loadManyByFieldEqualingAsync('name', 'hello'); expect(entities).toHaveLength(1); }); diff --git a/packages/entity-example/src/routers/notesRouter.ts b/packages/entity-example/src/routers/notesRouter.ts index a56537915..f4ea8d51e 100644 --- a/packages/entity-example/src/routers/notesRouter.ts +++ b/packages/entity-example/src/routers/notesRouter.ts @@ -30,7 +30,9 @@ router.get('/', async (ctx) => { router.get('/:id', async (ctx) => { const viewerContext = ctx.state.viewerContext; - const noteResult = await NoteEntity.loader(viewerContext).loadByIDAsync(ctx.params['id']!); + const noteResult = await NoteEntity.loader(viewerContext) + .withAuthorizationResults() + .loadByIDAsync(ctx.params['id']!); if (!noteResult.ok) { ctx.throw(403, noteResult.reason); return; @@ -72,7 +74,9 @@ router.put('/:id', async (ctx) => { const viewerContext = ctx.state.viewerContext; const { title, body } = ctx.request.body as any; - const noteLoadResult = await NoteEntity.loader(viewerContext).loadByIDAsync(ctx.params['id']!); + const noteLoadResult = await NoteEntity.loader(viewerContext) + .withAuthorizationResults() + .loadByIDAsync(ctx.params['id']!); if (!noteLoadResult.ok) { ctx.throw(403, noteLoadResult.reason); return; @@ -95,7 +99,9 @@ router.put('/:id', async (ctx) => { router.delete('/:id', async (ctx) => { const viewerContext = ctx.state.viewerContext; - const noteLoadResult = await NoteEntity.loader(viewerContext).loadByIDAsync(ctx.params['id']!); + const noteLoadResult = await NoteEntity.loader(viewerContext) + .withAuthorizationResults() + .loadByIDAsync(ctx.params['id']!); if (!noteLoadResult.ok) { ctx.throw(403, noteLoadResult.reason); return; diff --git a/packages/entity/src/AuthorizationResultBasedEntityLoader.ts b/packages/entity/src/AuthorizationResultBasedEntityLoader.ts new file mode 100644 index 000000000..6e5f5a7c6 --- /dev/null +++ b/packages/entity/src/AuthorizationResultBasedEntityLoader.ts @@ -0,0 +1,379 @@ +import { Result, asyncResult, result } from '@expo/results'; +import invariant from 'invariant'; +import nullthrows from 'nullthrows'; + +import { IEntityClass } from './Entity'; +import EntityConfiguration from './EntityConfiguration'; +import { + FieldEqualityCondition, + QuerySelectionModifiers, + isSingleValueFieldEqualityCondition, + QuerySelectionModifiersWithOrderByRaw, +} from './EntityDatabaseAdapter'; +import EntityPrivacyPolicy, { EntityPrivacyPolicyEvaluationContext } from './EntityPrivacyPolicy'; +import { EntityQueryContext } from './EntityQueryContext'; +import ReadonlyEntity from './ReadonlyEntity'; +import ViewerContext from './ViewerContext'; +import { pick } from './entityUtils'; +import EntityInvalidFieldValueError from './errors/EntityInvalidFieldValueError'; +import EntityNotFoundError from './errors/EntityNotFoundError'; +import EntityDataManager from './internal/EntityDataManager'; +import IEntityMetricsAdapter from './metrics/IEntityMetricsAdapter'; +import { mapMap, mapMapAsync } from './utils/collections/maps'; + +/** + * Authorization-result-based entity loader. All normal loads are batched, + * cached, and authorized against the entity's EntityPrivacyPolicy. All loads through this + * loader are are results (or null for some loader methods), where an unsuccessful result + * means an authorization error or entity construction error occurred. Other errors are thrown. + */ +export default class AuthorizationResultBasedEntityLoader< + TFields extends object, + TID extends NonNullable, + TViewerContext extends ViewerContext, + TEntity extends ReadonlyEntity, + TPrivacyPolicy extends EntityPrivacyPolicy< + TFields, + TID, + TViewerContext, + TEntity, + TSelectedFields + >, + TSelectedFields extends keyof TFields, +> { + constructor( + private readonly viewerContext: TViewerContext, + private readonly queryContext: EntityQueryContext, + private readonly privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext< + TFields, + TID, + TViewerContext, + TEntity, + TSelectedFields + >, + private readonly entityConfiguration: EntityConfiguration, + private readonly entityClass: IEntityClass< + TFields, + TID, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + >, + private readonly entitySelectedFields: TSelectedFields[] | undefined, + private readonly privacyPolicy: TPrivacyPolicy, + private readonly dataManager: EntityDataManager, + protected readonly metricsAdapter: IEntityMetricsAdapter, + ) {} + + /** + * Load many entities where fieldName is one of fieldValues. + * @param fieldName - entity field being queried + * @param fieldValues - fieldName field values being queried + * @returns map from fieldValue to entity results that match the query for that fieldValue, + * where result errors can be UnauthorizedError + */ + async loadManyByFieldEqualingManyAsync>( + fieldName: N, + fieldValues: readonly NonNullable[], + ): Promise, readonly Result[]>> { + this.validateFieldValues(fieldName, fieldValues); + + const fieldValuesToFieldObjects = await this.dataManager.loadManyByFieldEqualingAsync( + this.queryContext, + fieldName, + fieldValues, + ); + + return await this.constructAndAuthorizeEntitiesAsync(fieldValuesToFieldObjects); + } + + /** + * Load many entities where fieldName equals fieldValue. + * @param fieldName - entity field being queried + * @param fieldValue - fieldName field value being queried + * @returns array of entity results that match the query for fieldValue, where result error can be UnauthorizedError + */ + async loadManyByFieldEqualingAsync>( + fieldName: N, + fieldValue: NonNullable, + ): Promise[]> { + const entityResults = await this.loadManyByFieldEqualingManyAsync(fieldName, [fieldValue]); + const entityResultsForFieldValue = entityResults.get(fieldValue); + invariant( + entityResultsForFieldValue !== undefined, + `${fieldValue} should be guaranteed to be present in returned map of entities`, + ); + return entityResultsForFieldValue!; + } + + /** + * Load an entity where fieldName equals fieldValue, or null if no entity exists. + * @param uniqueFieldName - entity field being queried + * @param fieldValue - uniqueFieldName field value being queried + * @returns entity result where uniqueFieldName equals fieldValue, or null if no entity matches the condition. + * @throws when multiple entities match the condition + */ + async loadByFieldEqualingAsync>( + uniqueFieldName: N, + fieldValue: NonNullable, + ): Promise | null> { + const entityResults = await this.loadManyByFieldEqualingAsync(uniqueFieldName, fieldValue); + invariant( + entityResults.length <= 1, + `loadByFieldEqualing: Multiple entities of type ${this.entityClass.name} found for ${String( + uniqueFieldName, + )}=${fieldValue}`, + ); + return entityResults[0] ?? null; + } + + /** + * Loads an entity by a specified ID. + * @param id - ID of the entity + * @returns entity result for matching ID, where result error can be UnauthorizedError or EntityNotFoundError. + */ + async loadByIDAsync(id: TID): Promise> { + const entityResults = await this.loadManyByIDsAsync([id]); + // loadManyByIDsAsync is always populated for each id supplied + return nullthrows(entityResults.get(id)); + } + + /** + * Load an entity by a specified ID, or return null if non-existent. + * @param id - ID of the entity + * @returns entity result for matching ID, or null if no entity exists for ID. + */ + async loadByIDNullableAsync(id: TID): Promise | null> { + return await this.loadByFieldEqualingAsync( + this.entityConfiguration.idField as TSelectedFields, + id, + ); + } + + /** + * Loads many entities for a list of IDs. + * @param ids - IDs of the entities to load + * @returns map from ID to corresponding entity result, where result error can be UnauthorizedError or EntityNotFoundError. + */ + async loadManyByIDsAsync(ids: readonly TID[]): Promise>> { + const entityResults = (await this.loadManyByFieldEqualingManyAsync( + this.entityConfiguration.idField as TSelectedFields, + ids, + )) as ReadonlyMap[]>; + return mapMap(entityResults, (entityResultsForId, id) => { + const entityResult = entityResultsForId[0]; + return ( + entityResult ?? + result(new EntityNotFoundError(this.entityClass, this.entityConfiguration.idField, id)) + ); + }); + } + + /** + * Loads many entities for a list of IDs, returning null for any IDs that are non-existent. + * @param ids - IDs of the entities to load + * @returns map from ID to nullable corresponding entity result, where result error can be UnauthorizedError or EntityNotFoundError. + */ + async loadManyByIDsNullableAsync( + ids: readonly TID[], + ): Promise | null>> { + const entityResults = (await this.loadManyByFieldEqualingManyAsync( + this.entityConfiguration.idField as TSelectedFields, + ids, + )) as ReadonlyMap[]>; + return mapMap(entityResults, (entityResultsForId) => { + return entityResultsForId[0] ?? null; + }); + } + + /** + * Loads the first entity matching the selection constructed from the conjunction of specified + * operands, or null if no matching entity exists. Entities loaded using this method are not + * batched or cached. + * + * This is a convenience method for {@link loadManyByFieldEqualityConjunctionAsync}. However, the + * `orderBy` option must be specified to define what "first" means. If ordering doesn't matter, + * explicitly pass in an empty array. + * + * @param fieldEqualityOperands - list of field equality selection operand specifications + * @param querySelectionModifiers - orderBy and optional offset for the query + * @returns the first entity results that matches the query, where result error can be + * UnauthorizedError + */ + async loadFirstByFieldEqualityConjunctionAsync>( + fieldEqualityOperands: FieldEqualityCondition[], + querySelectionModifiers: Omit, 'limit'> & + Required, 'orderBy'>>, + ): Promise | null> { + const results = await this.loadManyByFieldEqualityConjunctionAsync(fieldEqualityOperands, { + ...querySelectionModifiers, + limit: 1, + }); + return results[0] ?? null; + } + + /** + * Loads many entities matching the selection constructed from the conjunction of specified operands. + * Entities loaded using this method are not batched or cached. + * + * @example + * fieldEqualityOperands: + * `[{fieldName: 'hello', fieldValue: 1}, {fieldName: 'world', fieldValues: [2, 3]}]` + * Entities returned with a SQL EntityDatabaseAdapter: + * `WHERE hello = 1 AND world = ANY({2, 3})` + * + * @param fieldEqualityOperands - list of field equality selection operand specifications + * @param querySelectionModifiers - limit, offset, and orderBy for the query + * @returns array of entity results that match the query, where result error can be UnauthorizedError + */ + async loadManyByFieldEqualityConjunctionAsync>( + fieldEqualityOperands: FieldEqualityCondition[], + querySelectionModifiers: QuerySelectionModifiers = {}, + ): Promise[]> { + for (const fieldEqualityOperand of fieldEqualityOperands) { + const fieldValues = isSingleValueFieldEqualityCondition(fieldEqualityOperand) + ? [fieldEqualityOperand.fieldValue] + : fieldEqualityOperand.fieldValues; + this.validateFieldValues(fieldEqualityOperand.fieldName, fieldValues); + } + + const fieldObjects = await this.dataManager.loadManyByFieldEqualityConjunctionAsync( + this.queryContext, + fieldEqualityOperands, + querySelectionModifiers, + ); + return await this.constructAndAuthorizeEntitiesArrayAsync(fieldObjects); + } + + /** + * Loads many entities matching the raw WHERE clause. Corresponds to the knex `whereRaw` argument format. + * + * @remarks + * Important notes: + * - Fields in clause are database column names instead of transformed entity field names. + * - Entities loaded using this method are not batched or cached. + * - Not all database adapters implement the ability to execute this method of fetching entities. + * + * @example + * rawWhereClause: `id = ?` + * bindings: `[1]` + * Entites returned `WHERE id = 1` + * + * http://knexjs.org/#Builder-whereRaw + * http://knexjs.org/#Raw-Bindings + * + * @param rawWhereClause - parameterized SQL WHERE clause with positional binding placeholders or named binding placeholders + * @param bindings - array of positional bindings or object of named bindings + * @param querySelectionModifiers - limit, offset, orderBy, and orderByRaw for the query + * @returns array of entity results that match the query, where result error can be UnauthorizedError + * @throws Error when rawWhereClause or bindings are invalid + */ + async loadManyByRawWhereClauseAsync( + rawWhereClause: string, + bindings: any[] | object, + querySelectionModifiers: QuerySelectionModifiersWithOrderByRaw = {}, + ): Promise[]> { + const fieldObjects = await this.dataManager.loadManyByRawWhereClauseAsync( + this.queryContext, + rawWhereClause, + bindings, + querySelectionModifiers, + ); + return await this.constructAndAuthorizeEntitiesArrayAsync(fieldObjects); + } + + /** + * Invalidate all caches for an entity's fields. Exposed primarily for internal use by EntityMutator. + * @param objectFields - entity data object to be invalidated + */ + async invalidateFieldsAsync(objectFields: Readonly): Promise { + await this.dataManager.invalidateObjectFieldsAsync(objectFields); + } + + /** + * Invalidate all caches for an entity. One potential use case would be to keep the entity + * framework in sync with changes made to data outside of the framework. + * @param entity - entity to be invalidated + */ + async invalidateEntityAsync(entity: TEntity): Promise { + await this.invalidateFieldsAsync(entity.getAllDatabaseFields()); + } + + private tryConstructEntities(fieldsObjects: readonly TFields[]): readonly Result[] { + return fieldsObjects.map((fieldsObject) => { + try { + return result(this.constructEntity(fieldsObject)); + } catch (e) { + if (!(e instanceof Error)) { + throw e; + } + return result(e); + } + }); + } + + public constructEntity(fieldsObject: TFields): TEntity { + const idField = this.entityConfiguration.idField; + const id = nullthrows(fieldsObject[idField], 'must provide ID to create an entity'); + const entitySelectedFields = + this.entitySelectedFields ?? Array.from(this.entityConfiguration.schema.keys()); + const selectedFields = pick(fieldsObject, entitySelectedFields); + return new this.entityClass({ + viewerContext: this.viewerContext, + id: id as TID, + databaseFields: fieldsObject, + selectedFields, + }); + } + + /** + * Construct and authorize entities from fields map, returning error results for entities that fail + * to construct or fail to authorize. + * + * @param map - map from an arbitrary key type to an array of entity field objects + */ + public async constructAndAuthorizeEntitiesAsync( + map: ReadonlyMap[]>, + ): Promise[]>> { + return await mapMapAsync(map, async (fieldObjects) => { + return await this.constructAndAuthorizeEntitiesArrayAsync(fieldObjects); + }); + } + + private async constructAndAuthorizeEntitiesArrayAsync( + fieldObjects: readonly Readonly[], + ): Promise[]> { + const uncheckedEntityResults = this.tryConstructEntities(fieldObjects); + return await Promise.all( + uncheckedEntityResults.map(async (uncheckedEntityResult) => { + if (!uncheckedEntityResult.ok) { + return uncheckedEntityResult; + } + return await asyncResult( + this.privacyPolicy.authorizeReadAsync( + this.viewerContext, + this.queryContext, + this.privacyPolicyEvaluationContext, + uncheckedEntityResult.value, + this.metricsAdapter, + ), + ); + }), + ); + } + + private validateFieldValues>( + fieldName: N, + fieldValues: readonly TFields[N][], + ): void { + const fieldDefinition = this.entityConfiguration.schema.get(fieldName); + invariant(fieldDefinition, `must have field definition for field = ${String(fieldName)}`); + for (const fieldValue of fieldValues) { + const isInputValid = fieldDefinition.validateInputValue(fieldValue); + if (!isInputValid) { + throw new EntityInvalidFieldValueError(this.entityClass, fieldName, fieldValue); + } + } + } +} diff --git a/packages/entity/src/EnforcingEntityLoader.ts b/packages/entity/src/EnforcingEntityLoader.ts index 05ee610c5..3e1632ce8 100644 --- a/packages/entity/src/EnforcingEntityLoader.ts +++ b/packages/entity/src/EnforcingEntityLoader.ts @@ -1,17 +1,18 @@ +import AuthorizationResultBasedEntityLoader from './AuthorizationResultBasedEntityLoader'; import { FieldEqualityCondition, QuerySelectionModifiers, QuerySelectionModifiersWithOrderByRaw, } from './EntityDatabaseAdapter'; -import EntityLoader from './EntityLoader'; import EntityPrivacyPolicy from './EntityPrivacyPolicy'; import ReadonlyEntity from './ReadonlyEntity'; import ViewerContext from './ViewerContext'; import { mapMap } from './utils/collections/maps'; /** - * Enforcing view on an entity loader. All loads through this loader will throw - * if the loads are not successful. + * Enforcing entity loader. All normal loads are batched, + * cached, and authorized against the entity's EntityPrivacyPolicy. All loads + * through this loader will throw if the load is not successful. */ export default class EnforcingEntityLoader< TFields extends object, @@ -28,7 +29,7 @@ export default class EnforcingEntityLoader< TSelectedFields extends keyof TFields, > { constructor( - private readonly entityLoader: EntityLoader< + private readonly entityLoader: AuthorizationResultBasedEntityLoader< TFields, TID, TViewerContext, diff --git a/packages/entity/src/EntityAssociationLoader.ts b/packages/entity/src/EntityAssociationLoader.ts index 4895b9f02..488a653d2 100644 --- a/packages/entity/src/EntityAssociationLoader.ts +++ b/packages/entity/src/EntityAssociationLoader.ts @@ -76,7 +76,9 @@ export default class EntityAssociationLoader< .getLoaderFactory() .forLoad(queryContext, { previousValue: null, cascadingDeleteCause: null }); - return (await loader.loadByIDAsync(associatedEntityID as unknown as TAssociatedID)) as Result< + return (await loader + .withAuthorizationResults() + .loadByIDAsync(associatedEntityID as unknown as TAssociatedID)) as Result< null extends TFields[TIdentifyingField] ? TAssociatedEntity | null : TAssociatedEntity >; } @@ -129,10 +131,9 @@ export default class EntityAssociationLoader< .getViewerScopedEntityCompanionForClass(associatedEntityClass) .getLoaderFactory() .forLoad(queryContext, { previousValue: null, cascadingDeleteCause: null }); - return await loader.loadManyByFieldEqualingAsync( - associatedEntityFieldContainingThisID, - thisID as any, - ); + return await loader + .withAuthorizationResults() + .loadManyByFieldEqualingAsync(associatedEntityFieldContainingThisID, thisID as any); } /** @@ -186,10 +187,9 @@ export default class EntityAssociationLoader< .getViewerScopedEntityCompanionForClass(associatedEntityClass) .getLoaderFactory() .forLoad(queryContext, { previousValue: null, cascadingDeleteCause: null }); - return await loader.loadByFieldEqualingAsync( - associatedEntityLookupByField, - associatedFieldValue as any, - ); + return await loader + .withAuthorizationResults() + .loadByFieldEqualingAsync(associatedEntityLookupByField, associatedFieldValue as any); } /** @@ -244,10 +244,9 @@ export default class EntityAssociationLoader< .getViewerScopedEntityCompanionForClass(associatedEntityClass) .getLoaderFactory() .forLoad(queryContext, { previousValue: null, cascadingDeleteCause: null }); - return await loader.loadManyByFieldEqualingAsync( - associatedEntityLookupByField, - associatedFieldValue as any, - ); + return await loader + .withAuthorizationResults() + .loadManyByFieldEqualingAsync(associatedEntityLookupByField, associatedFieldValue as any); } /** diff --git a/packages/entity/src/EntityLoader.ts b/packages/entity/src/EntityLoader.ts index e82af6032..636af2f38 100644 --- a/packages/entity/src/EntityLoader.ts +++ b/packages/entity/src/EntityLoader.ts @@ -1,26 +1,13 @@ -import { Result, asyncResult, result } from '@expo/results'; -import invariant from 'invariant'; -import nullthrows from 'nullthrows'; - +import AuthorizationResultBasedEntityLoader from './AuthorizationResultBasedEntityLoader'; import EnforcingEntityLoader from './EnforcingEntityLoader'; import { IEntityClass } from './Entity'; import EntityConfiguration from './EntityConfiguration'; -import { - FieldEqualityCondition, - QuerySelectionModifiers, - isSingleValueFieldEqualityCondition, - QuerySelectionModifiersWithOrderByRaw, -} from './EntityDatabaseAdapter'; import EntityPrivacyPolicy, { EntityPrivacyPolicyEvaluationContext } from './EntityPrivacyPolicy'; import { EntityQueryContext } from './EntityQueryContext'; import ReadonlyEntity from './ReadonlyEntity'; import ViewerContext from './ViewerContext'; -import { pick } from './entityUtils'; -import EntityInvalidFieldValueError from './errors/EntityInvalidFieldValueError'; -import EntityNotFoundError from './errors/EntityNotFoundError'; import EntityDataManager from './internal/EntityDataManager'; import IEntityMetricsAdapter from './metrics/IEntityMetricsAdapter'; -import { mapMap, mapMapAsync } from './utils/collections/maps'; /** * The primary interface for loading entities. All normal loads are batched, @@ -66,7 +53,7 @@ export default class EntityLoader< ) {} /** - * Enforcing view on this entity loader. All loads through this view are + * Enforcing entity loader. All loads through this loader are * guaranteed to be the values of successful results (or null for some loader methods), * and will throw otherwise. */ @@ -78,317 +65,32 @@ export default class EntityLoader< TPrivacyPolicy, TSelectedFields > { - return new EnforcingEntityLoader(this); - } - - /** - * Load many entities where fieldName is one of fieldValues. - * @param fieldName - entity field being queried - * @param fieldValues - fieldName field values being queried - * @returns map from fieldValue to entity results that match the query for that fieldValue, - * where result errors can be UnauthorizedError - */ - async loadManyByFieldEqualingManyAsync>( - fieldName: N, - fieldValues: readonly NonNullable[], - ): Promise, readonly Result[]>> { - this.validateFieldValues(fieldName, fieldValues); - - const fieldValuesToFieldObjects = await this.dataManager.loadManyByFieldEqualingAsync( - this.queryContext, - fieldName, - fieldValues, - ); - - return await this.constructAndAuthorizeEntitiesAsync(fieldValuesToFieldObjects); - } - - /** - * Load many entities where fieldName equals fieldValue. - * @param fieldName - entity field being queried - * @param fieldValue - fieldName field value being queried - * @returns array of entity results that match the query for fieldValue, where result error can be UnauthorizedError - */ - async loadManyByFieldEqualingAsync>( - fieldName: N, - fieldValue: NonNullable, - ): Promise[]> { - const entityResults = await this.loadManyByFieldEqualingManyAsync(fieldName, [fieldValue]); - const entityResultsForFieldValue = entityResults.get(fieldValue); - invariant( - entityResultsForFieldValue !== undefined, - `${fieldValue} should be guaranteed to be present in returned map of entities`, - ); - return entityResultsForFieldValue!; - } - - /** - * Load an entity where fieldName equals fieldValue, or null if no entity exists. - * @param uniqueFieldName - entity field being queried - * @param fieldValue - uniqueFieldName field value being queried - * @returns entity result where uniqueFieldName equals fieldValue, or null if no entity matches the condition. - * @throws when multiple entities match the condition - */ - async loadByFieldEqualingAsync>( - uniqueFieldName: N, - fieldValue: NonNullable, - ): Promise | null> { - const entityResults = await this.loadManyByFieldEqualingAsync(uniqueFieldName, fieldValue); - invariant( - entityResults.length <= 1, - `loadByFieldEqualing: Multiple entities of type ${this.entityClass.name} found for ${String( - uniqueFieldName, - )}=${fieldValue}`, - ); - return entityResults[0] ?? null; + return new EnforcingEntityLoader(this.withAuthorizationResults()); } /** - * Loads an entity by a specified ID. - * @param id - ID of the entity - * @returns entity result for matching ID, where result error can be UnauthorizedError or EntityNotFoundError. + * Authorization-result-based entity loader. All loads through this + * loader are are results (or null for some loader methods), where an unsuccessful result + * means an authorization error or entity construction error occurred. Other errors are thrown. */ - async loadByIDAsync(id: TID): Promise> { - const entityResults = await this.loadManyByIDsAsync([id]); - // loadManyByIDsAsync is always populated for each id supplied - return nullthrows(entityResults.get(id)); - } - - /** - * Load an entity by a specified ID, or return null if non-existent. - * @param id - ID of the entity - * @returns entity result for matching ID, or null if no entity exists for ID. - */ - async loadByIDNullableAsync(id: TID): Promise | null> { - return await this.loadByFieldEqualingAsync( - this.entityConfiguration.idField as TSelectedFields, - id, - ); - } - - /** - * Loads many entities for a list of IDs. - * @param ids - IDs of the entities to load - * @returns map from ID to corresponding entity result, where result error can be UnauthorizedError or EntityNotFoundError. - */ - async loadManyByIDsAsync(ids: readonly TID[]): Promise>> { - const entityResults = (await this.loadManyByFieldEqualingManyAsync( - this.entityConfiguration.idField as TSelectedFields, - ids, - )) as ReadonlyMap[]>; - return mapMap(entityResults, (entityResultsForId, id) => { - const entityResult = entityResultsForId[0]; - return ( - entityResult ?? - result(new EntityNotFoundError(this.entityClass, this.entityConfiguration.idField, id)) - ); - }); - } - - /** - * Loads many entities for a list of IDs, returning null for any IDs that are non-existent. - * @param ids - IDs of the entities to load - * @returns map from ID to nullable corresponding entity result, where result error can be UnauthorizedError or EntityNotFoundError. - */ - async loadManyByIDsNullableAsync( - ids: readonly TID[], - ): Promise | null>> { - const entityResults = (await this.loadManyByFieldEqualingManyAsync( - this.entityConfiguration.idField as TSelectedFields, - ids, - )) as ReadonlyMap[]>; - return mapMap(entityResults, (entityResultsForId) => { - return entityResultsForId[0] ?? null; - }); - } - - /** - * Loads the first entity matching the selection constructed from the conjunction of specified - * operands, or null if no matching entity exists. Entities loaded using this method are not - * batched or cached. - * - * This is a convenience method for {@link loadManyByFieldEqualityConjunctionAsync}. However, the - * `orderBy` option must be specified to define what "first" means. If ordering doesn't matter, - * explicitly pass in an empty array. - * - * @param fieldEqualityOperands - list of field equality selection operand specifications - * @param querySelectionModifiers - orderBy and optional offset for the query - * @returns the first entity results that matches the query, where result error can be - * UnauthorizedError - */ - async loadFirstByFieldEqualityConjunctionAsync>( - fieldEqualityOperands: FieldEqualityCondition[], - querySelectionModifiers: Omit, 'limit'> & - Required, 'orderBy'>>, - ): Promise | null> { - const results = await this.loadManyByFieldEqualityConjunctionAsync(fieldEqualityOperands, { - ...querySelectionModifiers, - limit: 1, - }); - return results[0] ?? null; - } - - /** - * Loads many entities matching the selection constructed from the conjunction of specified operands. - * Entities loaded using this method are not batched or cached. - * - * @example - * fieldEqualityOperands: - * `[{fieldName: 'hello', fieldValue: 1}, {fieldName: 'world', fieldValues: [2, 3]}]` - * Entities returned with a SQL EntityDatabaseAdapter: - * `WHERE hello = 1 AND world = ANY({2, 3})` - * - * @param fieldEqualityOperands - list of field equality selection operand specifications - * @param querySelectionModifiers - limit, offset, and orderBy for the query - * @returns array of entity results that match the query, where result error can be UnauthorizedError - */ - async loadManyByFieldEqualityConjunctionAsync>( - fieldEqualityOperands: FieldEqualityCondition[], - querySelectionModifiers: QuerySelectionModifiers = {}, - ): Promise[]> { - for (const fieldEqualityOperand of fieldEqualityOperands) { - const fieldValues = isSingleValueFieldEqualityCondition(fieldEqualityOperand) - ? [fieldEqualityOperand.fieldValue] - : fieldEqualityOperand.fieldValues; - this.validateFieldValues(fieldEqualityOperand.fieldName, fieldValues); - } - - const fieldObjects = await this.dataManager.loadManyByFieldEqualityConjunctionAsync( - this.queryContext, - fieldEqualityOperands, - querySelectionModifiers, - ); - return await this.constructAndAuthorizeEntitiesArrayAsync(fieldObjects); - } - - /** - * Loads many entities matching the raw WHERE clause. Corresponds to the knex `whereRaw` argument format. - * - * @remarks - * Important notes: - * - Fields in clause are database column names instead of transformed entity field names. - * - Entities loaded using this method are not batched or cached. - * - Not all database adapters implement the ability to execute this method of fetching entities. - * - * @example - * rawWhereClause: `id = ?` - * bindings: `[1]` - * Entites returned `WHERE id = 1` - * - * http://knexjs.org/#Builder-whereRaw - * http://knexjs.org/#Raw-Bindings - * - * @param rawWhereClause - parameterized SQL WHERE clause with positional binding placeholders or named binding placeholders - * @param bindings - array of positional bindings or object of named bindings - * @param querySelectionModifiers - limit, offset, orderBy, and orderByRaw for the query - * @returns array of entity results that match the query, where result error can be UnauthorizedError - * @throws Error when rawWhereClause or bindings are invalid - */ - async loadManyByRawWhereClauseAsync( - rawWhereClause: string, - bindings: any[] | object, - querySelectionModifiers: QuerySelectionModifiersWithOrderByRaw = {}, - ): Promise[]> { - const fieldObjects = await this.dataManager.loadManyByRawWhereClauseAsync( + withAuthorizationResults(): AuthorizationResultBasedEntityLoader< + TFields, + TID, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + > { + return new AuthorizationResultBasedEntityLoader( + this.viewerContext, this.queryContext, - rawWhereClause, - bindings, - querySelectionModifiers, + this.privacyPolicyEvaluationContext, + this.entityConfiguration, + this.entityClass, + this.entitySelectedFields, + this.privacyPolicy, + this.dataManager, + this.metricsAdapter, ); - return await this.constructAndAuthorizeEntitiesArrayAsync(fieldObjects); - } - - /** - * Invalidate all caches for an entity's fields. Exposed primarily for internal use by EntityMutator. - * @param objectFields - entity data object to be invalidated - */ - async invalidateFieldsAsync(objectFields: Readonly): Promise { - await this.dataManager.invalidateObjectFieldsAsync(objectFields); - } - - /** - * Invalidate all caches for an entity. One potential use case would be to keep the entity - * framework in sync with changes made to data outside of the framework. - * @param entity - entity to be invalidated - */ - async invalidateEntityAsync(entity: TEntity): Promise { - await this.invalidateFieldsAsync(entity.getAllDatabaseFields()); - } - - private tryConstructEntities(fieldsObjects: readonly TFields[]): readonly Result[] { - return fieldsObjects.map((fieldsObject) => { - try { - return result(this.constructEntity(fieldsObject)); - } catch (e) { - if (!(e instanceof Error)) { - throw e; - } - return result(e); - } - }); - } - - public constructEntity(fieldsObject: TFields): TEntity { - const idField = this.entityConfiguration.idField; - const id = nullthrows(fieldsObject[idField], 'must provide ID to create an entity'); - const entitySelectedFields = - this.entitySelectedFields ?? Array.from(this.entityConfiguration.schema.keys()); - const selectedFields = pick(fieldsObject, entitySelectedFields); - return new this.entityClass({ - viewerContext: this.viewerContext, - id: id as TID, - databaseFields: fieldsObject, - selectedFields, - }); - } - - /** - * Construct and authorize entities from fields map, returning error results for entities that fail - * to construct or fail to authorize. - * - * @param map - map from an arbitrary key type to an array of entity field objects - */ - public async constructAndAuthorizeEntitiesAsync( - map: ReadonlyMap[]>, - ): Promise[]>> { - return await mapMapAsync(map, async (fieldObjects) => { - return await this.constructAndAuthorizeEntitiesArrayAsync(fieldObjects); - }); - } - - private async constructAndAuthorizeEntitiesArrayAsync( - fieldObjects: readonly Readonly[], - ): Promise[]> { - const uncheckedEntityResults = this.tryConstructEntities(fieldObjects); - return await Promise.all( - uncheckedEntityResults.map(async (uncheckedEntityResult) => { - if (!uncheckedEntityResult.ok) { - return uncheckedEntityResult; - } - return await asyncResult( - this.privacyPolicy.authorizeReadAsync( - this.viewerContext, - this.queryContext, - this.privacyPolicyEvaluationContext, - uncheckedEntityResult.value, - this.metricsAdapter, - ), - ); - }), - ); - } - - private validateFieldValues>( - fieldName: N, - fieldValues: readonly TFields[N][], - ): void { - const fieldDefinition = this.entityConfiguration.schema.get(fieldName); - invariant(fieldDefinition, `must have field definition for field = ${String(fieldName)}`); - for (const fieldValue of fieldValues) { - const isInputValid = fieldDefinition.validateInputValue(fieldValue); - if (!isInputValid) { - throw new EntityInvalidFieldValueError(this.entityClass, fieldName, fieldValue); - } - } } } diff --git a/packages/entity/src/EntityMutator.ts b/packages/entity/src/EntityMutator.ts index 5c06d0267..87659550a 100644 --- a/packages/entity/src/EntityMutator.ts +++ b/packages/entity/src/EntityMutator.ts @@ -216,7 +216,7 @@ export class CreateMutator< cascadingDeleteCause: null, }); - const temporaryEntityForPrivacyCheck = entityLoader.constructEntity({ + const temporaryEntityForPrivacyCheck = entityLoader.withAuthorizationResults().constructEntity({ [this.entityConfiguration.idField]: '00000000-0000-0000-0000-000000000000', // zero UUID ...this.fieldsForEntity, } as unknown as TFields); @@ -256,10 +256,14 @@ export class CreateMutator< const insertResult = await this.databaseAdapter.insertAsync(queryContext, this.fieldsForEntity); queryContext.appendPostCommitInvalidationCallback( - entityLoader.invalidateFieldsAsync.bind(entityLoader, insertResult), + entityLoader + .withAuthorizationResults() + .invalidateFieldsAsync.bind(entityLoader, insertResult), ); - const unauthorizedEntityAfterInsert = entityLoader.constructEntity(insertResult); + const unauthorizedEntityAfterInsert = entityLoader + .withAuthorizationResults() + .constructEntity(insertResult); const newEntity = await entityLoader .enforcing() .loadByIDAsync(unauthorizedEntityAfterInsert.getID()); @@ -429,7 +433,9 @@ export class UpdateMutator< cascadingDeleteCause, }); - const entityAboutToBeUpdated = entityLoader.constructEntity(this.fieldsForEntity); + const entityAboutToBeUpdated = entityLoader + .withAuthorizationResults() + .constructEntity(this.fieldsForEntity); const authorizeUpdateResult = await asyncResult( this.privacyPolicy.authorizeUpdateAsync( this.viewerContext, @@ -473,13 +479,14 @@ export class UpdateMutator< } queryContext.appendPostCommitInvalidationCallback( - entityLoader.invalidateFieldsAsync.bind( - entityLoader, - this.originalEntity.getAllDatabaseFields(), - ), + entityLoader + .withAuthorizationResults() + .invalidateFieldsAsync.bind(entityLoader, this.originalEntity.getAllDatabaseFields()), ); queryContext.appendPostCommitInvalidationCallback( - entityLoader.invalidateFieldsAsync.bind(entityLoader, this.fieldsForEntity), + entityLoader + .withAuthorizationResults() + .invalidateFieldsAsync.bind(entityLoader, this.fieldsForEntity), ); const updatedEntity = await entityLoader @@ -682,7 +689,9 @@ export class DeleteMutator< cascadingDeleteCause, }); queryContext.appendPostCommitInvalidationCallback( - entityLoader.invalidateFieldsAsync.bind(entityLoader, this.entity.getAllDatabaseFields()), + entityLoader + .withAuthorizationResults() + .invalidateFieldsAsync.bind(entityLoader, this.entity.getAllDatabaseFields()), ); await this.executeMutationTriggersAsync( diff --git a/packages/entity/src/EntitySecondaryCacheLoader.ts b/packages/entity/src/EntitySecondaryCacheLoader.ts index a3bb5f90c..d3caf43b8 100644 --- a/packages/entity/src/EntitySecondaryCacheLoader.ts +++ b/packages/entity/src/EntitySecondaryCacheLoader.ts @@ -84,9 +84,11 @@ export default abstract class EntitySecondaryCacheLoader< ); // convert value to and from array to reuse complex code - const entitiesMap = await this.entityLoader.constructAndAuthorizeEntitiesAsync( - mapMap(loadParamsToFieldObjects, (fieldObject) => (fieldObject ? [fieldObject] : [])), - ); + const entitiesMap = await this.entityLoader + .withAuthorizationResults() + .constructAndAuthorizeEntitiesAsync( + mapMap(loadParamsToFieldObjects, (fieldObject) => (fieldObject ? [fieldObject] : [])), + ); return mapMap(entitiesMap, (fieldObjects) => fieldObjects[0] ?? null); } diff --git a/packages/entity/src/__tests__/EnforcingEntityLoader-test.ts b/packages/entity/src/__tests__/EnforcingEntityLoader-test.ts index d2555d859..c83c2eb9a 100644 --- a/packages/entity/src/__tests__/EnforcingEntityLoader-test.ts +++ b/packages/entity/src/__tests__/EnforcingEntityLoader-test.ts @@ -1,40 +1,48 @@ import { result } from '@expo/results'; import { mock, instance, when, anything } from 'ts-mockito'; +import AuthorizationResultBasedEntityLoader from '../AuthorizationResultBasedEntityLoader'; import EnforcingEntityLoader from '../EnforcingEntityLoader'; -import EntityLoader from '../EntityLoader'; describe(EnforcingEntityLoader, () => { describe('loadManyByFieldEqualingManyAsync', () => { it('throws when result is unsuccessful', async () => { - const entityLoaderMock = mock>(EntityLoader); + const nonEnforcingEntityLoaderMock = mock< + AuthorizationResultBasedEntityLoader + >(AuthorizationResultBasedEntityLoader); const rejection = new Error(); - when(entityLoaderMock.loadManyByFieldEqualingManyAsync(anything(), anything())).thenResolve( + when( + nonEnforcingEntityLoaderMock.loadManyByFieldEqualingManyAsync(anything(), anything()), + ).thenResolve( new Map( Object.entries({ hello: [result(rejection)], }), ), ); - const entityLoader = instance(entityLoaderMock); - const enforcingEntityLoader = new EnforcingEntityLoader(entityLoader); + const nonEnforcingEntityLoader = instance(nonEnforcingEntityLoaderMock); + const enforcingEntityLoader = new EnforcingEntityLoader(nonEnforcingEntityLoader); await expect( enforcingEntityLoader.loadManyByFieldEqualingManyAsync(anything(), anything()), ).rejects.toThrow(rejection); }); it('returns value when result is successful', async () => { - const entityLoaderMock = mock>(EntityLoader); + const nonEnforcingEntityLoaderMock = mock< + AuthorizationResultBasedEntityLoader + >(AuthorizationResultBasedEntityLoader); const resolved = {}; - when(entityLoaderMock.loadManyByFieldEqualingManyAsync(anything(), anything())).thenResolve( + when( + nonEnforcingEntityLoaderMock.loadManyByFieldEqualingManyAsync(anything(), anything()), + ).thenResolve( new Map( Object.entries({ hello: [result(resolved)], }), ), ); - const entityLoader = instance(entityLoaderMock); - const enforcingEntityLoader = new EnforcingEntityLoader(entityLoader); + const nonEnforcingEntityLoader = instance(nonEnforcingEntityLoaderMock); + const enforcingEntityLoader = new EnforcingEntityLoader(nonEnforcingEntityLoader); await expect( enforcingEntityLoader.loadManyByFieldEqualingManyAsync(anything(), anything()), ).resolves.toEqual( @@ -49,26 +57,30 @@ describe(EnforcingEntityLoader, () => { describe('loadManyByFieldEqualingAsync', () => { it('throws when result is unsuccessful', async () => { - const entityLoaderMock = mock>(EntityLoader); + const nonEnforcingEntityLoaderMock = mock< + AuthorizationResultBasedEntityLoader + >(AuthorizationResultBasedEntityLoader); const rejection = new Error(); - when(entityLoaderMock.loadManyByFieldEqualingAsync(anything(), anything())).thenResolve([ - result(rejection), - ]); - const entityLoader = instance(entityLoaderMock); - const enforcingEntityLoader = new EnforcingEntityLoader(entityLoader); + when( + nonEnforcingEntityLoaderMock.loadManyByFieldEqualingAsync(anything(), anything()), + ).thenResolve([result(rejection)]); + const nonEnforcingEntityLoader = instance(nonEnforcingEntityLoaderMock); + const enforcingEntityLoader = new EnforcingEntityLoader(nonEnforcingEntityLoader); await expect( enforcingEntityLoader.loadManyByFieldEqualingAsync(anything(), anything()), ).rejects.toThrow(rejection); }); it('returns value when result is successful', async () => { - const entityLoaderMock = mock>(EntityLoader); + const nonEnforcingEntityLoaderMock = mock< + AuthorizationResultBasedEntityLoader + >(AuthorizationResultBasedEntityLoader); const resolved = {}; - when(entityLoaderMock.loadManyByFieldEqualingAsync(anything(), anything())).thenResolve([ - result(resolved), - ]); - const entityLoader = instance(entityLoaderMock); - const enforcingEntityLoader = new EnforcingEntityLoader(entityLoader); + when( + nonEnforcingEntityLoaderMock.loadManyByFieldEqualingAsync(anything(), anything()), + ).thenResolve([result(resolved)]); + const nonEnforcingEntityLoader = instance(nonEnforcingEntityLoaderMock); + const enforcingEntityLoader = new EnforcingEntityLoader(nonEnforcingEntityLoader); await expect( enforcingEntityLoader.loadManyByFieldEqualingAsync(anything(), anything()), ).resolves.toEqual([resolved]); @@ -77,52 +89,60 @@ describe(EnforcingEntityLoader, () => { describe('loadByFieldEqualingAsync', () => { it('throws when result is unsuccessful', async () => { - const entityLoaderMock = mock>(EntityLoader); + const nonEnforcingEntityLoaderMock = mock< + AuthorizationResultBasedEntityLoader + >(AuthorizationResultBasedEntityLoader); const rejection = new Error(); - when(entityLoaderMock.loadByFieldEqualingAsync(anything(), anything())).thenResolve( - result(rejection), - ); - const entityLoader = instance(entityLoaderMock); - const enforcingEntityLoader = new EnforcingEntityLoader(entityLoader); + when( + nonEnforcingEntityLoaderMock.loadByFieldEqualingAsync(anything(), anything()), + ).thenResolve(result(rejection)); + const nonEnforcingEntityLoader = instance(nonEnforcingEntityLoaderMock); + const enforcingEntityLoader = new EnforcingEntityLoader(nonEnforcingEntityLoader); await expect( enforcingEntityLoader.loadByFieldEqualingAsync(anything(), anything()), ).rejects.toThrow(rejection); }); it('returns value when result is successful', async () => { - const entityLoaderMock = mock>(EntityLoader); + const nonEnforcingEntityLoaderMock = mock< + AuthorizationResultBasedEntityLoader + >(AuthorizationResultBasedEntityLoader); const resolved = {}; - when(entityLoaderMock.loadByFieldEqualingAsync(anything(), anything())).thenResolve( - result(resolved), - ); - const entityLoader = instance(entityLoaderMock); - const enforcingEntityLoader = new EnforcingEntityLoader(entityLoader); + when( + nonEnforcingEntityLoaderMock.loadByFieldEqualingAsync(anything(), anything()), + ).thenResolve(result(resolved)); + const nonEnforcingEntityLoader = instance(nonEnforcingEntityLoaderMock); + const enforcingEntityLoader = new EnforcingEntityLoader(nonEnforcingEntityLoader); await expect( enforcingEntityLoader.loadByFieldEqualingAsync(anything(), anything()), ).resolves.toEqual(resolved); }); it('returns null when result is successful and no entity is found', async () => { - const entityLoaderMock = mock>(EntityLoader); + const nonEnforcingEntityLoaderMock = mock< + AuthorizationResultBasedEntityLoader + >(AuthorizationResultBasedEntityLoader); const resolved = null; - when(entityLoaderMock.loadByFieldEqualingAsync(anything(), anything())).thenResolve( - result(resolved), - ); - const entityLoader = instance(entityLoaderMock); - const enforcingEntityLoader = new EnforcingEntityLoader(entityLoader); + when( + nonEnforcingEntityLoaderMock.loadByFieldEqualingAsync(anything(), anything()), + ).thenResolve(result(resolved)); + const nonEnforcingEntityLoader = instance(nonEnforcingEntityLoaderMock); + const enforcingEntityLoader = new EnforcingEntityLoader(nonEnforcingEntityLoader); await expect( enforcingEntityLoader.loadByFieldEqualingAsync(anything(), anything()), ).resolves.toEqual(resolved); }); it('throws when multiple matching entities are found', async () => { - const entityLoaderMock = mock>(EntityLoader); + const nonEnforcingEntityLoaderMock = mock< + AuthorizationResultBasedEntityLoader + >(AuthorizationResultBasedEntityLoader); const multipleEntitiesError = new Error(); - when(entityLoaderMock.loadByFieldEqualingAsync(anything(), anything())).thenReject( - multipleEntitiesError, - ); - const entityLoader = instance(entityLoaderMock); - const enforcingEntityLoader = new EnforcingEntityLoader(entityLoader); + when( + nonEnforcingEntityLoaderMock.loadByFieldEqualingAsync(anything(), anything()), + ).thenReject(multipleEntitiesError); + const nonEnforcingEntityLoader = instance(nonEnforcingEntityLoaderMock); + const enforcingEntityLoader = new EnforcingEntityLoader(nonEnforcingEntityLoader); await expect( enforcingEntityLoader.loadByFieldEqualingAsync(anything(), anything()), ).rejects.toEqual(multipleEntitiesError); @@ -131,53 +151,69 @@ describe(EnforcingEntityLoader, () => { describe('loadByIDAsync', () => { it('throws when result is unsuccessful', async () => { - const entityLoaderMock = mock>(EntityLoader); + const nonEnforcingEntityLoaderMock = mock< + AuthorizationResultBasedEntityLoader + >(AuthorizationResultBasedEntityLoader); const rejection = new Error(); - when(entityLoaderMock.loadByIDAsync(anything())).thenResolve(result(rejection)); - const entityLoader = instance(entityLoaderMock); - const enforcingEntityLoader = new EnforcingEntityLoader(entityLoader); + when(nonEnforcingEntityLoaderMock.loadByIDAsync(anything())).thenResolve(result(rejection)); + const nonEnforcingEntityLoader = instance(nonEnforcingEntityLoaderMock); + const enforcingEntityLoader = new EnforcingEntityLoader(nonEnforcingEntityLoader); await expect(enforcingEntityLoader.loadByIDAsync(anything())).rejects.toThrow(rejection); }); it('returns value when result is successful', async () => { - const entityLoaderMock = mock>(EntityLoader); + const nonEnforcingEntityLoaderMock = mock< + AuthorizationResultBasedEntityLoader + >(AuthorizationResultBasedEntityLoader); const resolved = {}; - when(entityLoaderMock.loadByIDAsync(anything())).thenResolve(result(resolved)); - const entityLoader = instance(entityLoaderMock); - const enforcingEntityLoader = new EnforcingEntityLoader(entityLoader); + when(nonEnforcingEntityLoaderMock.loadByIDAsync(anything())).thenResolve(result(resolved)); + const nonEnforcingEntityLoader = instance(nonEnforcingEntityLoaderMock); + const enforcingEntityLoader = new EnforcingEntityLoader(nonEnforcingEntityLoader); await expect(enforcingEntityLoader.loadByIDAsync(anything())).resolves.toEqual(resolved); }); }); describe('loadByIDNullableAsync', () => { it('throws when result is unsuccessful', async () => { - const entityLoaderMock = mock>(EntityLoader); + const nonEnforcingEntityLoaderMock = mock< + AuthorizationResultBasedEntityLoader + >(AuthorizationResultBasedEntityLoader); const rejection = new Error(); - when(entityLoaderMock.loadByIDNullableAsync(anything())).thenResolve(result(rejection)); - const entityLoader = instance(entityLoaderMock); - const enforcingEntityLoader = new EnforcingEntityLoader(entityLoader); + when(nonEnforcingEntityLoaderMock.loadByIDNullableAsync(anything())).thenResolve( + result(rejection), + ); + const nonEnforcingEntityLoader = instance(nonEnforcingEntityLoaderMock); + const enforcingEntityLoader = new EnforcingEntityLoader(nonEnforcingEntityLoader); await expect(enforcingEntityLoader.loadByIDNullableAsync(anything())).rejects.toThrow( rejection, ); }); it('returns value when result is successful', async () => { - const entityLoaderMock = mock>(EntityLoader); + const nonEnforcingEntityLoaderMock = mock< + AuthorizationResultBasedEntityLoader + >(AuthorizationResultBasedEntityLoader); const resolved = {}; - when(entityLoaderMock.loadByIDNullableAsync(anything())).thenResolve(result(resolved)); - const entityLoader = instance(entityLoaderMock); - const enforcingEntityLoader = new EnforcingEntityLoader(entityLoader); + when(nonEnforcingEntityLoaderMock.loadByIDNullableAsync(anything())).thenResolve( + result(resolved), + ); + const nonEnforcingEntityLoader = instance(nonEnforcingEntityLoaderMock); + const enforcingEntityLoader = new EnforcingEntityLoader(nonEnforcingEntityLoader); await expect(enforcingEntityLoader.loadByIDNullableAsync(anything())).resolves.toEqual( resolved, ); }); it('returns null when non-existent object', async () => { - const entityLoaderMock = mock>(EntityLoader); + const nonEnforcingEntityLoaderMock = mock< + AuthorizationResultBasedEntityLoader + >(AuthorizationResultBasedEntityLoader); const resolved = null; - when(entityLoaderMock.loadByIDNullableAsync(anything())).thenResolve(result(resolved)); - const entityLoader = instance(entityLoaderMock); - const enforcingEntityLoader = new EnforcingEntityLoader(entityLoader); + when(nonEnforcingEntityLoaderMock.loadByIDNullableAsync(anything())).thenResolve( + result(resolved), + ); + const nonEnforcingEntityLoader = instance(nonEnforcingEntityLoaderMock); + const enforcingEntityLoader = new EnforcingEntityLoader(nonEnforcingEntityLoader); await expect(enforcingEntityLoader.loadByIDNullableAsync(anything())).resolves.toEqual( resolved, ); @@ -186,32 +222,36 @@ describe(EnforcingEntityLoader, () => { describe('loadManyByIDsAsync', () => { it('throws when result is unsuccessful', async () => { - const entityLoaderMock = mock>(EntityLoader); + const nonEnforcingEntityLoaderMock = mock< + AuthorizationResultBasedEntityLoader + >(AuthorizationResultBasedEntityLoader); const rejection = new Error(); - when(entityLoaderMock.loadManyByIDsAsync(anything())).thenResolve( + when(nonEnforcingEntityLoaderMock.loadManyByIDsAsync(anything())).thenResolve( new Map( Object.entries({ hello: result(rejection), }), ), ); - const entityLoader = instance(entityLoaderMock); - const enforcingEntityLoader = new EnforcingEntityLoader(entityLoader); + const nonEnforcingEntityLoader = instance(nonEnforcingEntityLoaderMock); + const enforcingEntityLoader = new EnforcingEntityLoader(nonEnforcingEntityLoader); await expect(enforcingEntityLoader.loadManyByIDsAsync(anything())).rejects.toThrow(rejection); }); it('returns value when result is successful', async () => { - const entityLoaderMock = mock>(EntityLoader); + const nonEnforcingEntityLoaderMock = mock< + AuthorizationResultBasedEntityLoader + >(AuthorizationResultBasedEntityLoader); const resolved = {}; - when(entityLoaderMock.loadManyByIDsAsync(anything())).thenResolve( + when(nonEnforcingEntityLoaderMock.loadManyByIDsAsync(anything())).thenResolve( new Map( Object.entries({ hello: result(resolved), }), ), ); - const entityLoader = instance(entityLoaderMock); - const enforcingEntityLoader = new EnforcingEntityLoader(entityLoader); + const nonEnforcingEntityLoader = instance(nonEnforcingEntityLoaderMock); + const enforcingEntityLoader = new EnforcingEntityLoader(nonEnforcingEntityLoader); await expect(enforcingEntityLoader.loadManyByIDsAsync(anything())).resolves.toEqual( new Map( Object.entries({ @@ -224,9 +264,11 @@ describe(EnforcingEntityLoader, () => { describe('loadManyByIDsNullableAsync', () => { it('throws when result is unsuccessful even when there is a null result', async () => { - const entityLoaderMock = mock>(EntityLoader); + const nonEnforcingEntityLoaderMock = mock< + AuthorizationResultBasedEntityLoader + >(AuthorizationResultBasedEntityLoader); const rejection = new Error(); - when(entityLoaderMock.loadManyByIDsNullableAsync(anything())).thenResolve( + when(nonEnforcingEntityLoaderMock.loadManyByIDsNullableAsync(anything())).thenResolve( new Map( Object.entries({ hello: result(rejection), @@ -234,17 +276,19 @@ describe(EnforcingEntityLoader, () => { }), ), ); - const entityLoader = instance(entityLoaderMock); - const enforcingEntityLoader = new EnforcingEntityLoader(entityLoader); + const nonEnforcingEntityLoader = instance(nonEnforcingEntityLoaderMock); + const enforcingEntityLoader = new EnforcingEntityLoader(nonEnforcingEntityLoader); await expect(enforcingEntityLoader.loadManyByIDsNullableAsync(anything())).rejects.toThrow( rejection, ); }); it('returns value when result is successful', async () => { - const entityLoaderMock = mock>(EntityLoader); + const nonEnforcingEntityLoaderMock = mock< + AuthorizationResultBasedEntityLoader + >(AuthorizationResultBasedEntityLoader); const resolved = {}; - when(entityLoaderMock.loadManyByIDsNullableAsync(anything())).thenResolve( + when(nonEnforcingEntityLoaderMock.loadManyByIDsNullableAsync(anything())).thenResolve( new Map( Object.entries({ hello: result(resolved), @@ -252,8 +296,8 @@ describe(EnforcingEntityLoader, () => { }), ), ); - const entityLoader = instance(entityLoaderMock); - const enforcingEntityLoader = new EnforcingEntityLoader(entityLoader); + const nonEnforcingEntityLoader = instance(nonEnforcingEntityLoaderMock); + const enforcingEntityLoader = new EnforcingEntityLoader(nonEnforcingEntityLoader); await expect(enforcingEntityLoader.loadManyByIDsNullableAsync(anything())).resolves.toEqual( new Map( Object.entries({ @@ -267,38 +311,53 @@ describe(EnforcingEntityLoader, () => { describe('loadFirstByFieldEqualityConjunction', () => { it('throws when result is unsuccessful', async () => { - const entityLoaderMock = mock>(EntityLoader); + const nonEnforcingEntityLoaderMock = mock< + AuthorizationResultBasedEntityLoader + >(AuthorizationResultBasedEntityLoader); const rejection = new Error(); when( - entityLoaderMock.loadFirstByFieldEqualityConjunctionAsync(anything(), anything()), + nonEnforcingEntityLoaderMock.loadFirstByFieldEqualityConjunctionAsync( + anything(), + anything(), + ), ).thenResolve(result(rejection)); - const entityLoader = instance(entityLoaderMock); - const enforcingEntityLoader = new EnforcingEntityLoader(entityLoader); + const nonEnforcingEntityLoader = instance(nonEnforcingEntityLoaderMock); + const enforcingEntityLoader = new EnforcingEntityLoader(nonEnforcingEntityLoader); await expect( enforcingEntityLoader.loadFirstByFieldEqualityConjunctionAsync(anything(), anything()), ).rejects.toThrow(rejection); }); it('returns value when result is successful', async () => { - const entityLoaderMock = mock>(EntityLoader); + const nonEnforcingEntityLoaderMock = mock< + AuthorizationResultBasedEntityLoader + >(AuthorizationResultBasedEntityLoader); const resolved = {}; when( - entityLoaderMock.loadFirstByFieldEqualityConjunctionAsync(anything(), anything()), + nonEnforcingEntityLoaderMock.loadFirstByFieldEqualityConjunctionAsync( + anything(), + anything(), + ), ).thenResolve(result(resolved)); - const entityLoader = instance(entityLoaderMock); - const enforcingEntityLoader = new EnforcingEntityLoader(entityLoader); + const nonEnforcingEntityLoader = instance(nonEnforcingEntityLoaderMock); + const enforcingEntityLoader = new EnforcingEntityLoader(nonEnforcingEntityLoader); await expect( enforcingEntityLoader.loadFirstByFieldEqualityConjunctionAsync(anything(), anything()), ).resolves.toEqual(resolved); }); it('returns null when the query is successful but no rows match', async () => { - const entityLoaderMock = mock>(EntityLoader); + const nonEnforcingEntityLoaderMock = mock< + AuthorizationResultBasedEntityLoader + >(AuthorizationResultBasedEntityLoader); when( - entityLoaderMock.loadFirstByFieldEqualityConjunctionAsync(anything(), anything()), + nonEnforcingEntityLoaderMock.loadFirstByFieldEqualityConjunctionAsync( + anything(), + anything(), + ), ).thenResolve(null); - const entityLoader = instance(entityLoaderMock); - const enforcingEntityLoader = new EnforcingEntityLoader(entityLoader); + const nonEnforcingEntityLoader = instance(nonEnforcingEntityLoaderMock); + const enforcingEntityLoader = new EnforcingEntityLoader(nonEnforcingEntityLoader); await expect( enforcingEntityLoader.loadFirstByFieldEqualityConjunctionAsync(anything(), anything()), ).resolves.toBeNull(); @@ -307,26 +366,36 @@ describe(EnforcingEntityLoader, () => { describe('loadManyByFieldEqualityConjunction', () => { it('throws when result is unsuccessful', async () => { - const entityLoaderMock = mock>(EntityLoader); + const nonEnforcingEntityLoaderMock = mock< + AuthorizationResultBasedEntityLoader + >(AuthorizationResultBasedEntityLoader); const rejection = new Error(); when( - entityLoaderMock.loadManyByFieldEqualityConjunctionAsync(anything(), anything()), + nonEnforcingEntityLoaderMock.loadManyByFieldEqualityConjunctionAsync( + anything(), + anything(), + ), ).thenResolve([result(rejection)]); - const entityLoader = instance(entityLoaderMock); - const enforcingEntityLoader = new EnforcingEntityLoader(entityLoader); + const nonEnforcingEntityLoader = instance(nonEnforcingEntityLoaderMock); + const enforcingEntityLoader = new EnforcingEntityLoader(nonEnforcingEntityLoader); await expect( enforcingEntityLoader.loadManyByFieldEqualityConjunctionAsync(anything(), anything()), ).rejects.toThrow(rejection); }); it('returns value when result is successful', async () => { - const entityLoaderMock = mock>(EntityLoader); + const nonEnforcingEntityLoaderMock = mock< + AuthorizationResultBasedEntityLoader + >(AuthorizationResultBasedEntityLoader); const resolved = {}; when( - entityLoaderMock.loadManyByFieldEqualityConjunctionAsync(anything(), anything()), + nonEnforcingEntityLoaderMock.loadManyByFieldEqualityConjunctionAsync( + anything(), + anything(), + ), ).thenResolve([result(resolved)]); - const entityLoader = instance(entityLoaderMock); - const enforcingEntityLoader = new EnforcingEntityLoader(entityLoader); + const nonEnforcingEntityLoader = instance(nonEnforcingEntityLoaderMock); + const enforcingEntityLoader = new EnforcingEntityLoader(nonEnforcingEntityLoader); await expect( enforcingEntityLoader.loadManyByFieldEqualityConjunctionAsync(anything(), anything()), ).resolves.toEqual([resolved]); @@ -335,26 +404,38 @@ describe(EnforcingEntityLoader, () => { describe('loadManyByRawWhereClause', () => { it('throws when result is unsuccessful', async () => { - const entityLoaderMock = mock>(EntityLoader); + const nonEnforcingEntityLoaderMock = mock< + AuthorizationResultBasedEntityLoader + >(AuthorizationResultBasedEntityLoader); const rejection = new Error(); when( - entityLoaderMock.loadManyByRawWhereClauseAsync(anything(), anything(), anything()), + nonEnforcingEntityLoaderMock.loadManyByRawWhereClauseAsync( + anything(), + anything(), + anything(), + ), ).thenResolve([result(rejection)]); - const entityLoader = instance(entityLoaderMock); - const enforcingEntityLoader = new EnforcingEntityLoader(entityLoader); + const nonEnforcingEntityLoader = instance(nonEnforcingEntityLoaderMock); + const enforcingEntityLoader = new EnforcingEntityLoader(nonEnforcingEntityLoader); await expect( enforcingEntityLoader.loadManyByRawWhereClauseAsync(anything(), anything(), anything()), ).rejects.toThrow(rejection); }); it('returns value when result is successful', async () => { - const entityLoaderMock = mock>(EntityLoader); + const nonEnforcingEntityLoaderMock = mock< + AuthorizationResultBasedEntityLoader + >(AuthorizationResultBasedEntityLoader); const resolved = {}; when( - entityLoaderMock.loadManyByRawWhereClauseAsync(anything(), anything(), anything()), + nonEnforcingEntityLoaderMock.loadManyByRawWhereClauseAsync( + anything(), + anything(), + anything(), + ), ).thenResolve([result(resolved)]); - const entityLoader = instance(entityLoaderMock); - const enforcingEntityLoader = new EnforcingEntityLoader(entityLoader); + const nonEnforcingEntityLoader = instance(nonEnforcingEntityLoaderMock); + const enforcingEntityLoader = new EnforcingEntityLoader(nonEnforcingEntityLoader); await expect( enforcingEntityLoader.loadManyByRawWhereClauseAsync(anything(), anything(), anything()), ).resolves.toEqual([resolved]); @@ -363,11 +444,12 @@ describe(EnforcingEntityLoader, () => { it('has the same method names as EntityLoader', () => { const enforcingLoaderProperties = Object.getOwnPropertyNames(EnforcingEntityLoader.prototype); - const loaderProperties = Object.getOwnPropertyNames(EntityLoader.prototype); + const nonEnforcingLoaderProperties = Object.getOwnPropertyNames( + AuthorizationResultBasedEntityLoader.prototype, + ); // ensure known differences still exist for sanity check const knownLoaderOnlyDifferences = [ - 'enforcing', 'invalidateFieldsAsync', 'invalidateEntityAsync', 'tryConstructEntities', @@ -376,9 +458,11 @@ describe(EnforcingEntityLoader, () => { 'constructAndAuthorizeEntitiesArrayAsync', 'constructEntity', ]; - expect(loaderProperties).toEqual(expect.arrayContaining(knownLoaderOnlyDifferences)); + expect(nonEnforcingLoaderProperties).toEqual( + expect.arrayContaining(knownLoaderOnlyDifferences), + ); - const loaderPropertiesWithoutKnownDifferences = loaderProperties.filter( + const loaderPropertiesWithoutKnownDifferences = nonEnforcingLoaderProperties.filter( (p) => !knownLoaderOnlyDifferences.includes(p), ); diff --git a/packages/entity/src/__tests__/EntityCommonUseCases-test.ts b/packages/entity/src/__tests__/EntityCommonUseCases-test.ts index ad8410d2a..c7805de64 100644 --- a/packages/entity/src/__tests__/EntityCommonUseCases-test.ts +++ b/packages/entity/src/__tests__/EntityCommonUseCases-test.ts @@ -135,15 +135,21 @@ it('runs through a common workflow', async () => { // check that two people can't read each others data await expect( - enforceAsyncResult(BlahEntity.loader(vc1).loadByIDAsync(blahOwner2.getID())), + enforceAsyncResult( + BlahEntity.loader(vc1).withAuthorizationResults().loadByIDAsync(blahOwner2.getID()), + ), ).rejects.toBeInstanceOf(EntityNotAuthorizedError); await expect( - enforceAsyncResult(BlahEntity.loader(vc2).loadByIDAsync(blahOwner1.getID())), + enforceAsyncResult( + BlahEntity.loader(vc2).withAuthorizationResults().loadByIDAsync(blahOwner1.getID()), + ), ).rejects.toBeInstanceOf(EntityNotAuthorizedError); // check that all of owner 1's objects can be loaded const results = await enforceResultsAsync( - BlahEntity.loader(vc1).loadManyByFieldEqualingAsync('ownerID', vc1.getUserID()!), + BlahEntity.loader(vc1) + .withAuthorizationResults() + .loadManyByFieldEqualingAsync('ownerID', vc1.getUserID()!), ); expect(results).toHaveLength(2); @@ -155,7 +161,9 @@ it('runs through a common workflow', async () => { ).rejects.toBeInstanceOf(EntityNotAuthorizedError); // check that empty load many returns nothing - const results2 = await BlahEntity.loader(vc1).loadManyByFieldEqualingManyAsync('ownerID', []); + const results2 = await BlahEntity.loader(vc1) + .withAuthorizationResults() + .loadManyByFieldEqualingManyAsync('ownerID', []); for (const value in results2.values) { expect(value).toHaveLength(0); } diff --git a/packages/entity/src/__tests__/EntityLoader-constructor-test.ts b/packages/entity/src/__tests__/EntityLoader-constructor-test.ts index 8c4d07105..906c171f7 100644 --- a/packages/entity/src/__tests__/EntityLoader-constructor-test.ts +++ b/packages/entity/src/__tests__/EntityLoader-constructor-test.ts @@ -180,14 +180,16 @@ describe(EntityLoader, () => { let capturedThrownThing1: any; try { - await entityLoader.loadByIDAsync(ID_SENTINEL_THROW_LITERAL); + await entityLoader.withAuthorizationResults().loadByIDAsync(ID_SENTINEL_THROW_LITERAL); } catch (e) { capturedThrownThing1 = e; } expect(capturedThrownThing1).not.toBeInstanceOf(Error); expect(capturedThrownThing1).toEqual('hello'); - const result = await entityLoader.loadByIDAsync(ID_SENTINEL_THROW_ERROR); + const result = await entityLoader + .withAuthorizationResults() + .loadByIDAsync(ID_SENTINEL_THROW_ERROR); expect(result.ok).toBe(false); expect(result.enforceError().message).toEqual('world'); }); diff --git a/packages/entity/src/__tests__/EntityLoader-test.ts b/packages/entity/src/__tests__/EntityLoader-test.ts index 137b2a90e..f3ebd2c09 100644 --- a/packages/entity/src/__tests__/EntityLoader-test.ts +++ b/packages/entity/src/__tests__/EntityLoader-test.ts @@ -84,20 +84,26 @@ describe(EntityLoader, () => { dataManager, metricsAdapter, ); - const entity = await enforceAsyncResult(entityLoader.loadByIDAsync(id1)); + const entity = await enforceAsyncResult( + entityLoader.withAuthorizationResults().loadByIDAsync(id1), + ); expect(entity.getID()).toEqual(id1); expect(entity.getField('dateField')).toEqual(dateToInsert); const entities = await enforceResultsAsync( - entityLoader.loadManyByFieldEqualingAsync('stringField', 'huh'), + entityLoader.withAuthorizationResults().loadManyByFieldEqualingAsync('stringField', 'huh'), ); expect(entities.map((m) => m.getID())).toEqual([id1, id2]); - const entityResultNumber3 = await entityLoader.loadByFieldEqualingAsync('intField', 3); + const entityResultNumber3 = await entityLoader + .withAuthorizationResults() + .loadByFieldEqualingAsync('intField', 3); expect(entityResultNumber3).not.toBeNull(); expect(entityResultNumber3!.enforceValue().getID()).toEqual(id2); - const entityResultNumber4 = await entityLoader.loadByFieldEqualingAsync('intField', 4); + const entityResultNumber4 = await entityLoader + .withAuthorizationResults() + .loadByFieldEqualingAsync('intField', 4); expect(entityResultNumber4).toBeNull(); const entityResultDuplicateValues = await entityLoader @@ -106,25 +112,33 @@ describe(EntityLoader, () => { expect(entityResultDuplicateValues.size).toBe(1); expect(entityResultDuplicateValues.get('huh')?.map((m) => m.getID())).toEqual([id1, id2]); - await expect(entityLoader.loadByFieldEqualingAsync('stringField', 'huh')).rejects.toThrowError( + await expect( + entityLoader.withAuthorizationResults().loadByFieldEqualingAsync('stringField', 'huh'), + ).rejects.toThrowError( 'loadByFieldEqualing: Multiple entities of type TestEntity found for stringField=huh', ); - await expect(entityLoader.loadByIDNullableAsync(uuidv4())).resolves.toBeNull(); - await expect(entityLoader.loadByIDNullableAsync(id1)).resolves.not.toBeNull(); + await expect( + entityLoader.withAuthorizationResults().loadByIDNullableAsync(uuidv4()), + ).resolves.toBeNull(); + await expect( + entityLoader.withAuthorizationResults().loadByIDNullableAsync(id1), + ).resolves.not.toBeNull(); const nonExistentId = uuidv4(); - const manyIdResults = await entityLoader.loadManyByIDsNullableAsync([nonExistentId, id1]); + const manyIdResults = await entityLoader + .withAuthorizationResults() + .loadManyByIDsNullableAsync([nonExistentId, id1]); expect(manyIdResults.get(nonExistentId)).toBeNull(); expect(manyIdResults.get(id1)).not.toBeNull(); - await expect(enforceAsyncResult(entityLoader.loadByIDAsync(nonExistentId))).rejects.toThrow( - EntityNotFoundError, - ); + await expect( + enforceAsyncResult(entityLoader.withAuthorizationResults().loadByIDAsync(nonExistentId)), + ).rejects.toThrow(EntityNotFoundError); - await expect(entityLoader.loadByIDAsync('not-a-uuid')).rejects.toThrowError( - 'Entity field not valid: TestEntity (customIdField = not-a-uuid)', - ); + await expect( + entityLoader.withAuthorizationResults().loadByIDAsync('not-a-uuid'), + ).rejects.toThrowError('Entity field not valid: TestEntity (customIdField = not-a-uuid)'); }); it('loads entities with loadManyByFieldEqualityConjunction', async () => { @@ -200,7 +214,7 @@ describe(EntityLoader, () => { metricsAdapter, ); const entities = await enforceResultsAsync( - entityLoader.loadManyByFieldEqualityConjunctionAsync([ + entityLoader.withAuthorizationResults().loadManyByFieldEqualityConjunctionAsync([ { fieldName: 'stringField', fieldValue: 'huh', @@ -223,9 +237,11 @@ describe(EntityLoader, () => { ).twice(); await expect( - entityLoader.loadManyByFieldEqualityConjunctionAsync([ - { fieldName: 'customIdField', fieldValue: 'not-a-uuid' }, - ]), + entityLoader + .withAuthorizationResults() + .loadManyByFieldEqualityConjunctionAsync([ + { fieldName: 'customIdField', fieldValue: 'not-a-uuid' }, + ]), ).rejects.toThrowError('Entity field not valid: TestEntity (customIdField = not-a-uuid)'); }); @@ -301,19 +317,21 @@ describe(EntityLoader, () => { dataManager, metricsAdapter, ); - const result = await entityLoader.loadFirstByFieldEqualityConjunctionAsync( - [ - { - fieldName: 'stringField', - fieldValue: 'huh', - }, - { - fieldName: 'intField', - fieldValue: 4, - }, - ], - { orderBy: [{ fieldName: 'testIndexedField', order: OrderByOrdering.DESCENDING }] }, - ); + const result = await entityLoader + .withAuthorizationResults() + .loadFirstByFieldEqualityConjunctionAsync( + [ + { + fieldName: 'stringField', + fieldValue: 'huh', + }, + { + fieldName: 'intField', + fieldValue: 4, + }, + ], + { orderBy: [{ fieldName: 'testIndexedField', order: OrderByOrdering.DESCENDING }] }, + ); expect(result).not.toBeNull(); expect(result!.ok).toBe(true); expect(result!.enforceValue().getField('testIndexedField')).toEqual('5'); @@ -369,9 +387,11 @@ describe(EntityLoader, () => { dataManager, metricsAdapter, ); - const result = await entityLoader.loadManyByRawWhereClauseAsync('id = ?', [1], { - orderBy: [{ fieldName: 'testIndexedField', order: OrderByOrdering.DESCENDING }], - }); + const result = await entityLoader + .withAuthorizationResults() + .loadManyByRawWhereClauseAsync('id = ?', [1], { + orderBy: [{ fieldName: 'testIndexedField', order: OrderByOrdering.DESCENDING }], + }); expect(result).toHaveLength(1); expect(result[0]).not.toBeNull(); expect(result[0]!.ok).toBe(true); @@ -442,7 +462,9 @@ describe(EntityLoader, () => { dataManager, metricsAdapter, ); - const entity = await enforceAsyncResult(entityLoader.loadByIDAsync(id1)); + const entity = await enforceAsyncResult( + entityLoader.withAuthorizationResults().loadByIDAsync(id1), + ); verify( spiedPrivacyPolicy.authorizeReadAsync( viewerContext, @@ -478,7 +500,9 @@ describe(EntityLoader, () => { dataManagerInstance, metricsAdapter, ); - await entityLoader.invalidateFieldsAsync({ customIdField: id1 } as any); + await entityLoader + .withAuthorizationResults() + .invalidateFieldsAsync({ customIdField: id1 } as any); verify( dataManagerMock.invalidateObjectFieldsAsync(deepEqual({ customIdField: id1 } as any)), @@ -509,7 +533,9 @@ describe(EntityLoader, () => { dataManagerInstance, metricsAdapter, ); - await entityLoader.invalidateFieldsAsync({ customIdField: id1 } as any); + await entityLoader + .withAuthorizationResults() + .invalidateFieldsAsync({ customIdField: id1 } as any); verify( dataManagerMock.invalidateObjectFieldsAsync(deepEqual({ customIdField: id1 } as any)), ).once(); @@ -543,7 +569,7 @@ describe(EntityLoader, () => { dataManagerInstance, metricsAdapter, ); - await entityLoader.invalidateEntityAsync(entityInstance); + await entityLoader.withAuthorizationResults().invalidateEntityAsync(entityInstance); verify( dataManagerMock.invalidateObjectFieldsAsync(deepEqual({ customIdField: id1 } as any)), ).once(); @@ -592,7 +618,7 @@ describe(EntityLoader, () => { metricsAdapter, ); - const entityResult = await entityLoader.loadByIDAsync(id1); + const entityResult = await entityLoader.withAuthorizationResults().loadByIDAsync(id1); expect(entityResult.ok).toBe(false); expect(entityResult.reason).toEqual(rejectionError); expect(entityResult.value).toBe(undefined); @@ -631,22 +657,32 @@ describe(EntityLoader, () => { const loadByValue = uuidv4(); - await expect(entityLoader.loadByIDAsync(loadByValue)).rejects.toEqual(error); + await expect( + entityLoader.withAuthorizationResults().loadByIDAsync(loadByValue), + ).rejects.toEqual(error); await expect(entityLoader.enforcing().loadByIDAsync(loadByValue)).rejects.toEqual(error); - await expect(entityLoader.loadManyByIDsAsync([loadByValue])).rejects.toEqual(error); + await expect( + entityLoader.withAuthorizationResults().loadManyByIDsAsync([loadByValue]), + ).rejects.toEqual(error); await expect(entityLoader.enforcing().loadManyByIDsAsync([loadByValue])).rejects.toEqual(error); - await expect(entityLoader.loadManyByIDsNullableAsync([loadByValue])).rejects.toEqual(error); + await expect( + entityLoader.withAuthorizationResults().loadManyByIDsNullableAsync([loadByValue]), + ).rejects.toEqual(error); await expect( entityLoader.enforcing().loadManyByIDsNullableAsync([loadByValue]), ).rejects.toEqual(error); await expect( - entityLoader.loadManyByFieldEqualingAsync('customIdField', loadByValue), + entityLoader + .withAuthorizationResults() + .loadManyByFieldEqualingAsync('customIdField', loadByValue), ).rejects.toEqual(error); await expect( entityLoader.enforcing().loadManyByFieldEqualingAsync('customIdField', loadByValue), ).rejects.toEqual(error); await expect( - entityLoader.loadManyByFieldEqualingManyAsync('customIdField', [loadByValue]), + entityLoader + .withAuthorizationResults() + .loadManyByFieldEqualingManyAsync('customIdField', [loadByValue]), ).rejects.toEqual(error); await expect( entityLoader.enforcing().loadManyByFieldEqualingManyAsync('customIdField', [loadByValue]), diff --git a/packages/entity/src/__tests__/EntityMutator-test.ts b/packages/entity/src/__tests__/EntityMutator-test.ts index d8f29d203..2c070d08f 100644 --- a/packages/entity/src/__tests__/EntityMutator-test.ts +++ b/packages/entity/src/__tests__/EntityMutator-test.ts @@ -12,6 +12,7 @@ import { } from 'ts-mockito'; import { v4 as uuidv4 } from 'uuid'; +import AuthorizationResultBasedEntityLoader from '../AuthorizationResultBasedEntityLoader'; import EntityCompanionProvider from '../EntityCompanionProvider'; import EntityConfiguration from '../EntityConfiguration'; import EntityDatabaseAdapter from '../EntityDatabaseAdapter'; @@ -568,6 +569,7 @@ describe(EntityMutatorFactory, () => { const existingEntity = await enforceAsyncResult( entityLoaderFactory .forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext) + .withAuthorizationResults() .loadByIDAsync(id2), ); @@ -583,6 +585,7 @@ describe(EntityMutatorFactory, () => { const reloadedEntity = await enforceAsyncResult( entityLoaderFactory .forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext) + .withAuthorizationResults() .loadByIDAsync(id2), ); expect(reloadedEntity.getAllFields()).toMatchObject(updatedEntity.getAllFields()); @@ -619,6 +622,7 @@ describe(EntityMutatorFactory, () => { const existingEntity = await enforceAsyncResult( entityLoaderFactory .forLoad(viewerContext, queryContext, { previousValue: null, cascadingDeleteCause: null }) + .withAuthorizationResults() .loadByIDAsync(id2), ); @@ -691,6 +695,7 @@ describe(EntityMutatorFactory, () => { const existingEntity = await enforceAsyncResult( entityLoaderFactory .forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext) + .withAuthorizationResults() .loadByIDAsync(id2), ); @@ -761,6 +766,7 @@ describe(EntityMutatorFactory, () => { const existingEntity = await enforceAsyncResult( entityLoaderFactory .forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext) + .withAuthorizationResults() .loadByIDAsync(id2), ); @@ -807,6 +813,7 @@ describe(EntityMutatorFactory, () => { const existingEntity = await enforceAsyncResult( entityLoaderFactory .forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext) + .withAuthorizationResults() .loadByIDAsync(id1), ); @@ -820,6 +827,7 @@ describe(EntityMutatorFactory, () => { const reloadedEntity = await enforceAsyncResult( entityLoaderFactory .forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext) + .withAuthorizationResults() .loadByIDAsync(id1), ); expect(reloadedEntity.getAllFields()).toMatchObject(existingEntity.getAllFields()); @@ -858,6 +866,7 @@ describe(EntityMutatorFactory, () => { const existingEntity = await enforceAsyncResult( entityLoaderFactory .forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext) + .withAuthorizationResults() .loadByIDAsync(id1), ); expect(existingEntity).toBeTruthy(); @@ -868,6 +877,7 @@ describe(EntityMutatorFactory, () => { enforceAsyncResult( entityLoaderFactory .forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext) + .withAuthorizationResults() .loadByIDAsync(id1), ), ).rejects.toBeInstanceOf(Error); @@ -907,6 +917,7 @@ describe(EntityMutatorFactory, () => { const existingEntity = await enforceAsyncResult( entityLoaderFactory .forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext) + .withAuthorizationResults() .loadByIDAsync(id1), ); @@ -957,6 +968,7 @@ describe(EntityMutatorFactory, () => { const existingEntity = await enforceAsyncResult( entityLoaderFactory .forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext) + .withAuthorizationResults() .loadByIDAsync(id1), ); @@ -1011,6 +1023,7 @@ describe(EntityMutatorFactory, () => { const existingEntity = await enforceAsyncResult( entityLoaderFactory .forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext) + .withAuthorizationResults() .loadByIDAsync(id1), ); @@ -1053,6 +1066,7 @@ describe(EntityMutatorFactory, () => { const entites1 = await enforceResultsAsync( entityLoaderFactory .forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext) + .withAuthorizationResults() .loadManyByFieldEqualingAsync('stringField', 'huh'), ); expect(entites1).toHaveLength(1); @@ -1067,6 +1081,7 @@ describe(EntityMutatorFactory, () => { const entities2 = await enforceResultsAsync( entityLoaderFactory .forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext) + .withAuthorizationResults() .loadManyByFieldEqualingAsync('stringField', 'huh'), ); expect(entities2).toHaveLength(2); @@ -1138,7 +1153,20 @@ describe(EntityMutatorFactory, () => { keyof SimpleTestFields > >(EntityLoader); - when(entityLoaderMock.constructEntity(anything())).thenReturn(fakeEntity); + const nonEnforcingEntityLoaderMock = mock< + AuthorizationResultBasedEntityLoader< + SimpleTestFields, + string, + ViewerContext, + SimpleTestEntity, + SimpleTestEntityPrivacyPolicy, + keyof SimpleTestFields + > + >(AuthorizationResultBasedEntityLoader); + when(nonEnforcingEntityLoaderMock.constructEntity(anything())).thenReturn(fakeEntity); + when(entityLoaderMock.withAuthorizationResults()).thenReturn( + instance(nonEnforcingEntityLoaderMock), + ); const entityLoader = instance(entityLoaderMock); const entityLoaderFactoryMock = @@ -1262,7 +1290,20 @@ describe(EntityMutatorFactory, () => { keyof SimpleTestFields > >(EntityLoader); - when(entityLoaderMock.constructEntity(anything())).thenReturn(fakeEntity); + const nonEnforcingEntityLoaderMock = mock< + AuthorizationResultBasedEntityLoader< + SimpleTestFields, + string, + ViewerContext, + SimpleTestEntity, + SimpleTestEntityPrivacyPolicy, + keyof SimpleTestFields + > + >(AuthorizationResultBasedEntityLoader); + when(nonEnforcingEntityLoaderMock.constructEntity(anything())).thenReturn(fakeEntity); + when(entityLoaderMock.withAuthorizationResults()).thenReturn( + instance(nonEnforcingEntityLoaderMock), + ); const entityLoader = instance(entityLoaderMock); const entityLoaderFactoryMock = diff --git a/packages/entity/src/__tests__/cases/TwoEntitySameTableDisjointRows-test.ts b/packages/entity/src/__tests__/cases/TwoEntitySameTableDisjointRows-test.ts index c22507c46..be8b1d193 100644 --- a/packages/entity/src/__tests__/cases/TwoEntitySameTableDisjointRows-test.ts +++ b/packages/entity/src/__tests__/cases/TwoEntitySameTableDisjointRows-test.ts @@ -35,10 +35,9 @@ describe('Two entities backed by the same table', () => { OneTestEntity.loader(viewerContext).enforcing().loadByIDAsync(two.getID()), ).rejects.toThrowError('OneTestEntity must be instantiated with one data'); - const manyResults = await OneTestEntity.loader(viewerContext).loadManyByFieldEqualingAsync( - 'common_other_field', - 'wat', - ); + const manyResults = await OneTestEntity.loader(viewerContext) + .withAuthorizationResults() + .loadManyByFieldEqualingAsync('common_other_field', 'wat'); const successfulManyResults = successfulResults(manyResults); const failedManyResults = failedResults(manyResults); @@ -50,14 +49,14 @@ describe('Two entities backed by the same table', () => { 'OneTestEntity must be instantiated with one data', ); - const fieldEqualityConjunctionResults = await OneTestEntity.loader( - viewerContext, - ).loadManyByFieldEqualityConjunctionAsync([ - { - fieldName: 'common_other_field', - fieldValue: 'wat', - }, - ]); + const fieldEqualityConjunctionResults = await OneTestEntity.loader(viewerContext) + .withAuthorizationResults() + .loadManyByFieldEqualityConjunctionAsync([ + { + fieldName: 'common_other_field', + fieldValue: 'wat', + }, + ]); const successfulfieldEqualityConjunctionResultsResults = successfulResults( fieldEqualityConjunctionResults, ); diff --git a/packages/entity/src/utils/EntityPrivacyUtils.ts b/packages/entity/src/utils/EntityPrivacyUtils.ts index da86459ab..4155acd34 100644 --- a/packages/entity/src/utils/EntityPrivacyUtils.ts +++ b/packages/entity/src/utils/EntityPrivacyUtils.ts @@ -254,12 +254,14 @@ async function canViewerDeleteInternalAsync< continue; } - const entityResultsForInboundEdge = await loader.loadManyByFieldEqualingAsync( - fieldName, - association.associatedEntityLookupByField - ? sourceEntity.getField(association.associatedEntityLookupByField as any) - : sourceEntity.getID(), - ); + const entityResultsForInboundEdge = await loader + .withAuthorizationResults() + .loadManyByFieldEqualingAsync( + fieldName, + association.associatedEntityLookupByField + ? sourceEntity.getField(association.associatedEntityLookupByField as any) + : sourceEntity.getID(), + ); const failedEntityLoadResults = failedResults(entityResultsForInboundEdge); for (const failedResult of failedEntityLoadResults) {