mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-29 00:01:42 +08:00
389 lines
13 KiB
TypeScript
389 lines
13 KiB
TypeScript
import { generateKeys, sign } from 'paseto-ts/v4'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { getOrCreateInstanceId } from '../licensing/instance-id.js'
|
|
import { createLicenseBinding, loadLicenseState } from '../licensing/license-state.js'
|
|
import { PUBLIC_KEYS } from '../licensing/public-keys.js'
|
|
import { adminHeaders, authedHeaders, createTestApp } from '../test/setup.js'
|
|
|
|
function makeCloudResponse(body: unknown, status = 200): Response {
|
|
return {
|
|
ok: status >= 200 && status < 300,
|
|
status,
|
|
statusText: status === 200 ? 'OK' : 'Error',
|
|
json: async () => body,
|
|
text: async () => JSON.stringify(body),
|
|
} as unknown as Response
|
|
}
|
|
|
|
const { secretKey: TEST_SECRET, publicKey: TEST_PUBLIC } = generateKeys('public')
|
|
const originalKeys: string[] = []
|
|
|
|
function nowSec(): number {
|
|
return Math.floor(Date.now() / 1000)
|
|
}
|
|
|
|
function signCert(instanceId: string): string {
|
|
const now = nowSec()
|
|
return sign(TEST_SECRET, {
|
|
type: 'zpan.license',
|
|
issuer: 'https://cloud.zpan.space',
|
|
subject: 'bind-1',
|
|
accountId: 'acct-1',
|
|
instanceId,
|
|
edition: 'pro',
|
|
authorizedHosts: ['localhost'],
|
|
licenseValidUntil: now + 365 * 24 * 60 * 60,
|
|
issuedAt: now,
|
|
notBefore: now,
|
|
expiresAt: now + 3600,
|
|
})
|
|
}
|
|
|
|
async function seedBinding(db: Awaited<ReturnType<typeof createTestApp>>['db'], instanceId = 'inst-1') {
|
|
const now = nowSec()
|
|
const cert = signCert(instanceId)
|
|
await createLicenseBinding(db, {
|
|
cloudBindingId: 'bind-1',
|
|
instanceId,
|
|
cloudAccountId: 'acct-1',
|
|
cloudStoreId: 'store-1',
|
|
refreshToken: 'old-token',
|
|
cachedCert: cert,
|
|
cachedExpiresAt: now + 3600,
|
|
lastRefreshAt: now,
|
|
})
|
|
}
|
|
|
|
describe('Licensing Admin API — auth guards', () => {
|
|
it('POST /api/licensing/pair returns 401 without auth', async () => {
|
|
const { app } = await createTestApp()
|
|
const res = await app.request('/api/licensing/pair', { method: 'POST' })
|
|
expect(res.status).toBe(401)
|
|
})
|
|
|
|
it('GET /api/licensing/pair/:code/poll returns 401 without auth', async () => {
|
|
const { app } = await createTestApp()
|
|
const res = await app.request('/api/licensing/pair/ABC-123/poll')
|
|
expect(res.status).toBe(401)
|
|
})
|
|
|
|
it('POST /api/licensing/refresh returns 401 without auth', async () => {
|
|
const { app } = await createTestApp()
|
|
const res = await app.request('/api/licensing/refresh', { method: 'POST' })
|
|
expect(res.status).toBe(401)
|
|
})
|
|
|
|
it('DELETE /api/licensing/binding returns 401 without auth', async () => {
|
|
const { app } = await createTestApp()
|
|
const res = await app.request('/api/licensing/binding', { method: 'DELETE' })
|
|
expect(res.status).toBe(401)
|
|
})
|
|
|
|
it('POST /api/licensing/pair returns 403 for non-admin', async () => {
|
|
const { app } = await createTestApp()
|
|
await authedHeaders(app, 'admin@example.com')
|
|
await authedHeaders(app, 'regular@example.com')
|
|
const signInRes = await app.request('/api/auth/sign-in/email', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email: 'regular@example.com', password: 'password123456' }),
|
|
})
|
|
const headers = { Cookie: signInRes.headers.getSetCookie().join('; ') }
|
|
const res = await app.request('/api/licensing/pair', { method: 'POST', headers })
|
|
expect(res.status).toBe(403)
|
|
})
|
|
})
|
|
|
|
describe('POST /api/licensing/pair', () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal('fetch', vi.fn())
|
|
originalKeys.push(...PUBLIC_KEYS)
|
|
PUBLIC_KEYS.length = 0
|
|
PUBLIC_KEYS.push(TEST_PUBLIC)
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals()
|
|
PUBLIC_KEYS.length = 0
|
|
for (const key of originalKeys.splice(0)) PUBLIC_KEYS.push(key)
|
|
})
|
|
|
|
it('calls cloud and returns pairing info', async () => {
|
|
const { app } = await createTestApp()
|
|
const headers = await adminHeaders(app)
|
|
|
|
const cloudPayload = {
|
|
code: 'ABC-123',
|
|
pairingUrl: 'https://cloud.zpan.space/pair',
|
|
expiresAt: '2026-01-01T00:00:00Z',
|
|
}
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeCloudResponse(cloudPayload))
|
|
|
|
const res = await app.request('/api/licensing/pair', {
|
|
method: 'POST',
|
|
headers,
|
|
})
|
|
|
|
expect(res.status).toBe(200)
|
|
const body = (await res.json()) as Record<string, unknown>
|
|
expect(body.code).toBe('ABC-123')
|
|
expect(body.pairingUrl).toBe('https://cloud.zpan.space/pair')
|
|
|
|
const [, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
|
expect(JSON.parse(String(init.body)).instanceHost).toBe('http://localhost')
|
|
})
|
|
})
|
|
|
|
describe('GET /api/licensing/pair/:code/poll', () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal('fetch', vi.fn())
|
|
originalKeys.push(...PUBLIC_KEYS)
|
|
PUBLIC_KEYS.length = 0
|
|
PUBLIC_KEYS.push(TEST_PUBLIC)
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals()
|
|
PUBLIC_KEYS.length = 0
|
|
for (const key of originalKeys.splice(0)) PUBLIC_KEYS.push(key)
|
|
})
|
|
|
|
it('returns pending status when cloud returns pending', async () => {
|
|
const { app } = await createTestApp()
|
|
const headers = await adminHeaders(app)
|
|
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeCloudResponse({ status: 'pending' }))
|
|
|
|
const res = await app.request('/api/licensing/pair/ABC-123/poll', { headers })
|
|
|
|
expect(res.status).toBe(200)
|
|
const body = (await res.json()) as Record<string, unknown>
|
|
expect(body.status).toBe('pending')
|
|
})
|
|
|
|
it('stores binding on approved and returns approved status', async () => {
|
|
const { app, db } = await createTestApp()
|
|
const headers = await adminHeaders(app)
|
|
const instanceId = await getOrCreateInstanceId(db)
|
|
|
|
vi.mocked(fetch).mockResolvedValueOnce(
|
|
makeCloudResponse({
|
|
status: 'approved',
|
|
refreshToken: 'rt-secret',
|
|
certificate: signCert(instanceId),
|
|
binding: { id: 'bind-1', storeId: 'store-1', instanceId, authorizedHosts: ['localhost'] },
|
|
account: { id: 'acct-1', email: 'acct@example.com' },
|
|
}),
|
|
)
|
|
|
|
const res = await app.request('/api/licensing/pair/CODE-1/poll', { headers })
|
|
|
|
expect(res.status).toBe(200)
|
|
const body = (await res.json()) as Record<string, unknown>
|
|
expect(body.status).toBe('approved')
|
|
|
|
// Check that binding was persisted
|
|
const state = await loadLicenseState(db)
|
|
expect(state.refreshToken).toBe('rt-secret')
|
|
expect(state.cloudStoreId).toBe('store-1')
|
|
})
|
|
|
|
it('stores the pairing certificate when approved', async () => {
|
|
const { app, db } = await createTestApp()
|
|
const headers = await adminHeaders(app)
|
|
const instanceId = await getOrCreateInstanceId(db)
|
|
const certificate = signCert(instanceId)
|
|
|
|
vi.mocked(fetch).mockResolvedValueOnce(
|
|
makeCloudResponse({
|
|
status: 'approved',
|
|
refreshToken: 'pair-rt',
|
|
certificate,
|
|
binding: { id: 'bind-1', storeId: 'store-1', instanceId, authorizedHosts: ['localhost'] },
|
|
account: { id: 'acct-1', email: 'acct@example.com' },
|
|
}),
|
|
)
|
|
|
|
const res = await app.request('/api/licensing/pair/CODE-1/poll', { headers })
|
|
|
|
expect(res.status).toBe(200)
|
|
const state = await loadLicenseState(db)
|
|
expect(state.refreshToken).toBe('pair-rt')
|
|
expect(state.cachedCert).toBe(certificate)
|
|
expect(vi.mocked(fetch).mock.calls).toHaveLength(1)
|
|
})
|
|
|
|
it('rejects approved responses with an invalid certificate', async () => {
|
|
const { app, db } = await createTestApp()
|
|
const headers = await adminHeaders(app)
|
|
|
|
vi.mocked(fetch).mockResolvedValueOnce(
|
|
makeCloudResponse({
|
|
status: 'approved',
|
|
refreshToken: 'rt-secret',
|
|
certificate: signCert('wrong-instance'),
|
|
}),
|
|
)
|
|
|
|
const res = await app.request('/api/licensing/pair/CODE-1/poll', { headers })
|
|
|
|
expect(res.status).toBe(502)
|
|
const state = await loadLicenseState(db)
|
|
expect(state.refreshToken).toBeNull()
|
|
})
|
|
|
|
it('rejects approved responses when certificate is missing', async () => {
|
|
const { app, db } = await createTestApp()
|
|
const headers = await adminHeaders(app)
|
|
|
|
vi.mocked(fetch).mockResolvedValueOnce(
|
|
makeCloudResponse({
|
|
status: 'approved',
|
|
refreshToken: 'rt-secret',
|
|
}),
|
|
)
|
|
|
|
const res = await app.request('/api/licensing/pair/CODE-1/poll', { headers })
|
|
|
|
expect(res.status).toBe(502)
|
|
const state = await loadLicenseState(db)
|
|
expect(state.refreshToken).toBeNull()
|
|
})
|
|
|
|
it('rejects approved responses when binding metadata is missing', async () => {
|
|
const { app, db } = await createTestApp()
|
|
const headers = await adminHeaders(app)
|
|
const instanceId = await getOrCreateInstanceId(db)
|
|
|
|
vi.mocked(fetch).mockResolvedValueOnce(
|
|
makeCloudResponse({
|
|
status: 'approved',
|
|
refreshToken: 'rt-secret',
|
|
certificate: signCert(instanceId),
|
|
}),
|
|
)
|
|
|
|
const res = await app.request('/api/licensing/pair/CODE-1/poll', { headers })
|
|
|
|
expect(res.status).toBe(502)
|
|
await expect(res.json()).resolves.toEqual({ error: 'invalid_certificate' })
|
|
const state = await loadLicenseState(db)
|
|
expect(state.status).toBe('disconnected')
|
|
expect(state.refreshToken).toBeNull()
|
|
expect(state.cachedCert).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('POST /api/licensing/refresh', () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal('fetch', vi.fn())
|
|
originalKeys.push(...PUBLIC_KEYS)
|
|
PUBLIC_KEYS.length = 0
|
|
PUBLIC_KEYS.push(TEST_PUBLIC)
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals()
|
|
PUBLIC_KEYS.length = 0
|
|
for (const key of originalKeys.splice(0)) PUBLIC_KEYS.push(key)
|
|
})
|
|
|
|
it('returns success when binding exists and cloud responds OK', async () => {
|
|
const { app, db } = await createTestApp()
|
|
const headers = await adminHeaders(app)
|
|
|
|
await seedBinding(db)
|
|
|
|
vi.mocked(fetch).mockResolvedValueOnce(
|
|
makeCloudResponse({
|
|
refreshToken: 'new-token',
|
|
certificate: signCert('inst-1'),
|
|
binding: { id: 'bind-1', storeId: 'store-1', instanceId: 'inst-1', authorizedHosts: ['localhost'] },
|
|
account: { id: 'acct-1', email: 'acct@example.com' },
|
|
}),
|
|
)
|
|
|
|
const res = await app.request('/api/licensing/refresh', { method: 'POST', headers })
|
|
|
|
expect(res.status).toBe(200)
|
|
const body = (await res.json()) as Record<string, unknown>
|
|
expect(body.success).toBe(true)
|
|
})
|
|
|
|
it('returns success:true with null last_refresh_at when no binding exists', async () => {
|
|
const { app } = await createTestApp()
|
|
const headers = await adminHeaders(app)
|
|
|
|
// No binding exists — performRefresh is a no-op
|
|
const res = await app.request('/api/licensing/refresh', { method: 'POST', headers })
|
|
|
|
expect(res.status).toBe(200)
|
|
const body = (await res.json()) as Record<string, unknown>
|
|
expect(body.success).toBe(true)
|
|
expect(body.last_refresh_at).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('DELETE /api/licensing/binding', () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal('fetch', vi.fn())
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals()
|
|
})
|
|
|
|
it('unbinds from Cloud, deletes binding row, and returns deleted: true', async () => {
|
|
const { app, db } = await createTestApp()
|
|
const headers = await adminHeaders(app)
|
|
|
|
await seedBinding(db)
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeCloudResponse(null, 204))
|
|
|
|
const res = await app.request('/api/licensing/binding', { method: 'DELETE', headers })
|
|
|
|
expect(res.status).toBe(200)
|
|
const body = (await res.json()) as Record<string, unknown>
|
|
expect(body.deleted).toBe(true)
|
|
expect(fetch).toHaveBeenCalledWith('https://cloud.zpan.space/api/licenses/bind-1', {
|
|
method: 'DELETE',
|
|
headers: { Authorization: 'Bearer old-token' },
|
|
signal: expect.any(AbortSignal),
|
|
})
|
|
|
|
// Confirm binding is gone
|
|
const state = await loadLicenseState(db)
|
|
expect(state.refreshToken).toBeNull()
|
|
})
|
|
|
|
it('clears the local binding when Cloud unbind fails', async () => {
|
|
const { app, db } = await createTestApp()
|
|
const headers = await adminHeaders(app)
|
|
|
|
await seedBinding(db)
|
|
vi.mocked(fetch).mockResolvedValueOnce(makeCloudResponse({ error: 'unbound' }, 401))
|
|
|
|
const res = await app.request('/api/licensing/binding', { method: 'DELETE', headers })
|
|
|
|
expect(res.status).toBe(200)
|
|
const body = (await res.json()) as Record<string, unknown>
|
|
expect(body.deleted).toBe(true)
|
|
expect(body.cloud_unbind_error).toContain('Cloud unbind failed')
|
|
|
|
const state = await loadLicenseState(db)
|
|
expect(state.refreshToken).toBeNull()
|
|
})
|
|
|
|
it('returns deleted: true even when no binding exists', async () => {
|
|
const { app } = await createTestApp()
|
|
const headers = await adminHeaders(app)
|
|
|
|
const res = await app.request('/api/licensing/binding', { method: 'DELETE', headers })
|
|
|
|
expect(res.status).toBe(200)
|
|
const body = (await res.json()) as Record<string, unknown>
|
|
expect(body.deleted).toBe(true)
|
|
expect(fetch).not.toHaveBeenCalled()
|
|
})
|
|
})
|