feat: 普通用户可维护应用版本号

This commit is contained in:
shenlei 2026-07-20 15:42:52 +09:00
parent 51d7374f2d
commit 89fb633b00
4 changed files with 73 additions and 39 deletions

View File

@ -25,9 +25,10 @@ from ..deps import get_current_user, require_admin
def _require_config_permission(request: Request, user: dict = Depends(get_current_user)) -> dict: def _require_config_permission(request: Request, user: dict = Depends(get_current_user)) -> dict:
"""普通用户可管理 Apps其余配置及凭据只允许管理员访问。""" """普通用户可管理 Apps 与版本号;其余配置及凭据只允许管理员访问。"""
path = request.url.path.rstrip("/") path = request.url.path.rstrip("/")
if path == "/api/config/apps" or path.startswith("/api/config/apps/"): if (path == "/api/config/apps" or path.startswith("/api/config/apps/")
or path == "/api/config/versions"):
return user return user
return require_admin(user) return require_admin(user)

View File

@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils' import { mount, flushPromises } from '@vue/test-utils'
import { ref } from 'vue'
import ConfigView from '../views/ConfigView.vue' import ConfigView from '../views/ConfigView.vue'
global.fetch = vi.fn() global.fetch = vi.fn()
@ -23,6 +24,7 @@ function mockFetch(url) {
'/api/config/servers': { '测试环境': { api: 'https://test.com', assDom: '', universalLink: '' } }, '/api/config/servers': { '测试环境': { api: 'https://test.com', assDom: '', universalLink: '' } },
'/api/config/branches': ['main', 'dev'], '/api/config/branches': ['main', 'dev'],
'/api/config/build': { max_concurrent_builds: 2, build_dir_retention_hours: 24, build_base_dir: '/tmp' }, '/api/config/build': { max_concurrent_builds: 2, build_dir_retention_hours: 24, build_base_dir: '/tmp' },
'/api/config/versions': { app_ver: '2.195.0', build_ver: '2.195.0.0' },
'/api/config': { apps: {}, schemes: {}, branches: ['main'] }, '/api/config': { apps: {}, schemes: {}, branches: ['main'] },
} }
return Promise.resolve({ return Promise.resolve({
@ -30,6 +32,17 @@ function mockFetch(url) {
}) })
} }
const mountConfigView = ({ loggedIn = true, admin = true } = {}) => mount(ConfigView, {
global: {
provide: {
showLogin: ref(false),
getToken: () => '',
isLoggedIn: ref(loggedIn),
isAdmin: ref(admin),
},
},
})
describe('ConfigView.vue', () => { describe('ConfigView.vue', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
@ -39,21 +52,19 @@ describe('ConfigView.vue', () => {
}) })
it('未登录时显示权限提示', async () => { it('未登录时显示权限提示', async () => {
const wrapper = mount(ConfigView) const wrapper = mountConfigView({ loggedIn: false })
await flushPromises() await flushPromises()
expect(wrapper.text()).toContain('需要管理员权限') expect(wrapper.text()).toContain('请先登录')
}) })
it('已登录时显示管理页面', async () => { it('已登录时显示管理页面', async () => {
localStorageMock.getItem.mockReturnValue('true') const wrapper = mountConfigView()
const wrapper = mount(ConfigView)
await flushPromises() await flushPromises()
expect(wrapper.text()).toContain('配置管理') expect(wrapper.text()).toContain('配置管理')
}) })
it('显示侧边栏菜单', async () => { it('显示侧边栏菜单', async () => {
localStorageMock.getItem.mockReturnValue('true') const wrapper = mountConfigView()
const wrapper = mount(ConfigView)
await flushPromises() await flushPromises()
const menuItems = wrapper.findAll('.sidebar-menu li') const menuItems = wrapper.findAll('.sidebar-menu li')
@ -65,16 +76,14 @@ describe('ConfigView.vue', () => {
expect(texts).toContain('打包设置') expect(texts).toContain('打包设置')
}) })
it('默认显示服务器环境 tab', async () => { it('默认显示 Apps 配置 tab', async () => {
localStorageMock.getItem.mockReturnValue('true') const wrapper = mountConfigView()
const wrapper = mount(ConfigView)
await flushPromises() await flushPromises()
expect(wrapper.text()).toContain('服务器环境配置') expect(wrapper.text()).toContain('Apps 配置')
}) })
it('切换到分支管理 tab', async () => { it('切换到分支管理 tab', async () => {
localStorageMock.getItem.mockReturnValue('true') const wrapper = mountConfigView()
const wrapper = mount(ConfigView)
await flushPromises() await flushPromises()
const branchesMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '分支管理') const branchesMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '分支管理')
@ -93,8 +102,7 @@ describe('ConfigView.vue', () => {
return mockFetch(url) return mockFetch(url)
}) })
localStorageMock.getItem.mockReturnValue('true') const wrapper = mountConfigView()
const wrapper = mount(ConfigView)
await flushPromises() await flushPromises()
const branchesMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '分支管理') const branchesMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '分支管理')
@ -122,8 +130,7 @@ describe('ConfigView.vue', () => {
return mockFetch(url) return mockFetch(url)
}) })
localStorageMock.getItem.mockReturnValue('true') const wrapper = mountConfigView()
const wrapper = mount(ConfigView)
await flushPromises() await flushPromises()
const branchesMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '分支管理') const branchesMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '分支管理')
@ -147,8 +154,7 @@ describe('ConfigView.vue', () => {
}) })
window.alert = vi.fn() window.alert = vi.fn()
localStorageMock.getItem.mockReturnValue('true') const wrapper = mountConfigView()
const wrapper = mount(ConfigView)
await flushPromises() await flushPromises()
const buildMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '打包设置') const buildMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '打包设置')
@ -161,4 +167,20 @@ describe('ConfigView.vue', () => {
expect(window.alert).toHaveBeenCalledWith('设置已保存') expect(window.alert).toHaveBeenCalledWith('设置已保存')
}) })
it('普通账号可修改版本号但看不到打包参数', async () => {
const wrapper = mountConfigView({ admin: false })
await flushPromises()
const menuTexts = wrapper.findAll('.sidebar-menu li').map(li => li.text())
expect(menuTexts).toEqual(['Apps 配置', '打包设置'])
const buildMenu = wrapper.findAll('.sidebar-menu li').find(li => li.text() === '打包设置')
await buildMenu.trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('应用版本号')
expect(wrapper.text()).not.toContain('打包参数')
expect(wrapper.findAll('input').length).toBe(2)
})
}) })

