mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-30 08:22:34 +00:00
feat(accounts): make repeated static account setup safer
Admins often need another account with the same provider and routing configuration. Duplicate on the server so credentials never return to the browser, preserve exact group priorities atomically, start the copy paused, and recover the same copy after ambiguous idempotency-store failures. Constraint: Admin account responses redact credentials, so duplication must remain server-side Constraint: OAuth and setup-token credentials rotate and must not be shared across account rows Rejected: Copy raw account JSON to the clipboard | exposes credentials outside the server Rejected: Duplicate rotating credentials | account-scoped refresh locks can race token rotation Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep copies paused, avoid automatic upstream probes, and exclude rotating credential types unless token ownership is redesigned Tested: Targeted Go tests, Go vet, server build; frontend lint, typecheck, Vitest suite, production build; integration test compiled Not-tested: Docker-backed PostgreSQL execution because Docker is unavailable Related: Wei-Shaw/sub2api#1379 Related: Wei-Shaw/sub2api#2928
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { post } = vi.hoisted(() => ({
|
||||
post: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
apiClient: { post }
|
||||
}))
|
||||
|
||||
import { duplicate } from '@/api/admin/accounts'
|
||||
|
||||
describe('admin account duplicate API', () => {
|
||||
beforeEach(() => {
|
||||
post.mockReset()
|
||||
post.mockResolvedValue({ data: { id: 43, name: 'primary (Copy)' } })
|
||||
vi.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue('11111111-1111-4111-8111-111111111111')
|
||||
})
|
||||
|
||||
it('sends a stable idempotency key with the duplicate request', async () => {
|
||||
const account = await duplicate(42)
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/admin/accounts/42/duplicate', undefined, {
|
||||
headers: {
|
||||
'Idempotency-Key': 'account-duplicate-42-11111111-1111-4111-8111-111111111111'
|
||||
}
|
||||
})
|
||||
expect(account).toEqual({ id: 43, name: 'primary (Copy)' })
|
||||
})
|
||||
|
||||
it('reuses the operation key after an ambiguous failed request', async () => {
|
||||
post.mockRejectedValueOnce(new Error('network timeout'))
|
||||
await expect(duplicate(99)).rejects.toThrow('network timeout')
|
||||
|
||||
post.mockResolvedValueOnce({ data: { id: 100, name: 'retry (Copy)' } })
|
||||
await duplicate(99)
|
||||
|
||||
expect(post).toHaveBeenCalledTimes(2)
|
||||
const firstHeaders = post.mock.calls[0][2].headers
|
||||
const secondHeaders = post.mock.calls[1][2].headers
|
||||
expect(secondHeaders).toEqual(firstHeaders)
|
||||
})
|
||||
})
|
||||
@@ -138,6 +138,27 @@ export async function create(accountData: CreateAccountRequest): Promise<Account
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate an account while keeping credentials on the server.
|
||||
* @param id - Source account ID
|
||||
* @returns Newly created account
|
||||
*/
|
||||
const duplicateOperationKeys = new Map<number, string>()
|
||||
|
||||
export async function duplicate(id: number): Promise<Account> {
|
||||
let idempotencyKey = duplicateOperationKeys.get(id)
|
||||
if (!idempotencyKey) {
|
||||
const requestID = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
idempotencyKey = `account-duplicate-${id}-${requestID}`
|
||||
duplicateOperationKeys.set(id, idempotencyKey)
|
||||
}
|
||||
const { data } = await apiClient.post<Account>(`/admin/accounts/${id}/duplicate`, undefined, {
|
||||
headers: { 'Idempotency-Key': idempotencyKey }
|
||||
})
|
||||
duplicateOperationKeys.delete(id)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Update account
|
||||
* @param id - Account ID
|
||||
@@ -809,6 +830,7 @@ export const accountsAPI = {
|
||||
listWithEtag,
|
||||
getById,
|
||||
create,
|
||||
duplicate,
|
||||
update,
|
||||
checkMixedChannelRisk,
|
||||
delete: deleteAccount,
|
||||
|
||||
Reference in New Issue
Block a user