test(api-gateway): cover namespace rollback paths

This commit is contained in:
imccyu
2026-08-07 20:11:42 +08:00
parent 14ea7e134d
commit 8b51a1e95c
2 changed files with 62 additions and 36 deletions

View File

@@ -194,7 +194,7 @@ class ClientRemoteService extends Service implements TypeRTClientRemote {
throw error throw error
} }
return async () => { return async () => {
if (!namespace.service.remove('direct', descriptor.method, token)) return namespace.service.remove('direct', descriptor.method, token)
await this.disposeNamespace(descriptor.namespace, namespace) await this.disposeNamespace(descriptor.namespace, namespace)
} }
} }
@@ -212,7 +212,7 @@ class ClientRemoteService extends Service implements TypeRTClientRemote {
throw error throw error
} }
return async () => { return async () => {
if (!namespace.service.remove('scoped', descriptor.method, token)) return namespace.service.remove('scoped', descriptor.method, token)
await this.disposeNamespace(descriptor.namespace, namespace) await this.disposeNamespace(descriptor.namespace, namespace)
} }
} }
@@ -390,50 +390,36 @@ class RemoteNamespaceService extends Service {
let record = this.methods.get(method) let record = this.methods.get(method)
const fresh = record === undefined const fresh = record === undefined
record ??= {} record ??= {}
if (record[kind] !== undefined) { if (fresh) {
throw new Error(`client api: ${kind} method ${this.namespace}/${method} is already mounted`) Object.defineProperty(this, method, {
} configurable: true,
try { enumerable: true,
if (fresh) { get: function (this: RemoteNamespaceService): (...args: unknown[]) => Promise<unknown> {
Object.defineProperty(this, method, { const callerCtx = this.ctx
configurable: true, const current = this.methods.get(method)
enumerable: true, const direct = current?.direct
get: function (this: RemoteNamespaceService): (...args: unknown[]) => Promise<unknown> { const scoped = current?.scoped
const callerCtx = this.ctx return (...args: unknown[]) => {
const current = this.methods.get(method) return this.invokeRemote(direct, scoped, callerCtx, args)
const direct = current?.direct }
const scoped = current?.scoped },
return (...args: unknown[]) => { })
return this.invokeRemote(direct, scoped, callerCtx, args) this.methods.set(method, record)
}
},
})
this.methods.set(method, record)
}
if (kind === 'direct') record.direct = value
else record.scoped = value as ScopedMethod
} catch (error) {
if (kind === 'direct') delete record.direct
else delete record.scoped
if (fresh) {
this.methods.delete(method)
Reflect.deleteProperty(this, method)
}
throw error
} }
if (kind === 'direct') record.direct = value
else record.scoped = value as ScopedMethod
} }
remove(kind: 'direct' | 'scoped', method: string, token: MountToken): boolean { remove(kind: 'direct' | 'scoped', method: string, token: MountToken): void {
const record = this.methods.get(method) const record = this.methods.get(method)
const current = record?.[kind] const current = record?.[kind]
/* v8 ignore next -- duplicate live variants are rejected before installation, so no newer token can replace this one. */ /* v8 ignore next -- duplicate live variants are rejected before installation, so no newer token can replace this one. */
if (record === undefined || current?.token !== token) return false if (record === undefined || current?.token !== token) return
if (kind === 'direct') delete record.direct if (kind === 'direct') delete record.direct
else delete record.scoped else delete record.scoped
if (record.direct !== undefined || record.scoped !== undefined) return true if (record.direct !== undefined || record.scoped !== undefined) return
this.methods.delete(method) this.methods.delete(method)
Reflect.deleteProperty(this, method) Reflect.deleteProperty(this, method)
return true
} }
} }

View File

@@ -316,6 +316,32 @@ describe('Client TypeRT API', () => {
await retry() await retry()
}) })
it('rolls back a direct projection when its scoped projection fails to install', async () => {
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
const disposeContext = await ctx.remote.$mount({
package: '@fixture/context-anchor',
descriptors: [contextDescriptor()],
})
const namespace = ctx.get('remote.goals') as unknown as {
installScoped: (...args: unknown[]) => void
readonly create?: unknown
}
const installScoped = vi.spyOn(namespace, 'installScoped').mockImplementation(() => {
throw new Error('fixture scoped projection failure')
})
try {
await expect(ctx.remote.$mount({
package: '@fixture/direct-projection-failure',
descriptors: [directDescriptor()],
})).rejects.toThrow('fixture scoped projection failure')
} finally {
installScoped.mockRestore()
}
expect(namespace.create).toBeUndefined()
await disposeContext()
})
it('rejects weak parameter and Context codecs plus malformed scope projections', async () => { it('rejects weak parameter and Context codecs plus malformed scope projections', async () => {
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>()) const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
const direct = directDescriptor() const direct = directDescriptor()
@@ -406,6 +432,20 @@ describe('Client TypeRT API', () => {
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined() expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
}) })
it('rejects a method obtained from a withdrawn namespace getter', async () => {
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
const dispose = await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })
const namespace = ctx.get('remote.goals') as unknown as object
const getter = Object.getOwnPropertyDescriptor(namespace, 'create')?.get
await dispose()
expect(getter).toBeTypeOf('function')
const withdrawn = getter?.call(namespace) as (...args: unknown[]) => Promise<unknown>
await expect(withdrawn('agent-1', { objective: 'ship' }))
.rejects.toThrow('Remote method is no longer mounted')
})
it('preserves a __proto__ wire parameter as an own named argument', async () => { it('preserves a __proto__ wire parameter as an own named argument', async () => {
const call = vi.fn<ConnectionHandle['rpc']['call']>() const call = vi.fn<ConnectionHandle['rpc']['call']>()
.mockResolvedValue({ ok: true, value: { ref: 'goal-1' } }) .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })