fix(admin): 生产环境隐藏并禁用自动化测试
侧栏与路由按服务端 NODE_ENV 控制;可通过 ALLOW_SMOKE_TESTS=true 临时开启。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
24
apps/admin/src/composables/useSmokeTestsAllowed.ts
Normal file
24
apps/admin/src/composables/useSmokeTestsAllowed.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
import api from '../api';
|
||||||
|
|
||||||
|
const allowed = ref<boolean | null>(null);
|
||||||
|
let loadPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
export function useSmokeTestsAllowed() {
|
||||||
|
async function ensureLoaded() {
|
||||||
|
if (allowed.value !== null) return;
|
||||||
|
if (!loadPromise) {
|
||||||
|
loadPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/admin/system/smoke-tests');
|
||||||
|
allowed.value = !!data.data?.allowed;
|
||||||
|
} catch {
|
||||||
|
allowed.value = false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
await loadPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { allowed, ensureLoaded };
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
|||||||
import { RouterView, RouterLink, useRoute, useRouter } from 'vue-router';
|
import { RouterView, RouterLink, useRoute, useRouter } from 'vue-router';
|
||||||
import { useAuthStore } from '../stores/auth';
|
import { useAuthStore } from '../stores/auth';
|
||||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||||
|
import { useSmokeTestsAllowed } from '../composables/useSmokeTestsAllowed';
|
||||||
import AdminLocaleSwitcher from '../components/AdminLocaleSwitcher.vue';
|
import AdminLocaleSwitcher from '../components/AdminLocaleSwitcher.vue';
|
||||||
import AdminNavIcon from '../components/AdminNavIcon.vue';
|
import AdminNavIcon from '../components/AdminNavIcon.vue';
|
||||||
import { resolveAdminBreadcrumb } from '../utils/admin-breadcrumb';
|
import { resolveAdminBreadcrumb } from '../utils/admin-breadcrumb';
|
||||||
@@ -11,11 +12,13 @@ const route = useRoute();
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const { t } = useAdminLocale();
|
const { t } = useAdminLocale();
|
||||||
|
const { allowed: smokeTestsAllowed, ensureLoaded: ensureSmokeTestsAllowed } = useSmokeTestsAllowed();
|
||||||
|
|
||||||
const sidebarOpen = ref(false);
|
const sidebarOpen = ref(false);
|
||||||
const isMobileNav = ref(false);
|
const isMobileNav = ref(false);
|
||||||
|
|
||||||
const adminMenus = computed(() => [
|
const adminMenus = computed(() => {
|
||||||
|
const items = [
|
||||||
{ path: '/', label: t('nav.dashboard'), icon: 'dashboard', matchPrefix: true },
|
{ path: '/', label: t('nav.dashboard'), icon: 'dashboard', matchPrefix: true },
|
||||||
{ path: '/matches', label: t('nav.matches'), icon: 'matches', matchPrefix: true },
|
{ path: '/matches', label: t('nav.matches'), icon: 'matches', matchPrefix: true },
|
||||||
{ path: '/users', label: t('nav.agents_players'), icon: 'users' },
|
{ path: '/users', label: t('nav.agents_players'), icon: 'users' },
|
||||||
@@ -27,7 +30,12 @@ const adminMenus = computed(() => [
|
|||||||
{ path: '/media', label: t('nav.media'), icon: 'media' },
|
{ path: '/media', label: t('nav.media'), icon: 'media' },
|
||||||
{ path: '/audit', label: t('nav.audit'), icon: 'audit' },
|
{ path: '/audit', label: t('nav.audit'), icon: 'audit' },
|
||||||
{ path: '/smoke-tests', label: t('nav.smoke_tests'), icon: 'smoke-tests' },
|
{ path: '/smoke-tests', label: t('nav.smoke_tests'), icon: 'smoke-tests' },
|
||||||
]);
|
];
|
||||||
|
if (smokeTestsAllowed.value === false) {
|
||||||
|
return items.filter((item) => item.path !== '/smoke-tests');
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
});
|
||||||
|
|
||||||
const agentMenus = computed(() => [
|
const agentMenus = computed(() => [
|
||||||
{ path: '/', label: t('nav.dashboard'), icon: 'dashboard' },
|
{ path: '/', label: t('nav.dashboard'), icon: 'dashboard' },
|
||||||
@@ -114,6 +122,9 @@ function logout() {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
syncMobileNav();
|
syncMobileNav();
|
||||||
window.addEventListener('resize', syncMobileNav);
|
window.addEventListener('resize', syncMobileNav);
|
||||||
|
if (auth.isAdmin.value) {
|
||||||
|
void ensureSmokeTestsAllowed();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router';
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
import { useAuthStore } from '../stores/auth';
|
import { useAuthStore } from '../stores/auth';
|
||||||
|
import { useSmokeTestsAllowed } from '../composables/useSmokeTestsAllowed';
|
||||||
import { ensureStaffSession } from '../utils/session-hydrate';
|
import { ensureStaffSession } from '../utils/session-hydrate';
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
@@ -107,7 +108,7 @@ const router = createRouter({
|
|||||||
{
|
{
|
||||||
path: 'smoke-tests',
|
path: 'smoke-tests',
|
||||||
component: () => import('../views/SmokeTests.vue'),
|
component: () => import('../views/SmokeTests.vue'),
|
||||||
meta: { adminOnly: true },
|
meta: { adminOnly: true, smokeTestsOnly: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'media',
|
path: 'media',
|
||||||
@@ -185,6 +186,12 @@ router.beforeEach(async (to) => {
|
|||||||
return '/';
|
return '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (to.meta.smokeTestsOnly) {
|
||||||
|
const { ensureLoaded, allowed } = useSmokeTestsAllowed();
|
||||||
|
await ensureLoaded();
|
||||||
|
if (!allowed.value) return '/';
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue';
|
import { ref, computed, onMounted } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
import { ElMessage } from 'element-plus';
|
import { ElMessage } from 'element-plus';
|
||||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||||
|
import { useSmokeTestsAllowed } from '../composables/useSmokeTestsAllowed';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
|
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
|
||||||
|
|
||||||
const { t, locale, localeTag } = useAdminLocale();
|
const { t, locale, localeTag } = useAdminLocale();
|
||||||
|
const router = useRouter();
|
||||||
|
const { allowed: smokeTestsAllowed, ensureLoaded: ensureSmokeTestsAllowed } = useSmokeTestsAllowed();
|
||||||
|
|
||||||
type SuiteInfo = { id: string; name: string; description: string; caseCount: number };
|
type SuiteInfo = { id: string; name: string; description: string; caseCount: number };
|
||||||
type CaseMeta = { id: string; suite: string; name: string; uatRef?: string };
|
type CaseMeta = { id: string; suite: string; name: string; uatRef?: string };
|
||||||
@@ -153,7 +157,14 @@ function statusTagType(status: string) {
|
|||||||
return 'info';
|
return 'info';
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadMeta);
|
onMounted(async () => {
|
||||||
|
await ensureSmokeTestsAllowed();
|
||||||
|
if (!smokeTestsAllowed.value) {
|
||||||
|
router.replace('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loadMeta();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -2299,9 +2299,16 @@ export class AdminController {
|
|||||||
return jsonResponse(messages);
|
return jsonResponse(messages);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('system/smoke-tests')
|
||||||
|
@RequirePermissions(P.settings)
|
||||||
|
getSmokeTestsStatus() {
|
||||||
|
return jsonResponse({ allowed: this.smokeTests.isAllowed() });
|
||||||
|
}
|
||||||
|
|
||||||
@Get('smoke-tests/suites')
|
@Get('smoke-tests/suites')
|
||||||
@RequirePermissions(P.settings)
|
@RequirePermissions(P.settings)
|
||||||
async smokeTestSuites() {
|
async smokeTestSuites() {
|
||||||
|
this.smokeTests.assertAllowed();
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
suites: this.smokeTests.listSuites(),
|
suites: this.smokeTests.listSuites(),
|
||||||
cases: this.smokeTests.listCases(),
|
cases: this.smokeTests.listCases(),
|
||||||
@@ -2312,6 +2319,7 @@ export class AdminController {
|
|||||||
@Get('smoke-tests/last-run')
|
@Get('smoke-tests/last-run')
|
||||||
@RequirePermissions(P.settings)
|
@RequirePermissions(P.settings)
|
||||||
async smokeTestLastRun() {
|
async smokeTestLastRun() {
|
||||||
|
this.smokeTests.assertAllowed();
|
||||||
return jsonResponse(this.smokeTests.getLastRun());
|
return jsonResponse(this.smokeTests.getLastRun());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2321,6 +2329,7 @@ export class AdminController {
|
|||||||
@CurrentUser('id') operatorId: bigint,
|
@CurrentUser('id') operatorId: bigint,
|
||||||
@Body() body: { suites?: string[] },
|
@Body() body: { suites?: string[] },
|
||||||
) {
|
) {
|
||||||
|
this.smokeTests.assertAllowed();
|
||||||
const summary = await this.smokeTests.run(body?.suites, operatorId);
|
const summary = await this.smokeTests.run(body?.suites, operatorId);
|
||||||
await this.audit.log({
|
await this.audit.log({
|
||||||
operatorId,
|
operatorId,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { appForbidden } from '../../../shared/common/app-error';
|
||||||
import { PrismaService } from '../../../shared/prisma/prisma.service';
|
import { PrismaService } from '../../../shared/prisma/prisma.service';
|
||||||
import { AgentsService } from '../../agent/agents.service';
|
import { AgentsService } from '../../agent/agents.service';
|
||||||
import { BetsService } from '../../betting/bets.service';
|
import { BetsService } from '../../betting/bets.service';
|
||||||
@@ -30,6 +31,17 @@ export class SmokeTestService {
|
|||||||
private agents: AgentsService,
|
private agents: AgentsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
isAllowed(): boolean {
|
||||||
|
if (process.env.ALLOW_SMOKE_TESTS === 'true') return true;
|
||||||
|
return process.env.NODE_ENV !== 'production';
|
||||||
|
}
|
||||||
|
|
||||||
|
assertAllowed() {
|
||||||
|
if (!this.isAllowed()) {
|
||||||
|
throw appForbidden('SMOKE_TESTS_FORBIDDEN');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
listSuites(): SmokeTestSuiteInfo[] {
|
listSuites(): SmokeTestSuiteInfo[] {
|
||||||
const counts = new Map<string, number>();
|
const counts = new Map<string, number>();
|
||||||
for (const c of SMOKE_TEST_CASES) {
|
for (const c of SMOKE_TEST_CASES) {
|
||||||
|
|||||||
@@ -519,6 +519,11 @@ export const API_ERROR_MESSAGES = {
|
|||||||
'en-US': 'Database reset forbidden in production (set ALLOW_DB_RESET=true)',
|
'en-US': 'Database reset forbidden in production (set ALLOW_DB_RESET=true)',
|
||||||
'ms-MY': 'Reset DB dilarang di produksi (set ALLOW_DB_RESET=true)',
|
'ms-MY': 'Reset DB dilarang di produksi (set ALLOW_DB_RESET=true)',
|
||||||
},
|
},
|
||||||
|
SMOKE_TESTS_FORBIDDEN: {
|
||||||
|
'zh-CN': '生产环境已禁用自动化测试',
|
||||||
|
'en-US': 'Smoke tests are disabled in production',
|
||||||
|
'ms-MY': 'Ujian asap dilumpuhkan di produksi',
|
||||||
|
},
|
||||||
CASHBACK_DATE_RANGE_INVALID: {
|
CASHBACK_DATE_RANGE_INVALID: {
|
||||||
'zh-CN': '开始日期不能晚于结束日期',
|
'zh-CN': '开始日期不能晚于结束日期',
|
||||||
'en-US': 'Start date cannot be after end date',
|
'en-US': 'Start date cannot be after end date',
|
||||||
|
|||||||
Reference in New Issue
Block a user