363 lines
13 KiB
TypeScript
363 lines
13 KiB
TypeScript
import 'dotenv/config';
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { randomUUID, randomBytes } from 'node:crypto';
|
|
import { hash } from 'bcryptjs';
|
|
import { Database } from '../src/database';
|
|
import { AgentOAuth, urls } from '../src/mcp/oauth';
|
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
import { today } from '../src/validation';
|
|
|
|
test(
|
|
'connection permission edits, OAuth refresh, account repayment drafts and protecting non-default user accounts',
|
|
{
|
|
skip: process.env.TEST_ISOLATED !== 'true',
|
|
},
|
|
async () => {
|
|
const db = new Database(),
|
|
oauth = new AgentOAuth(db);
|
|
const ids: string[] = [],
|
|
clients: Client[] = [];
|
|
let clientId = '';
|
|
const password = randomBytes(20).toString('hex');
|
|
const base = process.env.TEST_API_URL!;
|
|
async function web(cookie: string, path: string, method = 'GET', body?: unknown) {
|
|
const r = await fetch(base + path, {
|
|
method,
|
|
headers: {
|
|
Origin: process.env.WEB_ORIGIN!,
|
|
Cookie: cookie,
|
|
...(body ? { 'Content-Type': 'application/json' } : {}),
|
|
},
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
return {
|
|
status: r.status,
|
|
data: await r.json(),
|
|
cookie: r.headers.get('set-cookie')?.split(';')[0] || cookie,
|
|
};
|
|
}
|
|
async function user(role: 'admin' | 'user' | 'readonly') {
|
|
const u = await db.user.create({
|
|
data: {
|
|
username: 'update_access_' + randomUUID(),
|
|
role,
|
|
passwordHash: await hash(password, 10),
|
|
},
|
|
});
|
|
ids.push(u.id);
|
|
const login = await web('', '/auth/login', 'POST', { username: u.username, password });
|
|
assert.equal(login.status, 201);
|
|
return { ...u, cookie: login.cookie };
|
|
}
|
|
async function connect(token: string) {
|
|
const client = new Client({ name: 'Update regression', version: '1' });
|
|
clients.push(client);
|
|
await client.connect(
|
|
new StreamableHTTPClientTransport(urls().resource, {
|
|
requestInit: { headers: { Authorization: 'Bearer ' + token } },
|
|
}),
|
|
);
|
|
assert.ok((await client.listTools()).tools.some((t) => t.name === 'movement_create'));
|
|
return client;
|
|
}
|
|
async function tool(c: Client, name: string, args: any = {}) {
|
|
const r: any = await c.callTool({ name, arguments: args });
|
|
assert.ok(!r.isError, JSON.stringify(r));
|
|
return r.structuredContent.data;
|
|
}
|
|
async function draft(c: Client, name: string, args: any) {
|
|
return tool(c, name, {
|
|
...args,
|
|
expectedState: (await tool(c, 'state_get')).state,
|
|
idempotencyKey: randomUUID(),
|
|
});
|
|
}
|
|
const position = {
|
|
kind: 'account',
|
|
side: 'asset',
|
|
name: 'payer',
|
|
category: 'bank',
|
|
currency: 'CNY',
|
|
amount: '100',
|
|
date: today(),
|
|
notes: '',
|
|
};
|
|
try {
|
|
const admin = await user('admin'),
|
|
owner = await user('user'),
|
|
readonly = await user('readonly');
|
|
const pat = await web(owner.cookie, '/agent/tokens', 'POST', {
|
|
name: 'draft grant',
|
|
scopes: ['read', 'draft'],
|
|
days: 1,
|
|
password,
|
|
});
|
|
assert.equal(pat.status, 201);
|
|
const sdk = await connect(pat.data.token);
|
|
const pending = await draft(sdk, 'position_create', position);
|
|
assert.equal(pending.status, 'pending');
|
|
assert.equal(
|
|
(
|
|
await web(owner.cookie, '/agent/connections/' + pat.data.id, 'PATCH', {
|
|
scopes: ['read', 'draft'],
|
|
password,
|
|
})
|
|
).status,
|
|
200,
|
|
);
|
|
assert.equal(
|
|
(await tool(sdk, 'operation_get', { operationId: pending.operationId })).status,
|
|
'pending',
|
|
);
|
|
|
|
const path = '/agent/connections/' + pat.data.id;
|
|
assert.equal(
|
|
(await web(admin.cookie, path, 'PATCH', { scopes: ['read', 'write'], password })).status,
|
|
404,
|
|
);
|
|
assert.equal(
|
|
(await web(owner.cookie, path, 'PATCH', { scopes: ['read', 'write'], password: 'wrong' }))
|
|
.status,
|
|
403,
|
|
);
|
|
assert.equal(
|
|
(await web(owner.cookie, path, 'PATCH', { scopes: ['read', 'draft', 'write'], password }))
|
|
.status,
|
|
400,
|
|
);
|
|
const before = await db.agentGrant.findUniqueOrThrow({ where: { id: pat.data.id } });
|
|
assert.equal(
|
|
(await web(owner.cookie, path, 'PATCH', { scopes: ['read', 'write'], password })).status,
|
|
200,
|
|
);
|
|
assert.equal((await tool(sdk, 'connection_info')).permission, 'write');
|
|
assert.equal(
|
|
(await tool(sdk, 'operation_get', { operationId: pending.operationId })).status,
|
|
'cancelled',
|
|
);
|
|
assert.equal(
|
|
(
|
|
await db.agentGrant.findUniqueOrThrow({ where: { id: before.id } })
|
|
).expiresAt?.toISOString(),
|
|
before.expiresAt?.toISOString(),
|
|
);
|
|
assert.equal(await db.position.count({ where: { userId: owner.id } }), 0);
|
|
assert.equal(
|
|
(await web(owner.cookie, path, 'PATCH', { scopes: ['read', 'draft'], password })).status,
|
|
200,
|
|
);
|
|
|
|
const source = await web(owner.cookie, '/positions', 'POST', position);
|
|
const target = await web(owner.cookie, '/positions', 'POST', {
|
|
...position,
|
|
side: 'liability',
|
|
name: 'credit',
|
|
category: 'credit_card',
|
|
amount: '80',
|
|
});
|
|
assert.equal(source.status, 201);
|
|
assert.equal(target.status, 201);
|
|
const repayment = {
|
|
operation: 'repay',
|
|
sourceId: source.data.id,
|
|
targetId: target.data.id,
|
|
amount: '50',
|
|
received: '50',
|
|
fee: '-2',
|
|
date: today(),
|
|
notes: '',
|
|
};
|
|
const pay = await draft(sdk, 'movement_create', repayment);
|
|
assert.equal(pay.status, 'pending');
|
|
assert.equal((await tool(sdk, 'position_get', { id: source.data.id })).amount, '100');
|
|
const approved = await web(owner.cookie, '/agent/operations/confirm-batch', 'POST', {
|
|
approve: true,
|
|
operationIds: [pay.operationId],
|
|
});
|
|
assert.equal(approved.status, 201, JSON.stringify(approved.data));
|
|
const applied = await tool(sdk, 'operation_get', { operationId: pay.operationId });
|
|
assert.equal(applied.status, 'completed');
|
|
assert.equal((await tool(sdk, 'position_get', { id: source.data.id })).amount, '52');
|
|
assert.equal((await tool(sdk, 'position_get', { id: target.data.id })).amount, '30');
|
|
const record = await db.transfer.findUniqueOrThrow({ where: { id: applied.result.id } });
|
|
assert.equal(record.operation, 'repay');
|
|
assert.equal(await db.positionLink.count({ where: { sourceId: target.data.id } }), 0);
|
|
assert.equal(
|
|
(
|
|
await web(owner.cookie, '/transfers/' + record.id, 'PUT', {
|
|
...repayment,
|
|
amount: '90',
|
|
received: '90',
|
|
fee: '0',
|
|
})
|
|
).status,
|
|
200,
|
|
);
|
|
assert.equal((await tool(sdk, 'position_get', { id: target.data.id })).amount, '-10');
|
|
assert.equal((await web(owner.cookie, '/transfers/' + record.id, 'DELETE')).status, 200);
|
|
assert.equal((await tool(sdk, 'position_get', { id: source.data.id })).amount, '100');
|
|
assert.equal((await tool(sdk, 'position_get', { id: target.data.id })).amount, '80');
|
|
assert.equal(
|
|
(
|
|
await web(owner.cookie, '/transfers', 'POST', {
|
|
...repayment,
|
|
sourceId: target.data.id,
|
|
targetId: source.data.id,
|
|
})
|
|
).status,
|
|
400,
|
|
);
|
|
assert.equal((await web(admin.cookie, '/transfers', 'POST', repayment)).status, 400);
|
|
|
|
const client = await oauth.clientsStore.registerClient({
|
|
client_name: 'Permission regression',
|
|
redirect_uris: ['http://127.0.0.1:47891/callback'],
|
|
token_endpoint_auth_method: 'none',
|
|
grant_types: ['authorization_code', 'refresh_token'],
|
|
});
|
|
clientId = client.client_id;
|
|
const issued = await db.atomic(() =>
|
|
oauth.issue(owner.id, 'OAuth regression', ['read'], 1 / 24, clientId, 7),
|
|
);
|
|
const oauthPath = '/agent/connections/' + issued.grant.id;
|
|
const rights = ['read', 'write', 'hidden_read', 'hidden_write'];
|
|
assert.equal(
|
|
(await web(owner.cookie, oauthPath, 'PATCH', { scopes: rights, password })).status,
|
|
200,
|
|
);
|
|
assert.deepEqual((await oauth.verifyAccessToken(issued.tokens.access_token)).scopes, rights);
|
|
const refreshed = await oauth.exchangeRefreshToken(
|
|
client,
|
|
issued.tokens.refresh_token!,
|
|
undefined,
|
|
urls().resource,
|
|
);
|
|
assert.deepEqual((await oauth.verifyAccessToken(refreshed.access_token)).scopes, rights);
|
|
assert.equal(
|
|
(
|
|
await db.agentGrant.findUniqueOrThrow({ where: { id: issued.grant.id } })
|
|
).refreshExpiresAt?.toISOString(),
|
|
issued.grant.refreshExpiresAt?.toISOString(),
|
|
);
|
|
await db.agentGrant.update({
|
|
where: { id: issued.grant.id },
|
|
data: { expiresAt: new Date(0) },
|
|
});
|
|
assert.equal(
|
|
(await web(owner.cookie, oauthPath, 'PATCH', { scopes: ['read'], password })).status,
|
|
200,
|
|
);
|
|
const renewed = await oauth.exchangeRefreshToken(
|
|
client,
|
|
refreshed.refresh_token!,
|
|
undefined,
|
|
urls().resource,
|
|
);
|
|
assert.deepEqual((await oauth.verifyAccessToken(renewed.access_token)).scopes, ['read']);
|
|
|
|
await assert.rejects(
|
|
oauth.exchangeRefreshToken(
|
|
client,
|
|
renewed.refresh_token!,
|
|
['read', 'write'],
|
|
urls().resource,
|
|
),
|
|
);
|
|
const concurrent = await Promise.all([
|
|
web(owner.cookie, oauthPath, 'PATCH', { scopes: rights, password }),
|
|
oauth.exchangeRefreshToken(client, renewed.refresh_token!, undefined, urls().resource),
|
|
]);
|
|
assert.equal(concurrent[0].status, 200);
|
|
assert.deepEqual((await oauth.verifyAccessToken(concurrent[1].access_token)).scopes, rights);
|
|
await db.agentGrant.update({
|
|
where: { id: issued.grant.id },
|
|
data: { refreshExpiresAt: new Date(0) },
|
|
});
|
|
assert.equal(
|
|
(await web(owner.cookie, oauthPath, 'PATCH', { scopes: ['read'], password })).status,
|
|
404,
|
|
);
|
|
const openapi = await web(owner.cookie, '/openapi.json');
|
|
assert.ok(
|
|
openapi.data.paths['/api/agent/connections/{id}'].patch.requestBody.content[
|
|
'application/json'
|
|
].schema.properties.scopes,
|
|
);
|
|
const ro = await web(readonly.cookie, '/agent/tokens', 'POST', {
|
|
name: 'readonly',
|
|
scopes: ['read'],
|
|
days: 1,
|
|
password,
|
|
});
|
|
assert.equal(ro.status, 201);
|
|
assert.equal(
|
|
(
|
|
await web(readonly.cookie, '/agent/connections/' + ro.data.id, 'PATCH', {
|
|
scopes: ['read', 'write'],
|
|
password,
|
|
})
|
|
).status,
|
|
403,
|
|
);
|
|
assert.equal(
|
|
(
|
|
await web(readonly.cookie, '/agent/connections/' + ro.data.id, 'PATCH', {
|
|
scopes: ['read', 'hidden_read'],
|
|
password,
|
|
})
|
|
).status,
|
|
200,
|
|
);
|
|
await web(owner.cookie, path, 'DELETE');
|
|
assert.equal(
|
|
(await web(owner.cookie, path, 'PATCH', { scopes: ['read'], password })).status,
|
|
404,
|
|
);
|
|
await assert.rejects(oauth.verifyAccessToken(pat.data.token));
|
|
|
|
for (const targetUser of [owner, readonly, await user('admin')]) {
|
|
const removePath = '/admin/users/' + targetUser.id;
|
|
const body = { confirmationUsername: targetUser.username, currentPassword: password };
|
|
assert.equal((await web(readonly.cookie, removePath, 'DELETE', body)).status, 403);
|
|
assert.equal(
|
|
(
|
|
await web(admin.cookie, removePath, 'DELETE', {
|
|
...body,
|
|
confirmationUsername: 'wrong',
|
|
})
|
|
).status,
|
|
403,
|
|
);
|
|
assert.equal(
|
|
(await web(admin.cookie, removePath, 'DELETE', { ...body, currentPassword: 'wrong' }))
|
|
.status,
|
|
403,
|
|
);
|
|
assert.equal((await web(admin.cookie, removePath, 'DELETE', body)).status, 403);
|
|
assert.ok(await db.user.findUnique({ where: { id: targetUser.id } }));
|
|
if (targetUser.id === owner.id)
|
|
assert.equal(await db.position.count({ where: { userId: targetUser.id } }), 2);
|
|
if (targetUser.id === owner.id)
|
|
assert.equal(await db.agentGrant.count({ where: { userId: targetUser.id } }), 2);
|
|
assert.equal((await web(targetUser.cookie, '/auth/me')).status, 200);
|
|
}
|
|
assert.equal(
|
|
(
|
|
await web(admin.cookie, '/admin/users/' + admin.id, 'DELETE', {
|
|
confirmationUsername: admin.username,
|
|
currentPassword: password,
|
|
})
|
|
).status,
|
|
403,
|
|
);
|
|
} finally {
|
|
for (const c of clients) await c.close().catch(() => {});
|
|
await db.user.deleteMany({ where: { id: { in: ids } } });
|
|
if (clientId) await db.agentClient.deleteMany({ where: { id: clientId } });
|
|
await db.$disconnect();
|
|
}
|
|
},
|
|
);
|