feat(app-state): App State Sync Resilience (#208)
feat(app-state): App State Sync Resilience (#208)
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
import { Boom } from '@hapi/boom'
|
||||
import { proto } from '../../../WAProto/index.js'
|
||||
import {
|
||||
decodeSyncdMutations,
|
||||
decodeSyncdPatch,
|
||||
decodeSyncdSnapshot,
|
||||
isAppStateSyncIrrecoverable,
|
||||
MAX_SYNC_ATTEMPTS,
|
||||
newLTHashState
|
||||
} from '../../Utils/chat-utils'
|
||||
|
||||
const missingKeyFn = async () => null
|
||||
|
||||
describe('App State Sync', () => {
|
||||
describe('missing key errors throw with statusCode 404', () => {
|
||||
it('decodeSyncdPatch throws 404 on missing key', async () => {
|
||||
const msg: proto.ISyncdPatch = {
|
||||
keyId: { id: Buffer.from('missing-key') },
|
||||
mutations: [],
|
||||
version: { version: 1 as any },
|
||||
snapshotMac: Buffer.alloc(32),
|
||||
patchMac: Buffer.alloc(32)
|
||||
}
|
||||
|
||||
await expect(
|
||||
decodeSyncdPatch(msg, 'regular_low', newLTHashState(), missingKeyFn, () => {}, true)
|
||||
).rejects.toMatchObject({ output: { statusCode: 404 } })
|
||||
})
|
||||
|
||||
it('decodeSyncdSnapshot throws 404 on missing snapshot key', async () => {
|
||||
const snapshot: proto.ISyncdSnapshot = {
|
||||
version: { version: 1 as any },
|
||||
records: [],
|
||||
keyId: { id: Buffer.from('missing-key') },
|
||||
mac: Buffer.alloc(32)
|
||||
}
|
||||
|
||||
await expect(decodeSyncdSnapshot('regular_low', snapshot, missingKeyFn, undefined, true)).rejects.toMatchObject({
|
||||
output: { statusCode: 404 }
|
||||
})
|
||||
})
|
||||
|
||||
it('decodeSyncdMutations throws 404 on missing mutation key', async () => {
|
||||
const records: proto.ISyncdRecord[] = [
|
||||
{
|
||||
keyId: { id: Buffer.from('missing-key') },
|
||||
value: { blob: Buffer.alloc(64) },
|
||||
index: { blob: Buffer.alloc(32) }
|
||||
}
|
||||
]
|
||||
|
||||
await expect(decodeSyncdMutations(records, newLTHashState(), missingKeyFn, () => {}, true)).rejects.toMatchObject(
|
||||
{ output: { statusCode: 404 } }
|
||||
)
|
||||
})
|
||||
|
||||
it('decodeSyncdMutations throws 404 even with validateMacs=false', async () => {
|
||||
const records: proto.ISyncdRecord[] = [
|
||||
{
|
||||
keyId: { id: Buffer.from('missing-key') },
|
||||
value: { blob: Buffer.alloc(64) },
|
||||
index: { blob: Buffer.alloc(32) }
|
||||
}
|
||||
]
|
||||
|
||||
await expect(
|
||||
decodeSyncdMutations(records, newLTHashState(), missingKeyFn, () => {}, false)
|
||||
).rejects.toMatchObject({ output: { statusCode: 404 } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAppStateSyncIrrecoverable', () => {
|
||||
it.each([400, 404, 405, 406])('should be irrecoverable for status %d on first attempt', statusCode => {
|
||||
expect(isAppStateSyncIrrecoverable(new Boom('test', { statusCode }), 1)).toBe(true)
|
||||
})
|
||||
|
||||
it('should be irrecoverable for TypeError', () => {
|
||||
expect(isAppStateSyncIrrecoverable(new TypeError('WASM crash'), 1)).toBe(true)
|
||||
})
|
||||
|
||||
it('should be irrecoverable when attempts >= MAX_SYNC_ATTEMPTS', () => {
|
||||
expect(isAppStateSyncIrrecoverable(new Error('generic'), MAX_SYNC_ATTEMPTS)).toBe(true)
|
||||
})
|
||||
|
||||
it('should NOT be irrecoverable for generic error below max attempts', () => {
|
||||
expect(isAppStateSyncIrrecoverable(new Error('generic'), 1)).toBe(false)
|
||||
})
|
||||
|
||||
it('should NOT be irrecoverable for non-fatal status codes', () => {
|
||||
expect(isAppStateSyncIrrecoverable(new Boom('server error', { statusCode: 500 }), 1)).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle null/undefined error gracefully', () => {
|
||||
expect(isAppStateSyncIrrecoverable(null, MAX_SYNC_ATTEMPTS)).toBe(true)
|
||||
expect(isAppStateSyncIrrecoverable(undefined, 1)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { LTHashState } from '../../Types'
|
||||
import { ensureLTHashStateVersion } from '../../Utils/chat-utils'
|
||||
|
||||
describe('ensureLTHashStateVersion', () => {
|
||||
const makeState = (version: any): LTHashState => ({
|
||||
version,
|
||||
hash: Buffer.alloc(128),
|
||||
indexValueMap: { someKey: { valueMac: Buffer.from([1, 2, 3]) } }
|
||||
})
|
||||
|
||||
it('should return state unchanged for valid numeric version', () => {
|
||||
const state = makeState(5)
|
||||
const result = ensureLTHashStateVersion(state)
|
||||
expect(result).toBe(state)
|
||||
expect(result.version).toBe(5)
|
||||
})
|
||||
|
||||
it('should fix undefined version to 0', () => {
|
||||
const state = makeState(undefined)
|
||||
const result = ensureLTHashStateVersion(state)
|
||||
expect(result.version).toBe(0)
|
||||
})
|
||||
|
||||
it('should fix null version to 0', () => {
|
||||
const state = makeState(null)
|
||||
const result = ensureLTHashStateVersion(state)
|
||||
expect(result.version).toBe(0)
|
||||
})
|
||||
|
||||
it('should fix NaN version to 0', () => {
|
||||
const state = makeState(NaN)
|
||||
const result = ensureLTHashStateVersion(state)
|
||||
expect(result.version).toBe(0)
|
||||
})
|
||||
|
||||
it('should fix string version to 0', () => {
|
||||
const state = makeState('3' as any)
|
||||
const result = ensureLTHashStateVersion(state)
|
||||
expect(result.version).toBe(0)
|
||||
})
|
||||
|
||||
it('should keep version 0 as-is', () => {
|
||||
const state = makeState(0)
|
||||
const result = ensureLTHashStateVersion(state)
|
||||
expect(result).toBe(state)
|
||||
expect(result.version).toBe(0)
|
||||
})
|
||||
|
||||
it('should preserve other state fields', () => {
|
||||
const state = makeState(undefined)
|
||||
const originalHash = state.hash
|
||||
const originalMap = state.indexValueMap
|
||||
const result = ensureLTHashStateVersion(state)
|
||||
expect(result.hash).toBe(originalHash)
|
||||
expect(result.indexValueMap).toBe(originalMap)
|
||||
})
|
||||
|
||||
it('should allow .toString() after fix without throwing', () => {
|
||||
const state = makeState(undefined)
|
||||
ensureLTHashStateVersion(state)
|
||||
expect(() => state.version.toString()).not.toThrow()
|
||||
expect(state.version.toString()).toBe('0')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,152 @@
|
||||
import { proto } from '../../../WAProto/index.js'
|
||||
import { makeLtHashGenerator } from '../../Utils/chat-utils'
|
||||
|
||||
const SET = proto.SyncdMutation.SyncdOperation.SET
|
||||
const REMOVE = proto.SyncdMutation.SyncdOperation.REMOVE
|
||||
|
||||
describe('makeLtHashGenerator', () => {
|
||||
const makeIndex = (id: string) => Buffer.from(id)
|
||||
const makeValue = (id: string) => Buffer.from(`value-${id}`)
|
||||
|
||||
it('should handle SET operation normally', () => {
|
||||
const state = {
|
||||
hash: Buffer.alloc(128),
|
||||
indexValueMap: {}
|
||||
}
|
||||
|
||||
const gen = makeLtHashGenerator(state)
|
||||
gen.mix({
|
||||
indexMac: makeIndex('idx1'),
|
||||
valueMac: makeValue('v1'),
|
||||
operation: SET
|
||||
})
|
||||
|
||||
const result = gen.finish()
|
||||
const key = makeIndex('idx1').toString('base64')
|
||||
expect(result.indexValueMap[key]).toBeDefined()
|
||||
expect(Buffer.from(result.indexValueMap[key]!.valueMac)).toEqual(makeValue('v1'))
|
||||
})
|
||||
|
||||
it('should handle REMOVE with existing previous op', () => {
|
||||
const indexKey = makeIndex('idx1').toString('base64')
|
||||
const state = {
|
||||
hash: Buffer.alloc(128),
|
||||
indexValueMap: {
|
||||
[indexKey]: { valueMac: makeValue('old-v1') }
|
||||
}
|
||||
}
|
||||
|
||||
const gen = makeLtHashGenerator(state)
|
||||
gen.mix({
|
||||
indexMac: makeIndex('idx1'),
|
||||
valueMac: makeValue('v1'),
|
||||
operation: REMOVE
|
||||
})
|
||||
|
||||
const result = gen.finish()
|
||||
expect(result.indexValueMap[indexKey]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should NOT throw on REMOVE without previous op (matches WA Web)', () => {
|
||||
const state = {
|
||||
hash: Buffer.alloc(128),
|
||||
indexValueMap: {}
|
||||
}
|
||||
|
||||
const gen = makeLtHashGenerator(state)
|
||||
|
||||
expect(() => {
|
||||
gen.mix({
|
||||
indexMac: makeIndex('idx1'),
|
||||
valueMac: makeValue('v1'),
|
||||
operation: REMOVE
|
||||
})
|
||||
}).not.toThrow()
|
||||
|
||||
const result = gen.finish()
|
||||
expect(result.hash).toBeDefined()
|
||||
expect(result.indexValueMap).toBeDefined()
|
||||
})
|
||||
|
||||
it('should continue processing after REMOVE without previous op', () => {
|
||||
const state = {
|
||||
hash: Buffer.alloc(128),
|
||||
indexValueMap: {}
|
||||
}
|
||||
|
||||
const gen = makeLtHashGenerator(state)
|
||||
|
||||
gen.mix({
|
||||
indexMac: makeIndex('idx1'),
|
||||
valueMac: makeValue('v1'),
|
||||
operation: REMOVE
|
||||
})
|
||||
|
||||
gen.mix({
|
||||
indexMac: makeIndex('idx2'),
|
||||
valueMac: makeValue('v2'),
|
||||
operation: SET
|
||||
})
|
||||
|
||||
const result = gen.finish()
|
||||
const key2 = makeIndex('idx2').toString('base64')
|
||||
expect(result.indexValueMap[key2]).toBeDefined()
|
||||
expect(Buffer.from(result.indexValueMap[key2]!.valueMac)).toEqual(makeValue('v2'))
|
||||
})
|
||||
|
||||
it('should not add missing REMOVE to indexValueMap', () => {
|
||||
const state = {
|
||||
hash: Buffer.alloc(128),
|
||||
indexValueMap: {}
|
||||
}
|
||||
|
||||
const gen = makeLtHashGenerator(state)
|
||||
gen.mix({
|
||||
indexMac: makeIndex('idx1'),
|
||||
valueMac: makeValue('v1'),
|
||||
operation: REMOVE
|
||||
})
|
||||
|
||||
const result = gen.finish()
|
||||
const key = makeIndex('idx1').toString('base64')
|
||||
expect(result.indexValueMap[key]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should process mix of SET, REMOVE-with-prev, and REMOVE-without-prev', () => {
|
||||
const existingKey = makeIndex('existing').toString('base64')
|
||||
const state = {
|
||||
hash: Buffer.alloc(128),
|
||||
indexValueMap: {
|
||||
[existingKey]: { valueMac: makeValue('old') }
|
||||
}
|
||||
}
|
||||
|
||||
const gen = makeLtHashGenerator(state)
|
||||
|
||||
gen.mix({
|
||||
indexMac: makeIndex('new-entry'),
|
||||
valueMac: makeValue('new'),
|
||||
operation: SET
|
||||
})
|
||||
|
||||
gen.mix({
|
||||
indexMac: makeIndex('existing'),
|
||||
valueMac: makeValue('existing'),
|
||||
operation: REMOVE
|
||||
})
|
||||
|
||||
gen.mix({
|
||||
indexMac: makeIndex('ghost'),
|
||||
valueMac: makeValue('ghost'),
|
||||
operation: REMOVE
|
||||
})
|
||||
|
||||
const result = gen.finish()
|
||||
const newKey = makeIndex('new-entry').toString('base64')
|
||||
const ghostKey = makeIndex('ghost').toString('base64')
|
||||
|
||||
expect(result.indexValueMap[newKey]).toBeDefined()
|
||||
expect(result.indexValueMap[existingKey]).toBeUndefined()
|
||||
expect(result.indexValueMap[ghostKey]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user