chore: initialize standalone business plugin repository
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
import { chromium } from 'playwright'
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
const origin = process.env.PLUGIN_BROWSER_ORIGIN || 'http://127.0.0.1:18081'
|
||||
const entry = `${origin}/extensions/qiu.subscription-admin/admin/`
|
||||
const outputDir = process.env.PLUGIN_SCREENSHOT_DIR || '.playwright-cli/subscription-admin'
|
||||
|
||||
await fs.mkdir(path.resolve(outputDir), { recursive: true })
|
||||
|
||||
const browser = await chromium.launch({ headless: true })
|
||||
try {
|
||||
for (const width of [425, 900, 1440]) {
|
||||
const page = await browser.newPage({ viewport: { width, height: 900 }, deviceScaleFactor: 1 })
|
||||
const responseLeaks = []
|
||||
page.on('response', async (response) => {
|
||||
if (!response.headers()['content-type']?.includes('application/json')) return
|
||||
try {
|
||||
const body = await response.text()
|
||||
if (/access_token|refresh_token|admin[_-]?api[_-]?key|password|client_secret/i.test(body)) responseLeaks.push(response.url())
|
||||
} catch (_) {}
|
||||
})
|
||||
await page.goto(entry, { waitUntil: 'domcontentloaded' })
|
||||
await page.getByLabel('管理员邮箱').fill('admin@example.com')
|
||||
await page.getByLabel('密码').fill('password')
|
||||
await page.getByRole('button', { name: '登录' }).click()
|
||||
await page.locator('#app-view').waitFor({ state: 'visible' })
|
||||
await page.waitForTimeout(50)
|
||||
if (responseLeaks.length) throw new Error(`sensitive response field exposed at ${width}px: ${responseLeaks.join(', ')}`)
|
||||
await page.getByRole('button', { name: '用户订阅' }).click()
|
||||
await page.locator('#subscriptions-list table').waitFor()
|
||||
await page.getByRole('button', { name: '下一页' }).click()
|
||||
await page.getByRole('button', { name: '概览' }).click()
|
||||
await page.reload({ waitUntil: 'domcontentloaded' })
|
||||
await page.locator('#app-view').waitFor({ state: 'visible' })
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth)
|
||||
if (overflow) throw new Error(`horizontal overflow at ${width}px`)
|
||||
await page.screenshot({ path: path.join(outputDir, `subscription-admin-${width}.png`), fullPage: true })
|
||||
await page.close()
|
||||
}
|
||||
} finally {
|
||||
await browser.close()
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import http from 'node:http'
|
||||
|
||||
const port = Number(process.env.MOCK_CORE_PORT || 18080)
|
||||
const token = process.env.MOCK_ACCESS_TOKEN || 'MOCK-ACCESS'
|
||||
const refresh = process.env.MOCK_REFRESH_TOKEN || 'MOCK-REFRESH'
|
||||
const users = new Map([
|
||||
['admin@example.com', { id: 1, role: 'admin', email: 'admin@example.com', username: 'admin' }],
|
||||
['user@example.com', { id: 2, role: 'user', email: 'user@example.com', username: 'user' }]
|
||||
])
|
||||
|
||||
function json(res, status, body) {
|
||||
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
||||
res.end(JSON.stringify(body))
|
||||
}
|
||||
|
||||
async function body(req) {
|
||||
const chunks = []
|
||||
for await (const chunk of req) chunks.push(chunk)
|
||||
return chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {}
|
||||
}
|
||||
|
||||
function authorized(req) {
|
||||
return req.headers.authorization === `Bearer ${token}` || req.headers.authorization === `Bearer NEW-MOCK-ACCESS`
|
||||
}
|
||||
|
||||
function subscriptions(page = 1, pageSize = 50) {
|
||||
const all = Array.from({ length: 57 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
user_id: 1,
|
||||
plan_id: 10 + (index % 2),
|
||||
plan_name: index % 2 ? '专业版' : '基础版',
|
||||
status: 'active',
|
||||
starts_at: '2026-08-01T00:00:00Z',
|
||||
expires_at: '2026-09-01T00:00:00Z',
|
||||
cycle_usage_usd: index / 100,
|
||||
cycle_quota_usd: 100,
|
||||
user: { id: 1, username: 'admin', email: 'admin@example.com', balance: 123.45 }
|
||||
}))
|
||||
const start = (page - 1) * pageSize
|
||||
return { items: all.slice(start, start + pageSize), total: all.length, page, page_size: pageSize, pages: Math.ceil(all.length / pageSize) }
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`)
|
||||
const path = url.pathname
|
||||
if (req.method === 'POST' && path === '/api/v1/auth/login') {
|
||||
const input = await body(req)
|
||||
const user = users.get(input.email)
|
||||
if (!user || input.password !== 'password') return json(res, 401, { code: 401, message: 'invalid credentials' })
|
||||
return json(res, 200, { code: 0, message: 'success', data: { access_token: token, refresh_token: refresh, user } })
|
||||
}
|
||||
if (req.method === 'POST' && path === '/api/v1/auth/refresh') {
|
||||
const input = await body(req)
|
||||
if (input.refresh_token !== refresh && input.refresh_token !== 'NEW-MOCK-REFRESH') return json(res, 401, { code: 401, message: 'expired' })
|
||||
return json(res, 200, { code: 0, message: 'success', data: { access_token: 'NEW-MOCK-ACCESS', refresh_token: 'NEW-MOCK-REFRESH' } })
|
||||
}
|
||||
if (req.method === 'POST' && path === '/api/v1/auth/logout') return json(res, 200, { code: 0, message: 'success', data: {} })
|
||||
if (req.method === 'GET' && path === '/api/v1/settings/public') return json(res, 200, { code: 0, message: 'success', data: { turnstile_enabled: false, geetest_captcha_enabled: false, tencent_captcha_enabled: false, aliyun_captcha_enabled: false } })
|
||||
if (!authorized(req)) return json(res, 401, { code: 401, message: 'unauthorized' })
|
||||
if (req.method === 'GET' && path === '/api/v1/auth/me') return json(res, 200, { code: 0, message: 'success', data: users.get('admin@example.com') })
|
||||
if (req.method === 'GET' && path === '/api/v1/admin/payment/plans') {
|
||||
return json(res, 200, { code: 0, message: 'success', data: [{ id: 10, name: '基础版', price: 9.9, currency: 'USD', validity_days: 30, validity_unit: 'day', for_sale: true, included_groups: [] }, { id: 11, name: '专业版', price: 19.9, currency: 'USD', validity_days: 30, validity_unit: 'day', for_sale: true, included_groups: [] }] })
|
||||
}
|
||||
if (req.method === 'GET' && path === '/api/v1/admin/subscriptions') {
|
||||
const page = Number(url.searchParams.get('page') || 1)
|
||||
const pageSize = Number(url.searchParams.get('page_size') || 50)
|
||||
return json(res, 200, { code: 0, message: 'success', data: subscriptions(page, pageSize) })
|
||||
}
|
||||
const subscriptionMatch = path.match(/^\/api\/v1\/admin\/subscriptions\/(\d+)$/)
|
||||
if (req.method === 'GET' && subscriptionMatch) return json(res, 200, { code: 0, message: 'success', data: subscriptions(1, 1).items[0] })
|
||||
const userSubscriptionsMatch = path.match(/^\/api\/v1\/admin\/users\/(\d+)\/subscriptions$/)
|
||||
if (req.method === 'GET' && userSubscriptionsMatch) return json(res, 200, { code: 0, message: 'success', data: subscriptions(1, 2).items })
|
||||
const userMatch = path.match(/^\/api\/v1\/admin\/users\/(\d+)$/)
|
||||
if (req.method === 'GET' && userMatch) return json(res, 200, { code: 0, message: 'success', data: { id: Number(userMatch[1]), username: 'admin', email: 'admin@example.com', balance: 123.45, frozen_balance: 0 } })
|
||||
return json(res, 404, { code: 404, message: 'not found' })
|
||||
})
|
||||
|
||||
server.listen(port, '127.0.0.1', () => {
|
||||
process.stdout.write(`mock core listening on 127.0.0.1:${port}\n`)
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import http from 'node:http'
|
||||
|
||||
const prefix = '/extensions/qiu.subscription-admin'
|
||||
const targetPort = Number(process.env.PLUGIN_TARGET_PORT || 18082)
|
||||
const port = Number(process.env.MOCK_PROXY_PORT || 18081)
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if (!req.url.startsWith(prefix)) {
|
||||
res.writeHead(404)
|
||||
res.end('not found')
|
||||
return
|
||||
}
|
||||
const forwardedPath = req.url.slice(prefix.length) || '/'
|
||||
const proxy = http.request({ hostname: '127.0.0.1', port: targetPort, method: req.method, path: forwardedPath, headers: { ...req.headers, host: `127.0.0.1:${targetPort}` } }, (upstream) => {
|
||||
const headers = { ...upstream.headers }
|
||||
if (headers.location && headers.location.startsWith('/admin/')) headers.location = `${prefix}${headers.location}`
|
||||
res.writeHead(upstream.statusCode || 502, headers)
|
||||
upstream.pipe(res)
|
||||
})
|
||||
proxy.on('error', () => {
|
||||
if (!res.headersSent) res.writeHead(502)
|
||||
res.end('proxy error')
|
||||
})
|
||||
req.pipe(proxy)
|
||||
})
|
||||
|
||||
server.listen(port, '127.0.0.1', () => process.stdout.write(`mock proxy listening on 127.0.0.1:${port}\n`))
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
cleanup() {
|
||||
kill "${PROXY_PID:-}" "${PLUGIN_PID:-}" "${CORE_PID:-}" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
MOCK_CORE_PORT=18080 node "$ROOT/test/mock-core.mjs" >/tmp/subscription-admin-mock-core.log 2>&1 & CORE_PID=$!
|
||||
CORE_BASE_URL=http://127.0.0.1:18080 \
|
||||
PLUGIN_HOST=127.0.0.1 \
|
||||
PLUGIN_PORT=18082 \
|
||||
PLUGIN_PUBLIC_BASE_PATH=/extensions/qiu.subscription-admin \
|
||||
PLUGIN_COOKIE_PATH=/extensions/qiu.subscription-admin/ \
|
||||
PLUGIN_COOKIE_SECURE=false \
|
||||
go run "$ROOT" >/tmp/subscription-admin-plugin.log 2>&1 & PLUGIN_PID=$!
|
||||
PLUGIN_TARGET_PORT=18082 MOCK_PROXY_PORT=18081 node "$ROOT/test/mock-proxy.mjs" >/tmp/subscription-admin-mock-proxy.log 2>&1 & PROXY_PID=$!
|
||||
|
||||
sleep 2
|
||||
PLUGIN_BROWSER_ORIGIN=http://127.0.0.1:18081 PLUGIN_SCREENSHOT_DIR="$ROOT/.screenshots/subscription-admin" node "$ROOT/test/browser-check.mjs"
|
||||
Reference in New Issue
Block a user