Skip to content

Commit

Permalink
feat: add basic field type runtime validators (#113)
Browse files Browse the repository at this point in the history
  • Loading branch information
wschurman authored Feb 8, 2021
1 parent 5a8479f commit 6c4d4b0
Show file tree
Hide file tree
Showing 20 changed files with 519 additions and 152 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export default class PostgresEntityDatabaseAdapter<TFields> extends EntityDataba

if (tableFieldMultiValueEqualityOperands.length > 0) {
for (const { tableField, tableValues } of tableFieldMultiValueEqualityOperands) {
query = query.whereRaw('?? = ANY(?)', [tableField, tableValues]);
query = query.whereRaw('?? = ANY(?)', [tableField, [...tableValues]]);
}
}

Expand Down
8 changes: 5 additions & 3 deletions packages/entity-example/src/__tests__/NoteEntity-test.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
import { createUnitTestEntityCompanionProvider } from '@expo/entity';
import { v4 as uuidv4 } from 'uuid';

import NoteEntity from '../entities/NoteEntity';
import { UserViewerContext } from '../viewerContexts';

describe(NoteEntity, () => {
test('demonstrate usage of business logic test utilities', async () => {
const companionProvider = createUnitTestEntityCompanionProvider();
const viewerContext = new UserViewerContext(companionProvider, '4');
const userId = uuidv4();
const viewerContext = new UserViewerContext(companionProvider, userId);

const createdEntityResult = await NoteEntity.creator(viewerContext)
.setField('userID', '4')
.setField('userID', userId)
.setField('body', 'image')
.setField('title', 'page')
.createAsync();
expect(createdEntityResult.ok).toBe(true);

const createdEntityResultImpersonate = await NoteEntity.creator(viewerContext)
.setField('userID', '5')
.setField('userID', uuidv4()) // a different user
.setField('body', 'image')
.setField('title', 'page')
.createAsync();
Expand Down
40 changes: 25 additions & 15 deletions packages/entity-example/src/__tests__/graphql-integration-test.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
import http from 'http';
import request from 'supertest';
import { v4 as uuidv4 } from 'uuid';

import app from '../app';

describe('graphql', () => {
it('allows CRUD of user notes', async () => {
const server = request(http.createServer(app.callback()));

const responseEmpty = await server.post('/graphql').set('totally-secure-user-id', '4').send({
const userId = uuidv4();
const responseEmpty = await server.post('/graphql').set('totally-secure-user-id', userId).send({
query: `query { me { notes { id } } }`,
});
expect(responseEmpty.body.data.me.notes).toEqual([]);

const createResponse1 = await server
.post('/graphql')
.set('totally-secure-user-id', '4')
.set('totally-secure-user-id', userId)
.send({
query: `mutation($note: NoteInput!) { addNote(note: $note) { id, title, body } }`,
variables: {
Expand All @@ -31,7 +33,7 @@ describe('graphql', () => {

const createResponse2 = await server
.post('/graphql')
.set('totally-secure-user-id', '4')
.set('totally-secure-user-id', userId)
.send({
query: `mutation($note: NoteInput!) { addNote(note: $note) { id, title, body } }`,
variables: {
Expand All @@ -42,14 +44,17 @@ describe('graphql', () => {
},
});

const responseLength2 = await server.post('/graphql').set('totally-secure-user-id', '4').send({
query: `query { me { notes { id } } }`,
});
const responseLength2 = await server
.post('/graphql')
.set('totally-secure-user-id', userId)
.send({
query: `query { me { notes { id } } }`,
});
expect(responseLength2.body.data.me.notes).toHaveLength(2);

const responseGetSingle = await server
.post('/graphql')
.set('totally-secure-user-id', '4')
.set('totally-secure-user-id', userId)
.send({
query: `query($id: ID!) { noteByID(id: $id) { id, title, body } }`,
variables: {
Expand All @@ -64,7 +69,7 @@ describe('graphql', () => {

const responseUpdateSingle = await server
.post('/graphql')
.set('totally-secure-user-id', '4')
.set('totally-secure-user-id', userId)
.send({
query: `mutation($id: ID!, $note: NoteInput!) { updateNote(id: $id, note: $note) { id, title, body } }`,
variables: {
Expand All @@ -84,7 +89,7 @@ describe('graphql', () => {

const responseDeleteSingle = await server
.post('/graphql')
.set('totally-secure-user-id', '4')
.set('totally-secure-user-id', userId)
.send({
query: `mutation($id: ID!) { deleteNote(id: $id) { id, title, body } }`,
variables: {
Expand All @@ -102,9 +107,12 @@ describe('graphql', () => {
body: 'boarding',
});

const responseLength1 = await server.post('/graphql').set('totally-secure-user-id', '4').send({
query: `query { me { notes { id } } }`,
});
const responseLength1 = await server
.post('/graphql')
.set('totally-secure-user-id', userId)
.send({
query: `query { me { notes { id } } }`,
});
expect(responseLength1.body.data.me.notes).toHaveLength(1);
});

Expand All @@ -127,9 +135,11 @@ describe('graphql', () => {
it('disallows cross-user note impersonation', async () => {
const server = request(http.createServer(app.callback()));

const userId = uuidv4();
const userId2 = uuidv4();
const createResponse1 = await server
.post('/graphql')
.set('totally-secure-user-id', '4')
.set('totally-secure-user-id', userId)
.send({
query: `mutation($note: NoteInput!) { addNote(note: $note) { id, title, body } }`,
variables: {
Expand All @@ -143,7 +153,7 @@ describe('graphql', () => {
// notes are public
const responseGetSingle = await server
.post('/graphql')
.set('totally-secure-user-id', '5')
.set('totally-secure-user-id', userId2)
.send({
query: `query($id: ID!) { noteByID(id: $id) { id, title, body } }`,
variables: {
Expand All @@ -159,7 +169,7 @@ describe('graphql', () => {
// but can only be updated by the owner
const responseUpdateSingle = await server
.post('/graphql')
.set('totally-secure-user-id', '5')
.set('totally-secure-user-id', userId2)
.send({
query: `mutation($id: ID!, $note: NoteInput!) { updateNote(id: $id, note: $note) { id, title, body } }`,
variables: {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import http from 'http';
import request from 'supertest';
import { v4 as uuidv4 } from 'uuid';

import app from '../app';

Expand All @@ -11,59 +12,74 @@ describe('notesRouter', () => {

it('allows CRUD of user notes', async () => {
const server = request(http.createServer(app.callback()));
const responseEmpty = await server.get('/notes/').set('totally-secure-user-id', '4').send();

const userId = uuidv4();

const responseEmpty = await server.get('/notes/').set('totally-secure-user-id', userId).send();
expect(responseEmpty.body.notes).toEqual([]);

const createResponse1 = await server.post('/notes/').set('totally-secure-user-id', '4').send({
title: 'track',
body: 'language',
});
const createResponse1 = await server
.post('/notes/')
.set('totally-secure-user-id', userId)
.send({
title: 'track',
body: 'language',
});
expect(createResponse1.body.note).toMatchObject({
userID: '4',
userID: userId,
title: 'track',
body: 'language',
});

const createResponse2 = await server.post('/notes/').set('totally-secure-user-id', '4').send({
title: 'nine',
body: 'lotion',
});
const createResponse2 = await server
.post('/notes/')
.set('totally-secure-user-id', userId)
.send({
title: 'nine',
body: 'lotion',
});

const responseLength2 = await server.get('/notes/').set('totally-secure-user-id', '4').send();
const responseLength2 = await server
.get('/notes/')
.set('totally-secure-user-id', userId)
.send();
expect(responseLength2.body.notes).toHaveLength(2);

const responseGetSingle = await server
.get(`/notes/${createResponse2.body.note.id}`)
.set('totally-secure-user-id', '4')
.set('totally-secure-user-id', userId)
.send();
expect(responseGetSingle.body.note).toMatchObject({
id: createResponse2.body.note.id,
userID: '4',
userID: userId,
title: 'nine',
body: 'lotion',
});

const responseUpdateSingle = await server
.put(`/notes/${createResponse2.body.note.id}`)
.set('totally-secure-user-id', '4')
.set('totally-secure-user-id', userId)
.send({
title: 'wave',
body: 'boarding',
});
expect(responseUpdateSingle.body.note).toMatchObject({
id: responseGetSingle.body.note.id,
userID: '4',
userID: userId,
title: 'wave',
body: 'boarding',
});

const responseDeleteSingle = await server
.delete(`/notes/${createResponse2.body.note.id}`)
.set('totally-secure-user-id', '4')
.set('totally-secure-user-id', userId)
.send();
expect(responseDeleteSingle.body.status).toEqual('ok');

const responseLength1 = await server.get('/notes/').set('totally-secure-user-id', '4').send();
const responseLength1 = await server
.get('/notes/')
.set('totally-secure-user-id', userId)
.send();
expect(responseLength1.body.notes).toHaveLength(1);
});

Expand All @@ -79,23 +95,28 @@ describe('notesRouter', () => {

it('disallows cross-user note impersonation', async () => {
const server = request(http.createServer(app.callback()));
const createResponse1 = await server.post('/notes/').set('totally-secure-user-id', '4').send({
title: 'track',
body: 'language',
});
const userId = uuidv4();
const userId2 = uuidv4();
const createResponse1 = await server
.post('/notes/')
.set('totally-secure-user-id', userId)
.send({
title: 'track',
body: 'language',
});
expect(createResponse1.ok).toBe(true);

// notes are public
const responseGetSingleOtherUser = await server
.get(`/notes/${createResponse1.body.note.id}`)
.set('totally-secure-user-id', '5')
.set('totally-secure-user-id', userId2)
.send();
expect(responseGetSingleOtherUser.ok).toBe(true);

// but can only be updated by the owner
const responseUpdateSingle = await server
.put(`/notes/${createResponse1.body.note.id}`)
.set('totally-secure-user-id', '5')
.set('totally-secure-user-id', userId2)
.send({
title: 'wave',
body: 'boarding',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
EntityCompanionDefinition,
EntityConfiguration,
UUIDField,
StringField,
} from '@expo/entity';
import { RedisCacheAdapterContext } from '@expo/entity-cache-adapter-redis';
import Redis from 'ioredis';
Expand Down Expand Up @@ -60,11 +61,11 @@ const testEntityConfiguration = new EntityConfiguration<TestFields>({
columnName: 'id',
cache: true,
}),
other_string: new UUIDField({
other_string: new StringField({
columnName: 'other_string',
cache: true,
}),
third_string: new UUIDField({
third_string: new StringField({
columnName: 'third_string',
}),
},
Expand Down
2 changes: 1 addition & 1 deletion packages/entity/src/EntityCompanion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export default class EntityCompanion<
TPrivacyPolicy,
TSelectedFields
>(
tableDataCoordinator.entityConfiguration.idField as keyof Pick<TFields, TSelectedFields>,
tableDataCoordinator.entityConfiguration,
entityClass,
privacyPolicy,
tableDataCoordinator.dataManager
Expand Down
8 changes: 4 additions & 4 deletions packages/entity/src/EntityConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export default class EntityConfiguration<TFields> {
readonly cacheKeyVersion: number;

readonly getInboundEdges: () => IEntityClass<any, any, any, any, any, any>[];
readonly schema: ReadonlyMap<keyof TFields, EntityFieldDefinition>;
readonly schema: ReadonlyMap<keyof TFields, EntityFieldDefinition<any>>;
readonly entityToDBFieldsKeyMapping: ReadonlyMap<keyof TFields, string>;
readonly dbToEntityFieldsKeyMapping: ReadonlyMap<string, keyof TFields>;

Expand All @@ -32,7 +32,7 @@ export default class EntityConfiguration<TFields> {
}: {
idField: keyof TFields;
tableName: string;
schema: Record<keyof TFields, EntityFieldDefinition>;
schema: Record<keyof TFields, EntityFieldDefinition<any>>;
getInboundEdges?: () => IEntityClass<any, any, any, any, any, any>[];
cacheKeyVersion?: number;
databaseAdapterFlavor: DatabaseAdapterFlavor;
Expand All @@ -59,7 +59,7 @@ export default class EntityConfiguration<TFields> {
}

private static computeCacheableKeys<TFields>(
schema: ReadonlyMap<keyof TFields, EntityFieldDefinition>
schema: ReadonlyMap<keyof TFields, EntityFieldDefinition<any>>
): ReadonlySet<keyof TFields> {
return reduceMap(
schema,
Expand All @@ -74,7 +74,7 @@ export default class EntityConfiguration<TFields> {
}

private static computeEntityToDBFieldsKeyMapping<TFields>(
schema: ReadonlyMap<keyof TFields, EntityFieldDefinition>
schema: ReadonlyMap<keyof TFields, EntityFieldDefinition<any>>
): ReadonlyMap<keyof TFields, string> {
return mapMap(schema, (v) => v.columnName);
}
Expand Down
11 changes: 7 additions & 4 deletions packages/entity/src/EntityDatabaseAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,22 @@ import {

interface SingleValueFieldEqualityCondition<TFields, N extends keyof TFields = keyof TFields> {
fieldName: N;
fieldValue: TFields[N];
fieldValue: NonNullable<TFields[N]>;
}

interface MultiValueFieldEqualityCondition<TFields, N extends keyof TFields = keyof TFields> {
fieldName: N;
fieldValues: TFields[N][];
fieldValues: readonly NonNullable<TFields[N]>[];
}

export type FieldEqualityCondition<TFields, N extends keyof TFields = keyof TFields> =
| SingleValueFieldEqualityCondition<TFields, N>
| MultiValueFieldEqualityCondition<TFields, N>;

function isSingleValueFieldEqualityCondition<TFields, N extends keyof TFields = keyof TFields>(
export function isSingleValueFieldEqualityCondition<
TFields,
N extends keyof TFields = keyof TFields
>(
condition: FieldEqualityCondition<TFields, N>
): condition is SingleValueFieldEqualityCondition<TFields, N> {
return (condition as SingleValueFieldEqualityCondition<TFields, N>).fieldValue !== undefined;
Expand All @@ -34,7 +37,7 @@ export interface TableFieldSingleValueEqualityCondition {

export interface TableFieldMultiValueEqualityCondition {
tableField: string;
tableValues: any[];
tableValues: readonly any[];
}

export enum OrderByOrdering {
Expand Down
Loading

0 comments on commit 6c4d4b0

Please sign in to comment.