feat(accounts): доработать управление аккаунтами хостеров
Docker / build (push) Has been cancelled

Добавить apiLogin из credentials, сводку и health-индикаторы на /accounts, фильтры, раздельную форму логина и пароля, безопасное удаление с 409 и тесты API/repository.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-06-28 16:53:51 +07:00
co-authored by Cursor
parent 06f5b27e53
commit c80090ed08
21 changed files with 844 additions and 84 deletions
@@ -0,0 +1,62 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { parseApiLogin } from '@cfdm/shared/utils/api-credentials'
import { providerAccountsRepository } from './provider-accounts.js'
import { resetTestDb, seedTestProvider } from '../test-setup.js'
import { getSqlite } from '../index.js'
describe('parseApiLogin', () => {
it('extracts login before colon', () => {
expect(parseApiLogin('user:secret')).toBe('user')
expect(parseApiLogin(' admin:pass ')).toBe('admin')
})
it('returns empty for invalid credentials', () => {
expect(parseApiLogin('')).toBe('')
expect(parseApiLogin('nocolon')).toBe('')
expect(parseApiLogin(':onlypass')).toBe('')
})
})
describe('providerAccountsRepository', () => {
beforeEach(() => {
resetTestDb()
seedTestProvider()
})
it('returns apiLogin without exposing password', () => {
const created = providerAccountsRepository.create({
providerId: 'prov-1',
name: 'Main',
apiCredentials: 'apiuser:apipass',
})
expect(created.apiLogin).toBe('apiuser')
expect(created.apiCredentialsSet).toBe(true)
expect('apiCredentials' in created).toBe(false)
})
it('preserves credentials on update when apiCredentials empty', () => {
providerAccountsRepository.create({
id: 'acc-1',
providerId: 'prov-1',
name: 'Main',
apiCredentials: 'keep:me',
})
const updated = providerAccountsRepository.update('acc-1', { name: 'Renamed', apiCredentials: '' })
expect(updated?.name).toBe('Renamed')
expect(updated?.apiLogin).toBe('keep')
})
it('counts dependencies before delete', () => {
providerAccountsRepository.create({
id: 'acc-1',
providerId: 'prov-1',
name: 'Main',
})
getSqlite()
.prepare(`INSERT INTO vps (id, ip, providerId, providerAccountId, status) VALUES ('vps-1', '1.1.1.1', 'prov-1', 'acc-1', 'active')`)
.run()
const deps = providerAccountsRepository.getDependencyCounts('acc-1')
expect(deps.vps).toBe(1)
expect(deps.payments).toBe(0)
})
})
@@ -1,4 +1,5 @@
import { asc, eq } from 'drizzle-orm'
import { asc, count, eq } from 'drizzle-orm'
import { parseApiLogin } from '@cfdm/shared'
import { getDb, schema } from '../index.js'
import { generateId } from './utils.js'
@@ -10,12 +11,25 @@ type AccountInsert = Partial<typeof schema.providerAccounts.$inferInsert> & {
export interface PublicAccountRow extends Omit<AccountRow, 'apiCredentials'> {
apiCredentialsSet: boolean
apiLogin: string
}
export interface AccountDependencyCounts {
vps: number
payments: number
balanceLedger: number
activeTariffs: number
syncLog: number
}
function sanitize(row: AccountRow | undefined): PublicAccountRow | undefined {
if (!row) return undefined
const { apiCredentials, ...rest } = row
return { ...rest, apiCredentialsSet: Boolean(apiCredentials) }
return {
...rest,
apiCredentialsSet: Boolean(apiCredentials),
apiLogin: parseApiLogin(apiCredentials),
}
}
function normalize(input: Partial<AccountRow>) {
@@ -35,6 +49,36 @@ function normalize(input: Partial<AccountRow>) {
}
}
function countVpsForAccount(id: string): number {
return Number(
getDb().select({ count: count() }).from(schema.vps).where(eq(schema.vps.providerAccountId, id)).get()?.count ?? 0,
)
}
function countPaymentsForAccount(id: string): number {
return Number(
getDb().select({ count: count() }).from(schema.payments).where(eq(schema.payments.providerAccountId, id)).get()?.count ?? 0,
)
}
function countLedgerForAccount(id: string): number {
return Number(
getDb().select({ count: count() }).from(schema.balanceLedger).where(eq(schema.balanceLedger.providerAccountId, id)).get()?.count ?? 0,
)
}
function countTariffsForAccount(id: string): number {
return Number(
getDb().select({ count: count() }).from(schema.activeTariffs).where(eq(schema.activeTariffs.providerAccountId, id)).get()?.count ?? 0,
)
}
function countSyncLogForAccount(id: string): number {
return Number(
getDb().select({ count: count() }).from(schema.syncLog).where(eq(schema.syncLog.accountId, id)).get()?.count ?? 0,
)
}
export const providerAccountsRepository = {
list(): PublicAccountRow[] {
const rows = getDb()
@@ -62,6 +106,16 @@ export const providerAccountsRepository = {
.get()
},
getDependencyCounts(id: string): AccountDependencyCounts {
return {
vps: countVpsForAccount(id),
payments: countPaymentsForAccount(id),
balanceLedger: countLedgerForAccount(id),
activeTariffs: countTariffsForAccount(id),
syncLog: countSyncLogForAccount(id),
}
},
create(input: AccountInsert, id?: string): PublicAccountRow {
const db = getDb()
const finalId = id ?? input.id ?? generateId('account')
@@ -76,8 +130,8 @@ export const providerAccountsRepository = {
const existing = this.getWithCredentials(id)
if (!existing) return undefined
const apiCredentials =
input.apiCredentials !== undefined
? String(input.apiCredentials || '')
input.apiCredentials !== undefined && String(input.apiCredentials || '').trim() !== ''
? String(input.apiCredentials)
: (existing.apiCredentials || '')
let balanceAlertBelow = existing.balanceAlertBelow
@@ -112,3 +166,5 @@ export const providerAccountsRepository = {
return res.changes > 0
},
}
export { parseApiLogin }
+104
View File
@@ -0,0 +1,104 @@
import { closeDb, getSqlite } from './index.js'
const TEST_SCHEMA = `
CREATE TABLE IF NOT EXISTS providers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
website TEXT,
contact TEXT,
baseCurrency TEXT,
usdRate TEXT,
eurRate TEXT,
notes TEXT,
apiType TEXT,
apiBaseUrl TEXT
);
CREATE TABLE IF NOT EXISTS provider_accounts (
id TEXT PRIMARY KEY,
providerId TEXT NOT NULL,
name TEXT NOT NULL,
panelUrl TEXT,
currency TEXT,
billingMode TEXT,
notes TEXT,
apiType TEXT,
apiBaseUrl TEXT,
apiCredentials TEXT,
balance_api REAL,
balance_currency TEXT,
balance_updated_at TEXT,
enoughmoneyto TEXT,
balance_alert_below REAL,
FOREIGN KEY (providerId) REFERENCES providers(id)
);
CREATE TABLE IF NOT EXISTS vps (
id TEXT PRIMARY KEY,
ip TEXT,
providerId TEXT,
providerAccountId TEXT,
status TEXT,
FOREIGN KEY (providerId) REFERENCES providers(id),
FOREIGN KEY (providerAccountId) REFERENCES provider_accounts(id)
);
CREATE TABLE IF NOT EXISTS payments (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
date TEXT NOT NULL,
amount REAL NOT NULL,
currency TEXT,
providerAccountId TEXT,
vpsId TEXT,
note TEXT,
FOREIGN KEY (providerAccountId) REFERENCES provider_accounts(id)
);
CREATE TABLE IF NOT EXISTS balance_ledger (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
date TEXT NOT NULL,
amount REAL NOT NULL,
currency TEXT,
direction TEXT,
providerAccountId TEXT,
vpsId TEXT,
note TEXT,
FOREIGN KEY (providerAccountId) REFERENCES provider_accounts(id)
);
CREATE TABLE IF NOT EXISTS sync_log (
id TEXT PRIMARY KEY,
accountId TEXT NOT NULL,
startedAt TEXT NOT NULL,
finishedAt TEXT,
status TEXT,
FOREIGN KEY (accountId) REFERENCES provider_accounts(id)
);
CREATE TABLE IF NOT EXISTS active_tariffs (
id TEXT PRIMARY KEY,
providerAccountId TEXT NOT NULL,
providerId TEXT NOT NULL,
externalId TEXT NOT NULL,
FOREIGN KEY (providerAccountId) REFERENCES provider_accounts(id),
FOREIGN KEY (providerId) REFERENCES providers(id)
);
`
export function resetTestDb(): void {
closeDb()
process.env.DB_PATH = ':memory:'
const sqlite = getSqlite()
sqlite.exec(TEST_SCHEMA)
}
export function seedTestProvider(id = 'prov-1'): void {
const sqlite = getSqlite()
sqlite
.prepare(
`INSERT INTO providers (id, name, apiType, apiBaseUrl) VALUES (?, 'Test Host', 'billmanager', 'https://bm.test')`,
)
.run(id)
}