5ade78ddb3
* Harden UTF8 length test cases * Harden tests to account for new fast path * Add fast paths that skip UTF8 encoding
1243 lines
38 KiB
TypeScript
1243 lines
38 KiB
TypeScript
import { CID } from 'multiformats/cid'
|
||
import { LexiconDoc, Lexicons, parseLexiconDoc } from '../src/index'
|
||
import LexiconDocs from './_scaffolds/lexicons'
|
||
|
||
describe('Lexicons collection', () => {
|
||
const lex = new Lexicons(LexiconDocs)
|
||
|
||
it('Adds schemas', () => {
|
||
expect(() => lex.add(LexiconDocs[0])).toThrow()
|
||
})
|
||
|
||
it('Correctly references all definitions', () => {
|
||
expect(lex.getDef('com.example.kitchenSink')).toEqual(
|
||
LexiconDocs[0].defs.main,
|
||
)
|
||
expect(lex.getDef('lex:com.example.kitchenSink')).toEqual(
|
||
LexiconDocs[0].defs.main,
|
||
)
|
||
expect(lex.getDef('com.example.kitchenSink#main')).toEqual(
|
||
LexiconDocs[0].defs.main,
|
||
)
|
||
expect(lex.getDef('lex:com.example.kitchenSink#main')).toEqual(
|
||
LexiconDocs[0].defs.main,
|
||
)
|
||
expect(lex.getDef('com.example.kitchenSink#object')).toEqual(
|
||
LexiconDocs[0].defs.object,
|
||
)
|
||
expect(lex.getDef('lex:com.example.kitchenSink#object')).toEqual(
|
||
LexiconDocs[0].defs.object,
|
||
)
|
||
})
|
||
})
|
||
|
||
describe('General validation', () => {
|
||
const lex = new Lexicons(LexiconDocs)
|
||
it('Validates records correctly', () => {
|
||
{
|
||
const res = lex.validate('com.example.kitchenSink', {
|
||
$type: 'com.example.kitchenSink',
|
||
object: {
|
||
object: { boolean: true },
|
||
array: ['one', 'two'],
|
||
boolean: true,
|
||
integer: 123,
|
||
string: 'string',
|
||
},
|
||
array: ['one', 'two'],
|
||
boolean: true,
|
||
integer: 123,
|
||
string: 'string',
|
||
datetime: new Date().toISOString(),
|
||
atUri: 'at://did:web:example.com/com.example.test/self',
|
||
did: 'did:web:example.com',
|
||
cid: 'bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a',
|
||
bytes: new Uint8Array([0, 1, 2, 3]),
|
||
cidLink: CID.parse(
|
||
'bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a',
|
||
),
|
||
})
|
||
expect(res.success).toBe(true)
|
||
}
|
||
{
|
||
const res = lex.validate('com.example.kitchenSink', {})
|
||
expect(res.success).toBe(false)
|
||
if (res.success) throw new Error('Asserted')
|
||
expect(res.error?.message).toBe('Record must have the property "object"')
|
||
}
|
||
})
|
||
it('Validates objects correctly', () => {
|
||
{
|
||
const res = lex.validate('com.example.kitchenSink#object', {
|
||
object: { boolean: true },
|
||
array: ['one', 'two'],
|
||
boolean: true,
|
||
integer: 123,
|
||
string: 'string',
|
||
})
|
||
expect(res.success).toBe(true)
|
||
}
|
||
{
|
||
const res = lex.validate('com.example.kitchenSink#object', {})
|
||
expect(res.success).toBe(false)
|
||
if (res.success) throw new Error('Asserted')
|
||
expect(res.error?.message).toBe('Object must have the property "object"')
|
||
}
|
||
})
|
||
it('fails when a required property is missing', () => {
|
||
const schema = {
|
||
lexicon: 1,
|
||
id: 'com.example.kitchenSink',
|
||
defs: {
|
||
test: {
|
||
type: 'object',
|
||
required: ['foo'],
|
||
properties: {},
|
||
},
|
||
},
|
||
}
|
||
expect(() => {
|
||
parseLexiconDoc(schema)
|
||
}).toThrow('Required field \\"foo\\" not defined')
|
||
})
|
||
it('fails when unknown fields are present', () => {
|
||
const schema = {
|
||
lexicon: 1,
|
||
id: 'com.example.unknownFields',
|
||
defs: {
|
||
test: {
|
||
type: 'object',
|
||
foo: 3,
|
||
},
|
||
},
|
||
}
|
||
|
||
expect(() => {
|
||
parseLexiconDoc(schema)
|
||
}).toThrow("Unrecognized key(s) in object: 'foo'")
|
||
})
|
||
it('fails lexicon parsing when uri is invalid', () => {
|
||
const schema: LexiconDoc = {
|
||
lexicon: 1,
|
||
id: 'com.example.invalidUri',
|
||
defs: {
|
||
main: {
|
||
type: 'object',
|
||
properties: {
|
||
test: { type: 'ref', ref: 'com.example.invalid#test#test' },
|
||
},
|
||
},
|
||
},
|
||
}
|
||
|
||
expect(() => {
|
||
new Lexicons([schema])
|
||
}).toThrow('Uri can only have one hash segment')
|
||
})
|
||
it('fails validation when ref uri has multiple hash segments', () => {
|
||
const schema: LexiconDoc = {
|
||
lexicon: 1,
|
||
id: 'com.example.invalidUri',
|
||
defs: {
|
||
main: {
|
||
type: 'object',
|
||
properties: {
|
||
test: { type: 'integer' },
|
||
},
|
||
},
|
||
object: {
|
||
type: 'object',
|
||
required: ['test'],
|
||
properties: {
|
||
test: {
|
||
type: 'union',
|
||
refs: ['com.example.invalidUri'],
|
||
},
|
||
},
|
||
},
|
||
},
|
||
}
|
||
const lexicons = new Lexicons([schema])
|
||
expect(() => {
|
||
lexicons.validate('com.example.invalidUri#object', {
|
||
test: {
|
||
$type: 'com.example.invalidUri#main#main',
|
||
test: 123,
|
||
},
|
||
})
|
||
}).toThrow('Uri can only have one hash segment')
|
||
})
|
||
it('union handles both implicit and explicit #main', () => {
|
||
const schemas: LexiconDoc[] = [
|
||
{
|
||
lexicon: 1,
|
||
id: 'com.example.implicitMain',
|
||
defs: {
|
||
main: {
|
||
type: 'object',
|
||
required: ['test'],
|
||
properties: {
|
||
test: { type: 'string' },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
lexicon: 1,
|
||
id: 'com.example.testImplicitMain',
|
||
defs: {
|
||
main: {
|
||
type: 'object',
|
||
required: ['union'],
|
||
properties: {
|
||
union: {
|
||
type: 'union',
|
||
refs: ['com.example.implicitMain'],
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
lexicon: 1,
|
||
id: 'com.example.testExplicitMain',
|
||
defs: {
|
||
main: {
|
||
type: 'object',
|
||
required: ['union'],
|
||
properties: {
|
||
union: {
|
||
type: 'union',
|
||
refs: ['com.example.implicitMain#main'],
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
]
|
||
|
||
const lexicon = new Lexicons(schemas)
|
||
|
||
let result = lexicon.validate('com.example.testImplicitMain', {
|
||
union: {
|
||
$type: 'com.example.implicitMain',
|
||
test: 123,
|
||
},
|
||
})
|
||
expect(result.success).toBeFalsy()
|
||
expect(result['error']?.message).toBe('Object/union/test must be a string')
|
||
|
||
result = lexicon.validate('com.example.testImplicitMain', {
|
||
union: {
|
||
$type: 'com.example.implicitMain#main',
|
||
test: 123,
|
||
},
|
||
})
|
||
expect(result.success).toBeFalsy()
|
||
expect(result['error']?.message).toBe('Object/union/test must be a string')
|
||
|
||
result = lexicon.validate('com.example.testExplicitMain', {
|
||
union: {
|
||
$type: 'com.example.implicitMain',
|
||
test: 123,
|
||
},
|
||
})
|
||
expect(result.success).toBeFalsy()
|
||
expect(result['error']?.message).toBe('Object/union/test must be a string')
|
||
|
||
result = lexicon.validate('com.example.testExplicitMain', {
|
||
union: {
|
||
$type: 'com.example.implicitMain#main',
|
||
test: 123,
|
||
},
|
||
})
|
||
expect(result.success).toBeFalsy()
|
||
expect(result['error']?.message).toBe('Object/union/test must be a string')
|
||
})
|
||
})
|
||
|
||
describe('Record validation', () => {
|
||
const lex = new Lexicons(LexiconDocs)
|
||
|
||
const passingSink = {
|
||
$type: 'com.example.kitchenSink',
|
||
object: {
|
||
object: { boolean: true },
|
||
array: ['one', 'two'],
|
||
boolean: true,
|
||
integer: 123,
|
||
string: 'string',
|
||
},
|
||
array: ['one', 'two'],
|
||
boolean: true,
|
||
integer: 123,
|
||
string: 'string',
|
||
bytes: new Uint8Array([0, 1, 2, 3]),
|
||
cidLink: CID.parse(
|
||
'bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a',
|
||
),
|
||
}
|
||
|
||
it('Passes valid schemas', () => {
|
||
lex.assertValidRecord('com.example.kitchenSink', passingSink)
|
||
})
|
||
|
||
it('Fails invalid input types', () => {
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.kitchenSink', undefined),
|
||
).toThrow('Record must be an object')
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.kitchenSink', 1234),
|
||
).toThrow('Record must be an object')
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.kitchenSink', 'string'),
|
||
).toThrow('Record must be an object')
|
||
})
|
||
|
||
it('Fails incorrect $type', () => {
|
||
expect(() => lex.assertValidRecord('com.example.kitchenSink', {})).toThrow(
|
||
'Record/$type must be a string',
|
||
)
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.kitchenSink', { $type: 'foo' }),
|
||
).toThrow('Invalid $type: must be lex:com.example.kitchenSink, got foo')
|
||
})
|
||
|
||
it('Fails missing required', () => {
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.kitchenSink', {
|
||
$type: 'com.example.kitchenSink',
|
||
array: ['one', 'two'],
|
||
boolean: true,
|
||
integer: 123,
|
||
string: 'string',
|
||
datetime: new Date().toISOString(),
|
||
atUri: 'at://did:web:example.com/com.example.test/self',
|
||
did: 'did:web:example.com',
|
||
cid: 'bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a',
|
||
bytes: new Uint8Array([0, 1, 2, 3]),
|
||
cidLink: CID.parse(
|
||
'bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a',
|
||
),
|
||
}),
|
||
).toThrow('Record must have the property "object"')
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.kitchenSink', {
|
||
...passingSink,
|
||
object: undefined,
|
||
}),
|
||
).toThrow('Record must have the property "object"')
|
||
})
|
||
|
||
it('Fails incorrect types', () => {
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.kitchenSink', {
|
||
...passingSink,
|
||
object: {
|
||
...passingSink.object,
|
||
object: { boolean: '1234' },
|
||
},
|
||
}),
|
||
).toThrow('Record/object/object/boolean must be a boolean')
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.kitchenSink', {
|
||
...passingSink,
|
||
object: true,
|
||
}),
|
||
).toThrow('Record/object must be an object')
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.kitchenSink', {
|
||
...passingSink,
|
||
array: 1234,
|
||
}),
|
||
).toThrow('Record/array must be an array')
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.kitchenSink', {
|
||
...passingSink,
|
||
integer: true,
|
||
}),
|
||
).toThrow('Record/integer must be an integer')
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.kitchenSink', {
|
||
...passingSink,
|
||
string: {},
|
||
}),
|
||
).toThrow('Record/string must be a string')
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.kitchenSink', {
|
||
...passingSink,
|
||
bytes: 1234,
|
||
}),
|
||
).toThrow('Record/bytes must be a byte array')
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.kitchenSink', {
|
||
...passingSink,
|
||
cidLink: 'bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a',
|
||
}),
|
||
).toThrow('Record/cidLink must be a CID')
|
||
})
|
||
|
||
it('Handles optional properties correctly', () => {
|
||
lex.assertValidRecord('com.example.optional', {
|
||
$type: 'com.example.optional',
|
||
})
|
||
})
|
||
|
||
it('Handles default properties correctly', () => {
|
||
const result = lex.assertValidRecord('com.example.default', {
|
||
$type: 'com.example.default',
|
||
object: {},
|
||
})
|
||
expect(result).toEqual({
|
||
$type: 'com.example.default',
|
||
boolean: false,
|
||
integer: 0,
|
||
string: '',
|
||
object: {
|
||
boolean: true,
|
||
integer: 1,
|
||
string: 'x',
|
||
},
|
||
})
|
||
expect(result).not.toHaveProperty('datetime')
|
||
})
|
||
|
||
it('Handles unions correctly', () => {
|
||
lex.assertValidRecord('com.example.union', {
|
||
$type: 'com.example.union',
|
||
unionOpen: {
|
||
$type: 'com.example.kitchenSink#object',
|
||
object: { boolean: true },
|
||
array: ['one', 'two'],
|
||
boolean: true,
|
||
integer: 123,
|
||
string: 'string',
|
||
},
|
||
unionClosed: {
|
||
$type: 'com.example.kitchenSink#subobject',
|
||
boolean: true,
|
||
},
|
||
})
|
||
lex.assertValidRecord('com.example.union', {
|
||
$type: 'com.example.union',
|
||
unionOpen: {
|
||
$type: 'com.example.other',
|
||
},
|
||
unionClosed: {
|
||
$type: 'com.example.kitchenSink#subobject',
|
||
boolean: true,
|
||
},
|
||
})
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.union', {
|
||
$type: 'com.example.union',
|
||
unionOpen: {},
|
||
unionClosed: {},
|
||
}),
|
||
).toThrow(
|
||
'Record/unionOpen must be an object which includes the "$type" property',
|
||
)
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.union', {
|
||
$type: 'com.example.union',
|
||
unionOpen: {
|
||
$type: 'com.example.other',
|
||
},
|
||
unionClosed: {
|
||
$type: 'com.example.other',
|
||
boolean: true,
|
||
},
|
||
}),
|
||
).toThrow(
|
||
'Record/unionClosed $type must be one of lex:com.example.kitchenSink#object, lex:com.example.kitchenSink#subobject',
|
||
)
|
||
})
|
||
|
||
it('Handles unknowns correctly', () => {
|
||
lex.assertValidRecord('com.example.unknown', {
|
||
$type: 'com.example.unknown',
|
||
unknown: { foo: 'bar' },
|
||
})
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.unknown', {
|
||
$type: 'com.example.unknown',
|
||
}),
|
||
).toThrow('Record must have the property "unknown"')
|
||
})
|
||
|
||
it('Applies array length constraints', () => {
|
||
lex.assertValidRecord('com.example.arrayLength', {
|
||
$type: 'com.example.arrayLength',
|
||
array: [1, 2, 3],
|
||
})
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.arrayLength', {
|
||
$type: 'com.example.arrayLength',
|
||
array: [1],
|
||
}),
|
||
).toThrow('Record/array must not have fewer than 2 elements')
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.arrayLength', {
|
||
$type: 'com.example.arrayLength',
|
||
array: [1, 2, 3, 4, 5],
|
||
}),
|
||
).toThrow('Record/array must not have more than 4 elements')
|
||
})
|
||
|
||
it('Applies array item constraints', () => {
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.arrayLength', {
|
||
$type: 'com.example.arrayLength',
|
||
array: [1, '2', 3],
|
||
}),
|
||
).toThrow('Record/array/1 must be an integer')
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.arrayLength', {
|
||
$type: 'com.example.arrayLength',
|
||
array: [1, undefined, 3],
|
||
}),
|
||
).toThrow('Record/array/1 must be an integer')
|
||
})
|
||
|
||
it('Applies boolean const constraint', () => {
|
||
lex.assertValidRecord('com.example.boolConst', {
|
||
$type: 'com.example.boolConst',
|
||
boolean: false,
|
||
})
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.boolConst', {
|
||
$type: 'com.example.boolConst',
|
||
boolean: true,
|
||
}),
|
||
).toThrow('Record/boolean must be false')
|
||
})
|
||
|
||
it('Applies integer range constraint', () => {
|
||
lex.assertValidRecord('com.example.integerRange', {
|
||
$type: 'com.example.integerRange',
|
||
integer: 2,
|
||
})
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.integerRange', {
|
||
$type: 'com.example.integerRange',
|
||
integer: 1,
|
||
}),
|
||
).toThrow('Record/integer can not be less than 2')
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.integerRange', {
|
||
$type: 'com.example.integerRange',
|
||
integer: 5,
|
||
}),
|
||
).toThrow('Record/integer can not be greater than 4')
|
||
})
|
||
|
||
it('Applies integer enum constraint', () => {
|
||
lex.assertValidRecord('com.example.integerEnum', {
|
||
$type: 'com.example.integerEnum',
|
||
integer: 2,
|
||
})
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.integerEnum', {
|
||
$type: 'com.example.integerEnum',
|
||
integer: 0,
|
||
}),
|
||
).toThrow('Record/integer must be one of (1|2)')
|
||
})
|
||
|
||
it('Applies integer const constraint', () => {
|
||
lex.assertValidRecord('com.example.integerConst', {
|
||
$type: 'com.example.integerConst',
|
||
integer: 0,
|
||
})
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.integerConst', {
|
||
$type: 'com.example.integerConst',
|
||
integer: 1,
|
||
}),
|
||
).toThrow('Record/integer must be 0')
|
||
})
|
||
|
||
it('Applies integer whole-number constraint', () => {
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.integerRange', {
|
||
$type: 'com.example.integerRange',
|
||
integer: 2.5,
|
||
}),
|
||
).toThrow('Record/integer must be an integer')
|
||
})
|
||
|
||
it('Applies string length constraint', () => {
|
||
// Shorter than two UTF8 characters
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.stringLength', {
|
||
$type: 'com.example.stringLength',
|
||
string: '',
|
||
}),
|
||
).toThrow('Record/string must not be shorter than 2 characters')
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.stringLength', {
|
||
$type: 'com.example.stringLength',
|
||
string: 'a',
|
||
}),
|
||
).toThrow('Record/string must not be shorter than 2 characters')
|
||
|
||
// Two to four UTF8 characters
|
||
lex.assertValidRecord('com.example.stringLength', {
|
||
$type: 'com.example.stringLength',
|
||
string: 'ab',
|
||
})
|
||
lex.assertValidRecord('com.example.stringLength', {
|
||
$type: 'com.example.stringLength',
|
||
string: '\u0301', // Combining acute accent (2 bytes)
|
||
})
|
||
lex.assertValidRecord('com.example.stringLength', {
|
||
$type: 'com.example.stringLength',
|
||
string: 'a\u0301', // 'a' + combining acute accent (1 + 2 bytes = 3 bytes)
|
||
})
|
||
lex.assertValidRecord('com.example.stringLength', {
|
||
$type: 'com.example.stringLength',
|
||
string: 'aé', // 'a' (1 byte) + 'é' (2 bytes) = 3 bytes
|
||
})
|
||
lex.assertValidRecord('com.example.stringLength', {
|
||
$type: 'com.example.stringLength',
|
||
string: 'abc',
|
||
})
|
||
lex.assertValidRecord('com.example.stringLength', {
|
||
$type: 'com.example.stringLength',
|
||
string: '一', // CJK character (3 bytes)
|
||
})
|
||
lex.assertValidRecord('com.example.stringLength', {
|
||
$type: 'com.example.stringLength',
|
||
string: '\uD83D', // Unpaired high surrogate (3 bytes)
|
||
})
|
||
lex.assertValidRecord('com.example.stringLength', {
|
||
$type: 'com.example.stringLength',
|
||
string: 'abcd',
|
||
})
|
||
lex.assertValidRecord('com.example.stringLength', {
|
||
$type: 'com.example.stringLength',
|
||
string: 'éé', // 'é' + 'é' (2 + 2 bytes = 4 bytes)
|
||
})
|
||
lex.assertValidRecord('com.example.stringLength', {
|
||
$type: 'com.example.stringLength',
|
||
string: 'aaé', // 1 + 1 + 2 = 4 bytes
|
||
})
|
||
lex.assertValidRecord('com.example.stringLength', {
|
||
$type: 'com.example.stringLength',
|
||
string: '👋', // 4 bytes
|
||
})
|
||
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.stringLength', {
|
||
$type: 'com.example.stringLength',
|
||
string: 'abcde',
|
||
}),
|
||
).toThrow('Record/string must not be longer than 4 characters')
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.stringLength', {
|
||
$type: 'com.example.stringLength',
|
||
string: 'a\u0301\u0301', // 1 + (2 * 2) = 5 bytes
|
||
}),
|
||
).toThrow('Record/string must not be longer than 4 characters')
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.stringLength', {
|
||
$type: 'com.example.stringLength',
|
||
string: '\uD83D\uD83D', // Two unpaired high surrogates (3 * 2 = 6 bytes)
|
||
}),
|
||
).toThrow('Record/string must not be longer than 4 characters')
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.stringLength', {
|
||
$type: 'com.example.stringLength',
|
||
string: 'ééé', // 2 + 2 + 2 bytes = 6 bytes
|
||
}),
|
||
).toThrow('Record/string must not be longer than 4 characters')
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.stringLength', {
|
||
$type: 'com.example.stringLength',
|
||
string: '👋a', // 4 + 1 bytes = 5 bytes
|
||
}),
|
||
).toThrow('Record/string must not be longer than 4 characters')
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.stringLength', {
|
||
$type: 'com.example.stringLength',
|
||
string: '👨👨', // 4 + 4 = 8 bytes
|
||
}),
|
||
).toThrow('Record/string must not be longer than 4 characters')
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.stringLength', {
|
||
$type: 'com.example.stringLength',
|
||
string: '👨👩👧👧', // 4 emojis × 4 bytes + 3 ZWJs × 3 bytes = 25 bytes
|
||
}),
|
||
).toThrow('Record/string must not be longer than 4 characters')
|
||
})
|
||
|
||
it('Applies string length constraint (no minLength)', () => {
|
||
// Shorter than two UTF8 characters
|
||
lex.assertValidRecord('com.example.stringLengthNoMinLength', {
|
||
$type: 'com.example.stringLengthNoMinLength',
|
||
string: '',
|
||
})
|
||
lex.assertValidRecord('com.example.stringLengthNoMinLength', {
|
||
$type: 'com.example.stringLengthNoMinLength',
|
||
string: 'a',
|
||
})
|
||
|
||
// Two to four UTF8 characters
|
||
lex.assertValidRecord('com.example.stringLengthNoMinLength', {
|
||
$type: 'com.example.stringLengthNoMinLength',
|
||
string: 'ab',
|
||
})
|
||
lex.assertValidRecord('com.example.stringLengthNoMinLength', {
|
||
$type: 'com.example.stringLengthNoMinLength',
|
||
string: '\u0301', // Combining acute accent (2 bytes)
|
||
})
|
||
lex.assertValidRecord('com.example.stringLengthNoMinLength', {
|
||
$type: 'com.example.stringLengthNoMinLength',
|
||
string: 'a\u0301', // 'a' + combining acute accent (1 + 2 bytes = 3 bytes)
|
||
})
|
||
lex.assertValidRecord('com.example.stringLengthNoMinLength', {
|
||
$type: 'com.example.stringLengthNoMinLength',
|
||
string: 'aé', // 'a' (1 byte) + 'é' (2 bytes) = 3 bytes
|
||
})
|
||
lex.assertValidRecord('com.example.stringLengthNoMinLength', {
|
||
$type: 'com.example.stringLengthNoMinLength',
|
||
string: 'abc',
|
||
})
|
||
lex.assertValidRecord('com.example.stringLengthNoMinLength', {
|
||
$type: 'com.example.stringLengthNoMinLength',
|
||
string: '一', // CJK character (3 bytes)
|
||
})
|
||
lex.assertValidRecord('com.example.stringLengthNoMinLength', {
|
||
$type: 'com.example.stringLengthNoMinLength',
|
||
string: '\uD83D', // Unpaired high surrogate (3 bytes)
|
||
})
|
||
lex.assertValidRecord('com.example.stringLengthNoMinLength', {
|
||
$type: 'com.example.stringLengthNoMinLength',
|
||
string: 'abcd',
|
||
})
|
||
lex.assertValidRecord('com.example.stringLengthNoMinLength', {
|
||
$type: 'com.example.stringLengthNoMinLength',
|
||
string: 'éé', // 'é' + 'é' (2 + 2 bytes = 4 bytes)
|
||
})
|
||
lex.assertValidRecord('com.example.stringLengthNoMinLength', {
|
||
$type: 'com.example.stringLengthNoMinLength',
|
||
string: 'aaé', // 1 + 1 + 2 = 4 bytes
|
||
})
|
||
lex.assertValidRecord('com.example.stringLengthNoMinLength', {
|
||
$type: 'com.example.stringLengthNoMinLength',
|
||
string: '👋', // 4 bytes
|
||
})
|
||
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.stringLengthNoMinLength', {
|
||
$type: 'com.example.stringLengthNoMinLength',
|
||
string: 'abcde',
|
||
}),
|
||
).toThrow('Record/string must not be longer than 4 characters')
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.stringLengthNoMinLength', {
|
||
$type: 'com.example.stringLengthNoMinLength',
|
||
string: 'a\u0301\u0301', // 1 + (2 * 2) = 5 bytes
|
||
}),
|
||
).toThrow('Record/string must not be longer than 4 characters')
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.stringLengthNoMinLength', {
|
||
$type: 'com.example.stringLengthNoMinLength',
|
||
string: '\uD83D\uD83D', // Two unpaired high surrogates (3 * 2 = 6 bytes)
|
||
}),
|
||
).toThrow('Record/string must not be longer than 4 characters')
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.stringLengthNoMinLength', {
|
||
$type: 'com.example.stringLengthNoMinLength',
|
||
string: 'ééé', // 2 + 2 + 2 bytes = 6 bytes
|
||
}),
|
||
).toThrow('Record/string must not be longer than 4 characters')
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.stringLengthNoMinLength', {
|
||
$type: 'com.example.stringLengthNoMinLength',
|
||
string: '👋a', // 4 + 1 bytes = 5 bytes
|
||
}),
|
||
).toThrow('Record/string must not be longer than 4 characters')
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.stringLengthNoMinLength', {
|
||
$type: 'com.example.stringLengthNoMinLength',
|
||
string: '👨👨', // 4 + 4 = 8 bytes
|
||
}),
|
||
).toThrow('Record/string must not be longer than 4 characters')
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.stringLengthNoMinLength', {
|
||
$type: 'com.example.stringLengthNoMinLength',
|
||
string: '👨👩👧👧', // 4 emojis × 4 bytes + 3 ZWJs × 3 bytes = 25 bytes
|
||
}),
|
||
).toThrow('Record/string must not be longer than 4 characters')
|
||
})
|
||
|
||
it('Applies grapheme string length constraint', () => {
|
||
// Shorter than two graphemes
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.stringLengthGrapheme', {
|
||
$type: 'com.example.stringLengthGrapheme',
|
||
string: '',
|
||
}),
|
||
).toThrow('Record/string must not be shorter than 2 graphemes')
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.stringLengthGrapheme', {
|
||
$type: 'com.example.stringLengthGrapheme',
|
||
string: '\u0301\u0301\u0301', // Three combining acute accents
|
||
}),
|
||
).toThrow('Record/string must not be shorter than 2 graphemes')
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.stringLengthGrapheme', {
|
||
$type: 'com.example.stringLengthGrapheme',
|
||
string: 'a',
|
||
}),
|
||
).toThrow('Record/string must not be shorter than 2 graphemes')
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.stringLengthGrapheme', {
|
||
$type: 'com.example.stringLengthGrapheme',
|
||
string: 'a\u0301\u0301\u0301\u0301', // 'á́́́' ('a' with four combining acute accents)
|
||
}),
|
||
).toThrow('Record/string must not be shorter than 2 graphemes')
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.stringLengthGrapheme', {
|
||
$type: 'com.example.stringLengthGrapheme',
|
||
string: '5\uFE0F', // '5️' with emoji presentation
|
||
}),
|
||
).toThrow('Record/string must not be shorter than 2 graphemes')
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.stringLengthGrapheme', {
|
||
$type: 'com.example.stringLengthGrapheme',
|
||
string: '👨👩👧👧',
|
||
}),
|
||
).toThrow('Record/string must not be shorter than 2 graphemes')
|
||
|
||
// Two to four graphemes
|
||
lex.assertValidRecord('com.example.stringLengthGrapheme', {
|
||
$type: 'com.example.stringLengthGrapheme',
|
||
string: 'ab',
|
||
})
|
||
lex.assertValidRecord('com.example.stringLengthGrapheme', {
|
||
$type: 'com.example.stringLengthGrapheme',
|
||
string: 'a\u0301b', // 'áb' with combining accent
|
||
})
|
||
lex.assertValidRecord('com.example.stringLengthGrapheme', {
|
||
$type: 'com.example.stringLengthGrapheme',
|
||
string: 'a\u0301b\u0301', // 'áb́'
|
||
})
|
||
lex.assertValidRecord('com.example.stringLengthGrapheme', {
|
||
$type: 'com.example.stringLengthGrapheme',
|
||
string: '😀😀',
|
||
})
|
||
lex.assertValidRecord('com.example.stringLengthGrapheme', {
|
||
$type: 'com.example.stringLengthGrapheme',
|
||
string: '12👨👩👧👧',
|
||
})
|
||
lex.assertValidRecord('com.example.stringLengthGrapheme', {
|
||
$type: 'com.example.stringLengthGrapheme',
|
||
string: 'abcd',
|
||
})
|
||
lex.assertValidRecord('com.example.stringLengthGrapheme', {
|
||
$type: 'com.example.stringLengthGrapheme',
|
||
string: 'a\u0301b\u0301c\u0301d\u0301', // 'áb́ćd́'
|
||
})
|
||
|
||
// Longer than four graphemes
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.stringLengthGrapheme', {
|
||
$type: 'com.example.stringLengthGrapheme',
|
||
string: 'abcde',
|
||
}),
|
||
).toThrow('Record/string must not be longer than 4 graphemes')
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.stringLengthGrapheme', {
|
||
$type: 'com.example.stringLengthGrapheme',
|
||
string: 'a\u0301b\u0301c\u0301d\u0301e\u0301', // 'áb́ćd́é'
|
||
}),
|
||
).toThrow('Record/string must not be longer than 4 graphemes')
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.stringLengthGrapheme', {
|
||
$type: 'com.example.stringLengthGrapheme',
|
||
string: '😀😀😀😀😀',
|
||
}),
|
||
).toThrow('Record/string must not be longer than 4 graphemes')
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.stringLengthGrapheme', {
|
||
$type: 'com.example.stringLengthGrapheme',
|
||
string: 'ab😀de',
|
||
}),
|
||
).toThrow('Record/string must not be longer than 4 graphemes')
|
||
})
|
||
|
||
it('Applies string enum constraint', () => {
|
||
lex.assertValidRecord('com.example.stringEnum', {
|
||
$type: 'com.example.stringEnum',
|
||
string: 'a',
|
||
})
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.stringEnum', {
|
||
$type: 'com.example.stringEnum',
|
||
string: 'c',
|
||
}),
|
||
).toThrow('Record/string must be one of (a|b)')
|
||
})
|
||
|
||
it('Applies string const constraint', () => {
|
||
lex.assertValidRecord('com.example.stringConst', {
|
||
$type: 'com.example.stringConst',
|
||
string: 'a',
|
||
})
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.stringConst', {
|
||
$type: 'com.example.stringConst',
|
||
string: 'b',
|
||
}),
|
||
).toThrow('Record/string must be a')
|
||
})
|
||
|
||
it('Applies datetime formatting constraint', () => {
|
||
for (const datetime of [
|
||
'2022-12-12T00:50:36.809Z',
|
||
'2022-12-12T00:50:36Z',
|
||
'2022-12-12T00:50:36.8Z',
|
||
'2022-12-12T00:50:36.80Z',
|
||
'2022-12-12T00:50:36+00:00',
|
||
'2022-12-12T00:50:36.8+00:00',
|
||
'2022-12-11T19:50:36-05:00',
|
||
'2022-12-11T19:50:36.8-05:00',
|
||
'2022-12-11T19:50:36.80-05:00',
|
||
'2022-12-11T19:50:36.809-05:00',
|
||
]) {
|
||
lex.assertValidRecord('com.example.datetime', {
|
||
$type: 'com.example.datetime',
|
||
datetime,
|
||
})
|
||
}
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.datetime', {
|
||
$type: 'com.example.datetime',
|
||
datetime: 'bad date',
|
||
}),
|
||
).toThrow(
|
||
'Record/datetime must be an valid atproto datetime (both RFC-3339 and ISO-8601)',
|
||
)
|
||
})
|
||
|
||
it('Applies uri formatting constraint', () => {
|
||
for (const uri of [
|
||
'https://example.com',
|
||
'https://example.com/with/path',
|
||
'https://example.com/with/path?and=query',
|
||
'at://bsky.social',
|
||
'did:example:test',
|
||
]) {
|
||
lex.assertValidRecord('com.example.uri', {
|
||
$type: 'com.example.uri',
|
||
uri,
|
||
})
|
||
}
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.uri', {
|
||
$type: 'com.example.uri',
|
||
uri: 'not a uri',
|
||
}),
|
||
).toThrow('Record/uri must be a uri')
|
||
})
|
||
|
||
it('Applies at-uri formatting constraint', () => {
|
||
lex.assertValidRecord('com.example.atUri', {
|
||
$type: 'com.example.atUri',
|
||
atUri: 'at://did:web:example.com/com.example.test/self',
|
||
})
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.atUri', {
|
||
$type: 'com.example.atUri',
|
||
atUri: 'http://not-atproto.com',
|
||
}),
|
||
).toThrow('Record/atUri must be a valid at-uri')
|
||
})
|
||
|
||
it('Applies did formatting constraint', () => {
|
||
lex.assertValidRecord('com.example.did', {
|
||
$type: 'com.example.did',
|
||
did: 'did:web:example.com',
|
||
})
|
||
lex.assertValidRecord('com.example.did', {
|
||
$type: 'com.example.did',
|
||
did: 'did:plc:12345678abcdefghijklmnop',
|
||
})
|
||
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.did', {
|
||
$type: 'com.example.did',
|
||
did: 'bad did',
|
||
}),
|
||
).toThrow('Record/did must be a valid did')
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.did', {
|
||
$type: 'com.example.did',
|
||
did: 'did:short',
|
||
}),
|
||
).toThrow('Record/did must be a valid did')
|
||
})
|
||
|
||
it('Applies handle formatting constraint', () => {
|
||
lex.assertValidRecord('com.example.handle', {
|
||
$type: 'com.example.handle',
|
||
handle: 'test.bsky.social',
|
||
})
|
||
lex.assertValidRecord('com.example.handle', {
|
||
$type: 'com.example.handle',
|
||
handle: 'bsky.test',
|
||
})
|
||
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.handle', {
|
||
$type: 'com.example.handle',
|
||
handle: 'bad handle',
|
||
}),
|
||
).toThrow('Record/handle must be a valid handle')
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.handle', {
|
||
$type: 'com.example.handle',
|
||
handle: '-bad-.test',
|
||
}),
|
||
).toThrow('Record/handle must be a valid handle')
|
||
})
|
||
|
||
it('Applies at-identifier formatting constraint', () => {
|
||
lex.assertValidRecord('com.example.atIdentifier', {
|
||
$type: 'com.example.atIdentifier',
|
||
atIdentifier: 'bsky.test',
|
||
})
|
||
lex.assertValidRecord('com.example.atIdentifier', {
|
||
$type: 'com.example.atIdentifier',
|
||
atIdentifier: 'did:plc:12345678abcdefghijklmnop',
|
||
})
|
||
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.atIdentifier', {
|
||
$type: 'com.example.atIdentifier',
|
||
atIdentifier: 'bad id',
|
||
}),
|
||
).toThrow('Record/atIdentifier must be a valid did or a handle')
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.atIdentifier', {
|
||
$type: 'com.example.atIdentifier',
|
||
atIdentifier: '-bad-.test',
|
||
}),
|
||
).toThrow('Record/atIdentifier must be a valid did or a handle')
|
||
})
|
||
|
||
it('Applies nsid formatting constraint', () => {
|
||
lex.assertValidRecord('com.example.nsid', {
|
||
$type: 'com.example.nsid',
|
||
nsid: 'com.atproto.test',
|
||
})
|
||
lex.assertValidRecord('com.example.nsid', {
|
||
$type: 'com.example.nsid',
|
||
nsid: 'app.bsky.nested.test',
|
||
})
|
||
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.nsid', {
|
||
$type: 'com.example.nsid',
|
||
nsid: 'bad nsid',
|
||
}),
|
||
).toThrow('Record/nsid must be a valid nsid')
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.nsid', {
|
||
$type: 'com.example.nsid',
|
||
nsid: 'com.bad-.foo',
|
||
}),
|
||
).toThrow('Record/nsid must be a valid nsid')
|
||
})
|
||
|
||
it('Applies cid formatting constraint', () => {
|
||
lex.assertValidRecord('com.example.cid', {
|
||
$type: 'com.example.cid',
|
||
cid: 'bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a',
|
||
})
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.cid', {
|
||
$type: 'com.example.cid',
|
||
cid: 'abapsdofiuwrpoiasdfuaspdfoiu',
|
||
}),
|
||
).toThrow('Record/cid must be a cid string')
|
||
})
|
||
|
||
it('Applies language formatting constraint', () => {
|
||
lex.assertValidRecord('com.example.language', {
|
||
$type: 'com.example.language',
|
||
language: 'en-US-boont',
|
||
})
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.language', {
|
||
$type: 'com.example.language',
|
||
language: 'not-a-language-',
|
||
}),
|
||
).toThrow('Record/language must be a well-formed BCP 47 language tag')
|
||
})
|
||
|
||
it('Applies bytes length constraints', () => {
|
||
lex.assertValidRecord('com.example.byteLength', {
|
||
$type: 'com.example.byteLength',
|
||
bytes: new Uint8Array([1, 2, 3]),
|
||
})
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.byteLength', {
|
||
$type: 'com.example.byteLength',
|
||
bytes: new Uint8Array([1]),
|
||
}),
|
||
).toThrow('Record/bytes must not be smaller than 2 bytes')
|
||
expect(() =>
|
||
lex.assertValidRecord('com.example.byteLength', {
|
||
$type: 'com.example.byteLength',
|
||
bytes: new Uint8Array([1, 2, 3, 4, 5]),
|
||
}),
|
||
).toThrow('Record/bytes must not be larger than 4 bytes')
|
||
})
|
||
})
|
||
|
||
describe('XRPC parameter validation', () => {
|
||
const lex = new Lexicons(LexiconDocs)
|
||
|
||
it('Passes valid parameters', () => {
|
||
const queryResult = lex.assertValidXrpcParams('com.example.query', {
|
||
boolean: true,
|
||
integer: 123,
|
||
string: 'string',
|
||
array: ['x', 'y'],
|
||
})
|
||
expect(queryResult).toEqual({
|
||
boolean: true,
|
||
integer: 123,
|
||
string: 'string',
|
||
array: ['x', 'y'],
|
||
def: 0,
|
||
})
|
||
const paramResult = lex.assertValidXrpcParams('com.example.procedure', {
|
||
boolean: true,
|
||
integer: 123,
|
||
string: 'string',
|
||
array: ['x', 'y'],
|
||
def: 1,
|
||
})
|
||
expect(paramResult).toEqual({
|
||
boolean: true,
|
||
integer: 123,
|
||
string: 'string',
|
||
array: ['x', 'y'],
|
||
def: 1,
|
||
})
|
||
})
|
||
|
||
it('Handles required correctly', () => {
|
||
lex.assertValidXrpcParams('com.example.query', {
|
||
boolean: true,
|
||
integer: 123,
|
||
})
|
||
expect(() =>
|
||
lex.assertValidXrpcParams('com.example.query', {
|
||
boolean: true,
|
||
}),
|
||
).toThrow('Params must have the property "integer"')
|
||
expect(() =>
|
||
lex.assertValidXrpcParams('com.example.query', {
|
||
boolean: true,
|
||
integer: undefined,
|
||
}),
|
||
).toThrow('Params must have the property "integer"')
|
||
})
|
||
|
||
it('Validates parameter types', () => {
|
||
expect(() =>
|
||
lex.assertValidXrpcParams('com.example.query', {
|
||
boolean: 'string',
|
||
integer: 123,
|
||
string: 'string',
|
||
}),
|
||
).toThrow('boolean must be a boolean')
|
||
expect(() =>
|
||
lex.assertValidXrpcParams('com.example.query', {
|
||
boolean: true,
|
||
float: 123.45,
|
||
integer: 123,
|
||
string: 'string',
|
||
array: 'x',
|
||
}),
|
||
).toThrow('array must be an array')
|
||
})
|
||
})
|
||
|
||
describe('XRPC input validation', () => {
|
||
const lex = new Lexicons(LexiconDocs)
|
||
|
||
it('Passes valid inputs', () => {
|
||
lex.assertValidXrpcInput('com.example.procedure', {
|
||
object: { boolean: true },
|
||
array: ['one', 'two'],
|
||
boolean: true,
|
||
float: 123.45,
|
||
integer: 123,
|
||
string: 'string',
|
||
})
|
||
})
|
||
|
||
it('Validates the input', () => {
|
||
// dont need to check this extensively since it's the same logic as tested in record validation
|
||
expect(() =>
|
||
lex.assertValidXrpcInput('com.example.procedure', {
|
||
object: { boolean: 'string' },
|
||
array: ['one', 'two'],
|
||
boolean: true,
|
||
float: 123.45,
|
||
integer: 123,
|
||
string: 'string',
|
||
}),
|
||
).toThrow('Input/object/boolean must be a boolean')
|
||
expect(() => lex.assertValidXrpcInput('com.example.procedure', {})).toThrow(
|
||
'Input must have the property "object"',
|
||
)
|
||
})
|
||
})
|
||
|
||
describe('XRPC output validation', () => {
|
||
const lex = new Lexicons(LexiconDocs)
|
||
|
||
it('Passes valid outputs', () => {
|
||
lex.assertValidXrpcOutput('com.example.query', {
|
||
object: { boolean: true },
|
||
array: ['one', 'two'],
|
||
boolean: true,
|
||
float: 123.45,
|
||
integer: 123,
|
||
string: 'string',
|
||
})
|
||
lex.assertValidXrpcOutput('com.example.procedure', {
|
||
object: { boolean: true },
|
||
array: ['one', 'two'],
|
||
boolean: true,
|
||
float: 123.45,
|
||
integer: 123,
|
||
string: 'string',
|
||
})
|
||
})
|
||
|
||
it('Validates the output', () => {
|
||
// dont need to check this extensively since it's the same logic as tested in record validation
|
||
expect(() =>
|
||
lex.assertValidXrpcOutput('com.example.query', {
|
||
object: { boolean: 'string' },
|
||
array: ['one', 'two'],
|
||
boolean: true,
|
||
float: 123.45,
|
||
integer: 123,
|
||
string: 'string',
|
||
}),
|
||
).toThrow('Output/object/boolean must be a boolean')
|
||
expect(() =>
|
||
lex.assertValidXrpcOutput('com.example.procedure', {}),
|
||
).toThrow('Output must have the property "object"')
|
||
})
|
||
})
|