fix: 终审修复——站点停用接线/prefix查重补创建reset/默认凭据警示+取舍补录
- I-1 站点停用全链接线: Site 数据类加 status 字段; DB 源 _load_one 与 文件源 read_tenant 均只输出 active 站点(停用站点域名不进 registry 索引 → 该域名 404;全停用=合法运营状态输出空 sites;文件源无 status 键默认 active 兼容)。E2E: PATCH 站点 disabled → 其域名 404、复启用 恢复;源级直测两源过滤。 - I-3 prefix 跨租户查重补两口: db.other_push_prefixes 共用助手 (MAX(version) 逐租户当前前缀,排除自身);创建向导(出厂档案前缀被占 → 400 提示改 blank/改占用方)与 reset push(重置回出厂撞车 → 400 提示 走 PUT)接入;PUT 原有查重改用同一助手。rollback 维持豁免(历史不可变 语义优先,取舍清单记档)。既有测试第二租户改 blank profile 避撞。 - I-4 默认凭据警示: lifespan 启动检测 ADMIN_PASSWORD 仍为 Settings 字段 默认值(非硬编码第二份,防双源漂移)→ logger.warning 醒目提示; CLAUDE.md Phase 4 部署记档补 ADMIN_PASSWORD 生产要求。 - I-2/M-2 取舍补录: 密钥掩码未实现(GET config/settings 明文返回 api_key/smtp 密码,Phase 3 前须定 __SET__ 哨兵方案);站点管理权限现为 仅超管(Phase 3 对接时决策放开或改 spec);取舍清单第 2 条同步刷新 (rollback 现为唯一豁免口)。 全量回归: 571 passed(基线 566 + 新增 5)。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -77,7 +77,7 @@
|
|||||||
**实现取舍(API 冻结 / Phase 3 前端对接前须知)**:
|
**实现取舍(API 冻结 / Phase 3 前端对接前须知)**:
|
||||||
|
|
||||||
1. **PUT config 整组替换语义**——前端必须整组读-改-写(Task 6;做部分更新需先 GET 全组再改再 PUT)。
|
1. **PUT config 整组替换语义**——前端必须整组读-改-写(Task 6;做部分更新需先 GET 全组再改再 PUT)。
|
||||||
2. **rollback 绕过 prefix 跨租户查重**(域名类校验只在 PUT 路径)——如需收口,在 rollback 前补跑查重。
|
2. **rollback 仍绕过 prefix 跨租户查重**(终审 I-3 已收口 PUT/创建向导/reset 三口;rollback 单独保留豁免 = 历史不可变语义优先——回滚到含已占前缀的历史版本属数据修复场景;如仍需收口,在 rollback 前补跑查重)。
|
||||||
3. **并发保存同租户撞 UNIQUE → 500**(单管理员场景可接受;收口 = 捕 IntegrityError 转 409)。
|
3. **并发保存同租户撞 UNIQUE → 500**(单管理员场景可接受;收口 = 捕 IntegrityError 转 409)。
|
||||||
4. **统计 API 无 `?site=` 筛选**(spec §7.6 提及;Phase 3 前端需要时补)。
|
4. **统计 API 无 `?site=` 筛选**(spec §7.6 提及;Phase 3 前端需要时补)。
|
||||||
5. **登录限流反代形态退化为按用户名**(限流键 username + request.client.host,无 XFF 解析;可信代理解析归部署批次)。
|
5. **登录限流反代形态退化为按用户名**(限流键 username + request.client.host,无 XFF 解析;可信代理解析归部署批次)。
|
||||||
@@ -86,6 +86,8 @@
|
|||||||
8. **400(预检撞车)vs 409(竞态 IntegrityError)并存**——API 冻结前统一。
|
8. **400(预检撞车)vs 409(竞态 IntegrityError)并存**——API 冻结前统一。
|
||||||
9. **limiter 全量清扫**(token/登录限流的内存键永不回访,长跑进程缓慢累积;收口 = 定期清扫)。
|
9. **limiter 全量清扫**(token/登录限流的内存键永不回访,长跑进程缓慢累积;收口 = 定期清扫)。
|
||||||
10. **lead_push_service 的 settings re-export 锚**(app.engine.config 的 settings 在 lead_push_service 顶部 re-export,22 处测试 monkeypatch 依赖此锚——改动前先迁测试)。
|
10. **lead_push_service 的 settings re-export 锚**(app.engine.config 的 settings 在 lead_push_service 顶部 re-export,22 处测试 monkeypatch 依赖此锚——改动前先迁测试)。
|
||||||
|
11. **密钥掩码未实现**——GET config/settings 返回明文 api_key/smtp 密码;Phase 3 前端对接前必须定掩码哨兵方案(GET 吐 `__SET__` + PUT 收哨兵保留旧值)(出处:终审审查 I-2)。
|
||||||
|
12. **站点管理权限现为仅超管**(spec §7.3 写租户管理员同左)——Phase 3 对接时决策放开或改 spec(出处:终审审查 M-2)。
|
||||||
|
|
||||||
**Phase 3 清理单**(Phase 1/2 审查挂账滚动合并;~~删除线~~ 项已在 Phase 2 完成):
|
**Phase 3 清理单**(Phase 1/2 审查挂账滚动合并;~~删除线~~ 项已在 Phase 2 完成):
|
||||||
|
|
||||||
@@ -115,6 +117,7 @@
|
|||||||
- **bootstrap_platform.py 用法**:`PYTHONUTF8=1 python scripts/bootstrap_platform.py [--db PATH] [--profiles SLUG ...] [--seed DIR]`。两步全幂等:init(schema + 超管引导,凭据取 env/.env 的 ADMIN_USERNAME/ADMIN_PASSWORD)+ 导入出厂租户(默认 auto jiancai 两租户,已有 slug 跳过);**不建租户管理员**(走后台创建向导补)。
|
- **bootstrap_platform.py 用法**:`PYTHONUTF8=1 python scripts/bootstrap_platform.py [--db PATH] [--profiles SLUG ...] [--seed DIR]`。两步全幂等:init(schema + 超管引导,凭据取 env/.env 的 ADMIN_USERNAME/ADMIN_PASSWORD)+ 导入出厂租户(默认 auto jiancai 两租户,已有 slug 跳过);**不建租户管理员**(走后台创建向导补)。
|
||||||
- **TENANT_SOURCE=db 默认**(生产装配形态);`file` 为冷备/本地调试回退。
|
- **TENANT_SOURCE=db 默认**(生产装配形态);`file` 为冷备/本地调试回退。
|
||||||
- **admin 域名反代区分**:`/api/platform/*` 前缀豁免租户 Host 解析——平台后台 API 可与租户聊天域名同源反代(按路径分流)或独立域名,两形态皆可;租户聊天面(`/`、`/static`、WS、`/api/public`、`/api/chat*`)仍严格按 Host 解析租户,未知 Host 404。
|
- **admin 域名反代区分**:`/api/platform/*` 前缀豁免租户 Host 解析——平台后台 API 可与租户聊天域名同源反代(按路径分流)或独立域名,两形态皆可;租户聊天面(`/`、`/static`、WS、`/api/public`、`/api/chat*`)仍严格按 Host 解析租户,未知 Host 404。
|
||||||
|
- **ADMIN_PASSWORD 默认值警示**(终审 I-4):lifespan 启动检测到 ADMIN_PASSWORD 仍是出厂默认(admin123)时打 WARNING 日志——生产部署必须设 ADMIN_PASSWORD 环境变量换掉,且超管首登立即改密(must_change_password 门强制)。
|
||||||
|
|
||||||
## 文档索引
|
## 文档索引
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,15 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
await ensure_platform_schema(platform_engine)
|
await ensure_platform_schema(platform_engine)
|
||||||
|
# 默认凭据警示(终审 I-4):ADMIN_PASSWORD 仍是出厂默认时启动日志
|
||||||
|
# 醒目提示。比较对象取 Settings 字段定义的默认值(非硬编码第二份
|
||||||
|
# "admin123"——将来改默认值,警示判定自动跟随,不双源漂移)。
|
||||||
|
if settings.ADMIN_PASSWORD == type(
|
||||||
|
settings).model_fields["ADMIN_PASSWORD"].default:
|
||||||
|
logger.warning(
|
||||||
|
"!!! 安全警示: 超管账号 %s 正在使用默认密码 —— 生产环境必须"
|
||||||
|
"设置 ADMIN_PASSWORD 环境变量,且首登后立即改密 !!!",
|
||||||
|
settings.ADMIN_USERNAME)
|
||||||
await bootstrap_superuser(
|
await bootstrap_superuser(
|
||||||
platform_engine,
|
platform_engine,
|
||||||
username=settings.ADMIN_USERNAME,
|
username=settings.ADMIN_USERNAME,
|
||||||
|
|||||||
@@ -205,6 +205,40 @@ async def write_config_version(
|
|||||||
return version
|
return version
|
||||||
|
|
||||||
|
|
||||||
|
async def other_push_prefixes(
|
||||||
|
session: AsyncSession, exclude_tenant_id: int | None) -> frozenset:
|
||||||
|
"""各租户**当前版本**的 push.prefix 集合(spec E1 查重数据源;空/坏数据
|
||||||
|
不参战)。MAX(version) 子查询定位每租户当前版。
|
||||||
|
|
||||||
|
终审 I-3 收口:自 config_routes 的私有 helper 提取共用——PUT 分组编辑
|
||||||
|
(经 config_validation 的 ValidationContext)、创建向导(首版快照带出厂
|
||||||
|
prefix,同 profile 第二个租户必撞)、reset 重置出厂(重置回出厂 prefix
|
||||||
|
可能撞上现在的占用方)三口共用。exclude_tenant_id=None = 不排除任何
|
||||||
|
租户(创建向导:租户尚未落库)。rollback 仍不查(历史不可变语义优先,
|
||||||
|
挂账见 CLAUDE.md 取舍清单)。"""
|
||||||
|
max_versions = (
|
||||||
|
select(TenantConfigVersion.tenant_id.label("tid"),
|
||||||
|
func.max(TenantConfigVersion.version).label("mv"))
|
||||||
|
.group_by(TenantConfigVersion.tenant_id).subquery())
|
||||||
|
rows = (await session.execute(
|
||||||
|
select(TenantConfigVersion.tenant_id, TenantConfigVersion.config_json)
|
||||||
|
.join(max_versions, (TenantConfigVersion.tenant_id == max_versions.c.tid)
|
||||||
|
& (TenantConfigVersion.version == max_versions.c.mv)))).all()
|
||||||
|
prefixes = set()
|
||||||
|
for tenant_id, config_json in rows:
|
||||||
|
if tenant_id == exclude_tenant_id:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
snapshot = json.loads(config_json)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
push = snapshot.get("push") if isinstance(snapshot, dict) else None
|
||||||
|
prefix = push.get("prefix") if isinstance(push, dict) else None
|
||||||
|
if isinstance(prefix, str) and prefix:
|
||||||
|
prefixes.add(prefix)
|
||||||
|
return frozenset(prefixes)
|
||||||
|
|
||||||
|
|
||||||
# ---- 超管引导 ---------------------------------------------------------------
|
# ---- 超管引导 ---------------------------------------------------------------
|
||||||
|
|
||||||
async def bootstrap_superuser(
|
async def bootstrap_superuser(
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ from app.platform.db import (
|
|||||||
PlatformSetting,
|
PlatformSetting,
|
||||||
Tenant,
|
Tenant,
|
||||||
TenantConfigVersion,
|
TenantConfigVersion,
|
||||||
|
other_push_prefixes,
|
||||||
write_config_version,
|
write_config_version,
|
||||||
)
|
)
|
||||||
from app.platform.defaults import DEFAULT_CONFIG
|
from app.platform.defaults import DEFAULT_CONFIG
|
||||||
@@ -105,33 +106,6 @@ async def _load_current(
|
|||||||
return row, snapshot
|
return row, snapshot
|
||||||
|
|
||||||
|
|
||||||
async def _other_prefixes(
|
|
||||||
session: AsyncSession, exclude_tenant_id: int) -> frozenset:
|
|
||||||
"""各租户**当前版本**的 push.prefix 集合(spec E1 查重数据源,排除自身;
|
|
||||||
空/坏数据不参战)。MAX(version) 子查询定位每租户当前版。"""
|
|
||||||
max_versions = (
|
|
||||||
select(TenantConfigVersion.tenant_id.label("tid"),
|
|
||||||
func.max(TenantConfigVersion.version).label("mv"))
|
|
||||||
.group_by(TenantConfigVersion.tenant_id).subquery())
|
|
||||||
rows = (await session.execute(
|
|
||||||
select(TenantConfigVersion.tenant_id, TenantConfigVersion.config_json)
|
|
||||||
.join(max_versions, (TenantConfigVersion.tenant_id == max_versions.c.tid)
|
|
||||||
& (TenantConfigVersion.version == max_versions.c.mv)))).all()
|
|
||||||
prefixes = set()
|
|
||||||
for tenant_id, config_json in rows:
|
|
||||||
if tenant_id == exclude_tenant_id:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
snapshot = json.loads(config_json)
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
push = snapshot.get("push") if isinstance(snapshot, dict) else None
|
|
||||||
prefix = push.get("prefix") if isinstance(push, dict) else None
|
|
||||||
if isinstance(prefix, str) and prefix:
|
|
||||||
prefixes.add(prefix)
|
|
||||||
return frozenset(prefixes)
|
|
||||||
|
|
||||||
|
|
||||||
def _updated_by(user: UserContext) -> str:
|
def _updated_by(user: UserContext) -> str:
|
||||||
return f"{user.role}#{user.user_id}"
|
return f"{user.role}#{user.user_id}"
|
||||||
|
|
||||||
@@ -163,7 +137,7 @@ async def put_config_group(
|
|||||||
async with AsyncSession(engine, expire_on_commit=False) as session:
|
async with AsyncSession(engine, expire_on_commit=False) as session:
|
||||||
tenant = await _get_tenant_or_404(session, slug)
|
tenant = await _get_tenant_or_404(session, slug)
|
||||||
context = ValidationContext(
|
context = ValidationContext(
|
||||||
other_prefixes=await _other_prefixes(session, tenant.id))
|
other_prefixes=await other_push_prefixes(session, tenant.id))
|
||||||
errors = validate_group(group, body, context)
|
errors = validate_group(group, body, context)
|
||||||
if errors:
|
if errors:
|
||||||
raise HTTPException(status_code=400, detail="; ".join(errors))
|
raise HTTPException(status_code=400, detail="; ".join(errors))
|
||||||
@@ -203,6 +177,16 @@ async def reset_config_group(
|
|||||||
value = copy.deepcopy(factory[group])
|
value = copy.deepcopy(factory[group])
|
||||||
else:
|
else:
|
||||||
value = copy.deepcopy(DEFAULT_CONFIG[group])
|
value = copy.deepcopy(DEFAULT_CONFIG[group])
|
||||||
|
# push 组重置补跨租户查重(终审 I-3):重置回出厂 prefix 可能撞上
|
||||||
|
# 现在的占用方(本租户改过前缀后,出厂前缀被别人取走)。
|
||||||
|
if group == "push" and isinstance(value, dict):
|
||||||
|
prefix = value.get("prefix")
|
||||||
|
if (isinstance(prefix, str) and prefix and prefix
|
||||||
|
in await other_push_prefixes(session, tenant.id)):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"重置后的 push.prefix {prefix!r} 已被其他租户"
|
||||||
|
f"占用——请改用 PUT /config/push 指定其他前缀")
|
||||||
_, current = await _load_current(session, tenant.id, slug)
|
_, current = await _load_current(session, tenant.id, slug)
|
||||||
new_snapshot = dict(current)
|
new_snapshot = dict(current)
|
||||||
new_snapshot[group] = value
|
new_snapshot[group] = value
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ from app.platform.db import (
|
|||||||
SiteDomain,
|
SiteDomain,
|
||||||
Tenant,
|
Tenant,
|
||||||
TenantConfigVersion,
|
TenantConfigVersion,
|
||||||
|
other_push_prefixes,
|
||||||
write_config_version,
|
write_config_version,
|
||||||
)
|
)
|
||||||
from app.platform.registry import _normalize_host
|
from app.platform.registry import _normalize_host
|
||||||
@@ -227,6 +228,18 @@ async def create_tenant(
|
|||||||
detail=f"管理员用户名已被占用: {body.admin_username!r}")
|
detail=f"管理员用户名已被占用: {body.admin_username!r}")
|
||||||
for domain in domains:
|
for domain in domains:
|
||||||
await _assert_domain_free(session, domain)
|
await _assert_domain_free(session, domain)
|
||||||
|
# push.prefix 跨租户查重(终审 I-3):首版快照带出厂 prefix——同
|
||||||
|
# profile 第二个租户必撞(jiancai=ai-kefu-jc / auto=ai-kefu),
|
||||||
|
# spec E1 前缀全局唯一防 CRM 线索串号,创建期就拦。blank 出厂
|
||||||
|
# prefix 为空串,不参战(与 config_validation 同款空串豁免)。
|
||||||
|
prefix = (snapshot.get("push") or {}).get("prefix")
|
||||||
|
if (isinstance(prefix, str) and prefix
|
||||||
|
and prefix in await other_push_prefixes(session, None)):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"出厂档案的 push.prefix {prefix!r} 已被其他租户占用"
|
||||||
|
f"——请改用 blank profile(创建后经配置 API 改前缀)"
|
||||||
|
f"或先修改占用方前缀")
|
||||||
|
|
||||||
tenant = Tenant(slug=body.slug, name=body.name, status="active",
|
tenant = Tenant(slug=body.slug, name=body.name, status="active",
|
||||||
profile=body.profile)
|
profile=body.profile)
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ class Site:
|
|||||||
domains: tuple[str, ...]
|
domains: tuple[str, ...]
|
||||||
brand: dict # 站点门面覆盖层 ⊕ 租户默认 brand(已深合并)。
|
brand: dict # 站点门面覆盖层 ⊕ 租户默认 brand(已深合并)。
|
||||||
# 只读约定:构造后不得修改
|
# 只读约定:构造后不得修改
|
||||||
|
status: str = "active" # 终审 I-1:站点停用接线——数据源只输出
|
||||||
|
# active 站点(停用站点的域名不进索引 → 404),
|
||||||
|
# 本字段记录该契约(当前恒为 "active")
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -32,6 +35,7 @@ class TenantConfig:
|
|||||||
merged = deep_merge(DEFAULT_CONFIG, snapshot)
|
merged = deep_merge(DEFAULT_CONFIG, snapshot)
|
||||||
resolved = tuple(
|
resolved = tuple(
|
||||||
Site(s["slug"], s.get("name", s["slug"]), tuple(s.get("domains", ())),
|
Site(s["slug"], s.get("name", s["slug"]), tuple(s.get("domains", ())),
|
||||||
deep_merge(merged["brand"], s.get("brand", {})))
|
deep_merge(merged["brand"], s.get("brand", {})),
|
||||||
|
s.get("status", "active"))
|
||||||
for s in sites)
|
for s in sites)
|
||||||
return cls(slug=slug, name=name, status=status, data=merged, sites=resolved)
|
return cls(slug=slug, name=name, status=status, data=merged, sites=resolved)
|
||||||
|
|||||||
@@ -114,6 +114,12 @@ class FileTenantSource:
|
|||||||
sites = tenant_json.get("sites") or []
|
sites = tenant_json.get("sites") or []
|
||||||
if not sites:
|
if not sites:
|
||||||
raise ValueError(f"{slug}: 每租户至少一个站点(sites 为空)")
|
raise ValueError(f"{slug}: 每租户至少一个站点(sites 为空)")
|
||||||
|
# 站点停用过滤(终审 I-1,与 DB 源同一语义):status 键缺省 = active
|
||||||
|
# (既有 tenant.json 均无该键,兼容);停用站点不进 LoadedTenant——
|
||||||
|
# 域名随之不进 registry 索引(该域名 404)、site_by_slug 取不到。
|
||||||
|
sites = [s for s in sites
|
||||||
|
if not isinstance(s, dict)
|
||||||
|
or s.get("status", "active") == "active"]
|
||||||
# wall 并入 snapshot["wall"]:data["wall"] 保留原始词表(后台编辑/
|
# wall 并入 snapshot["wall"]:data["wall"] 保留原始词表(后台编辑/
|
||||||
# 回滚用),编译产物挂 runtime.compiled_wall(引擎消费,单一编译)
|
# 回滚用),编译产物挂 runtime.compiled_wall(引擎消费,单一编译)
|
||||||
snapshot = dict(tenant_json.get("config") or {})
|
snapshot = dict(tenant_json.get("config") or {})
|
||||||
@@ -148,8 +154,10 @@ class DbTenantSource:
|
|||||||
的 config_json 解析。版本快照由 Task 5 创建向导写入(config 组 ⊕ wall
|
的 config_json 解析。版本快照由 Task 5 创建向导写入(config 组 ⊕ wall
|
||||||
⊕ prompts 同构嵌套)。
|
⊕ prompts 同构嵌套)。
|
||||||
|
|
||||||
已知边界:site.status 列暂不参与映射(全量输出)——Task 5 站点
|
已知边界(终审 I-1 已接线):site.status != "active" 的站点**不进**
|
||||||
CRUD/停用接线时定语义。
|
LoadedTenant(_load_one 过滤)——停用站点的域名不进 registry 索引
|
||||||
|
(该域名 404),site_by_slug 取不到;全停用输出空 sites(零站点校验
|
||||||
|
只对库表形状)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, engine: AsyncEngine):
|
def __init__(self, engine: AsyncEngine):
|
||||||
@@ -215,10 +223,16 @@ class DbTenantSource:
|
|||||||
f"{version.version} 的 config_json 缺少 {key!r} 配置组"
|
f"{version.version} 的 config_json 缺少 {key!r} 配置组"
|
||||||
f"(或其不是对象)")
|
f"(或其不是对象)")
|
||||||
|
|
||||||
sites = [{"slug": s.slug, "name": s.name,
|
# 站点停用过滤(终审 I-1):status != "active" 的站点不进
|
||||||
|
# LoadedTenant——其域名不进 registry 域名索引(该域名 404)、
|
||||||
|
# site_by_slug 取不到。与"租户停用 → TenantDisabledError → 503
|
||||||
|
# 中性页"的语义差:站点停用 = 这张门面下线,404 合理(不是服务暂停)。
|
||||||
|
# 全部站点停用 = 输出空 sites(零站点校验只对库表形状——行数为零
|
||||||
|
# 才是数据坏;全停用是合法运营状态,域名全 404)。
|
||||||
|
sites = [{"slug": s.slug, "name": s.name, "status": s.status,
|
||||||
"domains": list(domains_by_site.get(s.id, [])),
|
"domains": list(domains_by_site.get(s.id, [])),
|
||||||
"brand": dict(s.brand_overlay or {})}
|
"brand": dict(s.brand_overlay or {})}
|
||||||
for s in sites_rows]
|
for s in sites_rows if s.status == "active"]
|
||||||
return LoadedTenant(
|
return LoadedTenant(
|
||||||
slug=tenant.slug,
|
slug=tenant.slug,
|
||||||
name=tenant.name,
|
name=tenant.name,
|
||||||
|
|||||||
@@ -188,7 +188,8 @@ def test_traversal_safe(super_client, db_source_app, tmp_path):
|
|||||||
|
|
||||||
def test_upload_auth_boundaries(super_client):
|
def test_upload_auth_boundaries(super_client):
|
||||||
_create(super_client, slug="demo")
|
_create(super_client, slug="demo")
|
||||||
_create(super_client, slug="other")
|
# blank profile: 第二个租户不撞 jiancai 的出厂 push.prefix(终审 I-3)
|
||||||
|
_create(super_client, slug="other", profile="blank")
|
||||||
url = f"{TENANTS}/demo/brand/avatar"
|
url = f"{TENANTS}/demo/brand/avatar"
|
||||||
|
|
||||||
# 未认证 → 401
|
# 未认证 → 401
|
||||||
|
|||||||
@@ -350,6 +350,33 @@ def test_reset_blank_tenant_groups(super_client):
|
|||||||
assert r.json()["config"] == DEFAULT_CONFIG["contact"]
|
assert r.json()["config"] == DEFAULT_CONFIG["contact"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_push_prefix_collision(super_client):
|
||||||
|
"""终审 I-3:reset push 组补跨租户查重——本租户改过前缀后,出厂前缀被
|
||||||
|
别人取走,重置回出厂会撞车 → 400 提示改走 PUT 指定前缀。"""
|
||||||
|
_create(super_client, slug="jc1", profile="jiancai") # 出厂 ai-kefu-jc
|
||||||
|
_create(super_client, slug="au1", profile="auto") # 出厂 ai-kefu
|
||||||
|
# jc1 让出出厂前缀,au1 取走
|
||||||
|
assert _put_group(super_client, "jc1", "push",
|
||||||
|
{"prefix": "jc1-new"}).status_code == 200
|
||||||
|
assert _put_group(super_client, "au1", "push",
|
||||||
|
{"prefix": "ai-kefu-jc"}).status_code == 200
|
||||||
|
# 重置 jc1 的 push → 出厂 ai-kefu-jc 已被 au1 占用 → 400
|
||||||
|
r = super_client.post(f"{_cfg('jc1')}/push/reset",
|
||||||
|
headers=_auth(super_client))
|
||||||
|
assert r.status_code == 400
|
||||||
|
detail = r.json()["detail"]
|
||||||
|
assert "ai-kefu-jc" in detail and "占用" in detail
|
||||||
|
# 当前版本未被重置覆盖(仍是 jc1-new)
|
||||||
|
assert _get_config(super_client, "jc1")["config"]["push"]["prefix"] == "jc1-new"
|
||||||
|
# 撞车解除后(au1 改走)重置即可成功
|
||||||
|
assert _put_group(super_client, "au1", "push",
|
||||||
|
{"prefix": "au1-new"}).status_code == 200
|
||||||
|
r = super_client.post(f"{_cfg('jc1')}/push/reset",
|
||||||
|
headers=_auth(super_client))
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["config"]["prefix"] == "ai-kefu-jc"
|
||||||
|
|
||||||
|
|
||||||
# ---- 平台设置(仅超管;本阶段只落存储与接口) ----------------------------------------
|
# ---- 平台设置(仅超管;本阶段只落存储与接口) ----------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -423,7 +450,9 @@ def test_wall_group_round_trip_and_body_hygiene(super_client):
|
|||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
|
|
||||||
# 首登未改密的租户管理员 → 409(与登录/改密之外的受保护端点同款门)
|
# 首登未改密的租户管理员 → 409(与登录/改密之外的受保护端点同款门)
|
||||||
_create(super_client, slug="t2", admin="t2-admin", password="t2-pass-1")
|
# (blank: 第二个租户不撞 jiancai 出厂 push.prefix——终审 I-3 创建查重)
|
||||||
|
_create(super_client, slug="t2", admin="t2-admin", password="t2-pass-1",
|
||||||
|
profile="blank")
|
||||||
token = super_client.post(
|
token = super_client.post(
|
||||||
LOGIN, json={"username": "t2-admin", "password": "t2-pass-1"}
|
LOGIN, json={"username": "t2-admin", "password": "t2-pass-1"}
|
||||||
).json()["token"]
|
).json()["token"]
|
||||||
|
|||||||
@@ -53,11 +53,12 @@ def _bearer(token):
|
|||||||
return {"Authorization": f"Bearer {token}"}
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
def _create(client, slug="demo", admin=None, password=None, domains=None):
|
def _create(client, slug="demo", admin=None, password=None, domains=None,
|
||||||
|
profile="jiancai"):
|
||||||
return client.post(TENANTS, headers=_auth(client), json={
|
return client.post(TENANTS, headers=_auth(client), json={
|
||||||
"name": f"{slug} 租户",
|
"name": f"{slug} 租户",
|
||||||
"slug": slug,
|
"slug": slug,
|
||||||
"profile": "jiancai",
|
"profile": profile,
|
||||||
"admin_username": admin or f"{slug}-admin",
|
"admin_username": admin or f"{slug}-admin",
|
||||||
"admin_password": password or f"{slug}-pass-1",
|
"admin_password": password or f"{slug}-pass-1",
|
||||||
"domains": domains or [],
|
"domains": domains or [],
|
||||||
@@ -378,8 +379,9 @@ def test_push_logs_payload_lazy(super_client, tmp_path):
|
|||||||
def test_tenant_scope_isolation(super_client, tmp_path):
|
def test_tenant_scope_isolation(super_client, tmp_path):
|
||||||
_create(super_client, slug="jca", admin="jca-admin",
|
_create(super_client, slug="jca", admin="jca-admin",
|
||||||
password="jca-pass-1")
|
password="jca-pass-1")
|
||||||
|
# blank: 第二个租户不撞 jiancai 出厂 push.prefix(终审 I-3)
|
||||||
_create(super_client, slug="jcb", admin="jcb-admin",
|
_create(super_client, slug="jcb", admin="jcb-admin",
|
||||||
password="jcb-pass-1")
|
password="jcb-pass-1", profile="blank")
|
||||||
|
|
||||||
async def populate(session):
|
async def populate(session):
|
||||||
at = datetime(2026, 1, 5, 12, 0, tzinfo=UTC)
|
at = datetime(2026, 1, 5, 12, 0, tzinfo=UTC)
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from app.platform.db import (
|
|||||||
make_platform_engine,
|
make_platform_engine,
|
||||||
)
|
)
|
||||||
from app.platform.registry import UnknownTenantError, TenantRegistry
|
from app.platform.registry import UnknownTenantError, TenantRegistry
|
||||||
|
from app.platform.tenant_config import TenantConfig
|
||||||
from app.platform.tenant_source import DbTenantSource, FileTenantSource
|
from app.platform.tenant_source import DbTenantSource, FileTenantSource
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
@@ -61,7 +62,7 @@ async def _make_engine(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
async def _add_jiancai(engine, prefix="ai-kefu-jc", version=1,
|
async def _add_jiancai(engine, prefix="ai-kefu-jc", version=1,
|
||||||
config_json=None, with_version=True):
|
config_json=None, with_version=True, es_status="active"):
|
||||||
"""jiancai 租户 + main(JC_DOMAIN)+ es(brand_overlay)+ 版本行。"""
|
"""jiancai 租户 + main(JC_DOMAIN)+ es(brand_overlay)+ 版本行。"""
|
||||||
async with AsyncSession(engine, expire_on_commit=False) as session:
|
async with AsyncSession(engine, expire_on_commit=False) as session:
|
||||||
tenant = Tenant(slug="jiancai", name="HESUPIPE 建材", status="active")
|
tenant = Tenant(slug="jiancai", name="HESUPIPE 建材", status="active")
|
||||||
@@ -69,6 +70,7 @@ async def _add_jiancai(engine, prefix="ai-kefu-jc", version=1,
|
|||||||
await session.flush()
|
await session.flush()
|
||||||
main = Site(tenant_id=tenant.id, slug="main", name="main")
|
main = Site(tenant_id=tenant.id, slug="main", name="main")
|
||||||
es = Site(tenant_id=tenant.id, slug="es", name="es",
|
es = Site(tenant_id=tenant.id, slug="es", name="es",
|
||||||
|
status=es_status,
|
||||||
brand_overlay={"title": "HESUPIPE Soporte"})
|
brand_overlay={"title": "HESUPIPE Soporte"})
|
||||||
session.add_all([main, es])
|
session.add_all([main, es])
|
||||||
await session.flush()
|
await session.flush()
|
||||||
@@ -223,6 +225,34 @@ async def test_db_source_missing_prompts_group_raises(tmp_path):
|
|||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_db_source_filters_disabled_sites(tmp_path):
|
||||||
|
"""终审 I-1:site.status != active 的站点不进 LoadedTenant(域名不进
|
||||||
|
registry 索引 → 该域名 404;site_by_slug 取不到)。es 停用后只剩
|
||||||
|
main;全部停用 → 空 sites(零站点校验只对库表形状)。"""
|
||||||
|
engine = await _make_engine(tmp_path)
|
||||||
|
try:
|
||||||
|
await _add_jiancai(engine, es_status="disabled")
|
||||||
|
lt = await DbTenantSource(engine).load_tenant("jiancai")
|
||||||
|
assert [s["slug"] for s in lt.sites] == ["main"] # es 被过滤
|
||||||
|
main = lt.sites[0]
|
||||||
|
assert main["status"] == "active"
|
||||||
|
# TenantConfig 消费侧:Site 数据类带 status,域名索引只含 main
|
||||||
|
config = TenantConfig.from_snapshot(
|
||||||
|
lt.slug, lt.name, lt.snapshot, sites=lt.sites, status=lt.status)
|
||||||
|
assert [s.slug for s in config.sites] == ["main"]
|
||||||
|
assert config.sites[0].status == "active"
|
||||||
|
|
||||||
|
# 全部停用 = 合法运营状态(门面全下线),输出空 sites 不炸
|
||||||
|
async with AsyncSession(engine, expire_on_commit=False) as session:
|
||||||
|
await session.execute(text(
|
||||||
|
"UPDATE sites SET status='disabled'"))
|
||||||
|
await session.commit()
|
||||||
|
lt2 = await DbTenantSource(engine).load_tenant("jiancai")
|
||||||
|
assert lt2.sites == []
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
# ---- 文件源(直测;等价性由既有 test_registry 全绿证明)-----------------------
|
# ---- 文件源(直测;等价性由既有 test_registry 全绿证明)-----------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -270,6 +300,28 @@ def test_file_source_read_tenant_rereads_disk(tmp_path):
|
|||||||
assert src.read_tenant("t1").snapshot["push"]["prefix"] == "v2"
|
assert src.read_tenant("t1").snapshot["push"]["prefix"] == "v2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_source_filters_disabled_sites(tmp_path):
|
||||||
|
"""终审 I-1:tenant.json 站点带 status: "disabled" → 不进
|
||||||
|
LoadedTenant(文件源同步行为);无 status 键 → 默认 active,既有
|
||||||
|
种子零改动兼容。"""
|
||||||
|
_write_min_seed(tmp_path, {"t1": {
|
||||||
|
"slug": "t1", "name": "t1", "status": "active",
|
||||||
|
"sites": [
|
||||||
|
{"slug": "main", "domains": ["a.example.com"]}, # 无键=active
|
||||||
|
{"slug": "es", "status": "active", "domains": []},
|
||||||
|
{"slug": "down", "status": "disabled",
|
||||||
|
"domains": ["down.example.com"], "brand": {"title": "x"}},
|
||||||
|
],
|
||||||
|
"config": {}}})
|
||||||
|
lt = FileTenantSource(tmp_path / "seed").read_tenant("t1")
|
||||||
|
assert [s["slug"] for s in lt.sites] == ["main", "es"] # down 被滤
|
||||||
|
# 消费侧同构:from_snapshot 只见 active 站点
|
||||||
|
config = TenantConfig.from_snapshot(
|
||||||
|
lt.slug, lt.name, lt.snapshot, sites=lt.sites, status=lt.status)
|
||||||
|
assert [s.slug for s in config.sites] == ["main", "es"]
|
||||||
|
assert all(s.status == "active" for s in config.sites)
|
||||||
|
|
||||||
|
|
||||||
# ---- registry × DB 源(reload / upsert)--------------------------------------
|
# ---- registry × DB 源(reload / upsert)--------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -325,3 +325,42 @@ def test_lifespan_platform_engine_seam_and_bootstrap(tmp_path, monkeypatch):
|
|||||||
assert [u.username for u in users] == ["bootadmin"]
|
assert [u.username for u in users] == ["bootadmin"]
|
||||||
assert verify_password("boot-pw", users[0].password_hash, users[0].salt)
|
assert verify_password("boot-pw", users[0].password_hash, users[0].salt)
|
||||||
assert users[0].must_change_password is True
|
assert users[0].must_change_password is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_admin_password_warns_at_startup(
|
||||||
|
tmp_path, monkeypatch, caplog):
|
||||||
|
"""终审 I-4:lifespan 启动时 ADMIN_PASSWORD 仍为出厂默认 → 醒目
|
||||||
|
WARNING(生产必须设环境变量);设了自定义值 → 无警示。比较基准 =
|
||||||
|
Settings 字段默认值(与 main.py 同式,改默认值判定自动跟随)。"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.engine.config import settings
|
||||||
|
from app.main import app
|
||||||
|
from app.platform.registry import TenantRegistry
|
||||||
|
|
||||||
|
def _run_lifespan():
|
||||||
|
app.state.registry = TenantRegistry.load_from_dir(
|
||||||
|
SEED_DIR, data_dir=tmp_path / "tenants")
|
||||||
|
app.state.platform_engine = make_platform_engine(
|
||||||
|
tmp_path / "platform.db")
|
||||||
|
try:
|
||||||
|
with caplog.at_level(logging.WARNING, logger="app.main"):
|
||||||
|
with TestClient(app) as client:
|
||||||
|
assert client.get("/health").status_code == 200
|
||||||
|
finally:
|
||||||
|
app.state.registry = None
|
||||||
|
app.state.platform_engine = None
|
||||||
|
|
||||||
|
# 出厂默认密码 → WARNING 提及用户名与"默认密码"
|
||||||
|
monkeypatch.setattr(settings, "ADMIN_PASSWORD",
|
||||||
|
type(settings).model_fields["ADMIN_PASSWORD"].default)
|
||||||
|
_run_lifespan()
|
||||||
|
assert any("默认密码" in r.getMessage()
|
||||||
|
and settings.ADMIN_USERNAME in r.getMessage()
|
||||||
|
for r in caplog.records)
|
||||||
|
|
||||||
|
# 自定义密码 → 无该警示
|
||||||
|
caplog.clear()
|
||||||
|
monkeypatch.setattr(settings, "ADMIN_PASSWORD", "n0t-default-pw")
|
||||||
|
_run_lifespan()
|
||||||
|
assert not any("默认密码" in r.getMessage() for r in caplog.records)
|
||||||
|
|||||||
+5
-4
@@ -53,11 +53,11 @@ def _bearer(token):
|
|||||||
return {"Authorization": f"Bearer {token}"}
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
def _create(client, slug, admin=None, password=None):
|
def _create(client, slug, admin=None, password=None, profile="jiancai"):
|
||||||
return client.post(TENANTS, headers=_auth(client), json={
|
return client.post(TENANTS, headers=_auth(client), json={
|
||||||
"name": f"{slug} 租户",
|
"name": f"{slug} 租户",
|
||||||
"slug": slug,
|
"slug": slug,
|
||||||
"profile": "jiancai",
|
"profile": profile,
|
||||||
"admin_username": admin or f"{slug}-admin",
|
"admin_username": admin or f"{slug}-admin",
|
||||||
"admin_password": password or f"{slug}-pass-1",
|
"admin_password": password or f"{slug}-pass-1",
|
||||||
"domains": [],
|
"domains": [],
|
||||||
@@ -337,10 +337,11 @@ def test_cross_tenant_super_view(super_client, tmp_path):
|
|||||||
合并含停用租户、跳过无库文件的未懒建租户;403/401 边界。"""
|
合并含停用租户、跳过无库文件的未懒建租户;403/401 边界。"""
|
||||||
_create(super_client, slug="jca", admin="jca-admin",
|
_create(super_client, slug="jca", admin="jca-admin",
|
||||||
password="jca-pass-1")
|
password="jca-pass-1")
|
||||||
|
# blank: 第二/三个租户不撞 jiancai 出厂 push.prefix(终审 I-3)
|
||||||
_create(super_client, slug="jcb", admin="jcb-admin",
|
_create(super_client, slug="jcb", admin="jcb-admin",
|
||||||
password="jcb-pass-1")
|
password="jcb-pass-1", profile="blank")
|
||||||
# jcc: 创建后从未 resolve——库文件不存在,合并视图应跳过(不建空库)
|
# jcc: 创建后从未 resolve——库文件不存在,合并视图应跳过(不建空库)
|
||||||
_create(super_client, slug="jcc")
|
_create(super_client, slug="jcc", profile="blank")
|
||||||
|
|
||||||
async def seed_a(session):
|
async def seed_a(session):
|
||||||
a1 = datetime(2026, 1, 5, 10, 0, tzinfo=UTC) # 北京 1/5
|
a1 = datetime(2026, 1, 5, 10, 0, tzinfo=UTC) # 北京 1/5
|
||||||
|
|||||||
@@ -249,8 +249,10 @@ def test_tenant_admin_cannot_create_tenant(super_client):
|
|||||||
"name": "越权租户", "slug": "evil", "profile": "jiancai",
|
"name": "越权租户", "slug": "evil", "profile": "jiancai",
|
||||||
"admin_username": "evil-admin", "admin_password": "evil-pass-1"})
|
"admin_username": "evil-admin", "admin_password": "evil-pass-1"})
|
||||||
assert r.status_code == 403
|
assert r.status_code == 403
|
||||||
# 对照: 超管可建
|
# 对照: 超管可建(blank profile——jiancai 的出厂 prefix 已被 demo 占用,
|
||||||
assert _create(super_client, slug="ok1").status_code == 201
|
# 见 test_create_second_same_profile_prefix_conflict)
|
||||||
|
assert _create(super_client, slug="ok1",
|
||||||
|
profile="blank").status_code == 201
|
||||||
|
|
||||||
|
|
||||||
# ---- _blankify 单测 --------------------------------------------------------------
|
# ---- _blankify 单测 --------------------------------------------------------------
|
||||||
@@ -306,9 +308,27 @@ def test_create_auto_profile(super_client, tmp_path):
|
|||||||
assert r.json()["title"] == "HESU Support" # auto 档案身份
|
assert r.json()["title"] == "HESU Support" # auto 档案身份
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_second_same_profile_prefix_conflict(super_client, tmp_path):
|
||||||
|
"""终审 I-3:创建向导补 push.prefix 跨租户查重——同 profile 第二个租户
|
||||||
|
的出厂前缀必撞(jiancai=ai-kefu-jc),spec E1 前缀全局唯一创建期就拦;
|
||||||
|
blank 出厂前缀为空串,不参战。"""
|
||||||
|
assert _create(super_client, slug="jc1", profile="jiancai").status_code == 201
|
||||||
|
r = _create(super_client, slug="jc2", profile="jiancai")
|
||||||
|
assert r.status_code == 400
|
||||||
|
detail = r.json()["detail"]
|
||||||
|
assert "ai-kefu-jc" in detail and "push.prefix" in detail
|
||||||
|
# auto 前缀不同,不撞
|
||||||
|
assert _create(super_client, slug="au1", profile="auto").status_code == 201
|
||||||
|
# blank 出厂前缀为空 → 可建(创建后经配置 API 配前缀)
|
||||||
|
assert _create(super_client, slug="bl1", profile="blank").status_code == 201
|
||||||
|
# jc2 没落库(整事务未提交)
|
||||||
|
rows = _db_fetch(tmp_path, select(Tenant.slug).where(Tenant.slug == "jc2"))
|
||||||
|
assert rows == []
|
||||||
|
|
||||||
|
|
||||||
def test_domain_collision_reports_owner(super_client):
|
def test_domain_collision_reports_owner(super_client):
|
||||||
_create(super_client, slug="demo", domains=["demo.example.com"])
|
_create(super_client, slug="demo", domains=["demo.example.com"])
|
||||||
_create(super_client, slug="other")
|
_create(super_client, slug="other", profile="blank")
|
||||||
# 大写归一后同域 → 400 带现有归属(tenant + site)
|
# 大写归一后同域 → 400 带现有归属(tenant + site)
|
||||||
r = super_client.post(f"{TENANTS}/other/sites/main/domains",
|
r = super_client.post(f"{TENANTS}/other/sites/main/domains",
|
||||||
headers=_auth(super_client),
|
headers=_auth(super_client),
|
||||||
@@ -316,8 +336,9 @@ def test_domain_collision_reports_owner(super_client):
|
|||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
detail = r.json()["detail"]
|
detail = r.json()["detail"]
|
||||||
assert "demo" in detail and "main" in detail
|
assert "demo" in detail and "main" in detail
|
||||||
# 创建向导的初始域名同样预检
|
# 创建向导的初始域名同样预检(blank profile——排除 prefix 查重的干扰)
|
||||||
r = _create(super_client, slug="third", domains=["demo.example.com"])
|
r = _create(super_client, slug="third", profile="blank",
|
||||||
|
domains=["demo.example.com"])
|
||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
@@ -384,7 +405,9 @@ def test_site_create_patch_overlay(super_client, tmp_path):
|
|||||||
f"{TENANTS}/demo/sites/ghost", headers=_auth(super_client),
|
f"{TENANTS}/demo/sites/ghost", headers=_auth(super_client),
|
||||||
json={"name": "x"}).status_code == 404
|
json={"name": "x"}).status_code == 404
|
||||||
|
|
||||||
# 站点停用持久化(解析级语义后续任务接线,此处钉字段落库+reload 不炸)
|
# 站点停用全链路(终审 I-1):字段落库 + reload 后数据源过滤停用站点——
|
||||||
|
# 其域名不进索引(经中间件 404,与租户停用的 503 语义差:门面下线≠服务
|
||||||
|
# 暂停),同租户其他站点照常;?site= 覆盖也取不到(回退默认门面)。
|
||||||
r = super_client.patch(f"{TENANTS}/demo/sites/es",
|
r = super_client.patch(f"{TENANTS}/demo/sites/es",
|
||||||
headers=_auth(super_client),
|
headers=_auth(super_client),
|
||||||
json={"status": "disabled"})
|
json={"status": "disabled"})
|
||||||
@@ -393,6 +416,27 @@ def test_site_create_patch_overlay(super_client, tmp_path):
|
|||||||
.join(Tenant, Site.tenant_id == Tenant.id)
|
.join(Tenant, Site.tenant_id == Tenant.id)
|
||||||
.where(Tenant.slug == "demo", Site.slug == "es"))
|
.where(Tenant.slug == "demo", Site.slug == "es"))
|
||||||
assert rows[0][0].status == "disabled"
|
assert rows[0][0].status == "disabled"
|
||||||
|
r = super_client.get("/api/public/brand",
|
||||||
|
headers={"host": "es.demo.example.com"})
|
||||||
|
assert r.status_code == 404 # 停用站点域名 404
|
||||||
|
r = super_client.get("/api/public/brand",
|
||||||
|
headers={"host": "demo.example.com"})
|
||||||
|
assert r.status_code == 200 # main 站点不受影响
|
||||||
|
assert r.json()["title"] == "HESUPIPE Support" # 租户级门面(jiancai 档案)
|
||||||
|
# ?site=es 覆盖取不到停用站点 → 回退 main 门面(title 是租户级,非 ES v2)
|
||||||
|
r = super_client.get("/api/public/brand?site=es",
|
||||||
|
headers={"host": "demo.example.com"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["title"] != "Demo ES v2"
|
||||||
|
|
||||||
|
# 复启用 → 域名恢复服务
|
||||||
|
r = super_client.patch(f"{TENANTS}/demo/sites/es",
|
||||||
|
headers=_auth(super_client),
|
||||||
|
json={"status": "active"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert super_client.get(
|
||||||
|
"/api/public/brand",
|
||||||
|
headers={"host": "es.demo.example.com"}).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
def test_patch_tenant_name_and_validation(super_client, tmp_path):
|
def test_patch_tenant_name_and_validation(super_client, tmp_path):
|
||||||
@@ -417,7 +461,7 @@ def test_patch_tenant_name_and_validation(super_client, tmp_path):
|
|||||||
def test_list_tenants(super_client):
|
def test_list_tenants(super_client):
|
||||||
_create(super_client, slug="demo",
|
_create(super_client, slug="demo",
|
||||||
domains=["a.example.com", "b.example.com"])
|
domains=["a.example.com", "b.example.com"])
|
||||||
_create(super_client, slug="other")
|
_create(super_client, slug="other", profile="blank")
|
||||||
r = super_client.get(TENANTS, headers=_auth(super_client))
|
r = super_client.get(TENANTS, headers=_auth(super_client))
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
items = {i["slug"]: i for i in r.json()["items"]}
|
items = {i["slug"]: i for i in r.json()["items"]}
|
||||||
@@ -433,7 +477,7 @@ def test_list_tenants(super_client):
|
|||||||
def test_detail_shape_and_scope(super_client):
|
def test_detail_shape_and_scope(super_client):
|
||||||
_create(super_client, slug="demo", admin="demo-admin",
|
_create(super_client, slug="demo", admin="demo-admin",
|
||||||
password="demo-pass-1", domains=["demo.example.com"])
|
password="demo-pass-1", domains=["demo.example.com"])
|
||||||
_create(super_client, slug="other")
|
_create(super_client, slug="other", profile="blank")
|
||||||
r = super_client.get(f"{TENANTS}/demo", headers=_auth(super_client))
|
r = super_client.get(f"{TENANTS}/demo", headers=_auth(super_client))
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
d = r.json()
|
d = r.json()
|
||||||
|
|||||||
Reference in New Issue
Block a user