From 10857d5bbd6f7cb59a58641e0e8a3cb5dc0080e9 Mon Sep 17 00:00:00 2001 From: Nick Arocho <16296496+nickarocho@users.noreply.github.com> Date: Thu, 1 Jul 2021 09:06:26 -0700 Subject: [PATCH] feat(@aws-amplify/datastore): expose timestamp fields and prevent writing to read-only fields (#8509) * fix(@aws-amplify/datastore): expose timestamp fields and prevent writing to read-only fields * fix(@aws-amplify/datastore): add model meta data type * fix(@aws-amplify/datastore): fix compilation errors * fix(@aws-amplify/datastore): expose timestamp fields and prevent writing to read-only fields * fix(@aws-amplify/datastore): add model meta data type * fix(@aws-amplify/datastore): fix compilation errors * test(@aws-amplify/datastore): bypass TS compilation errors for unit tests --- packages/datastore/__tests__/DataStore.ts | 61 +++++++++++++++++++ packages/datastore/__tests__/helpers.ts | 18 ++++++ packages/datastore/src/datastore/datastore.ts | 46 +++++++++++++- packages/datastore/src/types.ts | 35 +++++++++-- 4 files changed, 154 insertions(+), 6 deletions(-) diff --git a/packages/datastore/__tests__/DataStore.ts b/packages/datastore/__tests__/DataStore.ts index 025bf182bdc..3339d7709e7 100644 --- a/packages/datastore/__tests__/DataStore.ts +++ b/packages/datastore/__tests__/DataStore.ts @@ -528,6 +528,67 @@ describe('DataStore tests', () => { expect(patches2).toMatchObject(expectedPatches2); }); + test('Read-only fields cannot be overwritten', async () => { + let model: Model; + const save = jest.fn(() => [model]); + const query = jest.fn(() => [model]); + + jest.resetModules(); + jest.doMock('../src/storage/storage', () => { + const mock = jest.fn().mockImplementation(() => { + const _mock = { + init: jest.fn(), + save, + query, + runExclusive: jest.fn(fn => fn.bind(this, _mock)()), + }; + + return _mock; + }); + + (mock).getNamespace = () => ({ models: {} }); + + return { ExclusiveStorage: mock }; + }); + + ({ initSchema, DataStore } = require('../src/datastore/datastore')); + + const classes = initSchema(testSchema()); + + const { Model } = classes as { Model: PersistentModelConstructor }; + + model = new Model({ + field1: 'something', + dateCreated: new Date().toISOString(), + createdAt: '2021-06-03T20:56:23.201Z', + } as any); + + await expect(DataStore.save(model)).rejects.toThrowError( + 'createdAt is read-only.' + ); + + model = new Model({ + field1: 'something', + dateCreated: new Date().toISOString(), + }); + + model = Model.copyOf(model, draft => { + (draft as any).createdAt = '2021-06-03T20:56:23.201Z'; + }); + + await expect(DataStore.save(model)).rejects.toThrowError( + 'createdAt is read-only.' + ); + + model = Model.copyOf(model, draft => { + (draft as any).updatedAt = '2021-06-03T20:56:23.201Z'; + }); + + await expect(DataStore.save(model)).rejects.toThrowError( + 'updatedAt is read-only.' + ); + }); + test('Instantiation validations', async () => { expect(() => { new Model({ diff --git a/packages/datastore/__tests__/helpers.ts b/packages/datastore/__tests__/helpers.ts index 735f093cecb..01ce1f08701 100644 --- a/packages/datastore/__tests__/helpers.ts +++ b/packages/datastore/__tests__/helpers.ts @@ -8,6 +8,8 @@ export declare class Model { public readonly emails?: string[]; public readonly ips?: (string | null)[]; public readonly metadata?: Metadata; + public readonly createdAt?: string; + public readonly updatedAt?: string; constructor(init: ModelInit); @@ -136,6 +138,22 @@ export function testSchema(): Schema { isRequired: false, attributes: [], }, + createdAt: { + name: 'createdAt', + isArray: false, + type: 'AWSDateTime', + isRequired: false, + attributes: [], + isReadOnly: true, + }, + updatedAt: { + name: 'updatedAt', + isArray: false, + type: 'AWSDateTime', + isRequired: false, + attributes: [], + isReadOnly: true, + }, }, }, Post: { diff --git a/packages/datastore/src/datastore/datastore.ts b/packages/datastore/src/datastore/datastore.ts index c16c226a964..efe4ae2f958 100644 --- a/packages/datastore/src/datastore/datastore.ts +++ b/packages/datastore/src/datastore/datastore.ts @@ -49,6 +49,7 @@ import { ErrorHandler, SyncExpression, AuthModeStrategyType, + ModelFields, } from '../types'; import { DATASTORE, @@ -406,7 +407,7 @@ const createModelClass = ( const model = produce( source, draft => { - fn(>draft); + fn(>(draft as unknown)); draft.id = source.id; const modelValidator = validateModelFields(modelDefinition); Object.entries(draft).forEach(([k, v]) => { @@ -813,6 +814,9 @@ class DataStore { const modelDefinition = getModelDefinition(modelConstructor); + // ensuring "read-only" data isn't being overwritten + this.checkReadOnlyProperty(modelDefinition.fields, model, patchesTuple); + const producedCondition = ModelPredicateCreator.createFromExisting( modelDefinition, condition @@ -830,6 +834,46 @@ class DataStore { return savedModel; }; + private checkReadOnlyProperty( + fields: ModelFields, + model: Record, + patchesTuple: [ + Patch[], + Readonly< + { + id: string; + } & Record + > + ] + ) { + if (!patchesTuple) { + // saving a new model instance + const modelKeys = Object.keys(model); + modelKeys.forEach(key => { + if (fields[key] && fields[key].isReadOnly) { + throw new Error(`${key} is read-only.`); + } + }); + } else { + // * Updating an existing instance via 'patchesTuple' + // patchesTuple[0] is an object that contains the info we need + // like the 'path' (mapped to the model's key) and the 'value' of the patch + const patchArray = patchesTuple[0].map(p => [p.path[0], p.value]); + patchArray.forEach(patch => { + const [key, val] = [...patch]; + + // the value of a read-only field should be undefined - if so, no need to do the following check + if (!val || !fields[key]) return; + + // if the value is NOT undefined, we have to check the 'isReadOnly' property + // and throw an error to avoid persisting a mutation + if (fields[key].isReadOnly) { + throw new Error(`${key} is read-only.`); + } + }); + } + } + setConflictHandler = (config: DataStoreConfig): ConflictHandler => { const { DataStore: configDataStore } = config; diff --git a/packages/datastore/src/types.ts b/packages/datastore/src/types.ts index 9ef8cfeba08..6223d83986c 100644 --- a/packages/datastore/src/types.ts +++ b/packages/datastore/src/types.ts @@ -287,6 +287,7 @@ type ModelField = { | EnumFieldType; isArray: boolean; isRequired?: boolean; + isReadOnly?: boolean; isArrayNullable?: boolean; association?: ModelAssociation; attributes?: ModelAttributes[]; @@ -299,24 +300,48 @@ export type NonModelTypeConstructor = { }; // Class for model -export type PersistentModelConstructor = { - new (init: ModelInit): T; - copyOf(src: T, mutator: (draft: MutableModel) => void): T; +export type PersistentModelConstructor< + T extends PersistentModel, + K extends PersistentModelMetaData = { + readOnlyFields: 'createdAt' | 'updatedAt'; + } +> = { + new (init: ModelInit): T; + copyOf(src: T, mutator: (draft: MutableModel) => void): T; }; + export type TypeConstructorMap = Record< string, PersistentModelConstructor | NonModelTypeConstructor >; // Instance of model +export type PersistentModelMetaData = { + readOnlyFields: string; +}; + export type PersistentModel = Readonly<{ id: string } & Record>; -export type ModelInit = Omit; +export type ModelInit< + T, + K extends PersistentModelMetaData = { + readOnlyFields: 'createdAt' | 'updatedAt'; + } +> = Omit; type DeepWritable = { -readonly [P in keyof T]: T[P] extends TypeName ? T[P] : DeepWritable; }; -export type MutableModel = Omit, 'id'>; + +export type MutableModel< + T extends Record, + K extends PersistentModelMetaData = { + readOnlyFields: 'createdAt' | 'updatedAt'; + } + // This provides Intellisense with ALL of the properties, regardless of read-only + // but will throw a linting error if trying to overwrite a read-only property +> = DeepWritable> & + Readonly>; export type ModelInstanceMetadata = { id: string;