View File

@ -15,7 +15,7 @@
<li v-if="isAdmin" :class="{ active: tab === 'schemes' }" @click="tab = 'schemes'">Schemes 配置</li> <li v-if="isAdmin" :class="{ active: tab === 'schemes' }" @click="tab = 'schemes'">Schemes 配置</li>
<li v-if="isAdmin" :class="{ active: tab === 'branches' }" @click="tab = 'branches'">分支管理</li> <li v-if="isAdmin" :class="{ active: tab === 'branches' }" @click="tab = 'branches'">分支管理</li>
<li v-if="isAdmin" :class="{ active: tab === 'upload' }" @click="tab = 'upload'">上传配置</li> <li v-if="isAdmin" :class="{ active: tab === 'upload' }" @click="tab = 'upload'">上传配置</li>
<li v-if="isAdmin" :class="{ active: tab === 'build' }" @click="tab = 'build'">打包设置</li> <li :class="{ active: tab === 'build' }" @click="tab = 'build'">打包设置</li>
<li v-if="isAdmin" :class="{ active: tab === 'json' }" @click="tab = 'json'">JSON 编辑</li> <li v-if="isAdmin" :class="{ active: tab === 'json' }" @click="tab = 'json'">JSON 编辑</li>
</ul> </ul>
</div> </div>
@ -280,21 +280,23 @@
</div> </div>
<button class="btn btn-primary" style="width: auto; padding: 10px 32px; margin-bottom: 24px;" @click="saveVersions">保存版本号</button> <button class="btn btn-primary" style="width: auto; padding: 10px 32px; margin-bottom: 24px;" @click="saveVersions">保存版本号</button>
<h4 class="section-title">打包参数</h4> <template v-if="isAdmin">
<div class="form-group"> <h4 class="section-title">打包参数</h4>
<label>最大并行打包数</label> <div class="form-group">
<input type="number" v-model.number="buildSettings.max_concurrent_builds" min="1" max="4"> <label>最大并行打包数</label>
<div style="font-size: 12px; color: #999; margin-top: 4px;">建议 1-4过高可能影响构建稳定性</div> <input type="number" v-model.number="buildSettings.max_concurrent_builds" min="1" max="4">
</div> <div style="font-size: 12px; color: #999; margin-top: 4px;">建议 1-4过高可能影响构建稳定性</div>
<div class="form-group"> </div>
<label>打包目录保留时间小时</label> <div class="form-group">
<input type="number" v-model.number="buildSettings.build_dir_retention_hours" min="1"> <label>打包目录保留时间小时</label>
</div> <input type="number" v-model.number="buildSettings.build_dir_retention_hours" min="1">
<div class="form-group"> </div>
<label>打包基础目录</label> <div class="form-group">
<input type="text" v-model="buildSettings.build_base_dir"> <label>打包基础目录</label>
</div> <input type="text" v-model="buildSettings.build_base_dir">
<button class="btn btn-primary" style="width: auto; padding: 10px 32px;" @click="saveBuildSettings">保存设置</button> </div>
<button class="btn btn-primary" style="width: auto; padding: 10px 32px;" @click="saveBuildSettings">保存设置</button>
</template>
</div> </div>
</div> </div>
@ -612,8 +614,12 @@ onMounted(async () => {
const loadData = async () => { const loadData = async () => {
if (!isAdmin.value) { if (!isAdmin.value) {
const appsRes = await authFetch('/api/config/apps') const [appsRes, versionsRes] = await Promise.all([
authFetch('/api/config/apps'),
authFetch('/api/config/versions'),
])
if (appsRes.ok) apps.value = await appsRes.json() if (appsRes.ok) apps.value = await appsRes.json()
if (versionsRes.ok) versions.value = await versionsRes.json()
return return
} }

View File

@ -37,8 +37,8 @@ def test_production_rejects_default_security_settings(monkeypatch):
config.validate_production_security() config.validate_production_security()
def test_config_routes_limit_regular_users_to_apps(client, tmp_config): def test_config_routes_limit_regular_users_to_apps_and_versions(client, tmp_config):
"""普通账号只能管理 Apps不能读取或修改包含密钥的配置。""" """普通账号可管理 Apps 与版本号,不能读取或修改管理员配置。"""
response = client.post("/api/users", json={ response = client.post("/api/users", json={
"username": "builder", "username": "builder",
"password": "builder-password-123", "password": "builder-password-123",
@ -55,6 +55,11 @@ def test_config_routes_limit_regular_users_to_apps(client, tmp_config):
assert client.get("/api/config/apps", headers=headers).status_code == 200 assert client.get("/api/config/apps", headers=headers).status_code == 200
assert client.post("/api/config/apps", json={"name": "普通用户 App"}, headers=headers).status_code == 200 assert client.post("/api/config/apps", json={"name": "普通用户 App"}, headers=headers).status_code == 200
assert client.get("/api/config/versions", headers=headers).status_code == 200
assert client.put("/api/config/versions", json={
"app_ver": "2.196.0", "build_ver": "2.196.0.0",
}, headers=headers).status_code == 200
assert client.get("/api/config", headers=headers).status_code == 403 assert client.get("/api/config", headers=headers).status_code == 403
assert client.get("/api/config/servers", headers=headers).status_code == 403 assert client.get("/api/config/servers", headers=headers).status_code == 403
assert client.put("/api/config/build", json={"max_concurrent_builds": 1}, headers=headers).status_code == 403
assert client.put("/api/config/upload", json={"mode": "oss"}, headers=headers).status_code == 403 assert client.put("/api/config/upload", json={"mode": "oss"}, headers=headers).status_code == 403