mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 11:37:06 +08:00
feat: 为本地 HTTP API 增加 Token 鉴权与安全加固
- 使用 Electron safeStorage 加密存储并自动初始化 API Token - 为 health 以外的接口增加 Bearer Token 鉴权 - 限制 CORS 仅允许可信本地 Origin - 增加鉴权、Token rotation、safeStorage 和手动验收测试
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ApiRuntimePanel } from '../../src/renderer/src/features/api-center/components/ApiRuntimePanel'
|
||||
|
||||
const token = 'fixture_token_visible_only_after_user_action'
|
||||
|
||||
function renderPanel(revealedToken = ''): {
|
||||
reveal: ReturnType<typeof vi.fn>
|
||||
copy: ReturnType<typeof vi.fn>
|
||||
rotate: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const reveal = vi.fn(async () => undefined)
|
||||
const copy = vi.fn(async () => undefined)
|
||||
const rotate = vi.fn(async () => undefined)
|
||||
render(
|
||||
<ApiRuntimePanel
|
||||
service={{ running: true, host: '127.0.0.1', port: 6131 }}
|
||||
tokenStatus={{ available: true, hasToken: true, maskedToken: '••••••••••••••••' }}
|
||||
revealedToken={revealedToken}
|
||||
dbReady
|
||||
response={null}
|
||||
history={[]}
|
||||
onControl={vi.fn()}
|
||||
onOpenSettings={vi.fn()}
|
||||
onCopy={vi.fn(async () => undefined)}
|
||||
onRevealToken={reveal}
|
||||
onHideToken={vi.fn()}
|
||||
onCopyToken={copy}
|
||||
onRotateToken={rotate}
|
||||
/>
|
||||
)
|
||||
return { reveal, copy, rotate }
|
||||
}
|
||||
|
||||
describe('API Token panel', () => {
|
||||
it('masks the token by default and exposes only explicit actions', async () => {
|
||||
const actions = renderPanel()
|
||||
expect(screen.getByText('••••••••••••••••')).toBeInTheDocument()
|
||||
expect(screen.queryByText(token)).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Token 已生成')).toBeInTheDocument()
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: '显示 Token' }))
|
||||
expect(actions.reveal).toHaveBeenCalledOnce()
|
||||
await userEvent.click(screen.getByRole('button', { name: '复制 Token' }))
|
||||
expect(actions.copy).toHaveBeenCalledOnce()
|
||||
await userEvent.click(screen.getByRole('button', { name: '重新生成 Token' }))
|
||||
expect(actions.rotate).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('shows the full token only when reveal state is explicitly present', () => {
|
||||
renderPanel(token)
|
||||
expect(screen.getByText(token)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '隐藏 Token' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a safe-storage error and disables token actions when unavailable', () => {
|
||||
render(
|
||||
<ApiRuntimePanel
|
||||
service={{ running: false, host: '127.0.0.1', port: 6131 }}
|
||||
tokenStatus={{
|
||||
available: false,
|
||||
hasToken: false,
|
||||
maskedToken: '••••••••••••••••',
|
||||
error: '系统安全存储不可用,本地 API 已安全停用。请检查系统钥匙串或凭据服务后重试。'
|
||||
}}
|
||||
revealedToken=""
|
||||
dbReady
|
||||
response={null}
|
||||
history={[]}
|
||||
onControl={vi.fn()}
|
||||
onOpenSettings={vi.fn()}
|
||||
onCopy={vi.fn(async () => undefined)}
|
||||
onRevealToken={vi.fn(async () => undefined)}
|
||||
onHideToken={vi.fn()}
|
||||
onCopyToken={vi.fn(async () => undefined)}
|
||||
onRotateToken={vi.fn(async () => undefined)}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(/系统安全存储不可用/)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '显示 Token' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: '复制 Token' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: '重新生成 Token' })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
@@ -114,6 +114,25 @@ test('NAV-01 NAV-02 every top-level page is unique and switchable', async () =>
|
||||
}
|
||||
})
|
||||
|
||||
test('API-01 masks, reveals, and confirms rotation of the local API token', async () => {
|
||||
const fixture = await launchTestApp()
|
||||
try {
|
||||
await fixture.page.getByRole('button', { name: 'API' }).click()
|
||||
await expect(fixture.page.getByText('API Token', { exact: true })).toBeVisible()
|
||||
await expect(fixture.page.getByText('••••••••••••••••')).toBeVisible()
|
||||
await expect(fixture.page.getByText('fixture-api-token')).toHaveCount(0)
|
||||
|
||||
await fixture.page.getByRole('button', { name: '显示 Token' }).click()
|
||||
await expect(fixture.page.getByText('fixture-api-token')).toBeVisible()
|
||||
|
||||
fixture.page.once('dialog', (dialog) => dialog.accept())
|
||||
await fixture.page.getByRole('button', { name: '重新生成 Token' }).click()
|
||||
await expect(fixture.page.getByText('Token 已生成')).toBeVisible()
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('EXPORT-01 multi-chat selection stays local to export and forces HTML', async () => {
|
||||
const fixture = await launchTestApp()
|
||||
try {
|
||||
|
||||
@@ -316,6 +316,31 @@ handle('app-log:reveal', () => undefined)
|
||||
handle('cache:getSummary', () => ({ bootstrapBytes: 0, electronBytes: 0, totalBytes: 0 }))
|
||||
handle('cache:clear', () => ({ bootstrapBytes: 0, electronBytes: 0, totalBytes: 0 }))
|
||||
handle('api:getStatus', () => ({ running: false, host: settings.apiHost, port: settings.apiPort }))
|
||||
handle('api:tokenStatus', () => ({
|
||||
success: true,
|
||||
available: true,
|
||||
hasToken: true,
|
||||
maskedToken: '••••••••••••••••'
|
||||
}))
|
||||
handle('api:revealToken', () => ({
|
||||
available: true,
|
||||
hasToken: true,
|
||||
maskedToken: '••••••••••••••••',
|
||||
token: 'fixture-api-token'
|
||||
}))
|
||||
handle('api:copyToken', () => ({
|
||||
success: true,
|
||||
available: true,
|
||||
hasToken: true,
|
||||
maskedToken: '••••••••••••••••'
|
||||
}))
|
||||
handle('api:rotateToken', () => ({
|
||||
success: true,
|
||||
available: true,
|
||||
hasToken: true,
|
||||
maskedToken: '••••••••••••••••'
|
||||
}))
|
||||
handle('api:copyCurl', () => ({ success: true }))
|
||||
handle('api:start', () => ({ running: true, host: settings.apiHost, port: settings.apiPort }))
|
||||
handle('api:stop', () => ({ running: false, host: settings.apiHost, port: settings.apiPort }))
|
||||
handle('api:toggle', (enabled) => ({
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
import fs from 'fs-extra'
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const fixture = vi.hoisted(() => ({
|
||||
root: `/tmp/wxe-local-api-auth-${process.pid}`,
|
||||
storageAvailable: true,
|
||||
contacts: [
|
||||
{
|
||||
m_nsUsrName: 'wxid_fixture',
|
||||
m_nsNickName: '测试联系人',
|
||||
md5: 'fixture-md5',
|
||||
type: 'user' as const
|
||||
}
|
||||
],
|
||||
testSend: vi.fn(async () => ({ success: true, status: 'sent' }))
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => fixture.root },
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => fixture.storageAvailable,
|
||||
encryptString: (value: string) => Buffer.from(`encrypted:${value}`, 'utf8'),
|
||||
decryptString: (value: Buffer) => value.toString('utf8').replace(/^encrypted:/, '')
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../../src/main/services/chat-service', () => ({
|
||||
isReady: () => true,
|
||||
listContacts: () => fixture.contacts,
|
||||
listMessages: () => [],
|
||||
getGroupSnapshot: () => ({ members: [] }),
|
||||
listRecentChat: () => [],
|
||||
resolveMd5: () => fixture.contacts[0]
|
||||
}))
|
||||
|
||||
vi.mock('../../src/main/group-report-service', () => ({
|
||||
exportGroupReport: vi.fn(async () => ({ success: true }))
|
||||
}))
|
||||
|
||||
vi.mock('../../src/main/services/agent-group-report-service', () => ({
|
||||
generateAgentGroupReport: vi.fn(async () => ({ success: true }))
|
||||
}))
|
||||
|
||||
vi.mock('../../src/main/services/agent-hub-service', () => ({
|
||||
agentHubService: {
|
||||
getStatus: () => ({
|
||||
hub: 'online',
|
||||
connector: 'online',
|
||||
dataApi: 'online',
|
||||
databaseReady: true
|
||||
}),
|
||||
testSend: fixture.testSend
|
||||
}
|
||||
}))
|
||||
|
||||
import { apiTokenStore } from '../../src/main/api-token-store'
|
||||
import { apiServer, startHttpServer, type HttpServerHandle } from '../../src/main/http-server'
|
||||
import {
|
||||
buildLocalApiCurlCommand,
|
||||
testLocalApiRequest
|
||||
} from '../../src/main/services/local-api-test-service'
|
||||
|
||||
const VALID_TOKEN = 'A'.repeat(43)
|
||||
const handles: HttpServerHandle[] = []
|
||||
|
||||
function baseUrl(handle: HttpServerHandle): string {
|
||||
return `http://${handle.host}:${handle.port}`
|
||||
}
|
||||
|
||||
async function startFixtureServer(
|
||||
tokenProvider = (): string => VALID_TOKEN
|
||||
): Promise<HttpServerHandle> {
|
||||
const handle = await startHttpServer('127.0.0.1', 0, { tokenProvider })
|
||||
handles.push(handle)
|
||||
return handle
|
||||
}
|
||||
|
||||
describe('Local API authentication', () => {
|
||||
beforeAll(() => fs.ensureDirSync(fixture.root))
|
||||
|
||||
beforeEach(() => {
|
||||
fixture.storageAvailable = true
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(handles.splice(0).map((handle) => handle.close()))
|
||||
await apiServer.stop()
|
||||
fixture.testSend.mockClear()
|
||||
})
|
||||
|
||||
afterAll(() => fs.removeSync(fixture.root))
|
||||
|
||||
it('keeps health public while protecting contact with a real HTTP request', async () => {
|
||||
const handle = await startFixtureServer()
|
||||
const health = await fetch(`${baseUrl(handle)}/api/v1/health`)
|
||||
expect(health.status).toBe(200)
|
||||
const healthBody = await health.json()
|
||||
expect(healthBody).toMatchObject({ ok: true, service: 'WechatExplorer Reader' })
|
||||
expect(JSON.stringify(healthBody)).not.toMatch(
|
||||
/token|authorization|wxid|databasePath|provider/i
|
||||
)
|
||||
await expect(fetch(`${baseUrl(handle)}/api/v1/contact`)).resolves.toMatchObject({ status: 401 })
|
||||
await expect(
|
||||
fetch(`${baseUrl(handle)}/api/v1/contact`, {
|
||||
headers: { Authorization: 'Bearer invalid' }
|
||||
})
|
||||
).resolves.toMatchObject({ status: 401 })
|
||||
const response = await fetch(`${baseUrl(handle)}/api/v1/contact`, {
|
||||
headers: { Authorization: `Bearer ${VALID_TOKEN}` }
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.json()).resolves.toMatchObject({ count: 1 })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['GET', '/api/v1/current_time'],
|
||||
['GET', '/api/v1/contact'],
|
||||
['GET', '/api/v1/chatroom'],
|
||||
['GET', '/api/v1/recent_chat'],
|
||||
['GET', '/api/v1/chatlog'],
|
||||
['GET', '/api/v1/group_snapshot'],
|
||||
['GET', '/api/v1/resolve'],
|
||||
['POST', '/api/v1/report'],
|
||||
['GET', '/api/v1/agent/status'],
|
||||
['POST', '/api/v1/agent/group-report'],
|
||||
['POST', '/api/v1/agent/send']
|
||||
])('protects every non-health route: %s %s', async (method, pathname) => {
|
||||
const handle = await startFixtureServer()
|
||||
const response = await fetch(`${baseUrl(handle)}${pathname}`, {
|
||||
method,
|
||||
...(method === 'POST' ? { headers: { 'Content-Type': 'application/json' }, body: '{}' } : {})
|
||||
})
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it.each(['Basic xxx', 'Bearer', 'bearer xxx', 'Bearer xxx', 'xxx'])(
|
||||
'rejects the invalid Authorization format %s',
|
||||
async (authorization) => {
|
||||
const handle = await startFixtureServer()
|
||||
const response = await fetch(`${baseUrl(handle)}/api/v1/contact`, {
|
||||
headers: { Authorization: authorization }
|
||||
})
|
||||
expect(response.status).toBe(401)
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
error: 'unauthorized',
|
||||
message: 'Valid API token required'
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('protects agent/send before entering its original handler', async () => {
|
||||
const handle = await startFixtureServer()
|
||||
const url = `${baseUrl(handle)}/api/v1/agent/send`
|
||||
const init = {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ to: 'fixture', text: 'test' })
|
||||
}
|
||||
expect((await fetch(url, init)).status).toBe(401)
|
||||
expect(
|
||||
(
|
||||
await fetch(url, {
|
||||
...init,
|
||||
headers: { ...init.headers, Authorization: 'Bearer invalid' }
|
||||
})
|
||||
).status
|
||||
).toBe(401)
|
||||
expect(fixture.testSend).not.toHaveBeenCalled()
|
||||
expect(
|
||||
(
|
||||
await fetch(url, {
|
||||
...init,
|
||||
headers: { ...init.headers, Authorization: `Bearer ${VALID_TOKEN}` }
|
||||
})
|
||||
).status
|
||||
).toBe(200)
|
||||
expect(fixture.testSend).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([
|
||||
'http://localhost',
|
||||
'http://localhost:5173',
|
||||
'http://127.0.0.1',
|
||||
'http://127.0.0.1:5173',
|
||||
'http://[::1]',
|
||||
'http://[::1]:5173'
|
||||
])('allows the trusted CORS origin %s', async (origin) => {
|
||||
const handle = await startFixtureServer()
|
||||
const response = await fetch(`${baseUrl(handle)}/api/v1/health`, {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
Origin: origin,
|
||||
'Access-Control-Request-Method': 'GET',
|
||||
'Access-Control-Request-Headers': 'Authorization'
|
||||
}
|
||||
})
|
||||
expect(response.status).toBe(204)
|
||||
expect(response.headers.get('access-control-allow-origin')).toBe(origin)
|
||||
expect(response.headers.get('access-control-allow-headers')).toBe('Content-Type, Authorization')
|
||||
})
|
||||
|
||||
it.each([
|
||||
'https://localhost',
|
||||
'http://localhost.example.com',
|
||||
'http://foo.localhost',
|
||||
'http://localhost.',
|
||||
'http://127.0.0.2',
|
||||
'http://2130706433',
|
||||
'https://example.com',
|
||||
'http://example.com'
|
||||
])('rejects the untrusted CORS origin %s', async (origin) => {
|
||||
const handle = await startFixtureServer()
|
||||
const response = await fetch(`${baseUrl(handle)}/api/v1/health`, {
|
||||
method: 'OPTIONS',
|
||||
headers: { Origin: origin }
|
||||
})
|
||||
expect(response.status).toBe(403)
|
||||
expect(response.headers.get('access-control-allow-origin')).toBeNull()
|
||||
})
|
||||
|
||||
it('allows clients without an Origin header', async () => {
|
||||
const handle = await startFixtureServer()
|
||||
const response = await fetch(`${baseUrl(handle)}/api/v1/contact`, {
|
||||
headers: { Authorization: `Bearer ${VALID_TOKEN}` }
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('access-control-allow-origin')).toBeNull()
|
||||
})
|
||||
|
||||
it('rotates immediately, authenticates the API Center client, and stops cleanly', async () => {
|
||||
const state = await apiServer.start('127.0.0.1', 0)
|
||||
expect(state.running).toBe(true)
|
||||
const serviceUrl = `http://${state.host}:${state.port}`
|
||||
const oldToken = apiTokenStore.revealToken().token
|
||||
expect(oldToken).toBeTruthy()
|
||||
expect(
|
||||
(
|
||||
await fetch(`${serviceUrl}/api/v1/contact`, {
|
||||
headers: { Authorization: `Bearer ${oldToken}` }
|
||||
})
|
||||
).status
|
||||
).toBe(200)
|
||||
await expect(testLocalApiRequest({ endpointId: 'contact' })).resolves.toMatchObject({
|
||||
ok: true,
|
||||
status: 200
|
||||
})
|
||||
expect(apiTokenStore.rotateToken().success).toBe(true)
|
||||
const newToken = apiTokenStore.revealToken().token
|
||||
expect(newToken).not.toBe(oldToken)
|
||||
expect(
|
||||
(
|
||||
await fetch(`${serviceUrl}/api/v1/contact`, {
|
||||
headers: { Authorization: `Bearer ${oldToken}` }
|
||||
})
|
||||
).status
|
||||
).toBe(401)
|
||||
expect(
|
||||
(
|
||||
await fetch(`${serviceUrl}/api/v1/contact`, {
|
||||
headers: { Authorization: `Bearer ${newToken}` }
|
||||
})
|
||||
).status
|
||||
).toBe(200)
|
||||
await expect(testLocalApiRequest({ endpointId: 'contact' })).resolves.toMatchObject({
|
||||
ok: true,
|
||||
status: 200
|
||||
})
|
||||
const curl = buildLocalApiCurlCommand({ endpointId: 'contact', query: { type: 'group' } })
|
||||
expect(curl.success).toBe(true)
|
||||
expect(curl.command).toContain(`Authorization: Bearer ${newToken}`)
|
||||
expect(curl.command).not.toContain(`token=${newToken}`)
|
||||
|
||||
await apiServer.stop()
|
||||
await expect(fetch(`${serviceUrl}/api/v1/health`)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('fails closed when Electron safeStorage is unavailable', async () => {
|
||||
fixture.storageAvailable = false
|
||||
const state = await apiServer.start('127.0.0.1', 0)
|
||||
expect(state).toMatchObject({ running: false, host: '127.0.0.1', port: 0 })
|
||||
expect(state.error).toContain('系统安全存储不可用')
|
||||
expect(apiServer.isRunning()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -105,6 +105,22 @@ describe('preload IPC contract', () => {
|
||||
expect(api).not.toHaveProperty('send')
|
||||
})
|
||||
|
||||
it('exposes only the intentional API token IPC operations', async () => {
|
||||
const api = await loadApi()
|
||||
invoke.mockResolvedValue({ available: true, hasToken: true, maskedToken: '••••' })
|
||||
|
||||
await api.apiTokenStatus()
|
||||
expect(invoke).toHaveBeenLastCalledWith('api:tokenStatus')
|
||||
await api.revealApiToken()
|
||||
expect(invoke).toHaveBeenLastCalledWith('api:revealToken')
|
||||
await api.copyApiToken()
|
||||
expect(invoke).toHaveBeenLastCalledWith('api:copyToken')
|
||||
await api.rotateApiToken()
|
||||
expect(invoke).toHaveBeenLastCalledWith('api:rotateToken')
|
||||
await api.copyLocalApiCurl({ endpointId: 'contact' })
|
||||
expect(invoke).toHaveBeenLastCalledWith('api:copyCurl', { endpointId: 'contact' })
|
||||
})
|
||||
|
||||
it('unsubscribes the same listener registered for native database changes', async () => {
|
||||
const api = await loadApi()
|
||||
const callback = vi.fn()
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
API_TOKEN_ROTATION_CONFIRMATION,
|
||||
confirmApiTokenRotation
|
||||
} from '../../src/renderer/src/features/api-center/utils/confirmApiTokenRotation'
|
||||
|
||||
describe('API token rotation confirmation', () => {
|
||||
it('requires explicit confirmation and explains immediate invalidation', () => {
|
||||
const reject = vi.fn(() => false)
|
||||
expect(confirmApiTokenRotation(reject)).toBe(false)
|
||||
expect(reject).toHaveBeenCalledWith(API_TOKEN_ROTATION_CONFIRMATION)
|
||||
expect(API_TOKEN_ROTATION_CONFIRMATION).toContain('旧 Token 将立即失效')
|
||||
expect(API_TOKEN_ROTATION_CONFIRMATION).toContain('Agent / Reader Skill 需要更新 Token')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import fs from 'fs-extra'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wxe-api-token-store-'))
|
||||
const storage = vi.hoisted(() => ({ available: true }))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => root },
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => storage.available,
|
||||
encryptString: (value: string) => Buffer.from(value, 'utf8').reverse(),
|
||||
decryptString: (value: Buffer) => Buffer.from(value).reverse().toString('utf8')
|
||||
}
|
||||
}))
|
||||
|
||||
import { ApiTokenStore } from '../../src/main/api-token-store'
|
||||
|
||||
describe('ApiTokenStore', () => {
|
||||
const filePath = path.join(root, 'fixture-token.bin')
|
||||
|
||||
beforeEach(() => {
|
||||
storage.available = true
|
||||
fs.removeSync(filePath)
|
||||
})
|
||||
|
||||
afterAll(() => fs.removeSync(root))
|
||||
|
||||
it('generates a 256-bit base64url token once and persists it', () => {
|
||||
const firstStore = new ApiTokenStore(filePath)
|
||||
expect(firstStore.ensureToken()).toMatchObject({ success: true, hasToken: true })
|
||||
const first = firstStore.revealToken().token
|
||||
expect(first).toMatch(/^[A-Za-z0-9_-]{43}$/)
|
||||
expect(fs.readFileSync(filePath, 'utf8')).not.toContain(String(first))
|
||||
expect(fs.statSync(filePath).mode & 0o777).toBe(0o600)
|
||||
|
||||
const secondStore = new ApiTokenStore(filePath)
|
||||
expect(secondStore.ensureToken()).toMatchObject({ success: true, hasToken: true })
|
||||
expect(secondStore.revealToken().token).toBe(first)
|
||||
})
|
||||
|
||||
it('rotates the token while keeping status responses masked', () => {
|
||||
const store = new ApiTokenStore(filePath)
|
||||
store.ensureToken()
|
||||
const oldToken = store.revealToken().token
|
||||
const result = store.rotateToken()
|
||||
const newToken = store.revealToken().token
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
available: true,
|
||||
hasToken: true,
|
||||
maskedToken: '••••••••••••••••'
|
||||
})
|
||||
expect(newToken).not.toBe(oldToken)
|
||||
})
|
||||
|
||||
it('fails closed without writing plaintext when safeStorage is unavailable', () => {
|
||||
storage.available = false
|
||||
const store = new ApiTokenStore(filePath)
|
||||
expect(store.ensureToken()).toMatchObject({ success: false, available: false, hasToken: false })
|
||||
expect(fs.existsSync(filePath)).toBe(false)
|
||||
expect(store.revealToken().token).toBeUndefined()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user