Skip to content
markpaper

src/account/agents.test.ts

v0.3.0 · 6.2 KB

Download file
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);
  });
});
All files