Skip to content

Commit

Permalink
fix(document): Enforce schema when loading genesis record (#472)
Browse files Browse the repository at this point in the history
  • Loading branch information
Spencer T Brody authored Nov 10, 2020
1 parent 23392f5 commit 37fc1e6
Show file tree
Hide file tree
Showing 4 changed files with 143 additions and 123 deletions.
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> {
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 = function (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 = 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)
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

0 comments on commit 37fc1e6

Please sign in to comment.