848 lines
28 KiB
JavaScript
848 lines
28 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import crypto from 'node:crypto';
|
|
import fs from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import test from 'node:test';
|
|
import { Algorithm as Argon2Algorithm, hashRawSync as argon2HashRawSync } from '@node-rs/argon2';
|
|
import { createUserAuth, clearUserLogoutCookies, userLoginCookies } from './user-auth.mjs';
|
|
|
|
test('ensureAllUserDataSpaces provisions every active non-admin user', async () => {
|
|
const calls = [];
|
|
const pool = {
|
|
async query(sql) {
|
|
if (sql.includes("WHERE role = 'user' AND status = 'active'")) {
|
|
return [[
|
|
{ id: 'user-1', workspace_root: '/tmp/user-1' },
|
|
{ id: 'user-2', workspace_root: '/tmp/user-2' },
|
|
]];
|
|
}
|
|
return [[]];
|
|
},
|
|
};
|
|
const auth = createUserAuth(pool, {
|
|
persistSessions: false,
|
|
provisionUserDataSpace: async (input) => calls.push(input),
|
|
});
|
|
const result = await auth.ensureAllUserDataSpaces();
|
|
assert.equal(result.provisioned, 2);
|
|
assert.deepEqual(result.errors, []);
|
|
assert.deepEqual(calls, [
|
|
{ userId: 'user-1', workspaceRoot: '/tmp/user-1' },
|
|
{ userId: 'user-2', workspaceRoot: '/tmp/user-2' },
|
|
]);
|
|
});
|
|
|
|
function hashPasswordPbkdf2(password, salt) {
|
|
return crypto.pbkdf2Sync(password, salt, 100000, 64, 'sha512').toString('hex');
|
|
}
|
|
|
|
function hashPasswordArgon2id(password, salt) {
|
|
return argon2HashRawSync(password, {
|
|
salt: Buffer.from(salt, 'hex'),
|
|
parallelism: 1,
|
|
outputLen: 32,
|
|
memoryCost: 64 * 1024,
|
|
timeCost: 3,
|
|
algorithm: Argon2Algorithm.Argon2id,
|
|
}).toString('hex');
|
|
}
|
|
|
|
function createAuthPool(userRow) {
|
|
const sessions = [];
|
|
return {
|
|
async query(sql, params = []) {
|
|
if (sql.includes('FROM h5_users u') && sql.includes('WHERE u.username = ?')) {
|
|
return userRow ? [[userRow]] : [[]];
|
|
}
|
|
if (sql.includes('UPDATE h5_users') && sql.includes('password_algorithm')) {
|
|
userRow.salt = params[0];
|
|
userRow.password_hash = params[1];
|
|
userRow.password_algorithm = params[2];
|
|
return [[]];
|
|
}
|
|
if (sql.includes('INSERT INTO h5_login_sessions')) {
|
|
sessions.push({
|
|
token_hash: params[2],
|
|
user_id: params[1],
|
|
expires_at: params[3],
|
|
revoked_at: null,
|
|
});
|
|
return [[]];
|
|
}
|
|
if (sql.includes('FROM h5_login_sessions s')) {
|
|
const row = sessions.find((item) => item.token_hash === params[0] && !item.revoked_at);
|
|
if (!row) return [[]];
|
|
return [[
|
|
{
|
|
user_id: row.user_id,
|
|
expires_at: row.expires_at,
|
|
role: userRow.role,
|
|
status: userRow.status,
|
|
},
|
|
]];
|
|
}
|
|
if (sql.includes('UPDATE h5_login_sessions SET expires_at')) {
|
|
const row = sessions.find((item) => item.token_hash === params[1]);
|
|
if (row) row.expires_at = params[0];
|
|
return [[]];
|
|
}
|
|
if (sql.includes('UPDATE h5_login_sessions SET revoked_at')) {
|
|
for (const row of sessions) {
|
|
if (row.user_id === params[1]) row.revoked_at = params[0];
|
|
}
|
|
return [[]];
|
|
}
|
|
if (sql.includes('FROM h5_users u') && sql.includes('WHERE u.id = ?')) {
|
|
return [[userRow]];
|
|
}
|
|
return [[]];
|
|
},
|
|
};
|
|
}
|
|
|
|
function createAdminLayoutPool(adminRow) {
|
|
return {
|
|
async query(sql, params = []) {
|
|
if (sql.includes('SELECT id FROM h5_users WHERE username = ? LIMIT 1')) {
|
|
return adminRow ? [[{ id: adminRow.id }]] : [[]];
|
|
}
|
|
if (sql.includes('FROM h5_users u') && sql.includes('WHERE u.id = ?')) {
|
|
return [[adminRow]];
|
|
}
|
|
if (sql.includes('FROM h5_assets a') && sql.includes('JOIN h5_space_categories')) {
|
|
return [[]];
|
|
}
|
|
if (sql.includes('UPDATE h5_users SET workspace_root = ?')) {
|
|
adminRow.workspace_root = params[0];
|
|
return [[]];
|
|
}
|
|
if (sql.includes('DELETE FROM h5_user_path_grants')) return [[]];
|
|
if (sql.includes('INSERT INTO h5_user_path_grants')) return [[]];
|
|
if (sql.includes('UPDATE h5_user_wallets SET balance_cents')) return [[]];
|
|
if (sql.includes('FROM h5_login_sessions')) return [[]];
|
|
return [[]];
|
|
},
|
|
};
|
|
}
|
|
|
|
function createRoleSkillSeedPool(adminRow, { existingRoleSkills = {} } = {}) {
|
|
const roleSkills = { ...existingRoleSkills };
|
|
const roleSkillSeedSql = [];
|
|
const basePool = createAdminLayoutPool(adminRow);
|
|
|
|
return {
|
|
roleSkills,
|
|
roleSkillSeedSql,
|
|
async query(sql, params = []) {
|
|
if (
|
|
sql.includes('INSERT INTO h5_user_skill_grants') &&
|
|
sql.includes("VALUES ('role', 'user'")
|
|
) {
|
|
roleSkillSeedSql.push(sql);
|
|
const [skillName, enabled] = params;
|
|
if (skillName in roleSkills) {
|
|
if (!sql.includes('ON DUPLICATE KEY UPDATE skill_name = skill_name')) {
|
|
roleSkills[skillName] = enabled;
|
|
}
|
|
} else {
|
|
roleSkills[skillName] = enabled;
|
|
}
|
|
return [[]];
|
|
}
|
|
return basePool.query(sql, params);
|
|
},
|
|
};
|
|
}
|
|
|
|
function createAgentPolicyPool(userRow) {
|
|
return {
|
|
async query(sql, params = []) {
|
|
if (sql.includes('FROM h5_users u') && sql.includes('WHERE u.id = ?')) {
|
|
return [[userRow]];
|
|
}
|
|
if (sql.includes('FROM h5_assets a') && sql.includes('JOIN h5_space_categories')) {
|
|
return [[]];
|
|
}
|
|
if (sql.includes('FROM h5_user_capability_grants')) return [[]];
|
|
if (sql.includes('FROM h5_user_policy_grants')) return [[]];
|
|
if (sql.includes('FROM h5_user_skill_grants')) return [[]];
|
|
if (sql.includes('UPDATE h5_users SET workspace_root = ?')) {
|
|
userRow.workspace_root = params[0];
|
|
return [[]];
|
|
}
|
|
if (sql.includes('DELETE FROM h5_user_path_grants')) return [[]];
|
|
if (sql.includes('INSERT INTO h5_user_path_grants')) return [[]];
|
|
return [[]];
|
|
},
|
|
};
|
|
}
|
|
|
|
test('login rate limits repeated failures', async () => {
|
|
const auth = createUserAuth(createAuthPool(null), {
|
|
loginMaxFailures: 2,
|
|
loginFailureWindowMs: 60_000,
|
|
persistSessions: false,
|
|
});
|
|
|
|
assert.equal((await auth.login({ username: 'john', password: 'bad', ip: '1.1.1.1' })).ok, false);
|
|
assert.equal((await auth.login({ username: 'john', password: 'bad', ip: '1.1.1.1' })).ok, false);
|
|
const blocked = await auth.login({ username: 'john', password: 'secret', ip: '1.1.1.1' });
|
|
assert.equal(blocked.ok, false);
|
|
assert.ok(blocked.retryAfterMs > 0);
|
|
});
|
|
|
|
test('verify rejects disabled users after login', async () => {
|
|
const salt = '00112233445566778899aabbccddeeff';
|
|
const userRow = {
|
|
id: 'user-1',
|
|
username: 'john',
|
|
slug: 'john',
|
|
email: 'john@example.com',
|
|
display_name: 'John',
|
|
role: 'user',
|
|
status: 'active',
|
|
plan_type: 'free',
|
|
workspace_root: '/tmp/john',
|
|
salt,
|
|
password_hash: hashPasswordArgon2id('secret', salt),
|
|
password_algorithm: 'argon2id',
|
|
balance_cents: 100,
|
|
tokens_used: 0,
|
|
};
|
|
const auth = createUserAuth(createAuthPool(userRow), { persistSessions: true });
|
|
const login = await auth.login({ username: 'john', password: 'secret', ip: '1.1.1.1' });
|
|
assert.equal(login.ok, true);
|
|
userRow.status = 'disabled';
|
|
assert.equal(await auth.verify(login.token), null);
|
|
});
|
|
|
|
test('resetPassword updates hash when username and email match', async () => {
|
|
const salt = '11112222333344445555666677778888';
|
|
const userRow = {
|
|
id: 'user-1',
|
|
username: 'john',
|
|
email: 'john@example.com',
|
|
status: 'active',
|
|
salt,
|
|
password_hash: hashPasswordPbkdf2('old-secret', salt),
|
|
password_algorithm: 'pbkdf2-sha512',
|
|
};
|
|
const pool = {
|
|
async query(sql, params = []) {
|
|
if (sql.includes('SELECT id, email, status FROM h5_users WHERE username = ?')) {
|
|
return userRow.username === params[0] ? [[userRow]] : [[]];
|
|
}
|
|
if (sql.includes('UPDATE h5_users SET salt = ?')) {
|
|
userRow.salt = params[0];
|
|
userRow.password_hash = params[1];
|
|
userRow.password_algorithm = params[2];
|
|
return [[]];
|
|
}
|
|
if (sql.includes('UPDATE h5_login_sessions SET revoked_at')) {
|
|
return [[]];
|
|
}
|
|
return [[]];
|
|
},
|
|
};
|
|
const auth = createUserAuth(pool, { persistSessions: false });
|
|
const ok = await auth.resetPassword({
|
|
username: 'john',
|
|
email: 'john@example.com',
|
|
password: 'new-secret',
|
|
});
|
|
assert.equal(ok.ok, true);
|
|
assert.equal(userRow.password_algorithm, 'argon2id');
|
|
assert.equal(userRow.password_hash, hashPasswordArgon2id('new-secret', userRow.salt));
|
|
const bad = await auth.resetPassword({
|
|
username: 'john',
|
|
email: 'wrong@example.com',
|
|
password: 'another',
|
|
});
|
|
assert.equal(bad.ok, false);
|
|
});
|
|
|
|
test('login migrates pbkdf2 passwords to argon2id after successful verification', async () => {
|
|
const salt = '9999aaaabbbbccccddddeeeeffff0000';
|
|
const userRow = {
|
|
id: 'user-1',
|
|
username: 'john',
|
|
slug: 'john',
|
|
email: 'john@example.com',
|
|
display_name: 'John',
|
|
role: 'user',
|
|
status: 'active',
|
|
plan_type: 'free',
|
|
workspace_root: '/tmp/john',
|
|
salt,
|
|
password_hash: hashPasswordPbkdf2('secret', salt),
|
|
password_algorithm: 'pbkdf2-sha512',
|
|
balance_cents: 100,
|
|
tokens_used: 0,
|
|
};
|
|
const auth = createUserAuth(createAuthPool(userRow), { persistSessions: false });
|
|
const login = await auth.login({ username: 'john', password: 'secret', ip: '1.1.1.1' });
|
|
assert.equal(login.ok, true);
|
|
assert.equal(userRow.password_algorithm, 'argon2id');
|
|
assert.equal(userRow.password_hash, hashPasswordArgon2id('secret', userRow.salt));
|
|
});
|
|
|
|
test('admin users get a real MindSpace publish layout', async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-admin-layout-'));
|
|
const adminRow = {
|
|
id: 'admin-1',
|
|
username: 'admin',
|
|
slug: 'admin',
|
|
email: 'admin@example.com',
|
|
display_name: '管理员',
|
|
role: 'admin',
|
|
status: 'active',
|
|
plan_type: 'free',
|
|
workspace_root: path.join(root, 'legacy-admin-root'),
|
|
balance_cents: 0,
|
|
tokens_used: 0,
|
|
};
|
|
const auth = createUserAuth(createAdminLayoutPool(adminRow), {
|
|
h5Root: root,
|
|
env: { H5_PUBLIC_BASE_URL: 'https://example.com' },
|
|
persistSessions: false,
|
|
});
|
|
|
|
const layout = await auth.getUserPublishLayout(adminRow.id);
|
|
assert.ok(layout, 'admin should receive a publish layout');
|
|
assert.equal(layout.slug, adminRow.id);
|
|
assert.equal(layout.username, 'admin');
|
|
assert.equal(layout.publishDir, path.join(root, 'MindSpace', adminRow.id));
|
|
assert.equal(layout.publicUrl, `https://example.com/MindSpace/${adminRow.id}/`);
|
|
|
|
const workingDir = await auth.resolveWorkingDir(adminRow.id);
|
|
assert.equal(workingDir, layout.publishDir);
|
|
assert.equal(adminRow.workspace_root, layout.publishDir);
|
|
});
|
|
|
|
test('ensureAdminUser repairs existing admin workspace without password env', async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-admin-repair-'));
|
|
const adminRow = {
|
|
id: 'admin-2',
|
|
username: 'admin',
|
|
slug: 'admin',
|
|
email: 'admin@example.com',
|
|
display_name: '管理员',
|
|
role: 'admin',
|
|
status: 'active',
|
|
plan_type: 'free',
|
|
workspace_root: path.join(root, 'legacy-admin-root'),
|
|
balance_cents: 0,
|
|
tokens_used: 0,
|
|
};
|
|
const auth = createUserAuth(createAdminLayoutPool(adminRow), {
|
|
h5Root: root,
|
|
env: { H5_PUBLIC_BASE_URL: 'https://example.com' },
|
|
persistSessions: false,
|
|
});
|
|
|
|
await auth.ensureAdminUser();
|
|
assert.equal(adminRow.workspace_root, path.join(root, 'MindSpace', adminRow.id));
|
|
});
|
|
|
|
test('ensureAdminUser seedRoleSkillDefaults preserves admin-configured role skill grants', async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-role-skill-seed-'));
|
|
const adminRow = {
|
|
id: 'admin-3',
|
|
username: 'admin',
|
|
slug: 'admin',
|
|
email: 'admin@example.com',
|
|
display_name: '管理员',
|
|
role: 'admin',
|
|
status: 'active',
|
|
plan_type: 'free',
|
|
workspace_root: path.join(root, 'MindSpace', 'admin-3'),
|
|
balance_cents: 0,
|
|
tokens_used: 0,
|
|
};
|
|
const pool = createRoleSkillSeedPool(adminRow, {
|
|
existingRoleSkills: { 'static-page-publish': 1 },
|
|
});
|
|
const auth = createUserAuth(pool, {
|
|
h5Root: root,
|
|
env: { H5_PUBLIC_BASE_URL: 'https://example.com' },
|
|
persistSessions: false,
|
|
});
|
|
|
|
await auth.ensureAdminUser();
|
|
|
|
assert.equal(pool.roleSkills['static-page-publish'], 1);
|
|
assert.ok(pool.roleSkillSeedSql.length > 0);
|
|
assert.match(pool.roleSkillSeedSql[0], /ON DUPLICATE KEY UPDATE skill_name = skill_name/);
|
|
assert.doesNotMatch(pool.roleSkillSeedSql.join('\n'), /ON DUPLICATE KEY UPDATE enabled = VALUES\(enabled\)/);
|
|
});
|
|
|
|
test('agent session policy preserves sandbox root and exposes workspace ref metadata', async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-agent-policy-'));
|
|
const userRow = {
|
|
id: 'user-1',
|
|
username: 'john',
|
|
slug: 'john',
|
|
email: 'john@example.com',
|
|
display_name: 'John',
|
|
role: 'user',
|
|
status: 'active',
|
|
plan_type: 'free',
|
|
workspace_root: path.join(root, 'legacy-root'),
|
|
quota_bytes: 1024,
|
|
used_bytes: 0,
|
|
reserved_bytes: 0,
|
|
balance_cents: 100,
|
|
tokens_used: 0,
|
|
};
|
|
const auth = createUserAuth(createAgentPolicyPool(userRow), {
|
|
h5Root: root,
|
|
persistSessions: false,
|
|
env: {
|
|
GOOSED_SANDBOX_PUBLISH_ROOT: '/srv/goosed-mindspace',
|
|
GOOSED_MCP_NODE_PATH: '/usr/local/bin/node',
|
|
GOOSED_MCP_SERVER_PATH: '/opt/portal/mindspace-sandbox-mcp.mjs',
|
|
GOOSED_MCP_CONTAINERIZED: '1',
|
|
MINDSPACE_USERDATA_BACKEND: 'postgres',
|
|
MINDSPACE_USERDATA_PG_URL: 'postgresql://mindspace:secret@127.0.0.1:5433/mindspace_userdata_prod',
|
|
MINDSPACE_USERDATA_AUTO_PROVISION: '1',
|
|
},
|
|
});
|
|
|
|
const policy = await auth.getAgentSessionPolicy(userRow.id);
|
|
const sandboxFs = policy.extensionOverrides.find((item) => item.name === 'sandbox-fs');
|
|
assert.ok(sandboxFs);
|
|
assert.equal(sandboxFs.envs.SANDBOX_ROOT, path.resolve('/srv/goosed-mindspace/user-1'));
|
|
assert.equal(sandboxFs.envs.MINDSPACE_WORKSPACE_ROOT, path.resolve(root, 'MindSpace', 'user-1'));
|
|
assert.equal(sandboxFs.envs.MINDSPACE_WORKSPACE_REF, 'mindspace://users/user-1/workspace');
|
|
assert.equal(new URL(sandboxFs.envs.MINDSPACE_USERDATA_PG_URL).hostname, 'host.docker.internal');
|
|
assert.equal(sandboxFs.envs.MINDSPACE_USERDATA_BACKEND, 'postgres');
|
|
assert.equal(sandboxFs.envs.MINDSPACE_USERDATA_AUTO_PROVISION, '1');
|
|
});
|
|
|
|
test('admin capabilities include granted platform skills', async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-admin-skills-'));
|
|
await fs.mkdir(path.join(root, 'skills', 'product-campaign-page'), { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(root, 'skills', 'product-campaign-page', 'SKILL.md'),
|
|
'---\nname: product-campaign-page\ndescription: 商品宣传 / 活动页技能\n---\n',
|
|
);
|
|
const adminRow = {
|
|
id: 'admin-3',
|
|
username: 'admin',
|
|
slug: 'admin',
|
|
email: 'admin@example.com',
|
|
display_name: '管理员',
|
|
role: 'admin',
|
|
status: 'active',
|
|
plan_type: 'free',
|
|
workspace_root: '/tmp/admin',
|
|
balance_cents: 0,
|
|
tokens_used: 0,
|
|
};
|
|
const auth = createUserAuth(createAdminLayoutPool(adminRow), {
|
|
h5Root: root,
|
|
persistSessions: false,
|
|
});
|
|
|
|
const resolved = await auth.resolveUserCapabilities(adminRow);
|
|
|
|
assert.equal(resolved.unrestricted, true);
|
|
assert.ok(resolved.grantedSkills.includes('product-campaign-page'));
|
|
});
|
|
|
|
test('recharge sends notifier payload after successful self recharge', async () => {
|
|
const userRow = {
|
|
id: 'user-1',
|
|
username: 'john',
|
|
slug: 'john',
|
|
email: 'john@example.com',
|
|
display_name: 'John',
|
|
role: 'user',
|
|
status: 'active',
|
|
plan_type: 'free',
|
|
workspace_root: '/tmp/john',
|
|
balance_cents: 1200,
|
|
tokens_used: 0,
|
|
spent_cents: 0,
|
|
};
|
|
const queries = [];
|
|
const notifierCalls = [];
|
|
const pool = {
|
|
async query(sql) {
|
|
queries.push(sql);
|
|
if (sql.includes('FROM h5_users u') && sql.includes('WHERE u.id = ?')) {
|
|
return [[userRow]];
|
|
}
|
|
throw new Error(`unexpected pool query: ${sql}`);
|
|
},
|
|
async getConnection() {
|
|
return {
|
|
async beginTransaction() {},
|
|
async commit() {},
|
|
async rollback() {},
|
|
release() {},
|
|
async query(sql) {
|
|
queries.push(sql);
|
|
if (sql.includes('INSERT INTO h5_user_wallets')) return [{ affectedRows: 1 }, []];
|
|
if (sql.includes('INSERT INTO h5_billing_ledger')) return [{ affectedRows: 1 }, []];
|
|
if (sql.includes('INSERT INTO h5_user_notifications')) return [{ affectedRows: 1 }, []];
|
|
throw new Error(`unexpected connection query: ${sql}`);
|
|
},
|
|
};
|
|
},
|
|
};
|
|
const auth = createUserAuth(pool, {
|
|
persistSessions: false,
|
|
onRechargeNotification: async (payload) => {
|
|
notifierCalls.push(payload);
|
|
},
|
|
});
|
|
|
|
const result = await auth.recharge('user-1', 2500, null, '用户自助充值', {
|
|
paymentOrderId: 'order-1',
|
|
});
|
|
assert.equal(result.ok, true);
|
|
assert.equal(notifierCalls.length, 1);
|
|
assert.equal(notifierCalls[0].notificationType, 'self_recharge');
|
|
assert.equal(notifierCalls[0].title, '充值成功');
|
|
assert.equal(notifierCalls[0].amountCents, 2500);
|
|
assert.equal(notifierCalls[0].paymentOrderId, 'order-1');
|
|
assert.ok(queries.some((sql) => sql.includes('INSERT INTO h5_user_notifications')));
|
|
});
|
|
|
|
test('purchaseSpaceQuota deducts balance and increases quota', async () => {
|
|
const userRow = {
|
|
id: 'user-2',
|
|
username: 'alice',
|
|
slug: 'alice',
|
|
email: 'alice@example.com',
|
|
display_name: 'Alice',
|
|
role: 'user',
|
|
status: 'active',
|
|
plan_type: 'free',
|
|
workspace_root: '/tmp/alice',
|
|
balance_cents: 5000,
|
|
tokens_used: 0,
|
|
spent_cents: 0,
|
|
quota_bytes: 5 * 1024 * 1024,
|
|
used_bytes: 1024,
|
|
reserved_bytes: 2048,
|
|
};
|
|
const spaceRow = {
|
|
id: 'space-1',
|
|
quota_bytes: 5 * 1024 * 1024,
|
|
used_bytes: 1024,
|
|
reserved_bytes: 2048,
|
|
};
|
|
const pool = {
|
|
async query(sql) {
|
|
if (sql.includes('FROM h5_users u') && sql.includes('WHERE u.id = ?')) {
|
|
return [[userRow]];
|
|
}
|
|
if (sql.includes('FROM h5_user_spaces') && sql.includes('WHERE user_id = ?')) {
|
|
return [[spaceRow]];
|
|
}
|
|
throw new Error(`unexpected pool query: ${sql}`);
|
|
},
|
|
async getConnection() {
|
|
return {
|
|
async beginTransaction() {},
|
|
async commit() {},
|
|
async rollback() {},
|
|
release() {},
|
|
async query(sql, params) {
|
|
if (sql.includes('SELECT id, quota_bytes, used_bytes, reserved_bytes')) {
|
|
return [[spaceRow]];
|
|
}
|
|
if (sql.includes('SELECT balance_cents')) {
|
|
return [[{ balance_cents: userRow.balance_cents }]];
|
|
}
|
|
if (sql.includes('UPDATE h5_user_wallets')) {
|
|
userRow.balance_cents -= params[0];
|
|
return [{ affectedRows: 1 }, []];
|
|
}
|
|
if (sql.includes('UPDATE h5_user_spaces')) {
|
|
spaceRow.quota_bytes += params[0];
|
|
userRow.quota_bytes = spaceRow.quota_bytes;
|
|
return [{ affectedRows: 1 }, []];
|
|
}
|
|
if (sql.includes('INSERT INTO h5_billing_ledger')) return [{ affectedRows: 1 }, []];
|
|
if (sql.includes('INSERT INTO h5_user_notifications')) return [{ affectedRows: 1 }, []];
|
|
throw new Error(`unexpected connection query: ${sql}`);
|
|
},
|
|
};
|
|
},
|
|
};
|
|
const auth = createUserAuth(pool, { persistSessions: false });
|
|
|
|
const result = await auth.purchaseSpaceQuota('user-2', 10);
|
|
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.balanceCents, 3000);
|
|
assert.equal(result.quota.quotaBytes, 15 * 1024 * 1024);
|
|
assert.equal(result.quota.availableBytes, 15 * 1024 * 1024 - 1024 - 2048);
|
|
});
|
|
|
|
test('billSessionUsage auto gifts low-balance bonus once for eligible new users', async () => {
|
|
const userRow = {
|
|
id: 'user-3',
|
|
username: 'bonus_user',
|
|
slug: 'bonus_user',
|
|
email: 'bonus@example.com',
|
|
display_name: 'Bonus User',
|
|
role: 'user',
|
|
status: 'active',
|
|
plan_type: 'free',
|
|
workspace_root: '/tmp/bonus-user',
|
|
balance_cents: 150,
|
|
tokens_used: 0,
|
|
spent_cents: 0,
|
|
};
|
|
const stateBySession = new Map();
|
|
let walletBalance = 150;
|
|
let tokensUsed = 0;
|
|
let lowBalanceGiftEligible = 1;
|
|
let lowBalanceGiftGrantedAt = null;
|
|
let ledgerAdjustCount = 0;
|
|
let notificationTypes = [];
|
|
|
|
const pool = {
|
|
async query(sql) {
|
|
if (sql.includes('FROM h5_users u') && sql.includes('WHERE u.id = ?')) {
|
|
return [[{ ...userRow, balance_cents: walletBalance, tokens_used: tokensUsed }]];
|
|
}
|
|
throw new Error(`unexpected pool query: ${sql}`);
|
|
},
|
|
async getConnection() {
|
|
return {
|
|
async beginTransaction() {},
|
|
async commit() {},
|
|
async rollback() {},
|
|
release() {},
|
|
async query(sql, params = []) {
|
|
if (sql.includes('SELECT cost_cents FROM h5_usage_records WHERE request_id = ? LIMIT 1')) return [[]];
|
|
if (sql.includes('INSERT INTO h5_session_billing_state') && sql.includes('agent_session_id = agent_session_id')) {
|
|
return [{ affectedRows: 1 }, []];
|
|
}
|
|
if (sql.includes('FROM h5_session_billing_state') && sql.includes('FOR UPDATE')) {
|
|
const row = stateBySession.get(params[0]);
|
|
return [row ? [row] : []];
|
|
}
|
|
if (sql.includes('INSERT INTO h5_session_billing_state') && sql.includes('ON DUPLICATE KEY UPDATE')) {
|
|
stateBySession.set(params[0], {
|
|
last_accumulated_cost: params[2],
|
|
last_input_tokens: params[3],
|
|
last_output_tokens: params[4],
|
|
});
|
|
return [{ affectedRows: 1 }, []];
|
|
}
|
|
if (sql.includes('SELECT w.balance_cents, w.tokens_used, u.status')) {
|
|
return [[{ balance_cents: walletBalance, tokens_used: tokensUsed, status: userRow.status }]];
|
|
}
|
|
if (sql.includes('UPDATE h5_user_wallets') && sql.includes('tokens_used = tokens_used + ?')) {
|
|
walletBalance = params[0];
|
|
tokensUsed += params[1];
|
|
return [{ affectedRows: 1 }, []];
|
|
}
|
|
if (sql.includes('INSERT INTO h5_usage_records')) return [{ affectedRows: 1 }, []];
|
|
if (sql.includes("INSERT INTO h5_billing_ledger") && sql.includes("'deduct'")) return [{ affectedRows: 1 }, []];
|
|
if (sql.includes('SELECT low_balance_gift_eligible, low_balance_gift_granted_at')) {
|
|
return [[{
|
|
low_balance_gift_eligible: lowBalanceGiftEligible,
|
|
low_balance_gift_granted_at: lowBalanceGiftGrantedAt,
|
|
}]];
|
|
}
|
|
if (sql.includes('UPDATE h5_user_wallets') && sql.includes('SET balance_cents = ?, updated_at = ?')) {
|
|
walletBalance = params[0];
|
|
return [{ affectedRows: 1 }, []];
|
|
}
|
|
if (sql.includes('UPDATE h5_users') && sql.includes('low_balance_gift_eligible = 0')) {
|
|
lowBalanceGiftEligible = 0;
|
|
lowBalanceGiftGrantedAt = params[0];
|
|
userRow.status = 'active';
|
|
return [{ affectedRows: 1 }, []];
|
|
}
|
|
if (sql.includes("INSERT INTO h5_billing_ledger") && sql.includes("'adjust'")) {
|
|
ledgerAdjustCount += 1;
|
|
return [{ affectedRows: 1 }, []];
|
|
}
|
|
if (sql.includes('INSERT INTO h5_user_notifications')) {
|
|
notificationTypes.push('low_balance_gift');
|
|
return [{ affectedRows: 1 }, []];
|
|
}
|
|
if (sql.includes('UPDATE h5_users SET status = \'suspended\'')) {
|
|
userRow.status = 'suspended';
|
|
return [{ affectedRows: 1 }, []];
|
|
}
|
|
throw new Error(`unexpected connection query: ${sql}`);
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
const auth = createUserAuth(pool, { persistSessions: false });
|
|
|
|
const first = await auth.billSessionUsage(
|
|
userRow.id,
|
|
'session-bonus-1',
|
|
{ accumulatedOutputTokens: 10_000 },
|
|
'req-bonus-1',
|
|
);
|
|
assert.equal(first.ok, true);
|
|
assert.equal(first.costCents, 60);
|
|
assert.equal(first.balanceCents, 1090);
|
|
assert.equal(ledgerAdjustCount, 1);
|
|
assert.deepEqual(notificationTypes, ['low_balance_gift']);
|
|
|
|
const second = await auth.billSessionUsage(
|
|
userRow.id,
|
|
'session-bonus-2',
|
|
{ accumulatedOutputTokens: 10_000 },
|
|
'req-bonus-2',
|
|
);
|
|
assert.equal(second.ok, true);
|
|
assert.equal(second.costCents, 60);
|
|
assert.equal(second.balanceCents, 1030);
|
|
assert.equal(ledgerAdjustCount, 1);
|
|
assert.deepEqual(notificationTypes, ['low_balance_gift']);
|
|
});
|
|
|
|
test('updateUser rejects quota smaller than occupied bytes', async () => {
|
|
const userRow = {
|
|
id: 'user-3',
|
|
username: 'bob',
|
|
slug: 'bob',
|
|
email: 'bob@example.com',
|
|
display_name: 'Bob',
|
|
role: 'user',
|
|
status: 'active',
|
|
plan_type: 'free',
|
|
workspace_root: '/tmp/bob',
|
|
balance_cents: 1000,
|
|
tokens_used: 0,
|
|
spent_cents: 0,
|
|
quota_bytes: 5 * 1024 * 1024,
|
|
used_bytes: 4 * 1024 * 1024,
|
|
reserved_bytes: 2 * 1024 * 1024,
|
|
};
|
|
const pool = {
|
|
async query(sql) {
|
|
if (sql.includes('FROM h5_users u') && sql.includes('WHERE u.id = ?')) {
|
|
return [[userRow]];
|
|
}
|
|
if (sql.includes('FROM h5_user_spaces') && sql.includes('WHERE user_id = ?')) {
|
|
return [[{
|
|
id: 'space-2',
|
|
quota_bytes: userRow.quota_bytes,
|
|
used_bytes: userRow.used_bytes,
|
|
reserved_bytes: userRow.reserved_bytes,
|
|
}]];
|
|
}
|
|
throw new Error(`unexpected pool query: ${sql}`);
|
|
},
|
|
};
|
|
const auth = createUserAuth(pool, { persistSessions: false });
|
|
|
|
const result = await auth.updateUser('user-3', {
|
|
spaceQuotaBytes: 5 * 1024 * 1024,
|
|
});
|
|
|
|
assert.equal(result.ok, false);
|
|
assert.match(result.message, /空间不能小于已使用容量/);
|
|
});
|
|
|
|
test('updateUser persists a larger space quota', async () => {
|
|
const userRow = {
|
|
id: 'user-4',
|
|
username: 'carol',
|
|
slug: 'carol',
|
|
email: 'carol@example.com',
|
|
display_name: 'Carol',
|
|
role: 'user',
|
|
status: 'active',
|
|
plan_type: 'free',
|
|
workspace_root: '/tmp/carol',
|
|
balance_cents: 1000,
|
|
tokens_used: 0,
|
|
spent_cents: 0,
|
|
quota_bytes: 5 * 1024 * 1024,
|
|
used_bytes: 1 * 1024 * 1024,
|
|
reserved_bytes: 0,
|
|
};
|
|
const pool = {
|
|
async query(sql, params = []) {
|
|
if (sql.includes('FROM h5_users u') && sql.includes('WHERE u.id = ?')) {
|
|
return [[userRow]];
|
|
}
|
|
if (sql.includes('FROM h5_user_spaces') && sql.includes('WHERE user_id = ?')) {
|
|
return [[{
|
|
id: 'space-4',
|
|
quota_bytes: userRow.quota_bytes,
|
|
used_bytes: userRow.used_bytes,
|
|
reserved_bytes: userRow.reserved_bytes,
|
|
}]];
|
|
}
|
|
if (sql.includes('UPDATE h5_user_spaces')) {
|
|
userRow.quota_bytes = params[0];
|
|
return [{ affectedRows: 1 }, []];
|
|
}
|
|
throw new Error(`unexpected pool query: ${sql}`);
|
|
},
|
|
};
|
|
const auth = createUserAuth(pool, { persistSessions: false });
|
|
|
|
const result = await auth.updateUser('user-4', {
|
|
spaceQuotaBytes: 20 * 1024 * 1024,
|
|
});
|
|
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.user.spaceQuotaBytes, 20 * 1024 * 1024);
|
|
assert.equal(result.user.spaceAvailableBytes, 19 * 1024 * 1024);
|
|
});
|
|
|
|
test('upsertWechatAgentRoute uses atomic insert-on-duplicate semantics', async () => {
|
|
const calls = [];
|
|
const pool = {
|
|
async query(sql, params = []) {
|
|
calls.push([sql, params]);
|
|
if (sql.includes('INSERT INTO h5_wechat_agent_routes')) return [[]];
|
|
if (sql.includes('FROM h5_wechat_agent_routes')) {
|
|
return [[{ id: 'route-1', user_id: 'user-1', agent_session_id: 'session-2', status: 'active', created_at: 1, updated_at: 2 }]];
|
|
}
|
|
return [[]];
|
|
},
|
|
};
|
|
const auth = createUserAuth(pool, { persistSessions: false });
|
|
|
|
const routeId = await auth.upsertWechatAgentRoute({
|
|
userId: 'user-1',
|
|
appId: 'wx123',
|
|
openid: 'openid-1',
|
|
agentSessionId: 'session-2',
|
|
});
|
|
|
|
assert.equal(routeId, 'route-1');
|
|
assert.match(calls[0][0], /ON DUPLICATE KEY UPDATE/);
|
|
assert.equal(calls.some(([sql]) => /UPDATE h5_wechat_agent_routes/.test(sql)), false);
|
|
});
|
|
|
|
test('clearUserLogoutCookies clears shared-domain and host-only cookies', () => {
|
|
const cookies = clearUserLogoutCookies(false, '.tkmind.cn');
|
|
assert.equal(cookies.length, 2);
|
|
assert.match(cookies[0], /tkmind_user_session=/);
|
|
assert.match(cookies[0], /Domain=\.tkmind\.cn/);
|
|
assert.match(cookies[0], /Max-Age=0/);
|
|
assert.match(cookies[1], /Max-Age=0/);
|
|
assert.doesNotMatch(cookies[1], /Domain=/);
|
|
});
|
|
|
|
test('userLoginCookies and clearUserLogoutCookies are symmetric for shared domain', () => {
|
|
const login = userLoginCookies('token-abc', true, '.tkmind.cn');
|
|
const logout = clearUserLogoutCookies(true, '.tkmind.cn');
|
|
assert.equal(login.length, 2);
|
|
assert.equal(logout.length, 2);
|
|
assert.match(login[1], /Max-Age=0/);
|
|
assert.match(logout[1], /Max-Age=0/);
|
|
});
|