-
Notifications
You must be signed in to change notification settings - Fork 126
fix(document): Enforce schema when loading genesis record #472
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -38,12 +38,14 @@ const createIPFS =(overrideConfig: Record<string, unknown> = {}): Promise<IPFSAp | |
| * document is anchored | ||
| * @param doc | ||
| */ | ||
| const syncDoc = async (doc: any): Promise<void> => { | ||
| await new Promise(resolve => { | ||
| const anchorDoc = async (ceramic: Ceramic, doc: any): Promise<void> => { | ||
| const p = new Promise(resolve => { | ||
| doc.on('change', () => { | ||
| resolve() | ||
| }) | ||
| }) | ||
| await ceramic.context.anchorService.anchor() | ||
| await p | ||
| } | ||
|
|
||
| describe('Ceramic API', () => { | ||
|
|
@@ -67,6 +69,7 @@ describe('Ceramic API', () => { | |
|
|
||
| const createCeramic = async (c: CeramicConfig = {}): Promise<Ceramic> => { | ||
| c.topic = topic | ||
| c.anchorOnRequest = false | ||
| const ceramic = await Ceramic.create(ipfs, c) | ||
|
|
||
| const config = { | ||
|
|
@@ -108,7 +111,7 @@ describe('Ceramic API', () => { | |
| }) | ||
|
|
||
| // wait for anchor (new version) | ||
| await syncDoc(docOg) | ||
| await anchorDoc(ceramic, docOg) | ||
|
|
||
| expect(docOg.state.log.length).toEqual(2) | ||
| expect(docOg.content).toEqual({ test: 321 }) | ||
|
|
@@ -119,7 +122,7 @@ describe('Ceramic API', () => { | |
| await docOg.change({ content: { test: 'abcde' } }) | ||
|
|
||
| // wait for anchor (new version) | ||
| await syncDoc(docOg) | ||
| await anchorDoc(ceramic, docOg) | ||
|
|
||
| expect(docOg.state.log.length).toEqual(4) | ||
| expect(docOg.content).toEqual({ test: 'abcde' }) | ||
|
|
@@ -339,7 +342,7 @@ describe('Ceramic API', () => { | |
| content: { a: 1 }, | ||
| } | ||
| const doc = await ceramic.createDocument<TileDoctype>(DOCTYPE_TILE, tileDocParams) | ||
| await syncDoc(doc) | ||
| await anchorDoc(ceramic, doc) | ||
|
|
||
| // Create schema that enforces that the content value is a string, which would reject | ||
| // the document created above. | ||
|
|
@@ -348,7 +351,7 @@ describe('Ceramic API', () => { | |
| metadata: { controllers: [controller] } | ||
| }) | ||
| // wait for anchor | ||
| await syncDoc(schemaDoc) | ||
| await anchorDoc(ceramic, schemaDoc) | ||
| expect(schemaDoc.state.anchorStatus).toEqual(AnchorStatus.ANCHORED) | ||
|
|
||
| // Update the schema to expect a number, so now the original doc should conform to the new | ||
|
|
@@ -357,7 +360,7 @@ describe('Ceramic API', () => { | |
| updatedSchema.additionalProperties.type = "number" | ||
| await schemaDoc.change({content: updatedSchema}) | ||
| // wait for anchor | ||
| await syncDoc(schemaDoc) | ||
| await anchorDoc(ceramic, schemaDoc) | ||
| expect(schemaDoc.state.anchorStatus).toEqual(AnchorStatus.ANCHORED) | ||
|
|
||
| // Test that we can assign the updated schema to the document without error. | ||
|
|
@@ -366,7 +369,7 @@ describe('Ceramic API', () => { | |
| controllers: [controller], schema: schemaDoc.id.toString() | ||
| } | ||
| }) | ||
| await syncDoc(doc) | ||
| await anchorDoc(ceramic, doc) | ||
| expect(doc.content).toEqual({ a: 1 }) | ||
|
|
||
| // Test that we can reload the document without issue | ||
|
|
@@ -377,74 +380,6 @@ describe('Ceramic API', () => { | |
| await ceramic.close() | ||
| }) | ||
|
|
||
| it('update schema so existing doc no longer conforms', async () => { | ||
| ceramic = await createCeramic() | ||
|
|
||
| const controller = ceramic.context.did.id | ||
|
|
||
| // Create doc with content that has type 'string'. | ||
| const tileDocParams: TileParams = { | ||
| metadata: { | ||
| controllers: [controller] | ||
| }, | ||
| content: { a: 'x' }, | ||
| } | ||
| const doc = await ceramic.createDocument<TileDoctype>(DOCTYPE_TILE, tileDocParams) | ||
| await syncDoc(doc) | ||
|
|
||
| // Create schema that enforces that the content value is a string | ||
| const schemaDoc = await ceramic.createDocument<TileDoctype>(DOCTYPE_TILE, { | ||
| content: stringMapSchema, | ||
| metadata: { controllers: [controller] } | ||
| }) | ||
| // wait for anchor | ||
| await syncDoc(schemaDoc) | ||
| expect(schemaDoc.state.anchorStatus).toEqual(AnchorStatus.ANCHORED) | ||
| const schemaV0Id = DocID.fromBytes(schemaDoc.id.bytes, schemaDoc.tip.toString()) | ||
|
|
||
| // Assign the schema to the conforming document. | ||
| await doc.change({ | ||
| metadata: { | ||
| controllers: [controller], schema: schemaDoc.id.toString() | ||
| } | ||
| }) | ||
| await syncDoc(doc) | ||
| expect(doc.content).toEqual({ a: 'x' }) | ||
|
|
||
| // Update schema so that existing doc no longer conforms | ||
| const updatedSchema = cloneDeep(stringMapSchema) | ||
| updatedSchema.additionalProperties.type = "number" | ||
| await schemaDoc.change({content: updatedSchema}) | ||
| await syncDoc(schemaDoc) | ||
|
|
||
| // Test that we can load the existing document without issue | ||
| const doc2 = await ceramic.loadDocument(doc.id) | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The whole point of this test was to test the behavior when loading a document that doesn't conform to its schema, but this check here isn't actually telling us anything useful since it's not going through the "real" document loading process, but instead just fetching it out of the local cache here. To do this test properly it would need to be re-written as an integration test with two ceramic instances, one that creates the non-conforming document and a second that loads it. I'm happy to do that if you think it's worthwhile to add, but in the interest of time I decided to just focus on unit testing the core Document/applyRecord behavior and trust that it continues to work when being driven by the pubsub network
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. feel free to add a test into |
||
| expect(doc2.content).toEqual(doc.content) | ||
|
|
||
| // Test that updating the existing document fails if it doesn't conform to the most recent | ||
| // version of the schema, when specifying just the schema document ID without a version | ||
| try { | ||
| await doc.change({ | ||
| content: {a: 'y'}, | ||
| metadata: {controllers: [controller], schema: schemaDoc.id.toString() } | ||
| }) | ||
| throw new Error('Should not be able to update the document with invalid content') | ||
| } catch (e) { | ||
| expect(e.message).toEqual('Validation Error: data[\'a\'] should be number') | ||
| } | ||
|
|
||
| // Test that we can update the existing document according to the original schema by manually | ||
| // specifying the old version of the schema | ||
| await doc.change({ | ||
| content: { a: 'z' }, | ||
| metadata: { controllers: [controller], schema: schemaV0Id.toString() } | ||
| }) | ||
| await syncDoc(doc) | ||
| expect(doc.content).toEqual({ a: 'z' }) | ||
|
|
||
| await ceramic.close() | ||
| }) | ||
|
|
||
| it('Pin schema to a specific version', async () => { | ||
| ceramic = await createCeramic() | ||
|
|
||
|
|
@@ -455,10 +390,11 @@ describe('Ceramic API', () => { | |
| metadata: { | ||
| controllers: [controller] | ||
| }, | ||
| content: { a: 'x' }, | ||
| content: { stuff: 'a' }, | ||
| } | ||
| const doc = await ceramic.createDocument<TileDoctype>(DOCTYPE_TILE, tileDocParams) | ||
| await syncDoc(doc) | ||
| await anchorDoc(ceramic, doc) | ||
| expect(doc.content).toEqual({ stuff: 'a' }) | ||
|
|
||
| // Create schema that enforces that the content value is a string | ||
| const schemaDoc = await ceramic.createDocument<TileDoctype>(DOCTYPE_TILE, { | ||
|
|
@@ -467,52 +403,48 @@ describe('Ceramic API', () => { | |
| }) | ||
|
|
||
| // wait for anchor | ||
| await syncDoc(schemaDoc) | ||
| await anchorDoc(ceramic, schemaDoc) | ||
| expect(schemaDoc.state.anchorStatus).toEqual(AnchorStatus.ANCHORED) | ||
| const schemaV0Id = DocID.fromBytes(schemaDoc.id.bytes, schemaDoc.tip.toString()) | ||
|
|
||
| // Assign the schema to the conforming document, specifying current version of the schema explicitly | ||
| await doc.change({ | ||
| metadata: { | ||
| controllers: [controller], schema: schemaV0Id.toString(), | ||
| } | ||
| metadata: { controllers: [controller], schema: schemaV0Id.toString() }, | ||
| content: {stuff: 'b'} | ||
| }) | ||
| await syncDoc(doc) | ||
| expect(doc.content).toEqual({ a: 'x' }) | ||
| await anchorDoc(ceramic, doc) | ||
| expect(doc.content).toEqual({ stuff: 'b' }) | ||
| expect(doc.metadata.schema).toEqual(schemaV0Id.toString()) | ||
|
|
||
| // Update schema so that existing doc no longer conforms | ||
| const updatedSchema = cloneDeep(stringMapSchema) | ||
| updatedSchema.additionalProperties.type = "number" | ||
| await schemaDoc.change({content: updatedSchema}) | ||
| await syncDoc(schemaDoc) | ||
| await anchorDoc(ceramic, schemaDoc) | ||
|
|
||
| expect(doc.metadata.schema.toString()).toEqual(schemaV0Id.toString()) | ||
|
|
||
| // Test that we can load the existing document without issue | ||
| const doc2 = await ceramic.loadDocument(doc.id) | ||
| expect(doc2.content).toEqual(doc.content) | ||
| expect(doc2.metadata).toEqual(doc.metadata) | ||
|
|
||
| // Test that we can update the existing document according to the original schema when taking | ||
| // the schema docID from the existing document. | ||
| await doc.change({ | ||
| content: { a: 'y' }, | ||
| content: { stuff: 'c' }, | ||
| metadata: { controllers: [controller], schema: doc.metadata.schema.toString() } | ||
| }) | ||
| await syncDoc(doc) | ||
| await anchorDoc(ceramic, doc) | ||
| expect(doc.content).toEqual({ stuff: 'c' }) | ||
|
|
||
| // Test that updating the existing document fails if it doesn't conform to the most recent | ||
| // version of the schema, when specifying just the schema document ID without a version | ||
| try { | ||
| await doc.change({ | ||
| content: {a: 'z'}, | ||
| content: {stuff: 'd'}, | ||
| metadata: {controllers: [controller], schema: schemaDoc.id.toString() } | ||
| }) | ||
| throw new Error('Should not be able to update the document with invalid content') | ||
| } catch (e) { | ||
| expect(e.message).toEqual('Validation Error: data[\'a\'] should be number') | ||
| expect(e.message).toEqual('Validation Error: data[\'stuff\'] should be number') | ||
| } | ||
| expect(doc.content).toEqual({ stuff: 'c' }) | ||
|
|
||
| await ceramic.close() | ||
| }) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -102,6 +102,15 @@ const create = async (params: TileParams, ceramic: Ceramic, context: Context, op | |
| return await ceramic._createDocFromGenesis(record, opts) | ||
| } | ||
|
|
||
| const stringMapSchema = { | ||
| "$schema": "http://json-schema.org/draft-07/schema#", | ||
| "title": "StringMap", | ||
| "type": "object", | ||
| "additionalProperties": { | ||
| "type": "string" | ||
| } | ||
| } | ||
|
|
||
| let stateStore: LevelStateStore | ||
| let pinStore: PinStore | ||
| let pinning: PinningBackend | ||
|
|
@@ -130,6 +139,7 @@ describe('Document', () => { | |
| let findHandler: any; | ||
| let anchorService: AnchorService; | ||
| let ceramic: Ceramic; | ||
| let ceramicWithoutSchemaValidation: Ceramic; | ||
| let context: Context; | ||
|
|
||
| beforeEach(() => { | ||
|
|
@@ -177,6 +187,9 @@ describe('Document', () => { | |
|
|
||
| ceramic = new Ceramic(dispatcher, pinStore, context) | ||
| ceramic._doctypeHandlers['tile'] = doctypeHandler | ||
|
|
||
| ceramicWithoutSchemaValidation = new Ceramic(dispatcher, pinStore, context, false) | ||
| ceramicWithoutSchemaValidation._doctypeHandlers['tile'] = doctypeHandler | ||
| }) | ||
|
|
||
| it('is created correctly', async () => { | ||
|
|
@@ -326,6 +339,66 @@ describe('Document', () => { | |
| await doc1._handleTip(tipInvalidUpdate) | ||
| expect(doc1.content).toEqual(newContent) | ||
| }) | ||
|
|
||
| it('Enforces schema at document creation', async () => { | ||
| const schemaDoc = await create({ content: stringMapSchema, metadata: { controllers } }, ceramic, context) | ||
| await anchorUpdate(schemaDoc) | ||
|
|
||
| try { | ||
| const docParams = { | ||
| content: {stuff: 1}, | ||
| metadata: {controllers, schema: schemaDoc.id.toString()} | ||
| } | ||
| await create(docParams, ceramic, context) | ||
| throw new Error('Should not be able to create a document with an invalid schema') | ||
| } catch (e) { | ||
| expect(e.message).toEqual('Validation Error: data[\'stuff\'] should be string') | ||
| } | ||
| }) | ||
|
|
||
| it('Enforces schema at document update', async () => { | ||
| const schemaDoc = await create({ content: stringMapSchema, metadata: { controllers } }, ceramic, context) | ||
| await anchorUpdate(schemaDoc) | ||
|
|
||
| const docParams = { | ||
| content: {stuff: 1}, | ||
| metadata: {controllers} | ||
| } | ||
| const doc = await create(docParams, ceramic, context) | ||
| await anchorUpdate(doc) | ||
|
|
||
| try { | ||
| const updateRec = await TileDoctype._makeRecord(doc.doctype, user, null, doc.controllers, schemaDoc.id.toString()) | ||
| await doc.applyRecord(updateRec) | ||
| throw new Error('Should not be able to assign a schema to a document that does not conform') | ||
| } catch (e) { | ||
| expect(e.message).toEqual('Validation Error: data[\'stuff\'] should be string') | ||
| } | ||
| }) | ||
|
|
||
| it('Enforces schema when loading genesis record', async () => { | ||
| const schemaDoc = await create({ content: stringMapSchema, metadata: { controllers } }, ceramic, context) | ||
| await anchorUpdate(schemaDoc) | ||
|
|
||
| const docParams = { | ||
| content: {stuff: 1}, | ||
| metadata: {controllers, schema: schemaDoc.id.toString()} | ||
| } | ||
| // Create a document that isn't conforming to the schema | ||
| const doc = await create(docParams, ceramicWithoutSchemaValidation, context) | ||
| await anchorUpdate(doc) | ||
|
|
||
| expect(doc.content).toEqual({stuff:1}) | ||
| expect(doc.metadata.schema).toEqual(schemaDoc.id.toString()) | ||
|
|
||
| try { | ||
| await Document.load(doc.id, findHandler, dispatcher, pinStore, context, {skipWait:true}) | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the actual test for the core fix in this PR. Without the change to Document.load, this test fails. |
||
| throw new Error('Should not be able to assign a schema to a document that does not conform') | ||
| } catch (e) { | ||
| expect(e.message).toEqual('Validation Error: data[\'stuff\'] should be string') | ||
| } | ||
| }) | ||
|
|
||
| }) | ||
|
|
||
| describe('Network update logic', () => { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -145,6 +145,13 @@ class Document extends EventEmitter { | |
| doc._doctype.state = await doc._doctypeHandler.applyRecord(record, doc._genesisCid, context) | ||
| } | ||
|
|
||
| if (validate) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yep, that's it 👍 |
||
| const schema = await Document.loadSchema(context.api, doc._doctype) | ||
| if (schema) { | ||
| Utils.validate(doc._doctype.content, schema) | ||
| } | ||
| } | ||
|
|
||
| await doc._register(opts) | ||
| return doc | ||
| } | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.