Merge PR #3: add association education links
Requested merge to main for user testing. Issue #2 remains open.
This commit was merged in pull request #3.
This commit is contained in:
commit
e6bc408c08
14 files changed
+490
-4
No files matched your search
@@ -0,0 +1,14 @@
|
||||
# 设计
|
||||
|
||||
沿用门户 edu.vue 布局、platforms.vue 卡片边框与主题变量,后台沿用 Element Plus 标签页和输入框。卡片复用后台配置的协会 LOGO,沿用现有图片+名称样式。
|
||||
|
||||
用户明确确认的原型:
|
||||
|
||||
- 门户 v2:https://www.figma.com/design/orfpx89Thcj7WO3hpqC8Si?node-id=5-316
|
||||
- 后台配置:https://www.figma.com/design/orfpx89Thcj7WO3hpqC8Si?node-id=7-20
|
||||
|
||||
独立站点配置键 association_education,JSON 保存 webUrl、mobileUrl,默认均为空。后台 GET/PUT /api/admin/edu/association,公开 GET /api/site/edu/association。写入要求有效工作人员与 edu:edit;读取后台要求有效工作人员和继续教育菜单。URL 仅接受完整 HTTP(S) 地址,允许本地开发地址,不将工单中的 localhost 固化为生产默认值。不请求目标地址。两个字段整体校验后原子保存。
|
||||
|
||||
门户 /edu/association:两个卡片始终存在,空地址显示暂未开放,无链接;加载与失败状态单独显示,允许重试;有效地址新窗口打开并设置 noopener noreferrer。
|
||||
|
||||
测试使用独立 SQLite 数据库验证配置持久化与鉴权。浏览器联调时新增接口使用同一业务路由与隔离 SQLite,既有接口转发到原服务;测试配置不写入共享 MySQL。构建在隔离 worktree 执行。验证结果与限制见 tasks.md。
|
||||
@@ -0,0 +1,5 @@
|
||||
# 协会教育入口
|
||||
|
||||
来源:https://gitea.aichengyan.com/cykj/gsfdc/issues/2 (2026-11-02 读取正文及全部评论,0 条评论,无图片)。
|
||||
|
||||
在继续教育下新增协会教育,固定提供 Web 端、移动端两个外链卡片;工作人员在现有后台继续教育页面配置地址。影响 portal、admin、server;不修改 edu 或教育平台。
|
||||
@@ -0,0 +1,8 @@
|
||||
# 验收条件
|
||||
|
||||
- 继续教育保留在线教育、教育公告,增加协会教育;导航选中和面包屑正确。
|
||||
- 协会教育固定显示 Web、移动端两个入口,响应式布局;可分别配置/清空地址。
|
||||
- 保存后重新读取可获得相同配置,门户跳转使用后台配置值,原在线教育数据不受影响。
|
||||
- 未配置不可跳转;加载失败可重试,保存失败保留输入。
|
||||
- 非 HTTP(S)、缺少主机、携带账号密码、非法端口和控制字符的地址被拒绝,拒绝时不部分保存。
|
||||
- 未登录、会员、停用工作人员、没有 edu:edit 的工作人员不能写入。
|
||||
@@ -0,0 +1,22 @@
|
||||
- [x] 工程接入、工单全文及占用检查
|
||||
- [x] 原型视觉核对与确认(门户 v2、后台配置均获用户明确批准)
|
||||
- [x] 后端配置与鉴权
|
||||
- [x] 门户入口与后台配置表单
|
||||
- [x] 任务范围测试、打包与视觉验证
|
||||
- [x] 独立代码审查
|
||||
- [ ] 远端 PR、集成验证及工单收尾(待授权及检查缺口处理)
|
||||
|
||||
## 验证记录(2026-11-02)
|
||||
|
||||
- `server`: `python -m pytest tests/test_association_education.py -q`,25 项通过。覆盖工作人员及角色权限、默认空值、保存/清空、非法地址、整体拒绝与原数据隔离。
|
||||
- 全量 `DATABASE_URL='' python -m pytest -q`:37 项通过、51 项因未连接共享数据库而跳过;跳过项不计为验收通过。
|
||||
- `portal`: `npm run build` 通过。
|
||||
- `admin`: `VITE_BASE_URL=/ vite build` 通过;本次三个改动文件 ESLint 通过。
|
||||
- 后台完整 `npm run build` 被 32 项原有 TypeScript 错误阻止,与主工作目录基线输出逐项相同。全量 ESLint 存在历史格式问题;基线 129 项,本任务首次检查 126 项,其中新增的控制字符正则规则问题已修复,其他均为既有格式问题。不能据此声称完整检查通过。
|
||||
- 实际浏览器验证:保存 Web/移动地址、刷新后回显、非法协议拒绝且原配置保持、门户读取新配置、两个入口在新窗口打开对应地址。桌面及 390×844 手机视口视觉检查通过,无横向页面溢出;原在线教育和教育公告保持。
|
||||
- 新增配置联调使用隔离 SQLite,未验证 MySQL 写入;未进行生产部署或教育平台内部业务验收。
|
||||
- 独立审查由 `review_issue2` 完成,未发现阻塞代码问题;提出的构建和浏览器验证已补齐,MySQL 验证缺口保留。
|
||||
- 工单交付前重读正文及全部评论:仍开放,0 条评论,需求无新增。
|
||||
- 联调结束后已恢复原主工作目录的门户 13000、后台 13006、后端 18000;核对三个进程工作目录及 HTTP 响应,释放共享测试环境占用。任务实现保存在 worktree,尚未替换主版本。
|
||||
|
||||
本地证据位于主工作目录 `.local/agent/tasks/issue-2/`:`portal-build.log`、`admin-vite-build.log`、`admin-build.log`、`admin-baseline-types.log`、`admin-lint.log`、`admin-baseline-lint.log`、`portal-actual.jpg`、`portal-mobile.jpg`、`admin-actual.jpg`。不包含账号密码。
|
||||
@@ -75,3 +75,16 @@ export function saveRelatedLinks(data: Record<string, unknown>) {
|
||||
export function saveCompliance(data: Record<string, unknown>) {
|
||||
return request.put({ url: '/api/admin/home/compliance', data })
|
||||
}
|
||||
|
||||
export interface AssociationEducationLinks {
|
||||
webUrl: string
|
||||
mobileUrl: string
|
||||
}
|
||||
|
||||
export function fetchAssociationEducation() {
|
||||
return request.get<AssociationEducationLinks>({ url: '/api/admin/edu/association' })
|
||||
}
|
||||
|
||||
export function saveAssociationEducation(data: AssociationEducationLinks) {
|
||||
return request.put({ url: '/api/admin/edu/association', data })
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<div v-loading="loading" class="min-h-40">
|
||||
<ElAlert v-if="failed" title="暂时无法读取协会教育配置" type="error" :closable="false">
|
||||
<ElButton link type="primary" @click="load">重试</ElButton>
|
||||
</ElAlert>
|
||||
<template v-else-if="ready">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||
<p class="m-0 text-sm text-g-600">配置协会教育平台入口,门户点击后在新窗口打开。</p>
|
||||
<ElButton v-if="canEdit" type="primary" :loading="saving" @click="save">保存</ElButton>
|
||||
</div>
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
:model="links"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
:disabled="!canEdit || saving"
|
||||
class="association-form"
|
||||
>
|
||||
<ElFormItem label="Web 端地址" prop="webUrl">
|
||||
<ElInput v-model="links.webUrl" placeholder="https://" maxlength="2048" clearable />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="移动端地址" prop="mobileUrl">
|
||||
<ElInput v-model="links.mobileUrl" placeholder="https://" maxlength="2048" clearable />
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<p class="m-0 text-sm text-g-600">留空时,门户对应入口显示“暂未开放”。</p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { fetchAssociationEducation, saveAssociationEducation } from '@/api/home'
|
||||
import { useAuth } from '@/hooks/core/useAuth'
|
||||
|
||||
const { hasAuth } = useAuth()
|
||||
const canEdit = computed(() => hasAuth('edu:edit'))
|
||||
const formRef = ref<FormInstance>()
|
||||
const links = reactive({ webUrl: '', mobileUrl: '' })
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const ready = ref(false)
|
||||
const failed = ref(false)
|
||||
|
||||
const validateUrl = (_rule: unknown, value: string, callback: (error?: Error) => void) => {
|
||||
const text = value.trim()
|
||||
if (!text) return callback()
|
||||
try {
|
||||
const url = new URL(text)
|
||||
if (
|
||||
!/^https?:\/\//i.test(text) ||
|
||||
!url.hostname ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
/[\s\\]/u.test(text) ||
|
||||
Array.from(text).some((char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127) ||
|
||||
url.port === '0'
|
||||
)
|
||||
throw new Error()
|
||||
callback()
|
||||
} catch {
|
||||
callback(new Error('请填写完整的 http:// 或 https:// 网址,不包含账号密码'))
|
||||
}
|
||||
}
|
||||
const rules: FormRules = {
|
||||
webUrl: [{ validator: validateUrl, trigger: 'blur' }],
|
||||
mobileUrl: [{ validator: validateUrl, trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const load = async () => {
|
||||
loading.value = true
|
||||
failed.value = false
|
||||
try {
|
||||
Object.assign(links, await fetchAssociationEducation())
|
||||
ready.value = true
|
||||
} catch {
|
||||
failed.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
const save = async () => {
|
||||
if (!canEdit.value || saving.value || !(await formRef.value?.validate().catch(() => false)))
|
||||
return
|
||||
saving.value = true
|
||||
try {
|
||||
const data = { webUrl: links.webUrl.trim(), mobileUrl: links.mobileUrl.trim() }
|
||||
await saveAssociationEducation(data)
|
||||
Object.assign(links, data)
|
||||
ElMessage.success('已保存')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.association-form {
|
||||
max-width: 600px;
|
||||
}
|
||||
</style>
|
||||
@@ -3,8 +3,12 @@
|
||||
<ElTabs v-if="ready" v-model="activeTab" type="border-card">
|
||||
<ElTabPane label="在线教育" name="platforms">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||
<p class="m-0 text-sm text-g-600">每张卡片一张图,图下是教育网站名称。门户点击后在新窗口打开网址。</p>
|
||||
<ElButton v-auth="'edu:edit'" type="primary" :loading="saving" @click="save">保存</ElButton>
|
||||
<p class="m-0 text-sm text-g-600"
|
||||
>每张卡片一张图,图下是教育网站名称。门户点击后在新窗口打开网址。</p
|
||||
>
|
||||
<ElButton v-auth="'edu:edit'" type="primary" :loading="saving" @click="save"
|
||||
>保存</ElButton
|
||||
>
|
||||
</div>
|
||||
<div class="media-grid">
|
||||
<article v-for="(item, index) in items" :key="index" class="media-card">
|
||||
@@ -14,7 +18,12 @@
|
||||
@pick="(payload) => assignImage(item, payload)"
|
||||
@clear="clearImage(item)"
|
||||
/>
|
||||
<ElInput v-model="item.name" class="mt-3" :disabled="!canEdit" placeholder="教育网站名称" />
|
||||
<ElInput
|
||||
v-model="item.name"
|
||||
class="mt-3"
|
||||
:disabled="!canEdit"
|
||||
placeholder="教育网站名称"
|
||||
/>
|
||||
<ElInput v-model="item.url" class="mt-2" :disabled="!canEdit" placeholder="https://" />
|
||||
<div class="mt-3 flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -26,7 +35,9 @@
|
||||
controls-position="right"
|
||||
/>
|
||||
</div>
|
||||
<ElButton v-if="canEdit" link type="danger" @click="items.splice(index, 1)">删除</ElButton>
|
||||
<ElButton v-if="canEdit" link type="danger" @click="items.splice(index, 1)"
|
||||
>删除</ElButton
|
||||
>
|
||||
</div>
|
||||
</article>
|
||||
<button v-if="canEdit" type="button" class="media-add" @click="addItem">
|
||||
@@ -43,11 +54,15 @@
|
||||
enable-cover
|
||||
/>
|
||||
</ElTabPane>
|
||||
<ElTabPane label="协会教育" name="association">
|
||||
<AssociationEducation v-if="activeTab === 'association'" />
|
||||
</ElTabPane>
|
||||
</ElTabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AssociationEducation from './components/AssociationEducation.vue'
|
||||
import AboutColumnEditor from '@/views/about/components/AboutColumnEditor.vue'
|
||||
import { fetchEduPlatforms, saveEduPlatforms } from '@/api/home'
|
||||
import { useAuth } from '@/hooks/core/useAuth'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const EDU_SECTIONS = [
|
||||
{ key: "platforms", label: "在线教育", path: "/edu/platforms" },
|
||||
{ key: "notices", label: "教育公告", path: "/edu/notices" },
|
||||
{ key: "association", label: "协会教育", path: "/edu/association" },
|
||||
] as const
|
||||
@@ -28,6 +28,7 @@ const { home, logoUrl, ensureChrome } = useSiteChrome()
|
||||
|
||||
const activeKey = computed(() => {
|
||||
const path = route.path
|
||||
if (path.startsWith("/edu/association")) return "association"
|
||||
if (path.startsWith("/edu/notices")) return "notices"
|
||||
return "platforms"
|
||||
})
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="page-title">协会教育</h2>
|
||||
<p v-if="loading" class="hint" role="status">列表加载中…</p>
|
||||
<p v-else-if="failed" class="placeholder" role="alert">
|
||||
暂时无法读取协会教育入口<button class="retry" type="button" @click="load">重试</button>
|
||||
</p>
|
||||
<ul v-else class="cards">
|
||||
<li v-for="item in items" :key="item.key">
|
||||
<component :is="item.url ? 'a' : 'div'" :href="item.url || undefined"
|
||||
:target="item.url ? '_blank' : undefined" :rel="item.url ? 'noopener noreferrer' : undefined"
|
||||
:class="{ unavailable: !item.url }">
|
||||
<img v-if="logoUrl" :src="logoUrl" alt="协会标志" />
|
||||
<div v-else class="card-image" aria-hidden="true" />
|
||||
<span>{{ item.name }}</span>
|
||||
<small v-if="!item.url">暂未开放</small>
|
||||
</component>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { get } = useSiteApi()
|
||||
const { logoUrl } = useSiteChrome()
|
||||
const loading = ref(true)
|
||||
const failed = ref(false)
|
||||
const links = ref({ webUrl: "", mobileUrl: "" })
|
||||
const items = computed(() => [
|
||||
{ key: "web", name: "协会教育(Web 端)", url: links.value.webUrl },
|
||||
{ key: "mobile", name: "协会教育(移动端)", url: links.value.mobileUrl },
|
||||
])
|
||||
async function load() {
|
||||
loading.value = true
|
||||
failed.value = false
|
||||
try {
|
||||
links.value = await get<typeof links.value>("/edu/association")
|
||||
} catch {
|
||||
failed.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
await load()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-title {
|
||||
margin: 0 0 18px;
|
||||
font-size: 22px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.placeholder {
|
||||
margin: 0;
|
||||
padding: 48px 20px;
|
||||
text-align: center;
|
||||
color: var(--muted, #5b6b85);
|
||||
font-size: 15px;
|
||||
border: 1px dashed var(--line, #d6e4ff);
|
||||
}
|
||||
.hint {
|
||||
margin: 12px 0 0;
|
||||
text-align: center;
|
||||
color: var(--muted, #5b6b85);
|
||||
font-size: 14px;
|
||||
}
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 16px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.cards a,
|
||||
.cards .unavailable {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
border: 1px solid var(--line, #d6e4ff);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: var(--chip, #f7faff);
|
||||
}
|
||||
.cards img,
|
||||
.card-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
object-fit: contain;
|
||||
background: #fff;
|
||||
}
|
||||
.cards span {
|
||||
display: block;
|
||||
padding: 12px 10px 14px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.45;
|
||||
text-align: center;
|
||||
}
|
||||
.cards a:hover span {
|
||||
color: var(--c, #1677ff);
|
||||
}
|
||||
|
||||
.cards .unavailable { color: var(--muted, #5b6b85); }
|
||||
.cards small { display: block; padding: 0 10px 12px; text-align: center; }
|
||||
.cards a:focus-visible { outline: 2px solid var(--c, #1677ff); outline-offset: 3px; }
|
||||
.retry { margin-left: 8px; border: 1px solid var(--line, #d6e4ff); border-radius: 4px; background: var(--surface, #fff); color: var(--c, #1677ff); padding: 4px 10px; cursor: pointer; }
|
||||
</style>
|
||||
@@ -32,3 +32,53 @@ def write_edu_platforms(body: EduPlatformsWrite, db: Session = Depends(get_db))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
class AssociationEducationWrite(BaseModel):
|
||||
webUrl: str = Field(max_length=2048)
|
||||
mobileUrl: str = Field(max_length=2048)
|
||||
|
||||
|
||||
def _association_access(db: Session, principal: dict, *, write: bool = False) -> None:
|
||||
from app.models.rbac import AdminUser
|
||||
from app.services.rbac import granted_resources
|
||||
|
||||
try:
|
||||
user_id = int(principal.get("sub", ""))
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=401, detail="登录已失效") from None
|
||||
user = db.get(AdminUser, user_id)
|
||||
if user is None or user.status != "active":
|
||||
raise HTTPException(status_code=401, detail="登录已失效")
|
||||
resources = granted_resources(db, user_id)
|
||||
allowed = any(
|
||||
(item.type == "button" and item.permission == "edu:edit") if write
|
||||
else (item.type == "menu" and item.path == "/edu")
|
||||
for item in resources
|
||||
)
|
||||
if not allowed:
|
||||
raise HTTPException(status_code=403, detail="没有继续教育操作权限")
|
||||
|
||||
|
||||
@router.get("/edu/association")
|
||||
def read_association(db: Session = Depends(get_db), principal: dict = Depends(require_staff)) -> dict:
|
||||
from app.services.association_education import read_links
|
||||
|
||||
_association_access(db, principal)
|
||||
return read_links(db)
|
||||
|
||||
|
||||
@router.put("/edu/association")
|
||||
def write_association(
|
||||
body: AssociationEducationWrite,
|
||||
db: Session = Depends(get_db),
|
||||
principal: dict = Depends(require_staff),
|
||||
) -> dict:
|
||||
from app.services.association_education import save_links
|
||||
|
||||
_association_access(db, principal, write=True)
|
||||
try:
|
||||
save_links(db, body.webUrl, body.mobileUrl)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return {"ok": True}
|
||||
@@ -10,3 +10,10 @@ router = APIRouter()
|
||||
@router.get("/edu/platforms")
|
||||
def read_edu_platforms(db: Session = Depends(get_db)) -> dict:
|
||||
return public_edu_platforms(db)
|
||||
|
||||
|
||||
@router.get("/edu/association")
|
||||
def read_association(db: Session = Depends(get_db)) -> dict:
|
||||
from app.services.association_education import read_links
|
||||
|
||||
return read_links(db)
|
||||
@@ -0,0 +1,49 @@
|
||||
"""协会教育只配置两个外链,不承载学习业务。"""
|
||||
import json
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.content import SiteSetting
|
||||
|
||||
SETTING_KEY = "association_education"
|
||||
|
||||
|
||||
def validate_url(value: str) -> str:
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return ""
|
||||
try:
|
||||
parsed = urlsplit(text)
|
||||
valid = (
|
||||
len(text) <= 2048
|
||||
and parsed.scheme.lower() in {"http", "https"}
|
||||
and bool(parsed.hostname)
|
||||
and parsed.username is None
|
||||
and parsed.password is None
|
||||
and "\\" not in text
|
||||
and not any(char.isspace() or ord(char) < 32 or ord(char) == 127 for char in text)
|
||||
and (parsed.port is None or 1 <= parsed.port <= 65535)
|
||||
)
|
||||
except ValueError:
|
||||
valid = False
|
||||
if not valid:
|
||||
raise ValueError("请填写完整的 http:// 或 https:// 网址,不包含账号密码")
|
||||
return text
|
||||
|
||||
|
||||
def read_links(db: Session) -> dict[str, str]:
|
||||
row = db.scalar(select(SiteSetting).where(SiteSetting.key == SETTING_KEY))
|
||||
stored = json.loads(row.value) if row else {}
|
||||
return {key: stored.get(key, "") for key in ("webUrl", "mobileUrl")}
|
||||
|
||||
|
||||
def save_links(db: Session, web_url: str, mobile_url: str) -> None:
|
||||
value = json.dumps({"webUrl": validate_url(web_url), "mobileUrl": validate_url(mobile_url)})
|
||||
row = db.scalar(select(SiteSetting).where(SiteSetting.key == SETTING_KEY))
|
||||
if row is None:
|
||||
db.add(SiteSetting(key=SETTING_KEY, value=value))
|
||||
else:
|
||||
row.value = value
|
||||
db.commit()
|
||||
@@ -0,0 +1,87 @@
|
||||
"""配置持久化和鉴权使用独立 SQLite 数据库,不连接业务库。"""
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from app.api.admin.edu import router as admin_router
|
||||
from app.api.site.edu import router as site_router
|
||||
from app.core.config import get_settings
|
||||
from app.core.security import create_access_token
|
||||
from app.db import Base, get_db
|
||||
from app.models.content import SiteSetting
|
||||
from app.models.rbac import AdminUser, AdminRole, AdminResource, AdminUserRole, AdminRoleResource
|
||||
from app.services.association_education import validate_url
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
monkeypatch.setenv('JWT_SECRET', 'test-secret-must-be-32-characters!')
|
||||
get_settings.cache_clear()
|
||||
engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(engine, tables=[m.__table__ for m in (SiteSetting, AdminUser, AdminRole, AdminResource, AdminUserRole, AdminRoleResource)])
|
||||
with Session(engine) as db:
|
||||
db.add_all([AdminUser(id=i, username=f'user{i}', password_hash='unused', display_name='测试', status='disabled' if i == 3 else 'active') for i in (1,2,3,4)])
|
||||
db.add_all([AdminRole(id=1, code='editor', name='教育编辑'), AdminRole(id=2, code='reader', name='只读')])
|
||||
db.add_all([AdminResource(id=1, type='menu', name='edu', title='继续教育', path='/edu'), AdminResource(id=2, type='button', name='edu:edit', title='编辑', permission='edu:edit')])
|
||||
db.flush()
|
||||
db.add_all([AdminUserRole(user_id=i, role_id=1) for i in (1,3)])
|
||||
db.add_all([AdminRoleResource(role_id=1, resource_id=i) for i in (1,2)])
|
||||
db.add(AdminUserRole(user_id=4, role_id=2))
|
||||
db.add(AdminRoleResource(role_id=2, resource_id=1))
|
||||
db.add(SiteSetting(key='unrelated',value='preserve'))
|
||||
db.commit()
|
||||
def database():
|
||||
with Session(engine) as db:
|
||||
yield db
|
||||
app=FastAPI()
|
||||
app.include_router(admin_router,prefix='/api/admin')
|
||||
app.include_router(site_router,prefix='/api/site')
|
||||
app.dependency_overrides[get_db]=database
|
||||
with TestClient(app) as api:
|
||||
yield api
|
||||
with Session(engine) as db:
|
||||
assert db.query(SiteSetting).filter_by(key='unrelated').one().value == 'preserve'
|
||||
engine.dispose()
|
||||
get_settings.cache_clear()
|
||||
|
||||
def headers(user=1,kind='staff'):
|
||||
return {'Authorization':f'Bearer {create_access_token(str(user),kind)}'}
|
||||
|
||||
def test_round_trip_atomic_rejection_and_clear(client):
|
||||
public='/api/site/edu/association'
|
||||
admin='/api/admin/edu/association'
|
||||
assert client.get(public).json()=={'webUrl':'','mobileUrl':''}
|
||||
data={'webUrl':'http://127.0.0.1:9800','mobileUrl':'http://127.0.0.1:9801'}
|
||||
assert client.put(admin,headers=headers(),json=data).status_code==200
|
||||
assert client.get(public).json()==data
|
||||
assert client.get(admin,headers=headers()).json()==data
|
||||
assert client.put(admin,headers=headers(),json={'webUrl':'https://example.com/new','mobileUrl':'javascript:alert(1)'}).status_code==400
|
||||
assert client.get(public).json()==data
|
||||
assert client.put(admin,headers=headers(),json={'webUrl':'','mobileUrl':'https://example.com/m'}).status_code==200
|
||||
assert client.get(public).json()=={'webUrl':'','mobileUrl':'https://example.com/m'}
|
||||
|
||||
@pytest.mark.parametrize('auth',[None,(1,'member'),(2,'staff'),(3,'staff'),(99,'staff')])
|
||||
def test_authorization(client,auth):
|
||||
kw={'headers':headers(*auth)} if auth else {}
|
||||
assert client.get('/api/admin/edu/association',**kw).status_code in (401,403)
|
||||
assert client.put('/api/admin/edu/association',json={'webUrl':'https://example.com','mobileUrl':''},**kw).status_code in (401,403)
|
||||
assert client.get('/api/site/edu/association').json()=={'webUrl':'','mobileUrl':''}
|
||||
|
||||
@pytest.mark.parametrize('url',['javascript:alert(1)','//example.com','https://','https://a:99999','https://a:bad','https://user:password@example.com','https://a\\@b','https://a\nb','data:text/html,test','https://exa mple.com'])
|
||||
def test_invalid_url(url):
|
||||
with pytest.raises(ValueError):validate_url(url)
|
||||
|
||||
@pytest.mark.parametrize('url',['','https://example.com/path?q=中文#section','http://127.0.0.1:9800','https://[::1]:9801'])
|
||||
def test_valid_url(url):
|
||||
assert validate_url(url)==url
|
||||
|
||||
|
||||
def test_read_only_staff_cannot_write(client):
|
||||
assert client.get('/api/admin/edu/association',headers=headers(4)).status_code == 200
|
||||
assert client.put('/api/admin/edu/association',headers=headers(4),json={'webUrl':'https://example.com','mobileUrl':''}).status_code == 403
|
||||
|
||||
@pytest.mark.parametrize('data',[{}, {'webUrl':''}, {'webUrl':'x'*2049,'mobileUrl':''}, {'webUrl':None,'mobileUrl':''}])
|
||||
def test_invalid_payload_does_not_change_settings(client,data):
|
||||
assert client.put('/api/admin/edu/association',headers=headers(),json=data).status_code == 422
|
||||
assert client.get('/api/site/edu/association').json() == {'webUrl':'','mobileUrl':''}
|
||||
Reference in new issue
Block a user