Files
zeekay c1c72dea64 Call IAM on api.hanzo.ai, by its resource routes
iam.hanzo.ai serves only the IAM web app, so every token exchange,
refresh and userinfo aimed there got HTML back. @hanzo/auth took its
API host from @hanzo/iam's BRAND_SERVER_URLS.hanzo, which in 0.21.17
still names that host; it now states the gateway, api.hanzo.ai, and
keeps taking the paths from @hanzo/iam. office and outlook ride on it.
vscode's standalone auth, cli-tools' HanzoAuth and the mcp server
default to the gateway too.

The mcp iam tool called IAM's verb paths, which answer 410 (get-users,
update-role, ...) or 404 (get-tokens, get-sessions, ...). It now uses
the resources: GET and POST on /v1/iam/<kind>, GET, PUT and DELETE on
/v1/iam/<kind>/<owner>/<name>. update_user nests the row under "user".
update_org writes /organizations/profile, since a PUT of the masked row
a read returns would store the mask. delete_session takes the full
owner/name/application key the session is filed under.

dxt's server.js is rebuilt from mcp. The Office manifests drop the
iam.hanzo.ai AppDomain.

Claude-Session: https://claude.ai/code/session_01Dc8DXc3fEQCRHUa6XWK4Ku
2026-09-29 12:09:09 -07:00

97 lines
5.0 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.hoisted(() => {
delete process.env.API_URL;
delete process.env.IAM_URL;
process.env.HANZO_API_KEY = 'sk-test';
});
import { iamTool, authTool } from '../src/tools/hanzo-cloud';
// IAM on the unified gateway, one entity at /v1/iam/<kind>/<owner>/<name>, the
// verb in the method. Each row is the requests one action makes, in order.
const B = 'https://api.hanzo.ai/v1/iam';
type Call = [method: string, url: string, body?: unknown];
let calls: Call[];
beforeEach(() => {
calls = [];
vi.stubGlobal('fetch', vi.fn(async (url: string, init: any = {}) => {
const call: Call = [init.method || 'GET', url];
if (init.body !== undefined) {
try { call.push(JSON.parse(init.body)); } catch { call.push(init.body); }
}
calls.push(call);
return { ok: true, text: async () => JSON.stringify({ name: 'x' }) } as any;
}));
});
const iam: Array<[Record<string, unknown>, Call[]]> = [
[{ action: 'list_users', owner: 'acme' }, [['GET', `${B}/users?owner=acme&limit=50`]]],
[{ action: 'get_user', id: 'acme/bob' }, [['GET', `${B}/users/acme/bob`]]],
[{ action: 'get_user', id: 'bob' }, [['GET', `${B}/users/admin/bob`]]],
[{ action: 'create_user', owner: 'acme', name: 'bob', email: '[email protected]', password: 'pw' },
[['POST', `${B}/users`, { user: { owner: 'acme', name: 'bob', email: '[email protected]' }, password: 'pw' }]]],
[{ action: 'update_user', id: 'acme/bob', displayName: 'Bob' },
[['GET', `${B}/users/acme/bob`], ['PUT', `${B}/users/acme/bob`, { user: { name: 'x', displayName: 'Bob' } }]]],
[{ action: 'delete_user', id: 'acme/bob' }, [['DELETE', `${B}/users/acme/bob`]]],
[{ action: 'list_orgs' }, [['GET', `${B}/organizations?limit=50`]]],
[{ action: 'get_org', id: 'acme' }, [['GET', `${B}/organizations/admin/acme`]]],
[{ action: 'create_org', name: 'acme' },
[['POST', `${B}/organizations`, { owner: 'admin', name: 'acme', displayName: 'acme' }]]],
[{ action: 'update_org', id: 'acme', displayName: 'Acme', data: { websiteUrl: 'https://acme.test' } },
[['POST', `${B}/organizations/profile`, { owner: 'admin', name: 'acme', displayName: 'Acme', websiteUrl: 'https://acme.test' }]]],
[{ action: 'delete_org', id: 'acme' }, [['DELETE', `${B}/organizations/admin/acme`]]],
[{ action: 'list_roles', owner: 'acme' }, [['GET', `${B}/roles?owner=acme`]]],
[{ action: 'get_role', id: 'acme/r' }, [['GET', `${B}/roles/acme/r`]]],
[{ action: 'create_role', owner: 'acme', name: 'r' },
[['POST', `${B}/roles`, { owner: 'acme', name: 'r', displayName: 'r' }]]],
[{ action: 'update_role', id: 'acme/r', data: { description: 'd' } },
[['GET', `${B}/roles/acme/r`], ['PUT', `${B}/roles/acme/r`, { name: 'x', description: 'd' }]]],
[{ action: 'delete_role', id: 'acme/r' }, [['DELETE', `${B}/roles/acme/r`]]],
[{ action: 'list_apps', owner: 'acme' }, [['GET', `${B}/applications?owner=acme`]]],
[{ action: 'get_app', id: 'acme/app' }, [['GET', `${B}/applications/acme/app`]]],
[{ action: 'create_app', owner: 'acme', name: 'app' },
[['POST', `${B}/applications`, { owner: 'acme', name: 'app', displayName: 'app', organization: 'hanzo' }]]],
[{ action: 'list_providers', owner: 'acme' }, [['GET', `${B}/providers?owner=acme`]]],
[{ action: 'list_tokens', owner: 'acme' }, [['GET', `${B}/tokens?owner=acme`]]],
[{ action: 'create_token', owner: 'acme', name: 't' }, [['POST', `${B}/tokens`, { owner: 'acme', name: 't' }]]],
[{ action: 'delete_token', owner: 'acme', id: 't' }, [['DELETE', `${B}/tokens/acme/t`]]],
[{ action: 'list_permissions', owner: 'acme' }, [['GET', `${B}/permissions?owner=acme`]]],
[{ action: 'assign_role', role: 'acme/r', user: 'acme/bob' },
[['GET', `${B}/roles/acme/r`], ['PUT', `${B}/roles/acme/r`, { name: 'x', users: ['acme/bob'] }]]],
[{ action: 'remove_role', role: 'acme/r', user: 'acme/bob' },
[['GET', `${B}/roles/acme/r`], ['PUT', `${B}/roles/acme/r`, { name: 'x', users: [] }]]],
[{ action: 'list_sessions', owner: 'acme' }, [['GET', `${B}/sessions?owner=acme`]]],
[{ action: 'delete_session', id: 'acme/bob/app' }, [['DELETE', `${B}/sessions/acme/bob/app`]]],
[{ action: 'health' }, [['GET', `${B}/health`]]],
];
describe('iam tool', () => {
it.each(iam)('%o', async (args, want) => {
const res = await iamTool.handler(args);
expect(res.isError).toBeUndefined();
expect(calls).toEqual(want);
});
it('delete_session refuses a key without the application', async () => {
const res = await iamTool.handler({ action: 'delete_session', id: 'bob' });
expect(res.isError).toBe(true);
expect(calls).toEqual([]);
});
});
describe('auth tool', () => {
it('reads userinfo, logs out and refreshes on the gateway', async () => {
await authTool.handler({ action: 'whoami' });
await authTool.handler({ action: 'logout' });
await authTool.handler({ action: 'refresh', refreshToken: 'RT' });
expect(calls.map(([m, u]) => [m, u])).toEqual([
['GET', `${B}/oauth/userinfo`],
['POST', `${B}/oauth/logout`],
['POST', `${B}/oauth/token`],
]);
});
});