Skip to content
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

fix(document): Enforce schema when loading genesis record #472

Merged
merged 4 commits into from
Nov 10, 2020
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 26 additions & 24 deletions packages/core/src/__tests__/ceramic-anchor.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Ceramic from '../ceramic'
import IdentityWallet from 'identity-wallet'
import { Doctype } from "@ceramicnetwork/common"
import {AnchorStatus, Doctype} from "@ceramicnetwork/common"
import { TileDoctype } from "@ceramicnetwork/doctype-tile"
import tmp from 'tmp-promise'
import IPFS from 'ipfs'
Expand Down Expand Up @@ -50,18 +50,27 @@ const createCeramic = async (ipfs: IPFSApi, anchorManual: boolean, topic: string
return ceramic
}

const anchor = async (ceramic: Ceramic): Promise<void> => {
await ceramic.context.anchorService.anchor()
}

const syncDoc = async (doctype: Doctype): Promise<void> => {
await new Promise(resolve => {
doctype.on('change', () => {
const registerChangeListener = function (doc: Doctype): Promise<void> {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I hit some weirdness trying to get this to work. I originally had this as an async function, but then waiting on the returned promise in anchorDoc would hang. Removing the 'return' keyword after making it async would get it to stop hanging, but then it would fail on the next line in every test because the document log would be one entry smaller than expected. I definitely think I'm hitting up against some rough edges of the concurrency/execution model of Node.js. Would you be open to taking a look at this together tomorrow morning @simonovic86?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay nevermind, I more or less see the problem. Making registerChangeListener async makes the compiler think that saying await registerChangeListener(doc) returns void, rather than Promise<void> like it should be. I tried to change the return type to Promise<Promise<void>> but that doesn't seem to help, the compiler seems to flatten the stack of Promises. I even found a bug report about it. Anyway I think the right move is just to leave the function synchronous, as I've done here. So I think this should be good as-is, pending your review @simonovic86

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep, this is OK 👍

return new Promise(resolve => {
doc.on('change', () => {
resolve()
})
})
}

/**
* Registers a listener for change notifications on a document, instructs the anchor service to
* perform an anchor, then waits for the change listener to resolve, indicating that the document
* got anchored.
* @param ceramic
* @param doc
*/
const anchorDoc = async (ceramic: Ceramic, doc: any): Promise<void> => {
const changeHandle = registerChangeListener(doc)
await ceramic.context.anchorService.anchor()
await changeHandle
}

describe('Ceramic anchoring', () => {
jest.setTimeout(60000)
let ipfs1: IPFSApi;
Expand Down Expand Up @@ -127,11 +136,11 @@ describe('Ceramic anchoring', () => {
await doctype1.change({ content: { a: 2 }, metadata: { controllers: [controller] } }, { applyOnly: false })
await doctype1.change({ content: { a: 3 }, metadata: { controllers: [controller] } }, { applyOnly: false })

await anchor(ceramic1)
await syncDoc(doctype1)
await anchorDoc(ceramic1, doctype1)

expect(doctype1.content).toEqual({ a: 3 })
expect(doctype1.state.log.length).toEqual(3)
expect(doctype1.state.anchorStatus).toEqual(AnchorStatus.ANCHORED)

const doctype2 = await ceramic2.loadDocument(doctype1.id)
expect(doctype1.content).toEqual(doctype2.content)
Expand Down Expand Up @@ -181,8 +190,7 @@ describe('Ceramic anchoring', () => {

expect(doctype1.state.log.length).toEqual(2)

await anchor(ceramic1)
await syncDoc(doctype1)
await anchorDoc(ceramic1, doctype1)

expect(doctype1.content).toEqual({ a: 123, b: 4567 })
expect(doctype1.state.log.length).toEqual(2)
Expand All @@ -209,8 +217,7 @@ describe('Ceramic anchoring', () => {

expect(doctype1.state.log.length).toEqual(2)

await anchor(ceramic1)
await syncDoc(doctype1)
await anchorDoc(ceramic1, doctype1)

expect(doctype1.content).toEqual({ a: 123 })
expect(doctype1.state.log.length).toEqual(2)
Expand All @@ -237,8 +244,7 @@ describe('Ceramic anchoring', () => {
await doctype1.change({ content: { x: doctype1.content.x + 1 }, metadata: { controllers: [controller] } }, { applyOnly: true })
await doctype1.change({ content: { x: doctype1.content.x + 1 }, metadata: { controllers: [controller] } }, { applyOnly: false })

await anchor(ceramic1)
await syncDoc(doctype1)
await anchorDoc(ceramic1, doctype1)

expect(doctype1.content).toEqual({ x: 3 })
expect(doctype1.state.log.length).toEqual(3)
Expand All @@ -264,8 +270,7 @@ describe('Ceramic anchoring', () => {
await doctype1.change({ content: { x: doctype1.content.x + 1 }, metadata: { controllers: [controller] } }, { applyOnly: true })
await doctype1.change({ content: { x: doctype1.content.x + 1 }, metadata: { controllers: [controller] } }, { applyOnly: false })

await anchor(ceramic1)
await syncDoc(doctype1)
await anchorDoc(ceramic1, doctype1)

expect(doctype1.content).toEqual({ x: 3 })
expect(doctype1.state.log.length).toEqual(3)
Expand Down Expand Up @@ -295,8 +300,7 @@ describe('Ceramic anchoring', () => {

await doctype1.change({ content: { x: doctype1.content.x + 1 }, metadata: { controllers: [controller] } }, { applyOnly: false })

await anchor(ceramic1)
await syncDoc(doctype1)
await anchorDoc(ceramic1, doctype1)

expect(doctype1.content).toEqual({ x: 3 })
expect(doctype1.state.log.length).toEqual(3)
Expand All @@ -305,8 +309,7 @@ describe('Ceramic anchoring', () => {
await doctype1.change({ content: { x: doctype1.content.x + 1 }, metadata: { controllers: [controller] } }, { applyOnly: true })
await doctype1.change({ content: { x: doctype1.content.x + 1 }, metadata: { controllers: [controller] } }, { applyOnly: false })

await anchor(ceramic1)
await syncDoc(doctype1)
await anchorDoc(ceramic1, doctype1)

expect(doctype1.content).toEqual({ x: 6 })
expect(doctype1.state.log.length).toEqual(5)
Expand All @@ -333,8 +336,7 @@ describe('Ceramic anchoring', () => {
await doctype1.change({ content: { x: 7 }, metadata: { controllers: [controller] } }, { applyOnly: false })
await cloned.change({ content: { x: 5 }, metadata: { controllers: [controller] } }, { applyOnly: false })

await anchor(ceramic1)
await syncDoc(doctype1)
await anchorDoc(ceramic1, doctype1)

expect(doctype1.content).toEqual({ x: 7 })
expect(doctype1.state.log.length).toEqual(3)
Expand Down
136 changes: 37 additions & 99 deletions packages/core/src/__tests__/ceramic-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,27 @@ const createIPFS =(overrideConfig: Record<string, unknown> = {}): Promise<IPFSAp
return IPFS.create(config)
}

/**
* Waits for a document change event on the given document. Used in these tests to wait until a
* document is anchored
* @param doc
*/
const syncDoc = async (doc: any): Promise<void> => {
await new Promise(resolve => {
const registerChangeListener = async (doc: any): Promise<void> => {
return new Promise(resolve => {
doc.on('change', () => {
resolve()
})
})
}

/**
* Registers a listener for change notifications on a document, instructs the anchor service to
* perform an anchor, then waits for the change listener to resolve, indicating that the document
* got anchored.
* @param ceramic
* @param doc
*/
const anchorDoc = async (ceramic: Ceramic, doc: any): Promise<void> => {
const changeHandle = await registerChangeListener(doc)
await ceramic.context.anchorService.anchor()
await changeHandle
}

describe('Ceramic API', () => {
jest.setTimeout(15000)
let ipfs: IPFSApi;
Expand All @@ -67,6 +75,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 = {
Expand Down Expand Up @@ -108,7 +117,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 })
Expand All @@ -119,7 +128,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' })
Expand Down Expand Up @@ -339,7 +348,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.
Expand All @@ -348,7 +357,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
Expand All @@ -357,7 +366,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.
Expand All @@ -366,7 +375,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
Expand All @@ -377,74 +386,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)
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

feel free to add a test into ceramic.test.ts test suite which is already spinning a couple of instances (ipfs+ceramic). It currently serves as a basic integration test for testing propagation between multiple instances. On the other side, it's not crucial to add it as part of this PR, we can open another story for that 👍

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()

Expand All @@ -455,10 +396,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, {
Expand All @@ -467,52 +409,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()
})
Expand Down
Loading