Добавить apiLogin из credentials, сводку и health-индикаторы на /accounts, фильтры, раздельную форму логина и пароля, безопасное удаление с 409 и тесты API/repository. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -17,19 +17,26 @@
|
||||
"./repositories/*": {
|
||||
"types": "./dist/repositories/*.d.ts",
|
||||
"default": "./dist/repositories/*.js"
|
||||
},
|
||||
"./test-setup": {
|
||||
"types": "./dist/test-setup.d.ts",
|
||||
"default": "./dist/test-setup.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json"
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"drizzle-orm": "^0.40.0",
|
||||
"better-sqlite3": "^11.10.0"
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"@cfdm/shared": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"drizzle-kit": "^0.30.0",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^22.10.0",
|
||||
"typescript": "^5.9.2"
|
||||
"typescript": "^5.9.2",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -25,6 +25,10 @@
|
||||
"./geo/*": {
|
||||
"types": "./dist/geo/*.d.ts",
|
||||
"default": "./dist/geo/*.js"
|
||||
},
|
||||
"./utils/*": {
|
||||
"types": "./dist/utils/*.d.ts",
|
||||
"default": "./dist/utils/*.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const providerAccountSchema = z.object({
|
||||
export const billingModeSchema = z.enum(['daily', 'monthly'])
|
||||
|
||||
export const providerAccountInputSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
providerId: z.string().min(1, 'Provider is required'),
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
panelUrl: z.string().optional().default(''),
|
||||
currency: z.string().optional().default(''),
|
||||
billingMode: z.string().optional().default(''),
|
||||
billingMode: billingModeSchema.optional().default('monthly'),
|
||||
notes: z.string().optional().default(''),
|
||||
apiCredentials: z.string().optional().default(''),
|
||||
balanceAlertBelow: z.union([z.number(), z.null()]).optional(),
|
||||
})
|
||||
|
||||
export type ProviderAccount = z.infer<typeof providerAccountSchema>
|
||||
export const providerAccountPublicSchema = providerAccountInputSchema
|
||||
.omit({ apiCredentials: true })
|
||||
.extend({
|
||||
apiCredentialsSet: z.boolean().optional(),
|
||||
apiLogin: z.string().optional(),
|
||||
})
|
||||
|
||||
/** @deprecated Используйте providerAccountInputSchema */
|
||||
export const providerAccountSchema = providerAccountInputSchema
|
||||
|
||||
export type ProviderAccountInput = z.infer<typeof providerAccountInputSchema>
|
||||
export type ProviderAccountPublic = z.infer<typeof providerAccountPublicSchema>
|
||||
export type ProviderAccount = ProviderAccountInput
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './contracts/provider.js'
|
||||
export * from './contracts/provider-account.js'
|
||||
export * from './utils/api-credentials.js'
|
||||
export * from './contracts/vps.js'
|
||||
export * from './contracts/payment.js'
|
||||
export * from './contracts/balance-ledger.js'
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Логин из BILLmanager-кредов формата `login:password`. */
|
||||
export function parseApiLogin(credentials: string | null | undefined): string {
|
||||
const cred = String(credentials ?? '').trim()
|
||||
const idx = cred.indexOf(':')
|
||||
return idx > 0 ? cred.slice(0, idx) : ''
|
||||
}
|
||||
|
||||
/** Собрать креды для API из отдельных полей формы. */
|
||||
export function buildApiCredentials(login: string, password: string): string {
|
||||
const l = login.trim()
|
||||
const p = password
|
||||
if (!l && !p) return ''
|
||||
if (!l) return p
|
||||
return p ? `${l}:${p}` : ''
|
||||
}
|
||||
Reference in New Issue
Block a user