mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 09:35:39 +00:00
21e0d02e3d
Prevent false UI success when Go refuses deleting the last Super Admin on dual-SQLite; mirror before local delete and keep installer admin reset unchanged.
371 lines
14 KiB
JavaScript
371 lines
14 KiB
JavaScript
/**
|
|
* BetterDesk Console - User Sync Tests
|
|
*/
|
|
|
|
const mockApiClient = {
|
|
get: jest.fn(),
|
|
post: jest.fn(),
|
|
put: jest.fn(),
|
|
delete: jest.fn(),
|
|
};
|
|
|
|
const mockDb = {
|
|
type: 'sqlite',
|
|
getDb: jest.fn(),
|
|
getAuthDb: jest.fn(),
|
|
getAllUsersForBackup: jest.fn(),
|
|
getAllUsers: jest.fn(),
|
|
getUserById: jest.fn(),
|
|
syncUserFromGo: jest.fn(),
|
|
};
|
|
|
|
jest.mock('../services/betterdeskApi', () => ({ apiClient: mockApiClient }));
|
|
jest.mock('../services/database', () => mockDb);
|
|
|
|
const userSync = require('../services/userSync');
|
|
|
|
function createSqliteMock(goUsers = [], inserts = [], updates = []) {
|
|
const goDb = {
|
|
prepare: jest.fn((sql) => {
|
|
if (sql.includes('sqlite_master')) return { get: jest.fn(() => ({ name: 'users' })) };
|
|
if (sql.startsWith('PRAGMA table_info(users)')) {
|
|
return { all: jest.fn(() => [
|
|
{ name: 'id' }, { name: 'username' }, { name: 'password_hash' },
|
|
{ name: 'role' }, { name: 'auth_provider' }, { name: 'totp_secret' }, { name: 'totp_enabled' },
|
|
{ name: 'totp_recovery_codes' }, { name: 'created_at' }, { name: 'last_login' },
|
|
]) };
|
|
}
|
|
if (sql.startsWith('SELECT id FROM users')) {
|
|
return {
|
|
get: jest.fn((username) => {
|
|
const user = goUsers.find(u => String(u.username || '').toLowerCase() === username);
|
|
return user ? { id: user.id } : undefined;
|
|
})
|
|
};
|
|
}
|
|
if (sql.startsWith('INSERT INTO users')) {
|
|
return {
|
|
run: jest.fn((...args) => {
|
|
inserts.push({ sql, args });
|
|
goUsers.push({
|
|
id: goUsers.length + 1,
|
|
username: args[0],
|
|
password_hash: args[1],
|
|
role: args[2],
|
|
auth_provider: args[3] || 'local',
|
|
});
|
|
}),
|
|
};
|
|
}
|
|
if (sql.startsWith('SELECT')) return { all: jest.fn(() => goUsers) };
|
|
if (sql.startsWith('UPDATE users SET totp_')) {
|
|
return { run: jest.fn((...args) => updates.push({ sql, args })) };
|
|
}
|
|
throw new Error(`Unexpected Go DB SQL: ${sql}`);
|
|
}),
|
|
};
|
|
|
|
const authDb = {
|
|
prepare: jest.fn((sql) => ({
|
|
run: jest.fn((...args) => inserts.push({ sql, args })),
|
|
})),
|
|
};
|
|
|
|
return { goDb, authDb };
|
|
}
|
|
|
|
describe('userSync', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
mockDb.type = 'sqlite';
|
|
});
|
|
|
|
it('restores missing local users from Go SQLite with preserved IDs and password hashes', async () => {
|
|
const inserts = [];
|
|
const { goDb, authDb } = createSqliteMock([
|
|
{
|
|
id: 7,
|
|
username: 'operator1',
|
|
password_hash: 'salt0123456789ab:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
|
role: 'operator',
|
|
totp_secret: 'SECRET',
|
|
totp_enabled: 1,
|
|
created_at: '2026-05-01T00:00:00Z',
|
|
last_login: null,
|
|
},
|
|
], inserts);
|
|
mockDb.getDb.mockReturnValue(goDb);
|
|
mockDb.getAuthDb.mockReturnValue(authDb);
|
|
mockDb.getAllUsersForBackup.mockResolvedValue([{ id: 1, username: 'admin' }]);
|
|
|
|
const result = await userSync.backfillFromGo();
|
|
|
|
expect(result.imported).toBe(1);
|
|
expect(inserts).toHaveLength(1);
|
|
expect(inserts[0].args).toEqual([
|
|
7,
|
|
'operator1',
|
|
'salt0123456789ab:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
|
'operator',
|
|
'local',
|
|
'2026-05-01T00:00:00Z',
|
|
null,
|
|
'SECRET',
|
|
1,
|
|
]);
|
|
});
|
|
|
|
it('does not duplicate users that already exist locally by username', async () => {
|
|
const inserts = [];
|
|
const { goDb, authDb } = createSqliteMock([
|
|
{ id: 7, username: 'Operator1', password_hash: 'hash', role: 'operator' },
|
|
], inserts);
|
|
mockDb.getDb.mockReturnValue(goDb);
|
|
mockDb.getAuthDb.mockReturnValue(authDb);
|
|
mockDb.getAllUsersForBackup.mockResolvedValue([{ id: 12, username: 'operator1', role: 'operator', auth_provider: 'local' }]);
|
|
|
|
const result = await userSync.backfillFromGo();
|
|
|
|
expect(result.imported).toBe(0);
|
|
expect(inserts).toHaveLength(0);
|
|
});
|
|
|
|
it('resolves a local user ID to the matching Go user ID by username', async () => {
|
|
mockDb.getUserById.mockResolvedValue({ id: 12, username: 'operator1' });
|
|
mockApiClient.get.mockResolvedValue({
|
|
data: [{ id: 7, username: 'operator1', role: 'operator' }],
|
|
});
|
|
|
|
await expect(userSync.resolveGoUserId(12)).resolves.toBe(7);
|
|
});
|
|
|
|
it('syncs auth_provider and role from Go API in PostgreSQL mode', async () => {
|
|
mockDb.type = 'postgres';
|
|
mockApiClient.get.mockResolvedValue({
|
|
data: [
|
|
{ id: 7, username: 'aduser', role: 'operator', auth_provider: 'ldap' },
|
|
],
|
|
});
|
|
mockDb.getAllUsers.mockResolvedValue([
|
|
{ id: 12, username: 'aduser', role: 'viewer', auth_provider: 'local' },
|
|
]);
|
|
mockDb.syncUserFromGo.mockResolvedValue(undefined);
|
|
|
|
const result = await userSync.backfillFromGo();
|
|
|
|
expect(result.synced).toBe(1);
|
|
expect(mockDb.syncUserFromGo).toHaveBeenCalledWith(12, {
|
|
authProvider: 'ldap',
|
|
role: 'operator',
|
|
});
|
|
});
|
|
|
|
it('mirrors TOTP enable directly to the existing Go SQLite user', async () => {
|
|
const updates = [];
|
|
const { goDb } = createSqliteMock([
|
|
{ id: 7, username: 'Admin', password_hash: 'hash', role: 'admin' },
|
|
], [], updates);
|
|
mockDb.getDb.mockReturnValue(goDb);
|
|
|
|
await userSync.mirrorTotpEnable('admin', { secret: 'SECRET123' });
|
|
|
|
expect(updates).toHaveLength(1);
|
|
expect(updates[0].sql).toContain('totp_enabled = 1');
|
|
expect(updates[0].sql).toContain('totp_recovery_codes = NULL');
|
|
expect(updates[0].args).toEqual(['SECRET123', 7]);
|
|
expect(mockApiClient.post).not.toHaveBeenCalled();
|
|
expect(mockApiClient.put).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not create a Go user when TOTP enable cannot find one', async () => {
|
|
const updates = [];
|
|
const { goDb } = createSqliteMock([], [], updates);
|
|
mockDb.getDb.mockReturnValue(goDb);
|
|
|
|
await userSync.mirrorTotpEnable('missing', { secret: 'SECRET123' });
|
|
|
|
expect(updates).toHaveLength(0);
|
|
expect(mockApiClient.post).not.toHaveBeenCalled();
|
|
expect(mockApiClient.put).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('mirrors TOTP disable directly to the existing Go SQLite user', async () => {
|
|
const updates = [];
|
|
const { goDb } = createSqliteMock([
|
|
{ id: 7, username: 'admin', password_hash: 'hash', role: 'admin' },
|
|
], [], updates);
|
|
mockDb.getDb.mockReturnValue(goDb);
|
|
|
|
await userSync.mirrorTotpDisable('admin');
|
|
|
|
expect(updates).toHaveLength(1);
|
|
expect(updates[0].sql).toContain("totp_secret = ''");
|
|
expect(updates[0].sql).toContain('totp_enabled = 0');
|
|
expect(updates[0].args).toEqual([7]);
|
|
expect(mockApiClient.post).not.toHaveBeenCalled();
|
|
expect(mockApiClient.put).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('backfillFromNode copies panel password_hash into missing Go SQLite users', async () => {
|
|
const inserts = [];
|
|
const { goDb } = createSqliteMock([], inserts);
|
|
mockDb.getDb.mockReturnValue(goDb);
|
|
mockApiClient.get.mockResolvedValue({ data: [{ id: 1, username: 'admin' }] });
|
|
mockDb.getAllUsers.mockResolvedValue([
|
|
{ id: 1, username: 'admin', password_hash: '$2b$10$existing', role: 'admin', auth_provider: 'local' },
|
|
{ id: 2, username: 'operator1', password_hash: '$2b$10$panelhash', role: 'operator', auth_provider: 'local' },
|
|
]);
|
|
|
|
await userSync.backfillFromNode();
|
|
|
|
expect(inserts).toHaveLength(1);
|
|
expect(inserts[0].sql).toContain('INSERT INTO users');
|
|
expect(inserts[0].args).toEqual(['operator1', '$2b$10$panelhash', 'operator', 'local']);
|
|
expect(mockApiClient.post).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('backfillFromNode falls back to API placeholder password when hash insert is unavailable', async () => {
|
|
mockDb.type = 'postgres';
|
|
mockApiClient.get.mockResolvedValue({ data: [] });
|
|
mockDb.getAllUsers.mockResolvedValue([
|
|
{ id: 2, username: 'operator1', password_hash: '$2b$10$panelhash', role: 'operator', auth_provider: 'local' },
|
|
]);
|
|
mockApiClient.post.mockResolvedValue({ data: { id: 9 } });
|
|
|
|
await userSync.backfillFromNode();
|
|
|
|
expect(mockApiClient.post).toHaveBeenCalledTimes(1);
|
|
const body = mockApiClient.post.mock.calls[0][1];
|
|
expect(body.username).toBe('operator1');
|
|
expect(body.role).toBe('operator');
|
|
expect(body.password).toEqual(expect.any(String));
|
|
expect(body.password.length).toBeGreaterThanOrEqual(16);
|
|
});
|
|
|
|
// Issue #301: POST 409 + empty GET must not recurse into endless CreateUser INSERTs.
|
|
it('mirrorCreate on 409 does not recurse when GET /users returns empty', async () => {
|
|
mockApiClient.post.mockRejectedValue({
|
|
response: { status: 409 },
|
|
message: 'Request failed with status code 409',
|
|
});
|
|
mockApiClient.get.mockResolvedValue({ data: [] });
|
|
|
|
await expect(userSync.mirrorCreate('Gerardo', 'StrongPass1!', 'viewer')).resolves.toBeUndefined();
|
|
|
|
expect(mockApiClient.post).toHaveBeenCalledTimes(1);
|
|
expect(mockApiClient.put).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('mirrorCreate on 409 updates existing Go user when list resolves', async () => {
|
|
mockApiClient.post.mockRejectedValue({
|
|
response: { status: 409 },
|
|
message: 'Request failed with status code 409',
|
|
});
|
|
mockApiClient.get.mockResolvedValue({
|
|
data: [{ id: 42, username: 'Gerardo', role: 'viewer' }],
|
|
});
|
|
mockApiClient.put.mockResolvedValue({ data: {} });
|
|
|
|
await userSync.mirrorCreate('Gerardo', 'StrongPass1!', 'operator');
|
|
|
|
expect(mockApiClient.post).toHaveBeenCalledTimes(1);
|
|
expect(mockApiClient.put).toHaveBeenCalledTimes(1);
|
|
expect(mockApiClient.put.mock.calls[0][0]).toBe('/users/42');
|
|
expect(mockApiClient.put.mock.calls[0][1]).toEqual({
|
|
password: 'StrongPass1!',
|
|
role: 'operator',
|
|
});
|
|
});
|
|
|
|
it('mirrorCreate is a no-op on shared PostgreSQL', async () => {
|
|
mockDb.type = 'postgres';
|
|
|
|
await userSync.mirrorCreate('Gerardo', 'StrongPass1!', 'viewer');
|
|
|
|
expect(mockApiClient.post).not.toHaveBeenCalled();
|
|
expect(mockApiClient.get).not.toHaveBeenCalled();
|
|
expect(mockApiClient.put).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('mirrorUpdate with allowCreate false never posts create', async () => {
|
|
mockApiClient.get.mockRejectedValue({
|
|
response: { status: 500 },
|
|
message: 'Request failed with status code 500',
|
|
});
|
|
|
|
await userSync.mirrorUpdate('Gerardo', {
|
|
password: 'StrongPass1!',
|
|
role: 'viewer',
|
|
allowCreate: false,
|
|
});
|
|
|
|
expect(mockApiClient.post).not.toHaveBeenCalled();
|
|
expect(mockApiClient.put).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('assertGoAllowsSuperAdminDelete refuses last Go Super Admin (Issue #315)', async () => {
|
|
mockApiClient.get.mockResolvedValue({
|
|
data: [
|
|
{ id: 1, username: 'admin', role: 'admin' },
|
|
{ id: 2, username: 'ops', role: 'global_admin' },
|
|
],
|
|
});
|
|
|
|
const result = await userSync.assertGoAllowsSuperAdminDelete('admin');
|
|
|
|
expect(result).toEqual({
|
|
ok: false,
|
|
status: 409,
|
|
reason: 'last_admin_go',
|
|
goAdminCount: 1,
|
|
});
|
|
});
|
|
|
|
it('assertGoAllowsSuperAdminDelete allows when another Go Super Admin exists', async () => {
|
|
mockApiClient.get.mockResolvedValue({
|
|
data: [
|
|
{ id: 1, username: 'admin', role: 'admin' },
|
|
{ id: 2, username: 'boss', role: 'super_admin' },
|
|
],
|
|
});
|
|
|
|
const result = await userSync.assertGoAllowsSuperAdminDelete('admin');
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(result.goAdminCount).toBe(2);
|
|
});
|
|
|
|
it('assertGoAllowsSuperAdminDelete skips on shared PostgreSQL', async () => {
|
|
mockDb.type = 'postgres';
|
|
|
|
const result = await userSync.assertGoAllowsSuperAdminDelete('admin');
|
|
|
|
expect(result).toEqual({ ok: true, reason: 'shared-db' });
|
|
expect(mockApiClient.get).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('mirrorDelete returns conflict on Go 409 without throwing', async () => {
|
|
mockApiClient.get.mockResolvedValue({
|
|
data: [{ id: 1, username: 'admin', role: 'admin' }],
|
|
});
|
|
mockApiClient.delete.mockRejectedValue({
|
|
response: { status: 409 },
|
|
message: 'Request failed with status code 409',
|
|
});
|
|
|
|
const result = await userSync.mirrorDelete('admin');
|
|
|
|
expect(result).toEqual({ ok: false, status: 409, conflict: true });
|
|
expect(mockApiClient.delete).toHaveBeenCalledWith('/users/1');
|
|
});
|
|
|
|
it('mirrorDelete is a no-op on shared PostgreSQL', async () => {
|
|
mockDb.type = 'postgres';
|
|
|
|
const result = await userSync.mirrorDelete('admin');
|
|
|
|
expect(result).toEqual({ ok: true, skipped: 'shared-db' });
|
|
expect(mockApiClient.delete).not.toHaveBeenCalled();
|
|
});
|
|
});
|