src/account/agents.test.ts
v0.3.0 · 6.2 KB
import { describe, expect, it } from 'vitest';
import type { InfoCallOptions, InfoRequest, InfoRequester } from '../transport/types.js';
import {
AGENT_LIMITS,
checkAgentApproval,
fetchAgents,
isAbstractionTransitionNotAllowedError,
isAgentActive,
isAgentNotFoundError,
isUnifiedAccountActionDisabledError,
} from './agents.js';
const addr = (n: number) => `0x${n.toString(16).padStart(40, '0')}`;
const MASTER = addr(1);
const AGENT_A = addr(0xa);
const AGENT_B = addr(0xb);
const AGENT_C = addr(0xc);
const NOW = 1_800_000_000_000;
function makeInfo(answer: unknown | ((body: InfoRequest) => unknown)) {
const calls: InfoRequest[] = [];
const info = (async (body: InfoRequest, _opts?: InfoCallOptions) => {
calls.push(body);
return typeof answer === 'function' ? (answer as (b: InfoRequest) => unknown)(body) : answer;
}) as InfoRequester;
return { info, calls };
}
const LIST = [
{ address: AGENT_A.toUpperCase().replace('0X', '0x'), name: 'bot-1', validUntil: NOW + 86_400_000 },
{ address: AGENT_B, name: 'bot-2', validUntil: NOW - 1 },
{ address: AGENT_C, name: 'forever', validUntil: null },
];
describe('isAgentActive', () => {
it('validUntil in ms; null / 0 / missing = no expiry; boundary is expired', () => {
expect(isAgentActive({ validUntil: NOW + 1 }, NOW)).toBe(true);
expect(isAgentActive({ validUntil: NOW }, NOW)).toBe(false);
expect(isAgentActive({ validUntil: NOW - 1 }, NOW)).toBe(false);
expect(isAgentActive({ validUntil: null }, NOW)).toBe(true);
expect(isAgentActive({ validUntil: 0 }, NOW)).toBe(true);
expect(isAgentActive({}, NOW)).toBe(true);
});
});
describe('fetchAgents', () => {
it('queries extraAgents for the master, lowercases addresses and evaluates expiry', async () => {
const { info, calls } = makeInfo(LIST);
const agents = await fetchAgents(info, MASTER, { now: NOW });
expect(calls).toEqual([{ type: 'extraAgents', user: MASTER }]);
expect(agents).toEqual([
{ address: AGENT_A, name: 'bot-1', validUntil: NOW + 86_400_000, active: true },
{ address: AGENT_B, name: 'bot-2', validUntil: NOW - 1, active: false },
{ address: AGENT_C, name: 'forever', validUntil: null, active: true },
]);
});
it('accepts numeric-string validUntil, maps 0 to null, skips malformed addresses', async () => {
const { info } = makeInfo([
{ address: AGENT_A, name: 'a', validUntil: String(NOW + 5) },
{ address: AGENT_B, name: 'b', validUntil: 0 },
{ address: '0xYOUR_ADDRESS', name: 'junk', validUntil: null },
]);
const agents = await fetchAgents(info, MASTER, { now: NOW });
expect(agents.map((a) => [a.address, a.validUntil, a.active])).toEqual([
[AGENT_A, NOW + 5, true],
[AGENT_B, null, true],
]);
});
it('throws on non-array answers and malformed validUntil', async () => {
await expect(fetchAgents(makeInfo({}).info, MASTER)).rejects.toMatchObject({ code: 'INVALID_RESPONSE' });
await expect(fetchAgents(makeInfo([{ address: AGENT_A, name: 'a', validUntil: 'soon' }]).info, MASTER)).rejects.toMatchObject({
code: 'INVALID_RESPONSE',
});
});
});
describe('checkAgentApproval', () => {
it('ok when listed (case-insensitive) and not expired', async () => {
const r = await checkAgentApproval(makeInfo(LIST).info, MASTER, AGENT_A.toUpperCase().replace('0X', '0x'), { now: NOW });
expect(r).toEqual({ approved: true, reason: 'ok', validUntil: NOW + 86_400_000 });
});
it('no-expiry agent is ok with validUntil null', async () => {
expect(await checkAgentApproval(makeInfo(LIST).info, MASTER, AGENT_C, { now: NOW })).toEqual({
approved: true,
reason: 'ok',
validUntil: null,
});
});
it('expired when validUntil <= now', async () => {
expect(await checkAgentApproval(makeInfo(LIST).info, MASTER, AGENT_B, { now: NOW })).toMatchObject({
approved: false,
reason: 'expired',
});
});
it('not_listed covers both "Authorize not signed" and unnamed agents (a warning, not proof)', async () => {
expect(await checkAgentApproval(makeInfo(LIST).info, MASTER, addr(0xd), { now: NOW })).toMatchObject({
approved: false,
reason: 'not_listed',
});
});
it('query_failed on transport errors or malformed answers', async () => {
const failing = makeInfo(() => {
throw new Error('ECONNRESET');
});
expect(await checkAgentApproval(failing.info, MASTER, AGENT_A)).toMatchObject({
approved: false,
reason: 'query_failed',
error: 'ECONNRESET',
});
expect(await checkAgentApproval(makeInfo('oops').info, MASTER, AGENT_A)).toMatchObject({ reason: 'query_failed' });
});
it('invalid addresses are caller errors, not query failures', async () => {
await expect(checkAgentApproval(makeInfo(LIST).info, MASTER, 'nope')).rejects.toMatchObject({ code: 'INVALID_ARGUMENT' });
await expect(checkAgentApproval(makeInfo(LIST).info, 'nope', AGENT_A)).rejects.toMatchObject({ code: 'INVALID_ARGUMENT' });
});
});
describe('error classifiers', () => {
it('detects the dead-agent error in Error, string and raw response forms', () => {
const text = `User or API Wallet ${AGENT_A} does not exist.`;
expect(isAgentNotFoundError(new Error(text))).toBe(true);
expect(isAgentNotFoundError(text)).toBe(true);
expect(isAgentNotFoundError({ status: 'err', response: text })).toBe(true);
expect(isAgentNotFoundError(new Error('Insufficient margin'))).toBe(false);
});
it('treats "Abstraction transition not allowed" as a recognisable success signal', () => {
expect(isAbstractionTransitionNotAllowedError(new Error('Abstraction transition not allowed'))).toBe(true);
expect(isAbstractionTransitionNotAllowedError(new Error('Invalid signature'))).toBe(false);
});
it('detects unified-account disabled actions', () => {
expect(isUnifiedAccountActionDisabledError(new Error('Action disabled when unified account is active'))).toBe(true);
expect(isUnifiedAccountActionDisabledError(new Error('Action disabled'))).toBe(false);
});
});
describe('AGENT_LIMITS', () => {
it('matches the documented limits', () => {
expect(AGENT_LIMITS).toEqual({ unnamedPerAccount: 1, namedPerAccount: 3, extraPerSubAccount: 2 });
expect(Object.isFrozen(AGENT_LIMITS)).toBe(true);
});
});